diff --git a/.gitignore b/.gitignore index 3c3629e6..17360031 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +.tool-versions diff --git a/README.md b/README.md index 9b32c9af..7d80e876 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Snippets files are stored in a package's `snippets/` folder and also loaded from The outermost keys are the selectors where these snippets should be active, prefixed with a period (`.`) (details below). -The next level of keys are the snippet names. +The next level of keys are the snippet names. Because this is object notation, each snippet must have a different name. Under each snippet name is a `body` to insert when the snippet is triggered. @@ -32,11 +32,11 @@ console.log("crash"); The string `"crash"` would be initially selected and pressing tab again would place the cursor after the `;` -A snippet must define **at least one** of the following keys: +A snippet specifies how it can be triggered. Thus it must provide **at least one** of the following keys: ### The ‘prefix’ key -If a `prefix` is defined, it specifies a string that can trigger the snippet: type the string in the editor and press Tab. In this example, typing `log` (as its own word) and then pressing Tab would replace `log` with the string `console.log("crash")` as described above. +If a `prefix` is defined, it specifies a string that can trigger the snippet. In the above example, typing `log` (as its own word) and then pressing Tab would replace `log` with the string `console.log("crash")` as described above. Prefix completions can be suggested if partially typed thanks to the `autocomplete-snippets` package. @@ -44,12 +44,14 @@ Prefix completions can be suggested if partially typed thanks to the `autocomple If a `command` is defined, it specifies a command name that can trigger the snippet. That command can be invoked from the command palette or mapped to a keyboard shortcut via your `keymap.cson`. -If you defined the `console.log` snippet described above in your own `snippets.cson`, it would be available in the command palette as “Snippets: Insert Console Log”, or could be referenced in a keymap file as `snippets:insert-console-log`. +If a package called `some-package` had defined that snippet, it would be available in the keymap as `some-package:insert-console-log`, or in the command palette as **Some Package: Insert Console Log**. -If a package called `some-package` had defined that snippet, it would be available in the keymap as `some-package:insert-console-log`, or in the command palette as “Some Package: Insert Console Log”. +If you defined the `console.log` snippet described above in your own `snippets.cson`, it could be referenced in a keymap file as `snippets:insert-console-log`, or in the command palette as **Snippets: Insert Console Log**. Invoking the command would insert the snippet at the cursor, replacing any text that may be selected. +Snippet command names must be unique. They can’t conflict with each other, nor can they conflict with any other commands that have been defined. If there is such a conflict, you’ll see an error notification describing the problem. + ### Optional parameters These parameters are meant to provide extra information about your snippet to [autocomplete-plus](https://github.com/atom/autocomplete-plus/wiki/Provider-API). @@ -74,24 +76,76 @@ Example: ### Determining the correct scope for a snippet -The outmost key of a snippet is the "scope" that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` => `.text.html.basic`). You can find out the correct scope by opening the Settings (cmd-, on macOS) and selecting the corresponding *Language [xxx]* package, e.g. for *Language Html*: +The outmost key of a snippet is the “scope” that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` → `.text.html.basic`). You can find out the correct scope by opening the Settings (cmd-, on macOS) and selecting the corresponding *Language [xxx]* package. For example, here’s the settings page for `language-html`:  -If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can also proceed as following. Put your cursor in a file in which you want the snippet to be available, open the [Command Palette](https://github.com/pulsar-edit/command-palette) -(cmd-shift-p), and run the `Editor: Log Cursor Scope` command. This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`. +If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can use another approach: + +1. Put your cursor in a file in which you want the snippet to be available. +2. Open the [Command Palette](https://github.com/pulsar-edit/command-palette) +(cmd-shift-p or ctrl-shift-p). +3. Run the `Editor: Log Cursor Scope` command. + +This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`. + +## Snippet syntax + +This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets), as well as most features described in the [LSP specification][lsp] and [supported by VSCode][vscode]. + +The following features from TextMate snippets are not yet supported: + +* Interpolated shell code can’t reliably be supported cross-platform, and is probably a bad idea anyway. No other editors that support snippets have adopted this feature, and Pulsar won’t either. + +The following features from VSCode snippets are not yet supported: + +* “Choice” syntax like `${1|one,two,three|}` requires that the autocomplete engine pop up a menu to offer the user a choice between the available placeholder options. This may be supported in the future, but right now Pulsar effectively converts this to `${1:one}`, treating the first choice as a conventional placeholder. + +### Variables + +Pulsar snippets support all of the variables mentioned in the [LSP specification][lsp], plus many of the variables [supported by VSCode][vscode]. + +Variables can be referenced with `$`, either without braces (`$CLIPBOARD`) or with braces (`${CLIPBOARD}`). Variables can also have fallback values (`${CLIPBOARD:http://example.com}`), simple flag-based transformations (`${CLIPBOARD:/upcase}`), or `sed`-style transformations `${CLIPBOARD/ /_/g}`. -### Snippet syntax +One of the most useful is `TM_SELECTED_TEXT`, which represents whatever text was selected when the snippet was invoked. (Naturally, this can only happen when a snippet is invoked via command or key shortcut, rather than by typing in a Tab trigger.) -This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets#transformations). +Others that can be useful: -The following features are not yet supported: +* `TM_FILENAME`: The name of the current file (`foo.rb`). +* `TM_FILENAME_BASE`: The name of the current file, but without its extension (`foo`). +* `TM_FILEPATH`: The entire path on disk to the current file. +* `TM_CURRENT_LINE`: The entire current line that the cursor is sitting on. +* `TM_CURRENT_WORD`: The entire word that the cursor is within or adjacent to, as interpreted by `cursor.getCurrentWordBufferRange`. +* `CLIPBOARD`: The current contents of the clipboard. +* `CURRENT_YEAR`, `CURRENT_MONTH`, et cetera: referneces to the current date and time in various formats. -* Variables -* Interpolated shell code -* Conditional insertions in transformations +Any variable that has no value — for instance, `TM_FILENAME` on an untitled document — will resolve to an empty string. -### Multi-line Snippet Body +#### Variable transformation flags + +Pulsar supports the three flags defined in the [LSP snippets specification][lsp] and two other flags that are [implemented in VSCode][vscode]: + +* `/upcase` (`foo` → `FOO`) +* `/downcase` (`BAR` → `bar`) +* `/capitalize` (`lorem ipsum dolor` → `Lorem ipsum dolor`) +* `/camelcase` (`foo bar` → `fooBar`, `lorem-ipsum.dolor` → `loremIpsumDolor`) +* `/pascalcase` (`foo bar` → `FooBar`, `lorem-ipsum.dolor` → `LoremIpsumDolor`) + +#### Variable caveats + +* `WORKSPACE_NAME`, `WORKSPACE_FOLDER`, and `RELATIVE_PATH` all rely on the presence of a root project folder, but a Pulsar project can technically have multiple root folders. While this is rare, it is handled by `snippets` as follows: whichever project path is an ancestor of the currently active file is treated as the project root — or the first one found if multiple roots are ancestors. +* `WORKSPACE_NAME` in VSCode refers to “the name of the opened workspace or folder.” In the former case, this appears to mean bundled projects with a `.code-workspace` file extension — which have no Pulsar equivalent. Instead, `WORKSPACE_NAME` will always refer to the last path component of your project’s root directory as defined above. + +#### Variables that are not yet supported + +Of the variables supported by VSCode, Pulsar does not yet support: + +* `UUID` +* `BLOCK_COMMENT_START` +* `BLOCK_COMMENT_END` +* `LINE_COMMENT` + +## Multi-line Snippet Body You can also use multi-line syntax using `"""` for larger templates: @@ -110,7 +164,7 @@ You can also use multi-line syntax using `"""` for larger templates: """ ``` -### Escaping Characters +## Escaping Characters Including a literal closing brace inside the text provided by a snippet's tab stop will close that tab stop early. To prevent that, escape the brace with two backslashes, like so: @@ -127,6 +181,12 @@ Including a literal closing brace inside the text provided by a snippet's tab st """ ``` -### Multiple snippets for the same scope +Likewise, if your snippet includes literal references to `$` or `{`, you may have to escape those with two backslashes as well, depending on the context. + +## Multiple snippets for the same scope + +Snippets for the same scope must be placed within the same key. See [this section of the Pulsar Flight Manual](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#configuring-with-cson) for more information. + -Snippets for the same scope must be placed within the same key. See [this section of the Atom Flight Manual](https://pulsar-edit.dev/docs/atom-archive/using-atom/#configuring-with-cson) for more information. +[lsp]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#variables +[vscode]: https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables diff --git a/lib/insertion.js b/lib/insertion.js index cc6d232f..74fc09f1 100644 --- a/lib/insertion.js +++ b/lib/insertion.js @@ -1,51 +1,7 @@ -const ESCAPES = { - u: (flags) => { - flags.lowercaseNext = false - flags.uppercaseNext = true - }, - l: (flags) => { - flags.uppercaseNext = false - flags.lowercaseNext = true - }, - U: (flags) => { - flags.lowercaseAll = false - flags.uppercaseAll = true - }, - L: (flags) => { - flags.uppercaseAll = false - flags.lowercaseAll = true - }, - E: (flags) => { - flags.uppercaseAll = false - flags.lowercaseAll = false - }, - r: (flags, result) => { - result.push('\\r') - }, - n: (flags, result) => { - result.push('\\n') - }, - $: (flags, result) => { - result.push('$') - } -} - -function transformText (str, flags) { - if (flags.uppercaseAll) { - return str.toUpperCase() - } else if (flags.lowercaseAll) { - return str.toLowerCase() - } else if (flags.uppercaseNext) { - flags.uppercaseNext = false - return str.replace(/^./, s => s.toUpperCase()) - } else if (flags.lowercaseNext) { - return str.replace(/^./, s => s.toLowerCase()) - } - return str -} +const Replacer = require('./replacer') class Insertion { - constructor ({ range, substitution, references }) { + constructor ({range, substitution, references}) { this.range = range this.substitution = substitution this.references = references @@ -53,7 +9,7 @@ class Insertion { if (substitution.replace === undefined) { substitution.replace = '' } - this.replacer = this.makeReplacer(substitution.replace) + this.replacer = new Replacer(substitution.replace) } } @@ -61,34 +17,14 @@ class Insertion { return !!this.substitution } - makeReplacer (replace) { - return function replacer (...match) { - let flags = { - uppercaseAll: false, - lowercaseAll: false, - uppercaseNext: false, - lowercaseNext: false - } - replace = [...replace] - let result = [] - replace.forEach(token => { - if (typeof token === 'string') { - result.push(transformText(token, flags)) - } else if (token.escape) { - ESCAPES[token.escape](flags, result) - } else if (token.backreference) { - let transformed = transformText(match[token.backreference], flags) - result.push(transformed) - } - }) - return result.join('') - } - } - transform (input) { - let { substitution } = this + let {substitution} = this if (!substitution) { return input } - return input.replace(substitution.find, this.replacer) + this.replacer.resetFlags() + return input.replace(substitution.find, (...args) => { + let result = this.replacer.replace(...args) + return result + }) } } diff --git a/lib/replacer.js b/lib/replacer.js new file mode 100644 index 00000000..78be522e --- /dev/null +++ b/lib/replacer.js @@ -0,0 +1,100 @@ +const ESCAPES = { + u: (flags) => { + flags.lowercaseNext = false + flags.uppercaseNext = true + }, + l: (flags) => { + flags.uppercaseNext = false + flags.lowercaseNext = true + }, + U: (flags) => { + flags.lowercaseAll = false + flags.uppercaseAll = true + }, + L: (flags) => { + flags.uppercaseAll = false + flags.lowercaseAll = true + }, + E: (flags) => { + flags.uppercaseAll = false + flags.lowercaseAll = false + }, + r: (flags, result) => { + result.push('\\r') + }, + n: (flags, result) => { + result.push('\\n') + }, + $: (flags, result) => { + result.push('$') + } +} + +function transformTextWithFlags (str, flags) { + if (flags.uppercaseAll) { + return str.toUpperCase() + } else if (flags.lowercaseAll) { + return str.toLowerCase() + } else if (flags.uppercaseNext) { + flags.uppercaseNext = false + return str.replace(/^./, s => s.toUpperCase()) + } else if (flags.lowercaseNext) { + return str.replace(/^./, s => s.toLowerCase()) + } + return str +} + + +// `Replacer` handles shared substitution semantics for tabstop and variable +// transformations. +class Replacer { + constructor (tokens) { + this.tokens = [...tokens] + this.resetFlags() + } + + resetFlags () { + this.flags = { + uppercaseAll: false, + lowercaseAll: false, + uppercaseNext: false, + lowercaseNext: false + } + } + + replace (...match) { + let result = [] + + function handleToken (token) { + if (typeof token === 'string') { + result.push(transformTextWithFlags(token, this.flags)) + } else if (token.escape) { + ESCAPES[token.escape](this.flags, result) + } else if (token.backreference) { + let {iftext, elsetext} = token + if (iftext != null && elsetext != null) { + // If-else syntax makes choices based on the presence or absence of a + // capture group backreference. + let m = match[token.backreference] + let tokenToHandle = m ? iftext : elsetext + if (Array.isArray(tokenToHandle)) { + result.push(...tokenToHandle.map(handleToken.bind(this))) + } else { + result.push(handleToken.call(this, tokenToHandle)) + } + } else { + let transformed = transformTextWithFlags( + match[token.backreference], + this.flags + ) + result.push(transformed) + } + } + } + + this.tokens.forEach(handleToken.bind(this)) + return result.join('') + } +} + +module.exports = Replacer diff --git a/lib/snippet-body.pegjs b/lib/snippet-body.pegjs index 42bb7dfa..dce8318c 100644 --- a/lib/snippet-body.pegjs +++ b/lib/snippet-body.pegjs @@ -1,6 +1,9 @@ + { - // Joins all consecutive strings in a collection without clobbering any - // non-string members. + function makeInteger(i) { + return parseInt(i.join(''), 10); + } + function coalesce (parts) { const result = []; for (let i = 0; i < parts.length; i++) { @@ -15,68 +18,211 @@ return result; } - function flatten (parts) { - return parts.reduce(function (flat, rest) { - return flat.concat(Array.isArray(rest) ? flatten(rest) : rest); - }, []); + function unwrap (val) { + let shouldUnwrap = Array.isArray(val) && val.length === 1 && typeof val[0] === 'string'; + return shouldUnwrap ? val[0] : val; } + } -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 +bodyContent = content:(tabstop / choice / variable / text)* { return content; } + +innerBodyContent = content:(tabstop / choice / variable / nonCloseBraceText)* { return content; } -simpleTabStop = '$' index:[0-9]+ { - return { index: parseInt(index.join("")), content: [] }; +tabstop = simpleTabstop / tabstopWithoutPlaceholder / tabstopWithPlaceholder / tabstopWithTransform + +simpleTabstop = '$' index:int { + return {index: makeInteger(index), content: []} } -tabStopWithoutPlaceholder = '${' index:[0-9]+ '}' { - return { index: parseInt(index.join("")), content: [] }; + +tabstopWithoutPlaceholder = '${' index:int '}' { + return {index: makeInteger(index), content: []} } -tabStopWithPlaceholder = '${' index:[0-9]+ ':' content:placeholderContent '}' { - return { index: parseInt(index.join("")), content: content }; + +tabstopWithPlaceholder = '${' index:int ':' content:innerBodyContent '}' { + return {index: makeInteger(index), content: content} } -tabStopWithTransformation = '${' index:[0-9]+ substitution:transformationSubstitution '}' { + +tabstopWithTransform = '${' index:int substitution:transform '}' { return { - index: parseInt(index.join(""), 10), + index: makeInteger(index), content: [], substitution: substitution - }; + } +} + +choice = '${' index:int '|' choice:choicecontents '|}' { + // Choice syntax requires an autocompleter to offer the user the options. As + // a fallback, we can take the first option and treat it as a placeholder. + const content = choice.length > 0 ? [choice[0]] : [] + return {index: makeInteger(index), choice: choice, content: content} +} + +choicecontents = elem:choicetext rest:(',' val:choicetext { return val } )* { + return [elem, ...rest] +} + +choicetext = choicetext:(choiceEscaped / [^|,] / barred:('|' &[^}]) { return barred.join('') } )+ { + return choicetext.join('') +} + +transform = '/' regex:regexString '/' replace:replace '/' flags:flags { + return {find: new RegExp(regex, flags), replace: replace} +} + +regexString = regex:(escaped / [^/])* { + return regex.join('') +} + +replace = (format / replacetext)* + +format = simpleFormat / formatWithoutPlaceholder / formatWithCaseTransform / formatWithIf / formatWithIfElse / formatWithElse / formatEscape / formatWithIfElseAlt / formatWithIfAlt + +simpleFormat = '$' index:int { + return {backreference: makeInteger(index)} +} + +formatWithoutPlaceholder = '${' index:int '}' { + return {backreference: makeInteger(index)} +} + +formatWithCaseTransform = '${' index:int ':' caseTransform:caseTransform '}' { + return {backreference: makeInteger(index), transform: caseTransform} +} + +formatWithIf = '${' index:int ':+' iftext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), iftext: unwrap(iftext), elsetext: ''} +} + +formatWithIfAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ')' { + return {backreference: makeInteger(index), iftext: unwrap(iftext), elseText: '' } +} + +formatWithElse = '${' index:int (':-' / ':') elsetext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), iftext: '', elsetext: unwrap(elsetext)} +} + +// Variable interpolation if-else; conditional clause queries the presence of a +// specific tabstop value. +formatWithIfElse = '${' index:int ':?' iftext:ifText ':' elsetext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext} } -placeholderContent = content:(tabStop / placeholderContentText / variable )* { return flatten(content); } -placeholderContentText = text:placeholderContentChar+ { return coalesce(text); } -placeholderContentChar = escaped / placeholderVariableReference / !tabStop !variable char:[^}] { return char; } +// Substitution if-else; conditional clause tests whether a given regex capture +// group matched anything. +formatWithIfElseAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ':' elsetext:(elseTextAlt / '') ')' { + return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext} +} + +nonColonText = text:('\\:' { return ':' } / escaped / [^:])* { + return text.join('') +} + +formatEscape = '\\' flag:[ULulErn] { + return {escape: flag} +} + +caseTransform = '/' type:[a-zA-Z]* { + return type.join('') +} + +replacetext = replacetext:(!formatEscape char:escaped { return char } / !format char:[^/] { return char })+ { + return replacetext.join('') +} + +variable = simpleVariable / variableWithSimpleTransform / variableWithoutPlaceholder / variableWithPlaceholder / variableWithTransform + +simpleVariable = '$' name:variableName { + return {variable: name} +} + +variableWithoutPlaceholder = '${' name:variableName '}' { + return {variable: name} +} + +variableWithPlaceholder = '${' name:variableName ':' content:innerBodyContent '}' { + return {variable: name, content: content} +} + +variableWithTransform = '${' name:variableName substitution:transform '}' { + return {variable: name, substitution: substitution} +} -placeholderVariableReference = '$' digit:[0-9]+ { - return { index: parseInt(digit.join(""), 10), content: [] }; +variableWithSimpleTransform = '${' name:variableName ':/' substitutionFlag:substitutionFlag '}' { + return {variable: name, substitution: {flag: substitutionFlag}} } -variable = '${' variableContent '}' { - return ''; // we eat variables and do nothing with them for now +variableName = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* { + return first + rest.join('') } -variableContent = content:(variable / variableContentText)* { return content; } -variableContentText = text:variableContentChar+ { return text.join(''); } -variableContentChar = !variable char:('\\}' / [^}]) { return char; } -escapedForwardSlash = pair:'\\/' { return pair; } +substitutionFlag = chars:[a-z]+ { + return chars.join('') +} + +int = [0-9]+ + +escaped = '\\' char:. { + switch (char) { + case '$': + case '\\': + case ':': + case '\x7D': // back brace; PEGjs would treat it as the JS scope end though + return char + default: + return '\\' + char + } +} + +choiceEscaped = '\\' char:. { + switch (char) { + case '$': + case '\\': + case '\x7D': + case '|': + case ',': + return char + default: + return '\\' + char + } +} + +flags = flags:[a-z]* { + return flags.join('') +} -// A pattern and replacement for a transformed tab stop. -transformationSubstitution = '/' find:(escapedForwardSlash / [^/])* '/' replace:formatString* '/' flags:[gimy]* { - let reFind = new RegExp(find.join(''), flags.join('')); - return { find: reFind, replace: replace[0] }; +text = text:(escaped / !tabstop !variable !choice char:. { return char })+ { + return text.join('') } -formatString = content:(formatStringEscape / formatStringReference / escapedForwardSlash / [^/])+ { - return content; +nonCloseBraceText = text:(escaped / !tabstop !variable !choice char:[^}] { return char })+ { + return text.join('') } -// 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 }; + +// Two kinds of format string conditional syntax: the `${` flavor and the `(?` +// flavor. +// +// VSCode supports only the `${` flavor. It's easier to parse because the +// if-result and else-result can only be plain text, as per the specification. +// +// TextMate supports both. `(?` is more powerful, but also harder to parse, +// because it can contain special flags and regex backreferences. + +// For the first part of a two-part if-else. Runs until the `:` delimiter. +ifText = text:(escaped / char:[^:] { return char })+ { + return text.join('') +} + +// For either the second part of a two-part if-else OR the sole part of a +// one-part if/else. Runs until the `}` that ends the expression. +ifElseText = text:(escaped / char:[^}] { return char })+ { + return text.join('') +} + +ifTextAlt = text:(formatEscape / format / escaped / char:[^:] { return char })+ { + return coalesce(text); +} + +elseTextAlt = text:(formatEscape / format / escaped / char:[^)] { return char })+ { + return coalesce(text); } diff --git a/lib/snippet-expansion.js b/lib/snippet-expansion.js index b962d803..859754f5 100644 --- a/lib/snippet-expansion.js +++ b/lib/snippet-expansion.js @@ -1,7 +1,7 @@ const {CompositeDisposable, Range, Point} = require('atom') module.exports = class SnippetExpansion { - constructor (snippet, editor, cursor, snippets) { + constructor (snippet, editor, cursor, snippets, {method} = {}) { this.settingTabStop = false this.isIgnoringBufferChanges = false this.onUndoOrRedo = this.onUndoOrRedo.bind(this) @@ -12,6 +12,11 @@ module.exports = class SnippetExpansion { this.subscriptions = new CompositeDisposable this.selections = [this.cursor.selection] + // Method refers to how the snippet was invoked; known values are `prefix` + // or `command`. If neither is present, then snippet was inserted + // programmatically. + this.method = method + // Holds the `Insertion` instance corresponding to each tab stop marker. We // don't use the tab stop's own numbering here; we renumber them // consecutively starting at 0 in the order in which they should be @@ -24,6 +29,9 @@ module.exports = class SnippetExpansion { // its old one. this.markersForInsertions = new Map() + this.resolutionsForVariables = new Map() + this.markersForVariables = new Map() + // The index of the active tab stop. this.tabStopIndex = null @@ -45,21 +53,56 @@ module.exports = class SnippetExpansion { tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent)) } - this.editor.transact(() => { - this.ignoringBufferChanges(() => { - this.editor.transact(() => { - // Insert the snippet body at the cursor. - const newRange = this.cursor.selection.insertText(body, {autoIndent: false}) - if (this.snippet.tabStopList.length > 0) { - // Listen for cursor changes so we can decide whether to keep the - // snippet active or terminate it. - this.subscriptions.add(this.cursor.onDidChangePosition(event => this.cursorMoved(event))) - this.subscriptions.add(this.cursor.onDidDestroy(() => this.cursorDestroyed())) - this.placeTabStopMarkers(startPosition, tabStops) - this.snippets.addExpansion(this.editor, this) - this.editor.normalizeTabsInBufferRange(newRange) - } - }) + this.ignoringBufferChanges(() => { + this.editor.transact(() => { + // Determine what each variable reference will be replaced by + // _before_ we make any changes to the state of the editor. This + // affects $TM_SELECTED_TEXT, $TM_CURRENT_WORD, and others. + this.resolveVariables(startPosition) + // Insert the snippet body at the cursor. + const newRange = this.cursor.selection.insertText(body, {autoIndent: false}) + // Mark the range we just inserted. Once we interpolate variables and + // apply transformations, the range may grow, and we need to keep + // track of that so we can normalize tabs later on. + const newRangeMarker = this.getMarkerLayer(this.editor).markBufferRange(newRange, {exclusive: false}) + + if (this.snippet.tabStopList.length > 0) { + // Listen for cursor changes so we can decide whether to keep the + // snippet active or terminate it. + this.subscriptions.add( + this.cursor.onDidChangePosition(event => this.cursorMoved(event)), + this.cursor.onDidDestroy(() => this.cursorDestroyed()) + ) + // First we'll add display markers for tab stops and variables. + // Both need these areas to be marked before any expansion happens + // so that they don't lose track of where their slots are. + this.placeTabStopMarkers(startPosition, tabStops) + this.markVariables(startPosition) + + // Now we'll expand variables. All markers in the previous step + // were defined with `exclusive: false`, so any that are affected + // by variable expansion will grow if necessary. + this.expandVariables(startPosition) + + // Now we'll make the first tab stop active and apply snippet + // transformations for the first time. As part of this process, + // most markers will be converted to `exclusive: true` and adjusted + // as necessary as the user tabs through the snippet. + this.setTabStopIndex(0) + this.applyAllTransformations() + + this.snippets.addExpansion(this.editor, this) + } else { + // No tab stops, so we're free to mark and expand variables without + // worrying about the delicate order of operations. + this.markVariables(startPosition) + this.expandVariables(startPosition) + } + + // Snippet bodies are written generically and don't know anything + // about the user's indentation settings. So we adjust them after + // expansion. + this.editor.normalizeTabsInBufferRange(newRangeMarker.getBufferRange()) }) }) } @@ -82,13 +125,18 @@ module.exports = class SnippetExpansion { this.destroy() } - cursorDestroyed () { if (!this.settingTabStop) { this.destroy() } } + cursorDestroyed () { + // The only time a cursor can be destroyed without it ending the snippet is + // if we move from a mirrored tab stop (i.e., multiple cursors) to a + // single-cursor tab stop. + if (!this.settingTabStop) { this.destroy() } + } textChanged (event) { if (this.isIgnoringBufferChanges) { return } - // Don't try to alter the buffer if all we're doing is restoring a - // snapshot from history. + // Don't try to alter the buffer if all we're doing is restoring a snapshot + // from history. if (this.isUndoingOrRedoing) { this.isUndoingOrRedoing = false return @@ -144,6 +192,48 @@ module.exports = class SnippetExpansion { }) } + resolveVariables (startPosition) { + let params = { + editor: this.editor, + cursor: this.cursor, + selectionRange: this.cursor.selection.getBufferRange(), + method: this.method + } + + for (const variable of this.snippet.variables) { + let resolution = variable.resolve(params) + this.resolutionsForVariables.set(variable, resolution) + } + } + + markVariables (startPosition) { + // We make two passes here. On the first pass, we create markers for each + // point where a variable will be inserted. On the second pass, we use each + // marker to insert the resolved variable value. + // + // Those points will move around as we insert text into them, so the + // markers are crucial for ensuring we adapt to those changes. + for (const variable of this.snippet.variables) { + const {point} = variable + const marker = this.getMarkerLayer(this.editor).markBufferRange([ + startPosition.traverse(point), + startPosition.traverse(point) + ], {exclusive: false}) + this.markersForVariables.set(variable, marker) + } + } + + expandVariables (startPosition) { + this.editor.transact(() => { + for (const variable of this.snippet.variables) { + let marker = this.markersForVariables.get(variable) + let resolution = this.resolutionsForVariables.get(variable) + let range = marker.getBufferRange() + this.editor.setTextInBufferRange(range, resolution) + } + }) + } + placeTabStopMarkers (startPosition, tabStops) { // Tab stops within a snippet refer to one another by their external index // (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but @@ -170,28 +260,34 @@ module.exports = class SnippetExpansion { if (insertion.references) { references = insertion.references.map(external => indexTable[external]) } - // Since this method is called only once at the beginning of a snippet expansion, we know that 0 is about to be the active tab stop. - const shouldBeInclusive = (index === 0) || (references && references.includes(0)) + // This is our initial pass at marking tab stop regions. In a minute, + // once the first tab stop is made active, we will make some of these + // markers exclusive and some inclusive. But right now we need them all + // to be inclusive, because we want them all to react when we resolve + // snippet variables, and grow if they need to. const marker = this.getMarkerLayer(this.editor).markBufferRange([ startPosition.traverse(start), startPosition.traverse(end) - ], {exclusive: !shouldBeInclusive}) + ], {exclusive: false}) // Now that we've created these markers, we need to store them in a // data structure because they'll need to be deleted and re-created // when their exclusivity changes. this.markersForInsertions.set(insertion, marker) if (references) { - const relatedInsertions = this.relatedInsertionsByIndex.get(index) || [] - relatedInsertions.push(insertion) - this.relatedInsertionsByIndex.set(index, relatedInsertions) + // The insertion at tab stop `index` (internal numbering) is related + // to, and affected by, all the tab stops mentioned in `references` + // (internal numbering). We need to make sure we're included in these + // other tab stops' exclusivity changes. + for (let ref of references) { + let relatedInsertions = this.relatedInsertionsByIndex.get(ref) || [] + relatedInsertions.push(insertion) + this.relatedInsertionsByIndex.set(ref, relatedInsertions) + } } } this.insertionsByIndex[index] = insertions } - - this.setTabStopIndex(0) - this.applyAllTransformations() } // When two insertion markers are directly adjacent to one another, and the @@ -200,33 +296,52 @@ module.exports = class SnippetExpansion { // // All else being equal, that content should get added to the marker (if any) // whose tab stop is active, or else the marker whose tab stop's placeholder - // references an active tab stop. The `exclusive` setting on a marker - // controls whether that marker grows to include content added at its edge. + // references an active tab stop. To use the terminology of Atom's + // `DisplayMarker`, all markers related to the active tab stop should be + // "inclusive," and all others should be "exclusive." + // + // Exclusivity cannot be changed after a marker is created. So we need to + // revisit the markers whenever the active tab stop changes, figure out which + // ones need to be touched, and replace them with markers that have the + // settings we need. // - // So we need to revisit the markers whenever the active tab stop changes, - // figure out which ones need to be touched, and replace them with markers - // that have the settings we need. adjustTabStopMarkers (oldIndex, newIndex) { - // Take all the insertions whose markers were made inclusive when they - // became active and restore their original marker settings. - const insertionsForOldIndex = [ - ...this.insertionsByIndex[oldIndex], - ...(this.relatedInsertionsByIndex.get(oldIndex) || []) + // All the insertions belonging to the newly active tab stop (and all + // insertions whose placeholders reference the newly active tab stop) + // should become inclusive. + const insertionsToMakeInclusive = [ + ...this.insertionsByIndex[newIndex], + ...(this.relatedInsertionsByIndex.get(newIndex) || []) ] - for (let insertion of insertionsForOldIndex) { - this.replaceMarkerForInsertion(insertion, {exclusive: true}) + // All insertions that are _not_ related to the newly active tab stop + // should become exclusive if they aren't already. + let insertionsToMakeExclusive + if (oldIndex === null) { + // This is the first index to be made active. Since all insertion markers + // were initially created to be inclusive, we need to adjust _all_ + // insertion markers that are not related to the new tab stop. + let allInsertions = this.insertionsByIndex.reduce((set, ins) => { + set.push(...ins) + return set + }, []) + insertionsToMakeExclusive = allInsertions.filter(ins => { + return !insertionsToMakeInclusive.includes(ins) + }) + } else { + // We are moving from one tab stop to another, so we only need to touch + // the markers related to the tab stop we're departing. + insertionsToMakeExclusive = [ + ...this.insertionsByIndex[oldIndex], + ...(this.relatedInsertionsByIndex.get(oldIndex) || []) + ] } - // Take all the insertions belonging to the newly active tab stop (and all - // insertions whose placeholders reference the newly active tab stop) and - // change their markers to be inclusive. - const insertionsForNewIndex = [ - ...this.insertionsByIndex[newIndex], - ...(this.relatedInsertionsByIndex.get(newIndex) || []) - ] + for (let insertion of insertionsToMakeExclusive) { + this.replaceMarkerForInsertion(insertion, {exclusive: true}) + } - for (let insertion of insertionsForNewIndex) { + for (let insertion of insertionsToMakeInclusive) { this.replaceMarkerForInsertion(insertion, {exclusive: false}) } } @@ -337,9 +452,7 @@ module.exports = class SnippetExpansion { this.snippets.stopObservingEditor(this.editor) } - if (oldIndex !== null) { - this.adjustTabStopMarkers(oldIndex, newIndex) - } + this.adjustTabStopMarkers(oldIndex, newIndex) return markerSelected } @@ -363,8 +476,11 @@ module.exports = class SnippetExpansion { this.subscriptions.dispose() this.getMarkerLayer(this.editor).clear() this.insertionsByIndex = [] - this.relatedInsertionsByIndex = new Map() - this.markersForInsertions = new Map() + this.relatedInsertionsByIndex.clear() + this.markersForInsertions.clear() + this.resolutionsForVariables.clear() + this.markersForVariables.clear() + this.snippets.stopObservingEditor(this.editor) this.snippets.clearExpansions(this.editor) } diff --git a/lib/snippet.js b/lib/snippet.js index b432fe83..86af19d8 100644 --- a/lib/snippet.js +++ b/lib/snippet.js @@ -1,5 +1,6 @@ -const {Range} = require('atom') +const {Point, Range} = require('atom') const TabStopList = require('./tab-stop-list') +const Variable = require('./variable') function tabStopsReferencedWithinTabStopContent (segment) { const results = [] @@ -12,7 +13,24 @@ function tabStopsReferencedWithinTabStopContent (segment) { } module.exports = class Snippet { - constructor ({name, prefix, command, bodyText, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyTree, selector}) { + constructor (attrs) { + let { + id, + bodyText, + bodyTree, + command, + description, + descriptionMoreURL, + leftLabel, + leftLabelHTML, + name, + prefix, + packageName, + rightLabelHTML, + selector + } = attrs + + this.id = id this.name = name this.prefix = prefix this.command = command @@ -24,44 +42,53 @@ module.exports = class Snippet { this.leftLabel = leftLabel this.leftLabelHTML = leftLabelHTML this.selector = selector + + this.variables = [] this.tabStopList = new TabStopList(this) - this.body = this.extractTabStops(bodyTree) + this.body = this.extractTokens(bodyTree) + if (packageName && command) { this.commandName = `${packageName}:${command}` } } - extractTabStops (bodyTree) { + extractTokens (bodyTree) { const bodyText = [] - let row = 0 - let column = 0 + let row = 0, column = 0 - // recursive helper function; mutates vars above - let extractTabStops = bodyTree => { - for (const segment of bodyTree) { + let extract = bodyTree => { + for (let segment of bodyTree) { if (segment.index != null) { + // Tabstop. let {index, content, substitution} = segment // Ensure tabstop `$0` is always last. if (index === 0) { index = Infinity } const start = [row, column] - extractTabStops(content) + extract(content) const referencedTabStops = tabStopsReferencedWithinTabStopContent(content) const range = new Range(start, [row, column]) + const tabStop = this.tabStopList.findOrCreate({ - index, - snippet: this + index, snippet: this }) + tabStop.addInsertion({ range, substitution, - references: Array.from(referencedTabStops) + references: [...referencedTabStops] }) + } else if (segment.variable != null) { + // Variable. + let point = new Point(row, column) + this.variables.push( + new Variable({...segment, point, snippet: this}) + ) } else if (typeof segment === 'string') { bodyText.push(segment) - var segmentLines = segment.split('\n') + let segmentLines = segment.split('\n') column += segmentLines.shift().length let nextLine while ((nextLine = segmentLines.shift()) != null) { @@ -72,10 +99,11 @@ module.exports = class Snippet { } } - extractTabStops(bodyTree) + extract(bodyTree) this.lineCount = row + 1 this.insertions = this.tabStopList.getInsertions() return bodyText.join('') } + } diff --git a/lib/snippets.js b/lib/snippets.js index b86427ec..ab361bca 100644 --- a/lib/snippets.js +++ b/lib/snippets.js @@ -116,7 +116,7 @@ const PackageNameResolver = { }, find (filePath) { for (let [packagePath, name] of this.pathsToNames.entries()) { - if (filePath.startsWith(packagePath)) return name + if (filePath.startsWith(`${packagePath}${path.sep}`)) return name } return null } @@ -274,7 +274,7 @@ module.exports = { } catch (e) { const message = `\ Unable to watch path: \`snippets.cson\`. Make sure you have permissions - to the \`~/.atom\` directory and \`${userSnippetsPath}\`. + to the \`~/.pulsar\` directory and \`${userSnippetsPath}\`. On linux there are currently problems with watch sizes. See [this document][watches] for more info. @@ -510,7 +510,6 @@ module.exports = { packageName = packageName || 'snippets' let {name, command} = attributes let commandName = `${packageName}:${command}` - console.trace() if (CommandMonitor.exists(commandName)) { console.error(`Skipping ${commandName} because it's already been registered!`) showCommandNameConflictNotification( @@ -538,7 +537,7 @@ module.exports = { let targetSnippet = null for (let snippet of Object.values(snippets)) { - if (snippet.command === command && snippet.packageName === packageName) { + if (snippet.id === attributes.id) { targetSnippet = snippet break } @@ -556,17 +555,7 @@ module.exports = { return event.abortKeyBinding() } - this.getStore(editor).observeHistory({ - undo: event => { this.onUndoOrRedo(editor, event, true) }, - redo: event => { this.onUndoOrRedo(editor, event, false) } - }) - this.findOrCreateMarkerLayer(editor) - editor.transact(() => { - const cursors = editor.getCursors() - for (const cursor of cursors) { - this.insert(targetSnippet, editor, cursor) - } - }) + this.expandSnippet(editor, targetSnippet) } let disposable = atom.commands.add( @@ -771,6 +760,25 @@ module.exports = { } }, + // Expands a snippet invoked via command. + expandSnippet (editor, snippet) { + this.getStore(editor).observeHistory({ + undo: event => { this.onUndoOrRedo(editor, event, true) }, + redo: event => { this.onUndoOrRedo(editor, event, false) } + }) + + this.findOrCreateMarkerLayer(editor) + + editor.transact(() => { + const cursors = editor.getCursors() + for (const cursor of cursors) { + this.insert(snippet, editor, cursor, {method: 'command'}) + } + }) + }, + + // Expands a snippet defined via tab trigger _if_ such a snippet can be found + // for the current prefix and scope. expandSnippetsUnderCursors (editor) { const snippet = this.snippetToExpandUnderCursor(editor) if (!snippet) { return false } @@ -784,10 +792,12 @@ module.exports = { editor.transact(() => { const cursors = editor.getCursors() for (const cursor of cursors) { + // Select the prefix text so that it gets consumed when the snippet + // expands. const cursorPosition = cursor.getBufferPosition() const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0]) cursor.selection.setBufferRange([startPoint, cursorPosition]) - this.insert(snippet, editor, cursor) + this.insert(snippet, editor, cursor, {method: 'prefix'}) } }) return true @@ -879,14 +889,14 @@ module.exports = { this.getStore(editor).makeCheckpoint() }, - insert (snippet, editor, cursor) { + insert (snippet, editor, cursor, {method = null} = {}) { if (editor == null) { editor = atom.workspace.getActiveTextEditor() } if (cursor == null) { cursor = editor.getLastCursor() } if (typeof snippet === 'string') { const bodyTree = this.getBodyParser().parse(snippet) - snippet = new Snippet({name: '__anonymous', prefix: '', bodyTree, bodyText: snippet}) + snippet = new Snippet({id: this.snippetIdCounter++, name: '__anonymous', prefix: '', bodyTree, bodyText: snippet}) } - return new SnippetExpansion(snippet, editor, cursor, this) + return new SnippetExpansion(snippet, editor, cursor, this, {method}) }, getUnparsedSnippets () { diff --git a/lib/variable.js b/lib/variable.js new file mode 100644 index 00000000..9d43a387 --- /dev/null +++ b/lib/variable.js @@ -0,0 +1,256 @@ +const path = require('path') +const crypto = require('crypto') +const Replacer = require('./replacer') +const {remote} = require('electron') + +function resolveClipboard () { + return atom.clipboard.read() +} + +function makeDateResolver (dateParams) { + // TODO: I do not know if this method ever returns anything other than + // 'en-us'; I suspect it does not. But this is likely the forward-compatible + // way of doing things. + // + // On the other hand, if the output of CURRENT_* variables _did_ vary based + // on locale, we'd probably need to implement a setting to force an arbitrary + // locale. I imagine lots of people use their native language for their OS's + // locale but write code in English. + // + let locale = remote.app.getLocale() + return () => new Date().toLocaleString(locale, dateParams) +} + +const RESOLVERS = { + // All the TM_-prefixed variables are part of the LSP specification for + // snippets. + 'TM_SELECTED_TEXT' ({editor, selectionRange, method}) { + // When a snippet is inserted via tab trigger, the trigger is + // programmatically selected prior to snippet expansion so that it is + // consumed when the snippet body is inserted. The trigger _should not_ be + // treated as selected text. There is no way for $TM_SELECTED_TEXT to + // contain anything when a snippet is invoked via tab trigger. + if (method === 'prefix') return '' + + if (!selectionRange || selectionRange.isEmpty()) return '' + return editor.getTextInBufferRange(selectionRange) + }, + 'TM_CURRENT_LINE' ({editor, cursor}) { + return editor.lineTextForBufferRow(cursor.getBufferRow()) + }, + 'TM_CURRENT_WORD' ({editor, cursor}) { + return editor.getTextInBufferRange(cursor.getCurrentWordBufferRange()) + }, + 'TM_LINE_INDEX' ({cursor}) { + return `${cursor.getBufferRow()}` + }, + 'TM_LINE_NUMBER' ({cursor}) { + return `${cursor.getBufferRow() + 1}` + }, + 'TM_FILENAME' ({editor}) { + return editor.getTitle() + }, + 'TM_FILENAME_BASE' ({editor}) { + let fileName = editor.getTitle() + if (!fileName) { return undefined } + + const index = fileName.lastIndexOf('.') + if (index >= 0) { + return fileName.slice(0, index) + } + return fileName + }, + 'TM_FILEPATH' ({editor}) { + return editor.getPath() + }, + 'TM_DIRECTORY' ({editor}) { + const filePath = editor.getPath() + if (filePath === undefined) return undefined + return path.dirname(filePath) + }, + + // VSCode supports these. + 'CLIPBOARD': resolveClipboard, + + 'CURRENT_YEAR': makeDateResolver({year: 'numeric'}), + 'CURRENT_YEAR_SHORT': makeDateResolver({year: '2-digit'}), + 'CURRENT_MONTH': makeDateResolver({month: '2-digit'}), + 'CURRENT_MONTH_NAME': makeDateResolver({month: 'long'}), + 'CURRENT_MONTH_NAME_SHORT': makeDateResolver({month: 'short'}), + 'CURRENT_DATE': makeDateResolver({day: '2-digit'}), + 'CURRENT_DAY_NAME': makeDateResolver({weekday: 'long'}), + 'CURRENT_DAY_NAME_SHORT': makeDateResolver({weekday: 'short'}), + 'CURRENT_HOUR': makeDateResolver({hour12: false, hour: '2-digit'}), + 'CURRENT_MINUTE': makeDateResolver({minute: '2-digit'}), + 'CURRENT_SECOND': makeDateResolver({second: '2-digit'}), + 'CURRENT_SECONDS_UNIX': () => { + return Math.floor( Date.now() / 1000 ) + }, + + // NOTE: "Ancestor project path" is determined as follows: + // + // * Get all project paths via `atom.project.getPaths()`. + // * Return the first path (in the order we received) that is an ancestor of + // the current file in the editor. + + // The current file's path relative to the ancestor project path. + 'RELATIVE_FILEPATH' ({editor}) { + let filePath = editor.getPath() + let projectPaths = atom.project.getPaths() + if (projectPaths.length === 0) { return filePath } + // A project can have multiple path roots. Return whichever is the first + // that is an ancestor of the file path. + let ancestor = projectPaths.find(pp => { + return filePath.startsWith(`${pp}${path.sep}`) + }) + if (!ancestor) return {filePath} + + return filePath.substring(ancestor.length) + }, + + // Last path component of the ancestor project path. + 'WORKSPACE_NAME' ({editor}) { + let projectPaths = atom.project.getPaths() + if (projectPaths.length === 0) { return '' } + let filePath = editor.getPath() + let ancestor = projectPaths.find(pp => { + return filePath.startsWith(`${pp}${path.sep}`) + }) + + return path.basename(ancestor) + }, + + // The full path to the ancestor project path. + 'WORKSPACE_FOLDER' ({editor}) { + let projectPaths = atom.project.getPaths() + if (projectPaths.length === 0) { return '' } + let filePath = editor.getPath() + let ancestor = projectPaths.find(pp => { + return filePath.startsWith(`${pp}${path.sep}`) + }) + + return ancestor + }, + + 'CURSOR_INDEX' ({editor, cursor}) { + let cursors = editor.getCursors() + let index = cursors.indexOf(cursor) + return index >= 0 ? String(index) : '' + }, + + 'CURSOR_NUMBER' ({editor, cursor}) { + let cursors = editor.getCursors() + let index = cursors.indexOf(cursor) + return index >= 0 ? String(index + 1) : '' + }, + + 'RANDOM' () { + return Math.random().toString().slice(-6) + }, + + 'RANDOM_HEX' () { + return Math.random().toString(16).slice(-6) + } + + // TODO: VSCode also supports: + // + // BLOCK_COMMENT_START + // BLOCK_COMMENT_END + // LINE_COMMENT + // + // (grammars don't provide this information right now; see + // https://github.com/atom/atom/pull/18816) + // + // UUID + // + // (can be done without dependencies once we use Node >= 14.17.0 or >= + // 15.6.0; see below) + // +} + +// $UUID will be easy to implement once Pulsar runs a newer version of Node, so +// there's no reason not to be proactive and sniff for the function we need. +if (('randomUUID' in crypto) && (typeof crypto.randomUUID === 'function')) { + RESOLVERS['UUID'] = () => { + return crypto.randomUUID({disableEntropyCache: true}) + } +} + + +// Simple transformation flags that can convert a string in various ways. They +// are specified for variables but not for transforms, which is why this logic +// isn't included in the `Replacer` class. +const FLAGS = { + // These are included in the LSP spec. + upcase: value => (value || '').toLocaleUpperCase(), + downcase: value => (value || '').toLocaleLowerCase(), + capitalize: (value) => { + return !value ? '' : (value[0].toLocaleUpperCase() + value.substr(1)) + }, + + // These are supported by VSCode. + pascalcase: (value) => { + const match = value.match(/[a-z0-9]+/gi) + if (!match) { + return value + } + return match.map(word => { + return word.charAt(0).toUpperCase() + word.substr(1) + }).join('') + }, + camelcase: (value) => { + const match = value.match(/[a-z0-9]+/gi) + if (!match) { + return value + } + return match.map((word, index) => { + if (index === 0) { + return word.charAt(0).toLowerCase() + word.substr(1) + } + return word.charAt(0).toUpperCase() + word.substr(1) + }).join('') + } +} + +function replaceByFlag (text, flag) { + let replacer = FLAGS[flag] + if (!replacer) { return text } + return replacer(text) +} + +class Variable { + constructor ({point, snippet, variable: name, substitution}) { + Object.assign(this, {point, snippet, name, substitution}) + } + + resolve (params) { + let base = '' + if (this.name in RESOLVERS) { + base = RESOLVERS[this.name](params) + } + + if (!this.substitution) { + return base + } + + let {flag, find, replace} = this.substitution + + // Two kinds of substitution. + if (flag) { + // This is the kind with the trailing `:/upcase`, `:/downcase`, etc. + return replaceByFlag(base, flag) + } else if (find && replace) { + // This is the more complex sed-style substitution. + let {find, replace} = this.substitution + this.replacer ??= new Replacer(replace) + let matches = base.match(find) + return base.replace(find, (...args) => { + return this.replacer.replace(...args) + }) + } else { + return base + } + } +} + +module.exports = Variable diff --git a/spec/body-parser-spec.js b/spec/body-parser-spec.js index 6f048c36..41511d06 100644 --- a/spec/body-parser-spec.js +++ b/spec/body-parser-spec.js @@ -1,6 +1,455 @@ const BodyParser = require('../lib/snippet-body-parser'); +function expectMatch (input, tree) { + expect(BodyParser.parse(input)).toEqual(tree); +} + describe("Snippet Body Parser", () => { + it("parses a snippet with no special behavior", () => { + const bodyTree = BodyParser.parse('${} $ n $}1} ${/upcase/} \n world ${||}'); + expect(bodyTree).toEqual([ + '${} $ n $}1} ${/upcase/} \n world ${||}' + ]); + }); + + describe('for snippets with variables', () => { + it('parses simple variables', () => { + expectMatch('$f_o_0', [{variable: 'f_o_0'}]); + expectMatch('$_FOO', [{variable: '_FOO'}]); + }); + + it('parses verbose variables', () => { + expectMatch('${foo}', [{variable: 'foo'}]); + expectMatch('${FOO}', [{variable: 'FOO'}]); + }); + + it('parses variables with placeholders', () => { + expectMatch( + '${f:placeholder}', + [{variable: 'f', content: ['placeholder']}] + ); + + expectMatch( + '${f:foo$1 $VAR}', + [ + { + variable: 'f', + content: [ + 'foo', + {index: 1, content: []}, + ' ', + {variable: 'VAR'} + ] + } + ] + ); + + // Allows a colon as part of the placeholder value. + expectMatch( + '${TM_SELECTED_TEXT:foo:bar}', + [ + { + variable: 'TM_SELECTED_TEXT', + content: [ + 'foo:bar' + ] + } + ] + ); + }); + + it('parses simple transformations like /upcase', () => { + const bodyTree = BodyParser.parse("lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet"); + expectMatch( + "lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet", + [ + "lorem ipsum ", + { + variable: 'CLIPBOARD', + substitution: {flag: 'upcase'} + }, + " dolor sit amet" + ] + ); + }); + + it('parses variables with transforms', () => { + expectMatch('${f/.*/$0/}', [ + { + variable: 'f', + substitution: { + find: /.*/, + replace: [ + {backreference: 0} + ] + } + } + ]); + }); + }); + + + describe('for snippets with tabstops', () => { + it('parses simple tabstops', () => { + expectMatch('hello$1world$2', [ + 'hello', + {index: 1, content: []}, + 'world', + {index: 2, content: []} + ]); + }); + + it('parses verbose tabstops', () => { + expectMatch('hello${1}world${2}', [ + 'hello', + {index: 1, content: []}, + 'world', + {index: 2, content: []} + ]); + }); + + it('skips escaped tabstops', () => { + expectMatch('$1 \\$2 $3 \\\\$4 \\\\\\$5 $6', [ + {index: 1, content: []}, + ' $2 ', + {index: 3, content: []}, + ' \\', + {index: 4, content: []}, + ' \\$5 ', + {index: 6, content: []} + ]); + }); + + describe('for tabstops with placeholders', () => { + it('parses them', () => { + expectMatch('hello${1:placeholder}world', [ + 'hello', + {index: 1, content: ['placeholder']}, + 'world' + ]); + }); + + it('allows escaped back braces', () => { + expectMatch('${1:{}}', [ + {index: 1, content: ['{']}, + '}' + ]); + expectMatch('${1:{\\}}', [ + {index: 1, content: ['{}']} + ]); + }); + }); + + it('parses tabstops with transforms', () => { + expectMatch('${1/.*/$0/}', [ + { + index: 1, + content: [], + substitution: { + find: /.*/, + replace: [{backreference: 0}] + } + } + ]); + }); + + it('parses tabstops with choices', () => { + expectMatch('${1|on}e,t\\|wo,th\\,ree|}', [ + {index: 1, content: ['on}e'], choice: ['on}e', 't|wo', 'th,ree']} + ]); + }); + + it('parses if-else syntax', () => { + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:+hey}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "hey", + elsetext: "" + } + ], + }, + }, + ] + ); + + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:?hey:nah}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "hey", + elsetext: "nah" + } + ], + }, + }, + ] + ); + + // else with `:` syntax + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:fallback}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "", + elsetext: "fallback" + } + ], + }, + }, + ] + ); + + + // else with `:-` syntax; should be same as above + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:-fallback}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "", + elsetext: "fallback" + } + ], + }, + }, + ] + ); + + }); + + it('parses alternative if-else syntax', () => { + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/(?1:hey:)/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: ["hey"], + elsetext: "" + } + ], + }, + }, + ] + ); + + + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/(?1:\\u$1:)/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: [ + {escape: 'u'}, + {backreference: 1} + ], + elsetext: "" + } + ], + }, + }, + ] + ); + + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/(?1::hey)/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "", + elsetext: ["hey"] + } + ], + }, + }, + ] + ); + + expectMatch( + 'class ${1:${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}} < ${2:Application}Controller\n $3\nend', + [ + 'class ', + { + index: 1, + content: [ + { + variable: 'TM_FILENAME', + substitution: { + find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g, + replace: [ + { + backreference: 2, + iftext: '', + elsetext: [ + {escape: 'u'}, + {backreference: 1} + ] + } + ] + } + } + ] + }, + ' < ', + { + index: 2, + content: ['Application'] + }, + 'Controller\n ', + {index: 3, content : []}, + '\nend' + ] + ); + }); + + it('recognizes escape characters in if/else syntax', () => { + + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:?hey\\:hey:nah}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "hey:hey", + elsetext: "nah" + } + ], + }, + }, + ] + ); + + expectMatch( + '$1 ${1/(?:(wat)|^.*$)$/${1:?hey:n\\}ah}/}', + [ + {index: 1, content: []}, + " ", + { + index: 1, + content: [], + substitution: { + find: /(?:(wat)|^.*$)$/, + replace: [ + { + backreference: 1, + iftext: "hey", + elsetext: "n}ah" + } + ], + }, + }, + ] + ); + + }); + + + it('parses nested tabstops', () => { + expectMatch( + '${1:place${2:hol${3:der}}}', + [ + { + index: 1, + content: [ + 'place', + {index: 2, content: [ + 'hol', + {index: 3, content: ['der']} + ]} + ] + } + ] + ); + + expectMatch( + '${1:${foo:${1}}}', + [ + { + index: 1, + content: [ + { + variable: 'foo', + content: [ + { + index: 1, + content: [] + } + ] + } + ] + } + ] + ); + }); + }); + + 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} @@ -26,15 +475,30 @@ the quick brown $1fox \${2:jumped \${3:over} ]); }); - 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::", ""] - } - ]); + + it('handles a snippet with a transformed variable', () => { + expectMatch( + 'module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/\\u$1/g}}', + [ + 'module ', + { + index: 1, + content: [ + 'ActiveRecord::', + { + variable: 'TM_FILENAME', + substitution: { + find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g, + replace: [ + {escape: 'u'}, + {backreference: 1} + ] + } + } + ] + } + ] + ); }); it("skips escaped tabstops", () => { @@ -150,7 +614,7 @@ the quick brown $1fox \${2:jumped \${3:over} }); it("parses a snippet with a format string and case-control flags", () => { - const bodyTree = BodyParser.parse("<${1:p}>$0${1/(.)(.*)/\\u$1$2/}>"); + const bodyTree = BodyParser.parse("<${1:p}>$0${1/(.)(.*)/\\u$1$2/g}>"); expect(bodyTree).toEqual([ '<', {index: 1, content: ['p']}, @@ -161,7 +625,7 @@ the quick brown $1fox \${2:jumped \${3:over} index: 1, content: [], substitution: { - find: /(.)(.*)/, + find: /(.)(.*)/g, replace: [ {escape: 'u'}, {backreference: 1}, @@ -176,7 +640,7 @@ the quick brown $1fox \${2:jumped \${3:over} 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${1/(.)\\/(.*)/\\u$1$2/}>"); + const bodyTree = BodyParser.parse("<${1:p}>$0${1/(.)\\/(.*)/\\u$1$2/g}>"); expect(bodyTree).toEqual([ '<', {index: 1, content: ['p']}, @@ -187,7 +651,7 @@ the quick brown $1fox \${2:jumped \${3:over} index: 1, content: [], substitution: { - find: /(.)\/(.*)/, + find: /(.)\/(.*)/g, replace: [ {escape: 'u'}, {backreference: 1}, diff --git a/spec/snippets-spec.js b/spec/snippets-spec.js index 0da6e4cd..8811cea1 100644 --- a/spec/snippets-spec.js +++ b/spec/snippets-spec.js @@ -302,6 +302,38 @@ third tabstop $3\ "banner with globalFlag": { prefix: "bannerCorrect", body: "// $1\n// ${1/./=/g}" + }, + 'TM iftext but no elsetext': { + prefix: 'ifelse1', + body: '$1 ${1/(wat)/(?1:hey:)/}' + }, + 'TM elsetext but no iftext': { + prefix: 'ifelse2', + body: '$1 ${1/(?:(wat)|^.*$)$/(?1::hey)/}' + }, + 'TM both iftext and elsetext': { + prefix: 'ifelse3', + body: '$1 ${1/^\\w+\\s(?:(wat)|\\w*?)$/(?1:Y:N)/}' + }, + 'VS iftext but no elsetext': { + prefix: 'vsifelse1', + body: '$1 ${1/(?:(wat)|^.*?$)/${1:+WAT}/}' + }, + 'VS elsetext but no iftext': { + prefix: 'vsifelse2', + body: '$1 ${1/(?:(wat)|^.*?$)/${1:-nah}/}' + }, + 'VS elsetext but no iftext (alt)': { + prefix: 'vsifelse2a', + body: '$1 ${1/(?:(wat)|^.*?$)/${1:nah}/}' + }, + 'VS both iftext and elsetext': { + prefix: 'vsifelse3', + body: '$1 ${1/(?:(wat)|^.*?$)/${1:?WAT:nah}/}' + }, + 'choice syntax': { + prefix: 'choice', + body: '${1|one, two, three|}' } } }); @@ -948,6 +980,141 @@ foo\ }); }); + describe("when the snippet contains a tab stop with choices", () => { + it("uses the first option as the placeholder", () => { + editor.setText(''); + editor.insertText('choice'); + simulateTabKeyEvent(); + + expect(editor.getText()).toBe('one'); + }); + }); + + describe("when the snippet contains VSCode-style if-else syntax", () => { + + it('understands if but no else', () => { + editor.setText(''); + editor.insertText('vsifelse1'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat WAT'); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('vsifelse1'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo '); + }); + + it('understands else but no if', () => { + editor.setText(''); + editor.insertText('vsifelse2'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat '); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('vsifelse2'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo nah'); + simulateTabKeyEvent(); + + // There are two syntaxes for this. + editor.setText(''); + editor.insertText('vsifelse2a'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat '); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('vsifelse2a'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo nah'); + }); + + it('understands both if and else', () => { + editor.setText(''); + editor.insertText('vsifelse3'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat WAT'); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('vsifelse3'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo nah'); + }); + }); + + describe("when the snippet contains TextMate-style if-else syntax", () => { + + it('understands if but no else', () => { + editor.setText(''); + editor.insertText('ifelse1'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat hey'); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('ifelse1'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo foo'); + }); + + it('understands else but no if', () => { + editor.setText(''); + editor.insertText('ifelse2'); + simulateTabKeyEvent(); + + editor.insertText('wat'); + expect(editor.getText()).toEqual('wat '); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('ifelse2'); + simulateTabKeyEvent(); + + editor.insertText('foo'); + expect(editor.getText()).toEqual('foo hey'); + }); + + it('understands both if and else', () => { + editor.setText(''); + editor.insertText('ifelse3'); + simulateTabKeyEvent(); + + editor.insertText('something wat'); + expect(editor.getText()).toEqual('something wat Y'); + simulateTabKeyEvent(); + + editor.setText(''); + editor.insertText('ifelse3'); + simulateTabKeyEvent(); + + editor.insertText('something foo'); + expect(editor.getText()).toEqual('something foo N'); + }); + }); + describe("when the snippet 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", () => { it("terminates the snippet upon such a cursor move", () => { editor.setText('t18'); @@ -1358,6 +1525,235 @@ foo\ }); }); + describe("when a snippet contains variables", () => { + + beforeEach(() => { + atom.grammars.assignLanguageMode(editor, 'source.js'); + Snippets.add( + __filename, { + ".source.js": { + "Uses TM_SELECTED_TEXT": { + body: 'lorem ipsum $TM_SELECTED_TEXT dolor sit amet', + command: 'test-command-tm-selected-text', + prefix: 'tmSelectedText' + }, + "Uses CLIPBOARD": { + body: 'lorem ipsum $CLIPBOARD dolor sit amet', + command: 'test-command-clipboard' + }, + "Transforms CLIPBOARD removing digits": { + body: 'lorem ipsum ${CLIPBOARD/\\d//g} dolor sit amet', + command: 'test-command-clipboard-transformed' + }, + "Transforms CLIPBOARD with casing flags": { + body: 'lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet\n${CLIPBOARD:/downcase}\n${CLIPBOARD:/camelcase}\n${CLIPBOARD:/pascalcase}\n${CLIPBOARD:/capitalize}', + command: 'test-command-clipboard-upcased' + }, + "Transforms day, month, year": { + body: 'Today is $CURRENT_MONTH $CURRENT_DATE, $CURRENT_YEAR', + command: 'test-command-date' + }, + "Transforms line numbers": { + prefix: 'ln', + body: 'line is $TM_LINE_NUMBER and index is $TM_LINE_INDEX' + }, + "Transforms workspace name": { + prefix: 'wn', + body: 'the name of this project is $WORKSPACE_NAME' + }, + "Gives random value": { + prefix: 'rndm', + body: 'random number is:\n$RANDOM' + }, + "Gives random hex vallue": { + prefix: 'rndmhex', + body: 'random hex is:\n$RANDOM_HEX' + }, + "Gives random UUID": { + prefix: 'rndmuuid', + body: 'random UUID is:\n$UUID' + }, + "Gives file paths": { + prefix: 'fpath', + body: 'file paths:\n$TM_FILEPATH\n$TM_FILENAME\n$TM_FILENAME_BASE' + }, + }, + ".text.html": { + "wrap in tag": { + "command": "wrap-in-html-tag", + "body": "<${1:div}>${2:$TM_SELECTED_TEXT}${1/[ ]+.*$//}>$0" + } + } + }, + 'test-package' + ); + + editor.setText(''); + }); + + it("interpolates the variables into the snippet expansion", () => { + editor.insertText('(selected text)'); + editor.selectToBeginningOfLine(); + + expect(editor.getSelectedText()).toBe('(selected text)'); + atom.commands.dispatch(editor.element, 'test-package:test-command-tm-selected-text'); + expect(editor.getText()).toBe('lorem ipsum (selected text) dolor sit amet'); + }); + + it("does not consider the tab trigger to be part of $TM_SELECTED_TEXT when a snippet is invoked via tab trigger", () => { + editor.insertText('tmSelectedText'); + simulateTabKeyEvent(); + + expect(editor.getText()).toBe('lorem ipsum dolor sit amet'); + }); + + it("interpolates line number variables correctly", () => { + editor.insertText('ln'); + simulateTabKeyEvent(); + expect(editor.getText()).toBe('line is 1 and index is 0'); + editor.setText(''); + editor.insertText("\n\n\nln"); + simulateTabKeyEvent(); + let cursor = editor.getLastCursor(); + let lineText = editor.lineTextForBufferRow(cursor.getBufferRow()); + expect(lineText).toBe('line is 4 and index is 3'); + }); + + it("interpolates WORKSPACE_NAME correctly", () => { + editor.insertText('wn'); + simulateTabKeyEvent(); + expect(editor.getText()).toBe('the name of this project is fixtures'); + }); + + it("interpolates date variables correctly", () => { + function pad (val) { + let str = String(val); + return str.length === 1 ? `0${str}` : str; + } + let now = new Date(); + let month = pad(now.getMonth() + 1); + let day = pad(now.getDate()); + let year = now.getFullYear(); + + let expected = `Today is ${month} ${day}, ${year}`; + + atom.commands.dispatch(editor.element, 'test-package:test-command-date'); + expect(editor.getText()).toBe(expected); + }); + + it("interpolates a CLIPBOARD variable into the snippet expansion", () => { + atom.clipboard.write('(clipboard text)'); + atom.commands.dispatch(editor.element, 'test-package:test-command-clipboard'); + expect(editor.getText()).toBe('lorem ipsum (clipboard text) dolor sit amet'); + }); + + it("interpolates a transformed variable into the snippet expansion", () => { + atom.clipboard.write('(clipboard 19283 text)'); + atom.commands.dispatch(editor.element, 'test-package:test-command-clipboard-transformed'); + expect(editor.getText()).toBe('lorem ipsum (clipboard text) dolor sit amet'); + }); + + it("interpolates an upcased variable", () => { + atom.clipboard.write('(clipboard Text is Multiple words)'); + atom.commands.dispatch(editor.element, 'test-package:test-command-clipboard-upcased'); + expect(editor.lineTextForBufferRow(0)).toBe('lorem ipsum (CLIPBOARD TEXT IS MULTIPLE WORDS) dolor sit amet'); + expect(editor.lineTextForBufferRow(1)).toBe('(clipboard text is multiple words)'); + expect(editor.lineTextForBufferRow(2)).toBe('clipboardTextIsMultipleWords'); + expect(editor.lineTextForBufferRow(3)).toBe('ClipboardTextIsMultipleWords'); + // The /capitalize flag will only uppercase the first character, so none + // of this clipboard value will be changed. + expect(editor.lineTextForBufferRow(4)).toBe('(clipboard Text is Multiple words)'); + }); + + it("interpolates file path variables", () => { + editor.insertText('fpath'); + simulateTabKeyEvent(); + let filePath = editor.getPath(); + + expect(editor.lineTextForBufferRow(0)).toEqual("file paths:"); + expect(editor.lineTextForBufferRow(1)).toEqual(filePath); + expect(editor.lineTextForBufferRow(2)).toEqual('sample.js'); + expect(editor.lineTextForBufferRow(3)).toEqual('sample'); + }); + + it("generates truly random values for RANDOM, RANDOM_HEX, and UUID", () => { + let reUUID = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/; + let reRandom = /^\d{6}$/; + let reRandomHex = /^[0-9a-f]{6}$/; + + editor.insertText('rndm'); + simulateTabKeyEvent(); + expect(editor.lineTextForBufferRow(0)).toEqual("random number is:"); + let randomFirst = editor.lineTextForBufferRow(1); + expect(reRandom.test(randomFirst)).toBe(true); + + editor.setText(''); + editor.insertText('rndm'); + simulateTabKeyEvent(); + + let randomSecond = editor.lineTextForBufferRow(1); + expect(reRandom.test(randomSecond)).toBe(true); + expect(randomSecond).not.toEqual(randomFirst); + + editor.setText(''); + editor.insertText('rndmhex'); + simulateTabKeyEvent(); + let randomHex1 = editor.lineTextForBufferRow(1); + expect(reRandomHex.test(randomHex1)).toBe(true); + + editor.setText(''); + editor.insertText('rndmhex'); + simulateTabKeyEvent(); + let randomHex2 = editor.lineTextForBufferRow(1); + expect(reRandomHex.test(randomHex2)).toBe(true); + expect(randomHex2).not.toEqual(randomHex1); + + // TODO: These tests are commented out because we won't support UUID + // until we use a version of Node that implements `crypto.randomUUID`. + // It's not crucial enough to require a new external dependency in the + // meantime. + + // editor.setText(''); + // editor.insertText('rndmuuid'); + // simulateTabKeyEvent(); + // let randomUUID1 = editor.lineTextForBufferRow(1); + // expect(reUUID.test(randomUUID1)).toBe(true); + // + // editor.setText(''); + // editor.insertText('rndmuuid'); + // simulateTabKeyEvent(); + // let randomUUID2 = editor.lineTextForBufferRow(1); + // expect(reUUID.test(randomUUID2)).toBe(true); + // expect(randomUUID2).not.toEqual(randomUUID1); + }); + + describe("and the command is invoked in an HTML document", () => { + beforeEach(() => { + atom.grammars.assignLanguageMode(editor, 'text.html.basic'); + editor.setText(''); + }); + + it("combines transformations and variable references", () => { + editor.insertText('lorem'); + editor.selectToBeginningOfLine(); + + atom.commands.dispatch(editor.element, 'test-package:wrap-in-html-tag'); + + expect(editor.getText()).toBe( + `