Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

completion for ids/classes in the template #844

Merged
merged 2 commits into from
Mar 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});