diff --git a/.changepacks/changepack_log_uSWu1SaFG_oJCtG2jljdM.json b/.changepacks/changepack_log_uSWu1SaFG_oJCtG2jljdM.json new file mode 100644 index 0000000..ded1335 --- /dev/null +++ b/.changepacks/changepack_log_uSWu1SaFG_oJCtG2jljdM.json @@ -0,0 +1 @@ +{"changes":{"package.json":"Minor"},"note":"Support type, declare, import, export expression","date":"2026-03-15T08:33:55.986255800Z"} \ No newline at end of file diff --git a/README.md b/README.md index c669662..92a1ba1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Visually distinguish Server Components and Client Components in React / Next.js projects directly in your editor. +[![Visual Studio Marketplace](https://img.shields.io/visual-studio-marketplace/v/devfive.react-component-lens)](https://marketplace.visualstudio.com/items?itemName=devfive.react-component-lens) + +![React Component Lens demo](medias/demo.png) + ## Why In Next.js App Router and React Server Components, the boundary between server and client execution is critical for performance and bundle size. But JSX like `` gives no visual cue about where it runs. diff --git a/bunfig.toml b/bunfig.toml index 365c50a..0a0611e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,5 @@ [test] coverage = true coverageSkipTestFiles = true -# coverageThreshold = 1 +coverageThreshold = 1 coverageReporter = ["text", "lcov"] diff --git a/medias/demo.png b/medias/demo.png new file mode 100644 index 0000000..33b6783 Binary files /dev/null and b/medias/demo.png differ diff --git a/package.json b/package.json index e4ea4bd..18af91b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,31 @@ "maximum": 2000, "description": "Debounce delay, in milliseconds, before recomputing decorations after workspace changes." }, + "reactComponentLens.scope.element": { + "type": "boolean", + "default": true, + "description": "Highlight JSX element tags (, )." + }, + "reactComponentLens.scope.declaration": { + "type": "boolean", + "default": true, + "description": "Highlight component declaration names (function, class, variable)." + }, + "reactComponentLens.scope.export": { + "type": "boolean", + "default": true, + "description": "Highlight component names in export declarations (export { Component }, export default Component)." + }, + "reactComponentLens.scope.import": { + "type": "boolean", + "default": true, + "description": "Highlight component names in import declarations (import { Component } from ...)." + }, + "reactComponentLens.scope.type": { + "type": "boolean", + "default": true, + "description": "Highlight TypeScript interface and type alias declaration names." + }, "reactComponentLens.highlightColors": { "type": "object", "default": { diff --git a/src/analyzer.ts b/src/analyzer.ts index fb11ca1..06a53ac 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -4,6 +4,14 @@ import type { ImportResolver, SourceHost } from './resolver' export type ComponentKind = 'client' | 'server' | 'unknown' +export interface ScopeConfig { + declaration: boolean + element: boolean + export: boolean + import: boolean + type: boolean +} + export interface ComponentUsage { kind: Exclude ranges: DecorationSegment[] @@ -26,11 +34,23 @@ interface CachedDirective { signature: string } +interface NamedRange { + name: string + ranges: DecorationSegment[] +} + +interface LocalComponent { + kind: Exclude + ranges: DecorationSegment[] +} + interface FileAnalysis { - imports: Map + exportReferences: NamedRange[] + imports: Map jsxTags: JsxTagReference[] - localComponentNames: Set + localComponents: Map ownComponentKind: Exclude + typeIdentifiers: TypeIdentifier[] } interface JsxTagReference { @@ -39,6 +59,12 @@ interface JsxTagReference { tagName: string } +interface TypeIdentifier { + enclosingComponent: string | undefined + name: string + ranges: DecorationSegment[] +} + export class ComponentLensAnalyzer { private readonly analysisCache = new Map() private readonly directiveCache = new Map() @@ -63,97 +89,188 @@ export class ComponentLensAnalyzer { filePath: string, sourceText: string, signature: string, + scope: ScopeConfig = { + declaration: true, + element: true, + export: true, + import: true, + type: true, + }, ): Promise { const analysis = this.getAnalysis(filePath, sourceText, signature) if (!analysis) { return [] } - const resolvedPaths = new Map() - const uniqueFilePaths = new Set() + const usages: ComponentUsage[] = [] + let resolvedPaths: Map | undefined + let componentKinds: Map | undefined - for (const jsxTag of analysis.jsxTags) { - const lookupName = jsxTag.lookupName - if ( - analysis.localComponentNames.has(lookupName) || - resolvedPaths.has(lookupName) - ) { - continue - } + if ((scope.element || scope.import) && analysis.imports.size > 0) { + resolvedPaths = new Map() + componentKinds = new Map() + const uniqueFilePaths = new Set() - const importSource = analysis.imports.get(lookupName) - if (!importSource) { - continue + for (const [lookupName, entry] of analysis.imports) { + if ( + analysis.localComponents.has(lookupName) || + resolvedPaths.has(lookupName) + ) { + continue + } + + const resolvedFilePath = this.resolver.resolveImport( + filePath, + entry.source, + ) + if (resolvedFilePath) { + resolvedPaths.set(lookupName, resolvedFilePath) + uniqueFilePaths.add(resolvedFilePath) + } } - const resolvedFilePath = this.resolver.resolveImport( - filePath, - importSource, - ) - if (resolvedFilePath) { - resolvedPaths.set(lookupName, resolvedFilePath) - uniqueFilePaths.add(resolvedFilePath) + if (uniqueFilePaths.size > 0) { + await Promise.all( + Array.from(uniqueFilePaths, (resolvedPath) => + this.getFileComponentKind(resolvedPath).then((kind) => { + componentKinds!.set(resolvedPath, kind) + }), + ), + ) } } - const componentKinds = new Map() - await Promise.all( - Array.from(uniqueFilePaths, (resolvedPath) => - this.getFileComponentKind(resolvedPath).then((kind) => { - componentKinds.set(resolvedPath, kind) - }), - ), - ) + if (scope.element) { + const jsxTags = analysis.jsxTags + for (let i = 0; i < jsxTags.length; i++) { + const jsxTag = jsxTags[i]! + const localComponent = analysis.localComponents.get(jsxTag.lookupName) + if (localComponent) { + usages.push({ + kind: localComponent.kind, + ranges: jsxTag.ranges, + sourceFilePath: filePath, + tagName: jsxTag.tagName, + }) + continue + } - const usages: ComponentUsage[] = [] + if (!resolvedPaths) { + continue + } + + const resolvedFilePath = resolvedPaths.get(jsxTag.lookupName) + if (!resolvedFilePath) { + continue + } + + const componentKind = componentKinds!.get(resolvedFilePath) + if (!componentKind || componentKind === 'unknown') { + continue + } - for (const jsxTag of analysis.jsxTags) { - if (analysis.localComponentNames.has(jsxTag.lookupName)) { usages.push({ - kind: analysis.ownComponentKind, + kind: componentKind, ranges: jsxTag.ranges, - sourceFilePath: filePath, + sourceFilePath: resolvedFilePath, tagName: jsxTag.tagName, }) - continue } + } - const resolvedFilePath = resolvedPaths.get(jsxTag.lookupName) - if (!resolvedFilePath) { - continue + if (scope.import && resolvedPaths) { + for (const [name, entry] of analysis.imports) { + const resolvedFilePath = resolvedPaths.get(name) + if (!resolvedFilePath) { + continue + } + + const componentKind = componentKinds!.get(resolvedFilePath) + if (!componentKind || componentKind === 'unknown') { + continue + } + + usages.push({ + kind: componentKind, + ranges: entry.ranges, + sourceFilePath: resolvedFilePath, + tagName: name, + }) } + } - const componentKind = componentKinds.get(resolvedFilePath) - if (!componentKind || componentKind === 'unknown') { - continue + if (scope.declaration) { + for (const [name, component] of analysis.localComponents) { + usages.push({ + kind: component.kind, + ranges: component.ranges, + sourceFilePath: filePath, + tagName: name, + }) + } + } + + if (scope.type) { + const typeUsageKinds = new Map< + string, + Exclude + >() + const deferredDeclarations: TypeIdentifier[] = [] + const typeIds = analysis.typeIdentifiers + + for (let i = 0; i < typeIds.length; i++) { + const typeId = typeIds[i]! + if (typeId.enclosingComponent) { + const kind = + analysis.localComponents.get(typeId.enclosingComponent)?.kind ?? + analysis.ownComponentKind + if (!typeUsageKinds.has(typeId.name) || kind === 'client') { + typeUsageKinds.set(typeId.name, kind) + } + usages.push({ + kind, + ranges: typeId.ranges, + sourceFilePath: filePath, + tagName: typeId.name, + }) + } else { + deferredDeclarations.push(typeId) + } } - usages.push({ - kind: componentKind, - ranges: jsxTag.ranges, - sourceFilePath: resolvedFilePath, - tagName: jsxTag.tagName, - }) + for (let i = 0; i < deferredDeclarations.length; i++) { + const typeId = deferredDeclarations[i]! + usages.push({ + kind: typeUsageKinds.get(typeId.name) ?? analysis.ownComponentKind, + ranges: typeId.ranges, + sourceFilePath: filePath, + tagName: typeId.name, + }) + } + } + + if (scope.export) { + const exportRefs = analysis.exportReferences + for (let i = 0; i < exportRefs.length; i++) { + const exportRef = exportRefs[i]! + usages.push({ + kind: analysis.ownComponentKind, + ranges: exportRef.ranges, + sourceFilePath: filePath, + tagName: exportRef.name, + }) + } } return usages } private async getFileComponentKind(filePath: string): Promise { - let sourceText: string | undefined - let signature: string | undefined - - if (this.host.readFileAsync) { - ;[sourceText, signature] = await Promise.all([ - this.host.readFileAsync(filePath), - this.host.getSignatureAsync!(filePath), - ]) - } else { - sourceText = this.host.readFile(filePath) - signature = this.host.getSignature(filePath) - } + const signature = this.host.getSignatureAsync + ? await this.host.getSignatureAsync(filePath) + : this.host.getSignature(filePath) - if (sourceText === undefined || signature === undefined) { + if (signature === undefined) { return 'unknown' } @@ -162,6 +279,14 @@ export class ComponentLensAnalyzer { return cached.kind } + const sourceText = this.host.readFileAsync + ? await this.host.readFileAsync(filePath) + : this.host.readFile(filePath) + + if (sourceText === undefined) { + return 'unknown' + } + const kind: Exclude = hasUseClientDirective( sourceText, ) @@ -196,20 +321,64 @@ function parseFileAnalysis(filePath: string, sourceText: string): FileAnalysis { getScriptKind(filePath), ) - const imports = new Map() - const localComponentNames = new Set() + const asyncComponents = new Set() + const componentRanges: { end: number; name: string; pos: number }[] = [] + const exportReferences: NamedRange[] = [] + const imports = new Map< + string, + { ranges: DecorationSegment[]; source: string } + >() + const localComponents = new Map() + const typeIdentifiers: TypeIdentifier[] = [] let ownComponentKind: Exclude = 'server' let statementIndex = 0 + const nodeRange = (node: ts.Node): DecorationSegment => ({ + end: node.end, + start: node.getStart(sourceFile), + }) + + const registerComponent = ( + name: string, + nameNode: ts.Node, + scopeNode: ts.Node, + ): void => { + localComponents.set(name, { + kind: ownComponentKind, + ranges: [nodeRange(nameNode)], + }) + componentRanges.push({ + end: scopeNode.end, + name, + pos: scopeNode.pos, + }) + } + + const hasAsyncModifier = ( + modifiers: ts.NodeArray | undefined, + ): boolean => { + if (!modifiers) return false + for (let i = 0; i < modifiers.length; i++) { + if (modifiers[i]!.kind === ASYNC_KEYWORD) return true + } + return false + } + + const addImport = (identifier: ts.Identifier, source: string): void => { + if (isComponentIdentifier(identifier.text)) { + imports.set(identifier.text, { + ranges: [nodeRange(identifier)], + source, + }) + } + } + for (; statementIndex < sourceFile.statements.length; statementIndex++) { const statement = sourceFile.statements[statementIndex]! - if ( - !ts.isExpressionStatement(statement) || - !ts.isStringLiteral(statement.expression) - ) { - break - } - if (statement.expression.text === 'use client') { + if (statement.kind !== SK_ExprStmt) break + const expr = (statement as ts.ExpressionStatement).expression + if (expr.kind !== SK_StringLiteral) break + if ((expr as ts.StringLiteral).text === 'use client') { ownComponentKind = 'client' statementIndex++ break @@ -219,175 +388,457 @@ function parseFileAnalysis(filePath: string, sourceText: string): FileAnalysis { for (; statementIndex < sourceFile.statements.length; statementIndex++) { const statement = sourceFile.statements[statementIndex]! - if ( - ts.isImportDeclaration(statement) && - ts.isStringLiteral(statement.moduleSpecifier) - ) { - const source = statement.moduleSpecifier.text - const importClause = statement.importClause - if (importClause) { - if (importClause.name) { - imports.set(importClause.name.text, source) + switch (statement.kind) { + case SK_ImportDecl: { + const importStmt = statement as ts.ImportDeclaration + if (importStmt.moduleSpecifier.kind === SK_StringLiteral) { + const source = (importStmt.moduleSpecifier as ts.StringLiteral).text + const importClause = importStmt.importClause + if (importClause) { + if (importClause.name) { + addImport(importClause.name, source) + } + const namedBindings = importClause.namedBindings + if (namedBindings) { + if (namedBindings.kind === SK_NamespaceImport) { + addImport((namedBindings as ts.NamespaceImport).name, source) + } else { + const elements = (namedBindings as ts.NamedImports).elements + for (let j = 0; j < elements.length; j++) { + addImport(elements[j]!.name, source) + } + } + } + } + } + break + } + case SK_FunctionDecl: { + const funcDecl = statement as ts.FunctionDeclaration + if (funcDecl.name && isComponentIdentifier(funcDecl.name.text)) { + registerComponent(funcDecl.name.text, funcDecl.name, funcDecl) + if (hasAsyncModifier(funcDecl.modifiers)) { + asyncComponents.add(funcDecl.name.text) + } + } + break + } + case SK_ClassDecl: { + const classDecl = statement as ts.ClassDeclaration + if (classDecl.name && isComponentIdentifier(classDecl.name.text)) { + registerComponent(classDecl.name.text, classDecl.name, classDecl) } - const namedBindings = importClause.namedBindings - if (namedBindings) { - if (ts.isNamespaceImport(namedBindings)) { - imports.set(namedBindings.name.text, source) - } else { - for (const element of namedBindings.elements) { - imports.set(element.name.text, source) + break + } + case SK_InterfaceDecl: + case SK_TypeAliasDecl: { + const namedStmt = statement as + | ts.InterfaceDeclaration + | ts.TypeAliasDeclaration + if (isComponentIdentifier(namedStmt.name.text)) { + typeIdentifiers.push({ + enclosingComponent: undefined, + name: namedStmt.name.text, + ranges: [nodeRange(namedStmt.name)], + }) + } + break + } + case SK_ExportDecl: { + const exportDecl = statement as ts.ExportDeclaration + if ( + exportDecl.exportClause && + exportDecl.exportClause.kind === SK_NamedExports + ) { + const elements = (exportDecl.exportClause as ts.NamedExports).elements + for (let j = 0; j < elements.length; j++) { + const element = elements[j]! + if (isComponentIdentifier(element.name.text)) { + exportReferences.push({ + name: element.name.text, + ranges: [nodeRange(element.name)], + }) } } } + break } - continue - } + case SK_ExportAssignment: { + const exportAssign = statement as ts.ExportAssignment + if ( + !exportAssign.isExportEquals && + exportAssign.expression.kind === SK_Identifier && + isComponentIdentifier((exportAssign.expression as ts.Identifier).text) + ) { + exportReferences.push({ + name: (exportAssign.expression as ts.Identifier).text, + ranges: [nodeRange(exportAssign.expression)], + }) + } + break + } + case SK_VariableStmt: { + const varStmt = statement as ts.VariableStatement + const declarations = varStmt.declarationList.declarations + for (let j = 0; j < declarations.length; j++) { + const declaration = declarations[j]! + if ( + declaration.name.kind !== SK_Identifier || + !isComponentIdentifier((declaration.name as ts.Identifier).text) || + !declaration.initializer + ) { + continue + } - if ( - ts.isFunctionDeclaration(statement) && - statement.name && - isComponentIdentifier(statement.name.text) - ) { - localComponentNames.add(statement.name.text) - continue - } + const declName = (declaration.name as ts.Identifier).text - if ( - ts.isClassDeclaration(statement) && - statement.name && - isComponentIdentifier(statement.name.text) - ) { - localComponentNames.add(statement.name.text) - continue - } + if (declaration.initializer.kind === SK_ClassExpr) { + registerComponent(declName, declaration.name, declaration) + continue + } - if (ts.isVariableStatement(statement)) { - for (const declaration of statement.declarationList.declarations) { - if ( - ts.isIdentifier(declaration.name) && - isComponentIdentifier(declaration.name.text) && - isComponentInitializer(declaration.initializer) - ) { - localComponentNames.add(declaration.name.text) + const fn = getComponentFunction(declaration.initializer) + if (fn) { + registerComponent(declName, declaration.name, declaration) + if (hasAsyncModifier(fn.modifiers)) { + asyncComponents.add(declName) + } + } } + break } } } + const jsxTags = collectSourceElements( + sourceFile, + componentRanges, + typeIdentifiers, + localComponents, + asyncComponents, + ownComponentKind === 'server', + ) + return { + exportReferences, imports, - jsxTags: collectJsxTags(sourceFile), - localComponentNames, + jsxTags, + localComponents, ownComponentKind, + typeIdentifiers, } } -const COMPONENT_WRAPPER_NAMES = new Set([ - 'forwardRef', - 'memo', - 'React.forwardRef', - 'React.memo', -]) +const ASYNC_KEYWORD = ts.SyntaxKind.AsyncKeyword +const SK_Identifier = ts.SyntaxKind.Identifier +const SK_PropertyAccess = ts.SyntaxKind.PropertyAccessExpression +const SK_JsxOpening = ts.SyntaxKind.JsxOpeningElement +const SK_JsxSelfClosing = ts.SyntaxKind.JsxSelfClosingElement +const SK_JsxClosing = ts.SyntaxKind.JsxClosingElement +const SK_TypeReference = ts.SyntaxKind.TypeReference +const SK_ImportDecl = ts.SyntaxKind.ImportDeclaration +const SK_EnumDecl = ts.SyntaxKind.EnumDeclaration +const SK_ExportDecl = ts.SyntaxKind.ExportDeclaration +const SK_FunctionDecl = ts.SyntaxKind.FunctionDeclaration +const SK_VariableDecl = ts.SyntaxKind.VariableDeclaration +const SK_JsxAttribute = ts.SyntaxKind.JsxAttribute +const SK_JsxExpression = ts.SyntaxKind.JsxExpression +const SK_ArrowFunction = ts.SyntaxKind.ArrowFunction +const SK_FunctionExpr = ts.SyntaxKind.FunctionExpression +const SK_CallExpression = ts.SyntaxKind.CallExpression +const SK_ClassExpr = ts.SyntaxKind.ClassExpression +const SK_Block = ts.SyntaxKind.Block +const SK_ExprStmt = ts.SyntaxKind.ExpressionStatement +const SK_StringLiteral = ts.SyntaxKind.StringLiteral +const SK_ClassDecl = ts.SyntaxKind.ClassDeclaration +const SK_InterfaceDecl = ts.SyntaxKind.InterfaceDeclaration +const SK_TypeAliasDecl = ts.SyntaxKind.TypeAliasDeclaration +const SK_ExportAssignment = ts.SyntaxKind.ExportAssignment +const SK_VariableStmt = ts.SyntaxKind.VariableStatement +const SK_NamespaceImport = ts.SyntaxKind.NamespaceImport +const SK_NamedExports = ts.SyntaxKind.NamedExports function isComponentIdentifier(name: string): boolean { const code = name.charCodeAt(0) return code >= 65 && code <= 90 } -function isComponentInitializer( - initializer: ts.Expression | undefined, -): boolean { - if (!initializer) { - return false +function getComponentFunction( + initializer: ts.Expression, +): ts.ArrowFunction | ts.FunctionExpression | undefined { + const kind = initializer.kind + if (kind === SK_ArrowFunction || kind === SK_FunctionExpr) { + return initializer as ts.ArrowFunction | ts.FunctionExpression } - if ( - ts.isArrowFunction(initializer) || - ts.isFunctionExpression(initializer) || - ts.isClassExpression(initializer) - ) { - return true + if (kind === SK_CallExpression) { + const call = initializer as ts.CallExpression + if (isComponentWrapper(call.expression)) { + const args = call.arguments + for (let i = 0; i < args.length; i++) { + const argKind = args[i]!.kind + if (argKind === SK_ArrowFunction || argKind === SK_FunctionExpr) { + return args[i] as ts.ArrowFunction | ts.FunctionExpression + } + } + } } - if (!ts.isCallExpression(initializer)) { + return undefined +} + +function hasUseServerDirective( + fn: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression, +): boolean { + const body = fn.body + if (!body || body.kind !== SK_Block) { return false } - if (!COMPONENT_WRAPPER_NAMES.has(getCalleeText(initializer.expression))) { - return false + const statements = (body as ts.Block).statements + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]! + if (stmt.kind !== SK_ExprStmt) break + const expr = (stmt as ts.ExpressionStatement).expression + if (expr.kind !== SK_StringLiteral) break + if ((expr as ts.StringLiteral).text === 'use server') { + return true + } } - return initializer.arguments.some( - (argument) => - ts.isArrowFunction(argument) || ts.isFunctionExpression(argument), - ) + return false } -function getCalleeText(expression: ts.Expression): string { - if (ts.isIdentifier(expression)) { - return expression.text +function isComponentWrapper(expr: ts.Expression): boolean { + if (expr.kind === SK_Identifier) { + const text = (expr as ts.Identifier).text + return text === 'forwardRef' || text === 'memo' } - - if ( - ts.isPropertyAccessExpression(expression) && - ts.isIdentifier(expression.expression) - ) { - return `${expression.expression.text}.${expression.name.text}` + if (expr.kind === SK_PropertyAccess) { + const pa = expr as ts.PropertyAccessExpression + return ( + pa.expression.kind === SK_Identifier && + (pa.expression as ts.Identifier).text === 'React' && + (pa.name.text === 'forwardRef' || pa.name.text === 'memo') + ) } - - return '' + return false } -function collectJsxTags(sourceFile: ts.SourceFile): JsxTagReference[] { +function collectSourceElements( + sourceFile: ts.SourceFile, + componentRanges: { end: number; name: string; pos: number }[], + typeIdentifiers: TypeIdentifier[], + localComponents: Map, + asyncComponents: Set, + inferClientKind: boolean, +): JsxTagReference[] { const jsxTags: JsxTagReference[] = [] + + const componentByPos = new Map() + for (let i = 0; i < componentRanges.length; i++) { + const range = componentRanges[i]! + componentByPos.set(range.pos, range) + } + + let perComponentFuncs: Map> | undefined + let perComponentRefs: Map | undefined + let componentsWithInlineFn: Set | undefined + + if (inferClientKind) { + perComponentFuncs = new Map() + perComponentRefs = new Map() + componentsWithInlineFn = new Set() + for (let i = 0; i < componentRanges.length; i++) { + const range = componentRanges[i]! + if (!asyncComponents.has(range.name)) { + perComponentFuncs.set(range.name, new Map()) + perComponentRefs.set(range.name, []) + } + } + } + + let currentComponent: string | undefined + let currentComponentTracked = false + const visit = (node: ts.Node): void => { + const nodeKind = node.kind + if ( - ts.isJsxOpeningElement(node) || - ts.isJsxSelfClosingElement(node) || - ts.isJsxClosingElement(node) + nodeKind === SK_ImportDecl || + nodeKind === SK_EnumDecl || + nodeKind === SK_ExportDecl ) { - const jsxTag = createJsxTagReference(node, sourceFile) + return + } + + const entry = componentByPos.get(node.pos) + const entered = entry !== undefined && entry.end === node.end + + let savedComponent: string | undefined + let savedTracked = false + if (entered) { + savedComponent = currentComponent + savedTracked = currentComponentTracked + currentComponent = entry.name + currentComponentTracked = perComponentFuncs?.has(entry.name) ?? false + } + + if ( + nodeKind === SK_JsxOpening || + nodeKind === SK_JsxSelfClosing || + nodeKind === SK_JsxClosing + ) { + const jsxTag = createJsxTagReference( + node as + | ts.JsxOpeningElement + | ts.JsxSelfClosingElement + | ts.JsxClosingElement, + sourceFile, + nodeKind, + ) if (jsxTag) { jsxTags.push(jsxTag) } + } else if (nodeKind === SK_TypeReference) { + const typeName = (node as ts.TypeReferenceNode).typeName + if ( + typeName.kind === SK_Identifier && + isComponentIdentifier((typeName as ts.Identifier).text) + ) { + const id = typeName as ts.Identifier + typeIdentifiers.push({ + enclosingComponent: currentComponent, + name: id.text, + ranges: [{ end: id.end, start: id.getStart(sourceFile) }], + }) + } + } + + if ( + currentComponentTracked && + !componentsWithInlineFn!.has(currentComponent!) + ) { + if (nodeKind === SK_FunctionDecl) { + const fn = node as ts.FunctionDeclaration + if (fn.name) { + perComponentFuncs! + .get(currentComponent!)! + .set(fn.name.text, hasUseServerDirective(fn)) + } + } else if (nodeKind === SK_VariableDecl) { + const decl = node as ts.VariableDeclaration + if ( + decl.name.kind === SK_Identifier && + decl.initializer && + (decl.initializer.kind === SK_ArrowFunction || + decl.initializer.kind === SK_FunctionExpr) + ) { + perComponentFuncs! + .get(currentComponent!)! + .set( + (decl.name as ts.Identifier).text, + hasUseServerDirective( + decl.initializer as ts.ArrowFunction | ts.FunctionExpression, + ), + ) + } + } else if (nodeKind === SK_JsxAttribute) { + const attr = node as ts.JsxAttribute + if (attr.initializer && attr.initializer.kind === SK_JsxExpression) { + const expr = (attr.initializer as ts.JsxExpression).expression + if (expr) { + const exprKind = expr.kind + if ( + (exprKind === SK_ArrowFunction || exprKind === SK_FunctionExpr) && + !hasUseServerDirective( + expr as ts.ArrowFunction | ts.FunctionExpression, + ) + ) { + componentsWithInlineFn!.add(currentComponent!) + } else if (exprKind === SK_Identifier) { + perComponentRefs! + .get(currentComponent!)! + .push((expr as ts.Identifier).text) + } + } + } + } } + ts.forEachChild(node, visit) + + if (entered) { + currentComponent = savedComponent + currentComponentTracked = savedTracked + } } ts.forEachChild(sourceFile, visit) + + if (!perComponentFuncs) { + return jsxTags + } + + for (const [name, funcs] of perComponentFuncs) { + if (componentsWithInlineFn!.has(name)) { + localComponents.get(name)!.kind = 'client' + continue + } + const refs = perComponentRefs!.get(name)! + let hasClientRef = false + for (let i = 0; i < refs.length; i++) { + if (funcs.get(refs[i]!) === false) { + hasClientRef = true + break + } + } + if (hasClientRef) { + localComponents.get(name)!.kind = 'client' + } + } + return jsxTags } function createJsxTagReference( node: ts.JsxOpeningElement | ts.JsxSelfClosingElement | ts.JsxClosingElement, sourceFile: ts.SourceFile, + nodeKind: ts.SyntaxKind, ): JsxTagReference | undefined { const tagNameExpression = node.tagName + const tagKind = tagNameExpression.kind - if (ts.isIdentifier(tagNameExpression)) { - if (!isComponentIdentifier(tagNameExpression.text)) { + if (tagKind === SK_Identifier) { + const text = (tagNameExpression as ts.Identifier).text + if (!isComponentIdentifier(text)) { return undefined } return { - lookupName: tagNameExpression.text, - ranges: getTagRanges(node, tagNameExpression, sourceFile), - tagName: tagNameExpression.text, + lookupName: text, + ranges: getTagRanges(node, tagNameExpression, sourceFile, nodeKind), + tagName: text, } } - if (!ts.isPropertyAccessExpression(tagNameExpression)) { + if (tagKind !== SK_PropertyAccess) { return undefined } - const rootIdentifier = getRootIdentifier(tagNameExpression.expression) + const rootIdentifier = getRootIdentifier( + (tagNameExpression as ts.PropertyAccessExpression).expression, + ) if (!rootIdentifier || !isComponentIdentifier(rootIdentifier.text)) { return undefined } return { lookupName: rootIdentifier.text, - ranges: getTagRanges(node, tagNameExpression, sourceFile), - tagName: tagNameExpression.getText(sourceFile), + ranges: getTagRanges(node, tagNameExpression, sourceFile, nodeKind), + tagName: sourceFile.text.substring( + tagNameExpression.getStart(sourceFile), + tagNameExpression.end, + ), } } @@ -395,19 +846,20 @@ function getTagRanges( node: ts.JsxOpeningElement | ts.JsxSelfClosingElement | ts.JsxClosingElement, tagNameExpression: ts.JsxTagNameExpression, sourceFile: ts.SourceFile, + nodeKind: ts.SyntaxKind, ): DecorationSegment[] { - if (ts.isJsxClosingElement(node)) { + if (nodeKind === SK_JsxClosing) { return [ { - end: node.getEnd(), + end: node.end, start: node.getStart(sourceFile), }, ] } - const tagNameEnd = tagNameExpression.getEnd() - const nodeEnd = node.getEnd() - const delimiterLength = ts.isJsxSelfClosingElement(node) ? 2 : 1 + const tagNameEnd = tagNameExpression.end + const nodeEnd = node.end + const delimiterLength = nodeKind === SK_JsxSelfClosing ? 2 : 1 const delimiterStart = nodeEnd - delimiterLength const ranges: DecorationSegment[] = [ @@ -431,10 +883,10 @@ function getRootIdentifier( expression: ts.Expression, ): ts.Identifier | undefined { let current = expression - while (ts.isPropertyAccessExpression(current)) { - current = current.expression + while (current.kind === SK_PropertyAccess) { + current = (current as ts.PropertyAccessExpression).expression } - return ts.isIdentifier(current) ? current : undefined + return current.kind === SK_Identifier ? (current as ts.Identifier) : undefined } function getScriptKind(filePath: string): ts.ScriptKind { @@ -488,15 +940,23 @@ function hasUseClientDirective(sourceText: string): boolean { } } - if (ch === 34 && sourceText.startsWith('"use client"', i)) { - return true - } - - if (ch === 39 && sourceText.startsWith("'use client'", i)) { - return true - } - if (ch === 34 || ch === 39) { + if ( + i + 11 < len && + sourceText.charCodeAt(i + 11) === ch && + sourceText.charCodeAt(i + 1) === 117 && + sourceText.charCodeAt(i + 2) === 115 && + sourceText.charCodeAt(i + 3) === 101 && + sourceText.charCodeAt(i + 4) === 32 && + sourceText.charCodeAt(i + 5) === 99 && + sourceText.charCodeAt(i + 6) === 108 && + sourceText.charCodeAt(i + 7) === 105 && + sourceText.charCodeAt(i + 8) === 101 && + sourceText.charCodeAt(i + 9) === 110 && + sourceText.charCodeAt(i + 10) === 116 + ) { + return true + } i++ while (i < len && sourceText.charCodeAt(i) !== ch) i++ if (i < len) i++ diff --git a/src/decorations.ts b/src/decorations.ts index 52e2137..0fd03c6 100644 --- a/src/decorations.ts +++ b/src/decorations.ts @@ -28,29 +28,35 @@ export class LensDecorations implements vscode.Disposable { public apply(editor: vscode.TextEditor, usages: ComponentUsage[]): void { const clientDecorations: vscode.DecorationOptions[] = [] const serverDecorations: vscode.DecorationOptions[] = [] - const editorDir = path.dirname(editor.document.uri.fsPath) - const hoverCache = new Map() + const document = editor.document + const editorDir = path.dirname(document.uri.fsPath) + const clientHoverCache = new Map() + const serverHoverCache = new Map() - for (const usage of usages) { - let hoverMessage = hoverCache.get(usage.sourceFilePath) + for (let i = 0; i < usages.length; i++) { + const usage = usages[i]! + const isClient = usage.kind === 'client' + const hoverMap = isClient ? clientHoverCache : serverHoverCache + let hoverMessage = hoverMap.get(usage.sourceFilePath) if (!hoverMessage) { const displayPath = toDisplayPath(editorDir, usage.sourceFilePath) - const label = usage.kind === 'client' ? 'Client' : 'Server' + const label = isClient ? 'Client' : 'Server' hoverMessage = new vscode.MarkdownString( `${label} component from \`${displayPath}\``, ) - hoverCache.set(usage.sourceFilePath, hoverMessage) + hoverMap.set(usage.sourceFilePath, hoverMessage) } - const target = - usage.kind === 'client' ? clientDecorations : serverDecorations + const target = isClient ? clientDecorations : serverDecorations + const ranges = usage.ranges - for (const range of usage.ranges) { + for (let j = 0; j < ranges.length; j++) { + const range = ranges[j]! target.push({ hoverMessage, range: new vscode.Range( - editor.document.positionAt(range.start), - editor.document.positionAt(range.end), + document.positionAt(range.start), + document.positionAt(range.end), ), }) } diff --git a/src/extension.ts b/src/extension.ts index 7ea7d42..838e259 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,11 +3,12 @@ import * as path from 'node:path' import * as vscode from 'vscode' -import { ComponentLensAnalyzer } from './analyzer' +import { ComponentLensAnalyzer, type ScopeConfig } from './analyzer' import { type HighlightColors, LensDecorations } from './decorations' import { ImportResolver, type SourceHost } from './resolver' -const SUPPORTED_LANGUAGE_IDS = new Set(['javascriptreact', 'typescriptreact']) +const LANG_JSX = 'javascriptreact' +const LANG_TSX = 'typescriptreact' const SOURCE_WATCH_PATTERN = '**/*.{js,jsx,ts,tsx}' const CONFIG_WATCH_PATTERN = '**/{tsconfig,jsconfig}.json' const DEFAULT_HIGHLIGHT_COLORS: HighlightColors = { @@ -51,16 +52,18 @@ export function activate(context: vscode.ExtensionContext): void { } const refreshEditor = async (editor: vscode.TextEditor): Promise => { - if (!config.enabled || !isSupportedDocument(editor.document)) { + const document = editor.document + if (!config.enabled || !isSupportedDocument(document)) { decorations.clear(editor) return } - const signature = createOpenSignature(editor.document.version) + const signature = createOpenSignature(document.version) const usages = await analyzer.analyzeDocument( - editor.document.fileName, - editor.document.getText(), + document.fileName, + document.getText(), signature, + config.scope, ) decorations.apply(editor, usages) } @@ -171,6 +174,7 @@ function getConfiguration(): { debounceMs: number enabled: boolean highlightColors: HighlightColors + scope: ScopeConfig } { const configuration = vscode.workspace.getConfiguration('reactComponentLens') const debounceMs = configuration.get('debounceMs', 200) @@ -192,13 +196,20 @@ function getConfiguration(): { DEFAULT_HIGHLIGHT_COLORS.serverComponent, ), }, + scope: { + declaration: configuration.get('scope.declaration', true), + element: configuration.get('scope.element', true), + export: configuration.get('scope.export', true), + import: configuration.get('scope.import', true), + type: configuration.get('scope.type', true), + }, } } function isSupportedDocument(document: vscode.TextDocument): boolean { return ( document.uri.scheme === 'file' && - SUPPORTED_LANGUAGE_IDS.has(document.languageId) + (document.languageId === LANG_TSX || document.languageId === LANG_JSX) ) } @@ -270,6 +281,8 @@ class WorkspaceSourceHost implements SourceHost { } private documentCache: Map | undefined + private lastFilePath = '' + private lastNormalizedPath = '' private getOpenDocument(filePath: string): vscode.TextDocument | undefined { if (!this.documentCache) { @@ -278,16 +291,21 @@ class WorkspaceSourceHost implements SourceHost { this.documentCache.set(path.normalize(document.fileName), document) } } - return this.documentCache.get(path.normalize(filePath)) + + if (filePath !== this.lastFilePath) { + this.lastFilePath = filePath + this.lastNormalizedPath = path.normalize(filePath) + } + return this.documentCache.get(this.lastNormalizedPath) } } function createOpenSignature(version: number): string { - return `open:${String(version)}` + return 'open:' + version } function createDiskSignature(mtimeMs: number, size: number): string { - return `disk:${String(mtimeMs)}:${String(size)}` + return 'disk:' + mtimeMs + ':' + size } function normalizeColor( @@ -295,5 +313,5 @@ function normalizeColor( fallbackColor: string, ): string { const trimmedColor = color?.trim() - return trimmedColor && trimmedColor.length > 0 ? trimmedColor : fallbackColor + return trimmedColor || fallbackColor } diff --git a/src/resolver.ts b/src/resolver.ts index 13fe799..ce88e41 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -19,7 +19,6 @@ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = { } const CONFIG_FILE_NAMES = ['tsconfig.json', 'jsconfig.json'] as const -const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']) interface CachedCompilerOptions { options: ts.CompilerOptions @@ -32,11 +31,15 @@ export class ImportResolver { CachedCompilerOptions >() private readonly configPathCache = new Map() - private readonly resolutionCache = new Map() + private readonly resolutionCache = new Map< + string, + Map + >() private readonly resolutionHost: ts.ModuleResolutionHost private currentDirectory = '' private lastFromPath = '' private lastNormalizedFromPath = '' + private lastFromDirectory = '' public constructor(private readonly host: SourceHost) { this.resolutionHost = { @@ -59,6 +62,7 @@ export class ImportResolver { this.resolutionCache.clear() this.lastFromPath = '' this.lastNormalizedFromPath = '' + this.lastFromDirectory = '' } public resolveImport( @@ -72,16 +76,22 @@ export class ImportResolver { normalizedFromFilePath = path.normalize(fromFilePath) this.lastFromPath = fromFilePath this.lastNormalizedFromPath = normalizedFromFilePath + this.lastFromDirectory = path.dirname(normalizedFromFilePath) } - const cacheKey = `${normalizedFromFilePath}::${specifier}` - const cached = this.resolutionCache.get(cacheKey) - if (cached !== undefined || this.resolutionCache.has(cacheKey)) { - return cached + let fileCache = this.resolutionCache.get(normalizedFromFilePath) + if (fileCache) { + const cached = fileCache.get(specifier) + if (cached !== undefined || fileCache.has(specifier)) { + return cached + } + } else { + fileCache = new Map() + this.resolutionCache.set(normalizedFromFilePath, fileCache) } - const compilerOptions = this.getCompilerOptions(normalizedFromFilePath) - this.currentDirectory = path.dirname(normalizedFromFilePath) + const compilerOptions = this.getCompilerOptions(this.lastFromDirectory) + this.currentDirectory = this.lastFromDirectory const result = ts.resolveModuleName( specifier, @@ -91,21 +101,18 @@ export class ImportResolver { ).resolvedModule if (!result || !isSupportedSourceFile(result.resolvedFileName)) { - this.resolutionCache.set(cacheKey, undefined) + fileCache.set(specifier, undefined) return undefined } const resolvedFilePath = path.normalize(result.resolvedFileName) - this.resolutionCache.set(cacheKey, resolvedFilePath) + fileCache.set(specifier, resolvedFilePath) return resolvedFilePath } - private getCompilerOptions(filePath: string): ts.CompilerOptions { - const directory = path.dirname(filePath) - let configPath: string | undefined - if (this.configPathCache.has(directory)) { - configPath = this.configPathCache.get(directory) - } else { + private getCompilerOptions(directory: string): ts.CompilerOptions { + let configPath = this.configPathCache.get(directory) + if (configPath === undefined && !this.configPathCache.has(directory)) { configPath = findNearestConfigFile(directory) this.configPathCache.set(directory, configPath) } @@ -146,7 +153,7 @@ export class ImportResolver { } function findNearestConfigFile(startDirectory: string): string | undefined { - let currentDirectory = path.normalize(startDirectory) + let currentDirectory = startDirectory for (;;) { for (const configFileName of CONFIG_FILE_NAMES) { @@ -170,5 +177,10 @@ function isSupportedSourceFile(filePath: string): boolean { return false } - return SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase()) + return ( + filePath.endsWith('.ts') || + filePath.endsWith('.tsx') || + filePath.endsWith('.js') || + filePath.endsWith('.jsx') + ) } diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 68d2a0b..519ceb1 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path' import { expect, test } from 'bun:test' -import { ComponentLensAnalyzer } from '../src/analyzer' +import { ComponentLensAnalyzer, type ScopeConfig } from '../src/analyzer' import { ImportResolver, type SourceHost } from '../src/resolver' test('detects client and server component usages from relative imports', async () => { @@ -46,12 +46,15 @@ test('detects client and server component usages from relative imports', async ( project.signature('Page.tsx'), ) - expect(usages.length).toBe(2) + expect(usages.length).toBe(5) expect( usages.map((usage) => ({ kind: usage.kind, tagName: usage.tagName })), ).toEqual([ { kind: 'client', tagName: 'Button' }, { kind: 'server', tagName: 'Layout' }, + { kind: 'client', tagName: 'Button' }, + { kind: 'server', tagName: 'Layout' }, + { kind: 'server', tagName: 'Page' }, ]) expect( usages.map((usage) => @@ -60,6 +63,9 @@ test('detects client and server component usages from relative imports', async ( ).toEqual([ [''], [''], + ['Button'], + ['Layout'], + ['Page'], ]) } finally { project[Symbol.dispose]() @@ -98,7 +104,7 @@ test('includes full delimiters for opening and closing tags', async () => { usages.map((usage) => usage.ranges.map((range) => source.slice(range.start, range.end)), ), - ).toEqual([[''], ['']]) + ).toEqual([[''], [''], ['Button'], ['Page']]) } finally { project[Symbol.dispose]() } @@ -134,7 +140,7 @@ test('excludes props while keeping self-closing delimiters', async () => { usages.map((usage) => usage.ranges.map((range) => source.slice(range.start, range.end)), ), - ).toEqual([['']]) + ).toEqual([[''], ['Button'], ['Page']]) } finally { project[Symbol.dispose]() } @@ -162,7 +168,7 @@ test('treats locally declared components as the current file kind', async () => project.signature('Card.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.tagName).toBe('Action') } finally { @@ -210,7 +216,7 @@ test('resolves tsconfig path aliases when mapping component types', async () => project.signature('src/Page.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.sourceFilePath ?? '').toMatch(/src[\\/]Button\.tsx$/u) } finally { @@ -288,13 +294,13 @@ test('clear() resets all caches and re-analyses from scratch', async () => { const sig = project.signature('Page.tsx') const before = await analyzer.analyzeDocument(filePath, source, sig) - expect(before.length).toBe(1) + expect(before.length).toBe(3) expect(before[0]?.kind).toBe('client') analyzer.clear() const after = await analyzer.analyzeDocument(filePath, source, sig) - expect(after.length).toBe(1) + expect(after.length).toBe(3) expect(after[0]?.kind).toBe('client') } finally { project[Symbol.dispose]() @@ -323,7 +329,7 @@ test('recognizes forwardRef-wrapped local components', async () => { project.signature('Card.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.tagName).toBe('Button') } finally { @@ -359,7 +365,7 @@ test('resolves namespaced JSX like ', async () => { project.signature('Page.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.tagName).toBe('UI.Button') } finally { @@ -398,7 +404,7 @@ test('resolves bare package imports through node_modules', async () => { project.signature('Page.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.tagName).toBe('Button') } finally { @@ -431,7 +437,7 @@ test('resolves deeply nested namespaced JSX like ', async () = project.signature('Page.tsx'), ) - expect(usages.length).toBe(1) + expect(usages.length).toBe(3) expect(usages[0]?.kind).toBe('client') expect(usages[0]?.tagName).toBe('UI.Forms.Input') } finally { @@ -439,6 +445,870 @@ test('resolves deeply nested namespaced JSX like ', async () = } }) +test('colors interface and type alias declaration names', async () => { + const project = createProject({ + 'Card.tsx': [ + "'use client';", + '', + 'export interface CardProps {', + ' title: string;', + '}', + '', + 'type CardVariant = "primary" | "secondary";', + '', + 'export function Card() {', + ' return
;', + '}', + ].join('\n'), + }) + + try { + const analyzer = createAnalyzer(project.host) + const filePath = project.filePath('Card.tsx') + const source = project.readFile('Card.tsx') + const usages = await analyzer.analyzeDocument( + filePath, + source, + project.signature('Card.tsx'), + ) + + expect(usages.length).toBe(3) + expect( + usages.map((usage) => ({ kind: usage.kind, tagName: usage.tagName })), + ).toEqual([ + { kind: 'client', tagName: 'Card' }, + { kind: 'client', tagName: 'CardProps' }, + { kind: 'client', tagName: 'CardVariant' }, + ]) + expect( + usages.map((usage) => + usage.ranges.map((range) => source.slice(range.start, range.end)), + ), + ).toEqual([['Card'], ['CardProps'], ['CardVariant']]) + } finally { + project[Symbol.dispose]() + } +}) + +test('colors type references in function parameter annotations', async () => { + const project = createProject({ + 'Button.tsx': [ + "'use client';", + '', + 'interface ThemeButtonProps {', + ' color: string;', + '}', + '', + 'export function ThemeButton({ color }: ThemeButtonProps) {', + ' return