diff --git a/packages/duoyun-ui/src/lib/cache.ts b/packages/duoyun-ui/src/lib/cache.ts index fc23d9ae..8438bc13 100644 --- a/packages/duoyun-ui/src/lib/cache.ts +++ b/packages/duoyun-ui/src/lib/cache.ts @@ -11,6 +11,9 @@ interface CacheItem { value: T; } +/** + * 过期的不能自动清理,但超出最大容量时会被修剪 + */ export class Cache { #max: number; #maxAge: number; @@ -18,7 +21,7 @@ export class Cache { #map = new Map>(); #reverseMap = new Map(); - #linkedList = new LinkedList(); + #addedLinked = new LinkedList(); constructor({ max = Infinity, maxAge = Infinity, renewal = false }: CacheOptions = {}) { this.#max = max; @@ -26,17 +29,23 @@ export class Cache { this.#renewal = renewal; } + setOptions(options: CacheOptions) { + this.#max = options.max ?? this.#max; + this.#maxAge = options.maxAge ?? this.#maxAge; + this.#renewal = options.renewal ?? this.#renewal; + } + #trim() { - for (let i = this.#linkedList.size - this.#max; i > 0; i--) { - const value = this.#linkedList.get(); - const key = this.#reverseMap.get(value!)!; - this.#reverseMap.delete(value!); + for (let i = this.#addedLinked.size - this.#max; i > 0; i--) { + const value = this.#addedLinked.get()!; + const key = this.#reverseMap.get(value)!; + this.#reverseMap.delete(value); this.#map.delete(key); } } set(key: string, value: T) { - this.#linkedList.add(value); + this.#addedLinked.add(value); this.#reverseMap.set(value, key); this.#map.set(key, { value, timestamp: Date.now() }); this.#trim(); @@ -51,8 +60,9 @@ export class Cache { return init && this.set(key, init(key)); } const { timestamp, value } = cache; + // 过期重新生成 if (Date.now() - timestamp > this.#maxAge) { - this.#linkedList.delete(value); + this.#addedLinked.delete(value); this.#reverseMap.delete(value); this.#map.delete(key); return init && this.set(key, init(key)); @@ -60,8 +70,8 @@ export class Cache { if (this.#renewal) { cache.timestamp = Date.now(); } - this.#linkedList.get(); - this.#linkedList.add(value); + // 调整下位置 + this.#addedLinked.add(value); return value; } } diff --git a/packages/duoyun-ui/src/lib/map.ts b/packages/duoyun-ui/src/lib/map.ts new file mode 100644 index 00000000..afe0500e --- /dev/null +++ b/packages/duoyun-ui/src/lib/map.ts @@ -0,0 +1,26 @@ +export class StringWeakMap { + #map = new Map>(); + #weakMap = new WeakMap(); + #registry = new FinalizationRegistry((key) => this.#map.delete(key)); + + set(key: string, val: T) { + this.#map.set(key, new WeakRef(val)); + this.#weakMap.set(val, key); + this.#registry.register(val, key); + } + + get(key: string) { + return this.#map.get(key)?.deref(); + } + + findKey(val: T) { + return this.#weakMap.get(val); + } + + *[Symbol.iterator]() { + const entries = this.#map.entries(); + for (const [tag, ref] of entries) { + yield [tag, ref.deref()!] as const; + } + } +} diff --git a/packages/gem/src/lib/utils.ts b/packages/gem/src/lib/utils.ts index 4cac866e..1e83df50 100644 --- a/packages/gem/src/lib/utils.ts +++ b/packages/gem/src/lib/utils.ts @@ -122,8 +122,10 @@ export class LinkedList extends EventTarget { return this.#map.get(value); } - // 添加到尾部,已存在时会删除老的项目 - // 如果是添加第一个,start 事件会在添加前触发,避免处理事件重复的逻辑 + /** + * 添加到尾部,已存在时会删除老的项目 + * 如果是添加第一个,start 事件会在添加前触发,避免处理事件重复的逻辑 + */ add(value: T) { if (!this.#lastItem) { this.dispatchEvent(new CustomEvent('start')); @@ -141,7 +143,7 @@ export class LinkedList extends EventTarget { this.#map.set(value, item); } - // 删除这个元素后没有其他元素时立即出发 end 事件 + /** 删除这个元素后没有其他元素时立即出发 end 事件 */ delete(value: T) { const deleteItem = this.#delete(value); if (!this.#firstItem) { @@ -150,8 +152,7 @@ export class LinkedList extends EventTarget { return deleteItem; } - // 获取头部元素 - // 会从链表删除 + /** 获取头部元素,会从链表删除 */ get(): T | undefined { const firstItem = this.#firstItem; if (!firstItem) return; diff --git a/packages/ts-gem-plugin/src/cache.ts b/packages/ts-gem-plugin/src/cache.ts index 8370c337..f667b14c 100644 --- a/packages/ts-gem-plugin/src/cache.ts +++ b/packages/ts-gem-plugin/src/cache.ts @@ -5,30 +5,16 @@ import { Cache } from 'duoyun-ui/lib/cache'; export type CacheContext = Pick; export class LRUCache { - #bucket = new Cache({ max: 25, renewal: true }); + #bucket: Cache; + constructor(...args: ConstructorParameters>) { + this.#bucket = new Cache({ max: 25, renewal: true, ...args }); + } #genKey(context: CacheContext, position?: Position) { return [context.fileName, position?.line, position?.character, context.text].join(';'); } - get(context: CacheContext, init: () => T) { - return this.#bucket.get(this.#genKey(context), init); - } - - getCached(context: CacheContext, position?: Position) { - return this.#bucket.get(this.#genKey(context, position)); - } - - updateCached(context: CacheContext, posOrContent: Position | T, contentOrUndefined?: T) { - let position: Position | undefined; - let content: T; - if ('line' in posOrContent && 'character' in posOrContent) { - position = posOrContent; - content = contentOrUndefined!; - } else { - position = undefined; - content = posOrContent; - } - return this.#bucket.set(this.#genKey(context, position), content); + get(context: CacheContext, position: Position | undefined, init: () => T) { + return this.#bucket.get(this.#genKey(context, position), init); } } diff --git a/packages/ts-gem-plugin/src/configuration.ts b/packages/ts-gem-plugin/src/configuration.ts index d1fbc639..50fc148c 100644 --- a/packages/ts-gem-plugin/src/configuration.ts +++ b/packages/ts-gem-plugin/src/configuration.ts @@ -1,8 +1,5 @@ -import type { Logger } from '@mantou/typescript-template-language-service-decorator'; -import type { VSCodeEmmetConfig } from '@mantou/vscode-emmet-helper'; -import type { LanguageService } from 'typescript'; -import type * as ts from 'typescript/lib/tsserverlibrary'; import { camelToKebabCase } from '@mantou/gem/lib/utils'; +import type { VSCodeEmmetConfig } from '@mantou/vscode-emmet-helper'; export interface PluginConfiguration { emmet: VSCodeEmmetConfig; @@ -55,39 +52,3 @@ export class Configuration { return this.#elementDefineRules; } } - -export class Store { - #map = new Map>(); - #weakMap = new WeakMap(); - #registry = new FinalizationRegistry((key) => this.#map.delete(key)); - - set(key: string, val: T) { - this.#map.set(key, new WeakRef(val)); - this.#weakMap.set(val, key); - this.#registry.register(val, key); - } - - get(key: string) { - return this.#map.get(key)?.deref(); - } - - findKey(val: T) { - return this.#weakMap.get(val); - } - - *[Symbol.iterator]() { - const entries = this.#map.entries(); - for (const [tag, ref] of entries) { - yield [tag, ref.deref()!] as const; - } - } -} - -export type Context = { - config: Configuration; - ts: typeof ts; - logger: Logger; - getProgram: LanguageService['getProgram']; - getProject: () => ts.server.Project; - elements: Store; -}; diff --git a/packages/ts-gem-plugin/src/context.ts b/packages/ts-gem-plugin/src/context.ts new file mode 100644 index 00000000..6315c418 --- /dev/null +++ b/packages/ts-gem-plugin/src/context.ts @@ -0,0 +1,156 @@ +import type { Logger, TemplateSettings } from '@mantou/typescript-template-language-service-decorator'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import type { HTMLDocument, IHTMLDataProvider } from '@mantou/vscode-html-languageservice'; +import { getLanguageService as getHTMLanguageService, TextDocument } from '@mantou/vscode-html-languageservice'; +import StandardTemplateSourceHelper from '@mantou/typescript-template-language-service-decorator/lib/standard-template-source-helper'; +import StandardScriptSourceHelper from '@mantou/typescript-template-language-service-decorator/lib/standard-script-source-helper'; +import type { Stylesheet } from '@mantou/vscode-css-languageservice'; +import { getCSSLanguageService } from '@mantou/vscode-css-languageservice'; +import { StringWeakMap } from 'duoyun-ui/lib/map'; + +import { isDepElement } from './utils'; +import type { Configuration } from './configuration'; +import { dataProvider, HTMLDataProvider } from './data-provider'; +import { LRUCache } from './cache'; + +/** + * 全局上下文,数据共享 + */ +export class Context { + elements: StringWeakMap; + ts: typeof ts; + config: Configuration; + project: ts.server.Project; + logger: Logger; + dataProvider: IHTMLDataProvider; + cssLanguageService: ReturnType; + htmlLanguageService: ReturnType; + htmlSourceHelper: StandardTemplateSourceHelper; + htmlTemplateStringSettings: TemplateSettings; + cssTemplateStringSettings: TemplateSettings; + getProgram: () => ts.Program; + + constructor(typescript: typeof ts, config: Configuration, info: ts.server.PluginCreateInfo, logger: Logger) { + this.ts = typescript; + this.config = config; + this.getProgram = () => info.languageService.getProgram()!; + this.project = info.project; + this.logger = logger; + this.dataProvider = dataProvider; + this.elements = new StringWeakMap(); + this.cssLanguageService = getCSSLanguageService({}); + this.htmlLanguageService = getHTMLanguageService({ + customDataProviders: [dataProvider, new HTMLDataProvider(typescript, this.elements, this.getProgram)], + }); + this.htmlTemplateStringSettings = { + tags: ['html', 'raw', 'h'], + enableForStringWithSubstitutions: true, + getSubstitution, + }; + this.cssTemplateStringSettings = { + tags: ['styled', 'css'], + enableForStringWithSubstitutions: true, + getSubstitution, + isValidTemplate: (node) => isValidCSSTemplate(typescript, node, 'css'), + }; + this.htmlSourceHelper = new StandardTemplateSourceHelper( + typescript, + this.htmlTemplateStringSettings, + new StandardScriptSourceHelper(typescript, info.project), + logger, + ); + } + + #virtualHtmlCache = new LRUCache<{ vDoc: TextDocument; vHtml: HTMLDocument }>({ max: 1000 }); + #virtualCssCache = new LRUCache<{ vDoc: TextDocument; vCss: Stylesheet }>({ max: 1000 }); + getCssDoc(text: string) { + return this.#virtualCssCache.get({ text, fileName: '' }, undefined, () => { + const vDoc = createVirtualDocument('css', text); + const vCss = this.cssLanguageService.parseStylesheet(vDoc); + return { vDoc, vCss }; + }); + } + + getHtmlDoc(text: string) { + return this.#virtualHtmlCache.get({ text, fileName: '' }, undefined, () => { + const vDoc = createVirtualDocument('html', text); + const vHtml = this.htmlLanguageService.parseHTMLDocument(vDoc); + return { vDoc, vHtml }; + }); + } + + getTagFromNode(node: ts.Node, supportClassName = isDepElement(node)) { + if (!this.ts.isClassDeclaration(node)) return; + + for (const modifier of node.modifiers || []) { + if ( + this.ts.isDecorator(modifier) && + this.ts.isCallExpression(modifier.expression) && + modifier.expression.expression.getText() === 'customElement' + ) { + const arg = modifier.expression.arguments.at(0); + if (arg && this.ts.isStringLiteral(arg)) { + return arg.text; + } + } + } + + // 只有声明文件 + if (supportClassName && node.name && this.ts.isIdentifier(node.name)) { + return this.config.elementDefineRules.findTag(node.name.text); + } + } + + updateElement(file: ts.SourceFile) { + const isDep = isDepElement(file); + // 只支持顶级 class 声明 + this.ts.forEachChild(file, (node) => { + const tag = this.getTagFromNode(node, isDep); + if (tag && this.ts.isClassDeclaration(node)) { + this.elements.set(tag, node); + } + }); + } + + #initElementsCache = new WeakSet(); + /** + * 当 project 准备好了执行 + */ + initElements() { + const program = this.getProgram(); + if (this.#initElementsCache.has(program)) return; + program.getSourceFiles().forEach((file) => this.updateElement(file)); + } +} + +function createVirtualDocument(languageId: string, content: string) { + return TextDocument.create(`embedded://document.${languageId}`, languageId, 1, content); +} + +function getSubstitution(templateString: string, start: number, end: number) { + return templateString.slice(start, end).replaceAll(/[^\n]/g, '_'); +} + +function isValidCSSTemplate( + typescript: typeof ts, + node: ts.NoSubstitutionTemplateLiteral | ts.TaggedTemplateExpression | ts.TemplateExpression, + callName: string, +) { + switch (node.kind) { + case typescript.SyntaxKind.NoSubstitutionTemplateLiteral: + case typescript.SyntaxKind.TemplateExpression: + const parent = node.parent; + if (typescript.isCallExpression(parent) && parent.expression.getText() === callName) { + return true; + } + if (typescript.isPropertyAssignment(parent)) { + const call = parent.parent.parent; + if (typescript.isCallExpression(call) && call.expression.getText() === callName) { + return true; + } + } + return false; + default: + return false; + } +} diff --git a/packages/ts-gem-plugin/src/data-provider.ts b/packages/ts-gem-plugin/src/data-provider.ts new file mode 100644 index 00000000..a9c9e28c --- /dev/null +++ b/packages/ts-gem-plugin/src/data-provider.ts @@ -0,0 +1,134 @@ +import { + getDefaultHTMLDataProvider, + type IHTMLDataProvider, + type IAttributeData, +} from '@mantou/vscode-html-languageservice'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import type { StringWeakMap } from 'duoyun-ui/lib/map'; + +import { isDepElement, isCustomElementTag, getAttrName } from './utils'; + +export const dataProvider = getDefaultHTMLDataProvider(); + +export class HTMLDataProvider implements IHTMLDataProvider { + #ts: typeof ts; + #elements: StringWeakMap; + #getProgram: () => ts.Program; + + constructor(typescript: typeof ts, elements: StringWeakMap, getProgram: () => ts.Program) { + this.#ts = typescript; + this.#elements = elements; + this.#getProgram = getProgram; + } + + getId() { + return 'gem'; + } + + isApplicable() { + return true; + } + + provideTags() { + return [...this.#elements].map(([tag, node]) => ({ + name: tag, + attributes: [], + description: getDocComment(this.#ts, node), + })); + } + + provideAttributes(tag: string) { + const ts = this.#ts; + const typeChecker = this.#getProgram().getTypeChecker(); + const node = this.#elements.get(tag); + if (!node) return []; + const isDep = isDepElement(node); + const result: IAttributeData[] = []; + const props = typeChecker.getTypeAtLocation(node).getApparentProperties(); + // TODO: props, attributes + props.forEach((e) => { + const declaration = e.getDeclarations()?.at(0); + const prop = declaration && ts.isPropertyDeclaration(declaration); + if (!prop) return; + const hasPropDecorator = declaration.modifiers?.some((m) => ts.isDecorator(m) && ts.isIdentifier(m.expression)); + if (!hasPropDecorator && !isDep) return; + const type = declaration.type && typeChecker.getTypeFromTypeNode(declaration.type); + const typeText = declaration.type?.getText(); + const description = getDocComment(ts, declaration!); + switch (type) { + case typeChecker.getStringType(): + case typeChecker.getNumberType(): + result.push({ name: e.name, description }); + break; + case typeChecker.getBooleanType(): + result.push({ name: e.name, description, valueSet: 'v' }); + result.push({ name: `?${e.name}`, description }); + break; + } + if (getBasicUnionValues(declaration)) { + result.push({ name: e.name, description }); + } + if (typeText?.startsWith('Emitter')) { + result.push({ name: `@${e.name}`, description }); + } else { + result.push({ name: `.${e.name}`, description }); + } + }); + const oResult = dataProvider.provideAttributes(isCustomElementTag(tag) ? 'div' : tag); + oResult.forEach((data) => { + const tryEvtName = data.name.replace(/^on/, '@'); + if (tryEvtName !== data.name) { + result.push({ ...data, name: tryEvtName }); + } + }); + return result; + } + + provideValues(tag: string, attr: string) { + const typeChecker = this.#getProgram().getTypeChecker(); + const node = this.#elements.get(tag); + if (!node) return []; + const prop = typeChecker.getTypeAtLocation(node).getProperty(getAttrName(attr).attr); + const declaration = prop?.getDeclarations()?.at(0); + const result = getBasicUnionValues(declaration); + return result?.map((name) => ({ name })) || []; + } +} + +// TODO: use typeChecker +const STRING_REG = /("|')(?.*)\1/; +function getBasicUnionValues(node?: ts.Node) { + const typeText = (node as ts.PropertyDeclaration)?.type?.getText(); + if (!typeText) return; + + const result: string[] = []; + for (const text of typeText.split('|')) { + const t = text.trim(); + if (!t) continue; + const number = Number(t); + if (!Number.isNaN(number)) { + result.push(t); + continue; + } + const match = t.match(STRING_REG); + if (!match) { + // 有个元素不是字符串也不是数字 + return; + } + result.push(match.groups!.str); + } + if (result.length) return result; +} + +const COMMENT_LINE_CONTENT = /^(\/?[ *\t]*)?(?.*?)(\**\/)?$/gm; +function getDocComment(typescript: typeof ts, declaration: ts.Node) { + const fullText = declaration.getSourceFile().getFullText(); + const commentRanges = typescript.getLeadingCommentRanges(fullText, declaration.getFullStart()); + const commentStrings = commentRanges + ?.filter(({ kind }) => kind === typescript.SyntaxKind.MultiLineCommentTrivia) + .map(({ pos, end }) => { + const fullComment = [...fullText.slice(pos, end).matchAll(COMMENT_LINE_CONTENT)]; + return fullComment.map((m) => m.groups!.str).join('\n'); + }); + return commentStrings?.join('\n\n'); +} diff --git a/packages/ts-gem-plugin/src/decorate-css.ts b/packages/ts-gem-plugin/src/decorate-css.ts index a00b2940..ce983362 100644 --- a/packages/ts-gem-plugin/src/decorate-css.ts +++ b/packages/ts-gem-plugin/src/decorate-css.ts @@ -1,38 +1,25 @@ -import type * as ts from 'typescript/lib/tsserverlibrary'; -import type { TemplateLanguageService, TemplateContext } from '@mantou/typescript-template-language-service-decorator'; -import type { CompletionList, Stylesheet, TextDocument, LanguageService } from '@mantou/vscode-css-languageservice'; -import { getCSSLanguageService, updateTags as updateCSSTags } from '@mantou/vscode-css-languageservice'; +import type { TemplateContext, TemplateLanguageService } from '@mantou/typescript-template-language-service-decorator'; +import type { CompletionList } from '@mantou/vscode-css-languageservice'; +import { updateTags as updateCSSTags } from '@mantou/vscode-css-languageservice'; import { doComplete as doEmmetComplete } from '@mantou/vscode-emmet-helper'; +import type * as ts from 'typescript/lib/tsserverlibrary'; -import type { Context } from './configuration'; +import { LRUCache } from './cache'; +import type { Context } from './context'; import { - createVirtualDocument, genDefaultCompletionEntryDetails, translateCompletionItemsToCompletionEntryDetails, translateCompletionItemsToCompletionInfo, translateHover, -} from './utils'; -import type { CacheContext } from './cache'; -import { LRUCache } from './cache'; +} from './translates'; export class CSSLanguageService implements TemplateLanguageService { - #completionsCache = new LRUCache(); + #completionsCache = new LRUCache({ max: 1 }); #diagnosticsCache = new LRUCache(); - #virtualCssCache = new LRUCache<{ vDoc: TextDocument; vCss: Stylesheet }>(); - #cssLanguageService: LanguageService; #ctx: Context; constructor(ctx: Context) { this.#ctx = ctx; - this.#cssLanguageService = getCSSLanguageService({}); - } - - #getCssDoc(context: CacheContext) { - return this.#virtualCssCache.get(context, () => { - const vDoc = createVirtualDocument('css', context.text); - const vCss = this.#cssLanguageService.parseStylesheet(vDoc); - return { vDoc, vCss }; - }); } #normalize(context: TemplateContext, position: ts.LineAndCharacter) { @@ -53,20 +40,19 @@ export class CSSLanguageService implements TemplateLanguageService { } #getCompletionsAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { - const cached = this.#completionsCache.getCached(context, position); - if (cached) return cached; - - const { text, pos } = this.#normalize(context, position); - const { vDoc, vCss } = this.#getCssDoc({ fileName: context.fileName, text }); - - let emmetResults: CompletionList | undefined; - const onCssProperty = () => (emmetResults = doEmmetComplete(vDoc, pos, 'css', this.#ctx.config.emmet)); - this.#cssLanguageService.setCompletionParticipants([{ onCssProperty }]); - updateCSSTags([...this.#ctx.elements].map(([tag]) => tag)); - const completions = this.#cssLanguageService.doComplete(vDoc, pos, vCss); - - completions.items.push(...(emmetResults?.items || [])); - return this.#completionsCache.updateCached(context, position, completions); + return this.#completionsCache.get(context, position, () => { + const { text, pos } = this.#normalize(context, position); + const { vDoc, vCss } = this.#ctx.getCssDoc(text); + + let emmetResults: CompletionList | undefined; + const onCssProperty = () => (emmetResults = doEmmetComplete(vDoc, pos, 'css', this.#ctx.config.emmet)); + this.#ctx.cssLanguageService.setCompletionParticipants([{ onCssProperty }]); + updateCSSTags([...this.#ctx.elements].map(([tag]) => tag)); + const completions = this.#ctx.cssLanguageService.doComplete(vDoc, pos, vCss); + + completions.items.push(...(emmetResults?.items || [])); + return completions; + }); } getCompletionsAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { @@ -87,8 +73,8 @@ export class CSSLanguageService implements TemplateLanguageService { getQuickInfoAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { const { text, pos } = this.#normalize(context, position); - const { vDoc, vCss } = this.#getCssDoc({ fileName: context.fileName, text }); - const hover = this.#cssLanguageService.doHover(vDoc, pos, vCss, { + const { vDoc, vCss } = this.#ctx.getCssDoc(text); + const hover = this.#ctx.cssLanguageService.doHover(vDoc, pos, vCss, { documentation: true, references: true, }); @@ -97,27 +83,26 @@ export class CSSLanguageService implements TemplateLanguageService { return translateHover(context, hover, position, pos.character - position.character); } - getSyntacticDiagnostics(context: TemplateContext): ts.Diagnostic[] { - const cached = this.#diagnosticsCache.getCached(context); - if (cached) return cached; - + #getSyntacticDiagnostics(context: TemplateContext): ts.Diagnostic[] { const { text, offset } = this.#normalize(context, { line: 0, character: 0 }); - const { vDoc, vCss } = this.#getCssDoc({ fileName: context.fileName, text }); - const oDiagnostics = this.#cssLanguageService.doValidation(vDoc, vCss); - const file = this.#ctx.getProgram()!.getSourceFile(context.fileName); - return this.#diagnosticsCache.updateCached( - context, - oDiagnostics.map(({ message, range }) => { - const start = context.toOffset(range.start); - return { - category: context.typescript.DiagnosticCategory.Warning, - code: 0, - file, - start: range.start.line === 0 ? start - offset : start, - length: context.toOffset(range.end) - start, - messageText: message, - }; - }), - ); + const { vDoc, vCss } = this.#ctx.getCssDoc(text); + const oDiagnostics = this.#ctx.cssLanguageService.doValidation(vDoc, vCss); + const file = this.#ctx.getProgram().getSourceFile(context.fileName); + return oDiagnostics.map(({ message, range }) => { + const start = context.toOffset(range.start); + return { + category: context.typescript.DiagnosticCategory.Warning, + code: 0, + file, + start: range.start.line === 0 ? start - offset : start, + length: context.toOffset(range.end) - start, + messageText: message, + }; + }); + } + + getSyntacticDiagnostics(context: TemplateContext): ts.Diagnostic[] { + this.#ctx.initElements(); + return this.#diagnosticsCache.get(context, undefined, () => this.#getSyntacticDiagnostics(context)); } } diff --git a/packages/ts-gem-plugin/src/decorate-html.ts b/packages/ts-gem-plugin/src/decorate-html.ts index 812d183b..9155f06b 100644 --- a/packages/ts-gem-plugin/src/decorate-html.ts +++ b/packages/ts-gem-plugin/src/decorate-html.ts @@ -1,156 +1,27 @@ -import type * as ts from 'typescript/lib/tsserverlibrary'; -import type { TemplateLanguageService, TemplateContext } from '@mantou/typescript-template-language-service-decorator'; -import type { - CompletionList, - HTMLDocument, - IAttributeData, - IHTMLDataProvider, - Position, - Range, - TextDocument, -} from '@mantou/vscode-html-languageservice'; -import { - getDefaultHTMLDataProvider, - getLanguageService as getHTMLanguageService, -} from '@mantou/vscode-html-languageservice'; -import { doComplete as doEmmetComplete, updateTags } from '@mantou/vscode-emmet-helper'; -import type { Stylesheet } from '@mantou/vscode-css-languageservice'; -import { getCSSLanguageService, updateTags as updateCSSTags } from '@mantou/vscode-css-languageservice'; import { kebabToCamelCase } from '@mantou/gem/lib/utils'; +import type { TemplateContext, TemplateLanguageService } from '@mantou/typescript-template-language-service-decorator'; +import { updateTags as updateCSSTags } from '@mantou/vscode-css-languageservice'; +import { doComplete as doEmmetComplete, updateTags } from '@mantou/vscode-emmet-helper'; +import type { CompletionList, HTMLDocument, Position, Range } from '@mantou/vscode-html-languageservice'; +import type * as ts from 'typescript/lib/tsserverlibrary'; -import type { Context } from './configuration'; +import { LRUCache } from './cache'; +import type { Context } from './context'; +import { forEachNode, getAttrName, getHTMLTextAtPosition, isCustomElementTag } from './utils'; import { - createVirtualDocument, genDefaultCompletionEntryDetails, - getAttrName, - getDocComment, - getHTMLTextAtPosition, - isCustomElementTag, - isDepElement, - getBasicUnionValues, translateCompletionItemsToCompletionEntryDetails, translateCompletionItemsToCompletionInfo, translateHover, -} from './utils'; -import type { CacheContext } from './cache'; -import { LRUCache } from './cache'; - -const dataProvider = getDefaultHTMLDataProvider(); - -class HTMLDataProvider implements IHTMLDataProvider { - #ctx: Context; - constructor(ctx: Context) { - this.#ctx = ctx; - } - - getId() { - return 'gem'; - } - - isApplicable() { - return true; - } - - provideTags() { - const { elements, ts } = this.#ctx; - return [...elements].map(([tag, node]) => ({ - name: tag, - attributes: [], - description: getDocComment(ts, node), - })); - } - - provideAttributes(tag: string) { - const { elements, ts, getProgram } = this.#ctx; - const typeChecker = getProgram()!.getTypeChecker(); - const node = elements.get(tag); - if (!node) return []; - const isDep = isDepElement(node); - const result: IAttributeData[] = []; - const props = typeChecker.getTypeAtLocation(node).getApparentProperties(); - // TODO: props, attributes - props.forEach((e) => { - const declaration = e.getDeclarations()?.at(0); - const prop = declaration && ts.isPropertyDeclaration(declaration); - if (!prop) return; - const hasPropDecorator = declaration.modifiers?.some((m) => ts.isDecorator(m) && ts.isIdentifier(m.expression)); - if (!hasPropDecorator && !isDep) return; - const type = declaration.type && typeChecker.getTypeFromTypeNode(declaration.type); - const typeText = declaration.type?.getText(); - const description = getDocComment(ts, declaration!); - switch (type) { - case typeChecker.getStringType(): - case typeChecker.getNumberType(): - result.push({ name: e.name, description }); - break; - case typeChecker.getBooleanType(): - result.push({ name: e.name, description, valueSet: 'v' }); - result.push({ name: `?${e.name}`, description }); - break; - } - if (getBasicUnionValues(declaration)) { - result.push({ name: e.name, description }); - } - if (typeText?.startsWith('Emitter')) { - result.push({ name: `@${e.name}`, description }); - } else { - result.push({ name: `.${e.name}`, description }); - } - }); - const oResult = dataProvider.provideAttributes(isCustomElementTag(tag) ? 'div' : tag); - oResult.forEach((data) => { - const tryEvtName = data.name.replace(/^on/, '@'); - if (tryEvtName !== data.name) { - result.push({ ...data, name: tryEvtName }); - } - }); - return result; - } - - provideValues(tag: string, attr: string) { - const { elements, getProgram } = this.#ctx; - const typeChecker = getProgram()!.getTypeChecker(); - const node = elements.get(tag); - if (!node) return []; - const prop = typeChecker.getTypeAtLocation(node).getProperty(getAttrName(attr).attr); - const declaration = prop?.getDeclarations()?.at(0); - const result = getBasicUnionValues(declaration); - return result || []; - } -} +} from './translates'; export class HTMLLanguageService implements TemplateLanguageService { - #completionsCache = new LRUCache(); + #completionsCache = new LRUCache({ max: 1 }); #diagnosticsCache = new LRUCache(); - #virtualHtmlCache = new LRUCache<{ vDoc: TextDocument; vHtml: HTMLDocument }>(); - #virtualCssCache = new LRUCache<{ vDoc: TextDocument; vCss: Stylesheet }>(); - - #cssLanguageService: ReturnType; - #htmlLanguageService: ReturnType; #ctx: Context; constructor(ctx: Context) { this.#ctx = ctx; - this.#htmlLanguageService = getHTMLanguageService({ - customDataProviders: [dataProvider, new HTMLDataProvider(ctx)], - }); - this.#cssLanguageService = getCSSLanguageService({}); - } - - #getCssDoc(context: CacheContext) { - return this.#virtualCssCache.get(context, () => { - const vDoc = createVirtualDocument('css', context.text); - const vCss = this.#cssLanguageService.parseStylesheet(vDoc); - return { vDoc, vCss }; - }); - } - - #getHtmlDoc(context: CacheContext) { - return this.#virtualHtmlCache.get(context, () => { - const vDoc = createVirtualDocument('html', context.text); - const vHtml = this.#htmlLanguageService.parseHTMLDocument(vDoc); - return { vDoc, vHtml }; - }); } #getAllStyleSheet(doc: HTMLDocument) { @@ -169,7 +40,7 @@ export class HTMLLanguageService implements TemplateLanguageService { const node = doc.findNodeAt(context.toOffset(position)); if (node.tag !== 'style') return; const text = context.text.slice(node.startTagEnd, node.endTagStart); - const { vDoc, vCss } = this.#getCssDoc({ fileName: context.fileName, text }); + const { vDoc, vCss } = this.#ctx.getCssDoc(text); const offset = context.toOffset(position) - node.startTagEnd!; const toPosition = (pos: Position) => context.toPosition(vDoc.offsetAt(pos) + node.startTagEnd!); return { @@ -189,9 +60,9 @@ export class HTMLLanguageService implements TemplateLanguageService { let emmetResults: CompletionList | undefined; const onCssProperty = () => (emmetResults = doEmmetComplete(css.vDoc, css.position, 'css', this.#ctx.config.emmet)); - this.#cssLanguageService.setCompletionParticipants([{ onCssProperty }]); + this.#ctx.cssLanguageService.setCompletionParticipants([{ onCssProperty }]); updateCSSTags([...this.#ctx.elements].map(([tag]) => tag)); - const completions = this.#cssLanguageService.doComplete(css.vDoc, css.position, css.style); + const completions = this.#ctx.cssLanguageService.doComplete(css.vDoc, css.position, css.style); completions.items.push(...(emmetResults?.items || [])); return completions.items.map((e) => { @@ -202,22 +73,22 @@ export class HTMLLanguageService implements TemplateLanguageService { } #getCompletionsAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { - const cached = this.#completionsCache.getCached(context, position); - if (cached) return cached; - const { vDoc, vHtml } = this.#getHtmlDoc(context); + return this.#completionsCache.get(context, position, () => { + const { vDoc, vHtml } = this.#ctx.getHtmlDoc(context.text); - let emmetResults: CompletionList | undefined; - const onHtmlContent = () => { - updateTags([...this.#ctx.elements].map(([tag]) => tag)); - emmetResults = doEmmetComplete(vDoc, position, 'html', this.#ctx.config.emmet); - }; - this.#htmlLanguageService.setCompletionParticipants([{ onHtmlContent }]); - const completions = this.#htmlLanguageService.doComplete(vDoc, position, vHtml); + let emmetResults: CompletionList | undefined; + const onHtmlContent = () => { + updateTags([...this.#ctx.elements].map(([tag]) => tag)); + emmetResults = doEmmetComplete(vDoc, position, 'html', this.#ctx.config.emmet); + }; + this.#ctx.htmlLanguageService.setCompletionParticipants([{ onHtmlContent }]); + const completions = this.#ctx.htmlLanguageService.doComplete(vDoc, position, vHtml); - completions.items.push(...(emmetResults?.items || [])); - completions.items.push(...this.#getCSSCompletionsAtPosition(context, position, vHtml)); + completions.items.push(...(emmetResults?.items || [])); + completions.items.push(...this.#getCSSCompletionsAtPosition(context, position, vHtml)); - return this.#completionsCache.updateCached(context, position, completions); + return completions; + }); } getCompletionsAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { @@ -239,7 +110,7 @@ export class HTMLLanguageService implements TemplateLanguageService { #getCSSQuickInfoAtPosition(context: TemplateContext, position: ts.LineAndCharacter, doc: HTMLDocument) { const css = this.#getEmbeddedCss(context, position, doc); if (!css) return; - const hover = this.#cssLanguageService.doHover(css.vDoc, css.position, css.style, { + const hover = this.#ctx.cssLanguageService.doHover(css.vDoc, css.position, css.style, { documentation: true, references: true, }); @@ -248,8 +119,8 @@ export class HTMLLanguageService implements TemplateLanguageService { } getQuickInfoAtPosition(context: TemplateContext, position: ts.LineAndCharacter) { - const { vDoc, vHtml } = this.#getHtmlDoc(context); - const htmlHover = this.#htmlLanguageService.doHover(vDoc, position, vHtml, { + const { vDoc, vHtml } = this.#ctx.getHtmlDoc(context.text); + const htmlHover = this.#ctx.htmlLanguageService.doHover(vDoc, position, vHtml, { documentation: true, references: true, }); @@ -258,38 +129,61 @@ export class HTMLLanguageService implements TemplateLanguageService { return translateHover(context, hover, position); } - getSyntacticDiagnostics(context: TemplateContext): ts.Diagnostic[] { - const cached = this.#diagnosticsCache.getCached(context); - if (cached) return cached; + #getCssSyntacticDiagnostics(context: TemplateContext) { + return this.#diagnosticsCache.get(context, undefined, () => { + const { vHtml } = this.#ctx.getHtmlDoc(context.text); + const file = this.#ctx.getProgram().getSourceFile(context.fileName); + const styles = this.#getAllStyleSheet(vHtml); + const diagnostics: ts.Diagnostic[] = []; + styles.forEach((node) => { + const text = context.text.slice(node.startTagEnd, node.endTagStart); + const { vDoc, vCss } = this.#ctx.getCssDoc(text); + this.#ctx.cssLanguageService.doValidation(vDoc, vCss).forEach(({ message, range }) => { + const start = node.startTagEnd! + vDoc.offsetAt(range.start); + const end = node.startTagEnd! + vDoc.offsetAt(range.end); + diagnostics.push({ + category: context.typescript.DiagnosticCategory.Warning, + code: 0, + file, + start, + length: end - start, + messageText: message, + }); + }); + }); + return diagnostics; + }); + } - const { vHtml } = this.#getHtmlDoc(context); - const styles = this.#getAllStyleSheet(vHtml); - const file = this.#ctx.getProgram()!.getSourceFile(context.fileName); - const diagnostics = styles.map((node) => { - const text = context.text.slice(node.startTagEnd, node.endTagStart); - const { vDoc, vCss } = this.#getCssDoc({ fileName: context.fileName, text }); - const oDiagnostics = this.#cssLanguageService.doValidation(vDoc, vCss); - return oDiagnostics.map(({ message, range }) => { - const start = node.startTagEnd! + vDoc.offsetAt(range.start); - const end = node.startTagEnd! + vDoc.offsetAt(range.end); - return { + #getHtmlSyntacticDiagnostics(context: TemplateContext) { + const { vHtml } = this.#ctx.getHtmlDoc(context.text); + const file = this.#ctx.getProgram().getSourceFile(context.fileName); + const diagnostics: ts.Diagnostic[] = []; + forEachNode(vHtml.roots, (node) => { + if (node.tag && isCustomElementTag(node.tag) && !this.#ctx.elements.get(node.tag)) { + diagnostics.push({ category: context.typescript.DiagnosticCategory.Warning, code: 0, file, - start, - length: end - start, - messageText: message, - }; - }); + start: node.start + 1, + length: node.tag.length, + messageText: 'Undefined element', + }); + } }); - return this.#diagnosticsCache.updateCached(context, diagnostics.flat()); + return diagnostics; + } + + getSyntacticDiagnostics(context: TemplateContext): ts.Diagnostic[] { + this.#ctx.initElements(); + return [...this.#getCssSyntacticDiagnostics(context), ...this.#getHtmlSyntacticDiagnostics(context)]; } getDefinitionAndBoundSpan(context: TemplateContext, position: ts.LineAndCharacter): ts.DefinitionInfoAndBoundSpan { // typescript-template-language-service-decorator 根据当前文档位置偏移了 const htmlOffset = context.node.pos + 1; const currentOffset = context.toOffset(position); - const { vHtml } = this.#getHtmlDoc(context); + const { vHtml } = this.#ctx.getHtmlDoc(context.text); const node = vHtml.findNodeAt(currentOffset); const { text, start, length, before } = getHTMLTextAtPosition(context.text, currentOffset); const definitionNode = this.#ctx.elements.get(node.tag!); @@ -319,7 +213,7 @@ export class HTMLLanguageService implements TemplateLanguageService { const { attr, offset } = getAttrName(text); if (before.length > attr.length) return empty; - const typeChecker = this.#ctx.getProgram()!.getTypeChecker(); + const typeChecker = this.#ctx.getProgram().getTypeChecker(); const propSymbol = typeChecker.getTypeAtLocation(definitionNode).getProperty(kebabToCamelCase(attr)); const propDeclaration = propSymbol?.getDeclarations()?.at(0); return { diff --git a/packages/ts-gem-plugin/src/decorate-ts.ts b/packages/ts-gem-plugin/src/decorate-ts.ts index e514a519..42362fe3 100644 --- a/packages/ts-gem-plugin/src/decorate-ts.ts +++ b/packages/ts-gem-plugin/src/decorate-ts.ts @@ -1,89 +1,60 @@ -import type * as ts from 'typescript/lib/tsserverlibrary'; import type { LanguageService } from 'typescript'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import type { Node } from '@mantou/vscode-html-languageservice'; +import { camelToKebabCase } from '@mantou/gem/lib/utils'; -import { - bindMemberFunction, - decorate, - forEachTag, - getAstNodeAtPosition, - getCustomElementTag, - isDepElement, -} from './utils'; -import type { Context } from './configuration'; - -function decorateTypeChecker(ctx: Context, typeChecker: ts.TypeChecker) { - // https://github.com/microsoft/TypeScript/blob/main/src/services/completions.ts#L3789 - // https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L5217 - const internal = typeChecker as unknown as { isValidPropertyAccessForCompletions: (...a: any) => any }; - const checker = bindMemberFunction(internal, ['isValidPropertyAccessForCompletions']); - internal.isValidPropertyAccessForCompletions = (...args: any[]) => { - const result = checker.isValidPropertyAccessForCompletions(...args); - if (!result) return false; - try { - const { declarations } = args.at(2) as ts.Symbol; - if (!declarations) return true; - const isNever = declarations.every( - (node) => ctx.ts.isPropertySignature(node) && node.type?.getText() === 'never', - ); - return !isNever; - } catch { - return true; - } - }; - return typeChecker; -} - -function updateElement(ctx: Context, file: ts.SourceFile) { - const isDep = isDepElement(file); - // 只支持顶级 class 声明 - ctx.ts.forEachChild(file, (node) => { - const tag = getCustomElementTag(ctx, node, isDep); - if (tag && ctx.ts.isClassDeclaration(node)) { - ctx.elements.set(tag, node); - } - }); -} +import type { Context } from './context'; +import { bindMemberFunction, decorate, forEachNode, getHTMLTextAtPosition } from './utils'; export function decorateLanguageService(ctx: Context, languageService: LanguageService) { const { ts, getProgram } = ctx; const ls = bindMemberFunction(languageService); + // `state.|` filter languageService.getCompletionsAtPosition = (...args) => { - const program = getProgram()!; + const program = getProgram(); const typeChecker = program.getTypeChecker(); // 可以移动到更合适的位置 decorate(typeChecker, () => decorateTypeChecker(ctx, typeChecker)); return ls.getCompletionsAtPosition(...args); }; + // `memo/effect` decorate + languageService.getSuggestionDiagnostics = (...args) => { + const program = getProgram(); + const file = program.getSourceFile(args[0])!; + const result = ls.getSuggestionDiagnostics(...args); + + // 更新文档会触发 `getSuggestionDiagnostics` + ctx.updateElement(file); + + return result.filter(({ start, reportsUnnecessary, category }) => { + if (!reportsUnnecessary || category !== ts.DiagnosticCategory.Suggestion) return true; + + const node = getAstNodeAtPosition(ts, file, start); + if (!node || !ts.isPrivateIdentifier(node)) return true; + + const declaration = (node as ts.PrivateIdentifier).parent; + if (!ts.isMethodDeclaration(declaration) && !ts.isPropertyDeclaration(declaration)) return true; + + return !declaration.modifiers?.some((e) => e?.kind === ts.SyntaxKind.Decorator); + }); + }; + languageService.findReferences = (...args) => { const oResult = ls.findReferences(...args) || []; - const program = getProgram()!; - const result: ReturnType = []; + const program = getProgram(); const currentNode = getAstNodeAtPosition(ts, program.getSourceFile(args[0])!, args[1]); if (!currentNode) return oResult; const isIdent = ts.isIdentifier(currentNode); if (!isIdent) return oResult; - const currentTag = - getCustomElementTag(ctx, currentNode.parent) || getCustomElementTag(ctx, currentNode.parent.parent); - const _propName = ts.isClassDeclaration(currentNode.parent.parent) && currentNode.text; + const currentTag = ctx.getTagFromNode(currentNode.parent) || ctx.getTagFromNode(currentNode.parent.parent); + const prop = ts.isClassDeclaration(currentNode.parent.parent) && currentNode; if (!currentTag) return oResult; - for (const file of program.getSourceFiles()) { - if (file.fileName.endsWith('.d.ts')) continue; - const references: ts.ReferencedSymbolEntry[] = []; - // TODO: find props / attr references - forEachTag(ts, file, ({ tag, length, start }) => { - if (tag !== currentTag) return; - references.push({ - fileName: file.fileName, - isWriteAccess: true, - textSpan: { start, length }, - }); - }); - - if (!references.length) continue; - result.push({ - references, + const map = new Map(); + forEachAllHtmlTemplateNode(ctx, currentTag, (file, tagInfo) => { + const symbol = map.get(file.fileName) || { + references: [], definition: { containerKind: ctx.ts.ScriptElementKind.unknown, containerName: '', @@ -93,38 +64,175 @@ export function decorateLanguageService(ctx: Context, languageService: LanguageS name: 'test', kind: ctx.ts.ScriptElementKind.unknown, }, - }); - } - return [...result, ...oResult]; + }; + map.set(file.fileName, symbol); + if (prop) { + const propNames = new Set([`.${prop.text}`]); + const kebabCaseName = camelToKebabCase(prop.text); + ['', '?', '@'].forEach((c) => propNames.add(`${c}${kebabCaseName}`)); + for (const propName of propNames) { + const { attributes = {}, start, startTagEnd } = tagInfo.node; + if (!(propName in attributes)) continue; + const attrStart = file.getFullText().slice(start + tagInfo.offset, startTagEnd! + tagInfo.offset); + const index = attrStart.split(propName)[0].length; + symbol.references.push({ + fileName: file.fileName, + isWriteAccess: true, + textSpan: { start: start + tagInfo.offset + index, length: propName.length }, + }); + } + } else { + symbol.references.push({ + fileName: file.fileName, + isWriteAccess: true, + textSpan: tagInfo.open, + }); + } + }); + return [...map.values(), ...oResult]; }; - languageService.getSuggestionDiagnostics = (...args) => { - const program = getProgram()!; + languageService.getRenameInfo = (fileName, position, ...args) => { + const result = ls.getRenameInfo(fileName, position, ...args); + const tagInfo = findCurrentTagInfo(ctx, fileName, position); + if (tagInfo) { + return { + canRename: true, + displayName: tagInfo.tag, + fullDisplayName: tagInfo.tag, + kind: ts.ScriptElementKind.alias, + kindModifiers: 'tag', + triggerSpan: tagInfo.open, + }; + } + const tagDefinedInfo = findDefinedTagInfo(ctx, fileName, position); + if (tagDefinedInfo) { + return { + canRename: true, + displayName: tagDefinedInfo.tag, + fullDisplayName: tagDefinedInfo.tag, + kind: ts.ScriptElementKind.alias, + kindModifiers: 'tag', + triggerSpan: tagDefinedInfo.textSpan, + }; + } + return result; + }; - // 可以移动到更合适的位置,这里仅仅用来收集自定义元素 - decorate(program, () => { - program.getSourceFiles().forEach((file) => updateElement(ctx, file)); - return program; - }); + languageService.findRenameLocations = (fileName, position, ...args) => { + const tagInfo = findCurrentTagInfo(ctx, fileName, position); + if (tagInfo) { + const result: ts.RenameLocation[] = [{ fileName, textSpan: tagInfo.open }]; + if (tagInfo.end) result.push({ fileName, textSpan: tagInfo.end }); + return result; + } + const tagDefinedInfo = findDefinedTagInfo(ctx, fileName, position); + if (tagDefinedInfo) { + const result: ts.RenameLocation[] = [{ fileName, textSpan: tagDefinedInfo.textSpan }]; + forEachAllHtmlTemplateNode(ctx, tagDefinedInfo.tag, (f, info) => { + result.push({ fileName: f.fileName, textSpan: info.open }); + if (info.end) result.push({ fileName: f.fileName, textSpan: info.end }); + }); + return result; + } + // TODO: prop rename + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return ls.findRenameLocations(fileName, position, ...args); + }; - const file = program.getSourceFile(args[0])!; - const result = ls.getSuggestionDiagnostics(...args); + return languageService; +} - // 更新文档会触发 `getSuggestionDiagnostics` - updateElement(ctx, file); +function getAstNodeAtPosition(typescript: typeof ts, node: ts.Node, pos: number) { + if (node.pos > pos || node.end <= pos) return; + while (node.kind >= typescript.SyntaxKind.FirstNode) { + const nested = typescript.forEachChild(node, (child) => (child.pos <= pos && child.end > pos ? child : undefined)); + if (nested === undefined) break; + node = nested; + } + return node; +} - return result.filter(({ start, reportsUnnecessary, category }) => { - if (!reportsUnnecessary || category !== ts.DiagnosticCategory.Suggestion) return true; +function forEachAllHtmlTemplateNode( + ctx: Context, + tag: string, + fn: (fileName: ts.SourceFile, info: ReturnType) => void, +) { + for (const file of ctx.getProgram().getSourceFiles()) { + if (file.fileName.endsWith('.d.ts')) continue; + for (const templateContext of ctx.htmlSourceHelper.getAllTemplates(file.fileName)) { + const { vHtml } = ctx.getHtmlDoc(templateContext.text); + forEachNode(vHtml.roots, (node) => { + if (node.tag !== tag) return; + fn(file, getTagInfo(node, templateContext.node.getStart() + 1)); + }); + } + } +} - const node = getAstNodeAtPosition(ts, file, start); - if (!node || !ts.isPrivateIdentifier(node)) return true; +function findCurrentTagInfo(ctx: Context, fileName: string, position: number) { + const templateContext = ctx.htmlSourceHelper.getTemplate(fileName, position); + if (!templateContext) return; + const htmlOffset = templateContext.node.pos + 1; + const { vHtml } = ctx.getHtmlDoc(templateContext.text); + const relativePosition = ctx.htmlSourceHelper.getRelativePosition(templateContext, position); + const offset = templateContext.toOffset(relativePosition); + const node = vHtml.findNodeAt(offset); + const { text } = getHTMLTextAtPosition(templateContext.text, offset); + const onTag = offset < node.startTagEnd! && text === node.tag; + if (!onTag || !node.tag) return; + return getTagInfo(node, htmlOffset); +} - const declaration = (node as ts.PrivateIdentifier).parent; - if (!ts.isMethodDeclaration(declaration) && !ts.isPropertyDeclaration(declaration)) return true; +function findDefinedTagInfo(ctx: Context, fileName: string, position: number) { + const file = ctx.getProgram().getSourceFile(fileName)!; + const node = getAstNodeAtPosition(ctx.ts, file, position); + if ( + !node || + !ctx.ts.isStringLiteral(node) || + !ctx.ts.isCallExpression(node.parent) || + node.parent.expression.getText() !== 'customElement' + ) { + return; + } + const tag = node.text; + return { tag, textSpan: { start: node.getStart() + 1, length: tag.length } }; +} - return !declaration.modifiers?.some((e) => e?.kind === ts.SyntaxKind.Decorator); - }); +function getTagInfo(node: Node, offset: number) { + const tag = node.tag!; + const openStart = node.start + 1 + offset; + return { + node, + tag, + offset, + open: { start: openStart, length: tag.length }, + end: node.endTagStart && { + start: node.endTagStart! + 2 + offset, + length: node.end - node.endTagStart! - 3, + }, }; +} - return languageService; +function decorateTypeChecker(ctx: Context, typeChecker: ts.TypeChecker) { + // https://github.com/microsoft/TypeScript/blob/main/src/services/completions.ts#L3789 + // https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L5217 + const internal = typeChecker as unknown as { isValidPropertyAccessForCompletions: (...a: any) => any }; + const checker = bindMemberFunction(internal, ['isValidPropertyAccessForCompletions']); + internal.isValidPropertyAccessForCompletions = (...args: any[]) => { + const result = checker.isValidPropertyAccessForCompletions(...args); + if (!result) return false; + try { + const { declarations } = args.at(2) as ts.Symbol; + if (!declarations) return true; + const isNever = declarations.every( + (node) => ctx.ts.isPropertySignature(node) && node.type?.getText() === 'never', + ); + return !isNever; + } catch { + return true; + } + }; + return typeChecker; } diff --git a/packages/ts-gem-plugin/src/index.ts b/packages/ts-gem-plugin/src/index.ts index bf5ba830..d9d5a97b 100644 --- a/packages/ts-gem-plugin/src/index.ts +++ b/packages/ts-gem-plugin/src/index.ts @@ -4,12 +4,12 @@ import { decorateWithTemplateLanguageService } from '@mantou/typescript-template import type { Logger } from '@mantou/typescript-template-language-service-decorator'; import type * as ts from 'typescript/lib/tsserverlibrary'; +import { Configuration } from './configuration'; +import { Context } from './context'; +import { CSSLanguageService } from './decorate-css'; import { HTMLLanguageService } from './decorate-html'; import { decorateLanguageService } from './decorate-ts'; -import { decorate, getSubstitution, isValidCSSTemplate } from './utils'; -import type { Context } from './configuration'; -import { Configuration, Store } from './configuration'; -import { CSSLanguageService } from './decorate-css'; +import { decorate } from './utils'; class LanguageServiceLogger implements Logger { #info: ts.server.PluginCreateInfo; @@ -36,18 +36,7 @@ class HtmlPlugin { logger.log('Starting ts-gem-plugin...'); this.#config.update(info.config); - const context: Context = { - config: this.#config, - ts: this.#ts, - logger, - elements: new Store(), - getProgram: () => { - return info.languageService.getProgram()!; - }, - getProject: () => { - return info.project; - }, - }; + const context = new Context(this.#ts, this.#config, info, logger); const decoratedService = decorateLanguageService(context, info.languageService); @@ -56,12 +45,7 @@ class HtmlPlugin { decoratedService, info.project, new CSSLanguageService(context), - { - tags: ['styled', 'css'], - enableForStringWithSubstitutions: true, - getSubstitution, - isValidTemplate: (node) => isValidCSSTemplate(this.#ts, node, 'css'), - }, + context.cssTemplateStringSettings, { logger }, ); @@ -70,11 +54,7 @@ class HtmlPlugin { decoratedService1, info.project, new HTMLLanguageService(context), - { - tags: ['html', 'raw', 'h'], - enableForStringWithSubstitutions: true, - getSubstitution, - }, + context.htmlTemplateStringSettings, { logger }, ); }); diff --git a/packages/ts-gem-plugin/src/translates.ts b/packages/ts-gem-plugin/src/translates.ts new file mode 100644 index 00000000..12e50759 --- /dev/null +++ b/packages/ts-gem-plugin/src/translates.ts @@ -0,0 +1,166 @@ +import type { TemplateContext } from '@mantou/typescript-template-language-service-decorator'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import * as vscode from 'vscode-languageserver-types'; + +export function translateCompletionItemsToCompletionInfo( + context: TemplateContext, + items: vscode.CompletionList, +): ts.CompletionInfo { + return { + defaultCommitCharacters: [], + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + entries: items.items.map((x) => translateCompletionEntry(context, x)), + }; +} + +function translateCompletionEntry(context: TemplateContext, vsItem: vscode.CompletionItem): ts.CompletionEntry { + const entry: ts.CompletionEntry = { + name: vsItem.label, + kind: translationCompletionItemKind(context, vsItem.kind), + sortText: '0', + filterText: vsItem.label, + labelDetails: { description: vsItem.detail }, + }; + + if (vsItem.textEdit) { + entry.isSnippet = vsItem.insertTextFormat === vscode.InsertTextFormat.Snippet || undefined; + entry.insertText = vsItem.textEdit.newText; + entry.replacementSpan = 'range' in vsItem.textEdit ? toTsSpan(context, vsItem.textEdit.range) : undefined; + } + + return entry; +} + +function translationCompletionItemKind(context: TemplateContext, kind?: vscode.CompletionItemKind) { + const typescript = context.typescript; + switch (kind) { + case vscode.CompletionItemKind.Method: + return typescript.ScriptElementKind.memberFunctionElement; + case vscode.CompletionItemKind.Function: + return typescript.ScriptElementKind.functionElement; + case vscode.CompletionItemKind.Constructor: + return typescript.ScriptElementKind.constructorImplementationElement; + case vscode.CompletionItemKind.Field: + case vscode.CompletionItemKind.Variable: + return typescript.ScriptElementKind.variableElement; + case vscode.CompletionItemKind.Class: + return typescript.ScriptElementKind.classElement; + case vscode.CompletionItemKind.Interface: + return typescript.ScriptElementKind.interfaceElement; + case vscode.CompletionItemKind.Module: + return typescript.ScriptElementKind.moduleElement; + case vscode.CompletionItemKind.Property: + return typescript.ScriptElementKind.memberVariableElement; + case vscode.CompletionItemKind.Unit: + case vscode.CompletionItemKind.Value: + return typescript.ScriptElementKind.constElement; + case vscode.CompletionItemKind.Enum: + return typescript.ScriptElementKind.enumElement; + case vscode.CompletionItemKind.Keyword: + return typescript.ScriptElementKind.keyword; + case vscode.CompletionItemKind.Color: + return typescript.ScriptElementKind.constElement; + case vscode.CompletionItemKind.Reference: + return typescript.ScriptElementKind.alias; + case vscode.CompletionItemKind.File: + return typescript.ScriptElementKind.moduleElement; + case vscode.CompletionItemKind.Snippet: + case vscode.CompletionItemKind.Text: + default: + return typescript.ScriptElementKind.unknown; + } +} + +function toTsSpan(context: TemplateContext, range: vscode.Range): ts.TextSpan { + const editStart = context.toOffset(range.start); + const editEnd = context.toOffset(range.end); + + return { + start: editStart, + length: editEnd - editStart, + }; +} + +export function translateHover( + context: TemplateContext, + hover: vscode.Hover, + position: ts.LineAndCharacter, + offset = 0, +): ts.QuickInfo { + const typescript = context.typescript; + const header: ts.SymbolDisplayPart[] = []; + const docs: ts.SymbolDisplayPart[] = []; + const convertPart = (hoverContents: typeof hover.contents) => { + if (typeof hoverContents === 'string') { + docs.push({ kind: 'unknown', text: hoverContents }); + } else if (Array.isArray(hoverContents)) { + hoverContents.forEach(convertPart); + } else if ('language' in hoverContents && hoverContents.language === 'html') { + header.push({ kind: 'unknown', text: hoverContents.value }); + } else { + docs.push({ kind: 'unknown', text: hoverContents.value }); + } + }; + convertPart(hover.contents); + const start = context.toOffset(hover.range ? hover.range.start : position); + return { + kind: typescript.ScriptElementKind.string, + kindModifiers: '', + textSpan: { + start: start - offset, + length: hover.range ? context.toOffset(hover.range.end) - start : 1, + }, + displayParts: header, + documentation: docs, + tags: [], + }; +} + +export function translateCompletionItemsToCompletionEntryDetails( + context: TemplateContext, + item: vscode.CompletionItem, +): ts.CompletionEntryDetails { + return { + name: item.label, + kindModifiers: 'declare', + kind: item.kind ? translationCompletionItemKind(context, item.kind) : context.typescript.ScriptElementKind.unknown, + displayParts: toDisplayParts(item.detail), + documentation: toDisplayParts(item.documentation, true), + tags: [], + }; +} + +export function genDefaultCompletionEntryDetails(context: TemplateContext, name: string): ts.CompletionEntryDetails { + return { + name, + kindModifiers: '', + kind: context.typescript.ScriptElementKind.unknown, + displayParts: toDisplayParts(name), + documentation: [], + tags: [], + }; +} + +function toDisplayParts(text: string | vscode.MarkupContent | undefined, isDoc = false): ts.SymbolDisplayPart[] { + if (!text) return []; + + const escape = (unsafe: string) => + unsafe + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll(' ', ' ') + .replaceAll('\n', ' \n') + .replaceAll('\t', ' '); + + return [ + { + kind: 'unknown', + text: typeof text !== 'string' ? text.value : isDoc ? escape(text) : text, + }, + ]; +} diff --git a/packages/ts-gem-plugin/src/utils.ts b/packages/ts-gem-plugin/src/utils.ts index ca7de777..97325e17 100644 --- a/packages/ts-gem-plugin/src/utils.ts +++ b/packages/ts-gem-plugin/src/utils.ts @@ -1,11 +1,4 @@ import type * as ts from 'typescript/lib/tsserverlibrary'; -import * as vscode from 'vscode-languageserver-types'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import type { TemplateContext } from '@mantou/typescript-template-language-service-decorator'; -import type { IValueData } from '@mantou/vscode-html-languageservice'; -import { camelToKebabCase } from '@mantou/gem/lib/utils'; - -import type { Context } from './configuration'; export function isCustomElementTag(tag: string) { return tag.includes('-'); @@ -19,31 +12,13 @@ export function bindMemberFunction(o: T, keys = Object.keys(o) return Object.fromEntries(keys.map((key) => [key, (o as any)[key].bind?.(o)])) as T; } -const OPEN_TAG_REG = /(?<)(?(\w+-)+\w+)\s+/g; -export function forEachTag( - typescript: typeof ts, - containerNode: ts.Node, - fn: (tags: { tag: string; length: number; start: number }) => void, -) { - const list = [containerNode]; +export function forEachNode(roots: T[], fn: (node: T) => void) { + const list = [...roots]; while (true) { const currentNode = list.pop(); if (!currentNode) return; - typescript.forEachChild(currentNode, (node) => { - list.push(node); - if ( - typescript.isTemplateHead(node) || - typescript.isTemplateMiddle(node) || - typescript.isTemplateTail(node) || - typescript.isNoSubstitutionTemplateLiteral(node) - ) { - [...node.text.matchAll(OPEN_TAG_REG)].forEach((e) => { - const { prefix = '', tag } = e.groups!; - const start = node.getStart() + 1 + prefix.length + e.index; - fn({ tag, length: tag.length, start }); - }); - } - }); + fn(currentNode); + list.push(...currentNode.children); } } @@ -65,76 +40,6 @@ export function getAttrName(text: string) { return { attr: attr.slice(offset), offset }; } -// TODO: use typeChecker -const STRING_REG = /("|')(?.*)\1/; -export function getBasicUnionValues(node?: ts.Node) { - const typeText = (node as ts.PropertyDeclaration)?.type?.getText(); - if (!typeText) return; - - const result: IValueData[] = []; - for (const text of typeText.split('|')) { - const t = text.trim(); - if (!t) continue; - const number = Number(t); - if (!Number.isNaN(number)) { - result.push({ name: t }); - continue; - } - const match = t.match(STRING_REG); - if (!match) { - // 有个元素不是字符串也不是数字 - return; - } - result.push({ name: match.groups!.str }); - } - if (result.length) return result; -} - -export function getCustomElementTag({ ts: typescript, config }: Context, node: ts.Node, isDep = isDepElement(node)) { - if (!typescript.isClassDeclaration(node)) return; - - for (const modifier of node.modifiers || []) { - if ( - typescript.isDecorator(modifier) && - typescript.isCallExpression(modifier.expression) && - modifier.expression.expression.getText() === 'customElement' - ) { - const arg = modifier.expression.arguments.at(0); - if (arg && typescript.isStringLiteral(arg)) { - return arg.text; - } - } - } - - // 只有声明文件 - if (isDep && node.name && typescript.isIdentifier(node.name)) { - return config.elementDefineRules.findTag(node.name.text); - } -} - -const COMMENT_LINE_CONTENT = /^(\/?[ *\t]*)?(?.*?)(\**\/)?$/gm; -export function getDocComment(typescript: typeof ts, declaration: ts.Node) { - const fullText = declaration.getSourceFile().getFullText(); - const commentRanges = typescript.getLeadingCommentRanges(fullText, declaration.getFullStart()); - const commentStrings = commentRanges - ?.filter(({ kind }) => kind === typescript.SyntaxKind.MultiLineCommentTrivia) - .map(({ pos, end }) => { - const fullComment = [...fullText.slice(pos, end).matchAll(COMMENT_LINE_CONTENT)]; - return fullComment.map((m) => m.groups!.str).join('\n'); - }); - return commentStrings?.join('\n\n'); -} - -export function getAstNodeAtPosition(typescript: typeof ts, node: ts.Node, pos: number) { - if (node.pos > pos || node.end <= pos) return; - while (node.kind >= typescript.SyntaxKind.FirstNode) { - const nested = typescript.forEachChild(node, (child) => (child.pos <= pos && child.end > pos ? child : undefined)); - if (nested === undefined) break; - node = nested; - } - return node; -} - const marker = Symbol(); /**只调用一次回调函数 */ export function decorate(origin: T, cb: (o: T) => T): T { @@ -143,198 +48,3 @@ export function decorate(origin: T, cb: (o: T) => T): T { (result as any)[marker] = true; return result; } - -export function createVirtualDocument(languageId: string, content: string) { - return TextDocument.create(`embedded://document.${languageId}`, languageId, 1, content); -} - -export function getSubstitution(templateString: string, start: number, end: number) { - return templateString.slice(start, end).replaceAll(/[^\n]/g, '_'); -} - -export function isValidCSSTemplate( - typescript: typeof ts, - node: ts.NoSubstitutionTemplateLiteral | ts.TaggedTemplateExpression | ts.TemplateExpression, - callName: string, -) { - switch (node.kind) { - case typescript.SyntaxKind.NoSubstitutionTemplateLiteral: - case typescript.SyntaxKind.TemplateExpression: - const parent = node.parent; - if (typescript.isCallExpression(parent) && parent.expression.getText() === callName) { - return true; - } - if (typescript.isPropertyAssignment(parent)) { - const call = parent.parent.parent; - if (typescript.isCallExpression(call) && call.expression.getText() === callName) { - return true; - } - } - return false; - default: - return false; - } -} - -export function translateCompletionItemsToCompletionInfo( - context: TemplateContext, - items: vscode.CompletionList, -): ts.CompletionInfo { - return { - defaultCommitCharacters: [], - isGlobalCompletion: false, - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: items.items.map((x) => translateCompletionEntry(context, x)), - }; -} - -function translateCompletionEntry(context: TemplateContext, vsItem: vscode.CompletionItem): ts.CompletionEntry { - const entry: ts.CompletionEntry = { - name: vsItem.label, - kind: translationCompletionItemKind(context, vsItem.kind), - sortText: '0', - filterText: vsItem.label, - labelDetails: { description: vsItem.detail }, - }; - - if (vsItem.textEdit) { - entry.isSnippet = vsItem.insertTextFormat === vscode.InsertTextFormat.Snippet || undefined; - entry.insertText = vsItem.textEdit.newText; - entry.replacementSpan = 'range' in vsItem.textEdit ? toTsSpan(context, vsItem.textEdit.range) : undefined; - } - - return entry; -} - -function translationCompletionItemKind(context: TemplateContext, kind?: vscode.CompletionItemKind) { - const typescript = context.typescript; - switch (kind) { - case vscode.CompletionItemKind.Method: - return typescript.ScriptElementKind.memberFunctionElement; - case vscode.CompletionItemKind.Function: - return typescript.ScriptElementKind.functionElement; - case vscode.CompletionItemKind.Constructor: - return typescript.ScriptElementKind.constructorImplementationElement; - case vscode.CompletionItemKind.Field: - case vscode.CompletionItemKind.Variable: - return typescript.ScriptElementKind.variableElement; - case vscode.CompletionItemKind.Class: - return typescript.ScriptElementKind.classElement; - case vscode.CompletionItemKind.Interface: - return typescript.ScriptElementKind.interfaceElement; - case vscode.CompletionItemKind.Module: - return typescript.ScriptElementKind.moduleElement; - case vscode.CompletionItemKind.Property: - return typescript.ScriptElementKind.memberVariableElement; - case vscode.CompletionItemKind.Unit: - case vscode.CompletionItemKind.Value: - return typescript.ScriptElementKind.constElement; - case vscode.CompletionItemKind.Enum: - return typescript.ScriptElementKind.enumElement; - case vscode.CompletionItemKind.Keyword: - return typescript.ScriptElementKind.keyword; - case vscode.CompletionItemKind.Color: - return typescript.ScriptElementKind.constElement; - case vscode.CompletionItemKind.Reference: - return typescript.ScriptElementKind.alias; - case vscode.CompletionItemKind.File: - return typescript.ScriptElementKind.moduleElement; - case vscode.CompletionItemKind.Snippet: - case vscode.CompletionItemKind.Text: - default: - return typescript.ScriptElementKind.unknown; - } -} - -function toTsSpan(context: TemplateContext, range: vscode.Range): ts.TextSpan { - const editStart = context.toOffset(range.start); - const editEnd = context.toOffset(range.end); - - return { - start: editStart, - length: editEnd - editStart, - }; -} - -export function translateHover( - context: TemplateContext, - hover: vscode.Hover, - position: ts.LineAndCharacter, - offset = 0, -): ts.QuickInfo { - const typescript = context.typescript; - const header: ts.SymbolDisplayPart[] = []; - const docs: ts.SymbolDisplayPart[] = []; - const convertPart = (hoverContents: typeof hover.contents) => { - if (typeof hoverContents === 'string') { - docs.push({ kind: 'unknown', text: hoverContents }); - } else if (Array.isArray(hoverContents)) { - hoverContents.forEach(convertPart); - } else if ('language' in hoverContents && hoverContents.language === 'html') { - header.push({ kind: 'unknown', text: hoverContents.value }); - } else { - docs.push({ kind: 'unknown', text: hoverContents.value }); - } - }; - convertPart(hover.contents); - const start = context.toOffset(hover.range ? hover.range.start : position); - return { - kind: typescript.ScriptElementKind.string, - kindModifiers: '', - textSpan: { - start: start - offset, - length: hover.range ? context.toOffset(hover.range.end) - start : 1, - }, - displayParts: header, - documentation: docs, - tags: [], - }; -} - -export function translateCompletionItemsToCompletionEntryDetails( - context: TemplateContext, - item: vscode.CompletionItem, -): ts.CompletionEntryDetails { - return { - name: item.label, - kindModifiers: 'declare', - kind: item.kind ? translationCompletionItemKind(context, item.kind) : context.typescript.ScriptElementKind.unknown, - displayParts: toDisplayParts(item.detail), - documentation: toDisplayParts(item.documentation, true), - tags: [], - }; -} - -export function genDefaultCompletionEntryDetails(context: TemplateContext, name: string): ts.CompletionEntryDetails { - return { - name, - kindModifiers: '', - kind: context.typescript.ScriptElementKind.unknown, - displayParts: toDisplayParts(name), - documentation: [], - tags: [], - }; -} - -function toDisplayParts(text: string | vscode.MarkupContent | undefined, isDoc = false): ts.SymbolDisplayPart[] { - if (!text) return []; - - const escape = (unsafe: string) => - unsafe - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", ''') - .replaceAll(' ', ' ') - .replaceAll('\n', ' \n') - .replaceAll('\t', ' '); - - return [ - { - kind: 'unknown', - text: typeof text !== 'string' ? text.value : isDoc ? escape(text) : text, - }, - ]; -}