Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.

Commit 09aa82a

Browse files
committed
even more tests
1 parent e4822b6 commit 09aa82a

File tree

2 files changed

+214
-9
lines changed

2 files changed

+214
-9
lines changed

lib/snippet-body.pegjs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,16 @@ regexString = r:([^/\\] / '\\' c:. { return '\\' + c } )* { return r.join(""); }
9797

9898
// The form of a substitution for a transformation. It is a mix of plain text + modifiers + backreferences to the find capture groups
9999
// It cannot access tab stop values.
100-
replace = r:(replaceText / format / replaceModifier / escapedReplace / [^}/])* { return coalesce(r); }
100+
replace = r:(replaceText / format / replaceModifier / escapedReplace / [^/])* { return coalesce(r); }
101+
102+
// Same as replace, but we disallow plain '}' instead of plain '/' because we are inside a format (ended by '}')
103+
// NOTE: Diallowing escape of '/' is consistent with VS Code. The general rule is "if it's not got a special meaning, it can't be escaped"
104+
// Inside a format there is no special meaning to '/', so we can't escape it.
105+
formatReplace = r:(replaceText / format / replaceModifier / escapedFormatReplace / [^}])* { return coalesce(r); }
106+
107+
// Another special case; the if half of an if-else format is terminated by ':'
108+
ifElseReplace = r:(replaceText / format / replaceModifier / escapedIfElseReplace / [^:])* { return coalesce(r); }
109+
101110

102111
// A reference to a capture group of the find regex of a transformation. Can conditionally
103112
// resolve based on if the match occurred, and have arbitrary modifiers applied to it.
@@ -117,13 +126,16 @@ formatWithModifier = '{' n:integer ':' modifier:modifier '}' { return { backrefe
117126
// If the `n`th capture group is non-empty, then resolve to the `ifContent` value, else an empty string
118127
// Note that ifContent is a replace itself; it's formats still refer to the original transformation find though,
119128
// as transformations cannot be nested.
120-
formatWithIf = '{' n:integer ':+' ifContent:replace '}' { return { backreference: n, ifContent }; }
129+
formatWithIf = '{' n:integer ':+' ifContent:formatReplace '}' { return { backreference: n, ifContent }; }
121130

122131
// Same as the if case, but resolve to `elseContent` if empty instead of the empty string
123-
formatWithIfElse = '{' n:integer ':?' ifContent:replace ':' elseContent:replace '}' { return { backreference: n, ifContent, elseContent }; }
132+
formatWithIfElse = '{' n:integer ':?' ifContent:ifElseReplace ':' elseContent:formatReplace '}' { return { backreference: n, ifContent, elseContent }; }
124133

125134
// Same as the if case, but reversed behaviour with empty vs non-empty `n`th match
126-
formatWithElse = '{' n:integer ':' '-'? elseContent:replace { return { backreference: n, elseContent }; }
135+
// NOTE: The ':' form can cause ambiguities when the contents starts with '/', '+', etc.
136+
// However, instead of allowing them to be escaped, just tell issue raisers to use the
137+
// less ambiguous ':-' form (which also has nice symmetry with the ':+' form).
138+
formatWithElse = '{' n:integer ':' '-'? elseContent:formatReplace '}' { return { backreference: n, elseContent }; }
127139

128140
// Used in `format`s to transform a string using a JS function
129141
modifier = '/' modifier:name { return modifier; }
@@ -153,14 +165,14 @@ variableWithTransform = '{' v:name t:transformation '}' { return { variable: v,
153165
// Top level text. Anything that cannot be the start of something special. False negatives are handled later by the `any` rule
154166
text = t:([^$\\}])+ { return t.join("") }
155167

156-
// None-special text inside a tab stop placeholder. Should be no different to regular top level text.
168+
// Non-special text inside a tab stop placeholder. Should be no different to regular top level text.
157169
tabStopText = text
158170

159-
// None-special text inside a choice. $, {, }, etc. are all regular text in this context.
171+
// Non-special text inside a choice. $, {, }, etc. are all regular text in this context.
160172
choiceText = b:(t:[^,|\\]+ { return t.join(""); } / '\\' c:[,|\\] { return c; } / '\\' c:. { return '\\' + c; } )+ { return b.join(""); }
161173

162-
// None-special text inside a replace (substitution part of transformation). Same as normal text, but `/` is special (the end of the regex-like pattern)
163-
replaceText = t:[^$\\}/]+ { return t.join(""); }
174+
// Non-special text inside a replace (substitution part of transformation). Same as normal text, but `/` and ':' is special (the end of the regex-like pattern and if half terminator for if-else format)
175+
replaceText = t:[^$\\}/:]+ { return t.join(""); }
164176

165177
// Match an escaped character. The set of characters that can be escaped is based on context, generally restricted to the minimum set that enables expressing any text content
166178
escapedTopLevel = '\\' c:[$\\}] { return c; }
@@ -174,8 +186,14 @@ escapedChoice = '\\' c:[$\\,|] { return c; }
174186
// Same as top level, but `/` can also be escaped
175187
escapedReplace = '\\' c:[$\\/] { return c; }
176188

