Skip to content

Commit b48f14a

Browse files
authored
Support specializing snippet definitions by containing scope (#1487)
I thought this one would be a quick win, but deciding which matching snippet should apply turned out to be complex - Closes #802 Note that we don't support scope specialisation for custom snippets; should be a fairly easy follow-up PR building on this machinery. ## Checklist - [ ] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [ ] I have not broken the cheatsheet
1 parent ca4b930 commit b48f14a

22 files changed

+1211
-73
lines changed

cursorless-snippets/functionDeclaration.cursorless-snippets

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@
2121
}
2222
}
2323
},
24+
{
25+
"scope": {
26+
"langIds": [
27+
"typescript",
28+
"typescriptreact",
29+
"javascript",
30+
"javascriptreact"
31+
],
32+
"scopeTypes": [
33+
"class"
34+
],
35+
"excludeDescendantScopeTypes": [
36+
"namedFunction"
37+
]
38+
},
39+
"body": [
40+
"$name($parameterList) {",
41+
"\t$body",
42+
"}"
43+
],
44+
"variables": {
45+
"name": {
46+
"formatter": "camelCase"
47+
}
48+
}
49+
},
2450
{
2551
"scope": {
2652
"langIds": [
@@ -36,6 +62,28 @@
3662
"formatter": "snakeCase"
3763
}
3864
}
65+
},
66+
{
67+
"scope": {
68+
"langIds": [
69+
"python"
70+
],
71+
"scopeTypes": [
72+
"class"
73+
],
74+
"excludeDescendantScopeTypes": [
75+
"namedFunction"
76+
]
77+
},
78+
"body": [
79+
"def $name(self${parameterList:, }):",
80+
"\t$body"
81+
],
82+
"variables": {
83+
"name": {
84+
"formatter": "snakeCase"
85+
}
86+
}
3987
}
4088
],
4189
"description": "Function declaration",

packages/common/src/types/snippet.types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export interface SnippetScope {
1313
* global scope.
1414
*/
1515
scopeTypes?: SimpleScopeTypeType[];
16+
17+
/**
18+
* Exclude regions of {@link scopeTypes} that are descendants of these scope
19+
* types. For example, if you have a snippet that should be active in a class
20+
* but not in a function within the class, you can specify
21+
* `scopeTypes: ["class"], excludeDescendantScopeTypes: ["namedFunction"]`.
22+
*/
23+
excludeDescendantScopeTypes?: SimpleScopeTypeType[];
1624
}
1725

1826
export type SnippetBody = string[];

packages/cursorless-engine/src/actions/InsertSnippet.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Target } from "../typings/target.types";
2323
import { ensureSingleEditor } from "../util/targetUtils";
2424
import { Actions } from "./Actions";
2525
import { Action, ActionReturnValue } from "./actions.types";
26+
import { UntypedTarget } from "../processTargets/targets";
2627

