diff --git a/Cargo.lock b/Cargo.lock index 7b82666..3a34108 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "camino" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" + [[package]] name = "cfg-if" version = "1.0.1" @@ -193,6 +199,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "hem" version = "0.1.0" dependencies = [ + "camino", "clap", "insta", "miette", diff --git a/Cargo.toml b/Cargo.toml index 8360f5b..8766b73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" description = "Hemera's Expression Manipulator, editing text on the CLI" [dependencies] +camino = "1.1.10" clap = { version = "4.5.40", features = ["derive"] } insta = "1.43.1" miette = { version = "7.6.0", features = ["fancy"] } diff --git a/flake.nix b/flake.nix index 30a42b8..8afc9d7 100644 --- a/flake.nix +++ b/flake.nix @@ -100,6 +100,7 @@ rustfmt' rustTarget + pkgs.cargo-insta inputs.cargo-changelog.packages.${system}.default ]; }; diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..766f441 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,49 @@ +use camino::Utf8PathBuf; +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct Args { + #[clap(short, long)] + pub expression: String, + + #[clap(short, long, default_value_t = Utf8PathBuf::from("-"))] + input: Utf8PathBuf, + + #[clap(short, long, group = "delimiters")] + lines: bool, + + #[clap(short, long, group = "delimiters", default_value_t = true)] + words: bool, +} + +pub enum InputDelimiter { + Lines, + Words, +} + +pub enum Input { + Stdin, + FilePath(Utf8PathBuf), +} + +impl Args { + pub fn delimiter(&self) -> InputDelimiter { + if self.lines { + return InputDelimiter::Lines; + } + + if self.words { + return InputDelimiter::Words; + } + + unreachable!("Either lines or words has to be true") + } + + pub fn input(&self) -> Input { + if self.input == "-" { + Input::Stdin + } else { + Input::FilePath(self.input.clone()) + } + } +} diff --git a/src/expr.rs b/src/expr.rs new file mode 100644 index 0000000..7c00b7a --- /dev/null +++ b/src/expr.rs @@ -0,0 +1,173 @@ +use std::ops::Range; + +use miette::LabeledSpan; +use winnow::LocatingSlice; +use winnow::ModalResult; +use winnow::Parser; +use winnow::ascii::escaped; +use winnow::ascii::space0; +use winnow::ascii::space1; +use winnow::combinator::alt; +use winnow::combinator::delimited; +use winnow::combinator::separated; +use winnow::error::ContextError; +use winnow::error::StrContext; +use winnow::token::none_of; + +pub fn parse(src: &str) -> miette::Result> { + Ok(TokenList { + expressions: parse_program + .parse(LocatingSlice::new(src)) + .map_err(|parse_error| { + let labels = vec![LabeledSpan::new( + Some("Here".to_string()), + parse_error.offset(), + 0, + )]; + + miette::diagnostic!( + labels = labels, + "Could not parse expression: {:?}", + parse_error + ) + })?, + src, + }) +} + +type Input<'p> = LocatingSlice<&'p str>; +type Error = ContextError; + +#[derive(Debug, Clone)] +pub struct TokenList<'p> { + expressions: Vec>, + src: &'p str, +} + +#[derive(Debug, Clone)] +pub struct Token<'p> { + span: Range, + expr: Tok<'p>, +} + +#[derive(Debug, Clone)] +pub enum Tok<'p> { + Print, + PerWord, + PerLine, + Match, + FunctionApplication, + String(String), + Dummy(&'p ()), +} + +fn parse_program<'p>(input: &mut Input<'p>) -> ModalResult>, Error> { + delimited(space0, separated(1.., parse_expression, space1), space0) + .context(StrContext::Label("program")) + .parse_next(input) +} + +fn parse_expression<'p>(input: &mut Input<'p>) -> ModalResult, Error> { + alt((parse_builtin, parse_string)) + .context(StrContext::Label("expression")) + .parse_next(input) +} + +fn parse_builtin<'p>(input: &mut Input<'p>) -> ModalResult, Error> { + alt(( + "@w".value(Tok::PerWord) + .context(winnow::error::StrContext::Label("@w")), + "@l".value(Tok::PerLine) + .context(winnow::error::StrContext::Label("@l")), + "print" + .value(Tok::Print) + .context(winnow::error::StrContext::Label("print")), + "match" + .value(Tok::Match) + .context(winnow::error::StrContext::Label("match")), + "|".value(Tok::FunctionApplication) + .context(winnow::error::StrContext::Label("|")), + )) + .with_span() + .map(|(expr, span)| Token { expr, span }) + .parse_next(input) +} + +fn parse_string<'p>(input: &mut Input<'p>) -> ModalResult, Error> { + let content = escaped( + none_of(['\'', '\\']), + '\\', + alt(("\\".value("\\"), "\'".value("\'"))), + ) + .map(Tok::String); + + delimited('\'', content, '\'') + .with_span() + .map(|(expr, span)| Token { expr, span }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_simple_print() { + let input = "@w print"; + + let expr = parse(input).unwrap(); + + insta::assert_debug_snapshot!(expr, @r#" + TokenList { + expressions: [ + Token { + span: 0..2, + expr: PerWord, + }, + Token { + span: 3..8, + expr: Print, + }, + ], + src: "@w print", + } + "#); + } + + #[test] + fn check_complex_print() { + let input = "@l match 'foo' | print"; + + let expr = parse(input).unwrap(); + + insta::assert_debug_snapshot!(expr, @r#" + TokenList { + expressions: [ + Token { + span: 0..2, + expr: PerLine, + }, + Token { + span: 3..8, + expr: Match, + }, + Token { + span: 9..14, + expr: String( + "foo", + ), + }, + Token { + span: 15..16, + expr: FunctionApplication, + }, + Token { + span: 17..22, + expr: Print, + }, + ], + src: "@l match 'foo' | print", + } + "#); + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..21a1e04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,14 @@ -fn main() { - println!("Hello, world!"); +use clap::Parser; + +mod cli; +mod expr; + +fn main() -> miette::Result<()> { + let args = cli::Args::parse(); + let input_delim = args.delimiter(); + let input = args.input(); + + let expression = expr::parse(&args.expression)?; + + Ok(()) }