Skip to content

Commit

Permalink
Automatic snippet generator (#310)
Browse files Browse the repository at this point in the history
* 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 <andreas.arvidsson87@gmail.com>
  • Loading branch information
pokey and AndreasArvidsson authored Jul 4, 2022
1 parent 56cc6cb commit 57ace86
Show file tree
Hide file tree
Showing 12 changed files with 609 additions and 1 deletion.
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
)
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
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
258 changes: 258 additions & 0 deletions src/actions/GenerateSnippet/GenerateSnippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { ensureSingleTarget } from "../../util/targetUtils";

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

/**
* 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)
);

if (isTesting()) {
// If we're testing, we just overwrite the current document
editor.selections = [
selectionFromRange(false, getDocumentRange(editor.document)),
];
} else {
// Otherwise, we create and open a new document for the snippet in the
// user snippets dir
await openNewSnippetFile(snippetName);
}

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

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

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;
}
79 changes: 79 additions & 0 deletions src/actions/GenerateSnippet/Substituter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
interface Substitution {
randomId: string;
to: string;
isQuoted: boolean;
}

/**
* This class constructs random strings that can be used as placeholders for the
* strings you'd like to insert into a document. This functionality is useful if
* the strings you'd like to insert might get modified by something like json
* serialization. You proceed by calling {@link addSubstitution} for each string you'd
* like to put into your document. This function returns a random id that you
* can put into your text. When you are done, call {@link makeSubstitutions}
* on the final text to replace the random id's with the original strings you
* desired.
*/
export default class Substituter {
private substitutions: Substitution[] = [];

/**
* Get a random id that can be put into your text body that will then be
* replaced by {@link to} when you call {@link makeSubstitutions}.
* @param to The string that you'd like to end up in the final document after
* replacements
* @param isQuoted Use this variable to indicate that in the final text the
* variable will end up quoted. This occurs if you use the replacement string
* as a stand alone string in a json document and then you serialize it
* @returns A unique random id that can be put into the document that will
* then be substituted later
*/
addSubstitution(to: string, isQuoted: boolean = false) {
const randomId = makeid(10);

this.substitutions.push({
to,
randomId,
isQuoted,
});

return randomId;
}

/**
* Performs substitutions on {@link text}, replacing the random ids generated
* by {@link addSubstitution} with the values passed in for `to`.
* @param text The text to perform substitutions on
* @returns The text with variable substituted for the original values you
* desired
*/
makeSubstitutions(text: string) {
this.substitutions.forEach(({ to, randomId, isQuoted }) => {
const from = isQuoted ? `"${randomId}"` : randomId;
// NB: We use split / join instead of replace because the latter doesn't
// handle dollar signs well
text = text.split(from).join(to);
});

return text;
}
}

/**
* Constructs a random id of the given length.
*
* From https://stackoverflow.com/a/1349426/2605678
*
* @param length Length of the string to generate
* @returns A string of random digits of length {@param length}
*/
function makeid(length: number) {
var result = "";
var characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
Loading

0 comments on commit 57ace86

Please sign in to comment.