@stacksjs/zig-dtsx 0.9.12 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.zig CHANGED
@@ -10,8 +10,14 @@ const ProcessResult = struct {
10
10
  };
11
11
 
12
12
  fn emptyResult() ProcessResult {
13
- const empty = std.heap.c_allocator.alloc(u8, 1) catch @panic("OOM");
14
- empty[0] = 0;
13
+ // Allocate 16 zero bytes so the SIMD result_length scan can read a full
14
+ // 16-byte vector without touching memory past the allocation. The caller
15
+ // sees `len == 0` and `ptr[0] == 0`, but free_result will receive `len + 1
16
+ // = 1` so the freed range matches the slice length the allocator tracks.
17
+ // Pre-fix this allocated 1 byte and the SIMD loop in result_length would
18
+ // read 15 bytes of unrelated heap state.
19
+ const empty = std.heap.c_allocator.alloc(u8, 16) catch @panic("OOM");
20
+ @memset(empty, 0);
15
21
  return .{ .ptr = empty.ptr, .len = 0 };
16
22
  }
17
23
 
@@ -98,11 +104,21 @@ fn processSourceInternal(
98
104
  return .{ .ptr = dts_output.ptr, .len = dts_output.len };
99
105
  }
100
106
 
101
- /// Get the length of a result string (without null terminator)
107
+ /// Get the length of a result string (without null terminator).
108
+ /// SIMD-scan 16 bytes at a time for the null terminator — faster on long
109
+ /// results than the byte-by-byte loop the compiler will generate from the
110
+ /// scalar form.
102
111
  export fn result_length(ptr: [*]const u8) usize {
103
112
  var i: usize = 0;
104
- while (ptr[i] != 0) i += 1;
105
- return i;
113
+ while (true) {
114
+ const chunk: @Vector(16, u8) = ptr[i..][0..16].*;
115
+ const zero_mask = chunk == @as(@Vector(16, u8), @splat(0));
116
+ if (@reduce(.Or, zero_mask)) {
117
+ const bits: u16 = @bitCast(zero_mask);
118
+ return i + @ctz(bits);
119
+ }
120
+ i += 16;
121
+ }
106
122
  }
107
123
 
108
124
  /// Free a result string previously returned by process_source
