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);
+ });
+});