From 95471a1079633279f10d7606eab58df34c74a0fb Mon Sep 17 00:00:00 2001 From: Ryu <114303361+ryuapp@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:55:32 +0900 Subject: [PATCH] feat: add fmt --- examples/abs.zy | 4 +- examples/get-readme.zy | 2 +- examples/switch.zy | 12 +- src/colors.rs | 33 +++ src/commands/check.rs | 180 +++++++++++-- src/commands/mod.rs | 22 +- src/fmt.rs | 366 ++++++++++++++++++++++++++ src/lexer.rs | 74 +++++- src/main.rs | 8 +- src/parser.rs | 29 +- src/tests/fixtures/alloc_propagate.zy | 4 +- src/tests/fixtures/enum_decl.zy | 7 +- src/tests/fixtures/modulo.zy | 2 +- src/tests/fixtures/struct_decl.zy | 5 +- src/tests/fmt.rs | 310 ++++++++++++++++++++++ src/tests/mod.rs | 5 +- src/typechecker.rs | 2 +- 17 files changed, 995 insertions(+), 70 deletions(-) create mode 100644 src/fmt.rs create mode 100644 src/tests/fmt.rs diff --git a/examples/abs.zy b/examples/abs.zy index ef4378e..34aabd9 100644 --- a/examples/abs.zy +++ b/examples/abs.zy @@ -1,9 +1,9 @@ -const std = import("std"); +const std = import("std") fn abs(x: i32): i32 { return if x < 0 then -x else x } fn main(): void { - std.debug.print(abs(-42)); + std.debug.print(abs(-42)) } diff --git a/examples/get-readme.zy b/examples/get-readme.zy index b2d2d1a..d020bc8 100644 --- a/examples/get-readme.zy +++ b/examples/get-readme.zy @@ -2,7 +2,7 @@ const std = import("std") const fs = std.fs fn getReadme(filename: string): !string { - const data = fs.readTextFile(filename)?; + const data = fs.readTextFile(filename)? return data } diff --git a/examples/switch.zy b/examples/switch.zy index a7bcb06..f7f6442 100644 --- a/examples/switch.zy +++ b/examples/switch.zy @@ -1,4 +1,4 @@ -const std = import("std"); +const std = import("std") fn label(n: i32): string { return switch n { @@ -6,12 +6,12 @@ fn label(n: i32): string { 2 => "two", 3 => "three", else => "other", - }; + } } fn main(): void { - std.debug.print(label(1)); - std.debug.print(label(2)); - std.debug.print(label(3)); - std.debug.print(label(99)); + std.debug.print(label(1)) + std.debug.print(label(2)) + std.debug.print(label(3)) + std.debug.print(label(99)) } diff --git a/src/colors.rs b/src/colors.rs index 97cbec2..960ae27 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -1,6 +1,39 @@ const RESET: &str = "\x1b[0m"; const BOLD_RED: &str = "\x1b[1;31m"; +const RED: &str = "\x1b[31m"; +const GREEN: &str = "\x1b[32m"; +const RED_BG: &str = "\x1b[97;41m"; // bright white on red bg +const GREEN_BG: &str = "\x1b[97;42m"; // bright white on green bg + +fn wrap(color: &str, s: &str) -> String { + format!("{}{}{}", color, s, RESET) +} + +fn wrap_diff(fg: &str, bg: &str, prefix: &str, mid: &str, suffix: &str) -> String { + format!( + "{}{}{}{}{}{}{}{}", + fg, prefix, bg, mid, RESET, fg, suffix, RESET + ) +} pub fn error(s: &str) -> String { format!("{}error{}: {}", BOLD_RED, RESET, s) } + +pub fn red(s: &str) -> String { + wrap(RED, s) +} + +pub fn green(s: &str) -> String { + wrap(GREEN, s) +} + +/// Red line with background highlight on the changed portion. +pub fn red_diff(prefix: &str, mid: &str, suffix: &str) -> String { + wrap_diff(RED, RED_BG, prefix, mid, suffix) +} + +/// Green line with background highlight on the changed portion. +pub fn green_diff(prefix: &str, mid: &str, suffix: &str) -> String { + wrap_diff(GREEN, GREEN_BG, prefix, mid, suffix) +} diff --git a/src/commands/check.rs b/src/commands/check.rs index c017c2d..9417594 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -1,26 +1,89 @@ -use super::{abort, render_error}; +use super::abort; use std::path::Path; -pub fn run(input_path: Option<&str>) { +pub fn run(input_path: Option<&str>, fix: bool) { match input_path { None => { let files = collect_zy_files(Path::new(".")); - let total_errors: usize = files.iter().map(|p| check_file(p)).sum(); - if total_errors > 0 { - std::process::exit(1); + let total = files.len(); + let mut type_error_count = 0usize; + let mut not_formatted = 0usize; + for p in &files { + let (type_errors, fmt_diff, _changed) = check_file(p, fix); + if !type_errors.is_empty() { + eprintln!("{}", super::full_path(p)); + for msg in &type_errors { + eprintln!("{}", msg); + } + type_error_count += type_errors.len(); + } + if let Some(diff) = &fmt_diff { + eprintln!("from {}:", super::full_path(p)); + eprintln!("{}", diff); + not_formatted += 1; + } } + print_summary(total, type_error_count, not_formatted); } Some(path) => { - check_source(&super::read_file(path), path); + let source = super::read_file(path); + let (_, type_errors, fmt_diff, _changed) = check_and_report(&source, path, fix); + if !type_errors.is_empty() { + eprintln!("{}", super::full_path(Path::new(path))); + for msg in &type_errors { + eprintln!("{}", msg); + } + } + let not_formatted = if let Some(diff) = &fmt_diff { + eprintln!("from {}:", super::full_path(Path::new(path))); + eprintln!("{}", diff); + 1 + } else { + 0 + }; + print_summary(1, type_errors.len(), not_formatted); + if !type_errors.is_empty() { + abort(type_errors.len()); + } + } + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +fn print_summary(total: usize, type_errors: usize, not_formatted: usize) { + if not_formatted > 0 { + eprintln!( + "{}", + crate::colors::error(&format!( + "Found {} not formatted file{} in {} file{}", + not_formatted, + plural(not_formatted), + total, + plural(total), + )) + ); + if type_errors == 0 { + std::process::exit(1); } + } else if type_errors == 0 { + eprintln!("Checked {} file{}", total, plural(total)); + } + if type_errors > 0 { + std::process::exit(1); } } /// Parses and type-checks source, aborting on errors. Returns the AST. pub fn check_source(source: &str, path: &str) -> crate::parser::Program { - let (ast, error_count) = check_and_report(source, path); - if error_count > 0 { - abort(error_count); + let (ast, type_errors, _, _) = check_and_report(source, path, false); + if !type_errors.is_empty() { + for msg in &type_errors { + eprintln!("{}", msg); + } + abort(type_errors.len()); } ast } @@ -41,35 +104,108 @@ fn collect_zy_files(dir: &Path) -> Vec { result } -fn check_file(path: &Path) -> usize { +fn check_file(path: &Path, fix: bool) -> (Vec, Option, bool) { let path_str = path.to_string_lossy(); let source = match std::fs::read_to_string(path) { Ok(s) => s, Err(e) => { - eprintln!("{}: {}", path_str, e); - return 1; + return (vec![format!("{}: {}", path_str, e)], None, false); } }; - let (_, error_count) = check_and_report(&source, &path_str); - error_count + let (_, type_errors, fmt_diff, changed) = check_and_report(&source, &path_str, fix); + (type_errors, fmt_diff, changed) +} + +fn diff_highlight(a: &str, b: &str) -> (String, String) { + let ac: Vec = a.chars().collect(); + let bc: Vec = b.chars().collect(); + let prefix = ac.iter().zip(bc.iter()).take_while(|(x, y)| x == y).count(); + let suffix = ac[prefix..] + .iter() + .rev() + .zip(bc[prefix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + let a_end = ac.len() - suffix; + let b_end = bc.len() - suffix; + let ap: String = ac[..prefix].iter().collect(); + let am: String = ac[prefix..a_end].iter().collect(); + let as_: String = ac[a_end..].iter().collect(); + let bp: String = bc[..prefix].iter().collect(); + let bm: String = bc[prefix..b_end].iter().collect(); + let bs: String = bc[b_end..].iter().collect(); + ( + crate::colors::red_diff(&ap, &am, &as_), + crate::colors::green_diff(&bp, &bm, &bs), + ) } -fn check_and_report(source: &str, path: &str) -> (crate::parser::Program, usize) { +fn fmt_diff(source: &str, formatted: &str) -> String { + let src_lines: Vec<&str> = source.lines().collect(); + let fmt_lines: Vec<&str> = formatted.lines().collect(); + let mut out = String::new(); + let max_len = src_lines.len().max(fmt_lines.len()); + let mut shown = 0; + for i in 0..max_len { + let a = src_lines.get(i).copied().unwrap_or(""); + let b = fmt_lines.get(i).copied().unwrap_or(""); + if a != b { + let (hl_a, hl_b) = diff_highlight(a, b); + let ln = i + 1; + out.push_str(&crate::colors::red(&format!("{} | -", ln))); + out.push_str(&hl_a); + out.push('\n'); + out.push_str(&crate::colors::green(&format!("{} | +", ln))); + out.push_str(&hl_b); + out.push('\n'); + shown += 1; + if shown >= 8 { + out.push_str("...\n"); + break; + } + } + } + let trimmed_len = out.trim_end().len(); + out.truncate(trimmed_len); + out +} + +fn check_and_report( + source: &str, + path: &str, + fix: bool, +) -> (crate::parser::Program, Vec, Option, bool) { let tokens = crate::lexer::tokenize(source); - let (ast, parse_errors) = crate::parser::parse(tokens); - let mut error_count = parse_errors.len(); + let (ast, parse_errors, blanks) = crate::parser::parse(tokens); + let mut type_errors: Vec = Vec::new(); + for e in &parse_errors { - render_error(&e.message, e.span, source, path); + type_errors.push(super::format_error(&e.message, e.span, source, path)); } let source_dir = Path::new(path).parent().unwrap_or(Path::new(".")); let zy_errors = crate::typechecker::check_with_diagnostics(&ast, source_dir); - error_count += zy_errors.len(); for e in &zy_errors { if let Some(span) = e.span { - render_error(&e.message, span, source, path); + type_errors.push(super::format_error(&e.message, span, source, path)); } else { - eprintln!("{}", crate::colors::error(&e.message)); + type_errors.push(crate::colors::error(&e.message).to_string()); } } - (ast, error_count) + + let mut fmt_diff_out: Option = None; + let mut changed = false; + if parse_errors.is_empty() { + let formatted = crate::fmt::format_program(&ast, &blanks); + if formatted.trim() != source.trim() { + if fix { + std::fs::write(path, &formatted) + .unwrap_or_else(|e| panic!("Failed to write '{}': {}", path, e)); + changed = true; + } else { + fmt_diff_out = Some(fmt_diff(source, &formatted)); + } + } + } + + (ast, type_errors, fmt_diff_out, changed) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7104530..39ec509 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,17 +9,23 @@ pub fn read_file(path: &str) -> String { std::fs::read_to_string(path).unwrap_or_else(|e| panic!("Failed to read '{}': {}", path, e)) } -pub fn render_error(message: &str, span: (usize, usize), source: &str, path: &str) { +pub fn full_path(p: &std::path::Path) -> String { + let s = p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); + let s = s.to_string_lossy(); + s.strip_prefix(r"\\?\").unwrap_or(&s).to_string() +} + +pub fn format_error(message: &str, span: (usize, usize), source: &str, path: &str) -> String { let (line, col, source_line, caret) = locate(span, source); - eprintln!( - "{}:{}:{}: {}", + format!( + "{}:{}:{}: {}\n {}\n {}", path, line, col, - crate::colors::error(message) - ); - eprintln!(" {}", source_line); - eprintln!(" {}", caret); + crate::colors::error(message), + source_line, + caret + ) } pub fn locate(span: (usize, usize), source: &str) -> (usize, usize, &str, String) { @@ -108,7 +114,7 @@ fn compile_module_inner( } let source = read_file(source_path); let tokens = crate::lexer::tokenize(&source); - let (ast, _) = crate::parser::parse(tokens); + let (ast, _, _) = crate::parser::parse(tokens); let zig_code = crate::codegen::generate(&ast); let cache_dir = Path::new(cache_path).parent().unwrap(); diff --git a/src/fmt.rs b/src/fmt.rs new file mode 100644 index 0000000..73c9833 --- /dev/null +++ b/src/fmt.rs @@ -0,0 +1,366 @@ +use crate::parser::{ + BinOp, Expr, ExprKind, FnDecl, StmtKind, SwitchArm, SwitchBody, SwitchPattern, TopLevel, + TypeExpr, UnOp, +}; + +struct Formatter { + buf: String, + indent: usize, +} + +impl Formatter { + fn new() -> Self { + Self { + buf: String::new(), + indent: 0, + } + } + + fn newline(&mut self) { + self.buf.push('\n'); + } + + fn indent_str(&self) -> String { + " ".repeat(self.indent) + } + + fn line(&mut self, s: &str) { + for _ in 0..self.indent { + self.buf.push_str(" "); + } + self.buf.push_str(s); + self.buf.push('\n'); + } + + /// Format a statement and return it as a String without writing to `self.buf`. + fn fmt_stmt_str(&mut self, stmt: &crate::parser::Stmt) -> String { + let old_len = self.buf.len(); + self.fmt_stmt(stmt); + let added = self.buf[old_len..].to_string(); + self.buf.truncate(old_len); + added + } + + fn format_program(&mut self, program: &crate::parser::Program, blanks: &[bool]) { + let mut prev_was_block = false; + for (i, item) in program.iter().enumerate() { + let is_block = matches!( + item, + TopLevel::FnDecl(_) | TopLevel::StructDecl { .. } | TopLevel::EnumDecl { .. } + ); + let blank_before = blanks.get(i).copied().unwrap_or(false); + if i > 0 && (is_block || prev_was_block || blank_before) { + self.newline(); + } + self.fmt_toplevel(item); + prev_was_block = is_block; + } + } + + fn fmt_toplevel(&mut self, item: &TopLevel) { + match item { + TopLevel::ConstDecl { + name, + value, + exported, + .. + } => { + let prefix = if *exported { "export " } else { "" }; + let val = self.fmt_expr(value); + self.line(&format!("{}const {} = {}", prefix, name, val)); + } + TopLevel::FnDecl(f) => self.fmt_fn(f), + TopLevel::StructDecl { + name, + fields, + exported, + } => { + let prefix = if *exported { "export " } else { "" }; + self.line(&format!("{}const {} = struct {{", prefix, name)); + self.indent += 1; + for field in fields { + let ty = fmt_type(&field.ty); + self.line(&format!("{}: {},", field.name, ty)); + } + self.indent -= 1; + self.line("};"); + } + TopLevel::EnumDecl { + name, + variants, + exported, + } => { + let prefix = if *exported { "export " } else { "" }; + self.line(&format!("{}const {} = enum {{", prefix, name)); + self.indent += 1; + for v in variants { + self.line(&format!("{},", v)); + } + self.indent -= 1; + self.line("};"); + } + TopLevel::Stmt(stmt) => self.fmt_stmt(stmt), + } + } + + fn fmt_fn(&mut self, f: &FnDecl) { + let prefix = if f.exported { "export " } else { "" }; + let params: Vec = f + .params + .iter() + .map(|(name, ty)| format!("{}: {}", name, fmt_type(ty))) + .collect(); + let ret = fmt_type(&f.ret); + self.line(&format!( + "{}fn {}({}): {} {{", + prefix, + f.name, + params.join(", "), + ret + )); + self.indent += 1; + for stmt in &f.body { + self.fmt_stmt(stmt); + } + self.indent -= 1; + self.line("}"); + } + + fn fmt_stmt(&mut self, stmt: &crate::parser::Stmt) { + match &stmt.kind { + StmtKind::ConstDecl { name, ty, value } => { + let val = self.fmt_expr(value); + if let Some(t) = ty { + self.line(&format!("const {}: {} = {}", name, fmt_type(t), val)); + } else { + self.line(&format!("const {} = {}", name, val)); + } + } + StmtKind::LetDecl { name, ty, value } => { + let val = self.fmt_expr(value); + if let Some(t) = ty { + self.line(&format!("let {}: {} = {}", name, fmt_type(t), val)); + } else { + self.line(&format!("let {} = {}", name, val)); + } + } + StmtKind::Return(None) => self.line("return"), + StmtKind::Return(Some(e)) => { + let val = self.fmt_expr(e); + self.line(&format!("return {}", val)); + } + StmtKind::If { + cond, + body, + else_body, + } => { + let c = self.fmt_expr(cond); + self.line(&format!("if {} {{", c)); + self.indent += 1; + for s in body { + self.fmt_stmt(s); + } + self.indent -= 1; + if let Some(else_stmts) = else_body { + self.line("} else {"); + self.indent += 1; + for s in else_stmts { + self.fmt_stmt(s); + } + self.indent -= 1; + self.line("}"); + } else { + self.line("}"); + } + } + StmtKind::While { cond, body } => { + let c = self.fmt_expr(cond); + self.line(&format!("while {} {{", c)); + self.indent += 1; + for s in body { + self.fmt_stmt(s); + } + self.indent -= 1; + self.line("}"); + } + StmtKind::Break => self.line("break"), + StmtKind::Continue => self.line("continue"), + StmtKind::ExprStmt(e) => { + let val = self.fmt_expr(e); + self.line(&val); + } + } + } + + fn fmt_expr(&mut self, expr: &Expr) -> String { + match &expr.kind { + ExprKind::Import(path) => format!("import(\"{}\")", path), + ExprKind::Var(s) => s.clone(), + ExprKind::Str(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), + ExprKind::Int(n) => n.to_string(), + ExprKind::Float(f) => { + if f.fract() == 0.0 { + format!("{:.1}", f) + } else { + format!("{}", f) + } + } + ExprKind::Bool(b) => b.to_string(), + ExprKind::MemberAccess { obj, prop } => { + format!("{}.{}", self.fmt_expr(obj), prop) + } + ExprKind::Call { callee, args } => { + let c = self.fmt_expr(callee); + let a: Vec = args.iter().map(|a| self.fmt_expr(a)).collect(); + format!("{}({})", c, a.join(", ")) + } + ExprKind::Index { obj, idx } => { + format!("{}[{}]", self.fmt_expr(obj), self.fmt_expr(idx)) + } + ExprKind::Propagate(e) => format!("{}?", self.fmt_expr(e)), + ExprKind::Catch { + expr, + err_name, + body, + } => { + let e = self.fmt_expr(expr); + let header = format!("{} catch {} {{", e, err_name); + let mut s = header; + s.push('\n'); + self.indent += 1; + for stmt in body { + s.push_str(&self.indent_str()); + s.push_str(self.fmt_stmt_str(stmt).trim_start()); + } + self.indent -= 1; + s.push_str(&self.indent_str()); + s.push('}'); + s + } + ExprKind::Switch { expr, arms } => self.fmt_switch(expr, arms), + ExprKind::ArrayLiteral(elems) => { + let e: Vec = elems.iter().map(|e| self.fmt_expr(e)).collect(); + format!("[{}]", e.join(", ")) + } + ExprKind::BinOp { op, lhs, rhs } => { + let l = self.fmt_binop_side(op, lhs, false); + let r = self.fmt_binop_side(op, rhs, true); + format!("{} {} {}", l, binop_str(op), r) + } + ExprKind::UnOp { op, expr } => match op { + UnOp::Neg => format!("-{}", self.fmt_unop_operand(expr)), + UnOp::Not => format!("!{}", self.fmt_unop_operand(expr)), + }, + ExprKind::If { cond, then, else_ } => { + format!( + "if {} then {} else {}", + self.fmt_expr(cond), + self.fmt_expr(then), + self.fmt_expr(else_) + ) + } + } + } + + fn fmt_binop_side(&mut self, parent: &BinOp, child: &Expr, is_rhs: bool) -> String { + let s = self.fmt_expr(child); + if let ExprKind::BinOp { op, .. } = &child.kind { + let cp = precedence(op); + let pp = precedence(parent); + if cp < pp || (is_rhs && cp == pp) { + return format!("({})", s); + } + } + s + } + + fn fmt_unop_operand(&mut self, expr: &Expr) -> String { + let s = self.fmt_expr(expr); + if matches!(expr.kind, ExprKind::BinOp { .. }) { + format!("({})", s) + } else { + s + } + } + + fn fmt_switch(&mut self, expr: &Expr, arms: &[SwitchArm]) -> String { + let e = self.fmt_expr(expr); + let mut s = format!("switch {} {{\n", e); + self.indent += 1; + for arm in arms { + let pat = match &arm.pattern { + SwitchPattern::Ident(n) => n.clone(), + SwitchPattern::Int(n) => n.to_string(), + SwitchPattern::Bool(b) => b.to_string(), + SwitchPattern::Else => "else".to_string(), + }; + match &arm.body { + SwitchBody::Expr(e) => { + let val = self.fmt_expr(e); + s.push_str(&self.indent_str()); + s.push_str(&format!("{} => {},\n", pat, val)); + } + SwitchBody::Block(stmts) => { + s.push_str(&self.indent_str()); + s.push_str(&format!("{} => {{\n", pat)); + self.indent += 1; + for stmt in stmts { + s.push_str(&self.indent_str()); + s.push_str(self.fmt_stmt_str(stmt).trim_start()); + } + self.indent -= 1; + s.push_str(&self.indent_str()); + s.push_str("},\n"); + } + } + } + self.indent -= 1; + s.push_str(&self.indent_str()); + s.push('}'); + s + } +} + +fn fmt_type(ty: &TypeExpr) -> String { + match ty { + TypeExpr::Name(s) => s.clone(), + TypeExpr::Array(inner, n) => format!("{}[{}]", fmt_type(inner), n), + TypeExpr::Optional(inner) => format!("?{}", fmt_type(inner)), + TypeExpr::ErrorUnion(None, t) => format!("!{}", fmt_type(t)), + TypeExpr::ErrorUnion(Some(e), t) => format!("{}!{}", fmt_type(e), fmt_type(t)), + } +} + +fn binop_str(op: &BinOp) -> &'static str { + match op { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Mod => "%", + BinOp::Eq => "==", + BinOp::NotEq => "!=", + BinOp::Lt => "<", + BinOp::Gt => ">", + BinOp::LtEq => "<=", + BinOp::GtEq => ">=", + BinOp::And => "and", + BinOp::Or => "or", + } +} + +fn precedence(op: &BinOp) -> u8 { + match op { + BinOp::Or => 1, + BinOp::And => 2, + BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::Gt | BinOp::LtEq | BinOp::GtEq => 3, + BinOp::Add | BinOp::Sub => 4, + BinOp::Mul | BinOp::Div | BinOp::Mod => 5, + } +} + +pub fn format_program(program: &crate::parser::Program, blanks: &[bool]) -> String { + let mut f = Formatter::new(); + f.format_program(program, blanks); + f.buf +} diff --git a/src/lexer.rs b/src/lexer.rs index 0d42c0c..87a3345 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -43,17 +43,19 @@ pub enum Token { Question, // ? FatArrow, // => // Delimiters - LParen, // ( - RParen, // ) - LBrace, // { - RBrace, // } - LBracket, // [ - RBracket, // ] - Colon, // : - Semi, // ; - AutoSemi, // ; automatically inserted from newline - Dot, // . - Comma, // , + LParen, // ( + RParen, // ) + LBrace, // { + RBrace, // } + LBracket, // [ + RBracket, // ] + Colon, // : + Semi, // ; + AutoSemi, // ; automatically inserted from newline + Dot, // . + Comma, // , + BlankLine, // blank line between top-level items (two consecutive newlines) + Unknown(char), // unrecognized character EOF, } @@ -98,24 +100,67 @@ pub fn tokenize(source: &str) -> Vec<(Token, Span)> { let tokens = tokenize_raw(source); // AutoSemi: drop it if the next token suppresses it, otherwise convert to Semi // Explicit Semi tokens are kept as-is + // BlankLine: inserted when two consecutive newlines appear between tokens let mut result = Vec::with_capacity(tokens.len()); + let mut prev_end = 0usize; let mut i = 0; while i < tokens.len() { - if tokens[i].0 == Token::AutoSemi { + let (tok, span) = &tokens[i]; + if *tok == Token::AutoSemi { let next = tokens.get(i + 1).map(|(t, _)| t).unwrap_or(&Token::EOF); if no_semi_before(next) { + // Keep prev_end at span.0 so the \n is available for blank line detection + prev_end = span.0; i += 1; continue; } - result.push((Token::Semi, tokens[i].1)); + maybe_push_blank(source, prev_end, span.0, &mut result, *span); + result.push((Token::Semi, *span)); + // Keep prev_end at span.0 so the \n is included in next gap check + prev_end = span.0; } else { + maybe_push_blank(source, prev_end, span.0, &mut result, *span); result.push(tokens[i].clone()); + prev_end = span.1; } i += 1; } result } +fn maybe_push_blank( + source: &str, + prev_end: usize, + cur_start: usize, + result: &mut Vec<(Token, Span)>, + span: Span, +) { + if result.last().is_some_and(|(t, _)| *t == Token::BlankLine) { + return; + } + let gap = &source[prev_end..cur_start.min(source.len())]; + if has_blank_line(gap) { + result.push((Token::BlankLine, span)); + } +} + +fn has_blank_line(s: &str) -> bool { + let mut saw_newline = false; + for c in s.chars() { + match c { + '\n' => { + if saw_newline { + return true; + } + saw_newline = true; + } + '\r' | ' ' | '\t' => {} + _ => saw_newline = false, + } + } + false +} + fn tokenize_raw(source: &str) -> Vec<(Token, Span)> { let mut tokens = Vec::new(); let mut chars = source.char_indices().peekable(); @@ -323,8 +368,9 @@ fn tokenize_raw(source: &str) -> Vec<(Token, Span)> { }; tokens.push((tok, (pos, end))); } - _ => { + c => { chars.next(); + tokens.push((Token::Unknown(c), (pos, pos + c.len_utf8()))); } } } diff --git a/src/main.rs b/src/main.rs index de1b664..51b874a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod codegen; mod colors; mod commands; +mod fmt; mod lexer; mod parser; #[cfg(test)] @@ -45,12 +46,9 @@ fn main() { match subcmd { "check" => { + let fix = flags.contains(&"--fix"); let path = positional.get(1).copied(); - if let Some(p) = path { - commands::check::check_source(&commands::read_file(p), p); - } else { - commands::check::run(None); - } + commands::check::run(path, fix); } "build" => { let input = positional.get(1).copied().unwrap_or_else(|| { diff --git a/src/parser.rs b/src/parser.rs index f571c7f..90c9e81 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -348,19 +348,38 @@ impl Parser { // --- Program --- - pub fn parse_program(&mut self) -> Program { + pub fn parse_program(&mut self) -> (Program, Vec) { let mut items = Vec::new(); + let mut blanks = Vec::new(); while self.peek() != &Token::EOF { // Skip stray `;` / AutoSemi at the top level while matches!(self.peek(), Token::Semi | Token::AutoSemi) { self.advance(); } + // Report and skip unknown characters + if let Token::Unknown(c) = self.peek().clone() { + self.error(format!("unexpected character '{}'", c)); + self.advance(); + continue; + } + // Detect blank line between top-level items + let blank_before = if self.peek() == &Token::BlankLine { + self.advance(); + true + } else { + false + }; + // Skip any trailing semis after blank line + while matches!(self.peek(), Token::Semi | Token::AutoSemi) { + self.advance(); + } if self.peek() == &Token::EOF { break; } + blanks.push(blank_before); items.push(self.parse_toplevel()); } - items + (items, blanks) } fn parse_toplevel(&mut self) -> TopLevel { @@ -1031,8 +1050,8 @@ impl Parser { } } -pub fn parse(tokens: Vec<(Token, Span)>) -> (Program, Vec) { +pub fn parse(tokens: Vec<(Token, Span)>) -> (Program, Vec, Vec) { let mut p = Parser::new(tokens); - let program = p.parse_program(); - (program, p.errors) + let (program, blanks) = p.parse_program(); + (program, p.errors, blanks) } diff --git a/src/tests/fixtures/alloc_propagate.zy b/src/tests/fixtures/alloc_propagate.zy index 9ea273a..bb10ac9 100644 --- a/src/tests/fixtures/alloc_propagate.zy +++ b/src/tests/fixtures/alloc_propagate.zy @@ -5,6 +5,8 @@ fn readIt(): !string { } fn main(): void { - const data = readIt() catch _err { return } + const data = readIt() catch _err { + return + } std.debug.print(data) } diff --git a/src/tests/fixtures/enum_decl.zy b/src/tests/fixtures/enum_decl.zy index 3dbd87a..c191878 100644 --- a/src/tests/fixtures/enum_decl.zy +++ b/src/tests/fixtures/enum_decl.zy @@ -1,6 +1,11 @@ const std = import("std") -const Direction = enum { North, South, East, West } +const Direction = enum { + North, + South, + East, + West, +}; fn describe(d: Direction): void { switch d { diff --git a/src/tests/fixtures/modulo.zy b/src/tests/fixtures/modulo.zy index e768797..4479af4 100644 --- a/src/tests/fixtures/modulo.zy +++ b/src/tests/fixtures/modulo.zy @@ -1,3 +1,3 @@ export fn rem(a: i32, b: i32): i32 { - return a % b; + return a % b } diff --git a/src/tests/fixtures/struct_decl.zy b/src/tests/fixtures/struct_decl.zy index 6634367..77273e3 100644 --- a/src/tests/fixtures/struct_decl.zy +++ b/src/tests/fixtures/struct_decl.zy @@ -1,6 +1,9 @@ const std = import("std") -const Point = struct { x: i32, y: i32 } +const Point = struct { + x: i32, + y: i32, +}; fn getX(p: Point): i32 { return p.x diff --git a/src/tests/fmt.rs b/src/tests/fmt.rs new file mode 100644 index 0000000..4bf7d11 --- /dev/null +++ b/src/tests/fmt.rs @@ -0,0 +1,310 @@ +fn check(src: &str, expected: &str) { + let out = fmt(src); + assert_eq!(out.trim(), expected.trim(), "fmt output mismatch"); + idempotent(src); +} + +fn fmt(src: &str) -> String { + let tokens = crate::lexer::tokenize(src); + let (ast, _, blanks) = crate::parser::parse(tokens); + crate::fmt::format_program(&ast, &blanks) +} + +fn idempotent(src: &str) { + let once = fmt(src); + let twice = fmt(&once); + assert_eq!(once, twice, "fmt is not idempotent:\n{}", once); +} + +#[test] +fn test_fmt_const() { + check("const x = 42", "const x = 42"); +} + +#[test] +fn test_fmt_const_typed() { + check( + "fn main(): void { const x: i32 = 42 }", + "fn main(): void {\n const x: i32 = 42\n}", + ); +} + +#[test] +fn test_fmt_let() { + check( + "fn main(): void { let x = 0 }", + "fn main(): void {\n let x = 0\n}", + ); +} + +#[test] +fn test_fmt_let_typed() { + check( + "fn main(): void { let x: i32 = 0 }", + "fn main(): void {\n let x: i32 = 0\n}", + ); +} + +#[test] +fn test_fmt_string_literal() { + check(r#"const s = "hello""#, r#"const s = "hello""#); +} + +#[test] +fn test_fmt_float() { + check("const f = 3.14", "const f = 3.14"); +} + +#[test] +fn test_fmt_bool() { + check("const b = true", "const b = true"); +} + +#[test] +fn test_fmt_return_void() { + check( + "fn main(): void { return }", + "fn main(): void {\n return\n}", + ); +} + +#[test] +fn test_fmt_break_continue() { + let src = "fn main(): void { while true { break } }"; + let out = fmt(src); + assert!(out.contains("break"), "got: {}", out); + idempotent(&out); +} + +#[test] +fn test_fmt_array_literal() { + check( + "fn main(): void { const a: i32[3] = [1, 2, 3] }", + "fn main(): void {\n const a: i32[3] = [1, 2, 3]\n}", + ); +} + +#[test] +fn test_fmt_array_index() { + check( + "fn main(): void { const x = a[0] }", + "fn main(): void {\n const x = a[0]\n}", + ); +} + +#[test] +fn test_fmt_optional_type() { + check( + "fn f(x: ?i32): ?string { return null }", + "fn f(x: ?i32): ?string {\n return null\n}", + ); +} + +#[test] +fn test_fmt_error_union_type() { + check( + "fn f(): !string { return \"ok\" }", + "fn f(): !string {\n return \"ok\"\n}", + ); +} + +#[test] +fn test_fmt_member_access() { + check("const x = obj.field", "const x = obj.field"); +} + +#[test] +fn test_fmt_call() { + check( + "fn main(): void { foo(1, 2) }", + "fn main(): void {\n foo(1, 2)\n}", + ); +} + +#[test] +fn test_fmt_propagate() { + check( + "fn main(): void { const x = foo()? }", + "fn main(): void {\n const x = foo()?\n}", + ); +} + +#[test] +fn test_fmt_unary_neg() { + check("const x = -1", "const x = -1"); +} + +#[test] +fn test_fmt_unary_not() { + check("const x = !true", "const x = !true"); +} + +#[test] +fn test_fmt_logical_operators() { + check( + "fn main(): void { const x = a and b }", + "fn main(): void {\n const x = a and b\n}", + ); + check( + "fn main(): void { const x = a or b }", + "fn main(): void {\n const x = a or b\n}", + ); +} + +#[test] +fn test_fmt_export_fn() { + let out = fmt("export fn add(a: i32, b: i32): i32 { return a + b }"); + assert!(out.contains("export fn add"), "got: {}", out); + idempotent(&out); +} + +#[test] +fn test_fmt_export_const() { + check("export const PI = 3.14", "export const PI = 3.14"); +} + +#[test] +fn test_fmt_import() { + check( + r#"const std = import("std")"#, + r#"const std = import("std")"#, + ); +} + +#[test] +fn test_fmt_switch_expr() { + let src = r#"const s = switch x { + 1 => "one", + else => "other", +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_nested_if_expr() { + let src = r#"fn sign(x: i32): string { + return if x > 0 then "positive" else if x < 0 then "negative" else "zero" +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_catch() { + let src = r#"fn main(): void { + const data = readFile("foo")? +}"#; + let out = fmt(src); + assert!( + out.contains("const data = readFile(\"foo\")?"), + "got: {}", + out + ); + idempotent(&out); +} + +#[test] +fn test_fmt_fn() { + let src = r#"fn add(a: i32, b: i32): i32 { + return a + b +}"#; + idempotent(src); + assert!(fmt(src).contains("fn add(a: i32, b: i32): i32 {")); +} + +#[test] +fn test_fmt_if_stmt() { + let src = r#"fn main(): void { + if x > 0 { + const y = 1 + } +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_if_else_stmt() { + let src = r#"fn main(): void { + if x > 0 { + const y = 1 + } else { + const y = 2 + } +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_if_expr() { + let out = fmt("fn abs(x: i32): i32 { return if x < 0 then -x else x }"); + assert!(out.contains("return if x < 0 then -x else x")); + idempotent(&out); +} + +#[test] +fn test_fmt_while() { + let src = r#"fn main(): void { + let i = 0 + while i < 10 { + i = i + 1 + } +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_binop_precedence() { + // lower-precedence subexpr should be parenthesized + let out = fmt("const x = (a + b) * c"); + assert!(out.contains("(a + b) * c"), "got: {}", out); + idempotent(&out); +} + +#[test] +fn test_fmt_blank_lines_between_fns() { + let src = "fn a(): void {}\n\nfn b(): void {}"; + let out = fmt(src); + assert!( + out.contains("\n\n"), + "expected blank line between fns, got:\n{}", + out + ); + idempotent(&out); +} + +#[test] +fn test_fmt_struct() { + let src = r#"const Point = struct { + x: f64, + y: f64, +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_enum() { + let src = r#"const Dir = enum { + North, + South, +}"#; + idempotent(src); +} + +#[test] +fn test_fmt_modulo() { + let out = fmt("fn rem(a: i32, b: i32): i32 { return a % b }"); + assert!(out.contains("a % b"), "got: {}", out); + idempotent(&out); +} + +#[test] +fn test_fmt_fixtures_idempotent() { + let fixtures = std::fs::read_dir("src/tests/fixtures") + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "zy").unwrap_or(false)); + + for entry in fixtures { + let src = std::fs::read_to_string(entry.path()).unwrap(); + idempotent(&src); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 8173faf..7ef6c61 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -3,6 +3,7 @@ use crate::parser::parse; use crate::typechecker::check; mod codegen; +mod fmt; mod lexer; mod snapshots; mod stdlib; @@ -10,7 +11,7 @@ mod typechecker; pub(super) fn run(src: &str) -> Vec { let tokens = tokenize(src); - let (ast, _) = parse(tokens); + let (ast, _, _) = parse(tokens); check(&ast, std::path::Path::new(".")) } @@ -31,7 +32,7 @@ pub(super) fn err(src: &str, expected: &str) { pub(super) fn compile(src: &str) -> String { let tokens = tokenize(src); - let (ast, _) = parse(tokens); + let (ast, _, _) = parse(tokens); crate::codegen::generate(&ast) } diff --git a/src/typechecker.rs b/src/typechecker.rs index aa31f22..05fc427 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -117,7 +117,7 @@ impl TypeChecker { Err(_) => return HashMap::new(), }; let tokens = lexer::tokenize(&source); - let (ast, _) = parser::parse(tokens); + let (ast, _, _) = parser::parse(tokens); let mut exports = HashMap::new(); for item in &ast { if let TopLevel::FnDecl(f) = item {