diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts index 94b8810e3..6441b8fc0 100644 --- a/packages/language-server/src/lib/documents/parseHtml.ts +++ b/packages/language-server/src/lib/documents/parseHtml.ts @@ -3,8 +3,11 @@ import { HTMLDocument, TokenType, ScannerState, - Scanner + Scanner, + Node, + Position } from 'vscode-html-languageservice'; +import { Document } from './Document'; import { isInsideMoustacheTag } from './utils'; const parser = getLanguageService(); @@ -83,3 +86,66 @@ function preprocess(text: string) { scanner = createScanner(text, offset, ScannerState.WithinTag); } } + +export interface AttributeContext { + name: string; + inValue: boolean; +} + +export function getAttributeContextAtPosition( + document: Document, + position: Position +): AttributeContext | null { + const offset = document.offsetAt(position); + const { html } = document; + const tag = html.findNodeAt(offset); + + if (!inStartTag(offset, tag) || !tag.attributes) { + return null; + } + + const text = document.getText(); + const beforeStartTagEnd = + text.substring(0, tag.start) + preprocess(text.substring(tag.start, tag.startTagEnd)); + + const scanner = createScanner(beforeStartTagEnd, tag.start); + + let token = scanner.scan(); + let currentAttributeName: string | undefined; + const inTokenRange = () => + scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd(); + while (token != TokenType.EOS) { + // adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402 + if (token === TokenType.AttributeName) { + currentAttributeName = scanner.getTokenText(); + if (inTokenRange()) { + return { + name: currentAttributeName, + inValue: false + }; + } + } else if (token === TokenType.DelimiterAssign) { + if (scanner.getTokenEnd() === offset && currentAttributeName) { + return { + name: currentAttributeName, + inValue: true + }; + } + } else if (token === TokenType.AttributeValue) { + if (inTokenRange() && currentAttributeName) { + return { + name: currentAttributeName, + inValue: true + }; + } + currentAttributeName = undefined; + } + token = scanner.scan(); + } + + return null; +} + +function inStartTag(offset: number, node: Node) { + return offset > node.start && node.startTagEnd != undefined && offset < node.startTagEnd; +} diff --git a/packages/language-server/src/plugins/css/CSSPlugin.ts b/packages/language-server/src/plugins/css/CSSPlugin.ts index 09f45266f..b1e3709a8 100644 --- a/packages/language-server/src/plugins/css/CSSPlugin.ts +++ b/packages/language-server/src/plugins/css/CSSPlugin.ts @@ -40,6 +40,8 @@ import { import { CSSDocument } from './CSSDocument'; import { getLanguage, getLanguageService } from './service'; import { GlobalVars } from './global-vars'; +import { getIdClassCompletion } from './features/getIdClassCompletion'; +import { getAttributeContextAtPosition } from '../../lib/documents/parseHtml'; export class CSSPlugin implements @@ -130,10 +132,10 @@ export class CSSPlugin ): CompletionList | null { const triggerCharacter = completionContext?.triggerCharacter; const triggerKind = completionContext?.triggerKind; - const isCustomTriggerCharater = triggerKind === CompletionTriggerKind.TriggerCharacter; + const isCustomTriggerCharacter = triggerKind === CompletionTriggerKind.TriggerCharacter; if ( - isCustomTriggerCharater && + isCustomTriggerCharacter && triggerCharacter && !this.triggerCharacters.includes(triggerCharacter) ) { @@ -146,7 +148,11 @@ export class CSSPlugin const cssDocument = this.getCSSDoc(document); if (!cssDocument.isInGenerated(position)) { - return null; + const attributeContext = getAttributeContextAtPosition(document, position); + if (!attributeContext) { + return null; + } + return getIdClassCompletion(cssDocument, attributeContext) ?? null; } if (isSASS(cssDocument)) { diff --git a/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts new file mode 100644 index 000000000..c10877e67 --- /dev/null +++ b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -0,0 +1,69 @@ +import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; +import { AttributeContext } from '../../../lib/documents/parseHtml'; +import { CSSDocument } from '../CSSDocument'; + +export function getIdClassCompletion( + cssDoc: CSSDocument, + attributeContext: AttributeContext +): CompletionList | undefined { + const collectingType = getCollectingType(attributeContext); + + if (!collectingType) { + return; + } + const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType); + + return CompletionList.create(items); +} + +function getCollectingType(attributeContext: AttributeContext): number | undefined { + if (attributeContext.inValue) { + if (attributeContext.name === 'class') { + return NodeType.ClassSelector; + } + if (attributeContext.name === 'id') { + return NodeType.IdentifierSelector; + } + } else if (attributeContext.name.startsWith('class:')) { + return NodeType.ClassSelector; + } +} + +/** + * incomplete see + * https://github.com/microsoft/vscode-css-languageservice/blob/master/src/parser/cssNodes.ts#L14 + * The enum is not exported. we have to update this whenever it changes + */ +export enum NodeType { + ClassSelector = 14, + IdentifierSelector = 15 +} + +export type CSSNode = { + type: number; + children: CSSNode[] | undefined; + getText(): string; +}; + +export function collectSelectors(stylesheet: CSSNode, type: number) { + const result: CSSNode[] = []; + walk(stylesheet, (node) => { + if (node.type === type) { + result.push(node); + } + }); + + return result.map( + (node): CompletionItem => ({ + label: node.getText().substring(1), + kind: CompletionItemKind.Keyword + }) + ); +} + +function walk(node: CSSNode, callback: (node: CSSNode) => void) { + callback(node); + if (node.children) { + node.children.forEach((node) => walk(node, callback)); + } +} diff --git a/packages/language-server/test/plugins/css/features/getIdClassCompletion.test.ts b/packages/language-server/test/plugins/css/features/getIdClassCompletion.test.ts new file mode 100644 index 000000000..6d3321246 --- /dev/null +++ b/packages/language-server/test/plugins/css/features/getIdClassCompletion.test.ts @@ -0,0 +1,78 @@ +import assert from 'assert'; +import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; +import { Document, DocumentManager } from '../../../../src/lib/documents'; +import { LSConfigManager } from '../../../../src/ls-config'; +import { CSSPlugin } from '../../../../src/plugins'; +import { CSSDocument } from '../../../../src/plugins/css/CSSDocument'; +import { + collectSelectors, + NodeType, + CSSNode +} from '../../../../src/plugins/css/features/getIdClassCompletion'; + +describe('getIdClassCompletion', () => { + function createDocument(content: string) { + return new Document('file:///hello.svelte', content); + } + + function createCSSDocument(content: string) { + return new CSSDocument(createDocument(content)); + } + + function testSelectors(items: CompletionItem[], expectedSelectors: string[]) { + assert.deepStrictEqual( + items.map((item) => item.label), + expectedSelectors, + 'vscode-language-services might have changed the NodeType enum. Check if we need to update it' + ); + } + + it('collect css classes', () => { + const actual = collectSelectors( + createCSSDocument('').stylesheet as CSSNode, + NodeType.ClassSelector + ); + testSelectors(actual, ['abc']); + }); + + it('collect css ids', () => { + const actual = collectSelectors( + createCSSDocument('').stylesheet as CSSNode, + NodeType.IdentifierSelector + ); + testSelectors(actual, ['abc']); + }); + + function setup(content: string) { + const document = createDocument(content); + const docManager = new DocumentManager(() => document); + const pluginManager = new LSConfigManager(); + const plugin = new CSSPlugin(docManager, pluginManager); + docManager.openDocument('some doc'); + return { plugin, document }; + } + + it('provides css classes completion for class attribute', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.getCompletions(document, { line: 0, character: 11 }), { + isIncomplete: false, + items: [{ label: 'abc', kind: CompletionItemKind.Keyword }] + } as CompletionList); + }); + + it('provides css classes completion for class directive', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.getCompletions(document, { line: 0, character: 11 }), { + isIncomplete: false, + items: [{ label: 'abc', kind: CompletionItemKind.Keyword }] + } as CompletionList); + }); + + it('provides css id completion for id attribute', () => { + const { plugin, document } = setup('
'); + assert.deepStrictEqual(plugin.getCompletions(document, { line: 0, character: 8 }), { + isIncomplete: false, + items: [{ label: 'abc', kind: CompletionItemKind.Keyword }] + } as CompletionList); + }); +});