const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers') const dedent = require('dedent') const TextBuffer = require('text-buffer') const {Point} = TextBuffer const TextEditor = require('../src/text-editor') const TreeSitterGrammar = require('../src/tree-sitter-grammar') const TreeSitterLanguageMode = require('../src/tree-sitter-language-mode') const cGrammarPath = require.resolve('language-c/grammars/tree-sitter-c.cson') const pythonGrammarPath = require.resolve('language-python/grammars/tree-sitter-python.cson') const jsGrammarPath = require.resolve('language-javascript/grammars/tree-sitter-javascript.cson') const htmlGrammarPath = require.resolve('language-html/grammars/tree-sitter-html.cson') const ejsGrammarPath = require.resolve('language-html/grammars/tree-sitter-ejs.cson') const rubyGrammarPath = require.resolve('language-ruby/grammars/tree-sitter-ruby.cson') describe('TreeSitterLanguageMode', () => { let editor, buffer beforeEach(async () => { editor = await atom.workspace.open('') buffer = editor.getBuffer() editor.displayLayer.reset({foldCharacter: '…'}) }) describe('highlighting', () => { it('applies the most specific scope mapping to each node in the syntax tree', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'program': 'source', 'call_expression > identifier': 'function', 'property_identifier': 'property', 'call_expression > member_expression > property_identifier': 'method' } }) buffer.setText('aa.bbb = cc(d.eee());') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [[ {text: 'aa.', scopes: ['source']}, {text: 'bbb', scopes: ['source', 'property']}, {text: ' = ', scopes: ['source']}, {text: 'cc', scopes: ['source', 'function']}, {text: '(d.', scopes: ['source']}, {text: 'eee', scopes: ['source', 'method']}, {text: '());', scopes: ['source']} ]]) }) it('can start or end multiple scopes at the same position', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'program': 'source', 'call_expression': 'call', 'member_expression': 'member', 'identifier': 'variable', '"("': 'open-paren', '")"': 'close-paren', } }) buffer.setText('a = bb.ccc();') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [[ {text: 'a', scopes: ['source', 'variable']}, {text: ' = ', scopes: ['source']}, {text: 'bb', scopes: ['source', 'call', 'member', 'variable']}, {text: '.ccc', scopes: ['source', 'call', 'member']}, {text: '(', scopes: ['source', 'call', 'open-paren']}, {text: ')', scopes: ['source', 'call', 'close-paren']}, {text: ';', scopes: ['source']} ]]) }) it('can resume highlighting on a line that starts with whitespace', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'call_expression > member_expression > property_identifier': 'function', 'property_identifier': 'member', 'identifier': 'variable' } }) buffer.setText('a\n .b();') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'a', scopes: ['variable']}, ], [ {text: ' ', scopes: ['leading-whitespace']}, {text: '.', scopes: []}, {text: 'b', scopes: ['function']}, {text: '();', scopes: []} ] ]) }) it('correctly skips over tokens with zero size', async () => { const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, { parser: 'tree-sitter-c', scopes: { 'primitive_type': 'type', 'identifier': 'variable', } }) buffer.setText('int main() {\n int a\n int b;\n}'); const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect( languageMode.tree.rootNode.descendantForPosition(Point(1, 2), Point(1, 6)).toString() ).toBe('(declaration (primitive_type) (identifier) (MISSING))') expectTokensToEqual(editor, [ [ {text: 'int', scopes: ['type']}, {text: ' ', scopes: []}, {text: 'main', scopes: ['variable']}, {text: '() {', scopes: []} ], [ {text: ' ', scopes: ['leading-whitespace']}, {text: 'int', scopes: ['type']}, {text: ' ', scopes: []}, {text: 'a', scopes: ['variable']} ], [ {text: ' ', scopes: ['leading-whitespace']}, {text: 'int', scopes: ['type']}, {text: ' ', scopes: []}, {text: 'b', scopes: ['variable']}, {text: ';', scopes: []} ], [ {text: '}', scopes: []} ] ]) }) it('updates lines\' highlighting when they are affected by distant changes', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'call_expression > identifier': 'function', 'property_identifier': 'member' } }) buffer.setText('a(\nb,\nc\n') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) // missing closing paren expectTokensToEqual(editor, [ [{text: 'a(', scopes: []}], [{text: 'b,', scopes: []}], [{text: 'c', scopes: []}], [{text: '', scopes: []}] ]) buffer.append(')') expectTokensToEqual(editor, [ [ {text: 'a', scopes: ['function']}, {text: '(', scopes: []} ], [{text: 'b,', scopes: []}], [{text: 'c', scopes: []}], [{text: ')', scopes: []}] ]) }) it('allows comma-separated selectors as scope mapping keys', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'identifier, call_expression > identifier': [ {match: '^[A-Z]', scopes: 'constructor'} ], 'call_expression > identifier': 'function' } }) buffer.setText(`a(B(new C))`) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'a', scopes: ['function']}, {text: '(', scopes: []}, {text: 'B', scopes: ['constructor']}, {text: '(new ', scopes: []}, {text: 'C', scopes: ['constructor']}, {text: '))', scopes: []}, ] ]) }) it('handles edits after tokens that end between CR and LF characters (regression)', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'comment': 'comment', 'string': 'string', 'property_identifier': 'property', } }) buffer.setText([ '// abc', '', 'a("b").c' ].join('\r\n')) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [{text: '// abc', scopes: ['comment']}], [{text: '', scopes: []}], [ {text: 'a(', scopes: []}, {text: '"b"', scopes: ['string']}, {text: ').', scopes: []}, {text: 'c', scopes: ['property']} ] ]) buffer.insert([2, 0], ' ') expectTokensToEqual(editor, [ [{text: '// abc', scopes: ['comment']}], [{text: '', scopes: []}], [ {text: ' ', scopes: ['leading-whitespace']}, {text: 'a(', scopes: []}, {text: '"b"', scopes: ['string']}, {text: ').', scopes: []}, {text: 'c', scopes: ['property']} ] ]) }) it('handles multi-line nodes with children on different lines (regression)', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'template_string': 'string', '"${"': 'interpolation', '"}"': 'interpolation' } }); buffer.setText('`\na${1}\nb${2}\n`;') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: '`', scopes: ['string']} ], [ {text: 'a', scopes: ['string']}, {text: '${', scopes: ['string', 'interpolation']}, {text: '1', scopes: ['string']}, {text: '}', scopes: ['string', 'interpolation']} ], [ {text: 'b', scopes: ['string']}, {text: '${', scopes: ['string', 'interpolation']}, {text: '2', scopes: ['string']}, {text: '}', scopes: ['string', 'interpolation']} ], [ {text: '`', scopes: ['string']}, {text: ';', scopes: []} ] ]) }) it('handles folds inside of highlighted tokens', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'comment': 'comment', 'call_expression > identifier': 'function', } }) buffer.setText(dedent ` /* * Hello */ hello(); `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) editor.foldBufferRange([[0, 2], [2, 0]]) expectTokensToEqual(editor, [ [ {text: '/*', scopes: ['comment']}, {text: '…', scopes: ['fold-marker']}, {text: ' */', scopes: ['comment']} ], [ {text: '', scopes: []} ], [ {text: 'hello', scopes: ['function']}, {text: '();', scopes: []}, ] ]) }) it('applies regex match rules when specified', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'identifier': [ {match: '^(exports|document|window|global)$', scopes: 'global'}, {match: '^[A-Z_]+$', scopes: 'constant'}, {match: '^[A-Z]', scopes: 'constructor'}, 'variable' ], } }) buffer.setText(`exports.object = Class(SOME_CONSTANT, x)`) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'exports', scopes: ['global']}, {text: '.object = ', scopes: []}, {text: 'Class', scopes: ['constructor']}, {text: '(', scopes: []}, {text: 'SOME_CONSTANT', scopes: ['constant']}, {text: ', ', scopes: []}, {text: 'x', scopes: ['variable']}, {text: ')', scopes: []}, ] ]) }) it('handles nodes that start before their first child and end after their last child', async () => { const grammar = new TreeSitterGrammar(atom.grammars, rubyGrammarPath, { parser: 'tree-sitter-ruby', scopes: { 'bare_string': 'string', 'interpolation': 'embedded', '"#{"': 'punctuation', '"}"': 'punctuation', } }) // The bare string node `bc#{d}ef` has one child: the interpolation, and that child // starts later and ends earlier than the bare string. buffer.setText('a = %W( bc#{d}ef )') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'a = %W( ', scopes: []}, {text: 'bc', scopes: ['string']}, {text: '#{', scopes: ['string', 'embedded', 'punctuation']}, {text: 'd', scopes: ['string', 'embedded']}, {text: '}', scopes: ['string', 'embedded', 'punctuation']}, {text: 'ef', scopes: ['string']}, {text: ' )', scopes: []}, ] ]) }) describe('when the buffer changes during a parse', () => { it('immediately parses again when the current parse completes', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'identifier': 'variable', 'call_expression > identifier': 'function', 'new_expression > call_expression > identifier': 'constructor' } }) buffer.setText('abc;'); const languageMode = new TreeSitterLanguageMode({buffer, grammar, syncOperationLimit: 0}) buffer.setLanguageMode(languageMode) await nextHighlightingUpdate(languageMode) await new Promise(process.nextTick) expectTokensToEqual(editor, [ [ {text: 'abc', scopes: ['variable']}, {text: ';', scopes: []} ], ]) buffer.setTextInRange([[0, 3], [0, 3]], '()'); expectTokensToEqual(editor, [ [ {text: 'abc()', scopes: ['variable']}, {text: ';', scopes: []} ], ]) buffer.setTextInRange([[0, 0], [0, 0]], 'new '); expectTokensToEqual(editor, [ [ {text: 'new ', scopes: []}, {text: 'abc()', scopes: ['variable']}, {text: ';', scopes: []} ], ]) await nextHighlightingUpdate(languageMode) expectTokensToEqual(editor, [ [ {text: 'new ', scopes: []}, {text: 'abc', scopes: ['function']}, {text: '();', scopes: []} ], ]) await nextHighlightingUpdate(languageMode) expectTokensToEqual(editor, [ [ {text: 'new ', scopes: []}, {text: 'abc', scopes: ['constructor']}, {text: '();', scopes: []} ], ]) }) }) describe('when changes are small enough to be re-parsed synchronously', () => { it('can incorporate multiple consecutive synchronous updates', () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: { 'property_identifier': 'property', 'call_expression > identifier': 'function', 'call_expression > member_expression > property_identifier': 'method', } }) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) buffer.setText('a'); expectTokensToEqual(editor, [[ {text: 'a', scopes: []}, ]]) buffer.append('.') expectTokensToEqual(editor, [[ {text: 'a.', scopes: []}, ]]) buffer.append('b') expectTokensToEqual(editor, [[ {text: 'a.', scopes: []}, {text: 'b', scopes: ['property']}, ]]) buffer.append('()') expectTokensToEqual(editor, [[ {text: 'a.', scopes: []}, {text: 'b', scopes: ['method']}, {text: '()', scopes: []}, ]]) buffer.delete([[0, 1], [0, 2]]) expectTokensToEqual(editor, [[ {text: 'ab', scopes: ['function']}, {text: '()', scopes: []}, ]]) }) }) describe('injectionPoints and injectionPatterns', () => { let jsGrammar, htmlGrammar beforeEach(() => { jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: { 'property_identifier': 'property', 'call_expression > identifier': 'function', 'template_string': 'string', 'template_substitution > "${"': 'interpolation', 'template_substitution > "}"': 'interpolation' }, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: { fragment: 'html', tag_name: 'tag', attribute_name: 'attr' }, injectionRegExp: 'html', injectionPoints: [SCRIPT_TAG_INJECTION_POINT] }) }) it('highlights code inside of injection points', async () => { atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText('node.innerHTML = html `\na ${b}\n`;') const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'node.', scopes: []}, {text: 'innerHTML', scopes: ['property']}, {text: ' = ', scopes: []}, {text: 'html', scopes: ['function']}, {text: ' ', scopes: []}, {text: '`', scopes: ['string']}, {text: '', scopes: ['string', 'html']} ], [ {text: 'a ', scopes: ['string', 'html']}, {text: '${', scopes: ['string', 'html', 'interpolation']}, {text: 'b', scopes: ['string', 'html']}, {text: '}', scopes: ['string', 'html', 'interpolation']}, {text: '<', scopes: ['string', 'html']}, {text: 'img', scopes: ['string', 'html', 'tag']}, {text: ' ', scopes: ['string', 'html']}, {text: 'src', scopes: ['string', 'html', 'attr']}, {text: '="d">', scopes: ['string', 'html']} ], [ {text: '`', scopes: ['string']}, {text: ';', scopes: []}, ], ]) const range = buffer.findSync('html') buffer.setTextInRange(range, 'xml') await nextHighlightingUpdate(languageMode) expectTokensToEqual(editor, [ [ {text: 'node.', scopes: []}, {text: 'innerHTML', scopes: ['property']}, {text: ' = ', scopes: []}, {text: 'xml', scopes: ['function']}, {text: ' ', scopes: []}, {text: '`', scopes: ['string']} ], [ {text: 'a ', scopes: ['string']}, {text: '${', scopes: ['string', 'interpolation']}, {text: 'b', scopes: ['string']}, {text: '}', scopes: ['string', 'interpolation']}, {text: '', scopes: ['string']}, ], [ {text: '`', scopes: ['string']}, {text: ';', scopes: []}, ], ]) }) it('highlights the content after injections', async () => { atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText('\n
\n
') const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: '<', scopes: ['html']}, {text: 'script', scopes: ['html', 'tag']}, {text: '>', scopes: ['html']}, ], [ {text: 'hello', scopes: ['html', 'function']}, {text: '();', scopes: ['html']}, ], [ {text: '', scopes: ['html']}, ], [ {text: '<', scopes: ['html']}, {text: 'div', scopes: ['html', 'tag']}, {text: '>', scopes: ['html']}, ], [ {text: '', scopes: ['html']}, ] ]) }) it('updates buffers highlighting when a grammar with injectionRegExp is added', async () => { atom.grammars.addGrammar(jsGrammar) buffer.setText('node.innerHTML = html `\na ${b}\n`;') const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: 'node.', scopes: []}, {text: 'innerHTML', scopes: ['property']}, {text: ' = ', scopes: []}, {text: 'html', scopes: ['function']}, {text: ' ', scopes: []}, {text: '`', scopes: ['string']} ], [ {text: 'a ', scopes: ['string']}, {text: '${', scopes: ['string', 'interpolation']}, {text: 'b', scopes: ['string']}, {text: '}', scopes: ['string', 'interpolation']}, {text: '', scopes: ['string']}, ], [ {text: '`', scopes: ['string']}, {text: ';', scopes: []}, ], ]) atom.grammars.addGrammar(htmlGrammar) await nextHighlightingUpdate(languageMode) expectTokensToEqual(editor, [ [ {text: 'node.', scopes: []}, {text: 'innerHTML', scopes: ['property']}, {text: ' = ', scopes: []}, {text: 'html', scopes: ['function']}, {text: ' ', scopes: []}, {text: '`', scopes: ['string']}, {text: '', scopes: ['string', 'html']} ], [ {text: 'a ', scopes: ['string', 'html']}, {text: '${', scopes: ['string', 'html', 'interpolation']}, {text: 'b', scopes: ['string', 'html']}, {text: '}', scopes: ['string', 'html', 'interpolation']}, {text: '<', scopes: ['string', 'html']}, {text: 'img', scopes: ['string', 'html', 'tag']}, {text: ' ', scopes: ['string', 'html']}, {text: 'src', scopes: ['string', 'html', 'attr']}, {text: '="d">', scopes: ['string', 'html']} ], [ {text: '`', scopes: ['string']}, {text: ';', scopes: []}, ], ]) }) it('handles injections that intersect', async () => { const ejsGrammar = new TreeSitterGrammar(atom.grammars, ejsGrammarPath, { id: 'ejs', parser: 'tree-sitter-embedded-template', scopes: { '"<%="': 'directive', '"%>"': 'directive', }, injectionPoints: [ { type: 'template', language (node) { return 'javascript' }, content (node) { return node.descendantsOfType('code') } }, { type: 'template', language (node) { return 'html' }, content (node) { return node.descendantsOfType('content') } } ] }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText('\n\n') const languageMode = new TreeSitterLanguageMode({ buffer, grammar: ejsGrammar, grammars: atom.grammars, }) buffer.setLanguageMode(languageMode) expectTokensToEqual(editor, [ [ {text: '<', scopes: ['html']}, {text: 'body', scopes: ['html', 'tag']}, {text: '>', scopes: ['html']} ], [ {text: '<', scopes: ['html']}, {text: 'script', scopes: ['html', 'tag']}, {text: '>', scopes: ['html']} ], [ {text: 'b', scopes: ['html', 'function']}, {text: '(', scopes: ['html']}, {text: '<%=', scopes: ['html', 'directive']}, {text: ' c.', scopes: ['html']}, {text: 'd', scopes: ['html', 'property']}, {text: ' ', scopes: ['html']}, {text: '%>', scopes: ['html', 'directive']}, {text: ')', scopes: ['html']}, ], [ {text: '', scopes: ['html']} ], [ {text: '', scopes: ['html']} ], ]) }) it('notifies onDidTokenize listeners the first time all syntax highlighting is done', async () => { const promise = new Promise(resolve => { editor.onDidTokenize(event => { expectTokensToEqual(editor, [ [ {text: '<', scopes: ['html']}, {text: 'script', scopes: ['html', 'tag']}, {text: '>', scopes: ['html']}, ], [ {text: 'hello', scopes: ['html', 'function']}, {text: '();', scopes: ['html']}, ], [ {text: '', scopes: ['html']}, ] ]) resolve() }) }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText('') const languageMode = new TreeSitterLanguageMode({ buffer, grammar: htmlGrammar, grammars: atom.grammars, syncOperationLimit: 0 }) buffer.setLanguageMode(languageMode) await promise }) }) }) describe('folding', () => { it('can fold nodes that start and end with specified tokens', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', folds: [ { start: {type: '{', index: 0}, end: {type: '}', index: -1} }, { start: {type: '(', index: 0}, end: {type: ')', index: -1} } ] }) buffer.setText(dedent ` module.exports = class A { getB (c, d, e) { return this.f(g) } } `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect(editor.isFoldableAtBufferRow(0)).toBe(false) expect(editor.isFoldableAtBufferRow(1)).toBe(true) expect(editor.isFoldableAtBufferRow(2)).toBe(true) expect(editor.isFoldableAtBufferRow(3)).toBe(false) expect(editor.isFoldableAtBufferRow(4)).toBe(true) expect(editor.isFoldableAtBufferRow(5)).toBe(false) editor.foldBufferRow(2) expect(getDisplayText(editor)).toBe(dedent ` module.exports = class A { getB (c,…) { return this.f(g) } } `) editor.foldBufferRow(4) expect(getDisplayText(editor)).toBe(dedent ` module.exports = class A { getB (c,…) {…} } `) }) it('folds entire buffer rows when necessary to keep words on separate lines', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', folds: [ { start: {type: '{', index: 0}, end: {type: '}', index: -1} }, { start: {type: '(', index: 0}, end: {type: ')', index: -1} } ] }) buffer.setText(dedent ` if (a) { b } else if (c) { d } else { e } `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) // Avoid bringing the `else if...` up onto the same screen line as the preceding `if`. editor.foldBufferRow(1) editor.foldBufferRow(3) expect(getDisplayText(editor)).toBe(dedent ` if (a) {… } else if (c) {… } else { e } `) // It's ok to bring the final `}` onto the same screen line as the preceding `else`. editor.foldBufferRow(5) expect(getDisplayText(editor)).toBe(dedent ` if (a) {… } else if (c) {… } else {…} `) }) it('can fold nodes of specified types', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', folds: [ // Start the fold after the first child (the opening tag) and end it at the last child // (the closing tag). { type: 'jsx_element', start: {index: 0}, end: {index: -1} }, // End the fold at the *second* to last child of the self-closing tag: the `/`. { type: 'jsx_self_closing_element', start: {index: 1}, end: {index: -2} } ] }) buffer.setText(dedent ` const element1 = const element2 = hello world `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect(editor.isFoldableAtBufferRow(0)).toBe(true) expect(editor.isFoldableAtBufferRow(1)).toBe(false) expect(editor.isFoldableAtBufferRow(2)).toBe(false) expect(editor.isFoldableAtBufferRow(3)).toBe(false) expect(editor.isFoldableAtBufferRow(4)).toBe(true) expect(editor.isFoldableAtBufferRow(5)).toBe(false) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe(dedent ` const element1 = const element2 = hello world `) editor.foldBufferRow(4) expect(getDisplayText(editor)).toBe(dedent ` const element1 = const element2 = `) }) it('can fold entire nodes when no start or end parameters are specified', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', folds: [ // By default, for a node with no children, folds are started at the *end* of the first // line of a node, and ended at the *beginning* of the last line. {type: 'comment'} ] }) buffer.setText(dedent ` /** * Important */ const x = 1 /* Also important */ `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect(editor.isFoldableAtBufferRow(0)).toBe(true) expect(editor.isFoldableAtBufferRow(1)).toBe(false) expect(editor.isFoldableAtBufferRow(2)).toBe(false) expect(editor.isFoldableAtBufferRow(3)).toBe(true) expect(editor.isFoldableAtBufferRow(4)).toBe(false) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe(dedent ` /**… */ const x = 1 /* Also important */ `) editor.foldBufferRow(3) expect(getDisplayText(editor)).toBe(dedent ` /**… */ const x = 1 /*…*/ `) }) it('tries each folding strategy for a given node in the order specified', async () => { const grammar = new TreeSitterGrammar(atom.grammars, cGrammarPath, { parser: 'tree-sitter-c', folds: [ // If the #ifdef has an `#else` clause, then end the fold there. { type: ['preproc_ifdef', 'preproc_elif'], start: {index: 1}, end: {type: ['preproc_else', 'preproc_elif']} }, // Otherwise, end the fold at the last child - the `#endif`. { type: 'preproc_ifdef', start: {index: 1}, end: {index: -1} }, // When folding an `#else` clause, the fold extends to the end of the clause. { type: 'preproc_else', start: {index: 0} } ] }) buffer.setText(dedent ` #ifndef FOO_H_ #define FOO_H_ #ifdef _WIN32 #include const char *path_separator = "\\"; #elif defined MACOS #include const char *path_separator = "/"; #else #include const char *path_separator = "/"; #endif #endif `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) editor.foldBufferRow(3) expect(getDisplayText(editor)).toBe(dedent ` #ifndef FOO_H_ #define FOO_H_ #ifdef _WIN32… #elif defined MACOS #include const char *path_separator = "/"; #else #include const char *path_separator = "/"; #endif #endif `) editor.foldBufferRow(8) expect(getDisplayText(editor)).toBe(dedent ` #ifndef FOO_H_ #define FOO_H_ #ifdef _WIN32… #elif defined MACOS… #else #include const char *path_separator = "/"; #endif #endif `) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe(dedent ` #ifndef FOO_H_… #endif `) editor.foldAllAtIndentLevel(1) expect(getDisplayText(editor)).toBe(dedent ` #ifndef FOO_H_ #define FOO_H_ #ifdef _WIN32… #elif defined MACOS… #else… #endif #endif `) }) it('does not fold when the start and end parameters match the same child', async () => { const grammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { parser: 'tree-sitter-html', folds: [ { type: 'element', start: {index: 0}, end: {index: -1} } ] }) buffer.setText(dedent ` `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) // Void elements have only one child expect(editor.isFoldableAtBufferRow(1)).toBe(false) expect(editor.isFoldableAtBufferRow(2)).toBe(false) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe(dedent ` … `) }) it('can target named vs anonymous nodes as fold boundaries', async () => { const grammar = new TreeSitterGrammar(atom.grammars, rubyGrammarPath, { parser: 'tree-sitter-ruby', folds: [ // Note that this isn't how folds actually work in language-ruby. It's // just to demonstrate the targeting of named vs anonymous nodes. { type: 'elsif', start: {index: 1}, // There are no double quotes around the `elsif` type. This indicates // that we're targeting a *named* node in the syntax tree. The fold // should end at the nested `elsif` node, not at the token that represents // the literal string "elsif". end: {type: ['else', 'elsif']} }, { type: 'else', // There are double quotes around the `else` type. This indicates that // we're targetting an *anonymous* node in the syntax tree. The fold // should start at the token representing the literal string "else", // not at an `else` node. start: {type: '"else"'} } ] }) buffer.setText(dedent ` if a b elsif c d else e end `) const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect(languageMode.tree.rootNode.toString()).toBe( "(program (if (identifier) (then " + "(identifier)) " + "(elsif (identifier) (then " + "(identifier)) " + "(else " + "(identifier)))))" ) editor.foldBufferRow(2) expect(getDisplayText(editor)).toBe(dedent ` if a b elsif c… else e end `) editor.foldBufferRow(4) expect(getDisplayText(editor)).toBe(dedent ` if a b elsif c… else… end `) }) describe('when folding a node that ends with a line break', () => { it('ends the fold at the end of the previous line', async () => { const grammar = new TreeSitterGrammar(atom.grammars, pythonGrammarPath, { parser: 'tree-sitter-python', folds: [ { type: 'function_definition', start: {type: ':'} } ] }) buffer.setText(dedent ` def ab(): print 'a' print 'b' def cd(): print 'c' print 'd' `) buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe(dedent ` def ab():… def cd(): print 'c' print 'd' `) }) }) it('folds code in injected languages', async () => { const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: {}, folds: [{ type: ['element', 'raw_element'], start: {index: 0}, end: {index: -1} }], injectionRegExp: 'html' }) const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: {}, folds: [{ type: ['template_string'], start: {index: 0}, end: {index: -1}, }, { start: {index: 0, type: '('}, end: {index: -1, type: ')'} }], injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) atom.grammars.addGrammar(htmlGrammar) buffer.setText( `a = html \`
c\${def( 1, 2, 3, )}e\${f}g
\` ` ) const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) editor.foldBufferRow(2) expect(getDisplayText(editor)).toBe( `a = html \`
c\${def(… )}e\${f}g
\` ` ) editor.foldBufferRow(1) expect(getDisplayText(editor)).toBe( `a = html \`
\` ` ) editor.foldBufferRow(0) expect(getDisplayText(editor)).toBe( `a = html \`…\` ` ) }) }) describe('.scopeDescriptorForPosition', () => { it('returns a scope descriptor representing the given position in the syntax tree', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'source.js', parser: 'tree-sitter-javascript', scopes: { program: 'source.js', property_identifier: 'property.name' } }) buffer.setText('foo({bar: baz});') buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) expect(editor.scopeDescriptorForBufferPosition([0, 'foo({b'.length]).getScopesArray()).toEqual([ 'source.js', 'property.name' ]) expect(editor.scopeDescriptorForBufferPosition([0, 'foo({'.length]).getScopesArray()).toEqual([ 'source.js', 'property.name' ]) // Drive-by test for .tokenForPosition() const token = editor.tokenForBufferPosition([0, 'foo({b'.length]) expect(token.value).toBe('bar') expect(token.scopes).toEqual([ 'source.js', 'property.name' ]) }) it('includes nodes in injected syntax trees', async () => { const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'source.js', parser: 'tree-sitter-javascript', scopes: { program: 'source.js', template_string: 'string.quoted', interpolation: 'meta.embedded', property_identifier: 'property.name' }, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'text.html', parser: 'tree-sitter-html', scopes: { fragment: 'text.html', raw_element: 'script.tag' }, injectionRegExp: 'html', injectionPoints: [SCRIPT_TAG_INJECTION_POINT] }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText(`
`) const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) const position = buffer.findSync('name').start expect(languageMode.scopeDescriptorForPosition(position).getScopesArray()).toEqual([ 'text.html', 'script.tag', 'source.js', 'string.quoted', 'text.html', 'property.name' ]) }) it('includes the root scope name even when the given position is in trailing whitespace at EOF', () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'source.js', parser: 'tree-sitter-javascript', scopes: { program: 'source.js', property_identifier: 'property.name' } }) buffer.setText('a; ') buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) expect(editor.scopeDescriptorForBufferPosition([0, 3]).getScopesArray()).toEqual([ 'source.js' ]) }) }) describe('.bufferRangeForScopeAtPosition(selector?, position)', () => { describe('when selector = null', () => { it('returns the range of the smallest node at position', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript' }) buffer.setText('foo({bar: baz});') buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) expect(editor.bufferRangeForScopeAtPosition(null, [0, 6])).toEqual( [[0, 5], [0, 8]] ) expect(editor.bufferRangeForScopeAtPosition(null, [0, 9])).toEqual( [[0, 8], [0, 9]] ) }) it('includes nodes in injected syntax trees', async () => { const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: {}, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: {}, injectionRegExp: 'html', injectionPoints: [SCRIPT_TAG_INJECTION_POINT] }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText(`
`) const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) const nameProperty = buffer.findSync('name') const {start} = nameProperty const position = Object.assign({}, start, {column: start.column + 2}) expect(languageMode.bufferRangeForScopeAtPosition(null, position)) .toEqual(nameProperty) }) }) describe('with a selector', () => { it('returns the range of the smallest matching node at position', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: { 'property_identifier': 'variable.other.object.property', 'template_string': 'string.quoted.template' } }) buffer.setText('a(`${b({ccc: ddd})} eee`);') buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) expect(editor.bufferRangeForScopeAtPosition('.variable.property', [0, 9])).toEqual( [[0, 8], [0, 11]] ) expect(editor.bufferRangeForScopeAtPosition('.string.quoted', [0, 6])).toEqual( [[0, 2], [0, 24]] ) }) it('includes nodes in injected syntax trees', async () => { const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: { 'property_identifier': 'variable.other.object.property', }, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: { 'element': 'meta.element.html' }, injectionRegExp: 'html', injectionPoints: [SCRIPT_TAG_INJECTION_POINT] }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText(`
`) const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) const nameProperty = buffer.findSync('name') const {start} = nameProperty const position = Object.assign({}, start, {column: start.column + 2}) expect(languageMode.bufferRangeForScopeAtPosition('.object.property', position)) .toEqual(nameProperty) expect(languageMode.bufferRangeForScopeAtPosition('.meta.element.html', position)) .toEqual(buffer.findSync('\\${person\\.name}')) }) it('accepts node-matching functions as selectors', async () => { const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: {}, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: {}, injectionRegExp: 'html', injectionPoints: [SCRIPT_TAG_INJECTION_POINT] }) atom.grammars.addGrammar(jsGrammar) atom.grammars.addGrammar(htmlGrammar) buffer.setText(`
`) const languageMode = new TreeSitterLanguageMode({buffer, grammar: htmlGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) const nameProperty = buffer.findSync('name') const {start} = nameProperty const position = Object.assign({}, start, {column: start.column + 2}) const templateStringInCallExpression = node => node.type === 'template_string' && node.parent.type === 'call_expression' expect(languageMode.bufferRangeForScopeAtPosition(templateStringInCallExpression, position)) .toEqual([[3, 19], [5, 15]]) }) }) }) describe('.getSyntaxNodeAtPosition(position, where?)', () => { it('returns the range of the smallest matching node at position', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript' }) buffer.setText('foo(bar({x: 2}));') const languageMode = new TreeSitterLanguageMode({buffer, grammar}) buffer.setLanguageMode(languageMode) expect(languageMode.getSyntaxNodeAtPosition([0, 6]).range).toEqual( buffer.findSync('bar') ) const findFoo = node => node.type === 'call_expression' && node.firstChild.text === 'foo' expect(languageMode.getSyntaxNodeAtPosition([0, 6], findFoo).range).toEqual( [[0, 0], [0, buffer.getText().length - 1]] ) }) }) describe('TextEditor.selectLargerSyntaxNode and .selectSmallerSyntaxNode', () => { it('expands and contracts the selection based on the syntax tree', async () => { const grammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { parser: 'tree-sitter-javascript', scopes: {'program': 'source'} }) buffer.setText(dedent ` function a (b, c, d) { eee.f() g() } `) buffer.setLanguageMode(new TreeSitterLanguageMode({buffer, grammar})) editor.setCursorBufferPosition([1, 3]) editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('eee') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('eee.f') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('eee.f()') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('function a (b, c, d) {\n eee.f()\n g()\n}') editor.selectSmallerSyntaxNode() expect(editor.getSelectedText()).toBe('{\n eee.f()\n g()\n}') editor.selectSmallerSyntaxNode() expect(editor.getSelectedText()).toBe('eee.f()') editor.selectSmallerSyntaxNode() expect(editor.getSelectedText()).toBe('eee.f') editor.selectSmallerSyntaxNode() expect(editor.getSelectedText()).toBe('eee') editor.selectSmallerSyntaxNode() expect(editor.getSelectedBufferRange()).toEqual([[1, 3], [1, 3]]) }) it('handles injected languages', async () => { const jsGrammar = new TreeSitterGrammar(atom.grammars, jsGrammarPath, { scopeName: 'javascript', parser: 'tree-sitter-javascript', scopes: { 'property_identifier': 'property', 'call_expression > identifier': 'function', 'template_string': 'string', 'template_substitution > "${"': 'interpolation', 'template_substitution > "}"': 'interpolation' }, injectionRegExp: 'javascript', injectionPoints: [HTML_TEMPLATE_LITERAL_INJECTION_POINT] }) const htmlGrammar = new TreeSitterGrammar(atom.grammars, htmlGrammarPath, { scopeName: 'html', parser: 'tree-sitter-html', scopes: { fragment: 'html', tag_name: 'tag', attribute_name: 'attr' }, injectionRegExp: 'html' }) atom.grammars.addGrammar(htmlGrammar) buffer.setText('a = html ` c${def()}e${f}g `') const languageMode = new TreeSitterLanguageMode({buffer, grammar: jsGrammar, grammars: atom.grammars}) buffer.setLanguageMode(languageMode) editor.setCursorBufferPosition({row: 0, column: buffer.getText().indexOf('ef()')}) editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('def') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('def()') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('${def()}') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('c${def()}e${f}g') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('c${def()}e${f}g') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe(' c${def()}e${f}g ') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('` c${def()}e${f}g `') editor.selectLargerSyntaxNode() expect(editor.getSelectedText()).toBe('html ` c${def()}e${f}g `') }) }) }) function nextHighlightingUpdate (languageMode) { return new Promise(resolve => { const subscription = languageMode.onDidChangeHighlighting(() => { subscription.dispose() resolve() }) }) } function getDisplayText (editor) { return editor.displayLayer.getText() } function expectTokensToEqual (editor, expectedTokenLines) { const lastRow = editor.getLastScreenRow() // Assert that the correct tokens are returned regardless of which row // the highlighting iterator starts on. for (let startRow = 0; startRow <= lastRow; startRow++) { // Clear the screen line cache between iterations, but not on the first // iteration, so that the first iteration tests that the cache has been // correctly invalidated by any changes. if (startRow > 0) { editor.displayLayer.clearSpatialIndex() } editor.displayLayer.getScreenLines(startRow, Infinity) const tokenLines = [] for (let row = startRow; row <= lastRow; row++) { tokenLines[row] = editor.tokensForScreenRow(row).map(({text, scopes}) => ({ text, scopes: scopes.map(scope => scope .split(' ') .map(className => className.replace('syntax--', '')) .join(' ')) })) } for (let row = startRow; row <= lastRow; row++) { const tokenLine = tokenLines[row] const expectedTokenLine = expectedTokenLines[row] expect(tokenLine.length).toEqual(expectedTokenLine.length) for (let i = 0; i < tokenLine.length; i++) { expect(tokenLine[i]).toEqual(expectedTokenLine[i], `Token ${i}, startRow: ${startRow}`) } } } // Fully populate the screen line cache again so that cache invalidation // due to subsequent edits can be tested. editor.displayLayer.getScreenLines(0, Infinity) } const HTML_TEMPLATE_LITERAL_INJECTION_POINT = { type: 'call_expression', language (node) { if (node.lastChild.type === 'template_string' && node.firstChild.type === 'identifier') { return node.firstChild.text } }, content (node) { return node.lastChild } } const SCRIPT_TAG_INJECTION_POINT = { type: 'raw_element', language () { return 'javascript' }, content (node) { return node.child(1) } }