Skip to content

Commit 57ace86

Browse files
Automatic snippet generator (#310)
* Generate snippets subrepo: subdir: "cursorless-talon" merged: "59c9118b" upstream: origin: "file:///Users/pokey/src/cursorless-talon-development" branch: "generate-snippet" commit: "59c9118b" git-subrepo: version: "0.4.3" origin: "https://github.com/ingydotnet/git-subrepo" commit: "2f68596" Old generate snippets Fixes * Some cleanup * Some cleanup * Stop modifying document * Fixed tests * Update doc * More cleanup * docs * More cleanup * Docs * Cleanup * Remove executable bet on actions Co-authored-by: Andreas Arvidsson <[email protected]>
1 parent 56cc6cb commit 57ace86

File tree

12 files changed

+609
-1
lines changed

12 files changed

+609
-1
lines changed

cursorless-talon/src/actions/actions.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from .actions_callback import callback_action_defaults, callback_action_map
55
from .actions_custom import custom_action_defaults
66
from .actions_makeshift import makeshift_action_defaults, makeshift_action_map
7-
from .actions_simple import positional_action_defaults, simple_action_defaults
7+
from .actions_simple import (
8+
no_wait_actions,
9+
positional_action_defaults,
10+
simple_action_defaults,
11+
)
812

913
mod = Module()
1014

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

5054
return return_value
55+
elif action_id in no_wait_actions:
56+
return actions.user.cursorless_single_target_command_no_wait(
57+
action_id, target
58+
)
5159
else:
5260
return actions.user.cursorless_single_target_command(action_id, target)
5361

cursorless-talon/src/actions/actions_simple.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"shuffle": "randomizeTargets",
3232
"reverse": "reverseTargets",
3333
"scout all": "findInWorkspace",
34+
"snippet make": "generateSnippet",
3435
"sort": "sortTargets",
3536
"take": "setSelection",
3637
"unfold": "unfoldRegion",
@@ -42,6 +43,11 @@
4243
"paste": "pasteFromClipboard",
4344
}
4445

