also looking at this
fix: generate React component declarations
#3104
14 files
+875
-35
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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' |
| @@ -536,6 +577,11 @@ export function inferFunctionBodyReturnType(body: string, isAsync: boolean = fal | ||
| 536 | 577 | braceDepth-- |
| 537 | 578 | } |
| 538 | 579 | else if (expressionChar === 59 && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) break |
| 580 | else if ((expressionChar === 10 || expressionChar === 13) && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { | |
| 581 | let next = i + 1 | |
| 582 | while (next < body.length && body.charCodeAt(next) <= 32) next++ | |
| 583 | if (body.startsWith('return', next) && !isWordChar(body.charCodeAt(next + 6))) break | |
| 584 | } | |
| 539 | 585 | i++ |
| 540 | 586 | } |
| 541 | 587 | |
| @@ -586,6 +632,7 @@ function collectParameterTypes(parameters: string): Map<string, string> { | ||
| 586 | 632 | function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string { |
| 587 | 633 | let value = expression.trim() |
| 588 | 634 | while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim() |
| 635 | if (isJsxExpression(value)) return 'JSX.Element' | |
| 589 | 636 | if (value.startsWith('await ')) { |
| 590 | 637 | const awaited = inferBodyExpressionType(value.slice(6), parameterTypes) |
| 591 | 638 | return awaited.startsWith('Promise<') && awaited.endsWith('>') ? awaited.slice(8, -1) : awaited |
| @@ -0,0 +1,53 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { processSource } from '../src/generator' | |
| 3 | ||
| 4 | describe('React and JSX component declarations', () => { | |
| 5 | test('infers JSX returns for function components', () => { | |
| 6 | const output = processSource(` | |
| 7 | export interface ButtonProps { label: string } | |
| 8 | export function Button({ label }: ButtonProps) { | |
| 9 | return <button aria-label={label}>{label}</button> | |
| 10 | } | |
| 11 | `) | |
| 12 | ||
| 13 | expect(output).toContain('export declare function Button({ label }: ButtonProps): JSX.Element;') | |
| 14 | }) | |
| 15 | ||
| 16 | test('infers JSX returns for arrow components', () => { | |
| 17 | const output = processSource(` | |
| 18 | export interface BadgeProps { text: string } | |
| 19 | export const Badge = ({ text }: BadgeProps) => <span>{text}</span> | |
| 20 | `) | |
| 21 | ||
| 22 | expect(output).toContain('export declare const Badge: ({ text }: BadgeProps) => JSX.Element;') | |
| 23 | }) | |
| 24 | ||
| 25 | test('supports fragments and member component tags', () => { | |
| 26 | const output = processSource(` | |
| 27 | export const Fields = () => <><Form.Field name="email" /><Form.Field name="name" /></> | |
| 28 | `) | |
| 29 | ||
| 30 | expect(output).toContain('export declare const Fields: () => JSX.Element;') | |
| 31 | }) | |
| 32 | ||
| 33 | test('unions nullable component returns', () => { | |
| 34 | const output = processSource(` | |
| 35 | export function OptionalPanel(hidden: boolean) { | |
| 36 | if (hidden) return null | |
| 37 | return <section>Visible</section> | |
| 38 | } | |
| 39 | `) | |
| 40 | ||
| 41 | expect(output).toContain('export declare function OptionalPanel(hidden: boolean): null | JSX.Element;') | |
| 42 | }) | |
| 43 | ||
| 44 | test('preserves explicit JSX return annotations', () => { | |
| 45 | const output = processSource('export function Portal(): JSX.Element | null { return null }') | |
| 46 | expect(output).toContain('export declare function Portal(): JSX.Element | null;') | |
| 47 | }) | |
| 48 | ||
| 49 | test('does not mistake angle-bracket assertions for JSX', () => { | |
| 50 | const output = processSource('export const value = <string>input') | |
| 51 | expect(output).not.toContain('value: JSX.Element') | |
| 52 | }) | |
| 53 | }) | |
| @@ -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; |
| @@ -990,6 +1018,11 @@ pub fn inferFunctionBodyReturnType(alloc: std.mem.Allocator, body: []const u8, p | ||
| 990 | 1018 | if (expression_depth == 0) break; |
| 991 | 1019 | expression_depth -= 1; |
| 992 | 1020 | } else if (c == ';' and expression_depth == 0) break; |
| 1021 | if ((c == '\n' or c == '\r') and expression_depth == 0) { | |
| 1022 | var next = i + 1; | |
| 1023 | while (next < content.len and ch.isWhitespace(content[next])) next += 1; | |
| 1024 | if (ch.startsWith(content[next..], "return") and (next + 6 >= content.len or !ch.isIdentChar(content[next + 6]))) break; | |
| 1025 | } | |
| 993 | 1026 | } |
| 994 | 1027 | const expression = trim(content[expression_start..i]); |
| 995 | 1028 | const inferred = try inferBodyExpressionType(alloc, expression, parameters, depth + 1); |
| @@ -1012,6 +1045,7 @@ fn inferBodyExpressionType(alloc: std.mem.Allocator, expression: []const u8, par | ||
| 1012 | 1045 | while (value.len >= 2 and value[0] == '(' and value[value.len - 1] == ')' and findMatchingBracket(value, 0, '(', ')') == value.len - 1) { |
| 1013 | 1046 | value = trim(value[1 .. value.len - 1]); |
| 1014 | 1047 | } |
| 1048 | if (isJsxExpression(value)) return "JSX.Element"; | |
| 1015 | 1049 | if (findParameterType(parameters, value)) |parameter_type| return parameter_type; |
| 1016 | 1050 | if (try inferBodyCallType(alloc, value, parameters)) |call_type| return call_type; |
| 1017 | 1051 | |
| @@ -1114,6 +1148,8 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo | ||
| 1114 | 1148 | return inferNarrowType(alloc, trim(trimmed[1 .. trimmed.len - 1]), is_const, in_union, depth + 1); |
| 1115 | 1149 | } |
| 1116 | 1150 | |
| 1151 | if (isJsxExpression(trimmed)) return "JSX.Element"; | |
| 1152 | ||
| 1117 | 1153 | // The comma operator evaluates to its final operand. Reuse the balanced |
| 1118 | 1154 | // element splitter so commas inside calls, arrays, and objects are ignored. |
| 1119 | 1155 | if (findTopLevelComma(trimmed)) |comma| return inferNarrowType(alloc, trim(trimmed[comma + 1 ..]), is_const, in_union, depth + 1); |
| @@ -2562,6 +2598,18 @@ test "arithmetic and update expressions emit valid result types" { | ||
| 2562 | 2598 | try std.testing.expectEqualStrings("typeof state.count", try inferNarrowType(alloc, "--state.count", false, false, 0)); |
| 2563 | 2599 | } |
| 2564 | 2600 | |
| 2601 | test "JSX expressions infer portable element types" { | |
| 2602 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | |
| 2603 | defer arena.deinit(); | |
| 2604 | const alloc = arena.allocator(); | |
| 2605 | try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<button>Save</button>", false, false, 0)); | |
| 2606 | try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<Form.Field name='email' />", false, false, 0)); | |
| 2607 | try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<><span>One</span><span>Two</span></>", false, false, 0)); | |
| 2608 | try std.testing.expectEqualStrings("JSX.Element", try inferFunctionBodyReturnType(alloc, "{ return <Card title={title} /> }", "(title: string)", 0)); | |
| 2609 | try std.testing.expectEqualStrings("null | JSX.Element", try inferFunctionBodyReturnType(alloc, "{ if (hidden) return null; return <Panel /> }", "(hidden: boolean)", 0)); | |
| 2610 | try std.testing.expectEqualStrings("boolean", try inferNarrowType(alloc, "<Value>input", false, false, 0)); | |
| 2611 | } | |
| 2612 | ||
| 2565 | 2613 | test "extractSatisfiesType" { |
| 2566 | 2614 | try std.testing.expectEqualStrings("Config", extractSatisfiesType("{ port: 3000 } satisfies Config").?); |
| 2567 | 2615 | try std.testing.expect(extractSatisfiesType("just a value without it") == null); |