2728
interface NamedSnippetArg {
2829
type: "named";
@@ -94,6 +95,7 @@ export default class InsertSnippet implements Action {
9495
const snippet = this.snippets.getSnippetStrict(name);
9596

9697
const definition = findMatchingSnippetDefinitionStrict(
98+
this.modifierStageFactory,
9799
targets,
98100
snippet.definitions,
99101
);
@@ -124,9 +126,28 @@ export default class InsertSnippet implements Action {
124126
): Promise<ActionReturnValue> {
125127
const editor = ide().getEditableTextEditor(ensureSingleEditor(targets));
126128

129+
await this.actions.editNew.run([targets]);
130+
131+
const targetSelectionInfos = editor.selections.map((selection) =>
132+
getSelectionInfo(
133+
editor.document,
134+
selection,
135+
RangeExpansionBehavior.openOpen,
136+
),
137+
);
127138
const { body, formatSubstitutions } = this.getSnippetInfo(
128139
snippetDescription,
129-
targets,
140+
// Use new selection locations instead of original targets because
141+
// that's where we'll be doing the snippet insertion
142+
editor.selections.map(
143+
(selection) =>
144+
new UntypedTarget({
145+
editor,
146+
contentRange: selection,
147+
isReversed: false,
148+
hasExplicitRange: true,
149+
}),
150+
),
130151
);
131152

132153
const parsedSnippet = this.snippetParser.parse(body);
@@ -139,16 +160,6 @@ export default class InsertSnippet implements Action {
139160

140161
const snippetString = parsedSnippet.toTextmateString();
141162

142-
await this.actions.editNew.run([targets]);
143-
144-
const targetSelectionInfos = editor.selections.map((selection) =>
145-
getSelectionInfo(
146-
editor.document,
147-
selection,
148-
RangeExpansionBehavior.openOpen,
149-
),
150-
);
151-
152163
// NB: We used the command "editor.action.insertSnippet" instead of calling editor.insertSnippet
153164
// because the latter doesn't support special variables like CLIPBOARD
154165
const [updatedTargetSelections] = await callFunctionAndUpdateSelectionInfos(

packages/cursorless-engine/src/actions/WrapWithSnippet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export default class WrapWithSnippet implements Action {
8686
const snippet = this.snippets.getSnippetStrict(name);
8787

8888
const definition = findMatchingSnippetDefinitionStrict(
89+
this.modifierStageFactory,
8990
targets,
9091
snippet.definitions,
9192
);

packages/cursorless-engine/src/core/Snippets.ts

Lines changed: 33 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { showError, Snippet, SnippetMap, walkFiles } from "@cursorless/common";
22
import { readFile, stat } from "fs/promises";
3-
import { cloneDeep, max, merge } from "lodash";
3+
import { max } from "lodash";
44
import { join } from "path";
55
import { ide } from "../singletons/ide.singleton";
66
import { mergeStrict } from "../util/object";
7+
import { mergeSnippets } from "./mergeSnippets";
78

89
const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets";
910
const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000;
@@ -21,7 +22,7 @@ interface DirectoryErrorMessage {
2122
export class Snippets {
2223
private coreSnippets!: SnippetMap;
2324
private thirdPartySnippets: Record<string, SnippetMap> = {};
24-
private userSnippets!: SnippetMap;
25+
private userSnippets!: SnippetMap[];
2526

2627
private mergedSnippets!: SnippetMap;
2728

@@ -133,7 +134,7 @@ export class Snippets {
133134
};
134135
}
135136

136-
this.userSnippets = {};
137+
this.userSnippets = [];
137138
this.mergeSnippets();
138139

139140
return;
@@ -154,34 +155,32 @@ export class Snippets {
154155

155156
this.maxSnippetMtimeMs = maxSnippetMtime;
156157

157-
this.userSnippets = mergeStrict(
158-
...(await Promise.all(
159-
snippetFiles.map(async (path) => {
160-
try {
161-
const content = await readFile(path, "utf8");
162-
163-
if (content.length === 0) {
164-
// Gracefully handle an empty file
165-
return {};
166-
}
167-
168-
return JSON.parse(content);
169-
} catch (err) {
170-
showError(
171-
ide().messages,
172-
"snippetsFileError",
173-
`Error with cursorless snippets file "${path}": ${
174-
(err as Error).message
175-
}`,
176-
);
177-
178-
// We don't want snippets from all files to stop working if there is
179-
// a parse error in one file, so we just effectively ignore this file
180-
// once we've shown an error message
158+
this.userSnippets = await Promise.all(
159+
snippetFiles.map(async (path) => {
160+
try {
161+
const content = await readFile(path, "utf8");
162+
163+
if (content.length === 0) {
164+
// Gracefully handle an empty file
181165
return {};
182166
}
183-
}),
184-
)),
167+
168+
return JSON.parse(content);
169+
} catch (err) {
170+
showError(
171+
ide().messages,
172+
"snippetsFileError",
173+
`Error with cursorless snippets file "${path}": ${
174+
(err as Error).message
175+
}`,
176+
);
177+
178+
// We don't want snippets from all files to stop working if there is
179+
// a parse error in one file, so we just effectively ignore this file
180+
// once we've shown an error message
181+
return {};
182+
}
183+
}),
185184
);
186185

187186
this.mergeSnippets();
@@ -206,34 +205,11 @@ export class Snippets {
206205
* party > core.
207206
*/
208207
private mergeSnippets() {
209-
this.mergedSnippets = {};
210-
211-
// We make a list of all entries from all sources, in order of increasing
212-
// precedence: user > third party > core.
213-
const entries = [
214-
...Object.entries(cloneDeep(this.coreSnippets)),
215-
...Object.values(this.thirdPartySnippets).flatMap((snippets) =>
216-
Object.entries(cloneDeep(snippets)),
217-
),
218-
...Object.entries(cloneDeep(this.userSnippets)),
219-
];
220-
221-
entries.forEach(([key, value]) => {
222-
if (Object.prototype.hasOwnProperty.call(this.mergedSnippets, key)) {
223-
const { definitions, ...rest } = value;
224-
const mergedSnippet = this.mergedSnippets[key];
225-
226-
// NB: We make sure that the new definitions appear before the previous
227-
// ones so that they take precedence
228-
mergedSnippet.definitions = definitions.concat(
229-
...mergedSnippet.definitions,
230-
);
231-
232-
merge(mergedSnippet, rest);
233-
} else {
234-
this.mergedSnippets[key] = value;
235-
}
236-
});
208+
this.mergedSnippets = mergeSnippets(
209+
this.coreSnippets,
210+
this.thirdPartySnippets,
211+
this.userSnippets,
212+
);
237213
}
238214

239215
/**
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
SimpleScopeTypeType,
3+
SnippetDefinition,
4+
SnippetScope,
5+
} from "@cursorless/common";
6+
import { SnippetOrigin } from "./mergeSnippets";
7+
8+
/**
9+
* Compares two snippet definitions by how specific their scope, breaking
10+
* ties by origin.
11+
* @param a One of the snippet definitions to compare
12+
* @param b The other snippet definition to compare
13+
* @returns A negative number if a should come before b, a positive number if b
14+
*/
15+
export function compareSnippetDefinitions(
16+
a: SnippetDefinitionWithOrigin,
17+
b: SnippetDefinitionWithOrigin,
18+
): number {
19+
const scopeComparision = compareSnippetScopes(
20+
a.definition.scope,
21+
b.definition.scope,
22+
);
23+
24+
// Prefer the more specific snippet definitino, no matter the origin
25+
if (scopeComparision !== 0) {
26+
return scopeComparision;
27+
}
28+
29+
// If the scopes are the same, prefer the snippet from the higher priority
30+
// origin
31+
return a.origin - b.origin;
32+
}
33+
34+
function compareSnippetScopes(
35+
a: SnippetScope | undefined,
36+
b: SnippetScope | undefined,
37+
): number {
38+
if (a == null && b == null) {
39+
return 0;
40+
}
41+
42+
// Prefer the snippet that has a scope at all
43+
if (a == null) {
44+
return -1;
45+
}
46+
47+
if (b == null) {
48+
return 1;
49+
}
50+
51+
// Prefer the snippet that is language-specific, regardless of scope type
52+
if (a.langIds == null && b.langIds != null) {
53+
return -1;
54+
}
55+
56+
if (b.langIds == null && a.langIds != null) {
57+
return 1;
58+
}
59+
60+
// If both snippets are language-specific, prefer the snippet that specifies
61+
// scope types. Note that this holds even if one snippet specifies more
62+
// languages than the other. The motivating use case is if you have a snippet
63+
// for functions in js and ts, and a snippet for methods in js and ts. If you
64+
// override the function snippet for ts, you still want the method snippet to
65+
// be used for ts methods.
66+
const scopeTypesComparision = compareScopeTypes(a.scopeTypes, b.scopeTypes);
67+
68+
if (scopeTypesComparision !== 0) {
69+
return scopeTypesComparision;
70+
}
71+
72+
// If snippets both have scope types or both don't have scope types, prefer
73+
// the snippet that specifies fewer languages
74+
return a.langIds == null ? 0 : b.langIds!.length - a.langIds.length;
75+
}
76+
77+
function compareScopeTypes(
78+
a: SimpleScopeTypeType[] | undefined,
79+
b: SimpleScopeTypeType[] | undefined,
80+
): number {
81+
if (a == null && b != null) {
82+
return -1;
83+
}
84+
85+
if (b == null && a != null) {
86+
return 1;
87+
}
88+
89+
return 0;
90+
}
91+
92+
interface SnippetDefinitionWithOrigin {
93+
origin: SnippetOrigin;
94+
definition: SnippetDefinition;
95+
}

0 commit comments

Comments
 (0)