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.
| @@ -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; |
| @@ -1082,64 +1083,6 @@ pub fn extractFunction(s: *Scanner, decl_start: usize, is_exported: bool, is_asy | ||
| 1082 | 1083 | // Variable extraction |
| 1083 | 1084 | // ======================================================================== |
| 1084 | 1085 | |
| 1085 | const JsxTagInfo = struct { delta: isize, end: usize }; | |
| 1086 | ||
| 1087 | fn jsxTagAt(source: []const u8, index: usize) ?JsxTagInfo { | |
| 1088 | if (index + 1 >= source.len or source[index] != ch.CH_LANGLE) return null; | |
| 1089 | const next = source[index + 1]; | |
| 1090 | if (next == ch.CH_RANGLE) return .{ .delta = 1, .end = index + 1 }; | |
| 1091 | if (next == ch.CH_SLASH) { | |
| 1092 | const close_end = ch.indexOfChar(source, ch.CH_RANGLE, index + 2) orelse return null; | |
| 1093 | return .{ .delta = -1, .end = close_end }; | |
| 1094 | } | |
| 1095 | if (!ch.isIdentStart(next)) return null; | |
| 1096 | ||
| 1097 | var name_end = index + 2; | |
| 1098 | while (name_end < source.len) : (name_end += 1) { | |
| 1099 | const code = source[name_end]; | |
| 1100 | if (!ch.isIdentChar(code) and code != ch.CH_DOT and code != ':' and code != '-') break; | |
| 1101 | } | |
| 1102 | const tag_name = source[index + 1 .. name_end]; | |
| 1103 | if (name_end >= source.len) return null; | |
| 1104 | const delimiter = source[name_end]; | |
| 1105 | if (!ch.isWhitespace(delimiter) and delimiter != ch.CH_RANGLE and delimiter != ch.CH_SLASH) return null; | |
| 1106 | ||
| 1107 | var brace_depth: usize = 0; | |
| 1108 | var quote: u8 = 0; | |
| 1109 | var tag_end = name_end; | |
| 1110 | while (tag_end < source.len) : (tag_end += 1) { | |
| 1111 | const code = source[tag_end]; | |
| 1112 | if (quote != 0) { | |
| 1113 | if (code == ch.CH_BACKSLASH) tag_end += 1 else if (code == quote) quote = 0; | |
| 1114 | continue; | |
| 1115 | } | |
| 1116 | if (code == ch.CH_SQUOTE or code == ch.CH_DQUOTE or code == ch.CH_BACKTICK) { | |
| 1117 | quote = code; | |
| 1118 | } else if (code == ch.CH_LBRACE) { | |
| 1119 | brace_depth += 1; | |
| 1120 | } else if (code == ch.CH_RBRACE and brace_depth > 0) { | |
| 1121 | brace_depth -= 1; | |
| 1122 | } else if (code == ch.CH_RANGLE and brace_depth == 0) { | |
| 1123 | break; | |
| 1124 | } | |
| 1125 | } | |
| 1126 | if (tag_end >= source.len) return null; | |
| 1127 | ||
| 1128 | var before_end = tag_end - 1; | |
| 1129 | while (before_end > index and ch.isWhitespace(source[before_end])) before_end -= 1; | |
| 1130 | if (source[before_end] == ch.CH_SLASH) return .{ .delta = 0, .end = tag_end }; | |
| 1131 | ||
| 1132 | var search_from = tag_end + 1; | |
| 1133 | while (ch.indexOf(source, "</", search_from)) |close_start| { | |
| 1134 | const close_name_start = close_start + 2; | |
| 1135 | const close_name_end = close_name_start + tag_name.len; | |
| 1136 | if (close_name_end <= source.len and std.mem.eql(u8, source[close_name_start..close_name_end], tag_name) and | |
| 1137 | close_name_end < source.len and (source[close_name_end] == ch.CH_RANGLE or ch.isWhitespace(source[close_name_end]))) return .{ .delta = 1, .end = tag_end }; | |
| 1138 | search_from = close_start + 2; | |
| 1139 | } | |
| 1140 | return null; | |
| 1141 | } | |
| 1142 | ||
| 1143 | 1086 | /// Extract variable declaration(s) |
| 1144 | 1087 | pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_exported: bool) []const Declaration { |
| 1145 | 1088 | s.pos += kind.len; // skip const/let/var |
| @@ -1199,6 +1142,7 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo | ||
| 1199 | 1142 | s.skipWhitespaceAndComments(); |
| 1200 | 1143 | const init_start = s.pos; |
| 1201 | 1144 | var depth: isize = 0; |
| 1145 | var angle_depth: isize = 0; | |
| 1202 | 1146 | var jsx_depth: isize = 0; |
| 1203 | 1147 | var jsx_tag_end: ?usize = null; |
| 1204 | 1148 | while (s.pos < s.len) { |
| @@ -1251,29 +1195,30 @@ pub fn extractVariable(s: *Scanner, decl_start: usize, kind: []const u8, is_expo | ||
| 1251 | 1195 | } |
| 1252 | 1196 | } |
| 1253 | 1197 | if (s.pos >= s.len) break; |
| 1254 | 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; | |
| 1255 | 1203 | const ic = s.source[s.pos]; |
| 1256 | if (ic == ch.CH_LANGLE) { | |
| 1257 | if (jsxTagAt(s.source, s.pos)) |tag| { | |
| 1204 | if (ic == ch.CH_LANGLE and depth == 0) { | |
| 1205 | if (scanner.jsxTagAt(s.source, s.pos)) |tag| { | |
| 1258 | 1206 | jsx_depth = @max(0, jsx_depth + tag.delta); |
| 1259 | 1207 | jsx_tag_end = tag.end; |
| 1260 | } else { | |
| 1261 | depth += 1; | |
| 1262 | } | |
| 1208 | } else if (s.pos + 1 >= s.len or s.source[s.pos + 1] != ch.CH_EQUAL) angle_depth += 1; | |
| 1263 | 1209 | } else if (ic == ch.CH_LPAREN or ic == ch.CH_LBRACE or ic == ch.CH_LBRACKET) { |
| 1264 | 1210 | depth += 1; |
| 1265 | 1211 | } else if (ic == ch.CH_RPAREN or ic == ch.CH_RBRACE or ic == ch.CH_RBRACKET) { |
| 1266 | 1212 | depth -= 1; |
| 1267 | } else if (ic == ch.CH_RANGLE) { | |
| 1213 | } else if (ic == ch.CH_RANGLE and depth == 0) { | |
| 1268 | 1214 | if (jsx_tag_end != null and jsx_tag_end.? == s.pos) { |
| 1269 | 1215 | jsx_tag_end = null; |
| 1270 | } else if (jsx_depth == 0 and !s.isArrowGT() and depth > 0) { | |
| 1271 | depth -= 1; | |
| 1272 | } | |
| 1273 | } else if (depth == 0 and jsx_depth == 0 and (ic == ch.CH_SEMI or ic == ch.CH_COMMA)) { | |
| 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)) { | |
| 1274 | 1219 | break; |
| 1275 | 1220 | } |
| 1276 | if (depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break; | |
| 1221 | if (depth == 0 and angle_depth == 0 and jsx_depth == 0 and s.checkASITopLevel()) break; | |
| 1277 | 1222 | s.pos += 1; |
| 1278 | 1223 | } |
| 1279 | 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 | } | |
| @@ -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 |
| @@ -2245,6 +2245,12 @@ pub fn inferFunctionType(alloc: std.mem.Allocator, value: []const u8, in_union: | ||
| 2245 | 2245 | return_type = try inferFunctionBodyReturnType(alloc, body, params, depth + 1); |
| 2246 | 2246 | } else if (isDeclarationType(body)) { |
| 2247 | 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"; | |
| 2248 | 2254 | } else if (ch.contains(body, "=>")) { |
| 2249 | 2255 | // Higher-order function returning another function |
| 2250 | 2256 | // Try to extract the outer function signature: (params) => |
| @@ -2615,6 +2621,9 @@ test "JSX expressions infer portable element types" { | ||
| 2615 | 2621 | try std.testing.expectEqualStrings("JSX.Element", try inferNarrowType(alloc, "<><span>One</span><span>Two</span></>", false, false, 0)); |
| 2616 | 2622 | try std.testing.expectEqualStrings("JSX.Element", try inferFunctionBodyReturnType(alloc, "{ return <Card title={title} /> }", "(title: string)", 0)); |
| 2617 | 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)); | |
| 2618 | 2627 | try std.testing.expectEqualStrings("boolean", try inferNarrowType(alloc, "<Value>input", false, false, 0)); |
| 2619 | 2628 | } |
| 2620 | 2629 | |
| @@ -66,4 +66,75 @@ describeIf('Zig React component declarations', () => { | ||
| 66 | 66 | expect(output).toContain("import { memo } from 'react';") |
| 67 | 67 | expect(output).toContain('MemoField: ReturnType<typeof memo>;') |
| 68 | 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 | }) | |
| 69 | 140 | }) |