diff options
Diffstat (limited to 'tools/language-server/src/plugins/css')
5 files changed, 400 insertions, 0 deletions
diff --git a/tools/language-server/src/plugins/css/CSSDocument.ts b/tools/language-server/src/plugins/css/CSSDocument.ts new file mode 100644 index 000000000..9f1839678 --- /dev/null +++ b/tools/language-server/src/plugins/css/CSSDocument.ts @@ -0,0 +1,95 @@ +import { Stylesheet, TextDocument } from 'vscode-css-languageservice'; +import { Position } from 'vscode-languageserver'; +import { getLanguageService } from './service'; +import { Document, DocumentMapper, ReadableDocument, TagInformation } from '../../core/documents/index'; + +export interface CSSDocumentBase extends DocumentMapper, TextDocument { + languageId: string; + stylesheet: Stylesheet; +} + +export class CSSDocument extends ReadableDocument implements DocumentMapper { + private styleInfo: Pick<TagInformation, 'attributes' | 'start' | 'end'>; + readonly version = this.parent.version; + + public stylesheet: Stylesheet; + public languageId: string; + + constructor(private parent: Document) { + super(); + + if (this.parent.styleInfo) { + this.styleInfo = this.parent.styleInfo; + } else { + this.styleInfo = { + attributes: {}, + start: -1, + end: -1, + }; + } + + this.languageId = this.language; + this.stylesheet = getLanguageService(this.language).parseStylesheet(this); + } + + /** + * Get the fragment position relative to the parent + * @param pos Position in fragment + */ + getOriginalPosition(pos: Position): Position { + const parentOffset = this.styleInfo.start + this.offsetAt(pos); + return this.parent.positionAt(parentOffset); + } + + /** + * Get the position relative to the start of the fragment + * @param pos Position in parent + */ + getGeneratedPosition(pos: Position): Position { + const fragmentOffset = this.parent.offsetAt(pos) - this.styleInfo.start; + return this.positionAt(fragmentOffset); + } + + /** + * Returns true if the given parent position is inside of this fragment + * @param pos Position in parent + */ + isInGenerated(pos: Position): boolean { + const offset = this.parent.offsetAt(pos); + return offset >= this.styleInfo.start && offset <= this.styleInfo.end; + } + + /** + * Get the fragment text from the parent + */ + getText(): string { + return this.parent.getText().slice(this.styleInfo.start, this.styleInfo.end); + } + + /** + * Returns the length of the fragment as calculated from the start and end positon + */ + getTextLength(): number { + return this.styleInfo.end - this.styleInfo.start; + } + + /** + * Return the parent file path + */ + getFilePath(): string | null { + return this.parent.getFilePath(); + } + + getURL() { + return this.parent.getURL(); + } + + getAttributes() { + return this.styleInfo.attributes; + } + + private get language() { + const attrs = this.getAttributes(); + return attrs.lang || attrs.type || 'css'; + } +} diff --git a/tools/language-server/src/plugins/css/CSSPlugin.ts b/tools/language-server/src/plugins/css/CSSPlugin.ts new file mode 100644 index 000000000..26c90ac66 --- /dev/null +++ b/tools/language-server/src/plugins/css/CSSPlugin.ts @@ -0,0 +1,118 @@ +import type { CompletionsProvider } from '../interfaces'; +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import { getEmmetCompletionParticipants, doComplete as doEmmetComplete } from 'vscode-emmet-helper'; +import { CompletionContext, CompletionList, CompletionTriggerKind, Position } from 'vscode-languageserver'; +import { isInsideFrontmatter } from '../../core/documents/utils'; +import { CSSDocument, CSSDocumentBase } from './CSSDocument'; +import { getLanguage, getLanguageService } from './service'; +import { StyleAttributeDocument } from './StyleAttributeDocument'; +import { mapCompletionItemToOriginal } from '../../core/documents'; +import { AttributeContext, getAttributeContextAtPosition } from '../../core/documents/parseHtml'; +import { getIdClassCompletion } from './features/getIdClassCompletion'; + +export class CSSPlugin implements CompletionsProvider { + private docManager: DocumentManager; + private configManager: ConfigManager; + private documents = new WeakMap<Document, CSSDocument>(); + private triggerCharacters = new Set(['.', ':', '-', '/']); + + constructor(docManager: DocumentManager, configManager: ConfigManager) { + this.docManager = docManager; + this.configManager = configManager; + + this.docManager.on('documentChange', (document) => { + this.documents.set(document, new CSSDocument(document)); + }); + } + + getCompletions(document: Document, position: Position, completionContext?: CompletionContext): CompletionList | null { + const triggerCharacter = completionContext?.triggerCharacter; + const triggerKind = completionContext?.triggerKind; + const isCustomTriggerCharacter = triggerKind === CompletionTriggerKind.TriggerCharacter; + + if (isCustomTriggerCharacter && triggerCharacter && !this.triggerCharacters.has(triggerCharacter)) { + return null; + } + + if (this.isInsideFrontmatter(document, position)) { + return null; + } + + const cssDocument = this.getCSSDoc(document); + + if (cssDocument.isInGenerated(position)) { + return this.getCompletionsInternal(document, position, cssDocument); + } + + const attributeContext = getAttributeContextAtPosition(document, position); + if (!attributeContext) { + return null; + } + + if (this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())) { + const [start, end] = attributeContext.valueRange; + return this.getCompletionsInternal(document, position, new StyleAttributeDocument(document, start, end)); + } else { + return getIdClassCompletion(cssDocument, attributeContext); + } + } + + private getCompletionsInternal(document: Document, position: Position, cssDocument: CSSDocumentBase) { + if (isSASS(cssDocument)) { + // the css language service does not support sass, still we can use + // the emmet helper directly to at least get emmet completions + return doEmmetComplete(document, position, 'sass', this.configManager.getEmmetConfig()); + } + + const type = extractLanguage(cssDocument); + + const lang = getLanguageService(type); + const emmetResults: CompletionList = { + isIncomplete: true, + items: [], + }; + if (false /* this.configManager.getConfig().css.completions.emmet */) { + lang.setCompletionParticipants([ + getEmmetCompletionParticipants(cssDocument, cssDocument.getGeneratedPosition(position), getLanguage(type), this.configManager.getEmmetConfig(), emmetResults), + ]); + } + const results = lang.doComplete(cssDocument, cssDocument.getGeneratedPosition(position), cssDocument.stylesheet); + return CompletionList.create( + [...(results ? results.items : []), ...emmetResults.items].map((completionItem) => mapCompletionItemToOriginal(cssDocument, completionItem)), + // Emmet completions change on every keystroke, so they are never complete + emmetResults.items.length > 0 + ); + } + + private inStyleAttributeWithoutInterpolation(attrContext: AttributeContext, text: string): attrContext is Required<AttributeContext> { + return attrContext.name === 'style' && !!attrContext.valueRange && !text.substring(attrContext.valueRange[0], attrContext.valueRange[1]).includes('{'); + } + + private getCSSDoc(document: Document) { + let cssDoc = this.documents.get(document); + if (!cssDoc || cssDoc.version < document.version) { + cssDoc = new CSSDocument(document); + this.documents.set(document, cssDoc); + } + return cssDoc; + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } +} + +function isSASS(document: CSSDocumentBase) { + switch (extractLanguage(document)) { + case 'sass': + return true; + default: + return false; + } +} + +function extractLanguage(document: CSSDocumentBase): string { + const lang = document.languageId; + return lang.replace(/^text\//, ''); +} diff --git a/tools/language-server/src/plugins/css/StyleAttributeDocument.ts b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts new file mode 100644 index 000000000..e00398037 --- /dev/null +++ b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts @@ -0,0 +1,72 @@ +import { Stylesheet } from 'vscode-css-languageservice'; +import { Position } from 'vscode-languageserver'; +import { getLanguageService } from './service'; +import { Document, DocumentMapper, ReadableDocument } from '../../core/documents'; + +const PREFIX = '__ {'; +const SUFFIX = '}'; + +export class StyleAttributeDocument extends ReadableDocument implements DocumentMapper { + readonly version = this.parent.version; + + public stylesheet: Stylesheet; + public languageId = 'css'; + + constructor(private readonly parent: Document, private readonly attrStart: number, private readonly attrEnd: number) { + super(); + + this.stylesheet = getLanguageService(this.languageId).parseStylesheet(this); + } + + /** + * Get the fragment position relative to the parent + * @param pos Position in fragment + */ + getOriginalPosition(pos: Position): Position { + const parentOffset = this.attrStart + this.offsetAt(pos) - PREFIX.length; + return this.parent.positionAt(parentOffset); + } + + /** + * Get the position relative to the start of the fragment + * @param pos Position in parent + */ + getGeneratedPosition(pos: Position): Position { + const fragmentOffset = this.parent.offsetAt(pos) - this.attrStart + PREFIX.length; + return this.positionAt(fragmentOffset); + } + + /** + * Returns true if the given parent position is inside of this fragment + * @param pos Position in parent + */ + isInGenerated(pos: Position): boolean { + const offset = this.parent.offsetAt(pos); + return offset >= this.attrStart && offset <= this.attrEnd; + } + + /** + * Get the fragment text from the parent + */ + getText(): string { + return PREFIX + this.parent.getText().slice(this.attrStart, this.attrEnd) + SUFFIX; + } + + /** + * Returns the length of the fragment as calculated from the start and end position + */ + getTextLength(): number { + return PREFIX.length + this.attrEnd - this.attrStart + SUFFIX.length; + } + + /** + * Return the parent file path + */ + getFilePath(): string | null { + return this.parent.getFilePath(); + } + + getURL() { + return this.parent.getURL(); + } +} diff --git a/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts new file mode 100644 index 000000000..45acb5ad6 --- /dev/null +++ b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -0,0 +1,67 @@ +import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; +import { AttributeContext } from '../../../core/documents/parseHtml'; +import { CSSDocument } from '../CSSDocument'; + +export function getIdClassCompletion(cssDoc: CSSDocument, attributeContext: AttributeContext): CompletionList | null { + const collectingType = getCollectingType(attributeContext); + + if (!collectingType) { + return null; + } + const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType); + + console.log('getIdClassCompletion items', items.length); + 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/tools/language-server/src/plugins/css/service.ts b/tools/language-server/src/plugins/css/service.ts new file mode 100644 index 000000000..78b11296e --- /dev/null +++ b/tools/language-server/src/plugins/css/service.ts @@ -0,0 +1,48 @@ +import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageService, ICSSDataProvider } from 'vscode-css-languageservice'; + +const customDataProvider: ICSSDataProvider = { + providePseudoClasses() { + return []; + }, + provideProperties() { + return []; + }, + provideAtDirectives() { + return []; + }, + providePseudoElements() { + return []; + }, +}; + +const [css, scss, less] = [getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService].map((getService) => + getService({ + customDataProviders: [customDataProvider], + }) +); + +const langs = { + css, + scss, + less, +}; + +export function getLanguage(kind?: string) { + switch (kind) { + case 'scss': + case 'text/scss': + return 'scss' as const; + case 'less': + case 'text/less': + return 'less' as const; + case 'css': + case 'text/css': + default: + return 'css' as const; + } +} + +export function getLanguageService(kind?: string): LanguageService { + const lang = getLanguage(kind); + return langs[lang]; +} |