ReviewOS

also looking at this

stacks/dtsx

fix: generate React component declarations

#3104
Merged chris wants to merge agent/react-component-declarations into main
14 files +875 -35
docs/guide/cli.mdmodified+4-3
Changes to docs/guide/cli.md
@@ -3,6 +3,7 @@ title: CLI Commands
33description: Complete reference for dtsx command-line interface.
44---
55
6```bash
67dtsx --help
78dtsx generate --help
89dtsx watch --help
@@ -23,7 +24,7 @@ dtsx --version
2324
2425### Text Output (Default)
2526
26```
27```text
2728
2829dtsx v1.0.0
2930
@@ -67,7 +68,7 @@ dtsx generate --verbose --stats
6768
6869```
6970
70```
71```text
7172
7273dtsx v1.0.0
7374
@@ -98,7 +99,7 @@ dtsx generate --dry-run --diff
9899
99100```
100101
101```
102```text
102103
103104dtsx v1.0.0
104105
packages/dtsx/src/extractor/scanner.tsmodified+72-14
Changes to packages/dtsx/src/extractor/scanner.ts
@@ -633,11 +633,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
633633 }
634634
635635 /** Skip to statement end (semicolon at depth 0, matching brace, or ASI) */
636 const STMT_DELIM_RE = /[{};'"`\/\n\r()[\]]/g
636 const STMT_DELIM_RE = /[<{};'"`\/\n\r()[\]]/g
637637 function skipToStatementEnd(): void {
638638 let braceDepth = 0
639639 let parenDepth = 0
640640 let bracketDepth = 0
641 let jsxDepth = 0
642 const terminateOnBalancedBrace = source.charCodeAt(pos) === CH_LBRACE
641643 STMT_DELIM_RE.lastIndex = pos
642644 let match
643645 while ((match = STMT_DELIM_RE.exec(source)) !== null) {
@@ -657,6 +659,11 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
657659 continue
658660 }
659661 if (ch === CH_SLASH) {
662 // A JSX closing tag (`</Tag>` or `</>`) is not a regex literal.
663 if (idx > 0 && source.charCodeAt(idx - 1) === CH_LANGLE) {
664 STMT_DELIM_RE.lastIndex = idx + 1
665 continue
666 }
660667 pos = idx
661668 if (skipNonCode()) {
662669 STMT_DELIM_RE.lastIndex = pos
@@ -666,6 +673,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
666673 continue
667674 }
668675
676 if (ch === CH_LANGLE) {
677 const jsxDelta = jsxTagDeltaAt(idx)
678 if (jsxDelta !== null) jsxDepth = Math.max(0, jsxDepth + jsxDelta)
679 STMT_DELIM_RE.lastIndex = idx + 1
680 continue
681 }
682
669683 if (ch === CH_LPAREN) {
670684 parenDepth++
671685 STMT_DELIM_RE.lastIndex = idx + 1
@@ -692,14 +706,15 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
692706 continue
693707 }
694708 if (ch === CH_RBRACE) {
695 braceDepth--
696 if (braceDepth <= 0 && parenDepth <= 0 && bracketDepth <= 0) { pos = idx + 1; return }
709 const closesNestedBrace = braceDepth > 0
710 if (closesNestedBrace) braceDepth--
711 if ((!closesNestedBrace || terminateOnBalancedBrace) && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && jsxDepth === 0) { pos = idx + 1; return }
697712 STMT_DELIM_RE.lastIndex = idx + 1
698713 continue
699714 }
700 if (ch === CH_SEMI && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0) { pos = idx + 1; return }
715 if (ch === CH_SEMI && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && jsxDepth === 0) { pos = idx + 1; return }
701716 // ASI: newline at depth 0 + keyword = end of statement
702 if ((ch === CH_LF || ch === CH_CR) && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0) {
717 if ((ch === CH_LF || ch === CH_CR) && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && jsxDepth === 0) {
703718 pos = idx
704719 if (checkASITopLevel()) return
705720 }
@@ -1979,6 +1994,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
19791994 // in value expressions (comparisons like <=, >=, and plain < >)
19801995 let depth = 0
19811996 let angleDepth = 0
1997 let jsxDepth = 0
19821998 while (pos < len) {
19831999 if (skipNonCode())
19842000 continue
@@ -1988,20 +2004,18 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
19882004 else if (ic === CH_RPAREN || ic === CH_RBRACE || ic === CH_RBRACKET)
19892005 depth--
19902006 else if (ic === CH_LANGLE && depth === 0) {
1991 // Only track angle brackets at depth 0 (top-level generics like Map<K,V>).
1992 // Inside braces (function bodies), < and > are comparison operators.
1993 // Don't count <= as opening angle bracket
1994 if (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL)
1995 angleDepth++
2007 const jsxDelta = jsxTagDeltaAt(pos)
2008 if (jsxDelta !== null) jsxDepth = Math.max(0, jsxDepth + jsxDelta)
2009 else if (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL) angleDepth++
19962010 }
1997 else if (ic === CH_RANGLE && depth === 0 && !isArrowGT()) {
2011 else if (ic === CH_RANGLE && depth === 0 && jsxDepth === 0 && !isArrowGT()) {
19982012 // Don't count >= as closing angle bracket, and prevent going negative
19992013 if (angleDepth > 0 && (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL))
20002014 angleDepth--
20012015 }
2002 else if (depth === 0 && angleDepth === 0 && (ic === CH_SEMI || ic === CH_COMMA))
2016 else if (depth === 0 && angleDepth === 0 && jsxDepth === 0 && (ic === CH_SEMI || ic === CH_COMMA))
20032017 break
2004 if (depth === 0 && angleDepth === 0 && checkASITopLevel())
2018 if (depth === 0 && angleDepth === 0 && jsxDepth === 0 && checkASITopLevel())
20052019 break
20062020 pos++
20072021 }
@@ -2051,6 +2065,47 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
20512065 return results
20522066 }
20532067
2068 /** Return the JSX nesting delta for a tag beginning at index, or null. */
2069 function jsxTagDeltaAt(index: number): number | null {
2070 if (source.charCodeAt(index) !== CH_LANGLE || index + 1 >= len) return null
2071 const next = source.charCodeAt(index + 1)
2072 if (next === CH_RANGLE) return 1 // fragment open: <>
2073 if (next === CH_SLASH) return -1 // element or fragment close
2074 if (!isIdentStart(next)) return null
2075
2076 let nameEnd = index + 2
2077 while (nameEnd < len) {
2078 const code = source.charCodeAt(nameEnd)
2079 if (!isIdentChar(code) && code !== CH_DOT && code !== 58 /* : */ && code !== 45 /* - */) break
2080 nameEnd++
2081 }
2082 const tagName = source.slice(index + 1, nameEnd)
2083 const delimiter = source.charCodeAt(nameEnd)
2084 if (delimiter > 32 && delimiter !== CH_RANGLE && delimiter !== CH_SLASH) return null
2085
2086 let braceDepth = 0
2087 let quote = 0
2088 let tagEnd = nameEnd
2089 for (; tagEnd < len; tagEnd++) {
2090 const code = source.charCodeAt(tagEnd)
2091 if (quote !== 0) {
2092 if (code === CH_BACKSLASH) tagEnd++
2093 else if (code === quote) quote = 0
2094 continue
2095 }
2096 if (code === CH_SQUOTE || code === CH_DQUOTE || code === CH_BACKTICK) quote = code
2097 else if (code === CH_LBRACE) braceDepth++
2098 else if (code === CH_RBRACE && braceDepth > 0) braceDepth--
2099 else if (code === CH_RANGLE && braceDepth === 0) break
2100 }
2101 if (tagEnd >= len) return null
2102
2103 let beforeEnd = tagEnd - 1
2104 while (beforeEnd > index && source.charCodeAt(beforeEnd) <= 32) beforeEnd--
2105 if (source.charCodeAt(beforeEnd) === CH_SLASH) return 0
2106 return source.indexOf(`</${tagName}`, tagEnd + 1) === -1 ? null : 1
2107 }
2108
20542109 /**
20552110 * Extract interface declaration.
20562111 * pos should be at 'interface' keyword.
@@ -3565,7 +3620,10 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
35653620 else {
35663621 // Skip unknown top-level content (string expressions like 'use strict', decorators, etc.)
35673622 const ch = source.charCodeAt(pos)
3568 if (ch === CH_SQUOTE || ch === CH_DQUOTE) {
3623 if (ch === CH_SEMI) {
3624 pos++
3625 }
3626 else if (ch === CH_SQUOTE || ch === CH_DQUOTE) {
35693627 skipString(ch)
35703628 if (pos < len && source.charCodeAt(pos) === CH_SEMI)
35713629 pos++
packages/dtsx/src/processor/index.tsmodified+1-0
Changes to packages/dtsx/src/processor/index.ts
@@ -258,6 +258,7 @@ export function processDeclarations(
258258 // them, so their type references must participate in import retention.
259259 textParts.push(variable.text)
260260 if (variable.typeAnnotation) textParts.push(variable.typeAnnotation)
261 else if (variable.isExported && variable.value) textParts.push(processVariableDeclaration(variable, false))
261262 }
262263 for (const iface of interfaces) {
263264 if (iface.isExported || interfaceReferences.has(iface.name)) textParts.push(iface.text)
packages/dtsx/src/processor/type-inference.tsmodified+92-1
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -110,6 +110,45 @@ function countOccurrences(str: string, sub: string): number {
110110 return count
111111}
112112
113function isJsxTagNameChar(code: number): boolean {
114 return (code >= 65 && code <= 90)
115 || (code >= 97 && code <= 122)
116 || (code >= 48 && code <= 57)
117 || code === 36 || code === 95 || code === 46 || code === 58 || code === 45
118}
119
120/**
121 * Detect a complete JSX element or fragment without assuming React as the JSX
122 * runtime. A matching outer close prevents generic arrows and type assertions
123 * from being classified as JSX.
124 */
125function isJsxExpression(value: string): boolean {
126 const expression = value.trim()
127 if (expression.length < 3 || expression.charCodeAt(0) !== 60) return false
128 if (expression.startsWith('<>')) return expression.endsWith('</>')
129
130 const firstTagCode = expression.charCodeAt(1)
131 const isIdentifierStart = (firstTagCode >= 65 && firstTagCode <= 90)
132 || (firstTagCode >= 97 && firstTagCode <= 122)
133 || firstTagCode === 36 || firstTagCode === 95
134 if (!isIdentifierStart) return false
135
136 let tagEnd = 2
137 while (tagEnd < expression.length && isJsxTagNameChar(expression.charCodeAt(tagEnd))) tagEnd++
138 const tagName = expression.slice(1, tagEnd)
139 const delimiter = expression.charCodeAt(tagEnd)
140 if (delimiter > 32 && delimiter !== 62 && delimiter !== 47) return false
141 if (expression.endsWith('/>')) return true
142
143 const closeStart = expression.lastIndexOf('</')
144 if (closeStart === -1) return false
145 let closeEnd = closeStart + 2
146 while (closeEnd < expression.length && isJsxTagNameChar(expression.charCodeAt(closeEnd))) closeEnd++
147 return expression.slice(closeStart + 2, closeEnd) === tagName
148 && expression.charCodeAt(closeEnd) === 62
149 && closeEnd === expression.length - 1
150}
151
113152/** Collapse runs of whitespace to single spaces (no regex) */
114153function collapseWhitespace(s: string): string {
115154 const len = s.length
@@ -169,6 +208,8 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
169208
170209 const trimmed = value.trim()
171210
211 if (isJsxExpression(trimmed)) return 'JSX.Element'
212
172213 // BigInt expressions (check early)
173214 if (trimmed.startsWith('BigInt(')) {
174215 return 'bigint'
@@ -266,7 +307,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
266307 // Function expressions. Check these after `as const` so an asserted
267308 // object or array containing arrow-function properties is inferred as a
268309 // container instead of treating the complete initializer as one function.
269 if (trimmed.includes('=>') || trimmed.startsWith('function') || trimmed.startsWith('async')) {
310 if (findMainArrowIndex(trimmed) !== -1 || trimmed.startsWith('function') || trimmed.startsWith('async')) {
270311 return inferFunctionType(trimmed, inUnion, _depth, isConst)
271312 }
272313
@@ -298,6 +339,9 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
298339 return 'symbol'
299340 }
300341
342 const callType = inferCallType(trimmed)
343 if (callType !== null) return callType
344
301345 if (hasTopLevelComparison(trimmed)) return 'boolean'
302346
303347 // Purely numeric arithmetic (`60 * 1000`, `(1 / 2) * 60`) widens to number.
@@ -536,6 +580,11 @@ export function inferFunctionBodyReturnType(body: string, isAsync: boolean = fal
536580 braceDepth--
537581 }
538582 else if (expressionChar === 59 && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) break
583 else if ((expressionChar === 10 || expressionChar === 13) && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
584 let next = i + 1
585 while (next < body.length && body.charCodeAt(next) <= 32) next++
586 if (body.startsWith('return', next) && !isWordChar(body.charCodeAt(next + 6))) break
587 }
539588 i++
540589 }
541590
@@ -586,6 +635,7 @@ function collectParameterTypes(parameters: string): Map<string, string> {
586635function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string {
587636 let value = expression.trim()
588637 while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim()
638 if (isJsxExpression(value)) return 'JSX.Element'
589639 if (value.startsWith('await ')) {
590640 const awaited = inferBodyExpressionType(value.slice(6), parameterTypes)
591641 return awaited.startsWith('Promise<') && awaited.endsWith('>') ? awaited.slice(8, -1) : awaited
@@ -1300,6 +1350,42 @@ function isEntityName(value: string): boolean {
13001350 return parts.length > 0 && parts.every(isIdentifierName)
13011351}
13021352
1353/** Infer calls whose callee can be referenced safely from declaration syntax. */
1354function inferCallType(value: string): string | null {
1355 if (value.length < 3 || !value.endsWith(')')) return null
1356 const open = value.indexOf('(')
1357 if (open === -1 || findMatchingBracket(value, open, '(', ')') !== value.length - 1) return null
1358
1359 let callee = value.slice(0, open).trim()
1360 let isOptional = false
1361 if (callee.endsWith('?.')) {
1362 isOptional = true
1363 callee = callee.slice(0, -2).trim()
1364 }
1365
1366 let entity = callee
1367 let hasTypeArguments = false
1368 const genericStart = callee.indexOf('<')
1369 if (genericStart !== -1) {
1370 const genericEnd = findMatchingBracket(callee, genericStart, '<', '>')
1371 if (genericEnd !== callee.length - 1) return null
1372 entity = callee.slice(0, genericStart).trim()
1373 hasTypeArguments = true
1374 }
1375 if (!isEntityName(entity)) return null
1376
1377 const argumentsText = value.slice(open + 1, -1).trim()
1378 const hasInlineFunction = findMainArrowIndex(argumentsText) !== -1
1379 || argumentsText.startsWith('function')
1380 || argumentsText.startsWith('async function')
1381 const hasReferenceArgument = isEntityName(argumentsText)
1382 const hasNestedCall = !hasInlineFunction && argumentsText.endsWith(')') && inferCallType(argumentsText) !== null
1383 if (!hasTypeArguments && !isOptional && !hasInlineFunction && !hasReferenceArgument && !hasNestedCall) return null
1384
1385 const type = `ReturnType<typeof ${callee}>`
1386 return isOptional ? `${type} | undefined` : type
1387}
1388
13031389/**
13041390 * Clean method signatures for declaration files
13051391 */
@@ -2313,6 +2399,11 @@ export function inferFunctionType(value: string, inUnion: boolean = false, _dept
23132399 else if (body.startsWith('{')) {
23142400 returnType = inferFunctionBodyReturnType(body.slice(1, body.endsWith('}') ? -1 : undefined), false, params)
23152401 }
2402 else if (isJsxExpression(body) || (hasBalancedOuterParentheses(body) && isJsxExpression(body.slice(1, -1).trim()))) {
2403 // Nested callbacks inside parenthesized JSX are implementation details,
2404 // not evidence that the component returns a higher-order function.
2405 returnType = 'JSX.Element'
2406 }
23162407 else if (body.includes('=>')) {
23172408 // This is a higher-order function returning another function
23182409 // For complex nested functions, try to extract just the outer function signature
packages/dtsx/test/react-components.test.tsadded+287-0
Changes to packages/dtsx/test/react-components.test.ts
@@ -0,0 +1,287 @@
1import { describe, expect, test } from 'bun:test'
2import { processSource } from '../src/generator'
3import { processSourceIsolated } from '../src/process-source'
4
5describe('React and JSX component declarations', () => {
6 test('infers JSX returns for function components', () => {
7 const output = processSource(`
8 export interface ButtonProps { label: string }
9 export function Button({ label }: ButtonProps) {
10 return <button aria-label={label}>{label}</button>
11 }
12 `)
13
14 expect(output).toContain('export declare function Button({ label }: ButtonProps): JSX.Element;')
15 })
16
17 test('infers JSX returns for arrow components', () => {
18 const output = processSource(`
19 export interface BadgeProps { text: string }
20 export const Badge = ({ text }: BadgeProps) => <span>{text}</span>
21 `)
22
23 expect(output).toContain('export declare const Badge: ({ text }: BadgeProps) => JSX.Element;')
24 })
25
26 test('supports fragments and member component tags', () => {
27 const output = processSource(`
28 export const Fields = () => <><Form.Field name="email" /><Form.Field name="name" /></>
29 `)
30
31 expect(output).toContain('export declare const Fields: () => JSX.Element;')
32 })
33
34 test('unions nullable component returns', () => {
35 const output = processSource(`
36 export function OptionalPanel(hidden: boolean) {
37 if (hidden) return null
38 return <section>Visible</section>
39 }
40 `)
41
42 expect(output).toContain('export declare function OptionalPanel(hidden: boolean): null | JSX.Element;')
43 })
44
45 test('preserves explicit JSX return annotations', () => {
46 const output = processSource('export function Portal(): JSX.Element | null { return null }')
47 expect(output).toContain('export declare function Portal(): JSX.Element | null;')
48 })
49
50 test('does not mistake angle-bracket assertions for JSX', () => {
51 const output = processSource('export const value = <string>input')
52 expect(output).not.toContain('value: JSX.Element')
53 })
54
55 test('preserves typed forwardRef wrappers and their imports', () => {
56 const output = processSource(`
57 import { forwardRef } from 'react'
58 export interface InputProps { invalid?: boolean }
59 export const Input = forwardRef<HTMLInputElement, InputProps>(
60 ({ invalid }, ref) => <input ref={ref} aria-invalid={invalid} />
61 )
62 `)
63
64 expect(output).toContain("import { forwardRef } from 'react';")
65 expect(output).toContain('Input: ReturnType<typeof forwardRef<HTMLInputElement, InputProps>>;')
66 })
67
68 test('preserves memo wrappers around inline components', () => {
69 const output = processSource(`
70 import { memo } from 'react'
71 export interface CardProps { title: string }
72 export const Card = memo(function Card({ title }: CardProps) {
73 return <article>{title}</article>
74 })
75 `)
76
77 expect(output).toContain("import { memo } from 'react';")
78 expect(output).toContain('Card: ReturnType<typeof memo>;')
79 })
80
81 test('preserves lazy wrappers around dynamic imports', () => {
82 const output = processSource(`
83 import { lazy } from 'react'
84 export const LazyPanel = lazy(() => import('./Panel'))
85 `)
86
87 expect(output).toContain("import { lazy } from 'react';")
88 expect(output).toContain('LazyPanel: ReturnType<typeof lazy>;')
89 })
90
91 test('keeps explicit component contracts on the isolated path', () => {
92 const output = processSourceIsolated(`
93 import type { FC } from 'react'
94 export interface GreetingProps { name: string }
95 export const Greeting: FC<GreetingProps> = ({ name }) => <div>{name}</div>
96 `, 'Greeting.tsx')
97
98 expect(output).toContain("import type { FC } from 'react';")
99 expect(output).toContain('Greeting: FC<GreetingProps>;')
100 })
101
102 test('emits local arrow components referenced by default exports', () => {
103 const output = processSource(`
104 export interface PanelProps { title: string }
105 const Panel = ({ title }: PanelProps) => <section>{title}, ready</section>
106 export default Panel
107 `)
108
109 expect(output).toContain('declare const Panel: ({ title }: PanelProps) => JSX.Element;')
110 expect(output).toContain('export default Panel;')
111 })
112
113 test('stops JSX initializers before following exports', () => {
114 const output = processSource(`
115 export const Header = () => <header>Hello, world</header>
116 export const footerLabel = 'Footer'
117 `)
118
119 expect(output).toContain('Header: () => JSX.Element;')
120 expect(output).toContain("footerLabel: 'Footer';")
121 })
122
123 test('supports semicolon-terminated props followed by components', () => {
124 const output = processSource(`
125 export interface ButtonProps { label: string };
126 export const Button = ({ label }: ButtonProps) => <button>{label}</button>
127 `)
128
129 expect(output).toContain('Button: ({ label }: ButtonProps) => JSX.Element;')
130 })
131
132 test('infers wrappers around named and nested components', () => {
133 const output = processSource(`
134 import { forwardRef, memo } from 'react'
135 export interface FieldProps { value: string };
136 const Field = ({ value }: FieldProps) => <span>{value}</span>
137 export const MemoField = memo(Field)
138 export const RefField = memo(forwardRef<HTMLDivElement, FieldProps>(
139 (props, ref) => <div ref={ref}>{props.value}</div>
140 ))
141 `)
142
143 expect(output).toContain("import { memo } from 'react';")
144 expect(output).not.toContain('forwardRef')
145 expect(output).toContain('MemoField: ReturnType<typeof memo>;')
146 expect(output).toContain('RefField: ReturnType<typeof memo>;')
147 })
148
149 test('handles generic components and class components', () => {
150 const output = processSource(`
151 import * as React from 'react'
152 export interface ListProps<T> { items: T[]; render: (item: T) => React.ReactNode };
153 export function List<T>({ items, render }: ListProps<T>) {
154 return <ul>{items.map(render)}</ul>
155 }
156 export class Boundary extends React.Component<{ children?: React.ReactNode }> {
157 render() { return <section>{this.props.children}</section> }
158 }
159 `)
160
161 expect(output).toContain("import * as React from 'react';")
162 expect(output).toContain('function List<T>({ items, render }: ListProps<T>): JSX.Element;')
163 expect(output).toContain('class Boundary extends React.Component<{ children?: React.ReactNode }>')
164 expect(output).toContain('render(): JSX.Element;')
165 })
166
167 test('scans nested JSX attributes containing comparisons and tag delimiters', () => {
168 const output = processSource(`
169 export const Results = () => (
170 <List
171 title="score > threshold"
172 render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>}
173 />
174 )
175 export const resultCount = 2
176 `)
177
178 expect(output).toContain('Results: () => JSX.Element;')
179 expect(output).toContain('resultCount: 2;')
180 })
181
182 test('infers unparenthesized JSX bodies containing callback props', () => {
183 const output = processSource(`
184 export const InlineResults = () => <List
185 render={item => item.visible ? <strong>{item.label}</strong> : null}
186 />
187 `)
188
189 expect(output).toContain('InlineResults: () => JSX.Element;')
190 })
191
192 test('handles generic arrow components without treating type parameters as JSX', () => {
193 const output = processSource(`
194 export interface CollectionProps<T> { items: T[] }
195 export const Collection = <T,>({ items }: CollectionProps<T>) => (
196 <ul>{items.map(item => <li>{String(item)}</li>)}</ul>
197 )
198 `)
199
200 expect(output).toContain('Collection: <T,>({ items }: CollectionProps<T>) => JSX.Element;')
201 })
202
203 test('supports custom elements, namespaced tags, spreads, and JSX comments', () => {
204 const output = processSource(`
205 export const Graphic = (props: Record<string, unknown>) => <>
206 {/* an embedded comment with ; , and > */}
207 <svg:path {...props} data-state="ready>pending" />
208 <my-element data-id="custom" />
209 </>
210 `)
211
212 expect(output).toContain('Graphic: (props: Record<string, unknown>) => JSX.Element;')
213 })
214
215 test('infers conditional JSX expression returns', () => {
216 const output = processSource(`
217 export const MaybePanel = (hidden: boolean) => hidden ? null : <section />
218 `)
219
220 expect(output).toContain('MaybePanel: (hidden: boolean) => null | JSX.Element;')
221 })
222
223 test('emits anonymous default arrow components and following declarations', () => {
224 const output = processSource(`
225 export interface WelcomeProps { name: string }
226 export default ({ name }: WelcomeProps) => <main>Hello, {name};</main>
227 export const version = '1.0.0'
228 `)
229
230 expect(output).toContain('declare const __dtsx_default_export__: ({ name }: WelcomeProps) => JSX.Element;')
231 expect(output).toContain('export default __dtsx_default_export__;')
232 expect(output).toContain("version: '1.0.0';")
233 })
234
235 test('preserves React namespace imports used by member wrappers', () => {
236 const output = processSource(`
237 import * as React from 'react'
238 export interface LabelProps { value: string }
239 const Label = ({ value }: LabelProps) => <span>{value}</span>
240 export const MemoLabel = React.memo(Label)
241 export const RefLabel = React.forwardRef<HTMLSpanElement, LabelProps>(
242 ({ value }, ref) => <span ref={ref}>{value}</span>
243 )
244 `)
245
246 expect(output).toContain("import * as React from 'react';")
247 expect(output).toContain('MemoLabel: ReturnType<typeof React.memo>;')
248 expect(output).toContain('RefLabel: ReturnType<typeof React.forwardRef<HTMLSpanElement, LabelProps>>;')
249 })
250
251 test('keeps type-only prop imports referenced by emitted component signatures', () => {
252 const output = processSource(`
253 import type { ExternalProps } from './types'
254 export const External = (props: ExternalProps) => <Widget {...props} />
255 `)
256
257 expect(output).toContain("import type { ExternalProps } from './types';")
258 expect(output).toContain('External: (props: ExternalProps) => JSX.Element;')
259 })
260
261 test('isolated declarations skip complex JSX bodies behind explicit contracts', () => {
262 const output = processSourceIsolated(`
263 import type { FC } from 'react'
264 export interface DashboardProps { title: string }
265 export const Dashboard: FC<DashboardProps> = ({ title }) => (
266 <DashboardShell title={\`status > \${title}\`}>
267 {title.length > 0 ? <h1>{title}</h1> : null}
268 </DashboardShell>
269 )
270 export const stable: 'yes' = 'yes'
271 `, 'Dashboard.tsx')
272
273 expect(output).toContain("import type { FC } from 'react';")
274 expect(output).toContain('Dashboard: FC<DashboardProps>;')
275 expect(output).toContain("stable: 'yes';")
276 })
277
278 test('does not classify incomplete JSX or comparison expressions as elements', () => {
279 const output = processSource(`
280 export const lower = left < right
281 export const malformed = '<Panel>'
282 `)
283
284 expect(output).not.toContain('lower: JSX.Element')
285 expect(output).not.toContain('malformed: JSX.Element')
286 })
287})
packages/dtsx/test/vue.test.tsmodified+2-2
Changes to packages/dtsx/test/vue.test.ts
@@ -182,8 +182,8 @@ describe('vue sfc declaration transform', () => {
182182
183183describe('comments inside collapsed props types', () => {
184184 it('keeps JSDoc comments valid in collapsed props types', async () => {
185 const source = await readFile(join(fixturesDir, 'NFTBurnButton.vue'), 'utf8')
186 const dts = processSource(source, 'NFTBurnButton.vue')
185 const source = await readFile(join(fixturesDir, 'BurnButton.vue'), 'utf8')
186 const dts = processSource(source, 'BurnButton.vue')
187187 // the collapsed props type retains the member and its type
188188 expect(dts).toContain('onBurn: (mint: string) => Promise<string>')
189189 // JSDoc survives as a block comment
packages/zig-dtsx/src/emitter.zigmodified+14-3
Changes to packages/zig-dtsx/src/emitter.zig
@@ -147,6 +147,8 @@ const ParsedImportResult = struct {
147147 named_items: []const []const u8,
148148 source: []const u8,
149149 is_type_only: bool,
150 is_namespace: bool,
151 namespace_name: ?[]const u8,
150152};
151153
152154/// Get parsed import components from a declaration's cached parsed_import.
@@ -157,6 +159,8 @@ fn getParsedImport(decl: Declaration) ?ParsedImportResult {
157159 .named_items = pi.named_items,
158160 .source = pi.source,
159161 .is_type_only = pi.is_type_only,
162 .is_namespace = pi.is_namespace,
163 .namespace_name = pi.namespace_name,
160164 };
161165}
162166
@@ -879,8 +883,11 @@ pub fn processDeclarations(
879883 .variable_decl => {
880884 if (d.is_exported) {
881885 extractWords(&combined_words, d.text);
882 if (d.type_annotation.len > 0)
886 if (d.type_annotation.len > 0) {
883887 extractWords(&combined_words, d.type_annotation);
888 } else if (d.value.len > 0) {
889 extractWords(&combined_words, try type_inf.inferNarrowType(alloc, d.value, true, false, 0));
890 }
884891 }
885892 },
886893 .type_decl, .class_decl, .enum_decl, .module_decl, .namespace_decl, .export_decl => {
@@ -951,6 +958,7 @@ pub fn processDeclarations(
951958 const parsed = getParsedImport(imp) orelse continue;
952959
953960 const used_default = if (parsed.default_name) |dn| used_import_items.contains(dn) else false;
961 const used_namespace = if (parsed.namespace_name) |name| used_import_items.contains(name) else false;
954962 used_named.clearRetainingCapacity();
955963
956964 for (parsed.named_items) |item| {
@@ -966,7 +974,7 @@ pub fn processDeclarations(
966974 }
967975 }
968976
969 if (used_default or used_named.items.len > 0) {
977 if (used_default or used_namespace or used_named.items.len > 0) {
970978 import_buf.clearRetainingCapacity();
971979 if (parsed.is_type_only) {
972980 try import_buf.appendSlice("import type ");
@@ -974,7 +982,10 @@ pub fn processDeclarations(
974982 try import_buf.appendSlice("import ");
975983 }
976984
977 if (used_default) {
985 if (used_namespace and parsed.is_namespace) {
986 try import_buf.appendSlice("* as ");
987 try import_buf.appendSlice(parsed.namespace_name.?);
988 } else if (used_default) {
978989 if (parsed.default_name) |dn| try import_buf.appendSlice(dn);
979990 if (used_named.items.len > 0) {
980991 try import_buf.appendSlice(", { ");
packages/zig-dtsx/src/extractors.zigmodified+24-6
Changes to packages/zig-dtsx/src/extractors.zig
@@ -4,7 +4,8 @@ const std = ;
44const ch = ;
55const types = ;
66const type_inf = ;
7const Scanner = .Scanner;
7const scanner = ;
8const Scanner = scanner.Scanner;
89const Declaration = types.Declaration;
910const DeclarationKind = types.DeclarationKind;
1011const Allocator = std.mem.Allocator;
@@ -1141,6 +1142,9 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
11411142 s.skipWhitespaceAndComments();
11421143 const init_start = s.pos;
11431144 var depth: isize = 0;
1145 var angle_depth: isize = 0;
1146 var jsx_depth: isize = 0;
1147 var jsx_tag_end: ?usize = null;
11441148 while (s.pos < s.len) {
11451149 // SIMD fast-skip: bulk-skip bytes that can't be structural
11461150 if (depth > 0) {
@@ -1191,16 +1195,30 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo
11911195 }
11921196 }
11931197 if (s.pos >= s.len) break;
1194 if (s.skipNonCode()) continue;
1198 // The slash in a JSX closing tag has already been
1199 // classified by jsxTagAt at the preceding `<`. Do not let
1200 // regex detection consume through a later closing tag.
1201 const jsx_closing_slash = s.source[s.pos] == ch.CH_SLASH and s.pos > 0 and s.source[s.pos - 1] == ch.CH_LANGLE;
1202 if (!jsx_closing_slash and s.skipNonCode()) continue;
11951203 const ic = s.source[s.pos];
1196 if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET or ic == ch.CH_LANGLE) {
1204 if (ic == ch.CH_LANGLE and depth == 0) {
1205 if (scanner.jsxTagAt(s.source, s.pos)) |tag| {
1206 jsx_depth = @max(0, jsx_depth + tag.delta);
1207 jsx_tag_end = tag.end;
1208 } else if (s.pos + 1 >= s.len or s.source[s.pos + 1] != ch.CH_EQUAL) angle_depth += 1;
1209 } else if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET) {
11971210 depth += 1;
1198 } else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET or (ic == ch.CH_RANGLE and !s.isArrowGT())) {
1211 } else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET) {
11991212 depth -= 1;
1200 } else if (depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
1213 } else if (ic == ch.CH_RANGLE and depth == 0) {
1214 if (jsx_tag_end != null and jsx_tag_end.? == s.pos) {
1215 jsx_tag_end = null;
1216 } else if (jsx_depth == 0 and !s.isArrowGT() and angle_depth > 0 and
1217 (s.pos + 1 >= s.len or s.source[s.pos + 1] != ch.CH_EQUAL)) angle_depth -= 1;
1218 } else if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) {
12011219 break;
12021220 }
1203 if (depth == 0 and s.checkASITopLevel()) break;
1221 if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break;
12041222 s.pos += 1;
12051223 }
12061224 initializer_text = if (skip_isolated_initializer) "" else s.sliceTrimmed(init_start, s.pos);
packages/zig-dtsx/src/lib.zigmodified+14-0
Changes to packages/zig-dtsx/src/lib.zig
@@ -275,3 +275,17 @@ test "result_length stays within the null-terminated allocation" {
275275 const empty = [_]u8{0};
276276 try std.testing.expectEqual(@as(usize, 0), result_length(&empty));
277277}
278
279test "processSourceInternal preserves nested JSX declarations end to end" {
280 const source =
281 \\export const Results = () => (
282 \\ <List title="score > threshold" render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />
283 \\)
284 \\export const resultCount = 2
285 ;
286 const result = try processSourceInternal(source.ptr, source.len, true, false);
287 defer std.heap.c_allocator.free(@constCast(result.ptr)[0 .. result.len + 1]);
288 const output = result.ptr[0..result.len];
289 try std.testing.expect(std.mem.indexOf(u8, output, "Results: () => JSX.Element;") != null);
290 try std.testing.expect(std.mem.indexOf(u8, output, "resultCount: 2;") != null);
291}
packages/zig-dtsx/src/scan_loop.zigmodified+3-1
Changes to packages/zig-dtsx/src/scan_loop.zig
@@ -104,7 +104,9 @@ pub fn scanMainLoop(s: *Scanner) !void {
104104 try s.declarations.append(decl);
105105 } else {
106106 // Skip unknown top-level content
107 if (ch0 == ch.CH_SQUOTE or ch0 == ch.CH_DQUOTE) {
107 if (ch0 == ch.CH_SEMI) {
108 s.pos += 1;
109 } else if (ch0 == ch.CH_SQUOTE or ch0 == ch.CH_DQUOTE) {
108110 s.skipString(ch0);
109111 if (s.pos < s.len and s.source[s.pos] == ch.CH_SEMI) s.pos += 1;
110112 } else if (ch0 == ch.CH_BACKTICK) {
packages/zig-dtsx/src/scanner.zigmodified+155-4
Changes to packages/zig-dtsx/src/scanner.zig
@@ -8,6 +8,68 @@ const Declaration = types.Declaration;
88const DeclarationKind = types.DeclarationKind;
99const Allocator = std.mem.Allocator;
1010
11pub const JsxTagInfo = struct { delta: isize, end: usize };
12
13/// Recognize a JSX tag while skipping an implementation statement. Requiring
14/// a matching close for non-self-closing tags keeps comparisons, assertions,
15/// and generic type parameters out of JSX depth accounting.
16pub fn jsxTagAt(source: []const u8, index: usize) ?JsxTagInfo {
17 if (index + 1 >= source.len or source[index] != ch.CH_LANGLE) return null;
18 const next = source[index + 1];
19 if (next == ch.CH_RANGLE) return .{ .delta = 1, .end = index + 1 };
20 if (next == ch.CH_SLASH) {
21 const close_end = ch.indexOfChar(source, ch.CH_RANGLE, index + 2) orelse return null;
22 return .{ .delta = -1, .end = close_end };
23 }
24 if (!ch.isIdentStart(next)) return null;
25
26 var name_end = index + 2;
27 while (name_end < source.len) : (name_end += 1) {
28 const code = source[name_end];
29 if (!ch.isIdentChar(code) and code != ch.CH_DOT and code != ':' and code != '-') break;
30 }
31 if (name_end >= source.len) return null;
32 const tag_name = source[index + 1 .. name_end];
33 const delimiter = source[name_end];
34 if (!ch.isWhitespace(delimiter) and delimiter != ch.CH_RANGLE and delimiter != ch.CH_SLASH) return null;
35
36 var brace_depth: usize = 0;
37 var quote: u8 = 0;
38 var tag_end = name_end;
39 while (tag_end < source.len) : (tag_end += 1) {
40 const code = source[tag_end];
41 if (quote != 0) {
42 if (code == ch.CH_BACKSLASH) tag_end += 1 else if (code == quote) quote = 0;
43 continue;
44 }
45 if (code == ch.CH_SQUOTE or code == ch.CH_DQUOTE or code == ch.CH_BACKTICK) {
46 quote = code;
47 } else if (code == ch.CH_LBRACE) {
48 brace_depth += 1;
49 } else if (code == ch.CH_RBRACE and brace_depth > 0) {
50 brace_depth -= 1;
51 } else if (code == ch.CH_RANGLE and brace_depth == 0) {
52 break;
53 }
54 }
55 if (tag_end >= source.len) return null;
56
57 var before_end = tag_end - 1;
58 while (before_end > index and ch.isWhitespace(source[before_end])) before_end -= 1;
59 if (source[before_end] == ch.CH_SLASH) return .{ .delta = 0, .end = tag_end };
60
61 var search_from = tag_end + 1;
62 while (ch.indexOf(source, "</", search_from)) |close_start| {
63 const close_name_start = close_start + 2;
64 const close_name_end = close_name_start + tag_name.len;
65 if (close_name_end < source.len and std.mem.eql(u8, source[close_name_start..close_name_end], tag_name) and
66 (source[close_name_end] == ch.CH_RANGLE or ch.isWhitespace(source[close_name_end])))
67 return .{ .delta = 1, .end = tag_end };
68 search_from = close_start + 2;
69 }
70 return null;
71}
72
1173/// Comptime-optimized word comparison: uses integer casts for power-of-2 sizes (1/2/4/8 bytes).
1274inline fn comptime_match(comptime N: usize, src: *const [N]u8, comptime word: []const u8) bool {
1375 // Use integer comparison for power-of-2 sizes (u8, u16, u32, u64)
@@ -580,6 +642,10 @@ pub const Scanner = struct {
580642 /// Uses SIMD to skip 16 non-structural bytes at a time.
581643 pub fn skipToStatementEnd(self: *Scanner) void {
582644 var brace_depth: isize = 0;
645 var paren_depth: isize = 0;
646 var bracket_depth: isize = 0;
647 var jsx_depth: isize = 0;
648 const terminate_on_balanced_brace = self.pos < self.len and self.source[self.pos] == ch.CH_LBRACE;
583649 while (self.pos < self.len) {
584650 // SIMD fast-skip: bulk-skip bytes that can't be structural.
585651 if (brace_depth > 0) {
@@ -588,6 +654,11 @@ pub const Scanner = struct {
588654 const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*;
589655 const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) |
590656 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACE))) |
657 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LPAREN))) |
658 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RPAREN))) |
659 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACKET))) |
660 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACKET))) |
661 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LANGLE))) |
591662 (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) |
592663 (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) |
593664 (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) |
@@ -604,6 +675,11 @@ pub const Scanner = struct {
604675 const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*;
605676 const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) |
606677 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACE))) |
678 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LPAREN))) |
679 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RPAREN))) |
680 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACKET))) |
681 (chunk == @as(@Vector(16, u8), @splat(ch.CH_RBRACKET))) |
682 (chunk == @as(@Vector(16, u8), @splat(ch.CH_LANGLE))) |
607683 (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) |
608684 (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) |
609685 (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) |
@@ -631,29 +707,62 @@ pub const Scanner = struct {
631707 continue;
632708 }
633709 if (c == ch.CH_SLASH) {
710 // JSX closing tags are not regex literals. Preserving the scan
711 // position here lets ASI terminate at the declaration after the
712 // complete element instead of consuming the following export.
713 if (self.pos > 0 and self.source[self.pos - 1] == ch.CH_LANGLE) {
714 self.pos += 1;
715 continue;
716 }
634717 if (self.skipNonCode()) continue;
635718 self.pos += 1;
636719 continue;
637720 }
721 if (c == ch.CH_LANGLE) {
722 if (jsxTagAt(self.source, self.pos)) |tag| jsx_depth = @max(0, jsx_depth + tag.delta);
723 self.pos += 1;
724 continue;
725 }
638726 if (c == ch.CH_LBRACE) {
639727 brace_depth += 1;
640728 self.pos += 1;
641729 continue;
642730 }
731 if (c == ch.CH_LPAREN) {
732 paren_depth += 1;
733 self.pos += 1;
734 continue;
735 }
736 if (c == ch.CH_RPAREN) {
737 if (paren_depth > 0) paren_depth -= 1;
738 self.pos += 1;
739 continue;
740 }
741 if (c == ch.CH_LBRACKET) {
742 bracket_depth += 1;
743 self.pos += 1;
744 continue;
745 }
746 if (c == ch.CH_RBRACKET) {
747 if (bracket_depth > 0) bracket_depth -= 1;
748 self.pos += 1;
749 continue;
750 }
643751 if (c == ch.CH_RBRACE) {
644 brace_depth -= 1;
645 if (brace_depth <= 0) {
752 const closes_nested_brace = brace_depth > 0;
753 if (closes_nested_brace) brace_depth -= 1;
754 if ((!closes_nested_brace or terminate_on_balanced_brace) and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
646755 self.pos += 1;
647756 return;
648757 }
649758 self.pos += 1;
650759 continue;
651760 }
652 if (c == ch.CH_SEMI and brace_depth == 0) {
761 if (c == ch.CH_SEMI and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
653762 self.pos += 1;
654763 return;
655764 }
656 if ((c == ch.CH_LF or c == ch.CH_CR) and brace_depth == 0) {
765 if ((c == ch.CH_LF or c == ch.CH_CR) and brace_depth == 0 and paren_depth == 0 and bracket_depth == 0 and jsx_depth == 0) {
657766 if (self.checkASITopLevel()) return;
658767 }
659768 self.pos += 1;
@@ -1094,6 +1203,48 @@ test "scanner template literals balance nested interpolation braces" {
10941203 try std.testing.expectEqualStrings(" rest", source[s.pos..]);
10951204}
10961205
1206test "scanner statement skipping does not treat JSX closing tags as regex" {
1207 const source = "({ value }) => (<div>{value}</div>)\nexport const next = 1";
1208 var s = Scanner.init(std.testing.allocator, source, true, true);
1209 defer s.deinit();
1210 s.skipToStatementEnd();
1211 try std.testing.expectEqual(std.mem.indexOfScalar(u8, source, '\n').?, s.pos);
1212
1213 const unwrapped = "({ value }) => <div>{value}</div>\nexport const next = 1";
1214 var unwrapped_scanner = Scanner.init(std.testing.allocator, unwrapped, true, true);
1215 defer unwrapped_scanner.deinit();
1216 unwrapped_scanner.skipToStatementEnd();
1217 try std.testing.expectEqual(std.mem.indexOfScalar(u8, unwrapped, '\n').?, unwrapped_scanner.pos);
1218
1219 const punctuation = "({ value }) => <div>{value}; ready</div>\nexport const next = 1";
1220 var punctuation_scanner = Scanner.init(std.testing.allocator, punctuation, true, true);
1221 defer punctuation_scanner.deinit();
1222 punctuation_scanner.skipToStatementEnd();
1223 try std.testing.expectEqual(std.mem.indexOfScalar(u8, punctuation, '\n').?, punctuation_scanner.pos);
1224}
1225
1226test "scanner keeps exports after nested JSX callback attributes" {
1227 const source =
1228 \\export const Results = () => (
1229 \\ <List render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />
1230 \\)
1231 \\export const resultCount = 2
1232 ;
1233 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1234 defer arena.deinit();
1235 var s = Scanner.init(arena.allocator(), source, true, false);
1236 defer s.deinit();
1237 _ = try s.scan();
1238 try std.testing.expect(s.declarations.items.len > 0);
1239 try std.testing.expectEqualStrings("Results", s.declarations.items[0].name);
1240 try std.testing.expectEqualStrings(
1241 "() => (\n <List render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />\n)",
1242 s.declarations.items[0].value,
1243 );
1244 try std.testing.expectEqual(@as(usize, 2), s.declarations.items.len);
1245 try std.testing.expectEqualStrings("resultCount", s.declarations.items[1].name);
1246}
1247
10971248// --- Tests added for performance/fix patches ---
10981249
10991250// isWordInText: when a hit fails the right-boundary check (e.g. searching for
packages/zig-dtsx/src/type_inference.zigmodified+66-1
Changes to packages/zig-dtsx/src/type_inference.zig
@@ -217,6 +217,34 @@ fn isRegexLiteral(value: []const u8) bool {
217217 return false;
218218}
219219
220fn isJsxTagNameChar(c: u8) bool {
221 return ch.isIdentChar(c) or c == '.' or c == ':' or c == '-';
222}
223
224/// Detect a complete JSX element or fragment without assuming a particular JSX
225/// runtime. This deliberately requires a matching outer close or a self-close,
226/// keeping angle-bracket assertions and generic arrow functions distinct.
227fn isJsxExpression(value: []const u8) bool {
228 const expression = trim(value);
229 if (expression.len < 3 or expression[0] != '<') return false;
230 if (ch.startsWith(expression, "<>")) return ch.endsWith(expression, "</>");
231 if (!ch.isIdentStart(expression[1])) return false;
232
233 var tag_end: usize = 2;
234 while (tag_end < expression.len and isJsxTagNameChar(expression[tag_end])) tag_end += 1;
235 const tag_name = expression[1..tag_end];
236 if (tag_name.len == 0 or tag_end >= expression.len) return false;
237 const delimiter = expression[tag_end];
238 if (!ch.isWhitespace(delimiter) and delimiter != '>' and delimiter != '/') return false;
239 if (ch.endsWith(expression, "/>")) return true;
240
241 const close_start = std.mem.lastIndexOf(u8, expression, "</") orelse return false;
242 var close_end = close_start + 2;
243 while (close_end < expression.len and isJsxTagNameChar(expression[close_end])) close_end += 1;
244 return std.mem.eql(u8, expression[close_start + 2 .. close_end], tag_name) and
245 close_end < expression.len and expression[close_end] == '>' and close_end == expression.len - 1;
246}
247
220248fn inferAccessType(alloc: std.mem.Allocator, value: []const u8) InferError!?[]const u8 {
221249 if (value.len > 3 and value[value.len - 1] == ']') {
222250 const bracket = std.mem.lastIndexOfScalar(u8, value, '[') orelse return null;
@@ -271,10 +299,14 @@ fn inferCallType(alloc: std.mem.Allocator, value: []const u8) InferError!?[]cons
271299 has_type_arguments = true;
272300 }
273301 if (!isEntityName(entity)) return null;
302 const arguments = trim(value[open + 1 .. value.len - 1]);
303 const has_inline_function = findMainArrowIndex(arguments) != null or ch.startsWith(arguments, "function") or ch.startsWith(arguments, "async function");
304 const has_reference_argument = isEntityName(arguments);
305 const has_nested_call = !has_inline_function and ch.endsWith(arguments, ")") and (try inferCallType(alloc, arguments)) != null;
274306 const dot = std.mem.indexOfScalar(u8, entity, '.');
275307 if (dot) |index| {
276308 if (ch.indexOfChar(entity, '.', index + 1) != null) return null;
277 } else if (!has_type_arguments and !is_optional) return null;
309 } else if (!has_type_arguments and !is_optional and !has_inline_function and !has_reference_argument and !has_nested_call) return null;
278310
279311 const call_type = try std.fmt.allocPrint(alloc, "ReturnType<typeof {s}>", .{callee});
280312 if (is_optional) return try std.fmt.allocPrint(alloc, "{s} | undefined", .{call_type});
@@ -990,6 +1022,11 @@ pub fn inferFunctionBodyReturnType(alloc: std.mem.Allocator, body: []const u8, p
9901022 if (expression_depth == 0) break;
9911023 expression_depth -= 1;
9921024 } else if (c == ';' and expression_depth == 0) break;
1025 if ((c == '\n' or c == '\r') and expression_depth == 0) {
1026 var next = i + 1;
1027 while (next < content.len and ch.isWhitespace(content[next])) next += 1;
1028 if (ch.startsWith(content[next..], "return") and (next + 6 >= content.len or !ch.isIdentChar(content[next + 6]))) break;
1029 }
9931030 }
9941031 const expression = trim(content[expression_start..i]);
9951032 const inferred = try inferBodyExpressionType(alloc, expression, parameters, depth + 1);
@@ -1012,6 +1049,7 @@ fn inferBodyExpressionType(alloc: std.mem.Allocator, expression: []const u8, par
10121049 while (value.len >= 2 and value[0] == '(' and value[value.len - 1] == ')' and findMatchingBracket(value, 0, '(', ')') == value.len - 1) {
10131050 value = trim(value[1 .. value.len - 1]);
10141051 }
1052 if (isJsxExpression(value)) return "JSX.Element";
10151053 if (findParameterType(parameters, value)) |parameter_type| return parameter_type;
10161054 if (try inferBodyCallType(alloc, value, parameters)) |call_type| return call_type;
10171055
@@ -1114,6 +1152,12 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo
11141152 return inferNarrowType(alloc, trim(trimmed[1 .. trimmed.len - 1]), is_const, in_union, depth + 1);
11151153 }
11161154
1155 if (isJsxExpression(trimmed)) return "JSX.Element";
1156
1157 // Recognize an outer arrow before comma-operator handling because JSX text
1158 // and children may contain commas that are not JavaScript operators.
1159 if (findMainArrowIndex(trimmed) != null) return inferFunctionType(alloc, trimmed, in_union, depth, is_const);
1160
11171161 // The comma operator evaluates to its final operand. Reuse the balanced
11181162 // element splitter so commas inside calls, arrays, and objects are ignored.
11191163 if (findTopLevelComma(trimmed)) |comma| return inferNarrowType(alloc, trim(trimmed[comma + 1 ..]), is_const, in_union, depth + 1);
@@ -2201,6 +2245,12 @@ pub fn inferFunctionType(alloc: std.mem.Allocator, value: []const u8, in_union:
22012245 return_type = try inferFunctionBodyReturnType(alloc, body, params, depth + 1);
22022246 } else if (isDeclarationType(body)) {
22032247 return_type = body;
2248 } else if (isJsxExpression(body) or (body.len >= 2 and body[0] == '(' and body[body.len - 1] == ')' and
2249 findMatchingBracket(body, 0, '(', ')') == body.len - 1 and isJsxExpression(trim(body[1 .. body.len - 1]))))
2250 {
2251 // An arrow inside JSX attributes or children is an
2252 // implementation detail, not a higher-order return value.
2253 return_type = "JSX.Element";
22042254 } else if (ch.contains(body, "=>")) {
22052255 // Higher-order function returning another function
22062256 // Try to extract the outer function signature: (params) =>
@@ -2562,6 +2612,21 @@ test "arithmetic and update expressions emit valid result types" {
25622612 try std.testing.expectEqualStrings("typeof state.count", try inferNarrowType(alloc, "--state.count", false, false, 0));
25632613}
25642614
2615test "JSX expressions infer portable element types" {
2616 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2617 defer arena.deinit();
2618 const alloc = arena.allocator();
2619 try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<button>Save</button>", false, false, 0));
2620 try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<Form.Field name='email' />", false, false, 0));
2621 try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<><span>One</span><span>Two</span></>", false, false, 0));
2622 try std.testing.expectEqualStrings("JSX.Element", try inferFunctionBodyReturnType(alloc, "{ return <Card title={title} /> }", "(title: string)", 0));
2623 try std.testing.expectEqualStrings("null | JSX.Element", try inferFunctionBodyReturnType(alloc, "{ if (hidden) return null; return <Panel /> }", "(hidden: boolean)", 0));
2624 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => (<List render={item => <span>{item}</span>} />)", false, false, 0));
2625 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => <List render={item => <span>{item}</span>} />", false, false, 0));
2626 try std.testing.expectEqualStrings("() => JSX.Element", try inferNarrowType(alloc, "() => (\n<List title=\"score > threshold\" render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>} />\n)", false, false, 0));
2627 try std.testing.expectEqualStrings("boolean", try inferNarrowType(alloc, "<Value>input", false, false, 0));
2628}
2629
25652630test "extractSatisfiesType" {
25662631 try std.testing.expectEqualStrings("Config", extractSatisfiesType("{ port: 3000 } satisfies Config").?);
25672632 try std.testing.expect(extractSatisfiesType("just a value without it") == null);
packages/zig-dtsx/test/fixtures/output/variable.d.tsmodified+1-0
Changes to packages/zig-dtsx/test/fixtures/output/variable.d.ts
@@ -1,3 +1,4 @@
1import process from 'node:process';
12/**
23 * Example of const declaration
34 * @defaultValue `{ apiUrl: 'https://api.stacksjs.org', timeout: '5000' }`
packages/zig-dtsx/test/react-components.test.tsadded+140-0
Changes to packages/zig-dtsx/test/react-components.test.ts
@@ -0,0 +1,140 @@
1import { describe, expect, test } from 'bun:test'
2import { processSource, ZIG_AVAILABLE } from '../src/index'
3
4const describeIf = ZIG_AVAILABLE ? describe : describe.skip
5
6function dts(source: string): string {
7 return processSource(source, true).trim()
8}
9
10describeIf('Zig React component declarations', () => {
11 test('retains namespace imports used by component annotations', () => {
12 const output = dts(`
13 import * as React from 'react'
14 export interface Props { value: string }
15 export const Component: React.FC<Props> = ({ value }) => <span>{value}</span>
16 `)
17
18 expect(output).toContain("import * as React from 'react';")
19 expect(output).toContain('Component: React.FC<Props>;')
20 })
21
22 test('retains forwardRef imports introduced by inferred call types', () => {
23 const output = dts(`
24 import { forwardRef } from 'react'
25 export interface InputProps { invalid?: boolean }
26 export const Input = forwardRef<HTMLInputElement, InputProps>(
27 ({ invalid }, ref) => <input ref={ref} aria-invalid={invalid} />
28 )
29 `)
30
31 expect(output).toContain("import { forwardRef } from 'react';")
32 expect(output).toContain('Input: ReturnType<typeof forwardRef<HTMLInputElement, InputProps>>;')
33 })
34
35 test('infers inline memo and lazy wrappers', () => {
36 const output = dts(`
37 import { lazy, memo } from 'react'
38 export const Card = memo(function Card() { return <article /> })
39 export const LazyPanel = lazy(() => import('./Panel'))
40 `)
41
42 expect(output).toContain("import { lazy, memo } from 'react';")
43 expect(output).toContain('Card: ReturnType<typeof memo>;')
44 expect(output).toContain('LazyPanel: ReturnType<typeof lazy>;')
45 })
46
47 test('emits default-exported local arrow components', () => {
48 const output = dts(`
49 export interface PanelProps { title: string }
50 const Panel = ({ title }: PanelProps) => <section>{title}, ready</section>
51 export default Panel
52 `)
53
54 expect(output).toContain('declare const Panel: ({ title }: PanelProps) => JSX.Element;')
55 expect(output).toContain('export default Panel;')
56 })
57
58 test('supports semicolon props and named component wrappers', () => {
59 const output = dts(`
60 import { memo } from 'react'
61 export interface FieldProps { value: string };
62 const Field = ({ value }: FieldProps) => <span>{value}</span>
63 export const MemoField = memo(Field)
64 `)
65
66 expect(output).toContain("import { memo } from 'react';")
67 expect(output).toContain('MemoField: ReturnType<typeof memo>;')
68 })
69
70 test('scans nested JSX attributes without swallowing adjacent exports', () => {
71 const output = dts(`
72 export const Results = () => <List
73 title="score > threshold"
74 render={item => item.score > 0 ? <strong>{item.label}</strong> : <em>none</em>}
75 />
76 export const resultCount = 2
77 `)
78
79 expect(output).toContain('Results: () => JSX.Element;')
80 expect(output).toContain('resultCount: 2;')
81 })
82
83 test('handles generic arrow components and custom element syntax', () => {
84 const output = dts(`
85 export interface CollectionProps<T> { items: T[] }
86 export const Collection = <T,>({ items }: CollectionProps<T>) => <>
87 <svg:path data-state="ready>pending" />
88 <my-element data-count={items.length} />
89 </>
90 `)
91
92 expect(output).toContain('Collection: <T,>({ items }: CollectionProps<T>) => JSX.Element;')
93 })
94
95 test('infers conditional JSX expression returns', () => {
96 const output = dts('export const MaybePanel = (hidden: boolean) => hidden ? null : <section />')
97 expect(output).toContain('MaybePanel: (hidden: boolean) => null | JSX.Element;')
98 })
99
100 test('preserves namespace wrappers and type-only prop imports', () => {
101 const output = dts(`
102 import * as React from 'react'
103 import type { ExternalProps } from './types'
104 const External = (props: ExternalProps) => <Widget {...props} />
105 export const MemoExternal = React.memo(External)
106 `)
107
108 expect(output).toContain("import * as React from 'react';")
109 expect(output).toContain('MemoExternal: ReturnType<typeof React.memo>;')
110 })
111
112 test('isolated declarations skip JSX bodies behind explicit contracts', () => {
113 const output = processSource(`
114 import type { FC } from 'react'
115 export interface DashboardProps { title: string }
116 export const Dashboard: FC<DashboardProps> = ({ title }) => (
117 <DashboardShell title={\`status > \${title}\`}>
118 {title.length > 0 ? <h1>{title}</h1> : null}
119 </DashboardShell>
120 )
121 export const stable: 'yes' = 'yes'
122 `, true, true)
123
124 expect(output).toContain("import type { FC } from 'react';")
125 expect(output).toContain('Dashboard: FC<DashboardProps>;')
126 expect(output).toContain("stable: 'yes';")
127 })
128
129 test('emits anonymous default JSX arrows containing text punctuation', () => {
130 const output = dts(`
131 export interface WelcomeProps { name: string }
132 export default ({ name }: WelcomeProps) => <main>Hello, {name};</main>
133 export const version = '1.0.0'
134 `)
135
136 expect(output).toContain('declare const __dtsx_default_export__: ({ name }: WelcomeProps) => JSX.Element;')
137 expect(output).toContain('export default __dtsx_default_export__;')
138 expect(output).toContain("version: '1.0.0';")
139 })
140})