46+
# Don't wait for these actions to finish, usually because they hang on some kind of user interaction
47+
no_wait_actions = [
48+
"generateSnippet",
49+
]
50+
4551
mod = Module()
4652
mod.list(
4753
"cursorless_simple_action",

src/actions/Actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import ToggleBreakpoint from "./ToggleBreakpoint";
3939
import Wrap from "./Wrap";
4040
import WrapWithSnippet from "./WrapWithSnippet";
4141
import InsertSnippet from "./InsertSnippet";
42+
import GenerateSnippet from "./GenerateSnippet";
4243

4344
class Actions implements ActionRecord {
4445
constructor(private graph: Graph) {}
@@ -57,6 +58,7 @@ class Actions implements ActionRecord {
5758
foldRegion = new Fold(this.graph);
5859
followLink = new FollowLink(this.graph);
5960
getText = new GetText(this.graph);
61+
generateSnippet = new GenerateSnippet(this.graph);
6062
highlight = new Highlight(this.graph);
6163
indentLine = new IndentLines(this.graph);
6264
insertCopyAfter = new InsertCopyAfter(this.graph);
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { ensureSingleTarget } from "../../util/targetUtils";
2+
3+
import { commands, Range, window } from "vscode";
4+
import { Offsets } from "../../processTargets/modifiers/surroundingPair/types";
5+
import isTesting from "../../testUtil/isTesting";
6+
import { Target } from "../../typings/target.types";
7+
import { Graph } from "../../typings/Types";
8+
import { getDocumentRange } from "../../util/range";
9+
import { selectionFromRange } from "../../util/selectionUtils";
10+
import { Action, ActionReturnValue } from "../actions.types";
11+
import { constructSnippetBody } from "./constructSnippetBody";
12+
import { editText } from "./editText";
13+
import { openNewSnippetFile } from "./openNewSnippetFile";
14+
import Substituter from "./Substituter";
15+
16+
/**
17+
* This action can be used to automatically create a snippet from a target. Any
18+
* cursor selections inside the target will become placeholders in the final
19+
* snippet. This action creates a new file, and inserts a snippet that the user
20+
* can fill out to construct their desired snippet.
21+
*
22+
* Note that there are two snippets involved in this implementation:
23+
*
24+
* - The snippet that the user is trying to create. We refer to this snippet as
25+
* the user snippet.
26+
* - The snippet that we insert that the user can use to build their snippet. We
27+
* refer to this as the meta snippet.
28+
*
29+
* We proceed as follows:
30+
*
31+
* 1. Ask user for snippet name if not provided as arg
32+
* 2. Find all cursor selections inside target - these will become the user
33+
* snippet variables
34+
* 3. Extract text of target
35+
* 4. Replace cursor selections in text with random ids that won't be affected
36+
* by json serialization. After serialization we'll replace these id's by
37+
* snippet placeholders.
38+
* 4. Construct the user snippet body as a list of strings
39+
* 5. Construct a javascript object that will be json-ified to become the meta
40+
* snippet
41+
* 6. Serialize the javascript object to json
42+
* 7. Perform replacements on the random id's appearing in this json to get the
43+
* text we desire. This modified json output is the meta snippet.
44+
* 8. Open a new document in user custom snippets dir to hold the new snippet.
45+
* 9. Insert the meta snippet so that the user can construct their snippet.
46+
*
47+
* Note that we avoid using JS interpolation strings here because the syntax is
48+
* very similar to snippet placeholders, so we would end up with lots of
49+
* confusing escaping.
50+
*/
51+
export default class GenerateSnippet implements Action {
52+
constructor(private graph: Graph) {
53+
this.run = this.run.bind(this);
54+
}
55+
56+
async run(
57+
[targets]: [Target[]],
58+
snippetName?: string
59+
): Promise<ActionReturnValue> {
60+
const target = ensureSingleTarget(targets);
61+
const editor = target.editor;
62+
63+
// NB: We don't await the pending edit decoration so that if the user
64+
// immediately starts saying the name of the snippet (eg command chain
65+
// "snippet make funk camel my function"), we're more likely to
66+
// win the race and have the input box ready for them
67+
this.graph.editStyles.displayPendingEditDecorations(
68+
targets,
69+
this.graph.editStyles.referenced
70+
);
71+
72+
if (snippetName == null) {
73+
snippetName = await window.showInputBox({
74+
prompt: "Name of snippet",
75+
placeHolder: "helloWorld",
76+
});
77+
}
78+
79+
// User cancelled; don't do anything
80+
if (snippetName == null) {
81+
return {};
82+
}
83+
84+
/** The next placeholder index to use for the meta snippet */
85+
let currentPlaceholderIndex = 1;
86+
87+
const baseOffset = editor.document.offsetAt(target.contentRange.start);
88+
89+
/**
90+
* The variables that will appear in the user snippet. Note that
91+
* `placeholderIndex` here is the placeholder index in the meta snippet not
92+
* the user snippet.
93+
*/
94+
const variables: Variable[] = editor.selections
95+
.filter((selection) => target.contentRange.contains(selection))
96+
.map((selection, index) => ({
97+
offsets: {
98+
start: editor.document.offsetAt(selection.start) - baseOffset,
99+
end: editor.document.offsetAt(selection.end) - baseOffset,
100+
},
101+
defaultName: `variable${index + 1}`,
102+
placeholderIndex: currentPlaceholderIndex++,
103+
}));
104+
105+
/**
106+
* Constructs random ids that can be put into the text that won't be
107+
* modified by json serialization.
108+
*/
109+
const substituter = new Substituter();
110+
111+
/**
112+
* Text before the start of the snippet in the snippet start line. We need
113+
* to pass this to {@link constructSnippetBody} so that it knows the
114+
* baseline indentation of the snippet
115+
*/
116+
const linePrefix = editor.document.getText(
117+
new Range(
118+
target.contentRange.start.with(undefined, 0),
119+
target.contentRange.start
120+
)
121+
);
122+
123+
/** The text of the snippet, with placeholders inserted for variables */
124+
const snippetBodyText = editText(
125+
editor.document.getText(target.contentRange),
126+
variables.map(({ offsets, defaultName, placeholderIndex }) => ({
127+
offsets,
128+
// Note that the reason we use the substituter here is primarily so
129+
// that the `\` below doesn't get escaped upon conversion to json.
130+
text: substituter.addSubstitution(
131+
[
132+
// This `\$` will end up being a `$` in the final document. It
133+
// indicates the start of a variable in the user snippet. We need
134+
// the `\` so that the meta-snippet doesn't see it as one of its
135+
// placeholders.
136+
"\\$",
137+
138+
// The remaining text here is a placeholder in the meta-snippet
139+
// that the user can use to name their snippet variable that will
140+
// be in the user snippet.
141+
"${",
142+
placeholderIndex,
143+
":",
144+
defaultName,
145+
"}",
146+
].join("")
147+
),
148+
}))
149+
);
150+
151+
const snippetLines = constructSnippetBody(snippetBodyText, linePrefix);
152+
153+
/**
154+
* Constructs a key-value entry for use in the variable description section
155+
* of the user snippet definition. It contains tabstops for use in the
156+
* meta-snippet.
157+
* @param variable The variable
158+
* @returns A [key, value] pair for use in the meta-snippet
159+
*/
160+
const constructVariableDescriptionEntry = ({
161+
placeholderIndex,
162+
}: Variable): [string, string] => {
163+
// The key will have the same placeholder index as the other location
164+
// where this variable appears.
165+
const key = "$" + placeholderIndex;
166+
167+
// The value will end up being an empty object with a tabstop in the
168+
// middle so that the user can add information about the variable, such
169+
// as wrapperScopeType. Ie the output will look like `{|}` (with the `|`
170+
// representing a tabstop in the meta-snippet)
171+
//
172+
// NB: We use the subsituter here, with `isQuoted=true` because in order
173+
// to make this work for the meta-snippet, we want to end up with
174+
// something like `{$3}`, which is not valid json. So we instead arrange
175+
// to end up with json like `"hgidfsivhs"`, and then replace the whole
176+
// string (including quotes) with `{$3}` after json-ification
177+
const value = substituter.addSubstitution(
178+
"{$" + currentPlaceholderIndex++ + "}",
179+
true
180+
);
181+
182+
return [key, value];
183+
};
184+
185+
/** An object that will be json-ified to become the meta-snippet */
186+
const snippet = {
187+
[snippetName]: {
188+
definitions: [
189+
{
190+
scope: {
191+
langIds: [editor.document.languageId],
192+
},
193+
body: snippetLines,
194+
},
195+
],
196+
description: "$" + currentPlaceholderIndex++,
197+
variables:
198+
variables.length === 0
199+
? undefined
200+
: Object.fromEntries(
201+
variables.map(constructVariableDescriptionEntry)
202+
),
203+
},
204+
};
205+
206+
/**
207+
* This is the text of the meta-snippet in Textmate format that we will
208+
* insert into the new document where the user will fill out their snippet
209+
* definition
210+
*/
211+
const snippetText = substituter.makeSubstitutions(
212+
JSON.stringify(snippet, null, 2)
213+
);
214+
215+
if (isTesting()) {
216+
// If we're testing, we just overwrite the current document
217+
editor.selections = [
218+
selectionFromRange(false, getDocumentRange(editor.document)),
219+
];
220+
} else {
221+
// Otherwise, we create and open a new document for the snippet in the
222+
// user snippets dir
223+
await openNewSnippetFile(snippetName);
224+
}
225+
226+
// Insert the meta-snippet
227+
await commands.executeCommand("editor.action.insertSnippet", {
228+
snippet: snippetText,
229+
});
230+
231+
return {
232+
thatMark: targets.map(({ editor, contentSelection }) => ({
233+
editor,
234+
selection: contentSelection,
235+
})),
236+
};
237+
}
238+
}
239+
240+
interface Variable {
241+
/**
242+
* The start an end offsets of the variable relative to the text of the
243+
* snippet that contains it
244+
*/
245+
offsets: Offsets;
246+
247+
/**
248+
* The default name for the given variable that will appear as the placeholder
249+
* text in the meta snippet
250+
*/
251+
defaultName: string;
252+
253+
/**
254+
* The placeholder to use when filling out the name of this variable in the
255+
* meta snippet.
256+
*/
257+
placeholderIndex: number;
258+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
interface Substitution {
2+
randomId: string;
3+
to: string;
4+
isQuoted: boolean;
5+
}
6+
7+
/**
8+
* This class constructs random strings that can be used as placeholders for the
9+
* strings you'd like to insert into a document. This functionality is useful if
10+
* the strings you'd like to insert might get modified by something like json
11+
* serialization. You proceed by calling {@link addSubstitution} for each string you'd
12+
* like to put into your document. This function returns a random id that you
13+
* can put into your text. When you are done, call {@link makeSubstitutions}
14+
* on the final text to replace the random id's with the original strings you
15+
* desired.
16+
*/
17+
export default class Substituter {
18+
private substitutions: Substitution[] = [];
19+
20+
/**
21+
* Get a random id that can be put into your text body that will then be
22+
* replaced by {@link to} when you call {@link makeSubstitutions}.
23+
* @param to The string that you'd like to end up in the final document after
24+
* replacements
25+
* @param isQuoted Use this variable to indicate that in the final text the
26+
* variable will end up quoted. This occurs if you use the replacement string
27+
* as a stand alone string in a json document and then you serialize it
28+
* @returns A unique random id that can be put into the document that will
29+
* then be substituted later
30+
*/
31+
addSubstitution(to: string, isQuoted: boolean = false) {
32+
const randomId = makeid(10);
33+
34+
this.substitutions.push({
35+
to,
36+
randomId,
37+
isQuoted,
38+
});
39+
40+
return randomId;
41+
}
42+
43+
/**
44+
* Performs substitutions on {@link text}, replacing the random ids generated
45+
* by {@link addSubstitution} with the values passed in for `to`.
46+
* @param text The text to perform substitutions on
47+
* @returns The text with variable substituted for the original values you
48+
* desired
49+
*/
50+
makeSubstitutions(text: string) {
51+
this.substitutions.forEach(({ to, randomId, isQuoted }) => {
52+
const from = isQuoted ? `"${randomId}"` : randomId;
53+
// NB: We use split / join instead of replace because the latter doesn't
54+
// handle dollar signs well
55+
text = text.split(from).join(to);
56+
});
57+
58+
return text;
59+
}
60+
}
61+
62+
/**
63+
* Constructs a random id of the given length.
64+
*
65+
* From https://stackoverflow.com/a/1349426/2605678
66+
*
67+
* @param length Length of the string to generate
68+
* @returns A string of random digits of length {@param length}
69+
*/
70+
function makeid(length: number) {
71+
var result = "";
72+
var characters =
73+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
74+
var charactersLength = characters.length;
75+
for (var i = 0; i < length; i++) {
76+
result += characters.charAt(Math.floor(Math.random() * charactersLength));
77+
}
78+
return result;
79+
}

0 commit comments

Comments
 (0)