Skip to content

Commit a0ad4f6

Browse files
committed
Support specializing snippet definitions by containing scope
1 parent 29056a7 commit a0ad4f6

File tree

13 files changed

+827
-72
lines changed

13 files changed

+827
-72
lines changed

cursorless-snippets/functionDeclaration.cursorless-snippets

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"typescriptreact",
99
"javascript",
1010
"javascriptreact"
11+
],
12+
"scopeTypes": [
13+
"namedFunction",
14+
"document"
1115
]
1216
},
1317
"body": [
@@ -21,6 +25,29 @@
2125
}
2226
}
2327
},
28+
{
29+
"scope": {
30+
"langIds": [
31+
"typescript",
32+
"typescriptreact",
33+
"javascript",
34+
"javascriptreact"
35+
],
36+
"scopeTypes": [
37+
"class"
38+
]
39+
},
40+
"body": [
41+
"$name($parameterList) {",
42+
"\t$body",
43+
"}"
44+
],
45+
"variables": {
46+
"name": {
47+
"formatter": "camelCase"
48+
}
49+
}
50+
},
2451
{
2552
"scope": {
2653
"langIds": [

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

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

9696
const definition = findMatchingSnippetDefinitionStrict(
97+
this.modifierStageFactory,
9798
targets,
9899
snippet.definitions,
99100
);

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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
if (scopeComparision !== 0) {
25+
return scopeComparision;
26+
}
27+
28+
return a.origin - b.origin;
29+
}
30+
31+
function compareSnippetScopes(
32+
a: SnippetScope | undefined,
33+
b: SnippetScope | undefined,
34+
): number {
35+
if (a == null && b == null) {
36+
return 0;
37+
}
38+
39+
if (a == null) {
40+
return -1;
41+
}
42+
43+
if (b == null) {
44+
return 1;
45+
}
46+
47+
const langIdsComparision = compareLangIds(a.langIds, b.langIds);
48+
49+
if (langIdsComparision !== 0) {
50+
return langIdsComparision;
51+
}
52+
53+
return compareScopeTypes(a.scopeTypes, b.scopeTypes);
54+
}
55+
56+
function compareLangIds(
57+
a: string[] | undefined,
58+
b: string[] | undefined,
59+
): number {
60+
if (a == null && b == null) {
61+
return 0;
62+
}
63+
64+
if (a == null) {
65+
return -1;
66+
}
67+
68+
if (b == null) {
69+
return 1;
70+
}
71+
72+
return b.length - a.length;
73+
}
74+
75+
function compareScopeTypes(
76+
a: SimpleScopeTypeType[] | undefined,
77+
b: SimpleScopeTypeType[] | undefined,
78+
): number {
79+
if (a == null && b != null) {
80+
return -1;
81+
}
82+
83+
if (b == null && a != null) {
84+
return 1;
85+
}
86+
87+
return 0;
88+
}
89+
90+
interface SnippetDefinitionWithOrigin {
91+
origin: SnippetOrigin;
92+
definition: SnippetDefinition;
93+
}

0 commit comments

Comments
 (0)