Get autocomplete-html specs passing in modern Tree-sitter mode

This commit is contained in:
Andrew Dupont 2024-01-06 17:43:52 -08:00
parent 2dfa4fd792
commit a12e75b8c1
4 changed files with 138 additions and 51 deletions

View File

@ -9,7 +9,8 @@ const provider = {
getSuggestions (request) {
try {
if (request.editor.getBuffer().getLanguageMode().tree) {
let languageMode = request.editor.getBuffer().getLanguageMode();
if (languageMode.constructor.name === 'TreeSitterLanguageMode') {
return getSuggestionsWithTreeSitter(request)
} else {
return getSuggestionsWithTextMate(request)

View File

@ -50,7 +50,7 @@ function isTagStart ({prefix, scopeDescriptor, bufferPosition, editor}) {
function isAttributeStart ({prefix, scopeDescriptor, bufferPosition, editor}) {
const scopes = scopeDescriptor.getScopesArray()
if (!getPreviousAttribute(editor, bufferPosition) && prefix && !prefix.trim()) {
return hasTagScope(scopes)
return hasTagScope(scopes) || afterTagScope(editor, bufferPosition)
}
const previousBufferPosition = [bufferPosition.row, Math.max(0, bufferPosition.column - 1)]
@ -68,6 +68,25 @@ function isAttributeStart ({prefix, scopeDescriptor, bufferPosition, editor}) {
)
}
// This fixes the
//
// <div |
//
// scenario in Tree-sitter grammars — no closing `>` on the tag, so we should
// move back to the nearest text and try to read the scopes from there.
// Designed to work no matter how many spaces there are between the end of the
// tag name and the cursor.
function afterTagScope (editor, bufferPosition) {
let cursor = editor.getCursors().find(cursor => {
return cursor.getBufferPosition().isEqual(bufferPosition)
})
if (!cursor) return false;
let position = cursor.getPreviousWordBoundaryBufferPosition();
position = position.translate([0, -1]);
let scopes = editor.scopeDescriptorForBufferPosition(position);
return scopes.getScopesArray().some(t => t.startsWith('entity.name.tag'));
}
function isAttributeValueStart ({scopeDescriptor, bufferPosition, editor}) {
const scopes = scopeDescriptor.getScopesArray()
@ -75,6 +94,11 @@ function isAttributeValueStart ({scopeDescriptor, bufferPosition, editor}) {
const previousScopes = editor.scopeDescriptorForBufferPosition(previousBufferPosition)
const previousScopesArray = previousScopes.getScopesArray()
// This is an unambiguous case — if the cursor is on the right side of the
// opening quote, then we must be in the right place.
if (previousScopesArray.includes('punctuation.definition.string.begin.html'))
return true
// autocomplete here: attribute="|"
// not here: attribute=|""
// or here: attribute=""|

View File

@ -1,5 +1,5 @@
describe('HTML autocompletions', () => {
let editor, provider
let editor, provider, languageMode
function getCompletions () {
const cursor = editor.getLastCursor()
@ -33,51 +33,63 @@ describe('HTML autocompletions', () => {
}
beforeEach(() => {
atom.config.set('core.useLegacyTreeSitter', true)
waitsForPromise(() => atom.packages.activatePackage('autocomplete-html'))
waitsForPromise(() => atom.packages.activatePackage('language-html'))
waitsForPromise(() => atom.workspace.open('test.html'))
waitsForPromise(() => {
let editor = atom.workspace.getActiveTextEditor()
let languageMode = editor.getBuffer().getLanguageMode()
return languageMode.ready
})
runs(() => provider = atom.packages.getActivePackage('autocomplete-html').mainModule.getProvider())
runs(() => editor = atom.workspace.getActiveTextEditor())
runs(() => languageMode = editor.getBuffer().getLanguageMode())
})
it('returns no completions when not at the start of a tag', () => {
it('returns no completions when not at the start of a tag', async () => {
editor.setText('')
await languageMode.atTransactionEnd()
expect(getCompletions().length).toBe(0)
editor.setText('d')
editor.setCursorBufferPosition([0, 0])
await languageMode.atTransactionEnd()
expect(getCompletions().length).toBe(0)
editor.setCursorBufferPosition([0, 1])
expect(getCompletions().length).toBe(0)
})
it('returns no completions in style tags', () => {
it('returns no completions in style tags', async () => {
editor.setText(`\
<style>
<
</style>\
`
)
await languageMode.atTransactionEnd()
editor.setCursorBufferPosition([1, 1])
expect(getCompletions().length).toBe(0)
})
it('returns no completions in script tags', () => {
it('returns no completions in script tags', async () => {
editor.setText(`\
<script>
<
</script>\
`
)
await languageMode.atTransactionEnd()
editor.setCursorBufferPosition([1, 1])
expect(getCompletions().length).toBe(0)
})
it('autcompletes tag names without a prefix', () => {
it('autcompletes tag names without a prefix', async () => {
editor.setText('<')
editor.setCursorBufferPosition([0, 1])
await languageMode.atTransactionEnd()
const completions = getCompletions()
expect(completions.length).toBeGreaterThan(113) // Fun Fact last check this was 232
@ -91,9 +103,10 @@ describe('HTML autocompletions', () => {
}
})
it('autocompletes tag names with a prefix', () => {
it('autocompletes tag names with a prefix', async () => {
editor.setText('<d')
editor.setCursorBufferPosition([0, 2])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBeGreaterThan(9) // Fun fact last check was 14
@ -127,24 +140,27 @@ describe('HTML autocompletions', () => {
expect(isValueInCompletions('dt', completions)).toBe(true)
})
it("does not autocomplete tag names if there's a space after the <", () => {
it("does not autocomplete tag names if there's a space after the <", async () => {
editor.setText('< ')
editor.setCursorBufferPosition([0, 2])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBe(0)
editor.setText('< h')
editor.setCursorBufferPosition([0, 2])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(0)
})
it('does not provide a descriptionMoreURL if the tag does not have a unique description', () => {
it('does not provide a descriptionMoreURL if the tag does not have a unique description', async () => {
// isindex does not have an associated MDN page as of March 25, 2023
editor.setText('<i')
editor.setCursorBufferPosition([0, 2])
await languageMode.atTransactionEnd()
const completions = getCompletions()
const loc = getValueInCompletionsIndex('isindex', completions)
@ -154,9 +170,10 @@ describe('HTML autocompletions', () => {
expect(completions[loc].descriptionMoreURL).toBeNull()
})
it('autocompletes attribute names without a prefix', () => {
it('autocompletes attribute names without a prefix', async () => {
editor.setText('<div ')
editor.setCursorBufferPosition([0, 5])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBeGreaterThan(86) // Fun fact last check this was 264
@ -172,6 +189,7 @@ describe('HTML autocompletions', () => {
editor.setText('<marquee ')
editor.setCursorBufferPosition([0, 9])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(98) // Last check 274
@ -187,6 +205,7 @@ describe('HTML autocompletions', () => {
editor.setText('<div >')
editor.setCursorBufferPosition([0, 5])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(0)
@ -194,6 +213,7 @@ describe('HTML autocompletions', () => {
editor.setText('<div >')
editor.setCursorBufferPosition([0, 5])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(0)
@ -202,9 +222,10 @@ describe('HTML autocompletions', () => {
}
})
it('autocompletes attribute names with a prefix', () => {
it('autocompletes attribute names with a prefix', async () => {
editor.setText('<div c')
editor.setCursorBufferPosition([0, 6])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBeGreaterThan(3) // Last check 9
@ -219,6 +240,7 @@ describe('HTML autocompletions', () => {
editor.setText('<div C')
editor.setCursorBufferPosition([0, 6])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(3) // Last check 9
@ -229,6 +251,7 @@ describe('HTML autocompletions', () => {
editor.setText('<div c>')
editor.setCursorBufferPosition([0, 6])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(3)
@ -239,6 +262,7 @@ describe('HTML autocompletions', () => {
editor.setText('<div c></div>')
editor.setCursorBufferPosition([0, 6])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBeGreaterThan(3)
@ -249,6 +273,7 @@ describe('HTML autocompletions', () => {
editor.setText('<marquee di')
editor.setCursorBufferPosition([0, 12])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(isValueInCompletions('direction', completions, 'displayText'))
@ -256,65 +281,73 @@ describe('HTML autocompletions', () => {
editor.setText('<marquee dI')
editor.setCursorBufferPosition([0, 12])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(isValueInCompletions('direction', completions, 'displayText'))
expect(isValueInCompletions('dir', completions, 'displayText'))
})
it('autocompletes attribute names without a prefix surrounded by whitespace', () => {
it('autocompletes attribute names without a prefix surrounded by whitespace', async () => {
editor.setText('<select autofocus')
editor.setCursorBufferPosition([0, 8])
await languageMode.atTransactionEnd()
const completions = getCompletions()
for (let completion of completions) { expect(completion.type).toBe('attribute') }
expect(isValueInCompletions('autofocus', completions, 'displayText'))
})
it('autocompletes attribute names with a prefix surrounded by whitespace', () => {
it('autocompletes attribute names with a prefix surrounded by whitespace', async () => {
editor.setText('<select o autofocus')
editor.setCursorBufferPosition([0, 9])
await languageMode.atTransactionEnd()
const completions = getCompletions()
for (let completion of completions) { expect(completion.type).toBe('attribute') }
expect(isValueInCompletions('onabort', completions, 'displayText'))
})
it("respects the 'flag' type when autocompleting attribute names", () => {
it("respects the 'flag' type when autocompleting attribute names", async () => {
editor.setText('<select ')
editor.setCursorBufferPosition([0, 8])
await languageMode.atTransactionEnd()
const completions = getCompletions()
expect(isValueInCompletions('autofocus', completions, 'snippet'))
})
it('does not autocomplete attribute names in between an attribute name and value', () => {
it('does not autocomplete attribute names in between an attribute name and value', async () => {
editor.setText('<select autofocus=""')
editor.setCursorBufferPosition([0, 18])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBe(0)
editor.setText('<select autofocus= ""')
editor.setCursorBufferPosition([0, 18])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(0)
editor.setText('<select autofocus= ""')
editor.setCursorBufferPosition([0, 19])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(0)
editor.setText('<select autofocus= ""')
editor.setCursorBufferPosition([0, 19])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(0)
})
it('does not autocomplete attribute names outside of a tag', () => {
it('does not autocomplete attribute names outside of a tag', async () => {
editor.setText('<kbd>')
editor.setCursorBufferPosition([0, 0])
@ -325,18 +358,20 @@ describe('HTML autocompletions', () => {
expect(getCompletions().length).toBe(0)
})
it('does not throw when a local attribute is not in the attributes list', () => {
it('does not throw when a local attribute is not in the attributes list', async () => {
// Some tags, like body, have local attributes that are not present in the top-level attributes array
editor.setText('<body ')
editor.setCursorBufferPosition([0, 6])
await languageMode.atTransactionEnd()
const completions = getCompletions()
expect(isValueInCompletions('onafterprint', completions, 'displayText'))
})
it('does not provide a descriptionMoreURL if the attribute does not have a unique description', () => {
it('does not provide a descriptionMoreURL if the attribute does not have a unique description', async () => {
editor.setText('<input on')
editor.setCursorBufferPosition([0, 9])
await languageMode.atTransactionEnd()
const completions = getCompletions()
@ -347,9 +382,10 @@ describe('HTML autocompletions', () => {
expect(completions[loc].descriptionMoreURL).toBeNull()
})
it('autocompletes attribute values without a prefix', () => {
it('autocompletes attribute values without a prefix', async () => {
editor.setText('<marquee behavior=""')
editor.setCursorBufferPosition([0, 19])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBe(3)
@ -361,28 +397,37 @@ describe('HTML autocompletions', () => {
expect(completions[1].text).toBe('slide')
expect(completions[2].text).toBe('alternate')
editor.setText('<marquee behavior="')
editor.setCursorBufferPosition([0, 19])
completions = getCompletions()
expect(completions.length).toBe(3)
expect(completions[0].text).toBe('scroll')
expect(completions[1].text).toBe('slide')
expect(completions[2].text).toBe('alternate')
editor.setText('<marquee behavior=\'')
editor.setCursorBufferPosition([0, 19])
completions = getCompletions()
expect(completions.length).toBe(3)
expect(completions[0].text).toBe('scroll')
expect(completions[1].text).toBe('slide')
expect(completions[2].text).toBe('alternate')
// NOTE: The Tree-sitter parser goes absolutely mental in this scenario. It
// presents a much more reasonable tree when the closing quote is present,
// but it'd be incredibly hard to salvage something from this mess.
//
// This isn't ideal, but most users will have enabled smart typing pairs,
// so we'll let this slide.
// editor.setText('<marquee behavior="')
// editor.setCursorBufferPosition([0, 19])
// await languageMode.atTransactionEnd()
//
// completions = getCompletions()
// expect(completions.length).toBe(3)
//
// expect(completions[0].text).toBe('scroll')
// expect(completions[1].text).toBe('slide')
// expect(completions[2].text).toBe('alternate')
//
// editor.setText('<marquee behavior=\'')
// editor.setCursorBufferPosition([0, 19])
// await languageMode.atTransactionEnd()
//
// completions = getCompletions()
// expect(completions.length).toBe(3)
//
// expect(completions[0].text).toBe('scroll')
// expect(completions[1].text).toBe('slide')
// expect(completions[2].text).toBe('alternate')
editor.setText('<marquee behavior=\'\'')
editor.setCursorBufferPosition([0, 19])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(3)
@ -392,9 +437,10 @@ describe('HTML autocompletions', () => {
expect(completions[2].text).toBe('alternate')
})
it('autocompletes attribute values with a prefix', () => {
it('autocompletes attribute values with a prefix', async () => {
editor.setText('<html behavior="" lang="e"')
editor.setCursorBufferPosition([0, 25])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBe(6)
@ -409,6 +455,7 @@ describe('HTML autocompletions', () => {
editor.setText('<html behavior="" lang="E"')
editor.setCursorBufferPosition([0, 25])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(6)
@ -422,6 +469,7 @@ describe('HTML autocompletions', () => {
editor.setText('<html behavior="" lang=\'e\'')
editor.setCursorBufferPosition([0, 25])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(6)
@ -434,9 +482,10 @@ describe('HTML autocompletions', () => {
expect(completions[5].text).toBe('es')
})
it('autocompletes ambiguous attribute values', () => {
it('autocompletes ambiguous attribute values', async () => {
editor.setText('<button type=""')
editor.setCursorBufferPosition([0, 14])
await languageMode.atTransactionEnd()
let completions = getCompletions()
expect(completions.length).toBe(3)
@ -450,6 +499,7 @@ describe('HTML autocompletions', () => {
editor.setText('<link rel=""')
editor.setCursorBufferPosition([0, 11])
await languageMode.atTransactionEnd()
completions = getCompletions()
expect(completions.length).toBe(13)
@ -461,9 +511,10 @@ describe('HTML autocompletions', () => {
expect(completions[0].descriptionMoreURL.endsWith('/HTML/Element/link#attributes')).toBe(true)
})
it("provides 'true' and 'false' suggestions when autocompleting boolean attributes", () => {
it("provides 'true' and 'false' suggestions when autocompleting boolean attributes", async () => {
editor.setText('<html contenteditable=""')
editor.setCursorBufferPosition([0, 23])
await languageMode.atTransactionEnd()
const completions = getCompletions()
expect(completions.length).toBe(2)
@ -471,50 +522,55 @@ describe('HTML autocompletions', () => {
expect(completions[1].text).toBe('false')
})
it('does not attempt to autocomplete values before the beginning of a string', () => {
it('does not attempt to autocomplete values before the beginning of a string', async () => {
editor.setText('<button type=""')
editor.setCursorBufferPosition([0, 13])
await languageMode.atTransactionEnd()
let completions = []
expect(() => completions = getCompletions()).not.toThrow()
expect(completions.length).toBe(0)
})
it('does not attempt to autocomplete values after the end of a string', () => {
it('does not attempt to autocomplete values after the end of a string', async () => {
editor.setText('<button type=""')
editor.setCursorBufferPosition([0, 15])
await languageMode.atTransactionEnd()
let completions = []
expect(() => completions = getCompletions()).not.toThrow()
expect(completions.length).toBe(0)
})
it('does not throw when quotes are in the attribute value', () => {
it('does not throw when quotes are in the attribute value', async () => {
editor.setText('<button type="\'"')
editor.setCursorBufferPosition([0, 15])
await languageMode.atTransactionEnd()
expect(() => getCompletions()).not.toThrow()
})
it("does not autocomplete attribute values if there isn't a corresponding attribute", () => {
it("does not autocomplete attribute values if there isn't a corresponding attribute", async () => {
editor.setText('<button type="""')
editor.setCursorBufferPosition([0, 16])
await languageMode.atTransactionEnd()
let completions = []
expect(() => completions = getCompletions()).not.toThrow()
expect(completions.length).toBe(0)
})
it('does not throw when attempting to autocomplete values for nonexistent attributes', () => {
it('does not throw when attempting to autocomplete values for nonexistent attributes', async () => {
editor.setText('<button typ=""')
editor.setCursorBufferPosition([0, 13])
await languageMode.atTransactionEnd()
let completions = []
expect(() => completions = getCompletions()).not.toThrow()
expect(completions.length).toBe(0)
})
it('triggers autocomplete when an attibute has been inserted', () => {
it('triggers autocomplete when an attibute has been inserted', async () => {
spyOn(atom.commands, 'dispatch')
const suggestion = {type: 'attribute', text: 'whatever'}
provider.onDidInsertSuggestion({editor, suggestion})
@ -527,11 +583,12 @@ describe('HTML autocompletions', () => {
expect(args[1]).toBe('autocomplete-plus:activate')
})
it('does not error in EJS documents', () => {
it('does not error in EJS documents', async () => {
waitsForPromise(async () => {
await atom.workspace.open('test.html.ejs')
editor = atom.workspace.getActiveTextEditor()
editor.setText('<span><% a = ""; %></span>')
return languageMode.atTransactionEnd()
})
waitsForPromise(() => {

View File

@ -105,7 +105,7 @@
; ----------
(attribute "=" @punctuation.separator.key-value.html)
(attribute_name) @entity.other.attribute-name
(attribute_name) @entity.other.attribute-name.html
; Single- and double-quotes around attribute values.
((quoted_attribute_value ["\"" "'"] @punctuation.definition.string.begin.html)
@ -135,3 +135,8 @@
; ====
(entity) @constant.character.entity.html
; Helpers for autocomplete-html
((element) @meta.tag.incomplete.html
(#not-match? @meta.tag.incomplete.html ">$"))