From c3c96bf498f9f989825ada7110be7bc680adac53 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 17 May 2021 14:27:24 -0400 Subject: [PATCH] Adds CSS completions to VSCode extension (#214) * Start on css completion * Support for CSS completions --- tools/astro-languageserver/package.json | 2 + .../src/core/documents/Document.ts | 7 + .../src/core/documents/DocumentBase.ts | 144 +++++++ .../src/core/documents/DocumentMapper.ts | 377 ++++++++++++++++++ .../src/core/documents/index.ts | 3 + .../src/core/documents/utils.ts | 130 +++++- tools/astro-languageserver/src/index.ts | 3 +- .../src/plugins/css/CSSDocument.ts | 95 +++++ .../src/plugins/css/CSSPlugin.ts | 153 +++++++ .../src/plugins/css/StyleAttributeDocument.ts | 76 ++++ .../css/features/getIdClassCompletion.ts | 70 ++++ .../src/plugins/css/service.ts | 58 +++ .../astro-languageserver/src/plugins/index.ts | 1 + yarn.lock | 21 +- 14 files changed, 1135 insertions(+), 5 deletions(-) create mode 100644 tools/astro-languageserver/src/core/documents/DocumentBase.ts create mode 100644 tools/astro-languageserver/src/core/documents/DocumentMapper.ts create mode 100644 tools/astro-languageserver/src/plugins/css/CSSDocument.ts create mode 100644 tools/astro-languageserver/src/plugins/css/CSSPlugin.ts create mode 100644 tools/astro-languageserver/src/plugins/css/StyleAttributeDocument.ts create mode 100644 tools/astro-languageserver/src/plugins/css/features/getIdClassCompletion.ts create mode 100644 tools/astro-languageserver/src/plugins/css/service.ts diff --git a/tools/astro-languageserver/package.json b/tools/astro-languageserver/package.json index ada41fba4..beb8dd9b7 100644 --- a/tools/astro-languageserver/package.json +++ b/tools/astro-languageserver/package.json @@ -19,7 +19,9 @@ "astro-scripts": "0.0.1" }, "dependencies": { + "source-map": "^0.7.3", "typescript": "^4.3.1-rc", + "vscode-css-languageservice": "^5.1.1", "vscode-emmet-helper": "2.1.2", "vscode-html-languageservice": "^3.0.3", "vscode-languageserver": "6.1.1", diff --git a/tools/astro-languageserver/src/core/documents/Document.ts b/tools/astro-languageserver/src/core/documents/Document.ts index 93217e891..5ee080fdf 100644 --- a/tools/astro-languageserver/src/core/documents/Document.ts +++ b/tools/astro-languageserver/src/core/documents/Document.ts @@ -1,3 +1,4 @@ +import type { TagInformation } from './utils'; import { Position, Range } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { HTMLDocument } from 'vscode-html-languageservice'; @@ -5,6 +6,7 @@ import { HTMLDocument } from 'vscode-html-languageservice'; import { clamp, urlToPath } from '../../utils'; import { parseHtml } from './parseHtml'; import { parseAstro, AstroDocument } from './parseAstro'; +import { extractStyleTag } from './utils'; export class Document implements TextDocument { private content: string; @@ -13,6 +15,7 @@ export class Document implements TextDocument { version = 0; html!: HTMLDocument; astro!: AstroDocument; + styleInfo: TagInformation | null = null; constructor(public uri: string, text: string) { this.content = text; @@ -22,6 +25,10 @@ export class Document implements TextDocument { private updateDocInfo() { this.html = parseHtml(this.content); this.astro = parseAstro(this.content); + this.styleInfo = extractStyleTag(this.content, this.html); + if(this.styleInfo) { + this.styleInfo.attributes.lang = 'css'; + } } setText(text: string) { diff --git a/tools/astro-languageserver/src/core/documents/DocumentBase.ts b/tools/astro-languageserver/src/core/documents/DocumentBase.ts new file mode 100644 index 000000000..19120f1c0 --- /dev/null +++ b/tools/astro-languageserver/src/core/documents/DocumentBase.ts @@ -0,0 +1,144 @@ +import { clamp } from '../../utils'; +import { Position, TextDocument } from 'vscode-languageserver'; + +/** + * Represents a textual document. + */ +export abstract class ReadableDocument implements TextDocument { + /** + * Get the text content of the document + */ + abstract getText(): string; + + /** + * Returns the url of the document + */ + abstract getURL(): string; + + /** + * Returns the file path if the url scheme is file + */ + abstract getFilePath(): string | null; + + /** + * Current version of the document. + */ + public version = 0; + + /** + * Get the length of the document's content + */ + getTextLength(): number { + return this.getText().length; + } + + /** + * Get the line and character based on the offset + * @param offset The index of the position + */ + positionAt(offset: number): Position { + offset = clamp(offset, 0, this.getTextLength()); + + const lineOffsets = this.getLineOffsets(); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); + } + + /** + * Get the index of the line and character position + * @param position Line and character position + */ + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + + if (position.line >= lineOffsets.length) { + return this.getTextLength(); + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = + position.line + 1 < lineOffsets.length + ? lineOffsets[position.line + 1] + : this.getTextLength(); + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); + } + + private getLineOffsets() { + const lineOffsets = []; + const text = this.getText(); + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; + } + + /** + * Implements TextDocument + */ + get uri(): string { + return this.getURL(); + } + + get lineCount(): number { + return this.getText().split(/\r?\n/).length; + } + + abstract languageId: string; +} + +/** + * Represents a textual document that can be manipulated. + */ +export abstract class WritableDocument extends ReadableDocument { + /** + * Set the text content of the document + * @param text The new text content + */ + abstract setText(text: string): void; + + /** + * Update the text between two positions. + * @param text The new text slice + * @param start Start offset of the new text + * @param end End offset of the new text + */ + update(text: string, start: number, end: number): void { + const content = this.getText(); + this.setText(content.slice(0, start) + text + content.slice(end)); + } +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/core/documents/DocumentMapper.ts b/tools/astro-languageserver/src/core/documents/DocumentMapper.ts new file mode 100644 index 000000000..815ce06ff --- /dev/null +++ b/tools/astro-languageserver/src/core/documents/DocumentMapper.ts @@ -0,0 +1,377 @@ +import { + Position, + Range, + CompletionItem, + Hover, + Diagnostic, + ColorPresentation, + SymbolInformation, + LocationLink, + TextDocumentEdit, + CodeAction, + SelectionRange, + TextEdit, + InsertReplaceEdit +} from 'vscode-languageserver'; +import { TagInformation, offsetAt, positionAt } from './utils'; +import { SourceMapConsumer } from 'source-map'; + +export interface DocumentMapper { + /** + * Map the generated position to the original position + * @param generatedPosition Position in fragment + */ + getOriginalPosition(generatedPosition: Position): Position; + + /** + * Map the original position to the generated position + * @param originalPosition Position in parent + */ + getGeneratedPosition(originalPosition: Position): Position; + + /** + * Returns true if the given original position is inside of the generated map + * @param pos Position in original + */ + isInGenerated(pos: Position): boolean; + + /** + * Get document URL + */ + getURL(): string; + + /** + * Implement this if you need teardown logic before this mapper gets cleaned up. + */ + destroy?(): void; +} + +/** +* Does not map, returns positions as is. +*/ +export class IdentityMapper implements DocumentMapper { + constructor(private url: string, private parent?: DocumentMapper) {} + + getOriginalPosition(generatedPosition: Position): Position { + if (this.parent) { + generatedPosition = this.getOriginalPosition(generatedPosition); + } + + return generatedPosition; + } + + getGeneratedPosition(originalPosition: Position): Position { + if (this.parent) { + originalPosition = this.getGeneratedPosition(originalPosition); + } + + return originalPosition; + } + + isInGenerated(position: Position): boolean { + if (this.parent && !this.parent.isInGenerated(position)) { + return false; + } + + return true; + } + + getURL(): string { + return this.url; + } + + destroy() { + this.parent?.destroy?.(); + } +} + +/** +* Maps positions in a fragment relative to a parent. +*/ +export class FragmentMapper implements DocumentMapper { + constructor( + private originalText: string, + private tagInfo: TagInformation, + private url: string + ) {} + + getOriginalPosition(generatedPosition: Position): Position { + const parentOffset = this.offsetInParent(offsetAt(generatedPosition, this.tagInfo.content)); + return positionAt(parentOffset, this.originalText); + } + + private offsetInParent(offset: number): number { + return this.tagInfo.start + offset; + } + + getGeneratedPosition(originalPosition: Position): Position { + const fragmentOffset = offsetAt(originalPosition, this.originalText) - this.tagInfo.start; + return positionAt(fragmentOffset, this.tagInfo.content); + } + + isInGenerated(pos: Position): boolean { + const offset = offsetAt(pos, this.originalText); + return offset >= this.tagInfo.start && offset <= this.tagInfo.end; + } + + getURL(): string { + return this.url; + } +} + +export class SourceMapDocumentMapper implements DocumentMapper { + constructor( + protected consumer: SourceMapConsumer, + protected sourceUri: string, + private parent?: DocumentMapper + ) {} + + getOriginalPosition(generatedPosition: Position): Position { + if (this.parent) { + generatedPosition = this.parent.getOriginalPosition(generatedPosition); + } + + if (generatedPosition.line < 0) { + return { line: -1, character: -1 }; + } + + const mapped = this.consumer.originalPositionFor({ + line: generatedPosition.line + 1, + column: generatedPosition.character + }); + + if (!mapped) { + return { line: -1, character: -1 }; + } + + if (mapped.line === 0) { + console.log('Got 0 mapped line from', generatedPosition, 'col was', mapped.column); + } + + return { + line: (mapped.line || 0) - 1, + character: mapped.column || 0 + }; + } + + getGeneratedPosition(originalPosition: Position): Position { + if (this.parent) { + originalPosition = this.parent.getGeneratedPosition(originalPosition); + } + + const mapped = this.consumer.generatedPositionFor({ + line: originalPosition.line + 1, + column: originalPosition.character, + source: this.sourceUri + }); + + if (!mapped) { + return { line: -1, character: -1 }; + } + + const result = { + line: (mapped.line || 0) - 1, + character: mapped.column || 0 + }; + + if (result.line < 0) { + return result; + } + + return result; + } + + isInGenerated(position: Position): boolean { + if (this.parent && !this.isInGenerated(position)) { + return false; + } + + const generated = this.getGeneratedPosition(position); + return generated.line >= 0; + } + + getURL(): string { + return this.sourceUri; + } + + /** + * Needs to be called when source mapper is no longer needed in order to prevent memory leaks. + */ + destroy() { + this.parent?.destroy?.(); + this.consumer.destroy(); + } +} + +export function mapRangeToOriginal( + fragment: Pick, + range: Range +): Range { + // DON'T use Range.create here! Positions might not be mapped + // and therefore return negative numbers, which makes Range.create throw. + // These invalid position need to be handled + // on a case-by-case basis in the calling functions. + const originalRange = { + start: fragment.getOriginalPosition(range.start), + end: fragment.getOriginalPosition(range.end) + }; + + // Range may be mapped one character short - reverse that for "in the same line" cases + if ( + originalRange.start.line === originalRange.end.line && + range.start.line === range.end.line && + originalRange.end.character - originalRange.start.character === + range.end.character - range.start.character - 1 + ) { + originalRange.end.character += 1; + } + + return originalRange; +} + +export function mapRangeToGenerated(fragment: DocumentMapper, range: Range): Range { + return Range.create( + fragment.getGeneratedPosition(range.start), + fragment.getGeneratedPosition(range.end) + ); +} + +export function mapCompletionItemToOriginal( + fragment: Pick, + item: CompletionItem +): CompletionItem { + if (!item.textEdit) { + return item; + } + + return { + ...item, + textEdit: mapEditToOriginal(fragment, item.textEdit) + }; +} + +export function mapHoverToParent( + fragment: Pick, + hover: Hover +): Hover { + if (!hover.range) { + return hover; + } + + return { ...hover, range: mapRangeToOriginal(fragment, hover.range) }; +} + +export function mapObjWithRangeToOriginal( + fragment: Pick, + objWithRange: T +): T { + return { ...objWithRange, range: mapRangeToOriginal(fragment, objWithRange.range) }; +} + +export function mapInsertReplaceEditToOriginal( + fragment: Pick, + edit: InsertReplaceEdit +): InsertReplaceEdit { + return { + ...edit, + insert: mapRangeToOriginal(fragment, edit.insert), + replace: mapRangeToOriginal(fragment, edit.replace) + }; +} + +export function mapEditToOriginal( + fragment: Pick, + edit: TextEdit | InsertReplaceEdit +): TextEdit | InsertReplaceEdit { + return TextEdit.is(edit) + ? mapObjWithRangeToOriginal(fragment, edit) + : mapInsertReplaceEditToOriginal(fragment, edit); +} + +export function mapDiagnosticToGenerated( + fragment: DocumentMapper, + diagnostic: Diagnostic +): Diagnostic { + return { ...diagnostic, range: mapRangeToGenerated(fragment, diagnostic.range) }; +} + +export function mapColorPresentationToOriginal( + fragment: Pick, + presentation: ColorPresentation +): ColorPresentation { + const item = { + ...presentation + }; + + if (item.textEdit) { + item.textEdit = mapObjWithRangeToOriginal(fragment, item.textEdit); + } + + if (item.additionalTextEdits) { + item.additionalTextEdits = item.additionalTextEdits.map((edit) => + mapObjWithRangeToOriginal(fragment, edit) + ); + } + + return item; +} + +export function mapSymbolInformationToOriginal( + fragment: Pick, + info: SymbolInformation +): SymbolInformation { + return { ...info, location: mapObjWithRangeToOriginal(fragment, info.location) }; +} + +export function mapLocationLinkToOriginal( + fragment: DocumentMapper, + def: LocationLink +): LocationLink { + return LocationLink.create( + def.targetUri, + fragment.getURL() === def.targetUri + ? mapRangeToOriginal(fragment, def.targetRange) + : def.targetRange, + fragment.getURL() === def.targetUri + ? mapRangeToOriginal(fragment, def.targetSelectionRange) + : def.targetSelectionRange, + def.originSelectionRange + ? mapRangeToOriginal(fragment, def.originSelectionRange) + : undefined + ); +} + +export function mapTextDocumentEditToOriginal(fragment: DocumentMapper, edit: TextDocumentEdit) { + if (edit.textDocument.uri !== fragment.getURL()) { + return edit; + } + + return TextDocumentEdit.create( + edit.textDocument, + edit.edits.map((textEdit) => mapObjWithRangeToOriginal(fragment, textEdit)) + ); +} + +export function mapCodeActionToOriginal(fragment: DocumentMapper, codeAction: CodeAction) { + return CodeAction.create( + codeAction.title, + { + documentChanges: codeAction.edit!.documentChanges!.map((edit) => + mapTextDocumentEditToOriginal(fragment, edit as TextDocumentEdit) + ) + }, + codeAction.kind + ); +} + +export function mapSelectionRangeToParent( + fragment: Pick, + selectionRange: SelectionRange +): SelectionRange { + const { range, parent } = selectionRange; + + return SelectionRange.create( + mapRangeToOriginal(fragment, range), + parent && mapSelectionRangeToParent(fragment, parent) + ); +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/core/documents/index.ts b/tools/astro-languageserver/src/core/documents/index.ts index 708a040c9..496107f3c 100644 --- a/tools/astro-languageserver/src/core/documents/index.ts +++ b/tools/astro-languageserver/src/core/documents/index.ts @@ -1,2 +1,5 @@ export * from './Document'; +export * from './DocumentBase'; export * from './DocumentManager'; +export * from './DocumentMapper'; +export * from './utils'; \ No newline at end of file diff --git a/tools/astro-languageserver/src/core/documents/utils.ts b/tools/astro-languageserver/src/core/documents/utils.ts index 3d12f35a3..220994f4c 100644 --- a/tools/astro-languageserver/src/core/documents/utils.ts +++ b/tools/astro-languageserver/src/core/documents/utils.ts @@ -1,5 +1,41 @@ -import { Position } from 'vscode-html-languageservice'; +import { HTMLDocument, Node, Position } from 'vscode-html-languageservice'; import { clamp } from '../../utils'; +import {parseHtml} from './parseHtml'; + +export interface TagInformation { + content: string; + attributes: Record; + start: number; + end: number; + startPos: Position; + endPos: Position; + container: { start: number; end: number }; +} + +function parseAttributes( + rawAttrs: Record | undefined +): Record { + const attrs: Record = {}; + if (!rawAttrs) { + return attrs; + } + + Object.keys(rawAttrs).forEach((attrName) => { + const attrValue = rawAttrs[attrName]; + attrs[attrName] = attrValue === null ? attrName : removeOuterQuotes(attrValue); + }); + return attrs; + + function removeOuterQuotes(attrValue: string) { + if ( + (attrValue.startsWith('"') && attrValue.endsWith('"')) || + (attrValue.startsWith("'") && attrValue.endsWith("'")) + ) { + return attrValue.slice(1, attrValue.length - 1); + } + return attrValue; + } +} /** * Gets word range at position. @@ -125,3 +161,95 @@ function getLineOffsets(text: string) { return lineOffsets; } + +export function* walk(node: Node): Generator { + for(let child of node.children) { + yield * walk(child); + } + yield node; +} + +/* +export function* walk(node: Node, startIndex = 0) { + let skip, tmp; + let depth = 0; + let index = startIndex; + + // Always start with the initial element. + do { + if ( !skip && (tmp = node.firstChild) ) { + depth++; + callback('child', node, tmp, index); + index++; + } else if ( tmp = node.nextSibling ) { + skip = false; + callback('sibling', node, tmp, index); + index++; + } else { + tmp = node.parentNode; + depth--; + skip = true; + } + node = tmp; + } while ( depth > 0 ); +}; +*/ + +/** + * Extracts a tag (style or script) from the given text + * and returns its start, end and the attributes on that tag. + * + * @param source text content to extract tag from + * @param tag the tag to extract + */ + function extractTags( + text: string, + tag: 'script' | 'style' | 'template', + html?: HTMLDocument +): TagInformation[] { + const rootNodes = html?.roots || parseHtml(text).roots; + const matchedNodes = rootNodes + .filter((node) => node.tag === tag); + + if(tag === 'style' && !matchedNodes.length && rootNodes.length && rootNodes[0].tag === 'html') { + for(let child of walk(rootNodes[0])) { + if(child.tag === 'style') { + matchedNodes.push(child); + } + } + } + + return matchedNodes.map(transformToTagInfo); + + function transformToTagInfo(matchedNode: Node) { + const start = matchedNode.startTagEnd ?? matchedNode.start; + const end = matchedNode.endTagStart ?? matchedNode.end; + const startPos = positionAt(start, text); + const endPos = positionAt(end, text); + const container = { + start: matchedNode.start, + end: matchedNode.end + }; + const content = text.substring(start, end); + + return { + content, + attributes: parseAttributes(matchedNode.attributes), + start, + end, + startPos, + endPos, + container + }; + } +} + +export function extractStyleTag(source: string, html?: HTMLDocument): TagInformation | null { + const styles = extractTags(source, 'style', html); + if (!styles.length) { + return null; + } + + // There can only be one style tag + return styles[0]; +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/index.ts b/tools/astro-languageserver/src/index.ts index bc25f9475..7c4417d69 100644 --- a/tools/astro-languageserver/src/index.ts +++ b/tools/astro-languageserver/src/index.ts @@ -1,7 +1,7 @@ import { RequestType, TextDocumentPositionParams, createConnection, ProposedFeatures, TextDocumentSyncKind, TextDocumentIdentifier } from 'vscode-languageserver'; import { Document, DocumentManager } from './core/documents'; import { ConfigManager } from './core/config'; -import { PluginHost, HTMLPlugin, TypeScriptPlugin, AppCompletionItem, AstroPlugin } from './plugins'; +import { PluginHost, CSSPlugin, HTMLPlugin, TypeScriptPlugin, AppCompletionItem, AstroPlugin } from './plugins'; import { urlToPath } from './utils'; const TagCloseRequest: RequestType = new RequestType('html/tag'); @@ -21,6 +21,7 @@ export function startServer() { pluginHost.register(new AstroPlugin(docManager, configManager)); pluginHost.register(new HTMLPlugin(docManager, configManager)); + pluginHost.register(new CSSPlugin(docManager, configManager)); pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris)); configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {}); diff --git a/tools/astro-languageserver/src/plugins/css/CSSDocument.ts b/tools/astro-languageserver/src/plugins/css/CSSDocument.ts new file mode 100644 index 000000000..90b28352c --- /dev/null +++ b/tools/astro-languageserver/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; + 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/astro-languageserver/src/plugins/css/CSSPlugin.ts b/tools/astro-languageserver/src/plugins/css/CSSPlugin.ts new file mode 100644 index 000000000..4c0dcb949 --- /dev/null +++ b/tools/astro-languageserver/src/plugins/css/CSSPlugin.ts @@ -0,0 +1,153 @@ +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(); + 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 { + 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\//, ''); +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/plugins/css/StyleAttributeDocument.ts b/tools/astro-languageserver/src/plugins/css/StyleAttributeDocument.ts new file mode 100644 index 000000000..7b49d771d --- /dev/null +++ b/tools/astro-languageserver/src/plugins/css/StyleAttributeDocument.ts @@ -0,0 +1,76 @@ +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(); + } +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/plugins/css/features/getIdClassCompletion.ts b/tools/astro-languageserver/src/plugins/css/features/getIdClassCompletion.ts new file mode 100644 index 000000000..368359ac9 --- /dev/null +++ b/tools/astro-languageserver/src/plugins/css/features/getIdClassCompletion.ts @@ -0,0 +1,70 @@ +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)); + } +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/plugins/css/service.ts b/tools/astro-languageserver/src/plugins/css/service.ts new file mode 100644 index 000000000..e8ac86a65 --- /dev/null +++ b/tools/astro-languageserver/src/plugins/css/service.ts @@ -0,0 +1,58 @@ +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]; +} \ No newline at end of file diff --git a/tools/astro-languageserver/src/plugins/index.ts b/tools/astro-languageserver/src/plugins/index.ts index c1b8a4062..368b339cb 100644 --- a/tools/astro-languageserver/src/plugins/index.ts +++ b/tools/astro-languageserver/src/plugins/index.ts @@ -3,3 +3,4 @@ export * from './astro/AstroPlugin'; export * from './html/HTMLPlugin'; export * from './typescript/TypeScriptPlugin'; export * from './interfaces'; +export * from './css/CSSPlugin'; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4b4829489..d4871061f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10538,9 +10538,9 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0: resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@~0.7.2: +source-map@^0.7.3, source-map@~0.7.2: version "0.7.3" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== sourcemap-codec@^1.4.4: @@ -12075,6 +12075,16 @@ void-elements@^2.0.1: resolved "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= +vscode-css-languageservice@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-5.1.1.tgz#d68a22ea0b34a8356c169cafc7d32564c2ff6e87" + integrity sha512-QW0oe/g2y5E2AbVqY7FJNr2Q8uHiAHNSFpptI6xB8Y0KgzVKppOcIVrgmBNzXhFp9IswAwptkdqr8ExSJbqPkQ== + dependencies: + vscode-languageserver-textdocument "^1.0.1" + vscode-languageserver-types "^3.16.0" + vscode-nls "^5.0.0" + vscode-uri "^3.0.2" + vscode-emmet-helper@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/vscode-emmet-helper/-/vscode-emmet-helper-2.1.2.tgz" @@ -12124,7 +12134,7 @@ vscode-languageserver-textdocument@^1.0.1: resolved "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz" integrity sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA== -vscode-languageserver-types@3.16.0, vscode-languageserver-types@^3.15.1: +vscode-languageserver-types@3.16.0, vscode-languageserver-types@^3.15.1, vscode-languageserver-types@^3.16.0: version "3.16.0" resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz" integrity sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA== @@ -12151,6 +12161,11 @@ vscode-uri@^2.1.2: resolved "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz" integrity sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A== +vscode-uri@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.2.tgz#ecfd1d066cb8ef4c3a208decdbab9a8c23d055d0" + integrity sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA== + vue@^3.0.10: version "3.0.11" resolved "https://registry.npmjs.org/vue/-/vue-3.0.11.tgz"