diff --git a/cmd/fun/main.zig b/cmd/fun/main.zig index a6d41d8c..4730de92 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,16 @@ 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; + } + var tp = codegen.TranspileProcess.init( global_allocator, options.input_file, diff --git a/modules/cli/cli.zig b/modules/cli/cli.zig index a020828a..1aa3d160 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,12 @@ 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, + + /// 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. @@ -57,10 +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 [-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) @@ -114,6 +124,8 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { var exec = true; 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")) { @@ -141,6 +153,10 @@ 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; + } else if (std.mem.eql(u8, arg, "-fmt-all")) { + fmt_all = true; } } @@ -162,9 +178,583 @@ pub fn parse_args(allocator: mem.Allocator) !CliOptions { .exec = exec, .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), + .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 => 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"), + }; +} + +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, "["); +} + +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"); +} + +fn appendAll(dst: *std.ArrayList(token.Token), src: []const token.Token) !void { + for (src) |t| { + try dst.append(t); + } +} + +const EmitState = struct { + indent: *usize, + at_line_start: *bool, + prev_token: *?token.Token, + out: *std.ArrayList(u8), + allocator: mem.Allocator, +}; + +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 (t2.type == .Comment) { + if (!state.at_line_start.*) { + try state.out.append('\n'); + state.at_line_start.* = true; + } + if (state.at_line_start.*) { + try state.out.appendNTimes(' ', state.indent.* * 4); + } + 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 (t2.type == .Symbol and t2.data.cval == '}') { + if (!state.at_line_start.*) { + try state.out.append('\n'); + state.at_line_start.* = true; + } + if (state.indent.* > 0) state.indent.* -= 1; + try state.out.appendNTimes(' ', state.indent.* * 4); + try state.out.append('}'); + + // Look ahead for `elif`/`else`. + 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 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 (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 (state.prev_token.*) |pt2| { + const needs_space = blk: { + 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 (pt2.type == .Symbol) { + const pc2 = pt2.data.cval; + if (pc2 == '(' or pc2 == '[' or pc2 == '{') break :blk false; + } + if (t2.type == .Operator) { + break :blk operator_needs_spaces(t2.data.sval.items); + } + if (pt2.type == .Operator) { + break :blk operator_needs_spaces(pt2.data.sval.items); + } + 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 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 (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 (c2 == ';') { + try state.out.append(';'); + try state.out.append('\n'); + state.at_line_start.* = true; + state.prev_token.* = null; + continue; + } + if (c2 == ',') { + try state.out.append(','); + try state.out.append(' '); + state.prev_token.* = null; + continue; + } + if (c2 == ':') { + try state.out.append(':'); + try state.out.append(' '); + state.prev_token.* = null; + continue; + } + 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 (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; + } + + 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; + } + + // 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; + } + } + + 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'); + } + + // 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, @@ -216,9 +806,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 @@ -289,7 +879,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; } } @@ -318,7 +910,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 new file mode 100644 index 00000000..696abbae --- /dev/null +++ b/tests/fmt_test.zig @@ -0,0 +1,214 @@ +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; +} + +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; + + 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); +} + +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); +} 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");