From 70c0eb86d2601c0c961963f792ab3d5b2e37c873 Mon Sep 17 00:00:00 2001 From: Lyu Jason Date: Wed, 24 Feb 2021 11:52:16 +0800 Subject: [PATCH 1/2] completion for ids/classes in template that are present in css --- .../src/lib/documents/parseHtml.ts | 68 +++++++++++++++- .../src/plugins/css/CSSPlugin.ts | 12 ++- .../css/features/getIdClassCompletion.ts | 69 ++++++++++++++++ .../css/features/getIdClassCompletion.test.ts | 78 +++++++++++++++++++ 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 packages/language-server/src/plugins/css/features/getIdClassCompletion.ts create mode 100644 packages/language-server/test/plugins/css/features/getIdClassCompletion.test.ts diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts index 94b8810e3..955775afe 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 AttributeInfo { + name: string; + inValue: boolean; +} + +export function foundCurrentAttributeInfo( + document: Document, + position: Position +): AttributeInfo | 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..f30ccc1bd 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 { foundCurrentAttributeInfo } 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 currentAttributeInfo = foundCurrentAttributeInfo(document, position); + if (!currentAttributeInfo) { + return null; + } + return getIdClassCompletion(cssDocument, currentAttributeInfo) ?? 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..d211b9926 --- /dev/null +++ b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -0,0 +1,69 @@ +import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; +import { AttributeInfo } from '../../../lib/documents/parseHtml'; +import { CSSDocument } from '../CSSDocument'; + +export function getIdClassCompletion( + cssDoc: CSSDocument, + currentAttrInfo: AttributeInfo +): CompletionList | undefined { + const collectingType = getCollectingType(currentAttrInfo); + + if (!collectingType) { + return; + } + const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType); + + return CompletionList.create(items); +} + +function getCollectingType(currentAttrInfo: AttributeInfo): number | undefined { + if (currentAttrInfo.inValue) { + if (currentAttrInfo.name === 'class') { + return NodeType.ClassSelector; + } + if (currentAttrInfo.name === 'id') { + return NodeType.IdentifierSelector; + } + } else if (currentAttrInfo.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); + }); +}); From 8438ca434c94034a32d88e2927a51303a1ed47b3 Mon Sep 17 00:00:00 2001 From: Lyu Jason Date: Thu, 25 Feb 2021 15:14:30 +0800 Subject: [PATCH 2/2] rename to AttributeContext --- .../src/lib/documents/parseHtml.ts | 6 +++--- .../language-server/src/plugins/css/CSSPlugin.ts | 8 ++++---- .../plugins/css/features/getIdClassCompletion.ts | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts index 955775afe..6441b8fc0 100644 --- a/packages/language-server/src/lib/documents/parseHtml.ts +++ b/packages/language-server/src/lib/documents/parseHtml.ts @@ -87,15 +87,15 @@ function preprocess(text: string) { } } -export interface AttributeInfo { +export interface AttributeContext { name: string; inValue: boolean; } -export function foundCurrentAttributeInfo( +export function getAttributeContextAtPosition( document: Document, position: Position -): AttributeInfo | null { +): AttributeContext | null { const offset = document.offsetAt(position); const { html } = document; const tag = html.findNodeAt(offset); diff --git a/packages/language-server/src/plugins/css/CSSPlugin.ts b/packages/language-server/src/plugins/css/CSSPlugin.ts index f30ccc1bd..b1e3709a8 100644 --- a/packages/language-server/src/plugins/css/CSSPlugin.ts +++ b/packages/language-server/src/plugins/css/CSSPlugin.ts @@ -41,7 +41,7 @@ import { CSSDocument } from './CSSDocument'; import { getLanguage, getLanguageService } from './service'; import { GlobalVars } from './global-vars'; import { getIdClassCompletion } from './features/getIdClassCompletion'; -import { foundCurrentAttributeInfo } from '../../lib/documents/parseHtml'; +import { getAttributeContextAtPosition } from '../../lib/documents/parseHtml'; export class CSSPlugin implements @@ -148,11 +148,11 @@ export class CSSPlugin const cssDocument = this.getCSSDoc(document); if (!cssDocument.isInGenerated(position)) { - const currentAttributeInfo = foundCurrentAttributeInfo(document, position); - if (!currentAttributeInfo) { + const attributeContext = getAttributeContextAtPosition(document, position); + if (!attributeContext) { return null; } - return getIdClassCompletion(cssDocument, currentAttributeInfo) ?? 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 index d211b9926..c10877e67 100644 --- a/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts +++ b/packages/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -1,12 +1,12 @@ import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; -import { AttributeInfo } from '../../../lib/documents/parseHtml'; +import { AttributeContext } from '../../../lib/documents/parseHtml'; import { CSSDocument } from '../CSSDocument'; export function getIdClassCompletion( cssDoc: CSSDocument, - currentAttrInfo: AttributeInfo + attributeContext: AttributeContext ): CompletionList | undefined { - const collectingType = getCollectingType(currentAttrInfo); + const collectingType = getCollectingType(attributeContext); if (!collectingType) { return; @@ -16,15 +16,15 @@ export function getIdClassCompletion( return CompletionList.create(items); } -function getCollectingType(currentAttrInfo: AttributeInfo): number | undefined { - if (currentAttrInfo.inValue) { - if (currentAttrInfo.name === 'class') { +function getCollectingType(attributeContext: AttributeContext): number | undefined { + if (attributeContext.inValue) { + if (attributeContext.name === 'class') { return NodeType.ClassSelector; } - if (currentAttrInfo.name === 'id') { + if (attributeContext.name === 'id') { return NodeType.IdentifierSelector; } - } else if (currentAttrInfo.name.startsWith('class:')) { + } else if (attributeContext.name.startsWith('class:')) { return NodeType.ClassSelector; } }