diff --git a/lib/insertion.js b/lib/insertion.js index 96065d1e..f9427805 100644 --- a/lib/insertion.js +++ b/lib/insertion.js @@ -45,19 +45,16 @@ function transformText (str, flags) { } class Insertion { - constructor ({ range, substitution }) { + constructor ({ range, transformation }) { this.range = range - this.substitution = substitution - if (substitution) { - if (substitution.replace === undefined) { - substitution.replace = '' - } - this.replacer = this.makeReplacer(substitution.replace) + this.transformation = transformation + if (transformation) { + this.replacer = this.makeReplacer(transformation.replace) } } isTransformation () { - return !!this.substitution + return !!this.transformation } makeReplacer (replace) { @@ -73,8 +70,8 @@ class Insertion { replace.forEach(token => { if (typeof token === 'string') { result.push(transformText(token, flags)) - } else if (token.escape) { - ESCAPES[token.escape](flags, result) + } else if (token.modifier) { + ESCAPES[token.modifier](flags, result) } else if (token.backreference) { let transformed = transformText(match[token.backreference], flags) result.push(transformed) @@ -85,9 +82,9 @@ class Insertion { } transform (input) { - let { substitution } = this - if (!substitution) { return input } - return input.replace(substitution.find, this.replacer) + let { transformation } = this + if (!transformation) { return input } + return input.replace(transformation.find, this.replacer) } } diff --git a/lib/snippet-body-old.pegjs b/lib/snippet-body-old.pegjs new file mode 100644 index 00000000..75e0be88 --- /dev/null +++ b/lib/snippet-body-old.pegjs @@ -0,0 +1,82 @@ +{ + // Joins all consecutive strings in a collection without clobbering any + // non-string members. + function coalesce (parts) { + const result = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const ri = result.length - 1; + if (typeof part === 'string' && typeof result[ri] === 'string') { + result[ri] = result[ri] + part; + } else { + result.push(part); + } + } + return result; + } + + function flatten (parts) { + return parts.reduce(function (flat, rest) { + return flat.concat(Array.isArray(rest) ? flatten(rest) : rest); + }, []); + } +} +bodyContent = content:(tabStop / bodyContentText)* { return content; } +bodyContentText = text:bodyContentChar+ { return text.join(''); } +bodyContentChar = escaped / !tabStop char:. { return char; } + +escaped = '\\' char:. { return char; } +tabStop = tabStopWithTransformation / tabStopWithPlaceholder / tabStopWithoutPlaceholder / simpleTabStop + +simpleTabStop = '$' index:[0-9]+ { + return { index: parseInt(index.join("")), content: [] }; +} +tabStopWithoutPlaceholder = '${' index:[0-9]+ '}' { + return { index: parseInt(index.join("")), content: [] }; +} +tabStopWithPlaceholder = '${' index:[0-9]+ ':' content:placeholderContent '}' { + return { index: parseInt(index.join("")), content: content }; +} +tabStopWithTransformation = '${' index:[0-9]+ transformation:transformationSubstitution '}' { + return { + index: parseInt(index.join(""), 10), + content: [], + transformation, + }; +} + +placeholderContent = content:(tabStop / placeholderContentText / variable )* { return flatten(content); } +placeholderContentText = text:placeholderContentChar+ { return coalesce(text); } +placeholderContentChar = escaped / placeholderVariableReference / !tabStop !variable char:[^}] { return char; } + +placeholderVariableReference = '$' digit:[0-9]+ { + return { index: parseInt(digit.join(""), 10), content: [] }; +} + +variable = '${' variableContent '}' { + return ''; // we eat variables and do nothing with them for now +} +variableContent = content:(variable / variableContentText)* { return content; } +variableContentText = text:variableContentChar+ { return text.join(''); } +variableContentChar = !variable char:('\\}' / [^}]) { return char; } + +escapedForwardSlash = pair:'\\/' { return pair; } + +// A pattern and replacement for a transformed tab stop. +transformationSubstitution = '/' find:(escapedForwardSlash / [^/])* '/' replace:formatString* '/' flags:[imy]* { + let reFind = new RegExp(find.join(''), flags.join('') + 'g'); + return { find: reFind, replace: replace[0] }; +} + +formatString = content:(formatStringEscape / formatStringReference / escapedForwardSlash / [^/])+ { + return content; +} +// Backreferencing a transformation capture group. Different from a tab stop. +formatStringReference = '$' digits:[0-9]+ { + return { backreference: parseInt(digits.join(''), 10) }; +}; +// One of the special control flags in a format string for case folding and +// other tasks. +formatStringEscape = '\\' flag:[ULulErn$] { + return { modifier: flag }; +} diff --git a/lib/snippet-body-parser.js b/lib/snippet-body-parser.js index d4293ecb..d49a115e 100644 --- a/lib/snippet-body-parser.js +++ b/lib/snippet-body-parser.js @@ -1,12 +1,15 @@ +const syntax = atom.config.get("snippets.snippetSyntax"); +const parserName = syntax === "original" ? "snippet-body-old" : "snippet-body"; + let parser try { - parser = require('./snippet-body') + parser = require(`./${parserName}`) } catch (error) { const {allowUnsafeEval} = require('loophole') const fs = require('fs-plus') const PEG = require('pegjs') - const grammarSrc = fs.readFileSync(require.resolve('./snippet-body.pegjs'), 'utf8') + const grammarSrc = fs.readFileSync(require.resolve(`./${parserName}.pegjs`), 'utf8') parser = null allowUnsafeEval(() => parser = PEG.buildParser(grammarSrc)) } diff --git a/lib/snippet-body.pegjs b/lib/snippet-body.pegjs index 476c65af..a3e5ebed 100644 --- a/lib/snippet-body.pegjs +++ b/lib/snippet-body.pegjs @@ -1,3 +1,44 @@ +/* + +Target grammar: + +any ::= (text | tabstop | choice | variable)* + +text ::= anything that's not something else + +tabstop ::= '$' int | '${' int '}' | '${' int transform '}' | '${' int ':' any '}' + +choice ::= '${' int '|' text (',' text)* '|}' + +variable ::= '$' var | '${' var '}' | '${' var ':' any '}' | '${' var transform '}' + +transform ::= '/' regex '/' replace '/' options + +replace ::= (format | text)* + +format ::= '$' int | '${' int '}' | '${' int ':' modifier '}' | '${' int ':+' if:replace '}' | '${' int ':?' if:replace ':' else:replace '}' | '${' int ':-' else:replace '}' | '${' int ':' else:replace '}' + +regex ::= JS regex value + +options ::= JS regex options + +modifier = '/' var + +var ::= [a-zA-Z_][a-zA-Z_0-9]* + +int ::= [0-9]+ + +(Based on VS Code and TextMate, with particular emphasis on supporting LSP snippets) +See https://microsoft.github.io/language-server-protocol/specification#snippet_syntax + +Parse issues (such as unclosed tab stops or invalid regexes) are simply treated +as plain text. + +NOTE: PEG.js is not designed for efficiency. With appropriate benchmarks, it should +be a significant gain to hand write a parser (and remove the PEG.js dependency). + +*/ + { // Joins all consecutive strings in a collection without clobbering any // non-string members. @@ -14,69 +55,150 @@ } return result; } +} + +// Grab anything that isn't \ or $, then try to build a special node out of it, and (at the top level) if that fails then just accept the first character as text and continue +topLevelContent = c:(text / escapedTopLevel / tabStop / choice / variable / .)* { return coalesce(c); } + +// Placeholder content. The same as top level, except we need to fail on '}' so that it can end the tab stop (the any matcher would eat it if we used it here) +tabStopContent = c:(tabStopText / escapedTabStop / tabStop / choice / variable / [^}])* { return coalesce(c); } + +// The forms of a tab stop. They all start with '$', so we pull that out here. +tabStop = '$' t:(tabStopSimple / tabStopWithoutPlaceholder / tabStopWithPlaceholder / tabStopWithTransform) { return t; } + +// The simplest form is just $n for some integer `n` +tabStopSimple = n:integer { return { index: n }; } - function flatten (parts) { - return parts.reduce(function (flat, rest) { - return flat.concat(Array.isArray(rest) ? flatten(rest) : rest); - }, []); +// The next simplest form is equivalent to the above, but wrapped in `{}` +tabStopWithoutPlaceholder = '{' n:integer '}' { return { index: n }; } + +// When a ':' follows `n`, the content after the ':' is the placeholder and it can be anything +tabStopWithPlaceholder = '{' n:integer ':' content:tabStopContent '}' { return { index: n, content }; } + +// When a transform follows `n` (indicated by '${n/...') +tabStopWithTransform = '{' n:integer t:transformation '}' { return { index: n, transformation: t }; } + +// Builds a capture regex and substitution tree. If the capture is not a valid regex, then the match fails +transformation = '/' find:regexString '/' replace:replace '/' flags:flags & { + // Predicate: only succeed if the `find` + `flags` values make a valid regex + // TODO: find a way to not build the same RegExp twice. May need to wait until + // hand written parser. + try { + find = new RegExp(find, flags); + return true; + } catch(e) { + return false; } +} { + return { find: new RegExp(find, flags), replace }; } -bodyContent = content:(tabStop / bodyContentText)* { return content; } -bodyContentText = text:bodyContentChar+ { return text.join(''); } -bodyContentChar = escaped / !tabStop char:. { return char; } -escaped = '\\' char:. { return char; } -tabStop = tabStopWithTransformation / tabStopWithPlaceholder / tabStopWithoutPlaceholder / simpleTabStop +// Pulls out the portion that would be for the find regex. Validation is done +// higher up, where we also have access to the flags. +regexString = r:([^/\\] / '\\' c:. { return '\\' + c } )* { return r.join(""); } -simpleTabStop = '$' index:[0-9]+ { - return { index: parseInt(index.join("")), content: [] }; -} -tabStopWithoutPlaceholder = '${' index:[0-9]+ '}' { - return { index: parseInt(index.join("")), content: [] }; -} -tabStopWithPlaceholder = '${' index:[0-9]+ ':' content:placeholderContent '}' { - return { index: parseInt(index.join("")), content: content }; -} -tabStopWithTransformation = '${' index:[0-9]+ substitution:transformationSubstitution '}' { - return { - index: parseInt(index.join(""), 10), - content: [], - substitution: substitution - }; -} +// The form of a substitution for a transformation. It is a mix of plain text + modifiers + backreferences to the find capture groups +// It cannot access tab stop values. +replace = r:(replaceText / format / replaceModifier / escapedReplace / [^/])* { return coalesce(r); } -placeholderContent = content:(tabStop / placeholderContentText / variable )* { return flatten(content); } -placeholderContentText = text:placeholderContentChar+ { return coalesce(text); } -placeholderContentChar = escaped / placeholderVariableReference / !tabStop !variable char:[^}] { return char; } +// Same as replace, but we disallow plain '}' instead of plain '/' because we are inside a format (ended by '}') +// 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" +// Inside a format there is no special meaning to '/', so we can't escape it. +formatReplace = r:(replaceText / format / replaceModifier / escapedFormatReplace / [^}])* { return coalesce(r); } -placeholderVariableReference = '$' digit:[0-9]+ { - return { index: parseInt(digit.join(""), 10), content: [] }; -} +// Another special case; the if half of an if-else format is terminated by ':' +ifElseReplace = r:(replaceText / format / replaceModifier / escapedIfElseReplace / [^:])* { return coalesce(r); } -variable = '${' variableContent '}' { - return ''; // we eat variables and do nothing with them for now -} -variableContent = content:(variable / variableContentText)* { return content; } -variableContentText = text:variableContentChar+ { return text.join(''); } -variableContentChar = !variable char:('\\}' / [^}]) { return char; } -escapedForwardSlash = pair:'\\/' { return pair; } +// A reference to a capture group of the find regex of a transformation. Can conditionally +// resolve based on if the match occurred, and have arbitrary modifiers applied to it. +// The common '$' prefix has been pulled out. +format = '$' f:(formatSimple / formatPlain / formatWithModifier / formatWithIf / formatWithIfElse / formatWithElse) { return f; } -// A pattern and replacement for a transformed tab stop. -transformationSubstitution = '/' find:(escapedForwardSlash / [^/])* '/' replace:formatString* '/' flags:[imy]* { - let reFind = new RegExp(find.join(''), flags.join('') + 'g'); - return { find: reFind, replace: replace[0] }; -} +// The simplest format form, resembles a simpel tab stop except `n` refers to the capture group index, not a tab stop +formatSimple = n:integer { return { backreference: n }; } -formatString = content:(formatStringEscape / formatStringReference / escapedForwardSlash / [^/])+ { - return content; -} -// Backreferencing a substitution. Different from a tab stop. -formatStringReference = '$' digits:[0-9]+ { - return { backreference: parseInt(digits.join(''), 10) }; -}; -// One of the special control flags in a format string for case folding and -// other tasks. -formatStringEscape = '\\' flag:[ULulErn$] { - return { escape: flag }; -} +// The same as the simple variant, but `n` is enclosed in {} +formatPlain = '{' n:integer '}' { return { backreference: n }; } + +// A modifier is something like "/upcase", "/pascalcase". If recognised, it resolves to the +// application of a JS function to the `n`th captured group. +formatWithModifier = '{' n:integer ':' modifier:modifier '}' { return { backreference: n, modifier }; } + +// If the `n`th capture group is non-empty, then resolve to the `ifContent` value, else an empty string +// Note that ifContent is a replace itself; it's formats still refer to the original transformation find though, +// as transformations cannot be nested. +formatWithIf = '{' n:integer ':+' ifContent:formatReplace '}' { return { backreference: n, ifContent }; } + +// Same as the if case, but resolve to `elseContent` if empty instead of the empty string +formatWithIfElse = '{' n:integer ':?' ifContent:ifElseReplace ':' elseContent:formatReplace '}' { return { backreference: n, ifContent, elseContent }; } + +// Same as the if case, but reversed behaviour with empty vs non-empty `n`th match +// NOTE: The ':' form can cause ambiguities when the contents starts with '/', '+', etc. +// However, instead of allowing them to be escaped, just tell issue raisers to use the +// less ambiguous ':-' form (which also has nice symmetry with the ':+' form). +formatWithElse = '{' n:integer ':' '-'? elseContent:formatReplace '}' { return { backreference: n, elseContent }; } + +// Used in `format`s to transform a string using a JS function +modifier = '/' modifier:name { return modifier; } + +// Regex flags. Validation is performed when the regex itself is also known. +flags = f:[a-z]* { return f.join(""); } + +// A tab stop that offers a choice between several fixed values. These values are plain text only. +// This feature is not implemented, but the syntax is parsed to reserve it for future use. +// It will currently just default to a regular tab stop with the first value as it's placeholder. +// Empty choices are still parsed, as we may wish to assign meaning to it in future. +choice = '${' n:integer '|' c:(a:choiceText b:(',' c:choiceText { return c; } )* { return [a, ...b] })? '|}' { return { index: n, choices: c || [] }; } + +// Syntactically looks like a named tab stop. Variables are resolved in JS and may be +// further processed with a transformation. Unrecognised variables are transformed into +// tab stops with the variable name as a placeholder. +variable = '$' v:(variableSimple / variablePlain / variableWithPlaceholder / variableWithTransform) { return v; } + +variableSimple = v:name { return { variable: v }; } + +variablePlain = '{' v:name '}' { return { variable: v }; } + +variableWithPlaceholder = '{' v:name ':' content:tabStopContent '}' { return { variable: v, content }; } + +variableWithTransform = '{' v:name t:transformation '}' { return { variable: v, transformation: t }; } + +// Top level text. Anything that cannot be the start of something special. False negatives are handled later by the `any` rule +text = t:([^$\\}])+ { return t.join("") } + +// Non-special text inside a tab stop placeholder. Should be no different to regular top level text. +tabStopText = text + +// Non-special text inside a choice. $, {, }, etc. are all regular text in this context. +choiceText = b:(t:[^,|\\]+ { return t.join(""); } / '\\' c:[,|\\] { return c; } / '\\' c:. { return '\\' + c; } )+ { return b.join(""); } + +// 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) +replaceText = t:[^$\\}/:]+ { return t.join(""); } + +// 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 +escapedTopLevel = '\\' c:[$\\}] { return c; } + +// Characters that can be escaped in a tab stop placeholder are the same as top level +escapedTabStop = escapedTopLevel + +// Only `,` and `|` can be escaped in a choice, as everything else is plain text +escapedChoice = '\\' c:[$\\,|] { return c; } + +// Same as top level, but `/` can also be escaped +escapedReplace = '\\' c:[$\\/] { return c; } + +// Format terminated by '}' instead of '/' +escapedFormatReplace = '\\' c:[$\\}] { return c; } + +// If half of if-else format terminated by ':' instead of '}' +escapedIfElseReplace = '\\' c:[$\\:] { return c; } + +// We handle 'modifiers' separately to escapes. These indicate a change in state when building the replacement (e.g., capitalisation) +replaceModifier = '\\' m:[ElLuU] { return { modifier: m }; } + +// Match nonnegative integers like those used for tab stop ordering +integer = digits:[0-9]+ { return parseInt(digits.join(""), 10); } + +// Match variable names like TM_SELECTED_TEXT +name = a:[a-zA-Z_] b:[a-zA-Z_0-9]* { return a + b.join(""); } diff --git a/lib/snippet.js b/lib/snippet.js index fcdfed90..b5428576 100644 --- a/lib/snippet.js +++ b/lib/snippet.js @@ -24,16 +24,16 @@ module.exports = class Snippet { let extractTabStops = bodyTree => { for (const segment of bodyTree) { if (segment.index != null) { - let {index, content, substitution} = segment + let {index, content, transformation} = segment if (index === 0) { index = Infinity; } const start = [row, column] - extractTabStops(content) + if (content) { extractTabStops(content); } const range = new Range(start, [row, column]) const tabStop = this.tabStopList.findOrCreate({ index, snippet: this }) - tabStop.addInsertion({ range, substitution }) + tabStop.addInsertion({ range, transformation }) } else if (typeof segment === 'string') { bodyText.push(segment) var segmentLines = segment.split('\n') diff --git a/lib/snippets.js b/lib/snippets.js index 8e67ec0b..2b433099 100644 --- a/lib/snippets.js +++ b/lib/snippets.js @@ -45,6 +45,10 @@ module.exports = { this.handleDisabledPackagesDidChange(newValue, oldValue) })) + this.subscriptions.add(atom.config.observe('snippets.snippetSyntax', ({newValue, oldValue}) => { + this.handleSnippetSyntaxDidChange(newValue, oldValue) + })) + const snippets = this this.subscriptions.add(atom.commands.add('atom-text-editor', { @@ -439,6 +443,10 @@ module.exports = { return this.bodyParser }, + handleSnippetSyntaxDidChange(oldValue, newValue) { + // TODO: Clear out all snippet parse trees && set the parser to the correct syntax + }, + // Get an {Object} with these keys: // * `snippetPrefix`: the possible snippet prefix text preceding the cursor // * `wordPrefix`: the word preceding the cursor diff --git a/lib/tab-stop.js b/lib/tab-stop.js index 61a423e4..24755e77 100644 --- a/lib/tab-stop.js +++ b/lib/tab-stop.js @@ -20,8 +20,8 @@ class TabStop { return !all } - addInsertion ({ range, substitution }) { - let insertion = new Insertion({ range, substitution }) + addInsertion ({ range, transformation }) { + let insertion = new Insertion({ range, transformation }) let insertions = this.insertions insertions.push(insertion) insertions = insertions.sort((i1, i2) => { @@ -38,7 +38,7 @@ class TabStop { copyWithIndent (indent) { let { snippet, index, insertions } = this let newInsertions = insertions.map(insertion => { - let { range, substitution } = insertion + let { range, transformation } = insertion let newRange = Range.fromObject(range, true) if (newRange.start.row) { newRange.start.column += indent.length @@ -46,7 +46,7 @@ class TabStop { } return new Insertion({ range: newRange, - substitution + transformation }) }) diff --git a/package.json b/package.json index 7ced0272..54832f06 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,16 @@ }, "devDependencies": { "coffeelint": "^1.9.7" + }, + "configSchema": { + "snippetSyntax": { + "type": "string", + "description": "(Requires restart) Configures the syntax used for snippets. LSP is mostly a superset of original, with support for more features (but not all features may be implemented yet).", + "default": "LSP", + "enum": [ + "LSP", + "original" + ] + } } } diff --git a/spec/body-parser-spec.js b/spec/body-parser-spec.js index 35492ded..089c1920 100644 --- a/spec/body-parser-spec.js +++ b/spec/body-parser-spec.js @@ -1,227 +1,636 @@ -const BodyParser = require('../lib/snippet-body-parser'); +const SnippetParser = require('../lib/snippet-body-parser'); describe("Snippet Body Parser", () => { - it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => { - const bodyTree = BodyParser.parse(`\ -the quick brown $1fox \${2:jumped \${3:over} -}the \${4:lazy} dog\ -` - ); - - expect(bodyTree).toEqual([ - "the quick brown ", - {index: 1, content: []}, - "fox ", - { - index: 2, - content: [ - "jumped ", - {index: 3, content: ["over"]}, - "\n" - ], - }, - "the ", - {index: 4, content: ["lazy"]}, - " dog" - ]); - }); + // Helper for testing a snippet parse tree. The `input` + // is the snippet string, the `tree` is the expected + // parse tree. + function expectMatch(input, tree) { + expect(SnippetParser.parse(input)).toEqual(tree); + } - it("removes interpolated variables in placeholder text (we don't currently support it)", () => { - const bodyTree = BodyParser.parse("module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}}"); - expect(bodyTree).toEqual([ - "module ", - { - "index": 1, - "content": ["ActiveRecord::", ""] - } - ]); - }); + describe("when parsing tab stops", () => { + it("parses simple tab stops", () => { + expectMatch("hello$1world${2}", [ + "hello", { index: 1 }, "world", { index: 2 }, + ]); + }); - it("skips escaped tabstops", () => { - const bodyTree = BodyParser.parse("snippet $1 escaped \\$2 \\\\$3"); - expect(bodyTree).toEqual([ - "snippet ", - { - index: 1, - content: [] - }, - " escaped $2 \\", - { - index: 3, - content: [] - } - ]); - }); + it("skips escaped tab stops", () => { + expectMatch("$1 \\$2 $3", [ + { index: 1 }, + " $2 ", + { index: 3 }, + ]); + }); - it("includes escaped right-braces", () => { - const bodyTree = BodyParser.parse("snippet ${1:{\\}}"); - expect(bodyTree).toEqual([ - "snippet ", - { - index: 1, - content: ["{}"] - } - ]); + it("only allows non-negative integer stop numbers", () => { + expectMatch("$0", [{ index: 0 }]); + expectMatch("$99999", [{ index: 99999 }]); + expectMatch("$-1", ["$-1"]); + expectMatch("${-1}", ["${-1}"]); + expectMatch("$1.5", [{ index: 1 }, ".5"]); + expectMatch("${1.5}", ["${1.5}"]); + }); + + describe("with placeholders", () => { + it("allows placeholders to be empty", () => { + expectMatch("${1:}", [{ index: 1, content: [] }]); + }); + + it("allows placeholders to be arbitrary", () => { + expectMatch("${1:${2}$foo${3|a,b|}}", [ + { + index: 1, + content: [ + { index: 2 }, + { variable: "foo" }, + { index: 3, choices: ["a", "b"] }, + ] + } + ]); + }); + + it("even lets placeholders contain placeholders", () => { + expectMatch("${1:${2:${3:levels}}}", [ + { + index: 1, + content: [ + { + index: 2, + content: [ + { + index: 3, + content: [ + "levels" + ] + } + ] + } + ] + } + ]); + }); + + it("ends the placeholder at an unmatched '}'", () => { + expectMatch("${1:}}", [ + { + index: 1, + content: [] + }, + "}" + ]); + }); + + it("allows escaping '}' in placeholders", () => { + expectMatch("${1:\\}}", [{ index: 1, content: ["}"] }]); + }); + + it("allows '$' in placeholders", () => { + expectMatch("${1:$}", [ + { + index: 1, + content: [ + "$" + ] + } + ]); + }); + }); + + // See the transformations section for more thorough testing + describe("with transformations", () => { + it("parses simple transformations", () => { + expectMatch("${1/foo/bar/}", [ + { + index: 1, + transformation: { + find: /foo/, + replace: [ + "bar" + ] + } + } + ]); + }); + }); }); - it("parses a snippet with transformations", () => { - const bodyTree = BodyParser.parse("<${1:p}>$0"); - expect(bodyTree).toEqual([ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - '' - ]); + describe("when parsing variables", () => { + it("parses simple variables", () => { + expectMatch("hello$foo2__bar&baz${abc}d", [ + "hello", + { variable: "foo2__bar" }, + "&baz", + { variable: "abc" }, + "d" + ]); + }); + + it("skips escaped variables", () => { + expectMatch("\\$foo $b\\ar $\\{baz}", [ + "$foo ", + { variable: "b" }, + "\\ar $\\{baz}", + ]); + }); + + describe("naming", () => { + it("only allows ASCII letters, numbers, and underscores in names", () => { + expectMatch("$abc_123-not", [{ variable: "abc_123" }, "-not"]); + }); + + it("allows names to start with underscores", () => { + expectMatch("$__properties", [{ variable: "__properties" }]); + }); + + it("doesn't allow names to start with a number", () => { + expectMatch("$1foo", [{ index: 1 }, "foo"]); + }); + }); + + // The placeholder implementation is expected to be the same as for tab stops, so + // see the tab stop placeholder section for more thorough tests + describe("with placeholders", () => { + it("allows placeholders to be arbitrary", () => { + expectMatch("${foo:${2}$bar${3|a,b|}}", [ + { + variable: "foo", + content: [ + { index: 2 }, + { variable: "bar" }, + { index: 3, choices: ["a", "b"] }, + ] + } + ]); + }); + + it("allows escaping '}' in placeholders", () => { + expectMatch("${foo:\\}}", [{ variable: "foo", content: ["}"] }]); + }); + }); + + // See the transformations section for more thorough testing + describe("with transformations", () => { + it("parses simple transformations", () => { + expectMatch("${var/foo/bar/}", [ + { + variable: "var", + transformation: { + find: /foo/, + replace: [ + "bar" + ] + } + } + ]); + }); + }); }); - it("parses a snippet with multiple tab stops with transformations", () => { - const bodyTree = BodyParser.parse("${1:placeholder} ${1/(.)/\\u$1/} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2"); - expect(bodyTree).toEqual([ - {index: 1, content: ['placeholder']}, - ' ', - { - index: 1, - content: [], - substitution: { - find: /(.)/g, - replace: [ - {escape: 'u'}, - {backreference: 1} - ] - } - }, - ' ', - {index: 1, content: []}, - ' ', - {index: 2, content: ['ANOTHER']}, - ' ', - { - index: 2, - content: [], - substitution: { - find: /^(.*)$/g, - replace: [ - {escape: 'L'}, - {backreference: 1} + describe("when parsing choices", () => { + it("parses simple choices", () => { + expectMatch("${1|a,b,c|}", [{ index: 1, choices: ["a", "b", "c"] }]); + }); + + it("parses empty choices", () => { + expectMatch("${1||}", [{ index: 1, choices: [] }]); + }); + + it("skips escaped choices", () => { + expectMatch("\\${1|a|}", ["${1|a|}"]); + }); + + it("treats choice items as plain text", () => { + expectMatch("${1|$2,$foo|}", [{ index: 1, choices: ["$2", "$foo"] }]); + }); + + it("only allows ',' and '|' to be escaped in choice text", () => { + expectMatch("${1|a,b\\,c,d\\|},e\\$f|}", [ + { + index: 1, + choices: [ + "a", + "b,c", + "d|}", + "e\\$f" ] } - }, - ' ', - {index: 2, content: []}, - ]); + ]); + }); }); + describe("when parsing transformations", () => { + it("allows an empty transformation", () => { + expectMatch("${1///}", [{ index: 1, transformation: { find: new RegExp(""), replace: [] } }]); + }); - it("parses a snippet with transformations and mirrors", () => { - const bodyTree = BodyParser.parse("${1:placeholder}\n${1/(.)/\\u$1/}\n$1"); - expect(bodyTree).toEqual([ - {index: 1, content: ['placeholder']}, - '\n', - { - index: 1, - content: [], - substitution: { - find: /(.)/g, - replace: [ - {escape: 'u'}, - {backreference: 1} - ] + it("applies flags to the find regex", () => { + expectMatch("${1/foo/bar/gimsuy}", [ + { + index: 1, + transformation: { + find: /foo/gimsuy, + replace: [ + "bar" + ] + } } - }, - '\n', - {index: 1, content: []} - ]); - }); + ]); + }); - it("parses a snippet with a format string and case-control flags", () => { - const bodyTree = BodyParser.parse("<${1:p}>$0"); - expect(bodyTree).toEqual([ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - ' { + expectMatch("${1/foo/bar/a}", ["${1/foo/bar/a}"]); // invalid flag + expectMatch("${1/fo)o$1/$bar/}", [ + "${1/fo)o", + { index: 1 }, + "/", + { variable: "bar" }, + "/}" + ]); + }); + + it("allows and preserves all escapes in regex strings", () => { + expectMatch("${1/foo\\/\\$\\:\\n\\r/baz/}", [ + { + index: 1, + transformation: { + find: /foo\/\$\:\n\r/, + replace: [ + "baz" + ] + } } - }, - '>' - ]); + ]); + }); + + describe("when parsing the replace section", () => { + // Helper for testing the relacement part of + // transformations, which are relatively deep in + // the tree and have a lot of behaviour to cover + // NOTE: Only use when the replace section is expected to + // be valid, or else you will be testing against the + // boilerplate (which is not a good idea) + function expectReplaceMatch(replace, tree) { + expectMatch(`\${1/foo/${replace}/}`, [ + { + index: 1, + transformation: { + find: /foo/, + replace: tree, + } + } + ]); + } + + it("allows '$' and '}' as plain text if not part of a format", () => { + expectReplaceMatch("$}", ["$}"]); + }); + + it("allows inline 'escaped modifiers'", () => { + expectReplaceMatch("foo\\E\\l\\L\\u\\Ubar", [ + "foo", + { modifier: "E" }, + { modifier: "l" }, + { modifier: "L" }, + { modifier: "u" }, + { modifier: "U" }, + "bar" + ]); + }); + + it("allows '$', '\\', and '/' to be escaped", () => { + expectReplaceMatch("\\$1 \\\\ \\/", [ + "$1 \\ /" + ]); + }); + + describe("when parsing formats", () => { + it("parses simple formats", () => { + expectReplaceMatch("$1${2}", [ + { backreference: 1 }, + { backreference: 2 } + ]); + }); + + it("parses formats with modifiers", () => { + expectReplaceMatch("${1:/upcase}", [ + { + backreference: 1, + modifier: "upcase", + } + ]); + }); + + it("parses formats with an if branch", () => { + expectReplaceMatch("${1:+foo$2$bar}", [ + { + backreference: 1, + ifContent: [ + "foo", + { backreference: 2, }, + "$bar" // no variables inside a replace / format + ] + } + ]); + }); + + it("parses formats with if and else branches", () => { + expectReplaceMatch("${1:?foo\\:stillIf:bar\\}stillElse}", [ + { + backreference: 1, + ifContent: [ + "foo:stillIf" + ], + elseContent: [ + "bar}stillElse" + ] + } + ]); + }); + + it("parses formats with an else branch", () => { + expectReplaceMatch("${1:-foo}", [ + { + backreference: 1, + elseContent: [ + "foo" + ] + } + ]); + }); + + it("parses formats with the old else branch syntax", () => { + expectReplaceMatch("${1:foo}", [ + { + backreference: 1, + elseContent: [ + "foo" + ] + } + ]); + }); + + it("allows nested replacements inside of formats", () => { + expectReplaceMatch("${1:+${2:-${3:?a lot of:layers}}}", [ + { + backreference: 1, + ifContent: [ + { + backreference: 2, + elseContent: [ + { + backreference: 3, + ifContent: [ + "a lot of" + ], + elseContent: [ + "layers" + ] + } + ] + } + ] + } + ]); + }); + }); + }); }); - it("parses a snippet with an escaped forward slash in a transform", () => { - // Annoyingly, a forward slash needs to be double-backslashed just like the - // other escapes. - const bodyTree = BodyParser.parse("<${1:p}>$0"); - expect(bodyTree).toEqual([ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - '' - ]); + describe("when parsing escaped characters", () => { + const escapeTest = "\\$ \\\\ \\} \\% \\* \\, \\| \\{ \\n \\r \\:"; + + const escapeResolveTop = "$ \\ } \\% \\* \\, \\| \\{ \\n \\r \\:"; + + const escapeResolveChoice = "\\$ \\ \\} \\% \\* , | \\{ \\n \\r \\:"; + + it("only escapes '$', '\\', and '}' in top level text", () => { + expectMatch(escapeTest, [ + escapeResolveTop + ]); + }); + + it("escapes the same characters inside tab stop placeholders as in top level text", () => { + expectMatch(`\${1:${escapeTest}}`, [ + { index: 1, content: [escapeResolveTop] }, + ]); + }); + + it("escapes the same characters inside variable placeholders as in top level text", () => { + expectMatch(`\${foo:${escapeTest}}`, [ + { variable: "foo", content: [escapeResolveTop] }, + ]); + }); + + it("escapes ',', '|', and '\\' in choice text", () => { + expectMatch(`\${1|${escapeTest}|}`, [ + { index: 1, choices: [escapeResolveChoice] }, + ]); + }); }); - it("parses a snippet with a placeholder that mirrors another tab stop's content", () => { - const bodyTree = BodyParser.parse("$4console.${3:log}('${2:$1}', $1);$0"); - expect(bodyTree).toEqual([ - {index: 4, content: []}, - 'console.', - {index: 3, content: ['log']}, - '(\'', - { - index: 2, content: [ - {index: 1, content: []} - ] - }, - '\', ', - {index: 1, content: []}, - ');', - {index: 0, content: []} - ]); + describe("when a potential non-text parse fails", () => { + it("accepts the first character as text and resumes", () => { + expectMatch("${1:${2}${3}", [ + "${1:", + { index: 2 }, + { index: 3 } + ]); + }); }); - it("parses a snippet with a placeholder that mixes text and tab stop references", () => { - const bodyTree = BodyParser.parse("$4console.${3:log}('${2:uh $1}', $1);$0"); - expect(bodyTree).toEqual([ - {index: 4, content: []}, - 'console.', - {index: 3, content: ['log']}, - '(\'', - { - index: 2, content: [ - 'uh ', - {index: 1, content: []} - ] - }, - '\', ', - {index: 1, content: []}, - ');', - {index: 0, content: []} - ]); + describe("examples", () => { + it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => { + expectMatch("the quick brown $1fox ${2:jumped ${3:over}\n}the ${4:lazy} dog", [ + "the quick brown ", + { index: 1 }, + "fox ", + { + index: 2, + content: [ + "jumped ", + { index: 3, content: ["over"] }, + "\n" + ], + }, + "the ", + { index: 4, content: ["lazy"] }, + " dog" + ]); + }); + + it("supports interpolated variables in placeholder text", () => { + expectMatch("module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}}", [ + "module ", + { + index: 1, + content: [ + "ActiveRecord::", + { + variable: "TM_FILENAME", + transformation: { + find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g, + replace: [ + "(?2::", + { + modifier: "u", + }, + { + backreference: 1, + }, + ")", + ] + } + } + ], + } + ]); + }); + + it("parses a snippet with transformations", () => { + expectMatch("<${1:p}>$0", [ + '<', + { index: 1, content: ['p'] }, + '>', + { index: 0 }, + '', + ]); + }); + + it("parses a snippet with multiple tab stops with transformations", () => { + expectMatch("${1:placeholder} ${1/(.)/\\u$1/} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2", [ + { index: 1, content: ['placeholder'] }, + ' ', + { + index: 1, + transformation: { + find: /(.)/, + replace: [ + { modifier: 'u' }, + { backreference: 1 }, + ], + }, + }, + ' ', + { index: 1 }, + ' ', + { index: 2, content: ['ANOTHER'] }, + ' ', + { + index: 2, + transformation: { + find: /^(.*)$/, + replace: [ + { modifier: 'L' }, + { backreference: 1 }, + ], + }, + }, + ' ', + { index: 2 }, + ]); + }); + + it("parses a snippet with transformations and mirrors", () => { + expectMatch("${1:placeholder}\n${1/(.)/\\u$1/}\n$1", [ + { index: 1, content: ['placeholder'] }, + '\n', + { + index: 1, + transformation: { + find: /(.)/, + replace: [ + { modifier: 'u' }, + { backreference: 1 }, + ], + }, + }, + '\n', + { index: 1 }, + ]); + }); + + it("parses a snippet with a format string and case-control flags", () => { + expectMatch("<${1:p}>$0", [ + '<', + { index: 1, content: ['p'] }, + '>', + { index: 0 }, + '', + ]); + }); + + it("parses a snippet with an escaped forward slash in a transform", () => { + expectMatch("<${1:p}>$0", [ + '<', + { index: 1, content: ['p'] }, + '>', + { index: 0 }, + '', + ]); + }); + + it("parses a snippet with a placeholder that mirrors another tab stop's content", () => { + expectMatch("$4console.${3:log}('${2:$1}', $1);$0", [ + { index: 4 }, + 'console.', + { index: 3, content: ['log'] }, + '(\'', + { + index: 2, content: [ + { index: 1 } + ] + }, + '\', ', + { index: 1 }, + ');', + { index: 0 } + ]); + }); + + it("parses a snippet with a placeholder that mixes text and tab stop references", () => { + expectMatch("$4console.${3:log}('${2:uh $1}', $1);$0", [ + { index: 4 }, + 'console.', + { index: 3, content: ['log'] }, + '(\'', + { + index: 2, content: [ + 'uh ', + { index: 1 } + ] + }, + '\', ', + { index: 1 }, + ');', + { index: 0 } + ]); + }); }); }); diff --git a/spec/insertion-spec.js b/spec/insertion-spec.js index 83fac925..dd997d99 100644 --- a/spec/insertion-spec.js +++ b/spec/insertion-spec.js @@ -4,10 +4,10 @@ const { Range } = require('atom') const range = new Range(0, 0) describe('Insertion', () => { - it('returns what it was given when it has no substitution', () => { + it('returns what it was given when it has no transformation', () => { let insertion = new Insertion({ range, - substitution: undefined + transformation: undefined }) let transformed = insertion.transform('foo!') @@ -17,7 +17,7 @@ describe('Insertion', () => { it('transforms what it was given when it has a regex transformation', () => { let insertion = new Insertion({ range, - substitution: { + transformation: { find: /foo/g, replace: ['bar'] } @@ -30,11 +30,11 @@ describe('Insertion', () => { it('transforms the case of the next character when encountering a \\u or \\l flag', () => { let uInsertion = new Insertion({ range, - substitution: { + transformation: { find: /(.)(.)(.*)/g, replace: [ { backreference: 1 }, - { escape: 'u' }, + { modifier: 'u' }, { backreference: 2 }, { backreference: 3 } ] @@ -47,11 +47,11 @@ describe('Insertion', () => { let lInsertion = new Insertion({ range, - substitution: { + transformation: { find: /(.{2})(.)(.*)/g, replace: [ { backreference: 1 }, - { escape: 'l' }, + { modifier: 'l' }, { backreference: 2 }, { backreference: 3 } ] @@ -67,11 +67,11 @@ describe('Insertion', () => { it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => { let uInsertion = new Insertion({ range, - substitution: { + transformation: { find: /(.)(.*)/, replace: [ { backreference: 1 }, - { escape: 'U' }, + { modifier: 'U' }, { backreference: 2 } ] } @@ -83,13 +83,13 @@ describe('Insertion', () => { let ueInsertion = new Insertion({ range, - substitution: { + transformation: { find: /(.)(.{3})(.*)/, replace: [ { backreference: 1 }, - { escape: 'U' }, + { modifier: 'U' }, { backreference: 2 }, - { escape: 'E' }, + { modifier: 'E' }, { backreference: 3 } ] } @@ -101,11 +101,11 @@ describe('Insertion', () => { let lInsertion = new Insertion({ range, - substitution: { + transformation: { find: /(.{4})(.)(.*)/, replace: [ { backreference: 1 }, - { escape: 'L' }, + { modifier: 'L' }, { backreference: 2 }, 'WHAT' ] @@ -116,13 +116,13 @@ describe('Insertion', () => { let leInsertion = new Insertion({ range, - substitution: { + transformation: { find: /^([A-Fa-f])(.*)(.)$/, replace: [ { backreference: 1 }, - { escape: 'L' }, + { modifier: 'L' }, { backreference: 2 }, - { escape: 'E' }, + { modifier: 'E' }, { backreference: 3 } ] } diff --git a/spec/snippets-spec.js b/spec/snippets-spec.js index 68994478..c41ea9b6 100644 --- a/spec/snippets-spec.js +++ b/spec/snippets-spec.js @@ -263,19 +263,19 @@ third tabstop $3\ }, "transform with non-transforming mirrors": { prefix: "t13", - body: "${1:placeholder}\n${1/(.)/\\u$1/}\n$1" + body: "${1:placeholder}\n${1/(.)/\\u$1/g}\n$1" }, "multiple tab stops, some with transforms and some without": { prefix: "t14", - body: "${1:placeholder} ${1/(.)/\\u$1/} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2" + body: "${1:placeholder} ${1/(.)/\\u$1/g} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2" }, "has a transformed tab stop without a corresponding ordinary tab stop": { prefix: 't15', - body: "${1/(.)/\\u$1/} & $2" + body: "${1/(.)/\\u$1/g} & $2" }, "has a transformed tab stop that occurs before the corresponding ordinary tab stop": { prefix: 't16', - body: "& ${1/(.)/\\u$1/} & ${1:q}" + body: "& ${1/(.)/\\u$1/g} & ${1:q}" }, "has a placeholder that mirrors another tab stop's content": { prefix: 't17', @@ -283,7 +283,7 @@ third tabstop $3\ }, "has a transformed tab stop such that it is possible to move the cursor between the ordinary tab stop and its transformed version without an intermediate step": { prefix: 't18', - body: '// $1\n// ${1/./=/}' + body: '// $1\n// ${1/./=/g}' } } });