189+
// Format terminated by '}' instead of '/'
190+
escapedFormatReplace = '\\' c:[$\\}] { return c; }
191+
192+
// If half of if-else format terminated by ':' instead of '}'
193+
escapedIfElseReplace = '\\' c:[$\\:] { return c; }
194+
177195
// We handle 'modifiers' separately to escapes. These indicate a change in state when building the replacement (e.g., capitalisation)
178-
replaceModifier = '\\' m:[ElLuUnr] { return { modifier: m }; }
196+
replaceModifier = '\\' m:[ElLuU] { return { modifier: m }; }
179197

180198
// Match nonnegative integers like those used for tab stop ordering
181199
integer = digits:[0-9]+ { return parseInt(digits.join(""), 10); }

spec/body-parser-spec.js

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
const SnippetParser = require('../lib/snippet-body-parser');
22

33
describe("Snippet Body Parser", () => {
4+
// Helper for testing a snippet parse tree. The `input`
5+
// is the snippet string, the `tree` is the expected
6+
// parse tree.
47
function expectMatch(input, tree) {
58
expect(SnippetParser.parse(input)).toEqual(tree);
69
}
@@ -30,6 +33,10 @@ describe("Snippet Body Parser", () => {
3033
});
3134

3235
describe("with placeholders", () => {
36+
it("allows placeholders to be empty", () => {
37+
expectMatch("${1:}", [{ index: 1, content: [] }]);
38+
});
39+
3340
it("allows placeholders to be arbitrary", () => {
3441
expectMatch("${1:${2}$foo${3|a,b|}}", [
3542
{
@@ -43,6 +50,37 @@ describe("Snippet Body Parser", () => {
4350
]);
4451
});
4552

53+
it("even lets placeholders contain placeholders", () => {
54+
expectMatch("${1:${2:${3:levels}}}", [
55+
{
56+
index: 1,
57+
content: [
58+
{
59+
index: 2,
60+
content: [
61+
{
62+
index: 3,
63+
content: [
64+
"levels"
65+
]
66+
}
67+
]
68+
}
69+
]
70+
}
71+
]);
72+
});
73+
74+
it("ends the placeholder at an unmatched '}'", () => {
75+
expectMatch("${1:}}", [
76+
{
77+
index: 1,
78+
content: []
79+
},
80+
"}"
81+
]);
82+
});
83+
4684
it("allows escaping '}' in placeholders", () => {
4785
expectMatch("${1:\\}}", [{ index: 1, content: ["}"] }]);
4886
});
@@ -110,6 +148,8 @@ describe("Snippet Body Parser", () => {
110148
});
111149
});
112150

151+
// The placeholder implementation is expected to be the same as for tab stops, so
152+
// see the tab stop placeholder section for more thorough tests
113153
describe("with placeholders", () => {
114154
it("allows placeholders to be arbitrary", () => {
115155
expectMatch("${foo:${2}$bar${3|a,b|}}", [
@@ -208,6 +248,153 @@ describe("Snippet Body Parser", () => {
208248
"/}"
209249
]);
210250
});
251+
252+
it("allows and preserves all escapes in regex strings", () => {
253+
expectMatch("${1/foo\\/\\$\\:\\n\\r/baz/}", [
254+
{
255+
index: 1,
256+
transformation: {
257+
find: /foo\/\$\:\n\r/,
258+
replace: [
259+
"baz"
260+
]
261+
}
262+
}
263+
]);
264+
});
265+
266+
describe("When parsing the replace section", () => {
267+
// Helper for testing the relacement part of
268+
// transformations, which are relatively deep in
269+
// the tree and have a lot of behaviour to cover
270+
// NOTE: Only use when the replace section is expected to
271+
// be valid, or else you will be testing against the
272+
// boilerplate (which is not a good idea)
273+
function expectReplaceMatch(replace, tree) {
274+
expectMatch(`\${1/foo/${replace}/}`, [
275+
{
276+
index: 1,
277+
transformation: {
278+
find: /foo/,
279+
replace: tree,
280+
}
281+
}
282+
]);
283+
}
284+
285+
it("allows '$' and '}' as plain text if not part of a format", () => {
286+
expectReplaceMatch("$}", ["$}"]);
287+
});
288+
289+
it("allows inline 'escaped modifiers'", () => {
290+
expectReplaceMatch("foo\\E\\l\\L\\u\\Ubar", [
291+
"foo",
292+
{ modifier: "E" },
293+
{ modifier: "l" },
294+
{ modifier: "L" },
295+
{ modifier: "u" },
296+
{ modifier: "U" },
297+
"bar"
298+
]);
299+
});
300+
301+
it("allows '$', '\\', and '/' to be escaped", () => {
302+
expectReplaceMatch("\\$1 \\\\ \\/", [
303+
"$1 \\ /"
304+
]);
305+
});
306+
307+
describe("When parsing formats", () => {
308+
it("parses simple formats", () => {
309+
expectReplaceMatch("$1${2}", [
310+
{ backreference: 1 },
311+
{ backreference: 2 }
312+
]);
313+
});
314+
315+
it("parses formats with modifiers", () => {
316+
expectReplaceMatch("${1:/upcase}", [
317+
{
318+
backreference: 1,
319+
modifier: "upcase",
320+
}
321+
]);
322+
});
323+
324+
it("parses formats with an if branch", () => {
325+
expectReplaceMatch("${1:+foo$2$bar}", [
326+
{
327+
backreference: 1,
328+
ifContent: [
329+
"foo",
330+
{ backreference: 2, },
331+
"$bar" // no variables inside a replace / format
332+
]
333+
}
334+
]);
335+
});
336+
337+
it("parses formats with if and else branches", () => {
338+
expectReplaceMatch("${1:?foo\\:stillIf:bar\\}stillElse}", [
339+
{
340+
backreference: 1,
341+
ifContent: [
342+
"foo:stillIf"
343+
],
344+
elseContent: [
345+
"bar}stillElse"
346+
]
347+
}
348+
]);
349+
});
350+
351+
it("parses formats with an else branch", () => {
352+
expectReplaceMatch("${1:-foo}", [
353+
{
354+
backreference: 1,
355+
elseContent: [
356+
"foo"
357+
]
358+
}
359+
]);
360+
});
361+
362+
it("parses formats with the old else branch syntax", () => {
363+
expectReplaceMatch("${1:foo}", [
364+
{
365+
backreference: 1,
366+
elseContent: [
367+
"foo"
368+
]
369+
}
370+
]);
371+
});
372+
373+
it("allows nested replacements inside of formats", () => {
374+
expectReplaceMatch("${1:+${2:-${3:?a lot of:layers}}}", [
375+
{
376+
backreference: 1,
377+
ifContent: [
378+
{
379+
backreference: 2,
380+
elseContent: [
381+
{
382+
backreference: 3,
383+
ifContent: [
384+
"a lot of"
385+
],
386+
elseContent: [
387+
"layers"
388+
]
389+
}
390+
]
391+
}
392+
]
393+
}
394+
]);
395+
});
396+
});
397+
});
211398
});
212399

213400
describe("When parsing escaped characters", () => {

0 commit comments

Comments
 (0)