Repository: sharkdp/numbat (rust)

Issue:
Support prefix unary '+'?

Pull request description:
Add support for prefix unary plus operator
Fix #120

Hey, I was looking for easy-to-implement issues just to improve my understanding of the code base.
Let me know if you have other ideas

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 1e07885..41863c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,12 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
 [[package]]
 name = "anstream"
 version = "0.5.0"
@@ -102,6 +108,17 @@ dependencies = [
  "webpki-roots",
 ]
 
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.1.0"
@@ -152,6 +169,12 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
 
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
 [[package]]
 name = "cc"
 version = "1.0.83"
@@ -167,6 +190,45 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "ciborium"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "effd91f6c78e5a4ace8a5d3c0b6bfaec9e2baaef55f3efc00e45fb2e477ee926"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdf919175532b369853f5d5e20b26b43112613fd6fe7aee757e35f7a44642656"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "defaa24ecc093c77630e6c15e17c51f5e187bf35ee514f4e2d67baaa96dae22b"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[package]]
+name = "clap"
+version = "3.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
+dependencies = [
+ "bitflags 1.3.2",
+ "clap_lex 0.2.4",
+ "indexmap",
+ "textwrap",
+]
+
 [[package]]
 name = "clap"
 version = "4.4.4"
@@ -185,7 +247,7 @@ checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56"
 dependencies = [
  "anstream",
  "anstyle",
- "clap_lex",
+ "clap_lex 0.5.1",
  "strsim",
  "terminal_size",
 ]
@@ -202,6 +264,15 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "clap_lex"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
+dependencies = [
+ "os_str_bytes",
+]
+
 [[package]]
 name = "clap_lex"
 version = "0.5.1"
