summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@matthewphillips.info> 2021-05-17 14:27:24 -0400
committerGravatar GitHub <noreply@github.com> 2021-05-17 14:27:24 -0400
commitc3c96bf498f9f989825ada7110be7bc680adac53 (patch)
tree7b5ad7597697c771d7bd917504706a721cbe026b
parent27a7986a384263fab81695e1f9b16eb2f12caa2c (diff)
downloadastro-c3c96bf498f9f989825ada7110be7bc680adac53.tar.gz
astro-c3c96bf498f9f989825ada7110be7bc680adac53.tar.zst
astro-c3c96bf498f9f989825ada7110be7bc680adac53.zip
Adds CSS completions to VSCode extension (#214)
* Start on css completion * Support for CSS completions
-rw-r--r--tools/astro-languageserver/package.json2
-rw-r--r--tools/astro-languageserver/src/core/documents/Document.ts7
-rw-r--r--tools/astro-languageserver/src/core/documents/DocumentBase.ts144
-rw-r--r--tools/astro-languageserver/src/core/documents/DocumentMapper.ts377
-rw-r--r--tools/astro-languageserver/src/core/documents/index.ts3
-rw-r--r--tools/astro-languageserver/src/core/documents/utils.ts130
-rw-r--r--tools/astro-languageserver/src/index.ts3
-rw-r--r--tools/astro-languageserver/src/plugins/css/CSSDocument.ts95
-rw-r--r--tools/astro-languageserver/src/plugins/css/CSSPlugin.ts153
-rw-r--r--tools/astro-languageserver/src/plugins/css/StyleAttributeDocument.ts76
-rw-r--r--tools/astro-languageserver/src/plugins/css/features/getIdClassCompletion.ts70
-rw-r--r--tools/astro-languageserver/src/plugins/css/service.ts58
-rw-r--r--tools/astro-languageserver/src/plugins/index.ts1
-rw-r--r--yarn.lock21
14 files changed, 1135 insertions, 5 deletions
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<DocumentMapper, 'getOriginalPosition'>,
+ 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<DocumentMapper, 'getOriginalPosition'>,
+ item: CompletionItem
+): CompletionItem {
+ if (!item.textEdit) {
+ return item;
+ }
+
+ return {
+ ...item,
+ textEdit: mapEditToOriginal(fragment, item.textEdit)
+ };
+}
+
+export function mapHoverToParent(
+ fragment: Pick<DocumentMapper, 'getOriginalPosition'>,
+ hover: Hover
+): Hover {
+ if (!hover.range) {
+ return hover;
+ }
+
+ return { ...hover, range: mapRangeToOriginal(fragment, hover.range) };
+}
+
+export function mapObjWithRangeToOriginal<T extends { range: Range }>(
+ fragment: Pick<DocumentMapper, 'getOriginalPosition'>,
+ objWithRange: T
+): T {
+ return { ...objWithRange, range: mapRangeToOriginal(fragment, objWithRange.range) };
+}
+
+export function mapInsertReplaceEditToOriginal(
+ fragment: Pick<DocumentMapper, 'getOriginalPosition'>,
+ edit: InsertReplaceEdit
+): InsertReplaceEdit {
+ return {
+ ...edit,
+ insert: mapRangeToOriginal(fragment, edit.insert),
+ replace: mapRangeToOriginal(fragment, edit.replace)
+ };
+}
+
+export function mapEditToOriginal(
+ fragment: Pick<DocumentMapper, 'getOriginalPosition'>,
+ 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<DocumentMapper, 'getOriginalPosition'>,
+ 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<DocumentMapper, 'getOriginalPosition'>,
+ 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<DocumentMapper, 'getOriginalPosition'>,
+ 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<string, string>;
+ start: number;
+ end: number;
+ startPos: Position;
+ endPos: Position;
+ container: { start: number; end: number };
+}
+
+function parseAttributes(
+ rawAttrs: Record<string, string | null> | undefined
+): Record<string, string> {
+ const attrs: Record<string, string> = {};
+ 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<Node, void, unknown> {
+ 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<TextDocumentPositionParams, string | null, any> = 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<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/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<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\//, '');
+} \ 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"