Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cursorless-talon/src/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from .actions_callback import callback_action_defaults, callback_action_map
from .actions_custom import custom_action_defaults
from .actions_makeshift import makeshift_action_defaults, makeshift_action_map
from .actions_simple import positional_action_defaults, simple_action_defaults
from .actions_simple import (
no_wait_actions,
positional_action_defaults,
simple_action_defaults,
)

mod = Module()

Expand Down Expand Up @@ -48,6 +52,10 @@ def cursorless_command(action_id: str, target: dict):
actions.sleep(f"{talon_options.post_command_sleep_ms}ms")

return return_value
elif action_id in no_wait_actions:
return actions.user.cursorless_single_target_command_no_wait(
action_id, target
)
Comment thread
AndreasArvidsson marked this conversation as resolved.
else:
return actions.user.cursorless_single_target_command(action_id, target)

Expand Down
6 changes: 6 additions & 0 deletions cursorless-talon/src/actions/actions_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"shuffle": "randomizeTargets",
"reverse": "reverseTargets",
"scout all": "findInWorkspace",
"snippet make": "generateSnippet",
"sort": "sortTargets",
"take": "setSelection",
"unfold": "unfoldRegion",
Expand All @@ -42,6 +43,11 @@
"paste": "pasteFromClipboard",
}

# Don't wait for these actions to finish, usually because they hang on some kind of user interaction
no_wait_actions = [
"generateSnippet",
]