@@ -267,6 +338,75 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "criterion"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c76e09c1aae2bc52b3d2f29e13c6572553b30c4aa1b8a49fd70de6412654cb"
+dependencies = [
+ "anes",
+ "atty",
+ "cast",
+ "ciborium",
+ "clap 3.2.25",
+ "criterion-plot",
+ "itertools 0.10.5",
+ "lazy_static",
+ "num-traits",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools 0.10.5",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
+dependencies = [
+ "cfg-if",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "crossbeam-utils",
+ "memoffset",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "crypto-common"
 version = "0.1.6"
@@ -451,6 +591,18 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
 
+[[package]]
+name = "half"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
 [[package]]
 name = "heck"
 version = "0.4.1"
@@ -460,6 +612,15 @@ dependencies = [
  "unicode-segmentation",
 ]
 
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.3.3"
@@ -496,6 +657,16 @@ dependencies = [
  "unicode-normalization",
 ]
 
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+]
+
 [[package]]
 name = "insta"
 version = "1.33.0"
@@ -515,11 +686,20 @@ version = "0.4.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
 dependencies = [
- "hermit-abi",
+ "hermit-abi 0.3.3",
  "rustix",
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.11.0"
@@ -580,6 +760,15 @@ version = "2.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
 
+[[package]]
+name = "memoffset"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "nibble_vec"
 version = "0.1.0"
@@ -654,10 +843,11 @@ version = "1.6.3"
 dependencies = [
  "approx",
  "codespan-reporting",
+ "criterion",
  "glob",
  "heck",
  "insta",
- "itertools",
+ "itertools 0.11.0",
  "libc",
  "num-integer",
  "num-rational",
@@ -678,10 +868,10 @@ version = "1.6.3"
 dependencies = [
  "anyhow",
  "assert_cmd",
- "clap",
+ "clap 4.4.4",
  "colored",
  "dirs 5.0.1",
- "itertools",
+ "itertools 0.11.0",
  "numbat",
  "predicates",
  "rustyline",
@@ -701,18 +891,58 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
 
+[[package]]
+name = "oorandom"
+version = "11.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575"
+
 [[package]]
 name = "option-ext"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
 
+[[package]]
+name = "os_str_bytes"
+version = "6.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
 
+[[package]]
+name = "plotters"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c224ba00d7cadd4d5c660deaf2098e5e80e07846537c51f9cfa4be50c1fd45"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e76628b4d3a7581389a35d5b6e2139607ad7c75b17aed325f210aa91f4a9609"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38f6d39893cca0701371e3c27294f09797214b86f1fb951b89ade8ec04e2abab"
+dependencies = [
+ "plotters-backend",
+]
+
 [[package]]
 name = "predicates"
 version = "3.0.4"
@@ -722,7 +952,7 @@ dependencies = [
  "anstyle",
  "difflib",
  "float-cmp",
- "itertools",
+ "itertools 0.11.0",
  "normalize-line-endings",
  "predicates-core",
  "regex",
@@ -790,6 +1020,26 @@ dependencies = [
  "nibble_vec",
 ]
 
+[[package]]
+name = "rayon"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.2.16"
@@ -959,6 +1209,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
 [[package]]
 name = "ryu_floating_decimal"
 version = "0.1.0"
@@ -1010,6 +1266,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
 [[package]]
 name = "sha2"
 version = "0.10.7"
@@ -1096,6 +1363,12 @@ version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76"
 
+[[package]]
+name = "textwrap"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
+
 [[package]]
 name = "thiserror"
 version = "1.0.48"
@@ -1116,6 +1389,16 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "tinyvec"
 version = "1.6.0"
diff --git a/examples/parse_error/duplicated_operator.nbt b/examples/parse_error/duplicated_operator.nbt
deleted file mode 100644
index 8778067..0000000
--- a/examples/parse_error/duplicated_operator.nbt
+++ /dev/null
@@ -1,1 +0,0 @@
-123 ++ 321
diff --git a/numbat/Cargo.toml b/numbat/Cargo.toml
index a8271d5..010fc4e 100644
--- a/numbat/Cargo.toml
+++ b/numbat/Cargo.toml
@@ -33,3 +33,8 @@ approx = "0.5"
 glob = "0.3"
 insta = "1.33.0"
 once_cell = "1.18.0"
+criterion = { version = "0.4", features = ["html_reports"] }
+
+[[bench]]
+name = "prelude"
+harness = false
diff --git a/numbat/benches/prelude.rs b/numbat/benches/prelude.rs
new file mode 100644
index 0000000..601e2a9
--- /dev/null
+++ b/numbat/benches/prelude.rs
@@ -0,0 +1,18 @@
+use criterion::{criterion_group, criterion_main, Criterion};
+use numbat::module_importer::BuiltinModuleImporter;
+use numbat::resolver::CodeSource;
+use numbat::Context;
+
+fn import_prelude(c: &mut Criterion) {
+    let importer = BuiltinModuleImporter::default();
+    let context = Context::new(importer);
+    c.bench_function("Import prelude", |b| {
+        b.iter_with_setup(
+            || context.clone(),
+            |mut ctx| ctx.interpret("use prelude", CodeSource::Text),
+        )
+    });
+}
+
+criterion_group!(benches, import_prelude);
+criterion_main!(benches);
diff --git a/numbat/src/interpreter.rs b/numbat/src/interpreter.rs
index 5fcb6b6..4ce9f63 100644
--- a/numbat/src/interpreter.rs
+++ b/numbat/src/interpreter.rs
@@ -136,6 +136,7 @@ mod tests {
             .interpret_statements(&mut InterpreterSettings::default(), &statements_typechecked)
     }
 
+    #[track_caller]
     fn assert_evaluates_to(input: &str, expected: Quantity) {
         if let InterpreterResult::Value(actual) = get_interpreter_result(input).unwrap() {
             let actual = actual.unsafe_as_quantity();
@@ -145,10 +146,12 @@ mod tests {
         }
     }
 
+    #[track_caller]
     fn assert_evaluates_to_scalar(input: &str, expected: f64) {
         assert_evaluates_to(input, Quantity::from_scalar(expected))
     }
 
+    #[track_caller]
     fn assert_runtime_error(input: &str, err_expected: RuntimeError) {
         if let Err(err_actual) = get_interpreter_result(input) {
             assert_eq!(err_actual, err_expected);
@@ -173,6 +176,10 @@ mod tests {
         assert_evaluates_to_scalar("2 * -3", 2.0 * -3.0);
         assert_evaluates_to_scalar("2 - 3 - 4", 2.0 - 3.0 - 4.0);
         assert_evaluates_to_scalar("2 - -3", 2.0 - -3.0);
+
+        assert_evaluates_to_scalar("+2 * 3", 2.0 * 3.0);
+        assert_evaluates_to_scalar("2 * +3", 2.0 * 3.0);
+        assert_evaluates_to_scalar("+2 - +3", 2.0 - 3.0);
     }
 
     #[test]
diff --git a/numbat/src/parser.rs b/numbat/src/parser.rs
index 3fea863..2d5edca 100644
--- a/numbat/src/parser.rs
+++ b/numbat/src/parser.rs
@@ -28,9 +28,9 @@
 //! conversion      ::=   comparison ( ( "→" | "->" | "to" ) comparison ) *
 //! comparison      ::=   term ( (">" | ">="| "≥" | "<" | "<=" | "≤" | "==" | "!=" | "≠" ) term ) *
 //! term            ::=   factor ( ( "+" | "-") factor ) *
-//! factor          ::=   negate ( ( "*" | "/") per_factor ) *
-//! per_factor      ::=   negate ( "per" negate ) *
-//! negate          ::=   ( "-" negate ) | ifactor
+//! factor          ::=   unary ( ( "*" | "/") per_factor ) *
+//! per_factor      ::=   unary ( "per" unary ) *
+//! unary           ::=   ( ( minus | plus ) unary ) | ifactor
 //! ifactor         ::=   power ( " " power ) *
 //! power           ::=   factorial ( "^" "-" ? power ) ?
 //! factorial       ::=   unicode_power "!" *
@@ -827,11 +827,11 @@ impl<'a> Parser<'a> {
     }
 
     fn per_factor(&mut self) -> Result<Expression> {
-        let mut expr = self.negate()?;
+        let mut expr = self.unary()?;
 
         while self.match_exact(TokenKind::Per).is_some() {
             let span_op = Some(self.last().unwrap().span);
-            let rhs = self.negate()?;
+            let rhs = self.unary()?;
 
             expr = Expression::BinaryOperator {
                 op: BinaryOperator::Div,
@@ -844,16 +844,20 @@ impl<'a> Parser<'a> {
         Ok(expr)
     }
 
-    fn negate(&mut self) -> Result<Expression> {
+    fn unary(&mut self) -> Result<Expression> {
         if self.match_exact(TokenKind::Minus).is_some() {
             let span = self.last().unwrap().span;
-            let rhs = self.negate()?;
+            let rhs = self.unary()?;
 
             Ok(Expression::UnaryOperator {
                 op: UnaryOperator::Negate,
                 expr: Box::new(rhs),
                 span_op: span,
             })
+        } else if self.match_exact(TokenKind::Plus).is_some() {
+            // A unary `+` is equivalent to nothing. We can get rid of the
+            // symbol without inserting any nodes in the AST.
+            self.unary()
         } else {
             self.ifactor()
         }
@@ -1377,6 +1381,7 @@ mod tests {
     use super::*;
     use crate::ast::{binop, conditional, factorial, identifier, negate, scalar, ReplaceSpans};
 
+    #[track_caller]
     fn parse_as(inputs: &[&str], statement_expected: Statement) {
         for input in inputs {
             let statements = parse(input, 0).expect("parse error").replace_spans();
@@ -1388,16 +1393,19 @@ mod tests {
         }
     }
 
+    #[track_caller]
     fn parse_as_expression(inputs: &[&str], expr_expected: Expression) {
         parse_as(inputs, Statement::Expression(expr_expected));
     }
 
+    #[track_caller]
     fn should_fail(inputs: &[&str]) {
         for input in inputs {
             assert!(parse(input, 0).is_err());
         }
     }
 
+    #[track_caller]
     fn should_fail_with(inputs: &[&str], error_kind: ParseErrorKind) {
         for input in inputs {
             match parse(input, 0) {
@@ -1490,7 +1498,7 @@ mod tests {
     }
 
     #[test]
-    fn negation() {
+    fn unary() {
         parse_as_expression(&["-1", "  - 1   "], negate!(scalar!(1.0)));
         parse_as_expression(&["-123.45"], negate!(scalar!(123.45)));
         parse_as_expression(&["--1", " -  - 1   "], negate!(negate!(scalar!(1.0))));
@@ -1501,6 +1509,22 @@ mod tests {
             &["-1 + 2"],
             binop!(negate!(scalar!(1.0)), Add, scalar!(2.0)),
         );
+
+        parse_as_expression(&["+1", "  + 1   "], scalar!(1.0));
+        parse_as_expression(&["+123.45"], scalar!(123.45));
+        parse_as_expression(&["++1", " +  + 1   "], scalar!(1.0));
+        parse_as_expression(&["+x", " + x"], identifier!("x"));
+        parse_as_expression(&["+0.61", "+.61", "+  .61"], scalar!(0.61));
+
+        parse_as_expression(
+            &["+1 + 2", "1 +++++ 2"],
+            binop!(scalar!(1.0), Add, scalar!(2.0)),
+        );
+
+        parse_as_expression(
+            &["+1 - 2", "+1 - +2 "],
+            binop!(scalar!(1.0), Sub, scalar!(2.0)),
+        );
     }
 
     #[test]


Test patch:
diff --git a/numbat-cli/tests/integration.rs b/numbat-cli/tests/integration.rs
index c2c600c..4465714 100644
--- a/numbat-cli/tests/integration.rs
+++ b/numbat-cli/tests/integration.rs
@@ -31,9 +31,8 @@ fn pass_expression_on_command_line() {
 
     numbat()
         .arg("--expression")
-        .arg("2 ++ 3")
+        .arg("2 +/ 3")
         .assert()
-        .failure()
         .stderr(predicates::str::contains("while parsing"));
 
     numbat()