diff --git a/lib/editor-store.js b/lib/editor-store.js index c57cb7ad..f338a3b8 100644 --- a/lib/editor-store.js +++ b/lib/editor-store.js @@ -5,7 +5,6 @@ class EditorStore { this.editor = editor this.buffer = this.editor.getBuffer() this.observer = null - this.checkpoint = null this.expansions = [] this.existingHistoryProvider = null } @@ -52,14 +51,6 @@ class EditorStore { this.observer = null return true } - - makeCheckpoint () { - const existing = this.checkpoint - if (existing) { - this.buffer.groupChangesSinceCheckpoint(existing) - } - this.checkpoint = this.buffer.createCheckpoint() - } } EditorStore.store = new WeakMap() diff --git a/lib/helpers.js b/lib/helpers.js index 0814a3df..647b54f5 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,8 +1,6 @@ -/** @babel */ +const path = require('path') -import path from 'path' - -export function getPackageRoot() { +function getPackageRoot() { const {resourcePath} = atom.getLoadSettings() const currentFileWasRequiredFromSnapshot = !path.isAbsolute(__dirname) if (currentFileWasRequiredFromSnapshot) { @@ -11,3 +9,5 @@ export function getPackageRoot() { return path.resolve(__dirname, '..') } } + +module.exports = {getPackageRoot} diff --git a/lib/insertion.js b/lib/insertion.js index 96065d1e..0519d783 100644 --- a/lib/insertion.js +++ b/lib/insertion.js @@ -1,93 +1,27 @@ -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 {transformWithSubstitution} = require('./util') class Insertion { - constructor ({ range, substitution }) { + constructor ({range, substitution, references, choices=[], transformResolver}) { this.range = range this.substitution = substitution - if (substitution) { - if (substitution.replace === undefined) { - substitution.replace = '' - } - this.replacer = this.makeReplacer(substitution.replace) + this.references = references + if (substitution && substitution.replace === undefined) { + substitution.replace = '' } + this.choices = choices + this.transformResolver = transformResolver } isTransformation () { 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('') - } + isChoice () { + return this.choices.length > 0 } transform (input) { - let { substitution } = this - if (!substitution) { return input } - return input.replace(substitution.find, this.replacer) + return transformWithSubstitution(input, this.substitution, this.transformResolver) } } diff --git a/lib/resolvers.js b/lib/resolvers.js new file mode 100644 index 00000000..4556f45d --- /dev/null +++ b/lib/resolvers.js @@ -0,0 +1,223 @@ +const path = require('path') + + +class ValueResolver { + constructor (resolvers = new Map) { + this.resolvers = resolvers + } + + add (varName, resolver) { + this.resolvers.set(varName, resolver) + } + + /* + Params depend on context. VariableResolver can expect the following, but TransformResolver will likely get a restricted number + (VariableResolver) params = { + variable: the variable name this was called with + editor: the TextEditor we are expanding in + cursor: the cursor we are expanding from + indent: the indent of the original cursor line. Automatically applied to all text (post variable transformation) + selectionRange: the original selection range of the cursor. This has been modified on the actual cursor to now select the prefix + startPosition: the cursor selection start position, after being adjusted to select the prefix + row: the row the start of the variable will be inserted on (final) <- final for snippet body creation. Does not account for changes when the user starts typing + column: the column the start of the variable will be inserted on (final; accounts for indent) + } + + (TransformResolver) params = { + input: the text to be transformed + transform: the transform this was called with + } + */ + resolve (name, params) { + const resolver = this.resolvers.get(name) + if (resolver) { + return { + hasResolver: true, + value: resolver(params) + } + } + return { + hasResolver: false, + value: undefined + } + } +} + +class VariableResolver extends ValueResolver { + constructor (resolvers = new Map) { + super(new Map([ + ['CLIPBOARD', resolveClipboard], + + ['TM_SELECTED_TEXT', resolveSelected], + ['TM_CURRENT_LINE', resolveCurrentLine], + ['TM_CURRENT_WORD', resolveCurrentWord], + ['TM_LINE_INDEX', resolveLineIndex], + ['TM_LINE_NUMBER', resolveLineNumber], + ['TM_FILENAME', resolveFileName], + ['TM_FILENAME_BASE', resolveFileNameBase], + ['TM_DIRECTORY', resolveFileDirectory], + ['TM_FILEPATH', resolveFilePath], + + ['CURRENT_YEAR', resolveYear], + ['CURRENT_YEAR_SHORT', resolveYearShort], + ['CURRENT_MONTH', resolveMonth], + ['CURRENT_MONTH_NAME', resolveMonthName], + ['CURRENT_MONTH_NAME_SHORT', resolveMonthNameShort], + ['CURRENT_DATE', resolveDate], + ['CURRENT_DAY_NAME', resolveDayName], + ['CURRENT_DAY_NAME_SHORT', resolveDayNameShort], + ['CURRENT_HOUR', resolveHour], + ['CURRENT_MINUTE', resolveMinute], + ['CURRENT_SECOND', resolveSecond], + + ['BLOCK_COMMENT_START', resolveBlockCommentStart], + ['BLOCK_COMMENT_END', resolveBlockCommentEnd], + ['LINE_COMMENT', resolveLineComment], + + ...resolvers + ])) + } +} + +function resolveClipboard () { + return atom.clipboard.read() +} + +function resolveSelected ({editor, selectionRange}) { + if (!selectionRange || selectionRange.isEmpty()) return undefined + return editor.getTextInBufferRange(selectionRange) +} + +function resolveCurrentLine ({editor, cursor}) { + return editor.lineTextForBufferRow(cursor.getBufferRow()) +} + +function resolveCurrentWord ({editor, cursor}) { + return editor.getTextInBufferRange(cursor.getCurrentWordBufferRange()) +} + +function resolveLineIndex ({cursor}) { + return `${cursor.getBufferRow()}` +} + +function resolveLineNumber ({cursor}) { + return `${cursor.getBufferRow() + 1}` +} + +function resolveFileName ({editor}) { + return editor.getTitle() +} + +function resolveFileNameBase ({editor}) { + const fileName = resolveFileName({editor}) + if (!fileName) { return undefined } + + const index = fileName.lastIndexOf('.') + if (index >= 0) { + return fileName.slice(0, index) + } + + return fileName +} + +function resolveFileDirectory ({editor}) { + const filePath = editor.getPath() + if (filePath === undefined) return undefined + return path.dirname(filePath) +} + +function resolveFilePath ({editor}) { + return editor.getPath() +} + + +// TODO: Use correct locale +function resolveYear () { + return new Date().toLocaleString('en-us', {year: 'numeric'}) +} + +function resolveYearShort () { // last two digits of year + return new Date().toLocaleString('en-us', {year: '2-digit'}) +} + +function resolveMonth () { + return new Date().toLocaleString('en-us', {month: '2-digit'}) +} + +function resolveMonthName () { + return new Date().toLocaleString('en-us', {month: 'long'}) +} + +function resolveMonthNameShort () { + return new Date().toLocaleString('en-us', {month: 'short'}) +} + +function resolveDate () { + return new Date().toLocaleString('en-us', {day: '2-digit'}) +} + +function resolveDayName () { + return new Date().toLocaleString('en-us', {weekday: 'long'}) +} + +function resolveDayNameShort () { + return new Date().toLocaleString('en-us', {weekday: 'short'}) +} + +function resolveHour () { + return new Date().toLocaleString('en-us', {hour12: false, hour: '2-digit'}) +} + +function resolveMinute () { + return new Date().toLocaleString('en-us', {minute: '2-digit'}) +} + +function resolveSecond () { + return new Date().toLocaleString('en-us', {second: '2-digit'}) +} + +// TODO: wait for https://github.com/atom/atom/issues/18812 +// Could make a start with what we have; one of the two should be available +function getEditorCommentStringsForPoint (_editor, _point) { + return {line: '//', start: '/*', end: '*/'} +} + +function resolveBlockCommentStart ({editor, cursor}) { + const delims = getEditorCommentStringsForPoint(editor, cursor.getBufferPosition()) + return delims.start +} + +function resolveBlockCommentEnd ({editor, cursor}) { + const delims = getEditorCommentStringsForPoint(editor, cursor.getBufferPosition()) + return delims.end +} + +function resolveLineComment ({editor, cursor}) { + const delims = getEditorCommentStringsForPoint(editor, cursor.getBufferPosition()) + return delims.line +} + +class TransformResolver extends ValueResolver { + constructor (resolvers = new Map) { + super(new Map([ + ['upcase', transformUpcase], + ['downcase', transformDowncase], + ['capitalize', transformCapitalize], + ...resolvers + ])) + } +} + +function transformUpcase ({input}) { + return input.toLocaleUpperCase() +} + +function transformDowncase ({input}) { + return input.toLocaleLowerCase() +} + +function transformCapitalize ({input}) { + return input ? input[0].toLocaleUpperCase() + input.substr(1) : '' +} + +module.exports = { ValueResolver, VariableResolver, TransformResolver } diff --git a/lib/snippet-body.pegjs b/lib/snippet-body.pegjs index 476c65af..cc54819f 100644 --- a/lib/snippet-body.pegjs +++ b/lib/snippet-body.pegjs @@ -1,82 +1,172 @@ { - // 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); - }, []); + function makeInteger(i) { + return parseInt(i.join(''), 10); } } -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; } + +tabstop = simpleTabstop / tabstopWithoutPlaceholder / tabstopWithPlaceholder / tabstopWithTransform -simpleTabStop = '$' index:[0-9]+ { - return { index: parseInt(index.join("")), content: [] }; +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 - }; + } } -placeholderContent = content:(tabStop / placeholderContentText / variable )* { return flatten(content); } -placeholderContentText = text:placeholderContentChar+ { return coalesce(text); } -placeholderContentChar = escaped / placeholderVariableReference / !tabStop !variable char:[^}] { return char; } +choice = '${' index:int '|' choice:choicecontents '|}' { + 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('') +} -placeholderVariableReference = '$' digit:[0-9]+ { - return { index: parseInt(digit.join(""), 10), content: [] }; +transform = '/' regex:regexString '/' replace:replace '/' flags:flags { + return {find: new RegExp(regex, flags), replace: replace} } -variable = '${' variableContent '}' { - return ''; // we eat variables and do nothing with them for now +regexString = regex:(escaped / [^/])* { + return regex.join('') } -variableContent = content:(variable / variableContentText)* { return content; } -variableContentText = text:variableContentChar+ { return text.join(''); } -variableContentChar = !variable char:('\\}' / [^}]) { return char; } -escapedForwardSlash = pair:'\\/' { return pair; } +replace = (format / replacetext)* + +format = simpleFormat / formatWithoutPlaceholder / formatWithCaseTransform / formatWithIf / formatWithIfElse / formatWithElse / formatEscape / formatWithIfElseAlt / formatWithIfAlt + +simpleFormat = '$' index:int { + return {backreference: makeInteger(index)} +} -// 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] }; +formatWithoutPlaceholder = '${' index:int '}' { + return {backreference: makeInteger(index)} } -formatString = content:(formatStringEscape / formatStringReference / escapedForwardSlash / [^/])+ { - return content; +formatWithCaseTransform = '${' index:int ':' caseTransform:caseTransform '}' { + return {backreference: makeInteger(index), transform: caseTransform} } -// 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 }; + +formatWithIf = '${' index:int ':+' iftext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), iftext: iftext} +} + +formatWithIfAlt = '(?' index:int ':' iftext:(ifElseTextAlt / '') ')' { + return {backreference: makeInteger(index), iftext: iftext} +} + +formatWithElse = '${' index:int (':-' / ':') elsetext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), elsetext: elsetext} +} + +formatWithIfElse = '${' index:int ':?' iftext:nonColonText ':' elsetext:(ifElseText / '') '}' { + return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext} +} + +formatWithIfElseAlt = '(?' index:int ':' iftext:nonColonText ':' elsetext:(ifElseTextAlt / '') ')' { + 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 / 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} +} + +variableName = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* { + return first + rest.join('') +} + +int = [0-9]+ + +escaped = '\\' char:. { + switch (char) { + 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('') +} + +text = text:(escaped / !tabstop !variable !choice char:. { return char })+ { + return text.join('') +} + +nonCloseBraceText = text:(escaped / !tabstop !variable !choice char:[^}] { return char })+ { + return text.join('') +} + +ifElseText = text:(escaped / char:[^}] { return char })+ { + return text.join('') +} + +ifElseTextAlt = text:(escaped / char:[^)] { return char })+ { + return text.join('') } diff --git a/lib/snippet-expansion.js b/lib/snippet-expansion.js index 54a525bc..928b2ea9 100644 --- a/lib/snippet-expansion.js +++ b/lib/snippet-expansion.js @@ -1,43 +1,59 @@ const {CompositeDisposable, Range, Point} = require('atom') +const {getEndpointOfText} = require('./util') module.exports = class SnippetExpansion { - constructor(snippet, editor, cursor, snippets) { + constructor (snippet, editor, cursor, oldSelectionRange, snippets) { this.settingTabStop = false this.isIgnoringBufferChanges = false this.onUndoOrRedo = this.onUndoOrRedo.bind(this) + this.isUndoingOrRedoing = false this.snippet = snippet this.editor = editor this.cursor = cursor this.snippets = snippets this.subscriptions = new CompositeDisposable - this.tabStopMarkers = [] + this.insertionsByIndex = [] + this.markersForInsertions = new Map() + + // The index of the active tab stop. 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 visited. So `$1` will always be index `0` in the + // above list, and `$0` (if present) will always be the last index. + this.tabStopIndex = null + + // If, say, tab stop 4's placeholder references tab stop 2, then tab stop + // 4's insertion goes into this map as a "related" insertion to tab stop 2. + // We need to keep track of this because tab stop 4's marker will need to be + // replaced while 2 is the active index. + this.relatedInsertionsByIndex = new Map() + this.selections = [this.cursor.selection] const startPosition = this.cursor.selection.getBufferRange().start - let {body, tabStopList} = this.snippet - let tabStops = tabStopList.toArray() - - let indent = this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0] - if (this.snippet.lineCount > 1 && indent) { - // Add proper leading indentation to the snippet - body = body.replace(/\n/g, `\n${indent}`) - - tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent)) - } - - this.editor.transact(() => { - this.ignoringBufferChanges(() => { - this.editor.transact(() => { - const newRange = this.cursor.selection.insertText(body, {autoIndent: false}) - if (this.snippet.tabStopList.length > 0) { - 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) - } - }) - }) + + const {body, tabStopList} = this.snippet.toString({ + editor: this.editor, + cursor: this.cursor, + indent: this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0], + selectionRange: oldSelectionRange, // used by variable resolver + startPosition: startPosition + }) + + this.tabStopList = tabStopList + + const tabStops = this.tabStopList.toArray() + this.ignoringBufferChanges(() => { + // Insert the snippet body at the cursor. + const newRange = this.cursor.selection.insertText(body, {autoIndent: false}) + if (this.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(tabStops) + this.snippets.addExpansion(this.editor, this) + this.editor.normalizeTabsInBufferRange(newRange) + } }) } @@ -45,18 +61,42 @@ module.exports = class SnippetExpansion { // They're already accounted for in the history. onUndoOrRedo (isUndo) { this.isUndoingOrRedoing = true + this.isUndo = isUndo } cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) { - if (this.settingTabStop || textChanged) { return } - const itemWithCursor = this.tabStopMarkers[this.tabStopIndex].find(item => item.marker.getBufferRange().containsPoint(newBufferPosition)) + if (this.settingTabStop || (textChanged && !this.isUndoingOrRedoing)) { return } - if (itemWithCursor && !itemWithCursor.insertion.isTransformation()) { return } + const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find((insertion) => { + let marker = this.markersForInsertions.get(insertion) + return marker.getBufferRange().containsPoint(newBufferPosition) + }) + + if (insertionAtCursor && !insertionAtCursor.isTransformation()) { + // The cursor is still inside an insertion. Return so that the snippet doesn't get destroyed. + return + } + + // we get here if there is no item for the current index with the cursor + if (this.isUndoingOrRedoing) { + if (this.isUndo) { + this.goToPreviousTabStop() + } else { + this.goToNextTabStop() + } + return + } this.destroy() + this.snippets.destroyExpansions(this.editor) } - cursorDestroyed () { if (!this.settingTabStop) { this.destroy() } } + cursorDestroyed () { + if (!this.settingTabStop) { + this.destroy() + this.snippets.destroyExpansions(this.editor) + } + } textChanged (event) { if (this.isIgnoringBufferChanges) { return } @@ -79,116 +119,220 @@ module.exports = class SnippetExpansion { } applyAllTransformations () { - this.editor.transact(() => { - this.tabStopMarkers.forEach((item, index) => - this.applyTransformations(index, true)) - }) + this.insertionsByIndex.forEach((_, index) => this.applyTransformations(index)) } - applyTransformations (tabStop, initial = false) { - const items = [...this.tabStopMarkers[tabStop]] - if (items.length === 0) { return } + applyTransformations (tabStop) { + const insertions = [...this.insertionsByIndex[tabStop]] + if (insertions.length === 0) { return } - const primary = items.shift() - const primaryRange = primary.marker.getBufferRange() + const primaryInsertion = insertions.shift() + const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange() const inputText = this.editor.getTextInBufferRange(primaryRange) this.ignoringBufferChanges(() => { - for (const item of items) { - const {marker, insertion} = item - var range = marker.getBufferRange() - + for (const insertion of insertions) { // Don't transform mirrored tab stops. They have their own cursors, so // mirroring happens automatically. if (!insertion.isTransformation()) { continue } + let marker = this.markersForInsertions.get(insertion) + let range = marker.getBufferRange() + var outputText = insertion.transform(inputText) - this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText)) + + this.editor.setTextInBufferRange(range, outputText) + // this.editor.buffer.groupLastChanges() + + // Manually adjust the marker's range rather than rely on its internal + // heuristics. (We don't have to worry about whether it's been + // invalidated because setting its buffer range implicitly marks it as + // valid again.) const newRange = new Range( range.start, - range.start.traverse(new Point(0, outputText.length)) + range.start.traverse(getEndpointOfText(outputText)) ) marker.setBufferRange(newRange) } }) } - placeTabStopMarkers (startPosition, tabStops) { + placeTabStopMarkers (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 + // we renumber them starting at 0 and using consecutive numbers. + // + // Luckily, we don't need to convert between the two numbering systems very + // often. But we do have to build a map from external index to our internal + // index. We do this in a separate loop so that the table is complete before + // we need to consult it in the following loop. + let indexTable = {} + Object.keys(tabStops).forEach((key, index) => { + let tabStop = tabStops[key] + indexTable[tabStop.index] = index + }) + const markerLayer = this.getMarkerLayer(this.editor) + + let tabStopIndex = -1 for (const tabStop of tabStops) { + tabStopIndex++ const {insertions} = tabStop - const markers = [] - if (!tabStop.isValid()) { continue } for (const insertion of insertions) { - const {range} = insertion - const {start, end} = range - const marker = this.getMarkerLayer(this.editor).markBufferRange([ - startPosition.traverse(start), - startPosition.traverse(end) - ]) - markers.push({ - index: markers.length, - marker, - insertion - }) + const {range: {start, end}} = insertion + let references = null + if (insertion.references) { + references = insertion.references.map(external => indexTable[external]) + } + // Since this method is only called once at the beginning of a snippet + // expansion, we know that 0 is about to be the active tab stop. + let shouldBeInclusive = (tabStopIndex === 0) || (references && references.includes(0)) + + const marker = markerLayer.markBufferRange(insertion.range, {exclusive: !shouldBeInclusive}) + this.markersForInsertions.set(insertion, marker) + if (references) { + let relatedInsertions = this.relatedInsertionsByIndex.get(tabStopIndex) || [] + relatedInsertions.push(insertion) + this.relatedInsertionsByIndex.set(tabStopIndex, relatedInsertions) + } } - this.tabStopMarkers.push(markers) + // Since we have to replace markers in place when we change their + // exclusivity, we'll store them in a map keyed on the insertion itself. + this.insertionsByIndex[tabStopIndex] = insertions } this.setTabStopIndex(0) this.applyAllTransformations() } + // When two insertion markers are directly adjacent to one another, and the + // cursor is placed right at the border between them, the marker that should + // "claim" the newly-typed content will vary based on context. + // + // All else being equal, that content should get added to the marker (if any) + // whose tab stop is active (or the marker whose tab stop's placeholder + // references an active tab stop). The `exclusive` setting controls whether a + // marker grows to include content added at its edge. + // + // 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 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. + let insertionsForNewIndex = [ + ...this.insertionsByIndex[newIndex], + ...(this.relatedInsertionsByIndex.get(newIndex) || []) + ] + + for (let insertion of insertionsForNewIndex) { + this.replaceMarkerForInsertion(insertion, {exclusive: false}) + } + + // Take all the insertions whose markers were made inclusive when they + // became active and restore their original marker settings. + let insertionsForOldIndex = [ + ...this.insertionsByIndex[oldIndex], + ...(this.relatedInsertionsByIndex.get(oldIndex) || []) + ] + + for (let insertion of insertionsForOldIndex) { + this.replaceMarkerForInsertion(insertion, {exclusive: true}) + } + } + + replaceMarkerForInsertion (insertion, settings) { + let marker = this.markersForInsertions.get(insertion) + + // If the marker is invalid or destroyed, return it as-is. Other methods + // need to know if a marker has been invalidated or destroyed, and there's + // no case in which we'd need to change the settings on such a marker + // anyway. + if (!marker.isValid() || marker.isDestroyed()) { + return marker + } + + // Otherwise, create a new marker with an identical range and the specified + // settings. + let range = marker.getBufferRange() + let replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings) + + marker.destroy() + this.markersForInsertions.set(insertion, replacement) + return replacement + } + goToNextTabStop () { const nextIndex = this.tabStopIndex + 1 - if (nextIndex < this.tabStopMarkers.length) { - if (this.setTabStopIndex(nextIndex)) { - return true - } else { - return this.goToNextTabStop() - } - } else { - // The user has tabbed past the last tab stop. If the last tab stop is a - // $0, we shouldn't move the cursor any further. - if (this.snippet.tabStopList.hasEndStop) { - this.destroy() - return false - } else { - const succeeded = this.goToEndOfLastTabStop() - this.destroy() - return succeeded - } + + // if we have an endstop (implicit ends have already been added) it will be the last one + if (nextIndex === this.insertionsByIndex.length - 1 && this.tabStopList.hasEndStop) { + const succeeded = this.setTabStopIndex(nextIndex) + this.destroy() + return {succeeded, isDestroyed: true} } + + // we are not at the end, and the next is not the endstop; just go to next stop + if (nextIndex < this.insertionsByIndex.length) { + const succeeded = this.setTabStopIndex(nextIndex) + if (succeeded) { return {succeeded, isDestroyed: false} } + return this.goToNextTabStop() + } + + // we have just tabbed past the final tabstop; silently clean up, and let an actual tab be inserted + this.destroy() + return {succeeded: false, isDestroyed: true} } goToPreviousTabStop () { - if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) } + if (this.tabStopIndex > 0) { + return { + succeeded: this.setTabStopIndex(this.tabStopIndex - 1), + isDestroyed: false + } + } + return { + succeeded: atom.config.get('snippets.disableTabDedentInSnippet'), + isDestroyed: false + } } setTabStopIndex (tabStopIndex) { + let oldIndex = this.tabStopIndex this.tabStopIndex = tabStopIndex + + // Set a flag before we move any selections so that our change handlers + // will know that the movements were initiated by us. this.settingTabStop = true + + // Keep track of whether we replaced any selections or cursors. let markerSelected = false - const items = this.tabStopMarkers[this.tabStopIndex] - if (items.length === 0) { return false } + let insertions = this.insertionsByIndex[this.tabStopIndex] + if (insertions.length === 0) { return false } const ranges = [] - this.hasTransforms = false - for (const item of items) { - const {marker, insertion} = item - if (marker.isDestroyed()) { continue } - if (!marker.isValid()) { continue } + let hasTransforms = false + // Go through the active tab stop's markers to figure out where to place + // cursors and/or selections. + for (const insertion of insertions) { + const marker = this.markersForInsertions.get(insertion) + if (marker.isDestroyed() || !marker.isValid()) { continue } if (insertion.isTransformation()) { - this.hasTransforms = true + // Set a flag for later, but skip transformation insertions because + // they don't get their own cursors. + hasTransforms = true continue } ranges.push(marker.getBufferRange()) } if (ranges.length > 0) { + // We have new selections to apply. Reuse existing selections if + // possible, and destroy the unused ones if we already have too many. for (const selection of this.selections.slice(ranges.length)) { selection.destroy() } this.selections = this.selections.slice(0, ranges.length) for (let i = 0; i < ranges.length; i++) { @@ -202,36 +346,30 @@ module.exports = class SnippetExpansion { this.selections.push(newSelection) } } + // We placed at least one selection, so this tab stop was successfully + // set. Update our return value. markerSelected = true } this.settingTabStop = false // If this snippet has at least one transform, we need to observe changes // made to the editor so that we can update the transformed tab stops. - if (this.hasTransforms) { this.snippets.observeEditor(this.editor) } - - return markerSelected - } - - goToEndOfLastTabStop () { - if (this.tabStopMarkers.length === 0) { return } - const items = this.tabStopMarkers[this.tabStopMarkers.length - 1] - if (items.length === 0) { return } - const {marker: lastMarker} = items[items.length - 1] - if (lastMarker.isDestroyed()) { - return false + if (hasTransforms) { + this.snippets.observeEditor(this.editor) } else { - this.editor.setCursorBufferPosition(lastMarker.getEndBufferPosition()) - return true + this.snippets.stopObservingEditor(this.editor) } + + if (oldIndex !== null) { + this.adjustTabStopMarkers(oldIndex, this.tabStopIndex) + } + + return markerSelected } destroy () { this.subscriptions.dispose() - this.getMarkerLayer(this.editor).clear() - this.tabStopMarkers = [] - this.snippets.stopObservingEditor(this.editor) - this.snippets.clearExpansions(this.editor) + this.insertionsByIndex = [] } getMarkerLayer () { diff --git a/lib/snippet-history-provider.js b/lib/snippet-history-provider.js index b1b3e57c..4bba42ff 100644 --- a/lib/snippet-history-provider.js +++ b/lib/snippet-history-provider.js @@ -1,11 +1,10 @@ function wrap (manager, callbacks) { - let klass = new SnippetHistoryProvider(manager) return new Proxy(manager, { get (target, name) { if (name in callbacks) { callbacks[name]() } - return name in klass ? klass[name] : target[name] + return target[name] } }) } diff --git a/lib/snippet.js b/lib/snippet.js index fcdfed90..cd6bbe4e 100644 --- a/lib/snippet.js +++ b/lib/snippet.js @@ -1,56 +1,186 @@ -const {Range} = require('atom') +const {Point, Range} = require('atom') const TabStopList = require('./tab-stop-list') +const {transformWithSubstitution} = require('./util') + +const tabStopsReferencedWithinTabStopContent = (segment) => { + let results = [] + for (let item of segment) { + if (item.index) { + results.push( + item.index, + ...tabStopsReferencedWithinTabStopContent(item.content) + ) + } + } + return new Set(results) +} module.exports = class Snippet { - constructor({name, prefix, bodyText, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyTree}) { - this.name = name - this.prefix = prefix - this.bodyText = bodyText - this.description = description - this.descriptionMoreURL = descriptionMoreURL - this.rightLabelHTML = rightLabelHTML - this.leftLabel = leftLabel - this.leftLabelHTML = leftLabelHTML - this.tabStopList = new TabStopList(this) - this.body = this.extractTabStops(bodyTree) - } - - extractTabStops (bodyTree) { - const bodyText = [] - let row = 0 - let column = 0 - - // recursive helper function; mutates vars above - let extractTabStops = bodyTree => { - for (const segment of bodyTree) { - if (segment.index != null) { - let {index, content, substitution} = segment - if (index === 0) { index = Infinity; } - const start = [row, column] - extractTabStops(content) - const range = new Range(start, [row, column]) - const tabStop = this.tabStopList.findOrCreate({ - index, - snippet: this - }) - tabStop.addInsertion({ range, substitution }) - } else if (typeof segment === 'string') { - bodyText.push(segment) - var segmentLines = segment.split('\n') - column += segmentLines.shift().length - let nextLine - while ((nextLine = segmentLines.shift()) != null) { - row += 1 - column = nextLine.length - } - } + constructor(params) { + this.name = params.name + this.prefix = params.prefix + this.description = params.description + this.descriptionMoreURL = params.descriptionMoreURL + this.rightLabelHTML = params.rightLabelHTML + this.leftLabel = params.leftLabel + this.leftLabelHTML = params.leftLabelHTML + this.bodyTree = params.bodyTree + this.variableResolver = params.variableResolver + this.transformResolver = params.transformResolver + } + + toString (params = {startPosition: {row: 0, column: 0}, indent: ''}) { + params.variableResolver = this.variableResolver + params.transformResolver = this.transformResolver + + // accumulator to keep track of constructed text, tabstops, and position + const acc = { + tabStopList: new TabStopList(this), + unknownVariables: new Map(), // name -> [range] + bodyText: '', + row: params.startPosition.row, + column: params.startPosition.column + } + + let endsWithTabstop = stringifyContent(this.bodyTree, params, acc) + + addTabstopsForUnknownVariables(acc.unknownVariables, acc.tabStopList) + + if (!acc.tabStopList.hasEndStop && !endsWithTabstop && atom.config.get('snippets.implicitEndTabstop')) { + const endRange = new Range([acc.row, acc.column], [acc.row, acc.column]) + acc.tabStopList.findOrCreate({index: Infinity, snippet: this}).addInsertion({range: endRange}) + } + + return {body: acc.bodyText, tabStopList: acc.tabStopList} + } +} + +function addTabstopsForUnknownVariables (unknowns, tabStopList) { + let index = tabStopList.getHighestIndex() + 1 + for (const ranges of unknowns.values()) { + const tabstop = tabStopList.findOrCreate({index, snippet: this}) + for (const range of ranges) { + tabstop.addInsertion({range}) + } + index++ + } +} + +function stringifyContent (content = [], params, acc) { + let endsWithTabstop + for (let node of content) { + endsWithTabstop = true + if (node.index !== undefined) { // only tabstops and choices have an index + if (node.choice !== undefined) { + stringifyChoice(node, params, acc) + continue } + stringifyTabstop(node, params, acc) + continue + } + if (node.variable !== undefined) { + stringifyVariable(node, params, acc) + continue } + stringifyText(node, params, acc) + endsWithTabstop = false + } + return endsWithTabstop +} - extractTabStops(bodyTree) - this.lineCount = row + 1 - this.insertions = this.tabStopList.getInsertions() +function stringifyTabstop (node, params, acc) { + const index = node.index === 0 ? Infinity : node.index + const start = new Point(acc.row, acc.column) + stringifyContent(node.content, params, acc) + let referencedTabStops = tabStopsReferencedWithinTabStopContent(node.content) + const range = new Range(start, [acc.row, acc.column]) + acc.tabStopList.findOrCreate({index, snippet: this}).addInsertion({ + range, + substitution: node.substitution, + references: [...referencedTabStops] + }) +} - return bodyText.join('') +function stringifyChoice (node, params, acc) { + // TODO: Support choices + // NOTE: will need to make sure all choices appear consistently + // VS Code treats first non-simple use as the true def. So + // `${1:foo} ${1|one,two|}` expands to `foo| foo|`, but reversing + // them expands to `one| one|` (with choice) + if (node.choice.length > 0) { + stringifyTabstop({...node, content: [node.choice[0]]}, params, acc) + } else { + stringifyTabstop(node, params, acc) } } + +// NOTE: VS Code does not apply the transformation in this case, so we won't either +function addUnknownVariable (variableName, acc) { + const {row, column} = acc + acc.bodyText += variableName + acc.column += variableName.length + const range = new Range([row, column], [row, acc.column]) + + const ranges = acc.unknownVariables.get(variableName) + if (ranges !== undefined) { + ranges.push(range) + return + } + + acc.unknownVariables.set(variableName, [range]) +} + +function stringifyVariable (node, params, acc) { + const {hasResolver, value} = params.variableResolver.resolve(node.variable, {variable: node.variable, ...params, ...acc}) + + if (!hasResolver) { // variable unknown; convert to tabstop that goes at the end of all proper tabstops + addUnknownVariable(node.variable, acc) + return + } + + let resolvedValue + if (node.substitution) { + try { + resolvedValue = transformWithSubstitution(value || '', node.substitution, params.transformResolver) + } catch (e) { + atom.notifications.addError(`Failed to transform snippet variable $${segment.variable}`, {detail: e}) // TODO: add snippet location + } + } else { + resolvedValue = value + } + + if (resolvedValue == undefined) { // variable known, but no value: use default contents or (implicitly) empty string + if (node.content) { + stringifyContent(node.content, params, acc) + } + return + } + + // if we get to here, the variable is effectively a regular string now + stringifyText(resolvedValue, params, acc) +} + +// NOTE: Unlike the original version, this also applies +// the indent and uses the 'true' row and columns +function stringifyText (text, params, acc) { + const origLength = text.length + const replacement = '\n' + params.indent // NOTE: Line endings normalised by default for setTextInBufferRange + + let rowDiff = 0 + let finalOffset = 0 + + text = text.replace(/\n/g, (...match) => { + rowDiff += 1 + finalOffset = match[match.length - 2] // this holds the current match offset relative to the original string + return replacement + }) + + if (rowDiff > 0) { + acc.row += rowDiff + acc.column = params.indent.length + (origLength - finalOffset - 1) + } else { + acc.column += origLength + } + + acc.bodyText += text +} diff --git a/lib/snippets-available.js b/lib/snippets-available.js index d244cb16..2d99fb61 100644 --- a/lib/snippets-available.js +++ b/lib/snippets-available.js @@ -1,9 +1,7 @@ -/** @babel */ +const _ = require('underscore-plus') +const SelectListView = require('atom-select-list') -import _ from 'underscore-plus' -import SelectListView from 'atom-select-list' - -export default class SnippetsAvailable { +module.exports = class SnippetsAvailable { constructor (snippets) { this.panel = null this.snippets = snippets @@ -28,7 +26,7 @@ export default class SnippetsAvailable { }, didConfirmSelection: (snippet) => { for (const cursor of this.editor.getCursors()) { - this.snippets.insert(snippet.bodyText, this.editor, cursor) + this.snippets.insert(snippet, this.editor, cursor, cursor.selection.getBufferRange()) } this.cancel() }, diff --git a/lib/snippets.js b/lib/snippets.js index c42d8bb8..20b4525e 100644 --- a/lib/snippets.js +++ b/lib/snippets.js @@ -9,6 +9,7 @@ const ScopedPropertyStore = require('scoped-property-store') const Snippet = require('./snippet') const SnippetExpansion = require('./snippet-expansion') const EditorStore = require('./editor-store') +const {VariableResolver, TransformResolver} = require('./resolvers') const {getPackageRoot} = require('./helpers') module.exports = { @@ -19,6 +20,8 @@ module.exports = { this.snippetsByPackage = new Map this.parsedSnippetsById = new Map this.editorMarkerLayers = new WeakMap + this.variableResolver = new VariableResolver + this.transformResolver = new TransformResolver this.scopedPropertyStore = new ScopedPropertyStore // The above ScopedPropertyStore will store the main registry of snippets. @@ -50,9 +53,10 @@ module.exports = { this.subscriptions.add(atom.commands.add('atom-text-editor', { 'snippets:expand'(event) { const editor = this.getModel() - if (snippets.snippetToExpandUnderCursor(editor)) { + const snippet = snippets.snippetToExpandUnderCursor(editor) + if (snippet) { snippets.clearExpansions(editor) - snippets.expandSnippetsUnderCursors(editor) + snippets.expandSnippetsUnderCursors(editor, snippet) } else { event.abortKeyBinding() } @@ -113,7 +117,7 @@ module.exports = { }, loadBundledSnippets (callback) { - const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'lib', 'snippets')) + const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'snippets', 'snippets')) this.loadSnippetsFile(bundledSnippetsPath, snippets => { const snippetsByPath = {} snippetsByPath[bundledSnippetsPath] = snippets @@ -355,7 +359,7 @@ module.exports = { // prefix for expansion, but both stores have their contents exported when // the settings view asks for all available snippets. const unparsedSnippets = {} - unparsedSnippets[selector] = {"snippets": value} + unparsedSnippets[selector] = {'snippets': value} const store = isDisabled ? this.disabledSnippetsScopedPropertyStore : this.scopedPropertyStore store.addProperties(path, unparsedSnippets, {priority: this.priorityForSource(path)}) }, @@ -377,7 +381,7 @@ module.exports = { const unparsedSnippetsByPrefix = this.scopedPropertyStore.getPropertyValue( this.getScopeChain(scopeDescriptor), - "snippets" + 'snippets' ) const legacyScopeDescriptor = atom.config.getLegacyScopeDescriptorForNewScopeDescriptor @@ -387,7 +391,7 @@ module.exports = { if (legacyScopeDescriptor) { unparsedLegacySnippetsByPrefix = this.scopedPropertyStore.getPropertyValue( this.getScopeChain(legacyScopeDescriptor), - "snippets" + 'snippets' ) } @@ -418,7 +422,7 @@ module.exports = { if (snippet == null) { let {id, prefix, name, body, bodyTree, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML} = attributes if (bodyTree == null) { bodyTree = this.getBodyParser().parse(body) } - snippet = new Snippet({id, name, prefix, bodyTree, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyText: body}) + snippet = new Snippet({id, name, prefix, bodyTree, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyText: body, variableResolver: this.variableResolver, transformResolver: this.transformResolver}) this.parsedSnippetsById.set(attributes.id, snippet) } return snippet @@ -509,10 +513,14 @@ module.exports = { if (prefixData) { return this.snippetForPrefix(snippets, prefixData.snippetPrefix, prefixData.wordPrefix) } + return false }, - expandSnippetsUnderCursors (editor) { - const snippet = this.snippetToExpandUnderCursor(editor) + expandSnippetsUnderCursors (editor, snippet) { + if (!snippet) { + snippet = this.snippetToExpandUnderCursor(editor) + } + if (!snippet) { return false } this.getStore(editor).observeHistory({ @@ -521,35 +529,54 @@ module.exports = { }) this.findOrCreateMarkerLayer(editor) - editor.transact(() => { - const cursors = editor.getCursors() - for (const cursor of cursors) { - const cursorPosition = cursor.getBufferPosition() - const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0]) - cursor.selection.setBufferRange([startPoint, cursorPosition]) - this.insert(snippet, editor, cursor) - } - }) + const checkpoint = editor.createCheckpoint() + const cursors = editor.getCursors() + for (const cursor of cursors) { + const cursorPosition = cursor.getBufferPosition() + const startPoint = cursorPosition.translate([0, -snippet.prefix.length]) + const oldSelectionRange = cursor.selection.getBufferRange() + cursor.selection.setBufferRange([startPoint, cursorPosition]) + this.insert(snippet, editor, cursor, oldSelectionRange) + } + editor.groupChangesSinceCheckpoint(checkpoint) + return true }, goToNextTabStop (editor) { let nextTabStopVisited = false + let destroy = false + for (const expansion of this.getExpansions(editor)) { - if (expansion && expansion.goToNextTabStop()) { - nextTabStopVisited = true - } + if (!expansion) { continue } + const {succeeded, isDestroyed} = expansion.goToNextTabStop() + if (!destroy) { destroy = isDestroyed } + if (succeeded) { nextTabStopVisited = true } + } + + if (destroy) { + this.destroyExpansions(editor) } + + if (nextTabStopVisited) { + editor.buffer.createCheckpoint() + } + return nextTabStopVisited }, goToPreviousTabStop (editor) { let previousTabStopVisited = false for (const expansion of this.getExpansions(editor)) { - if (expansion && expansion.goToPreviousTabStop()) { - previousTabStopVisited = true - } + if (!expansion) { continue; } + const {succeeded} = expansion.goToPreviousTabStop(!previousTabStopVisited) + if (succeeded) { previousTabStopVisited = true } } + + if (previousTabStopVisited) { + editor.buffer.createCheckpoint() + } + return previousTabStopVisited }, @@ -583,20 +610,24 @@ module.exports = { this.getStore(editor).addExpansion(snippetExpansion) }, + destroyExpansions (editor) { + this.findOrCreateMarkerLayer(editor).clear() + this.stopObservingEditor(editor) + this.clearExpansions(editor) + }, + textChanged (editor, event) { const store = this.getStore(editor) const activeExpansions = store.getExpansions() if ((activeExpansions.length === 0) || activeExpansions[0].isIgnoringBufferChanges) { return } - this.ignoringTextChangesForEditor(editor, () => - editor.transact(() => + this.ignoringTextChangesForEditor(editor, () => { + // HACK: relies on private editor property, and on one that will likely change in future (to non time based undo) + const interval = editor.undoGroupingInterval == undefined ? 300 : editor.undoGroupingInterval + editor.transact(interval, () => activeExpansions.map(expansion => expansion.textChanged(event))) - ) - - // Create a checkpoint here to consolidate all the changes we just made into - // the transaction that prompted them. - this.makeCheckpoint(editor) + }) }, // Perform an action inside the editor without triggering our `textChanged` @@ -615,18 +646,15 @@ module.exports = { this.getStore(editor).stopObserving() }, - makeCheckpoint (editor) { - this.getStore(editor).makeCheckpoint() - }, - - insert (snippet, editor, cursor) { + insert (snippet, editor, cursor, oldSelectionRange) { if (editor == null) { editor = atom.workspace.getActiveTextEditor() } + if (editor == null) { return } 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({name: '__anonymous', prefix: '', bodyTree, bodyText: snippet, variableResolver: this.variableResolver, transformResolver: this.transformResolver}) } - return new SnippetExpansion(snippet, editor, cursor, this) + return new SnippetExpansion(snippet, editor, cursor, oldSelectionRange, this) }, getUnparsedSnippets () { @@ -659,8 +687,39 @@ module.exports = { } }, - onUndoOrRedo (editor, isUndo) { + onUndoOrRedo (editor, event, isUndo) { const activeExpansions = this.getExpansions(editor) activeExpansions.forEach(expansion => expansion.onUndoOrRedo(isUndo)) + }, + + consumeResolver (payload) { + if (payload === null || typeof payload !== 'object') return + + const variableResolvers = payload.variableResolvers + const transformResolvers = payload.transformResolvers + + if (variableResolvers) { + this.addResolvers(this.variableResolver, variableResolvers) + } + + if (transformResolvers) { + this.addResolvers(this.transformResolver, transformResolvers) + } + }, + + addResolvers (resolverObject, nameResolverPairs) { + let itr + if (nameResolverPairs instanceof Map) { + itr = nameResolverPairs.entries() + } else if (typeof nameResolverPairs === 'object') { + itr = Object.entries(nameResolverPairs) + } else { + return + } + + for (const [varName, resolver] of itr) { + if (typeof varName !== 'string' || typeof resolver !== 'function') continue + resolverObject.add(varName, resolver) + } } } diff --git a/lib/tab-stop-list.js b/lib/tab-stop-list.js index 0d3bd010..211dd2b6 100644 --- a/lib/tab-stop-list.js +++ b/lib/tab-stop-list.js @@ -14,32 +14,43 @@ class TabStopList { return !!this.list[Infinity] } - findOrCreate ({ index, snippet }) { + findOrCreate ({index, snippet}) { if (!this.list[index]) { - this.list[index] = new TabStop({ index, snippet }) + this.list[index] = new TabStop({index, snippet, transformResolver: this.snippet.transformResolver}) } return this.list[index] } forEachIndex (iterator) { - let indices = Object.keys(this.list).sort((a1, a2) => a1 - a2) + const indices = Object.keys(this.list).sort((a1, a2) => a1 - a2) indices.forEach(iterator) } getInsertions () { - let results = [] + const results = [] this.forEachIndex(index => { results.push(...this.list[index].insertions) }) return results } + getHighestIndex () { + // the keys are strings... + return Object.keys(this.list).reduce((m, i) => { + const index = parseInt(i) + return index > m + ? index + : m + }, 0) + } + toArray () { - let results = [] + const results = [] this.forEachIndex(index => { - let tabStop = this.list[index] - if (!tabStop.isValid()) return - results.push(tabStop) + const tabStop = this.list[index] + if (tabStop.isValid()) { + results.push(tabStop) + } }) return results } diff --git a/lib/tab-stop.js b/lib/tab-stop.js index 61a423e4..833bbc5f 100644 --- a/lib/tab-stop.js +++ b/lib/tab-stop.js @@ -6,56 +6,35 @@ const Insertion = require('./insertion') // * has an index (one tab stop per index) // * has multiple Insertions class TabStop { - constructor ({ snippet, index, insertions }) { + constructor ({snippet, index, insertions, transformResolver}) { this.insertions = insertions || [] - Object.assign(this, { snippet, index }) + this.transformResolver = transformResolver + Object.assign(this, {snippet, index}) } isValid () { let any = this.insertions.some(insertion => insertion.isTransformation()) if (!any) return true - let all = this.insertions.every(insertion => insertion.isTransformation()) + const all = this.insertions.every(insertion => insertion.isTransformation()) // If there are any transforming insertions, there must be at least one // non-transforming insertion to act as the primary. return !all } - addInsertion ({ range, substitution }) { - let insertion = new Insertion({ range, substitution }) - let insertions = this.insertions + addInsertion (insertionParams) { + const insertion = new Insertion({...insertionParams, transformResolver: this.transformResolver}) + const insertions = this.insertions insertions.push(insertion) - insertions = insertions.sort((i1, i2) => { + insertions.sort((i1, i2) => { return i1.range.start.compare(i2.range.start) }) - let initial = insertions.find(insertion => !insertion.isTransformation()) + const initial = insertions.find(insertion => !insertion.isTransformation()) if (initial) { insertions.splice(insertions.indexOf(initial), 1) insertions.unshift(initial) } this.insertions = insertions } - - copyWithIndent (indent) { - let { snippet, index, insertions } = this - let newInsertions = insertions.map(insertion => { - let { range, substitution } = insertion - let newRange = Range.fromObject(range, true) - if (newRange.start.row) { - newRange.start.column += indent.length - newRange.end.column += indent.length - } - return new Insertion({ - range: newRange, - substitution - }) - }) - - return new TabStop({ - snippet, - index, - insertions: newInsertions - }) - } } module.exports = TabStop diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..dfdfc412 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,121 @@ +const {Point} = require('atom') + +module.exports = {transformWithSubstitution, getEndpointOfText} + +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 + } +} + +function flagTransformText (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 +} + +function transformWithSubstitution (input, substitution, transformResolver) { + if (!substitution) { return input } + + return input.replace(substitution.find, (...match) => { + const flags = { + uppercaseAll: false, + lowercaseAll: false, + uppercaseNext: false, + lowercaseNext: false + } + + let result = '' + + substitution.replace.forEach(token => { + if (typeof token === 'string') { + result += flagTransformText(token, flags) + return + } + + if (token.escape !== undefined) { + switch (token.escape) { + case 'r': + result += '\\r' + break + case 'n': + result += '\\n' + break + case '$': + result += '$' + break + default: + ESCAPES[token.escape](flags) + } + return + } + + if (token.backreference === undefined) { return } // NOTE: this shouldn't trigger, but can safeguard against future grammar refactors + + let original = match[token.backreference] + + if (original === undefined) { + if (token.elsetext) { + result += flagTransformText(token.elsetext, flags) + } + return + } + + if (token.iftext !== undefined) { // NOTE: Should we treat the empty string as a match? + original = token.iftext + } + + if (token.transform) { + if (transformResolver === undefined) return + + const {hasResolver, value} = transformResolver.resolve(token.transform, {transform: token.transform, input: original}) + if (hasResolver && value) { + result += value + } + return + } + + result += flagTransformText(original, flags) + }) + + return result + }) +} + +function getEndpointOfText (text) { + const newlineMatch = /\n/g // NOTE: This is the same as used by TextBuffer, so should work even with \r + let row = 0 + let lastIndex = 0 + + while (newlineMatch.exec(text) !== null) { + row += 1 + lastIndex = newlineMatch.lastIndex + } + + return new Point(row, text.length - lastIndex) +} diff --git a/package.json b/package.json index 8d8332b1..fbacf69e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,27 @@ } } }, + "consumedServices": { + "snippetsResolver": { + "description": "Provide custom functions to resolve snippet variables and transforms.", + "versions": { + "0.0.0": "consumeResolver" + } + } + }, "devDependencies": { "coffeelint": "^1.9.7" + }, + "configSchema": { + "implicitEndTabstop": { + "description": "Add a final tabstop at the end of the snippet when the $0 stop is not set", + "type": "boolean", + "default": true + }, + "disableTabDedentInSnippet": { + "description": "When pressing shift-tab on the first placeholder, prevent the line from being dedented", + "type": "boolean", + "default": false + } } } diff --git a/lib/snippets.cson b/snippets/snippets.cson similarity index 100% rename from lib/snippets.cson rename to snippets/snippets.cson diff --git a/spec/body-parser-spec.coffee b/spec/body-parser-spec.coffee deleted file mode 100644 index 2f8a28e7..00000000 --- a/spec/body-parser-spec.coffee +++ /dev/null @@ -1,243 +0,0 @@ -BodyParser = 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", -> - 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" - ] - - it "removes interpolated variables in placeholder text (we don't currently support it)", -> - 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 "skips escaped tabstops", -> - bodyTree = BodyParser.parse """ - snippet $1 escaped \\$2 \\\\$3 - """ - - expect(bodyTree).toEqual [ - "snippet ", - { - index: 1, - content: [] - }, - " escaped $2 \\", - { - index: 3, - content: [] - } - ] - - it "includes escaped right-braces", -> - bodyTree = BodyParser.parse """ - snippet ${1:{\\}} - """ - - expect(bodyTree).toEqual [ - "snippet ", - { - index: 1, - content: ["{}"] - } - ] - - it "parses a snippet with transformations", -> - bodyTree = BodyParser.parse """ - <${1:p}>$0 - """ - expect(bodyTree).toEqual [ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - '' - ] - - it "parses a snippet with multiple tab stops with transformations", -> - 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} - ] - } - }, - ' ', - {index: 2, content: []}, - ] - - - it "parses a snippet with transformations and mirrors", -> - 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} - ] - } - }, - '\n', - {index: 1, content: []} - ] - - it "parses a snippet with a format string and case-control flags", -> - bodyTree = BodyParser.parse """ - <${1:p}>$0 - """ - - expect(bodyTree).toEqual [ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - '' - ] - - 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. - bodyTree = BodyParser.parse """ - <${1:p}>$0 - """ - - expect(bodyTree).toEqual [ - '<', - {index: 1, content: ['p']}, - '>', - {index: 0, content: []}, - '' - ] - - it "parses a snippet with a placeholder that mirrors another tab stop's content", -> - 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: []} - ] - - it "parses a snippet with a placeholder that mixes text and tab stop references", -> - 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: []} - ] diff --git a/spec/body-parser-spec.js b/spec/body-parser-spec.js new file mode 100644 index 00000000..5240e0ee --- /dev/null +++ b/spec/body-parser-spec.js @@ -0,0 +1,449 @@ +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 behaviour', () => { + expectMatch('${} $ n $}1} ${/upcase/} \n world ${||}', [ + '${} $ n $}1} ${/upcase/} \n world ${||}' + ]) + }) + + 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 nested tabstops', () => { + expectMatch('${1:place${2:hol${3:der}}}', [ + { + index: 1, + content: [ + 'place', + {index: 2, content: [ + 'hol', + {index: 3, content: ['der']} + ]} + ] + } + ]) + }) + }) + + 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'} + ] + } + ]) + }) + + it('parses variables with transforms', () => { + expectMatch('${f/.*/$0/}', [ + { + variable: 'f', + substitution: { + find: /.*/, + replace: [ + {backreference: 0} + ] + } + } + ]) + }) + }) + + describe('for escaped characters', () => { + it('treats a selection of escaped characters specially', () => { + expectMatch('\\$ \\\\ \\}', [ + '$ \\ }' + ]) + }) + + it('returns the literal slash and character otherwise', () => { + expectMatch('\\ \\. \\# \\n \\r \\', [ + '\\ \\. \\# \\n \\r \\' + ]) + }) + }) + + describe('for transforms', () => { + it('allows an empty transform', () => { + expectMatch('${a///}', [ + { + variable: 'a', + substitution: { + find: new RegExp(), + replace: [] + } + } + ]) + }) + + it('appends the declared flags', () => { + expectMatch('${a/.//g}', [ + { + variable: 'a', + substitution: { + find: /./g, + replace: [] + } + } + ]) + expectMatch('${a/.//gimuy}', [ // s flag not available apparently + { + variable: 'a', + substitution: { + find: /./gimuy, + replace: [] + } + } + ]) + // NOTE: We do not try to filter out invalid flags. This + // helps protect against future flag changes, such as when + // 's' is introduced + }) + + it('allows searching with an escaped forwards slash', () => { + expectMatch('${a/^\\/5/bar/}', [ + { + variable: 'a', + substitution: { + find: /^\/5/, + replace: ['bar'] + } + } + ]) + }) + + it('allows an escaped back brace, removing the backslash', () => { + expectMatch('${a/^\\}5//}', [ + { + variable: 'a', + substitution: { + find: /^}5/, + replace: [] + } + } + ]) + }) + + it('supports worded transformations', () => { + expectMatch('${a/./foo${0:/Bar}/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + 'foo', + { + backreference: 0, + transform: 'Bar' + } + ] + } + } + ]) + }) + + it('supports flag transformations', () => { + expectMatch('${a/./foo\\ubar\\n\\r\\U\\L\\l\\E\\$0/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + 'foo', + {escape: 'u'}, + 'bar', + {escape: 'n'}, + {escape: 'r'}, + {escape: 'U'}, + {escape: 'L'}, + {escape: 'l'}, + {escape: 'E'}, + '$0' + ] + } + } + ]) + }) + + it('treats invalid flag transforms as literal', () => { + expectMatch('${a/./foo\\p5/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + 'foo\\p5' + ] + } + } + ]) + }) + + it('supports if-else replacements', () => { + // NOTE: the '+' cannot be escaped. If you want it to be part of + // a placeholder (else only), use ':-' + // NOTE 2: the ${n:} syntax should really be deprecated, + // as it's very easy to accidentally use; e.g., when you have an invalid + // transformation like `${1:/foo3}` (has number) + expectMatch('${a/./${1:+foo$0bar\\}baz}/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + { + backreference: 1, + iftext: 'foo$0bar}baz' + } + ] + } + } + ]) + + expectMatch('${a/./${1:-foo$0bar\\}baz}/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + { + backreference: 1, + elsetext: 'foo$0bar}baz' + } + ] + } + } + ]) + + expectMatch('${a/./${1:foo$0bar\\}baz}/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + { + backreference: 1, + elsetext: 'foo$0bar}baz' + } + ] + } + } + ]) + + // NOTE: colon can be escaped in if text, but not in else text as it is + // unnecessary + expectMatch('${a/./${1:?foo$0bar\\}baz\\:hux\\\\:foo$0bar\\}baz\\:hux\\\\}/}', [ + { + variable: 'a', + substitution: { + find: /./, + replace: [ + { + backreference: 1, + iftext: 'foo$0bar}baz:hux\\', + elsetext: 'foo$0bar}baz\\:hux\\' + } + ] + } + } + ]) + }) + }) + + describe('on miscellaneous examples', () => { + it('handles a simple snippet', () => { + expectMatch( + 'the quick brown $1fox ${2:jumped ${3:over}\n}the ${4:lazy} dog', + [ + 'the quick brown ', + {index: 1, content: []}, + 'fox ', + { + index: 2, + content: [ + 'jumped ', + {index: 3, content: ['over']}, + '\n' + ] + }, + 'the ', + {index: 4, content: ['lazy']}, + ' dog' + ] + ) + }) + + 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('handles 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, + content: [], + substitution: { + find: /(.)/, + replace: [ + {escape: 'u'}, + {backreference: 1} + ] + } + }, + ' ', + {index: 1, content: []}, + ' ', + {index: 2, content: ['ANOTHER']}, + ' ', + { + index: 2, + content: [], + substitution: { + find: /^(.*)$/, + replace: [ + {escape: 'L'}, + {backreference: 1} + ] + } + }, + ' ', + {index: 2, content: []} + ] + ) + }) + + it('handles a snippet with a placeholder that mirrors another tab stops content', () => { + expectMatch( + '$4console.${3:log}(\'${2:$1}\', $1);$0', + [ + {index: 4, content: []}, + 'console.', + {index: 3, content: ['log']}, + '(\'', + { + index: 2, + content: [{index: 1, content: []}] + }, + '\', ', + {index: 1, content: []}, + ');', + {index: 0, content: []} + ] + ) + }) + }) +}) diff --git a/spec/fixtures/test-snippets.cson b/spec/fixtures/test-snippets.cson new file mode 100644 index 00000000..36bd2dda --- /dev/null +++ b/spec/fixtures/test-snippets.cson @@ -0,0 +1,163 @@ +'*': + 'without tab stops': + prefix: 't1' + body: 'this is a test' + + 'with only an end tab stop': + prefix: 't1a' + body: 'something $0 strange' + + 'overlapping prefix': + prefix: 'tt1' + body: 'this is another test' + + 'special chars': + prefix: '!@#$%^&*()-_=+[]{}54|\\;:?.,unique' + body: '@unique see' + + 'tab stops': + prefix: 't2' + body: ''' + go here next:($2) and finally go here:($0) + go here first:($1) + ''' + + 'indented second line': + prefix: 't3' + body: ''' + line 1 + \tline 2$1 + $2 + ''' + + 'multiline with indented placeholder tabstop': + prefix: 't4' + body: ''' + line ${1:1} + ${2:body...} + ''' + + 'multiline starting with tabstop': + prefix: 't4b' + body: ''' + $1 = line 1 { + line 2 + } + ''' + + 'nested tab stops': + prefix: 't5' + body: "${1:'${2:key}'}: ${3:value}" + + 'caused problems with undo': + prefix: 't6' + body: ''' + first line$1 + ${2:placeholder ending second line} + ''' + + 'tab stops at beginning and then end of snippet': + prefix: 't6b' + body: '$1expanded$0' + + 'tab stops at end and then beginning of snippet': + prefix: 't6c' + body: '$0expanded$1' + + 'contains empty lines': + prefix: 't7' + body: ''' + first line $1 + + + fourth line after blanks $2 + ''' + 'with/without placeholder': + prefix: 't8' + body: ''' + with placeholder ${1:test} + without placeholder ${2} + ''' + + 'multi-caret': + prefix: 't9' + body: ''' + with placeholder ${1:test} + without placeholder $1 + ''' + + 'multi-caret-multi-tabstop': + prefix: 't9b' + body: ''' + with placeholder ${1:test} + without placeholder $1 + second tabstop $2 + third tabstop $3 + ''' + + 'large indices': + prefix: 't10' + body: ''' + hello${10} ${11:large} indices${1} + ''' + + 'no body': + prefix: 'bad1' + + 'number body': + prefix: 'bad2' + body: 100 + + 'many tabstops': + prefix: 't11' + body: ''' + $0one${1} ${2:two} three${3} + ''' + + 'simple transform': + prefix: 't12' + body: ''' + [${1:b}][/${1/[ ]+.*$//}] + ''' + + 'transform with non-transforming mirrors': + prefix: 't13' + 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/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/g} & $2 + ''' + + 'has a transformed tab stop that occurs before the corresponding ordinary tab stop': + prefix: 't16' + body: ''' + & ${1/(.)/\\u$1/g} & ${1:q} + ''' + + "has a placeholder that mirrors another tab stop's content": + prefix: 't17' + body: "$4console.${3:log}('${2:uh $1}', $1);$0" + + '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/./=/g}' + "has two tab stops adjacent to one another": + prefix: 't19' + body: """ + ${2:bar}${3:baz} + """ + "has several adjacent tab stops, one of which has a placeholder with a reference to another tab stop at its edge": + prefix: 't20' + body: """ + ${1:foo}${2:bar}${3:baz $1}$4 + """ diff --git a/spec/insertion-spec.js b/spec/insertion-spec.js index 83fac925..d11bb44d 100644 --- a/spec/insertion-spec.js +++ b/spec/insertion-spec.js @@ -1,134 +1,252 @@ -const Insertion = require('../lib/insertion') -const { Range } = require('atom') - -const range = new Range(0, 0) +const Snippets = require('../lib/snippets') describe('Insertion', () => { - it('returns what it was given when it has no substitution', () => { - let insertion = new Insertion({ - range, - substitution: undefined + let editor + let editorElement + + beforeEach(() => { + spyOn(Snippets, 'loadAll') + spyOn(Snippets, 'getUserSnippetsPath').andReturn('') + + waitsForPromise(() => atom.workspace.open()) + waitsForPromise(() => atom.packages.activatePackage('snippets')) + + runs(() => { + editor = atom.workspace.getActiveTextEditor() + editorElement = atom.views.getView(editor) }) - let transformed = insertion.transform('foo!') + }) - expect(transformed).toEqual('foo!') + afterEach(() => { + waitsForPromise(() => atom.packages.deactivatePackage('snippets')) }) - it('transforms what it was given when it has a regex transformation', () => { - let insertion = new Insertion({ - range, - substitution: { - find: /foo/g, - replace: ['bar'] + function resolve (snippet) { + Snippets.add(__filename, { + '*': { + 'a': { + prefix: 'a', + body: snippet + } } }) - let transformed = insertion.transform('foo!') - expect(transformed).toEqual('bar!') + editor.setText('a') + editor.setCursorBufferPosition([0, 1]) + atom.commands.dispatch(editorElement, 'snippets:expand') + Snippets.clearExpansions(editor) + return editor.getText() + } + + function transform (input, transform, replacement, flags = '') { + return resolve(`\${1:${input}}\${1/${transform}/${replacement}/${flags}}`).slice(input.length) + } + + it('resolves a plain snippet', () => { + expect(resolve('${} $ n $}1} ${/upcase/} \n world ${||}')) + .toEqual('${} $ n $}1} ${/upcase/} \n world ${||}') }) - it('transforms the case of the next character when encountering a \\u or \\l flag', () => { - let uInsertion = new Insertion({ - range, - substitution: { - find: /(.)(.)(.*)/g, - replace: [ - { backreference: 1 }, - { escape: 'u' }, - { backreference: 2 }, - { backreference: 3 } - ] - } + it('resolves a snippet with tabstops', () => { + expect(resolve('hello$1world${2}')).toEqual('helloworld') + }) + + it('resolves snippets with placeholders', () => { + expect(resolve('${1:hello} world')).toEqual('hello world') + expect(resolve('${1:one${2:two${3:thr}e}e}')).toEqual('onetwothree') + }) + + describe('when resolving choices', () => { + it('uses the first choice as a placeholder', () => { + expect(resolve('${1|one,two,three|}')).toEqual('one') }) - expect(uInsertion.transform('foo!')).toEqual('fOo!') - expect(uInsertion.transform('fOo!')).toEqual('fOo!') - expect(uInsertion.transform('FOO!')).toEqual('FOO!') - - let lInsertion = new Insertion({ - range, - substitution: { - find: /(.{2})(.)(.*)/g, - replace: [ - { backreference: 1 }, - { escape: 'l' }, - { backreference: 2 }, - { backreference: 3 } - ] - } + it('uses the first non transforming placeholder for transformations', () => { + expect(resolve('${1:foo} ${1|one,two,three|} ${1/.*/$0/}')).toEqual('foo one foo') + expect(resolve('${1|one,two,three|} ${1:foo} ${1/.*/$0/}')).toEqual('one foo one') + }) + }) + + describe('when resolving variables', () => { + it('resolves base variables', () => { + expect(resolve('$TM_LINE_INDEX')).toEqual('0') + expect(resolve('$TM_LINE_NUMBER')).toEqual('1') + expect(/\d{4,}/.test(resolve('$CURRENT_YEAR'))).toEqual(true) + + atom.clipboard.write('foo') + expect(resolve('$CLIPBOARD')).toEqual('foo') + }) + + it('allows more resolvers to be provided', () => { + Snippets.consumeResolver({ + variableResolvers: { + 'EXTENDED': () => 'calculated resolution', + 'POSITION': ({row}) => `${row}` + } + }) + + expect(resolve('$EXTENDED')).toEqual('calculated resolution') + expect(resolve('$POSITION\n$POSITION')).toEqual('0\n1') + }) + + describe('when a variable is unknown', () => { + it('uses uses the variable name as a placeholder', () => { + expect(resolve('$GaRBag3')).toEqual('GaRBag3') + }) + + it('will not try to transform an unknown variable', () => { + expect(resolve('${GaRBag3/.*/foo/}')).toEqual('GaRBag3') + }) }) - expect(lInsertion.transform('FOO!')).toEqual('FOo!') - expect(lInsertion.transform('FOo!')).toEqual('FOo!') - expect(lInsertion.transform('FoO!')).toEqual('Foo!') - expect(lInsertion.transform('foo!')).toEqual('foo!') + describe('when a variable is known but not set', () => { + beforeEach(() => { + Snippets.consumeResolver({ + variableResolvers: { + 'UNDEFINED': () => undefined, + 'NULL': () => null, + 'EMPTY': () => '' + } + }) + }) + + it('uses the placeholder value if possible', () => { + expect(resolve('${UNDEFINED:placeholder}')).toEqual('placeholder') + expect(resolve('${NULL:placeholder}')).toEqual('placeholder') + expect(resolve('${EMPTY:placeholder}')).toEqual('') // empty string is a valid resolution + }) + + it('will transform an unset variable as if it was the empty string', () => { + expect(resolve('${UNDEFINED/^$/foo/}')).toEqual('foo') + }) + + it('can resolve variables in placeholders', () => { + expect(resolve('${UNDEFINED:$TM_LINE_INDEX}')).toEqual('0') + }) + }) + + it('allows provided resolvers to override builtins', () => { + expect(resolve('$TM_LINE_INDEX')).toEqual('0') + Snippets.consumeResolver({ + variableResolvers: { + 'TM_LINE_INDEX': () => 'umbrella' + } + }) + expect(resolve('$TM_LINE_INDEX')).toEqual('umbrella') + }) }) - 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: { - find: /(.)(.*)/, - replace: [ - { backreference: 1 }, - { escape: 'U' }, - { backreference: 2 } - ] - } + describe('when resolving transforms', () => { + beforeEach(() => { + Snippets.consumeResolver({ + variableResolvers: { + 'A': () => 'hello world', + 'B': () => 'foo\nbar\nbaz', + 'C': () => '😄foo', + 'D': () => 'baz foo', + 'E': () => 'foo baz foo' + } + }) }) - expect(uInsertion.transform('lorem ipsum!')).toEqual('lOREM IPSUM!') - expect(uInsertion.transform('lOREM IPSUM!')).toEqual('lOREM IPSUM!') - expect(uInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') - - let ueInsertion = new Insertion({ - range, - substitution: { - find: /(.)(.{3})(.*)/, - replace: [ - { backreference: 1 }, - { escape: 'U' }, - { backreference: 2 }, - { escape: 'E' }, - { backreference: 3 } - ] - } + it('leaves the existing value when the transform is empty', () => { + expect(resolve('${A///}')).toEqual('hello world') }) - expect(ueInsertion.transform('lorem ipsum!')).toEqual('lOREm ipsum!') - expect(ueInsertion.transform('lOREm ipsum!')).toEqual('lOREm ipsum!') - expect(ueInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') - - let lInsertion = new Insertion({ - range, - substitution: { - find: /(.{4})(.)(.*)/, - replace: [ - { backreference: 1 }, - { escape: 'L' }, - { backreference: 2 }, - 'WHAT' - ] - } + it('respects the provided regex flags', () => { + expect(resolve('${A/.//}')).toEqual('ello world') + expect(resolve('${A/.//g}')).toEqual('') + + expect(resolve('${A/HELLO//}')).toEqual('hello world') + expect(resolve('${A/HELLO//i}')).toEqual(' world') + + expect(resolve('${B/^ba(.)$/$1/}')).toEqual('foo\nbar\nbaz') + expect(resolve('${B/^ba(.)$/$1/m}')).toEqual('foo\nr\nbaz') + + expect(resolve('${C/^.foo$/bar/}')).toEqual('😄foo') // without /u, the emoji is seen as two characters + expect(resolve('${C/^.foo$/bar/u}')).toEqual('bar') + + expect(resolve('${D/foo/bar/}')).toEqual('baz bar') + expect(resolve('${D/foo/bar/y}')).toEqual('baz foo') // with /y, the search is only from index 0 and fails + expect(resolve('${E/foo/bar/g}')).toEqual('bar baz bar') + expect(resolve('${E/foo/bar/gy}')).toEqual('bar baz foo') }) + }) - expect(lInsertion.transform('LOREM IPSUM!')).toEqual('LOREmwhat') + describe('when there are case flags', () => { + it('transforms the case of the next character when encountering a \\u or \\l flag', () => { + let find = '(.)(.)(.*)' + let replace = '$1\\u$2$3' + expect(transform('foo!', find, replace, 'g')).toEqual('fOo!') + expect(transform('fOo!', find, replace, 'g')).toEqual('fOo!') + expect(transform('FOO!', find, replace, 'g')).toEqual('FOO!') + + find = '(.{2})(.)(.*)' + replace = '$1\\l$2$3' + expect(transform('FOO!', find, replace, 'g')).toEqual('FOo!') + expect(transform('FOo!', find, replace, 'g')).toEqual('FOo!') + expect(transform('FoO!', find, replace, 'g')).toEqual('Foo!') + expect(transform('foo!', find, replace, 'g')).toEqual('foo!') + }) - let leInsertion = new Insertion({ - range, - substitution: { - find: /^([A-Fa-f])(.*)(.)$/, - replace: [ - { backreference: 1 }, - { escape: 'L' }, - { backreference: 2 }, - { escape: 'E' }, - { backreference: 3 } - ] - } + it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => { + let find = '(.)(.*)' + let replace = '$1\\U$2' + expect(transform('lorem ipsum!', find, replace)).toEqual('lOREM IPSUM!') + expect(transform('lOREM IPSUM!', find, replace)).toEqual('lOREM IPSUM!') + expect(transform('LOREM IPSUM!', find, replace)).toEqual('LOREM IPSUM!') + + find = '(.)(.{3})(.*)' + replace = '$1\\U$2\\E$3' + expect(transform('lorem ipsum!', find, replace)).toEqual('lOREm ipsum!') + expect(transform('lOREm ipsum!', find, replace)).toEqual('lOREm ipsum!') + expect(transform('LOREM IPSUM!', find, replace)).toEqual('LOREM IPSUM!') + + expect(transform('LOREM IPSUM!', '(.{4})(.)(.*)', '$1\\L$2WHAT')).toEqual('LOREmwhat') + + find = '^([A-Fa-f])(.*)(.)$' + replace = '$1\\L$2\\E$3' + expect(transform('LOREM IPSUM!', find, replace)).toEqual('LOREM IPSUM!') + expect(transform('CONSECUETUR', find, replace)).toEqual('ConsecuetuR') + }) + }) + + describe('when there are replacement transformations', () => { + it('knows some basic transformations', () => { + expect(transform('foo', '.*', '${0:/upcase}')).toEqual('FOO') + expect(transform('FOO', '.*', '${0:/downcase}')).toEqual('foo') + expect(transform('foo bar', '.*', '${0:/capitalize}')).toEqual('Foo bar') + }) + + it('uses the empty string for an unknown transformation', () => { + expect(transform('foo', '.*', '${0:/GaRBagE}')).toEqual('') }) - expect(leInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') - expect(leInsertion.transform('CONSECUETUR')).toEqual('ConsecuetuR') + it('allows more transformations to be provided', () => { + expect(transform('foo', '.*', '${0:/extension}')).toEqual('') + Snippets.consumeResolver({ + transformResolvers: { + 'extension': () => 'extended', + 'echo': ({input}) => input + '... ' + input + } + }) + expect(transform('foo', '.*', '${0:/extension}')).toEqual('extended') + expect(transform('foo', '.*', '${0:/echo}')).toEqual('foo... foo') + }) + + it('allows provided transformations to override builtins', () => { + expect(transform('foo', '.*', '${0:/capitalize}')).toEqual('Foo') + Snippets.consumeResolver({ + transformResolvers: { + 'capitalize': () => 'different' + } + }) + expect(transform('foo', '.*', '${0:/capitalize}')).toEqual('different') + }) + + it('lets verbose transforms take priority over case flags', () => { + expect(transform('foo bar baz', '(foo) (bar) (baz)', '$1 \\U$2 $3')).toEqual('foo BAR BAZ') + expect(transform('foo bar baz', '(foo) (bar) (baz)', '$1 \\U${2:/downcase} $3')).toEqual('foo bar BAZ') + }) }) }) diff --git a/spec/snippet-loading-spec.coffee b/spec/snippet-loading-spec.coffee index 12a6c2aa..c87227a3 100644 --- a/spec/snippet-loading-spec.coffee +++ b/spec/snippet-loading-spec.coffee @@ -2,7 +2,7 @@ path = require 'path' fs = require 'fs-plus' temp = require('temp').track() -describe "Snippet Loading", -> +describe 'Snippet Loading', -> [configDirPath, snippetsService] = [] beforeEach -> @@ -25,57 +25,57 @@ describe "Snippet Loading", -> activateSnippetsPackage = -> waitsForPromise -> - atom.packages.activatePackage("snippets").then ({mainModule}) -> + atom.packages.activatePackage('snippets').then ({mainModule}) -> snippetsService = mainModule.provideSnippets() mainModule.loaded = false - waitsFor "all snippets to load", 3000, -> + waitsFor 'all snippets to load', 3000, -> snippetsService.bundledSnippetsLoaded() - it "loads the bundled snippet template snippets", -> + it 'loads the bundled snippet template snippets', -> activateSnippetsPackage() runs -> jsonSnippet = snippetsService.snippetsForScopes(['.source.json'])['snip'] expect(jsonSnippet.name).toBe 'Atom Snippet' expect(jsonSnippet.prefix).toBe 'snip' - expect(jsonSnippet.body).toContain '"prefix":' - expect(jsonSnippet.body).toContain '"body":' - expect(jsonSnippet.tabStopList.length).toBeGreaterThan(0) + expect(jsonSnippet.toString().body).toContain '"prefix":' + expect(jsonSnippet.toString().body).toContain '"body":' + expect(jsonSnippet.toString().tabStopList.length).toBeGreaterThan(0) csonSnippet = snippetsService.snippetsForScopes(['.source.coffee'])['snip'] expect(csonSnippet.name).toBe 'Atom Snippet' expect(csonSnippet.prefix).toBe 'snip' - expect(csonSnippet.body).toContain "'prefix':" - expect(csonSnippet.body).toContain "'body':" - expect(csonSnippet.tabStopList.length).toBeGreaterThan(0) + expect(csonSnippet.toString().body).toContain "'prefix':" + expect(csonSnippet.toString().body).toContain "'body':" + expect(csonSnippet.toString().tabStopList.length).toBeGreaterThan(0) - it "loads non-hidden snippet files from atom packages with snippets directories", -> + it 'loads non-hidden snippet files from atom packages with snippets directories', -> activateSnippetsPackage() runs -> snippet = snippetsService.snippetsForScopes(['.test'])['test'] expect(snippet.prefix).toBe 'test' - expect(snippet.body).toBe 'testing 123' + expect(snippet.toString().body).toBe 'testing 123' snippet = snippetsService.snippetsForScopes(['.test'])['testd'] expect(snippet.prefix).toBe 'testd' - expect(snippet.body).toBe 'testing 456' + expect(snippet.toString().body).toBe 'testing 456' expect(snippet.description).toBe 'a description' expect(snippet.descriptionMoreURL).toBe 'http://google.com' snippet = snippetsService.snippetsForScopes(['.test'])['testlabelleft'] expect(snippet.prefix).toBe 'testlabelleft' - expect(snippet.body).toBe 'testing 456' + expect(snippet.toString().body).toBe 'testing 456' expect(snippet.leftLabel).toBe 'a label' snippet = snippetsService.snippetsForScopes(['.test'])['testhtmllabels'] expect(snippet.prefix).toBe 'testhtmllabels' - expect(snippet.body).toBe 'testing 456' - expect(snippet.leftLabelHTML).toBe 'Label' - expect(snippet.rightLabelHTML).toBe 'Label' + expect(snippet.toString().body).toBe 'testing 456' + expect(snippet.leftLabelHTML).toBe 'Label' + expect(snippet.rightLabelHTML).toBe 'Label' - it "logs a warning if package snippets files cannot be parsed", -> + it 'logs a warning if package snippets files cannot be parsed', -> activateSnippetsPackage() runs -> @@ -83,7 +83,7 @@ describe "Snippet Loading", -> expect(console.warn.calls.length).toBeGreaterThan 0 expect(console.warn.mostRecentCall.args[0]).toMatch(/Error reading.*package-with-broken-snippets/) - describe "::loadPackageSnippets(callback)", -> + describe '::loadPackageSnippets(callback)', -> beforeEach -> # simulate a list of packages where the javascript core package is returned at the end atom.packages.getLoadedPackages.andReturn [ @@ -93,28 +93,28 @@ describe "Snippet Loading", -> it "allows other packages to override core packages' snippets", -> waitsForPromise -> - atom.packages.activatePackage("language-javascript") + atom.packages.activatePackage('language-javascript') activateSnippetsPackage() runs -> snippet = snippetsService.snippetsForScopes(['.source.js'])['log'] - expect(snippet.body).toBe "from-a-community-package" + expect(snippet.toString().body).toBe 'from-a-community-package' - describe "::onDidLoadSnippets(callback)", -> - it "invokes listeners when all snippets are loaded", -> + describe '::onDidLoadSnippets(callback)', -> + it 'invokes listeners when all snippets are loaded', -> loadedCallback = null - waitsFor "package to activate", (done) -> - atom.packages.activatePackage("snippets").then ({mainModule}) -> + waitsFor 'package to activate', (done) -> + atom.packages.activatePackage('snippets').then ({mainModule}) -> mainModule.onDidLoadSnippets(loadedCallback = jasmine.createSpy('onDidLoadSnippets callback')) done() - waitsFor "onDidLoad callback to be called", -> loadedCallback.callCount > 0 + waitsFor 'onDidLoad callback to be called', -> loadedCallback.callCount > 0 - describe "when ~/.atom/snippets.json exists", -> + describe 'when ~/.atom/snippets.json exists', -> beforeEach -> - fs.writeFileSync path.join(configDirPath, 'snippets.json'), """ + fs.writeFileSync path.join(configDirPath, 'snippets.json'), ''' { ".foo": { "foo snippet": { @@ -123,10 +123,10 @@ describe "Snippet Loading", -> } } } - """ + ''' activateSnippetsPackage() - it "loads the snippets from that file", -> + it 'loads the snippets from that file', -> snippet = null waitsFor -> @@ -134,12 +134,12 @@ describe "Snippet Loading", -> runs -> expect(snippet.name).toBe 'foo snippet' - expect(snippet.prefix).toBe "foo" - expect(snippet.body).toBe "bar1" + expect(snippet.prefix).toBe 'foo' + expect(snippet.toString().body).toBe 'bar1' - describe "when that file changes", -> - it "reloads the snippets", -> - fs.writeFileSync path.join(configDirPath, 'snippets.json'), """ + describe 'when that file changes', -> + it 'reloads the snippets', -> + fs.writeFileSync path.join(configDirPath, 'snippets.json'), ''' { ".foo": { "foo snippet": { @@ -148,29 +148,29 @@ describe "Snippet Loading", -> } } } - """ + ''' - waitsFor "snippets to be changed", -> + waitsFor 'snippets to be changed', -> snippet = snippetsService.snippetsForScopes(['.foo'])['foo'] - snippet?.body is 'bar2' + snippet?.toString().body is 'bar2' runs -> - fs.writeFileSync path.join(configDirPath, 'snippets.json'), "" + fs.writeFileSync path.join(configDirPath, 'snippets.json'), '' - waitsFor "snippets to be removed", -> + waitsFor 'snippets to be removed', -> not snippetsService.snippetsForScopes(['.foo'])['foo'] - describe "when ~/.atom/snippets.cson exists", -> + describe 'when ~/.atom/snippets.cson exists', -> beforeEach -> - fs.writeFileSync path.join(configDirPath, 'snippets.cson'), """ - ".foo": - "foo snippet": - "prefix": "foo" - "body": "bar1" - """ + fs.writeFileSync path.join(configDirPath, 'snippets.cson'), ''' + '.foo': + 'foo snippet': + 'prefix': 'foo' + 'body': 'bar1' + ''' activateSnippetsPackage() - it "loads the snippets from that file", -> + it 'loads the snippets from that file', -> snippet = null waitsFor -> @@ -178,33 +178,33 @@ describe "Snippet Loading", -> runs -> expect(snippet.name).toBe 'foo snippet' - expect(snippet.prefix).toBe "foo" - expect(snippet.body).toBe "bar1" - - describe "when that file changes", -> - it "reloads the snippets", -> - fs.writeFileSync path.join(configDirPath, 'snippets.cson'), """ - ".foo": - "foo snippet": - "prefix": "foo" - "body": "bar2" - """ - - waitsFor "snippets to be changed", -> + expect(snippet.prefix).toBe 'foo' + expect(snippet.toString().body).toBe 'bar1' + + describe 'when that file changes', -> + it 'reloads the snippets', -> + fs.writeFileSync path.join(configDirPath, 'snippets.cson'), ''' + '.foo': + 'foo snippet': + 'prefix': 'foo' + 'body': 'bar2' + ''' + + waitsFor 'snippets to be changed', -> snippet = snippetsService.snippetsForScopes(['.foo'])['foo'] - snippet?.body is 'bar2' + snippet?.toString().body is 'bar2' runs -> - fs.writeFileSync path.join(configDirPath, 'snippets.cson'), "" + fs.writeFileSync path.join(configDirPath, 'snippets.cson'), '' - waitsFor "snippets to be removed", -> + waitsFor 'snippets to be removed', -> snippet = snippetsService.snippetsForScopes(['.foo'])['foo'] not snippet? - it "notifies the user when the user snippets file cannot be loaded", -> - fs.writeFileSync path.join(configDirPath, 'snippets.cson'), """ - ".junk"::: - """ + it 'notifies the user when the user snippets file cannot be loaded', -> + fs.writeFileSync path.join(configDirPath, 'snippets.cson'), ''' + '.junk'::: + ''' activateSnippetsPackage() @@ -212,8 +212,8 @@ describe "Snippet Loading", -> expect(console.warn).toHaveBeenCalled() expect(atom.notifications.addError).toHaveBeenCalled() if atom.notifications? - describe "packages-with-snippets-disabled feature", -> - it "disables no snippets if the config option is empty", -> + describe 'packages-with-snippets-disabled feature', -> + it 'disables no snippets if the config option is empty', -> originalConfig = atom.config.get('core.packagesWithSnippetsDisabled') atom.config.set('core.packagesWithSnippetsDisabled', []) @@ -246,7 +246,7 @@ describe "Snippet Loading", -> expect(Object.keys(snippets).length).toBe 0 atom.config.set('core.packagesWithSnippetsDisabled', originalConfig) - it "unloads and/or reloads snippets from a package if the config option is changed after activation", -> + it 'unloads and/or reloads snippets from a package if the config option is changed after activation', -> originalConfig = atom.config.get('core.packagesWithSnippetsDisabled') atom.config.set('core.packagesWithSnippetsDisabled', []) diff --git a/spec/snippets-spec.coffee b/spec/snippets-spec.coffee deleted file mode 100644 index dd00a5d1..00000000 --- a/spec/snippets-spec.coffee +++ /dev/null @@ -1,1072 +0,0 @@ -path = require 'path' -temp = require('temp').track() -Snippets = require '../lib/snippets' -{TextEditor} = require 'atom' - -describe "Snippets extension", -> - [editorElement, editor] = [] - - simulateTabKeyEvent = ({shift}={}) -> - event = atom.keymaps.constructor.buildKeydownEvent('tab', {shift, target: editorElement}) - atom.keymaps.handleKeyboardEvent(event) - - beforeEach -> - spyOn(Snippets, 'loadAll') - spyOn(Snippets, 'getUserSnippetsPath').andReturn('') - - waitsForPromise -> - atom.workspace.open('sample.js') - - waitsForPromise -> - atom.packages.activatePackage('language-javascript') - - waitsForPromise -> - atom.packages.activatePackage('snippets') - - runs -> - editor = atom.workspace.getActiveTextEditor() - editorElement = atom.views.getView(editor) - - afterEach -> - waitsForPromise -> - atom.packages.deactivatePackage('snippets') - - describe "provideSnippets interface", -> - snippetsInterface = null - - beforeEach -> - snippetsInterface = Snippets.provideSnippets() - - describe "bundledSnippetsLoaded", -> - it "indicates the loaded state of the bundled snippets", -> - expect(snippetsInterface.bundledSnippetsLoaded()).toBe false - Snippets.doneLoading() - expect(snippetsInterface.bundledSnippetsLoaded()).toBe true - - it "resets the loaded state after snippets is deactivated", -> - expect(snippetsInterface.bundledSnippetsLoaded()).toBe false - Snippets.doneLoading() - expect(snippetsInterface.bundledSnippetsLoaded()).toBe true - - waitsForPromise -> atom.packages.deactivatePackage('snippets') - waitsForPromise -> atom.packages.activatePackage('snippets') - - runs -> - expect(snippetsInterface.bundledSnippetsLoaded()).toBe false - Snippets.doneLoading() - expect(snippetsInterface.bundledSnippetsLoaded()).toBe true - - describe "insertSnippet", -> - it "can insert a snippet", -> - editor.setSelectedBufferRange([[0, 4], [0, 13]]) - snippetsInterface.insertSnippet("hello ${1:world}", editor) - expect(editor.lineTextForBufferRow(0)).toBe "var hello world = function () {" - - it "returns false for snippetToExpandUnderCursor if getSnippets returns {}", -> - snippets = atom.packages.getActivePackage('snippets').mainModule - expect(snippets.snippetToExpandUnderCursor(editor)).toEqual false - - it "ignores invalid snippets in the config", -> - snippets = atom.packages.getActivePackage('snippets').mainModule - - invalidSnippets = null - spyOn(snippets.scopedPropertyStore, 'getPropertyValue').andCallFake -> invalidSnippets - expect(snippets.getSnippets(editor)).toEqual {} - - invalidSnippets = 'test' - expect(snippets.getSnippets(editor)).toEqual {} - - invalidSnippets = [] - expect(snippets.getSnippets(editor)).toEqual {} - - invalidSnippets = 3 - expect(snippets.getSnippets(editor)).toEqual {} - - invalidSnippets = {a: null} - expect(snippets.getSnippets(editor)).toEqual {} - - describe "when null snippets are present", -> - beforeEach -> - Snippets.add __filename, - '.source.js': - "some snippet": - prefix: "t1" - body: "this is a test" - - '.source.js .nope': - "some snippet": - prefix: "t1" - body: null - - it "overrides the less-specific defined snippet", -> - snippets = Snippets.provideSnippets() - expect(snippets.snippetsForScopes(['.source.js'])['t1']).toBeTruthy() - expect(snippets.snippetsForScopes(['.source.js .nope.not-today'])['t1']).toBeFalsy() - - describe "when 'tab' is triggered on the editor", -> - beforeEach -> - Snippets.add __filename, - ".source.js": - "without tab stops": - prefix: "t1" - body: "this is a test" - - "with only an end tab stop": - prefix: "t1a" - body: "something $0 strange" - - "overlapping prefix": - prefix: "tt1" - body: "this is another test" - - "special chars": - prefix: "@unique" - body: "@unique see" - - "tab stops": - prefix: "t2" - body: """ - go here next:($2) and finally go here:($0) - go here first:($1) - - """ - - "indented second line": - prefix: "t3" - body: """ - line 1 - \tline 2$1 - $2 - """ - - "multiline with indented placeholder tabstop": - prefix: "t4" - body: """ - line ${1:1} - ${2:body...} - """ - - "multiline starting with tabstop": - prefix: "t4b" - body: """ - $1 = line 1 { - line 2 - } - """ - - "nested tab stops": - prefix: "t5" - body: '${1:"${2:key}"}: ${3:value}' - - "caused problems with undo": - prefix: "t6" - body: """ - first line$1 - ${2:placeholder ending second line} - """ - - "tab stops at beginning and then end of snippet": - prefix: "t6b" - body: "$1expanded$0" - - "tab stops at end and then beginning of snippet": - prefix: "t6c" - body: "$0expanded$1" - - "contains empty lines": - prefix: "t7" - body: """ - first line $1 - - - fourth line after blanks $2 - """ - "with/without placeholder": - prefix: "t8" - body: """ - with placeholder ${1:test} - without placeholder ${2} - """ - - "multi-caret": - prefix: "t9" - body: """ - with placeholder ${1:test} - without placeholder $1 - """ - - "multi-caret-multi-tabstop": - prefix: "t9b" - body: """ - with placeholder ${1:test} - without placeholder $1 - second tabstop $2 - third tabstop $3 - """ - - "large indices": - prefix: "t10" - body: """ - hello${10} ${11:large} indices${1} - """ - - "no body": - prefix: "bad1" - - "number body": - prefix: "bad2" - body: 100 - - "many tabstops": - prefix: "t11" - body: """ - $0one${1} ${2:two} three${3} - """ - - "simple transform": - prefix: "t12" - body: """ - [${1:b}][/${1/[ ]+.*$//}] - """ - "transform with non-transforming mirrors": - prefix: "t13" - body: """ - ${1:placeholder}\n${1/(.)/\\u$1/}\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 - """ - "has a transformed tab stop without a corresponding ordinary tab stop": - prefix: 't15' - body: """ - ${1/(.)/\\u$1/} & $2 - """ - "has a transformed tab stop that occurs before the corresponding ordinary tab stop": - prefix: 't16' - body: """ - & ${1/(.)/\\u$1/} & ${1:q} - """ - "has a placeholder that mirrors another tab stop's content": - prefix: 't17' - body: "$4console.${3:log}('${2:uh $1}', $1);$0" - "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/./=/}' - - it "parses snippets once, reusing cached ones on subsequent queries", -> - spyOn(Snippets, "getBodyParser").andCallThrough() - - editor.insertText("t1") - simulateTabKeyEvent() - - expect(Snippets.getBodyParser).toHaveBeenCalled() - expect(editor.lineTextForBufferRow(0)).toBe "this is a testvar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 14] - - Snippets.getBodyParser.reset() - - editor.setText("") - editor.insertText("t1") - simulateTabKeyEvent() - - expect(Snippets.getBodyParser).not.toHaveBeenCalled() - expect(editor.lineTextForBufferRow(0)).toBe "this is a test" - expect(editor.getCursorScreenPosition()).toEqual [0, 14] - - Snippets.getBodyParser.reset() - - Snippets.add __filename, - ".source.js": - "invalidate previous snippet": - prefix: "t1" - body: "new snippet" - - editor.setText("") - editor.insertText("t1") - simulateTabKeyEvent() - - expect(Snippets.getBodyParser).toHaveBeenCalled() - expect(editor.lineTextForBufferRow(0)).toBe "new snippet" - expect(editor.getCursorScreenPosition()).toEqual [0, 11] - - describe "when the snippet body is invalid or missing", -> - it "does not register the snippet", -> - editor.setText('') - editor.insertText('bad1') - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.getText()).toBe 'bad1' - - editor.setText('') - editor.setText('bad2') - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.getText()).toBe 'bad2' - - describe "when the letters preceding the cursor trigger a snippet", -> - describe "when the snippet contains no tab stops", -> - it "replaces the prefix with the snippet text and places the cursor at its end", -> - editor.insertText("t1") - expect(editor.getCursorScreenPosition()).toEqual [0, 2] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "this is a testvar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 14] - - it "inserts a real tab the next time a tab is pressed after the snippet is expanded", -> - editor.insertText("t1") - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "this is a testvar quicksort = function () {" - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "this is a test var quicksort = function () {" - - describe "when the snippet contains tab stops", -> - it "places the cursor at the first tab-stop, and moves the cursor in response to 'next-tab-stop' events", -> - markerCountBefore = editor.getMarkerCount() - editor.setCursorScreenPosition([2, 0]) - editor.insertText('t2') - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(2)).toBe "go here next:() and finally go here:()" - expect(editor.lineTextForBufferRow(3)).toBe "go here first:()" - expect(editor.lineTextForBufferRow(4)).toBe " if (items.length <= 1) return items;" - expect(editor.getSelectedBufferRange()).toEqual [[3, 15], [3, 15]] - - simulateTabKeyEvent() - expect(editor.getSelectedBufferRange()).toEqual [[2, 14], [2, 14]] - editor.insertText 'abc' - - simulateTabKeyEvent() - expect(editor.getSelectedBufferRange()).toEqual [[2, 40], [2, 40]] - - # tab backwards - simulateTabKeyEvent(shift: true) - expect(editor.getSelectedBufferRange()).toEqual [[2, 14], [2, 17]] # should highlight text typed at tab stop - - simulateTabKeyEvent(shift: true) - expect(editor.getSelectedBufferRange()).toEqual [[3, 15], [3, 15]] - - # shift-tab on first tab-stop does nothing - simulateTabKeyEvent(shift: true) - expect(editor.getCursorScreenPosition()).toEqual [3, 15] - - # tab through all tab stops, then tab on last stop to terminate snippet - simulateTabKeyEvent() - simulateTabKeyEvent() - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(2)).toBe "go here next:(abc) and finally go here:( )" - expect(editor.getMarkerCount()).toBe markerCountBefore - - describe "when tab stops are nested", -> - it "destroys the inner tab stop if the outer tab stop is modified", -> - editor.setText('') - editor.insertText 't5' - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.lineTextForBufferRow(0)).toBe '"key": value' - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 5]] - editor.insertText("foo") - simulateTabKeyEvent() - expect(editor.getSelectedBufferRange()).toEqual [[0, 5], [0, 10]] - - describe "when the only tab stop is an end stop", -> - it "terminates the snippet immediately after moving the cursor to the end stop", -> - editor.setText('') - editor.insertText 't1a' - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "something strange" - expect(editor.getCursorBufferPosition()).toEqual [0, 10] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "something strange" - expect(editor.getCursorBufferPosition()).toEqual [0, 12] - - describe "when tab stops are separated by blank lines", -> - it "correctly places the tab stops (regression)", -> - editor.setText('') - editor.insertText 't7' - atom.commands.dispatch editorElement, 'snippets:expand' - atom.commands.dispatch editorElement, 'snippets:next-tab-stop' - expect(editor.getCursorBufferPosition()).toEqual [3, 25] - - describe "when the cursor is moved beyond the bounds of the current tab stop", -> - it "terminates the snippet", -> - editor.setCursorScreenPosition([2, 0]) - editor.insertText('t2') - simulateTabKeyEvent() - - editor.moveUp() - editor.moveLeft() - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(2)).toBe "go here next:( ) and finally go here:()" - expect(editor.getCursorBufferPosition()).toEqual [2, 16] - - # test we can terminate with shift-tab - editor.setCursorScreenPosition([4, 0]) - editor.insertText('t2') - simulateTabKeyEvent() - simulateTabKeyEvent() - - editor.moveRight() - simulateTabKeyEvent(shift: true) - expect(editor.getCursorBufferPosition()).toEqual [4, 15] - - describe "when the cursor is moved within the bounds of the current tab stop", -> - it "should not terminate the snippet", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t8') - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder test" - editor.moveRight() - editor.moveLeft() - editor.insertText("foo") - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder tesfoot" - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder var quicksort = function () {" - editor.insertText("test") - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder testvar quicksort = function () {" - editor.moveLeft() - editor.insertText("foo") - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder tesfootvar quicksort = function () {" - - describe "when the backspace is press within the bounds of the current tab stop", -> - it "should not terminate the snippet", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t8') - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder test" - editor.moveRight() - editor.backspace() - editor.insertText("foo") - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder tesfoo" - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder var quicksort = function () {" - editor.insertText("test") - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder testvar quicksort = function () {" - editor.backspace() - editor.insertText("foo") - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder tesfoovar quicksort = function () {" - - describe "when the snippet contains hard tabs", -> - describe "when the edit session is in soft-tabs mode", -> - it "translates hard tabs in the snippet to the appropriate number of spaces", -> - expect(editor.getSoftTabs()).toBeTruthy() - editor.insertText("t3") - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(1)).toBe " line 2" - expect(editor.getCursorBufferPosition()).toEqual [1, 8] - - describe "when the edit session is in hard-tabs mode", -> - it "inserts hard tabs in the snippet directly", -> - editor.setSoftTabs(false) - editor.insertText("t3") - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(1)).toBe "\tline 2" - expect(editor.getCursorBufferPosition()).toEqual [1, 7] - - describe "when the snippet prefix is indented", -> - describe "when the snippet spans a single line", -> - it "does not indent the next line", -> - editor.setCursorScreenPosition([2, Infinity]) - editor.insertText ' t1' - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.lineTextForBufferRow(3)).toBe " var pivot = items.shift(), current, left = [], right = [];" - - describe "when the snippet spans multiple lines", -> - it "indents the subsequent lines of the snippet to be even with the start of the first line", -> - expect(editor.getSoftTabs()).toBeTruthy() - editor.setCursorScreenPosition([2, Infinity]) - editor.insertText ' t3' - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.lineTextForBufferRow(2)).toBe " if (items.length <= 1) return items; line 1" - expect(editor.lineTextForBufferRow(3)).toBe " line 2" - expect(editor.getCursorBufferPosition()).toEqual [3, 12] - - describe "when the snippet spans multiple lines", -> - beforeEach -> - editor.update({autoIndent: true}) - # editor.update() returns a Promise that never gets resolved, so we - # need to return undefined to avoid a timeout in the spec. - # TODO: Figure out why `editor.update({autoIndent: true})` never gets resolved. - return - - it "places tab stops correctly", -> - expect(editor.getSoftTabs()).toBeTruthy() - editor.setCursorScreenPosition([2, Infinity]) - editor.insertText ' t3' - atom.commands.dispatch editorElement, 'snippets:expand' - expect(editor.getCursorBufferPosition()).toEqual [3, 12] - atom.commands.dispatch editorElement, 'snippets:next-tab-stop' - expect(editor.getCursorBufferPosition()).toEqual [4, 4] - - it "indents the subsequent lines of the snippet based on the indent level before the snippet is inserted", -> - editor.setCursorScreenPosition([2, Infinity]) - editor.insertNewline() - editor.insertText 't4b' - atom.commands.dispatch editorElement, 'snippets:expand' - - expect(editor.lineTextForBufferRow(3)).toBe " = line 1 {" # 4 + 1 spaces (because the tab stop is invisible) - expect(editor.lineTextForBufferRow(4)).toBe " line 2" - expect(editor.lineTextForBufferRow(5)).toBe " }" - expect(editor.getCursorBufferPosition()).toEqual [3, 4] - - it "does not change the relative positioning of the tab stops when inserted multiple times", -> - editor.setCursorScreenPosition([2, Infinity]) - editor.insertNewline() - editor.insertText 't4' - atom.commands.dispatch editorElement, 'snippets:expand' - - expect(editor.getSelectedBufferRange()).toEqual [[3, 9], [3, 10]] - atom.commands.dispatch editorElement, 'snippets:next-tab-stop' - expect(editor.getSelectedBufferRange()).toEqual [[4, 6], [4, 13]] - - editor.insertText 't4' - atom.commands.dispatch editorElement, 'snippets:expand' - - expect(editor.getSelectedBufferRange()).toEqual [[4, 11], [4, 12]] - atom.commands.dispatch editorElement, 'snippets:next-tab-stop' - expect(editor.getSelectedBufferRange()).toEqual [[5, 8], [5, 15]] - - editor.setText('') # Clear editor - editor.insertText 't4' - atom.commands.dispatch editorElement, 'snippets:expand' - - expect(editor.getSelectedBufferRange()).toEqual [[0, 5], [0, 6]] - atom.commands.dispatch editorElement, 'snippets:next-tab-stop' - expect(editor.getSelectedBufferRange()).toEqual [[1, 2], [1, 9]] - - describe "when multiple snippets match the prefix", -> - it "expands the snippet that is the longest match for the prefix", -> - editor.insertText('t113') - expect(editor.getCursorScreenPosition()).toEqual [0, 4] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "t113 var quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 6] - - editor.undo() - editor.undo() - - editor.insertText("tt1") - expect(editor.getCursorScreenPosition()).toEqual [0, 3] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "this is another testvar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 20] - - editor.undo() - editor.undo() - - editor.insertText("@t1") - expect(editor.getCursorScreenPosition()).toEqual [0, 3] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "@this is a testvar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 15] - - describe "when the word preceding the cursor ends with a snippet prefix", -> - it "inserts a tab as normal", -> - editor.insertText("t1t1t1") - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "t1t1t1 var quicksort = function () {" - - describe "when the letters preceding the cursor don't match a snippet", -> - it "inserts a tab as normal", -> - editor.insertText("xxte") - expect(editor.getCursorScreenPosition()).toEqual [0, 4] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "xxte var quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 6] - - describe "when text is selected", -> - it "inserts a tab as normal", -> - editor.insertText("t1") - editor.setSelectedBufferRange([[0, 0], [0, 2]]) - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe " t1var quicksort = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 0], [0, 4]] - - describe "when a previous snippet expansion has just been undone", -> - describe "when the tab stops appear in the middle of the snippet", -> - it "expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", -> - editor.insertText 't6\n' - editor.setCursorBufferPosition [0, 2] - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "first line" - editor.undo() - expect(editor.lineTextForBufferRow(0)).toBe "t6" - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "first line" - - describe "when the tab stops appear at the beginning and then the end of snippet", -> - it "expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", -> - editor.insertText 't6b\n' - editor.setCursorBufferPosition [0, 3] - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "expanded" - editor.undo() - expect(editor.lineTextForBufferRow(0)).toBe "t6b" - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "expanded" - expect(editor.getCursorBufferPosition()).toEqual([0, 0]) - - describe "when the tab stops appear at the end and then the beginning of snippet", -> - it "expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", -> - editor.insertText 't6c\n' - editor.setCursorBufferPosition [0, 3] - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "expanded" - editor.undo() - expect(editor.lineTextForBufferRow(0)).toBe "t6c" - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "expanded" - expect(editor.getCursorBufferPosition()).toEqual([0, 8]) - - describe "when the prefix contains non-word characters", -> - it "selects the non-word characters as part of the prefix", -> - editor.insertText("@unique") - expect(editor.getCursorScreenPosition()).toEqual [0, 7] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "@unique seevar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 11] - - editor.setCursorBufferPosition [10, 0] - editor.insertText("'@unique") - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(10)).toBe "'@unique see" - expect(editor.getCursorScreenPosition()).toEqual [10, 12] - - it "does not select the whitespace before the prefix", -> - editor.insertText("a; @unique") - expect(editor.getCursorScreenPosition()).toEqual [0, 10] - - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "a; @unique seevar quicksort = function () {" - expect(editor.getCursorScreenPosition()).toEqual [0, 14] - - describe "when snippet contains tabstops with or without placeholder", -> - it "should create two markers", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t8') - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder test" - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder var quicksort = function () {" - - expect(editor.getSelectedBufferRange()).toEqual [[0, 17], [0, 21]] - - simulateTabKeyEvent() - expect(editor.getSelectedBufferRange()).toEqual [[1, 20], [1, 20]] - - describe "when snippet contains multi-caret tabstops with or without placeholder", -> - it "should create two markers", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t9') - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder test" - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder var quicksort = function () {" - editor.insertText('hello') - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder hello" - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder hellovar quicksort = function () {" - - it "terminates the snippet when cursors are destroyed", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t9b') - simulateTabKeyEvent() - editor.getCursors()[0].destroy() - editor.getCursorBufferPosition() - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(1)).toEqual("without placeholder ") - - it "terminates the snippet expansion if a new cursor moves outside the bounds of the tab stops", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t9b') - simulateTabKeyEvent() - editor.insertText('test') - - editor.getCursors()[0].destroy() - editor.moveDown() # this should destroy the previous expansion - editor.moveToBeginningOfLine() - - # this should insert whitespace instead of going through tabstops of the previous destroyed snippet - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(2).indexOf(" second")).toBe 0 - - it "moves to the second tabstop after a multi-caret tabstop", -> - editor.setCursorScreenPosition([0, 0]) - editor.insertText('t9b') - simulateTabKeyEvent() - editor.insertText('line 1') - - simulateTabKeyEvent() - editor.insertText('line 2') - - simulateTabKeyEvent() - editor.insertText('line 3') - - expect(editor.lineTextForBufferRow(2).indexOf("line 2 ")).toBe -1 - - it "mirrors input properly when a tabstop's placeholder refers to another tabstop", -> - editor.setText('t17') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - editor.insertText("foo") - expect(editor.getText()).toBe "console.log('uh foo', foo);" - simulateTabKeyEvent() - editor.insertText("bar") - expect(editor.getText()).toBe "console.log('bar', foo);" - - describe "when the snippet contains tab stops with transformations", -> - it "transforms the text typed into the first tab stop before setting it in the transformed tab stop", -> - editor.setText('t12') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.getText()).toBe("[b][/b]") - editor.insertText('img src') - expect(editor.getText()).toBe("[img src][/img]") - - it "bundles the transform mutations along with the original manual mutation for the purposes of undo and redo", -> - editor.setText('t12') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - editor.insertText('i') - expect(editor.getText()).toBe("[i][/i]") - - editor.insertText('mg src') - expect(editor.getText()).toBe("[img src][/img]") - - editor.undo() - expect(editor.getText()).toBe("[i][/i]") - - editor.redo() - expect(editor.getText()).toBe("[img src][/img]") - - it "can pick the right insertion to use as the primary even if a transformed insertion occurs first in the snippet", -> - editor.setText('t16') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe("& Q & q") - expect(editor.getCursorBufferPosition()).toEqual([0, 7]) - - editor.insertText('rst') - expect(editor.lineTextForBufferRow(0)).toBe("& RST & rst") - - it "silently ignores a tab stop without a non-transformed insertion to use as the primary", -> - editor.setText('t15') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - editor.insertText('a') - expect(editor.lineTextForBufferRow(0)).toBe(" & a") - expect(editor.getCursorBufferPosition()).toEqual([0, 4]) - - describe "when the snippet contains mirrored tab stops and tab stops with transformations", -> - it "adds cursors for the mirrors but not the transformations", -> - editor.setText('t13') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.getCursors().length).toBe(2) - expect(editor.getText()).toBe """ - placeholder - PLACEHOLDER - - """ - - editor.insertText('foo') - - expect(editor.getText()).toBe """ - foo - FOO - foo - """ - - describe "when the snippet contains multiple tab stops, some with transformations and some without", -> - it "does not get confused", -> - editor.setText('t14') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.getCursors().length).toBe(2) - expect(editor.getText()).toBe "placeholder PLACEHOLDER ANOTHER another " - simulateTabKeyEvent() - expect(editor.getCursors().length).toBe(2) - editor.insertText('FOO') - expect(editor.getText()).toBe """ - placeholder PLACEHOLDER FOO foo FOO - """ - - 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') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.getText()).toBe("// \n// ") - expect(editor.getCursorBufferPosition()).toEqual [0, 3] - editor.insertText('wat') - expect(editor.getText()).toBe("// wat\n// ===") - # Move the cursor down one line, then up one line. This puts the cursor - # back in its previous position, but the snippet should no longer be - # active, so when we type more text, it should not be mirrored. - editor.setCursorScreenPosition([1, 6]) - editor.setCursorScreenPosition([0, 6]) - editor.insertText('wat') - expect(editor.getText()).toBe("// watwat\n// ===") - - - describe "when the snippet contains tab stops with an index >= 10", -> - it "parses and orders the indices correctly", -> - editor.setText('t10') - editor.setCursorScreenPosition([0, 3]) - simulateTabKeyEvent() - expect(editor.getText()).toBe "hello large indices" - expect(editor.getCursorBufferPosition()).toEqual [0, 19] - simulateTabKeyEvent() - expect(editor.getCursorBufferPosition()).toEqual [0, 5] - simulateTabKeyEvent() - expect(editor.getSelectedBufferRange()).toEqual [[0, 6], [0, 11]] - - describe "when there are multiple cursors", -> - describe "when the cursors share a common snippet prefix", -> - it "expands the snippet for all cursors and allows simultaneous editing", -> - editor.insertText('t9') - editor.setCursorBufferPosition([12, 2]) - editor.insertText(' t9') - editor.addCursorAtBufferPosition([0, 2]) - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder test" - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder var quicksort = function () {" - expect(editor.lineTextForBufferRow(13)).toBe "}; with placeholder test" - expect(editor.lineTextForBufferRow(14)).toBe "without placeholder " - - editor.insertText('hello') - expect(editor.lineTextForBufferRow(0)).toBe "with placeholder hello" - expect(editor.lineTextForBufferRow(1)).toBe "without placeholder hellovar quicksort = function () {" - expect(editor.lineTextForBufferRow(13)).toBe "}; with placeholder hello" - expect(editor.lineTextForBufferRow(14)).toBe "without placeholder hello" - - it "applies transformations identically to single-expansion mode", -> - editor.setText('t14\nt14') - editor.setCursorBufferPosition([1, 3]) - editor.addCursorAtBufferPosition([0, 3]) - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "placeholder PLACEHOLDER ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "placeholder PLACEHOLDER ANOTHER another " - - editor.insertText "testing" - - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing ANOTHER another " - - simulateTabKeyEvent() - editor.insertText "AGAIN" - - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing AGAIN again AGAIN" - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing AGAIN again AGAIN" - - it "bundles transform-induced mutations into a single history entry along with their triggering edit, even across multiple snippets", -> - editor.setText('t14\nt14') - editor.setCursorBufferPosition([1, 3]) - editor.addCursorAtBufferPosition([0, 3]) - simulateTabKeyEvent() - - expect(editor.lineTextForBufferRow(0)).toBe "placeholder PLACEHOLDER ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "placeholder PLACEHOLDER ANOTHER another " - - editor.insertText "testing" - - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing ANOTHER another " - - simulateTabKeyEvent() - editor.insertText "AGAIN" - - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing AGAIN again AGAIN" - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing AGAIN again AGAIN" - - editor.undo() - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing ANOTHER another " - - editor.undo() - expect(editor.lineTextForBufferRow(0)).toBe "placeholder PLACEHOLDER ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "placeholder PLACEHOLDER ANOTHER another " - - editor.redo() - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing ANOTHER another " - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing ANOTHER another " - - editor.redo() - expect(editor.lineTextForBufferRow(0)).toBe "testing TESTING testing AGAIN again AGAIN" - expect(editor.lineTextForBufferRow(1)).toBe "testing TESTING testing AGAIN again AGAIN" - - describe "when there are many tabstops", -> - it "moves the cursors between the tab stops for their corresponding snippet when tab and shift-tab are pressed", -> - editor.addCursorAtBufferPosition([7, 5]) - editor.addCursorAtBufferPosition([12, 2]) - editor.insertText('t11') - simulateTabKeyEvent() - - cursors = editor.getCursors() - expect(cursors.length).toEqual 3 - - expect(cursors[0].getBufferPosition()).toEqual [0, 3] - expect(cursors[1].getBufferPosition()).toEqual [7, 8] - expect(cursors[2].getBufferPosition()).toEqual [12, 5] - expect(cursors[0].selection.isEmpty()).toBe true - expect(cursors[1].selection.isEmpty()).toBe true - expect(cursors[2].selection.isEmpty()).toBe true - - simulateTabKeyEvent() - expect(cursors[0].getBufferPosition()).toEqual [0, 7] - expect(cursors[1].getBufferPosition()).toEqual [7, 12] - expect(cursors[2].getBufferPosition()).toEqual [12, 9] - expect(cursors[0].selection.isEmpty()).toBe false - expect(cursors[1].selection.isEmpty()).toBe false - expect(cursors[2].selection.isEmpty()).toBe false - expect(cursors[0].selection.getText()).toEqual 'two' - expect(cursors[1].selection.getText()).toEqual 'two' - expect(cursors[2].selection.getText()).toEqual 'two' - - simulateTabKeyEvent() - expect(cursors[0].getBufferPosition()).toEqual [0, 13] - expect(cursors[1].getBufferPosition()).toEqual [7, 18] - expect(cursors[2].getBufferPosition()).toEqual [12, 15] - expect(cursors[0].selection.isEmpty()).toBe true - expect(cursors[1].selection.isEmpty()).toBe true - expect(cursors[2].selection.isEmpty()).toBe true - - simulateTabKeyEvent() - expect(cursors[0].getBufferPosition()).toEqual [0, 0] - expect(cursors[1].getBufferPosition()).toEqual [7, 5] - expect(cursors[2].getBufferPosition()).toEqual [12, 2] - expect(cursors[0].selection.isEmpty()).toBe true - expect(cursors[1].selection.isEmpty()).toBe true - expect(cursors[2].selection.isEmpty()).toBe true - - describe "when the cursors do not share common snippet prefixes", -> - it "inserts tabs as normal", -> - editor.insertText('t9') - editor.setCursorBufferPosition([12, 2]) - editor.insertText(' t8') - editor.addCursorAtBufferPosition([0, 2]) - simulateTabKeyEvent() - expect(editor.lineTextForBufferRow(0)).toBe "t9 var quicksort = function () {" - expect(editor.lineTextForBufferRow(12)).toBe "}; t8 " - - describe "when a snippet is triggered within an existing snippet expansion", -> - it "ignores the snippet expansion and goes to the next tab stop", -> - editor.addCursorAtBufferPosition([7, 5]) - editor.addCursorAtBufferPosition([12, 2]) - editor.insertText('t11') - simulateTabKeyEvent() - simulateTabKeyEvent() - - editor.insertText('t1') - simulateTabKeyEvent() - - cursors = editor.getCursors() - expect(cursors.length).toEqual 3 - - expect(cursors[0].getBufferPosition()).toEqual [0, 12] - expect(cursors[1].getBufferPosition()).toEqual [7, 17] - expect(cursors[2].getBufferPosition()).toEqual [12, 14] - expect(cursors[0].selection.isEmpty()).toBe true - expect(cursors[1].selection.isEmpty()).toBe true - expect(cursors[2].selection.isEmpty()).toBe true - expect(editor.lineTextForBufferRow(0)).toBe "one t1 threevar quicksort = function () {" - expect(editor.lineTextForBufferRow(7)).toBe " }one t1 three" - expect(editor.lineTextForBufferRow(12)).toBe "};one t1 three" - - describe "when the editor is not a pane item (regression)", -> - it "handles tab stops correctly", -> - editor = new TextEditor() - atom.grammars.assignLanguageMode(editor, 'source.js') - editorElement = editor.getElement() - - editor.insertText('t2') - simulateTabKeyEvent() - editor.insertText('ABC') - expect(editor.getText()).toContain('go here first:(ABC)') - - editor.undo() - editor.undo() - expect(editor.getText()).toBe('t2') - simulateTabKeyEvent() - editor.insertText('ABC') - expect(editor.getText()).toContain('go here first:(ABC)') - - describe "when atom://.atom/snippets is opened", -> - it "opens ~/.atom/snippets.cson", -> - jasmine.unspy(Snippets, 'getUserSnippetsPath') - atom.workspace.destroyActivePaneItem() - configDirPath = temp.mkdirSync('atom-config-dir-') - spyOn(atom, 'getConfigDirPath').andReturn configDirPath - atom.workspace.open('atom://.atom/snippets') - - waitsFor -> - atom.workspace.getActiveTextEditor()? - - runs -> - expect(atom.workspace.getActiveTextEditor().getURI()).toBe path.join(configDirPath, 'snippets.cson') - - describe "snippet insertion API", -> - it "will automatically parse snippet definition and replace selection", -> - editor.setSelectedBufferRange([[0, 4], [0, 13]]) - Snippets.insert("hello ${1:world}", editor) - - expect(editor.lineTextForBufferRow(0)).toBe "var hello world = function () {" - expect(editor.getSelectedBufferRange()).toEqual [[0, 10], [0, 15]] - - describe "when the 'snippets:available' command is triggered", -> - availableSnippetsView = null - - beforeEach -> - Snippets.add __filename, - ".source.js": - "test": - prefix: "test" - body: "${1:Test pass you will}, young " - - "challenge": - prefix: "chal" - body: "$1: ${2:To pass this challenge}" - - delete Snippets.availableSnippetsView - - atom.commands.dispatch(editorElement, "snippets:available") - - waitsFor -> - atom.workspace.getModalPanels().length is 1 - - runs -> - availableSnippetsView = atom.workspace.getModalPanels()[0].getItem() - - it "renders a select list of all available snippets", -> - expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe 'test' - expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe 'test' - expect(availableSnippetsView.selectListView.getSelectedItem().bodyText).toBe '${1:Test pass you will}, young ' - - availableSnippetsView.selectListView.selectNext() - - expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe 'chal' - expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe 'challenge' - expect(availableSnippetsView.selectListView.getSelectedItem().bodyText).toBe '$1: ${2:To pass this challenge}' - - it "writes the selected snippet to the editor as snippet", -> - availableSnippetsView.selectListView.confirmSelection() - - expect(editor.getCursorScreenPosition()).toEqual [0, 18] - expect(editor.getSelectedText()).toBe 'Test pass you will' - expect(editor.lineTextForBufferRow(0)).toBe 'Test pass you will, young var quicksort = function () {' - - it "closes the dialog when triggered again", -> - atom.commands.dispatch availableSnippetsView.selectListView.refs.queryEditor.element, 'snippets:available' - expect(atom.workspace.getModalPanels().length).toBe 0 diff --git a/spec/snippets-spec.js b/spec/snippets-spec.js new file mode 100644 index 00000000..17d3a93c --- /dev/null +++ b/spec/snippets-spec.js @@ -0,0 +1,1042 @@ +const path = require('path') +const temp = require('temp').track() +const CSON = require('season') +const Snippets = require('../lib/snippets') + +describe('Snippets extension', () => { + let editorElement + let editor + + function simulateTabKeyEvent ({shift}={}) { + const event = atom.keymaps.constructor.buildKeydownEvent('tab', {shift, target: editorElement}) + atom.keymaps.handleKeyboardEvent(event) + } + + function expandSnippetUnderCursor () { + atom.commands.dispatch(editorElement, 'snippets:expand') + } + + function gotoNextTabstop () { + atom.commands.dispatch(editorElement, 'snippets:next-tab-stop') + } + + function gotoPreviousTabstop () { + atom.commands.dispatch(editorElement, 'snippets:previous-tab-stop') + } + + // NOTE: Required for undo behaviour to work as if text was + // typed. Time based, so possibly flaky in CI. Increase + // the grouping interval (ms) to be more lenient with the + // grouping. + function editorTransact (input) { + editor.transact(300, () => { + editor.insertText(input) + }) + } + + beforeEach(() => { + spyOn(Snippets, 'loadAll') + spyOn(Snippets, 'getUserSnippetsPath').andReturn('') + + waitsForPromise(() => atom.workspace.open()) + waitsForPromise(() => atom.packages.activatePackage('snippets')) + + runs(() => { + editor = atom.workspace.getActiveTextEditor() + editorElement = atom.views.getView(editor) + }) + }) + + afterEach(() => { + waitsForPromise(() => atom.packages.deactivatePackage('snippets')) + }) + + describe('provideSnippets interface', () => { + let snippetsInterface = null + + beforeEach(() => { + snippetsInterface = Snippets.provideSnippets() + }) + + describe('bundledSnippetsLoaded', () => { + it('indicates the loaded state of the bundled snippets', () => { + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false) + Snippets.doneLoading() + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true) + }) + + it('resets the loaded state after snippets is deactivated', () => { + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false) + Snippets.doneLoading() + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true) + + waitsForPromise(() => atom.packages.deactivatePackage('snippets')) + waitsForPromise(() => atom.packages.activatePackage('snippets')) + + runs(() => { + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false) + Snippets.doneLoading() + expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true) + }) + }) + }) + + describe('insertSnippet', () => { + it('can insert a snippet', () => { + editor.setText('var quicksort = function () {') + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + snippetsInterface.insertSnippet("hello world", editor) + expect(editor.lineTextForBufferRow(0)).toBe("var hello world = function () {") + }) + }) + }) + + it('returns false for snippetToExpandUnderCursor if getSnippets returns {}', () => { + snippets = atom.packages.getActivePackage('snippets').mainModule + expect(snippets.snippetToExpandUnderCursor(editor)).toEqual(false) + }) + + it('ignores invalid snippets in the config', () => { + snippets = atom.packages.getActivePackage('snippets').mainModule + + invalidSnippets = null + spyOn(snippets.scopedPropertyStore, 'getPropertyValue').andCallFake(() => invalidSnippets) + expect(snippets.getSnippets(editor)).toEqual({}) + + invalidSnippets = 'test' + expect(snippets.getSnippets(editor)).toEqual({}) + + invalidSnippets = [] + expect(snippets.getSnippets(editor)).toEqual({}) + + invalidSnippets = 3 + expect(snippets.getSnippets(editor)).toEqual({}) + + invalidSnippets = {a: null} + expect(snippets.getSnippets(editor)).toEqual({}) + }) + + describe('when null snippets are present', () => { + beforeEach(() => { + Snippets.add(__filename, { + '.source.js': { + 'some snippet': { + prefix: 't1', + body: 'this is a test' + } + }, + '.source.js .nope': { + 'some snippet': { + prefix: 't1', + body: null + } + } + }) + }) + + it('overrides the less-specific defined snippet', () => { + snippets = Snippets.provideSnippets() + expect(snippets.snippetsForScopes(['.source.js'])['t1']).toBeTruthy() + expect(snippets.snippetsForScopes(['.source.js .nope.not-today'])['t1']).toBeFalsy() + }) + }) + + describe('when "tab" is triggered on the editor', () => { + const testSnippets = CSON.readFileSync(path.join(__dirname, 'fixtures', 'test-snippets.cson')) + + beforeEach(() => { + Snippets.add(__filename, testSnippets) + editor.setSoftTabs(false) // hard tabs are easier to reason with + editor.setText('') + }) + + it('parses snippets once, reusing cached ones on subsequent queries', () => { + spyOn(Snippets, 'getBodyParser').andCallThrough() + editor.setText('var quicksort = function () {') + editor.setCursorBufferPosition([0, 0]) + editor.insertText('t1') + simulateTabKeyEvent() + + expect(Snippets.getBodyParser).toHaveBeenCalled() + expect(editor.lineTextForBufferRow(0)).toBe('this is a testvar quicksort = function () {') + expect(editor.getCursorScreenPosition()).toEqual([0, 14]) + + Snippets.getBodyParser.reset() + + editor.setText('') + editor.insertText('t1') + simulateTabKeyEvent() + + expect(Snippets.getBodyParser).not.toHaveBeenCalled() + expect(editor.lineTextForBufferRow(0)).toBe('this is a test') + expect(editor.getCursorScreenPosition()).toEqual([0, 14]) + + Snippets.getBodyParser.reset() + + Snippets.add(__filename, { + '*': { + 'invalidate previous snippet': { + prefix: 't1', + body: 'new snippet' + } + } + }) + + editor.setText('') + editor.insertText('t1') + simulateTabKeyEvent() + + expect(Snippets.getBodyParser).toHaveBeenCalled() + expect(editor.lineTextForBufferRow(0)).toBe('new snippet') + expect(editor.getCursorScreenPosition()).toEqual([0, 11]) + }) + + describe('when the snippet body is invalid or missing', () => { + it('does not register the snippet', () => { + editor.insertText('bad1') + expandSnippetUnderCursor() + expect(editor.getText()).toBe('bad1') + + editor.setText('') + editor.setText('bad2') + expandSnippetUnderCursor() + expect(editor.getText()).toBe('bad2') + }) + }) + + describe('when the letters preceding the cursor trigger a snippet', () => { + describe('when the snippet contains no tab stops', () => { + it('replaces the prefix with the snippet text and places the cursor at its end', () => { + editor.setText('hello world') + editor.setCursorBufferPosition([0, 6]) + editor.insertText('t1') + expect(editor.getCursorScreenPosition()).toEqual([0, 8]) + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('hello this is a testworld') + expect(editor.getCursorScreenPosition()).toEqual([0, 20]) + }) + + it('inserts a real tab the next time a tab is pressed after the snippet is expanded', () => { + editor.insertText('t1') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('this is a test') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('this is a test\t') + }) + }) + + describe('when the snippet contains tab stops', () => { + it('places the cursor at the first tab-stop, and moves the cursor in response to "next-tab-stop" events', () => { + markerCountBefore = editor.getMarkerCount() + editor.insertText('t2') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('go here next:() and finally go here:()') + expect(editor.lineTextForBufferRow(1)).toBe('go here first:()') + expect(editor.getSelectedBufferRange()).toEqual([[1, 15], [1, 15]]) + editor.insertText('abc') + + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 14], [0, 14]]) + + // tab backwards + simulateTabKeyEvent({shift: true}) + expect(editor.getSelectedBufferRange()).toEqual([[1, 15], [1, 18]]) // should highlight text typed at tab stop + + // shift-tab on first tab-stop does nothing + simulateTabKeyEvent({shift: true}) + expect(editor.getSelectedBufferRange()).toEqual([[1, 15], [1, 18]]) + + // jump to second tab-stop + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 14], [0, 14]]) + + // jump to end tab-stop + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 37], [0, 37]]) + + expect(editor.lineTextForBufferRow(0)).toBe('go here next:() and finally go here:()') + expect(editor.lineTextForBufferRow(1)).toBe('go here first:(abc)') + expect(editor.getMarkerCount()).toBe(markerCountBefore) + + // We have reached $0, so the next tab press should be an actual tab + simulateTabKeyEvent() + const firstLine = 'go here next:() and finally go here:(\t)'; + expect(editor.lineTextForBufferRow(0)).toBe(firstLine) + expect(editor.getSelectedBufferRange()).toEqual([[0, firstLine.length - 1], [0, firstLine.length - 1]]) + }) + + describe('when tab stops are nested', () => { + it('destroys the inner tab stop if the outer tab stop is modified', () => { + editor.insertText('t5') + expandSnippetUnderCursor() + expect(editor.lineTextForBufferRow(0)).toBe("'key': value") + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 5]]) + editor.insertText('foo') + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 10]]) + }) + }) + + describe('when the only tab stop is an end stop', () => { + it('terminates the snippet immediately after moving the cursor to the end stop', () => { + editor.insertText('t1a') + simulateTabKeyEvent() + + expect(editor.lineTextForBufferRow(0)).toBe('something strange') + expect(editor.getCursorBufferPosition()).toEqual([0, 10]) + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('something \t strange') + expect(editor.getCursorBufferPosition()).toEqual([0, 11]) + }) + }) + + describe('when tab stops are separated by blank lines', () => { + it('correctly places the tab stops (regression)', () => { + editor.insertText('t7') + expandSnippetUnderCursor() + gotoNextTabstop() + expect(editor.getCursorBufferPosition()).toEqual([3, 25]) + }) + }) + + describe('when the cursor is moved beyond the bounds of the current tab stop', () => { + it('terminates the snippet', () => { + editor.insertText('t2') + simulateTabKeyEvent() + + editor.moveUp() + editor.moveLeft() + simulateTabKeyEvent() + + expect(editor.lineTextForBufferRow(0)).toBe('go here next:(\t) and finally go here:()') + expect(editor.getCursorBufferPosition()).toEqual([0, 15]) + }) + }) + + describe('when the cursor is moved within the bounds of the current tab stop', () => { + it('should not terminate the snippet', () => { + editor.insertText('t8') + simulateTabKeyEvent() + + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder test') + editor.moveRight() + editor.moveLeft() + editor.insertText('foo') + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder tesfoot') + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder ') + editor.insertText('test') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder test') + editor.moveLeft() + editor.insertText('foo') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder tesfoot') + + simulateTabKeyEvent({shift: true}) + expect(editor.getSelectedBufferRange()).toEqual([[0, 17], [0, 24]]) + }) + }) + + describe('when the backspace is press within the bounds of the current tab stop', () => { + it('should not terminate the snippet', () => { + editor.insertText('t8') + simulateTabKeyEvent() + + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder test') + editor.moveRight() + editor.backspace() + editor.insertText('foo') + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder tesfoo') + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder ') + editor.insertText('test') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder test') + editor.backspace() + editor.insertText('foo') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder tesfoo') + }) + }) + + }) + + describe('when the snippet contains hard tabs', () => { + describe('when the edit session is in soft-tabs mode', () => { + beforeEach(() => editor.setSoftTabs(true)) + + it('translates hard tabs in the snippet to the appropriate number of spaces', () => { + expect(editor.getSoftTabs()).toBeTruthy() + editor.insertText('t3') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(1)).toBe(' line 2') + expect(editor.getCursorBufferPosition()).toEqual([1, 8]) + }) + }) + + describe('when the edit session is in hard-tabs mode', () => { + beforeEach(() => editor.setSoftTabs(false)) + + it('inserts hard tabs in the snippet directly', () => { + expect(editor.getSoftTabs()).toBeFalsy() + editor.insertText('t3') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(1)).toBe("\tline 2") + expect(editor.getCursorBufferPosition()).toEqual([1, 7]) + }) + }) + }) + + describe('when the snippet prefix is indented', () => { + describe('when the snippet spans a single line', () => { + it('does not indent the next line', () => { + editor.setText('first line\n\t\nthird line') + editor.setCursorScreenPosition([1, Infinity]) + editor.insertText('t1') + expect(editor.lineTextForBufferRow(1)).toBe('\tt1') + expandSnippetUnderCursor() + expect(editor.lineTextForBufferRow(2)).toBe('third line') + }) + }) + + describe('when the snippet spans multiple lines', () => { + it('indents the subsequent lines of the snippet to be even with the start of the first line', () => { + editor.setSoftTabs(true) + const tabSpace = editor.getTabText() + editor.setText(tabSpace + 't3') + expandSnippetUnderCursor() + expect(editor.lineTextForBufferRow(0)).toBe(tabSpace + 'line 1') + expect(editor.lineTextForBufferRow(1)).toBe(tabSpace + tabSpace + 'line 2') + gotoNextTabstop() + expect(editor.getCursorBufferPosition()).toEqual([2, tabSpace.length]) + }) + }) + }) + + describe('when the snippet spans multiple lines', () => { + beforeEach(() => { + // editor.update() returns a Promise that never gets resolved, so we + // need to return undefined to avoid a timeout in the spec. + // TODO: Figure out why `editor.update({autoIndent: true})` never gets resolved. + editor.update({autoIndent: true}) + }) + + it('places tab stops correctly', () => { + editor.insertText('t3') + expandSnippetUnderCursor() + expect(editor.getCursorBufferPosition()).toEqual([1, 7]) + gotoNextTabstop() + expect(editor.getCursorBufferPosition()).toEqual([2, 0]) + }) + + it('indents the subsequent lines of the snippet based on the indent level before the snippet is inserted', () => { + editor.insertText('\tt4b') + expandSnippetUnderCursor() + + expect(editor.lineTextForBufferRow(0)).toBe('\t = line 1 {') + expect(editor.lineTextForBufferRow(1)).toBe('\t line 2') + expect(editor.lineTextForBufferRow(2)).toBe('\t}') + expect(editor.getCursorBufferPosition()).toEqual([0, 1]) + }) + + it('does not change the relative positioning of the tab stops when inserted multiple times', () => { + editor.insertText('t4') + expandSnippetUnderCursor() + + expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 6]]) + gotoNextTabstop() + expect(editor.getSelectedBufferRange()).toEqual([[1, 2], [1, 9]]) + + editor.insertText('t4') + expandSnippetUnderCursor() + + expect(editor.getSelectedBufferRange()).toEqual([[1, 7], [1, 8]]) + gotoNextTabstop() + expect(editor.getSelectedBufferRange()).toEqual([[2, 4], [2, 11]]) // prefix was on line indented by 2 spaces + + editor.setText('') + editor.insertText('t4') + expandSnippetUnderCursor() + + expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 6]]) + gotoNextTabstop() + expect(editor.getSelectedBufferRange()).toEqual([[1, 2], [1, 9]]) + }) + }) + + describe('when multiple snippets match the prefix', () => { + it('expands the snippet that is the longest match for the prefix', () => { + editor.setText('t113') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('t113\t') + expect(editor.getCursorBufferPosition()).toEqual([0, 5]) + + editor.setText('tt1') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('this is another test') + expect(editor.getCursorBufferPosition()).toEqual([0, 20]) + + editor.setText('@t1') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('@this is a test') + expect(editor.getCursorBufferPosition()).toEqual([0, 15]) + }) + }) + }) + + describe('when the word preceding the cursor ends with a snippet prefix', () => { + it('inserts a tab as normal', () => { + editor.setText('t1t1t1') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('t1t1t1\t') + }) + }) + + describe("when the letters preceding the cursor don't match a snippet", () => { + it('inserts a tab as normal', () => { + editor.setText('xxte') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('xxte\t') + expect(editor.getCursorBufferPosition()).toEqual([0, 5]) + }) + }) + + describe('when text is selected', () => { + it('inserts a tab as normal', () => { + editor.setText('t1') + editor.setSelectedBufferRange([[0, 0], [0, 2]]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('\tt1') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + }) + }) + + describe('when a previous snippet expansion has just been undone', () => { + describe('when the tab stops appear in the middle of the snippet', () => { + it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { + editor.setText('t6\n') + editor.setCursorBufferPosition([0, 2]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('first line') + expect(editor.lineTextForBufferRow(1)).toBe(' placeholder ending second line') + + editor.undo() + expect(editor.lineTextForBufferRow(0)).toBe('t6') + expect(editor.lineTextForBufferRow(1)).toBe('') + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('first line') + expect(editor.lineTextForBufferRow(1)).toBe(' placeholder ending second line') + }) + }) + + describe('when the tab stops appear at the beginning and then the end of snippet', () => { + it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { + editor.insertText('t6b\n') + editor.setCursorBufferPosition([0, 3]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('expanded') + + editor.undo() + expect(editor.lineTextForBufferRow(0)).toBe('t6b') + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('expanded') + expect(editor.getCursorBufferPosition()).toEqual([0, 0]) + }) + }) + + describe('when the tab stops appear at the end and then the beginning of snippet', () => { + it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { + editor.insertText('t6c\n') + editor.setCursorBufferPosition ([0, 3]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('expanded') + + editor.undo() + expect(editor.lineTextForBufferRow(0)).toBe('t6c') + + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('expanded') + expect(editor.getCursorBufferPosition()).toEqual([0, 8]) + }) + }) + }) + + describe('when the prefix contains non-word characters', () => { + it('selects the non-word characters as part of the prefix', () => { + editor.setText("!@#$%^&*()-_=+[]{}54|\\;:?.,unique") + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe("@unique see") + expect(editor.getCursorScreenPosition()).toEqual([0, 11]) + + editor.setText("'!@#$%^&*()-_=+[]{}54|\\;:?.,unique") // has ' at start (this char is not in any loaded snippet prefix) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe("'@unique see") + expect(editor.getCursorBufferPosition()).toEqual([0, 12]) + }) + + it('does not select the whitespace before the prefix', () => { + editor.setText('a; !@#$%^&*()-_=+[]{}54|\\;:?.,unique') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('a; @unique see') + expect(editor.getCursorBufferPosition()).toEqual([0, 14]) + }) + }) + + describe('when snippet contains tabstops with and without placeholder', () => { + it('should create two markers', () => { + editor.setText('t8') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder test') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder ') + expect(editor.getSelectedBufferRange()).toEqual([[0, 17], [0, 21]]) + + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[1, 20], [1, 20]]) + }) + }) + + describe('when snippet contains multi-caret tabstops with and without placeholder', () => { + it('should create two markers', () => { + editor.setText('t9') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder test') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder ') + editor.insertText('hello') + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder hello') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder hello') + }) + + it('terminates the snippet when cursors are destroyed', () => { + editor.setText('t9b') + simulateTabKeyEvent() + editor.getCursors()[0].destroy() + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toEqual("with placeholder test") + expect(editor.lineTextForBufferRow(1)).toEqual("without placeholder \t") + }) + + it('terminates the snippet expansion if a new cursor moves outside the bounds of the tab stops', () => { + editor.setCursorScreenPosition([0, 0]) + editor.insertText('t9b') + simulateTabKeyEvent() + editor.insertText('test') + + editor.getCursors()[0].destroy() + editor.moveDown() // this should destroy the previous expansion + editor.moveToBeginningOfLine() + + // this should insert whitespace instead of going through tabstops of the previous destroyed snippet + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(2).indexOf("\tsecond")).toBe(0) + }) + + it('moves to the second tabstop after a multi-caret tabstop', () => { + editor.setText('t9b') + simulateTabKeyEvent() + editor.insertText('line 1') + + simulateTabKeyEvent() + editor.insertText('line 2') + + simulateTabKeyEvent() + editor.insertText('line 3') + + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder line 1') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder line 1') + expect(editor.lineTextForBufferRow(2)).toBe('second tabstop line 2') + expect(editor.lineTextForBufferRow(3)).toBe('third tabstop line 3') + }) + + it("mirrors input properly when a tabstop's placeholder refers to another tabstop", () => { + editor.setText('t17') + simulateTabKeyEvent() + editor.insertText('foo') + expect(editor.getText()).toBe("console.log('uh foo', foo);") + + simulateTabKeyEvent() + editor.insertText('bar') + expect(editor.getText()).toBe("console.log('bar', foo);") + }) + }) + + describe('when the snippet contains tab stops with transformations', () => { + it('transforms the text typed into the first tab stop before setting it in the transformed tab stop', () => { + editor.setText('t12') + simulateTabKeyEvent() + expect(editor.getText()).toBe('[b][/b]') + editor.insertText('img src') + expect(editor.getText()).toBe('[img src][/img]') + }) + + it('bundles the transform mutations along with the original manual mutation for the purposes of undo and redo', () => { + editor.setText('t12') + simulateTabKeyEvent() + editorTransact('i') + expect(editor.getText()).toBe("[i][/i]") + + editorTransact('mg src') + expect(editor.getText()).toBe("[img src][/img]") + + editor.undo() + expect(editor.getText()).toBe("[b][/b]") + + editor.redo() + expect(editor.getText()).toBe("[img src][/img]") + }) + + it('can pick the right insertion to use as the primary even if a transformed insertion occurs first in the snippet', () => { + editor.setText('t16') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('& Q & q') + expect(editor.getCursorBufferPosition()).toEqual([0, 7]) + + editor.insertText('rst') + expect(editor.lineTextForBufferRow(0)).toBe('& RST & rst') + }) + + it('silently ignores a tab stop without a non-transformed insertion to use as the primary', () => { + editor.setText('t15') + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe(' & ') + + editor.insertText('a') + expect(editor.lineTextForBufferRow(0)).toBe(' & a') + expect(editor.getCursorBufferPosition()).toEqual([0, 4]) + }) + }) + + describe('when the snippet contains mirrored tab stops and tab stops with transformations', () => { + it('adds cursors for the mirrors but not the transformations', () => { + editor.setText('t13') + simulateTabKeyEvent() + expect(editor.getCursors().length).toBe(2) + expect(editor.getText()).toBe('placeholder\nPLACEHOLDER\n') + + editor.insertText('foo') + expect(editor.getText()).toBe('foo\nFOO\nfoo') + }) + }) + + describe('when the snippet contains multiple tab stops, some with transformations and some without', () => { + it('does not get confused', () => { + editor.setText('t14') + simulateTabKeyEvent() + expect(editor.getCursors().length).toBe(2) + expect(editor.getText()).toBe('placeholder PLACEHOLDER ANOTHER another ') + + simulateTabKeyEvent() + expect(editor.getCursors().length).toBe(2) + + editor.insertText('FOO') + expect(editor.getText()).toBe('placeholder PLACEHOLDER FOO foo FOO') + }) + }) + + 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') + simulateTabKeyEvent() + expect(editor.getText()).toBe('// \n// ') + expect(editor.getCursorBufferPosition()).toEqual([0, 3]) + + editor.insertText('wat') + expect(editor.getText()).toBe('// wat\n// ===') + // Move the cursor down one line, then up one line. This puts the cursor + // back in its previous position, but the snippet should no longer be + // active, so when we type more text, it should not be mirrored. + editor.moveDown() + editor.moveUp() + editor.insertText('wat') + expect(editor.getText()).toBe('// watwat\n// ===') + }) + }) + + describe('when the snippet contains tab stops with an index >= 10', () => { + it('parses and orders the indices correctly', () => { + editor.setText('t10') + simulateTabKeyEvent() + expect(editor.getText()).toBe('hello large indices') + expect(editor.getCursorBufferPosition()).toEqual([0, 19]) + + simulateTabKeyEvent() + expect(editor.getCursorBufferPosition()).toEqual([0, 5]) + + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 6], [0, 11]]) + }) + }) + + describe('when the snippet has two adjacent tab stops', () => { + it('ensures insertions are treated as part of the active tab stop', () => { + editor.setText('t19') + editor.setCursorScreenPosition([0, 3]) + simulateTabKeyEvent() + expect(editor.getText()).toBe('barbaz') + expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 3]]) + editor.insertText('w') + expect(editor.getText()).toBe('wbaz') + editor.insertText('at') + expect(editor.getText()).toBe('watbaz') + simulateTabKeyEvent() + expect(editor.getSelectedBufferRange()).toEqual([[0, 3], [0, 6]]) + editor.insertText('foo') + expect(editor.getText()).toBe('watfoo') + }) + }) + + describe('when the snippet has a placeholder with a tabstop mirror at its edge', () => { + it('allows the associated marker to include the inserted text', () => { + editor.setText('t20') + editor.setCursorScreenPosition([0, 3]) + simulateTabKeyEvent() + expect(editor.getText()).toBe('foobarbaz ') + expect(editor.getCursors().length).toBe(2) + let selections = editor.getSelections() + expect(selections[0].getBufferRange()).toEqual([[0, 0], [0, 3]]) + expect(selections[1].getBufferRange()).toEqual([[0, 10], [0, 10]]) + editor.insertText('nah') + expect(editor.getText()).toBe('nahbarbaz nah') + simulateTabKeyEvent() + editor.insertText('meh') + simulateTabKeyEvent() + editor.insertText('yea') + expect(editor.getText()).toBe('nahmehyea') + }) + }) + + describe('when there are multiple cursors', () => { + describe('when the cursors share a common snippet prefix', () => { + it('expands the snippet for all cursors and allows simultaneous editing', () => { + editor.setText('t9\nt9') + editor.setCursorBufferPosition([0, 2]) + editor.addCursorAtBufferPosition([1, 2]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder test') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder ') + expect(editor.lineTextForBufferRow(2)).toBe('with placeholder test') + expect(editor.lineTextForBufferRow(3)).toBe('without placeholder ') + + editor.insertText('hello') + expect(editor.lineTextForBufferRow(0)).toBe('with placeholder hello') + expect(editor.lineTextForBufferRow(1)).toBe('without placeholder hello') + expect(editor.lineTextForBufferRow(2)).toBe('with placeholder hello') + expect(editor.lineTextForBufferRow(3)).toBe('without placeholder hello') + }) + + it('applies transformations identically to single-expansion mode', () => { + editor.setText('t14\nt14') + editor.setCursorBufferPosition([1, 3]) + editor.addCursorAtBufferPosition([0, 3]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('placeholder PLACEHOLDER ANOTHER another ') + expect(editor.lineTextForBufferRow(1)).toBe('placeholder PLACEHOLDER ANOTHER another ') + + editor.insertText('testing') + expect(editor.lineTextForBufferRow(0)).toBe('testing TESTING testing ANOTHER another ') + expect(editor.lineTextForBufferRow(1)).toBe('testing TESTING testing ANOTHER another ') + + simulateTabKeyEvent() + editor.insertText('AGAIN') + expect(editor.lineTextForBufferRow(0)).toBe('testing TESTING testing AGAIN again AGAIN') + expect(editor.lineTextForBufferRow(1)).toBe('testing TESTING testing AGAIN again AGAIN') + }) + + it('bundles transform-induced mutations into a single history entry along with their triggering edit, even across multiple snippets', () => { + editor.setText('t14\nt14') + editor.setCursorBufferPosition([1, 3]) + editor.addCursorAtBufferPosition([0, 3]) + simulateTabKeyEvent() + editorTransact('testing') + simulateTabKeyEvent() + + editorTransact('AGAIN') + + editor.undo() + expect(editor.lineTextForBufferRow(0)).toBe('testing TESTING testing ANOTHER another ') + expect(editor.lineTextForBufferRow(1)).toBe('testing TESTING testing ANOTHER another ') + + editor.undo() + expect(editor.lineTextForBufferRow(0)).toBe('placeholder PLACEHOLDER ANOTHER another ') + expect(editor.lineTextForBufferRow(1)).toBe('placeholder PLACEHOLDER ANOTHER another ') + + editor.redo() + expect(editor.lineTextForBufferRow(0)).toBe('testing TESTING testing ANOTHER another ') + expect(editor.lineTextForBufferRow(1)).toBe('testing TESTING testing ANOTHER another ') + + editor.redo() + expect(editor.lineTextForBufferRow(0)).toBe('testing TESTING testing AGAIN again AGAIN') + expect(editor.lineTextForBufferRow(1)).toBe('testing TESTING testing AGAIN again AGAIN') + }) + }) + + describe('when there are many tabstops', () => { + it('moves the cursors between the tab stops for their corresponding snippet when tab and shift-tab are pressed', () => { + editor.setText('t11\nt11\nt11') + editor.setCursorBufferPosition([0, 3]) + editor.addCursorAtBufferPosition([1, 3]) + editor.addCursorAtBufferPosition([2, 3]) + simulateTabKeyEvent() + const cursors = editor.getCursors() + expect(cursors.length).toEqual(3) + + expect(cursors[0].getBufferPosition()).toEqual([0, 3]) + expect(cursors[1].getBufferPosition()).toEqual([1, 3]) + expect(cursors[2].getBufferPosition()).toEqual([2, 3]) + expect(cursors[0].selection.isEmpty()).toBe(true) + expect(cursors[1].selection.isEmpty()).toBe(true) + expect(cursors[2].selection.isEmpty()).toBe(true) + + simulateTabKeyEvent() + expect(cursors[0].selection.getBufferRange()).toEqual([[0, 4], [0, 7]]) + expect(cursors[1].selection.getBufferRange()).toEqual([[1, 4], [1, 7]]) + expect(cursors[2].selection.getBufferRange()).toEqual([[2, 4], [2, 7]]) + + simulateTabKeyEvent() + expect(cursors[0].getBufferPosition()).toEqual([0, 13]) + expect(cursors[1].getBufferPosition()).toEqual([1, 13]) + expect(cursors[2].getBufferPosition()).toEqual([2, 13]) + expect(cursors[0].selection.isEmpty()).toBe(true) + expect(cursors[1].selection.isEmpty()).toBe(true) + expect(cursors[2].selection.isEmpty()).toBe(true) + + simulateTabKeyEvent() + expect(cursors[0].getBufferPosition()).toEqual([0, 0]) + expect(cursors[1].getBufferPosition()).toEqual([1, 0]) + expect(cursors[2].getBufferPosition()).toEqual([2, 0]) + expect(cursors[0].selection.isEmpty()).toBe(true) + expect(cursors[1].selection.isEmpty()).toBe(true) + expect(cursors[2].selection.isEmpty()).toBe(true) + }) + }) + + describe('when the cursors do not share common snippet prefixes', () => { + it('inserts tabs as normal', () => { + editor.setText('t8\nt9') + editor.setCursorBufferPosition([0, 2]) + editor.addCursorAtBufferPosition([1, 2]) + simulateTabKeyEvent() + expect(editor.lineTextForBufferRow(0)).toBe('t8\t') + expect(editor.lineTextForBufferRow(1)).toBe('t9\t') + }) + }) + + describe('when a snippet is triggered within an existing snippet expansion', () => { + it ('ignores the snippet expansion and goes to the next tab stop', () => { + // NOTE: The snippet will actually expand if triggered by expandSnippetUnderCursor() + // So the title should be 'when a snippet is triggered with TAB', or the spec is wrong + + editor.setText('t11') + simulateTabKeyEvent() + simulateTabKeyEvent() + + editor.insertText('t1') + expect(editor.getText()).toEqual('one t1 three') + expect(editor.getCursorBufferPosition()).toEqual([0, 6]) + + simulateTabKeyEvent() + expect(editor.getText()).toEqual('one t1 three') + expect(editor.getCursorBufferPosition()).toEqual([0, 12]) + }) + }) + }) + + describe('when the editor is not a pane item (regression)', () => { + it('handles tab stops correctly', () => { + editor.setText('t2') + simulateTabKeyEvent() + editorTransact('ABC') + expect(editor.lineTextForBufferRow(1)).toEqual('go here first:(ABC)') + + editor.undo() + editor.undo() + expect(editor.getText()).toBe('t2') + simulateTabKeyEvent() + editorTransact('ABC') + expect(editor.getText()).toContain('go here first:(ABC)') + }) + }) + }) + + describe('when atom://.atom/snippets is opened', () => { + it('opens ~/.atom/snippets.cson', () => { + jasmine.unspy(Snippets, 'getUserSnippetsPath') + atom.workspace.destroyActivePaneItem() + const configDirPath = temp.mkdirSync('atom-config-dir-') + spyOn(atom, 'getConfigDirPath').andReturn(configDirPath) + atom.workspace.open('atom://.atom/snippets') + + waitsFor(() => atom.workspace.getActiveTextEditor()) // NOTE: CS had a trailing ? + + runs(() => { + expect(atom.workspace.getActiveTextEditor().getURI()).toBe(path.join(configDirPath, 'snippets.cson')) + }) + }) + }) + + describe('snippet insertion API', () => { + it('will automatically parse snippet definition and replace selection', () => { + editor.setText('var quicksort = function () {') + editor.setSelectedBufferRange([[0, 4], [0, 13]]) + Snippets.insert('hello ${1:world}', editor) + + expect(editor.lineTextForBufferRow(0)).toBe('var hello world = function () {') + expect(editor.getSelectedBufferRange()).toEqual([[0, 10], [0, 15]]) + }) + }) + + describe('when the "snippets:available" command is triggered', () => { + let availableSnippetsView = null + + beforeEach(() => { + Snippets.add(__filename, { + '*': { + 'test': { + prefix: 'test', + body: '${1:Test pass you will}, young ' + }, + 'challenge': { + prefix: 'chal', + body: '$1: ${2:To pass this challenge}' + } + } + }) + + delete Snippets.availableSnippetsView + + atom.commands.dispatch(editorElement, "snippets:available") + + waitsFor(() => atom.workspace.getModalPanels().length === 1) + + runs(() => { + availableSnippetsView = atom.workspace.getModalPanels()[0].getItem() + }) + }) + + it('renders a select list of all available snippets', () => { + expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe('test') + expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe('test') + expect(availableSnippetsView.selectListView.getSelectedItem().toString().body).toBe('Test pass you will, young ') + + availableSnippetsView.selectListView.selectNext() + + expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe('chal') + expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe('challenge') + expect(availableSnippetsView.selectListView.getSelectedItem().toString().body).toBe(': To pass this challenge') + }) + + it('writes the selected snippet to the editor as snippet', () => { + availableSnippetsView.selectListView.confirmSelection() + expect(editor.getCursorBufferPosition()).toEqual([0, 18]) + expect(editor.getSelectedText()).toBe('Test pass you will') + expect(editor.getText()).toBe('Test pass you will, young ') + }) + + it('closes the dialog when triggered again', () => { + atom.commands.dispatch(availableSnippetsView.selectListView.refs.queryEditor.element, 'snippets:available') + expect(atom.workspace.getModalPanels().length).toBe(0) + }) + }) +})