also looking at this
fix: generate React component declarations
#3104
14 files
+875
-35
| @@ -3,6 +3,7 @@ title: CLI Commands | ||
| 3 | 3 | description: Complete reference for dtsx command-line interface. |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | ```bash | |
| 6 | 7 | dtsx --help |
| 7 | 8 | dtsx generate --help |
| 8 | 9 | dtsx watch --help |
| @@ -23,7 +24,7 @@ dtsx --version | ||
| 23 | 24 | |
| 24 | 25 | ### Text Output (Default) |
| 25 | 26 | |
| 26 | ``` | |
| 27 | ```text | |
| 27 | 28 | |
| 28 | 29 | dtsx v1.0.0 |
| 29 | 30 | |
| @@ -67,7 +68,7 @@ dtsx generate --verbose --stats | ||
| 67 | 68 | |
| 68 | 69 | ``` |
| 69 | 70 | |
| 70 | ``` | |
| 71 | ```text | |
| 71 | 72 | |
| 72 | 73 | dtsx v1.0.0 |
| 73 | 74 | |
| @@ -98,7 +99,7 @@ dtsx generate --dry-run --diff | ||
| 98 | 99 | |
| 99 | 100 | ``` |
| 100 | 101 | |
| 101 | ``` | |
| 102 | ```text | |
| 102 | 103 | |
| 103 | 104 | dtsx v1.0.0 |
| 104 | 105 | |
| @@ -633,11 +633,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 633 | 633 | } |
| 634 | 634 | |
| 635 | 635 | /** 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 | |
| 637 | 637 | function skipToStatementEnd(): void { |
| 638 | 638 | let braceDepth = 0 |
| 639 | 639 | let parenDepth = 0 |
| 640 | 640 | let bracketDepth = 0 |
| 641 | let jsxDepth = 0 | |
| 642 | const terminateOnBalancedBrace = source.charCodeAt(pos) === CH_LBRACE | |
| 641 | 643 | STMT_DELIM_RE.lastIndex = pos |
| 642 | 644 | let match |
| 643 | 645 | while ((match = STMT_DELIM_RE.exec(source)) !== null) { |
| @@ -657,6 +659,11 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 657 | 659 | continue |
| 658 | 660 | } |
| 659 | 661 | 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 | } | |
| 660 | 667 | pos = idx |
| 661 | 668 | if (skipNonCode()) { |
| 662 | 669 | STMT_DELIM_RE.lastIndex = pos |
| @@ -666,6 +673,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 666 | 673 | continue |
| 667 | 674 | } |
| 668 | 675 | |
| 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 | ||
| 669 | 683 | if (ch === CH_LPAREN) { |
| 670 | 684 | parenDepth++ |
| 671 | 685 | STMT_DELIM_RE.lastIndex = idx + 1 |
| @@ -692,14 +706,15 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 692 | 706 | continue |
| 693 | 707 | } |
| 694 | 708 | 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 } | |
| 697 | 712 | STMT_DELIM_RE.lastIndex = idx + 1 |
| 698 | 713 | continue |
| 699 | 714 | } |
| 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 } | |
| 701 | 716 | // 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) { | |
| 703 | 718 | pos = idx |
| 704 | 719 | if (checkASITopLevel()) return |
| 705 | 720 | } |
| @@ -1979,6 +1994,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 1979 | 1994 | // in value expressions (comparisons like <=, >=, and plain < >) |
| 1980 | 1995 | let depth = 0 |
| 1981 | 1996 | let angleDepth = 0 |
| 1997 | let jsxDepth = 0 | |
| 1982 | 1998 | while (pos < len) { |
| 1983 | 1999 | if (skipNonCode()) |
| 1984 | 2000 | continue |
| @@ -1988,20 +2004,18 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 1988 | 2004 | else if (ic === CH_RPAREN || ic === CH_RBRACE || ic === CH_RBRACKET) |
| 1989 | 2005 | depth-- |
| 1990 | 2006 | 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++ | |
| 1996 | 2010 | } |
| 1997 | else if (ic === CH_RANGLE && depth === 0 && !isArrowGT()) { | |
| 2011 | else if (ic === CH_RANGLE && depth === 0 && jsxDepth === 0 && !isArrowGT()) { | |
| 1998 | 2012 | // Don't count >= as closing angle bracket, and prevent going negative |
| 1999 | 2013 | if (angleDepth > 0 && (pos + 1 >= len || source.charCodeAt(pos + 1) !== CH_EQUAL)) |
| 2000 | 2014 | angleDepth-- |
| 2001 | 2015 | } |
| 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)) | |
| 2003 | 2017 | break |
| 2004 | if (depth === 0 && angleDepth === 0 && checkASITopLevel()) | |
| 2018 | if (depth === 0 && angleDepth === 0 && jsxDepth === 0 && checkASITopLevel()) | |
| 2005 | 2019 | break |
| 2006 | 2020 | pos++ |
| 2007 | 2021 | } |
| @@ -2051,6 +2065,47 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 2051 | 2065 | return results |
| 2052 | 2066 | } |
| 2053 | 2067 | |
| 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 | ||
| 2054 | 2109 | /** |
| 2055 | 2110 | * Extract interface declaration. |
| 2056 | 2111 | * pos should be at 'interface' keyword. |
| @@ -3565,7 +3620,10 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 3565 | 3620 | else { |
| 3566 | 3621 | // Skip unknown top-level content (string expressions like 'use strict', decorators, etc.) |
| 3567 | 3622 | 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) { | |
| 3569 | 3627 | skipString(ch) |
| 3570 | 3628 | if (pos < len && source.charCodeAt(pos) === CH_SEMI) |
| 3571 | 3629 | pos++ |
| @@ -258,6 +258,7 @@ export function processDeclarations( | ||
| 258 | 258 | // them, so their type references must participate in import retention. |
| 259 | 259 | textParts.push(variable.text) |
| 260 | 260 | if (variable.typeAnnotation) textParts.push(variable.typeAnnotation) |
| 261 | else if (variable.isExported && variable.value) textParts.push(processVariableDeclaration(variable, false)) | |
| 261 | 262 | } |
| 262 | 263 | for (const iface of interfaces) { |
| 263 | 264 | if (iface.isExported || interfaceReferences.has(iface.name)) textParts.push(iface.text) |
| @@ -110,6 +110,45 @@ function countOccurrences(str: string, sub: string): number { | ||
| 110 | 110 | return count |
| 111 | 111 | } |
| 112 | 112 | |
| 113 | function 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 | */ | |
| 125 | function 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 | ||
| 113 | 152 | /** Collapse runs of whitespace to single spaces (no regex) */ |
| 114 | 153 | function collapseWhitespace(s: string): string { |
| 115 | 154 | const len = s.length |
| @@ -169,6 +208,8 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio | ||
| 169 | 208 | |
| 170 | 209 | const trimmed = value.trim() |
| 171 | 210 | |
| 211 | if (isJsxExpression(trimmed)) return 'JSX.Element' | |
| 212 | ||
| 172 | 213 | // BigInt expressions (check early) |
| 173 | 214 | if (trimmed.startsWith('BigInt(')) { |
| 174 | 215 | return 'bigint' |
| @@ -266,7 +307,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio | ||
| 266 | 307 | // Function expressions. Check these after `as const` so an asserted |
| 267 | 308 | // object or array containing arrow-function properties is inferred as a |
| 268 | 309 | // 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')) { | |
| 270 | 311 | return inferFunctionType(trimmed, inUnion, _depth, isConst) |
| 271 | 312 | } |
| 272 | 313 | |
| @@ -298,6 +339,9 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio | ||
| 298 | 339 | return 'symbol' |
| 299 | 340 | } |
| 300 | 341 | |
| 342 | const callType = inferCallType(trimmed) | |
| 343 | if (callType !== null) return callType | |
| 344 | ||
| 301 | 345 | if (hasTopLevelComparison(trimmed)) return 'boolean' |
| 302 | 346 | |
| 303 | 347 | // Purely numeric arithmetic (`60 * 1000`, `(1 / 2) * 60`) widens to number. |
| @@ -536,6 +580,11 @@ export function inferFunctionBodyReturnType(body: string, isAsync: boolean = fal | ||
| 536 | 580 | braceDepth-- |
| 537 | 581 | } |
| 538 | 582 | 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 | } | |
| 539 | 588 | i++ |
| 540 | 589 | } |
| 541 | 590 | |
| @@ -586,6 +635,7 @@ function collectParameterTypes(parameters: string): Map<string, string> { | ||
| 586 | 635 | function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string { |
| 587 | 636 | let value = expression.trim() |
| 588 | 637 | while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim() |
| 638 | if (isJsxExpression(value)) return 'JSX.Element' | |
| 589 | 639 | if (value.startsWith('await ')) { |
| 590 | 640 | const awaited = inferBodyExpressionType(value.slice(6), parameterTypes) |
| 591 | 641 | return awaited.startsWith('Promise<') && awaited.endsWith('>') ? awaited.slice(8, -1) : awaited |
| @@ -1300,6 +1350,42 @@ function isEntityName(value: string): boolean { | ||
| 1300 | 1350 | return parts.length > 0 && parts.every(isIdentifierName) |
| 1301 | 1351 | } |
| 1302 | 1352 | |
| 1353 | /** Infer calls whose callee can be referenced safely from declaration syntax. */ | |
| 1354 | function 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 | ||
| 1303 | 1389 | /** |
| 1304 | 1390 | * Clean method signatures for declaration files |
| 1305 | 1391 | */ |
| @@ -2313,6 +2399,11 @@ export function inferFunctionType(value: string, inUnion: boolean = false, _dept | ||
| 2313 | 2399 | else if (body.startsWith('{')) { |
| 2314 | 2400 | returnType = inferFunctionBodyReturnType(body.slice(1, body.endsWith('}') ? -1 : undefined), false, params) |
| 2315 | 2401 | } |
| 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 | } | |
| 2316 | 2407 | else if (body.includes('=>')) { |
| 2317 | 2408 | // This is a higher-order function returning another function |
| 2318 | 2409 | // For complex nested functions, try to extract just the outer function signature |
| @@ -0,0 +1,287 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { processSource } from '../src/generator' | |
| 3 | import { processSourceIsolated } from '../src/process-source' | |
| 4 | ||
| 5 | describe('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 | }) | |
| @@ -182,8 +182,8 @@ describe('vue sfc declaration transform', () => { | ||
| 182 | 182 | |
| 183 | 183 | describe('comments inside collapsed props types', () => { |
| 184 | 184 | 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') | |
| 187 | 187 | // the collapsed props type retains the member and its type |
| 188 | 188 | expect(dts).toContain('onBurn: (mint: string) => Promise<string>') |
| 189 | 189 | // JSDoc survives as a block comment |
| @@ -147,6 +147,8 @@ const ParsedImportResult = struct { | ||
| 147 | 147 | named_items: []const []const u8, |
| 148 | 148 | source: []const u8, |
| 149 | 149 | is_type_only: bool, |
| 150 | is_namespace: bool, | |
| 151 | namespace_name: ?[]const u8, | |
| 150 | 152 | }; |
| 151 | 153 | |
| 152 | 154 | /// Get parsed import components from a declaration's cached parsed_import. |
| @@ -157,6 +159,8 @@ fn getParsedImport(decl: Declaration) ?ParsedImportResult { | ||
| 157 | 159 | .named_items = pi.named_items, |
| 158 | 160 | .source = pi.source, |
| 159 | 161 | .is_type_only = pi.is_type_only, |
| 162 | .is_namespace = pi.is_namespace, | |
| 163 | .namespace_name = pi.namespace_name, | |
| 160 | 164 | }; |
| 161 | 165 | } |
| 162 | 166 | |
| @@ -879,8 +883,11 @@ pub fn processDeclarations( | ||
| 879 | 883 | .variable_decl => { |
| 880 | 884 | if (d.is_exported) { |
| 881 | 885 | extractWords(&combined_words, d.text); |
| 882 | if (d.type_annotation.len > 0) | |
| 886 | if (d.type_annotation.len > 0) { | |
| 883 | 887 | 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 | } | |
| 884 | 891 | } |
| 885 | 892 | }, |
| 886 | 893 | .type_decl, .class_decl, .enum_decl, .module_decl, .namespace_decl, .export_decl => { |
| @@ -951,6 +958,7 @@ pub fn processDeclarations( | ||
| 951 | 958 | const parsed = getParsedImport(imp) orelse continue; |
| 952 | 959 | |
| 953 | 960 | 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; | |
| 954 | 962 | used_named.clearRetainingCapacity(); |
| 955 | 963 | |
| 956 | 964 | for (parsed.named_items) |item| { |
| @@ -966,7 +974,7 @@ pub fn processDeclarations( | ||
| 966 | 974 | } |
| 967 | 975 | } |
| 968 | 976 | |
| 969 | if (used_default or used_named.items.len > 0) { | |
| 977 | if (used_default or used_namespace or used_named.items.len > 0) { | |
| 970 | 978 | import_buf.clearRetainingCapacity(); |
| 971 | 979 | if (parsed.is_type_only) { |
| 972 | 980 | try import_buf.appendSlice("import type "); |
| @@ -974,7 +982,10 @@ pub fn processDeclarations( | ||
| 974 | 982 | try import_buf.appendSlice("import "); |
| 975 | 983 | } |
| 976 | 984 | |
| 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) { | |
| 978 | 989 | if (parsed.default_name) |dn| try import_buf.appendSlice(dn); |
| 979 | 990 | if (used_named.items.len > 0) { |
| 980 | 991 | try import_buf.appendSlice(", { "); |
| @@ -4,7 +4,8 @@ const std = ; | ||
| 4 | 4 | const ch = ; |
| 5 | 5 | const types = ; |
| 6 | 6 | const type_inf = ; |
| 7 | const Scanner = .Scanner; | |
| 7 | const scanner = ; | |
| 8 | const Scanner = scanner.Scanner; | |
| 8 | 9 | const Declaration = types.Declaration; |
| 9 | 10 | const DeclarationKind = types.DeclarationKind; |
| 10 | 11 | const Allocator = std.mem.Allocator; |
| @@ -1141,6 +1142,9 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo | ||
| 1141 | 1142 | s.skipWhitespaceAndComments(); |
| 1142 | 1143 | const init_start = s.pos; |
| 1143 | 1144 | var depth: isize = 0; |
| 1145 | var angle_depth: isize = 0; | |
| 1146 | var jsx_depth: isize = 0; | |
| 1147 | var jsx_tag_end: ?usize = null; | |
| 1144 | 1148 | while (s.pos < s.len) { |
| 1145 | 1149 | // SIMD fast-skip: bulk-skip bytes that can't be structural |
| 1146 | 1150 | if (depth > 0) { |
| @@ -1191,16 +1195,30 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo | ||
| 1191 | 1195 | } |
| 1192 | 1196 | } |
| 1193 | 1197 | 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; | |
| 1195 | 1203 | 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) { | |
| 1197 | 1210 | 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) { | |
| 1199 | 1212 | 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)) { | |
| 1201 | 1219 | break; |
| 1202 | 1220 | } |
| 1203 | if (depth == 0 and s.checkASITopLevel()) break; | |
| 1221 | if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break; | |
| 1204 | 1222 | s.pos += 1; |
| 1205 | 1223 | } |
| 1206 | 1224 | initializer_text = if (skip_isolated_initializer) "" else s.sliceTrimmed(init_start, s.pos); |
| @@ -275,3 +275,17 @@ test "result_length stays within the null-terminated allocation" { | ||
| 275 | 275 | const empty = [_]u8{0}; |
| 276 | 276 | try std.testing.expectEqual(@as(usize, 0), result_length(&empty)); |
| 277 | 277 | } |
| 278 | ||
| 279 | test "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 | } | |
| @@ -104,7 +104,9 @@ pub fn scanMainLoop(s: *Scanner) !void { | ||
| 104 | 104 | try s.declarations.append(decl); |
| 105 | 105 | } else { |
| 106 | 106 | // 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) { | |
| 108 | 110 | s.skipString(ch0); |
| 109 | 111 | if (s.pos < s.len and s.source[s.pos] == ch.CH_SEMI) s.pos += 1; |
| 110 | 112 | } else if (ch0 == ch.CH_BACKTICK) { |
| @@ -8,6 +8,68 @@ const Declaration = types.Declaration; | ||
| 8 | 8 | const DeclarationKind = types.DeclarationKind; |
| 9 | 9 | const Allocator = std.mem.Allocator; |
| 10 | 10 | |
| 11 | pub 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. | |
| 16 | pub 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 | ||
| 11 | 73 | /// Comptime-optimized word comparison: uses integer casts for power-of-2 sizes (1/2/4/8 bytes). |
| 12 | 74 | inline fn comptime_match(comptime N: usize, src: *const [N]u8, comptime word: []const u8) bool { |
| 13 | 75 | // Use integer comparison for power-of-2 sizes (u8, u16, u32, u64) |
| @@ -580,6 +642,10 @@ pub const Scanner = struct { | ||
| 580 | 642 | /// Uses SIMD to skip 16 non-structural bytes at a time. |
| 581 | 643 | pub fn skipToStatementEnd(self: *Scanner) void { |
| 582 | 644 | 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; | |
| 583 | 649 | while (self.pos < self.len) { |
| 584 | 650 | // SIMD fast-skip: bulk-skip bytes that can't be structural. |
| 585 | 651 | if (brace_depth > 0) { |
| @@ -588,6 +654,11 @@ pub const Scanner = struct { | ||
| 588 | 654 | const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*; |
| 589 | 655 | const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) | |
| 590 | 656 | (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))) | | |
| 591 | 662 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) | |
| 592 | 663 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) | |
| 593 | 664 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) | |
| @@ -604,6 +675,11 @@ pub const Scanner = struct { | ||
| 604 | 675 | const chunk: @Vector(16, u8) = self.source[self.pos..][0..16].*; |
| 605 | 676 | const interesting = (chunk == @as(@Vector(16, u8), @splat(ch.CH_LBRACE))) | |
| 606 | 677 | (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))) | | |
| 607 | 683 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_SQUOTE))) | |
| 608 | 684 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_DQUOTE))) | |
| 609 | 685 | (chunk == @as(@Vector(16, u8), @splat(ch.CH_BACKTICK))) | |
| @@ -631,29 +707,62 @@ pub const Scanner = struct { | ||
| 631 | 707 | continue; |
| 632 | 708 | } |
| 633 | 709 | 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 | } | |
| 634 | 717 | if (self.skipNonCode()) continue; |
| 635 | 718 | self.pos += 1; |
| 636 | 719 | continue; |
| 637 | 720 | } |
| 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 | } | |
| 638 | 726 | if (c == ch.CH_LBRACE) { |
| 639 | 727 | brace_depth += 1; |
| 640 | 728 | self.pos += 1; |
| 641 | 729 | continue; |
| 642 | 730 | } |
| 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 | } | |
| 643 | 751 | 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) { | |
| 646 | 755 | self.pos += 1; |
| 647 | 756 | return; |
| 648 | 757 | } |
| 649 | 758 | self.pos += 1; |
| 650 | 759 | continue; |
| 651 | 760 | } |
| 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) { | |
| 653 | 762 | self.pos += 1; |
| 654 | 763 | return; |
| 655 | 764 | } |
| 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) { | |
| 657 | 766 | if (self.checkASITopLevel()) return; |
| 658 | 767 | } |
| 659 | 768 | self.pos += 1; |
| @@ -1094,6 +1203,48 @@ test "scanner template literals balance nested interpolation braces" { | ||
| 1094 | 1203 | try std.testing.expectEqualStrings(" rest", source[s.pos..]); |
| 1095 | 1204 | } |
| 1096 | 1205 | |
| 1206 | test "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 | ||
| 1226 | test "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 | ||
| 1097 | 1248 | // --- Tests added for performance/fix patches --- |
| 1098 | 1249 | |
| 1099 | 1250 | // isWordInText: when a hit fails the right-boundary check (e.g. searching for |
| @@ -217,6 +217,34 @@ fn isRegexLiteral(value: []const u8) bool { | ||
| 217 | 217 | return false; |
| 218 | 218 | } |
| 219 | 219 | |
| 220 | fn 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. | |
| 227 | fn 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 | ||
| 220 | 248 | fn inferAccessType(alloc: std.mem.Allocator, value: []const u8) InferError!?[]const u8 { |
| 221 | 249 | if (value.len > 3 and value[value.len - 1] == ']') { |
| 222 | 250 | 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 | ||
| 271 | 299 | has_type_arguments = true; |
| 272 | 300 | } |
| 273 | 301 | 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; | |
| 274 | 306 | const dot = std.mem.indexOfScalar(u8, entity, '.'); |
| 275 | 307 | if (dot) |index| { |
| 276 | 308 | 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; | |
| 278 | 310 | |
| 279 | 311 | const call_type = try std.fmt.allocPrint(alloc, "ReturnType<typeof {s}>", .{callee}); |
| 280 | 312 | 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 | ||
| 990 | 1022 | if (expression_depth == 0) break; |
| 991 | 1023 | expression_depth -= 1; |
| 992 | 1024 | } 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 | } | |
| 993 | 1030 | } |
| 994 | 1031 | const expression = trim(content[expression_start..i]); |
| 995 | 1032 | const inferred = try inferBodyExpressionType(alloc, expression, parameters, depth + 1); |
| @@ -1012,6 +1049,7 @@ fn inferBodyExpressionType(alloc: std.mem.Allocator, expression: []const u8, par | ||
| 1012 | 1049 | while (value.len >= 2 and value[0] == '(' and value[value.len - 1] == ')' and findMatchingBracket(value, 0, '(', ')') == value.len - 1) { |
| 1013 | 1050 | value = trim(value[1 .. value.len - 1]); |
| 1014 | 1051 | } |
| 1052 | if (isJsxExpression(value)) return "JSX.Element"; | |
| 1015 | 1053 | if (findParameterType(parameters, value)) |parameter_type| return parameter_type; |
| 1016 | 1054 | if (try inferBodyCallType(alloc, value, parameters)) |call_type| return call_type; |
| 1017 | 1055 | |
| @@ -1114,6 +1152,12 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo | ||
| 1114 | 1152 | return inferNarrowType(alloc, trim(trimmed[1 .. trimmed.len - 1]), is_const, in_union, depth + 1); |
| 1115 | 1153 | } |
| 1116 | 1154 | |
| 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 | ||
| 1117 | 1161 | // The comma operator evaluates to its final operand. Reuse the balanced |
| 1118 | 1162 | // element splitter so commas inside calls, arrays, and objects are ignored. |
| 1119 | 1163 | 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: | ||
| 2201 | 2245 | return_type = try inferFunctionBodyReturnType(alloc, body, params, depth + 1); |
| 2202 | 2246 | } else if (isDeclarationType(body)) { |
| 2203 | 2247 | 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"; | |
| 2204 | 2254 | } else if (ch.contains(body, "=>")) { |
| 2205 | 2255 | // Higher-order function returning another function |
| 2206 | 2256 | // Try to extract the outer function signature: (params) => |
| @@ -2562,6 +2612,21 @@ test "arithmetic and update expressions emit valid result types" { | ||
| 2562 | 2612 | try std.testing.expectEqualStrings("typeof state.count", try inferNarrowType(alloc, "--state.count", false, false, 0)); |
| 2563 | 2613 | } |
| 2564 | 2614 | |
| 2615 | test "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 | ||
| 2565 | 2630 | test "extractSatisfiesType" { |
| 2566 | 2631 | try std.testing.expectEqualStrings("Config", extractSatisfiesType("{ port: 3000 } satisfies Config").?); |
| 2567 | 2632 | try std.testing.expect(extractSatisfiesType("just a value without it") == null); |
| @@ -0,0 +1,140 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { processSource, ZIG_AVAILABLE } from '../src/index' | |
| 3 | ||
| 4 | const describeIf = ZIG_AVAILABLE ? describe : describe.skip | |
| 5 | ||
| 6 | function dts(source: string): string { | |
| 7 | return processSource(source, true).trim() | |
| 8 | } | |
| 9 | ||
| 10 | describeIf('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 | }) | |