From 53941e537ad04f76dabe6b2909d3a0369bc5668c Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Sun, 22 Mar 2026 11:48:17 +0100 Subject: [PATCH 1/8] chore: Bring back tests in concerned files Part of #2 Some cleanup before continue to work on features *No functional changes expected* --- src/cli/build.rs | 23 ++++++++ src/cli/mod.rs | 48 ++++++++++++++- src/cli/new.rs | 54 +++++++++++++++++ src/cli/test.rs | 141 --------------------------------------------- src/errors/mod.rs | 25 +++++++- src/errors/test.rs | 35 ----------- 6 files changed, 145 insertions(+), 181 deletions(-) delete mode 100644 src/cli/test.rs delete mode 100644 src/errors/test.rs diff --git a/src/cli/build.rs b/src/cli/build.rs index 45d090a..6b49966 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -34,3 +34,26 @@ pub struct CommandBuild { pub fn run(_cli: &Cli, _command: &CommandBuild) -> Result<()> { Err(NotImplementedError::new("build command").into()) } + +#[cfg(test)] +mod test { + use crate::cli::build::CommandBuild; + use crate::cli::{Cli, Command, build}; + use pretty_assertions::assert_eq; + + #[test] + fn it_returns_err() { + let result = build::run( + &Cli { + config: "".to_string(), + command: Command::Build(CommandBuild { out_dir: None }), + }, + &CommandBuild { out_dir: None }, + ); + assert_eq!(true, result.is_err()); + assert_eq!( + "build command is not yet implemented", + result.unwrap_err().to_string() + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9b6d67c..06fb498 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -17,8 +17,6 @@ mod build; mod new; -#[cfg(test)] -mod test; use crate::errors::Result; use clap::builder::Styles; @@ -79,3 +77,49 @@ pub fn run(cli: Cli) -> Result<()> { Command::Build(b) => build::run(&cli, b), } } + +#[cfg(test)] +mod test { + use crate::cli::{Command, parse}; + use pretty_assertions::assert_eq; + + fn make_args(args: Vec<&str>) -> Vec { + args.iter().map(|&arg| arg.parse().unwrap()).collect() + } + + #[test] + fn it_parses_command_new_args() { + let result = parse(make_args(vec!["fil", "new", "--name", "foo"])); + match result.command { + Command::New(n) => assert_eq!("foo", n.name.unwrap()), + Command::Build(_) => panic!("Should have parsed command new"), + } + } + + #[test] + fn it_parses_command_new_args_default() { + let result = parse(make_args(vec!["fil", "new"])); + match result.command { + Command::New(n) => assert_eq!(None, n.name), + Command::Build(_) => panic!("Should have parsed command new"), + } + } + + #[test] + fn it_parses_command_build_args() { + let result = parse(make_args(vec!["fil", "build", "-o", "dist"])); + match result.command { + Command::New(_) => panic!("Should have parsed command build"), + Command::Build(b) => assert_eq!("dist", b.out_dir.unwrap()), + } + } + + #[test] + fn it_parses_command_build_args_default() { + let result = parse(make_args(vec!["fil", "build"])); + match result.command { + Command::New(_) => panic!("Should have parsed command build"), + Command::Build(b) => assert_eq!("build", b.out_dir.unwrap()), + } + } +} diff --git a/src/cli/new.rs b/src/cli/new.rs index d2e8397..2a01c4d 100644 --- a/src/cli/new.rs +++ b/src/cli/new.rs @@ -174,8 +174,62 @@ fn sanitize_name(name: &String) -> String { #[cfg(test)] mod test { + use crate::cli::new::CommandNew; use crate::cli::new::sanitize_name; + use crate::cli::{Cli, Command, new}; use pretty_assertions::assert_eq; + use std::io::Read; + use vfs::{MemoryFS, VfsPath}; + + fn random_name() -> String { + format!("project_{}", rand::random::()) + } + + fn run_new(path: &VfsPath) { + let result = new::run( + &Cli { + config: "".to_string(), + command: Command::New(CommandNew { + name: Some(path.as_str().to_string()), + git: Some(false), + }), + }, + &CommandNew { + name: Some(path.as_str().to_string()), + git: Some(false), + }, + &path, + ); + assert_eq!(true, result.is_ok()); + } + + #[test] + fn it_creates_project_dir() { + let root = VfsPath::new(MemoryFS::new()); + let name = random_name(); + let path = root.join(format!("/tmp/{}", name)).unwrap(); + run_new(&path); + + assert_eq!(true, path.is_dir().unwrap()); + let content: Vec<_> = path.read_dir().unwrap().collect(); + assert_eq!(vec![path.join("package.toml").unwrap()], content); + let mut package_content = String::new(); + println!("{:?}", root); + path.join("package.toml") + .unwrap() + .open_file() + .unwrap() + .read_to_string(&mut package_content) + .unwrap(); + assert_eq!( + format!( + "[package] +name = {}", + name + ), + package_content + ); + } #[test] fn test_sanitize_name() { diff --git a/src/cli/test.rs b/src/cli/test.rs deleted file mode 100644 index c9a163a..0000000 --- a/src/cli/test.rs +++ /dev/null @@ -1,141 +0,0 @@ -// fil -// Copyright (C) 2026 - Present fil contributors -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, write to the Free Software Foundation, Inc., -// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -mod cli { - use crate::cli::{Command, parse}; - use pretty_assertions::assert_eq; - - fn make_args(args: Vec<&str>) -> Vec { - args.iter().map(|&arg| arg.parse().unwrap()).collect() - } - - #[test] - fn it_parses_command_new_args() { - let result = parse(make_args(vec!["fil", "new", "--name", "foo"])); - match result.command { - Command::New(n) => assert_eq!("foo", n.name.unwrap()), - Command::Build(_) => panic!("Should have parsed command new"), - } - } - - #[test] - fn it_parses_command_new_args_default() { - let result = parse(make_args(vec!["fil", "new"])); - match result.command { - Command::New(n) => assert_eq!(None, n.name), - Command::Build(_) => panic!("Should have parsed command new"), - } - } - - #[test] - fn it_parses_command_build_args() { - let result = parse(make_args(vec!["fil", "build", "-o", "dist"])); - match result.command { - Command::New(_) => panic!("Should have parsed command build"), - Command::Build(b) => assert_eq!("dist", b.out_dir.unwrap()), - } - } - - #[test] - fn it_parses_command_build_args_default() { - let result = parse(make_args(vec!["fil", "build"])); - match result.command { - Command::New(_) => panic!("Should have parsed command build"), - Command::Build(b) => assert_eq!("build", b.out_dir.unwrap()), - } - } -} - -mod new { - use crate::cli::new::CommandNew; - use crate::cli::{Cli, Command, new}; - use pretty_assertions::assert_eq; - use std::io::Read; - use vfs::{MemoryFS, VfsPath}; - - fn random_name() -> String { - format!("project_{}", rand::random::()) - } - - fn run_new(path: &VfsPath) { - let result = new::run( - &Cli { - config: "".to_string(), - command: Command::New(CommandNew { - name: Some(path.as_str().to_string()), - git: Some(false), - }), - }, - &CommandNew { - name: Some(path.as_str().to_string()), - git: Some(false), - }, - &path, - ); - assert_eq!(true, result.is_ok()); - } - - #[test] - fn it_creates_project_dir() { - let root = VfsPath::new(MemoryFS::new()); - let name = random_name(); - let path = root.join(format!("/tmp/{}", name)).unwrap(); - run_new(&path); - - assert_eq!(true, path.is_dir().unwrap()); - let content: Vec<_> = path.read_dir().unwrap().collect(); - assert_eq!(vec![path.join("package.toml").unwrap()], content); - let mut package_content = String::new(); - println!("{:?}", root); - path.join("package.toml") - .unwrap() - .open_file() - .unwrap() - .read_to_string(&mut package_content) - .unwrap(); - assert_eq!( - format!( - "[package] -name = {}", - name - ), - package_content - ); - } -} - -mod build { - use crate::cli::build::CommandBuild; - use crate::cli::{Cli, Command, build}; - use pretty_assertions::assert_eq; - - #[test] - fn it_returns_err() { - let result = build::run( - &Cli { - config: "".to_string(), - command: Command::Build(CommandBuild { out_dir: None }), - }, - &CommandBuild { out_dir: None }, - ); - assert_eq!(true, result.is_err()); - assert_eq!( - "build command is not yet implemented", - result.unwrap_err().to_string() - ); - } -} diff --git a/src/errors/mod.rs b/src/errors/mod.rs index 2f6e265..2a0708d 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -15,9 +15,6 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -#[cfg(test)] -mod test; - use std::error; use std::fmt; @@ -64,3 +61,25 @@ impl fmt::Display for NotImplementedError { } impl error::Error for NotImplementedError {} + +#[cfg(test)] +mod test { + use crate::errors::{GenericError, NotImplementedError}; + use pretty_assertions::assert_eq; + + #[test] + fn it_stores_message() { + assert_eq!( + "My error message", + GenericError::new("My error message").to_string() + ); + } + + #[test] + fn it_tells_feature_is_not_implemented() { + assert_eq!( + "foo is not yet implemented", + NotImplementedError::new("foo").to_string() + ); + } +} diff --git a/src/errors/test.rs b/src/errors/test.rs deleted file mode 100644 index 772d820..0000000 --- a/src/errors/test.rs +++ /dev/null @@ -1,35 +0,0 @@ -// fil -// Copyright (C) 2026 - Present fil contributors -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, write to the Free Software Foundation, Inc., -// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -use crate::errors::{GenericError, NotImplementedError}; -use pretty_assertions::assert_eq; - -#[test] -fn it_stores_message() { - assert_eq!( - "My error message", - GenericError::new("My error message").to_string() - ); -} - -#[test] -fn it_tells_feature_is_not_implemented() { - assert_eq!( - "foo is not yet implemented", - NotImplementedError::new("foo").to_string() - ); -} From f5f46d001173ede2dab554322cdac9c7ea1885c9 Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Mon, 23 Mar 2026 21:01:38 +0100 Subject: [PATCH 2/8] chore: Extract create_project in its own module Part of #2 End of cleanup: extracted project creation logic of new command in its own module. It avoid having too large files *No functional changes expected* --- src/cli/new.rs | 149 ++++++++++++------------------------------------- src/main.rs | 1 + src/new/mod.rs | 126 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 114 deletions(-) create mode 100644 src/new/mod.rs diff --git a/src/cli/new.rs b/src/cli/new.rs index 2a01c4d..b74bbdf 100644 --- a/src/cli/new.rs +++ b/src/cli/new.rs @@ -16,9 +16,9 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. use crate::cli::Cli; -use crate::errors::{GenericError, Result}; +use crate::errors::Result; +use crate::new::create_project; use clap::Args; -use std::process; #[derive(Args)] pub struct CommandNew { @@ -39,40 +39,12 @@ pub fn run(_cli: &Cli, command: &CommandNew, filesystem: &vfs::path::VfsPath) -> cliclack::log::success("Let's create an awesome project 🤘")?; - let name = if let Some(given_name) = &command.name { - cliclack::log::step(format!( - "Project will be created with name: {}", - console::style(given_name).bold() - ))?; - given_name - } else { - &cliclack::input("How do you want to call it?") - .placeholder("blazing-fast-forward") - .validate(|input: &String| { - if input.is_empty() { - Err("Please enter a valid name") - } else { - Ok(()) - } - }) - .interact()? - }; - - let git = if let Some(given_git) = &command.git { - if *given_git { - cliclack::log::step(format!( - "{} will be called", - console::style("git init").bold() - ))?; - } - given_git - } else { - &cliclack::confirm("Do you want to init git?").interact()? - }; + let name = get_name(&command)?; + let git = get_git(&command)?; let spinner = cliclack::spinner(); spinner.start("Initializing the project"); - create_project(name, git, &filesystem).and_then(|_| { + create_project(&name, &git, &filesystem).and_then(|_| { spinner.stop("Done!"); cliclack::note( @@ -99,83 +71,44 @@ pub fn run(_cli: &Cli, command: &CommandNew, filesystem: &vfs::path::VfsPath) -> }) } -fn create_project(name: &String, git: &bool, filesystem: &vfs::path::VfsPath) -> Result<()> { - let name = sanitize_name(name); - let path = if name.starts_with("/") { - filesystem.root().join(&name)? - } else { - filesystem - .join(std::env::current_dir()?.to_str().unwrap())? - .join(&name)? - }; - let name = if name.contains("/") { - path.filename() +fn get_name(command: &&CommandNew) -> Result { + if let Some(given_name) = &command.name { + cliclack::log::step(format!( + "Project will be created with name: {}", + console::style(given_name).bold() + ))?; + Ok(given_name.clone()) } else { - name.clone() - }; - - check_path(&path) - .and_then(|_| { - path.create_dir_all() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - .and_then(|_| { - path.join("package.toml") - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and_then(|file| { - file.create_file() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - .and_then(|mut file_stream| { - write!(file_stream, "[package]\nname = {}", name) - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - }) - .and_then(|_| { - if *git { - process::Command::new("git") - .args(vec!["init", path.as_str()]) - .output() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and(Ok(())) - } else { - Ok(()) - } - }) -} - -fn check_path(path: &vfs::path::VfsPath) -> Result<()> { - path.exists() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and_then(|exists| { - if exists { - path.read_dir() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and_then(|read_dir| { - if read_dir.count() > 0 { - Err(GenericError::new( - format!("Directory {} is not empty", path.as_str()).as_str(), - ) - .into()) - } else { - Ok(()) - } - }) - } else { - Ok(()) - } - }) + Ok(cliclack::input("How do you want to call it?") + .placeholder("blazing-fast-forward") + .validate(|input: &String| { + if input.is_empty() { + Err("Please enter a valid name") + } else { + Ok(()) + } + }) + .interact()?) + } } -fn sanitize_name(name: &String) -> String { - let parts: Vec<_> = name.trim().split_whitespace().collect(); - parts.join("-").replace("*", "-") +fn get_git(command: &CommandNew) -> Result { + if let Some(given_git) = command.git { + if given_git { + cliclack::log::step(format!( + "{} will be called", + console::style("git init").bold() + ))?; + } + Ok(given_git.clone()) + } else { + Ok(cliclack::confirm("Do you want to init git?").interact()?) + } } #[cfg(test)] mod test { use crate::cli::new::CommandNew; - use crate::cli::new::sanitize_name; use crate::cli::{Cli, Command, new}; use pretty_assertions::assert_eq; use std::io::Read; @@ -230,16 +163,4 @@ name = {}", package_content ); } - - #[test] - fn test_sanitize_name() { - assert_eq!("foo", sanitize_name(&"foo".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo bar".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo-bar".to_string())); - assert_eq!("foo_bar", sanitize_name(&"foo_bar".to_string())); - assert_eq!("foo", sanitize_name(&" foo ".to_string())); - assert_eq!("foo-bar", sanitize_name(&" foo bar ".to_string())); - assert_eq!("foo&bar", sanitize_name(&"foo&bar".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo*bar".to_string())); - } } diff --git a/src/main.rs b/src/main.rs index 6b598ee..813953d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use std::env; mod cli; mod errors; +mod new; fn main() -> Result<(), String> { cli::run(cli::parse(env::args().collect())).map_err(|err| err.to_string()) diff --git a/src/new/mod.rs b/src/new/mod.rs new file mode 100644 index 0000000..c3bc0d1 --- /dev/null +++ b/src/new/mod.rs @@ -0,0 +1,126 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use crate::errors::GenericError; +use std::process; + +pub fn create_project( + name: &String, + git: &bool, + filesystem: &vfs::path::VfsPath, +) -> crate::errors::Result<()> { + let name = sanitize_name(name); + let path = if name.starts_with("/") { + filesystem.root().join(&name)? + } else { + filesystem + .join(std::env::current_dir()?.to_str().unwrap())? + .join(&name)? + }; + let name = if name.contains("/") { + path.filename() + } else { + name.clone() + }; + + check_path(&path) + .and_then(|_| { + path.create_dir_all() + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + }) + .and_then(|_| { + path.join("package.toml") + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .and_then(|file| { + file.create_file() + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + }) + .and_then(|mut file_stream| { + write!(file_stream, "[package]\nname = {}", name) + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + }) + }) + .and_then(|_| { + if *git { + process::Command::new("git") + .args(vec!["init", path.as_str()]) + .output() + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .and(Ok(())) + } else { + Ok(()) + } + }) +} + +fn check_path(path: &vfs::path::VfsPath) -> crate::errors::Result<()> { + path.exists() + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .and_then(|exists| { + if exists { + path.read_dir() + .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .and_then(|read_dir| { + if read_dir.count() > 0 { + Err(GenericError::new( + format!("Directory {} is not empty", path.as_str()).as_str(), + ) + .into()) + } else { + Ok(()) + } + }) + } else { + Ok(()) + } + }) +} + +fn sanitize_name(name: &String) -> String { + let parts: Vec<_> = name.trim().split_whitespace().collect(); + parts.join("-").replace("*", "-") +} + +#[cfg(test)] +mod test { + use crate::new::{check_path, sanitize_name}; + use pretty_assertions::assert_eq; + use vfs::{MemoryFS, VfsPath}; + + #[test] + fn test_check_path() { + let root = VfsPath::new(MemoryFS::new()); + root.join("foo").unwrap().create_dir().unwrap(); + root.join("bar").unwrap().create_dir().unwrap(); + root.join("bar/lorem").unwrap().create_file().unwrap(); + + assert_eq!(true, check_path(&root.join("foo").unwrap()).is_ok()); + assert_eq!(true, check_path(&root.join("bar").unwrap()).is_err()); + } + + #[test] + fn test_sanitize_name() { + assert_eq!("foo", sanitize_name(&"foo".to_string())); + assert_eq!("foo-bar", sanitize_name(&"foo bar".to_string())); + assert_eq!("foo-bar", sanitize_name(&"foo-bar".to_string())); + assert_eq!("foo_bar", sanitize_name(&"foo_bar".to_string())); + assert_eq!("foo", sanitize_name(&" foo ".to_string())); + assert_eq!("foo-bar", sanitize_name(&" foo bar ".to_string())); + assert_eq!("foo&bar", sanitize_name(&"foo&bar".to_string())); + assert_eq!("foo-bar", sanitize_name(&"foo*bar".to_string())); + } +} From 0bdecc4e87346d4a5b311509a4f30f11ce7a0ab9 Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Wed, 25 Mar 2026 21:08:08 +0100 Subject: [PATCH 3/8] refactor: Replace mod errors by fault Part of #2 Instead of having multiple Error type, I choose to have a single one named Fault which holds a message and an Error. This way, each time something may return an error, it will return a fault with the corresponding message. I got the idea to handle errors this way because: - it will be the way in fil - I use it in php at my work *No functional changes expected* --- src/cli/build.rs | 8 +-- src/cli/mod.rs | 4 +- src/cli/new.rs | 102 +++++++++++++++++++--------------- src/errors/mod.rs | 85 ---------------------------- src/fault/mod.rs | 101 +++++++++++++++++++++++++++++++++ src/main.rs | 2 +- src/new/mod.rs | 138 +++++++++++++++++++++++++++++----------------- 7 files changed, 251 insertions(+), 189 deletions(-) delete mode 100644 src/errors/mod.rs create mode 100644 src/fault/mod.rs diff --git a/src/cli/build.rs b/src/cli/build.rs index 6b49966..1270e2d 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -16,8 +16,8 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. use crate::cli::Cli; -use crate::errors::NotImplementedError; -use crate::errors::Result; +use crate::fault; +use crate::fault::Fault; use clap::Args; #[derive(Args)] @@ -31,8 +31,8 @@ pub struct CommandBuild { pub out_dir: Option, } -pub fn run(_cli: &Cli, _command: &CommandBuild) -> Result<()> { - Err(NotImplementedError::new("build command").into()) +pub fn run(_cli: &Cli, _command: &CommandBuild) -> fault::Result<()> { + Err(Fault::from_message("build command is not yet implemented")) } #[cfg(test)] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 06fb498..9ff0729 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,7 +18,7 @@ mod build; mod new; -use crate::errors::Result; +use crate::fault; use clap::builder::Styles; use clap::builder::styling::{AnsiColor, Style}; use clap::{Args, FromArgMatches, Parser, Subcommand, crate_name}; @@ -71,7 +71,7 @@ pub fn parse(args: Vec) -> Cli { .unwrap() } -pub fn run(cli: Cli) -> Result<()> { +pub fn run(cli: Cli) -> fault::Result<()> { match &cli.command { Command::New(n) => new::run(&cli, n, &vfs::PhysicalFS::new("/").into()), Command::Build(b) => build::run(&cli, b), diff --git a/src/cli/new.rs b/src/cli/new.rs index b74bbdf..008bd79 100644 --- a/src/cli/new.rs +++ b/src/cli/new.rs @@ -16,9 +16,11 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. use crate::cli::Cli; -use crate::errors::Result; +use crate::fault; +use crate::fault::Fault; use crate::new::create_project; use clap::Args; +use cliclack::ProgressBar; #[derive(Args)] pub struct CommandNew { @@ -34,52 +36,58 @@ pub struct CommandNew { pub git: Option, } -pub fn run(_cli: &Cli, command: &CommandNew, filesystem: &vfs::path::VfsPath) -> Result<()> { - cliclack::intro(console::style(" New project ").on_green().black())?; - - cliclack::log::success("Let's create an awesome project 🤘")?; - - let name = get_name(&command)?; - let git = get_git(&command)?; - - let spinner = cliclack::spinner(); - spinner.start("Initializing the project"); - create_project(&name, &git, &filesystem).and_then(|_| { - spinner.stop("Done!"); - - cliclack::note( - "Project created! 🚀 ", - format!( - "{}\n{}{}\n", - console::style("Next steps:").bold(), - if name == "." { - String::new() - } else { - console::style(format!("cd {name}\n")).dim().to_string() - }, - "Enjoy!" - ), - )?; - - cliclack::outro(format!( - "Got problems? {}", - console::style("https://github.com/Gashmob/fil/issues/new/choose") - .yellow() - .underlined() - ))?; - Ok(()) - }) +pub fn run(_cli: &Cli, command: &CommandNew, filesystem: &vfs::path::VfsPath) -> fault::Result<()> { + cliclack::intro(console::style(" New project ").on_green().black()) + .and_then(|_| cliclack::log::success("Let's create an awesome project 🤘")) + .map_err(|error| Fault::from_error(Box::from(error))) + .map(|_| { + let spinner = cliclack::spinner(); + spinner.start("Initializing the project"); + spinner + }) + .and_then(|spinner| get_name(&command).map(|name| (spinner, name))) + .and_then(|(spinner, name)| get_git(&command).map(|git| (spinner, name, git))) + .and_then(|(spinner, name, git)| { + create_project(&name, &git, &filesystem).map(|_| (spinner, name)) + }) + .and_then(|(spinner, name): (ProgressBar, String)| { + spinner.stop("Done!"); + + cliclack::note( + "Project created! 🚀 ", + format!( + "{}\n{}{}\n", + console::style("Next steps:").bold(), + if name == "." { + String::new() + } else { + console::style(format!("cd {name}\n")).dim().to_string() + }, + "Enjoy!" + ), + ) + .map_err(|error| Fault::from_error(Box::from(error))) + }) + .and_then(|_| { + cliclack::outro(format!( + "Got problems? {}", + console::style("https://github.com/Gashmob/fil/issues/new/choose") + .yellow() + .underlined() + )) + .map_err(|error| Fault::from_error(Box::from(error))) + }) } -fn get_name(command: &&CommandNew) -> Result { +fn get_name(command: &CommandNew) -> fault::Result { if let Some(given_name) = &command.name { cliclack::log::step(format!( "Project will be created with name: {}", console::style(given_name).bold() - ))?; - Ok(given_name.clone()) + )) + .map(|_| given_name.clone()) } else { - Ok(cliclack::input("How do you want to call it?") + cliclack::input("How do you want to call it?") .placeholder("blazing-fast-forward") .validate(|input: &String| { if input.is_empty() { @@ -88,22 +96,26 @@ fn get_name(command: &&CommandNew) -> Result { Ok(()) } }) - .interact()?) + .interact() } + .map_err(|error| Fault::from_error(Box::from(error))) } -fn get_git(command: &CommandNew) -> Result { +fn get_git(command: &CommandNew) -> fault::Result { if let Some(given_git) = command.git { if given_git { cliclack::log::step(format!( "{} will be called", console::style("git init").bold() - ))?; + )) + } else { + Ok(()) } - Ok(given_git.clone()) + .map(|_| given_git.clone()) } else { - Ok(cliclack::confirm("Do you want to init git?").interact()?) + cliclack::confirm("Do you want to init git?").interact() } + .map_err(|error| Fault::from_error(Box::from(error))) } #[cfg(test)] diff --git a/src/errors/mod.rs b/src/errors/mod.rs deleted file mode 100644 index 2a0708d..0000000 --- a/src/errors/mod.rs +++ /dev/null @@ -1,85 +0,0 @@ -// fil -// Copyright (C) 2026 - Present fil contributors -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, write to the Free Software Foundation, Inc., -// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -use std::error; -use std::fmt; - -pub type Result = std::result::Result>; - -#[derive(Debug, Clone)] -pub struct GenericError { - message: String, -} - -impl GenericError { - pub fn new(message: &str) -> Self { - Self { - message: message.to_string(), - } - } -} - -impl fmt::Display for GenericError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl error::Error for GenericError {} - -#[derive(Debug, Clone)] -pub struct NotImplementedError { - feature_name: String, -} - -impl NotImplementedError { - pub fn new(feature_name: &str) -> Self { - Self { - feature_name: feature_name.to_string(), - } - } -} - -impl fmt::Display for NotImplementedError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} is not yet implemented", self.feature_name) - } -} - -impl error::Error for NotImplementedError {} - -#[cfg(test)] -mod test { - use crate::errors::{GenericError, NotImplementedError}; - use pretty_assertions::assert_eq; - - #[test] - fn it_stores_message() { - assert_eq!( - "My error message", - GenericError::new("My error message").to_string() - ); - } - - #[test] - fn it_tells_feature_is_not_implemented() { - assert_eq!( - "foo is not yet implemented", - NotImplementedError::new("foo").to_string() - ); - } -} diff --git a/src/fault/mod.rs b/src/fault/mod.rs new file mode 100644 index 0000000..46c1842 --- /dev/null +++ b/src/fault/mod.rs @@ -0,0 +1,101 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use std::fmt::Formatter; +use std::{error, fmt}; + +#[derive(Debug)] +pub struct Fault { + message: Option, + error: Option>, +} + +impl Fault { + pub fn from_message(message: &str) -> Self { + Self { + message: Some(message.to_string()), + error: None, + } + } + + pub fn from_error(error: Box) -> Self { + Self { + message: Some(error.to_string()), + error: Some(error), + } + } + + pub fn from_error_with_message(error: Box, message: &str) -> Self { + Self { + message: Some(message.to_string()), + error: Some(error), + } + } +} + +impl fmt::Display for Fault { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match (&self.message, &self.error) { + (Some(message), None) => write!(f, "{message}"), + (Some(message), Some(error)) => write!(f, "{message}: {:?}", error.to_string()), + (None, Some(error)) => write!(f, "{}", error.to_string()), + _ => write!(f, "Got an unknown fault, please open an issue"), + } + } +} + +pub type Result = std::result::Result; + +#[cfg(test)] +mod test { + use crate::fault::Fault; + use pretty_assertions::assert_eq; + use std::fmt::Formatter; + use std::{error, fmt}; + + #[derive(Debug)] + struct ErrorStub {} + + impl fmt::Display for ErrorStub { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "Some stub error") + } + } + + impl error::Error for ErrorStub {} + + #[test] + fn test_fault_from_message() { + assert_eq!("Oh snap!", Fault::from_message("Oh snap!").to_string()) + } + + #[test] + fn test_fault_from_error() { + assert_eq!( + "Some stub error: \"Some stub error\"", + Fault::from_error(Box::new(ErrorStub {})).to_string() + ) + } + + #[test] + fn test_fault_from_error_with_message() { + assert_eq!( + "Oopsie: \"Some stub error\"", + Fault::from_error_with_message(Box::new(ErrorStub {}), "Oopsie").to_string() + ) + } +} diff --git a/src/main.rs b/src/main.rs index 813953d..1cb08c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use std::env; mod cli; -mod errors; +mod fault; mod new; fn main() -> Result<(), String> { diff --git a/src/new/mod.rs b/src/new/mod.rs index c3bc0d1..a3093e1 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -15,71 +15,74 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -use crate::errors::GenericError; +use crate::fault; +use crate::fault::Fault; use std::process; +use vfs::VfsPath; -pub fn create_project( - name: &String, - git: &bool, - filesystem: &vfs::path::VfsPath, -) -> crate::errors::Result<()> { +pub fn create_project(name: &String, git: &bool, filesystem: &VfsPath) -> fault::Result<()> { let name = sanitize_name(name); - let path = if name.starts_with("/") { - filesystem.root().join(&name)? - } else { + get_path(&filesystem, &name) + .map(|path| (path.clone(), get_name(&name, &path))) + .and_then(|(path, name)| check_path_is_empty(&path).map(|_| (path, name))) + .and_then(|(path, name)| { + path.create_dir_all() + .map_err(|error| Fault::from_error(Box::from(error))) + .map(|_| (path, name)) + }) + .and_then(|(path, name)| init_project(&path, &name).map(|_| path)) + .and_then(|path| init_git(&git, &path)) +} + +fn get_path(filesystem: &VfsPath, name: &String) -> fault::Result { + if name.starts_with("/") { filesystem - .join(std::env::current_dir()?.to_str().unwrap())? - .join(&name)? - }; - let name = if name.contains("/") { + .root() + .join(&name) + .map_err(|error| Fault::from_error(Box::from(error))) + } else { + std::env::current_dir() + .map_err(|error| Fault::from_error(Box::from(error))) + .and_then(|current_dir_path| { + if let Some(current_dir) = current_dir_path.to_str() { + Ok(String::from(current_dir)) + } else { + Err(Fault::from_message("")) + } + }) + .and_then(|current_dir| { + filesystem + .join(current_dir) + .map_err(|error| Fault::from_error(Box::from(error))) + }) + .and_then(|_| { + filesystem + .join(&name) + .map_err(|error| Fault::from_error(Box::from(error))) + }) + } +} + +fn get_name(name: &String, path: &VfsPath) -> String { + if name.contains("/") { path.filename() } else { name.clone() - }; - - check_path(&path) - .and_then(|_| { - path.create_dir_all() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - .and_then(|_| { - path.join("package.toml") - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and_then(|file| { - file.create_file() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - .and_then(|mut file_stream| { - write!(file_stream, "[package]\nname = {}", name) - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - }) - }) - .and_then(|_| { - if *git { - process::Command::new("git") - .args(vec!["init", path.as_str()]) - .output() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) - .and(Ok(())) - } else { - Ok(()) - } - }) + } } -fn check_path(path: &vfs::path::VfsPath) -> crate::errors::Result<()> { +fn check_path_is_empty(path: &VfsPath) -> fault::Result<()> { path.exists() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .map_err(|error| Fault::from_error(Box::from(error))) .and_then(|exists| { if exists { path.read_dir() - .map_err(|err| GenericError::new(err.to_string().as_str()).into()) + .map_err(|error| Fault::from_error(Box::from(error))) .and_then(|read_dir| { if read_dir.count() > 0 { - Err(GenericError::new( + Err(Fault::from_message( format!("Directory {} is not empty", path.as_str()).as_str(), - ) - .into()) + )) } else { Ok(()) } @@ -90,6 +93,31 @@ fn check_path(path: &vfs::path::VfsPath) -> crate::errors::Result<()> { }) } +fn init_project(path: &VfsPath, name: &String) -> fault::Result<()> { + path.join("package.toml") + .map_err(|error| Fault::from_error(Box::from(error))) + .and_then(|file| { + file.create_file() + .map_err(|error| Fault::from_error(Box::from(error))) + }) + .and_then(|mut file_stream| { + write!(file_stream, "[package]\nname = {}", name) + .map_err(|error| Fault::from_error(Box::from(error))) + }) +} + +fn init_git(git: &bool, path: &VfsPath) -> fault::Result<()> { + if *git { + process::Command::new("git") + .args(vec!["init", path.as_str()]) + .output() + .map_err(|error| Fault::from_error(Box::from(error))) + .and(Ok(())) + } else { + Ok(()) + } +} + fn sanitize_name(name: &String) -> String { let parts: Vec<_> = name.trim().split_whitespace().collect(); parts.join("-").replace("*", "-") @@ -97,7 +125,7 @@ fn sanitize_name(name: &String) -> String { #[cfg(test)] mod test { - use crate::new::{check_path, sanitize_name}; + use crate::new::{check_path_is_empty, sanitize_name}; use pretty_assertions::assert_eq; use vfs::{MemoryFS, VfsPath}; @@ -108,8 +136,14 @@ mod test { root.join("bar").unwrap().create_dir().unwrap(); root.join("bar/lorem").unwrap().create_file().unwrap(); - assert_eq!(true, check_path(&root.join("foo").unwrap()).is_ok()); - assert_eq!(true, check_path(&root.join("bar").unwrap()).is_err()); + assert_eq!( + true, + check_path_is_empty(&root.join("foo").unwrap()).is_ok() + ); + assert_eq!( + true, + check_path_is_empty(&root.join("bar").unwrap()).is_err() + ); } #[test] From f22c7bb87a1ea6d461ae1a8291478ead6215d6c5 Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Thu, 2 Apr 2026 22:48:39 +0200 Subject: [PATCH 4/8] feat: Use lalrpop for the parser Part of #2 [lalrpop](https://github.com/lalrpop/lalrpop) is a rust LR parser generator. It seems able to handle fil syntax. For now, the syntax is the one their tutorial (we don't need more for this issue). *Testing:* Have a simple arithmetic operation (+-/*) in a `src/main.fil`, run `fil build` you should get a "Not yet implemented" message. Replace the file content by anything but an operation, you should get a syntax error coming from lalrpop --- Cargo.lock | 354 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + build.rs | 25 +++ src/build/.gitignore | 1 + src/build/ast.rs | 50 ++++++ src/build/grammar.lalrpop | 33 ++++ src/build/mod.rs | 95 ++++++++++ src/cli/build.rs | 33 +--- src/cli/mod.rs | 4 +- src/fault/mod.rs | 10 +- src/main.rs | 3 +- 11 files changed, 576 insertions(+), 36 deletions(-) create mode 100644 build.rs create mode 100644 src/build/.gitignore create mode 100644 src/build/ast.rs create mode 100644 src/build/grammar.lalrpop create mode 100644 src/build/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d6426a1..6dfa234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -58,12 +67,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.1" @@ -83,7 +125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -161,6 +203,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -170,12 +221,47 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -205,6 +291,8 @@ dependencies = [ "clap", "cliclack", "console", + "lalrpop", + "lalrpop-util", "pretty_assertions", "rand", "vfs", @@ -221,12 +309,28 @@ dependencies = [ "libredox", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.1" @@ -299,6 +403,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -315,6 +428,46 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lalrpop" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a80a963123205c7157323c99611bc4abb65dcbd62ef46dc4bac74a3941bc75" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884f3e747ed2dcee867cda1b0c31a048f9e20de2d916a248949319921a2e666e" +dependencies = [ + "regex-automata", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -335,7 +488,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall", + "redox_syscall 0.7.1", ] [[package]] @@ -344,6 +497,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -356,6 +518,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "once_cell" version = "1.21.3" @@ -368,12 +536,67 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -435,6 +658,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.1" @@ -444,6 +676,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustix" version = "1.1.3" @@ -463,6 +724,21 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -511,12 +787,46 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "smawk" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -534,6 +844,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "terminal_size" version = "0.4.3" @@ -555,6 +874,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -591,6 +916,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vfs" version = "0.12.2" @@ -600,6 +931,16 @@ dependencies = [ "filetime", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -707,6 +1048,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index e223f7e..64a03d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,11 @@ clap = { version = "4.5.58", features = ["derive", "wrap_help", "string", "cargo cliclack = "0.3.9" console = "0.16.2" vfs = "0.12.2" +lalrpop-util = "0.23.1" [dev-dependencies] pretty_assertions = "1.4.1" rand = "0.10.0" + +[build-dependencies] +lalrpop = "0.23.1" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..c2cc128 --- /dev/null +++ b/build.rs @@ -0,0 +1,25 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +fn main() { + lalrpop::Configuration::new() + .set_in_dir("./src") + .set_out_dir("./src") + .force_build(true) + .process() + .unwrap() +} diff --git a/src/build/.gitignore b/src/build/.gitignore new file mode 100644 index 0000000..718e625 --- /dev/null +++ b/src/build/.gitignore @@ -0,0 +1 @@ +grammar.rs diff --git a/src/build/ast.rs b/src/build/ast.rs new file mode 100644 index 0000000..c3d144b --- /dev/null +++ b/src/build/ast.rs @@ -0,0 +1,50 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use std::fmt::{Debug, Formatter}; + +pub enum Expr { + Number(i32), + Op(Box, Opcode, Box), +} + +pub enum Opcode { + Mul, + Div, + Add, + Sub, +} + +impl Debug for Expr { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Expr::Number(n) => write!(f, "{n:?}"), + Expr::Op(l, op, r) => write!(f, "({l:?} {op:?} {r:?})"), + } + } +} + +impl Debug for Opcode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Opcode::Mul => write!(f, "*"), + Opcode::Div => write!(f, "/"), + Opcode::Add => write!(f, "+"), + Opcode::Sub => write!(f, "-"), + } + } +} diff --git a/src/build/grammar.lalrpop b/src/build/grammar.lalrpop new file mode 100644 index 0000000..5a7d2de --- /dev/null +++ b/src/build/grammar.lalrpop @@ -0,0 +1,33 @@ +use std::str::FromStr; +use crate::build::ast::{Expr, Opcode}; + +grammar; + +pub Expr: Box = { + Expr ExprOp Factor => Box::new(Expr::Op(<>)), + Factor, +}; + +ExprOp: Opcode = { + "+" => Opcode::Add, + "-" => Opcode::Sub, +}; + +Factor: Box = { + Factor FactorOp Term => Box::new(Expr::Op(<>)), + Term, +}; + +FactorOp: Opcode = { + "*" => Opcode::Mul, + "/" => Opcode::Div, +}; + +Term: Box = { + Num => Box::new(Expr::Number(<>)), + "(" ")" +}; + +Num: i32 = { + r"[0-9]+" => i32::from_str(<>).unwrap() +}; diff --git a/src/build/mod.rs b/src/build/mod.rs new file mode 100644 index 0000000..ed188a4 --- /dev/null +++ b/src/build/mod.rs @@ -0,0 +1,95 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +mod ast; +mod grammar; + +use crate::build::ast::Expr; +use crate::cli::Cli; +use crate::cli::build::CommandBuild; +use crate::fault; +use crate::fault::Fault; + +pub fn build( + _cli: &Cli, + _command: &CommandBuild, + filesystem: &vfs::path::VfsPath, +) -> fault::Result<()> { + let expr = filesystem + .join("src/main.fil") + .map_err(|error| Fault::from_error(Box::from(error))) + .and_then(|main_source_file| parse_file(&main_source_file)); + + expr.and_then(|_| Err(Fault::from_message("Not yet implemented"))) +} + +fn parse_file(main_source_file: &vfs::path::VfsPath) -> fault::Result> { + main_source_file + .read_to_string() + .map_err(|error| Fault::from_error(Box::from(error))) + .and_then(|content| { + grammar::ExprParser::new() + .parse(content.as_str()) + .map_err(|error| Fault::from_message(format!("{error}").as_str())) + }) +} + +#[cfg(test)] +mod test { + use crate::build::{grammar, parse_file}; + use pretty_assertions::assert_eq; + use vfs::{MemoryFS, VfsPath}; + + #[test] + fn test_grammar() { + let expr = grammar::ExprParser::new().parse("22 * 44 + 66").unwrap(); + assert_eq!(&format!("{:?}", expr), "((22 * 44) + 66)"); + } + + #[test] + fn test_parse_file() { + let root = VfsPath::new(MemoryFS::new()); + root.join("src").unwrap().create_dir().unwrap(); + let source_file = root.join("src/main.rs").unwrap(); + source_file.create_file().unwrap(); + source_file + .append_file() + .unwrap() + .write_fmt(format_args!("1 + 3 * 12 -4")) + .unwrap(); + + let expr = parse_file(&source_file).unwrap(); + assert_eq!(&format!("{:?}", expr), "((1 + (3 * 12)) - 4)"); + } + + #[test] + fn test_parse_file_err() { + let root = VfsPath::new(MemoryFS::new()); + root.join("src").unwrap().create_dir().unwrap(); + let source_file = root.join("src/main.rs").unwrap(); + source_file.create_file().unwrap(); + source_file + .append_file() + .unwrap() + .write_fmt(format_args!("1 + hello")) + .unwrap(); + + let result = parse_file(&source_file); + assert_eq!(result.is_err(), true); + assert_eq!(format!("{}", result.err().unwrap()), "Invalid token at 4"); + } +} diff --git a/src/cli/build.rs b/src/cli/build.rs index 1270e2d..cc1135f 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -15,9 +15,9 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +use crate::build::build; use crate::cli::Cli; use crate::fault; -use crate::fault::Fault; use clap::Args; #[derive(Args)] @@ -31,29 +31,10 @@ pub struct CommandBuild { pub out_dir: Option, } -pub fn run(_cli: &Cli, _command: &CommandBuild) -> fault::Result<()> { - Err(Fault::from_message("build command is not yet implemented")) -} - -#[cfg(test)] -mod test { - use crate::cli::build::CommandBuild; - use crate::cli::{Cli, Command, build}; - use pretty_assertions::assert_eq; - - #[test] - fn it_returns_err() { - let result = build::run( - &Cli { - config: "".to_string(), - command: Command::Build(CommandBuild { out_dir: None }), - }, - &CommandBuild { out_dir: None }, - ); - assert_eq!(true, result.is_err()); - assert_eq!( - "build command is not yet implemented", - result.unwrap_err().to_string() - ); - } +pub fn run( + cli: &Cli, + command: &CommandBuild, + filesystem: &vfs::path::VfsPath, +) -> fault::Result<()> { + build(cli, command, filesystem) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9ff0729..b9dcd9a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -15,7 +15,7 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -mod build; +pub mod build; mod new; use crate::fault; @@ -74,7 +74,7 @@ pub fn parse(args: Vec) -> Cli { pub fn run(cli: Cli) -> fault::Result<()> { match &cli.command { Command::New(n) => new::run(&cli, n, &vfs::PhysicalFS::new("/").into()), - Command::Build(b) => build::run(&cli, b), + Command::Build(b) => build::run(&cli, b, &vfs::PhysicalFS::new(".").into()), } } diff --git a/src/fault/mod.rs b/src/fault/mod.rs index 46c1842..85e2d37 100644 --- a/src/fault/mod.rs +++ b/src/fault/mod.rs @@ -34,7 +34,7 @@ impl Fault { pub fn from_error(error: Box) -> Self { Self { - message: Some(error.to_string()), + message: None, error: Some(error), } } @@ -51,8 +51,8 @@ impl fmt::Display for Fault { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match (&self.message, &self.error) { (Some(message), None) => write!(f, "{message}"), - (Some(message), Some(error)) => write!(f, "{message}: {:?}", error.to_string()), - (None, Some(error)) => write!(f, "{}", error.to_string()), + (Some(message), Some(error)) => write!(f, "{message}: {error}"), + (None, Some(error)) => write!(f, "{error}"), _ => write!(f, "Got an unknown fault, please open an issue"), } } @@ -86,7 +86,7 @@ mod test { #[test] fn test_fault_from_error() { assert_eq!( - "Some stub error: \"Some stub error\"", + "Some stub error", Fault::from_error(Box::new(ErrorStub {})).to_string() ) } @@ -94,7 +94,7 @@ mod test { #[test] fn test_fault_from_error_with_message() { assert_eq!( - "Oopsie: \"Some stub error\"", + "Oopsie: Some stub error", Fault::from_error_with_message(Box::new(ErrorStub {}), "Oopsie").to_string() ) } diff --git a/src/main.rs b/src/main.rs index 1cb08c2..049391a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,10 +17,11 @@ use std::env; +mod build; mod cli; mod fault; mod new; fn main() -> Result<(), String> { - cli::run(cli::parse(env::args().collect())).map_err(|err| err.to_string()) + cli::run(cli::parse(env::args().collect())).map_err(|err| format!("{err}")) } From 750c265cf6f4d82320e913f194784a438a8297ad Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Wed, 8 Apr 2026 22:12:14 +0200 Subject: [PATCH 5/8] feat: Add a formatter for lalrpop errors Part of #2 This way error are more intelligible by a human --- src/build/mod.rs | 19 +- src/build/parse_error_formatter.rs | 384 +++++++++++++++++++++++++++++ src/cli/mod.rs | 8 +- src/cli/new.rs | 5 +- src/fault/mod.rs | 8 +- src/main.rs | 11 +- src/new/mod.rs | 18 +- 7 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 src/build/parse_error_formatter.rs diff --git a/src/build/mod.rs b/src/build/mod.rs index ed188a4..465f98c 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -17,8 +17,10 @@ mod ast; mod grammar; +mod parse_error_formatter; use crate::build::ast::Expr; +use crate::build::parse_error_formatter::format_parse_error; use crate::cli::Cli; use crate::cli::build::CommandBuild; use crate::fault; @@ -44,20 +46,20 @@ fn parse_file(main_source_file: &vfs::path::VfsPath) -> fault::Result> .and_then(|content| { grammar::ExprParser::new() .parse(content.as_str()) - .map_err(|error| Fault::from_message(format!("{error}").as_str())) + .map_err(|error| Fault::from_message(format_parse_error(error, &content).as_str())) }) } #[cfg(test)] mod test { use crate::build::{grammar, parse_file}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use vfs::{MemoryFS, VfsPath}; #[test] fn test_grammar() { let expr = grammar::ExprParser::new().parse("22 * 44 + 66").unwrap(); - assert_eq!(&format!("{:?}", expr), "((22 * 44) + 66)"); + assert_str_eq!(&format!("{:?}", expr), "((22 * 44) + 66)"); } #[test] @@ -73,7 +75,7 @@ mod test { .unwrap(); let expr = parse_file(&source_file).unwrap(); - assert_eq!(&format!("{:?}", expr), "((1 + (3 * 12)) - 4)"); + assert_str_eq!(&format!("{:?}", expr), "((1 + (3 * 12)) - 4)"); } #[test] @@ -90,6 +92,13 @@ mod test { let result = parse_file(&source_file); assert_eq!(result.is_err(), true); - assert_eq!(format!("{}", result.err().unwrap()), "Invalid token at 4"); + assert_str_eq!( + format!("{}", result.err().unwrap()), + "Invalid token at line 1: + + 1 | 1 + hello + ^ +" + ); } } diff --git a/src/build/parse_error_formatter.rs b/src/build/parse_error_formatter.rs new file mode 100644 index 0000000..7c75d67 --- /dev/null +++ b/src/build/parse_error_formatter.rs @@ -0,0 +1,384 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use lalrpop_util::ParseError; +use lalrpop_util::lexer::Token; + +pub fn format_parse_error(error: ParseError, source: &String) -> String { + let result = match error { + ParseError::InvalidToken { location } => parse_invalid_token(location, source), + ParseError::UnrecognizedEof { + location, + ref expected, + } => parse_unrecognized_eof(location, expected, source), + ParseError::UnrecognizedToken { + ref token, + ref expected, + } => parse_unrecognized_token(token, expected, source), + ParseError::ExtraToken { ref token } => parse_extra_token(token, source), + ParseError::User { error } => Some(String::from(error)), + }; + + result.unwrap_or(format!("{error}")) +} + +fn parse_invalid_token(location: usize, source: &String) -> Option { + find_line(location, location, source) + .map(|(line, n)| format!("Invalid token at line {n}:\n\n{line}")) +} + +fn parse_unrecognized_eof( + location: usize, + expected: &Vec, + source: &String, +) -> Option { + find_line(location, location, source).map(|(line, n)| { + let expected_str = format_expected(expected); + format!("Unexpected end of file (EOF) at line {n}:\n\n{line}\n{expected_str}\n") + }) +} + +fn parse_unrecognized_token( + (start, token, end): &(usize, Token, usize), + expected: &Vec, + source: &String, +) -> Option { + find_line(*start, *end, source).map(|(line, n)| { + let expected_str = format_expected(expected); + format!("Unexpected token '{token}' at line {n}:\n\n{line}\n{expected_str}\n") + }) +} + +fn parse_extra_token( + (start, token, end): &(usize, Token, usize), + source: &String, +) -> Option { + find_line(*start, *end, source) + .map(|(line, n)| format!("Extra token '{token}' found at line {n}:\n\n{line}\n")) +} + +fn format_expected(expected: &Vec) -> String { + let mut result = String::new(); + for (i, e) in expected.iter().enumerate() { + let sep = match i { + 0 => "Expected", + _ if i < expected.len() - 1 => ",", + _ => " or", + }; + result = format!("{result}{sep} '{e}'"); + } + result +} + +struct LineEntry { + line: String, + nth_line: usize, + range: (usize, usize), +} + +fn find_line(start: usize, end: usize, source: &String) -> Option<(String, usize)> { + let lines = collect_lines(start, end, source); + if lines.is_empty() { + None + } else if lines.len() == 1 { + let line = lines.first()?; + let n = line.nth_line; + let content = &line.line; + let n_spacing = " ".repeat(format!("{n}").len()); + let spacing = " ".repeat(line.range.0); + let hats = "^".repeat(line.range.1 - line.range.0 + 1); + Some(( + format!(" {n} | {content}\n {n_spacing} {spacing}{hats}\n"), + n, + )) + } else { + let first_line = lines.first()?; + let last_line = lines.last()?; + let n_len = format!("{}", last_line.nth_line).len(); + let n_spacing = " ".repeat(n_len); + let mut content = String::new(); + for (i, line) in lines.iter().enumerate() { + let n = line.nth_line; + let front_spacing = " ".repeat(n_len - format!("{n}").len()); + let line_content = &line.line; + if i == 0 { + let spacing = " ".repeat(line.range.0); + content += + format!(" {n_spacing} {spacing}v\n {front_spacing}{n} | {line_content}\n") + .as_str(); + } else if i == lines.len() - 1 { + let spacing = " ".repeat(line.range.1); + content += + format!(" {front_spacing}{n} | {line_content}\n {n_spacing} {spacing}^\n") + .as_str(); + } else { + content += format!(" {front_spacing}{n} | {line_content}\n").as_str(); + } + } + Some((content, first_line.nth_line)) + } +} + +fn collect_lines(start: usize, end: usize, source: &String) -> Vec { + let mut lines = Vec::new(); + let mut position_start = 0; + let mut position_end = 0; + let mut collect = false; + for (n, line) in source.split("\n").enumerate() { + position_end += line.len() + 1; + let mut line_start = 0; + let mut line_end = line.len(); + if start >= position_start && start < position_end { + line_start = start - position_start; + collect = true; + } + if collect { + if end >= position_start && end < position_end { + line_end = end - position_start; + } + lines.push(LineEntry { + line: String::from(line), + nth_line: n + 1, + range: (line_start, line_end), + }); + } + if end >= position_start && end < position_end { + collect = false; + } + + position_start = position_end; + } + lines +} + +#[cfg(test)] +mod test { + use crate::build::parse_error_formatter::{find_line, format_expected, format_parse_error}; + use lalrpop_util::ParseError; + use lalrpop_util::lexer::Token; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn test_parse_invalid_token() { + assert_str_eq!( + "Invalid token at line 2: + + 2 | bar + ^ +", + format_parse_error( + ParseError::InvalidToken { location: 5 }, + &String::from("foo\nbar\nbaz") + ) + ); + assert_str_eq!( + "Invalid token at 50", + format_parse_error( + ParseError::InvalidToken { location: 50 }, + &String::from("foo\nbar\nbaz") + ) + ) + } + + #[test] + fn test_parse_unrecognized_eof() { + assert_str_eq!( + "Unexpected end of file (EOF) at line 3: + + 3 | baz + ^ + +Expected 'toto' or 'titi' +", + format_parse_error( + ParseError::UnrecognizedEof { + location: 11, + expected: vec![String::from("toto"), String::from("titi")], + }, + &String::from("foo\nbar\nbaz"), + ) + ); + } + + #[test] + fn test_parse_unrecognized_token() { + assert_str_eq!( + "Unexpected token 'hello' at line 2: + + 2 | bar + ^^ + +Expected 'toto' or 'titi' +", + format_parse_error( + ParseError::UnrecognizedToken { + token: (5, Token(0, "hello"), 6), + expected: vec![String::from("toto"), String::from("titi")], + }, + &String::from("foo\nbar\nbaz"), + ), + ); + } + + #[test] + fn test_parse_user() { + assert_str_eq!( + "factoring", + format_parse_error(ParseError::User { error: "factoring" }, &String::new(),) + ) + } + + #[test] + fn test_parse_extra_token() { + assert_str_eq!( + "Extra token 'hello' found at line 2: + + 2 | bar + ^^ + +", + format_parse_error( + ParseError::ExtraToken { + token: (5, Token(0, "hello"), 6) + }, + &String::from("foo\nbar\nbaz"), + ), + ) + } + + #[test] + fn test_format_expected() { + assert_str_eq!( + "Expected 'foo'", + format_expected(&vec![String::from("foo")]) + ); + assert_str_eq!( + "Expected 'foo' or 'bar'", + format_expected(&vec![String::from("foo"), String::from("bar")]) + ); + assert_str_eq!( + "Expected 'foo', 'bar' or 'baz'", + format_expected(&vec![ + String::from("foo"), + String::from("bar"), + String::from("baz") + ]) + ); + } + + #[test] + fn test_find_line() { + assert_eq!( + Some(( + String::from( + " 2 | bar + ^ +" + ), + 2 + )), + find_line(5, 5, &String::from("foo\nbar\nbaz")), + ); + assert_eq!( + Some(( + String::from( + " 1 | foo + ^ +" + ), + 1 + )), + find_line(0, 0, &String::from("foo\nbar\nbaz")), + ); + assert_eq!( + Some(( + String::from( + " 1 | foo + ^ +" + ), + 1 + )), + find_line(3, 3, &String::from("foo\nbar\nbaz")), + ); + assert_eq!( + Some(( + String::from( + " 3 | baz + ^ +" + ), + 3 + )), + find_line(10, 10, &String::from("foo\nbar\nbaz")), + ); + assert_eq!(None, find_line(100, 100, &String::from(""))); + } + + #[test] + fn test_find_line_range() { + assert_eq!( + Some(( + String::from( + " 2 | Hello World! + ^^^^^ +" + ), + 2 + )), + find_line(10, 14, &String::from("foo\nHello World!\nbaz")), + ); + } + + #[test] + fn test_find_line_multiline() { + assert_eq!( + Some(( + String::from( + " v + 2 | tete + 3 | titi + 4 | toto + ^ +" + ), + 2 + )), + find_line(7, 16, &String::from("tata\ntete\ntiti\ntoto\ntutu\n")), + ); + assert_eq!( + Some(( + String::from( + " v + 9 | i + 10 | j + 11 | k + ^ +" + ), + 9 + )), + find_line( + 16, + 20, + &String::from( + "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz" + ) + ), + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b9dcd9a..7e0ec61 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -81,7 +81,7 @@ pub fn run(cli: Cli) -> fault::Result<()> { #[cfg(test)] mod test { use crate::cli::{Command, parse}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; fn make_args(args: Vec<&str>) -> Vec { args.iter().map(|&arg| arg.parse().unwrap()).collect() @@ -91,7 +91,7 @@ mod test { fn it_parses_command_new_args() { let result = parse(make_args(vec!["fil", "new", "--name", "foo"])); match result.command { - Command::New(n) => assert_eq!("foo", n.name.unwrap()), + Command::New(n) => assert_str_eq!("foo", n.name.unwrap()), Command::Build(_) => panic!("Should have parsed command new"), } } @@ -110,7 +110,7 @@ mod test { let result = parse(make_args(vec!["fil", "build", "-o", "dist"])); match result.command { Command::New(_) => panic!("Should have parsed command build"), - Command::Build(b) => assert_eq!("dist", b.out_dir.unwrap()), + Command::Build(b) => assert_str_eq!("dist", b.out_dir.unwrap()), } } @@ -119,7 +119,7 @@ mod test { let result = parse(make_args(vec!["fil", "build"])); match result.command { Command::New(_) => panic!("Should have parsed command build"), - Command::Build(b) => assert_eq!("build", b.out_dir.unwrap()), + Command::Build(b) => assert_str_eq!("build", b.out_dir.unwrap()), } } } diff --git a/src/cli/new.rs b/src/cli/new.rs index 008bd79..e7dfc3f 100644 --- a/src/cli/new.rs +++ b/src/cli/new.rs @@ -122,7 +122,7 @@ fn get_git(command: &CommandNew) -> fault::Result { mod test { use crate::cli::new::CommandNew; use crate::cli::{Cli, Command, new}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use std::io::Read; use vfs::{MemoryFS, VfsPath}; @@ -159,14 +159,13 @@ mod test { let content: Vec<_> = path.read_dir().unwrap().collect(); assert_eq!(vec![path.join("package.toml").unwrap()], content); let mut package_content = String::new(); - println!("{:?}", root); path.join("package.toml") .unwrap() .open_file() .unwrap() .read_to_string(&mut package_content) .unwrap(); - assert_eq!( + assert_str_eq!( format!( "[package] name = {}", diff --git a/src/fault/mod.rs b/src/fault/mod.rs index 85e2d37..5438bd8 100644 --- a/src/fault/mod.rs +++ b/src/fault/mod.rs @@ -63,7 +63,7 @@ pub type Result = std::result::Result; #[cfg(test)] mod test { use crate::fault::Fault; - use pretty_assertions::assert_eq; + use pretty_assertions::assert_str_eq; use std::fmt::Formatter; use std::{error, fmt}; @@ -80,12 +80,12 @@ mod test { #[test] fn test_fault_from_message() { - assert_eq!("Oh snap!", Fault::from_message("Oh snap!").to_string()) + assert_str_eq!("Oh snap!", Fault::from_message("Oh snap!").to_string()) } #[test] fn test_fault_from_error() { - assert_eq!( + assert_str_eq!( "Some stub error", Fault::from_error(Box::new(ErrorStub {})).to_string() ) @@ -93,7 +93,7 @@ mod test { #[test] fn test_fault_from_error_with_message() { - assert_eq!( + assert_str_eq!( "Oopsie: Some stub error", Fault::from_error_with_message(Box::new(ErrorStub {}), "Oopsie").to_string() ) diff --git a/src/main.rs b/src/main.rs index 049391a..ec0d348 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,12 +16,19 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. use std::env; +use std::process::ExitCode; mod build; mod cli; mod fault; mod new; -fn main() -> Result<(), String> { - cli::run(cli::parse(env::args().collect())).map_err(|err| format!("{err}")) +fn main() -> ExitCode { + match cli::run(cli::parse(env::args().collect())) { + Ok(_) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + ExitCode::FAILURE + } + } } diff --git a/src/new/mod.rs b/src/new/mod.rs index a3093e1..11fda75 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -126,7 +126,7 @@ fn sanitize_name(name: &String) -> String { #[cfg(test)] mod test { use crate::new::{check_path_is_empty, sanitize_name}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use vfs::{MemoryFS, VfsPath}; #[test] @@ -148,13 +148,13 @@ mod test { #[test] fn test_sanitize_name() { - assert_eq!("foo", sanitize_name(&"foo".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo bar".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo-bar".to_string())); - assert_eq!("foo_bar", sanitize_name(&"foo_bar".to_string())); - assert_eq!("foo", sanitize_name(&" foo ".to_string())); - assert_eq!("foo-bar", sanitize_name(&" foo bar ".to_string())); - assert_eq!("foo&bar", sanitize_name(&"foo&bar".to_string())); - assert_eq!("foo-bar", sanitize_name(&"foo*bar".to_string())); + assert_str_eq!("foo", sanitize_name(&"foo".to_string())); + assert_str_eq!("foo-bar", sanitize_name(&"foo bar".to_string())); + assert_str_eq!("foo-bar", sanitize_name(&"foo-bar".to_string())); + assert_str_eq!("foo_bar", sanitize_name(&"foo_bar".to_string())); + assert_str_eq!("foo", sanitize_name(&" foo ".to_string())); + assert_str_eq!("foo-bar", sanitize_name(&" foo bar ".to_string())); + assert_str_eq!("foo&bar", sanitize_name(&"foo&bar".to_string())); + assert_str_eq!("foo-bar", sanitize_name(&"foo*bar".to_string())); } } From f6e2f08b2da80d971c2e2b329b4bdf1f79e975bc Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Thu, 9 Apr 2026 22:07:37 +0200 Subject: [PATCH 6/8] feat: Add validator of ast Part of #2 It is still very basic but the idea is present. In future ast (the real one), we'll have more information (like the position) and thus we will be able to have something more precise and useful *Testing:* Try build a valid operation -> you have the not yet implemented message Try build a division by 0 -> you have an error message telling you it is not valid --- src/build/mod.rs | 8 +++- src/build/validator/mod.rs | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/build/validator/mod.rs diff --git a/src/build/mod.rs b/src/build/mod.rs index 465f98c..eb829de 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -18,9 +18,11 @@ mod ast; mod grammar; mod parse_error_formatter; +mod validator; use crate::build::ast::Expr; use crate::build::parse_error_formatter::format_parse_error; +use crate::build::validator::validate; use crate::cli::Cli; use crate::cli::build::CommandBuild; use crate::fault; @@ -34,7 +36,11 @@ pub fn build( let expr = filesystem .join("src/main.fil") .map_err(|error| Fault::from_error(Box::from(error))) - .and_then(|main_source_file| parse_file(&main_source_file)); + .and_then(|main_source_file| parse_file(&main_source_file)) + .and_then(|expr| validate(&expr)); + // TODO: + // - match visitor to build IR (llvm?) + // - linking into executable expr.and_then(|_| Err(Fault::from_message("Not yet implemented"))) } diff --git a/src/build/validator/mod.rs b/src/build/validator/mod.rs new file mode 100644 index 0000000..13798fb --- /dev/null +++ b/src/build/validator/mod.rs @@ -0,0 +1,84 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use crate::build::ast::{Expr, Opcode}; +use crate::fault; +use crate::fault::Fault; + +pub fn validate(expr: &Expr) -> fault::Result<()> { + validate_expression(expr).map(|_| ()) +} + +fn validate_expression(expr: &Expr) -> fault::Result { + match expr { + Expr::Number(n) => Ok(*n), + Expr::Op(l, o, r) => validate_operator(l, o, r), + } +} + +fn validate_operator(left: &Expr, operator: &Opcode, right: &Expr) -> fault::Result { + match operator { + Opcode::Mul => validate_expression(right).and_then(|right_result| { + validate_expression(left).map(|left_result| left_result * right_result) + }), + Opcode::Div => validate_expression(right).and_then(|right_result| { + if right_result == 0 { + Err(Fault::from_message("You cannot divide by 0")) + } else { + validate_expression(left).map(|left_result| left_result / right_result) + } + }), + Opcode::Add => validate_expression(right).and_then(|right_result| { + validate_expression(left).map(|left_result| left_result + right_result) + }), + Opcode::Sub => validate_expression(right).and_then(|right_result| { + validate_expression(left).map(|left_result| left_result - right_result) + }), + } +} + +#[cfg(test)] +mod test { + use crate::build::grammar; + use crate::build::validator::validate_expression; + use crate::fault; + use crate::fault::Fault; + use pretty_assertions::{assert_eq, assert_str_eq}; + + fn parse_and_validate(input: &str) -> fault::Result { + grammar::ExprParser::new() + .parse(input) + .map_err(|_| Fault::from_message("Failed to parse expression")) + .and_then(|expr| validate_expression(&expr)) + } + + #[test] + fn test_validate_expression() { + assert_eq!(12, parse_and_validate("12").unwrap()); + assert_eq!(5, parse_and_validate("2 + 3").unwrap()); + assert_eq!(1, parse_and_validate("3 - 2").unwrap()); + assert_eq!(6, parse_and_validate("2 * 3").unwrap()); + assert_eq!(2, parse_and_validate("4 / 2").unwrap()); + assert_eq!(1034, parse_and_validate("22 * 44 + 66").unwrap()); + assert_eq!(2420, parse_and_validate("22 * (44 + 66)").unwrap()); + + assert_str_eq!( + "You cannot divide by 0", + format!("{}", parse_and_validate("4 / 0").err().unwrap()) + ); + } +} From 7d83cb56e398877fb80cd2aa2c341dabffb04bd8 Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Mon, 13 Apr 2026 21:10:14 +0200 Subject: [PATCH 7/8] chore: Extract parse_file in its own mod Part of #2 Validation is already in its own mod, so Parsing and grammar should be too. It is easier to read having each part separated --- src/build/{ => grammar}/.gitignore | 0 src/build/{ => grammar}/ast.rs | 0 src/build/{ => grammar}/grammar.lalrpop | 2 +- src/build/grammar/mod.rs | 89 +++++++++++++++++++++++++ src/build/mod.rs | 68 +------------------ src/build/validator/mod.rs | 4 +- 6 files changed, 93 insertions(+), 70 deletions(-) rename src/build/{ => grammar}/.gitignore (100%) rename src/build/{ => grammar}/ast.rs (100%) rename src/build/{ => grammar}/grammar.lalrpop (91%) create mode 100644 src/build/grammar/mod.rs diff --git a/src/build/.gitignore b/src/build/grammar/.gitignore similarity index 100% rename from src/build/.gitignore rename to src/build/grammar/.gitignore diff --git a/src/build/ast.rs b/src/build/grammar/ast.rs similarity index 100% rename from src/build/ast.rs rename to src/build/grammar/ast.rs diff --git a/src/build/grammar.lalrpop b/src/build/grammar/grammar.lalrpop similarity index 91% rename from src/build/grammar.lalrpop rename to src/build/grammar/grammar.lalrpop index 5a7d2de..ee179aa 100644 --- a/src/build/grammar.lalrpop +++ b/src/build/grammar/grammar.lalrpop @@ -1,5 +1,5 @@ use std::str::FromStr; -use crate::build::ast::{Expr, Opcode}; +use crate::build::grammar::ast::{Expr, Opcode}; grammar; diff --git a/src/build/grammar/mod.rs b/src/build/grammar/mod.rs new file mode 100644 index 0000000..d9edd70 --- /dev/null +++ b/src/build/grammar/mod.rs @@ -0,0 +1,89 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use crate::build::grammar::ast::Expr; +use crate::build::parse_error_formatter::format_parse_error; +use crate::fault; +use crate::fault::Fault; + +pub mod ast; +pub mod grammar; + +pub fn parse_file(main_source_file: &vfs::path::VfsPath) -> fault::Result> { + main_source_file + .read_to_string() + .map_err(|error| Fault::from_error(Box::from(error))) + .and_then(|content| { + grammar::ExprParser::new() + .parse(content.as_str()) + .map_err(|error| Fault::from_message(format_parse_error(error, &content).as_str())) + }) +} + +#[cfg(test)] +mod test { + use crate::build::grammar::grammar; + use crate::build::grammar::parse_file; + use pretty_assertions::{assert_eq, assert_str_eq}; + use vfs::{MemoryFS, VfsPath}; + + #[test] + fn test_grammar() { + let expr = grammar::ExprParser::new().parse("22 * 44 + 66").unwrap(); + assert_str_eq!(&format!("{:?}", expr), "((22 * 44) + 66)"); + } + + #[test] + fn test_parse_file() { + let root = VfsPath::new(MemoryFS::new()); + root.join("src").unwrap().create_dir().unwrap(); + let source_file = root.join("src/main.rs").unwrap(); + source_file.create_file().unwrap(); + source_file + .append_file() + .unwrap() + .write_fmt(format_args!("1 + 3 * 12 -4")) + .unwrap(); + + let expr = parse_file(&source_file).unwrap(); + assert_str_eq!(&format!("{:?}", expr), "((1 + (3 * 12)) - 4)"); + } + + #[test] + fn test_parse_file_err() { + let root = VfsPath::new(MemoryFS::new()); + root.join("src").unwrap().create_dir().unwrap(); + let source_file = root.join("src/main.rs").unwrap(); + source_file.create_file().unwrap(); + source_file + .append_file() + .unwrap() + .write_fmt(format_args!("1 + hello")) + .unwrap(); + + let result = parse_file(&source_file); + assert_eq!(result.is_err(), true); + assert_str_eq!( + format!("{}", result.err().unwrap()), + "Invalid token at line 1: + + 1 | 1 + hello + ^ +" + ); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs index eb829de..0d5cec1 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -15,13 +15,11 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -mod ast; mod grammar; mod parse_error_formatter; mod validator; -use crate::build::ast::Expr; -use crate::build::parse_error_formatter::format_parse_error; +use crate::build::grammar::parse_file; use crate::build::validator::validate; use crate::cli::Cli; use crate::cli::build::CommandBuild; @@ -44,67 +42,3 @@ pub fn build( expr.and_then(|_| Err(Fault::from_message("Not yet implemented"))) } - -fn parse_file(main_source_file: &vfs::path::VfsPath) -> fault::Result> { - main_source_file - .read_to_string() - .map_err(|error| Fault::from_error(Box::from(error))) - .and_then(|content| { - grammar::ExprParser::new() - .parse(content.as_str()) - .map_err(|error| Fault::from_message(format_parse_error(error, &content).as_str())) - }) -} - -#[cfg(test)] -mod test { - use crate::build::{grammar, parse_file}; - use pretty_assertions::{assert_eq, assert_str_eq}; - use vfs::{MemoryFS, VfsPath}; - - #[test] - fn test_grammar() { - let expr = grammar::ExprParser::new().parse("22 * 44 + 66").unwrap(); - assert_str_eq!(&format!("{:?}", expr), "((22 * 44) + 66)"); - } - - #[test] - fn test_parse_file() { - let root = VfsPath::new(MemoryFS::new()); - root.join("src").unwrap().create_dir().unwrap(); - let source_file = root.join("src/main.rs").unwrap(); - source_file.create_file().unwrap(); - source_file - .append_file() - .unwrap() - .write_fmt(format_args!("1 + 3 * 12 -4")) - .unwrap(); - - let expr = parse_file(&source_file).unwrap(); - assert_str_eq!(&format!("{:?}", expr), "((1 + (3 * 12)) - 4)"); - } - - #[test] - fn test_parse_file_err() { - let root = VfsPath::new(MemoryFS::new()); - root.join("src").unwrap().create_dir().unwrap(); - let source_file = root.join("src/main.rs").unwrap(); - source_file.create_file().unwrap(); - source_file - .append_file() - .unwrap() - .write_fmt(format_args!("1 + hello")) - .unwrap(); - - let result = parse_file(&source_file); - assert_eq!(result.is_err(), true); - assert_str_eq!( - format!("{}", result.err().unwrap()), - "Invalid token at line 1: - - 1 | 1 + hello - ^ -" - ); - } -} diff --git a/src/build/validator/mod.rs b/src/build/validator/mod.rs index 13798fb..269b674 100644 --- a/src/build/validator/mod.rs +++ b/src/build/validator/mod.rs @@ -15,7 +15,7 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -use crate::build::ast::{Expr, Opcode}; +use crate::build::grammar::ast::{Expr, Opcode}; use crate::fault; use crate::fault::Fault; @@ -53,7 +53,7 @@ fn validate_operator(left: &Expr, operator: &Opcode, right: &Expr) -> fault::Res #[cfg(test)] mod test { - use crate::build::grammar; + use crate::build::grammar::grammar; use crate::build::validator::validate_expression; use crate::fault; use crate::fault::Fault; From 2c346a7a847678899cf5e2de29a06db12c8d4f91 Mon Sep 17 00:00:00 2001 From: Kevin Traini Date: Tue, 14 Apr 2026 22:16:51 +0200 Subject: [PATCH 8/8] feat: Transform AST to LLVM IR Part of #2 *Testing:* run `fil build` in a valid project, it should output corresponding llvm ir. If you interpret it with lli you should get the result of the operation as exit code --- Cargo.lock | 93 ++++++++++++ Cargo.toml | 1 + fil.nix | 16 ++- flake.lock | 8 +- flake.nix | 21 ++- src/build/grammar/ast.rs | 2 +- src/build/grammar/grammar.lalrpop | 4 +- src/build/grammar/mod.rs | 3 +- .../{ => grammar}/parse_error_formatter.rs | 4 +- src/build/ir/builder_error_formatter.rs | 81 +++++++++++ src/build/ir/mod.rs | 132 ++++++++++++++++++ src/build/mod.rs | 8 +- src/build/validator/mod.rs | 6 +- 13 files changed, 360 insertions(+), 19 deletions(-) rename src/build/{ => grammar}/parse_error_formatter.rs (98%) create mode 100644 src/build/ir/builder_error_formatter.rs create mode 100644 src/build/ir/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 6dfa234..1875360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,16 @@ version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -291,6 +301,7 @@ dependencies = [ "clap", "cliclack", "console", + "inkwell", "lalrpop", "lalrpop-util", "pretty_assertions", @@ -309,6 +320,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -397,6 +414,30 @@ dependencies = [ "web-time", ] +[[package]] +name = "inkwell" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7decbc9dfa45a4a827a6ff7b822c113b1285678a937e84213417d4ca8a095782" +dependencies = [ + "bitflags", + "inkwell_internals", + "libc", + "llvm-sys", + "thiserror", +] + +[[package]] +name = "inkwell_internals" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfe97ee860815a90ed17e09639513269e39420a7440f3f4c996f238c514cf8d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -468,6 +509,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -497,6 +544,20 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "llvm-sys" +version = "221.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2abcc34a3b190f03c2a61b555f218f529589ff13657bdd2ff8ac3e85f2abe6bb" +dependencies = [ + "anyhow", + "cc", + "lazy_static", + "libc", + "regex-lite", + "semver", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -699,6 +760,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -797,6 +864,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "siphasher" version = "1.0.2" @@ -874,6 +947,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 64a03d7..5c5d34e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ cliclack = "0.3.9" console = "0.16.2" vfs = "0.12.2" lalrpop-util = "0.23.1" +inkwell = { version = "0.9.0", features = ["llvm22-1"] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/fil.nix b/fil.nix index ee4378d..045aec0 100644 --- a/fil.nix +++ b/fil.nix @@ -1,4 +1,11 @@ -{ rustPlatform, lib }: +{ + rustPlatform, + lib, + libllvm, + libffi, + libxml2, + zlib, +}: rustPlatform.buildRustPackage rec { name = "fil"; @@ -8,6 +15,13 @@ rustPlatform.buildRustPackage rec { lockFile = "${src}/Cargo.lock"; }; + nativeBuildInputs = [ libllvm ]; + buildInputs = [ + libffi + libxml2 + zlib + ]; + doCheck = true; checkPhase = '' runHook preCheck diff --git a/flake.lock b/flake.lock index 16a747c..31b5d54 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1769598131, - "narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=", + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 2ccafe5..546857b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,6 @@ { inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; treefmt-nix = { url = "github:numtide/treefmt-nix/28b19c5844cc6e2257801d43f2772a4b4c050a1b"; @@ -22,9 +22,14 @@ eachSystem = nixpkgs.lib.genAttrs (import systems); pkgs = eachSystem (system: import nixpkgs { inherit system; }); - fil-version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version; + fil-version = (fromTOML (builtins.readFile ./Cargo.toml)).package.version; - fil-package = eachSystem (system: pkgs.${system}.callPackage ./fil.nix { }); + fil-package = eachSystem ( + system: + pkgs.${system}.callPackage ./fil.nix { + libllvm = pkgs.${system}.llvmPackages_22.libllvm; + } + ); rpm-package = eachSystem ( system: pkgs.${system}.callPackage ./tools/package/rpm.nix { @@ -56,12 +61,22 @@ packages = with pkgs.${system}; [ git rustup + llvmPackages_22.libllvm + libffi + libxml2 (import ./tools/nix/treefmt.nix { inherit treefmt-nix; pkgs = pkgs.${system}; }) ]; + LD_LIBRARY_PATH = + with pkgs.${system}; + lib.makeLibraryPath [ + libffi + stdenv.cc.cc + ]; + shellHook = '' export ROOT_DIR=$(git rev-parse --show-toplevel) export PATH="$PATH:$ROOT_DIR/tools/bin" diff --git a/src/build/grammar/ast.rs b/src/build/grammar/ast.rs index c3d144b..2422a3b 100644 --- a/src/build/grammar/ast.rs +++ b/src/build/grammar/ast.rs @@ -18,7 +18,7 @@ use std::fmt::{Debug, Formatter}; pub enum Expr { - Number(i32), + Number(u32), Op(Box, Opcode, Box), } diff --git a/src/build/grammar/grammar.lalrpop b/src/build/grammar/grammar.lalrpop index ee179aa..57772fe 100644 --- a/src/build/grammar/grammar.lalrpop +++ b/src/build/grammar/grammar.lalrpop @@ -28,6 +28,6 @@ Term: Box = { "(" ")" }; -Num: i32 = { - r"[0-9]+" => i32::from_str(<>).unwrap() +Num: u32 = { + r"[0-9]+" => u32::from_str(<>).unwrap() }; diff --git a/src/build/grammar/mod.rs b/src/build/grammar/mod.rs index d9edd70..c2075ab 100644 --- a/src/build/grammar/mod.rs +++ b/src/build/grammar/mod.rs @@ -16,12 +16,13 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. use crate::build::grammar::ast::Expr; -use crate::build::parse_error_formatter::format_parse_error; use crate::fault; use crate::fault::Fault; +use parse_error_formatter::format_parse_error; pub mod ast; pub mod grammar; +mod parse_error_formatter; pub fn parse_file(main_source_file: &vfs::path::VfsPath) -> fault::Result> { main_source_file diff --git a/src/build/parse_error_formatter.rs b/src/build/grammar/parse_error_formatter.rs similarity index 98% rename from src/build/parse_error_formatter.rs rename to src/build/grammar/parse_error_formatter.rs index 7c75d67..67c5317 100644 --- a/src/build/parse_error_formatter.rs +++ b/src/build/grammar/parse_error_formatter.rs @@ -167,7 +167,9 @@ fn collect_lines(start: usize, end: usize, source: &String) -> Vec { #[cfg(test)] mod test { - use crate::build::parse_error_formatter::{find_line, format_expected, format_parse_error}; + use crate::build::grammar::parse_error_formatter::{ + find_line, format_expected, format_parse_error, + }; use lalrpop_util::ParseError; use lalrpop_util::lexer::Token; use pretty_assertions::{assert_eq, assert_str_eq}; diff --git a/src/build/ir/builder_error_formatter.rs b/src/build/ir/builder_error_formatter.rs new file mode 100644 index 0000000..46c8470 --- /dev/null +++ b/src/build/ir/builder_error_formatter.rs @@ -0,0 +1,81 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use inkwell::builder::BuilderError; + +pub fn format_builder_error(error: &BuilderError) -> String { + format!("{error}") +} + +#[cfg(test)] +mod test { + use crate::build::ir::builder_error_formatter::format_builder_error; + use inkwell::builder::{BuilderError, CmpxchgOrderingError}; + use inkwell::error::AlignmentError; + use inkwell::values::AtomicError; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_it_returns_error_message() { + assert_str_eq!( + "Builder position is not set", + format_builder_error(&BuilderError::UnsetPosition) + ); + assert_str_eq!( + "Alignment error", + format_builder_error(&BuilderError::AlignmentError(AlignmentError::Unsized)) + ); + assert_str_eq!( + "Aggregate extract index out of range", + format_builder_error(&BuilderError::ExtractOutOfRange) + ); + assert_str_eq!( + "The bitwidth of value must be a power of 2 and greater than or equal to 8.", + format_builder_error(&BuilderError::BitwidthError) + ); + assert_str_eq!( + "Pointee type does not match the value's type", + format_builder_error(&BuilderError::PointeeTypeMismatch) + ); + assert_str_eq!( + "Values must have the same type", + format_builder_error(&BuilderError::NotSameType) + ); + assert_str_eq!( + "Values must have pointer or integer type", + format_builder_error(&BuilderError::NotPointerOrInteger) + ); + assert_str_eq!( + "Cmpxchg ordering error or mismatch", + format_builder_error(&BuilderError::CmpxchgOrdering( + CmpxchgOrderingError::WeakerThanMonotic + )) + ); + assert_str_eq!( + "Atomic ordering error", + format_builder_error(&BuilderError::AtomicOrdering(AtomicError::ReleaseOnLoad)) + ); + assert_str_eq!( + "GEP pointee is not a struct", + format_builder_error(&BuilderError::GEPPointee) + ); + assert_str_eq!( + "GEP index out of range", + format_builder_error(&BuilderError::GEPIndex) + ); + } +} diff --git a/src/build/ir/mod.rs b/src/build/ir/mod.rs new file mode 100644 index 0000000..bdcc33c --- /dev/null +++ b/src/build/ir/mod.rs @@ -0,0 +1,132 @@ +// fil +// Copyright (C) 2026 - Present fil contributors +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +mod builder_error_formatter; + +use crate::build::grammar::ast::{Expr, Opcode}; +use crate::build::ir::builder_error_formatter::format_builder_error; +use crate::fault; +use crate::fault::Fault; +use inkwell::builder::Builder; +use inkwell::context::Context; +use inkwell::module::Module; +use inkwell::values::{FunctionValue, IntValue}; + +struct Compiler<'a, 'ctx> { + pub context: &'ctx Context, + pub builder: &'a Builder<'ctx>, + pub module: &'a Module<'ctx>, +} + +impl<'a, 'ctx> Compiler<'a, 'ctx> { + fn compile_expr(&mut self, expr: &Expr) -> fault::Result> { + match expr { + Expr::Number(n) => Ok(self.context.i32_type().const_int(*n as u64, false)), + Expr::Op(l, o, r) => self.compile_operator(l, o, r), + } + } + + fn compile_operator( + &mut self, + left: &Expr, + operator: &Opcode, + right: &Expr, + ) -> fault::Result> { + let lhs = self.compile_expr(left)?; + let rhs = self.compile_expr(right)?; + + match operator { + Opcode::Mul => self.builder.build_int_mul(lhs, rhs, "fil_mul"), + Opcode::Div => self.builder.build_int_unsigned_div(lhs, rhs, "fil_dib"), + Opcode::Add => self.builder.build_int_add(lhs, rhs, "fil_add"), + Opcode::Sub => self.builder.build_int_sub(lhs, rhs, "fil_sub"), + } + .map_err(|err| Fault::from_message(format_builder_error(&err).as_str())) + } + + fn entry_function(&self) -> fault::Result> { + let function_type = self.context.i32_type().fn_type(&[], false); + let function_value = self.module.add_function("main", function_type, None); + + Ok(function_value) + } + + pub fn compile( + context: &'ctx Context, + builder: &'a Builder<'ctx>, + module: &'a Module<'ctx>, + expr: &Expr, + ) -> fault::Result> { + let mut compiler = Self { + context, + builder, + module, + }; + + let function = compiler.entry_function()?; + let entry = compiler.context.append_basic_block(function, "entry"); + compiler.builder.position_at_end(entry); + + let body = compiler.compile_expr(expr)?; + + compiler + .builder + .build_return(Some(&body)) + .map_err(|err| Fault::from_error(Box::from(err)))?; + + if function.verify(true) { + Ok(function) + } else { + unsafe { + function.delete(); + } + Err(Fault::from_message("Invalid generated main function")) + } + } +} + +pub fn transform_to_ir(expr: &Expr) -> fault::Result { + let context = Context::create(); + let builder = context.create_builder(); + let module = context.create_module("fil"); + + Compiler::compile(&context, &builder, &module, expr).map(|ir| format!("{ir}")) +} + +#[cfg(test)] +mod test { + use crate::build::grammar::grammar; + use crate::build::ir::transform_to_ir; + use crate::fault; + use crate::fault::Fault; + use pretty_assertions::assert_str_eq; + + fn generate_ir(input: &str) -> fault::Result { + let expr = grammar::ExprParser::new() + .parse(input) + .map_err(|_| Fault::from_message("Failed to parse input"))?; + transform_to_ir(&expr) + } + + #[test] + fn test_it_generates_some_ir() { + assert_str_eq!( + "\"define i32 @main() {\\nentry:\\n ret i32 2\\n}\\n\"", + generate_ir("1+1").unwrap() + ); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs index 0d5cec1..ea3e5f4 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -16,10 +16,11 @@ // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. mod grammar; -mod parse_error_formatter; +mod ir; mod validator; use crate::build::grammar::parse_file; +use crate::build::ir::transform_to_ir; use crate::build::validator::validate; use crate::cli::Cli; use crate::cli::build::CommandBuild; @@ -35,9 +36,10 @@ pub fn build( .join("src/main.fil") .map_err(|error| Fault::from_error(Box::from(error))) .and_then(|main_source_file| parse_file(&main_source_file)) - .and_then(|expr| validate(&expr)); + .and_then(|expr| validate(&expr).map(|_| expr)) + .and_then(|expr| transform_to_ir(&expr)) + .map(|ir| println!("{ir}")); // TODO: - // - match visitor to build IR (llvm?) // - linking into executable expr.and_then(|_| Err(Fault::from_message("Not yet implemented"))) diff --git a/src/build/validator/mod.rs b/src/build/validator/mod.rs index 269b674..3432762 100644 --- a/src/build/validator/mod.rs +++ b/src/build/validator/mod.rs @@ -23,14 +23,14 @@ pub fn validate(expr: &Expr) -> fault::Result<()> { validate_expression(expr).map(|_| ()) } -fn validate_expression(expr: &Expr) -> fault::Result { +fn validate_expression(expr: &Expr) -> fault::Result { match expr { Expr::Number(n) => Ok(*n), Expr::Op(l, o, r) => validate_operator(l, o, r), } } -fn validate_operator(left: &Expr, operator: &Opcode, right: &Expr) -> fault::Result { +fn validate_operator(left: &Expr, operator: &Opcode, right: &Expr) -> fault::Result { match operator { Opcode::Mul => validate_expression(right).and_then(|right_result| { validate_expression(left).map(|left_result| left_result * right_result) @@ -59,7 +59,7 @@ mod test { use crate::fault::Fault; use pretty_assertions::{assert_eq, assert_str_eq}; - fn parse_and_validate(input: &str) -> fault::Result { + fn parse_and_validate(input: &str) -> fault::Result { grammar::ExprParser::new() .parse(input) .map_err(|_| Fault::from_message("Failed to parse expression"))