@stacksjs/zig-dtsx 0.9.10

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/main.zig ADDED
@@ -0,0 +1,532 @@
1
+ /// CLI entry point for zig-dtsx.
2
+ /// Reads TypeScript source from stdin or file, writes .d.ts to stdout or file.
3
+ /// Supports batch mode via --project <dir> --outdir <dir>.
4
+ const std = @import("std");
5
+ const builtin = @import("builtin");
6
+ const Scanner = @import("scanner.zig").Scanner;
7
+ const emitter = @import("emitter.zig");
8
+
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.
12
+ const c = if (builtin.os.tag == .windows) struct {
13
+ pub const FILE = opaque {};
14
+ pub extern "c" fn __acrt_iob_func(index: c_int) *FILE;
15
+ pub extern "c" fn fopen(filename: [*:0]const u8, mode: [*:0]const u8) ?*FILE;
16
+ pub extern "c" fn fclose(stream: *FILE) c_int;
17
+ pub extern "c" fn fread(ptr: [*]u8, size: usize, nmemb: usize, stream: *FILE) usize;
18
+ pub extern "c" fn fwrite(ptr: [*]const u8, size: usize, nmemb: usize, stream: *FILE) usize;
19
+ pub extern "c" fn fseek(stream: *FILE, offset: c_long, whence: c_int) c_int;
20
+ pub extern "c" fn ftell(stream: *FILE) c_long;
21
+ pub const SEEK_END: c_int = 2;
22
+ pub const SEEK_SET: c_int = 0;
23
+
24
+ // Windows directory iteration via UCRT _findfirst/_findnext
25
+ pub const _finddata_t = extern struct {
26
+ attrib: c_uint,
27
+ time_create: isize,
28
+ time_access: isize,
29
+ time_write: isize,
30
+ size: usize,
31
+ name: [260]u8,
32
+ };
33
+ pub extern "c" fn _findfirst(filespec: [*:0]const u8, fileinfo: *_finddata_t) isize;
34
+ pub extern "c" fn _findnext(handle: isize, fileinfo: *_finddata_t) c_int;
35
+ pub extern "c" fn _findclose(handle: isize) c_int;
36
+ 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
+ });
45
+
46
+ fn getStdout() *c.FILE {
47
+ if (builtin.os.tag == .windows) return c.__acrt_iob_func(1);
48
+ return getStdioPtr(c.stdout);
49
+ }
50
+
51
+ fn getStdin() *c.FILE {
52
+ if (builtin.os.tag == .windows) return c.__acrt_iob_func(0);
53
+ return getStdioPtr(c.stdin);
54
+ }
55
+
56
+ fn getStderr() *c.FILE {
57
+ if (builtin.os.tag == .windows) return c.__acrt_iob_func(2);
58
+ return getStdioPtr(c.stderr);
59
+ }
60
+
61
+ /// Resolve a C stdio handle that may be a pointer, optional pointer, or function.
62
+ /// All parameters are runtime-evaluated to avoid comptime issues on Linux.
63
+ inline fn getStdioPtr(val: anytype) *c.FILE {
64
+ const T = @TypeOf(val);
65
+ const info = @typeInfo(T);
66
+ if (info == .optional) {
67
+ return val.?;
68
+ } else if (info == .pointer) {
69
+ return @ptrCast(val);
70
+ } else {
71
+ // Function-like (e.g., macOS where stdout is a function)
72
+ return val();
73
+ }
74
+ }
75
+
76
+ fn writeAll(data: []const u8) void {
77
+ _ = c.fwrite(data.ptr, 1, data.len, getStdout());
78
+ }
79
+
80
+ fn writeErr(data: []const u8) void {
81
+ _ = c.fwrite(data.ptr, 1, data.len, getStderr());
82
+ }
83
+
84
+ fn readFile(alloc: std.mem.Allocator, path: []const u8) ![]const u8 {
85
+ const path_z = try alloc.dupeZ(u8, path);
86
+ defer alloc.free(path_z);
87
+
88
+ const fp = c.fopen(path_z.ptr, "rb") orelse return error.FileNotFound;
89
+ defer _ = c.fclose(fp);
90
+
91
+ _ = c.fseek(fp, 0, c.SEEK_END);
92
+ const tell_result = c.ftell(fp);
93
+ if (tell_result < 0) return error.FileNotFound;
94
+ const size: usize = @intCast(tell_result);
95
+ _ = c.fseek(fp, 0, c.SEEK_SET);
96
+
97
+ const buf = try alloc.alloc(u8, size);
98
+ const read = c.fread(buf.ptr, 1, size, fp);
99
+ return buf[0..read];
100
+ }
101
+
102
+ fn readStdin(alloc: std.mem.Allocator) ![]const u8 {
103
+ var buf = std.array_list.Managed(u8).init(alloc);
104
+ var read_buf: [4096]u8 = undefined;
105
+ while (true) {
106
+ const n = c.fread(&read_buf, 1, read_buf.len, getStdin());
107
+ if (n == 0) break;
108
+ try buf.appendSlice(read_buf[0..n]);
109
+ }
110
+ return try buf.toOwnedSlice();
111
+ }
112
+
113
+ fn writeFile(alloc: std.mem.Allocator, path: []const u8, data: []const u8) !void {
114
+ const path_z = try alloc.dupeZ(u8, path);
115
+ defer alloc.free(path_z);
116
+
117
+ const fp = c.fopen(path_z.ptr, "wb") orelse return error.FileNotFound;
118
+ defer _ = c.fclose(fp);
119
+
120
+ _ = c.fwrite(data.ptr, 1, data.len, fp);
121
+ _ = c.fwrite("\n", 1, 1, fp);
122
+ }
123
+
124
+ /// Collect .ts filenames from a directory (excluding .d.ts files).
125
+ fn collectTsFiles(alloc: std.mem.Allocator, dir_path: []const u8) ![][]const u8 {
126
+ var files = std.array_list.Managed([]const u8).init(alloc);
127
+ errdefer {
128
+ for (files.items) |name| alloc.free(name);
129
+ files.deinit();
130
+ }
131
+
132
+ if (builtin.os.tag == .windows) {
133
+ // Windows: use _findfirst/_findnext
134
+ const pattern_str = try std.fmt.allocPrint(alloc, "{s}\\*.ts", .{dir_path});
135
+ const pattern = try alloc.dupeZ(u8, pattern_str);
136
+ alloc.free(pattern_str);
137
+ defer alloc.free(pattern);
138
+
139
+ var fdata: c._finddata_t = undefined;
140
+ const handle = c._findfirst(pattern.ptr, &fdata);
141
+ if (handle == -1) return files.toOwnedSlice();
142
+ defer _ = c._findclose(handle);
143
+
144
+ while (true) {
145
+ const name_ptr: [*:0]const u8 = @ptrCast(&fdata.name);
146
+ const name = std.mem.span(name_ptr);
147
+ if (std.mem.endsWith(u8, name, ".ts") and !std.mem.endsWith(u8, name, ".d.ts")) {
148
+ try files.append(try alloc.dupe(u8, name));
149
+ }
150
+ if (c._findnext(handle, &fdata) != 0) break;
151
+ }
152
+ } else {
153
+ // POSIX: use opendir/readdir
154
+ const dir_z = try alloc.dupeZ(u8, dir_path);
155
+ defer alloc.free(dir_z);
156
+
157
+ const dir = c.opendir(dir_z.ptr) orelse return files.toOwnedSlice();
158
+ defer _ = c.closedir(dir);
159
+
160
+ while (c.readdir(dir)) |entry| {
161
+ const name_ptr: [*:0]const u8 = @ptrCast(&entry.*.d_name);
162
+ const name = std.mem.span(name_ptr);
163
+ if (std.mem.endsWith(u8, name, ".ts") and !std.mem.endsWith(u8, name, ".d.ts")) {
164
+ try files.append(try alloc.dupe(u8, name));
165
+ }
166
+ }
167
+ }
168
+
169
+ return files.toOwnedSlice();
170
+ }
171
+
172
+ /// Per-file work item for threaded processing.
173
+ const FileTask = struct {
174
+ input_name_z: [*:0]const u8,
175
+ output_name_z: [*:0]const u8,
176
+ keep_comments: bool,
177
+ };
178
+
179
+ /// Thread context: directory fds + task slice.
180
+ const WorkerCtx = struct {
181
+ input_dir_fd: c_int,
182
+ output_dir_fd: c_int,
183
+ tasks: []const FileTask,
184
+ };
185
+
186
+ /// Worker: read + process + write each file using thread-local arena.
187
+ /// Uses POSIX openat for directory-relative I/O (no path resolution overhead).
188
+ fn workerFn(ctx: WorkerCtx) void {
189
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
190
+ defer arena.deinit();
191
+ const default_import_order = [_][]const u8{"bun"};
192
+
193
+ for (ctx.tasks) |task| {
194
+ const alloc = arena.allocator();
195
+
196
+ 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
+ };
202
+ _ = c.fseek(fp, 0, c.SEEK_END);
203
+ const tell_result = c.ftell(fp);
204
+ if (tell_result < 0) {
205
+ _ = c.fclose(fp);
206
+ _ = arena.reset(.retain_capacity);
207
+ continue;
208
+ }
209
+ const size: usize = @intCast(tell_result);
210
+ _ = 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
+ };
216
+ const nread = c.fread(buf.ptr, 1, size, fp);
217
+ _ = c.fclose(fp);
218
+
219
+ var scanner = Scanner.init(alloc, buf[0..nread], task.keep_comments, false);
220
+ _ = scanner.scan() catch {
221
+ _ = arena.reset(.retain_capacity);
222
+ continue;
223
+ };
224
+ const output = emitter.processDeclarations(
225
+ alloc, alloc, scanner.declarations.items, buf[0..nread],
226
+ task.keep_comments, &default_import_order,
227
+ ) catch {
228
+ _ = arena.reset(.retain_capacity);
229
+ continue;
230
+ };
231
+
232
+ const out_fp = c.fopen(task.output_name_z, "wb") orelse {
233
+ _ = arena.reset(.retain_capacity);
234
+ continue;
235
+ };
236
+ _ = c.fwrite(output.ptr, 1, output.len, out_fp);
237
+ _ = c.fwrite("\n", 1, 1, out_fp);
238
+ _ = c.fclose(out_fp);
239
+ } else {
240
+ // POSIX: openat + read/write (no path resolution overhead)
241
+ 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
+ }
246
+ 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
+ }
252
+ _ = c.lseek(fd, 0, 0); // SEEK_SET
253
+ 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
+ };
259
+ var total: usize = 0;
260
+ while (total < size) {
261
+ const n = c.read(fd, @ptrCast(buf.ptr + total), size - total);
262
+ if (n <= 0) break;
263
+ total += @as(usize, @intCast(n));
264
+ }
265
+ _ = c.close(fd);
266
+ const source = buf[0..total];
267
+
268
+ var scanner = Scanner.init(alloc, source, task.keep_comments, false);
269
+ _ = scanner.scan() catch {
270
+ _ = arena.reset(.retain_capacity);
271
+ continue;
272
+ };
273
+ const output = emitter.processDeclarations(
274
+ alloc, alloc, scanner.declarations.items, source,
275
+ task.keep_comments, &default_import_order,
276
+ ) catch {
277
+ _ = arena.reset(.retain_capacity);
278
+ continue;
279
+ };
280
+
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';
288
+
289
+ const out_fd = c.openat(ctx.output_dir_fd, task.output_name_z,
290
+ c.O_WRONLY | c.O_CREAT | c.O_TRUNC, @as(c_uint, 0o644));
291
+ if (out_fd >= 0) {
292
+ _ = c.write(out_fd, @ptrCast(combined.ptr), combined.len);
293
+ _ = c.close(out_fd);
294
+ }
295
+ }
296
+
297
+ _ = arena.reset(.retain_capacity);
298
+ }
299
+ }
300
+
301
+ /// Process all .ts files in a directory, writing .d.ts outputs to outdir.
302
+ /// Uses openat with directory fds (POSIX) and multi-threaded processing.
303
+ fn processProject(alloc: std.mem.Allocator, project_dir: []const u8, out_dir: []const u8, keep_comments: bool) !void {
304
+ _ = alloc;
305
+
306
+ var setup_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
307
+ defer setup_arena.deinit();
308
+ const sa = setup_arena.allocator();
309
+
310
+ // Ensure output directory exists
311
+ const out_z = try sa.dupeZ(u8, out_dir);
312
+ if (builtin.os.tag == .windows) {
313
+ _ = c._mkdir(out_z.ptr);
314
+ } else {
315
+ _ = c.mkdir(out_z.ptr, 0o755);
316
+ }
317
+
318
+ // Open directory file descriptors for openat (POSIX only)
319
+ var input_dir_fd: c_int = -1;
320
+ var output_dir_fd: c_int = -1;
321
+ if (builtin.os.tag != .windows) {
322
+ const dir_z = try sa.dupeZ(u8, project_dir);
323
+ input_dir_fd = c.open(dir_z.ptr, c.O_RDONLY);
324
+ if (input_dir_fd < 0) return error.DirNotFound;
325
+ output_dir_fd = c.open(out_z.ptr, c.O_RDONLY);
326
+ if (output_dir_fd < 0) {
327
+ _ = c.close(input_dir_fd);
328
+ return error.OutDirNotFound;
329
+ }
330
+ }
331
+ defer if (builtin.os.tag != .windows) {
332
+ _ = c.close(input_dir_fd);
333
+ _ = c.close(output_dir_fd);
334
+ };
335
+
336
+ // Collect .ts filenames
337
+ const filenames = try collectTsFiles(sa, project_dir);
338
+ if (filenames.len == 0) return;
339
+
340
+ // Build file tasks
341
+ const tasks = try sa.alloc(FileTask, filenames.len);
342
+ if (builtin.os.tag == .windows) {
343
+ for (filenames, 0..) |filename, idx| {
344
+ const in_str = try std.fmt.allocPrint(sa, "{s}\\{s}", .{ project_dir, filename });
345
+ const stem = filename[0 .. filename.len - 3];
346
+ const out_str = try std.fmt.allocPrint(sa, "{s}\\{s}.d.ts", .{ out_dir, stem });
347
+ tasks[idx] = .{
348
+ .input_name_z = (try sa.dupeZ(u8, in_str)).ptr,
349
+ .output_name_z = (try sa.dupeZ(u8, out_str)).ptr,
350
+ .keep_comments = keep_comments,
351
+ };
352
+ }
353
+ } else {
354
+ for (filenames, 0..) |filename, idx| {
355
+ const name_z = try sa.dupeZ(u8, filename);
356
+ const stem = filename[0 .. filename.len - 3];
357
+ const out_buf = try sa.alloc(u8, stem.len + 6);
358
+ @memcpy(out_buf[0..stem.len], stem);
359
+ @memcpy(out_buf[stem.len .. stem.len + 5], ".d.ts");
360
+ out_buf[stem.len + 5] = 0;
361
+ tasks[idx] = .{
362
+ .input_name_z = name_z.ptr,
363
+ .output_name_z = @ptrCast(out_buf.ptr),
364
+ .keep_comments = keep_comments,
365
+ };
366
+ }
367
+ }
368
+
369
+ // Thread pool — use all CPU cores for mixed I/O + compute workload
370
+ const cpu_count = std.Thread.getCpuCount() catch 4;
371
+ const max_threads = @min(cpu_count, filenames.len);
372
+
373
+ if (max_threads <= 1) {
374
+ workerFn(.{ .input_dir_fd = input_dir_fd, .output_dir_fd = output_dir_fd, .tasks = tasks });
375
+ return;
376
+ }
377
+
378
+ const files_per_thread = filenames.len / max_threads;
379
+ const remainder = filenames.len % max_threads;
380
+ const threads = try sa.alloc(std.Thread, max_threads);
381
+
382
+ var thread_spawned: [256]bool = .{false} ** 256; // max 256 threads
383
+ var offset: usize = 0;
384
+ for (0..max_threads) |t| {
385
+ const count = files_per_thread + @as(usize, if (t < remainder) 1 else 0);
386
+ const ctx = WorkerCtx{
387
+ .input_dir_fd = input_dir_fd,
388
+ .output_dir_fd = output_dir_fd,
389
+ .tasks = tasks[offset .. offset + count],
390
+ };
391
+ offset += count;
392
+
393
+ threads[t] = std.Thread.spawn(.{}, workerFn, .{ctx}) catch {
394
+ workerFn(ctx);
395
+ continue;
396
+ };
397
+ thread_spawned[t] = true;
398
+ }
399
+
400
+ for (0..max_threads) |t| {
401
+ if (thread_spawned[t]) {
402
+ threads[t].join();
403
+ }
404
+ }
405
+ }
406
+
407
+ // Support both Zig 0.15.x (argsAlloc) and 0.16+ (Init.Minimal)
408
+ const has_process_init = @hasDecl(std.process, "Init");
409
+
410
+ pub const main = if (has_process_init) mainInit else mainLegacy;
411
+
412
+ fn mainInit(init: std.process.Init.Minimal) !void {
413
+ const alloc = std.heap.c_allocator;
414
+ var args_buf = std.array_list.Managed([]const u8).init(alloc);
415
+ defer args_buf.deinit();
416
+ var iter = if (builtin.os.tag == .windows)
417
+ try std.process.Args.Iterator.initAllocator(init.args, alloc)
418
+ else
419
+ init.args.iterate();
420
+ defer iter.deinit();
421
+ while (iter.next()) |arg| {
422
+ try args_buf.append(arg);
423
+ }
424
+ try run(alloc, args_buf.items);
425
+ }
426
+
427
+ fn mainLegacy() !void {
428
+ const alloc = std.heap.c_allocator;
429
+ const raw_args = try std.process.argsAlloc(alloc);
430
+ defer std.process.argsFree(alloc, raw_args);
431
+ var args_buf = std.array_list.Managed([]const u8).init(alloc);
432
+ defer args_buf.deinit();
433
+ for (raw_args) |arg| {
434
+ try args_buf.append(arg);
435
+ }
436
+ try run(alloc, args_buf.items);
437
+ }
438
+
439
+ fn run(alloc: std.mem.Allocator, args: []const []const u8) !void {
440
+ var input_file: ?[]const u8 = null;
441
+ var output_file: ?[]const u8 = null;
442
+ var project_dir: ?[]const u8 = null;
443
+ var out_dir: ?[]const u8 = null;
444
+ var keep_comments: bool = true;
445
+ var isolated_declarations: bool = false;
446
+ var show_help: bool = false;
447
+
448
+ var i: usize = 1;
449
+ while (i < args.len) : (i += 1) {
450
+ const arg = args[i];
451
+ if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
452
+ show_help = true;
453
+ } else if (std.mem.eql(u8, arg, "--no-comments")) {
454
+ keep_comments = false;
455
+ } else if (std.mem.eql(u8, arg, "--isolated-declarations")) {
456
+ isolated_declarations = true;
457
+ } else if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output")) {
458
+ i += 1;
459
+ if (i < args.len) output_file = args[i];
460
+ } else if (std.mem.eql(u8, arg, "--project")) {
461
+ i += 1;
462
+ if (i < args.len) project_dir = args[i];
463
+ } else if (std.mem.eql(u8, arg, "--outdir")) {
464
+ i += 1;
465
+ if (i < args.len) out_dir = args[i];
466
+ } else if (arg.len > 0 and arg[0] != '-') {
467
+ input_file = arg;
468
+ }
469
+ }
470
+
471
+ if (show_help) {
472
+ writeAll(
473
+ \\zig-dtsx - Generate TypeScript declaration files
474
+ \\
475
+ \\Usage: zig-dtsx [options] [input-file]
476
+ \\ zig-dtsx --project <dir> --outdir <dir>
477
+ \\
478
+ \\Options:
479
+ \\ -h, --help Show this help message
480
+ \\ -o, --output FILE Write output to FILE instead of stdout
481
+ \\ --project DIR Process all .ts files in DIR (batch mode)
482
+ \\ --outdir DIR Output directory for --project mode
483
+ \\ --no-comments Strip comments from output
484
+ \\ --isolated-declarations Skip initializer parsing when type annotations exist
485
+ \\
486
+ \\If no input file is specified, reads from stdin.
487
+ \\
488
+ );
489
+ return;
490
+ }
491
+
492
+ // Project mode: process entire directory
493
+ if (project_dir) |dir| {
494
+ const outdir = out_dir orelse {
495
+ writeErr("error: --project requires --outdir\n");
496
+ return;
497
+ };
498
+ try processProject(alloc, dir, outdir, keep_comments);
499
+ return;
500
+ }
501
+
502
+ // Single-file mode
503
+ const source = if (input_file) |path|
504
+ try readFile(alloc, path)
505
+ else
506
+ try readStdin(alloc);
507
+ defer alloc.free(source);
508
+
509
+ var arena = std.heap.ArenaAllocator.init(alloc);
510
+ defer arena.deinit();
511
+ const arena_alloc = arena.allocator();
512
+
513
+ var scanner = Scanner.init(arena_alloc, source, keep_comments, isolated_declarations);
514
+ _ = try scanner.scan();
515
+
516
+ const default_import_order = [_][]const u8{"bun"};
517
+ const dts_output = try emitter.processDeclarations(
518
+ arena_alloc,
519
+ arena_alloc,
520
+ scanner.declarations.items,
521
+ source,
522
+ keep_comments,
523
+ &default_import_order,
524
+ );
525
+
526
+ if (output_file) |path| {
527
+ try writeFile(alloc, path, dts_output);
528
+ } else {
529
+ writeAll(dts_output);
530
+ writeAll("\n");
531
+ }
532
+ }