Skip to content

Commit

Permalink
(feat) completion for ids/classes in the template (#844)
Browse files Browse the repository at this point in the history
Scan the Stylesheet for class and id names and provide autocompletion. Works for Less/SCSS/CSS.
  • Loading branch information
jasonlyu123 authored Mar 2, 2021
1 parent cbcbea3 commit 214d105
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 4 deletions.
68 changes: 67 additions & 1 deletion packages/language-server/src/lib/documents/parseHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
12 changes: 9 additions & 3 deletions packages/language-server/src/plugins/css/CSSPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
) {
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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('<style>.abc {}</style>').stylesheet as CSSNode,
NodeType.ClassSelector
);
testSelectors(actual, ['abc']);
});

it('collect css ids', () => {
const actual = collectSelectors(
createCSSDocument('<style>#abc {}</style>').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(<any>'some doc');
return { plugin, document };
}

it('provides css classes completion for class attribute', () => {
const { plugin, document } = setup('<div class=></div><style>.abc{}</style>');
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('<div class:></div><style>.abc{}</style>');
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('<div id=></div><style>#abc{}</style>');
assert.deepStrictEqual(plugin.getCompletions(document, { line: 0, character: 8 }), {
isIncomplete: false,
items: [{ label: 'abc', kind: CompletionItemKind.Keyword }]
} as CompletionList);
});
});

0 comments on commit 214d105

Please sign in to comment.