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

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.

packages/dtsx/src/processor/type-inference.tsmodified+47-0
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'
@@ -536,6 +577,11 @@ export function inferFunctionBodyReturnType(body: string, isAsync: boolean = fal
536577 braceDepth--
537578 }
538579 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 }
539585 i++
540586 }
541587
@@ -586,6 +632,7 @@ function collectParameterTypes(parameters: string): Map<string, string> {
586632function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string {
587633 let value = expression.trim()
588634 while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim()
635 if (isJsxExpression(value)) return 'JSX.Element'
589636 if (value.startsWith('await ')) {
590637 const awaited = inferBodyExpressionType(value.slice(6), parameterTypes)
591638 return awaited.startsWith('Promise<') && awaited.endsWith('>') ? awaited.slice(8, -1) : awaited
packages/dtsx/test/react-components.test.tsadded+53-0
Changes to packages/dtsx/test/react-components.test.ts
@@ -0,0 +1,53 @@
1import { describe, expect, test } from 'bun:test'
2import { processSource } from '../src/generator'
3
4describe('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})
packages/zig-dtsx/src/type_inference.zigmodified+48-0
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;
@@ -990,6 +1018,11 @@ pub fn inferFunctionBodyReturnType(alloc: std.mem.Allocator, body: []const u8, p
9901018 if (expression_depth == 0) break;
9911019 expression_depth -= 1;
9921020 } 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 }
9931026 }
9941027 const expression = trim(content[expression_start..i]);
9951028 const inferred = try inferBodyExpressionType(alloc, expression, parameters, depth + 1);
@@ -1012,6 +1045,7 @@ fn inferBodyExpressionType(alloc: std.mem.Allocator, expression: []const u8, par
10121045 while (value.len >= 2 and value[0] == '(' and value[value.len - 1] == ')' and findMatchingBracket(value, 0, '(', ')') == value.len - 1) {
10131046 value = trim(value[1 .. value.len - 1]);
10141047 }
1048 if (isJsxExpression(value)) return "JSX.Element";
10151049 if (findParameterType(parameters, value)) |parameter_type| return parameter_type;
10161050 if (try inferBodyCallType(alloc, value, parameters)) |call_type| return call_type;
10171051
@@ -1114,6 +1148,8 @@ pub fn inferNarrowType(alloc: std.mem.Allocator, value: []const u8, is_const: bo
11141148 return inferNarrowType(alloc, trim(trimmed[1 .. trimmed.len - 1]), is_const, in_union, depth + 1);
11151149 }
11161150
1151 if (isJsxExpression(trimmed)) return "JSX.Element";
1152
11171153 // The comma operator evaluates to its final operand. Reuse the balanced
11181154 // element splitter so commas inside calls, arrays, and objects are ignored.
11191155 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" {
25622598 try std.testing.expectEqualStrings("typeof state.count", try inferNarrowType(alloc, "--state.count", false, false, 0));
25632599}
25642600
2601test "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
25652613test "extractSatisfiesType" {
25662614 try std.testing.expectEqualStrings("Config", extractSatisfiesType("{ port: 3000 } satisfies Config").?);
25672615 try std.testing.expect(extractSatisfiesType("just a value without it") == null);