Skip to content

Commit

Permalink
[ls] Use language service
Browse files Browse the repository at this point in the history
  • Loading branch information
mantou132 committed Nov 24, 2024
1 parent 5395960 commit edf4d45
Show file tree
Hide file tree
Showing 25 changed files with 393 additions and 571 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"test": "lerna exec --concurrency=1 --scope \"{@mantou/gem,gem-book,duoyun-ui}\" -- pnpm test",
"release": "lerna version",
"prepare:build": "pnpm -C packages/gem build && lerna exec --scope \"{gem-analyzer,duoyun-ui,gem-book,gem-port}\" -- pnpm build",
"prepare:link": "lerna exec --scope \"{gem-book,@mantou/gem-port}\" -- npm link",
"prepare:link": "lerna exec --scope \"{gem-book,@mantou/gem-port}\" -- pnpm link -g",
"prepare": "husky install && pnpm prepare:build && pnpm prepare:link"
},
"devDependencies": {
Expand Down
6 changes: 3 additions & 3 deletions packages/duoyun-ui/src/lib/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function b64ToUtf8(str: string) {
}

export function base64ToArrayBuffer(str: string) {
return new Uint8Array([...self.atob(safeUrlToBase64Str(str))].map((char) => char.charCodeAt(0))).buffer;
return new Uint8Array([...atob(safeUrlToBase64Str(str))].map((char) => char.charCodeAt(0))).buffer;
}

function base64ToSafeUrl(str: string) {
Expand All @@ -25,15 +25,15 @@ function base64ToSafeUrl(str: string) {

/**Converted string to Base64, `isSafe` indicates URL safe */
export function utf8ToB64(str: string, isSafe?: boolean) {
const base64 = self.btoa(
const base64 = btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(Number(`0x${p1}`))),
);
return isSafe ? base64ToSafeUrl(base64) : base64;
}

// https://github.com/tc39/proposal-arraybuffer-base64
export function arrayBufferToBase64(arrayBuffer: ArrayBuffer, isSafe?: boolean) {
const base64 = self.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
return isSafe ? base64ToSafeUrl(base64) : base64;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/duoyun-ui/src/lib/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function createCanvas(width?: number, height?: number) {
}

export function createDataURLFromSVG(rawStr: string) {
return `data:image/svg+xml;base64,${self.btoa(rawStr)}`;
return `data:image/svg+xml;base64,${btoa(rawStr)}`;
}

// if `bg` is't `HexColor`, text fill color error
Expand Down
12 changes: 7 additions & 5 deletions packages/duoyun-ui/src/lib/timer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { logger } from '@mantou/gem/helper/logger';

const setTimeout = globalThis.setTimeout as typeof self.setTimeout;

/**Until the callback function resolve */
export async function forever<T>(fn: () => Promise<T>, interval = 1000): Promise<T> {
try {
Expand All @@ -21,7 +23,7 @@ export function polling(fn: (args?: any[]) => any, delay: number) {
} catch {
} finally {
if (!hasExit) {
timer = self.setTimeout(poll, delay);
timer = setTimeout(poll, delay);
}
}
};
Expand All @@ -48,7 +50,7 @@ export function throttle<T extends (...args: any) => any>(
let timer = 0;
let first = 0;
const exec = (...rest: Parameters<T>) => {
timer = self.setTimeout(() => (timer = 0), wait);
timer = setTimeout(() => (timer = 0), wait);
fn(...(rest as any));
};
return (...rest: Parameters<T>) => {
Expand All @@ -62,7 +64,7 @@ export function throttle<T extends (...args: any) => any>(
exec(...rest);
} else {
clearTimeout(timer);
timer = self.setTimeout(() => exec(...rest), wait);
timer = setTimeout(() => exec(...rest), wait);
}
};
}
Expand All @@ -76,9 +78,9 @@ export function debounce<T extends (...args: any) => any>(
return function (...args: Parameters<T>) {
return new Promise<Awaited<ReturnType<typeof fn>>>((resolve, reject) => {
clearTimeout(timer);
timer = self.setTimeout(
timer = setTimeout(
() => {
timer = self.setTimeout(() => (timer = 0), wait);
timer = setTimeout(() => (timer = 0), wait);
Promise.resolve(fn(...(args as any)))
.then(resolve)
.catch(reject);
Expand Down
2 changes: 1 addition & 1 deletion packages/duoyun-ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export function createCacheStore<T extends Record<string, any>>(
);
};

self.addEventListener('pagehide', saveStore);
addEventListener('pagehide', saveStore);

return { store, saveStore };
}
Expand Down
6 changes: 5 additions & 1 deletion packages/language-service/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vscode-gem-languageservice",
"version": "0.0.1",
"version": "0.0.2",
"description": "Language service for Gem",
"keywords": [
"gem",
Expand All @@ -14,9 +14,13 @@
],
"scripts": {},
"dependencies": {
"@vscode/emmet-helper": "^2.9.3",
"duoyun-ui": "^2.2.0",
"gem-analyzer": "^2.2.0",
"ts-morph": "^13.0.0",
"typescript": "^5.6.2",
"vscode-css-languageservice": "^6.3.1",
"vscode-html-languageservice": "^5.3.1",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.12"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CompletionList, TextDocument, Position } from 'vscode';
import type { CompletionList } from 'vscode-languageserver';
import type { Position, TextDocument } from 'vscode-languageserver-textdocument';

export class CompletionsCache {
#cachedCompletionsFile?: string;
Expand All @@ -13,7 +14,7 @@ export class CompletionsCache {
getCached(doc: TextDocument, position: Position) {
if (
this.#completions &&
doc.fileName === this.#cachedCompletionsFile &&
doc.uri === this.#cachedCompletionsFile &&
this.#equalPositions(position, this.#cachedCompletionsPosition) &&
doc.getText() === this.#cachedCompletionsContent
) {
Expand All @@ -24,7 +25,7 @@ export class CompletionsCache {
}

updateCached(context: TextDocument, position: Position, completions: CompletionList) {
this.#cachedCompletionsFile = context.fileName;
this.#cachedCompletionsFile = context.uri;
this.#cachedCompletionsPosition = position;
this.#cachedCompletionsContent = context.getText();
this.#completions = completions;
Expand Down
31 changes: 31 additions & 0 deletions packages/language-service/src/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { rgbToHexColor, parseHexColor } from 'duoyun-ui/lib/color';
import { Range, Color } from 'vscode-languageserver/node';
import type { ColorInformation, ColorPresentation } from 'vscode-languageserver/node';
import type { HexColor } from 'duoyun-ui/lib/color';
import type { TextDocument } from 'vscode-languageserver-textdocument';

import { COLOR_REG } from './constants';

export class ColorProvider {
provideDocumentColors(document: TextDocument) {
COLOR_REG.exec('null');

const documentText = document.getText();
const colors: ColorInformation[] = [];

let match: RegExpExecArray | null;
while ((match = COLOR_REG.exec(documentText)) !== null) {
const hex = match.groups!.content as HexColor;
const [red, green, blue, alpha] = parseHexColor(hex);
const offset = match.index + (match.groups!.start?.length || 0);
const range = Range.create(document.positionAt(offset), document.positionAt(offset + hex.length));
const color = Color.create(red / 255, green / 255, blue / 255, alpha);
colors.push({ range, color });
}
return colors;
}

provideColorPresentations({ red, green, blue, alpha }: Color): ColorPresentation[] {
return [{ label: rgbToHexColor([red * 255, green * 255, blue * 255, alpha]) }];
}
}
10 changes: 10 additions & 0 deletions packages/language-service/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const COLOR_REG = /(?<start>'|")?(?<content>#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4}))($1|\s*;|\s*\))/g;

// 直接通过正则匹配 css 片段,通过条件的结束 ` 号匹配
export const CSS_REG = /(?<start>\/\*\s*css\s*\*\/\s*`|(?<!`)(?:css|less|scss)\s*`)(?<content>.*?)(`(?=;|,?\s*\)))/gs;
// 直接通过正则匹配 style 片段,通过条件的结束 ` 号匹配
// 语言服务和高亮都只支持 styled 写法
export const STYLE_REG = /(?<start>\/\*\s*style\s*\*\/\s*`|(?<!`)styled?\s*`)(?<content>.*?)(`(?=,|\s*}\s*\)))/gs;

// 处理后进行正则匹配,所以不需要验证后面的 ` 号
export const HTML_REG = /(?<start>\/\*\s*html\s*\*\/\s*`|(?<!`)(?:html|raw)\s*`)(?<content>[^`]*)(`)/g;
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import type {
CompletionList,
CompletionItem,
TextDocument,
Position,
CancellationToken,
CompletionItemProvider,
} from 'vscode';
import type { LanguageService as HTMLanguageService } from 'vscode-html-languageservice';
import type { Position, TextDocument } from 'vscode-languageserver-textdocument';
import { getLanguageService as getHTMLanguageService, TokenType as HTMLTokenType } from 'vscode-html-languageservice';
import { getCSSLanguageService as getCSSLanguageService } from 'vscode-css-languageservice';

import { matchOffset, createVirtualDocument, translateCompletionList, removeSlot } from '../util';
import { CSS_REG, HTML_REG } from '../constants';
import { getCSSLanguageService } from 'vscode-css-languageservice';

import { matchOffset, createVirtualDocument, removeSlot, translateCompletionList } from './util';
import { CSS_REG, HTML_REG } from './constants';
import { CompletionsCache } from './cache';

export function getRegionAtOffset(regions: IEmbeddedRegion[], offset: number) {
function getRegionAtOffset(regions: IEmbeddedRegion[], offset: number) {
for (const region of regions) {
if (region.start <= offset) {
if (offset <= region.end) {
Expand All @@ -28,15 +20,15 @@ export function getRegionAtOffset(regions: IEmbeddedRegion[], offset: number) {
return null;
}

export interface IEmbeddedRegion {
interface IEmbeddedRegion {
languageId: string;
start: number;
end: number;
length: number;
content: string;
}

export function getLanguageRegions(service: HTMLanguageService, data: string) {
function getLanguageRegions(service: HTMLanguageService, data: string) {
const scanner = service.createScanner(data);
const regions: IEmbeddedRegion[] = [];
let tokenType: HTMLTokenType;
Expand All @@ -60,35 +52,30 @@ export function getLanguageRegions(service: HTMLanguageService, data: string) {
return regions;
}

export class HTMLStyleCompletionItemProvider implements CompletionItemProvider {
export class HTMLStyleCompletionItemProvider {
#cssLanguageService = getCSSLanguageService();
#htmlLanguageService = getHTMLanguageService();
#cache = new CompletionsCache();

provideCompletionItems(document: TextDocument, position: Position, _token: CancellationToken) {
provideCompletionItems(document: TextDocument, position: Position) {
const cached = this.#cache.getCached(document, position);
if (cached) return cached;

const currentLine = document.lineAt(position.line);
const empty: CompletionList = { isIncomplete: false, items: [] };

if (currentLine.isEmptyOrWhitespace) return empty;

const currentOffset = document.offsetAt(position);
const documentText = document.getText();
const match = matchOffset(HTML_REG, documentText, currentOffset);

if (!match) return empty;
if (!match) return;

const matchContent = match.groups!.content;
const matchStartOffset = match.index + match.groups!.start.length;
const regions = getLanguageRegions(this.#htmlLanguageService, matchContent);

if (regions.length <= 0) return empty;
if (regions.length <= 0) return;

const region = getRegionAtOffset(regions, currentOffset - matchStartOffset);

if (!region) return empty;
if (!region) return;

const virtualOffset = currentOffset - (matchStartOffset + region.start);
const virtualDocument = createVirtualDocument('css', removeSlot(region.content));
Expand All @@ -100,32 +87,23 @@ export class HTMLStyleCompletionItemProvider implements CompletionItemProvider {
stylesheet,
);

return this.#cache.updateCached(document, position, translateCompletionList(completions, currentLine));
}

resolveCompletionItem(item: CompletionItem, _token: CancellationToken) {
return item;
return this.#cache.updateCached(document, position, translateCompletionList(completions, position));
}
}

export class CSSCompletionItemProvider implements CompletionItemProvider {
export class CSSCompletionItemProvider {
#cssLanguageService = getCSSLanguageService();
#cache = new CompletionsCache();

provideCompletionItems(document: TextDocument, position: Position, _token: CancellationToken) {
provideCompletionItems(document: TextDocument, position: Position) {
const cached = this.#cache.getCached(document, position);
if (cached) return cached;

const currentLine = document.lineAt(position.line);
const empty: CompletionList = { isIncomplete: false, items: [] };

if (currentLine.isEmptyOrWhitespace) return empty;

const currentOffset = document.offsetAt(position);
const documentText = document.getText();
const match = matchOffset(CSS_REG, documentText, currentOffset);

if (!match) return empty;
if (!match) return;

const matchContent = match.groups!.content;
const matchStartOffset = match.index + match.groups!.start.length;
Expand All @@ -139,10 +117,6 @@ export class CSSCompletionItemProvider implements CompletionItemProvider {
vCss,
);

return this.#cache.updateCached(document, position, translateCompletionList(completions, currentLine));
}

resolveCompletionItem(item: CompletionItem, _token: CancellationToken) {
return item;
return this.#cache.updateCached(document, position, translateCompletionList(completions, position));
}
}
45 changes: 45 additions & 0 deletions packages/language-service/src/diagnostic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 只对 CSS 语法和属性做了简单的检查,不做值检查
// TODO: 激活扩展、打开工作区时需要自动诊断所有文件
// TODO: 使用 LRU 缓存

import { getCSSLanguageService } from 'vscode-css-languageservice';
import { Range } from 'vscode-languageserver/node';
import type { Diagnostic } from 'vscode-languageserver/node';
import type { TextDocument } from 'vscode-languageserver-textdocument';

import { CSS_REG, STYLE_REG } from './constants';
import { createVirtualDocument, removeSlot } from './util';

const cssLanguageService = getCSSLanguageService();

export function getDiagnostics(document: TextDocument, _relatedInformation: boolean) {
const diagnostics: Diagnostic[] = [];
const text = document.getText();

const matchFragments = (regexp: RegExp, appendBefore: string, appendAfter: string) => {
regexp.exec('null');

let match;
while ((match = regexp.exec(text))) {
const matchContent = match.groups!.content;
const offset = match.index + match.groups!.start.length;
const virtualDocument = createVirtualDocument('css', `${appendBefore}${removeSlot(matchContent)}${appendAfter}`);
const vCss = cssLanguageService.parseStylesheet(virtualDocument);
const oDiagnostics = cssLanguageService.doValidation(virtualDocument, vCss) as Diagnostic[];
for (const { message, range } of oDiagnostics) {
const { start, end } = range;
const startOffset = virtualDocument.offsetAt(start) - appendBefore.length + offset;
const endOffset = virtualDocument.offsetAt(end) - appendBefore.length + offset;
diagnostics.push({
range: Range.create(document.positionAt(startOffset), document.positionAt(endOffset)),
message,
});
}
}
};

matchFragments(CSS_REG, '', '');
matchFragments(STYLE_REG, ':host { ', ' }');

return diagnostics;
}
Loading

0 comments on commit edf4d45

Please sign in to comment.