From 59d6640e39eb328862cbbf07aa4e76cd0558e297 Mon Sep 17 00:00:00 2001 From: Sascha Tandel Date: Sun, 21 Mar 2021 19:53:58 +0100 Subject: [PATCH 1/5] feat: autocompletion for jsx, style and styled --- src/language-service.ts | 38 ++-- src/{load-twind-config.ts => load.ts} | 18 +- src/match.ts | 83 +++++++++ src/parser.test.ts | 32 ++++ src/parser.ts | 4 +- src/plugin.ts | 180 +++++++++++++------ src/source-helper.ts | 239 ++++++++++++++++++++++++++ src/source-matcher.ts | 149 ++++++++++++++++ src/twind.ts | 71 +++++++- 9 files changed, 738 insertions(+), 76 deletions(-) rename src/{load-twind-config.ts => load.ts} (88%) create mode 100644 src/match.ts create mode 100644 src/source-helper.ts create mode 100644 src/source-matcher.ts diff --git a/src/language-service.ts b/src/language-service.ts index 3b772b6..6ed5c57 100644 --- a/src/language-service.ts +++ b/src/language-service.ts @@ -68,7 +68,7 @@ const prepareText = (value: string): string => .replace(/\d(?!$|\d)/g, '$& ') .replace(/\s+/g, ' ') -export class TwindTemplateLanguageService implements TemplateLanguageService { +export class TwindLanguageService implements TemplateLanguageService { private readonly typescript: typeof ts private readonly configurationManager: ConfigurationManager private readonly logger: Logger @@ -234,11 +234,11 @@ export class TwindTemplateLanguageService implements TemplateLanguageService { switch (info.id) { case 'UNKNOWN_DIRECTIVE': { return { - messageText: `Unknown utility "${rule.name}"`, + messageText: `Unknown utility "${info.rule}"`, start: rule.loc.start, length: rule.loc.end - rule.loc.start, file: context.node.getSourceFile(), - category: this.typescript.DiagnosticCategory.Warning, + category: this.typescript.DiagnosticCategory.Error, code: ErrorCodes.UNKNOWN_DIRECTIVE, } } @@ -251,17 +251,36 @@ export class TwindTemplateLanguageService implements TemplateLanguageService { start: rule.loc.start, length: rule.loc.end - rule.loc.start, file: context.node.getSourceFile(), - category: this.typescript.DiagnosticCategory.Warning, + category: this.typescript.DiagnosticCategory.Error, code: ErrorCodes.UNKNOWN_THEME_VALUE, } } } } }) + // Check non-empty directive + .concat( + rule.name + ? undefined + : { + messageText: `Missing utility class`, + start: rule.loc.start, + length: rule.loc.end - rule.loc.start, + file: context.node.getSourceFile(), + category: this.typescript.DiagnosticCategory.Error, + code: ErrorCodes.UNKNOWN_DIRECTIVE, + }, + ) // check if every rule.variants exist .concat( rule.variants - .filter((variant) => !this._twind.completions.variants.has(variant.value)) + .filter( + (variant) => + !( + this._twind.completions.variants.has(variant.value) || + (variant.value[0] == '[' && variant.value[variant.value.length - 2] == ']') + ), + ) .map( (variant): ts.Diagnostic => ({ messageText: `Unknown variant "${variant.value}"`, @@ -390,13 +409,8 @@ export class TwindTemplateLanguageService implements TemplateLanguageService { keys: [(completion) => prepareText(completion.value + ' ' + completion.detail)], baseSort, }), - ...matchSorter(utilities, needle, { - // threshold: matchSorter.rankings.ACRONYM, - keys: [(completion) => prepareText(completion.value)], - baseSort, - }), - ...matchSorter(variants, needle, { - // threshold: matchSorter.rankings.ACRONYM, + ...matchSorter([...utilities, ...variants], needle, { + // threshold: matchSorter.rankings.MATCHES, keys: [(completion) => prepareText(completion.value)], baseSort, }), diff --git a/src/load-twind-config.ts b/src/load.ts similarity index 88% rename from src/load-twind-config.ts rename to src/load.ts index 35bc3c3..62862b0 100644 --- a/src/load-twind-config.ts +++ b/src/load.ts @@ -31,10 +31,10 @@ export const findConfig = (cwd = process.cwd()): string | undefined => findUp.sync(TWIND_CONFIG_FILES, { cwd }) || findUp.sync(TAILWIND_CONFIG_FILES, { cwd }) -export const loadConfig = (configFile: string, cwd = process.cwd()): Configuration => { +export const loadFile = (file: string, cwd = process.cwd()): T => { const result = buildSync({ bundle: true, - entryPoints: [configFile], + entryPoints: [file], format: 'cjs', platform: 'node', target: 'es2018', // `node${process.versions.node}`, @@ -60,13 +60,19 @@ export const loadConfig = (configFile: string, cwd = process.cwd()): Configurati result.outputFiles[0].text, )( module.exports, - Module.createRequire?.(configFile) || Module.createRequireFromPath(configFile), + Module.createRequire?.(file) || Module.createRequireFromPath(file), module, - configFile, - Path.dirname(configFile), + file, + Path.dirname(file), ) - const config = module.exports.default || module.exports || {} + return module.exports as T +} + +export const loadConfig = (configFile: string, cwd = process.cwd()): Configuration => { + const exports = loadFile<{ default: Configuration } & Configuration>(configFile, cwd) + + const config = exports.default || exports || {} // could be tailwind config if ( diff --git a/src/match.ts b/src/match.ts new file mode 100644 index 0000000..d1861a8 --- /dev/null +++ b/src/match.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type Predicate = + | ((this: undefined, value: any, key: undefined, object: any, matcher: undefined) => unknown) + | ((this: T, value: any, key: string, object: any, matcher: T) => unknown) + +export interface RegExpLike { + /** + * Returns a Boolean value that indicates whether or not a pattern exists in a searched string. + * @param string String on which to perform the search. + */ + test(string: string): boolean +} + +export type Matcher = Predicate | Predicates | RegExp | unknown + +/** + * Defines the predicate properties to be invoked with the corresponding property values of a given object. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Predicates extends Record { + // Support cyclic references +} + +export function match( + value: unknown, + predicate: Matcher, + key?: string | undefined, + object?: unknown, + matcher?: Matcher | Matcher[], +): boolean { + if (isEqual(value, predicate)) { + return true + } + + if (Array.isArray(predicate)) { + return predicate.some((item) => match(value, item, key, object, matcher)) + } + + if (typeof predicate == 'function') { + return Boolean(predicate.call(matcher, value, key, object, matcher)) + } + + if (typeof value == 'string' && isRegExpLike(predicate)) { + return predicate.test(value) + } + + if (isObjectLike(value) && isObjectLike(predicate)) { + return Object.keys(predicate).every((key) => + match((value as any)[key], (predicate as any)[key], key, value, predicate), + ) + } + + return false +} + +/** + * Performs a [SameValueZero](https://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) comparison + * between two values to determine if they are equivalent. + * + * **Note** SameValueZero differs from SameValue only in its treatment of `+0` and `-0`. + * For SameValue comparison use `Object.is()`. + */ +function isEqual(value: unknown, other: unknown): boolean { + return value === other || (Number.isNaN(value) && Number.isNaN(other)) +} + +/** + * Return `true` if `value` is an object-like. + * + * A value is object-like if it's not `null` and has a `typeof` result of `"object"`. + * + * **Note** Keep in mind that functions are objects too. + * + * @param value to check + */ +// eslint-disable-next-line @typescript-eslint/ban-types +function isObjectLike(value: unknown): value is object { + return value != null && typeof value == 'object' +} + +function isRegExpLike(value: unknown): value is RegExpLike { + return isObjectLike(value) && typeof (value as RegExp).test == 'function' +} diff --git a/src/parser.test.ts b/src/parser.test.ts index b96f4cd..ee16da8 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -14,6 +14,38 @@ const test = suite('Parser') ;([ ['', []], [' \t \n\r ', []], + [ + 'focus:', + [ + { + raw: '', + value: 'focus:', + name: '', + prefix: '', + important: false, + negated: false, + loc: { start: 6, end: 6 }, + spans: [{ start: 0, end: 6 }], + variants: [{ name: 'focus', raw: 'focus:', value: 'focus:', loc: { start: 0, end: 6 } }], + }, + ], + ], + [ + 'focus: ', + [ + { + raw: '', + value: 'focus:', + name: '', + prefix: '', + important: false, + negated: false, + loc: { start: 6, end: 6 }, + spans: [{ start: 0, end: 6 }], + variants: [{ name: 'focus', raw: 'focus:', value: 'focus:', loc: { start: 0, end: 6 } }], + }, + ], + ], [ 'underline', [ diff --git a/src/parser.ts b/src/parser.ts index 36f9b49..691a538 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -330,7 +330,7 @@ export function astish(text: string, atPosition = Infinity): Group { case '\t': case '\n': case '\r': - if (buffer) { + if (buffer || node.kind == NodeKind.Variant) { parent = node = node.next = createIdentifier( node, parent, @@ -358,7 +358,7 @@ export function astish(text: string, atPosition = Infinity): Group { } // Consume remaining buffer or completion triggered at the end - if (buffer || atPosition === text.length) { + if (buffer || node.kind == NodeKind.Variant || atPosition === text.length) { node.next = createIdentifier(node, parent, buffer, start, true) } diff --git a/src/plugin.ts b/src/plugin.ts index f3a85c4..51e5915 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,11 +1,48 @@ import type * as ts from 'typescript/lib/tsserverlibrary' -import type { TemplateSettings } from 'typescript-template-language-service-decorator' +import type { + TemplateContext, + TemplateSettings, +} from 'typescript-template-language-service-decorator' + +import StandardScriptSourceHelper from 'typescript-template-language-service-decorator/lib/standard-script-source-helper' -import { decorateWithTemplateLanguageService } from 'typescript-template-language-service-decorator' import { ConfigurationManager, TwindPluginConfiguration } from './configuration' -import { TwindTemplateLanguageService } from './language-service' +import { TwindLanguageService } from './language-service' +import { StandardTemplateSourceHelper } from './source-helper' import { LanguageServiceLogger } from './logger' import { getSubstitutions } from './substituter' +import { getSourceMatchers } from './source-matcher' + +// https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/standard-template-source-helper.ts#L75 + +const translateTextSpan = (context: TemplateContext, span: ts.TextSpan): ts.TextSpan => { + return { + start: context.node.getStart() + 1 + span.start, + length: span.length, + } +} + +const translateCompletionInfo = ( + context: TemplateContext, + info: ts.CompletionInfo, +): ts.CompletionInfo => { + return { + ...info, + entries: info.entries.map((entry) => translateCompletionEntry(context, entry)), + } +} + +const translateCompletionEntry = ( + context: TemplateContext, + entry: ts.CompletionEntry, +): ts.CompletionEntry => { + return { + ...entry, + replacementSpan: entry.replacementSpan + ? translateTextSpan(context, entry.replacementSpan) + : undefined, + } +} export class TwindPlugin { private readonly typescript: typeof ts @@ -22,62 +59,99 @@ export class TwindPlugin { this._logger.log('config: ' + JSON.stringify(this._configManager.config)) + const { languageService } = info + if (!isValidTypeScriptVersion(this.typescript)) { this._logger.log('Invalid typescript version detected. TypeScript 4.1 required.') - return info.languageService + return languageService } - // Set up decorator - // const { languageService } = info - - // this.typescript.parseIsolatedEntityName(text, languageVersion) - // info.languageService = { - // ...info.languageService, - - // getCompletionEntrySymbol(fileName, position, name, source) { - // const prior = languageService.getCompletionEntrySymbol(fileName, position, name, source) - - // logger.log( - // 'getCompletionEntrySymbol: ' + JSON.stringify({ fileName, position, name, source }), - // ) - - // // prior.entries = prior.entries.filter((e) => e.name !== 'caller') - // return prior - // }, - // getCompletionsAtPosition: (fileName, position, options) => { - // // emmetCompletions: false - // const prior = languageService.getCompletionsAtPosition(fileName, position, options) - - // // options?.triggerCharacter - // // TODO match file [t]sx? - // const sourceFile = info.languageService.getProgram()?.getSourceFile(fileName) - // sourceFile?.getLineAndCharacterOfPosition(position) - // sourceFile?.getText() - // // IDEA: find last "'` before position - - // // logger.log('getCompletionsAtPosition: ' + JSON.stringify({ fileName, position })) - - // console.log('') - // console.log('') - // console.log( - // 'getCompletionsAtPosition', - // JSON.stringify({ fileName, position, options, prior }, null, 2), - // ) - // console.log('') - // console.log('') - // // prior.entries = prior.entries.filter((e) => e.name !== 'caller') - // return prior - // }, - // } - - return decorateWithTemplateLanguageService( + const ttls = new TwindLanguageService(this.typescript, info, this._configManager, this._logger) + + const templateSettings = getTemplateSettings(this._configManager, this._logger) + + const helper = new StandardTemplateSourceHelper( this.typescript, - info.languageService, - info.project, - new TwindTemplateLanguageService(this.typescript, info, this._configManager, this._logger), - getTemplateSettings(this._configManager, this._logger), - { logger: this._logger }, + templateSettings, + new StandardScriptSourceHelper(this.typescript, info.project), + getSourceMatchers(this.typescript, templateSettings), ) + + // Set up decorator + return { + ...languageService, + + getCompletionEntryDetails: (fileName, position, name, ...rest: any[]) => { + const context = helper.getTemplate(fileName, position) + + if (context) { + return ttls.getCompletionEntryDetails( + context, + helper.getRelativePosition(context, position), + name, + ) + } + + return (languageService.getCompletionsAtPosition as any)(fileName, position, name, ...rest) + }, + + getCompletionsAtPosition: (fileName, position, options) => { + const context = helper.getTemplate(fileName, position) + + if (context) { + return translateCompletionInfo( + context, + ttls.getCompletionsAtPosition(context, helper.getRelativePosition(context, position)), + ) + } + + return languageService.getCompletionsAtPosition(fileName, position, options) + }, + + getQuickInfoAtPosition: (fileName, position) => { + const context = helper.getTemplate(fileName, position) + + if (context) { + const quickInfo = ttls.getQuickInfoAtPosition( + context, + helper.getRelativePosition(context, position), + ) + + if (quickInfo) { + return { + ...quickInfo, + textSpan: translateTextSpan(context, quickInfo.textSpan), + } + } + } + + return languageService.getQuickInfoAtPosition(fileName, position) + }, + + getSemanticDiagnostics: (fileName) => { + const diagnostics = [...languageService.getSemanticDiagnostics(fileName)] + + helper.getAllTemplates(fileName).forEach((context) => { + for (const diagnostic of ttls.getSemanticDiagnostics(context)) { + diagnostics.push({ + ...diagnostic, + start: context.node.getStart() + 1 + (diagnostic.start || 0), + }) + } + }) + + return diagnostics + }, + } + + // return decorateWithTemplateLanguageService( + // this.typescript, + // info.languageService, + // info.project, + // new TwindLanguageService(this.typescript, info, this._configManager, this._logger), + // getTemplateSettings(this._configManager, this._logger), + // { logger: this._logger }, + // ) } public onConfigurationChanged(config: TwindPluginConfiguration): void { diff --git a/src/source-helper.ts b/src/source-helper.ts new file mode 100644 index 0000000..a6649dc --- /dev/null +++ b/src/source-helper.ts @@ -0,0 +1,239 @@ +// Based on https://github.com/microsoft/typescript-template-language-service-decorator/blob/26deaa4fc4af1237a94a44e033e92514077fbede/src/standard-template-source-helper.ts + +import type * as ts from 'typescript/lib/tsserverlibrary' + +import type { + TemplateContext, + TemplateSettings, +} from 'typescript-template-language-service-decorator' +import type ScriptSourceHelper from 'typescript-template-language-service-decorator/lib/script-source-helper' +import type TemplateSourceHelper from 'typescript-template-language-service-decorator/lib/template-source-helper' +import { relative } from 'typescript-template-language-service-decorator/lib/nodes' +import { match, Matcher, Predicates } from './match' + +class PlaceholderSubstituter { + public static replacePlaceholders( + typescript: typeof ts, + settings: TemplateSettings, + node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral, + ): string { + const literalContents = node.getText().slice(1, -1) + if (node.kind === typescript.SyntaxKind.NoSubstitutionTemplateLiteral) { + return literalContents + } + + return PlaceholderSubstituter.getSubstitutions( + settings, + literalContents, + PlaceholderSubstituter.getPlaceholderSpans(node), + ) + } + + private static getPlaceholderSpans(node: ts.TemplateExpression) { + const spans: Array<{ start: number; end: number }> = [] + const stringStart = node.getStart() + 1 + + let nodeStart = node.head.end - stringStart - 2 + for (const child of node.templateSpans.map((x) => x.literal)) { + const start = child.getStart() - stringStart + 1 + spans.push({ start: nodeStart, end: start }) + nodeStart = child.getEnd() - stringStart - 2 + } + return spans + } + + private static getSubstitutions( + settings: TemplateSettings, + contents: string, + locations: ReadonlyArray<{ start: number; end: number }>, + ): string { + if (settings.getSubstitutions) { + return settings.getSubstitutions(contents, locations) + } + + const parts: string[] = [] + let lastIndex = 0 + for (const span of locations) { + parts.push(contents.slice(lastIndex, span.start)) + parts.push(this.getSubstitution(settings, contents, span.start, span.end)) + lastIndex = span.end + } + parts.push(contents.slice(lastIndex)) + return parts.join('') + } + + private static getSubstitution( + settings: TemplateSettings, + templateString: string, + start: number, + end: number, + ): string { + return settings.getSubstitution + ? settings.getSubstitution(templateString, start, end) + : 'x'.repeat(end - start) + } +} + +class StandardTemplateContext /* implements TemplateContext */ { + constructor( + public readonly typescript: typeof ts, + public readonly fileName: string, + public readonly node: ts.StringLiteralLike | ts.TemplateLiteral, + private readonly helper: ScriptSourceHelper, + private readonly templateSettings: TemplateSettings, + ) {} + + public toOffset(position: ts.LineAndCharacter): number { + const docOffset = this.helper.getOffset( + this.fileName, + position.line + this.stringBodyPosition.line, + position.line === 0 + ? this.stringBodyPosition.character + position.character + : position.character, + ) + return docOffset - this.stringBodyOffset + } + + public toPosition(offset: number): ts.LineAndCharacter { + const docPosition = this.helper.getLineAndChar(this.fileName, this.stringBodyOffset + offset) + return relative(this.stringBodyPosition, docPosition) + } + + // @memoize + private get stringBodyOffset(): number { + return this.node.getStart() + 1 + } + + // @memoize + private get stringBodyPosition(): ts.LineAndCharacter { + return this.helper.getLineAndChar(this.fileName, this.stringBodyOffset) + } + + // @memoize + public get text(): string { + return this.typescript.isTemplateExpression(this.node) + ? PlaceholderSubstituter.replacePlaceholders( + this.typescript, + this.templateSettings, + this.node, + ) + : this.node.text + } + + // @memoize + public get rawText(): string { + return this.node.getText().slice(1, -1) + } +} + +export class StandardTemplateSourceHelper implements TemplateSourceHelper { + constructor( + private readonly typescript: typeof ts, + private readonly templateStringSettings: TemplateSettings, + private readonly helper: ScriptSourceHelper, + private readonly sourceMatchers: Matcher[], + ) {} + + public getTemplate(fileName: string, position: number): TemplateContext | undefined { + const node = this.getValidTemplateNode(this.helper.getNode(fileName, position)) + + if (!node) { + return undefined + } + + // Make sure we are inside the template string + if (position <= node.pos) { + return undefined + } + + // Make sure we are not inside of a placeholder + if (this.typescript.isTemplateExpression(node)) { + let start = node.head.end + for (const child of node.templateSpans.map((x) => x.literal)) { + const nextStart = child.getStart() + if (position >= start && position <= nextStart) { + return undefined + } + start = child.getEnd() + } + } + + return new StandardTemplateContext( + this.typescript, + fileName, + node, + this.helper, + this.templateStringSettings, + ) as TemplateContext + } + + public getAllTemplates(fileName: string): readonly TemplateContext[] { + return this.helper + .getAllNodes(fileName, (node) => this.getValidTemplateNode(node) !== undefined) + .map( + (node) => + new StandardTemplateContext( + this.typescript, + fileName, + this.getValidTemplateNode(node) as ts.StringLiteralLike, + this.helper, + this.templateStringSettings, + ) as TemplateContext, + ) + } + + public getRelativePosition(context: TemplateContext, offset: number): ts.LineAndCharacter { + const baseLC = this.helper.getLineAndChar(context.fileName, context.node.getStart() + 1) + const cursorLC = this.helper.getLineAndChar(context.fileName, offset) + return relative(baseLC, cursorLC) + } + + private getValidTemplateNode( + node: ts.Node | undefined, + ): ts.StringLiteralLike | ts.TemplateLiteral | undefined { + if (!node) { + return undefined + } + + if (this.typescript.isTaggedTemplateExpression(node)) { + return this.getValidTemplateNode(node.template) + } + + // TODO if templateStringSettings.enableForStringWithSubstitutions + if (this.typescript.isTemplateHead(node) || this.typescript.isTemplateSpan(node)) { + return this.getValidTemplateNode(node.parent) + } + + if (this.typescript.isTemplateMiddle(node) || this.typescript.isTemplateTail(node)) { + return this.getValidTemplateNode(node.parent) + } + + // TODO Identifier, TemplateHead, TemplateMiddle, TemplateTail + // export type StringLiteralLike = StringLiteral | NoSubstitutionTemplateLiteral; + // export type PropertyNameLiteral = Identifier | StringLiteralLike | NumericLiteral; + if ( + !( + this.typescript.isStringLiteralLike(node) || + this.typescript.isTemplateLiteral(node) || + this.typescript.isTemplateExpression(node) + ) + ) { + return undefined + } + + let currentNode: ts.Node = node + + while (currentNode && !this.typescript.isSourceFile(currentNode)) { + if (match(currentNode, this.sourceMatchers)) { + return node + } + + if (this.typescript.isCallLikeExpression(currentNode)) { + return undefined + } + + // TODO stop conditions + currentNode = currentNode.parent + } + } +} diff --git a/src/source-matcher.ts b/src/source-matcher.ts new file mode 100644 index 0000000..9777af8 --- /dev/null +++ b/src/source-matcher.ts @@ -0,0 +1,149 @@ +import type { TemplateSettings } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary' +import type { Matcher } from './match' + +export const getSourceMatchers = ( + { SyntaxKind }: typeof ts, + templateStringSettings: TemplateSettings, +): Matcher[] => [ + // tw`...` + { + kind: SyntaxKind.TaggedTemplateExpression, + // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/nodes.ts#L62 + // TODO styled.button, styled() + tag: { + kind: SyntaxKind.Identifier, + text: templateStringSettings.tags, + }, + }, + // tw(...) + { + kind: SyntaxKind.CallExpression, + // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/nodes.ts#L62 + // TODO styled.button, styled() + expression: { + kind: SyntaxKind.Identifier, + text: templateStringSettings.tags, + }, + }, + // JsxAttribute -> className="" + { + kind: SyntaxKind.JsxAttribute, + name: { + kind: SyntaxKind.Identifier, + text: ['tw', 'class', 'className'], + }, + }, + // { '@apply': `...` } + { + // Do not match the @apply property itself + text: (value: string) => value !== '@apply', + parent: { + kind: SyntaxKind.PropertyAssignment, + name: { + kind: SyntaxKind.StringLiteral, + text: '@apply', + }, + }, + }, + // style({ base: '' }) + { + kind: SyntaxKind.PropertyAssignment, + name: { + kind: [SyntaxKind.Identifier, SyntaxKind.StringLiteral], + text: 'base', + }, + initializer: (node: ts.Node) => node.kind != SyntaxKind.ObjectLiteralExpression, + // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/nodes.ts#L62 + // TODO styled.button, styled() + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.CallExpression, + expression: { + kind: SyntaxKind.Identifier, + text: ['style', 'styled'], + }, + }, + }, + }, + // style({ matches: [{ use: '' }] }) + { + kind: SyntaxKind.PropertyAssignment, + name: { + kind: [SyntaxKind.Identifier, SyntaxKind.StringLiteral], + text: 'use', + }, + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.ArrayLiteralExpression, + parent: { + kind: SyntaxKind.PropertyAssignment, + name: { + kind: [SyntaxKind.Identifier, SyntaxKind.StringLiteral], + text: 'matches', + }, + // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/nodes.ts#L62 + // TODO styled.button, styled() + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.CallExpression, + expression: { + kind: SyntaxKind.Identifier, + text: ['style', 'styled'], + }, + }, + }, + }, + }, + }, + }, + // style({ variants: { [...]: { [...]: '...' }} }) + { + kind: SyntaxKind.PropertyAssignment, + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.PropertyAssignment, + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.PropertyAssignment, + name: { + kind: [SyntaxKind.Identifier, SyntaxKind.StringLiteral], + text: 'variants', + }, + // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/nodes.ts#L62 + // TODO styled.button, styled() + parent: { + kind: SyntaxKind.ObjectLiteralExpression, + parent: { + kind: SyntaxKind.CallExpression, + expression: { + kind: SyntaxKind.Identifier, + text: ['style', 'styled'], + }, + }, + }, + }, + }, + }, + }, + }, + // Debug helper + // (value: ts.Node): boolean => { + // if (value?.kind == SyntaxKind.JsxAttribute) { + // console.log() + // console.log(value.kind, value.getText()) + // console.log(Object.keys(value)) + // console.log((value as any).name.kind, (value as any).name.text) + // // console.log((value as any).name?.kind) + + // // console.log(value.parent.kind, value.getText()) + // console.log() + // } + // return false + // }, +] diff --git a/src/twind.ts b/src/twind.ts index 142f5ee..a7d9d8c 100644 --- a/src/twind.ts +++ b/src/twind.ts @@ -21,11 +21,12 @@ import type { } from 'twind' import { theme, create, silent } from 'twind' import { VirtualSheet, virtualSheet } from 'twind/sheets' -import { getConfig } from './load-twind-config' +import { getConfig } from './load' import { getColor } from './colors' import type { ConfigurationManager } from './configuration' import { watch } from './watch' +import { parse } from './parser' const isCSSProperty = (key: string, value: CSSRuleValue): boolean => !'@:&'.includes(key[0]) && ('rg'.includes((typeof value)[5]) || Array.isArray(value)) @@ -300,7 +301,7 @@ export class Twind { return state && generateCSS(state.sheet, state.tw, rule) } - getDiagnostics(...tokens: Token[]): ReportInfo[] | undefined { + getDiagnostics(rule: string): ReportInfo[] | undefined { const { state } = this if (!state) { @@ -308,7 +309,62 @@ export class Twind { } state.sheet.reset() - state.tw(...tokens) + + // verifiy rule with types: align-xxx -> invalid + const { completions } = this + + for (const parsed of parse(rule)) { + const [, arbitrayValue] = parsed.name.match(/-(\[[^\]]+])/) || [] + + const utilitiyExists = + !parsed.name || + completions.tokens.some((completion) => { + if (completion.kind != 'utility') return false + + if (arbitrayValue) { + return ( + completion.theme && + completion.raw.replace(`{{theme(${completion.theme?.section})}}`, arbitrayValue) === + parsed.name + ) + } + + switch (completion.interpolation) { + case 'string': { + return parsed.name.startsWith(completion.value) && parsed.name != completion.value + } + case 'number': { + return ( + parsed.name.startsWith(completion.value) && + parsed.name != completion.value && + Number(parsed.name.slice(completion.value.length)) >= 0 + ) + } + case 'nonzero': { + return ( + parsed.name.startsWith(completion.value) && + parsed.name != completion.value && + Number(parsed.name.slice(completion.value.length)) > 0 + ) + } + default: { + return completion.value == parsed.name + } + } + }) + + if (!utilitiyExists) { + state.reports.push({ + id: 'UNKNOWN_DIRECTIVE', + rule: parsed.name, + }) + } + } + + if (!state.reports.length) { + state.tw(rule) + } + return [...state.reports] } @@ -347,6 +403,7 @@ export class Twind { const visit = (node: TS.Node) => { if (tokens.length) return + // TODO use CoreCompletionTokens and UserCompletionTokens if ( ts.isTypeAliasDeclaration(node) && ts.isIdentifier(node.name) && @@ -393,6 +450,7 @@ export class Twind { kind, raw, value, + interpolation, label, color, theme, @@ -495,6 +553,11 @@ export class Twind { ) } } else { + const x = createCompletionToken(prefix, { + raw: directive, + label: directive, + interpolation: value as CompletionToken['interpolation'], + }) completionTokens.set( prefix, createCompletionToken(prefix, { @@ -503,6 +566,8 @@ export class Twind { interpolation: value as CompletionToken['interpolation'], }), ) + + this.logger.log(`interpolation: "${x.value}" (${x.interpolation})`) } }) From ce3d3d79f83e324f10684d8feeb08d94d2ef7640 Mon Sep 17 00:00:00 2001 From: Sascha Tandel Date: Sun, 21 Mar 2021 21:36:09 +0100 Subject: [PATCH 2/5] fix: use typescript to lookup files, config file options --- package.json | 3 - src/configuration.ts | 14 +++-- src/language-service.ts | 12 ++-- src/load.ts | 25 ++++++--- src/plugin.ts | 13 ++--- src/source-helper.ts | 2 +- src/substituter.ts | 7 --- src/twind.ts | 118 ++++++++++++++++++++++++---------------- src/watch.ts | 16 ++++-- yarn.lock | 7 --- 10 files changed, 122 insertions(+), 95 deletions(-) delete mode 100644 src/substituter.ts diff --git a/package.json b/package.json index 80fbd1a..2462b7f 100644 --- a/package.json +++ b/package.json @@ -97,10 +97,7 @@ "dependencies": { "cssbeautify": "^0.3.1", "esbuild": "^0.9.3", - "import-from": "^3.0.0", - "locate-path": "^6.0.0", "match-sorter": "^6.3.0", - "resolve-from": "^5.0.0", "twind": "^0.16.6", "typescript": "^4.1.0", "typescript-template-language-service-decorator": "^2.2.0", diff --git a/src/configuration.ts b/src/configuration.ts index e392ca0..210b475 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,7 +1,7 @@ export interface TwindPluginConfiguration { readonly tags: ReadonlyArray - readonly configFile?: string; - readonly debug: boolean + readonly configFile?: string + readonly debug?: boolean // Readonly validate: boolean; // readonly lint: { [key: string]: any }; // readonly emmet: { [key: string]: any }; @@ -24,10 +24,13 @@ export class ConfigurationManager { return this._configuration } - private _configuration: TwindPluginConfiguration = ConfigurationManager.defaultConfiguration + private _configuration: TwindPluginConfiguration = { + ...ConfigurationManager.defaultConfiguration, + tags: [...ConfigurationManager.defaultConfiguration.tags], + } public updateFromPluginConfig(config: Partial = {}): void { - const mergedConfig = { + const { tags, ...mergedConfig } = { ...ConfigurationManager.defaultConfiguration, ...config, } @@ -35,7 +38,10 @@ export class ConfigurationManager { this._configuration = { ...mergedConfig, debug: 'true' == String(mergedConfig.debug), + tags: this._configuration.tags, } + ;(this._configuration.tags as string[]).length = 0 + ;(this._configuration.tags as string[]).push(...tags) for (const listener of this._configUpdatedListeners) { listener() diff --git a/src/language-service.ts b/src/language-service.ts index 6ed5c57..bdd644f 100644 --- a/src/language-service.ts +++ b/src/language-service.ts @@ -194,7 +194,10 @@ export class TwindLanguageService implements TemplateLanguageService { ): ts.QuickInfo | undefined { const rules = parse(context.text, context.toOffset(position)) - const rule = rules.map((rule) => rule.value).join(' ') + const rule = rules + .filter((rule) => !/\${x*}/.test(rule.value)) + .map((rule) => rule.value) + .join(' ') if (!rule) { return undefined @@ -264,8 +267,8 @@ export class TwindLanguageService implements TemplateLanguageService { ? undefined : { messageText: `Missing utility class`, - start: rule.loc.start, - length: rule.loc.end - rule.loc.start, + start: rule.spans[0].start, + length: rule.spans[rule.spans.length - 1].end - rule.spans[0].start, file: context.node.getSourceFile(), category: this.typescript.DiagnosticCategory.Error, code: ErrorCodes.UNKNOWN_DIRECTIVE, @@ -278,7 +281,8 @@ export class TwindLanguageService implements TemplateLanguageService { (variant) => !( this._twind.completions.variants.has(variant.value) || - (variant.value[0] == '[' && variant.value[variant.value.length - 2] == ']') + (variant.value[0] == '[' && variant.value[variant.value.length - 2] == ']') || + /\${x*}/.test(variant.value) ), ) .map( diff --git a/src/load.ts b/src/load.ts index 62862b0..e5e8748 100644 --- a/src/load.ts +++ b/src/load.ts @@ -1,9 +1,9 @@ +import type * as ts from 'typescript/lib/tsserverlibrary' + import * as Path from 'path' import Module from 'module' import { buildSync } from 'esbuild' -import findUp from 'find-up' -import locatePath from 'locate-path' import type { Configuration } from 'twind' @@ -24,12 +24,18 @@ const TAILWIND_CONFIG_FILES = [ // TODO use typescript to check files // this.typescript.server.toNormalizedPath(fileName) // info.project.containsFile() -export const findConfig = (cwd = process.cwd()): string | undefined => - locatePath.sync(TWIND_CONFIG_FILES.map((file) => Path.resolve(cwd, 'config', file))) || - locatePath.sync(TWIND_CONFIG_FILES.map((file) => Path.resolve(cwd, 'src', file))) || - locatePath.sync(TWIND_CONFIG_FILES.map((file) => Path.resolve(cwd, 'public', file))) || - findUp.sync(TWIND_CONFIG_FILES, { cwd }) || - findUp.sync(TAILWIND_CONFIG_FILES, { cwd }) +export const findConfig = (project: ts.server.Project, cwd = process.cwd()): string | undefined => { + const locatePath = (files: string[]) => + files.map((file) => Path.resolve(cwd, file)).find((file) => project.fileExists(file)) + + return ( + locatePath(TWIND_CONFIG_FILES.map((file) => Path.join('config', file))) || + locatePath(TWIND_CONFIG_FILES.map((file) => Path.join('src', file))) || + locatePath(TWIND_CONFIG_FILES.map((file) => Path.join('public', file))) || + locatePath(TWIND_CONFIG_FILES) || + locatePath(TAILWIND_CONFIG_FILES) + ) +} export const loadFile = (file: string, cwd = process.cwd()): T => { const result = buildSync({ @@ -107,10 +113,11 @@ export const loadConfig = (configFile: string, cwd = process.cwd()): Configurati } export const getConfig = ( + project: ts.server.Project, cwd = process.cwd(), configFile?: string, ): Configuration & { configFile: string | undefined } => { - configFile ??= findConfig(cwd) + configFile ??= findConfig(project, cwd) return { ...(configFile ? loadConfig(Path.resolve(cwd, configFile), cwd) : {}), diff --git a/src/plugin.ts b/src/plugin.ts index 51e5915..b338aac 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,7 +10,6 @@ import { ConfigurationManager, TwindPluginConfiguration } from './configuration' import { TwindLanguageService } from './language-service' import { StandardTemplateSourceHelper } from './source-helper' import { LanguageServiceLogger } from './logger' -import { getSubstitutions } from './substituter' import { getSourceMatchers } from './source-matcher' // https://github.com/microsoft/typescript-template-language-service-decorator/blob/main/src/standard-template-source-helper.ts#L75 @@ -68,7 +67,7 @@ export class TwindPlugin { const ttls = new TwindLanguageService(this.typescript, info, this._configManager, this._logger) - const templateSettings = getTemplateSettings(this._configManager, this._logger) + const templateSettings = getTemplateSettings(this._configManager) const helper = new StandardTemplateSourceHelper( this.typescript, @@ -163,18 +162,14 @@ export class TwindPlugin { } } -export function getTemplateSettings( - configManager: ConfigurationManager, - logger: LanguageServiceLogger, -): TemplateSettings { +export function getTemplateSettings(configManager: ConfigurationManager): TemplateSettings { return { get tags() { return configManager.config.tags }, enableForStringWithSubstitutions: true, - getSubstitutions(templateString, spans): string { - logger.log(`getSubstitutions: ${JSON.stringify(templateString)} (${JSON.stringify(spans)})`) - return getSubstitutions(/* templateString, spans */) + getSubstitution(templateString, start, end) { + return `\${${'x'.repeat(end - start - 3)}}` }, } } diff --git a/src/source-helper.ts b/src/source-helper.ts index a6649dc..00c1085 100644 --- a/src/source-helper.ts +++ b/src/source-helper.ts @@ -9,7 +9,7 @@ import type { import type ScriptSourceHelper from 'typescript-template-language-service-decorator/lib/script-source-helper' import type TemplateSourceHelper from 'typescript-template-language-service-decorator/lib/template-source-helper' import { relative } from 'typescript-template-language-service-decorator/lib/nodes' -import { match, Matcher, Predicates } from './match' +import { match, Matcher } from './match' class PlaceholderSubstituter { public static replacePlaceholders( diff --git a/src/substituter.ts b/src/substituter.ts deleted file mode 100644 index 8fbfa28..0000000 --- a/src/substituter.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function getSubstitutions(): string { - // _contents: string, - // _spans: ReadonlyArray<{ start: number; end: number }>, - const parts: string[] = [] - - return parts.join('') -} diff --git a/src/twind.ts b/src/twind.ts index a7d9d8c..d5ca159 100644 --- a/src/twind.ts +++ b/src/twind.ts @@ -3,8 +3,6 @@ import * as path from 'path' import type { Logger } from 'typescript-template-language-service-decorator' import type * as TS from 'typescript/lib/tsserverlibrary' -import resolveFrom from 'resolve-from' -import importFrom from 'import-from' import cssbeautify from 'cssbeautify' import type { @@ -17,11 +15,10 @@ import type { TW, Configuration, ReportInfo, - Token, } from 'twind' import { theme, create, silent } from 'twind' import { VirtualSheet, virtualSheet } from 'twind/sheets' -import { getConfig } from './load' +import { getConfig, loadFile } from './load' import { getColor } from './colors' import type { ConfigurationManager } from './configuration' @@ -198,9 +195,7 @@ export interface Completions { } export class Twind { - private readonly typescript: typeof TS - private readonly info: ts.server.PluginCreateInfo - private readonly logger: Logger + private _watchers: (() => void)[] = [] private _completions: Completions | undefined private _state: | { @@ -210,25 +205,28 @@ export class Twind { tw: TW context: Context config: Configuration + twindDTSSourceFile: TS.SourceFile | undefined } | undefined constructor( - typescript: typeof TS, - info: ts.server.PluginCreateInfo, - configurationManager: ConfigurationManager, - logger: Logger, + private readonly typescript: typeof TS, + private readonly info: ts.server.PluginCreateInfo, + private readonly configurationManager: ConfigurationManager, + private readonly logger: Logger, ) { - this.typescript = typescript - this.info = info - this.logger = logger - configurationManager.onUpdatedConfig(() => this._reset()) // TODO watch changes to package.json, package-lock.json, yarn.lock, pnpm-lock.yaml + ;['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'].forEach((file) => { + watch(path.resolve(info.project.getCurrentDirectory(), file), () => this._reset()) + }) } private _reset(): void { + this.logger.log('reset state') this._state = this._completions = undefined + this._watchers.forEach((unwatch) => unwatch()) + this._watchers.length = 0 } private get state() { @@ -242,13 +240,17 @@ export class Twind { return undefined } - const { configFile, ...config } = getConfig(program.getCurrentDirectory()) + const { configFile, ...config } = getConfig( + this.info.project, + program.getCurrentDirectory(), + this.configurationManager.config.configFile, + ) if (configFile) { this.logger.log(`Loaded twind config from ${configFile}`) - // Resez all state on config file changes - watch(configFile, () => this._reset()) + // Reset all state on config file changes + this._watchers.push(watch(configFile, () => this._reset(), { once: true })) } else { this.logger.log(`No twind config found`) } @@ -259,18 +261,50 @@ export class Twind { reports.length = 0 }) - // Load twind from project - // TODO Use esbuild and watch twindPackageFile + // Prefer project twind and fallback to bundled twind + const twindDTSFile = this.info.project + .resolveModuleNames(['twind'], program.getRootFileNames()[0]) + .map((moduleName) => moduleName?.resolvedFileName)[0] + + const twindDTSSourceFile = + (twindDTSFile && + program.getSourceFiles().find((sourceFile) => sourceFile.fileName == twindDTSFile)) || + program + .getSourceFiles() + .find((sourceFile) => sourceFile.fileName.endsWith('twind/twind.d.ts')) + + this.logger.log('twindDTSSourceFile: ' + twindDTSSourceFile?.fileName) + + if (twindDTSSourceFile) { + this._watchers.push(watch(twindDTSSourceFile.fileName, () => this._reset(), { once: true })) + } + + const twindFile = twindDTSSourceFile?.fileName.replace(/\.d\.ts/, '.js') + if (twindFile) { + this._watchers.push(watch(twindFile, () => this._reset(), { once: true })) + } + + this.logger.log('twindFile: ' + twindFile) + const { tw } = ( - (importFrom.silent(program.getCurrentDirectory(), 'twind') as typeof import('twind')) - ?.create || create + (twindFile && + (loadFile(twindFile, program.getCurrentDirectory()) as typeof import('twind'))?.create) || + create )({ ...config, sheet, mode: { ...silent, report: (info) => { - reports.push(info) + // Ignore error from substitions + if ( + !( + (info.id === 'UNKNOWN_DIRECTIVE' && /\${x*}/.test(info.rule)) || + (info.id === 'UNKNOWN_THEME_VALUE' && /\${x*}/.test(String(info.key))) + ) + ) { + reports.push(info) + } }, }, plugins: { @@ -289,8 +323,16 @@ export class Twind { return '' }) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this._state = { program, sheet, tw, reports, context: context!, config } + this._state = { + program, + sheet, + tw, + reports, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + context: context!, + config, + twindDTSSourceFile, + } return this._state } @@ -314,6 +356,8 @@ export class Twind { const { completions } = this for (const parsed of parse(rule)) { + if (/\${x*}/.test(parsed.name)) continue + const [, arbitrayValue] = parsed.name.match(/-(\[[^\]]+])/) || [] const utilitiyExists = @@ -385,20 +429,7 @@ export class Twind { let tokens: string[] = [] - // Prefer project twind and fallback to bundled twind - const twindPackageFile = resolveFrom.silent(program.getCurrentDirectory(), 'twind/package.json') - this.logger.log('twindPackageFile: ' + twindPackageFile) - - const twindDTSSourceFile = - (twindPackageFile && - program.getSourceFile(path.resolve(path.dirname(twindPackageFile), 'twind.d.ts'))) || - program - .getSourceFiles() - .find((sourceFile) => sourceFile.fileName.endsWith('twind/twind.d.ts')) - - this.logger.log('twindDTSSourceFile: ' + twindDTSSourceFile?.fileName) - - if (twindDTSSourceFile) { + if (state.twindDTSSourceFile) { const { typescript: ts } = this const visit = (node: TS.Node) => { if (tokens.length) return @@ -422,7 +453,7 @@ export class Twind { } // Walk the tree to search for classes - this.typescript.forEachChild(twindDTSSourceFile, visit) + this.typescript.forEachChild(state.twindDTSSourceFile, visit) } // Add plugins and variants from loaded config @@ -553,11 +584,6 @@ export class Twind { ) } } else { - const x = createCompletionToken(prefix, { - raw: directive, - label: directive, - interpolation: value as CompletionToken['interpolation'], - }) completionTokens.set( prefix, createCompletionToken(prefix, { @@ -566,8 +592,6 @@ export class Twind { interpolation: value as CompletionToken['interpolation'], }), ) - - this.logger.log(`interpolation: "${x.value}" (${x.interpolation})`) } }) diff --git a/src/watch.ts b/src/watch.ts index ed0b571..721abca 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -3,7 +3,8 @@ import * as fs from 'fs' export function watch( file: string, listener: (event: 'rename' | 'change', filename: string) => void, -): void { + { once }: { once?: boolean } = {}, +): () => void { try { const watcher = fs.watch( file, @@ -11,13 +12,20 @@ export function watch( persistent: false, }, (event, filename) => { - watcher.close() + if (once) watcher.close() + listener(event, filename) }, ) + + return () => watcher.close() } catch (error) { - if (error.code !== 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { - throw error + if (['ERR_FEATURE_UNAVAILABLE_ON_PLATFORM', 'ENOENT'].includes(error.code)) { + return () => { + /* no-op*/ + } } + + throw error } } diff --git a/yarn.lock b/yarn.lock index a4d6221..9c4401e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -759,13 +759,6 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-from@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966" - integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ== - dependencies: - resolve-from "^5.0.0" - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" From a872f15e301d284f53c948435c5c28d956f171ad Mon Sep 17 00:00:00 2001 From: Sascha Tandel Date: Sun, 21 Mar 2021 22:02:02 +0100 Subject: [PATCH 3/5] feat: attributes and styles configurable --- src/configuration.ts | 11 ++++++----- src/plugin.ts | 19 +++---------------- src/source-helper.ts | 36 ++++++++++++++++++++++++++++++------ src/source-matcher.ts | 16 ++++++++-------- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index 210b475..760088b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,6 +1,8 @@ export interface TwindPluginConfiguration { - readonly tags: ReadonlyArray readonly configFile?: string + readonly tags: ReadonlyArray + readonly attributes: ReadonlyArray + readonly styles: ReadonlyArray readonly debug?: boolean // Readonly validate: boolean; // readonly lint: { [key: string]: any }; @@ -10,6 +12,8 @@ export interface TwindPluginConfiguration { export class ConfigurationManager { private static readonly defaultConfiguration: TwindPluginConfiguration = { tags: ['tw', 'apply'], + attributes: ['tw', 'class', 'className'], + styles: ['style', 'styled'], debug: false, // Validate: true, // lint: { @@ -30,7 +34,7 @@ export class ConfigurationManager { } public updateFromPluginConfig(config: Partial = {}): void { - const { tags, ...mergedConfig } = { + const mergedConfig = { ...ConfigurationManager.defaultConfiguration, ...config, } @@ -38,10 +42,7 @@ export class ConfigurationManager { this._configuration = { ...mergedConfig, debug: 'true' == String(mergedConfig.debug), - tags: this._configuration.tags, } - ;(this._configuration.tags as string[]).length = 0 - ;(this._configuration.tags as string[]).push(...tags) for (const listener of this._configUpdatedListeners) { listener() diff --git a/src/plugin.ts b/src/plugin.ts index b338aac..f1d9b91 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -67,19 +67,17 @@ export class TwindPlugin { const ttls = new TwindLanguageService(this.typescript, info, this._configManager, this._logger) - const templateSettings = getTemplateSettings(this._configManager) - const helper = new StandardTemplateSourceHelper( this.typescript, - templateSettings, + this._configManager, new StandardScriptSourceHelper(this.typescript, info.project), - getSourceMatchers(this.typescript, templateSettings), ) // Set up decorator return { ...languageService, + // eslint-disable-next-line @typescript-eslint/no-explicit-any getCompletionEntryDetails: (fileName, position, name, ...rest: any[]) => { const context = helper.getTemplate(fileName, position) @@ -91,6 +89,7 @@ export class TwindPlugin { ) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (languageService.getCompletionsAtPosition as any)(fileName, position, name, ...rest) }, @@ -162,18 +161,6 @@ export class TwindPlugin { } } -export function getTemplateSettings(configManager: ConfigurationManager): TemplateSettings { - return { - get tags() { - return configManager.config.tags - }, - enableForStringWithSubstitutions: true, - getSubstitution(templateString, start, end) { - return `\${${'x'.repeat(end - start - 3)}}` - }, - } -} - function isValidTypeScriptVersion(typescript: typeof ts): boolean { const [major, minor] = typescript.version.split('.') diff --git a/src/source-helper.ts b/src/source-helper.ts index 00c1085..a054a4b 100644 --- a/src/source-helper.ts +++ b/src/source-helper.ts @@ -9,7 +9,9 @@ import type { import type ScriptSourceHelper from 'typescript-template-language-service-decorator/lib/script-source-helper' import type TemplateSourceHelper from 'typescript-template-language-service-decorator/lib/template-source-helper' import { relative } from 'typescript-template-language-service-decorator/lib/nodes' +import type { ConfigurationManager } from './configuration' import { match, Matcher } from './match' +import { getSourceMatchers } from './source-matcher' class PlaceholderSubstituter { public static replacePlaceholders( @@ -126,13 +128,35 @@ class StandardTemplateContext /* implements TemplateContext */ { } } +export function getTemplateSettings(configManager: ConfigurationManager): TemplateSettings { + return { + get tags() { + return configManager.config.tags + }, + enableForStringWithSubstitutions: true, + getSubstitution(templateString, start, end) { + return `\${${'x'.repeat(end - start - 3)}}` + }, + } +} + export class StandardTemplateSourceHelper implements TemplateSourceHelper { + private templateSettings: TemplateSettings + private sourceMatchers: Matcher[] + constructor( private readonly typescript: typeof ts, - private readonly templateStringSettings: TemplateSettings, + private readonly configManager: ConfigurationManager, private readonly helper: ScriptSourceHelper, - private readonly sourceMatchers: Matcher[], - ) {} + ) { + this.templateSettings = getTemplateSettings(this.configManager) + this.sourceMatchers = getSourceMatchers(this.typescript, this.configManager.config) + + configManager.onUpdatedConfig(() => { + this.templateSettings = getTemplateSettings(this.configManager) + this.sourceMatchers = getSourceMatchers(this.typescript, this.configManager.config) + }) + } public getTemplate(fileName: string, position: number): TemplateContext | undefined { const node = this.getValidTemplateNode(this.helper.getNode(fileName, position)) @@ -163,7 +187,7 @@ export class StandardTemplateSourceHelper implements TemplateSourceHelper { fileName, node, this.helper, - this.templateStringSettings, + this.templateSettings, ) as TemplateContext } @@ -177,7 +201,7 @@ export class StandardTemplateSourceHelper implements TemplateSourceHelper { fileName, this.getValidTemplateNode(node) as ts.StringLiteralLike, this.helper, - this.templateStringSettings, + this.templateSettings, ) as TemplateContext, ) } @@ -199,7 +223,7 @@ export class StandardTemplateSourceHelper implements TemplateSourceHelper { return this.getValidTemplateNode(node.template) } - // TODO if templateStringSettings.enableForStringWithSubstitutions + // TODO if templateSettings.enableForStringWithSubstitutions if (this.typescript.isTemplateHead(node) || this.typescript.isTemplateSpan(node)) { return this.getValidTemplateNode(node.parent) } diff --git a/src/source-matcher.ts b/src/source-matcher.ts index 9777af8..b322537 100644 --- a/src/source-matcher.ts +++ b/src/source-matcher.ts @@ -1,10 +1,10 @@ -import type { TemplateSettings } from 'typescript-template-language-service-decorator' import type * as ts from 'typescript/lib/tsserverlibrary' import type { Matcher } from './match' +import type { TwindPluginConfiguration } from './configuration' export const getSourceMatchers = ( { SyntaxKind }: typeof ts, - templateStringSettings: TemplateSettings, + config: TwindPluginConfiguration, ): Matcher[] => [ // tw`...` { @@ -13,7 +13,7 @@ export const getSourceMatchers = ( // TODO styled.button, styled() tag: { kind: SyntaxKind.Identifier, - text: templateStringSettings.tags, + text: config.tags, }, }, // tw(...) @@ -23,7 +23,7 @@ export const getSourceMatchers = ( // TODO styled.button, styled() expression: { kind: SyntaxKind.Identifier, - text: templateStringSettings.tags, + text: config.tags, }, }, // JsxAttribute -> className="" @@ -31,7 +31,7 @@ export const getSourceMatchers = ( kind: SyntaxKind.JsxAttribute, name: { kind: SyntaxKind.Identifier, - text: ['tw', 'class', 'className'], + text: config.attributes, }, }, // { '@apply': `...` } @@ -62,7 +62,7 @@ export const getSourceMatchers = ( kind: SyntaxKind.CallExpression, expression: { kind: SyntaxKind.Identifier, - text: ['style', 'styled'], + text: config.styles, }, }, }, @@ -92,7 +92,7 @@ export const getSourceMatchers = ( kind: SyntaxKind.CallExpression, expression: { kind: SyntaxKind.Identifier, - text: ['style', 'styled'], + text: config.styles, }, }, }, @@ -123,7 +123,7 @@ export const getSourceMatchers = ( kind: SyntaxKind.CallExpression, expression: { kind: SyntaxKind.Identifier, - text: ['style', 'styled'], + text: config.styles, }, }, }, From b74aad9e4d7304c137cc2a32cf91a85f4c1ca745 Mon Sep 17 00:00:00 2001 From: Sascha Tandel Date: Sun, 21 Mar 2021 23:09:41 +0100 Subject: [PATCH 4/5] chore --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5a4755..1e87b73 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ declare module 'twind' { } ``` -> If no `twind.config.{ts,js,cjs,mjs}` and a `tailwind.config.{ts,js,cjs,mjs}` exists, the compatible values from the tailwind config will be used. +> If no `twind.config.{ts,js,cjs,mjs}` exists and a `tailwind.config.{ts,js,cjs,mjs}` is found, the compatible values from the tailwind config will be used. ### With VS Code From 2a468cfdfcf6ebad04c573708ce5b36676fa8faa Mon Sep 17 00:00:00 2001 From: Sascha Tandel Date: Sun, 21 Mar 2021 23:14:46 +0100 Subject: [PATCH 5/5] v0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2462b7f..5c99b26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@twind/typescript-plugin", - "version": "0.2.4", + "version": "0.3.0", "description": "TypeScript language service plugin that adds IntelliSense for twind", "//": "mark as private to prevent accidental publish - use 'yarn release'", "private": true,