From dd913b0b14c25f925defa86db16d4eb8c815ce26 Mon Sep 17 00:00:00 2001 From: omdxp Date: Wed, 31 Dec 2025 02:37:54 +0100 Subject: [PATCH 1/3] feat: implement in-place file formatting and add related tests --- cmd/fun/main.zig | 6 ++ modules/cli/cli.zig | 240 +++++++++++++++++++++++++++++++++++++++++++- tests/fmt_test.zig | 46 +++++++++ tests/main_test.zig | 1 + 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 tests/fmt_test.zig diff --git a/cmd/fun/main.zig b/cmd/fun/main.zig index a6d41d8c..8ab58345 100644 --- a/cmd/fun/main.zig +++ b/cmd/fun/main.zig @@ -38,6 +38,7 @@ fn print_error_and_exit(err: anyerror) noreturn { cli.CliError.ShowHelp => { std.process.exit(0); }, + // Formatting uses the same lexer/transpiler error types; they are printed elsewhere. error.FileNotFound => { _ = stderr.writeAll("Error: Input file not found\n") catch {}; }, @@ -64,6 +65,11 @@ pub fn main() void { const options = cli.parse_args(global_allocator) catch |err| print_error_and_exit(err); + if (options.fmt) { + cli.format_file_in_place(global_allocator, options.input_file) catch |err| print_error_and_exit(err); + return; + } + var tp = codegen.TranspileProcess.init( global_allocator, options.input_file, diff --git a/modules/cli/cli.zig b/modules/cli/cli.zig index a020828a..b235e0d6 100644 --- a/modules/cli/cli.zig +++ b/modules/cli/cli.zig @@ -4,6 +4,8 @@ const fs = std.fs; const process = std.process; const Child = std.process.Child; const codegen = @import("codegen"); +const lexer = @import("lexer"); +const token = lexer.token; const utils = @import("utils"); const builtin = @import("builtin"); @@ -43,6 +45,9 @@ pub const CliOptions = struct { /// Flag to control AST node printing. /// When true, the Abstract Syntax Tree nodes will be printed during transpilation. print_ast: bool, + + /// Flag to format the input `.fn` file in-place. + fmt: bool, }; /// Prints the usage information for the transpiler command-line interface. @@ -57,10 +62,11 @@ pub const CliOptions = struct { /// - Might return an error if writing to the output fails. fn print_usage(writer: anytype) !void { try writer.writeAll( - \\Usage: fun -in [-out ] [-no-exec] [-outf] [-ast] [-help] + \\Usage: fun -in [-fmt] [-out ] [-no-exec] [-outf] [-ast] [-help] \\ \\Arguments: \\ -in Input file to compile (required) + \\ -fmt Format the input file in-place (optional) \\ -out Output file (optional, defaults to input filename with .c extension) \\ -no-exec Disable automatic compilation and execution (optional, execution enabled by default) \\ -outf Generate .c output file (optional, disabled by default) @@ -114,6 +120,7 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { var exec = true; var outf = false; var print_ast = false; + var fmt = false; while (args.next()) |arg| { if (std.mem.eql(u8, arg, "-help")) { @@ -141,6 +148,8 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { outf = true; } else if (std.mem.eql(u8, arg, "-ast")) { print_ast = true; + } else if (std.mem.eql(u8, arg, "-fmt")) { + fmt = true; } } @@ -162,9 +171,238 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { .exec = exec, .outf = outf, .print_ast = print_ast, + .fmt = fmt, + }; +} + +fn token_text(allocator: mem.Allocator, t: token.Token) ![]const u8 { + return switch (t.type) { + .Identifier, .Keyword, .Operator => allocator.dupe(u8, t.data.sval.items), + .Symbol => blk: { + var buf: [1]u8 = .{t.data.cval}; + break :blk allocator.dupe(u8, buf[0..]); + }, + .Number => { + const base = try std.fmt.allocPrint(allocator, "{d}", .{t.data.llnum}); + errdefer allocator.free(base); + return switch (t.num.?.type) { + .Normal => base, + .Long => std.mem.concat(allocator, u8, &.{ base, "L" }) catch |e| { + allocator.free(base); + return e; + }, + .Float => std.mem.concat(allocator, u8, &.{ base, "f" }) catch |e| { + allocator.free(base); + return e; + }, + else => base, + }; + }, + .String => std.fmt.allocPrint(allocator, "\"{s}\"", .{t.data.sval.items}), + .Boolean => allocator.dupe(u8, if (t.data.bval) "true" else "false"), + .Comment => std.fmt.allocPrint(allocator, "//{s}", .{std.mem.trim(u8, t.data.sval.items, " \t")}), + .NewLine => allocator.dupe(u8, "\n"), + }; +} + +fn is_closing_symbol(c: u8) bool { + return c == ')' or c == ']'; +} + +fn is_word_like(t: token.Token) bool { + return switch (t.type) { + .Identifier, .Keyword, .Number, .String, .Boolean => true, + else => false, }; } +fn operator_needs_spaces(op: []const u8) bool { + // Operators that should not be surrounded by spaces are handled separately by symbols. + // Keep this conservative. + return !std.mem.eql(u8, op, ",") and + !std.mem.eql(u8, op, ".") and + !std.mem.eql(u8, op, "(") and + !std.mem.eql(u8, op, "["); +} + +pub fn format_file_in_place(allocator: mem.Allocator, input_file: []const u8) !void { + // Lex tokens from the file. + var tp = try codegen.TranspileProcess.init( + allocator, + input_file, + "__fmt_unused__.c", + .{ .exec = false, .outf = false, .ast = false }, + ); + defer tp.deinit(); + var lp = lexer.LexProcess.init(&tp); + defer lp.deinit(); + try lp.lex(); + + var out = std.ArrayList(u8).init(allocator); + defer out.deinit(); + + var indent: usize = 0; + var at_line_start = true; + var prev_token: ?token.Token = null; + + const tokens = tp.tokens.items(); + var i: usize = 0; + while (i < tokens.len) : (i += 1) { + const t = tokens[i]; + if (t.type == .NewLine) continue; + + if (t.type == .Comment) { + if (!at_line_start) { + try out.append('\n'); + at_line_start = true; + } + if (at_line_start) { + try out.appendNTimes(' ', indent * 4); + } + const s = try token_text(allocator, t); + defer allocator.free(s); + try out.appendSlice(s); + try out.append('\n'); + at_line_start = true; + prev_token = null; + continue; + } + + // Handle closing brace with optional same-line `elif`/`else`. + if (t.type == .Symbol and t.data.cval == '}') { + if (!at_line_start) { + try out.append('\n'); + at_line_start = true; + } + if (indent > 0) indent -= 1; + try out.appendNTimes(' ', indent * 4); + try out.append('}'); + + // Look ahead for `elif`/`else`. + var j = i + 1; + while (j < tokens.len and (tokens[j].type == .NewLine or tokens[j].type == .Comment)) : (j += 1) {} + if (j < tokens.len and tokens[j].type == .Keyword) { + const kw = tokens[j].data.sval.items; + if (std.mem.eql(u8, kw, "elif") or std.mem.eql(u8, kw, "else")) { + try out.append(' '); + at_line_start = false; + prev_token = t; + continue; + } + } + + try out.append('\n'); + at_line_start = true; + prev_token = t; + continue; + } + + // Indent at start of line. + if (at_line_start) { + try out.appendNTimes(' ', indent * 4); + at_line_start = false; + } + + // Decide whether to add a space before this token. + if (prev_token) |pt| { + const needs_space = blk: { + if (t.type == .Symbol) { + const c = t.data.cval; + if (c == ',' or c == ';' or c == ')' or c == ']' or c == '}' or c == ':') break :blk false; + if (c == '{') break :blk true; + if (c == '(' or c == '[') { + // No space for calls/indexing: `foo(`, `arr[`. + break :blk false; + } + } + if (pt.type == .Symbol) { + const pc = pt.data.cval; + if (pc == '(' or pc == '[' or pc == '{') break :blk false; + } + if (t.type == .Operator) { + break :blk operator_needs_spaces(t.data.sval.items); + } + if (pt.type == .Operator) { + break :blk operator_needs_spaces(pt.data.sval.items); + } + if (is_word_like(pt) and is_word_like(t)) break :blk true; + if (pt.type == .Symbol and is_closing_symbol(pt.data.cval) and is_word_like(t)) break :blk true; + break :blk false; + }; + if (needs_space) { + const last = if (out.items.len > 0) out.items[out.items.len - 1] else 0; + if (last != ' ' and last != '\n' and last != '\t') try out.append(' '); + } + } + + // Emit token. + if (t.type == .Symbol) { + const c = t.data.cval; + if (c == '{') { + try out.append('{'); + try out.append('\n'); + indent += 1; + at_line_start = true; + prev_token = null; + continue; + } + if (c == ';') { + try out.append(';'); + try out.append('\n'); + at_line_start = true; + prev_token = null; + continue; + } + if (c == ',') { + try out.append(','); + try out.append(' '); + prev_token = null; + continue; + } + if (c == ':') { + try out.append(':'); + try out.append(' '); + prev_token = null; + continue; + } + try out.append(c); + prev_token = t; + continue; + } + + if (t.type == .Operator and std.mem.eql(u8, t.data.sval.items, ",")) { + try out.append(','); + try out.append(' '); + prev_token = null; + continue; + } + + if (t.type == .Operator and (std.mem.eql(u8, t.data.sval.items, "(") or std.mem.eql(u8, t.data.sval.items, "["))) { + const s = try token_text(allocator, t); + defer allocator.free(s); + try out.appendSlice(s); + prev_token = t; + continue; + } + + const s = try token_text(allocator, t); + defer allocator.free(s); + try out.appendSlice(s); + + prev_token = t; + } + + // Ensure exactly one trailing newline. + if (out.items.len == 0 or out.items[out.items.len - 1] != '\n') { + try out.append('\n'); + } + + // Overwrite input file in-place. + try tp.ifile.seekTo(0); + try tp.ifile.setEndPos(0); + _ = try tp.ifile.writeAll(out.items); +} + /// Compiles and runs the generated C code. /// /// This function takes a C source file, compiles it using GCC, diff --git a/tests/fmt_test.zig b/tests/fmt_test.zig new file mode 100644 index 00000000..9ee3f185 --- /dev/null +++ b/tests/fmt_test.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const cli = @import("cli"); + +fn writeTempFnFile(allocator: std.mem.Allocator, prefix: []const u8, contents: []const u8) ![]const u8 { + const ts = std.time.nanoTimestamp(); + const name = try std.fmt.allocPrint(allocator, "{s}_{d}.fn", .{ prefix, ts }); + errdefer allocator.free(name); + + const f = try std.fs.cwd().createFile(name, .{ .read = true }); + defer f.close(); + try f.writeAll(contents); + return name; +} + +test "-fmt formats file in-place" { + const allocator = std.testing.allocator; + + const ugly = + "fun add(num a,num b) num{ret a+b;}\n" ++ + "//comment\n" ++ + "if true{ret 1;}else{ret 2;}\n"; + + const path = try writeTempFnFile(allocator, "fmt", ugly); + defer { + std.fs.cwd().deleteFile(path) catch {}; + allocator.free(path); + } + + try cli.format_file_in_place(allocator, path); + + const got = try std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024); + defer allocator.free(got); + + const expected = + "fun add(num a, num b) num {\n" ++ + " ret a + b;\n" ++ + "}\n" ++ + "//comment\n" ++ + "if true {\n" ++ + " ret 1;\n" ++ + "} else {\n" ++ + " ret 2;\n" ++ + "}\n"; + + try std.testing.expectEqualStrings(expected, got); +} diff --git a/tests/main_test.zig b/tests/main_test.zig index 52902fa6..9baee57f 100644 --- a/tests/main_test.zig +++ b/tests/main_test.zig @@ -8,6 +8,7 @@ comptime { _ = @import("codegen_test.zig"); _ = @import("utils_test.zig"); _ = @import("cli_test.zig"); + _ = @import("fmt_test.zig"); _ = @import("imports_test.zig"); _ = @import("for_test.zig"); _ = @import("fit_exhaustive_test.zig"); From ec603d42ab2d432f9ec5a81013cee8b0f8d65ec2 Mon Sep 17 00:00:00 2001 From: omdxp Date: Wed, 31 Dec 2025 02:58:38 +0100 Subject: [PATCH 2/3] feat: add in-place formatting for all imported modules and enhance CLI options --- cmd/fun/main.zig | 5 + modules/cli/cli.zig | 579 +++++++++++++++++++++++++++++++++++--------- tests/fmt_test.zig | 168 +++++++++++++ 3 files changed, 638 insertions(+), 114 deletions(-) diff --git a/cmd/fun/main.zig b/cmd/fun/main.zig index 8ab58345..4730de92 100644 --- a/cmd/fun/main.zig +++ b/cmd/fun/main.zig @@ -65,6 +65,11 @@ pub fn main() void { const options = cli.parse_args(global_allocator) catch |err| print_error_and_exit(err); + if (options.fmt_all) { + cli.format_file_and_imports_in_place(global_allocator, options.input_file) catch |err| print_error_and_exit(err); + return; + } + if (options.fmt) { cli.format_file_in_place(global_allocator, options.input_file) catch |err| print_error_and_exit(err); return; diff --git a/modules/cli/cli.zig b/modules/cli/cli.zig index b235e0d6..a538e44a 100644 --- a/modules/cli/cli.zig +++ b/modules/cli/cli.zig @@ -48,6 +48,9 @@ pub const CliOptions = struct { /// Flag to format the input `.fn` file in-place. fmt: bool, + + /// Flag to format the input file and all locally imported modules (skips `std.*`). + fmt_all: bool, }; /// Prints the usage information for the transpiler command-line interface. @@ -62,11 +65,12 @@ pub const CliOptions = struct { /// - Might return an error if writing to the output fails. fn print_usage(writer: anytype) !void { try writer.writeAll( - \\Usage: fun -in [-fmt] [-out ] [-no-exec] [-outf] [-ast] [-help] + \\Usage: fun -in [-fmt | -fmt-all] [-out ] [-no-exec] [-outf] [-ast] [-help] \\ \\Arguments: \\ -in Input file to compile (required) \\ -fmt Format the input file in-place (optional) + \\ -fmt-all Format the input file and all locally imported modules (optional) \\ -out Output file (optional, defaults to input filename with .c extension) \\ -no-exec Disable automatic compilation and execution (optional, execution enabled by default) \\ -outf Generate .c output file (optional, disabled by default) @@ -121,6 +125,7 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { var outf = false; var print_ast = false; var fmt = false; + var fmt_all = false; while (args.next()) |arg| { if (std.mem.eql(u8, arg, "-help")) { @@ -150,6 +155,8 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { print_ast = true; } else if (std.mem.eql(u8, arg, "-fmt")) { fmt = true; + } else if (std.mem.eql(u8, arg, "-fmt-all")) { + fmt_all = true; } } @@ -172,9 +179,193 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { .outf = outf, .print_ast = print_ast, .fmt = fmt, + .fmt_all = fmt_all, }; } +fn build_full_import_path_for_formatter(allocator: mem.Allocator, input_file_path: []const u8, import_path: []const u8) ![]const u8 { + var file_path = std.ArrayList(u8).init(allocator); + defer file_path.deinit(); + + const dir_path = std.fs.path.dirname(input_file_path) orelse "."; + try file_path.appendSlice(dir_path); + try file_path.append('/'); + + for (import_path) |ch| { + if (ch == '.') { + try file_path.append('/'); + } else { + try file_path.append(ch); + } + } + try file_path.appendSlice(".fn"); + + return file_path.toOwnedSlice(); +} + +fn freeOwnedStringMap(allocator: mem.Allocator, map: *std.StringHashMap(void)) void { + var it = map.keyIterator(); + while (it.next()) |k| { + allocator.free(k.*); + } + map.deinit(); +} + +fn parse_local_import_paths(allocator: mem.Allocator, input_file: []const u8) !std.ArrayList([]const u8) { + var result = std.ArrayList([]const u8).init(allocator); + errdefer { + for (result.items) |p| allocator.free(p); + result.deinit(); + } + + var tp = try codegen.TranspileProcess.init( + allocator, + input_file, + "__fmt_unused__.c", + .{ .exec = false, .outf = false, .ast = false }, + ); + defer tp.deinit(); + var lp = lexer.LexProcess.init(&tp); + defer lp.deinit(); + try lp.lex(); + + const tokens = tp.tokens.items(); + var brace_depth: isize = 0; + var paren_depth: isize = 0; + var bracket_depth: isize = 0; + var can_start_stmt = true; + + var i: usize = 0; + while (i < tokens.len) : (i += 1) { + const t = tokens[i]; + if (t.type == .NewLine) continue; + if (t.type == .Comment) continue; + + const is_top = brace_depth == 0; + const is_stmt_start = is_top and paren_depth == 0 and bracket_depth == 0 and can_start_stmt; + + if (is_stmt_start and t.type == .Keyword and std.mem.eql(u8, t.data.sval.items, "imp")) { + // Parse `imp foo.bar;` + var j: usize = i + 1; + while (j < tokens.len and (tokens[j].type == .NewLine or tokens[j].type == .Comment)) : (j += 1) {} + if (j >= tokens.len or tokens[j].type != .Identifier) { + // Let the normal compiler path handle detailed errors; formatter just ignores malformed imports. + continue; + } + + var import_name = std.ArrayList(u8).init(allocator); + defer import_name.deinit(); + try import_name.appendSlice(tokens[j].data.sval.items); + j += 1; + + while (true) { + while (j < tokens.len and (tokens[j].type == .NewLine or tokens[j].type == .Comment)) : (j += 1) {} + if (j >= tokens.len) break; + if (tokens[j].type != .Operator or !utils.is_access_operator(tokens[j].data.sval.items)) break; + j += 1; + + while (j < tokens.len and (tokens[j].type == .NewLine or tokens[j].type == .Comment)) : (j += 1) {} + if (j >= tokens.len or tokens[j].type != .Identifier) break; + try import_name.append('.'); + try import_name.appendSlice(tokens[j].data.sval.items); + j += 1; + } + + const imp_path = try import_name.toOwnedSlice(); + if (!std.mem.startsWith(u8, imp_path, "std.")) { + try result.append(imp_path); + } else { + allocator.free(imp_path); + } + + i = j; + can_start_stmt = true; + continue; + } + + // Track depth/boundaries. + if (t.type == .Operator) { + if (std.mem.eql(u8, t.data.sval.items, "(")) paren_depth += 1; + if (std.mem.eql(u8, t.data.sval.items, "[")) bracket_depth += 1; + } + if (t.type == .Symbol) { + if (t.data.cval == ')') { + if (paren_depth > 0) paren_depth -= 1; + } + if (t.data.cval == ']') { + if (bracket_depth > 0) bracket_depth -= 1; + } + if (t.data.cval == '{') brace_depth += 1; + if (t.data.cval == '}' and brace_depth > 0) brace_depth -= 1; + + if (t.data.cval == ';' and brace_depth == 0) { + can_start_stmt = true; + } else if (t.data.cval == '}' and brace_depth == 0) { + can_start_stmt = true; + } else if (t.data.cval != ';') { + can_start_stmt = false; + } + } else { + can_start_stmt = false; + } + } + + return result; +} + +fn format_file_and_imports_recursive( + allocator: mem.Allocator, + file_path: []const u8, + visiting: *std.StringHashMap(void), + visited: *std.StringHashMap(void), +) !void { + const canonical = try std.fs.cwd().realpathAlloc(allocator, file_path); + errdefer allocator.free(canonical); + + if (visited.contains(canonical)) { + allocator.free(canonical); + return; + } + if (visiting.contains(canonical)) { + // Cycle detected; stop recursion. + allocator.free(canonical); + return; + } + + // Store owned canonical path in `visiting`. + try visiting.put(canonical, {}); + + // Format current file. + try format_file_in_place(allocator, canonical); + + // Discover local imports and recurse. + var imports = try parse_local_import_paths(allocator, canonical); + defer { + for (imports.items) |p| allocator.free(p); + imports.deinit(); + } + + for (imports.items) |imp| { + const rel = try build_full_import_path_for_formatter(allocator, canonical, imp); + defer allocator.free(rel); + try format_file_and_imports_recursive(allocator, rel, visiting, visited); + } + + // Move from visiting -> visited. + _ = visiting.remove(canonical); + try visited.put(canonical, {}); +} + +pub fn format_file_and_imports_in_place(allocator: mem.Allocator, input_file: []const u8) !void { + var visiting = std.StringHashMap(void).init(allocator); + defer freeOwnedStringMap(allocator, &visiting); + + var visited = std.StringHashMap(void).init(allocator); + defer freeOwnedStringMap(allocator, &visited); + + try format_file_and_imports_recursive(allocator, input_file, &visiting, &visited); +} + fn token_text(allocator: mem.Allocator, t: token.Token) ![]const u8 { return switch (t.type) { .Identifier, .Keyword, .Operator => allocator.dupe(u8, t.data.sval.items), @@ -225,173 +416,329 @@ fn operator_needs_spaces(op: []const u8) bool { !std.mem.eql(u8, op, "["); } -pub fn format_file_in_place(allocator: mem.Allocator, input_file: []const u8) !void { - // Lex tokens from the file. - var tp = try codegen.TranspileProcess.init( - allocator, - input_file, - "__fmt_unused__.c", - .{ .exec = false, .outf = false, .ast = false }, - ); - defer tp.deinit(); - var lp = lexer.LexProcess.init(&tp); - defer lp.deinit(); - try lp.lex(); +fn is_builtin_type_keyword(kw: []const u8) bool { + return std.mem.eql(u8, kw, "num") or + std.mem.eql(u8, kw, "str") or + std.mem.eql(u8, kw, "bin") or + std.mem.eql(u8, kw, "chr"); +} - var out = std.ArrayList(u8).init(allocator); - defer out.deinit(); +fn appendAll(dst: *std.ArrayList(token.Token), src: []const token.Token) !void { + for (src) |t| { + try dst.append(t); + } +} - var indent: usize = 0; - var at_line_start = true; - var prev_token: ?token.Token = null; +const EmitState = struct { + indent: *usize, + at_line_start: *bool, + prev_token: *?token.Token, + out: *std.ArrayList(u8), + allocator: mem.Allocator, +}; - const tokens = tp.tokens.items(); - var i: usize = 0; - while (i < tokens.len) : (i += 1) { - const t = tokens[i]; - if (t.type == .NewLine) continue; +fn emitTokens(state: *EmitState, toks: []const token.Token) !void { + var idx: usize = 0; + while (idx < toks.len) : (idx += 1) { + const t2 = toks[idx]; + if (t2.type == .NewLine) continue; - if (t.type == .Comment) { - if (!at_line_start) { - try out.append('\n'); - at_line_start = true; + if (t2.type == .Comment) { + if (!state.at_line_start.*) { + try state.out.append('\n'); + state.at_line_start.* = true; } - if (at_line_start) { - try out.appendNTimes(' ', indent * 4); + if (state.at_line_start.*) { + try state.out.appendNTimes(' ', state.indent.* * 4); } - const s = try token_text(allocator, t); - defer allocator.free(s); - try out.appendSlice(s); - try out.append('\n'); - at_line_start = true; - prev_token = null; + const s2 = try token_text(state.allocator, t2); + defer state.allocator.free(s2); + try state.out.appendSlice(s2); + try state.out.append('\n'); + state.at_line_start.* = true; + state.prev_token.* = null; continue; } // Handle closing brace with optional same-line `elif`/`else`. - if (t.type == .Symbol and t.data.cval == '}') { - if (!at_line_start) { - try out.append('\n'); - at_line_start = true; + if (t2.type == .Symbol and t2.data.cval == '}') { + if (!state.at_line_start.*) { + try state.out.append('\n'); + state.at_line_start.* = true; } - if (indent > 0) indent -= 1; - try out.appendNTimes(' ', indent * 4); - try out.append('}'); + if (state.indent.* > 0) state.indent.* -= 1; + try state.out.appendNTimes(' ', state.indent.* * 4); + try state.out.append('}'); // Look ahead for `elif`/`else`. - var j = i + 1; - while (j < tokens.len and (tokens[j].type == .NewLine or tokens[j].type == .Comment)) : (j += 1) {} - if (j < tokens.len and tokens[j].type == .Keyword) { - const kw = tokens[j].data.sval.items; - if (std.mem.eql(u8, kw, "elif") or std.mem.eql(u8, kw, "else")) { - try out.append(' '); - at_line_start = false; - prev_token = t; + var j2 = idx + 1; + while (j2 < toks.len and (toks[j2].type == .NewLine or toks[j2].type == .Comment)) : (j2 += 1) {} + if (j2 < toks.len and toks[j2].type == .Keyword) { + const kw2 = toks[j2].data.sval.items; + if (std.mem.eql(u8, kw2, "elif") or std.mem.eql(u8, kw2, "else")) { + try state.out.append(' '); + state.at_line_start.* = false; + state.prev_token.* = t2; continue; } } - try out.append('\n'); - at_line_start = true; - prev_token = t; + try state.out.append('\n'); + state.at_line_start.* = true; + + // Add a blank line between top-level function declarations. + if (state.indent.* == 0) { + var k2 = idx + 1; + while (k2 < toks.len and (toks[k2].type == .NewLine or toks[k2].type == .Comment)) : (k2 += 1) {} + if (k2 < toks.len and toks[k2].type == .Keyword and std.mem.eql(u8, toks[k2].data.sval.items, "fun")) { + try state.out.append('\n'); + state.at_line_start.* = true; + } + } + + state.prev_token.* = t2; continue; } // Indent at start of line. - if (at_line_start) { - try out.appendNTimes(' ', indent * 4); - at_line_start = false; + if (state.at_line_start.*) { + try state.out.appendNTimes(' ', state.indent.* * 4); + state.at_line_start.* = false; } // Decide whether to add a space before this token. - if (prev_token) |pt| { + if (state.prev_token.*) |pt2| { const needs_space = blk: { - if (t.type == .Symbol) { - const c = t.data.cval; - if (c == ',' or c == ';' or c == ')' or c == ']' or c == '}' or c == ':') break :blk false; - if (c == '{') break :blk true; - if (c == '(' or c == '[') { + if (t2.type == .Symbol) { + const c2 = t2.data.cval; + if (c2 == ',' or c2 == ';' or c2 == ')' or c2 == ']' or c2 == '}' or c2 == ':') break :blk false; + if (c2 == '{') break :blk true; + if (c2 == '(' or c2 == '[') { // No space for calls/indexing: `foo(`, `arr[`. break :blk false; } } - if (pt.type == .Symbol) { - const pc = pt.data.cval; - if (pc == '(' or pc == '[' or pc == '{') break :blk false; + if (pt2.type == .Symbol) { + const pc2 = pt2.data.cval; + if (pc2 == '(' or pc2 == '[' or pc2 == '{') break :blk false; } - if (t.type == .Operator) { - break :blk operator_needs_spaces(t.data.sval.items); + if (t2.type == .Operator) { + break :blk operator_needs_spaces(t2.data.sval.items); } - if (pt.type == .Operator) { - break :blk operator_needs_spaces(pt.data.sval.items); + if (pt2.type == .Operator) { + break :blk operator_needs_spaces(pt2.data.sval.items); } - if (is_word_like(pt) and is_word_like(t)) break :blk true; - if (pt.type == .Symbol and is_closing_symbol(pt.data.cval) and is_word_like(t)) break :blk true; + if (is_word_like(pt2) and is_word_like(t2)) break :blk true; + if (pt2.type == .Symbol and is_closing_symbol(pt2.data.cval) and is_word_like(t2)) break :blk true; break :blk false; }; if (needs_space) { - const last = if (out.items.len > 0) out.items[out.items.len - 1] else 0; - if (last != ' ' and last != '\n' and last != '\t') try out.append(' '); + const last2 = if (state.out.items.len > 0) state.out.items[state.out.items.len - 1] else 0; + if (last2 != ' ' and last2 != '\n' and last2 != '\t') try state.out.append(' '); } } // Emit token. - if (t.type == .Symbol) { - const c = t.data.cval; - if (c == '{') { - try out.append('{'); - try out.append('\n'); - indent += 1; - at_line_start = true; - prev_token = null; + if (t2.type == .Symbol) { + const c2 = t2.data.cval; + if (c2 == '{') { + try state.out.append('{'); + try state.out.append('\n'); + state.indent.* += 1; + state.at_line_start.* = true; + state.prev_token.* = null; continue; } - if (c == ';') { - try out.append(';'); - try out.append('\n'); - at_line_start = true; - prev_token = null; + if (c2 == ';') { + try state.out.append(';'); + try state.out.append('\n'); + state.at_line_start.* = true; + state.prev_token.* = null; continue; } - if (c == ',') { - try out.append(','); - try out.append(' '); - prev_token = null; + if (c2 == ',') { + try state.out.append(','); + try state.out.append(' '); + state.prev_token.* = null; continue; } - if (c == ':') { - try out.append(':'); - try out.append(' '); - prev_token = null; + if (c2 == ':') { + try state.out.append(':'); + try state.out.append(' '); + state.prev_token.* = null; continue; } - try out.append(c); - prev_token = t; + try state.out.append(c2); + state.prev_token.* = t2; + continue; + } + + if (t2.type == .Operator and std.mem.eql(u8, t2.data.sval.items, ",")) { + try state.out.append(','); + try state.out.append(' '); + state.prev_token.* = null; continue; } - if (t.type == .Operator and std.mem.eql(u8, t.data.sval.items, ",")) { - try out.append(','); - try out.append(' '); - prev_token = null; + if (t2.type == .Operator and (std.mem.eql(u8, t2.data.sval.items, "(") or std.mem.eql(u8, t2.data.sval.items, "["))) { + const s2 = try token_text(state.allocator, t2); + defer state.allocator.free(s2); + try state.out.appendSlice(s2); + state.prev_token.* = t2; + continue; + } + + const s2 = try token_text(state.allocator, t2); + defer state.allocator.free(s2); + try state.out.appendSlice(s2); + state.prev_token.* = t2; + } +} + +pub fn format_file_in_place(allocator: mem.Allocator, input_file: []const u8) !void { + // Lex tokens from the file. + var tp = try codegen.TranspileProcess.init( + allocator, + input_file, + "__fmt_unused__.c", + .{ .exec = false, .outf = false, .ast = false }, + ); + defer tp.deinit(); + var lp = lexer.LexProcess.init(&tp); + defer lp.deinit(); + try lp.lex(); + + var out = std.ArrayList(u8).init(allocator); + defer out.deinit(); + + var indent: usize = 0; + var at_line_start = true; + var prev_token: ?token.Token = null; + + const tokens = tp.tokens.items(); + + // Partition top-level imports + global vars into groups. + var imports = std.ArrayList(token.Token).init(allocator); + defer imports.deinit(); + var globals = std.ArrayList(token.Token).init(allocator); + defer globals.deinit(); + var rest = std.ArrayList(token.Token).init(allocator); + defer rest.deinit(); + var pending_comments = std.ArrayList(token.Token).init(allocator); + defer pending_comments.deinit(); + + var brace_depth: isize = 0; + var paren_depth: isize = 0; + var bracket_depth: isize = 0; + var can_start_stmt = true; + var i: usize = 0; + while (i < tokens.len) : (i += 1) { + const t = tokens[i]; + if (t.type == .NewLine) continue; + + const is_top = brace_depth == 0; + const is_stmt_start = is_top and paren_depth == 0 and bracket_depth == 0 and can_start_stmt; + + if (is_top and t.type == .Comment) { + try pending_comments.append(t); continue; } - if (t.type == .Operator and (std.mem.eql(u8, t.data.sval.items, "(") or std.mem.eql(u8, t.data.sval.items, "["))) { - const s = try token_text(allocator, t); - defer allocator.free(s); - try out.appendSlice(s); - prev_token = t; + const is_kw = t.type == .Keyword; + const kw = if (is_kw) t.data.sval.items else ""; + + const starts_import_stmt = is_stmt_start and is_kw and std.mem.eql(u8, kw, "imp"); + const starts_global_stmt = is_stmt_start and is_kw and is_builtin_type_keyword(kw); + + if (starts_import_stmt or starts_global_stmt) { + // Collect up to ';' + var stmt = std.ArrayList(token.Token).init(allocator); + defer stmt.deinit(); + try appendAll(&stmt, pending_comments.items); + pending_comments.clearRetainingCapacity(); + + try stmt.append(t); + + var j: usize = i + 1; + while (j < tokens.len) : (j += 1) { + const tt = tokens[j]; + if (tt.type == .NewLine) continue; + try stmt.append(tt); + if (tt.type == .Symbol and tt.data.cval == ';') { + break; + } + } + + if (starts_import_stmt) { + try appendAll(&imports, stmt.items); + } else { + try appendAll(&globals, stmt.items); + } + + i = j; + can_start_stmt = true; continue; } - const s = try token_text(allocator, t); - defer allocator.free(s); - try out.appendSlice(s); + // Not a top-level import/global-var statement: keep in place. + if (pending_comments.items.len > 0) { + try appendAll(&rest, pending_comments.items); + pending_comments.clearRetainingCapacity(); + } + try rest.append(t); + + // Track structural depth and statement boundaries. + if (t.type == .Operator) { + if (std.mem.eql(u8, t.data.sval.items, "(")) paren_depth += 1; + if (std.mem.eql(u8, t.data.sval.items, "[")) bracket_depth += 1; + } + if (t.type == .Symbol) { + if (t.data.cval == ')') { + if (paren_depth > 0) paren_depth -= 1; + } + if (t.data.cval == ']') { + if (bracket_depth > 0) bracket_depth -= 1; + } + if (t.data.cval == '{') brace_depth += 1; + if (t.data.cval == '}' and brace_depth > 0) brace_depth -= 1; + if (t.data.cval == ';' and brace_depth == 0) { + can_start_stmt = true; + } else if (t.data.cval == '}' and brace_depth == 0) { + can_start_stmt = true; + } else if (t.data.cval != ';') { + can_start_stmt = false; + } + } else { + // Any non-comment, non-newline token consumes the statement start. + can_start_stmt = false; + } + } - prev_token = t; + if (pending_comments.items.len > 0) { + try appendAll(&rest, pending_comments.items); + pending_comments.clearRetainingCapacity(); } + var state: EmitState = .{ .indent = &indent, .at_line_start = &at_line_start, .prev_token = &prev_token, .out = &out, .allocator = allocator }; + + if (imports.items.len > 0) { + try emitTokens(&state, imports.items); + if (out.items.len > 0 and out.items[out.items.len - 1] != '\n') try out.append('\n'); + try out.append('\n'); + at_line_start = true; + prev_token = null; + } + + if (globals.items.len > 0) { + try emitTokens(&state, globals.items); + if (out.items.len > 0 and out.items[out.items.len - 1] != '\n') try out.append('\n'); + try out.append('\n'); + at_line_start = true; + prev_token = null; + } + + try emitTokens(&state, rest.items); + // Ensure exactly one trailing newline. if (out.items.len == 0 or out.items[out.items.len - 1] != '\n') { try out.append('\n'); @@ -454,9 +801,9 @@ pub fn compile_and_run(allocator: mem.Allocator, c_file_or_content: []const u8, }; // When `zig build test` runs tests with `--listen=-`, stdout is used for the - // test runner protocol. Writing arbitrary output to stdout from tests can - // corrupt the protocol and appear as a hang. - const stdout = if (builtin.is_test) std.io.getStdErr().writer() else std.io.getStdOut().writer(); + // test runner protocol. Also, noisy test stderr makes it look like failures. + // Keep tests quiet, but preserve normal CLI output. + const stdout = if (builtin.is_test) std.io.null_writer else std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); // If content is provided instead of a file, write it to a temporary file first @@ -527,7 +874,9 @@ pub fn compile_and_run(allocator: mem.Allocator, c_file_or_content: []const u8, } if (result.term.Exited != 0) { - try stderr.print("Compilation error:\n{s}", .{result.stderr}); + if (!builtin.is_test) { + try stderr.print("Compilation error:\n{s}", .{result.stderr}); + } return CliError.CompilationFailed; } } @@ -556,7 +905,9 @@ pub fn compile_and_run(allocator: mem.Allocator, c_file_or_content: []const u8, } if (result.term.Exited != 0) { - try stderr.print("Runtime error: {s}", .{result.stderr}); + if (!builtin.is_test) { + try stderr.print("Runtime error: {s}", .{result.stderr}); + } return CliError.ExecutionFailed; } diff --git a/tests/fmt_test.zig b/tests/fmt_test.zig index 9ee3f185..e66c64f7 100644 --- a/tests/fmt_test.zig +++ b/tests/fmt_test.zig @@ -12,6 +12,32 @@ fn writeTempFnFile(allocator: std.mem.Allocator, prefix: []const u8, contents: [ return name; } +fn makeTempDir(allocator: std.mem.Allocator, prefix: []const u8) ![]const u8 { + const ts = std.time.nanoTimestamp(); + const name = try std.fmt.allocPrint(allocator, "{s}_{d}", .{ prefix, ts }); + errdefer allocator.free(name); + try std.fs.cwd().makePath(name); + return name; +} + +fn writeFileInDir(allocator: std.mem.Allocator, dir: []const u8, rel: []const u8, contents: []const u8) ![]const u8 { + const path = try std.fs.path.join(allocator, &.{ dir, rel }); + errdefer allocator.free(path); + + if (std.fs.path.dirname(path)) |pdir| { + try std.fs.cwd().makePath(pdir); + } + + const f = try std.fs.cwd().createFile(path, .{ .read = true }); + defer f.close(); + try f.writeAll(contents); + return path; +} + +fn deleteTreeIfExists(path: []const u8) void { + std.fs.cwd().deleteTree(path) catch {}; +} + test "-fmt formats file in-place" { const allocator = std.testing.allocator; @@ -44,3 +70,145 @@ test "-fmt formats file in-place" { try std.testing.expectEqualStrings(expected, got); } + +test "-fmt keeps a blank line between functions" { + const allocator = std.testing.allocator; + + const ugly = + "fun a() num{ret 1;}fun b() num{ret 2;}\n"; + + const path = try writeTempFnFile(allocator, "fmt_two", ugly); + defer { + std.fs.cwd().deleteFile(path) catch {}; + allocator.free(path); + } + + try cli.format_file_in_place(allocator, path); + + const got = try std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024); + defer allocator.free(got); + + const expected = + "fun a() num {\n" ++ + " ret 1;\n" ++ + "}\n" ++ + "\n" ++ + "fun b() num {\n" ++ + " ret 2;\n" ++ + "}\n"; + + try std.testing.expectEqualStrings(expected, got); +} + +test "-fmt groups imports and globals at top" { + const allocator = std.testing.allocator; + + const ugly = + "fun main() num{ret 0;}\n" ++ + "imp std.io;\n" ++ + "num x=1;\n" ++ + "imp foo.bar;\n" ++ + "num y=2;\n"; + + const path = try writeTempFnFile(allocator, "fmt_groups", ugly); + defer { + std.fs.cwd().deleteFile(path) catch {}; + allocator.free(path); + } + + try cli.format_file_in_place(allocator, path); + + const got = try std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024); + defer allocator.free(got); + + const expected = + "imp std.io;\n" ++ + "imp foo.bar;\n" ++ + "\n" ++ + "num x = 1;\n" ++ + "num y = 2;\n" ++ + "\n" ++ + "fun main() num {\n" ++ + " ret 0;\n" ++ + "}\n"; + + try std.testing.expectEqualStrings(expected, got); +} + +test "-fmt-all formats local imports recursively (skips std.*)" { + const allocator = std.testing.allocator; + + const dir = try makeTempDir(allocator, "fmt_all"); + defer { + deleteTreeIfExists(dir); + allocator.free(dir); + } + + // main.fn imports a local module and std.io (std import should be skipped). + const main_path = try writeFileInDir( + allocator, + dir, + "main.fn", + "imp foo.bar; imp std.io; fun main() num{ret 0;}\n", + ); + defer allocator.free(main_path); + + const imported_path = try writeFileInDir( + allocator, + dir, + "foo/bar.fn", + "fun add(num a,num b) num{ret a+b;}\n", + ); + defer allocator.free(imported_path); + + try cli.format_file_and_imports_in_place(allocator, main_path); + + const got_main = try std.fs.cwd().readFileAlloc(allocator, main_path, 1024 * 1024); + defer allocator.free(got_main); + try std.testing.expect(std.mem.indexOf(u8, got_main, "imp foo.bar;\n") != null); + try std.testing.expect(std.mem.indexOf(u8, got_main, "imp std.io;\n") != null); + + const got_import = try std.fs.cwd().readFileAlloc(allocator, imported_path, 1024 * 1024); + defer allocator.free(got_import); + const expected_imported = + "fun add(num a, num b) num {\n" ++ + " ret a + b;\n" ++ + "}\n"; + try std.testing.expectEqualStrings(expected_imported, got_import); +} + +test "-fmt-all has cycle protection" { + const allocator = std.testing.allocator; + + const dir = try makeTempDir(allocator, "fmt_cycle"); + defer { + deleteTreeIfExists(dir); + allocator.free(dir); + } + + const a_path = try writeFileInDir( + allocator, + dir, + "a.fn", + "imp b; fun a() num{ret 1;}\n", + ); + defer allocator.free(a_path); + + const b_path = try writeFileInDir( + allocator, + dir, + "b.fn", + "imp a; fun b() num{ret 2;}\n", + ); + defer allocator.free(b_path); + + try cli.format_file_and_imports_in_place(allocator, a_path); + + const got_a = try std.fs.cwd().readFileAlloc(allocator, a_path, 1024 * 1024); + defer allocator.free(got_a); + try std.testing.expect(std.mem.indexOf(u8, got_a, "fun a() num") != null); + + const got_b = try std.fs.cwd().readFileAlloc(allocator, b_path, 1024 * 1024); + defer allocator.free(got_b); + try std.testing.expect(std.mem.indexOf(u8, got_b, "fun b() num") != null); +} From fd4e073b1b6a37e42b7425db091be8aaea9edfe3 Mon Sep 17 00:00:00 2001 From: omdxp Date: Wed, 31 Dec 2025 03:04:33 +0100 Subject: [PATCH 3/3] feat: improve comment formatting by adding a space after "//" in token output --- modules/cli/cli.zig | 7 ++++++- tests/fmt_test.zig | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/cli/cli.zig b/modules/cli/cli.zig index a538e44a..1aa3d160 100644 --- a/modules/cli/cli.zig +++ b/modules/cli/cli.zig @@ -391,7 +391,12 @@ fn token_text(allocator: mem.Allocator, t: token.Token) ![]const u8 { }, .String => std.fmt.allocPrint(allocator, "\"{s}\"", .{t.data.sval.items}), .Boolean => allocator.dupe(u8, if (t.data.bval) "true" else "false"), - .Comment => std.fmt.allocPrint(allocator, "//{s}", .{std.mem.trim(u8, t.data.sval.items, " \t")}), + .Comment => blk: { + // Always emit exactly one space after // + const trimmed = std.mem.trim(u8, t.data.sval.items, " \t"); + const out = try std.fmt.allocPrint(allocator, "// {s}", .{trimmed}); + break :blk out; + }, .NewLine => allocator.dupe(u8, "\n"), }; } diff --git a/tests/fmt_test.zig b/tests/fmt_test.zig index e66c64f7..696abbae 100644 --- a/tests/fmt_test.zig +++ b/tests/fmt_test.zig @@ -61,7 +61,7 @@ test "-fmt formats file in-place" { "fun add(num a, num b) num {\n" ++ " ret a + b;\n" ++ "}\n" ++ - "//comment\n" ++ + "// comment\n" ++ "if true {\n" ++ " ret 1;\n" ++ "} else {\n" ++