add sx migrate tuple-syntax migration tool

Temporary scaffolding for the tuple-syntax cutover. Parses old-grammar
.sx and rewrites tuple syntax to the new spelling:
  - tuple TYPES   `(A, B)`        -> `Tuple(A, B)`   (named keeps `:`)
  - tuple VALUES  `(a, b)`        -> `.(a, b)`        (named flips `:` -> `=`)
  - 1-tuples / empty / spread     -> `.(x)` / `.()` / `.(..xs)`, `Tuple(..Ts)`
  - failable returns: the `!` channel stays OUTSIDE Tuple
      `-> (T, !)`        -> `-> T !`
      `-> (T1, T2, !)`   -> `-> Tuple(T1, T2) !`

AST-walk based: rewrites only `tuple_literal` / `tuple_type_expr` nodes
(function types, param lists, match bindings, arrays, struct literals,
Closure sigs, groupings are left untouched). Nested tuples rewrite
recursively as a single non-overlapping edit per outermost tuple.

Value-vs-type ambiguity (call-arg tuples whose elements could be types,
e.g. `size_of((Box, i32))`, empty `()`) is never guessed: such sites go
to a worklist. A non-empty worklist exits nonzero and suppresses the
"looks-done" stdout output unless `--force` is passed.

`sx migrate <f>` prints migrated source; `--dry-run` prints only the
worklist. Built against the old grammar; removed after the cutover.
This commit is contained in:
agra
2026-06-25 15:23:18 +03:00
parent 820cd62fa1
commit c882c6c63e
4 changed files with 918 additions and 0 deletions

View File

@@ -23,6 +23,13 @@ pub fn main(init: std.process.Init) !void {
return;
}
// `migrate` has its own flag (`--dry-run`) the generic flag loop below would
// reject, so dispatch it here before that loop runs.
if (std.mem.eql(u8, command, "migrate")) {
runMigrate(allocator, io, args[2..]);
return;
}
// Parse flags and positional arguments
var input_path: ?[]const u8 = null;
var target_config = sx.target.TargetConfig{};
@@ -407,6 +414,7 @@ fn printUsage() void {
\\ ir Print LLVM IR to stdout
\\ asm Emit assembly (.s) file
\\ lsp Start language server (LSP)
\\ migrate Rewrite old tuple syntax to new (`(a,b)`->`.(a,b)`, type `(A,B)`->`Tuple(A,B)`); `--dry-run` prints only the worklist, `--force` emits output despite unmigrated ambiguous sites
\\
\\Options:
\\ --target <target> Target triple or shorthand: wasm, macos, linux, windows, ios, ios-sim (default: host)
@@ -517,6 +525,72 @@ fn compilePipeline(allocator: std.mem.Allocator, io: std.Io, input_path: []const
return comp;
}
/// `sx migrate [--dry-run] [--force] <file.sx>` — tuple-syntax migration tool.
///
/// Without flags: parse-only, rewrite the old tuple syntax, print the migrated
/// source to stdout and any ambiguous-site worklist entries to stderr. A
/// NON-EMPTY worklist is a hard failure (exit 2) — the migration is incomplete,
/// so we do NOT print the rewritten source (which could be redirected over the
/// input, silently shipping half-migrated code) unless `--force` is passed.
///
/// With `--dry-run`: print ONLY the worklist (to stderr), no rewritten source —
/// so ambiguous sites can be audited first. A non-empty worklist still exits 2.
///
/// With `--force`: print the rewritten source even when the worklist is
/// non-empty (the ambiguous sites are left in the OLD syntax). Exit is still 2
/// so a script can detect the partial migration.
fn runMigrate(allocator: std.mem.Allocator, io: std.Io, sub_args: []const []const u8) void {
var dry_run = false;
var force = false;
var input_path: ?[]const u8 = null;
for (sub_args) |a| {
if (std.mem.eql(u8, a, "--dry-run")) {
dry_run = true;
} else if (std.mem.eql(u8, a, "--force")) {
force = true;
} else if (std.mem.startsWith(u8, a, "-")) {
std.debug.print("error: unknown flag '{s}' for migrate\n", .{a});
std.process.exit(1);
} else {
input_path = a;
}
}
const path = input_path orelse {
std.debug.print("usage: sx migrate [--dry-run] [--force] <file.sx>\n", .{});
std.process.exit(1);
};
const source = readSource(allocator, io, path) catch |err| {
std.debug.print("error: cannot read '{s}': {}\n", .{ path, err });
std.process.exit(1);
};
const result = sx.migrate.migrateSource(allocator, io, path, source) catch |err| {
std.debug.print("error: migrate failed for '{s}': {}\n", .{ path, err });
std.process.exit(1);
};
// Worklist (ambiguous sites) always goes to stderr.
for (result.worklist) |w| {
std.debug.print("{s}:{d}:{d}: {s}: {s}\n", .{ path, w.line, w.col, w.reason, w.text });
}
const has_worklist = result.worklist.len > 0;
// Emit the rewritten source unless we'd be shipping a half-migrated file: a
// non-empty worklist in non-dry-run mode suppresses output unless --force.
if (!dry_run and (!has_worklist or force)) {
_ = std.c.write(1, result.output.ptr, result.output.len);
}
if (has_worklist) {
std.debug.print(
"{d} ambiguous site(s) unmigrated; resolve by hand or pass --force\n",
.{result.worklist.len},
);
std.process.exit(2);
}
}
fn dumpSxIR(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8, stdlib_paths: []const []const u8) !void {
const source = try readSource(allocator, io, input_path);
var comp = sx.core.Compilation.init(allocator, io, input_path, source, .{}, stdlib_paths);