├── .coffeelintignore ├── .github ├── no-response.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pairs ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── coffeelint.json ├── keymaps ├── snippets-1.cson └── snippets-2.cson ├── lib ├── editor-store.js ├── helpers.js ├── insertion.js ├── snippet-body-parser.js ├── snippet-body.pegjs ├── snippet-expansion.js ├── snippet-history-provider.js ├── snippet.js ├── snippets-available.js ├── snippets.cson ├── snippets.js ├── tab-stop-list.js └── tab-stop.js ├── menus └── snippets.cson ├── package-lock.json ├── package.json └── spec ├── body-parser-spec.js ├── fixtures ├── package-with-broken-snippets │ └── snippets │ │ ├── .hidden-file │ │ └── invalid.json ├── package-with-snippets │ └── snippets │ │ ├── .hidden-file │ │ ├── junk-file │ │ └── test.cson └── sample.js ├── insertion-spec.js ├── snippet-loading-spec.js └── snippets-spec.js /.coffeelintignore: -------------------------------------------------------------------------------- 1 | spec/fixtures 2 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an issue is closed for lack of response 4 | daysUntilClose: 28 5 | 6 | # Label requiring a response 7 | responseRequiredLabel: more-information-needed 8 | 9 | # Comment to post when closing an issue for lack of response. Set to `false` to disable. 10 | closeComment: > 11 | This issue has been automatically closed because there has been no response 12 | to our request for more information from the original author. With only the 13 | information that is currently in the issue, we don't have enough information 14 | to take action. Please reach out if you have or find the answers we need so 15 | that we can investigate further. 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest] 13 | channel: [stable, beta] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: UziTech/action-setup-atom@v2 18 | with: 19 | version: ${{ matrix.channel }} 20 | - name: Install dependencies 21 | run: apm install 22 | - name: Run tests 23 | run: atom --test spec 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.pairs: -------------------------------------------------------------------------------- 1 | pairs: 2 | ns: Nathan Sobo; nathan 3 | cj: Corey Johnson; cj 4 | dg: David Graham; dgraham 5 | ks: Kevin Sawicki; kevin 6 | jc: Jerry Cheung; jerry 7 | bl: Brian Lopez; brian 8 | jp: Justin Palmer; justin 9 | gt: Garen Torikian; garen 10 | mc: Matt Colyer; mcolyer 11 | bo: Ben Ogle; benogle 12 | jr: Jason Rudolph; jasonrudolph 13 | jl: Jessica Lord; jlord 14 | email: 15 | domain: github.com 16 | #global: true 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | See the [Atom contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) 2 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Prerequisites 10 | 11 | * [ ] Put an X between the brackets on this line if you have done all of the following: 12 | * Reproduced the problem in Safe Mode: http://flight-manual.atom.io/hacking-atom/sections/debugging/#using-safe-mode 13 | * Followed all applicable steps in the debugging guide: http://flight-manual.atom.io/hacking-atom/sections/debugging/ 14 | * Checked the FAQs on the message board for common solutions: https://discuss.atom.io/c/faq 15 | * Checked that your issue isn't already filed: https://github.com/issues?utf8=✓&q=is%3Aissue+user%3Aatom 16 | * Checked that there is not already an Atom package that provides the described functionality: https://atom.io/packages 17 | 18 | ### Description 19 | 20 | [Description of the issue] 21 | 22 | ### Steps to Reproduce 23 | 24 | 1. [First Step] 25 | 2. [Second Step] 26 | 3. [and so on...] 27 | 28 | **Expected behavior:** [What you expect to happen] 29 | 30 | **Actual behavior:** [What actually happens] 31 | 32 | **Reproduces how often:** [What percentage of the time does it reproduce?] 33 | 34 | ### Versions 35 | 36 | You can get this information from copy and pasting the output of `atom --version` and `apm --version` from the command line. Also, please include the OS and what version of the OS you're running. 37 | 38 | ### Additional Information 39 | 40 | Any additional information, configuration or data that might be necessary to reproduce the issue. 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Requirements 2 | 3 | * Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. 4 | * All new code requires tests to ensure against regressions 5 | 6 | ### Description of the Change 7 | 8 | 13 | 14 | ### Alternate Designs 15 | 16 | 17 | 18 | ### Benefits 19 | 20 | 21 | 22 | ### Possible Drawbacks 23 | 24 | 25 | 26 | ### Applicable Issues 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Snippets package 3 | [![macOS Build Status](https://travis-ci.org/atom/snippets.svg?branch=master)](https://travis-ci.org/atom/snippets) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/8hlc0onofkgbxw53/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/snippets/branch/master) [![Dependency Status](https://david-dm.org/atom/snippets.svg)](https://david-dm.org/atom/snippets) 4 | 5 | Expand snippets matching the current prefix with tab in Atom. 6 | 7 | To add your own snippets, select the _Atom > Snippets..._ menu option if you're using macOS, or the _File > Snippets..._ menu option if you're using Windows, or the _Edit > Snippets..._ menu option if you are using Linux. 8 | 9 | ## Snippet Format 10 | 11 | Snippets files are stored in a package's `snippets/` folder and also loaded from `~/.atom/snippets.cson`. They can be either `.json` or `.cson` file types. 12 | 13 | ```coffee 14 | '.source.js': 15 | 'console.log': 16 | 'prefix': 'log' 17 | 'body': 'console.log(${1:"crash"});$2' 18 | ``` 19 | 20 | The outermost keys are the selectors where these snippets should be active, prefixed with a period (`.`) (details below). 21 | 22 | The next level of keys are the snippet names. 23 | 24 | Under each snippet name is a `prefix` that should trigger the snippet and a `body` to insert when the snippet is triggered. 25 | 26 | `$` followed by a number are the tabs stops which can be cycled between by pressing tab once a snippet has been triggered. 27 | 28 | The above example adds a `log` snippet to JavaScript files that would expand to. 29 | 30 | ```js 31 | console.log("crash"); 32 | ``` 33 | 34 | The string `"crash"` would be initially selected and pressing tab again would place the cursor after the `;` 35 | 36 | ### Optional parameters 37 | These parameters are meant to provide extra information about your snippet to [autocomplete-plus](https://github.com/atom/autocomplete-plus/wiki/Provider-API). 38 | 39 | * `leftLabel` will add text to the left part of the autocomplete results box. 40 | * `leftLabelHTML` will overwrite what's in `leftLabel` and allow you to use a bit of CSS such as `color`. 41 | * `rightLabelHTML`. By default, in the right part of the results box you will see the name of the snippet. When using `rightLabelHTML` the name of the snippet will no longer be displayed, and you will be able to use a bit of CSS. 42 | * `description` will add text to a description box under the autocomplete results list. 43 | * `descriptionMoreURL` URL to the documentation of the snippet. 44 | 45 | ![autocomplete-description](http://i.imgur.com/cvI2lOq.png) 46 | 47 | Example: 48 | ```coffee 49 | '.source.js': 50 | 'console.log': 51 | 'prefix': 'log' 52 | 'body': 'console.log(${1:"crash"});$2' 53 | 'description': 'Output data to the console' 54 | 'rightLabelHTML': 'JS' 55 | ``` 56 | 57 | ### Determining the correct scope for a snippet 58 | 59 | The outmost key of a snippet is the "scope" that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` => `.text.html.basic`). You can find out the correct scope by opening the Settings (cmd-, on macOS) and selecting the corresponding *Language [xxx]* package, e.g. for *Language Html*: 60 | 61 | ![Screenshot of Language Html settings](https://cloud.githubusercontent.com/assets/1038121/5137632/126beb66-70f2-11e4-839b-bc7e84103f67.png) 62 | 63 | If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can also proceed as following. Put your cursor in a file in which you want the snippet to be available, open the [Command Palette](https://github.com/atom/command-palette) 64 | (cmd-shift-p), and run the `Editor: Log Cursor Scope` command. This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`. 65 | 66 | ### Snippet syntax 67 | 68 | This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets#transformations). 69 | 70 | The following features are not yet supported: 71 | 72 | * Variables 73 | * Interpolated shell code 74 | * Conditional insertions in transformations 75 | 76 | ### Multi-line Snippet Body 77 | 78 | You can also use multi-line syntax using `"""` for larger templates: 79 | 80 | ```coffee 81 | '.source.js': 82 | 'if, else if, else': 83 | 'prefix': 'ieie' 84 | 'body': """ 85 | if (${1:true}) { 86 | $2 87 | } else if (${3:false}) { 88 | $4 89 | } else { 90 | $5 91 | } 92 | """ 93 | ``` 94 | 95 | ### Escaping Characters 96 | 97 | Including a literal closing brace inside the text provided by a snippet's tab stop will close that tab stop early. To prevent that, escape the brace with two backslashes, like so: 98 | 99 | ```coffee 100 | '.source.js': 101 | 'function': 102 | 'prefix': 'funct' 103 | 'body': """ 104 | ${1:function () { 105 | statements; 106 | \\} 107 | this line is also included in the snippet tab; 108 | } 109 | """ 110 | ``` 111 | 112 | ### Multiple snippets for the same scope 113 | 114 | Snippets for the same scope must be placed within the same key. See [this section of the Atom Flight Manual](http://flight-manual.atom.io/using-atom/sections/basic-customization/#configuring-with-cson) for more information. 115 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "max_line_length": { 3 | "level": "ignore" 4 | }, 5 | "no_empty_param_list": { 6 | "level": "error" 7 | }, 8 | "arrow_spacing": { 9 | "level": "error" 10 | }, 11 | "no_interpolation_in_single_quotes": { 12 | "level": "error" 13 | }, 14 | "no_debugger": { 15 | "level": "error" 16 | }, 17 | "prefer_english_operator": { 18 | "level": "error" 19 | }, 20 | "colon_assignment_spacing": { 21 | "spacing": { 22 | "left": 0, 23 | "right": 1 24 | }, 25 | "level": "error" 26 | }, 27 | "braces_spacing": { 28 | "spaces": 0, 29 | "level": "error" 30 | }, 31 | "spacing_after_comma": { 32 | "level": "error" 33 | }, 34 | "no_stand_alone_at": { 35 | "level": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /keymaps/snippets-1.cson: -------------------------------------------------------------------------------- 1 | 'atom-text-editor:not([mini])': 2 | 'tab': 'snippets:expand' 3 | -------------------------------------------------------------------------------- /keymaps/snippets-2.cson: -------------------------------------------------------------------------------- 1 | # it's critical that these bindings be loaded after those snippets-1 so they 2 | # are later in the cascade, hence breaking the keymap into 2 files 3 | 4 | 'atom-text-editor:not([mini])': 5 | 'tab': 'snippets:next-tab-stop' 6 | 'shift-tab': 'snippets:previous-tab-stop' 7 | -------------------------------------------------------------------------------- /lib/editor-store.js: -------------------------------------------------------------------------------- 1 | const SnippetHistoryProvider = require('./snippet-history-provider') 2 | 3 | class EditorStore { 4 | constructor (editor) { 5 | this.editor = editor 6 | this.buffer = this.editor.getBuffer() 7 | this.observer = null 8 | this.checkpoint = null 9 | this.expansions = [] 10 | this.existingHistoryProvider = null 11 | } 12 | 13 | getExpansions () { 14 | return this.expansions 15 | } 16 | 17 | setExpansions (list) { 18 | this.expansions = list 19 | } 20 | 21 | clearExpansions () { 22 | this.expansions = [] 23 | } 24 | 25 | addExpansion (snippetExpansion) { 26 | this.expansions.push(snippetExpansion) 27 | } 28 | 29 | observeHistory (delegates) { 30 | if (this.existingHistoryProvider == null) { 31 | this.existingHistoryProvider = this.buffer.historyProvider 32 | } 33 | 34 | const newProvider = SnippetHistoryProvider(this.existingHistoryProvider, delegates) 35 | this.buffer.setHistoryProvider(newProvider) 36 | } 37 | 38 | stopObservingHistory (editor) { 39 | if (this.existingHistoryProvider == null) { return } 40 | this.buffer.setHistoryProvider(this.existingHistoryProvider) 41 | this.existingHistoryProvider = null 42 | } 43 | 44 | observe (callback) { 45 | if (this.observer != null) { this.observer.dispose() } 46 | this.observer = this.buffer.onDidChangeText(callback) 47 | } 48 | 49 | stopObserving () { 50 | if (this.observer == null) { return false } 51 | this.observer.dispose() 52 | this.observer = null 53 | return true 54 | } 55 | 56 | makeCheckpoint () { 57 | const existing = this.checkpoint 58 | if (existing) { 59 | this.buffer.groupChangesSinceCheckpoint(existing) 60 | } 61 | this.checkpoint = this.buffer.createCheckpoint() 62 | } 63 | } 64 | 65 | EditorStore.store = new WeakMap() 66 | EditorStore.findOrCreate = function (editor) { 67 | if (!this.store.has(editor)) { 68 | this.store.set(editor, new EditorStore(editor)) 69 | } 70 | return this.store.get(editor) 71 | } 72 | 73 | module.exports = EditorStore 74 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | import path from 'path' 4 | 5 | export function getPackageRoot() { 6 | const {resourcePath} = atom.getLoadSettings() 7 | const currentFileWasRequiredFromSnapshot = !path.isAbsolute(__dirname) 8 | if (currentFileWasRequiredFromSnapshot) { 9 | return path.join(resourcePath, 'node_modules', 'snippets') 10 | } else { 11 | return path.resolve(__dirname, '..') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/insertion.js: -------------------------------------------------------------------------------- 1 | const ESCAPES = { 2 | u: (flags) => { 3 | flags.lowercaseNext = false 4 | flags.uppercaseNext = true 5 | }, 6 | l: (flags) => { 7 | flags.uppercaseNext = false 8 | flags.lowercaseNext = true 9 | }, 10 | U: (flags) => { 11 | flags.lowercaseAll = false 12 | flags.uppercaseAll = true 13 | }, 14 | L: (flags) => { 15 | flags.uppercaseAll = false 16 | flags.lowercaseAll = true 17 | }, 18 | E: (flags) => { 19 | flags.uppercaseAll = false 20 | flags.lowercaseAll = false 21 | }, 22 | r: (flags, result) => { 23 | result.push('\\r') 24 | }, 25 | n: (flags, result) => { 26 | result.push('\\n') 27 | }, 28 | $: (flags, result) => { 29 | result.push('$') 30 | } 31 | } 32 | 33 | function transformText (str, flags) { 34 | if (flags.uppercaseAll) { 35 | return str.toUpperCase() 36 | } else if (flags.lowercaseAll) { 37 | return str.toLowerCase() 38 | } else if (flags.uppercaseNext) { 39 | flags.uppercaseNext = false 40 | return str.replace(/^./, s => s.toUpperCase()) 41 | } else if (flags.lowercaseNext) { 42 | return str.replace(/^./, s => s.toLowerCase()) 43 | } 44 | return str 45 | } 46 | 47 | class Insertion { 48 | constructor ({ range, substitution, references }) { 49 | this.range = range 50 | this.substitution = substitution 51 | this.references = references 52 | if (substitution) { 53 | if (substitution.replace === undefined) { 54 | substitution.replace = '' 55 | } 56 | this.replacer = this.makeReplacer(substitution.replace) 57 | } 58 | } 59 | 60 | isTransformation () { 61 | return !!this.substitution 62 | } 63 | 64 | makeReplacer (replace) { 65 | return function replacer (...match) { 66 | let flags = { 67 | uppercaseAll: false, 68 | lowercaseAll: false, 69 | uppercaseNext: false, 70 | lowercaseNext: false 71 | } 72 | replace = [...replace] 73 | let result = [] 74 | replace.forEach(token => { 75 | if (typeof token === 'string') { 76 | result.push(transformText(token, flags)) 77 | } else if (token.escape) { 78 | ESCAPES[token.escape](flags, result) 79 | } else if (token.backreference) { 80 | let transformed = transformText(match[token.backreference], flags) 81 | result.push(transformed) 82 | } 83 | }) 84 | return result.join('') 85 | } 86 | } 87 | 88 | transform (input) { 89 | let { substitution } = this 90 | if (!substitution) { return input } 91 | return input.replace(substitution.find, this.replacer) 92 | } 93 | } 94 | 95 | module.exports = Insertion 96 | -------------------------------------------------------------------------------- /lib/snippet-body-parser.js: -------------------------------------------------------------------------------- 1 | let parser 2 | try { 3 | parser = require('./snippet-body') 4 | } catch (error) { 5 | const {allowUnsafeEval} = require('loophole') 6 | const fs = require('fs-plus') 7 | const PEG = require('pegjs') 8 | 9 | const grammarSrc = fs.readFileSync(require.resolve('./snippet-body.pegjs'), 'utf8') 10 | parser = null 11 | allowUnsafeEval(() => parser = PEG.buildParser(grammarSrc)) 12 | } 13 | 14 | module.exports = parser 15 | -------------------------------------------------------------------------------- /lib/snippet-body.pegjs: -------------------------------------------------------------------------------- 1 | { 2 | // Joins all consecutive strings in a collection without clobbering any 3 | // non-string members. 4 | function coalesce (parts) { 5 | const result = []; 6 | for (let i = 0; i < parts.length; i++) { 7 | const part = parts[i]; 8 | const ri = result.length - 1; 9 | if (typeof part === 'string' && typeof result[ri] === 'string') { 10 | result[ri] = result[ri] + part; 11 | } else { 12 | result.push(part); 13 | } 14 | } 15 | return result; 16 | } 17 | 18 | function flatten (parts) { 19 | return parts.reduce(function (flat, rest) { 20 | return flat.concat(Array.isArray(rest) ? flatten(rest) : rest); 21 | }, []); 22 | } 23 | } 24 | bodyContent = content:(tabStop / bodyContentText)* { return content; } 25 | bodyContentText = text:bodyContentChar+ { return text.join(''); } 26 | bodyContentChar = escaped / !tabStop char:. { return char; } 27 | 28 | escaped = '\\' char:. { return char; } 29 | tabStop = tabStopWithTransformation / tabStopWithPlaceholder / tabStopWithoutPlaceholder / simpleTabStop 30 | 31 | simpleTabStop = '$' index:[0-9]+ { 32 | return { index: parseInt(index.join("")), content: [] }; 33 | } 34 | tabStopWithoutPlaceholder = '${' index:[0-9]+ '}' { 35 | return { index: parseInt(index.join("")), content: [] }; 36 | } 37 | tabStopWithPlaceholder = '${' index:[0-9]+ ':' content:placeholderContent '}' { 38 | return { index: parseInt(index.join("")), content: content }; 39 | } 40 | tabStopWithTransformation = '${' index:[0-9]+ substitution:transformationSubstitution '}' { 41 | return { 42 | index: parseInt(index.join(""), 10), 43 | content: [], 44 | substitution: substitution 45 | }; 46 | } 47 | 48 | placeholderContent = content:(tabStop / placeholderContentText / variable )* { return flatten(content); } 49 | placeholderContentText = text:placeholderContentChar+ { return coalesce(text); } 50 | placeholderContentChar = escaped / placeholderVariableReference / !tabStop !variable char:[^}] { return char; } 51 | 52 | placeholderVariableReference = '$' digit:[0-9]+ { 53 | return { index: parseInt(digit.join(""), 10), content: [] }; 54 | } 55 | 56 | variable = '${' variableContent '}' { 57 | return ''; // we eat variables and do nothing with them for now 58 | } 59 | variableContent = content:(variable / variableContentText)* { return content; } 60 | variableContentText = text:variableContentChar+ { return text.join(''); } 61 | variableContentChar = !variable char:('\\}' / [^}]) { return char; } 62 | 63 | escapedForwardSlash = pair:'\\/' { return pair; } 64 | 65 | // A pattern and replacement for a transformed tab stop. 66 | transformationSubstitution = '/' find:(escapedForwardSlash / [^/])* '/' replace:formatString* '/' flags:[imy]* { 67 | let reFind = new RegExp(find.join(''), flags.join('') + 'g'); 68 | return { find: reFind, replace: replace[0] }; 69 | } 70 | 71 | formatString = content:(formatStringEscape / formatStringReference / escapedForwardSlash / [^/])+ { 72 | return content; 73 | } 74 | // Backreferencing a substitution. Different from a tab stop. 75 | formatStringReference = '$' digits:[0-9]+ { 76 | return { backreference: parseInt(digits.join(''), 10) }; 77 | }; 78 | // One of the special control flags in a format string for case folding and 79 | // other tasks. 80 | formatStringEscape = '\\' flag:[ULulErn$] { 81 | return { escape: flag }; 82 | } 83 | -------------------------------------------------------------------------------- /lib/snippet-expansion.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Range, Point} = require('atom') 2 | 3 | module.exports = class SnippetExpansion { 4 | constructor(snippet, editor, cursor, snippets) { 5 | this.settingTabStop = false 6 | this.isIgnoringBufferChanges = false 7 | this.onUndoOrRedo = this.onUndoOrRedo.bind(this) 8 | this.snippet = snippet 9 | this.editor = editor 10 | this.cursor = cursor 11 | this.snippets = snippets 12 | this.subscriptions = new CompositeDisposable 13 | this.selections = [this.cursor.selection] 14 | 15 | // Holds the `Insertion` instance corresponding to each tab stop marker. We 16 | // don't use the tab stop's own numbering here; we renumber them 17 | // consecutively starting at 0 in the order in which they should be 18 | // visited. So `$1` (if present) will always be at index `0`, and `$0` (if 19 | // present) will always be the last index. 20 | this.insertionsByIndex = [] 21 | 22 | // Each insertion has a corresponding marker. We keep them in a map so we 23 | // can easily reassociate an insertion with its new marker when we destroy 24 | // its old one. 25 | this.markersForInsertions = new Map() 26 | 27 | // The index of the active tab stop. 28 | this.tabStopIndex = null 29 | 30 | // If, say, tab stop 4's placeholder references tab stop 2, then tab stop 31 | // 4's insertion goes into this map as a "related" insertion to tab stop 2. 32 | // We need to keep track of this because tab stop 4's marker will need to 33 | // be replaced while 2 is the active index. 34 | this.relatedInsertionsByIndex = new Map() 35 | 36 | const startPosition = this.cursor.selection.getBufferRange().start 37 | let {body, tabStopList} = this.snippet 38 | let tabStops = tabStopList.toArray() 39 | 40 | let indent = this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0] 41 | if (this.snippet.lineCount > 1 && indent) { 42 | // Add proper leading indentation to the snippet 43 | body = body.replace(/\n/g, `\n${indent}`) 44 | 45 | tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent)) 46 | } 47 | 48 | this.editor.transact(() => { 49 | this.ignoringBufferChanges(() => { 50 | this.editor.transact(() => { 51 | // Insert the snippet body at the cursor. 52 | const newRange = this.cursor.selection.insertText(body, {autoIndent: false}) 53 | if (this.snippet.tabStopList.length > 0) { 54 | // Listen for cursor changes so we can decide whether to keep the 55 | // snippet active or terminate it. 56 | this.subscriptions.add(this.cursor.onDidChangePosition(event => this.cursorMoved(event))) 57 | this.subscriptions.add(this.cursor.onDidDestroy(() => this.cursorDestroyed())) 58 | this.placeTabStopMarkers(startPosition, tabStops) 59 | this.snippets.addExpansion(this.editor, this) 60 | this.editor.normalizeTabsInBufferRange(newRange) 61 | } 62 | }) 63 | }) 64 | }) 65 | } 66 | 67 | // Set a flag on undo or redo so that we know not to re-apply transforms. 68 | // They're already accounted for in the history. 69 | onUndoOrRedo (isUndo) { 70 | this.isUndoingOrRedoing = true 71 | } 72 | 73 | cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) { 74 | if (this.settingTabStop || textChanged) { return } 75 | const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find(insertion => { 76 | let marker = this.markersForInsertions.get(insertion) 77 | return marker.getBufferRange().containsPoint(newBufferPosition) 78 | }) 79 | 80 | if (insertionAtCursor && !insertionAtCursor.isTransformation()) { return } 81 | 82 | this.destroy() 83 | } 84 | 85 | cursorDestroyed () { if (!this.settingTabStop) { this.destroy() } } 86 | 87 | textChanged (event) { 88 | if (this.isIgnoringBufferChanges) { return } 89 | 90 | // Don't try to alter the buffer if all we're doing is restoring a 91 | // snapshot from history. 92 | if (this.isUndoingOrRedoing) { 93 | this.isUndoingOrRedoing = false 94 | return 95 | } 96 | 97 | this.applyTransformations(this.tabStopIndex) 98 | } 99 | 100 | ignoringBufferChanges (callback) { 101 | const wasIgnoringBufferChanges = this.isIgnoringBufferChanges 102 | this.isIgnoringBufferChanges = true 103 | callback() 104 | this.isIgnoringBufferChanges = wasIgnoringBufferChanges 105 | } 106 | 107 | applyAllTransformations () { 108 | this.editor.transact(() => { 109 | this.insertionsByIndex.forEach((insertion, index) => 110 | this.applyTransformations(index)) 111 | }) 112 | } 113 | 114 | applyTransformations (tabStopIndex) { 115 | const insertions = [...this.insertionsByIndex[tabStopIndex]] 116 | if (insertions.length === 0) { return } 117 | 118 | const primaryInsertion = insertions.shift() 119 | const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange() 120 | const inputText = this.editor.getTextInBufferRange(primaryRange) 121 | 122 | this.ignoringBufferChanges(() => { 123 | for (const [index, insertion] of insertions.entries()) { 124 | // Don't transform mirrored tab stops. They have their own cursors, so 125 | // mirroring happens automatically. 126 | if (!insertion.isTransformation()) { continue } 127 | 128 | var marker = this.markersForInsertions.get(insertion) 129 | var range = marker.getBufferRange() 130 | 131 | var outputText = insertion.transform(inputText) 132 | this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText)) 133 | 134 | // Manually adjust the marker's range rather than rely on its internal 135 | // heuristics. (We don't have to worry about whether it's been 136 | // invalidated because setting its buffer range implicitly marks it as 137 | // valid again.) 138 | const newRange = new Range( 139 | range.start, 140 | range.start.traverse(new Point(0, outputText.length)) 141 | ) 142 | marker.setBufferRange(newRange) 143 | } 144 | }) 145 | } 146 | 147 | placeTabStopMarkers (startPosition, tabStops) { 148 | // Tab stops within a snippet refer to one another by their external index 149 | // (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but 150 | // we renumber them starting at 0 and using consecutive numbers. 151 | // 152 | // Luckily, we don't need to convert between the two numbering systems very 153 | // often. But we do have to build a map from external index to our internal 154 | // index. We do this in a separate loop so that the table is complete 155 | // before we need to consult it in the following loop. 156 | const indexTable = {} 157 | for (let [index, tabStop] of tabStops.entries()) { 158 | indexTable[tabStop.index] = index 159 | } 160 | 161 | for (let [index, tabStop] of tabStops.entries()) { 162 | const {insertions} = tabStop 163 | 164 | if (!tabStop.isValid()) { continue } 165 | 166 | for (const insertion of insertions) { 167 | const {range} = insertion 168 | const {start, end} = range 169 | let references = null 170 | if (insertion.references) { 171 | references = insertion.references.map(external => indexTable[external]) 172 | } 173 | // Since this method is called only once at the beginning of a snippet expansion, we know that 0 is about to be the active tab stop. 174 | const shouldBeInclusive = (index === 0) || (references && references.includes(0)) 175 | const marker = this.getMarkerLayer(this.editor).markBufferRange([ 176 | startPosition.traverse(start), 177 | startPosition.traverse(end) 178 | ], { exclusive: !shouldBeInclusive }) 179 | // Now that we've created these markers, we need to store them in a 180 | // data structure because they'll need to be deleted and re-created 181 | // when their exclusivity changes. 182 | this.markersForInsertions.set(insertion, marker) 183 | 184 | if (references) { 185 | const relatedInsertions = this.relatedInsertionsByIndex.get(index) || [] 186 | relatedInsertions.push(insertion) 187 | this.relatedInsertionsByIndex.set(index, relatedInsertions) 188 | } 189 | } 190 | this.insertionsByIndex[index] = insertions 191 | } 192 | 193 | this.setTabStopIndex(0) 194 | this.applyAllTransformations() 195 | } 196 | 197 | // When two insertion markers are directly adjacent to one another, and the 198 | // cursor is placed right at the border between them, the marker that should 199 | // "claim" the newly typed content will vary based on context. 200 | // 201 | // All else being equal, that content should get added to the marker (if any) 202 | // whose tab stop is active, or else the marker whose tab stop's placeholder 203 | // references an active tab stop. The `exclusive` setting on a marker 204 | // controls whether that marker grows to include content added at its edge. 205 | // 206 | // So we need to revisit the markers whenever the active tab stop changes, 207 | // figure out which ones need to be touched, and replace them with markers 208 | // that have the settings we need. 209 | adjustTabStopMarkers (oldIndex, newIndex) { 210 | // Take all the insertions whose markers were made inclusive when they 211 | // became active and restore their original marker settings. 212 | const insertionsForOldIndex = [ 213 | ...this.insertionsByIndex[oldIndex], 214 | ...(this.relatedInsertionsByIndex.get(oldIndex) || []) 215 | ] 216 | 217 | for (let insertion of insertionsForOldIndex) { 218 | this.replaceMarkerForInsertion(insertion, {exclusive: true}) 219 | } 220 | 221 | // Take all the insertions belonging to the newly active tab stop (and all 222 | // insertions whose placeholders reference the newly active tab stop) and 223 | // change their markers to be inclusive. 224 | const insertionsForNewIndex = [ 225 | ...this.insertionsByIndex[newIndex], 226 | ...(this.relatedInsertionsByIndex.get(newIndex) || []) 227 | ] 228 | 229 | for (let insertion of insertionsForNewIndex) { 230 | this.replaceMarkerForInsertion(insertion, {exclusive: false}) 231 | } 232 | } 233 | 234 | replaceMarkerForInsertion (insertion, settings) { 235 | const marker = this.markersForInsertions.get(insertion) 236 | 237 | // If the marker is invalid or destroyed, return it as-is. Other methods 238 | // need to know if a marker has been invalidated or destroyed, and we have 239 | // no need to change the settings on such markers anyway. 240 | if (!marker.isValid() || marker.isDestroyed()) { 241 | return marker 242 | } 243 | 244 | // Otherwise, create a new marker with an identical range and the specified 245 | // settings. 246 | const range = marker.getBufferRange() 247 | const replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings) 248 | 249 | marker.destroy() 250 | this.markersForInsertions.set(insertion, replacement) 251 | return replacement 252 | } 253 | 254 | goToNextTabStop () { 255 | const nextIndex = this.tabStopIndex + 1 256 | if (nextIndex < this.insertionsByIndex.length) { 257 | if (this.setTabStopIndex(nextIndex)) { 258 | return true 259 | } else { 260 | return this.goToNextTabStop() 261 | } 262 | } else { 263 | // The user has tabbed past the last tab stop. If the last tab stop is a 264 | // $0, we shouldn't move the cursor any further. 265 | if (this.snippet.tabStopList.hasEndStop) { 266 | this.destroy() 267 | return false 268 | } else { 269 | const succeeded = this.goToEndOfLastTabStop() 270 | this.destroy() 271 | return succeeded 272 | } 273 | } 274 | } 275 | 276 | goToPreviousTabStop () { 277 | if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) } 278 | } 279 | 280 | setTabStopIndex (newIndex) { 281 | const oldIndex = this.tabStopIndex 282 | this.tabStopIndex = newIndex 283 | // Set a flag before moving any selections so that our change handlers know 284 | // that the movements were initiated by us. 285 | this.settingTabStop = true 286 | // Keep track of whether we placed any selections or cursors. 287 | let markerSelected = false 288 | 289 | const insertions = this.insertionsByIndex[this.tabStopIndex] 290 | if (insertions.length === 0) { return false } 291 | 292 | const ranges = [] 293 | this.hasTransforms = false 294 | 295 | // Go through the active tab stop's markers to figure out where to place 296 | // cursors and/or selections. 297 | for (const insertion of insertions) { 298 | const marker = this.markersForInsertions.get(insertion) 299 | if (marker.isDestroyed()) { continue } 300 | if (!marker.isValid()) { continue } 301 | if (insertion.isTransformation()) { 302 | // Set a flag for later, but skip transformation insertions because 303 | // they don't get their own cursors. 304 | this.hasTransforms = true 305 | continue 306 | } 307 | ranges.push(marker.getBufferRange()) 308 | } 309 | 310 | if (ranges.length > 0) { 311 | // We have new selections to apply. Reuse existing selections if 312 | // possible, destroying the unused ones if we already have too many. 313 | for (const selection of this.selections.slice(ranges.length)) { selection.destroy() } 314 | this.selections = this.selections.slice(0, ranges.length) 315 | for (let i = 0; i < ranges.length; i++) { 316 | const range = ranges[i] 317 | if (this.selections[i]) { 318 | this.selections[i].setBufferRange(range) 319 | } else { 320 | const newSelection = this.editor.addSelectionForBufferRange(range) 321 | this.subscriptions.add(newSelection.cursor.onDidChangePosition(event => this.cursorMoved(event))) 322 | this.subscriptions.add(newSelection.cursor.onDidDestroy(() => this.cursorDestroyed())) 323 | this.selections.push(newSelection) 324 | } 325 | } 326 | // We placed at least one selection, so this tab stop was successfully 327 | // set. 328 | markerSelected = true 329 | } 330 | 331 | this.settingTabStop = false 332 | // If this snippet has at least one transform, we need to observe changes 333 | // made to the editor so that we can update the transformed tab stops. 334 | if (this.hasTransforms) { 335 | this.snippets.observeEditor(this.editor) 336 | } else { 337 | this.snippets.stopObservingEditor(this.editor) 338 | } 339 | 340 | if (oldIndex !== null) { 341 | this.adjustTabStopMarkers(oldIndex, newIndex) 342 | } 343 | 344 | return markerSelected 345 | } 346 | 347 | goToEndOfLastTabStop () { 348 | const size = this.insertionsByIndex.length 349 | if (size === 0) { return } 350 | const insertions = this.insertionsByIndex[size - 1] 351 | if (insertions.length === 0) { return } 352 | const lastMarker = this.markersForInsertions.get(insertions[insertions.length - 1]) 353 | 354 | if (lastMarker.isDestroyed()) { 355 | return false 356 | } else { 357 | this.seditor.setCursorBufferPosition(lastMarker.getEndBufferPosition()) 358 | return true 359 | } 360 | } 361 | 362 | destroy () { 363 | this.subscriptions.dispose() 364 | this.getMarkerLayer(this.editor).clear() 365 | this.insertionsByIndex = [] 366 | this.relatedInsertionsByIndex = new Map() 367 | this.markersForInsertions = new Map(); 368 | this.snippets.stopObservingEditor(this.editor) 369 | this.snippets.clearExpansions(this.editor) 370 | } 371 | 372 | getMarkerLayer () { 373 | return this.snippets.findOrCreateMarkerLayer(this.editor) 374 | } 375 | 376 | restore (editor) { 377 | this.editor = editor 378 | this.snippets.addExpansion(this.editor, this) 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /lib/snippet-history-provider.js: -------------------------------------------------------------------------------- 1 | function wrap (manager, callbacks) { 2 | let klass = new SnippetHistoryProvider(manager) 3 | return new Proxy(manager, { 4 | get (target, name) { 5 | if (name in callbacks) { 6 | callbacks[name]() 7 | } 8 | return name in klass ? klass[name] : target[name] 9 | } 10 | }) 11 | } 12 | 13 | class SnippetHistoryProvider { 14 | constructor (manager) { 15 | this.manager = manager 16 | } 17 | 18 | undo (...args) { 19 | return this.manager.undo(...args) 20 | } 21 | 22 | redo (...args) { 23 | return this.manager.redo(...args) 24 | } 25 | } 26 | 27 | module.exports = wrap 28 | -------------------------------------------------------------------------------- /lib/snippet.js: -------------------------------------------------------------------------------- 1 | const {Range} = require('atom') 2 | const TabStopList = require('./tab-stop-list') 3 | 4 | function tabStopsReferencedWithinTabStopContent (segment) { 5 | const results = [] 6 | for (const item of segment) { 7 | if (item.index) { 8 | results.push(item.index, ...tabStopsReferencedWithinTabStopContent(item.content)) 9 | } 10 | } 11 | return new Set(results) 12 | } 13 | 14 | module.exports = class Snippet { 15 | constructor({name, prefix, bodyText, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyTree}) { 16 | this.name = name 17 | this.prefix = prefix 18 | this.bodyText = bodyText 19 | this.description = description 20 | this.descriptionMoreURL = descriptionMoreURL 21 | this.rightLabelHTML = rightLabelHTML 22 | this.leftLabel = leftLabel 23 | this.leftLabelHTML = leftLabelHTML 24 | this.tabStopList = new TabStopList(this) 25 | this.body = this.extractTabStops(bodyTree) 26 | } 27 | 28 | extractTabStops (bodyTree) { 29 | const bodyText = [] 30 | let row = 0 31 | let column = 0 32 | 33 | // recursive helper function; mutates vars above 34 | let extractTabStops = bodyTree => { 35 | for (const segment of bodyTree) { 36 | if (segment.index != null) { 37 | let {index, content, substitution} = segment 38 | if (index === 0) { index = Infinity; } 39 | const start = [row, column] 40 | extractTabStops(content) 41 | const referencedTabStops = tabStopsReferencedWithinTabStopContent(content) 42 | const range = new Range(start, [row, column]) 43 | const tabStop = this.tabStopList.findOrCreate({ 44 | index, 45 | snippet: this 46 | }) 47 | tabStop.addInsertion({ 48 | range, 49 | substitution, 50 | references: Array.from(referencedTabStops) 51 | }) 52 | } else if (typeof segment === 'string') { 53 | bodyText.push(segment) 54 | var segmentLines = segment.split('\n') 55 | column += segmentLines.shift().length 56 | let nextLine 57 | while ((nextLine = segmentLines.shift()) != null) { 58 | row += 1 59 | column = nextLine.length 60 | } 61 | } 62 | } 63 | } 64 | 65 | extractTabStops(bodyTree) 66 | this.lineCount = row + 1 67 | this.insertions = this.tabStopList.getInsertions() 68 | 69 | return bodyText.join('') 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/snippets-available.js: -------------------------------------------------------------------------------- 1 | /** @babel */ 2 | 3 | import _ from 'underscore-plus' 4 | import SelectListView from 'atom-select-list' 5 | 6 | export default class SnippetsAvailable { 7 | constructor (snippets) { 8 | this.panel = null 9 | this.snippets = snippets 10 | this.selectListView = new SelectListView({ 11 | items: [], 12 | filterKeyForItem: (snippet) => snippet.searchText, 13 | elementForItem: (snippet) => { 14 | const li = document.createElement('li') 15 | li.classList.add('two-lines') 16 | 17 | const primaryLine = document.createElement('div') 18 | primaryLine.classList.add('primary-line') 19 | primaryLine.textContent = snippet.prefix 20 | li.appendChild(primaryLine) 21 | 22 | const secondaryLine = document.createElement('div') 23 | secondaryLine.classList.add('secondary-line') 24 | secondaryLine.textContent = snippet.name 25 | li.appendChild(secondaryLine) 26 | 27 | return li 28 | }, 29 | didConfirmSelection: (snippet) => { 30 | for (const cursor of this.editor.getCursors()) { 31 | this.snippets.insert(snippet.bodyText, this.editor, cursor) 32 | } 33 | this.cancel() 34 | }, 35 | didConfirmEmptySelection: () => { 36 | this.cancel() 37 | }, 38 | didCancelSelection: () => { 39 | this.cancel() 40 | } 41 | }) 42 | this.selectListView.element.classList.add('available-snippets') 43 | this.element = this.selectListView.element 44 | } 45 | 46 | async toggle (editor) { 47 | this.editor = editor 48 | if (this.panel != null) { 49 | this.cancel() 50 | } else { 51 | this.selectListView.reset() 52 | await this.populate() 53 | this.attach() 54 | } 55 | } 56 | 57 | cancel () { 58 | this.editor = null 59 | 60 | if (this.panel != null) { 61 | this.panel.destroy() 62 | this.panel = null 63 | } 64 | 65 | if (this.previouslyFocusedElement) { 66 | this.previouslyFocusedElement.focus() 67 | this.previouslyFocusedElement = null 68 | } 69 | } 70 | 71 | populate () { 72 | const snippets = Object.values(this.snippets.getSnippets(this.editor)) 73 | for (let snippet of snippets) { 74 | snippet.searchText = _.compact([snippet.prefix, snippet.name]).join(' ') 75 | } 76 | return this.selectListView.update({items: snippets}) 77 | } 78 | 79 | attach () { 80 | this.previouslyFocusedElement = document.activeElement 81 | this.panel = atom.workspace.addModalPanel({item: this}) 82 | this.selectListView.focus() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/snippets.cson: -------------------------------------------------------------------------------- 1 | '.source.json': 2 | 'Atom Snippet': 3 | prefix: 'snip' 4 | body: """ 5 | { 6 | "${1:.source.js}": { 7 | "${2:Snippet Name}": { 8 | "prefix": "${3:Snippet Trigger}", 9 | "body": "${4:Hello World!}" 10 | } 11 | } 12 | }$5 13 | """ 14 | 15 | 'Atom Snippet With No Selector': 16 | prefix: 'snipns' 17 | body: """ 18 | "${1:Snippet Name}": { 19 | "prefix": "${2:Snippet Trigger}", 20 | "body": "${3:Hello World!}" 21 | }$4 22 | """ 23 | 24 | 'Atom Keymap': 25 | prefix: 'key' 26 | body: """ 27 | { 28 | "${1:body}": { 29 | "${2:cmd}-${3:i}": "${4:namespace}:${5:event}" 30 | } 31 | }$6 32 | """ 33 | 34 | '.source.coffee': 35 | 'Atom Snippet': 36 | prefix: 'snip' 37 | body: """ 38 | '${1:.source.js}': 39 | '${2:Snippet Name}': 40 | 'prefix': '${3:Snippet Trigger}' 41 | 'body': '${4:Hello World!}'$5 42 | """ 43 | 44 | 'Atom Snippet With No Selector': 45 | prefix: 'snipns' 46 | body: """ 47 | '${1:Snippet Name}': 48 | 'prefix': '${2:Snippet Trigger}' 49 | 'body': '${3:Hello World!}'$4 50 | """ 51 | 52 | 'Atom Keymap': 53 | prefix: 'key' 54 | body: """ 55 | '${1:body}': 56 | '${2:cmd}-${3:i}': '${4:namespace}:${5:event}'$6 57 | """ 58 | -------------------------------------------------------------------------------- /lib/snippets.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {Emitter, Disposable, CompositeDisposable, File} = require('atom') 3 | const _ = require('underscore-plus') 4 | const async = require('async') 5 | const CSON = require('season') 6 | const fs = require('fs-plus') 7 | const ScopedPropertyStore = require('scoped-property-store') 8 | 9 | const Snippet = require('./snippet') 10 | const SnippetExpansion = require('./snippet-expansion') 11 | const EditorStore = require('./editor-store') 12 | const {getPackageRoot} = require('./helpers') 13 | 14 | module.exports = { 15 | activate () { 16 | this.loaded = false 17 | this.userSnippetsPath = null 18 | this.snippetIdCounter = 0 19 | this.snippetsByPackage = new Map 20 | this.parsedSnippetsById = new Map 21 | this.editorMarkerLayers = new WeakMap 22 | 23 | this.scopedPropertyStore = new ScopedPropertyStore 24 | // The above ScopedPropertyStore will store the main registry of snippets. 25 | // But we need a separate ScopedPropertyStore for the snippets that come 26 | // from disabled packages. They're isolated so that they're not considered 27 | // as candidates when the user expands a prefix, but we still need the data 28 | // around so that the snippets provided by those packages can be shown in 29 | // the settings view. 30 | this.disabledSnippetsScopedPropertyStore = new ScopedPropertyStore 31 | 32 | this.subscriptions = new CompositeDisposable 33 | this.subscriptions.add(atom.workspace.addOpener(uri => { 34 | if (uri === 'atom://.atom/snippets') { 35 | return atom.workspace.openTextFile(this.getUserSnippetsPath()) 36 | } 37 | })) 38 | 39 | this.loadAll() 40 | this.watchUserSnippets(watchDisposable => { 41 | this.subscriptions.add(watchDisposable) 42 | }) 43 | 44 | this.subscriptions.add(atom.config.onDidChange('core.packagesWithSnippetsDisabled', ({newValue, oldValue}) => { 45 | this.handleDisabledPackagesDidChange(newValue, oldValue) 46 | })) 47 | 48 | const snippets = this 49 | 50 | this.subscriptions.add(atom.commands.add('atom-text-editor', { 51 | 'snippets:expand'(event) { 52 | const editor = this.getModel() 53 | if (snippets.snippetToExpandUnderCursor(editor)) { 54 | snippets.clearExpansions(editor) 55 | snippets.expandSnippetsUnderCursors(editor) 56 | } else { 57 | event.abortKeyBinding() 58 | } 59 | }, 60 | 61 | 'snippets:next-tab-stop'(event) { 62 | const editor = this.getModel() 63 | if (!snippets.goToNextTabStop(editor)) { event.abortKeyBinding() } 64 | }, 65 | 66 | 'snippets:previous-tab-stop'(event) { 67 | const editor = this.getModel() 68 | if (!snippets.goToPreviousTabStop(editor)) { event.abortKeyBinding() } 69 | }, 70 | 71 | 'snippets:available'(event) { 72 | const editor = this.getModel() 73 | const SnippetsAvailable = require('./snippets-available') 74 | if (snippets.availableSnippetsView == null) { snippets.availableSnippetsView = new SnippetsAvailable(snippets) } 75 | snippets.availableSnippetsView.toggle(editor) 76 | } 77 | })) 78 | }, 79 | 80 | deactivate () { 81 | if (this.emitter != null) { 82 | this.emitter.dispose() 83 | } 84 | this.emitter = null 85 | this.editorSnippetExpansions = null 86 | atom.config.transact(() => this.subscriptions.dispose()) 87 | }, 88 | 89 | getUserSnippetsPath () { 90 | if (this.userSnippetsPath != null) { return this.userSnippetsPath } 91 | 92 | this.userSnippetsPath = CSON.resolve(path.join(atom.getConfigDirPath(), 'snippets')) 93 | if (this.userSnippetsPath == null) { this.userSnippetsPath = path.join(atom.getConfigDirPath(), 'snippets.cson') } 94 | return this.userSnippetsPath 95 | }, 96 | 97 | loadAll () { 98 | this.loadBundledSnippets(bundledSnippets => { 99 | this.loadPackageSnippets(packageSnippets => { 100 | this.loadUserSnippets(userSnippets => { 101 | atom.config.transact(() => { 102 | for (const snippetSet of [bundledSnippets, packageSnippets, userSnippets]) { 103 | for (const filepath in snippetSet) { 104 | const snippetsBySelector = snippetSet[filepath] 105 | this.add(filepath, snippetsBySelector) 106 | } 107 | } 108 | }) 109 | this.doneLoading() 110 | }) 111 | }) 112 | }) 113 | }, 114 | 115 | loadBundledSnippets (callback) { 116 | const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'lib', 'snippets')) 117 | this.loadSnippetsFile(bundledSnippetsPath, snippets => { 118 | const snippetsByPath = {} 119 | snippetsByPath[bundledSnippetsPath] = snippets 120 | callback(snippetsByPath) 121 | }) 122 | }, 123 | 124 | loadUserSnippets (callback) { 125 | const userSnippetsPath = this.getUserSnippetsPath() 126 | fs.stat(userSnippetsPath, (error, stat) => { 127 | if (stat != null && stat.isFile()) { 128 | this.loadSnippetsFile(userSnippetsPath, snippets => { 129 | const result = {} 130 | result[userSnippetsPath] = snippets 131 | callback(result) 132 | }) 133 | } else { 134 | callback({}) 135 | } 136 | }) 137 | }, 138 | 139 | watchUserSnippets (callback) { 140 | const userSnippetsPath = this.getUserSnippetsPath() 141 | fs.stat(userSnippetsPath, (error, stat) => { 142 | if (stat != null && stat.isFile()) { 143 | const userSnippetsFileDisposable = new CompositeDisposable() 144 | const userSnippetsFile = new File(userSnippetsPath) 145 | try { 146 | userSnippetsFileDisposable.add(userSnippetsFile.onDidChange(() => this.handleUserSnippetsDidChange())) 147 | userSnippetsFileDisposable.add(userSnippetsFile.onDidDelete(() => this.handleUserSnippetsDidChange())) 148 | userSnippetsFileDisposable.add(userSnippetsFile.onDidRename(() => this.handleUserSnippetsDidChange())) 149 | } catch (e) { 150 | const message = `\ 151 | Unable to watch path: \`snippets.cson\`. Make sure you have permissions 152 | to the \`~/.atom\` directory and \`${userSnippetsPath}\`. 153 | 154 | On linux there are currently problems with watch sizes. See 155 | [this document][watches] for more info. 156 | [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\ 157 | ` 158 | atom.notifications.addError(message, {dismissable: true}) 159 | } 160 | 161 | callback(userSnippetsFileDisposable) 162 | } else { 163 | callback(new Disposable()) 164 | } 165 | }) 166 | }, 167 | 168 | // Called when a user's snippets file is changed, deleted, or moved so that we 169 | // can immediately re-process the snippets it contains. 170 | handleUserSnippetsDidChange () { 171 | const userSnippetsPath = this.getUserSnippetsPath() 172 | atom.config.transact(() => { 173 | this.clearSnippetsForPath(userSnippetsPath) 174 | this.loadSnippetsFile(userSnippetsPath, result => { 175 | this.add(userSnippetsPath, result) 176 | }) 177 | }) 178 | }, 179 | 180 | // Called when the "Enable" checkbox is checked/unchecked in the Snippets 181 | // section of a package's settings view. 182 | handleDisabledPackagesDidChange (newDisabledPackages = [], oldDisabledPackages = []) { 183 | const packagesToAdd = [] 184 | const packagesToRemove = [] 185 | for (const p of oldDisabledPackages) { 186 | if (!newDisabledPackages.includes(p)) { packagesToAdd.push(p) } 187 | } 188 | 189 | for (const p of newDisabledPackages) { 190 | if (!oldDisabledPackages.includes(p)) { packagesToRemove.push(p) } 191 | } 192 | 193 | atom.config.transact(() => { 194 | for (const p of packagesToRemove) { this.removeSnippetsForPackage(p) } 195 | for (const p of packagesToAdd) { this.addSnippetsForPackage(p) } 196 | }) 197 | }, 198 | 199 | addSnippetsForPackage (packageName) { 200 | const snippetSet = this.snippetsByPackage.get(packageName) 201 | for (const filePath in snippetSet) { 202 | const snippetsBySelector = snippetSet[filePath] 203 | this.add(filePath, snippetsBySelector) 204 | } 205 | }, 206 | 207 | removeSnippetsForPackage (packageName) { 208 | const snippetSet = this.snippetsByPackage.get(packageName) 209 | // Copy these snippets to the "quarantined" ScopedPropertyStore so that they 210 | // remain present in the list of unparsed snippets reported to the settings 211 | // view. 212 | this.addSnippetsInDisabledPackage(snippetSet) 213 | for (const filePath in snippetSet) { 214 | this.clearSnippetsForPath(filePath) 215 | } 216 | }, 217 | 218 | loadPackageSnippets (callback) { 219 | const disabledPackageNames = atom.config.get('core.packagesWithSnippetsDisabled') || [] 220 | const packages = atom.packages.getLoadedPackages().sort((pack, _) => { 221 | return /\/node_modules\//.test(pack.path) ? -1 : 1 222 | }) 223 | 224 | const snippetsDirPaths = [] 225 | for (const pack of packages) { 226 | snippetsDirPaths.push(path.join(pack.path, 'snippets')) 227 | } 228 | 229 | async.map(snippetsDirPaths, this.loadSnippetsDirectory.bind(this), (error, results) => { 230 | const zipped = [] 231 | for (const key in results) { 232 | zipped.push({result: results[key], pack: packages[key]}) 233 | } 234 | 235 | const enabledPackages = [] 236 | for (const o of zipped) { 237 | // Skip packages that contain no snippets. 238 | if (Object.keys(o.result).length === 0) { continue } 239 | // Keep track of which snippets come from which packages so we can 240 | // unload them selectively later. All packages get put into this map, 241 | // even disabled packages, because we need to know which snippets to add 242 | // if those packages are enabled again. 243 | this.snippetsByPackage.set(o.pack.name, o.result) 244 | if (disabledPackageNames.includes(o.pack.name)) { 245 | // Since disabled packages' snippets won't get added to the main 246 | // ScopedPropertyStore, we'll keep track of them in a separate 247 | // ScopedPropertyStore so that they can still be represented in the 248 | // settings view. 249 | this.addSnippetsInDisabledPackage(o.result) 250 | } else { 251 | enabledPackages.push(o.result) 252 | } 253 | } 254 | 255 | callback(_.extend({}, ...enabledPackages)) 256 | }) 257 | }, 258 | 259 | doneLoading () { 260 | this.loaded = true 261 | this.getEmitter().emit('did-load-snippets') 262 | }, 263 | 264 | onDidLoadSnippets (callback) { 265 | this.getEmitter().on('did-load-snippets', callback) 266 | }, 267 | 268 | getEmitter () { 269 | if (this.emitter == null) { 270 | this.emitter = new Emitter 271 | } 272 | return this.emitter 273 | }, 274 | 275 | loadSnippetsDirectory (snippetsDirPath, callback) { 276 | fs.isDirectory(snippetsDirPath, isDirectory => { 277 | if (!isDirectory) { return callback(null, {}) } 278 | 279 | fs.readdir(snippetsDirPath, (error, entries) => { 280 | if (error) { 281 | console.warn(`Error reading snippets directory ${snippetsDirPath}`, error) 282 | return callback(null, {}) 283 | } 284 | 285 | async.map( 286 | entries, 287 | (entry, done) => { 288 | const filePath = path.join(snippetsDirPath, entry) 289 | this.loadSnippetsFile(filePath, snippets => done(null, {filePath, snippets})) 290 | }, 291 | (error, results) => { 292 | const snippetsByPath = {} 293 | for (const {filePath, snippets} of results) { 294 | snippetsByPath[filePath] = snippets 295 | } 296 | callback(null, snippetsByPath) 297 | }) 298 | }) 299 | }) 300 | }, 301 | 302 | loadSnippetsFile (filePath, callback) { 303 | if (!CSON.isObjectPath(filePath)) { return callback({}) } 304 | CSON.readFile(filePath, {allowDuplicateKeys: false}, (error, object = {}) => { 305 | if (error != null) { 306 | console.warn(`Error reading snippets file '${filePath}': ${error.stack != null ? error.stack : error}`) 307 | atom.notifications.addError(`Failed to load snippets from '${filePath}'`, {detail: error.message, dismissable: true}) 308 | } 309 | callback(object) 310 | }) 311 | }, 312 | 313 | add (filePath, snippetsBySelector, isDisabled = false) { 314 | for (const selector in snippetsBySelector) { 315 | const snippetsByName = snippetsBySelector[selector] 316 | const unparsedSnippetsByPrefix = {} 317 | for (const name in snippetsByName) { 318 | const attributes = snippetsByName[name] 319 | const {prefix, body} = attributes 320 | attributes.name = name 321 | attributes.id = this.snippetIdCounter++ 322 | if (typeof body === 'string') { 323 | unparsedSnippetsByPrefix[prefix] = attributes 324 | } else if (body == null) { 325 | unparsedSnippetsByPrefix[prefix] = null 326 | } 327 | } 328 | 329 | this.storeUnparsedSnippets(unparsedSnippetsByPrefix, filePath, selector, isDisabled) 330 | } 331 | }, 332 | 333 | addSnippetsInDisabledPackage (bundle) { 334 | for (const filePath in bundle) { 335 | const snippetsBySelector = bundle[filePath] 336 | this.add(filePath, snippetsBySelector, true) 337 | } 338 | }, 339 | 340 | getScopeChain (object) { 341 | let scopesArray = object 342 | if (object && object.getScopesArray) { 343 | scopesArray = object.getScopesArray() 344 | } 345 | 346 | return scopesArray 347 | .map(scope => scope[0] === '.' ? scope : `.${scope}`) 348 | .join(' ') 349 | }, 350 | 351 | storeUnparsedSnippets (value, path, selector, isDisabled = false) { 352 | // The `isDisabled` flag determines which scoped property store we'll use. 353 | // Active snippets get put into one and inactive snippets get put into 354 | // another. Only the first one gets consulted when we look up a snippet 355 | // prefix for expansion, but both stores have their contents exported when 356 | // the settings view asks for all available snippets. 357 | const unparsedSnippets = {} 358 | unparsedSnippets[selector] = {"snippets": value} 359 | const store = isDisabled ? this.disabledSnippetsScopedPropertyStore : this.scopedPropertyStore 360 | store.addProperties(path, unparsedSnippets, {priority: this.priorityForSource(path)}) 361 | }, 362 | 363 | clearSnippetsForPath (path) { 364 | for (const scopeSelector in this.scopedPropertyStore.propertiesForSource(path)) { 365 | const object = this.scopedPropertyStore.propertiesForSourceAndSelector(path, scopeSelector) 366 | for (const prefix in object) { 367 | const attributes = object[prefix] 368 | this.parsedSnippetsById.delete(attributes.id) 369 | } 370 | 371 | this.scopedPropertyStore.removePropertiesForSourceAndSelector(path, scopeSelector) 372 | } 373 | }, 374 | 375 | parsedSnippetsForScopes (scopeDescriptor) { 376 | let unparsedLegacySnippetsByPrefix 377 | 378 | const unparsedSnippetsByPrefix = this.scopedPropertyStore.getPropertyValue( 379 | this.getScopeChain(scopeDescriptor), 380 | "snippets" 381 | ) 382 | 383 | const legacyScopeDescriptor = atom.config.getLegacyScopeDescriptorForNewScopeDescriptor 384 | ? atom.config.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor) 385 | : undefined 386 | 387 | if (legacyScopeDescriptor) { 388 | unparsedLegacySnippetsByPrefix = this.scopedPropertyStore.getPropertyValue( 389 | this.getScopeChain(legacyScopeDescriptor), 390 | "snippets" 391 | ) 392 | } 393 | 394 | const snippets = {} 395 | 396 | if (unparsedSnippetsByPrefix) { 397 | for (const prefix in unparsedSnippetsByPrefix) { 398 | const attributes = unparsedSnippetsByPrefix[prefix] 399 | if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue } 400 | snippets[prefix] = this.getParsedSnippet(attributes) 401 | } 402 | } 403 | 404 | if (unparsedLegacySnippetsByPrefix) { 405 | for (const prefix in unparsedLegacySnippetsByPrefix) { 406 | const attributes = unparsedLegacySnippetsByPrefix[prefix] 407 | if (snippets[prefix]) { continue } 408 | if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue } 409 | snippets[prefix] = this.getParsedSnippet(attributes) 410 | } 411 | } 412 | 413 | return snippets 414 | }, 415 | 416 | getParsedSnippet (attributes) { 417 | let snippet = this.parsedSnippetsById.get(attributes.id) 418 | if (snippet == null) { 419 | let {id, prefix, name, body, bodyTree, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML} = attributes 420 | if (bodyTree == null) { bodyTree = this.getBodyParser().parse(body) } 421 | snippet = new Snippet({id, name, prefix, bodyTree, description, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, bodyText: body}) 422 | this.parsedSnippetsById.set(attributes.id, snippet) 423 | } 424 | return snippet 425 | }, 426 | 427 | priorityForSource (source) { 428 | if (source === this.getUserSnippetsPath()) { 429 | return 1000 430 | } else { 431 | return 0 432 | } 433 | }, 434 | 435 | getBodyParser () { 436 | if (this.bodyParser == null) { 437 | this.bodyParser = require('./snippet-body-parser') 438 | } 439 | return this.bodyParser 440 | }, 441 | 442 | // Get an {Object} with these keys: 443 | // * `snippetPrefix`: the possible snippet prefix text preceding the cursor 444 | // * `wordPrefix`: the word preceding the cursor 445 | // 446 | // Returns `null` if the values aren't the same for all cursors 447 | getPrefixText (snippets, editor) { 448 | const wordRegex = this.wordRegexForSnippets(snippets) 449 | 450 | let snippetPrefix = null 451 | let wordPrefix = null 452 | 453 | for (const cursor of editor.getCursors()) { 454 | const position = cursor.getBufferPosition() 455 | 456 | const prefixStart = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex}) 457 | const cursorSnippetPrefix = editor.getTextInRange([prefixStart, position]) 458 | if ((snippetPrefix != null) && (cursorSnippetPrefix !== snippetPrefix)) { return null } 459 | snippetPrefix = cursorSnippetPrefix 460 | 461 | const wordStart = cursor.getBeginningOfCurrentWordBufferPosition() 462 | const cursorWordPrefix = editor.getTextInRange([wordStart, position]) 463 | if ((wordPrefix != null) && (cursorWordPrefix !== wordPrefix)) { return null } 464 | wordPrefix = cursorWordPrefix 465 | } 466 | 467 | return {snippetPrefix, wordPrefix} 468 | }, 469 | 470 | // Get a RegExp of all the characters used in the snippet prefixes 471 | wordRegexForSnippets (snippets) { 472 | const prefixes = {} 473 | 474 | for (const prefix in snippets) { 475 | for (const character of prefix) { prefixes[character] = true } 476 | } 477 | 478 | const prefixCharacters = Object.keys(prefixes).join('') 479 | return new RegExp(`[${_.escapeRegExp(prefixCharacters)}]+`) 480 | }, 481 | 482 | // Get the best match snippet for the given prefix text. This will return 483 | // the longest match where there is no exact match to the prefix text. 484 | snippetForPrefix (snippets, prefix, wordPrefix) { 485 | let longestPrefixMatch = null 486 | 487 | for (const snippetPrefix in snippets) { 488 | const snippet = snippets[snippetPrefix] 489 | if (prefix.endsWith(snippetPrefix) && (wordPrefix.length <= snippetPrefix.length)) { 490 | if ((longestPrefixMatch == null) || (snippetPrefix.length > longestPrefixMatch.prefix.length)) { 491 | longestPrefixMatch = snippet 492 | } 493 | } 494 | } 495 | 496 | return longestPrefixMatch 497 | }, 498 | 499 | getSnippets (editor) { 500 | return this.parsedSnippetsForScopes(editor.getLastCursor().getScopeDescriptor()) 501 | }, 502 | 503 | snippetToExpandUnderCursor (editor) { 504 | if (!editor.getLastSelection().isEmpty()) { return false } 505 | const snippets = this.getSnippets(editor) 506 | if (_.isEmpty(snippets)) { return false } 507 | 508 | const prefixData = this.getPrefixText(snippets, editor) 509 | if (prefixData) { 510 | return this.snippetForPrefix(snippets, prefixData.snippetPrefix, prefixData.wordPrefix) 511 | } 512 | }, 513 | 514 | expandSnippetsUnderCursors (editor) { 515 | const snippet = this.snippetToExpandUnderCursor(editor) 516 | if (!snippet) { return false } 517 | 518 | this.getStore(editor).observeHistory({ 519 | undo: event => { this.onUndoOrRedo(editor, event, true) }, 520 | redo: event => { this.onUndoOrRedo(editor, event, false) } 521 | }) 522 | 523 | this.findOrCreateMarkerLayer(editor) 524 | editor.transact(() => { 525 | const cursors = editor.getCursors() 526 | for (const cursor of cursors) { 527 | const cursorPosition = cursor.getBufferPosition() 528 | const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0]) 529 | cursor.selection.setBufferRange([startPoint, cursorPosition]) 530 | this.insert(snippet, editor, cursor) 531 | } 532 | }) 533 | return true 534 | }, 535 | 536 | goToNextTabStop (editor) { 537 | let nextTabStopVisited = false 538 | for (const expansion of this.getExpansions(editor)) { 539 | if (expansion && expansion.goToNextTabStop()) { 540 | nextTabStopVisited = true 541 | } 542 | } 543 | return nextTabStopVisited 544 | }, 545 | 546 | goToPreviousTabStop (editor) { 547 | let previousTabStopVisited = false 548 | for (const expansion of this.getExpansions(editor)) { 549 | if (expansion && expansion.goToPreviousTabStop()) { 550 | previousTabStopVisited = true 551 | } 552 | } 553 | return previousTabStopVisited 554 | }, 555 | 556 | getStore (editor) { 557 | return EditorStore.findOrCreate(editor) 558 | }, 559 | 560 | findOrCreateMarkerLayer (editor) { 561 | let layer = this.editorMarkerLayers.get(editor) 562 | if (layer === undefined) { 563 | layer = editor.addMarkerLayer({maintainHistory: true}) 564 | this.editorMarkerLayers.set(editor, layer) 565 | } 566 | return layer 567 | }, 568 | 569 | getExpansions (editor) { 570 | return this.getStore(editor).getExpansions() 571 | }, 572 | 573 | clearExpansions (editor) { 574 | const store = this.getStore(editor) 575 | store.clearExpansions() 576 | // There are no more active instances of this expansion, so we should undo 577 | // the spying we set up on this editor. 578 | store.stopObserving() 579 | store.stopObservingHistory() 580 | }, 581 | 582 | addExpansion (editor, snippetExpansion) { 583 | this.getStore(editor).addExpansion(snippetExpansion) 584 | }, 585 | 586 | textChanged (editor, event) { 587 | const store = this.getStore(editor) 588 | const activeExpansions = store.getExpansions() 589 | 590 | if ((activeExpansions.length === 0) || activeExpansions[0].isIgnoringBufferChanges) { return } 591 | 592 | this.ignoringTextChangesForEditor(editor, () => 593 | editor.transact(() => 594 | activeExpansions.map(expansion => expansion.textChanged(event))) 595 | ) 596 | 597 | // Create a checkpoint here to consolidate all the changes we just made into 598 | // the transaction that prompted them. 599 | this.makeCheckpoint(editor) 600 | }, 601 | 602 | // Perform an action inside the editor without triggering our `textChanged` 603 | // callback. 604 | ignoringTextChangesForEditor (editor, callback) { 605 | this.stopObservingEditor(editor) 606 | callback() 607 | this.observeEditor(editor) 608 | }, 609 | 610 | observeEditor (editor) { 611 | this.getStore(editor).observe(event => this.textChanged(editor, event)) 612 | }, 613 | 614 | stopObservingEditor (editor) { 615 | this.getStore(editor).stopObserving() 616 | }, 617 | 618 | makeCheckpoint (editor) { 619 | this.getStore(editor).makeCheckpoint() 620 | }, 621 | 622 | insert (snippet, editor, cursor) { 623 | if (editor == null) { editor = atom.workspace.getActiveTextEditor() } 624 | if (cursor == null) { cursor = editor.getLastCursor() } 625 | if (typeof snippet === 'string') { 626 | const bodyTree = this.getBodyParser().parse(snippet) 627 | snippet = new Snippet({name: '__anonymous', prefix: '', bodyTree, bodyText: snippet}) 628 | } 629 | return new SnippetExpansion(snippet, editor, cursor, this) 630 | }, 631 | 632 | getUnparsedSnippets () { 633 | const results = [] 634 | const iterate = sets => { 635 | for (const item of sets) { 636 | const newItem = _.deepClone(item) 637 | // The atom-slick library has already parsed the `selector` property, so 638 | // it's an AST here instead of a string. The object has a `toString` 639 | // method that turns it back into a string. That custom behavior won't 640 | // be preserved in the deep clone of the object, so we have to handle it 641 | // separately. 642 | newItem.selectorString = item.selector.toString() 643 | results.push(newItem) 644 | } 645 | } 646 | 647 | iterate(this.scopedPropertyStore.propertySets) 648 | iterate(this.disabledSnippetsScopedPropertyStore.propertySets) 649 | return results 650 | }, 651 | 652 | provideSnippets () { 653 | return { 654 | bundledSnippetsLoaded: () => this.loaded, 655 | insertSnippet: this.insert.bind(this), 656 | snippetsForScopes: this.parsedSnippetsForScopes.bind(this), 657 | getUnparsedSnippets: this.getUnparsedSnippets.bind(this), 658 | getUserSnippetsPath: this.getUserSnippetsPath.bind(this) 659 | } 660 | }, 661 | 662 | onUndoOrRedo (editor, isUndo) { 663 | const activeExpansions = this.getExpansions(editor) 664 | activeExpansions.forEach(expansion => expansion.onUndoOrRedo(isUndo)) 665 | } 666 | } 667 | -------------------------------------------------------------------------------- /lib/tab-stop-list.js: -------------------------------------------------------------------------------- 1 | const TabStop = require('./tab-stop') 2 | 3 | class TabStopList { 4 | constructor (snippet) { 5 | this.snippet = snippet 6 | this.list = {} 7 | } 8 | 9 | get length () { 10 | return Object.keys(this.list).length 11 | } 12 | 13 | get hasEndStop () { 14 | return !!this.list[Infinity] 15 | } 16 | 17 | findOrCreate ({ index, snippet }) { 18 | if (!this.list[index]) { 19 | this.list[index] = new TabStop({ index, snippet }) 20 | } 21 | return this.list[index] 22 | } 23 | 24 | forEachIndex (iterator) { 25 | let indices = Object.keys(this.list).sort((a1, a2) => a1 - a2) 26 | indices.forEach(iterator) 27 | } 28 | 29 | getInsertions () { 30 | let results = [] 31 | this.forEachIndex(index => { 32 | results.push(...this.list[index].insertions) 33 | }) 34 | return results 35 | } 36 | 37 | toArray () { 38 | let results = [] 39 | this.forEachIndex(index => { 40 | let tabStop = this.list[index] 41 | if (!tabStop.isValid()) return 42 | results.push(tabStop) 43 | }) 44 | return results 45 | } 46 | } 47 | 48 | module.exports = TabStopList 49 | -------------------------------------------------------------------------------- /lib/tab-stop.js: -------------------------------------------------------------------------------- 1 | const {Range} = require('atom') 2 | const Insertion = require('./insertion') 3 | 4 | // A tab stop: 5 | // * belongs to a snippet 6 | // * has an index (one tab stop per index) 7 | // * has multiple Insertions 8 | class TabStop { 9 | constructor ({ snippet, index, insertions }) { 10 | this.insertions = insertions || [] 11 | Object.assign(this, { snippet, index }) 12 | } 13 | 14 | isValid () { 15 | let any = this.insertions.some(insertion => insertion.isTransformation()) 16 | if (!any) return true 17 | let all = this.insertions.every(insertion => insertion.isTransformation()) 18 | // If there are any transforming insertions, there must be at least one 19 | // non-transforming insertion to act as the primary. 20 | return !all 21 | } 22 | 23 | addInsertion ({ range, substitution, references }) { 24 | let insertion = new Insertion({ range, substitution, references }) 25 | let insertions = this.insertions 26 | insertions.push(insertion) 27 | insertions = insertions.sort((i1, i2) => { 28 | return i1.range.start.compare(i2.range.start) 29 | }) 30 | let initial = insertions.find(insertion => !insertion.isTransformation()) 31 | if (initial) { 32 | insertions.splice(insertions.indexOf(initial), 1) 33 | insertions.unshift(initial) 34 | } 35 | this.insertions = insertions 36 | } 37 | 38 | copyWithIndent (indent) { 39 | let { snippet, index, insertions } = this 40 | let newInsertions = insertions.map(insertion => { 41 | let { range, substitution } = insertion 42 | let newRange = Range.fromObject(range, true) 43 | if (newRange.start.row) { 44 | newRange.start.column += indent.length 45 | newRange.end.column += indent.length 46 | } 47 | return new Insertion({ 48 | range: newRange, 49 | substitution 50 | }) 51 | }) 52 | 53 | return new TabStop({ 54 | snippet, 55 | index, 56 | insertions: newInsertions 57 | }) 58 | } 59 | } 60 | 61 | module.exports = TabStop 62 | -------------------------------------------------------------------------------- /menus/snippets.cson: -------------------------------------------------------------------------------- 1 | 'menu': [ 2 | 'label': 'Packages' 3 | 'submenu': [ 4 | 'label': 'Snippets' 5 | 'submenu': [ 6 | { 'label': 'Expand', 'command': 'snippets:show' } 7 | { 'label': 'Next Stop', 'command': 'snippets:next-tab-stop' } 8 | { 'label': 'Previous Stop', 'command': 'snippets:previous-tab-stop' } 9 | { 'label': 'Available', 'command': 'snippets:available' } 10 | ] 11 | ] 12 | ] 13 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snippets", 3 | "version": "1.6.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "ansi-regex": { 8 | "version": "2.1.1", 9 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 10 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 11 | }, 12 | "async": { 13 | "version": "0.2.10", 14 | "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", 15 | "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" 16 | }, 17 | "atom-select-list": { 18 | "version": "0.7.2", 19 | "resolved": "https://registry.npmjs.org/atom-select-list/-/atom-select-list-0.7.2.tgz", 20 | "integrity": "sha512-a707OB1DhLGjzqtFrtMQKH7BBxFuCh8UBoUWxgFOrLrSwVh3g+/TlVPVDOz12+U0mDu3mIrnYLqQyhywQOTxhw==", 21 | "requires": { 22 | "etch": "^0.12.6", 23 | "fuzzaldrin": "^2.1.0" 24 | } 25 | }, 26 | "atom-slick": { 27 | "version": "2.0.0", 28 | "resolved": "https://registry.npmjs.org/atom-slick/-/atom-slick-2.0.0.tgz", 29 | "integrity": "sha1-/w2+Fb4sTtomi50w124lF+C308o=" 30 | }, 31 | "balanced-match": { 32 | "version": "1.0.0", 33 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 34 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" 35 | }, 36 | "brace-expansion": { 37 | "version": "1.1.8", 38 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", 39 | "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", 40 | "requires": { 41 | "balanced-match": "^1.0.0", 42 | "concat-map": "0.0.1" 43 | } 44 | }, 45 | "camelcase": { 46 | "version": "2.1.1", 47 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", 48 | "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" 49 | }, 50 | "cliui": { 51 | "version": "3.2.0", 52 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", 53 | "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", 54 | "requires": { 55 | "string-width": "^1.0.1", 56 | "strip-ansi": "^3.0.1", 57 | "wrap-ansi": "^2.0.0" 58 | } 59 | }, 60 | "code-point-at": { 61 | "version": "1.1.0", 62 | "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", 63 | "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" 64 | }, 65 | "coffee-script": { 66 | "version": "1.12.7", 67 | "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", 68 | "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==" 69 | }, 70 | "coffeelint": { 71 | "version": "1.16.0", 72 | "resolved": "https://registry.npmjs.org/coffeelint/-/coffeelint-1.16.0.tgz", 73 | "integrity": "sha1-g9jtHa/eOmd95E57ihi+YHdh5tg=", 74 | "dev": true, 75 | "requires": { 76 | "coffee-script": "~1.11.0", 77 | "glob": "^7.0.6", 78 | "ignore": "^3.0.9", 79 | "optimist": "^0.6.1", 80 | "resolve": "^0.6.3", 81 | "strip-json-comments": "^1.0.2" 82 | }, 83 | "dependencies": { 84 | "coffee-script": { 85 | "version": "1.11.1", 86 | "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.11.1.tgz", 87 | "integrity": "sha1-vxxHrWREOg2V0S3ysUfMCk2q1uk=", 88 | "dev": true 89 | }, 90 | "optimist": { 91 | "version": "0.6.1", 92 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 93 | "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", 94 | "dev": true, 95 | "requires": { 96 | "minimist": "~0.0.1", 97 | "wordwrap": "~0.0.2" 98 | } 99 | } 100 | } 101 | }, 102 | "concat-map": { 103 | "version": "0.0.1", 104 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 105 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 106 | }, 107 | "cson-parser": { 108 | "version": "1.3.5", 109 | "resolved": "https://registry.npmjs.org/cson-parser/-/cson-parser-1.3.5.tgz", 110 | "integrity": "sha1-fsZ14DkUVTO/KmqFYHPxWZ2cLSQ=", 111 | "requires": { 112 | "coffee-script": "^1.10.0" 113 | } 114 | }, 115 | "d": { 116 | "version": "0.1.1", 117 | "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz", 118 | "integrity": "sha1-2hhMU10Y2O57oqoim5FACfrhEwk=", 119 | "requires": { 120 | "es5-ext": "~0.10.2" 121 | } 122 | }, 123 | "decamelize": { 124 | "version": "1.2.0", 125 | "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 126 | "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" 127 | }, 128 | "emissary": { 129 | "version": "1.3.3", 130 | "resolved": "https://registry.npmjs.org/emissary/-/emissary-1.3.3.tgz", 131 | "integrity": "sha1-phjZLWgrIy0xER3DYlpd9mF5lgY=", 132 | "requires": { 133 | "es6-weak-map": "^0.1.2", 134 | "mixto": "1.x", 135 | "property-accessors": "^1.1", 136 | "underscore-plus": "1.x" 137 | } 138 | }, 139 | "es5-ext": { 140 | "version": "0.10.30", 141 | "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.30.tgz", 142 | "integrity": "sha1-cUGhaDZpfbq/qq7uQUlc4p9SyTk=", 143 | "requires": { 144 | "es6-iterator": "2", 145 | "es6-symbol": "~3.1" 146 | }, 147 | "dependencies": { 148 | "d": { 149 | "version": "1.0.0", 150 | "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", 151 | "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", 152 | "requires": { 153 | "es5-ext": "^0.10.9" 154 | } 155 | }, 156 | "es6-iterator": { 157 | "version": "2.0.1", 158 | "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.1.tgz", 159 | "integrity": "sha1-jjGcnwRTv1ddN0lAplWSDlnKVRI=", 160 | "requires": { 161 | "d": "1", 162 | "es5-ext": "^0.10.14", 163 | "es6-symbol": "^3.1" 164 | } 165 | }, 166 | "es6-symbol": { 167 | "version": "3.1.1", 168 | "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", 169 | "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", 170 | "requires": { 171 | "d": "1", 172 | "es5-ext": "~0.10.14" 173 | } 174 | } 175 | } 176 | }, 177 | "es6-iterator": { 178 | "version": "0.1.3", 179 | "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", 180 | "integrity": "sha1-1vWLjE/EE8JJtLqhl2j45NfIlE4=", 181 | "requires": { 182 | "d": "~0.1.1", 183 | "es5-ext": "~0.10.5", 184 | "es6-symbol": "~2.0.1" 185 | } 186 | }, 187 | "es6-symbol": { 188 | "version": "2.0.1", 189 | "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-2.0.1.tgz", 190 | "integrity": "sha1-dhtcZ8/U8dGK+yNPaR1nhoLLO/M=", 191 | "requires": { 192 | "d": "~0.1.1", 193 | "es5-ext": "~0.10.5" 194 | } 195 | }, 196 | "es6-weak-map": { 197 | "version": "0.1.4", 198 | "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-0.1.4.tgz", 199 | "integrity": "sha1-cGzvnpmqI2undmwjnIueKG6n0ig=", 200 | "requires": { 201 | "d": "~0.1.1", 202 | "es5-ext": "~0.10.6", 203 | "es6-iterator": "~0.1.3", 204 | "es6-symbol": "~2.0.1" 205 | } 206 | }, 207 | "etch": { 208 | "version": "0.12.8", 209 | "resolved": "https://registry.npmjs.org/etch/-/etch-0.12.8.tgz", 210 | "integrity": "sha512-dFLRe4wLroVtwzyy1vGlE3BSDZHiL0kZME5XgNGzZIULcYTvVno8vbiIleAesoKJmwWaxDTzG+4eppg2zk14JQ==" 211 | }, 212 | "event-kit": { 213 | "version": "1.5.0", 214 | "resolved": "https://registry.npmjs.org/event-kit/-/event-kit-1.5.0.tgz", 215 | "integrity": "sha1-Ek72qtgyjcsmtxxHWQtbjmPrxIc=", 216 | "requires": { 217 | "grim": "^1.2.1" 218 | } 219 | }, 220 | "fs-plus": { 221 | "version": "3.0.1", 222 | "resolved": "https://registry.npmjs.org/fs-plus/-/fs-plus-3.0.1.tgz", 223 | "integrity": "sha1-VMFpxA4ohKZtNSeA0Y3TH5HToQ0=", 224 | "requires": { 225 | "async": "^1.5.2", 226 | "mkdirp": "^0.5.1", 227 | "rimraf": "^2.5.2", 228 | "underscore-plus": "1.x" 229 | }, 230 | "dependencies": { 231 | "async": { 232 | "version": "1.5.2", 233 | "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", 234 | "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" 235 | } 236 | } 237 | }, 238 | "fs.realpath": { 239 | "version": "1.0.0", 240 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 241 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 242 | }, 243 | "fuzzaldrin": { 244 | "version": "2.1.0", 245 | "resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz", 246 | "integrity": "sha1-kCBMPi/appQbso0WZF1BgGOpDps=" 247 | }, 248 | "glob": { 249 | "version": "7.1.2", 250 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 251 | "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=", 252 | "requires": { 253 | "fs.realpath": "^1.0.0", 254 | "inflight": "^1.0.4", 255 | "inherits": "2", 256 | "minimatch": "^3.0.4", 257 | "once": "^1.3.0", 258 | "path-is-absolute": "^1.0.0" 259 | } 260 | }, 261 | "grim": { 262 | "version": "1.5.0", 263 | "resolved": "https://registry.npmjs.org/grim/-/grim-1.5.0.tgz", 264 | "integrity": "sha1-sysI71Z88YUvgXWe2caLDXE5ajI=", 265 | "requires": { 266 | "emissary": "^1.2.0" 267 | } 268 | }, 269 | "ignore": { 270 | "version": "3.3.5", 271 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.5.tgz", 272 | "integrity": "sha1-xOcVRV9gc6jX5drnLS/J1xZj26Y=", 273 | "dev": true 274 | }, 275 | "inflight": { 276 | "version": "1.0.6", 277 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 278 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 279 | "requires": { 280 | "once": "^1.3.0", 281 | "wrappy": "1" 282 | } 283 | }, 284 | "inherits": { 285 | "version": "2.0.3", 286 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 287 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 288 | }, 289 | "invert-kv": { 290 | "version": "1.0.0", 291 | "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", 292 | "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" 293 | }, 294 | "is-fullwidth-code-point": { 295 | "version": "1.0.0", 296 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", 297 | "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", 298 | "requires": { 299 | "number-is-nan": "^1.0.0" 300 | } 301 | }, 302 | "key-path-helpers": { 303 | "version": "0.1.0", 304 | "resolved": "https://registry.npmjs.org/key-path-helpers/-/key-path-helpers-0.1.0.tgz", 305 | "integrity": "sha1-zYFJULeZzHRaNGqlIfkilK9du6Q=" 306 | }, 307 | "lcid": { 308 | "version": "1.0.0", 309 | "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", 310 | "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", 311 | "requires": { 312 | "invert-kv": "^1.0.0" 313 | } 314 | }, 315 | "loophole": { 316 | "version": "1.1.0", 317 | "resolved": "https://registry.npmjs.org/loophole/-/loophole-1.1.0.tgz", 318 | "integrity": "sha1-N5Sf6kU7YlasxyXDIM4MWn9wor0=" 319 | }, 320 | "minimatch": { 321 | "version": "3.0.4", 322 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 323 | "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", 324 | "requires": { 325 | "brace-expansion": "^1.1.7" 326 | } 327 | }, 328 | "minimist": { 329 | "version": "0.0.8", 330 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 331 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 332 | }, 333 | "mixto": { 334 | "version": "1.0.0", 335 | "resolved": "https://registry.npmjs.org/mixto/-/mixto-1.0.0.tgz", 336 | "integrity": "sha1-wyDvYbUvKJj1IuF9i7xtUG2EJbY=" 337 | }, 338 | "mkdirp": { 339 | "version": "0.5.1", 340 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 341 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 342 | "requires": { 343 | "minimist": "0.0.8" 344 | } 345 | }, 346 | "number-is-nan": { 347 | "version": "1.0.1", 348 | "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", 349 | "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" 350 | }, 351 | "once": { 352 | "version": "1.4.0", 353 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 354 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 355 | "requires": { 356 | "wrappy": "1" 357 | } 358 | }, 359 | "os-locale": { 360 | "version": "1.4.0", 361 | "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", 362 | "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", 363 | "requires": { 364 | "lcid": "^1.0.0" 365 | } 366 | }, 367 | "os-tmpdir": { 368 | "version": "1.0.2", 369 | "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", 370 | "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" 371 | }, 372 | "path-is-absolute": { 373 | "version": "1.0.1", 374 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 375 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 376 | }, 377 | "pegjs": { 378 | "version": "0.8.0", 379 | "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.8.0.tgz", 380 | "integrity": "sha1-l28GfaE+XFsVAcAXklZoolOBFWE=" 381 | }, 382 | "property-accessors": { 383 | "version": "1.1.3", 384 | "resolved": "https://registry.npmjs.org/property-accessors/-/property-accessors-1.1.3.tgz", 385 | "integrity": "sha1-Hd6EAkYxhlkJ7zBwM2VoDF+SixU=", 386 | "requires": { 387 | "es6-weak-map": "^0.1.2", 388 | "mixto": "1.x" 389 | } 390 | }, 391 | "resolve": { 392 | "version": "0.6.3", 393 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", 394 | "integrity": "sha1-3ZV5gufnNt699TtYpN2RdUV13UY=", 395 | "dev": true 396 | }, 397 | "rimraf": { 398 | "version": "2.6.2", 399 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", 400 | "integrity": "sha1-LtgVDSShbqhlHm1u8PR8QVjOejY=", 401 | "requires": { 402 | "glob": "^7.0.5" 403 | } 404 | }, 405 | "scoped-property-store": { 406 | "version": "0.17.0", 407 | "resolved": "https://registry.npmjs.org/scoped-property-store/-/scoped-property-store-0.17.0.tgz", 408 | "integrity": "sha1-raAsANYC/SBQlh4nF92dArozGDE=", 409 | "requires": { 410 | "atom-slick": "^2", 411 | "event-kit": "^1.0.0", 412 | "grim": "^1.2.1", 413 | "key-path-helpers": "^0.1.0", 414 | "underscore-plus": "^1.6.3" 415 | } 416 | }, 417 | "season": { 418 | "version": "6.0.2", 419 | "resolved": "https://registry.npmjs.org/season/-/season-6.0.2.tgz", 420 | "integrity": "sha1-naWPsd3SSCTXYhstxjpxI7UCF7Y=", 421 | "requires": { 422 | "cson-parser": "^1.3.0", 423 | "fs-plus": "^3.0.0", 424 | "yargs": "^3.23.0" 425 | } 426 | }, 427 | "string-width": { 428 | "version": "1.0.2", 429 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", 430 | "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", 431 | "requires": { 432 | "code-point-at": "^1.0.0", 433 | "is-fullwidth-code-point": "^1.0.0", 434 | "strip-ansi": "^3.0.0" 435 | } 436 | }, 437 | "strip-ansi": { 438 | "version": "3.0.1", 439 | "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 440 | "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", 441 | "requires": { 442 | "ansi-regex": "^2.0.0" 443 | } 444 | }, 445 | "strip-json-comments": { 446 | "version": "1.0.4", 447 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", 448 | "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", 449 | "dev": true 450 | }, 451 | "temp": { 452 | "version": "0.8.3", 453 | "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.3.tgz", 454 | "integrity": "sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=", 455 | "requires": { 456 | "os-tmpdir": "^1.0.0", 457 | "rimraf": "~2.2.6" 458 | }, 459 | "dependencies": { 460 | "rimraf": { 461 | "version": "2.2.8", 462 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz", 463 | "integrity": "sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=" 464 | } 465 | } 466 | }, 467 | "underscore": { 468 | "version": "1.6.0", 469 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", 470 | "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" 471 | }, 472 | "underscore-plus": { 473 | "version": "1.6.6", 474 | "resolved": "https://registry.npmjs.org/underscore-plus/-/underscore-plus-1.6.6.tgz", 475 | "integrity": "sha1-ZezeG9xEGjXYnmUP1w3PE65Dmn0=", 476 | "requires": { 477 | "underscore": "~1.6.0" 478 | } 479 | }, 480 | "window-size": { 481 | "version": "0.1.4", 482 | "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", 483 | "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=" 484 | }, 485 | "wordwrap": { 486 | "version": "0.0.3", 487 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 488 | "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", 489 | "dev": true 490 | }, 491 | "wrap-ansi": { 492 | "version": "2.1.0", 493 | "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", 494 | "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", 495 | "requires": { 496 | "string-width": "^1.0.1", 497 | "strip-ansi": "^3.0.1" 498 | } 499 | }, 500 | "wrappy": { 501 | "version": "1.0.2", 502 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 503 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 504 | }, 505 | "y18n": { 506 | "version": "3.2.1", 507 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", 508 | "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" 509 | }, 510 | "yargs": { 511 | "version": "3.32.0", 512 | "resolved": "http://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", 513 | "integrity": "sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU=", 514 | "requires": { 515 | "camelcase": "^2.0.1", 516 | "cliui": "^3.0.3", 517 | "decamelize": "^1.1.1", 518 | "os-locale": "^1.4.0", 519 | "string-width": "^1.0.1", 520 | "window-size": "^0.1.4", 521 | "y18n": "^3.2.0" 522 | } 523 | } 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snippets", 3 | "version": "1.6.0", 4 | "main": "./lib/snippets", 5 | "description": "Expand snippets matching the current prefix with `tab`.", 6 | "repository": "https://github.com/atom/snippets", 7 | "license": "MIT", 8 | "engines": { 9 | "atom": "*" 10 | }, 11 | "dependencies": { 12 | "async": "~0.2.6", 13 | "atom-select-list": "^0.7.0", 14 | "fs-plus": "^3.0.0", 15 | "loophole": "^1", 16 | "pegjs": "~0.8.0", 17 | "scoped-property-store": "^0.17.0", 18 | "season": "^6.0.2", 19 | "temp": "~0.8.0", 20 | "underscore-plus": "^1.0.0" 21 | }, 22 | "providedServices": { 23 | "snippets": { 24 | "description": "Snippets are text shortcuts that can be expanded to their definition.", 25 | "versions": { 26 | "0.1.0": "provideSnippets" 27 | } 28 | } 29 | }, 30 | "devDependencies": { 31 | "coffeelint": "^1.9.7" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spec/body-parser-spec.js: -------------------------------------------------------------------------------- 1 | const BodyParser = require('../lib/snippet-body-parser'); 2 | 3 | describe("Snippet Body Parser", () => { 4 | it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => { 5 | const bodyTree = BodyParser.parse(`\ 6 | the quick brown $1fox \${2:jumped \${3:over} 7 | }the \${4:lazy} dog\ 8 | ` 9 | ); 10 | 11 | expect(bodyTree).toEqual([ 12 | "the quick brown ", 13 | {index: 1, content: []}, 14 | "fox ", 15 | { 16 | index: 2, 17 | content: [ 18 | "jumped ", 19 | {index: 3, content: ["over"]}, 20 | "\n" 21 | ], 22 | }, 23 | "the ", 24 | {index: 4, content: ["lazy"]}, 25 | " dog" 26 | ]); 27 | }); 28 | 29 | it("removes interpolated variables in placeholder text (we don't currently support it)", () => { 30 | const bodyTree = BodyParser.parse("module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}}"); 31 | expect(bodyTree).toEqual([ 32 | "module ", 33 | { 34 | "index": 1, 35 | "content": ["ActiveRecord::", ""] 36 | } 37 | ]); 38 | }); 39 | 40 | it("skips escaped tabstops", () => { 41 | const bodyTree = BodyParser.parse("snippet $1 escaped \\$2 \\\\$3"); 42 | expect(bodyTree).toEqual([ 43 | "snippet ", 44 | { 45 | index: 1, 46 | content: [] 47 | }, 48 | " escaped $2 \\", 49 | { 50 | index: 3, 51 | content: [] 52 | } 53 | ]); 54 | }); 55 | 56 | it("includes escaped right-braces", () => { 57 | const bodyTree = BodyParser.parse("snippet ${1:{\\}}"); 58 | expect(bodyTree).toEqual([ 59 | "snippet ", 60 | { 61 | index: 1, 62 | content: ["{}"] 63 | } 64 | ]); 65 | }); 66 | 67 | it("parses a snippet with transformations", () => { 68 | const bodyTree = BodyParser.parse("<${1:p}>$0"); 69 | expect(bodyTree).toEqual([ 70 | '<', 71 | {index: 1, content: ['p']}, 72 | '>', 73 | {index: 0, content: []}, 74 | '' 77 | ]); 78 | }); 79 | 80 | it("parses a snippet with multiple tab stops with transformations", () => { 81 | const bodyTree = BodyParser.parse("${1:placeholder} ${1/(.)/\\u$1/} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2"); 82 | expect(bodyTree).toEqual([ 83 | {index: 1, content: ['placeholder']}, 84 | ' ', 85 | { 86 | index: 1, 87 | content: [], 88 | substitution: { 89 | find: /(.)/g, 90 | replace: [ 91 | {escape: 'u'}, 92 | {backreference: 1} 93 | ] 94 | } 95 | }, 96 | ' ', 97 | {index: 1, content: []}, 98 | ' ', 99 | {index: 2, content: ['ANOTHER']}, 100 | ' ', 101 | { 102 | index: 2, 103 | content: [], 104 | substitution: { 105 | find: /^(.*)$/g, 106 | replace: [ 107 | {escape: 'L'}, 108 | {backreference: 1} 109 | ] 110 | } 111 | }, 112 | ' ', 113 | {index: 2, content: []}, 114 | ]); 115 | }); 116 | 117 | 118 | it("parses a snippet with transformations and mirrors", () => { 119 | const bodyTree = BodyParser.parse("${1:placeholder}\n${1/(.)/\\u$1/}\n$1"); 120 | expect(bodyTree).toEqual([ 121 | {index: 1, content: ['placeholder']}, 122 | '\n', 123 | { 124 | index: 1, 125 | content: [], 126 | substitution: { 127 | find: /(.)/g, 128 | replace: [ 129 | {escape: 'u'}, 130 | {backreference: 1} 131 | ] 132 | } 133 | }, 134 | '\n', 135 | {index: 1, content: []} 136 | ]); 137 | }); 138 | 139 | it("parses a snippet with a format string and case-control flags", () => { 140 | const bodyTree = BodyParser.parse("<${1:p}>$0"); 141 | expect(bodyTree).toEqual([ 142 | '<', 143 | {index: 1, content: ['p']}, 144 | '>', 145 | {index: 0, content: []}, 146 | '' 160 | ]); 161 | }); 162 | 163 | it("parses a snippet with an escaped forward slash in a transform", () => { 164 | // Annoyingly, a forward slash needs to be double-backslashed just like the 165 | // other escapes. 166 | const bodyTree = BodyParser.parse("<${1:p}>$0"); 167 | expect(bodyTree).toEqual([ 168 | '<', 169 | {index: 1, content: ['p']}, 170 | '>', 171 | {index: 0, content: []}, 172 | '' 186 | ]); 187 | }); 188 | 189 | it("parses a snippet with a placeholder that mirrors another tab stop's content", () => { 190 | const bodyTree = BodyParser.parse("$4console.${3:log}('${2:$1}', $1);$0"); 191 | expect(bodyTree).toEqual([ 192 | {index: 4, content: []}, 193 | 'console.', 194 | {index: 3, content: ['log']}, 195 | '(\'', 196 | { 197 | index: 2, content: [ 198 | {index: 1, content: []} 199 | ] 200 | }, 201 | '\', ', 202 | {index: 1, content: []}, 203 | ');', 204 | {index: 0, content: []} 205 | ]); 206 | }); 207 | 208 | it("parses a snippet with a placeholder that mixes text and tab stop references", () => { 209 | const bodyTree = BodyParser.parse("$4console.${3:log}('${2:uh $1}', $1);$0"); 210 | expect(bodyTree).toEqual([ 211 | {index: 4, content: []}, 212 | 'console.', 213 | {index: 3, content: ['log']}, 214 | '(\'', 215 | { 216 | index: 2, content: [ 217 | 'uh ', 218 | {index: 1, content: []} 219 | ] 220 | }, 221 | '\', ', 222 | {index: 1, content: []}, 223 | ');', 224 | {index: 0, content: []} 225 | ]); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /spec/fixtures/package-with-broken-snippets/snippets/.hidden-file: -------------------------------------------------------------------------------- 1 | I am hidden so I shouldn't be loaded 2 | -------------------------------------------------------------------------------- /spec/fixtures/package-with-broken-snippets/snippets/invalid.json: -------------------------------------------------------------------------------- 1 | I am not a valid JSON file but that shouldn't cause a crisis 2 | -------------------------------------------------------------------------------- /spec/fixtures/package-with-snippets/snippets/.hidden-file: -------------------------------------------------------------------------------- 1 | This is a hidden file. Don't even try to load it as a snippet 2 | -------------------------------------------------------------------------------- /spec/fixtures/package-with-snippets/snippets/junk-file: -------------------------------------------------------------------------------- 1 | This file isn't CSON, but shouldn't be a big deal -------------------------------------------------------------------------------- /spec/fixtures/package-with-snippets/snippets/test.cson: -------------------------------------------------------------------------------- 1 | ".test": 2 | "Test Snippet": 3 | prefix: "test" 4 | body: "testing 123" 5 | "Test Snippet With Description": 6 | prefix: "testd" 7 | body: "testing 456" 8 | description: "a description" 9 | descriptionMoreURL: "http://google.com" 10 | "Test Snippet With A Label On The Left": 11 | prefix: "testlabelleft" 12 | body: "testing 456" 13 | leftLabel: "a label" 14 | "Test Snippet With HTML Labels": 15 | prefix: "testhtmllabels" 16 | body: "testing 456" 17 | leftLabelHTML: "Label" 18 | rightLabelHTML: "Label" 19 | 20 | ".package-with-snippets-unique-scope": 21 | "Test Snippet": 22 | prefix: "test" 23 | body: "testing 123" 24 | 25 | ".source.js": 26 | "Overrides a core package's snippet": 27 | prefix: "log" 28 | body: "from-a-community-package" 29 | -------------------------------------------------------------------------------- /spec/fixtures/sample.js: -------------------------------------------------------------------------------- 1 | var quicksort = function () { 2 | var sort = function(items) { 3 | if (items.length <= 1) return items; 4 | var pivot = items.shift(), current, left = [], right = []; 5 | while(items.length > 0) { 6 | current = items.shift(); 7 | current < pivot ? left.push(current) : right.push(current); 8 | } 9 | return sort(left).concat(pivot).concat(sort(right)); 10 | }; 11 | 12 | return sort(Array.apply(this, arguments)); 13 | }; 14 | -------------------------------------------------------------------------------- /spec/insertion-spec.js: -------------------------------------------------------------------------------- 1 | const Insertion = require('../lib/insertion') 2 | const { Range } = require('atom') 3 | 4 | const range = new Range(0, 0) 5 | 6 | describe('Insertion', () => { 7 | it('returns what it was given when it has no substitution', () => { 8 | let insertion = new Insertion({ 9 | range, 10 | substitution: undefined 11 | }) 12 | let transformed = insertion.transform('foo!') 13 | 14 | expect(transformed).toEqual('foo!') 15 | }) 16 | 17 | it('transforms what it was given when it has a regex transformation', () => { 18 | let insertion = new Insertion({ 19 | range, 20 | substitution: { 21 | find: /foo/g, 22 | replace: ['bar'] 23 | } 24 | }) 25 | let transformed = insertion.transform('foo!') 26 | 27 | expect(transformed).toEqual('bar!') 28 | }) 29 | 30 | it('transforms the case of the next character when encountering a \\u or \\l flag', () => { 31 | let uInsertion = new Insertion({ 32 | range, 33 | substitution: { 34 | find: /(.)(.)(.*)/g, 35 | replace: [ 36 | { backreference: 1 }, 37 | { escape: 'u' }, 38 | { backreference: 2 }, 39 | { backreference: 3 } 40 | ] 41 | } 42 | }) 43 | 44 | expect(uInsertion.transform('foo!')).toEqual('fOo!') 45 | expect(uInsertion.transform('fOo!')).toEqual('fOo!') 46 | expect(uInsertion.transform('FOO!')).toEqual('FOO!') 47 | 48 | let lInsertion = new Insertion({ 49 | range, 50 | substitution: { 51 | find: /(.{2})(.)(.*)/g, 52 | replace: [ 53 | { backreference: 1 }, 54 | { escape: 'l' }, 55 | { backreference: 2 }, 56 | { backreference: 3 } 57 | ] 58 | } 59 | }) 60 | 61 | expect(lInsertion.transform('FOO!')).toEqual('FOo!') 62 | expect(lInsertion.transform('FOo!')).toEqual('FOo!') 63 | expect(lInsertion.transform('FoO!')).toEqual('Foo!') 64 | expect(lInsertion.transform('foo!')).toEqual('foo!') 65 | }) 66 | 67 | it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => { 68 | let uInsertion = new Insertion({ 69 | range, 70 | substitution: { 71 | find: /(.)(.*)/, 72 | replace: [ 73 | { backreference: 1 }, 74 | { escape: 'U' }, 75 | { backreference: 2 } 76 | ] 77 | } 78 | }) 79 | 80 | expect(uInsertion.transform('lorem ipsum!')).toEqual('lOREM IPSUM!') 81 | expect(uInsertion.transform('lOREM IPSUM!')).toEqual('lOREM IPSUM!') 82 | expect(uInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') 83 | 84 | let ueInsertion = new Insertion({ 85 | range, 86 | substitution: { 87 | find: /(.)(.{3})(.*)/, 88 | replace: [ 89 | { backreference: 1 }, 90 | { escape: 'U' }, 91 | { backreference: 2 }, 92 | { escape: 'E' }, 93 | { backreference: 3 } 94 | ] 95 | } 96 | }) 97 | 98 | expect(ueInsertion.transform('lorem ipsum!')).toEqual('lOREm ipsum!') 99 | expect(ueInsertion.transform('lOREm ipsum!')).toEqual('lOREm ipsum!') 100 | expect(ueInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') 101 | 102 | let lInsertion = new Insertion({ 103 | range, 104 | substitution: { 105 | find: /(.{4})(.)(.*)/, 106 | replace: [ 107 | { backreference: 1 }, 108 | { escape: 'L' }, 109 | { backreference: 2 }, 110 | 'WHAT' 111 | ] 112 | } 113 | }) 114 | 115 | expect(lInsertion.transform('LOREM IPSUM!')).toEqual('LOREmwhat') 116 | 117 | let leInsertion = new Insertion({ 118 | range, 119 | substitution: { 120 | find: /^([A-Fa-f])(.*)(.)$/, 121 | replace: [ 122 | { backreference: 1 }, 123 | { escape: 'L' }, 124 | { backreference: 2 }, 125 | { escape: 'E' }, 126 | { backreference: 3 } 127 | ] 128 | } 129 | }) 130 | 131 | expect(leInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!') 132 | expect(leInsertion.transform('CONSECUETUR')).toEqual('ConsecuetuR') 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /spec/snippet-loading-spec.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-plus'); 3 | const temp = require('temp').track(); 4 | 5 | describe("Snippet Loading", () => { 6 | let configDirPath, snippetsService; 7 | 8 | beforeEach(() => { 9 | configDirPath = temp.mkdirSync('atom-config-dir-'); 10 | spyOn(atom, 'getConfigDirPath').andReturn(configDirPath); 11 | 12 | spyOn(console, 'warn'); 13 | if (atom.notifications != null) { spyOn(atom.notifications, 'addError'); } 14 | 15 | spyOn(atom.packages, 'getLoadedPackages').andReturn([ 16 | atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')), 17 | atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-broken-snippets')), 18 | ]); 19 | }); 20 | 21 | afterEach(() => { 22 | waitsForPromise(() => Promise.resolve(atom.packages.deactivatePackages('snippets'))); 23 | runs(() => { 24 | jasmine.unspy(atom.packages, 'getLoadedPackages'); 25 | }); 26 | }); 27 | 28 | const activateSnippetsPackage = () => { 29 | waitsForPromise(() => atom.packages.activatePackage("snippets").then(({mainModule}) => { 30 | snippetsService = mainModule.provideSnippets(); 31 | mainModule.loaded = false; 32 | })); 33 | 34 | waitsFor("all snippets to load", 3000, () => snippetsService.bundledSnippetsLoaded()); 35 | }; 36 | 37 | it("loads the bundled snippet template snippets", () => { 38 | activateSnippetsPackage(); 39 | 40 | runs(() => { 41 | const jsonSnippet = snippetsService.snippetsForScopes(['.source.json'])['snip']; 42 | expect(jsonSnippet.name).toBe('Atom Snippet'); 43 | expect(jsonSnippet.prefix).toBe('snip'); 44 | expect(jsonSnippet.body).toContain('"prefix":'); 45 | expect(jsonSnippet.body).toContain('"body":'); 46 | expect(jsonSnippet.tabStopList.length).toBeGreaterThan(0); 47 | 48 | const csonSnippet = snippetsService.snippetsForScopes(['.source.coffee'])['snip']; 49 | expect(csonSnippet.name).toBe('Atom Snippet'); 50 | expect(csonSnippet.prefix).toBe('snip'); 51 | expect(csonSnippet.body).toContain("'prefix':"); 52 | expect(csonSnippet.body).toContain("'body':"); 53 | expect(csonSnippet.tabStopList.length).toBeGreaterThan(0); 54 | }); 55 | }); 56 | 57 | it("loads non-hidden snippet files from atom packages with snippets directories", () => { 58 | activateSnippetsPackage(); 59 | 60 | runs(() => { 61 | let snippet = snippetsService.snippetsForScopes(['.test'])['test']; 62 | expect(snippet.prefix).toBe('test'); 63 | expect(snippet.body).toBe('testing 123'); 64 | 65 | snippet = snippetsService.snippetsForScopes(['.test'])['testd']; 66 | expect(snippet.prefix).toBe('testd'); 67 | expect(snippet.body).toBe('testing 456'); 68 | expect(snippet.description).toBe('a description'); 69 | expect(snippet.descriptionMoreURL).toBe('http://google.com'); 70 | 71 | snippet = snippetsService.snippetsForScopes(['.test'])['testlabelleft']; 72 | expect(snippet.prefix).toBe('testlabelleft'); 73 | expect(snippet.body).toBe('testing 456'); 74 | expect(snippet.leftLabel).toBe('a label'); 75 | 76 | snippet = snippetsService.snippetsForScopes(['.test'])['testhtmllabels']; 77 | expect(snippet.prefix).toBe('testhtmllabels'); 78 | expect(snippet.body).toBe('testing 456'); 79 | expect(snippet.leftLabelHTML).toBe('Label'); 80 | expect(snippet.rightLabelHTML).toBe('Label'); 81 | }); 82 | }); 83 | 84 | it("logs a warning if package snippets files cannot be parsed", () => { 85 | activateSnippetsPackage(); 86 | 87 | runs(() => { 88 | // Warn about invalid-file, but don't even try to parse a hidden file 89 | expect(console.warn.calls.length).toBeGreaterThan(0); 90 | expect(console.warn.mostRecentCall.args[0]).toMatch(/Error reading.*package-with-broken-snippets/); 91 | }); 92 | }); 93 | 94 | describe("::loadPackageSnippets(callback)", () => { 95 | beforeEach(() => { // simulate a list of packages where the javascript core package is returned at the end 96 | atom.packages.getLoadedPackages.andReturn([ 97 | atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')), 98 | atom.packages.loadPackage('language-javascript') 99 | ]) 100 | }); 101 | 102 | it("allows other packages to override core packages' snippets", () => { 103 | waitsForPromise(() => atom.packages.activatePackage("language-javascript")); 104 | 105 | activateSnippetsPackage(); 106 | 107 | runs(() => { 108 | const snippet = snippetsService.snippetsForScopes(['.source.js'])['log']; 109 | expect(snippet.body).toBe("from-a-community-package"); 110 | }); 111 | }); 112 | }); 113 | 114 | describe("::onDidLoadSnippets(callback)", () => { 115 | it("invokes listeners when all snippets are loaded", () => { 116 | let loadedCallback = null; 117 | 118 | waitsFor("package to activate", done => atom.packages.activatePackage("snippets").then(({mainModule}) => { 119 | mainModule.onDidLoadSnippets(loadedCallback = jasmine.createSpy('onDidLoadSnippets callback')); 120 | done(); 121 | })); 122 | 123 | waitsFor("onDidLoad callback to be called", () => loadedCallback.callCount > 0); 124 | }); 125 | }); 126 | 127 | describe("when ~/.atom/snippets.json exists", () => { 128 | beforeEach(() => { 129 | fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\ 130 | { 131 | ".foo": { 132 | "foo snippet": { 133 | "prefix": "foo", 134 | "body": "bar1" 135 | } 136 | } 137 | }\ 138 | ` 139 | ); 140 | activateSnippetsPackage(); 141 | }); 142 | 143 | it("loads the snippets from that file", () => { 144 | let snippet = null; 145 | 146 | waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']); 147 | 148 | runs(() => { 149 | expect(snippet.name).toBe('foo snippet'); 150 | expect(snippet.prefix).toBe("foo"); 151 | expect(snippet.body).toBe("bar1"); 152 | }); 153 | }); 154 | 155 | describe("when that file changes", () => { 156 | it("reloads the snippets", () => { 157 | fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\ 158 | { 159 | ".foo": { 160 | "foo snippet": { 161 | "prefix": "foo", 162 | "body": "bar2" 163 | } 164 | } 165 | }\ 166 | ` 167 | ); 168 | 169 | waitsFor("snippets to be changed", () => { 170 | const snippet = snippetsService.snippetsForScopes(['.foo'])['foo']; 171 | return snippet && snippet.body === 'bar2'; 172 | }); 173 | 174 | runs(() => { 175 | fs.writeFileSync(path.join(configDirPath, 'snippets.json'), ""); 176 | }); 177 | 178 | waitsFor("snippets to be removed", () => !snippetsService.snippetsForScopes(['.foo'])['foo']); 179 | }); 180 | }); 181 | }); 182 | 183 | describe("when ~/.atom/snippets.cson exists", () => { 184 | beforeEach(() => { 185 | fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\ 186 | ".foo": 187 | "foo snippet": 188 | "prefix": "foo" 189 | "body": "bar1"\ 190 | ` 191 | ); 192 | activateSnippetsPackage(); 193 | }); 194 | 195 | it("loads the snippets from that file", () => { 196 | let snippet = null; 197 | 198 | waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']); 199 | 200 | runs(() => { 201 | expect(snippet.name).toBe('foo snippet'); 202 | expect(snippet.prefix).toBe("foo"); 203 | expect(snippet.body).toBe("bar1"); 204 | }); 205 | }); 206 | 207 | describe("when that file changes", () => { 208 | it("reloads the snippets", () => { 209 | fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\ 210 | ".foo": 211 | "foo snippet": 212 | "prefix": "foo" 213 | "body": "bar2"\ 214 | ` 215 | ); 216 | 217 | waitsFor("snippets to be changed", () => { 218 | const snippet = snippetsService.snippetsForScopes(['.foo'])['foo']; 219 | return snippet && snippet.body === 'bar2'; 220 | }); 221 | 222 | runs(() => { 223 | fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), ""); 224 | }); 225 | 226 | waitsFor("snippets to be removed", () => { 227 | const snippet = snippetsService.snippetsForScopes(['.foo'])['foo']; 228 | return snippet == null; 229 | }); 230 | }); 231 | }); 232 | }); 233 | 234 | it("notifies the user when the user snippets file cannot be loaded", () => { 235 | fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), '".junk":::'); 236 | 237 | activateSnippetsPackage(); 238 | 239 | runs(() => { 240 | expect(console.warn).toHaveBeenCalled(); 241 | if (atom.notifications != null) { 242 | expect(atom.notifications.addError).toHaveBeenCalled(); 243 | } 244 | }); 245 | }); 246 | 247 | describe("packages-with-snippets-disabled feature", () => { 248 | it("disables no snippets if the config option is empty", () => { 249 | const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled'); 250 | atom.config.set('core.packagesWithSnippetsDisabled', []); 251 | 252 | activateSnippetsPackage(); 253 | runs(() => { 254 | const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']); 255 | expect(Object.keys(snippets).length).toBe(1); 256 | atom.config.set('core.packagesWithSnippetsDisabled', originalConfig); 257 | }); 258 | }); 259 | 260 | it("still includes a disabled package's snippets in the list of unparsed snippets", () => { 261 | let originalConfig = atom.config.get('core.packagesWithSnippetsDisabled'); 262 | atom.config.set('core.packagesWithSnippetsDisabled', []); 263 | 264 | activateSnippetsPackage(); 265 | runs(() => { 266 | atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']); 267 | const allSnippets = snippetsService.getUnparsedSnippets(); 268 | const scopedSnippet = allSnippets.find(s => s.selectorString === '.package-with-snippets-unique-scope'); 269 | expect(scopedSnippet).not.toBe(undefined); 270 | atom.config.set('core.packagesWithSnippetsDisabled', originalConfig); 271 | }); 272 | }); 273 | 274 | it("never loads a package's snippets when that package is disabled in config", () => { 275 | const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled'); 276 | atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']); 277 | 278 | activateSnippetsPackage(); 279 | runs(() => { 280 | const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']); 281 | expect(Object.keys(snippets).length).toBe(0); 282 | atom.config.set('core.packagesWithSnippetsDisabled', originalConfig); 283 | }); 284 | }); 285 | 286 | it("unloads and/or reloads snippets from a package if the config option is changed after activation", () => { 287 | const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled'); 288 | atom.config.set('core.packagesWithSnippetsDisabled', []); 289 | 290 | activateSnippetsPackage(); 291 | runs(() => { 292 | let snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']); 293 | expect(Object.keys(snippets).length).toBe(1); 294 | 295 | // Disable it. 296 | atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']); 297 | snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']); 298 | expect(Object.keys(snippets).length).toBe(0); 299 | 300 | // Re-enable it. 301 | atom.config.set('core.packagesWithSnippetsDisabled', []); 302 | snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']); 303 | expect(Object.keys(snippets).length).toBe(1); 304 | 305 | atom.config.set('core.packagesWithSnippetsDisabled', originalConfig); 306 | }); 307 | }); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /spec/snippets-spec.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const temp = require('temp').track(); 3 | const Snippets = require('../lib/snippets'); 4 | const {TextEditor} = require('atom'); 5 | 6 | describe("Snippets extension", () => { 7 | let editorElement, editor; 8 | 9 | const simulateTabKeyEvent = (param) => { 10 | if (param == null) { 11 | param = {}; 12 | } 13 | const {shift} = param; 14 | const event = atom.keymaps.constructor.buildKeydownEvent('tab', {shift, target: editorElement}); 15 | atom.keymaps.handleKeyboardEvent(event); 16 | }; 17 | 18 | beforeEach(() => { 19 | spyOn(Snippets, 'loadAll'); 20 | spyOn(Snippets, 'getUserSnippetsPath').andReturn(''); 21 | 22 | waitsForPromise(() => atom.workspace.open('sample.js')); 23 | waitsForPromise(() => atom.packages.activatePackage('language-javascript')); 24 | waitsForPromise(() => atom.packages.activatePackage('snippets')); 25 | 26 | runs(() => { 27 | editor = atom.workspace.getActiveTextEditor(); 28 | editorElement = atom.views.getView(editor); 29 | }); 30 | }); 31 | 32 | afterEach(() => { 33 | waitsForPromise(() => atom.packages.deactivatePackage('snippets')); 34 | }); 35 | 36 | describe("provideSnippets interface", () => { 37 | let snippetsInterface = null; 38 | 39 | beforeEach(() => { 40 | snippetsInterface = Snippets.provideSnippets(); 41 | }); 42 | 43 | describe("bundledSnippetsLoaded", () => { 44 | it("indicates the loaded state of the bundled snippets", () => { 45 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false); 46 | Snippets.doneLoading(); 47 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true); 48 | }); 49 | 50 | it("resets the loaded state after snippets is deactivated", () => { 51 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false); 52 | Snippets.doneLoading(); 53 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true); 54 | 55 | waitsForPromise(() => atom.packages.deactivatePackage('snippets')); 56 | waitsForPromise(() => atom.packages.activatePackage('snippets')); 57 | 58 | runs(() => { 59 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(false); 60 | Snippets.doneLoading(); 61 | expect(snippetsInterface.bundledSnippetsLoaded()).toBe(true); 62 | }); 63 | }); 64 | }); 65 | 66 | describe("insertSnippet", () => { 67 | it("can insert a snippet", () => { 68 | editor.setSelectedBufferRange([[0, 4], [0, 13]]); 69 | snippetsInterface.insertSnippet("hello ${1:world}", editor); 70 | expect(editor.lineTextForBufferRow(0)).toBe("var hello world = function () {"); 71 | }); 72 | }); 73 | }); 74 | 75 | it("returns false for snippetToExpandUnderCursor if getSnippets returns {}", () => { 76 | const snippets = atom.packages.getActivePackage('snippets').mainModule; 77 | expect(snippets.snippetToExpandUnderCursor(editor)).toEqual(false); 78 | }); 79 | 80 | it("ignores invalid snippets in the config", () => { 81 | const snippets = atom.packages.getActivePackage('snippets').mainModule; 82 | 83 | let invalidSnippets = null; 84 | spyOn(snippets.scopedPropertyStore, 'getPropertyValue').andCallFake(() => invalidSnippets); 85 | expect(snippets.getSnippets(editor)).toEqual({}); 86 | 87 | invalidSnippets = 'test'; 88 | expect(snippets.getSnippets(editor)).toEqual({}); 89 | 90 | invalidSnippets = []; 91 | expect(snippets.getSnippets(editor)).toEqual({}); 92 | 93 | invalidSnippets = 3; 94 | expect(snippets.getSnippets(editor)).toEqual({}); 95 | 96 | invalidSnippets = {a: null}; 97 | expect(snippets.getSnippets(editor)).toEqual({}); 98 | }); 99 | 100 | describe("when null snippets are present", () => { 101 | beforeEach(() => Snippets.add(__filename, { 102 | ".source.js": { 103 | "some snippet": { 104 | prefix: "t1", 105 | body: "this is a test" 106 | } 107 | }, 108 | 109 | ".source.js .nope": { 110 | "some snippet": { 111 | prefix: "t1", 112 | body: null 113 | } 114 | } 115 | })); 116 | 117 | it("overrides the less-specific defined snippet", () => { 118 | const snippets = Snippets.provideSnippets(); 119 | expect(snippets.snippetsForScopes(['.source.js'])['t1']).toBeTruthy(); 120 | expect(snippets.snippetsForScopes(['.source.js .nope.not-today'])['t1']).toBeFalsy(); 121 | }); 122 | }); 123 | 124 | describe("when 'tab' is triggered on the editor", () => { 125 | beforeEach(() => { 126 | Snippets.add(__filename, { 127 | ".source.js": { 128 | "without tab stops": { 129 | prefix: "t1", 130 | body: "this is a test" 131 | }, 132 | 133 | "with only an end tab stop": { 134 | prefix: "t1a", 135 | body: "something $0 strange" 136 | }, 137 | 138 | "overlapping prefix": { 139 | prefix: "tt1", 140 | body: "this is another test" 141 | }, 142 | 143 | "special chars": { 144 | prefix: "@unique", 145 | body: "@unique see" 146 | }, 147 | 148 | "tab stops": { 149 | prefix: "t2", 150 | body: `\ 151 | go here next:($2) and finally go here:($0) 152 | go here first:($1) 153 | \ 154 | ` 155 | }, 156 | 157 | "indented second line": { 158 | prefix: "t3", 159 | body: `\ 160 | line 1 161 | \tline 2$1 162 | $2\ 163 | ` 164 | }, 165 | 166 | "multiline with indented placeholder tabstop": { 167 | prefix: "t4", 168 | body: `\ 169 | line \${1:1} 170 | \${2:body...}\ 171 | ` 172 | }, 173 | 174 | "multiline starting with tabstop": { 175 | prefix: "t4b", 176 | body: `\ 177 | $1 = line 1 { 178 | line 2 179 | }\ 180 | ` 181 | }, 182 | 183 | "nested tab stops": { 184 | prefix: "t5", 185 | body: '${1:"${2:key}"}: ${3:value}' 186 | }, 187 | 188 | "caused problems with undo": { 189 | prefix: "t6", 190 | body: `\ 191 | first line$1 192 | \${2:placeholder ending second line}\ 193 | ` 194 | }, 195 | 196 | "tab stops at beginning and then end of snippet": { 197 | prefix: "t6b", 198 | body: "$1expanded$0" 199 | }, 200 | 201 | "tab stops at end and then beginning of snippet": { 202 | prefix: "t6c", 203 | body: "$0expanded$1" 204 | }, 205 | 206 | "contains empty lines": { 207 | prefix: "t7", 208 | body: `\ 209 | first line $1 210 | 211 | 212 | fourth line after blanks $2\ 213 | ` 214 | }, 215 | "with/without placeholder": { 216 | prefix: "t8", 217 | body: `\ 218 | with placeholder \${1:test} 219 | without placeholder \${2}\ 220 | ` 221 | }, 222 | 223 | "multi-caret": { 224 | prefix: "t9", 225 | body: `\ 226 | with placeholder \${1:test} 227 | without placeholder $1\ 228 | ` 229 | }, 230 | 231 | "multi-caret-multi-tabstop": { 232 | prefix: "t9b", 233 | body: `\ 234 | with placeholder \${1:test} 235 | without placeholder $1 236 | second tabstop $2 237 | third tabstop $3\ 238 | ` 239 | }, 240 | 241 | "large indices": { 242 | prefix: "t10", 243 | body: "hello${10} ${11:large} indices${1}" 244 | }, 245 | 246 | "no body": { 247 | prefix: "bad1" 248 | }, 249 | 250 | "number body": { 251 | prefix: "bad2", 252 | body: 100 253 | }, 254 | 255 | "many tabstops": { 256 | prefix: "t11", 257 | body: "$0one${1} ${2:two} three${3}" 258 | }, 259 | 260 | "simple transform": { 261 | prefix: "t12", 262 | body: "[${1:b}][/${1/[ ]+.*$//}]" 263 | }, 264 | "transform with non-transforming mirrors": { 265 | prefix: "t13", 266 | body: "${1:placeholder}\n${1/(.)/\\u$1/}\n$1" 267 | }, 268 | "multiple tab stops, some with transforms and some without": { 269 | prefix: "t14", 270 | body: "${1:placeholder} ${1/(.)/\\u$1/} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2" 271 | }, 272 | "has a transformed tab stop without a corresponding ordinary tab stop": { 273 | prefix: 't15', 274 | body: "${1/(.)/\\u$1/} & $2" 275 | }, 276 | "has a transformed tab stop that occurs before the corresponding ordinary tab stop": { 277 | prefix: 't16', 278 | body: "& ${1/(.)/\\u$1/} & ${1:q}" 279 | }, 280 | "has a placeholder that mirrors another tab stop's content": { 281 | prefix: 't17', 282 | body: "$4console.${3:log}('${2:uh $1}', $1);$0" 283 | }, 284 | "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": { 285 | prefix: 't18', 286 | body: '// $1\n// ${1/./=/}' 287 | }, 288 | "has two tab stops adjacent to one another": { 289 | prefix: 't19', 290 | body: '${2:bar}${3:baz}' 291 | }, 292 | "has several adjacent tab stops, one of which has a placeholder with reference to another tab stop at its edge": { 293 | prefix: 't20', 294 | body: '${1:foo}${2:bar}${3:baz $1}$4' 295 | } 296 | } 297 | }); 298 | }); 299 | 300 | it("parses snippets once, reusing cached ones on subsequent queries", () => { 301 | spyOn(Snippets, "getBodyParser").andCallThrough(); 302 | 303 | editor.insertText("t1"); 304 | simulateTabKeyEvent(); 305 | 306 | expect(Snippets.getBodyParser).toHaveBeenCalled(); 307 | expect(editor.lineTextForBufferRow(0)).toBe("this is a testvar quicksort = function () {"); 308 | expect(editor.getCursorScreenPosition()).toEqual([0, 14]); 309 | 310 | Snippets.getBodyParser.reset(); 311 | 312 | editor.setText(""); 313 | editor.insertText("t1"); 314 | simulateTabKeyEvent(); 315 | 316 | expect(Snippets.getBodyParser).not.toHaveBeenCalled(); 317 | expect(editor.lineTextForBufferRow(0)).toBe("this is a test"); 318 | expect(editor.getCursorScreenPosition()).toEqual([0, 14]); 319 | 320 | Snippets.getBodyParser.reset(); 321 | 322 | Snippets.add(__filename, { 323 | ".source.js": { 324 | "invalidate previous snippet": { 325 | prefix: "t1", 326 | body: "new snippet" 327 | } 328 | } 329 | }); 330 | 331 | editor.setText(""); 332 | editor.insertText("t1"); 333 | simulateTabKeyEvent(); 334 | 335 | expect(Snippets.getBodyParser).toHaveBeenCalled(); 336 | expect(editor.lineTextForBufferRow(0)).toBe("new snippet"); 337 | expect(editor.getCursorScreenPosition()).toEqual([0, 11]); 338 | }); 339 | 340 | describe("when the snippet body is invalid or missing", () => { 341 | it("does not register the snippet", () => { 342 | editor.setText(''); 343 | editor.insertText('bad1'); 344 | atom.commands.dispatch(editorElement, 'snippets:expand'); 345 | expect(editor.getText()).toBe('bad1'); 346 | 347 | editor.setText(''); 348 | editor.setText('bad2'); 349 | atom.commands.dispatch(editorElement, 'snippets:expand'); 350 | expect(editor.getText()).toBe('bad2'); 351 | }); 352 | }); 353 | 354 | describe("when the letters preceding the cursor trigger a snippet", () => { 355 | describe("when the snippet contains no tab stops", () => { 356 | it("replaces the prefix with the snippet text and places the cursor at its end", () => { 357 | editor.insertText("t1"); 358 | expect(editor.getCursorScreenPosition()).toEqual([0, 2]); 359 | 360 | simulateTabKeyEvent(); 361 | expect(editor.lineTextForBufferRow(0)).toBe("this is a testvar quicksort = function () {"); 362 | expect(editor.getCursorScreenPosition()).toEqual([0, 14]); 363 | }); 364 | 365 | it("inserts a real tab the next time a tab is pressed after the snippet is expanded", () => { 366 | editor.insertText("t1"); 367 | simulateTabKeyEvent(); 368 | expect(editor.lineTextForBufferRow(0)).toBe("this is a testvar quicksort = function () {"); 369 | simulateTabKeyEvent(); 370 | expect(editor.lineTextForBufferRow(0)).toBe("this is a test var quicksort = function () {"); 371 | }); 372 | }); 373 | 374 | describe("when the snippet contains tab stops", () => { 375 | it("places the cursor at the first tab-stop, and moves the cursor in response to 'next-tab-stop' events", () => { 376 | const markerCountBefore = editor.getMarkerCount(); 377 | editor.setCursorScreenPosition([2, 0]); 378 | editor.insertText('t2'); 379 | simulateTabKeyEvent(); 380 | expect(editor.lineTextForBufferRow(2)).toBe("go here next:() and finally go here:()"); 381 | expect(editor.lineTextForBufferRow(3)).toBe("go here first:()"); 382 | expect(editor.lineTextForBufferRow(4)).toBe(" if (items.length <= 1) return items;"); 383 | expect(editor.getSelectedBufferRange()).toEqual([[3, 15], [3, 15]]); 384 | 385 | simulateTabKeyEvent(); 386 | expect(editor.getSelectedBufferRange()).toEqual([[2, 14], [2, 14]]); 387 | editor.insertText('abc'); 388 | 389 | simulateTabKeyEvent(); 390 | expect(editor.getSelectedBufferRange()).toEqual([[2, 40], [2, 40]]); 391 | 392 | // tab backwards 393 | simulateTabKeyEvent({shift: true}); 394 | expect(editor.getSelectedBufferRange()).toEqual([[2, 14], [2, 17]]); // should highlight text typed at tab stop 395 | 396 | simulateTabKeyEvent({shift: true}); 397 | expect(editor.getSelectedBufferRange()).toEqual([[3, 15], [3, 15]]); 398 | 399 | // shift-tab on first tab-stop does nothing 400 | simulateTabKeyEvent({shift: true}); 401 | expect(editor.getCursorScreenPosition()).toEqual([3, 15]); 402 | 403 | // tab through all tab stops, then tab on last stop to terminate snippet 404 | simulateTabKeyEvent(); 405 | simulateTabKeyEvent(); 406 | simulateTabKeyEvent(); 407 | expect(editor.lineTextForBufferRow(2)).toBe("go here next:(abc) and finally go here:( )"); 408 | expect(editor.getMarkerCount()).toBe(markerCountBefore); 409 | }); 410 | 411 | describe("when tab stops are nested", () => { 412 | it("destroys the inner tab stop if the outer tab stop is modified", () => { 413 | editor.setText(''); 414 | editor.insertText('t5'); 415 | atom.commands.dispatch(editorElement, 'snippets:expand'); 416 | expect(editor.lineTextForBufferRow(0)).toBe('"key": value'); 417 | expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 5]]); 418 | editor.insertText("foo"); 419 | simulateTabKeyEvent(); 420 | expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 10]]); 421 | }); 422 | }); 423 | 424 | describe("when the only tab stop is an end stop", () => { 425 | it("terminates the snippet immediately after moving the cursor to the end stop", () => { 426 | editor.setText(''); 427 | editor.insertText('t1a'); 428 | simulateTabKeyEvent(); 429 | 430 | expect(editor.lineTextForBufferRow(0)).toBe("something strange"); 431 | expect(editor.getCursorBufferPosition()).toEqual([0, 10]); 432 | 433 | simulateTabKeyEvent(); 434 | expect(editor.lineTextForBufferRow(0)).toBe("something strange"); 435 | expect(editor.getCursorBufferPosition()).toEqual([0, 12]); 436 | }); 437 | }); 438 | 439 | describe("when tab stops are separated by blank lines", () => { 440 | it("correctly places the tab stops (regression)", () => { 441 | editor.setText(''); 442 | editor.insertText('t7'); 443 | atom.commands.dispatch(editorElement, 'snippets:expand'); 444 | atom.commands.dispatch(editorElement, 'snippets:next-tab-stop'); 445 | expect(editor.getCursorBufferPosition()).toEqual([3, 25]); 446 | }); 447 | }); 448 | 449 | describe("when the cursor is moved beyond the bounds of the current tab stop", () => { 450 | it("terminates the snippet", () => { 451 | editor.setCursorScreenPosition([2, 0]); 452 | editor.insertText('t2'); 453 | simulateTabKeyEvent(); 454 | 455 | editor.moveUp(); 456 | editor.moveLeft(); 457 | simulateTabKeyEvent(); 458 | 459 | expect(editor.lineTextForBufferRow(2)).toBe("go here next:( ) and finally go here:()"); 460 | expect(editor.getCursorBufferPosition()).toEqual([2, 16]); 461 | 462 | // test we can terminate with shift-tab 463 | editor.setCursorScreenPosition([4, 0]); 464 | editor.insertText('t2'); 465 | simulateTabKeyEvent(); 466 | simulateTabKeyEvent(); 467 | 468 | editor.moveRight(); 469 | simulateTabKeyEvent({shift: true}); 470 | expect(editor.getCursorBufferPosition()).toEqual([4, 15]); 471 | }); 472 | }); 473 | 474 | describe("when the cursor is moved within the bounds of the current tab stop", () => { 475 | it("should not terminate the snippet", () => { 476 | editor.setCursorScreenPosition([0, 0]); 477 | editor.insertText('t8'); 478 | simulateTabKeyEvent(); 479 | 480 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder test"); 481 | editor.moveRight(); 482 | editor.moveLeft(); 483 | editor.insertText("foo"); 484 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder tesfoot"); 485 | 486 | simulateTabKeyEvent(); 487 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder var quicksort = function () {"); 488 | editor.insertText("test"); 489 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder testvar quicksort = function () {"); 490 | editor.moveLeft(); 491 | editor.insertText("foo"); 492 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder tesfootvar quicksort = function () {"); 493 | }); 494 | }); 495 | 496 | describe("when the backspace is press within the bounds of the current tab stop", () => { 497 | it("should not terminate the snippet", () => { 498 | editor.setCursorScreenPosition([0, 0]); 499 | editor.insertText('t8'); 500 | simulateTabKeyEvent(); 501 | 502 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder test"); 503 | editor.moveRight(); 504 | editor.backspace(); 505 | editor.insertText("foo"); 506 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder tesfoo"); 507 | 508 | simulateTabKeyEvent(); 509 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder var quicksort = function () {"); 510 | editor.insertText("test"); 511 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder testvar quicksort = function () {"); 512 | editor.backspace(); 513 | editor.insertText("foo"); 514 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder tesfoovar quicksort = function () {"); 515 | }); 516 | }); 517 | }); 518 | 519 | describe("when the snippet contains hard tabs", () => { 520 | describe("when the edit session is in soft-tabs mode", () => { 521 | it("translates hard tabs in the snippet to the appropriate number of spaces", () => { 522 | expect(editor.getSoftTabs()).toBeTruthy(); 523 | editor.insertText("t3"); 524 | simulateTabKeyEvent(); 525 | expect(editor.lineTextForBufferRow(1)).toBe(" line 2"); 526 | expect(editor.getCursorBufferPosition()).toEqual([1, 8]); 527 | }); 528 | }); 529 | 530 | describe("when the edit session is in hard-tabs mode", () => { 531 | it("inserts hard tabs in the snippet directly", () => { 532 | editor.setSoftTabs(false); 533 | editor.insertText("t3"); 534 | simulateTabKeyEvent(); 535 | expect(editor.lineTextForBufferRow(1)).toBe("\tline 2"); 536 | expect(editor.getCursorBufferPosition()).toEqual([1, 7]); 537 | }); 538 | }); 539 | }); 540 | 541 | describe("when the snippet prefix is indented", () => { 542 | describe("when the snippet spans a single line", () => { 543 | it("does not indent the next line", () => { 544 | editor.setCursorScreenPosition([2, Infinity]); 545 | editor.insertText(' t1'); 546 | atom.commands.dispatch(editorElement, 'snippets:expand'); 547 | expect(editor.lineTextForBufferRow(3)).toBe(" var pivot = items.shift(), current, left = [], right = [];"); 548 | }); 549 | }); 550 | 551 | describe("when the snippet spans multiple lines", () => { 552 | it("indents the subsequent lines of the snippet to be even with the start of the first line", () => { 553 | expect(editor.getSoftTabs()).toBeTruthy(); 554 | editor.setCursorScreenPosition([2, Infinity]); 555 | editor.insertText(' t3'); 556 | atom.commands.dispatch(editorElement, 'snippets:expand'); 557 | expect(editor.lineTextForBufferRow(2)).toBe(" if (items.length <= 1) return items; line 1"); 558 | expect(editor.lineTextForBufferRow(3)).toBe(" line 2"); 559 | expect(editor.getCursorBufferPosition()).toEqual([3, 12]); 560 | }); 561 | }); 562 | }); 563 | 564 | describe("when the snippet spans multiple lines", () => { 565 | beforeEach(() => { 566 | editor.update({autoIndent: true}); 567 | // editor.update() returns a Promise that never gets resolved, so we 568 | // need to return undefined to avoid a timeout in the spec. 569 | // TODO: Figure out why `editor.update({autoIndent: true})` never gets resolved. 570 | }); 571 | 572 | it("places tab stops correctly", () => { 573 | expect(editor.getSoftTabs()).toBeTruthy(); 574 | editor.setCursorScreenPosition([2, Infinity]); 575 | editor.insertText(' t3'); 576 | atom.commands.dispatch(editorElement, 'snippets:expand'); 577 | expect(editor.getCursorBufferPosition()).toEqual([3, 12]); 578 | atom.commands.dispatch(editorElement, 'snippets:next-tab-stop'); 579 | expect(editor.getCursorBufferPosition()).toEqual([4, 4]); 580 | }); 581 | 582 | it("indents the subsequent lines of the snippet based on the indent level before the snippet is inserted", () => { 583 | editor.setCursorScreenPosition([2, Infinity]); 584 | editor.insertNewline(); 585 | editor.insertText('t4b'); 586 | atom.commands.dispatch(editorElement, 'snippets:expand'); 587 | 588 | expect(editor.lineTextForBufferRow(3)).toBe(" = line 1 {"); // 4 + 1 spaces (because the tab stop is invisible) 589 | expect(editor.lineTextForBufferRow(4)).toBe(" line 2"); 590 | expect(editor.lineTextForBufferRow(5)).toBe(" }"); 591 | expect(editor.getCursorBufferPosition()).toEqual([3, 4]); 592 | }); 593 | 594 | it("does not change the relative positioning of the tab stops when inserted multiple times", () => { 595 | editor.setCursorScreenPosition([2, Infinity]); 596 | editor.insertNewline(); 597 | editor.insertText('t4'); 598 | atom.commands.dispatch(editorElement, 'snippets:expand'); 599 | 600 | expect(editor.getSelectedBufferRange()).toEqual([[3, 9], [3, 10]]); 601 | atom.commands.dispatch(editorElement, 'snippets:next-tab-stop'); 602 | expect(editor.getSelectedBufferRange()).toEqual([[4, 6], [4, 13]]); 603 | 604 | editor.insertText('t4'); 605 | atom.commands.dispatch(editorElement, 'snippets:expand'); 606 | 607 | expect(editor.getSelectedBufferRange()).toEqual([[4, 11], [4, 12]]); 608 | atom.commands.dispatch(editorElement, 'snippets:next-tab-stop'); 609 | expect(editor.getSelectedBufferRange()).toEqual([[5, 8], [5, 15]]); 610 | 611 | editor.setText(''); // Clear editor 612 | editor.insertText('t4'); 613 | atom.commands.dispatch(editorElement, 'snippets:expand'); 614 | 615 | expect(editor.getSelectedBufferRange()).toEqual([[0, 5], [0, 6]]); 616 | atom.commands.dispatch(editorElement, 'snippets:next-tab-stop'); 617 | expect(editor.getSelectedBufferRange()).toEqual([[1, 2], [1, 9]]); 618 | }); 619 | }); 620 | 621 | describe("when multiple snippets match the prefix", () => { 622 | it("expands the snippet that is the longest match for the prefix", () => { 623 | editor.insertText('t113'); 624 | expect(editor.getCursorScreenPosition()).toEqual([0, 4]); 625 | 626 | simulateTabKeyEvent(); 627 | expect(editor.lineTextForBufferRow(0)).toBe("t113 var quicksort = function () {"); 628 | expect(editor.getCursorScreenPosition()).toEqual([0, 6]); 629 | 630 | editor.undo(); 631 | editor.undo(); 632 | 633 | editor.insertText("tt1"); 634 | expect(editor.getCursorScreenPosition()).toEqual([0, 3]); 635 | 636 | simulateTabKeyEvent(); 637 | expect(editor.lineTextForBufferRow(0)).toBe("this is another testvar quicksort = function () {"); 638 | expect(editor.getCursorScreenPosition()).toEqual([0, 20]); 639 | 640 | editor.undo(); 641 | editor.undo(); 642 | 643 | editor.insertText("@t1"); 644 | expect(editor.getCursorScreenPosition()).toEqual([0, 3]); 645 | 646 | simulateTabKeyEvent(); 647 | expect(editor.lineTextForBufferRow(0)).toBe("@this is a testvar quicksort = function () {"); 648 | expect(editor.getCursorScreenPosition()).toEqual([0, 15]); 649 | }); 650 | }); 651 | }); 652 | 653 | describe("when the word preceding the cursor ends with a snippet prefix", () => { 654 | it("inserts a tab as normal", () => { 655 | editor.insertText("t1t1t1"); 656 | simulateTabKeyEvent(); 657 | expect(editor.lineTextForBufferRow(0)).toBe("t1t1t1 var quicksort = function () {"); 658 | }); 659 | }); 660 | 661 | describe("when the letters preceding the cursor don't match a snippet", () => { 662 | it("inserts a tab as normal", () => { 663 | editor.insertText("xxte"); 664 | expect(editor.getCursorScreenPosition()).toEqual([0, 4]); 665 | 666 | simulateTabKeyEvent(); 667 | expect(editor.lineTextForBufferRow(0)).toBe("xxte var quicksort = function () {"); 668 | expect(editor.getCursorScreenPosition()).toEqual([0, 6]); 669 | }); 670 | }); 671 | 672 | describe("when text is selected", () => { 673 | it("inserts a tab as normal", () => { 674 | editor.insertText("t1"); 675 | editor.setSelectedBufferRange([[0, 0], [0, 2]]); 676 | 677 | simulateTabKeyEvent(); 678 | expect(editor.lineTextForBufferRow(0)).toBe(" t1var quicksort = function () {"); 679 | expect(editor.getSelectedBufferRange()).toEqual([[0, 0], [0, 4]]); 680 | }); 681 | }); 682 | 683 | describe("when a previous snippet expansion has just been undone", () => { 684 | describe("when the tab stops appear in the middle of the snippet", () => { 685 | it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { 686 | editor.insertText('t6\n'); 687 | editor.setCursorBufferPosition([0, 2]); 688 | simulateTabKeyEvent(); 689 | expect(editor.lineTextForBufferRow(0)).toBe("first line"); 690 | editor.undo(); 691 | expect(editor.lineTextForBufferRow(0)).toBe("t6"); 692 | simulateTabKeyEvent(); 693 | expect(editor.lineTextForBufferRow(0)).toBe("first line"); 694 | }); 695 | }); 696 | 697 | describe("when the tab stops appear at the beginning and then the end of snippet", () => { 698 | it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { 699 | editor.insertText('t6b\n'); 700 | editor.setCursorBufferPosition([0, 3]); 701 | simulateTabKeyEvent(); 702 | expect(editor.lineTextForBufferRow(0)).toBe("expanded"); 703 | editor.undo(); 704 | expect(editor.lineTextForBufferRow(0)).toBe("t6b"); 705 | simulateTabKeyEvent(); 706 | expect(editor.lineTextForBufferRow(0)).toBe("expanded"); 707 | expect(editor.getCursorBufferPosition()).toEqual([0, 0]); 708 | }); 709 | }); 710 | 711 | describe("when the tab stops appear at the end and then the beginning of snippet", () => { 712 | it("expands the snippet based on the current prefix rather than jumping to the old snippet's tab stop", () => { 713 | editor.insertText('t6c\n'); 714 | editor.setCursorBufferPosition([0, 3]); 715 | simulateTabKeyEvent(); 716 | expect(editor.lineTextForBufferRow(0)).toBe("expanded"); 717 | editor.undo(); 718 | expect(editor.lineTextForBufferRow(0)).toBe("t6c"); 719 | simulateTabKeyEvent(); 720 | expect(editor.lineTextForBufferRow(0)).toBe("expanded"); 721 | expect(editor.getCursorBufferPosition()).toEqual([0, 8]); 722 | }); 723 | }); 724 | }); 725 | 726 | describe("when the prefix contains non-word characters", () => { 727 | it("selects the non-word characters as part of the prefix", () => { 728 | editor.insertText("@unique"); 729 | expect(editor.getCursorScreenPosition()).toEqual([0, 7]); 730 | 731 | simulateTabKeyEvent(); 732 | expect(editor.lineTextForBufferRow(0)).toBe("@unique seevar quicksort = function () {"); 733 | expect(editor.getCursorScreenPosition()).toEqual([0, 11]); 734 | 735 | editor.setCursorBufferPosition([10, 0]); 736 | editor.insertText("'@unique"); 737 | 738 | simulateTabKeyEvent(); 739 | expect(editor.lineTextForBufferRow(10)).toBe("'@unique see"); 740 | expect(editor.getCursorScreenPosition()).toEqual([10, 12]); 741 | }); 742 | 743 | it("does not select the whitespace before the prefix", () => { 744 | editor.insertText("a; @unique"); 745 | expect(editor.getCursorScreenPosition()).toEqual([0, 10]); 746 | 747 | simulateTabKeyEvent(); 748 | expect(editor.lineTextForBufferRow(0)).toBe("a; @unique seevar quicksort = function () {"); 749 | expect(editor.getCursorScreenPosition()).toEqual([0, 14]); 750 | }); 751 | }); 752 | 753 | describe("when snippet contains tabstops with or without placeholder", () => { 754 | it("should create two markers", () => { 755 | editor.setCursorScreenPosition([0, 0]); 756 | editor.insertText('t8'); 757 | simulateTabKeyEvent(); 758 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder test"); 759 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder var quicksort = function () {"); 760 | 761 | expect(editor.getSelectedBufferRange()).toEqual([[0, 17], [0, 21]]); 762 | 763 | simulateTabKeyEvent(); 764 | expect(editor.getSelectedBufferRange()).toEqual([[1, 20], [1, 20]]); 765 | }); 766 | }); 767 | 768 | describe("when snippet contains multi-caret tabstops with or without placeholder", () => { 769 | it("should create two markers", () => { 770 | editor.setCursorScreenPosition([0, 0]); 771 | editor.insertText('t9'); 772 | simulateTabKeyEvent(); 773 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder test"); 774 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder var quicksort = function () {"); 775 | editor.insertText('hello'); 776 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder hello"); 777 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder hellovar quicksort = function () {"); 778 | }); 779 | 780 | it("terminates the snippet when cursors are destroyed", () => { 781 | editor.setCursorScreenPosition([0, 0]); 782 | editor.insertText('t9b'); 783 | simulateTabKeyEvent(); 784 | editor.getCursors()[0].destroy(); 785 | editor.getCursorBufferPosition(); 786 | simulateTabKeyEvent(); 787 | 788 | expect(editor.lineTextForBufferRow(1)).toEqual("without placeholder "); 789 | }); 790 | 791 | it("terminates the snippet expansion if a new cursor moves outside the bounds of the tab stops", () => { 792 | editor.setCursorScreenPosition([0, 0]); 793 | editor.insertText('t9b'); 794 | simulateTabKeyEvent(); 795 | editor.insertText('test'); 796 | 797 | editor.getCursors()[0].destroy(); 798 | editor.moveDown(); // this should destroy the previous expansion 799 | editor.moveToBeginningOfLine(); 800 | 801 | // this should insert whitespace instead of going through tabstops of the previous destroyed snippet 802 | simulateTabKeyEvent(); 803 | expect(editor.lineTextForBufferRow(2).indexOf(" second")).toBe(0); 804 | }); 805 | 806 | it("moves to the second tabstop after a multi-caret tabstop", () => { 807 | editor.setCursorScreenPosition([0, 0]); 808 | editor.insertText('t9b'); 809 | simulateTabKeyEvent(); 810 | editor.insertText('line 1'); 811 | 812 | simulateTabKeyEvent(); 813 | editor.insertText('line 2'); 814 | 815 | simulateTabKeyEvent(); 816 | editor.insertText('line 3'); 817 | 818 | expect(editor.lineTextForBufferRow(2).indexOf("line 2 ")).toBe(-1); 819 | }); 820 | 821 | it("mirrors input properly when a tabstop's placeholder refers to another tabstop", () => { 822 | editor.setText('t17'); 823 | editor.setCursorScreenPosition([0, 3]); 824 | simulateTabKeyEvent(); 825 | editor.insertText("foo"); 826 | expect(editor.getText()).toBe("console.log('uh foo', foo);"); 827 | simulateTabKeyEvent(); 828 | editor.insertText("bar"); 829 | expect(editor.getText()).toBe("console.log('bar', foo);"); 830 | }); 831 | }); 832 | 833 | describe("when the snippet contains tab stops with transformations", () => { 834 | it("transforms the text typed into the first tab stop before setting it in the transformed tab stop", () => { 835 | editor.setText('t12'); 836 | editor.setCursorScreenPosition([0, 3]); 837 | simulateTabKeyEvent(); 838 | expect(editor.getText()).toBe("[b][/b]"); 839 | editor.insertText('img src'); 840 | expect(editor.getText()).toBe("[img src][/img]"); 841 | }); 842 | 843 | it("bundles the transform mutations along with the original manual mutation for the purposes of undo and redo", () => { 844 | editor.setText('t12'); 845 | editor.setCursorScreenPosition([0, 3]); 846 | simulateTabKeyEvent(); 847 | editor.insertText('i'); 848 | expect(editor.getText()).toBe("[i][/i]"); 849 | 850 | editor.insertText('mg src'); 851 | expect(editor.getText()).toBe("[img src][/img]"); 852 | 853 | editor.undo(); 854 | expect(editor.getText()).toBe("[i][/i]"); 855 | 856 | editor.redo(); 857 | expect(editor.getText()).toBe("[img src][/img]"); 858 | }); 859 | 860 | it("can pick the right insertion to use as the primary even if a transformed insertion occurs first in the snippet", () => { 861 | editor.setText('t16'); 862 | editor.setCursorScreenPosition([0, 3]); 863 | simulateTabKeyEvent(); 864 | expect(editor.lineTextForBufferRow(0)).toBe("& Q & q"); 865 | expect(editor.getCursorBufferPosition()).toEqual([0, 7]); 866 | 867 | editor.insertText('rst'); 868 | expect(editor.lineTextForBufferRow(0)).toBe("& RST & rst"); 869 | }); 870 | 871 | it("silently ignores a tab stop without a non-transformed insertion to use as the primary", () => { 872 | editor.setText('t15'); 873 | editor.setCursorScreenPosition([0, 3]); 874 | simulateTabKeyEvent(); 875 | editor.insertText('a'); 876 | expect(editor.lineTextForBufferRow(0)).toBe(" & a"); 877 | expect(editor.getCursorBufferPosition()).toEqual([0, 4]); 878 | }); 879 | }); 880 | 881 | describe("when the snippet contains mirrored tab stops and tab stops with transformations", () => { 882 | it("adds cursors for the mirrors but not the transformations", () => { 883 | editor.setText('t13'); 884 | editor.setCursorScreenPosition([0, 3]); 885 | simulateTabKeyEvent(); 886 | expect(editor.getCursors().length).toBe(2); 887 | expect(editor.getText()).toBe(`\ 888 | placeholder 889 | PLACEHOLDER 890 | \ 891 | ` 892 | ); 893 | 894 | editor.insertText('foo'); 895 | 896 | expect(editor.getText()).toBe(`\ 897 | foo 898 | FOO 899 | foo\ 900 | ` 901 | ); 902 | }); 903 | }); 904 | 905 | describe("when the snippet contains multiple tab stops, some with transformations and some without", () => { 906 | it("does not get confused", () => { 907 | editor.setText('t14'); 908 | editor.setCursorScreenPosition([0, 3]); 909 | simulateTabKeyEvent(); 910 | expect(editor.getCursors().length).toBe(2); 911 | expect(editor.getText()).toBe("placeholder PLACEHOLDER ANOTHER another "); 912 | simulateTabKeyEvent(); 913 | expect(editor.getCursors().length).toBe(2); 914 | editor.insertText('FOO'); 915 | expect(editor.getText()).toBe("placeholder PLACEHOLDER FOO foo FOO"); 916 | }); 917 | }); 918 | 919 | 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", () => { 920 | it("terminates the snippet upon such a cursor move", () => { 921 | editor.setText('t18'); 922 | editor.setCursorScreenPosition([0, 3]); 923 | simulateTabKeyEvent(); 924 | expect(editor.getText()).toBe("// \n// "); 925 | expect(editor.getCursorBufferPosition()).toEqual([0, 3]); 926 | editor.insertText('wat'); 927 | expect(editor.getText()).toBe("// wat\n// ==="); 928 | // Move the cursor down one line, then up one line. This puts the cursor 929 | // back in its previous position, but the snippet should no longer be 930 | // active, so when we type more text, it should not be mirrored. 931 | editor.setCursorScreenPosition([1, 6]); 932 | editor.setCursorScreenPosition([0, 6]); 933 | editor.insertText('wat'); 934 | expect(editor.getText()).toBe("// watwat\n// ==="); 935 | }); 936 | }); 937 | 938 | describe("when the snippet has two adjacent tab stops", () => { 939 | it("ensures insertions are treated as part of the active tab stop", () => { 940 | editor.setText('t19'); 941 | editor.setCursorScreenPosition([0, 3]); 942 | simulateTabKeyEvent(); 943 | expect(editor.getText()).toBe('barbaz'); 944 | expect( 945 | editor.getSelectedBufferRange() 946 | ).toEqual([ 947 | [0, 0], 948 | [0, 3] 949 | ]); 950 | editor.insertText('w'); 951 | expect(editor.getText()).toBe('wbaz'); 952 | editor.insertText('at'); 953 | expect(editor.getText()).toBe('watbaz'); 954 | simulateTabKeyEvent(); 955 | expect( 956 | editor.getSelectedBufferRange() 957 | ).toEqual([ 958 | [0, 3], 959 | [0, 6] 960 | ]); 961 | editor.insertText('foo'); 962 | expect(editor.getText()).toBe('watfoo'); 963 | }); 964 | }); 965 | 966 | describe("when the snippet has a placeholder with a tabstop mirror at its edge", () => { 967 | it("allows the associated marker to include the inserted text", () => { 968 | editor.setText('t20'); 969 | editor.setCursorScreenPosition([0, 3]); 970 | simulateTabKeyEvent(); 971 | expect(editor.getText()).toBe('foobarbaz '); 972 | expect(editor.getCursors().length).toBe(2); 973 | let selections = editor.getSelections(); 974 | expect(selections[0].getBufferRange()).toEqual([[0, 0], [0, 3]]); 975 | expect(selections[1].getBufferRange()).toEqual([[0, 10], [0, 10]]); 976 | editor.insertText('nah'); 977 | expect(editor.getText()).toBe('nahbarbaz nah'); 978 | simulateTabKeyEvent(); 979 | editor.insertText('meh'); 980 | simulateTabKeyEvent(); 981 | editor.insertText('yea'); 982 | expect(editor.getText()).toBe('nahmehyea'); 983 | }); 984 | }); 985 | 986 | describe("when the snippet contains tab stops with an index >= 10", () => { 987 | it("parses and orders the indices correctly", () => { 988 | editor.setText('t10'); 989 | editor.setCursorScreenPosition([0, 3]); 990 | simulateTabKeyEvent(); 991 | expect(editor.getText()).toBe("hello large indices"); 992 | expect(editor.getCursorBufferPosition()).toEqual([0, 19]); 993 | simulateTabKeyEvent(); 994 | expect(editor.getCursorBufferPosition()).toEqual([0, 5]); 995 | simulateTabKeyEvent(); 996 | expect(editor.getSelectedBufferRange()).toEqual([[0, 6], [0, 11]]); 997 | }); 998 | }); 999 | 1000 | describe("when there are multiple cursors", () => { 1001 | describe("when the cursors share a common snippet prefix", () => { 1002 | it("expands the snippet for all cursors and allows simultaneous editing", () => { 1003 | editor.insertText('t9'); 1004 | editor.setCursorBufferPosition([12, 2]); 1005 | editor.insertText(' t9'); 1006 | editor.addCursorAtBufferPosition([0, 2]); 1007 | simulateTabKeyEvent(); 1008 | 1009 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder test"); 1010 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder var quicksort = function () {"); 1011 | expect(editor.lineTextForBufferRow(13)).toBe("}; with placeholder test"); 1012 | expect(editor.lineTextForBufferRow(14)).toBe("without placeholder "); 1013 | 1014 | editor.insertText('hello'); 1015 | expect(editor.lineTextForBufferRow(0)).toBe("with placeholder hello"); 1016 | expect(editor.lineTextForBufferRow(1)).toBe("without placeholder hellovar quicksort = function () {"); 1017 | expect(editor.lineTextForBufferRow(13)).toBe("}; with placeholder hello"); 1018 | expect(editor.lineTextForBufferRow(14)).toBe("without placeholder hello"); 1019 | }); 1020 | 1021 | it("applies transformations identically to single-expansion mode", () => { 1022 | editor.setText('t14\nt14'); 1023 | editor.setCursorBufferPosition([1, 3]); 1024 | editor.addCursorAtBufferPosition([0, 3]); 1025 | simulateTabKeyEvent(); 1026 | 1027 | expect(editor.lineTextForBufferRow(0)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1028 | expect(editor.lineTextForBufferRow(1)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1029 | 1030 | editor.insertText("testing"); 1031 | 1032 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing ANOTHER another "); 1033 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing ANOTHER another "); 1034 | 1035 | simulateTabKeyEvent(); 1036 | editor.insertText("AGAIN"); 1037 | 1038 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing AGAIN again AGAIN"); 1039 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing AGAIN again AGAIN"); 1040 | }); 1041 | 1042 | it("bundles transform-induced mutations into a single history entry along with their triggering edit, even across multiple snippets", () => { 1043 | editor.setText('t14\nt14'); 1044 | editor.setCursorBufferPosition([1, 3]); 1045 | editor.addCursorAtBufferPosition([0, 3]); 1046 | simulateTabKeyEvent(); 1047 | 1048 | expect(editor.lineTextForBufferRow(0)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1049 | expect(editor.lineTextForBufferRow(1)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1050 | 1051 | editor.insertText("testing"); 1052 | 1053 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing ANOTHER another "); 1054 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing ANOTHER another "); 1055 | 1056 | simulateTabKeyEvent(); 1057 | editor.insertText("AGAIN"); 1058 | 1059 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing AGAIN again AGAIN"); 1060 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing AGAIN again AGAIN"); 1061 | 1062 | editor.undo(); 1063 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing ANOTHER another "); 1064 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing ANOTHER another "); 1065 | 1066 | editor.undo(); 1067 | expect(editor.lineTextForBufferRow(0)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1068 | expect(editor.lineTextForBufferRow(1)).toBe("placeholder PLACEHOLDER ANOTHER another "); 1069 | 1070 | editor.redo(); 1071 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing ANOTHER another "); 1072 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing ANOTHER another "); 1073 | 1074 | editor.redo(); 1075 | expect(editor.lineTextForBufferRow(0)).toBe("testing TESTING testing AGAIN again AGAIN"); 1076 | expect(editor.lineTextForBufferRow(1)).toBe("testing TESTING testing AGAIN again AGAIN"); 1077 | }); 1078 | 1079 | describe("when there are many tabstops", () => { 1080 | it("moves the cursors between the tab stops for their corresponding snippet when tab and shift-tab are pressed", () => { 1081 | editor.addCursorAtBufferPosition([7, 5]); 1082 | editor.addCursorAtBufferPosition([12, 2]); 1083 | editor.insertText('t11'); 1084 | simulateTabKeyEvent(); 1085 | 1086 | const cursors = editor.getCursors(); 1087 | expect(cursors.length).toEqual(3); 1088 | 1089 | expect(cursors[0].getBufferPosition()).toEqual([0, 3]); 1090 | expect(cursors[1].getBufferPosition()).toEqual([7, 8]); 1091 | expect(cursors[2].getBufferPosition()).toEqual([12, 5]); 1092 | expect(cursors[0].selection.isEmpty()).toBe(true); 1093 | expect(cursors[1].selection.isEmpty()).toBe(true); 1094 | expect(cursors[2].selection.isEmpty()).toBe(true); 1095 | 1096 | simulateTabKeyEvent(); 1097 | expect(cursors[0].getBufferPosition()).toEqual([0, 7]); 1098 | expect(cursors[1].getBufferPosition()).toEqual([7, 12]); 1099 | expect(cursors[2].getBufferPosition()).toEqual([12, 9]); 1100 | expect(cursors[0].selection.isEmpty()).toBe(false); 1101 | expect(cursors[1].selection.isEmpty()).toBe(false); 1102 | expect(cursors[2].selection.isEmpty()).toBe(false); 1103 | expect(cursors[0].selection.getText()).toEqual('two'); 1104 | expect(cursors[1].selection.getText()).toEqual('two'); 1105 | expect(cursors[2].selection.getText()).toEqual('two'); 1106 | 1107 | simulateTabKeyEvent(); 1108 | expect(cursors[0].getBufferPosition()).toEqual([0, 13]); 1109 | expect(cursors[1].getBufferPosition()).toEqual([7, 18]); 1110 | expect(cursors[2].getBufferPosition()).toEqual([12, 15]); 1111 | expect(cursors[0].selection.isEmpty()).toBe(true); 1112 | expect(cursors[1].selection.isEmpty()).toBe(true); 1113 | expect(cursors[2].selection.isEmpty()).toBe(true); 1114 | 1115 | simulateTabKeyEvent(); 1116 | expect(cursors[0].getBufferPosition()).toEqual([0, 0]); 1117 | expect(cursors[1].getBufferPosition()).toEqual([7, 5]); 1118 | expect(cursors[2].getBufferPosition()).toEqual([12, 2]); 1119 | expect(cursors[0].selection.isEmpty()).toBe(true); 1120 | expect(cursors[1].selection.isEmpty()).toBe(true); 1121 | expect(cursors[2].selection.isEmpty()).toBe(true); 1122 | }); 1123 | }); 1124 | }); 1125 | 1126 | describe("when the cursors do not share common snippet prefixes", () => { 1127 | it("inserts tabs as normal", () => { 1128 | editor.insertText('t9'); 1129 | editor.setCursorBufferPosition([12, 2]); 1130 | editor.insertText(' t8'); 1131 | editor.addCursorAtBufferPosition([0, 2]); 1132 | simulateTabKeyEvent(); 1133 | expect(editor.lineTextForBufferRow(0)).toBe("t9 var quicksort = function () {"); 1134 | expect(editor.lineTextForBufferRow(12)).toBe("}; t8 "); 1135 | }); 1136 | }); 1137 | 1138 | describe("when a snippet is triggered within an existing snippet expansion", () => { 1139 | it("ignores the snippet expansion and goes to the next tab stop", () => { 1140 | editor.addCursorAtBufferPosition([7, 5]); 1141 | editor.addCursorAtBufferPosition([12, 2]); 1142 | editor.insertText('t11'); 1143 | simulateTabKeyEvent(); 1144 | simulateTabKeyEvent(); 1145 | 1146 | editor.insertText('t1'); 1147 | simulateTabKeyEvent(); 1148 | 1149 | const cursors = editor.getCursors(); 1150 | expect(cursors.length).toEqual(3); 1151 | 1152 | expect(cursors[0].getBufferPosition()).toEqual([0, 12]); 1153 | expect(cursors[1].getBufferPosition()).toEqual([7, 17]); 1154 | expect(cursors[2].getBufferPosition()).toEqual([12, 14]); 1155 | expect(cursors[0].selection.isEmpty()).toBe(true); 1156 | expect(cursors[1].selection.isEmpty()).toBe(true); 1157 | expect(cursors[2].selection.isEmpty()).toBe(true); 1158 | expect(editor.lineTextForBufferRow(0)).toBe("one t1 threevar quicksort = function () {"); 1159 | expect(editor.lineTextForBufferRow(7)).toBe(" }one t1 three"); 1160 | expect(editor.lineTextForBufferRow(12)).toBe("};one t1 three"); 1161 | }); 1162 | }); 1163 | }); 1164 | 1165 | describe("when the editor is not a pane item (regression)", () => { 1166 | it("handles tab stops correctly", () => { 1167 | editor = new TextEditor(); 1168 | atom.grammars.assignLanguageMode(editor, 'source.js'); 1169 | editorElement = editor.getElement(); 1170 | 1171 | editor.insertText('t2'); 1172 | simulateTabKeyEvent(); 1173 | editor.insertText('ABC'); 1174 | expect(editor.getText()).toContain('go here first:(ABC)'); 1175 | 1176 | editor.undo(); 1177 | editor.undo(); 1178 | expect(editor.getText()).toBe('t2'); 1179 | simulateTabKeyEvent(); 1180 | editor.insertText('ABC'); 1181 | expect(editor.getText()).toContain('go here first:(ABC)'); 1182 | }); 1183 | }); 1184 | }); 1185 | 1186 | describe("when atom://.atom/snippets is opened", () => { 1187 | it("opens ~/.atom/snippets.cson", () => { 1188 | jasmine.unspy(Snippets, 'getUserSnippetsPath'); 1189 | atom.workspace.destroyActivePaneItem(); 1190 | const configDirPath = temp.mkdirSync('atom-config-dir-'); 1191 | spyOn(atom, 'getConfigDirPath').andReturn(configDirPath); 1192 | atom.workspace.open('atom://.atom/snippets'); 1193 | 1194 | waitsFor(() => atom.workspace.getActiveTextEditor() != null); 1195 | 1196 | runs(() => { 1197 | expect(atom.workspace.getActiveTextEditor().getURI()).toBe(path.join(configDirPath, 'snippets.cson')); 1198 | }); 1199 | }); 1200 | }); 1201 | 1202 | describe("snippet insertion API", () => { 1203 | it("will automatically parse snippet definition and replace selection", () => { 1204 | editor.setSelectedBufferRange([[0, 4], [0, 13]]); 1205 | Snippets.insert("hello ${1:world}", editor); 1206 | 1207 | expect(editor.lineTextForBufferRow(0)).toBe("var hello world = function () {"); 1208 | expect(editor.getSelectedBufferRange()).toEqual([[0, 10], [0, 15]]); 1209 | }); 1210 | }); 1211 | 1212 | describe("when the 'snippets:available' command is triggered", () => { 1213 | let availableSnippetsView = null; 1214 | 1215 | beforeEach(() => { 1216 | Snippets.add(__filename, { 1217 | ".source.js": { 1218 | "test": { 1219 | prefix: "test", 1220 | body: "${1:Test pass you will}, young " 1221 | }, 1222 | 1223 | "challenge": { 1224 | prefix: "chal", 1225 | body: "$1: ${2:To pass this challenge}" 1226 | } 1227 | } 1228 | } 1229 | ); 1230 | 1231 | delete Snippets.availableSnippetsView; 1232 | 1233 | atom.commands.dispatch(editorElement, "snippets:available"); 1234 | 1235 | waitsFor(() => atom.workspace.getModalPanels().length === 1); 1236 | 1237 | runs(() => { 1238 | availableSnippetsView = atom.workspace.getModalPanels()[0].getItem(); 1239 | }); 1240 | }); 1241 | 1242 | it("renders a select list of all available snippets", () => { 1243 | expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe('test'); 1244 | expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe('test'); 1245 | expect(availableSnippetsView.selectListView.getSelectedItem().bodyText).toBe('${1:Test pass you will}, young '); 1246 | 1247 | availableSnippetsView.selectListView.selectNext(); 1248 | 1249 | expect(availableSnippetsView.selectListView.getSelectedItem().prefix).toBe('chal'); 1250 | expect(availableSnippetsView.selectListView.getSelectedItem().name).toBe('challenge'); 1251 | expect(availableSnippetsView.selectListView.getSelectedItem().bodyText).toBe('$1: ${2:To pass this challenge}'); 1252 | }); 1253 | 1254 | it("writes the selected snippet to the editor as snippet", () => { 1255 | availableSnippetsView.selectListView.confirmSelection(); 1256 | 1257 | expect(editor.getCursorScreenPosition()).toEqual([0, 18]); 1258 | expect(editor.getSelectedText()).toBe('Test pass you will'); 1259 | expect(editor.lineTextForBufferRow(0)).toBe('Test pass you will, young var quicksort = function () {'); 1260 | }); 1261 | 1262 | it("closes the dialog when triggered again", () => { 1263 | atom.commands.dispatch(availableSnippetsView.selectListView.refs.queryEditor.element, 'snippets:available'); 1264 | expect(atom.workspace.getModalPanels().length).toBe(0); 1265 | }); 1266 | }); 1267 | }); 1268 | --------------------------------------------------------------------------------