Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: '1.3.9'
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
Expand All @@ -40,7 +40,7 @@ jobs:
needs:
- test
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: changepacks/action@main
id: changepacks
with:
Expand All @@ -58,11 +58,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: '1.3.9'

- run: bun install --frozen-lockfile

Expand All @@ -82,10 +82,10 @@ jobs:
needs:
- changepacks
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: '1.3.9'
- run: bun install --frozen-lockfile
- name: Publish to VS Code Marketplace
run: bunx @vscode/vsce publish --no-dependencies
Expand Down
9 changes: 8 additions & 1 deletion src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ const SK_NamespaceImport = ts.SyntaxKind.NamespaceImport
const SK_NamedExports = ts.SyntaxKind.NamedExports
const SK_ExportKw = ts.SyntaxKind.ExportKeyword
const SK_DefaultKw = ts.SyntaxKind.DefaultKeyword
const SK_TypeLiteral = ts.SyntaxKind.TypeLiteral

function isComponentIdentifier(name: string): boolean {
const code = name.charCodeAt(0)
Expand Down Expand Up @@ -829,6 +830,7 @@ function collectSourceElements(

let currentComponent: string | undefined
let currentComponentTracked = false
let typeLiteralDepth = 0

const visit = (node: ts.Node): void => {
const nodeKind = node.kind
Expand All @@ -841,6 +843,9 @@ function collectSourceElements(
return
}

const isTypeLiteral = nodeKind === SK_TypeLiteral
if (isTypeLiteral) typeLiteralDepth++

const entry = componentByPos.get(node.pos)
const entered = entry !== undefined && entry.end === node.end

Expand Down Expand Up @@ -869,7 +874,7 @@ function collectSourceElements(
if (jsxTag) {
jsxTags.push(jsxTag)
}
} else if (nodeKind === SK_TypeReference) {
} else if (nodeKind === SK_TypeReference && typeLiteralDepth === 0) {
const typeName = (node as ts.TypeReferenceNode).typeName
if (
typeName.kind === SK_Identifier &&
Expand Down Expand Up @@ -937,6 +942,8 @@ function collectSourceElements(

ts.forEachChild(node, visit)

if (isTypeLiteral) typeLiteralDepth--

if (entered) {
currentComponent = savedComponent
currentComponentTracked = savedTracked
Expand Down
192 changes: 192 additions & 0 deletions test/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { expect, test } from 'bun:test'
import { ComponentLensAnalyzer, type ScopeConfig } from '../src/analyzer'
import {
createDiskSignature,
createOpenSignature,
ImportResolver,
type SourceHost,
} from '../src/resolver'
Expand Down Expand Up @@ -1688,6 +1689,197 @@ test('codelens scope tracks source file paths for imports', async () => {
}
})

test('does not color type references inside inline object types', async () => {
const project = createProject({
'Button.tsx': [
"'use client';",
'',
'interface ButtonProps {',
' label: string;',
'}',
'',
'function Button(props: { children: ReactNode }) {',
' return <button />;',
'}',
'',
'function IconButton(props: ButtonProps) {',
' return <button />;',
'}',
].join('\n'),
})

try {
const analyzer = createAnalyzer(project.host)
const filePath = project.filePath('Button.tsx')
const source = project.readFile('Button.tsx')
const scope: ScopeConfig = {
declaration: false,
element: false,
export: false,
import: false,
type: true,
}
const usages = await analyzer.analyzeDocument(
filePath,
source,
project.signature('Button.tsx'),
scope,
)

expect(usages.map((u) => u.tagName)).toEqual(['ButtonProps', 'ButtonProps'])
} finally {
project[Symbol.dispose]()
}
})

test('colors named type reference but not inline object type members', async () => {
const project = createProject({
'Card.tsx': [
'interface CardProps {',
' title: string;',
'}',
'',
'function Card({ title }: CardProps) {',
' return <div>{title}</div>;',
'}',
'',
'function Badge(props: { icon: ReactElement, label: string }) {',
' return <span />;',
'}',
].join('\n'),
})

try {
const analyzer = createAnalyzer(project.host)
const filePath = project.filePath('Card.tsx')
const source = project.readFile('Card.tsx')
const scope: ScopeConfig = {
declaration: false,
element: false,
export: false,
import: false,
type: true,
}
const usages = await analyzer.analyzeDocument(
filePath,
source,
project.signature('Card.tsx'),
scope,
)

expect(usages.map((u) => u.tagName)).toEqual(['CardProps', 'CardProps'])
} finally {
project[Symbol.dispose]()
}
})

test('findComponentDeclaration returns position for existing component', async () => {
const project = createProject({
'Card.tsx': [
"'use client';",
'',
'export function Card() {',
' return <div />;',
'}',
].join('\n'),
})

try {
const analyzer = createAnalyzer(project.host)
const filePath = project.filePath('Card.tsx')

const result = await analyzer.findComponentDeclaration(filePath, 'Card')
expect(result).toEqual({ line: 2, character: 16 })
} finally {
project[Symbol.dispose]()
}
})

test('findComponentDeclaration returns undefined for non-existent file', async () => {
const project = createProject({
'Card.tsx': ['export function Card() {', ' return <div />;', '}'].join(
'\n',
),
})

try {
const analyzer = createAnalyzer(project.host)
const result = await analyzer.findComponentDeclaration(
project.filePath('Missing.tsx'),
'Card',
)
expect(result).toBeUndefined()
} finally {
project[Symbol.dispose]()
}
})

test('findComponentDeclaration returns undefined for unknown component name', async () => {
const project = createProject({
'Card.tsx': ['export function Card() {', ' return <div />;', '}'].join(
'\n',
),
})

try {
const analyzer = createAnalyzer(project.host)
const filePath = project.filePath('Card.tsx')

const result = await analyzer.findComponentDeclaration(
filePath,
'NonExistent',
)
expect(result).toBeUndefined()
} finally {
project[Symbol.dispose]()
}
})

test('findComponentDeclaration returns undefined when signature is unavailable', async () => {
const project = createProject({
'Card.tsx': ['export function Card() {', ' return <div />;', '}'].join(
'\n',
),
})

try {
const host: SourceHost = {
fileExists: project.host.fileExists,
getSignature: () => undefined,
readFile: project.host.readFile,
}
const analyzer = createAnalyzer(host)
const result = await analyzer.findComponentDeclaration(
project.filePath('Card.tsx'),
'Card',
)
expect(result).toBeUndefined()
} finally {
project[Symbol.dispose]()
}
})

test('findComponentDeclaration locates component on first line', async () => {
const project = createProject({
'Hero.tsx': ['function Hero() {', ' return <div />;', '}'].join('\n'),
})

try {
const analyzer = createAnalyzer(project.host)
const filePath = project.filePath('Hero.tsx')

const result = await analyzer.findComponentDeclaration(filePath, 'Hero')
expect(result).toEqual({ line: 0, character: 9 })
} finally {
project[Symbol.dispose]()
}
})

test('createOpenSignature formats version string', () => {
expect(createOpenSignature(42)).toBe('open:42')
expect(createOpenSignature(0)).toBe('open:0')
})

function createAnalyzer(host: SourceHost): ComponentLensAnalyzer {
const resolver = new ImportResolver(host)
return new ComponentLensAnalyzer(host, resolver)
Expand Down
Loading