@@ -204,7 +220,12 @@ export fn process_batch(
204
220
  @intCast(thread_count)
205
221
  else
206
222
  @intCast(std.Thread.getCpuCount() catch 4);
207
- const num_threads = @min(max_threads, n);
223
+ // Cap the thread count so each thread gets at least ~4 files. Spawn+join
224
+ // overhead (~100µs per thread) dominates if a thread has only 1-2 small
225
+ // files to process. Mirrors the heuristic in main.zig:processProject.
226
+ const min_files_per_thread = 4;
227
+ const desired_threads = @max(n / min_files_per_thread, 1);
228
+ const num_threads = @min(@min(max_threads, desired_threads), n);
208
229
 
209
230
  if (num_threads <= 1) {
210
231
  // Single-threaded: process all sequentially
@@ -220,9 +241,15 @@ export fn process_batch(
220
241
  };
221
242
  defer std.heap.c_allocator.free(threads);
222
243
 
223
- const chunk_size = (n + num_threads - 1) / num_threads;
224
- var thread_spawned: [64]bool = .{false} ** 64; // max 64 threads
244
+ // Heap-allocate thread-spawned flags so we don't overflow when num_threads > 64.
245
+ const thread_spawned = std.heap.c_allocator.alloc(bool, num_threads) catch {
246
+ batchWorker(tasks);
247
+ return;
248
+ };
249
+ defer std.heap.c_allocator.free(thread_spawned);
250
+ @memset(thread_spawned, false);
225
251
 
252
+ const chunk_size = (n + num_threads - 1) / num_threads;
226
253
  for (0..num_threads) |t| {
227
254
  const start = t * chunk_size;
228
255
  if (start >= n) break;
package/src/main.zig CHANGED
@@ -7,8 +7,17 @@ const Scanner = @import("scanner.zig").Scanner;
7
7
  const emitter = @import("emitter.zig");
8
8
 
9
9
  // Platform-aware C stdio bindings.
10
- // On Windows, @cImport fails because stdin/stdout are runtime function calls
11
- // that Zig can't evaluate at comptime. We declare the extern functions manually.
10
+ //
11
+ // Zig 0.17 removed `@cImport` as a language builtin, so we declare every C
12
+ // symbol we need manually here instead of pulling them in from system
13
+ // headers. This also keeps cross-compilation working on CI runners where
14
+ // `addTranslateC` blows up with `CacheCheckFailed` for cross targets.
15
+ //
16
+ // On Windows we use UCRT's `_findfirst`/`__acrt_iob_func` family. On POSIX
17
+ // (Linux + the BSD-derived Apple platforms) we use stdio + dirent + open(2)
18
+ // directly. Stdio FILE* globals have different external symbol names
19
+ // across libcs (`stdin`/`stdout`/`stderr` on glibc/musl, `__stdinp` etc.
20
+ // on Apple), so we expose them as functions that resolve via `@extern`.
12
21
  const c = if (builtin.os.tag == .windows) struct {
13
22
  pub const FILE = opaque {};
14
23
  pub extern "c" fn __acrt_iob_func(index: c_int) *FILE;
@@ -34,14 +43,59 @@ const c = if (builtin.os.tag == .windows) struct {
34
43
  pub extern "c" fn _findnext(handle: isize, fileinfo: *_finddata_t) c_int;
35
44
  pub extern "c" fn _findclose(handle: isize) c_int;
36
45
  pub extern "c" fn _mkdir(path: [*:0]const u8) c_int;
37
- } else @cImport({
38
- @cInclude("stdio.h");
39
- @cInclude("stdlib.h");
40
- @cInclude("dirent.h");
41
- @cInclude("sys/stat.h");
42
- @cInclude("fcntl.h");
43
- @cInclude("unistd.h");
44
- });
46
+ } else struct {
47
+ pub const FILE = opaque {};
48
+
49
+ /// True when targeting a BSD-derived libc (Darwin, FreeBSD, DragonFly).
50
+ /// These share the `__stdinp` / `__stdoutp` / `__stderrp` stdio symbol
51
+ /// naming and the BSD-style hex `O_CREAT`/`O_TRUNC` flag values, both
52
+ /// of which differ from glibc/musl Linux.
53
+ const bsd_libc = builtin.os.tag.isDarwin() or
54
+ builtin.os.tag == .freebsd or
55
+ builtin.os.tag == .dragonfly;
56
+
57
+ pub extern "c" fn fopen(path: [*:0]const u8, mode: [*:0]const u8) ?*FILE;
58
+ pub extern "c" fn fclose(stream: *FILE) c_int;
59
+ pub extern "c" fn fread(ptr: [*]u8, size: usize, nmemb: usize, stream: *FILE) usize;
60
+ pub extern "c" fn fwrite(ptr: [*]const u8, size: usize, nmemb: usize, stream: *FILE) usize;
61
+ pub extern "c" fn fseek(stream: *FILE, offset: c_long, whence: c_int) c_int;
62
+ pub extern "c" fn ftell(stream: *FILE) c_long;
63
+ pub const SEEK_SET: c_int = 0;
64
+ pub const SEEK_END: c_int = 2;
65
+
66
+ pub extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
67
+ pub extern "c" fn openat(dirfd: c_int, path: [*:0]const u8, flags: c_int, ...) c_int;
68
+ pub extern "c" fn close(fd: c_int) c_int;
69
+ pub extern "c" fn read(fd: c_int, buf: [*]u8, count: usize) isize;
70
+ pub extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize;
71
+ pub extern "c" fn lseek(fd: c_int, offset: c_long, whence: c_int) c_long;
72
+ pub extern "c" fn mkdir(path: [*:0]const u8, mode: c_uint) c_int;
73
+
74
+ // fcntl.h flag values. BSD-derived libcs (Darwin/FreeBSD/DragonFly) use
75
+ // hex bits; glibc/musl Linux use octal — these specific constants are
76
+ // mutually inconsistent so we have to pick per OS.
77
+ pub const O_RDONLY: c_int = 0;
78
+ pub const O_WRONLY: c_int = 1;
79
+ pub const O_CREAT: c_int = if (bsd_libc) 0x0200 else 0o100;
80
+ pub const O_TRUNC: c_int = if (bsd_libc) 0x0400 else 0o1000;
81
+
82
+ // Stdio FILE* globals. The on-disk symbol names differ between BSD
83
+ // libcs (`__stdinp` etc.) and glibc/musl (`stdin` etc.), so resolve
84
+ // them via @extern. Exposed as functions so getStdioPtr's
85
+ // "function-like" branch picks them up correctly.
86
+ pub fn stdin() *FILE {
87
+ const ptr = @extern(**FILE, .{ .name = if (bsd_libc) "__stdinp" else "stdin" });
88
+ return ptr.*;
89
+ }
90
+ pub fn stdout() *FILE {
91
+ const ptr = @extern(**FILE, .{ .name = if (bsd_libc) "__stdoutp" else "stdout" });
92
+ return ptr.*;
93
+ }
94
+ pub fn stderr() *FILE {
95
+ const ptr = @extern(**FILE, .{ .name = if (bsd_libc) "__stderrp" else "stderr" });
96
+ return ptr.*;
97
+ }
98
+ };
45
99
 
46
100
  fn getStdout() *c.FILE {
47
101
  if (builtin.os.tag == .windows) return c.__acrt_iob_func(1);
@@ -150,15 +204,19 @@ fn collectTsFiles(alloc: std.mem.Allocator, dir_path: []const u8) ![][]const u8
150
204
  if (c._findnext(handle, &fdata) != 0) break;
151
205
  }
152
206
  } else {
153
- // POSIX: use opendir/readdir
207
+ // POSIX: use std.c's per-platform `dirent` layout + `opendir` /
208
+ // `readdir` / `closedir`. Zig's std already encodes the right
209
+ // struct shape for glibc/musl, Darwin, FreeBSD, DragonFly, etc.,
210
+ // so we don't have to (and `readdir` is dispatched to the right
211
+ // symbol on macOS x86_64 — `readdir$INODE64` — automatically).
154
212
  const dir_z = try alloc.dupeZ(u8, dir_path);
155
213
  defer alloc.free(dir_z);
156
214
 
157
- const dir = c.opendir(dir_z.ptr) orelse return files.toOwnedSlice();
158
- defer _ = c.closedir(dir);
215
+ const dir = std.c.opendir(dir_z.ptr) orelse return files.toOwnedSlice();
216
+ defer _ = std.c.closedir(dir);
159
217
 
160
- while (c.readdir(dir)) |entry| {
161
- const name_ptr: [*:0]const u8 = @ptrCast(&entry.*.d_name);
218
+ while (std.c.readdir(dir)) |entry| {
219
+ const name_ptr: [*:0]const u8 = @ptrCast(&entry.*.name);
162
220
  const name = std.mem.span(name_ptr);
163
221
  if (std.mem.endsWith(u8, name, ".ts") and !std.mem.endsWith(u8, name, ".d.ts")) {
164
222
  try files.append(try alloc.dupe(u8, name));
@@ -185,77 +243,50 @@ const WorkerCtx = struct {
185
243
 
186
244
  /// Worker: read + process + write each file using thread-local arena.
187
245
  /// Uses POSIX openat for directory-relative I/O (no path resolution overhead).
246
+ /// Arena is reset every N files to batch allocations and reduce overhead.
188
247
  fn workerFn(ctx: WorkerCtx) void {
189
248
  var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
190
249
  defer arena.deinit();
191
250
  const default_import_order = [_][]const u8{"bun"};
251
+ var files_since_reset: usize = 0;
192
252
 
193
253
  for (ctx.tasks) |task| {
194
254
  const alloc = arena.allocator();
195
255
 
196
256
  if (builtin.os.tag == .windows) {
197
- // Windows: C stdio with full paths
198
- const fp = c.fopen(task.input_name_z, "rb") orelse {
199
- _ = arena.reset(.retain_capacity);
200
- continue;
201
- };
257
+ const fp = c.fopen(task.input_name_z, "rb") orelse continue;
202
258
  _ = c.fseek(fp, 0, c.SEEK_END);
203
259
  const tell_result = c.ftell(fp);
204
- if (tell_result < 0) {
205
- _ = c.fclose(fp);
206
- _ = arena.reset(.retain_capacity);
207
- continue;
208
- }
260
+ if (tell_result < 0) { _ = c.fclose(fp); continue; }
209
261
  const size: usize = @intCast(tell_result);
210
262
  _ = c.fseek(fp, 0, c.SEEK_SET);
211
- const buf = alloc.alloc(u8, size) catch {
212
- _ = c.fclose(fp);
213
- _ = arena.reset(.retain_capacity);
214
- continue;
215
- };
263
+ const buf = alloc.alloc(u8, size) catch { _ = c.fclose(fp); continue; };
216
264
  const nread = c.fread(buf.ptr, 1, size, fp);
217
265
  _ = c.fclose(fp);
218
266
 
219
267
  var scanner = Scanner.init(alloc, buf[0..nread], task.keep_comments, false);
220
- _ = scanner.scan() catch {
221
- _ = arena.reset(.retain_capacity);
222
- continue;
223
- };
268
+ _ = scanner.scan() catch continue;
224
269
  const output = emitter.processDeclarations(
225
270
  alloc, alloc, scanner.declarations.items, buf[0..nread],
226
271
  task.keep_comments, &default_import_order,
227
- ) catch {
228
- _ = arena.reset(.retain_capacity);
229
- continue;
230
- };
272
+ ) catch continue;
231
273
 
232
- const out_fp = c.fopen(task.output_name_z, "wb") orelse {
233
- _ = arena.reset(.retain_capacity);
234
- continue;
235
- };
274
+ const out_fp = c.fopen(task.output_name_z, "wb") orelse continue;
236
275
  _ = c.fwrite(output.ptr, 1, output.len, out_fp);
237
276
  _ = c.fwrite("\n", 1, 1, out_fp);
238
277
  _ = c.fclose(out_fp);
239
278
  } else {
240
- // POSIX: openat + read/write (no path resolution overhead)
279
+ // POSIX: openat + fstat + read (fewer syscalls than lseek+lseek+read)
241
280
  const fd = c.openat(ctx.input_dir_fd, task.input_name_z, c.O_RDONLY);
242
- if (fd < 0) {
243
- _ = arena.reset(.retain_capacity);
244
- continue;
245
- }
281
+ if (fd < 0) continue;
282
+
283
+ // Use lseek to get file size (avoids opaque cimport struct_stat)
246
284
  const end_off = c.lseek(fd, 0, 2); // SEEK_END
247
- if (end_off < 0) {
248
- _ = c.close(fd);
249
- _ = arena.reset(.retain_capacity);
250
- continue;
251
- }
285
+ if (end_off < 0) { _ = c.close(fd); continue; }
252
286
  _ = c.lseek(fd, 0, 0); // SEEK_SET
253
287
  const size: usize = @intCast(end_off);
254
- const buf = alloc.alloc(u8, size) catch {
255
- _ = c.close(fd);
256
- _ = arena.reset(.retain_capacity);
257
- continue;
258
- };
288
+
289
+ const buf = alloc.alloc(u8, size) catch { _ = c.close(fd); continue; };
259
290
  var total: usize = 0;
260
291
  while (total < size) {
261
292
  const n = c.read(fd, @ptrCast(buf.ptr + total), size - total);
@@ -266,35 +297,32 @@ fn workerFn(ctx: WorkerCtx) void {
266
297
  const source = buf[0..total];
267
298
 
268
299
  var scanner = Scanner.init(alloc, source, task.keep_comments, false);
269
- _ = scanner.scan() catch {
270
- _ = arena.reset(.retain_capacity);
271
- continue;
272
- };
300
+ _ = scanner.scan() catch continue;
273
301
  const output = emitter.processDeclarations(
274
302
  alloc, alloc, scanner.declarations.items, source,
275
303
  task.keep_comments, &default_import_order,
276
- ) catch {
277
- _ = arena.reset(.retain_capacity);
278
- continue;
279
- };
304
+ ) catch continue;
280
305
 
281
- // Combined write: data + "\n" in single syscall
282
- const combined = alloc.alloc(u8, output.len + 1) catch {
283
- _ = arena.reset(.retain_capacity);
284
- continue;
285
- };
286
- @memcpy(combined[0..output.len], output);
287
- combined[output.len] = '\n';
306
+ // Single-syscall write: overwrite the null terminator with '\n'.
307
+ // The emitter appends '\0' after content, so output.ptr[output.len] == 0.
308
+ // Since output is arena-allocated, the byte is writable.
309
+ @as([*]u8, @constCast(output.ptr))[output.len] = '\n';
288
310
 
289
311
  const out_fd = c.openat(ctx.output_dir_fd, task.output_name_z,
290
312
  c.O_WRONLY | c.O_CREAT | c.O_TRUNC, @as(c_uint, 0o644));
291
313
  if (out_fd >= 0) {
292
- _ = c.write(out_fd, @ptrCast(combined.ptr), combined.len);
314
+ _ = c.write(out_fd, @ptrCast(output.ptr), output.len + 1);
293
315
  _ = c.close(out_fd);
294
316
  }
295
317
  }
296
318
 
297
- _ = arena.reset(.retain_capacity);
319
+ files_since_reset += 1;
320
+ // Batch arena reset: every 4 files to amortize reset overhead.
321
+ // Typical file processing uses ~50-200KB; 4 files fits comfortably.
322
+ if (files_since_reset >= 4) {
323
+ _ = arena.reset(.retain_capacity);
324
+ files_since_reset = 0;
325
+ }
298
326
  }
299
327
  }
300
328
 
@@ -366,9 +394,10 @@ fn processProject(alloc: std.mem.Allocator, project_dir: []const u8, out_dir: []
366
394
  }
367
395
  }
368
396
 
369
- // Thread pool — use all CPU cores for mixed I/O + compute workload
397
+ // Thread pool — cap threads to avoid spawn/join overhead dominating for small projects.
398
+ // Each thread needs ~8 files minimum to amortize ~100µs spawn+join cost.
370
399
  const cpu_count = std.Thread.getCpuCount() catch 4;
371
- const max_threads = @min(cpu_count, filenames.len);
400
+ const max_threads = @min(cpu_count, @max(filenames.len / 8, 1));
372
401
 
373
402
  if (max_threads <= 1) {
374
403
  workerFn(.{ .input_dir_fd = input_dir_fd, .output_dir_fd = output_dir_fd, .tasks = tasks });
@@ -379,7 +408,9 @@ fn processProject(alloc: std.mem.Allocator, project_dir: []const u8, out_dir: []
379
408
  const remainder = filenames.len % max_threads;
380
409
  const threads = try sa.alloc(std.Thread, max_threads);
381
410
 
382
- var thread_spawned: [256]bool = .{false} ** 256; // max 256 threads
411
+ // Heap-allocate so >256-core machines don't silently truncate.
412
+ const thread_spawned = try sa.alloc(bool, max_threads);
413
+ @memset(thread_spawned, false);
383
414
  var offset: usize = 0;
384
415
  for (0..max_threads) |t| {
385
416
  const count = files_per_thread + @as(usize, if (t < remainder) 1 else 0);
package/src/scan_loop.zig CHANGED
@@ -16,21 +16,38 @@ pub fn scanMainLoop(s: *Scanner) !void {
16
16
  const stmt_start = s.pos;
17
17
  const ch0 = s.source[s.pos];
18
18
 
19
- if (ch0 == 'i' and s.matchWord("import")) {
20
- const decl = ext.extractImport(s, stmt_start);
21
- try s.declarations.append(decl);
22
- } else if (ch0 == 'e' and s.matchWord("export")) {
23
- try handleExport(s, stmt_start);
19
+ if (ch0 == 'i') {
20
+ // Combined dispatch for both 'i'-keywords ("import" / "interface")
21
+ // saves an extra first-char check vs the previous separate branches.
22
+ if (s.matchWord("import")) {
23
+ const decl = ext.extractImport(s, stmt_start);
24
+ try s.declarations.append(decl);
25
+ } else if (s.matchWord("interface")) {
26
+ const decl = ext.extractInterface(s, stmt_start, false);
27
+ s.putNonExportedType(decl.name, decl);
28
+ } else {
29
+ s.pos += 1;
30
+ s.skipToStatementEnd();
31
+ }
32
+ } else if (ch0 == 'e') {
33
+ // Combined dispatch for "export" / "enum".
34
+ if (s.matchWord("export")) {
35
+ try handleExport(s, stmt_start);
36
+ } else if (s.matchWord("enum")) {
37
+ const decl = ext.extractEnum(s, stmt_start, false, false);
38
+ s.putNonExportedType(decl.name, decl);
39
+ try s.declarations.append(decl);
40
+ } else {
41
+ s.pos += 1;
42
+ s.skipToStatementEnd();
43
+ }
24
44
  } else if (ch0 == 'd' and s.matchWord("declare")) {
25
45
  s.pos += 7;
26
46
  s.skipWhitespaceAndComments();
27
47
  ext.handleDeclare(s, stmt_start, false);
28
- } else if (ch0 == 'i' and s.matchWord("interface")) {
29
- const decl = ext.extractInterface(s, stmt_start, false);
30
- s.non_exported_types.put(decl.name, decl) catch {};
31
48
  } else if (ch0 == 't' and s.matchWord("type")) {
32
49
  const decl = ext.extractTypeAlias(s, stmt_start, false);
33
- s.non_exported_types.put(decl.name, decl) catch {};
50
+ s.putNonExportedType(decl.name, decl);
34
51
  try s.declarations.append(decl);
35
52
  } else if (ch0 == 'f' and s.matchWord("function")) {
36
53
  s.skipToStatementEnd();
@@ -42,7 +59,7 @@ pub fn scanMainLoop(s: *Scanner) !void {
42
59
  s.skipWhitespaceAndComments();
43
60
  if (s.matchWord("class")) {
44
61
  const decl = ext.extractClass(s, stmt_start, false, false);
45
- s.non_exported_types.put(decl.name, decl) catch {};
62
+ s.putNonExportedType(decl.name, decl);
46
63
  try s.declarations.append(decl);
47
64
  } else {
48
65
  s.skipToStatementEnd();
@@ -54,7 +71,7 @@ pub fn scanMainLoop(s: *Scanner) !void {
54
71
  } else if (ch0 == 'c') {
55
72
  if (s.matchWord("class")) {
56
73
  const decl = ext.extractClass(s, stmt_start, false, false);
57
- s.non_exported_types.put(decl.name, decl) catch {};
74
+ s.putNonExportedType(decl.name, decl);
58
75
  try s.declarations.append(decl);
59
76
  } else if (s.matchWord("const")) {
60
77
  const saved_pos = s.pos;
@@ -64,7 +81,7 @@ pub fn scanMainLoop(s: *Scanner) !void {
64
81
  s.pos = saved_pos + 5;
65
82
  s.skipWhitespaceAndComments();
66
83
  const decl = ext.extractEnum(s, stmt_start, false, true);
67
- s.non_exported_types.put(decl.name, decl) catch {};
84
+ s.putNonExportedType(decl.name, decl);
68
85
  try s.declarations.append(decl);
69
86
  } else {
70
87
  s.pos = saved_pos;
@@ -74,19 +91,14 @@ pub fn scanMainLoop(s: *Scanner) !void {
74
91
  s.pos += 1;
75
92
  s.skipToStatementEnd();
76
93
  }
77
- } else if (ch0 == 'e' and s.matchWord("enum")) {
78
- const decl = ext.extractEnum(s, stmt_start, false, false);
79
- s.non_exported_types.put(decl.name, decl) catch {};
80
- try s.declarations.append(decl);
81
- } else if (ch0 == 'l' and s.matchWord("let")) {
94
+ } else if ((ch0 == 'l' and s.matchWord("let")) or (ch0 == 'v' and s.matchWord("var"))) {
95
+ // Top-level let/var without `export` are skipped — same handling for both.
82
96
  s.skipToStatementEnd();
83
- } else if (ch0 == 'v' and s.matchWord("var")) {
84
- s.skipToStatementEnd();
85
- } else if (ch0 == 'm' and s.matchWord("module")) {
86
- const decl = ext.extractModule(s, stmt_start, false, "module");
87
- try s.declarations.append(decl);
88
- } else if (ch0 == 'n' and s.matchWord("namespace")) {
89
- const decl = ext.extractModule(s, stmt_start, false, "namespace");
97
+ } else if ((ch0 == 'm' and s.matchWord("module")) or (ch0 == 'n' and s.matchWord("namespace"))) {
98
+ // Both module/namespace dispatch to the same extractor; share the
99
+ // append step and pick the keyword from the first byte.
100
+ const kw: []const u8 = if (ch0 == 'm') "module" else "namespace";
101
+ const decl = ext.extractModule(s, stmt_start, false, kw);
90
102
  try s.declarations.append(decl);
91
103
  } else {
92
104
  // Skip unknown top-level content
@@ -133,34 +145,51 @@ fn handleExport(s: *Scanner, stmt_start: usize) !void {
133
145
  if (dch == 'f' and s.matchWord("function")) {
134
146
  const decl = ext.extractFunction(s, stmt_start, true, false, true);
135
147
  if (decl) |d| try s.declarations.append(d);
136
- } else if (dch == 'a' and s.matchWord("async")) {
137
- s.pos += 5;
138
- s.skipWhitespaceAndComments();
139
- if (s.matchWord("function")) {
140
- const decl = ext.extractFunction(s, stmt_start, true, true, true);
141
- if (decl) |d| try s.declarations.append(d);
148
+ } else if (dch == 'c' and s.matchWord("class")) {
149
+ const decl = ext.extractClass(s, stmt_start, true, false);
150
+ try s.declarations.append(decl);
151
+ } else if (dch == 'a') {
152
+ // Combined dispatch both async and abstract start with 'a',
153
+ // and only one matchWord runs per code path.
154
+ if (s.matchWord("async")) {
155
+ s.pos += 5;
156
+ s.skipWhitespaceAndComments();
157
+ if (s.matchWord("function")) {
158
+ const decl = ext.extractFunction(s, stmt_start, true, true, true);
159
+ if (decl) |d| try s.declarations.append(d);
160
+ } else {
161
+ s.skipToStatementEnd();
162
+ const full_text = s.sliceTrimmed(stmt_start, s.pos);
163
+ try s.declarations.append(.{
164
+ .kind = .export_decl,
165
+ .name = "default",
166
+ .text = full_text,
167
+ .is_exported = true,
168
+ .start = stmt_start,
169
+ .end = s.pos,
170
+ });
171
+ }
172
+ } else if (s.matchWord("abstract")) {
173
+ s.pos += 8;
174
+ s.skipWhitespaceAndComments();
175
+ if (s.matchWord("class")) {
176
+ const decl = ext.extractClass(s, stmt_start, true, true);
177
+ try s.declarations.append(decl);
178
+ }
142
179
  } else {
143
180
  s.skipToStatementEnd();
144
- const full_text = s.sliceTrimmed(stmt_start, s.pos);
181
+ const text = s.sliceTrimmed(stmt_start, s.pos);
182
+ const comments = ext.extractLeadingComments(s, stmt_start);
145
183
  try s.declarations.append(.{
146
184
  .kind = .export_decl,
147
185
  .name = "default",
148
- .text = full_text,
186
+ .text = text,
149
187
  .is_exported = true,
188
+ .leading_comments = comments,
150
189
  .start = stmt_start,
151
190
  .end = s.pos,
152
191
  });
153
192
  }
154
- } else if (dch == 'c' and s.matchWord("class")) {
155
- const decl = ext.extractClass(s, stmt_start, true, false);
156
- try s.declarations.append(decl);
157
- } else if (dch == 'a' and s.matchWord("abstract")) {
158
- s.pos += 8;
159
- s.skipWhitespaceAndComments();
160
- if (s.matchWord("class")) {
161
- const decl = ext.extractClass(s, stmt_start, true, true);
162
- try s.declarations.append(decl);
163
- }
164
193
  } else {
165
194
  s.skipToStatementEnd();
166
195
  const text = s.sliceTrimmed(stmt_start, s.pos);
@@ -216,12 +245,25 @@ fn handleExport(s: *Scanner, stmt_start: usize) !void {
216
245
  } else if (ech == 'f' and s.matchWord("function")) {
217
246
  const decl = ext.extractFunction(s, stmt_start, true, false, false);
218
247
  if (decl) |d| try s.declarations.append(d);
219
- } else if (ech == 'a' and s.matchWord("async")) {
220
- s.pos += 5;
221
- s.skipWhitespaceAndComments();
222
- if (s.matchWord("function")) {
223
- const decl = ext.extractFunction(s, stmt_start, true, true, false);
224
- if (decl) |d| try s.declarations.append(d);
248
+ } else if (ech == 'a') {
249
+ // Combined dispatch — both async and abstract start with 'a',
250
+ // and only one matchWord runs in any given path.
251
+ if (s.matchWord("async")) {
252
+ s.pos += 5;
253
+ s.skipWhitespaceAndComments();
254
+ if (s.matchWord("function")) {
255
+ const decl = ext.extractFunction(s, stmt_start, true, true, false);
256
+ if (decl) |d| try s.declarations.append(d);
257
+ } else {
258
+ s.skipToStatementEnd();
259
+ }
260
+ } else if (s.matchWord("abstract")) {
261
+ s.pos += 8;
262
+ s.skipWhitespaceAndComments();
263
+ if (s.matchWord("class")) {
264
+ const decl = ext.extractClass(s, stmt_start, true, true);
265
+ try s.declarations.append(decl);
266
+ }
225
267
  } else {
226
268
  s.skipToStatementEnd();
227
269
  }
@@ -246,13 +288,6 @@ fn handleExport(s: *Scanner, stmt_start: usize) !void {
246
288
  } else {
247
289
  s.skipToStatementEnd();
248
290
  }
249
- } else if (ech == 'a' and s.matchWord("abstract")) {
250
- s.pos += 8;
251
- s.skipWhitespaceAndComments();
252
- if (s.matchWord("class")) {
253
- const decl = ext.extractClass(s, stmt_start, true, true);
254
- try s.declarations.append(decl);
255
- }
256
291
  } else if (ech == 'l' and s.matchWord("let")) {
257
292
  const decls = ext.extractVariable(s, stmt_start, "let", true);
258
293
  for (decls) |d| try s.declarations.append(d);
@@ -266,16 +301,17 @@ fn handleExport(s: *Scanner, stmt_start: usize) !void {
266
301
  s.pos += 7;
267
302
  s.skipWhitespaceAndComments();
268
303
  ext.handleDeclare(s, stmt_start, true);
269
- } else if (ech == 'n' and s.matchWord("namespace")) {
270
- const decl = ext.extractModule(s, stmt_start, true, "namespace");
271
- try s.declarations.append(decl);
272
- } else if (ech == 'm' and s.matchWord("module")) {
273
- const decl = ext.extractModule(s, stmt_start, true, "module");
304
+ } else if ((ech == 'n' and s.matchWord("namespace")) or (ech == 'm' and s.matchWord("module"))) {
305
+ // Same extractor for both pick the keyword from the first byte.
306
+ const kw: []const u8 = if (ech == 'n') "namespace" else "module";
307
+ const decl = ext.extractModule(s, stmt_start, true, kw);
274
308
  try s.declarations.append(decl);
275
309
  } else if (ech == ch.CH_LBRACE) {
276
310
  s.skipExportBraces();
277
311
  const text = s.sliceTrimmed(stmt_start, s.pos);
278
- const is_type_only = ch.contains(text, "export type");
312
+ // type-only exports start with "export type {…} " — use startsWith
313
+ // instead of scanning the entire text with ch.contains.
314
+ const is_type_only = ch.startsWith(text, "export type");
279
315
  const comments = ext.extractLeadingComments(s, stmt_start);
280
316
  try s.declarations.append(.{
281
317
  .kind = .export_decl,
@@ -291,16 +327,16 @@ fn handleExport(s: *Scanner, stmt_start: usize) !void {
291
327
  s.skipExportStar();
292
328
  const text = s.sliceTrimmed(stmt_start, s.pos);
293
329
  const comments = ext.extractLeadingComments(s, stmt_start);
294
- // Extract source from 'from "..."'
330
+ // Extract source from 'from "..."'. indexOfChar is the single-byte
331
+ // SIMD path; the previous indexOf with a 1-char needle was strictly slower.
295
332
  var export_source: []const u8 = "";
296
333
  const from_idx = ch.indexOf(text, "from ", 0);
297
334
  if (from_idx) |fi| {
298
335
  var qi = fi + 5;
299
336
  while (qi < text.len and (text[qi] == ' ' or text[qi] == '\t')) qi += 1;
300
337
  if (qi < text.len and (text[qi] == '\'' or text[qi] == '"')) {
301
- const q_str: []const u8 = if (text[qi] == '\'') "'" else "\"";
302
- const q_end = ch.indexOf(text, q_str, qi + 1);
303
- if (q_end) |qe| export_source = text[qi + 1 .. qe];
338
+ const quote = text[qi];
339
+ if (ch.indexOfChar(text, quote, qi + 1)) |qe| export_source = text[qi + 1 .. qe];
304
340
  }
305
341
  }
306
342
  try s.declarations.append(.{