mod = Module()
mod.list(
"cursorless_simple_action",
Expand Down
2 changes: 2 additions & 0 deletions src/actions/Actions.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import ToggleBreakpoint from "./ToggleBreakpoint";
import Wrap from "./Wrap";
import WrapWithSnippet from "./WrapWithSnippet";
import InsertSnippet from "./InsertSnippet";
import GenerateSnippet from "./GenerateSnippet";

class Actions implements ActionRecord {
constructor(private graph: Graph) {}
Expand All @@ -57,6 +58,7 @@ class Actions implements ActionRecord {
foldRegion = new Fold(this.graph);
followLink = new FollowLink(this.graph);
getText = new GetText(this.graph);
generateSnippet = new GenerateSnippet(this.graph);
highlight = new Highlight(this.graph);
indentLine = new IndentLines(this.graph);
insertCopyAfter = new InsertCopyAfter(this.graph);
Expand Down
275 changes: 275 additions & 0 deletions src/actions/GenerateSnippet/GenerateSnippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { ensureSingleTarget } from "../../util/targetUtils";

import { open } from "fs/promises";
import { join } from "path";
import { commands, Range, window, workspace } from "vscode";
import { Offsets } from "../../processTargets/modifiers/surroundingPair/types";
import { Target } from "../../typings/target.types";
import { Graph } from "../../typings/Types";
import { Action, ActionReturnValue } from "../actions.types";
import { constructSnippetBody } from "./constructSnippetBody";
import { editText } from "./editText";
import Substituter from "./Substituter";
import isTesting from "../../testUtil/isTesting";
import { getDocumentRange } from "../../util/range";
import { selectionFromRange } from "../../util/selectionUtils";

/**
* This action can be used to automatically create a snippet from a target. Any
* cursor selections inside the target will become placeholders in the final
* snippet. This action creates a new file, and inserts a snippet that the user
* can fill out to construct their desired snippet.
*
* Note that there are two snippets involved in this implementation:
*
* - The snippet that the user is trying to create. We refer to this snippet as
* the user snippet.
* - The snippet that we insert that the user can use to build their snippet. We
* refer to this as the meta snippet.
*
* We proceed as follows:
*
* 1. Ask user for snippet name if not provided as arg
* 2. Find all cursor selections inside target - these will become the user
* snippet variables
* 3. Extract text of target
* 4. Replace cursor selections in text with random ids that won't be affected
* by json serialization. After serialization we'll replace these id's by
* snippet placeholders.
* 4. Construct the user snippet body as a list of strings
* 5. Construct a javascript object that will be json-ified to become the meta
* snippet
* 6. Serialize the javascript object to json
* 7. Perform replacements on the random id's appearing in this json to get the
* text we desire. This modified json output is the meta snippet.
* 8. Open a new document in user custom snippets dir to hold the new snippet.
* 9. Insert the meta snippet so that the user can construct their snippet.
*
* Note that we avoid using JS interpolation strings here because the syntax is
* very similar to snippet placeholders, so we would end up with lots of
* confusing escaping.
*/
export default class GenerateSnippet implements Action {
constructor(private graph: Graph) {
this.run = this.run.bind(this);
}

async run(
[targets]: [Target[]],
snippetName?: string
): Promise<ActionReturnValue> {
const target = ensureSingleTarget(targets);
const editor = target.editor;

// NB: We don't await the pending edit decoration so that if the user
// immediately starts saying the name of the snippet (eg command chain
// "snippet make funk camel my function"), we're more likely to
// win the race and have the input box ready for them
this.graph.editStyles.displayPendingEditDecorations(
targets,
this.graph.editStyles.referenced
);

if (snippetName == null) {
snippetName = await window.showInputBox({
prompt: "Name of snippet",
placeHolder: "helloWorld",
});
}

// User cancelled; don't do anything
if (snippetName == null) {
return {};
}

/** The next placeholder index to use for the meta snippet */
let currentPlaceholderIndex = 1;

const baseOffset = editor.document.offsetAt(target.contentRange.start);

/**
* The variables that will appear in the user snippet. Note that
* `placeholderIndex` here is the placeholder index in the meta snippet not
* the user snippet.
*/
const variables: Variable[] = editor.selections
.filter((selection) => target.contentRange.contains(selection))
.map((selection, index) => ({
offsets: {
start: editor.document.offsetAt(selection.start) - baseOffset,
end: editor.document.offsetAt(selection.end) - baseOffset,
},
defaultName: `variable${index + 1}`,
placeholderIndex: currentPlaceholderIndex++,
}));

/**
* Constructs random ids that can be put into the text that won't be
* modified by json serialization.
*/
const substituter = new Substituter();

/**
* Text before the start of the snippet in the snippet start line. We need
* to pass this to {@link constructSnippetBody} so that it knows the
* baseline indentation of the snippet
*/
const linePrefix = editor.document.getText(
new Range(
target.contentRange.start.with(undefined, 0),
target.contentRange.start
)
);

/** The text of the snippet, with placeholders inserted for variables */
const snippetBodyText = editText(
editor.document.getText(target.contentRange),
variables.map(({ offsets, defaultName, placeholderIndex }) => ({
offsets,
// Note that the reason we use the substituter here is primarily so
// that the `\` below doesn't get escaped upon conversion to json.
text: substituter.addSubstitution(
[
// This `\$` will end up being a `$` in the final document. It
// indicates the start of a variable in the user snippet. We need
// the `\` so that the meta-snippet doesn't see it as one of its
// placeholders.
"\\$",

// The remaining text here is a placeholder in the meta-snippet
// that the user can use to name their snippet variable that will
// be in the user snippet.
"${",
placeholderIndex,
":",
defaultName,
"}",
].join("")
),
}))
);

const snippetLines = constructSnippetBody(snippetBodyText, linePrefix);

/**
* Constructs a key-value entry for use in the variable description section
* of the user snippet definition. It contains tabstops for use in the
* meta-snippet.
* @param variable The variable
* @returns A [key, value] pair for use in the meta-snippet
*/
const constructVariableDescriptionEntry = ({
placeholderIndex,
}: Variable): [string, string] => {
// The key will have the same placeholder index as the other location
// where this variable appears.
const key = "$" + placeholderIndex;

// The value will end up being an empty object with a tabstop in the
// middle so that the user can add information about the variable, such
// as wrapperScopeType. Ie the output will look like `{|}` (with the `|`
// representing a tabstop in the meta-snippet)
//
// NB: We use the subsituter here, with `isQuoted=true` because in order
// to make this work for the meta-snippet, we want to end up with
// something like `{$3}`, which is not valid json. So we instead arrange
// to end up with json like `"hgidfsivhs"`, and then replace the whole
// string (including quotes) with `{$3}` after json-ification
const value = substituter.addSubstitution(
"{$" + currentPlaceholderIndex++ + "}",
true
);

return [key, value];
};

/** An object that will be json-ified to become the meta-snippet */
const snippet = {
[snippetName]: {
definitions: [
{
scope: {
langIds: [editor.document.languageId],
},
body: snippetLines,
},
],
description: "$" + currentPlaceholderIndex++,
variables:
variables.length === 0
? undefined
: Object.fromEntries(
variables.map(constructVariableDescriptionEntry)
),
},
};

/**
* This is the text of the meta-snippet in Textmate format that we will
* insert into the new document where the user will fill out their snippet
* definition
*/
const snippetText = substituter.makeSubstitutions(
JSON.stringify(snippet, null, 2)
);

const userSnippetsDir = workspace
.getConfiguration("cursorless.experimental")
.get<string>("snippetsDir");

if (!userSnippetsDir) {
throw new Error("User snippets dir not configured.");
}

if (isTesting()) {
// If we're testing, we just overwrite the current document
editor.selections = [
selectionFromRange(false, getDocumentRange(editor.document)),
];
} else {
// Otherwise, we create a new document for the snippet in the user
// snippets dir
const path = join(userSnippetsDir, `${snippetName}.cursorless-snippets`);
await touch(path);
const snippetDoc = await workspace.openTextDocument(path);
await window.showTextDocument(snippetDoc);
}

// Insert the meta-snippet
await commands.executeCommand("editor.action.insertSnippet", {
snippet: snippetText,
});

return {
thatMark: targets.map(({ editor, contentSelection }) => ({
editor,
selection: contentSelection,
})),
};
}
}

async function touch(path: string) {
const file = await open(path, "w");
await file.close();
}

interface Variable {
/**
* The start an end offsets of the variable relative to the text of the
* snippet that contains it
*/
offsets: Offsets;

/**
* The default name for the given variable that will appear as the placeholder
* text in the meta snippet
*/
defaultName: string;

/**
* The placeholder to use when filling out the name of this variable in the
* meta snippet.
*/
placeholderIndex: number;
}
Loading