Browse Source

Merge branch 'master' into vscode-extension-quality-of-life-changes

Mads M. Jensen 1 year ago
parent
commit
7d1c912344
82 changed files with 1929 additions and 1240 deletions
  1. 20 0
      CITATION.cff
  2. 104 166
      Cargo.lock
  3. 25 15
      book/build.py
  4. 15 15
      book/src/list-functions-datetime.md
  5. 63 24
      book/src/list-functions-lists.md
  6. 139 45
      book/src/list-functions-math.md
  7. 90 24
      book/src/list-functions-other.md
  8. 16 16
      book/src/list-functions-strings.md
  9. 11 1
      examples/tests/core.nbt
  10. 23 0
      examples/tests/lists.nbt
  11. 78 0
      examples/tests/math_functions.nbt
  12. 0 2
      examples/tests/mixed_units.nbt
  13. 1 1
      numbat-cli/Cargo.toml
  14. 7 4
      numbat-cli/src/ansi_formatter.rs
  15. 3 3
      numbat-cli/src/completer.rs
  16. 3 2
      numbat-cli/src/config.rs
  17. 15 10
      numbat-cli/src/highlighter.rs
  18. 7 3
      numbat-cli/src/main.rs
  19. 21 10
      numbat-wasm/src/jquery_terminal_formatter.rs
  20. 1 1
      numbat-wasm/src/lib.rs
  21. 1 0
      numbat/Cargo.toml
  22. 12 11
      numbat/examples/inspect.rs
  23. 4 1
      numbat/examples/unit_graph.rs
  24. 11 0
      numbat/modules/core/functions.nbt
  25. 24 1
      numbat/modules/core/lists.nbt
  26. 10 0
      numbat/modules/core/mixed_units.nbt
  27. 18 0
      numbat/modules/core/numbers.nbt
  28. 1 1
      numbat/modules/core/quantities.nbt
  29. 35 0
      numbat/modules/math/combinatorics.nbt
  30. 2 0
      numbat/modules/prelude.nbt
  31. 2 3
      numbat/modules/units/mixed.nbt
  32. 14 13
      numbat/src/arithmetic.rs
  33. 12 8
      numbat/src/ast.rs
  34. 29 28
      numbat/src/bytecode_interpreter.rs
  35. 8 5
      numbat/src/column_formatter.rs
  36. 16 14
      numbat/src/datetime.rs
  37. 9 7
      numbat/src/decorator.rs
  38. 8 11
      numbat/src/diagnostic.rs
  39. 5 3
      numbat/src/dimension.rs
  40. 14 8
      numbat/src/ffi/datetime.rs
  41. 5 2
      numbat/src/ffi/functions.rs
  42. 1 1
      numbat/src/ffi/lists.rs
  43. 34 15
      numbat/src/ffi/lookup.rs
  44. 10 2
      numbat/src/ffi/macros.rs
  45. 1 0
      numbat/src/ffi/math.rs
  46. 2 4
      numbat/src/ffi/mod.rs
  47. 10 9
      numbat/src/ffi/plot.rs
  48. 12 6
      numbat/src/ffi/procedures.rs
  49. 5 5
      numbat/src/ffi/strings.rs
  50. 11 10
      numbat/src/html_formatter.rs
  51. 40 9
      numbat/src/interpreter/assert_eq.rs
  52. 17 13
      numbat/src/interpreter/mod.rs
  53. 75 38
      numbat/src/lib.rs
  54. 3 3
      numbat/src/list.rs
  55. 60 20
      numbat/src/markup.rs
  56. 4 3
      numbat/src/module_importer.rs
  57. 11 10
      numbat/src/name_resolution.rs
  58. 10 6
      numbat/src/number.rs
  59. 13 9
      numbat/src/parser.rs
  60. 2 1
      numbat/src/plot.rs
  61. 88 86
      numbat/src/prefix.rs
  62. 8 7
      numbat/src/prefix_parser.rs
  63. 93 159
      numbat/src/prefix_transformer.rs
  64. 23 10
      numbat/src/pretty_print.rs
  65. 8 10
      numbat/src/product.rs
  66. 32 6
      numbat/src/quantity.rs
  67. 13 12
      numbat/src/registry.rs
  68. 12 6
      numbat/src/resolver.rs
  69. 7 6
      numbat/src/session_history.rs
  70. 3 1
      numbat/src/type_variable.rs
  71. 17 7
      numbat/src/typechecker/constraints.rs
  72. 23 9
      numbat/src/typechecker/environment.rs
  73. 2 1
      numbat/src/typechecker/error.rs
  74. 20 15
      numbat/src/typechecker/incompatible_dimensions.rs
  75. 182 167
      numbat/src/typechecker/mod.rs
  76. 4 2
      numbat/src/typechecker/type_scheme.rs
  77. 35 31
      numbat/src/typed_ast.rs
  78. 38 31
      numbat/src/unit.rs
  79. 5 4
      numbat/src/unit_registry.rs
  80. 9 8
      numbat/src/value.rs
  81. 50 35
      numbat/src/vm.rs
  82. 54 5
      numbat/tests/interpreter.rs

+ 20 - 0
CITATION.cff

@@ -0,0 +1,20 @@
+cff-version: 1.2.0
+title: Numbat
+message: >-
+  If you use this software in scientific publications,
+  please consider citing it using the metadata from
+  this file.
+type: software
+authors:
+  - given-names: David
+    family-names: Peter
+    email: [email protected]
+    orcid: 'https://orcid.org/0000-0001-7950-9915'
+repository-code: 'https://github.com/sharkdp/numbat'
+abstract: >-
+  A statically typed programming language for scientific
+  computations with first class support for physical
+  dimensions and units.
+license: MIT
+version: 1.14.0
+date-released: '2024-10-11'

+ 104 - 166
Cargo.lock

@@ -2,19 +2,6 @@
 # It is not intended for manual editing.
 version = 3
 
-[[package]]
-name = "ahash"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
-dependencies = [
- "cfg-if",
- "getrandom",
- "once_cell",
- "version_check",
- "zerocopy",
-]
-
 [[package]]
 name = "aho-corasick"
 version = "1.1.3"
@@ -47,9 +34,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
 
 [[package]]
 name = "anstream"
-version = "0.6.15"
+version = "0.6.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
+checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338"
 dependencies = [
  "anstyle",
  "anstyle-parse",
@@ -62,43 +49,43 @@ dependencies = [
 
 [[package]]
 name = "anstyle"
-version = "1.0.8"
+version = "1.0.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
+checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
 
 [[package]]
 name = "anstyle-parse"
-version = "0.2.5"
+version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
+checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
 dependencies = [
  "utf8parse",
 ]
 
 [[package]]
 name = "anstyle-query"
-version = "1.1.1"
+version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
+checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
 name = "anstyle-wincon"
-version = "3.0.4"
+version = "3.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
+checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125"
 dependencies = [
  "anstyle",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
 name = "anyhow"
-version = "1.0.89"
+version = "1.0.92"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
+checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13"
 
 [[package]]
 name = "approx"
@@ -205,9 +192,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
 [[package]]
 name = "bytes"
-version = "1.7.2"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
+checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da"
 
 [[package]]
 name = "cast"
@@ -215,11 +202,20 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
 
+[[package]]
+name = "castaway"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "cc"
-version = "1.1.29"
+version = "1.1.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58e804ac3194a48bb129643eb1d62fcc20d18c6b8c181704489353d13120bcd1"
+checksum = "67b9470d453346108f93a59222a9a1a5724db32d0a4727b7ab7ace4b4d822dc9"
 dependencies = [
  "shlex",
 ]
@@ -230,6 +226,12 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "cfg_aliases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
+
 [[package]]
 name = "chrono"
 version = "0.4.38"
@@ -332,9 +334,9 @@ dependencies = [
 
 [[package]]
 name = "colorchoice"
-version = "1.0.2"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
+checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 
 [[package]]
 name = "colored"
@@ -346,6 +348,21 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "compact_str"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "serde",
+ "static_assertions",
+]
+
 [[package]]
 name = "console"
 version = "0.15.8"
@@ -806,9 +823,9 @@ dependencies = [
 
 [[package]]
 name = "insta"
-version = "1.40.0"
+version = "1.41.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6593a41c7a73841868772495db7dc1e8ecab43bb5c0b6da2059246c4b506ab60"
+checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8"
 dependencies = [
  "console",
  "lazy_static",
@@ -859,9 +876,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
 
 [[package]]
 name = "jiff"
-version = "0.1.13"
+version = "0.1.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a45489186a6123c128fdf6016183fcfab7113e1820eb813127e036e287233fb"
+checksum = "b9d9d414fc817d3e3d62b2598616733f76c4cc74fbac96069674739b881295c8"
 dependencies = [
  "jiff-tzdb-platform",
  "js-sys",
@@ -901,15 +918,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
 
 [[package]]
 name = "libc"
-version = "0.2.159"
+version = "0.2.161"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
+checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
 
 [[package]]
 name = "libm"
-version = "0.2.8"
+version = "0.2.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
+checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
 
 [[package]]
 name = "libredox"
@@ -933,16 +950,6 @@ version = "0.4.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
 
-[[package]]
-name = "lock_api"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
-dependencies = [
- "autocfg",
- "scopeguard",
-]
-
 [[package]]
 name = "log"
 version = "0.4.22"
@@ -957,9 +964,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
 [[package]]
 name = "mendeleev"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8312069eadd5b2cb9ff1bbd3874927d0cc60007ef608507cf8a59920b8a140fd"
+checksum = "7f8dd6ec5207f7f69db7abb42466511394956dc85faf163de1fe393246c8b7e4"
 dependencies = [
  "serde",
 ]
@@ -997,12 +1004,13 @@ dependencies = [
 
 [[package]]
 name = "nix"
-version = "0.27.1"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
 dependencies = [
  "bitflags",
  "cfg-if",
+ "cfg_aliases",
  "libc",
 ]
 
@@ -1083,6 +1091,7 @@ version = "1.14.0"
 dependencies = [
  "approx",
  "codespan-reporting",
+ "compact_str",
  "criterion",
  "glob",
  "heck 0.4.1",
@@ -1146,18 +1155,6 @@ version = "1.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
 
-[[package]]
-name = "once_map"
-version = "0.4.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed29bb6f7d6ac14023acb332a356f3891265d780e254057c866dbe7a909d2d2d"
-dependencies = [
- "ahash",
- "hashbrown 0.15.0",
- "parking_lot",
- "stable_deref_trait",
-]
-
 [[package]]
 name = "oorandom"
 version = "11.1.4"
@@ -1170,29 +1167,6 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
 
-[[package]]
-name = "parking_lot"
-version = "0.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
-dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-targets 0.52.6",
-]
-
 [[package]]
 name = "percent-encoding"
 version = "2.3.1"
@@ -1313,9 +1287,9 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.87"
+version = "1.0.89"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a"
+checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e"
 dependencies = [
  "unicode-ident",
 ]
@@ -1398,15 +1372,6 @@ dependencies = [
  "crossbeam-utils",
 ]
 
-[[package]]
-name = "redox_syscall"
-version = "0.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f"
-dependencies = [
- "bitflags",
-]
-
 [[package]]
 name = "redox_users"
 version = "0.4.6"
@@ -1420,9 +1385,9 @@ dependencies = [
 
 [[package]]
 name = "regex"
-version = "1.11.0"
+version = "1.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -1464,13 +1429,12 @@ dependencies = [
 
 [[package]]
 name = "rinja"
-version = "0.3.4"
+version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f28580fecce391f3c0e65a692e5f2b5db258ba2346ee04f355ae56473ab973dc"
+checksum = "3dc4940d00595430b3d7d5a01f6222b5e5b51395d1120bdb28d854bb8abb17a5"
 dependencies = [
  "humansize",
  "itoa",
- "num-traits",
  "percent-encoding",
  "rinja_derive",
  "serde",
@@ -1479,15 +1443,14 @@ dependencies = [
 
 [[package]]
 name = "rinja_derive"
-version = "0.3.4"
+version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f1ae91455a4c82892d9513fcfa1ac8faff6c523602d0041536341882714aede"
+checksum = "08d9ed0146aef6e2825f1b1515f074510549efba38d71f4554eec32eb36ba18b"
 dependencies = [
  "basic-toml",
  "memchr",
  "mime",
  "mime_guess",
- "once_map",
  "proc-macro2",
  "quote",
  "rinja_parser",
@@ -1498,9 +1461,9 @@ dependencies = [
 
 [[package]]
 name = "rinja_parser"
-version = "0.3.4"
+version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06ea17639e1f35032e1c67539856e498c04cd65fe2a45f55ec437ec55e4be941"
+checksum = "93f9a866e2e00a7a1fb27e46e9e324a6f7c0e7edc4543cae1d38f4e4a100c610"
 dependencies = [
  "memchr",
  "nom",
@@ -1550,9 +1513,9 @@ checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
 
 [[package]]
 name = "rustix"
-version = "0.38.37"
+version = "0.38.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811"
+checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a"
 dependencies = [
  "bitflags",
  "errno",
@@ -1577,9 +1540,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-pki-types"
-version = "1.9.0"
+version = "1.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55"
+checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
 
 [[package]]
 name = "rustls-webpki"
@@ -1592,11 +1555,17 @@ dependencies = [
  "untrusted",
 ]
 
+[[package]]
+name = "rustversion"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248"
+
 [[package]]
 name = "rustyline"
-version = "13.0.0"
+version = "14.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86"
+checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
 dependencies = [
  "bitflags",
  "cfg-if",
@@ -1612,7 +1581,7 @@ dependencies = [
  "unicode-segmentation",
  "unicode-width",
  "utf8parse",
- "winapi",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
@@ -1647,26 +1616,20 @@ dependencies = [
  "winapi-util",
 ]
 
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
 [[package]]
 name = "serde"
-version = "1.0.210"
+version = "1.0.214"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
+checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.210"
+version = "1.0.214"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
+checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1675,9 +1638,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.128"
+version = "1.0.132"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
+checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
 dependencies = [
  "itoa",
  "memchr",
@@ -1780,10 +1743,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
 [[package]]
-name = "stable_deref_trait"
-version = "1.2.0"
+name = "static_assertions"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
 [[package]]
 name = "strfmt"
@@ -1805,9 +1768,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
-version = "2.0.79"
+version = "2.0.87"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
+checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1851,18 +1814,18 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76"
 
 [[package]]
 name = "thiserror"
-version = "1.0.64"
+version = "1.0.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84"
+checksum = "3b3c6efbfc763e64eb85c11c25320f0737cb7364c4b6336db90aa9ebe27a0bbd"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.64"
+version = "1.0.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
+checksum = "b607164372e89797d78b8e23a6d67d5d1038c1c65efd52e1389ef8b77caba2a6"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1973,12 +1936,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
 
 [[package]]
 name = "unicase"
-version = "2.7.0"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
-dependencies = [
- "version_check",
-]
+checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df"
 
 [[package]]
 name = "unicode-bidi"
@@ -2147,22 +2107,6 @@ dependencies = [
  "rustls-pki-types",
 ]
 
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
 [[package]]
 name = "winapi-util"
 version = "0.1.9"
@@ -2172,12 +2116,6 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
 [[package]]
 name = "windows-core"
 version = "0.52.0"

+ 25 - 15
book/build.py

@@ -2,6 +2,7 @@ import subprocess
 from pathlib import Path
 import urllib.parse
 import os
+import sys
 
 
 SCRIPT_DIR = Path(__file__).parent.resolve()
@@ -122,21 +123,26 @@ def list_of_functions(file_name, document):
                 )
                 env = os.environ.copy()
                 env["TZ"] = "UTC"
-                subprocess.run(
-                    [
-                        "cargo",
-                        "run",
-                        "--release",
-                        "--quiet",
-                        "--example=inspect",
-                        "--",
-                        "functions",
-                        module,
-                    ],
-                    stdout=f,
-                    text=True,
-                    env=env,
-                )
+
+                try:
+                    subprocess.run(
+                        [
+                            "cargo",
+                            "run",
+                            "--release",
+                            "--quiet",
+                            "--example=inspect",
+                            "--",
+                            "functions",
+                            module,
+                        ],
+                        stdout=f,
+                        text=True,
+                        env=env,
+                        check=True
+                    )
+                except subprocess.CalledProcessError as e:
+                    sys.exit(e.returncode)
 
 
 list_of_functions(
@@ -160,6 +166,10 @@ list_of_functions(
                 "title": "Statistics",
                 "modules": ["math::statistics"],
             },
+            {
+                "title": "Combinatorics",
+                "modules": ["math::combinatorics"],
+            },
             {
                 "title": "Random sampling, distributions",
                 "modules": ["core::random", "math::distributions"],

+ 15 - 15
book/src/list-functions-datetime.md

@@ -21,17 +21,17 @@ fn datetime(input: String) -> DateTime
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20T21%3A52%2B0200%22%29')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022-07-20T21:52+0200")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20T21%3A52%2B0200%22%29')""></button></div><code class="language-nbt hljs numbat">datetime("2022-07-20T21:52+0200")
 
     = 2022-07-20 19:52:00 UTC    [DateTime]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20Europe%2FBerlin%22%29')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022-07-20 21:52 Europe/Berlin")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20Europe%2FBerlin%22%29')""></button></div><code class="language-nbt hljs numbat">datetime("2022-07-20 21:52 Europe/Berlin")
 
     = 2022-07-20 21:52:00 CEST (UTC +02), Europe/Berlin    [DateTime]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2F07%2F20%2009%3A52%20PM%20%2B0200%22%29')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022/07/20 09:52 PM +0200")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2F07%2F20%2009%3A52%20PM%20%2B0200%22%29')""></button></div><code class="language-nbt hljs numbat">datetime("2022/07/20 09:52 PM +0200")
 
     = 2022-07-20 21:52:00 (UTC +02)    [DateTime]
 </code></pre>
@@ -48,7 +48,7 @@ fn format_datetime(format: String, input: DateTime) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=format%5Fdatetime%28%22This%20is%20a%20date%20in%20%25B%20in%20the%20year%20%25Y%2E%22%2C%20datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">>>> format_datetime("This is a date in %B in the year %Y.", datetime("2022-07-20 21:52 +0200"))
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=format%5Fdatetime%28%22This%20is%20a%20date%20in%20%25B%20in%20the%20year%20%25Y%2E%22%2C%20datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">format_datetime("This is a date in %B in the year %Y.", datetime("2022-07-20 21:52 +0200"))
 
     = "This is a date in July in the year 2022."    [String]
 </code></pre>
@@ -65,7 +65,7 @@ fn get_local_timezone() -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=get%5Flocal%5Ftimezone%28%29')""></button></div><code class="language-nbt hljs numbat">>>> get_local_timezone()
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=get%5Flocal%5Ftimezone%28%29')""></button></div><code class="language-nbt hljs numbat">get_local_timezone()
 
     = "UTC"    [String]
 </code></pre>
@@ -82,12 +82,12 @@ fn tz(tz: String) -> Fn[(DateTime) -> DateTime]
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20tz%28%22Europe%2FAmsterdam%22%29')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022-07-20 21:52 +0200") -> tz("Europe/Amsterdam")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20tz%28%22Europe%2FAmsterdam%22%29')""></button></div><code class="language-nbt hljs numbat">datetime("2022-07-20 21:52 +0200") -> tz("Europe/Amsterdam")
 
     = 2022-07-20 21:52:00 CEST (UTC +02), Europe/Amsterdam    [DateTime]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20tz%28%22Asia%2FTaipei%22%29')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022-07-20 21:52 +0200") -> tz("Asia/Taipei")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20tz%28%22Asia%2FTaipei%22%29')""></button></div><code class="language-nbt hljs numbat">datetime("2022-07-20 21:52 +0200") -> tz("Asia/Taipei")
 
     = 2022-07-21 03:52:00 CST (UTC +08), Asia/Taipei    [DateTime]
 </code></pre>
@@ -104,7 +104,7 @@ fn unixtime(input: DateTime) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20unixtime')""></button></div><code class="language-nbt hljs numbat">>>> datetime("2022-07-20 21:52 +0200") -> unixtime
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%20%2D%3E%20unixtime')""></button></div><code class="language-nbt hljs numbat">datetime("2022-07-20 21:52 +0200") -> unixtime
 
     = 1_658_346_720
 </code></pre>
@@ -121,7 +121,7 @@ fn from_unixtime(input: Scalar) -> DateTime
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Funixtime%282%5E31%29')""></button></div><code class="language-nbt hljs numbat">>>> from_unixtime(2^31)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Funixtime%282%5E31%29')""></button></div><code class="language-nbt hljs numbat">from_unixtime(2^31)
 
     = 2038-01-19 03:14:08 UTC    [DateTime]
 </code></pre>
@@ -145,7 +145,7 @@ fn date(input: String) -> DateTime
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=date%28%222022%2D07%2D20%22%29')""></button></div><code class="language-nbt hljs numbat">>>> date("2022-07-20")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=date%28%222022%2D07%2D20%22%29')""></button></div><code class="language-nbt hljs numbat">date("2022-07-20")
 
     = 2022-07-20 00:00:00 UTC    [DateTime]
 </code></pre>
@@ -169,7 +169,7 @@ fn calendar_add(dt: DateTime, span: Time) -> DateTime
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=calendar%5Fadd%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%2C%202%20years%29')""></button></div><code class="language-nbt hljs numbat">>>> calendar_add(datetime("2022-07-20 21:52 +0200"), 2 years)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=calendar%5Fadd%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%2C%202%20years%29')""></button></div><code class="language-nbt hljs numbat">calendar_add(datetime("2022-07-20 21:52 +0200"), 2 years)
 
     = 2024-07-20 21:52:00 (UTC +02)    [DateTime]
 </code></pre>
@@ -186,7 +186,7 @@ fn calendar_sub(dt: DateTime, span: Time) -> DateTime
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=calendar%5Fsub%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%2C%203%20years%29')""></button></div><code class="language-nbt hljs numbat">>>> calendar_sub(datetime("2022-07-20 21:52 +0200"), 3 years)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=calendar%5Fsub%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%2C%203%20years%29')""></button></div><code class="language-nbt hljs numbat">calendar_sub(datetime("2022-07-20 21:52 +0200"), 3 years)
 
     = 2019-07-20 21:52:00 (UTC +02)    [DateTime]
 </code></pre>
@@ -203,7 +203,7 @@ fn weekday(dt: DateTime) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=weekday%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">>>> weekday(datetime("2022-07-20 21:52 +0200"))
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=weekday%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">weekday(datetime("2022-07-20 21:52 +0200"))
 
     = "Wednesday"    [String]
 </code></pre>
@@ -221,7 +221,7 @@ fn julian_date(dt: DateTime) -> Time
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=julian%5Fdate%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">>>> julian_date(datetime("2022-07-20 21:52 +0200"))
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=julian%5Fdate%28datetime%28%222022%2D07%2D20%2021%3A52%20%2B0200%22%29%29')""></button></div><code class="language-nbt hljs numbat">julian_date(datetime("2022-07-20 21:52 +0200"))
 
     = 2.45978e+6 day    [Time]
 </code></pre>
@@ -240,7 +240,7 @@ fn human(time: Time) -> String
 <summary>Examples</summary>
 
 How long is a microcentury?
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=century%2F1e6%20%2D%3E%20human')""></button></div><code class="language-nbt hljs numbat">>>> century/1e6 -> human
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=century%2F1e6%20%2D%3E%20human')""></button></div><code class="language-nbt hljs numbat">century/1e6 -> human
 
     = "52 minutes + 35.693 seconds"    [String]
 </code></pre>

+ 63 - 24
book/src/list-functions-lists.md

@@ -12,7 +12,7 @@ fn len<A>(xs: List<A>) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=len%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> len([3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=len%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">len([3, 2, 1])
 
     = 3
 </code></pre>
@@ -29,7 +29,7 @@ fn head<A>(xs: List<A>) -> A
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=head%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> head([3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=head%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">head([3, 2, 1])
 
     = 3
 </code></pre>
@@ -46,7 +46,7 @@ fn tail<A>(xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=tail%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> tail([3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=tail%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">tail([3, 2, 1])
 
     = [2, 1]    [List<Scalar>]
 </code></pre>
@@ -63,7 +63,7 @@ fn cons<A>(x: A, xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cons%2877%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> cons(77, [3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cons%2877%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">cons(77, [3, 2, 1])
 
     = [77, 3, 2, 1]    [List<Scalar>]
 </code></pre>
@@ -80,7 +80,7 @@ fn cons_end<A>(x: A, xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cons%5Fend%2877%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> cons_end(77, [3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cons%5Fend%2877%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">cons_end(77, [3, 2, 1])
 
     = [3, 2, 1, 77]    [List<Scalar>]
 </code></pre>
@@ -97,12 +97,12 @@ fn is_empty<A>(xs: List<A>) -> Bool
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fempty%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> is_empty([3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fempty%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">is_empty([3, 2, 1])
 
     = false    [Bool]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fempty%28%5B%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> is_empty([])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fempty%28%5B%5D%29')""></button></div><code class="language-nbt hljs numbat">is_empty([])
 
     = true    [Bool]
 </code></pre>
@@ -119,7 +119,7 @@ fn concat<A>(xs1: List<A>, xs2: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=concat%28%5B3%2C%202%2C%201%5D%2C%20%5B10%2C%2011%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> concat([3, 2, 1], [10, 11])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=concat%28%5B3%2C%202%2C%201%5D%2C%20%5B10%2C%2011%5D%29')""></button></div><code class="language-nbt hljs numbat">concat([3, 2, 1], [10, 11])
 
     = [3, 2, 1, 10, 11]    [List<Scalar>]
 </code></pre>
@@ -136,7 +136,7 @@ fn take<A>(n: Scalar, xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=take%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> take(2, [3, 2, 1, 0])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=take%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">take(2, [3, 2, 1, 0])
 
     = [3, 2]    [List<Scalar>]
 </code></pre>
@@ -153,7 +153,7 @@ fn drop<A>(n: Scalar, xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=drop%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> drop(2, [3, 2, 1, 0])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=drop%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">drop(2, [3, 2, 1, 0])
 
     = [1, 0]    [List<Scalar>]
 </code></pre>
@@ -170,7 +170,7 @@ fn element_at<A>(i: Scalar, xs: List<A>) -> A
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%5Fat%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> element_at(2, [3, 2, 1, 0])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%5Fat%282%2C%20%5B3%2C%202%2C%201%2C%200%5D%29')""></button></div><code class="language-nbt hljs numbat">element_at(2, [3, 2, 1, 0])
 
     = 1
 </code></pre>
@@ -187,7 +187,7 @@ fn range(start: Scalar, end: Scalar) -> List<Scalar>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=range%282%2C%2012%29')""></button></div><code class="language-nbt hljs numbat">>>> range(2, 12)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=range%282%2C%2012%29')""></button></div><code class="language-nbt hljs numbat">range(2, 12)
 
     = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]    [List<Scalar>]
 </code></pre>
@@ -204,7 +204,7 @@ fn reverse<A>(xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=reverse%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> reverse([3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=reverse%28%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">reverse([3, 2, 1])
 
     = [1, 2, 3]    [List<Scalar>]
 </code></pre>
@@ -222,7 +222,7 @@ fn map<A, B>(f: Fn[(A) -> B], xs: List<A>) -> List<B>
 <summary>Examples</summary>
 
 Square all elements of a list.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=map%28sqr%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> map(sqr, [3, 2, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=map%28sqr%2C%20%5B3%2C%202%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">map(sqr, [3, 2, 1])
 
     = [9, 4, 1]    [List<Scalar>]
 </code></pre>
@@ -239,7 +239,7 @@ fn filter<A>(p: Fn[(A) -> Bool], xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=filter%28is%5Ffinite%2C%20%5B0%2C%201e10%2C%20NaN%2C%20%2Dinf%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> filter(is_finite, [0, 1e10, NaN, -inf])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=filter%28is%5Ffinite%2C%20%5B0%2C%201e10%2C%20NaN%2C%20%2Dinf%5D%29')""></button></div><code class="language-nbt hljs numbat">filter(is_finite, [0, 1e10, NaN, -inf])
 
     = [0, 10_000_000_000]    [List<Scalar>]
 </code></pre>
@@ -257,7 +257,7 @@ fn foldl<A, B>(f: Fn[(A, B) -> A], acc: A, xs: List<B>) -> A
 <summary>Examples</summary>
 
 Join a list of strings by folding.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=foldl%28str%5Fappend%2C%20%22%22%2C%20%5B%22Num%22%2C%20%22bat%22%2C%20%22%21%22%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> foldl(str_append, "", ["Num", "bat", "!"])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=foldl%28str%5Fappend%2C%20%22%22%2C%20%5B%22Num%22%2C%20%22bat%22%2C%20%22%21%22%5D%29')""></button></div><code class="language-nbt hljs numbat">foldl(str_append, "", ["Num", "bat", "!"])
 
     = "Numbat!"    [String]
 </code></pre>
@@ -275,7 +275,7 @@ fn sort_by_key<A, D: Dim>(key: Fn[(A) -> D], xs: List<A>) -> List<A>
 <summary>Examples</summary>
 
 Sort by last digit.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=fn%20last%5Fdigit%28x%29%20%3D%20mod%28x%2C%2010%29%0Asort%5Fby%5Fkey%28last%5Fdigit%2C%20%5B701%2C%20313%2C%209999%2C%204%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> fn last_digit(x) = mod(x, 10)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=fn%20last%5Fdigit%28x%29%20%3D%20mod%28x%2C%2010%29%0Asort%5Fby%5Fkey%28last%5Fdigit%2C%20%5B701%2C%20313%2C%209999%2C%204%5D%29')""></button></div><code class="language-nbt hljs numbat">fn last_digit(x) = mod(x, 10)
 sort_by_key(last_digit, [701, 313, 9999, 4])
 
     = [701, 313, 4, 9999]    [List<Scalar>]
@@ -284,7 +284,7 @@ sort_by_key(last_digit, [701, 313, 9999, 4])
 </details>
 
 ### `sort`
-Sort a list of quantities.
+Sort a list of quantities in ascending order.
 
 ```nbt
 fn sort<D: Dim>(xs: List<D>) -> List<D>
@@ -293,13 +293,52 @@ fn sort<D: Dim>(xs: List<D>) -> List<D>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sort%28%5B3%2C%202%2C%207%2C%208%2C%20%2D4%2C%200%2C%20%2D5%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> sort([3, 2, 7, 8, -4, 0, -5])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sort%28%5B3%2C%202%2C%207%2C%208%2C%20%2D4%2C%200%2C%20%2D5%5D%29')""></button></div><code class="language-nbt hljs numbat">sort([3, 2, 7, 8, -4, 0, -5])
 
     = [-5, -4, 0, 2, 3, 7, 8]    [List<Scalar>]
 </code></pre>
 
 </details>
 
+### `contains`
+Returns true if the element `x` is in the list `xs`.
+
+```nbt
+fn contains<A>(x: A, xs: List<A>) -> Bool
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=%5B3%2C%202%2C%207%2C%208%2C%20%2D4%2C%200%2C%20%2D5%5D%20%7C%3E%20contains%280%29')""></button></div><code class="language-nbt hljs numbat">[3, 2, 7, 8, -4, 0, -5] |> contains(0)
+
+    = true    [Bool]
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=%5B3%2C%202%2C%207%2C%208%2C%20%2D4%2C%200%2C%20%2D5%5D%20%7C%3E%20contains%281%29')""></button></div><code class="language-nbt hljs numbat">[3, 2, 7, 8, -4, 0, -5] |> contains(1)
+
+    = false    [Bool]
+</code></pre>
+
+</details>
+
+### `unique`
+Remove duplicates from a given list.
+
+```nbt
+fn unique<A>(xs: List<A>) -> List<A>
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=unique%28%5B1%2C%202%2C%202%2C%203%2C%203%2C%203%5D%29')""></button></div><code class="language-nbt hljs numbat">unique([1, 2, 2, 3, 3, 3])
+
+    = [1, 2, 3]    [List<Scalar>]
+</code></pre>
+
+</details>
+
 ### `intersperse`
 Add an element between each pair of elements in a list.
 
@@ -310,7 +349,7 @@ fn intersperse<A>(sep: A, xs: List<A>) -> List<A>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=intersperse%280%2C%20%5B1%2C%201%2C%201%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> intersperse(0, [1, 1, 1, 1])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=intersperse%280%2C%20%5B1%2C%201%2C%201%2C%201%5D%29')""></button></div><code class="language-nbt hljs numbat">intersperse(0, [1, 1, 1, 1])
 
     = [1, 0, 1, 0, 1, 0, 1]    [List<Scalar>]
 </code></pre>
@@ -327,7 +366,7 @@ fn sum<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sum%28%5B3%20m%2C%20200%20cm%2C%201000%20mm%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> sum([3 m, 200 cm, 1000 mm])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sum%28%5B3%20m%2C%20200%20cm%2C%201000%20mm%5D%29')""></button></div><code class="language-nbt hljs numbat">sum([3 m, 200 cm, 1000 mm])
 
     = 6 m    [Length]
 </code></pre>
@@ -344,7 +383,7 @@ fn linspace<D: Dim>(start: D, end: D, n_steps: Scalar) -> List<D>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=linspace%28%2D5%20m%2C%205%20m%2C%2011%29')""></button></div><code class="language-nbt hljs numbat">>>> linspace(-5 m, 5 m, 11)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=linspace%28%2D5%20m%2C%205%20m%2C%2011%29')""></button></div><code class="language-nbt hljs numbat">linspace(-5 m, 5 m, 11)
 
     = [-5 m, -4 m, -3 m, -2 m, -1 m, 0 m, 1 m, 2 m, 3 m, 4 m, 5 m]    [List<Length>]
 </code></pre>
@@ -361,7 +400,7 @@ fn join(xs: List<String>, sep: String) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=join%28%5B%22snake%22%2C%20%22case%22%5D%2C%20%22%5F%22%29')""></button></div><code class="language-nbt hljs numbat">>>> join(["snake", "case"], "_")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=join%28%5B%22snake%22%2C%20%22case%22%5D%2C%20%22%5F%22%29')""></button></div><code class="language-nbt hljs numbat">join(["snake", "case"], "_")
 
     = "snake_case"    [String]
 </code></pre>
@@ -378,7 +417,7 @@ fn split(input: String, separator: String) -> List<String>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=split%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22%20%22%29')""></button></div><code class="language-nbt hljs numbat">>>> split("Numbat is a statically typed programming language.", " ")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=split%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22%20%22%29')""></button></div><code class="language-nbt hljs numbat">split("Numbat is a statically typed programming language.", " ")
 
     = ["Numbat", "is", "a", "statically", "typed", "programming", "language."]    [List<String>]
 </code></pre>

+ 139 - 45
book/src/list-functions-math.md

@@ -1,6 +1,6 @@
 # Mathematical functions
 
-[Basics](#basics) · [Transcendental functions](#transcendental-functions) · [Trigonometry](#trigonometry) · [Statistics](#statistics) · [Random sampling, distributions](#random-sampling-distributions) · [Number theory](#number-theory) · [Numerical methods](#numerical-methods) · [Percentage calculations](#percentage-calculations) · [Geometry](#geometry) · [Algebra](#algebra) · [Trigonometry (extra)](#trigonometry-(extra))
+[Basics](#basics) · [Transcendental functions](#transcendental-functions) · [Trigonometry](#trigonometry) · [Statistics](#statistics) · [Combinatorics](#combinatorics) · [Random sampling, distributions](#random-sampling-distributions) · [Number theory](#number-theory) · [Numerical methods](#numerical-methods) · [Percentage calculations](#percentage-calculations) · [Geometry](#geometry) · [Algebra](#algebra) · [Trigonometry (extra)](#trigonometry-(extra))
 
 ## Basics
 
@@ -16,7 +16,7 @@ fn id<A>(x: A) -> A
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=id%288%20kg%29')""></button></div><code class="language-nbt hljs numbat">>>> id(8 kg)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=id%288%20kg%29')""></button></div><code class="language-nbt hljs numbat">id(8 kg)
 
     = 8 kg    [Mass]
 </code></pre>
@@ -34,7 +34,7 @@ fn abs<T: Dim>(x: T) -> T
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=abs%28%2D22%2E2%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> abs(-22.2 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=abs%28%2D22%2E2%20m%29')""></button></div><code class="language-nbt hljs numbat">abs(-22.2 m)
 
     = 22.2 m    [Length]
 </code></pre>
@@ -52,7 +52,7 @@ fn sqrt<D: Dim>(x: D^2) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sqrt%284%20are%29%20%2D%3E%20m')""></button></div><code class="language-nbt hljs numbat">>>> sqrt(4 are) -> m
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sqrt%284%20are%29%20%2D%3E%20m')""></button></div><code class="language-nbt hljs numbat">sqrt(4 are) -> m
 
     = 20 m    [Length]
 </code></pre>
@@ -70,7 +70,7 @@ fn cbrt<D: Dim>(x: D^3) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cbrt%288%20L%29%20%2D%3E%20cm')""></button></div><code class="language-nbt hljs numbat">>>> cbrt(8 L) -> cm
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=cbrt%288%20L%29%20%2D%3E%20cm')""></button></div><code class="language-nbt hljs numbat">cbrt(8 L) -> cm
 
     = 20.0 cm    [Length]
 </code></pre>
@@ -87,7 +87,7 @@ fn sqr<D: Dim>(x: D) -> D^2
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sqr%287%29')""></button></div><code class="language-nbt hljs numbat">>>> sqr(7)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=sqr%287%29')""></button></div><code class="language-nbt hljs numbat">sqr(7)
 
     = 49
 </code></pre>
@@ -105,12 +105,12 @@ fn round(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> round(5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">round(5.5)
 
     = 6
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%28%2D5%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> round(-5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%28%2D5%2E5%29')""></button></div><code class="language-nbt hljs numbat">round(-5.5)
 
     = -6
 </code></pre>
@@ -128,13 +128,13 @@ fn round_in<D: Dim>(base: D, value: D) -> D
 <summary>Examples</summary>
 
 Round in meters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%5Fin%28m%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> round_in(m, 5.3 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%5Fin%28m%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">round_in(m, 5.3 m)
 
     = 5 m    [Length]
 </code></pre>
 
 Round in centimeters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%5Fin%28cm%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> round_in(cm, 5.3 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=round%5Fin%28cm%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">round_in(cm, 5.3 m)
 
     = 530 cm    [Length]
 </code></pre>
@@ -152,7 +152,7 @@ fn floor(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> floor(5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">floor(5.5)
 
     = 5
 </code></pre>
@@ -170,13 +170,13 @@ fn floor_in<D: Dim>(base: D, value: D) -> D
 <summary>Examples</summary>
 
 Floor in meters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%5Fin%28m%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> floor_in(m, 5.7 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%5Fin%28m%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">floor_in(m, 5.7 m)
 
     = 5 m    [Length]
 </code></pre>
 
 Floor in centimeters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%5Fin%28cm%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> floor_in(cm, 5.7 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=floor%5Fin%28cm%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">floor_in(cm, 5.7 m)
 
     = 570 cm    [Length]
 </code></pre>
@@ -194,7 +194,7 @@ fn ceil(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> ceil(5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">ceil(5.5)
 
     = 6
 </code></pre>
@@ -212,13 +212,13 @@ fn ceil_in<D: Dim>(base: D, value: D) -> D
 <summary>Examples</summary>
 
 Ceil in meters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%5Fin%28m%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> ceil_in(m, 5.3 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%5Fin%28m%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">ceil_in(m, 5.3 m)
 
     = 6 m    [Length]
 </code></pre>
 
 Ceil in centimeters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%5Fin%28cm%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> ceil_in(cm, 5.3 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ceil%5Fin%28cm%2C%205%2E3%20m%29')""></button></div><code class="language-nbt hljs numbat">ceil_in(cm, 5.3 m)
 
     = 530 cm    [Length]
 </code></pre>
@@ -236,12 +236,12 @@ fn trunc(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> trunc(5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">trunc(5.5)
 
     = 5
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%28%2D5%2E5%29')""></button></div><code class="language-nbt hljs numbat">>>> trunc(-5.5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%28%2D5%2E5%29')""></button></div><code class="language-nbt hljs numbat">trunc(-5.5)
 
     = -5
 </code></pre>
@@ -259,19 +259,50 @@ fn trunc_in<D: Dim>(base: D, value: D) -> D
 <summary>Examples</summary>
 
 Truncate in meters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%5Fin%28m%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> trunc_in(m, 5.7 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%5Fin%28m%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">trunc_in(m, 5.7 m)
 
     = 5 m    [Length]
 </code></pre>
 
 Truncate in centimeters.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%5Fin%28cm%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> trunc_in(cm, 5.7 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=trunc%5Fin%28cm%2C%205%2E7%20m%29')""></button></div><code class="language-nbt hljs numbat">trunc_in(cm, 5.7 m)
 
     = 570 cm    [Length]
 </code></pre>
 
 </details>
 
+### `fract` (Fractional part)
+Returns the fractional part of \\( x \\), i.e. the remainder when divided by 1.
+	If \\( x < 0 \\), then so will be `fract(x)`. Note that due to floating point error, a
+	number’s fractional part can be slightly “off”; for instance, `fract(1.2) ==
+	0.1999...996 != 0.2`.
+More information [here](https://doc.rust-lang.org/std/primitive.f64.html#method.fract).
+
+```nbt
+fn fract(x: Scalar) -> Scalar
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=fract%280%2E0%29')""></button></div><code class="language-nbt hljs numbat">fract(0.0)
+
+    = 0
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=fract%285%2E5%29')""></button></div><code class="language-nbt hljs numbat">fract(5.5)
+
+    = 0.5
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=fract%28%2D5%2E5%29')""></button></div><code class="language-nbt hljs numbat">fract(-5.5)
+
+    = -0.5
+</code></pre>
+
+</details>
+
 ### `mod` (Modulo)
 Calculates the least nonnegative remainder of \\( a (\mod b) \\).
 More information [here](https://doc.rust-lang.org/std/primitive.f64.html#method.rem_euclid).
@@ -283,7 +314,7 @@ fn mod<T: Dim>(a: T, b: T) -> T
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=mod%2827%2C%205%29')""></button></div><code class="language-nbt hljs numbat">>>> mod(27, 5)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=mod%2827%2C%205%29')""></button></div><code class="language-nbt hljs numbat">mod(27, 5)
 
     = 2
 </code></pre>
@@ -305,7 +336,7 @@ fn exp(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=exp%284%29')""></button></div><code class="language-nbt hljs numbat">>>> exp(4)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=exp%284%29')""></button></div><code class="language-nbt hljs numbat">exp(4)
 
     = 54.5982
 </code></pre>
@@ -323,7 +354,7 @@ fn ln(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ln%2820%29')""></button></div><code class="language-nbt hljs numbat">>>> ln(20)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=ln%2820%29')""></button></div><code class="language-nbt hljs numbat">ln(20)
 
     = 2.99573
 </code></pre>
@@ -341,7 +372,7 @@ fn log(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log%2820%29')""></button></div><code class="language-nbt hljs numbat">>>> log(20)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log%2820%29')""></button></div><code class="language-nbt hljs numbat">log(20)
 
     = 2.99573
 </code></pre>
@@ -359,7 +390,7 @@ fn log10(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log10%28100%29')""></button></div><code class="language-nbt hljs numbat">>>> log10(100)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log10%28100%29')""></button></div><code class="language-nbt hljs numbat">log10(100)
 
     = 2
 </code></pre>
@@ -377,7 +408,7 @@ fn log2(x: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log2%28256%29')""></button></div><code class="language-nbt hljs numbat">>>> log2(256)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=log2%28256%29')""></button></div><code class="language-nbt hljs numbat">log2(256)
 
     = 8
 </code></pre>
@@ -501,7 +532,7 @@ fn maximum<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=maximum%28%5B30%20cm%2C%202%20m%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> maximum([30 cm, 2 m])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=maximum%28%5B30%20cm%2C%202%20m%5D%29')""></button></div><code class="language-nbt hljs numbat">maximum([30 cm, 2 m])
 
     = 2 m    [Length]
 </code></pre>
@@ -518,7 +549,7 @@ fn minimum<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=minimum%28%5B30%20cm%2C%202%20m%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> minimum([30 cm, 2 m])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=minimum%28%5B30%20cm%2C%202%20m%5D%29')""></button></div><code class="language-nbt hljs numbat">minimum([30 cm, 2 m])
 
     = 30 cm    [Length]
 </code></pre>
@@ -536,7 +567,7 @@ fn mean<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=mean%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> mean([1 m, 2 m, 300 cm])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=mean%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">mean([1 m, 2 m, 300 cm])
 
     = 2 m    [Length]
 </code></pre>
@@ -554,7 +585,7 @@ fn variance<D: Dim>(xs: List<D>) -> D^2
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=variance%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> variance([1 m, 2 m, 300 cm])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=variance%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">variance([1 m, 2 m, 300 cm])
 
     = 0.666667 m²    [Area]
 </code></pre>
@@ -572,7 +603,7 @@ fn stdev<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=stdev%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> stdev([1 m, 2 m, 300 cm])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=stdev%28%5B1%20m%2C%202%20m%2C%20300%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">stdev([1 m, 2 m, 300 cm])
 
     = 0.816497 m    [Length]
 </code></pre>
@@ -590,13 +621,76 @@ fn median<D: Dim>(xs: List<D>) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=median%28%5B1%20m%2C%202%20m%2C%20400%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> median([1 m, 2 m, 400 cm])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=median%28%5B1%20m%2C%202%20m%2C%20400%20cm%5D%29')""></button></div><code class="language-nbt hljs numbat">median([1 m, 2 m, 400 cm])
 
     = 2 m    [Length]
 </code></pre>
 
 </details>
 
+## Combinatorics
+
+Defined in: `math::combinatorics`
+
+### `factorial` (Factorial)
+The product of the integers 1 through n. Numbat also supports calling this via the postfix operator `n!`.
+More information [here](https://en.wikipedia.org/wiki/Factorial).
+
+```nbt
+fn factorial(n: Scalar) -> Scalar
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=factorial%284%29')""></button></div><code class="language-nbt hljs numbat">factorial(4)
+
+    = 24
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=4%21')""></button></div><code class="language-nbt hljs numbat">4!
+
+    = 24
+</code></pre>
+
+</details>
+
+### `falling_factorial` (Falling factorial)
+Equal to \\( n⋅(n-1)⋅…⋅(n-k+2)⋅(n-k+1) \\) (k terms total). If n is an integer, this is the number of k-element permutations from a set of size n. k must always be an integer.
+More information [here](https://en.wikipedia.org/wiki/Falling_and_rising_factorials).
+
+```nbt
+fn falling_factorial(n: Scalar, k: Scalar) -> Scalar
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=falling%5Ffactorial%284%2C%202%29')""></button></div><code class="language-nbt hljs numbat">falling_factorial(4, 2)
+
+    = 12
+</code></pre>
+
+</details>
+
+### `binom` (Binomial coefficient)
+Equal to falling_factorial(n, k)/k!, this is the coefficient of \\( x^k \\) in the series expansion of \\( (1+x)^n \\) (see “binomial series”). If n is an integer, then this this is the number of k-element subsets of a set of size n, often read "n choose k". k must always be an integer.
+More information [here](https://en.wikipedia.org/wiki/Binomial_coefficient).
+
+```nbt
+fn binom(n: Scalar, k: Scalar) -> Scalar
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=binom%285%2C%202%29')""></button></div><code class="language-nbt hljs numbat">binom(5, 2)
+
+    = 10
+</code></pre>
+
+</details>
+
 ## Random sampling, distributions
 
 Defined in: `core::random`, `math::distributions`
@@ -704,7 +798,7 @@ fn gcd(a: Scalar, b: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=gcd%2860%2C%2042%29')""></button></div><code class="language-nbt hljs numbat">>>> gcd(60, 42)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=gcd%2860%2C%2042%29')""></button></div><code class="language-nbt hljs numbat">gcd(60, 42)
 
     = 6
 </code></pre>
@@ -722,7 +816,7 @@ fn lcm(a: Scalar, b: Scalar) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=lcm%2814%2C%204%29')""></button></div><code class="language-nbt hljs numbat">>>> lcm(14, 4)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=lcm%2814%2C%204%29')""></button></div><code class="language-nbt hljs numbat">lcm(14, 4)
 
     = 28
 </code></pre>
@@ -745,7 +839,7 @@ fn diff<X: Dim, Y: Dim>(f: Fn[(X) -> Y], x: X) -> Y / X
 <summary>Examples</summary>
 
 Compute the derivative of \\( f(x) = x² -x -1 \\) at \\( x=1 \\).
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Adiff%0Afn%20polynomial%28x%29%20%3D%20x%C2%B2%20%2D%20x%20%2D%201%0Adiff%28polynomial%2C%201%29')""></button></div><code class="language-nbt hljs numbat">>>> use numerics::diff
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Adiff%0Afn%20polynomial%28x%29%20%3D%20x%C2%B2%20%2D%20x%20%2D%201%0Adiff%28polynomial%2C%201%29')""></button></div><code class="language-nbt hljs numbat">use numerics::diff
 fn polynomial(x) = x² - x - 1
 diff(polynomial, 1)
 
@@ -753,7 +847,7 @@ diff(polynomial, 1)
 </code></pre>
 
 Compute the free fall velocity after \\( t=2 s \\).
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Adiff%0Afn%20distance%28t%29%20%3D%200%2E5%20g0%20t%C2%B2%0Afn%20velocity%28t%29%20%3D%20diff%28distance%2C%20t%29%0Avelocity%282%20s%29')""></button></div><code class="language-nbt hljs numbat">>>> use numerics::diff
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Adiff%0Afn%20distance%28t%29%20%3D%200%2E5%20g0%20t%C2%B2%0Afn%20velocity%28t%29%20%3D%20diff%28distance%2C%20t%29%0Avelocity%282%20s%29')""></button></div><code class="language-nbt hljs numbat">use numerics::diff
 fn distance(t) = 0.5 g0 t²
 fn velocity(t) = diff(distance, t)
 velocity(2 s)
@@ -775,7 +869,7 @@ fn root_bisect<X: Dim, Y: Dim>(f: Fn[(X) -> Y], x1: X, x2: X, x_tol: X, y_tol: Y
 <summary>Examples</summary>
 
 Find the root of \\( f(x) = x² +x -2 \\) in the interval \\( [0, 100] \\).
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Asolve%0Afn%20f%28x%29%20%3D%20x%C2%B2%20%2Bx%20%2D2%0Aroot%5Fbisect%28f%2C%200%2C%20100%2C%200%2E01%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">>>> use numerics::solve
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Asolve%0Afn%20f%28x%29%20%3D%20x%C2%B2%20%2Bx%20%2D2%0Aroot%5Fbisect%28f%2C%200%2C%20100%2C%200%2E01%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">use numerics::solve
 fn f(x) = x² +x -2
 root_bisect(f, 0, 100, 0.01, 0.01)
 
@@ -796,7 +890,7 @@ fn root_newton<X: Dim, Y: Dim>(f: Fn[(X) -> Y], f_prime: Fn[(X) -> Y / X], x0: X
 <summary>Examples</summary>
 
 Find a root of \\( f(x) = x² -3x +2 \\) using Newton's method.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Asolve%0Afn%20f%28x%29%20%3D%20x%C2%B2%20%2D3x%20%2B2%0Afn%20f%5Fprime%28x%29%20%3D%202x%20%2D3%0Aroot%5Fnewton%28f%2C%20f%5Fprime%2C%200%20%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">>>> use numerics::solve
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Asolve%0Afn%20f%28x%29%20%3D%20x%C2%B2%20%2D3x%20%2B2%0Afn%20f%5Fprime%28x%29%20%3D%202x%20%2D3%0Aroot%5Fnewton%28f%2C%20f%5Fprime%2C%200%20%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">use numerics::solve
 fn f(x) = x² -3x +2
 fn f_prime(x) = 2x -3
 root_newton(f, f_prime, 0 , 0.01)
@@ -818,7 +912,7 @@ fn fixed_point<X: Dim>(f: Fn[(X) -> X], x0: X, ε: X) -> X
 <summary>Examples</summary>
 
 Compute the fixed poin of \\( f(x) = x/2 -1 \\).
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Afixed%5Fpoint%0Afn%20function%28x%29%20%3D%20x%2F2%20%2D%201%0Afixed%5Fpoint%28function%2C%200%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">>>> use numerics::fixed_point
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20numerics%3A%3Afixed%5Fpoint%0Afn%20function%28x%29%20%3D%20x%2F2%20%2D%201%0Afixed%5Fpoint%28function%2C%200%2C%200%2E01%29')""></button></div><code class="language-nbt hljs numbat">use numerics::fixed_point
 fn function(x) = x/2 - 1
 fixed_point(function, 0, 0.01)
 
@@ -842,7 +936,7 @@ fn increase_by<D: Dim>(percentage: Scalar, quantity: D) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=72%20%E2%82%AC%20%7C%3E%20increase%5Fby%2815%25%29')""></button></div><code class="language-nbt hljs numbat">>>> 72 € |> increase_by(15%)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=72%20%E2%82%AC%20%7C%3E%20increase%5Fby%2815%25%29')""></button></div><code class="language-nbt hljs numbat">72 € |> increase_by(15%)
 
     = 82.8 €    [Money]
 </code></pre>
@@ -860,7 +954,7 @@ fn decrease_by<D: Dim>(percentage: Scalar, quantity: D) -> D
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=210%20cm%20%7C%3E%20decrease%5Fby%2810%25%29')""></button></div><code class="language-nbt hljs numbat">>>> 210 cm |> decrease_by(10%)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=210%20cm%20%7C%3E%20decrease%5Fby%2810%25%29')""></button></div><code class="language-nbt hljs numbat">210 cm |> decrease_by(10%)
 
     = 189 cm    [Length]
 </code></pre>
@@ -878,7 +972,7 @@ fn percentage_change<D: Dim>(old: D, new: D) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=percentage%5Fchange%2835%20kg%2C%2042%20kg%29')""></button></div><code class="language-nbt hljs numbat">>>> percentage_change(35 kg, 42 kg)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=percentage%5Fchange%2835%20kg%2C%2042%20kg%29')""></button></div><code class="language-nbt hljs numbat">percentage_change(35 kg, 42 kg)
 
     = 20 %
 </code></pre>
@@ -899,7 +993,7 @@ fn hypot2<T: Dim>(x: T, y: T) -> T
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=hypot2%283%20m%2C%204%20m%29')""></button></div><code class="language-nbt hljs numbat">>>> hypot2(3 m, 4 m)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=hypot2%283%20m%2C%204%20m%29')""></button></div><code class="language-nbt hljs numbat">hypot2(3 m, 4 m)
 
     = 5 m    [Length]
 </code></pre>
@@ -916,7 +1010,7 @@ fn hypot3<T: Dim>(x: T, y: T, z: T) -> T
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=hypot3%288%2C%209%2C%2012%29')""></button></div><code class="language-nbt hljs numbat">>>> hypot3(8, 9, 12)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=hypot3%288%2C%209%2C%2012%29')""></button></div><code class="language-nbt hljs numbat">hypot3(8, 9, 12)
 
     = 17
 </code></pre>
@@ -967,7 +1061,7 @@ fn quadratic_equation<A: Dim, B: Dim>(a: A, b: B, c: B^2 / A) -> List<B / A>
 <summary>Examples</summary>
 
 Solve the equation \\( 2x² -x -1 = 0 \\)
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Aalgebra%0Aquadratic%5Fequation%282%2C%20%2D1%2C%20%2D1%29')""></button></div><code class="language-nbt hljs numbat">>>> use extra::algebra
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Aalgebra%0Aquadratic%5Fequation%282%2C%20%2D1%2C%20%2D1%29')""></button></div><code class="language-nbt hljs numbat">use extra::algebra
 quadratic_equation(2, -1, -1)
 
     = [1, -0.5]    [List<Scalar>]

+ 90 - 24
book/src/list-functions-other.md

@@ -28,12 +28,12 @@ fn is_nan<T: Dim>(n: T) -> Bool
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnan%2837%29')""></button></div><code class="language-nbt hljs numbat">>>> is_nan(37)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnan%2837%29')""></button></div><code class="language-nbt hljs numbat">is_nan(37)
 
     = false    [Bool]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnan%28NaN%29')""></button></div><code class="language-nbt hljs numbat">>>> is_nan(NaN)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnan%28NaN%29')""></button></div><code class="language-nbt hljs numbat">is_nan(NaN)
 
     = true    [Bool]
 </code></pre>
@@ -51,12 +51,12 @@ fn is_infinite<T: Dim>(n: T) -> Bool
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finfinite%2837%29')""></button></div><code class="language-nbt hljs numbat">>>> is_infinite(37)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finfinite%2837%29')""></button></div><code class="language-nbt hljs numbat">is_infinite(37)
 
     = false    [Bool]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finfinite%28%2Dinf%29')""></button></div><code class="language-nbt hljs numbat">>>> is_infinite(-inf)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finfinite%28%2Dinf%29')""></button></div><code class="language-nbt hljs numbat">is_infinite(-inf)
 
     = true    [Bool]
 </code></pre>
@@ -73,12 +73,78 @@ fn is_finite<T: Dim>(n: T) -> Bool
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Ffinite%2837%29')""></button></div><code class="language-nbt hljs numbat">>>> is_finite(37)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Ffinite%2837%29')""></button></div><code class="language-nbt hljs numbat">is_finite(37)
 
     = true    [Bool]
 </code></pre>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Ffinite%28%2Dinf%29')""></button></div><code class="language-nbt hljs numbat">>>> is_finite(-inf)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Ffinite%28%2Dinf%29')""></button></div><code class="language-nbt hljs numbat">is_finite(-inf)
+
+    = false    [Bool]
+</code></pre>
+
+</details>
+
+### `is_zero`
+Returns true if the input is 0 (zero).
+
+```nbt
+fn is_zero<D: Dim>(value: D) -> Bool
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fzero%2837%29')""></button></div><code class="language-nbt hljs numbat">is_zero(37)
+
+    = false    [Bool]
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fzero%280%29')""></button></div><code class="language-nbt hljs numbat">is_zero(0)
+
+    = true    [Bool]
+</code></pre>
+
+</details>
+
+### `is_nonzero`
+Returns true unless the input is 0 (zero).
+
+```nbt
+fn is_nonzero<D: Dim>(value: D) -> Bool
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnonzero%2837%29')""></button></div><code class="language-nbt hljs numbat">is_nonzero(37)
+
+    = true    [Bool]
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Fnonzero%280%29')""></button></div><code class="language-nbt hljs numbat">is_nonzero(0)
+
+    = false    [Bool]
+</code></pre>
+
+</details>
+
+### `is_integer`
+Returns true if the input is an integer.
+
+```nbt
+fn is_integer(x: Scalar) -> Bool
+```
+
+<details>
+<summary>Examples</summary>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finteger%283%29')""></button></div><code class="language-nbt hljs numbat">is_integer(3)
+
+    = true    [Bool]
+</code></pre>
+
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=is%5Finteger%28pi%29')""></button></div><code class="language-nbt hljs numbat">is_integer(pi)
 
     = false    [Bool]
 </code></pre>
@@ -99,7 +165,7 @@ fn value_of<T: Dim>(x: T) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=value%5Fof%2820%20km%2Fh%29')""></button></div><code class="language-nbt hljs numbat">>>> value_of(20 km/h)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=value%5Fof%2820%20km%2Fh%29')""></button></div><code class="language-nbt hljs numbat">value_of(20 km/h)
 
     = 20
 </code></pre>
@@ -116,7 +182,7 @@ fn unit_of<T: Dim>(x: T) -> T
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=unit%5Fof%2820%20km%2Fh%29')""></button></div><code class="language-nbt hljs numbat">>>> unit_of(20 km/h)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=unit%5Fof%2820%20km%2Fh%29')""></button></div><code class="language-nbt hljs numbat">unit_of(20 km/h)
 
     = 1 km/h    [Velocity]
 </code></pre>
@@ -138,13 +204,13 @@ fn element(pattern: String) -> ChemicalElement
 <summary>Examples</summary>
 
 Get the entire element struct for hydrogen.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%28%22H%22%29')""></button></div><code class="language-nbt hljs numbat">>>> element("H")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%28%22H%22%29')""></button></div><code class="language-nbt hljs numbat">element("H")
 
     = ChemicalElement { symbol: "H", name: "Hydrogen", atomic_number: 1, group: 1, group_name: "Alkali metals", period: 1, melting_point: 13.99 K, boiling_point: 20.271 K, density: 0.00008988 g/cm³, electron_affinity: 0.754 eV, ionization_energy: 13.598 eV, vaporization_heat: 0.904 kJ/mol }    [ChemicalElement]
 </code></pre>
 
 Get the ionization energy of hydrogen.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%28%22hydrogen%22%29%2Eionization%5Fenergy')""></button></div><code class="language-nbt hljs numbat">>>> element("hydrogen").ionization_energy
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=element%28%22hydrogen%22%29%2Eionization%5Fenergy')""></button></div><code class="language-nbt hljs numbat">element("hydrogen").ionization_energy
 
     = 13.598 eV    [Energy or Torque]
 </code></pre>
@@ -165,7 +231,7 @@ fn unit_list<D: Dim>(units: List<D>, value: D) -> List<D>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=5500%20m%20%7C%3E%20unit%5Flist%28%5Bmiles%2C%20yards%2C%20feet%2C%20inches%5D%29')""></button></div><code class="language-nbt hljs numbat">>>> 5500 m |> unit_list([miles, yards, feet, inches])
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=5500%20m%20%7C%3E%20unit%5Flist%28%5Bmiles%2C%20yards%2C%20feet%2C%20inches%5D%29')""></button></div><code class="language-nbt hljs numbat">5500 m |> unit_list([miles, yards, feet, inches])
 
     = [3 mi, 734 yd, 2 ft, 7.43307 in]    [List<Length>]
 </code></pre>
@@ -183,7 +249,7 @@ fn DMS(alpha: Angle) -> List<Angle>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=46%2E5858%C2%B0%20%2D%3E%20DMS')""></button></div><code class="language-nbt hljs numbat">>>> 46.5858° -> DMS
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=46%2E5858%C2%B0%20%2D%3E%20DMS')""></button></div><code class="language-nbt hljs numbat">46.5858° -> DMS
 
     = [46°, 35′, 8.88″]    [List<Scalar>]
 </code></pre>
@@ -201,7 +267,7 @@ fn DM(alpha: Angle) -> List<Angle>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=46%2E5858%C2%B0%20%2D%3E%20DM')""></button></div><code class="language-nbt hljs numbat">>>> 46.5858° -> DM
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=46%2E5858%C2%B0%20%2D%3E%20DM')""></button></div><code class="language-nbt hljs numbat">46.5858° -> DM
 
     = [46°, 35.148′]    [List<Scalar>]
 </code></pre>
@@ -219,7 +285,7 @@ fn feet_and_inches(length: Length) -> List<Length>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=180%20cm%20%2D%3E%20feet%5Fand%5Finches')""></button></div><code class="language-nbt hljs numbat">>>> 180 cm -> feet_and_inches
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=180%20cm%20%2D%3E%20feet%5Fand%5Finches')""></button></div><code class="language-nbt hljs numbat">180 cm -> feet_and_inches
 
     = [5 ft, 10.8661 in]    [List<Length>]
 </code></pre>
@@ -237,7 +303,7 @@ fn pounds_and_ounces(mass: Mass) -> List<Mass>
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=1%20kg%20%2D%3E%20pounds%5Fand%5Founces')""></button></div><code class="language-nbt hljs numbat">>>> 1 kg -> pounds_and_ounces
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=1%20kg%20%2D%3E%20pounds%5Fand%5Founces')""></button></div><code class="language-nbt hljs numbat">1 kg -> pounds_and_ounces
 
     = [2 lb, 3.27396 oz]    [List<Mass>]
 </code></pre>
@@ -260,7 +326,7 @@ fn from_celsius(t_celsius: Scalar) -> Temperature
 <summary>Examples</summary>
 
 300 °C in Kelvin.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Fcelsius%28300%29')""></button></div><code class="language-nbt hljs numbat">>>> from_celsius(300)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Fcelsius%28300%29')""></button></div><code class="language-nbt hljs numbat">from_celsius(300)
 
     = 573.15 K    [Temperature]
 </code></pre>
@@ -279,7 +345,7 @@ fn celsius(t_kelvin: Temperature) -> Scalar
 <summary>Examples</summary>
 
 300 K in degree Celsius.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=300K%20%2D%3E%20celsius')""></button></div><code class="language-nbt hljs numbat">>>> 300K -> celsius
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=300K%20%2D%3E%20celsius')""></button></div><code class="language-nbt hljs numbat">300K -> celsius
 
     = 26.85
 </code></pre>
@@ -298,7 +364,7 @@ fn from_fahrenheit(t_fahrenheit: Scalar) -> Temperature
 <summary>Examples</summary>
 
 300 °F in Kelvin.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Ffahrenheit%28300%29')""></button></div><code class="language-nbt hljs numbat">>>> from_fahrenheit(300)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=from%5Ffahrenheit%28300%29')""></button></div><code class="language-nbt hljs numbat">from_fahrenheit(300)
 
     = 422.039 K    [Temperature]
 </code></pre>
@@ -317,7 +383,7 @@ fn fahrenheit(t_kelvin: Temperature) -> Scalar
 <summary>Examples</summary>
 
 300 K in degree Fahrenheit.
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=300K%20%2D%3E%20fahrenheit')""></button></div><code class="language-nbt hljs numbat">>>> 300K -> fahrenheit
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=300K%20%2D%3E%20fahrenheit')""></button></div><code class="language-nbt hljs numbat">300K -> fahrenheit
 
     = 80.33
 </code></pre>
@@ -338,7 +404,7 @@ fn rgb(red: Scalar, green: Scalar, blue: Scalar) -> Color
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Argb%28125%2C%20128%2C%20218%29')""></button></div><code class="language-nbt hljs numbat">>>> use extra::color
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Argb%28125%2C%20128%2C%20218%29')""></button></div><code class="language-nbt hljs numbat">use extra::color
 rgb(125, 128, 218)
 
     = Color { red: 125, green: 128, blue: 218 }    [Color]
@@ -356,7 +422,7 @@ fn color(rgb_hex: Scalar) -> Color
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acolor%280xff7700%29')""></button></div><code class="language-nbt hljs numbat">>>> use extra::color
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acolor%280xff7700%29')""></button></div><code class="language-nbt hljs numbat">use extra::color
 color(0xff7700)
 
     = Color { red: 255, green: 119, blue: 0 }    [Color]
@@ -374,7 +440,7 @@ fn color_rgb(color: Color) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acyan%20%2D%3E%20color%5Frgb')""></button></div><code class="language-nbt hljs numbat">>>> use extra::color
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acyan%20%2D%3E%20color%5Frgb')""></button></div><code class="language-nbt hljs numbat">use extra::color
 cyan -> color_rgb
 
     = "rgb(0, 255, 255)"    [String]
@@ -392,7 +458,7 @@ fn color_rgb_float(color: Color) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acyan%20%2D%3E%20color%5Frgb%5Ffloat')""></button></div><code class="language-nbt hljs numbat">>>> use extra::color
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Acyan%20%2D%3E%20color%5Frgb%5Ffloat')""></button></div><code class="language-nbt hljs numbat">use extra::color
 cyan -> color_rgb_float
 
     = "rgb(0.000, 1.000, 1.000)"    [String]
@@ -410,7 +476,7 @@ fn color_hex(color: Color) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Argb%28225%2C%2036%2C%20143%29%20%2D%3E%20color%5Fhex')""></button></div><code class="language-nbt hljs numbat">>>> use extra::color
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=use%20extra%3A%3Acolor%0Argb%28225%2C%2036%2C%20143%29%20%2D%3E%20color%5Fhex')""></button></div><code class="language-nbt hljs numbat">use extra::color
 rgb(225, 36, 143) -> color_hex
 
     = "#e1248f"    [String]

+ 16 - 16
book/src/list-functions-strings.md

@@ -12,7 +12,7 @@ fn str_length(s: String) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Flength%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">>>> str_length("Numbat")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Flength%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">str_length("Numbat")
 
     = 6
 </code></pre>
@@ -29,7 +29,7 @@ fn str_slice(s: String, start: Scalar, end: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fslice%28%22Numbat%22%2C%203%2C%206%29')""></button></div><code class="language-nbt hljs numbat">>>> str_slice("Numbat", 3, 6)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fslice%28%22Numbat%22%2C%203%2C%206%29')""></button></div><code class="language-nbt hljs numbat">str_slice("Numbat", 3, 6)
 
     = "bat"    [String]
 </code></pre>
@@ -46,7 +46,7 @@ fn chr(n: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=0x2764%20%2D%3E%20chr')""></button></div><code class="language-nbt hljs numbat">>>> 0x2764 -> chr
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=0x2764%20%2D%3E%20chr')""></button></div><code class="language-nbt hljs numbat">0x2764 -> chr
 
     = "❤"    [String]
 </code></pre>
@@ -63,7 +63,7 @@ fn ord(s: String) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=%22%E2%9D%A4%22%20%2D%3E%20ord')""></button></div><code class="language-nbt hljs numbat">>>> "❤" -> ord
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=%22%E2%9D%A4%22%20%2D%3E%20ord')""></button></div><code class="language-nbt hljs numbat">"❤" -> ord
 
     = 10084
 </code></pre>
@@ -80,7 +80,7 @@ fn lowercase(s: String) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=lowercase%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">>>> lowercase("Numbat")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=lowercase%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">lowercase("Numbat")
 
     = "numbat"    [String]
 </code></pre>
@@ -97,7 +97,7 @@ fn uppercase(s: String) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=uppercase%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">>>> uppercase("Numbat")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=uppercase%28%22Numbat%22%29')""></button></div><code class="language-nbt hljs numbat">uppercase("Numbat")
 
     = "NUMBAT"    [String]
 </code></pre>
@@ -114,7 +114,7 @@ fn str_append(a: String, b: String) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fappend%28%22Numbat%22%2C%20%22%21%22%29')""></button></div><code class="language-nbt hljs numbat">>>> str_append("Numbat", "!")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fappend%28%22Numbat%22%2C%20%22%21%22%29')""></button></div><code class="language-nbt hljs numbat">str_append("Numbat", "!")
 
     = "Numbat!"    [String]
 </code></pre>
@@ -131,7 +131,7 @@ fn str_find(haystack: String, needle: String) -> Scalar
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Ffind%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22typed%22%29')""></button></div><code class="language-nbt hljs numbat">>>> str_find("Numbat is a statically typed programming language.", "typed")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Ffind%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22typed%22%29')""></button></div><code class="language-nbt hljs numbat">str_find("Numbat is a statically typed programming language.", "typed")
 
     = 23
 </code></pre>
@@ -148,7 +148,7 @@ fn str_contains(haystack: String, needle: String) -> Bool
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fcontains%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22typed%22%29')""></button></div><code class="language-nbt hljs numbat">>>> str_contains("Numbat is a statically typed programming language.", "typed")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Fcontains%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22typed%22%29')""></button></div><code class="language-nbt hljs numbat">str_contains("Numbat is a statically typed programming language.", "typed")
 
     = true    [Bool]
 </code></pre>
@@ -165,7 +165,7 @@ fn str_replace(s: String, pattern: String, replacement: String) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Freplace%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22statically%20typed%20programming%20language%22%2C%20%22scientific%20calculator%22%29')""></button></div><code class="language-nbt hljs numbat">>>> str_replace("Numbat is a statically typed programming language.", "statically typed programming language", "scientific calculator")
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Freplace%28%22Numbat%20is%20a%20statically%20typed%20programming%20language%2E%22%2C%20%22statically%20typed%20programming%20language%22%2C%20%22scientific%20calculator%22%29')""></button></div><code class="language-nbt hljs numbat">str_replace("Numbat is a statically typed programming language.", "statically typed programming language", "scientific calculator")
 
     = "Numbat is a scientific calculator."    [String]
 </code></pre>
@@ -182,7 +182,7 @@ fn str_repeat(a: String, n: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Frepeat%28%22abc%22%2C%204%29')""></button></div><code class="language-nbt hljs numbat">>>> str_repeat("abc", 4)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=str%5Frepeat%28%22abc%22%2C%204%29')""></button></div><code class="language-nbt hljs numbat">str_repeat("abc", 4)
 
     = "abcabcabcabc"    [String]
 </code></pre>
@@ -199,7 +199,7 @@ fn base(b: Scalar, x: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%7C%3E%20base%2816%29')""></button></div><code class="language-nbt hljs numbat">>>> 42 |> base(16)
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%7C%3E%20base%2816%29')""></button></div><code class="language-nbt hljs numbat">42 |> base(16)
 
     = "2a"    [String]
 </code></pre>
@@ -216,7 +216,7 @@ fn bin(x: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%2D%3E%20bin')""></button></div><code class="language-nbt hljs numbat">>>> 42 -> bin
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%2D%3E%20bin')""></button></div><code class="language-nbt hljs numbat">42 -> bin
 
     = "0b101010"    [String]
 </code></pre>
@@ -233,7 +233,7 @@ fn oct(x: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%2D%3E%20oct')""></button></div><code class="language-nbt hljs numbat">>>> 42 -> oct
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=42%20%2D%3E%20oct')""></button></div><code class="language-nbt hljs numbat">42 -> oct
 
     = "0o52"    [String]
 </code></pre>
@@ -250,7 +250,7 @@ fn dec(x: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=0b111%20%2D%3E%20dec')""></button></div><code class="language-nbt hljs numbat">>>> 0b111 -> dec
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=0b111%20%2D%3E%20dec')""></button></div><code class="language-nbt hljs numbat">0b111 -> dec
 
     = "7"    [String]
 </code></pre>
@@ -267,7 +267,7 @@ fn hex(x: Scalar) -> String
 <details>
 <summary>Examples</summary>
 
-<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=2%5E31%2D1%20%2D%3E%20hex')""></button></div><code class="language-nbt hljs numbat">>>> 2^31-1 -> hex
+<pre><div class="buttons"><button class="fa fa-play play-button" title="Run this code" aria-label="Run this code"  onclick=" window.open('https://numbat.dev/?q=2%5E31%2D1%20%2D%3E%20hex')""></button></div><code class="language-nbt hljs numbat">2^31-1 -> hex
 
     = "0x7fffffff"    [String]
 </code></pre>

+ 11 - 1
examples/tests/core.nbt

@@ -58,7 +58,7 @@ assert_eq(ceil(-1.2), -1)
 assert_eq(1.2 m |> ceil_in(m), 2 m)
 assert_eq(1.2 m |> ceil_in(cm), 120 cm)
 
-# trunc, trunc_in
+# trunc, trunc_in, fract
 
 assert_eq(trunc(1.2), 1)
 assert_eq(trunc(1.8), 1)
@@ -67,3 +67,13 @@ assert_eq(trunc(-1.8), -1)
 
 assert_eq(1.8 m |> trunc_in(m), 1 m)
 assert_eq(1.8 m |> trunc_in(cm), 180 cm)
+
+assert_eq(fract(1.2), 0.2, 1e-12)
+assert_eq(fract(1.8), 0.8, 1e-12)
+assert_eq(fract(0), 0)
+assert_eq(fract(1), 0)
+assert_eq(fract(1e10), 0)
+assert_eq(fract(-1.2), -0.2, 1e-12)
+assert_eq(fract(-1.8), -0.8, 1e-12)
+assert(is_nan(fract(NaN)))
+assert(is_nan(fract(inf)))

+ 23 - 0
examples/tests/lists.nbt

@@ -70,6 +70,29 @@ fn negate(x) = -x
 assert_eq(sort_by_key(negate, [1, 2, 3]), [3, 2, 1])
 assert_eq(sort_by_key(str_length, ["aa", "", "aaaa", "aaa"]), ["", "aa", "aaa", "aaaa"])
 
+# contains:
+assert(contains(1, [1]))
+
+assert(contains(1, [1, 2, 3]))
+assert(contains(1, [3, 2, 1]))
+assert(contains(1, [3, 1, 2]))
+
+assert(!contains(10, [1, 2, 3]))
+
+assert(contains("1", ["1", "2", "3"]))
+
+# unique:
+assert_eq(unique([]), [])
+
+assert_eq(unique([1, 2, 3]), [1, 2, 3])
+
+assert_eq(unique([1, 2, 2, 3, 3, 3]), [1, 2, 3])
+assert_eq(unique([3, 3, 3, 2, 2, 1]), [3, 2, 1])
+assert_eq(unique([1, 3, 2, 3, 2, 3]), [1, 3, 2])
+
+assert_eq(unique([1, 3, 2, 3, 2, 3]), unique(unique([1, 3, 2, 3, 2, 3,])))
+
+# intersperse:
 assert_eq(intersperse(0, []), [])
 assert_eq(intersperse(0, [1]), [1])
 assert_eq(intersperse(0, [1, 2, 3]), [1, 0, 2, 0, 3])

+ 78 - 0
examples/tests/math_functions.nbt

@@ -108,6 +108,84 @@ assert_eq(gamma(2.5), 1.329_340_388, 1e-8)
 assert_eq(gamma(3), 2)
 assert_eq(gamma(4), 6)
 
+# factorial
+
+assert_eq(factorial(0), 1)
+assert_eq(factorial(1), 1)
+assert_eq(factorial(2), 2)
+assert_eq(factorial(3), 6)
+assert_eq(factorial(4), 24)
+assert_eq(factorial(20), 2432902008176640000)
+
+# falling factorial
+
+assert_eq(falling_factorial(0, 0), 1)
+assert_eq(falling_factorial(1, 0), 1)
+assert_eq(falling_factorial(2, 0), 1)
+assert_eq(falling_factorial(42.5, 0), 1)
+
+assert_eq(falling_factorial(0, 1), 0)
+assert_eq(falling_factorial(1, 1), 1)
+assert_eq(falling_factorial(2, 1), 2)
+assert_eq(falling_factorial(42.5, 1), 42.5)
+
+assert_eq(falling_factorial(0, 2), 0)
+assert_eq(falling_factorial(1, 2), 0)
+assert_eq(falling_factorial(2, 2), 2)
+assert_eq(falling_factorial(42.5, 2), 1763.75)
+
+assert_eq(falling_factorial(4, 0), 1)
+assert_eq(falling_factorial(4, 1), 4)
+assert_eq(falling_factorial(4, 2), 12)
+assert_eq(falling_factorial(4, 3), 24)
+assert_eq(falling_factorial(4, 4), 24)
+assert_eq(falling_factorial(4, 5), 0)
+assert_eq(falling_factorial(4, 6), 0)
+
+assert_eq(falling_factorial(20, 0), 1)
+assert_eq(falling_factorial(20, 1), 20)
+assert_eq(falling_factorial(20, 2), 380)
+assert_eq(falling_factorial(20, 20), 2432902008176640000)
+assert_eq(falling_factorial(20, 21), 0)
+
+# binomial coefficient
+assert_eq(binom(0, -1), 0)
+assert_eq(binom(0, 0), 1)
+assert_eq(binom(0, 1), 0)
+
+assert_eq(binom(1, -1), 0)
+assert_eq(binom(1, 0), 1)
+assert_eq(binom(1, 1), 1)
+assert_eq(binom(1, 2), 0)
+
+assert_eq(binom(2, -1), 0)
+assert_eq(binom(2, 0), 1)
+assert_eq(binom(2, 1), 2)
+assert_eq(binom(2, 2), 1)
+assert_eq(binom(2, 3), 0)
+
+assert_eq(binom(3, -1), 0)
+assert_eq(binom(3, 0), 1)
+assert_eq(binom(3, 1), 3)
+assert_eq(binom(3, 2), 3)
+assert_eq(binom(3, 3), 1)
+assert_eq(binom(3, 4), 0)
+
+assert_eq(binom(4, -1), 0)
+assert_eq(binom(4, 0), 1)
+assert_eq(binom(4, 1), 4)
+assert_eq(binom(4, 2), 6)
+assert_eq(binom(4, 3), 4)
+assert_eq(binom(4, 4), 1)
+assert_eq(binom(4, 5), 0)
+
+assert_eq(binom(1.5, -1), 0)
+assert_eq(binom(1.5, 0), 1)
+assert_eq(binom(1.5, 1), 1.5)
+assert_eq(binom(1.5, 2), 0.375)
+assert_eq(binom(1.5, 3), -0.0625)
+assert_eq(binom(1.5, 4), 0.0234375)
+
 # maximum
 
 assert_eq(maximum([1]), 1)

+ 0 - 2
examples/tests/mixed_units.nbt

@@ -47,5 +47,3 @@ let test2 = 12 degree + 34 arcminute + 5 arcsec
 assert_eq(test2 |> unit_list([degree]) |> head, test2)
 assert_eq(test2 |> unit_list([degree, arcmin]) |> sum, test2)
 assert_eq(test2 |> unit_list([degree, arcmin, arcsec]) |> sum, test2)
-
-

+ 1 - 1
numbat-cli/Cargo.toml

@@ -14,7 +14,7 @@ rust-version = "1.70"
 
 [dependencies]
 anyhow = "1"
-rustyline = { version = "13", features = ["derive"] }
+rustyline = { version = "14.0.0", features = ["derive"] }
 dirs = "5"
 numbat = { version = "1.14.0", path = "../numbat" }
 colored = "2"

+ 7 - 4
numbat-cli/src/ansi_formatter.rs

@@ -1,4 +1,7 @@
-use numbat::markup::{FormatType, FormattedString, Formatter, Markup};
+use numbat::{
+    compact_str::{CompactString, ToCompactString},
+    markup::{FormatType, FormattedString, Formatter, Markup},
+};
 
 use colored::Colorize;
 
@@ -8,7 +11,7 @@ impl Formatter for ANSIFormatter {
     fn format_part(
         &self,
         FormattedString(_output_type, format_type, text): &FormattedString,
-    ) -> String {
+    ) -> CompactString {
         (match format_type {
             FormatType::Whitespace => text.normal(),
             FormatType::Emphasized => text.bold(),
@@ -23,10 +26,10 @@ impl Formatter for ANSIFormatter {
             FormatType::Operator => text.bold(),
             FormatType::Decorator => text.green(),
         })
-        .to_string()
+        .to_compact_string()
     }
 }
 
-pub fn ansi_format(m: &Markup, indent: bool) -> String {
+pub fn ansi_format(m: &Markup, indent: bool) -> CompactString {
     ANSIFormatter {}.format(m, indent)
 }

+ 3 - 3
numbat-cli/src/completer.rs

@@ -1,12 +1,12 @@
 use std::sync::{Arc, Mutex};
 
-use numbat::{unicode_input::UNICODE_INPUT, Context};
+use numbat::{compact_str::CompactString, unicode_input::UNICODE_INPUT, Context};
 use rustyline::completion::{extract_word, Completer, Pair};
 
 pub struct NumbatCompleter {
     pub context: Arc<Mutex<Context>>,
-    pub modules: Vec<String>,
-    pub all_timezones: Vec<String>,
+    pub modules: Vec<CompactString>,
+    pub all_timezones: Vec<CompactString>,
 }
 
 impl Completer for NumbatCompleter {

+ 3 - 2
numbat-cli/src/config.rs

@@ -1,4 +1,5 @@
 use clap::ValueEnum;
+use numbat::compact_str::CompactString;
 use serde::{Deserialize, Serialize};
 
 #[derive(Serialize, Deserialize, PartialEq, Eq, Default, Debug, Clone, Copy, ValueEnum)]
@@ -52,7 +53,7 @@ pub enum ColorMode {
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct Config {
     pub intro_banner: IntroBanner,
-    pub prompt: String,
+    pub prompt: CompactString,
     pub pretty_print: PrettyPrintMode,
     pub color: ColorMode,
 
@@ -70,7 +71,7 @@ pub struct Config {
 impl Default for Config {
     fn default() -> Self {
         Self {
-            prompt: ">>> ".to_owned(),
+            prompt: CompactString::const_new(">>> "),
             intro_banner: IntroBanner::default(),
             pretty_print: PrettyPrintMode::Auto,
             color: ColorMode::default(),

+ 15 - 10
numbat-cli/src/highlighter.rs

@@ -1,4 +1,5 @@
 use colored::Colorize;
+use numbat::compact_str::ToCompactString;
 use numbat::keywords::KEYWORDS;
 use numbat::{markup, Context};
 use rustyline::{highlight::Highlighter, CompletionType};
@@ -40,23 +41,27 @@ impl Highlighter for NumbatHighlighter {
         if ctx.variable_names().any(|n| n == candidate)
             || ctx.function_names().any(|n| format!("{n}(") == candidate)
         {
-            Cow::Owned(ansi_format(
-                &markup::identifier(candidate.to_string()),
-                false,
-            ))
+            Cow::Owned(
+                ansi_format(&markup::identifier(candidate.to_compact_string()), false).to_string(),
+            )
         } else if ctx
             .unit_names()
             .iter()
             .any(|un| un.iter().any(|n| n == candidate))
         {
-            Cow::Owned(ansi_format(&markup::unit(candidate.to_string()), false))
+            Cow::Owned(ansi_format(&markup::unit(candidate.to_compact_string()), false).to_string())
         } else if ctx.dimension_names().iter().any(|n| n == candidate) {
-            Cow::Owned(ansi_format(
-                &markup::type_identifier(candidate.to_string()),
-                false,
-            ))
+            Cow::Owned(
+                ansi_format(
+                    &markup::type_identifier(candidate.to_compact_string()),
+                    false,
+                )
+                .to_string(),
+            )
         } else if KEYWORDS.iter().any(|k| k == &candidate) {
-            Cow::Owned(ansi_format(&markup::keyword(candidate.to_string()), false))
+            Cow::Owned(
+                ansi_format(&markup::keyword(candidate.to_compact_string()), false).to_string(),
+            )
         } else {
             Cow::Borrowed(candidate)
         }

+ 7 - 3
numbat-cli/src/main.rs

@@ -11,6 +11,7 @@ use highlighter::NumbatHighlighter;
 
 use itertools::Itertools;
 use numbat::command::{self, CommandParser, SourcelessCommandParser};
+use numbat::compact_str::{CompactString, ToCompactString};
 use numbat::diagnostic::ErrorDiagnostic;
 use numbat::help::help_markup;
 use numbat::markup as m;
@@ -297,7 +298,10 @@ impl Cli {
             completer: NumbatCompleter {
                 context: self.context.clone(),
                 modules: self.context.lock().unwrap().list_modules().collect(),
-                all_timezones: jiff::tz::db().available().collect(),
+                all_timezones: jiff::tz::db()
+                    .available()
+                    .map(CompactString::from)
+                    .collect(),
             },
             highlighter: NumbatHighlighter {
                 context: self.context.clone(),
@@ -418,7 +422,7 @@ impl Cli {
                                                 let m = m::text(
                                                     "successfully saved session history to",
                                                 ) + m::space()
-                                                    + m::string(dst.to_string());
+                                                    + m::string(dst.to_compact_string());
                                                 println!("{}", ansi_format(&m, interactive));
                                             }
                                             Err(err) => {
@@ -462,7 +466,7 @@ impl Cli {
                             }
                         }
 
-                        session_history.push(line, result);
+                        session_history.push(CompactString::from(line), result);
                     }
                 }
                 Err(ReadlineError::Interrupted) => {}

+ 21 - 10
numbat-wasm/src/jquery_terminal_formatter.rs

@@ -1,21 +1,29 @@
 use numbat::buffered_writer::BufferedWriter;
 use numbat::markup::{FormatType, FormattedString, Formatter};
 
+use numbat::compact_str::{format_compact, CompactString};
 use termcolor::{Color, WriteColor};
 
 pub struct JqueryTerminalFormatter;
 
-pub fn jt_format(class: Option<&str>, content: &str) -> String {
+pub fn jt_format(class: Option<&str>, content: &str) -> CompactString {
     if content.is_empty() {
-        return "".into();
+        return CompactString::const_new("");
     }
 
-    let content = html_escape::encode_text(content)
-        .replace('[', "&#91;")
-        .replace(']', "&#93;");
+    let escaped = html_escape::encode_text(content);
+    let mut content = CompactString::with_capacity(escaped.len());
+
+    for c in escaped.chars() {
+        match c {
+            '[' => content.push_str("&#91;"),
+            ']' => content.push_str("&#93;"),
+            _ => content.push(c),
+        }
+    }
 
     if let Some(class) = class {
-        format!("[[;;;hl-{class}]{content}]")
+        format_compact!("[[;;;hl-{class}]{content}]")
     } else {
         content
     }
@@ -25,7 +33,7 @@ impl Formatter for JqueryTerminalFormatter {
     fn format_part(
         &self,
         FormattedString(_output_type, format_type, s): &FormattedString,
-    ) -> String {
+    ) -> CompactString {
         let css_class = match format_type {
             FormatType::Whitespace => None,
             FormatType::Emphasized => Some("emphasized"),
@@ -68,17 +76,20 @@ impl std::io::Write for JqueryTerminalWriter {
     fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
         if let Some(color) = &self.color {
             if color.fg() == Some(&Color::Red) {
-                self.buffer.write_all("[[;;;hl-diagnostic-red]".as_bytes())?;
+                self.buffer
+                    .write_all("[[;;;hl-diagnostic-red]".as_bytes())?;
                 let size = self.buffer.write(buf)?;
                 self.buffer.write_all("]".as_bytes())?;
                 Ok(size)
             } else if color.fg() == Some(&Color::Blue) {
-                self.buffer.write_all("[[;;;hl-diagnostic-blue]".as_bytes())?;
+                self.buffer
+                    .write_all("[[;;;hl-diagnostic-blue]".as_bytes())?;
                 let size = self.buffer.write(buf)?;
                 self.buffer.write_all("]".as_bytes())?;
                 Ok(size)
             } else if color.bold() {
-                self.buffer.write_all("[[;;;hl-diagnostic-bold]".as_bytes())?;
+                self.buffer
+                    .write_all("[[;;;hl-diagnostic-bold]".as_bytes())?;
                 let size = self.buffer.write(buf)?;
                 self.buffer.write_all("]".as_bytes())?;
                 Ok(size)

+ 1 - 1
numbat-wasm/src/lib.rs

@@ -79,7 +79,7 @@ impl Numbat {
             FormatType::JqueryTerminal => Box::new(JqueryTerminalFormatter {}),
             FormatType::Html => Box::new(HtmlFormatter {}),
         };
-        fmt.format(markup, indent)
+        fmt.format(markup, indent).to_string()
     }
 
     pub fn interpret(&mut self, code: &str) -> InterpreterOutput {

+ 1 - 0
numbat/Cargo.toml

@@ -37,6 +37,7 @@ strfmt = "0.2.4"
 indexmap = "2.2.6"
 mendeleev = "0.8.1"
 plotly = "0.10.0"
+compact_str = { version="0.8.0", features = ["serde"] }
 
 [features]
 default = ["fetch-exchangerates"]

+ 12 - 11
numbat/examples/inspect.rs

@@ -1,9 +1,11 @@
+use compact_str::{format_compact, CompactString};
 use itertools::Itertools;
 use numbat::markup::plain_text_format;
 use numbat::module_importer::FileSystemImporter;
 use numbat::resolver::CodeSource;
 use numbat::Context;
 use std::path::Path;
+use std::process::exit;
 
 const AUTO_GENERATED_HINT: &str = "<!-- NOTE! This file is auto-generated -->";
 
@@ -32,7 +34,7 @@ and — where sensible — units allow for [binary prefixes](https://en.wikipedi
         let name = unit_metadata.name.unwrap_or(unit_name.clone());
 
         let name_with_url = if let Some(url) = url {
-            format!("[{name}]({url})")
+            format_compact!("[{name}]({url})")
         } else {
             name.clone()
         };
@@ -60,7 +62,7 @@ fn inspect_functions_in_module(ctx: &Context, prelude_ctx: &Context, module: Str
         }
 
         if let Some(ref description_raw) = description {
-            let description = replace_equation_delimiters(description_raw.trim().to_string());
+            let description = replace_equation_delimiters(description_raw.trim());
 
             if description.ends_with('.') {
                 println!("{description}");
@@ -101,16 +103,13 @@ fn inspect_functions_in_module(ctx: &Context, prelude_ctx: &Context, module: Str
                 if let Ok((statements, results)) =
                     example_ctx.interpret(&example_code, CodeSource::Internal)
                 {
-                    let code = extra_import + &example_code;
-
-                    //Format the example input
-                    let example_input = format!(">>> {}", code);
+                    let example_input = extra_import + &example_code;
 
                     //Encode the example url
                     let example_url = format!(
                         "https://numbat.dev/?q={}",
                         percent_encoding::utf8_percent_encode(
-                            &code,
+                            &example_input,
                             percent_encoding::NON_ALPHANUMERIC
                         )
                     );
@@ -126,7 +125,7 @@ fn inspect_functions_in_module(ctx: &Context, prelude_ctx: &Context, module: Str
 
                     //Print the example
                     if let Some(example_description) = example_description {
-                        println!("{}", replace_equation_delimiters(example_description));
+                        println!("{}", replace_equation_delimiters(&example_description));
                     }
 
                     print!("<pre>");
@@ -146,8 +145,9 @@ fn inspect_functions_in_module(ctx: &Context, prelude_ctx: &Context, module: Str
                     println!();
                 } else {
                     eprintln!(
-                        "Warning: Example \"{example_code}\" of function {fn_name} did not run successfully."
+                        "Error: Example \"{example_code}\" of function {fn_name} did not run successfully."
                     );
+                    exit(1);
                 }
             }
             println!("</details>");
@@ -157,8 +157,9 @@ fn inspect_functions_in_module(ctx: &Context, prelude_ctx: &Context, module: Str
 }
 
 // Replace $..$ with \\( .. \\) for mdbook.
-fn replace_equation_delimiters(text_in: String) -> String {
-    let mut text_out = String::new();
+fn replace_equation_delimiters(text_in: &str) -> CompactString {
+    let mut text_out = CompactString::with_capacity(text_in.len());
+    // TODO: handle \$ in math
     for (i, part) in text_in.split('$').enumerate() {
         if i % 2 == 0 {
             text_out.push_str(part);

+ 4 - 1
numbat/examples/unit_graph.rs

@@ -35,7 +35,10 @@ fn main() {
     })
     // TODO: check if to_integer can fail here.sorted_by_key(|(_, b)| b.clone())
     {
-        let is_base = base_representation == vec![(unit_name.into(), 1i128)];
+        let is_base = base_representation
+            .iter()
+            .map(|(a, b)| (a, b))
+            .eq([(unit_name, &1i128)]);
 
         if !is_base {
             for (base_factor, _) in base_representation {

+ 11 - 0
numbat/modules/core/functions.nbt

@@ -79,6 +79,17 @@ fn trunc(x: Scalar) -> Scalar
 @example("trunc_in(cm, 5.7 m)", "Truncate in centimeters.")
 fn trunc_in<D: Dim>(base: D, value: D) -> D = trunc(value / base) × base
 
+@name("Fractional part")
+@description("Returns the fractional part of $x$, i.e. the remainder when divided by 1.
+	If $x < 0$, then so will be `fract(x)`. Note that due to floating point error, a
+	number’s fractional part can be slightly “off”; for instance, `fract(1.2) ==
+	0.1999...996 != 0.2`.")
+@url("https://doc.rust-lang.org/std/primitive.f64.html#method.fract")
+@example("fract(0.0)")
+@example("fract(5.5)")
+@example("fract(-5.5)")
+fn fract(x: Scalar) -> Scalar
+
 @name("Modulo")
 @description("Calculates the least nonnegative remainder of $a (\\mod b)$.")
 @url("https://doc.rust-lang.org/std/primitive.f64.html#method.rem_euclid")

+ 24 - 1
numbat/modules/core/lists.nbt

@@ -114,10 +114,33 @@ fn sort_by_key<A, D: Dim>(key: Fn[(A) -> D], xs: List<A>) -> List<A> =
                   sort_by_key(key, drop(floor(len(xs) / 2), xs)),
                   key)
 
-@description("Sort a list of quantities")
+@description("Sort a list of quantities in ascending order")
 @example("sort([3, 2, 7, 8, -4, 0, -5])")
 fn sort<D: Dim>(xs: List<D>) -> List<D> = sort_by_key(id, xs)
 
+@description("Returns true if the element `x` is in the list `xs`.")
+@example("[3, 2, 7, 8, -4, 0, -5] |> contains(0)")
+@example("[3, 2, 7, 8, -4, 0, -5] |> contains(1)")
+fn contains<A>(x: A, xs: List<A>) -> Bool = 
+  if is_empty(xs)
+    then false
+    else if x == head(xs)
+      then true
+      else contains(x, tail(xs))
+
+fn _unique<A>(acc: List<A>, xs: List<A>) -> List<A> = 
+  if is_empty(xs)
+    then acc
+    else if is_empty(acc)
+      then _unique([head(xs)], tail(xs))
+      else if (acc |> contains(head(xs)))
+        then _unique(acc, tail(xs))
+        else _unique((cons_end(head(xs), acc)), tail(xs))
+
+@description("Remove duplicates from a given list.")
+@example("unique([1, 2, 2, 3, 3, 3])")
+fn unique<A>(xs: List<A>) -> List<A> = xs |> _unique([])
+
 @description("Add an element between each pair of elements in a list")
 @example("intersperse(0, [1, 1, 1, 1])")
 fn intersperse<A>(sep: A, xs: List<A>) -> List<A> =

+ 10 - 0
numbat/modules/core/mixed_units.nbt

@@ -1,5 +1,7 @@
 use core::strings
 use core::lists
+use core::numbers
+use core::quantities
 
 # Helper functions for mixed-unit conversions. See units::mixed for more.
 
@@ -15,3 +17,11 @@ fn _mixed_unit_list<D: Dim>(val: D, units: List<D>, acc: List<D>) -> List<D> =
     if (len(units) > 0)
       then (val |> trunc_in(head(units)))
       else error("Units list cannot be empty")
+  
+fn _negate<D: Dim>(x: D) = -x
+
+fn _sort_descending<D: Dim>(xs: List<D>) -> List<D> = sort_by_key(_negate, xs)
+
+fn _clean_units<D: Dim>(units: List<D>) -> List<D> = units |> unique() |> _sort_descending()
+
+fn _unit_list<D: Dim>(units: List<D>, value: D) -> List<D> = _mixed_unit_list(value, _clean_units(units), [])

+ 18 - 0
numbat/modules/core/numbers.nbt

@@ -1,3 +1,6 @@
+use core::scalar
+use core::functions
+
 @description("Returns true if the input is `NaN`.")
 @url("https://doc.rust-lang.org/std/primitive.f64.html#method.is_nan")
 @example("is_nan(37)")
@@ -14,3 +17,18 @@ fn is_infinite<T: Dim>(n: T) -> Bool
 @example("is_finite(37)")
 @example("is_finite(-inf)")
 fn is_finite<T: Dim>(n: T) -> Bool = !is_nan(n) && !is_infinite(n)
+
+@description("Returns true if the input is 0 (zero).")
+@example("is_zero(37)")
+@example("is_zero(0)")
+fn is_zero<D: Dim>(value: D) -> Bool = value == 0
+
+@description("Returns true unless the input is 0 (zero).")
+@example("is_nonzero(37)")
+@example("is_nonzero(0)")
+fn is_nonzero<D: Dim>(value: D) -> Bool = !is_zero(value)
+
+@description("Returns true if the input is an integer.")
+@example("is_integer(3)")
+@example("is_integer(pi)")
+fn is_integer(x: Scalar) -> Bool = is_zero(fract(x))

+ 1 - 1
numbat/modules/core/quantities.nbt

@@ -7,5 +7,5 @@ fn value_of<T: Dim>(x: T) -> Scalar
 
 @description("Extract the unit of a quantity (the `km/h` in `20 km/h`). This can be useful in generic code, but should generally be avoided otherwise. Returns an error if the quantity is zero.")
 @example("unit_of(20 km/h)")
-fn unit_of<T: Dim>(x: T) -> T = if x_value == 0 then error("") else x / value_of(x)
+fn unit_of<T: Dim>(x: T) -> T = if x_value == 0 then error("Invalid argument: cannot call `unit_of` on a value that evaluates to 0") else x / value_of(x)
     where x_value = value_of(x)

+ 35 - 0
numbat/modules/math/combinatorics.nbt

@@ -0,0 +1,35 @@
+use core::error
+use core::functions
+use core::numbers
+use math::transcendental
+
+@name("Factorial")
+@description("The product of the integers 1 through n. Numbat also supports calling this via the postfix operator `n!`.")
+@url("https://en.wikipedia.org/wiki/Factorial")
+@example("factorial(4)")
+@example("4!")
+fn factorial(n: Scalar) -> Scalar = n!
+
+@name("Falling factorial")
+@description("Equal to $n⋅(n-1)⋅…⋅(n-k+2)⋅(n-k+1)$ (k terms total). If n is an integer, this is the number of k-element permutations from a set of size n. k must always be an integer.")
+@url("https://en.wikipedia.org/wiki/Falling_and_rising_factorials")
+@example("falling_factorial(4, 2)")
+fn falling_factorial(n: Scalar, k: Scalar) -> Scalar =
+	if k < 0 || !is_integer(k) then
+		error("in falling_factorial(n, k), k must be a nonnegative integer")
+	else if is_zero(k) then
+		1
+	else
+		n * falling_factorial(n-1, k-1)
+
+@name("Binomial coefficient")
+@description("Equal to falling_factorial(n, k)/k!, this is the coefficient of $x^k$ in the series expansion of $(1+x)^n$ (see “binomial series”). If n is an integer, then this this is the number of k-element subsets of a set of size n, often read \"n choose k\". k must always be an integer.")
+@url("https://en.wikipedia.org/wiki/Binomial_coefficient")
+@example("binom(5, 2)")
+fn binom(n: Scalar, k: Scalar) -> Scalar =
+	if !is_integer(k) then
+		error("in binom(n, k), k must be an integer")
+	else if k < 0 || (k > n && is_integer(n)) then
+		0
+	else
+		falling_factorial(n, k) / k!

+ 2 - 0
numbat/modules/prelude.nbt

@@ -7,6 +7,7 @@ use core::strings
 use core::error
 use core::random
 use core::numbers
+use core::mixed_units
 
 use math::constants
 use math::transcendental
@@ -17,6 +18,7 @@ use math::number_theory
 use math::distributions
 use math::geometry
 use math::percentage_calculations
+use math::combinatorics
 
 use units::si
 use units::time

+ 2 - 3
numbat/modules/units/mixed.nbt

@@ -5,7 +5,7 @@ use units::imperial
 @name("Unit list")
 @description("Convert a value to a mixed representation using the provided units.")
 @example("5500 m |> unit_list([miles, yards, feet, inches])")
-fn unit_list<D: Dim>(units: List<D>, value: D) -> List<D> = _mixed_unit_list(value, units, [])
+fn unit_list<D: Dim>(units: List<D>, value: D) -> List<D> = _unit_list(units, value)
 
 @name("Degrees, minutes, seconds")
 @description("Convert an angle to a mixed degrees, (arc)minutes, and (arc)seconds representation. Also called sexagesimal degree notation.")
@@ -33,5 +33,4 @@ fn feet_and_inches(length: Length) -> List<Length> =
 @url("https://en.wikipedia.org/wiki/Pound_(mass)")
 @example("1 kg -> pounds_and_ounces")
 fn pounds_and_ounces(mass: Mass) -> List<Mass> =
-  unit_list([pound, ounce], mass)
-
+  unit_list([pound, ounce], mass)

+ 14 - 13
numbat/src/arithmetic.rs

@@ -1,3 +1,4 @@
+use compact_str::{format_compact, CompactString};
 use num_rational::Ratio;
 use num_traits::Signed;
 
@@ -15,30 +16,30 @@ pub trait Power {
     }
 }
 
-pub fn pretty_exponent(e: &Exponent) -> String {
+pub fn pretty_exponent(e: &Exponent) -> CompactString {
     if e == &Ratio::from_integer(5) {
-        "⁵".into()
+        CompactString::const_new("⁵")
     } else if e == &Ratio::from_integer(4) {
-        "⁴".into()
+        CompactString::const_new("⁴")
     } else if e == &Ratio::from_integer(3) {
-        "³".into()
+        CompactString::const_new("³")
     } else if e == &Ratio::from_integer(2) {
-        "²".into()
+        CompactString::const_new("²")
     } else if e == &Ratio::from_integer(1) {
-        "".into()
+        CompactString::const_new("")
     } else if e == &Ratio::from_integer(-1) {
-        "⁻¹".into()
+        CompactString::const_new("⁻¹")
     } else if e == &Ratio::from_integer(-2) {
-        "⁻²".into()
+        CompactString::const_new("⁻²")
     } else if e == &Ratio::from_integer(-3) {
-        "⁻³".into()
+        CompactString::const_new("⁻³")
     } else if e == &Ratio::from_integer(-4) {
-        "⁻⁴".into()
+        CompactString::const_new("⁻⁴")
     } else if e == &Ratio::from_integer(-5) {
-        "⁻⁵".into()
+        CompactString::const_new("⁻⁵")
     } else if e.is_positive() && e.is_integer() {
-        format!("^{e}")
+        format_compact!("^{e}")
     } else {
-        format!("^({e})")
+        format_compact!("^({e})")
     }
 }

+ 12 - 8
numbat/src/ast.rs

@@ -1,9 +1,11 @@
 use crate::markup as m;
+use crate::resolver::ModulePathBorrowed;
 use crate::span::Span;
 use crate::{
     arithmetic::Exponent, decorator::Decorator, markup::Markup, number::Number, prefix::Prefix,
-    pretty_print::PrettyPrint, resolver::ModulePath,
+    pretty_print::PrettyPrint,
 };
+use compact_str::{format_compact, CompactString, ToCompactString};
 use itertools::Itertools;
 use num_traits::Signed;
 
@@ -62,7 +64,7 @@ impl PrettyPrint for BinaryOperator {
 
 #[derive(Debug, Clone, PartialEq)]
 pub enum StringPart<'a> {
-    Fixed(String),
+    Fixed(CompactString),
     Interpolation {
         span: Span,
         expr: Box<Expression<'a>>,
@@ -74,7 +76,7 @@ pub enum StringPart<'a> {
 pub enum Expression<'a> {
     Scalar(Span, Number),
     Identifier(Span, &'a str),
-    UnitIdentifier(Span, Prefix, String, String), // can't easily be made &'a str
+    UnitIdentifier(Span, Prefix, CompactString, CompactString), // can't easily be made &'a str
     TypedHole(Span),
     UnaryOperator {
         op: UnaryOperator,
@@ -325,7 +327,7 @@ impl PrettyPrint for TypeAnnotation {
 
 pub enum TypeExpression {
     Unity(Span),
-    TypeIdentifier(Span, String),
+    TypeIdentifier(Span, CompactString),
     Multiply(Span, Box<TypeExpression>, Box<TypeExpression>),
     Divide(Span, Box<TypeExpression>, Box<TypeExpression>),
     Power(
@@ -370,7 +372,9 @@ impl PrettyPrint for TypeExpression {
     fn pretty_print(&self) -> Markup {
         match self {
             TypeExpression::Unity(_) => m::type_identifier("1"),
-            TypeExpression::TypeIdentifier(_, ident) => m::type_identifier(ident.clone()),
+            TypeExpression::TypeIdentifier(_, ident) => {
+                m::type_identifier(ident.to_compact_string())
+            }
             TypeExpression::Multiply(_, lhs, rhs) => {
                 lhs.pretty_print() + m::space() + m::operator("×") + m::space() + rhs.pretty_print()
             }
@@ -381,9 +385,9 @@ impl PrettyPrint for TypeExpression {
                 with_parens(lhs)
                     + m::operator("^")
                     + if exp.is_positive() {
-                        m::value(format!("{exp}"))
+                        m::value(format_compact!("{exp}"))
                     } else {
-                        m::operator("(") + m::value(format!("{exp}")) + m::operator(")")
+                        m::operator("(") + m::value(format_compact!("{exp}")) + m::operator(")")
                     }
             }
         }
@@ -441,7 +445,7 @@ pub enum Statement<'a> {
         decorators: Vec<Decorator<'a>>,
     },
     ProcedureCall(Span, ProcedureKind, Vec<Expression<'a>>),
-    ModuleImport(Span, ModulePath),
+    ModuleImport(Span, ModulePathBorrowed<'a>),
     DefineStruct {
         struct_name_span: Span,
         struct_name: &'a str,

+ 29 - 28
numbat/src/bytecode_interpreter.rs

@@ -1,5 +1,6 @@
 use std::collections::HashMap;
 
+use compact_str::{CompactString, ToCompactString};
 use itertools::Itertools;
 
 use crate::ast::ProcedureKind;
@@ -23,15 +24,15 @@ use crate::{decorator, ffi, Type};
 
 #[derive(Debug, Clone, Default)]
 pub struct LocalMetadata {
-    pub name: Option<String>,
-    pub url: Option<String>,
-    pub description: Option<String>,
-    pub aliases: Vec<String>,
+    pub name: Option<CompactString>,
+    pub url: Option<CompactString>,
+    pub description: Option<CompactString>,
+    pub aliases: Vec<CompactString>,
 }
 
 #[derive(Debug, Clone)]
 pub struct Local {
-    identifier: String,
+    identifier: CompactString,
     depth: usize,
     pub metadata: LocalMetadata,
 }
@@ -42,9 +43,9 @@ pub struct BytecodeInterpreter {
     /// List of local variables currently in scope, one vector for each scope (for now: 0: 'global' scope, 1: function scope)
     locals: Vec<Vec<Local>>,
     // Maps names of units to indices of the respective constants in the VM
-    unit_name_to_constant_index: HashMap<String, u16>,
+    unit_name_to_constant_index: HashMap<CompactString, u16>,
     /// List of functions
-    functions: HashMap<String, bool>,
+    functions: HashMap<CompactString, bool>,
 }
 
 impl BytecodeInterpreter {
@@ -61,12 +62,12 @@ impl BytecodeInterpreter {
 
                 if let Some(position) = self.locals[current_depth]
                     .iter()
-                    .rposition(|l| &l.identifier == identifier && l.depth == current_depth)
+                    .rposition(|l| l.identifier == identifier && l.depth == current_depth)
                 {
                     self.vm.add_op1(Op::GetLocal, position as u16); // TODO: check overflow
                 } else if let Some(upvalue_position) = self.locals[0]
                     .iter()
-                    .rposition(|l| &l.identifier == identifier)
+                    .rposition(|l| l.identifier == identifier)
                 {
                     self.vm.add_op1(Op::GetUpvalue, upvalue_position as u16);
                 } else if LAST_RESULT_IDENTIFIERS.contains(identifier) {
@@ -75,9 +76,9 @@ impl BytecodeInterpreter {
                     let index = self
                         .vm
                         .add_constant(Constant::FunctionReference(if *is_foreign {
-                            FunctionReference::Foreign(identifier.to_string())
+                            FunctionReference::Foreign(identifier.to_compact_string())
                         } else {
-                            FunctionReference::Normal(identifier.to_string())
+                            FunctionReference::Normal(identifier.to_compact_string())
                         }));
                     self.vm.add_op1(Op::LoadConstant, index);
                 } else {
@@ -221,7 +222,7 @@ impl BytecodeInterpreter {
                 for part in string_parts {
                     match part {
                         StringPart::Fixed(s) => {
-                            let index = self.vm.add_constant(Constant::String(s.to_string()));
+                            let index = self.vm.add_constant(Constant::String(s.clone()));
                             self.vm.add_op1(Op::LoadConstant, index)
                         }
                         StringPart::Interpolation {
@@ -231,7 +232,7 @@ impl BytecodeInterpreter {
                         } => {
                             self.compile_expression(expr)?;
                             let index = self.vm.add_constant(Constant::FormatSpecifiers(
-                                format_specifiers.map(|s| s.to_string()),
+                                format_specifiers.map(|s| s.to_compact_string()),
                             ));
                             self.vm.add_op1(Op::LoadConstant, index)
                         }
@@ -283,11 +284,11 @@ impl BytecodeInterpreter {
 
         // For variables, we ignore the prefix info and only use the names
         let aliases = crate::decorator::name_and_aliases(identifier, decorators)
-            .map(|(name, _)| name.to_owned())
+            .map(|(name, _)| name.to_compact_string())
             .collect::<Vec<_>>();
         let metadata = LocalMetadata {
-            name: crate::decorator::name(decorators).map(ToOwned::to_owned),
-            url: crate::decorator::url(decorators).map(ToOwned::to_owned),
+            name: crate::decorator::name(decorators).map(CompactString::from),
+            url: crate::decorator::url(decorators).map(CompactString::from),
             description: crate::decorator::description(decorators),
             aliases: aliases.clone(),
         };
@@ -335,7 +336,7 @@ impl BytecodeInterpreter {
                 let current_depth = self.current_depth();
                 for parameter in parameters {
                     self.locals[current_depth].push(Local {
-                        identifier: parameter.1.to_string(),
+                        identifier: parameter.1.to_compact_string(),
                         depth: current_depth,
                         metadata: LocalMetadata::default(),
                     });
@@ -352,7 +353,7 @@ impl BytecodeInterpreter {
 
                 self.vm.end_function();
 
-                self.functions.insert(name.to_string(), false);
+                self.functions.insert(name.to_compact_string(), false);
             }
             Statement::DefineFunction(
                 name,
@@ -371,7 +372,7 @@ impl BytecodeInterpreter {
                 self.vm
                     .add_foreign_function(name, parameters.len()..=parameters.len());
 
-                self.functions.insert(name.to_string(), true);
+                self.functions.insert(name.to_compact_string(), true);
             }
             Statement::DefineDimension(_name, _dexprs) => {
                 // Declaring a dimension is like introducing a new type. The information
@@ -379,7 +380,7 @@ impl BytecodeInterpreter {
             }
             Statement::DefineBaseUnit(unit_name, decorators, annotation, type_) => {
                 let aliases = decorator::name_and_aliases(unit_name, decorators)
-                    .map(|(name, ap)| (name.to_owned(), ap))
+                    .map(|(name, ap)| (name.to_compact_string(), ap))
                     .collect();
 
                 self.vm
@@ -393,11 +394,11 @@ impl BytecodeInterpreter {
                                 .map(|a| a.pretty_print())
                                 .unwrap_or(type_.to_readable_type(dimension_registry, false)),
                             aliases,
-                            name: decorator::name(decorators).map(ToOwned::to_owned),
+                            name: decorator::name(decorators).map(CompactString::from),
                             canonical_name: decorator::get_canonical_unit_name(
                                 unit_name, decorators,
                             ),
-                            url: decorator::url(decorators).map(ToOwned::to_owned),
+                            url: decorator::url(decorators).map(CompactString::from),
                             description: decorator::description(decorators),
                             binary_prefixes: decorators.contains(&Decorator::BinaryPrefixes),
                             metric_prefixes: decorators.contains(&Decorator::MetricPrefixes),
@@ -406,7 +407,7 @@ impl BytecodeInterpreter {
                     .map_err(RuntimeError::UnitRegistryError)?;
 
                 let constant_idx = self.vm.add_constant(Constant::Unit(Unit::new_base(
-                    unit_name,
+                    unit_name.to_compact_string(),
                     crate::decorator::get_canonical_unit_name(unit_name, &decorators[..]),
                 )));
                 for (name, _) in decorator::name_and_aliases(unit_name, decorators) {
@@ -423,13 +424,13 @@ impl BytecodeInterpreter {
                 _readable_type,
             ) => {
                 let aliases = decorator::name_and_aliases(unit_name, decorators)
-                    .map(|(name, ap)| (name.to_owned(), ap))
+                    .map(|(name, ap)| (name.to_compact_string(), ap))
                     .collect();
 
                 let constant_idx = self.vm.add_constant(Constant::Unit(Unit::new_base(
-                    "<dummy>",
+                    CompactString::const_new("<dummy>"),
                     CanonicalName {
-                        name: "<dummy>".to_string(),
+                        name: CompactString::const_new("<dummy>"),
                         accepts_prefix: AcceptsPrefix::both(),
                     },
                 ))); // TODO: dummy is just a temp. value until the SetUnitConstant op runs
@@ -445,9 +446,9 @@ impl BytecodeInterpreter {
                             .map(|a| a.pretty_print())
                             .unwrap_or(type_.to_readable_type(dimension_registry, false)),
                         aliases,
-                        name: decorator::name(decorators).map(ToOwned::to_owned),
+                        name: decorator::name(decorators).map(CompactString::from),
                         canonical_name: decorator::get_canonical_unit_name(unit_name, decorators),
-                        url: decorator::url(decorators).map(ToOwned::to_owned),
+                        url: decorator::url(decorators).map(CompactString::from),
                         description: decorator::description(decorators),
                         binary_prefixes: decorators.contains(&Decorator::BinaryPrefixes),
                         metric_prefixes: decorators.contains(&Decorator::MetricPrefixes),

+ 8 - 5
numbat/src/column_formatter.rs

@@ -1,3 +1,4 @@
+use compact_str::{CompactString, ToCompactString};
 use unicode_width::UnicodeWidthStr;
 
 use crate::markup as m;
@@ -28,7 +29,7 @@ impl ColumnFormatter {
 
         let entries: Vec<_> = entries
             .into_iter()
-            .map(|s| s.as_ref().to_string())
+            .map(|s| s.as_ref().to_compact_string())
             .collect();
 
         if let Some(max_entry_width) = entries.iter().map(|s| s.width()).max() {
@@ -39,7 +40,7 @@ impl ColumnFormatter {
                 for entry in entries {
                     result +=
                         Markup::from(FormattedString(OutputType::Normal, format, entry.into()))
-                            + m::whitespace(" ".repeat(self.padding));
+                            + m::whitespace(CompactString::const_new(" ").repeat(self.padding));
                 }
                 return result;
             }
@@ -80,9 +81,11 @@ impl ColumnFormatter {
                             result += Markup::from(FormattedString(
                                 OutputType::Normal,
                                 format,
-                                entry.to_string().into(),
+                                entry.to_compact_string().into(),
                             ));
-                            result += m::whitespace(" ".repeat(whitespace_length));
+                            result += m::whitespace(
+                                CompactString::const_new(" ").repeat(whitespace_length),
+                            );
                         } else {
                             break;
                         }
@@ -98,7 +101,7 @@ impl ColumnFormatter {
 }
 
 #[cfg(test)]
-fn format(width: usize, entries: &[&str]) -> String {
+fn format(width: usize, entries: &[&str]) -> CompactString {
     use crate::markup::{Formatter, PlainTextFormatter};
 
     let formatter = ColumnFormatter::new(width);

+ 16 - 14
numbat/src/datetime.rs

@@ -1,3 +1,4 @@
+use compact_str::{CompactString, ToCompactString};
 use jiff::{civil::DateTime, fmt::rfc2822, tz::TimeZone, Timestamp, Zoned};
 use std::str::FromStr;
 
@@ -68,28 +69,29 @@ pub fn parse_datetime(input: &str) -> Result<Zoned, jiff::Error> {
     Timestamp::from_str(input).map(|ts| ts.to_zoned(get_local_timezone_or_utc()))
 }
 
-pub fn to_string(dt: &Zoned) -> String {
+pub fn to_string(dt: &Zoned) -> CompactString {
     let tz = dt.time_zone();
 
     if dt.time_zone() == &TimeZone::UTC {
-        dt.strftime("%Y-%m-%d %H:%M:%S UTC").to_string()
+        dt.strftime("%Y-%m-%d %H:%M:%S UTC").to_compact_string()
     } else {
+        use std::fmt::Write;
+        let mut out = CompactString::with_capacity("2000-01-01 00:00:00 (UTC +00:00)".len());
+        write!(out, "{}", dt.strftime("%Y-%m-%d %H:%M:%S")).unwrap();
+
         let offset = dt.offset();
         let zone_abbreviation = tz.to_offset(dt.timestamp()).2;
-        let abbreviation_and_offset =
-            if zone_abbreviation.starts_with('+') || zone_abbreviation.starts_with('-') {
-                format!("(UTC {offset})")
-            } else {
-                format!("{zone_abbreviation} (UTC {offset})")
-            };
-
-        let timezone_name = if let Some(iana_tz_name) = tz.iana_name() {
-            format!(", {iana_tz_name}")
+
+        if zone_abbreviation.starts_with('+') || zone_abbreviation.starts_with('-') {
+            write!(out, " (UTC {offset})").unwrap();
         } else {
-            "".into()
+            write!(out, " {zone_abbreviation} (UTC {offset})").unwrap();
         };
 
-        let dt_str = dt.strftime("%Y-%m-%d %H:%M:%S");
-        format!("{dt_str} {abbreviation_and_offset}{timezone_name}")
+        if let Some(iana_tz_name) = tz.iana_name() {
+            write!(out, ", {iana_tz_name}").unwrap();
+        }
+
+        out
     }
 }

+ 9 - 7
numbat/src/decorator.rs

@@ -1,3 +1,5 @@
+use compact_str::CompactString;
+
 use crate::{prefix_parser::AcceptsPrefix, span::Span, unit::CanonicalName};
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -5,10 +7,10 @@ pub enum Decorator<'a> {
     MetricPrefixes,
     BinaryPrefixes,
     Aliases(Vec<(&'a str, Option<AcceptsPrefix>, Span)>),
-    Url(String),
-    Name(String),
-    Description(String),
-    Example(String, Option<String>),
+    Url(CompactString),
+    Name(CompactString),
+    Description(CompactString),
+    Example(CompactString, Option<CompactString>),
 }
 
 /// Get an iterator of data computed from a name and/or its alias's `AcceptsPrefix` and
@@ -103,8 +105,8 @@ pub fn url<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a str> {
     None
 }
 
-pub fn description(decorators: &[Decorator]) -> Option<String> {
-    let mut description = String::new();
+pub fn description(decorators: &[Decorator]) -> Option<CompactString> {
+    let mut description = CompactString::with_capacity(decorators.len());
     for decorator in decorators {
         if let Decorator::Description(d) = decorator {
             description += d;
@@ -118,7 +120,7 @@ pub fn description(decorators: &[Decorator]) -> Option<String> {
     }
 }
 
-pub fn examples(decorators: &[Decorator]) -> Vec<(String, Option<String>)> {
+pub fn examples(decorators: &[Decorator]) -> Vec<(CompactString, Option<CompactString>)> {
     let mut examples = Vec::new();
     for decorator in decorators {
         if let Decorator::Example(example_code, example_description) = decorator {

+ 8 - 11
numbat/src/diagnostic.rs

@@ -449,12 +449,7 @@ impl ErrorDiagnostic for TypeCheckError {
                         .with_message("Struct defined here"),
                 ])
                 .with_notes(vec!["Missing fields: ".to_owned()])
-                .with_notes(
-                    missing
-                        .iter()
-                        .map(|(n, t)| n.to_owned() + ": " + &t.to_string())
-                        .collect(),
-                ),
+                .with_notes(missing.iter().map(|(n, t)| format!("{n}: {t}")).collect()),
             TypeCheckError::NameResolutionError(inner) => {
                 return inner.diagnostics();
             }
@@ -505,16 +500,18 @@ impl ErrorDiagnostic for RuntimeError {
                 .with_labels(vec![span
                     .diagnostic_label(LabelStyle::Primary)
                     .with_message("assertion failed")])],
-            RuntimeError::AssertEq2Failed(span_lhs, lhs, span_rhs, rhs) => {
+            RuntimeError::AssertEq2Failed(assert_eq2_error) => {
                 vec![Diagnostic::error()
                     .with_message("Assertion failed")
                     .with_labels(vec![
-                        span_lhs
+                        assert_eq2_error
+                            .span_lhs
                             .diagnostic_label(LabelStyle::Secondary)
-                            .with_message(format!("{lhs}")),
-                        span_rhs
+                            .with_message(format!("{}", assert_eq2_error.lhs)),
+                        assert_eq2_error
+                            .span_rhs
                             .diagnostic_label(LabelStyle::Primary)
-                            .with_message(format!("{rhs}")),
+                            .with_message(format!("{}", assert_eq2_error.rhs)),
                     ])
                     .with_notes(vec![inner])]
             }

+ 5 - 3
numbat/src/dimension.rs

@@ -1,3 +1,5 @@
+use compact_str::{CompactString, ToCompactString};
+
 use crate::arithmetic::{Exponent, Power};
 use crate::ast::{TypeExpression, TypeParameterBound};
 use crate::registry::{BaseRepresentation, Registry, Result};
@@ -7,7 +9,7 @@ use crate::BaseRepresentationFactor;
 #[derive(Default, Clone)]
 pub struct DimensionRegistry {
     registry: Registry<()>,
-    pub introduced_type_parameters: Vec<(Span, String, Option<TypeParameterBound>)>,
+    pub introduced_type_parameters: Vec<(Span, CompactString, Option<TypeParameterBound>)>,
 }
 
 impl DimensionRegistry {
@@ -24,7 +26,7 @@ impl DimensionRegistry {
                     .any(|(_, n, _)| n == name)
                 {
                     Ok(BaseRepresentation::from_factor(BaseRepresentationFactor(
-                        name.to_string(),
+                        name.to_compact_string(),
                         Exponent::from_integer(1),
                     )))
                 } else {
@@ -60,7 +62,7 @@ impl DimensionRegistry {
     pub fn get_derived_entry_names_for(
         &self,
         base_representation: &BaseRepresentation,
-    ) -> Vec<String> {
+    ) -> Vec<CompactString> {
         self.registry
             .get_derived_entry_names_for(base_representation)
     }

+ 14 - 8
numbat/src/ffi/datetime.rs

@@ -1,3 +1,6 @@
+use compact_str::CompactString;
+use jiff::fmt::strtime::BrokenDownTime;
+use jiff::fmt::StdFmtWrite;
 use jiff::Span;
 use jiff::Timestamp;
 use jiff::Zoned;
@@ -12,8 +15,6 @@ use crate::value::FunctionReference;
 use crate::value::Value;
 use crate::RuntimeError;
 
-use std::fmt::Write;
-
 pub fn now(_args: Args) -> Result<Value> {
     return_datetime!(Zoned::now())
 }
@@ -31,17 +32,22 @@ pub fn format_datetime(mut args: Args) -> Result<Value> {
     let format = string_arg!(args);
     let dt = datetime_arg!(args);
 
-    let mut output = String::new();
-    write!(output, "{}", dt.strftime(&format)).map_err(|_| RuntimeError::DateFormattingError)?;
+    let mut output = CompactString::with_capacity(format.len());
+    BrokenDownTime::from(&dt)
+        // jiff::fmt::StdFmtWrite is a wrapper that turns an arbitrary std::fmt::Write
+        // into a jiff::fmt::Write, which is necessary to write a formatted datetime
+        // into it
+        .format(&format, StdFmtWrite(&mut output))
+        .map_err(|e| RuntimeError::DateFormattingError(e.to_string()))?;
 
-    return_string!(output)
+    return_string!(owned = output)
 }
 
 pub fn get_local_timezone(_args: Args) -> Result<Value> {
     let local_tz = datetime::get_local_timezone_or_utc();
     let tz_name = local_tz.iana_name().unwrap_or("<unknown timezone>");
 
-    return_string!(tz_name)
+    return_string!(borrowed = tz_name)
 }
 
 pub fn tz(mut args: Args) -> Result<Value> {
@@ -79,9 +85,9 @@ fn calendar_add(
     let n = quantity_arg!(args).unsafe_value().to_f64();
 
     if n.fract() != 0.0 {
-        return Err(RuntimeError::UserError(format!(
+        return Err(Box::new(RuntimeError::UserError(format!(
             "calendar_add: requires an integer number of {unit_name}s"
-        )));
+        ))));
     }
 
     let n_i64 = n.to_i64().ok_or_else(|| {

+ 5 - 2
numbat/src/ffi/functions.rs

@@ -27,7 +27,7 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
                     ForeignFunction {
                         name: $fn_name,
                         arity: $arity,
-                        callable: Callable::Function(Box::new($callable)),
+                        callable: Callable::Function($callable),
                     },
                 );
             };
@@ -48,6 +48,7 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
         insert_function!(floor, 1..=1);
         insert_function!(ceil, 1..=1);
         insert_function!(trunc, 1..=1);
+        insert_function!(fract, 1..=1);
 
         insert_function!(sin, 1..=1);
         insert_function!(cos, 1..=1);
@@ -115,7 +116,9 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
 }
 
 fn error(mut args: Args) -> Result<Value> {
-    Err(RuntimeError::UserError(arg!(args).unsafe_as_string()))
+    Err(Box::new(RuntimeError::UserError(
+        arg!(args).unsafe_as_string().to_string(),
+    )))
 }
 
 fn value_of(mut args: Args) -> Result<Value> {

+ 1 - 1
numbat/src/ffi/lists.rs

@@ -16,7 +16,7 @@ pub fn head(mut args: Args) -> Result<Value> {
     if let Some(first) = list.head() {
         Ok(first)
     } else {
-        Err(RuntimeError::EmptyList)
+        Err(Box::new(RuntimeError::EmptyList))
     }
 }
 

+ 34 - 15
numbat/src/ffi/lookup.rs

@@ -1,3 +1,5 @@
+use compact_str::CompactString;
+
 use super::macros::*;
 use super::Args;
 use super::Result;
@@ -28,43 +30,58 @@ pub fn _get_chemical_element_data_raw(mut args: Args) -> Result<Value> {
 
         let type_scalar = Type::Dimension(DType::scalar());
 
-        let mut fields: IndexMap<String, (Span, Type)> = IndexMap::new();
-        fields.insert("symbol".to_string(), (unknown_span, Type::String));
-        fields.insert("name".to_string(), (unknown_span, Type::String));
+        let mut fields: IndexMap<CompactString, (Span, Type)> = IndexMap::new();
+        fields.insert(
+            CompactString::const_new("symbol"),
+            (unknown_span, Type::String),
+        );
+        fields.insert(
+            CompactString::const_new("name"),
+            (unknown_span, Type::String),
+        );
+        fields.insert(
+            CompactString::const_new("atomic_number"),
+            (unknown_span, type_scalar.clone()),
+        );
+        fields.insert(
+            CompactString::const_new("group"),
+            (unknown_span, type_scalar.clone()),
+        );
+        fields.insert(
+            CompactString::const_new("group_name"),
+            (unknown_span, Type::String),
+        );
         fields.insert(
-            "atomic_number".to_string(),
+            CompactString::const_new("period"),
             (unknown_span, type_scalar.clone()),
         );
-        fields.insert("group".to_string(), (unknown_span, type_scalar.clone()));
-        fields.insert("group_name".to_string(), (unknown_span, Type::String));
-        fields.insert("period".to_string(), (unknown_span, type_scalar.clone()));
         fields.insert(
-            "melting_point_kelvin".to_string(),
+            CompactString::const_new("melting_point_kelvin"),
             (unknown_span, type_scalar.clone()),
         );
         fields.insert(
-            "boiling_point_kelvin".to_string(),
+            CompactString::const_new("boiling_point_kelvin"),
             (unknown_span, type_scalar.clone()),
         );
         fields.insert(
-            "density_gram_per_cm3".to_string(),
+            CompactString::const_new("density_gram_per_cm3"),
             (unknown_span, type_scalar.clone()),
         );
         fields.insert(
-            "electron_affinity_electronvolt".to_string(),
+            CompactString::const_new("electron_affinity_electronvolt"),
             (unknown_span, type_scalar.clone()),
         );
         fields.insert(
-            "ionization_energy_electronvolt".to_string(),
+            CompactString::const_new("ionization_energy_electronvolt"),
             (unknown_span, type_scalar.clone()),
         );
         fields.insert(
-            "vaporization_heat_kilojoule_per_mole".to_string(),
+            CompactString::const_new("vaporization_heat_kilojoule_per_mole"),
             (unknown_span, type_scalar.clone()),
         );
 
         let info = StructInfo {
-            name: "_ChemicalElementRaw".to_string(),
+            name: CompactString::const_new("_ChemicalElementRaw"),
             definition_span: unknown_span,
             fields,
         };
@@ -125,6 +142,8 @@ pub fn _get_chemical_element_data_raw(mut args: Args) -> Result<Value> {
             ],
         ))
     } else {
-        Err(RuntimeError::ChemicalElementNotFound(pattern))
+        Err(Box::new(RuntimeError::ChemicalElementNotFound(
+            pattern.to_string(),
+        )))
     }
 }

+ 10 - 2
numbat/src/ffi/macros.rs

@@ -71,8 +71,16 @@ macro_rules! return_list {
 pub(crate) use return_list;
 
 macro_rules! return_string {
-    ($value:expr) => {
-        Ok(Value::String($value.into()))
+    (owned = $value:expr) => {
+        Ok(Value::String($value))
+    };
+    (borrowed = $value:expr) => {
+        Ok(Value::String(::compact_str::CompactString::new($value)))
+    };
+    (from = $value:expr) => {
+        Ok(Value::String(
+            ::compact_str::ToCompactString::to_compact_string($value),
+        ))
     };
 }
 pub(crate) use return_string;

+ 1 - 0
numbat/src/ffi/math.rs

@@ -34,6 +34,7 @@ simple_scalar_math_function!(round, round);
 simple_scalar_math_function!(floor, floor);
 simple_scalar_math_function!(ceil, ceil);
 simple_scalar_math_function!(trunc, trunc);
+simple_scalar_math_function!(fract, fract);
 
 simple_scalar_math_function!(sin, sin);
 simple_scalar_math_function!(cos, cos);

+ 2 - 4
numbat/src/ffi/mod.rs

@@ -20,14 +20,12 @@ type ControlFlow = std::ops::ControlFlow<RuntimeError>;
 
 pub(crate) type ArityRange = std::ops::RangeInclusive<usize>;
 
-type Result<T> = std::result::Result<T, RuntimeError>;
+type Result<T> = std::result::Result<T, Box<RuntimeError>>;
 
 pub(crate) type Args = VecDeque<Value>;
 
-type BoxedFunction = Box<dyn Fn(Args) -> Result<Value> + Send + Sync>;
-
 pub(crate) enum Callable {
-    Function(BoxedFunction),
+    Function(fn(Args) -> Result<Value>),
     Procedure(fn(&mut ExecutionContext, Args, Vec<Span>) -> ControlFlow),
 }
 

+ 10 - 9
numbat/src/ffi/plot.rs

@@ -5,6 +5,7 @@ use super::Args;
 use super::Result;
 use crate::value::Value;
 use crate::RuntimeError;
+use compact_str::CompactString;
 
 fn line_plot(mut args: Args) -> Plot {
     let mut fields = arg!(args).unsafe_as_struct_fields();
@@ -81,27 +82,27 @@ fn bar_chart(mut args: Args) -> Plot {
 }
 
 #[cfg(not(target_family = "wasm"))]
-fn show_plot(plot: Plot) -> String {
+fn show_plot(plot: Plot) -> CompactString {
     plot.show();
 
-    "Plot will be opened in the browser".into()
+    CompactString::const_new("Plot will be opened in the browser")
 }
 
 #[cfg(target_family = "wasm")]
-fn show_plot(_plot: Plot) -> String {
+fn show_plot(_plot: Plot) -> CompactString {
     // The way we could implement this would be to return plot.to_inline_html(..).
     // This would have to be retrieved on the JS side and then rendered using plotly.js.
 
-    "Plotting is currently not supported on this platform.".into()
+    CompactString::const_new("Plotting is currently not supported on this platform.")
 }
 
 pub fn show(args: Args) -> Result<Value> {
     // Dynamic dispatch hack since we don't have bounded polymorphism.
     // And no real support for generics in the FFI.
     let Value::StructInstance(info, _) = args.front().unwrap() else {
-        return Err(RuntimeError::UserError(
+        return Err(Box::new(RuntimeError::UserError(
             "Unsupported argument to 'show'.".into(),
-        ));
+        )));
     };
 
     let plot = if info.name == "LinePlot" {
@@ -109,11 +110,11 @@ pub fn show(args: Args) -> Result<Value> {
     } else if info.name == "BarChart" {
         bar_chart(args)
     } else {
-        return Err(RuntimeError::UserError(format!(
+        return Err(Box::new(RuntimeError::UserError(format!(
             "Unsupported plot type: {}",
             info.name
-        )));
+        ))));
     };
 
-    return_string!(show_plot(plot))
+    return_string!(owned = show_plot(plot))
 }

+ 12 - 6
numbat/src/ffi/procedures.rs

@@ -4,8 +4,14 @@ use std::sync::OnceLock;
 
 use super::macros::*;
 use crate::{
-    ast::ProcedureKind, ffi::ControlFlow, interpreter::assert_eq_3::AssertEq3Error,
-    pretty_print::PrettyPrint, span::Span, value::Value, vm::ExecutionContext, RuntimeError,
+    ast::ProcedureKind,
+    ffi::ControlFlow,
+    interpreter::assert_eq::{AssertEq2Error, AssertEq3Error},
+    pretty_print::PrettyPrint,
+    span::Span,
+    value::Value,
+    vm::ExecutionContext,
+    RuntimeError,
 };
 
 use super::{Args, Callable, ForeignFunction};
@@ -81,12 +87,12 @@ fn assert_eq(_: &mut ExecutionContext, mut args: Args, arg_spans: Vec<Span>) ->
         let lhs = arg!(args);
         let rhs = arg!(args);
 
-        let error = ControlFlow::Break(RuntimeError::AssertEq2Failed(
+        let error = ControlFlow::Break(RuntimeError::AssertEq2Failed(AssertEq2Error {
             span_lhs,
-            lhs.clone(),
+            lhs: lhs.clone(),
             span_rhs,
-            rhs.clone(),
-        ));
+            rhs: rhs.clone(),
+        }));
 
         if lhs.is_quantity() {
             let lhs = lhs.unsafe_as_quantity();

+ 5 - 5
numbat/src/ffi/strings.rs

@@ -11,11 +11,11 @@ pub fn str_length(mut args: Args) -> Result<Value> {
 }
 
 pub fn lowercase(mut args: Args) -> Result<Value> {
-    return_string!(string_arg!(args).to_lowercase())
+    return_string!(owned = string_arg!(args).to_lowercase())
 }
 
 pub fn uppercase(mut args: Args) -> Result<Value> {
-    return_string!(string_arg!(args).to_uppercase())
+    return_string!(owned = string_arg!(args).to_uppercase())
 }
 
 pub fn str_slice(mut args: Args) -> Result<Value> {
@@ -25,7 +25,7 @@ pub fn str_slice(mut args: Args) -> Result<Value> {
 
     let output = input.get(start..end).unwrap_or_default();
 
-    return_string!(output)
+    return_string!(borrowed = output)
 }
 
 pub fn chr(mut args: Args) -> Result<Value> {
@@ -33,14 +33,14 @@ pub fn chr(mut args: Args) -> Result<Value> {
 
     let output = char::from_u32(idx).unwrap_or('�');
 
-    return_string!(output)
+    return_string!(from = &output)
 }
 
 pub fn ord(mut args: Args) -> Result<Value> {
     let input = string_arg!(args);
 
     if input.is_empty() {
-        return Err(RuntimeError::EmptyList);
+        return Err(Box::new(RuntimeError::EmptyList));
     }
 
     let output = input.chars().next().unwrap() as u32;

+ 11 - 10
numbat/src/html_formatter.rs

@@ -1,19 +1,20 @@
 use crate::buffered_writer::BufferedWriter;
 use crate::markup::{FormatType, FormattedString, Formatter};
 
+use compact_str::{format_compact, CompactString};
 use termcolor::{Color, WriteColor};
 
 pub struct HtmlFormatter;
 
-pub fn html_format(class: Option<&str>, content: &str) -> String {
+pub fn html_format(class: Option<&str>, content: &str) -> CompactString {
     if content.is_empty() {
-        return "".into();
+        return CompactString::const_new("");
     }
 
     let content = html_escape::encode_text(content);
 
     if let Some(class) = class {
-        format!("<span class=\"numbat-{class}\">{content}</span>")
+        format_compact!("<span class=\"numbat-{class}\">{content}</span>")
     } else {
         content.into()
     }
@@ -23,7 +24,7 @@ impl Formatter for HtmlFormatter {
     fn format_part(
         &self,
         FormattedString(_output_type, format_type, s): &FormattedString,
-    ) -> String {
+    ) -> CompactString {
         let css_class = match format_type {
             FormatType::Whitespace => None,
             FormatType::Emphasized => Some("emphasized"),
@@ -73,21 +74,21 @@ impl std::io::Write for HtmlWriter {
         if let Some(color) = &self.color {
             if color.fg() == Some(&Color::Red) {
                 self.buffer
-                    .write("<span class=\"numbat-diagnostic-red\">".as_bytes())?;
+                    .write_all("<span class=\"numbat-diagnostic-red\">".as_bytes())?;
                 let size = self.buffer.write(buf)?;
-                self.buffer.write("</span>".as_bytes())?;
+                self.buffer.write_all("</span>".as_bytes())?;
                 Ok(size)
             } else if color.fg() == Some(&Color::Blue) {
                 self.buffer
-                    .write("<span class=\"numbat-diagnostic-blue\">".as_bytes())?;
+                    .write_all("<span class=\"numbat-diagnostic-blue\">".as_bytes())?;
                 let size = self.buffer.write(buf)?;
-                self.buffer.write("</span>".as_bytes())?;
+                self.buffer.write_all("</span>".as_bytes())?;
                 Ok(size)
             } else if color.bold() {
                 self.buffer
-                    .write("<span class=\"numbat-diagnostic-bold\">".as_bytes())?;
+                    .write_all("<span class=\"numbat-diagnostic-bold\">".as_bytes())?;
                 let size = self.buffer.write(buf)?;
-                self.buffer.write("</span>".as_bytes())?;
+                self.buffer.write_all("</span>".as_bytes())?;
                 Ok(size)
             } else {
                 self.buffer.write(buf)

+ 40 - 9
numbat/src/interpreter/assert_eq_3.rs → numbat/src/interpreter/assert_eq.rs

@@ -1,8 +1,34 @@
-use crate::{quantity::Quantity, span::Span};
+use crate::{quantity::Quantity, span::Span, value::Value};
+use compact_str::{format_compact, CompactString};
 use std::fmt::Display;
 use thiserror::Error;
 
-#[derive(Debug, Clone, Error, PartialEq, Eq)]
+#[derive(Debug, Clone, Error, PartialEq)]
+pub struct AssertEq2Error {
+    pub span_lhs: Span,
+    pub lhs: Value,
+    pub span_rhs: Span,
+    pub rhs: Value,
+}
+
+impl Display for AssertEq2Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let optional_message = if format!("{}", self.lhs) == format!("{}", self.rhs) {
+            "\nNote: The two printed values appear to be the same, this may be due to floating point precision errors.\n      \
+            For dimension types you may want to test approximate equality instead: assert_eq(q1, q2, ε)."
+        } else {
+            ""
+        };
+
+        write!(
+            f,
+            "Assertion failed because the following two values are not the same:\n  {}\n  {}{}",
+            self.lhs, self.rhs, optional_message
+        )
+    }
+}
+
+#[derive(Debug, Clone, Error, PartialEq)]
 pub struct AssertEq3Error {
     pub span_lhs: Span,
     pub lhs_original: Quantity,
@@ -32,7 +58,7 @@ impl AssertEq3Error {
     }
 
     /// Returns the comparand quantities formatted as strings for pretty error message display.
-    pub fn fmt_comparands(&self) -> (String, String) {
+    pub fn fmt_comparands(&self) -> (CompactString, CompactString) {
         let (lhs_converted_len, _) =
             get_float_part_lengths(&self.lhs_converted.unsafe_value_as_string());
         let (rhs_converted_len, _) =
@@ -46,7 +72,12 @@ impl AssertEq3Error {
         (lhs, rhs)
     }
 
-    fn fmt_comparand(&self, converted: &Quantity, original: &Quantity, width: usize) -> String {
+    fn fmt_comparand(
+        &self,
+        converted: &Quantity,
+        original: &Quantity,
+        width: usize,
+    ) -> CompactString {
         let pretty_converted_str = left_pad_integer_part(
             converted
                 .pretty_print_with_precision(self.eps_precision())
@@ -58,7 +89,7 @@ impl AssertEq3Error {
         if converted.unit() == original.unit() {
             pretty_converted_str
         } else {
-            format!("{} ({})", pretty_converted_str, original)
+            format_compact!("{} ({})", pretty_converted_str, original)
         }
     }
 }
@@ -91,7 +122,7 @@ fn get_float_part_lengths(number: &str) -> (usize, usize) {
 
 /// Returns the input number padded with 0s until the integer part width (number of characters) is exactly integer_part_width
 /// The input number should be a float plainly formatted as a string as with `to_string`.
-fn left_pad_integer_part(number: &str, integer_part_width: usize) -> String {
+fn left_pad_integer_part(number: &str, integer_part_width: usize) -> CompactString {
     let (integer_part, fractional_part) = get_float_parts(number);
     let integer_part_len = integer_part.len();
     let is_negative = integer_part.starts_with('-');
@@ -111,7 +142,7 @@ fn left_pad_integer_part(number: &str, integer_part_width: usize) -> String {
     };
 
     // Pad integer part with 0s
-    let integer_part_abs_padded = format!(
+    let integer_part_abs_padded = format_compact!(
         "{:0>width$}",
         integer_part_abs,
         width = padding_needed + integer_part_abs.len()
@@ -121,12 +152,12 @@ fn left_pad_integer_part(number: &str, integer_part_width: usize) -> String {
     let padded_str = if fractional_part.is_empty() {
         integer_part_abs_padded
     } else {
-        format!("{}.{}", integer_part_abs_padded, fractional_part)
+        format_compact!("{}.{}", integer_part_abs_padded, fractional_part)
     };
 
     // Add the negative sign if necessary
     if is_negative {
-        format!("-{}", padded_str)
+        format_compact!("-{}", padded_str)
     } else {
         padded_str
     }

+ 17 - 13
numbat/src/interpreter/mod.rs

@@ -1,4 +1,4 @@
-pub(crate) mod assert_eq_3;
+pub(crate) mod assert_eq;
 
 use crate::{
     dimension::DimensionRegistry,
@@ -12,12 +12,14 @@ use crate::{
 
 pub use crate::markup as m;
 
-use assert_eq_3::AssertEq3Error;
+use assert_eq::{AssertEq2Error, AssertEq3Error};
+use compact_str::{CompactString, ToCompactString};
 use thiserror::Error;
 
 pub use crate::value::Value;
 
-#[derive(Debug, Clone, Error, PartialEq, Eq)]
+#[derive(Debug, Clone, Error, PartialEq)]
+#[allow(clippy::large_enum_variant)]
 pub enum RuntimeError {
     #[error("Division by zero")]
     DivisionByZero,
@@ -31,8 +33,8 @@ pub enum RuntimeError {
     QuantityError(QuantityError),
     #[error("Assertion failed")]
     AssertFailed(Span),
-    #[error("Assertion failed because the following two values are not the same:\n  {1}\n  {3}")]
-    AssertEq2Failed(Span, Value, Span, Value),
+    #[error("{0}")]
+    AssertEq2Failed(AssertEq2Error),
     #[error("{0}")]
     AssertEq3Failed(AssertEq3Error),
     #[error("Could not load exchange rates from European Central Bank.")]
@@ -47,8 +49,8 @@ pub enum RuntimeError {
     DurationOutOfRange,
     #[error("DateTime out of range")]
     DateTimeOutOfRange,
-    #[error("Error in datetime format. See https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html#conversion-specifications for possible format specifiers.")]
-    DateFormattingError,
+    #[error("{0}. See https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html#conversion-specifications for possible format specifiers.")]
+    DateFormattingError(String),
 
     #[error("Invalid format specifiers: {0}")]
     InvalidFormatSpecifiers(String),
@@ -65,7 +67,7 @@ pub enum RuntimeError {
     FileWrite(std::path::PathBuf),
 }
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq)]
 #[must_use]
 pub enum InterpreterResult {
     Value(Value),
@@ -127,10 +129,10 @@ impl InterpreterResult {
         matches!(self, Self::Continue)
     }
 
-    pub fn value_as_string(&self) -> Option<String> {
+    pub fn value_as_string(&self) -> Option<CompactString> {
         match self {
             Self::Continue => None,
-            Self::Value(value) => Some(value.to_string()),
+            Self::Value(value) => Some(value.to_compact_string()),
         }
     }
 }
@@ -167,6 +169,8 @@ pub trait Interpreter {
 
 #[cfg(test)]
 mod tests {
+    use compact_str::CompactString;
+
     use crate::prefix_parser::AcceptsPrefix;
     use crate::quantity::Quantity;
     use crate::unit::{CanonicalName, Unit};
@@ -273,11 +277,11 @@ mod tests {
             "1 meter > alternative_length_base_unit",
             RuntimeError::QuantityError(QuantityError::IncompatibleUnits(
                 Unit::new_base(
-                    "meter",
+                    CompactString::const_new("meter"),
                     CanonicalName::new("m", AcceptsPrefix::only_short()),
                 ),
                 Unit::new_base(
-                    "alternative_length_base_unit",
+                    CompactString::const_new("alternative_length_base_unit"),
                     CanonicalName::new("alternative_length_base_unit", AcceptsPrefix::only_long()),
                 ),
             )),
@@ -300,7 +304,7 @@ mod tests {
              2 * pixel",
             Quantity::from_scalar(2.0)
                 * Quantity::from_unit(Unit::new_base(
-                    "pixel",
+                    CompactString::const_new("pixel"),
                     CanonicalName::new("px", AcceptsPrefix::only_short()),
                 )),
         );

+ 75 - 38
numbat/src/lib.rs

@@ -51,6 +51,9 @@ use std::borrow::Cow;
 
 use bytecode_interpreter::BytecodeInterpreter;
 use column_formatter::ColumnFormatter;
+use compact_str::CompactString;
+use compact_str::CompactStringExt;
+use compact_str::ToCompactString;
 use currency::ExchangeRatesCache;
 use diagnostic::ErrorDiagnostic;
 use dimension::DimensionRegistry;
@@ -84,6 +87,8 @@ use unit_registry::UnitMetadata;
 use crate::prefix_parser::PrefixParserResult;
 use crate::unicode_input::UNICODE_INPUT;
 
+pub use compact_str;
+
 #[derive(Debug, Clone, Error)]
 pub enum NumbatError {
     #[error("{0}")]
@@ -145,7 +150,7 @@ impl Context {
         ExchangeRatesCache::use_test_rates();
     }
 
-    pub fn variable_names(&self) -> impl Iterator<Item = String> + '_ {
+    pub fn variable_names(&self) -> impl Iterator<Item = CompactString> + '_ {
         self.prefix_transformer
             .variable_names
             .iter()
@@ -153,7 +158,7 @@ impl Context {
             .cloned()
     }
 
-    pub fn function_names(&self) -> impl Iterator<Item = String> + '_ {
+    pub fn function_names(&self) -> impl Iterator<Item = CompactString> + '_ {
         self.prefix_transformer
             .function_names
             .iter()
@@ -165,12 +170,12 @@ impl Context {
         &self,
     ) -> impl Iterator<
         Item = (
-            String,
-            Option<String>,
-            String,
-            Option<String>,
-            Option<String>,
-            Vec<(String, Option<String>)>,
+            CompactString,
+            Option<CompactString>,
+            CompactString,
+            Option<CompactString>,
+            Option<CompactString>,
+            Vec<(CompactString, Option<CompactString>)>,
             CodeSource,
         ),
     > + '_ {
@@ -185,7 +190,7 @@ impl Context {
                     meta.name.clone(),
                     signature
                         .pretty_print(self.dimension_registry())
-                        .to_string(),
+                        .to_compact_string(),
                     meta.description.clone(),
                     meta.url.clone(),
                     meta.examples.clone(),
@@ -195,11 +200,11 @@ impl Context {
             })
     }
 
-    pub fn unit_names(&self) -> &[Vec<String>] {
+    pub fn unit_names(&self) -> &[Vec<CompactString>] {
         &self.prefix_transformer.unit_names
     }
 
-    pub fn dimension_names(&self) -> &[String] {
+    pub fn dimension_names(&self) -> &[CompactString] {
         &self.prefix_transformer.dimension_names
     }
 
@@ -221,7 +226,7 @@ impl Context {
         output
     }
 
-    fn print_sorted(&self, mut entries: Vec<String>, format_type: FormatType) -> Markup {
+    fn print_sorted(&self, mut entries: Vec<CompactString>, format_type: FormatType) -> Markup {
         entries.sort_by_key(|e| e.to_lowercase());
 
         let formatter = ColumnFormatter::new(self.terminal_width.unwrap_or(80));
@@ -322,7 +327,17 @@ impl Context {
     }
 
     pub fn print_info_for_keyword(&mut self, keyword: &str) -> Markup {
-        let url_encode = |s: &str| s.replace('(', "%28").replace(')', "%29");
+        fn url_encode(s: &str) -> CompactString {
+            let mut out = CompactString::with_capacity(s.len());
+            for c in s.chars() {
+                match c {
+                    '(' => out.push_str("%28"),
+                    ')' => out.push_str("%29"),
+                    _ => out.push(c),
+                }
+            }
+            out
+        }
 
         if keyword.is_empty() {
             return m::text("Usage: info <unit or variable>");
@@ -338,8 +353,8 @@ impl Context {
                 .ok()
                 .map(|(_, md)| md)
             {
-                let mut help =
-                    m::text("Unit: ") + m::unit(md.name.unwrap_or_else(|| keyword.to_string()));
+                let mut help = m::text("Unit: ")
+                    + m::unit(md.name.unwrap_or_else(|| keyword.to_compact_string()));
                 if let Some(url) = &md.url {
                     help += m::text(" (") + m::string(url_encode(url)) + m::text(")");
                 }
@@ -351,7 +366,7 @@ impl Context {
                                 .iter()
                                 .map(|(x, _)| x.as_str())
                                 .collect::<Vec<_>>()
-                                .join(", "),
+                                .join_compact(", "),
                         )
                         + m::nl();
                 }
@@ -360,12 +375,19 @@ impl Context {
                     let desc = "Description: ";
                     let mut lines = description.lines();
                     help += m::text(desc)
-                        + m::text(lines.by_ref().next().unwrap_or("").trim().to_string())
+                        + m::text(
+                            lines
+                                .by_ref()
+                                .next()
+                                .unwrap_or("")
+                                .trim()
+                                .to_compact_string(),
+                        )
                         + m::nl();
 
                     for line in lines {
-                        help += m::whitespace(" ".repeat(desc.len()))
-                            + m::text(line.trim().to_string())
+                        help += m::whitespace(CompactString::const_new(" ").repeat(desc.len()))
+                            + m::text(line.trim().to_compact_string())
                             + m::nl();
                     }
                 }
@@ -389,17 +411,17 @@ impl Context {
                     if !prefix.is_none() {
                         help += m::nl()
                             + m::value("1 ")
-                            + m::unit(keyword.to_string())
+                            + m::unit(keyword.to_compact_string())
                             + m::text(" = ")
                             + m::value(prefix.factor().pretty_print())
                             + m::space()
-                            + m::unit(full_name.to_string());
+                            + m::unit(full_name.to_compact_string());
                     }
 
                     if let Some(BaseUnitAndFactor(prod, num)) = x {
                         help += m::nl()
                             + m::value("1 ")
-                            + m::unit(full_name.to_string())
+                            + m::unit(full_name.to_compact_string())
                             + m::text(" = ")
                             + m::value(num.pretty_print())
                             + m::space()
@@ -411,8 +433,9 @@ impl Context {
                                 Some(m::FormatType::Unit),
                             );
                     } else {
-                        help +=
-                            m::nl() + m::unit(full_name.to_string()) + m::text(" is a base unit");
+                        help += m::nl()
+                            + m::unit(full_name.to_compact_string())
+                            + m::text(" is a base unit");
                     }
                 };
 
@@ -427,7 +450,7 @@ impl Context {
             if let Some(name) = &l.metadata.name {
                 help += m::text(name.clone());
             } else {
-                help += m::identifier(keyword.to_string());
+                help += m::identifier(keyword.to_compact_string());
             }
             if let Some(url) = &l.metadata.url {
                 help += m::text(" (") + m::string(url_encode(url)) + m::text(")");
@@ -438,12 +461,19 @@ impl Context {
                 let desc = "Description: ";
                 let mut lines = description.lines();
                 help += m::text(desc)
-                    + m::text(lines.by_ref().next().unwrap_or("").trim().to_string())
+                    + m::text(
+                        lines
+                            .by_ref()
+                            .next()
+                            .unwrap_or("")
+                            .trim()
+                            .to_compact_string(),
+                    )
                     + m::nl();
 
                 for line in lines {
-                    help += m::whitespace(" ".repeat(desc.len()))
-                        + m::text(line.trim().to_string())
+                    help += m::whitespace(CompactString::const_new(" ").repeat(desc.len()))
+                        + m::text(line.trim().to_compact_string())
                         + m::nl();
                 }
             }
@@ -456,7 +486,7 @@ impl Context {
                             .iter()
                             .map(|x| x.as_str())
                             .collect::<Vec<_>>()
-                            .join(", "),
+                            .join_compact(", "),
                     )
                     + m::nl();
             }
@@ -473,9 +503,9 @@ impl Context {
 
             let mut help = m::text("Function:    ");
             if let Some(name) = &metadata.name {
-                help += m::text(name.to_string());
+                help += m::text(name.clone());
             } else {
-                help += m::identifier(keyword.to_string());
+                help += m::identifier(keyword.to_compact_string());
             }
             if let Some(url) = &metadata.url {
                 help += m::text(" (") + m::string(url_encode(url)) + m::text(")");
@@ -491,12 +521,19 @@ impl Context {
                 let desc = "Description: ";
                 let mut lines = description.lines();
                 help += m::text(desc)
-                    + m::text(lines.by_ref().next().unwrap_or("").trim().to_string())
+                    + m::text(
+                        lines
+                            .by_ref()
+                            .next()
+                            .unwrap_or("")
+                            .trim()
+                            .to_compact_string(),
+                    )
                     + m::nl();
 
                 for line in lines {
-                    help += m::whitespace(" ".repeat(desc.len()))
-                        + m::text(line.trim().to_string())
+                    help += m::whitespace(CompactString::new(" ").repeat(desc.len()))
+                        + m::text(line.trim().to_compact_string())
                         + m::nl();
                 }
             }
@@ -507,16 +544,16 @@ impl Context {
         m::text("Not found")
     }
 
-    pub fn list_modules(&self) -> impl Iterator<Item = String> {
+    pub fn list_modules(&self) -> impl Iterator<Item = CompactString> {
         let modules = self.resolver.get_importer().list_modules();
-        modules.into_iter().map(|m| m.0.join("::"))
+        modules.into_iter().map(|m| m.0.join_compact("::"))
     }
 
     pub fn dimension_registry(&self) -> &DimensionRegistry {
         self.typechecker.registry()
     }
 
-    pub fn base_units(&self) -> impl Iterator<Item = String> + '_ {
+    pub fn base_units(&self) -> impl Iterator<Item = CompactString> + '_ {
         self.interpreter
             .get_unit_registry()
             .inner
@@ -525,7 +562,7 @@ impl Context {
 
     pub fn unit_representations(
         &self,
-    ) -> impl Iterator<Item = (String, (BaseRepresentation, UnitMetadata))> + '_ {
+    ) -> impl Iterator<Item = (CompactString, (BaseRepresentation, UnitMetadata))> + '_ {
         let registry = self.interpreter.get_unit_registry();
 
         let unit_names = registry

+ 3 - 3
numbat/src/list.rs

@@ -76,9 +76,9 @@ impl<T> NumbatList<T> {
 
     /// Return the tail of the list without the first element.
     /// Return an error if the list is empty.
-    pub fn tail(&mut self) -> Result<(), RuntimeError> {
+    pub fn tail(&mut self) -> Result<(), Box<RuntimeError>> {
         if self.is_empty() {
-            return Err(RuntimeError::EmptyList);
+            return Err(Box::new(RuntimeError::EmptyList));
         }
         if let Some(view) = &mut self.view {
             view.0 += 1;
@@ -239,7 +239,7 @@ mod test {
         assert!(list.is_empty());
         assert_eq!(alloc, Arc::as_ptr(&list.alloc));
 
-        assert_eq!(list.tail(), Err(RuntimeError::EmptyList));
+        assert_eq!(list.tail(), Err(Box::new(RuntimeError::EmptyList)));
     }
 
     #[test]

+ 60 - 20
numbat/src/markup.rs

@@ -1,4 +1,6 @@
-use std::{borrow::Cow, fmt::Display};
+use std::fmt::Display;
+
+use compact_str::CompactString;
 
 #[derive(Debug, Copy, Clone, PartialEq)]
 pub enum FormatType {
@@ -23,7 +25,45 @@ pub enum OutputType {
 }
 
 #[derive(Debug, Clone, PartialEq)]
-pub struct FormattedString(pub OutputType, pub FormatType, pub Cow<'static, str>);
+pub enum CompactStrCow {
+    Owned(CompactString),
+    Static(&'static str),
+}
+
+impl From<CompactStrCow> for CompactString {
+    fn from(value: CompactStrCow) -> Self {
+        match value {
+            CompactStrCow::Owned(compact_string) => compact_string,
+            CompactStrCow::Static(s) => CompactString::const_new(s),
+        }
+    }
+}
+
+impl std::ops::Deref for CompactStrCow {
+    type Target = str;
+
+    fn deref(&self) -> &Self::Target {
+        match self {
+            CompactStrCow::Owned(compact_string) => compact_string,
+            CompactStrCow::Static(s) => s,
+        }
+    }
+}
+
+impl From<CompactString> for CompactStrCow {
+    fn from(value: CompactString) -> Self {
+        Self::Owned(value)
+    }
+}
+
+impl From<&'static str> for CompactStrCow {
+    fn from(value: &'static str) -> Self {
+        Self::Static(value)
+    }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct FormattedString(pub OutputType, pub FormatType, pub CompactStrCow);
 
 #[derive(Debug, Clone, Default, PartialEq)]
 pub struct Markup(pub Vec<FormattedString>);
@@ -73,7 +113,7 @@ pub fn empty() -> Markup {
     Markup::default()
 }
 
-pub fn whitespace(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn whitespace(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Whitespace,
@@ -81,7 +121,7 @@ pub fn whitespace(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn emphasized(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn emphasized(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Emphasized,
@@ -89,7 +129,7 @@ pub fn emphasized(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn dimmed(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn dimmed(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Dimmed,
@@ -97,7 +137,7 @@ pub fn dimmed(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn text(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn text(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Text,
@@ -105,7 +145,7 @@ pub fn text(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn string(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn string(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::String,
@@ -113,7 +153,7 @@ pub fn string(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn keyword(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn keyword(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Keyword,
@@ -121,7 +161,7 @@ pub fn keyword(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn value(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn value(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Value,
@@ -129,7 +169,7 @@ pub fn value(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn unit(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn unit(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Unit,
@@ -137,7 +177,7 @@ pub fn unit(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn identifier(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn identifier(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Identifier,
@@ -145,7 +185,7 @@ pub fn identifier(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn type_identifier(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn type_identifier(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::TypeIdentifier,
@@ -153,7 +193,7 @@ pub fn type_identifier(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn operator(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn operator(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Operator,
@@ -161,7 +201,7 @@ pub fn operator(text: impl Into<Cow<'static, str>>) -> Markup {
     ))
 }
 
-pub fn decorator(text: impl Into<Cow<'static, str>>) -> Markup {
+pub fn decorator(text: impl Into<CompactStrCow>) -> Markup {
     Markup::from(FormattedString(
         OutputType::Normal,
         FormatType::Decorator,
@@ -178,16 +218,16 @@ pub fn nl() -> Markup {
 }
 
 pub trait Formatter {
-    fn format_part(&self, part: &FormattedString) -> String;
+    fn format_part(&self, part: &FormattedString) -> CompactString;
 
-    fn format(&self, markup: &Markup, indent: bool) -> String {
+    fn format(&self, markup: &Markup, indent: bool) -> CompactString {
         let spaces = self.format_part(&FormattedString(
             OutputType::Normal,
             FormatType::Whitespace,
             "  ".into(),
         ));
 
-        let mut output: String = String::new();
+        let mut output = CompactString::with_capacity(spaces.len() + markup.0.len());
         if indent {
             output.push_str(&spaces);
         }
@@ -204,11 +244,11 @@ pub trait Formatter {
 pub struct PlainTextFormatter;
 
 impl Formatter for PlainTextFormatter {
-    fn format_part(&self, FormattedString(_, _, text): &FormattedString) -> String {
-        text.to_string()
+    fn format_part(&self, FormattedString(_, _, text): &FormattedString) -> CompactString {
+        text.clone().into()
     }
 }
 
-pub fn plain_text_format(m: &Markup, indent: bool) -> String {
+pub fn plain_text_format(m: &Markup, indent: bool) -> CompactString {
     PlainTextFormatter {}.format(m, indent)
 }

+ 4 - 3
numbat/src/module_importer.rs

@@ -4,6 +4,7 @@ use std::{
     path::{Path, PathBuf},
 };
 
+use compact_str::ToCompactString;
 use rust_embed::RustEmbed;
 
 use crate::resolver::ModulePath;
@@ -68,13 +69,13 @@ impl ModuleImporter for FileSystemImporter {
                 let path = entry.path();
                 if path.is_file() && path.extension() == Some(OsStr::new("nbt")) {
                     if let Ok(relative_path) = path.strip_prefix(root_path) {
-                        let components: Vec<String> = relative_path
+                        let components = relative_path
                             .components()
                             .map(|c| {
                                 c.as_os_str()
                                     .to_string_lossy()
                                     .trim_end_matches(".nbt")
-                                    .to_string()
+                                    .to_compact_string()
                             })
                             .collect();
 
@@ -121,7 +122,7 @@ impl ModuleImporter for BuiltinModuleImporter {
                 ModulePath(
                     path.trim_end_matches(".nbt")
                         .split('/')
-                        .map(|s| s.to_string())
+                        .map(|s| s.to_compact_string())
                         .collect(),
                 )
             })

+ 11 - 10
numbat/src/name_resolution.rs

@@ -1,3 +1,4 @@
+use compact_str::CompactString;
 use thiserror::Error;
 
 use crate::{span::Span, typechecker::map_stack::MapStack};
@@ -21,7 +22,7 @@ pub enum NameResolutionError {
 
 #[derive(Debug, Clone, Default)]
 pub struct Namespace {
-    seen: MapStack<String, (String, Span)>,
+    seen: MapStack<CompactString, (CompactString, Span)>,
 }
 
 impl Namespace {
@@ -35,18 +36,18 @@ impl Namespace {
 
     pub fn add_identifier_allow_override(
         &mut self,
-        name: String,
+        name: CompactString,
         span: Span,
-        item_type: String,
+        item_type: CompactString,
     ) -> Result<(), NameResolutionError> {
         self.add_impl(name, span, item_type, true)
     }
 
     pub fn add_identifier(
         &mut self,
-        name: String,
+        name: CompactString,
         span: Span,
-        item_type: String,
+        item_type: CompactString,
     ) -> Result<(), NameResolutionError> {
         self.add_impl(name, span, item_type, false)
     }
@@ -57,9 +58,9 @@ impl Namespace {
 
     fn add_impl(
         &mut self,
-        name: String,
+        name: CompactString,
         span: Span,
-        item_type: String,
+        item_type: CompactString,
         allow_override: bool,
     ) -> Result<(), NameResolutionError> {
         if let Some((original_item_type, original_span)) = self.seen.get(&name) {
@@ -67,15 +68,15 @@ impl Namespace {
                 return Ok(());
             }
 
-            if allow_override && original_item_type == &item_type {
+            if allow_override && original_item_type == item_type {
                 return Ok(());
             }
 
             return Err(NameResolutionError::IdentifierClash {
-                conflicting_identifier: name,
+                conflicting_identifier: name.to_string(),
                 conflict_span: span,
                 original_span: *original_span,
-                original_item_type: Some(original_item_type.clone()),
+                original_item_type: Some(original_item_type.to_string()),
             });
         }
 

+ 10 - 6
numbat/src/number.rs

@@ -1,5 +1,6 @@
 use std::fmt::Display;
 
+use compact_str::{format_compact, CompactString, ToCompactString};
 use num_traits::{Pow, ToPrimitive};
 use pretty_dtoa::FmtFloatConfig;
 
@@ -31,14 +32,14 @@ impl Number {
     }
 
     /// Pretty prints with default options
-    pub fn pretty_print(self) -> String {
+    pub fn pretty_print(self) -> CompactString {
         self.pretty_print_with_options(None)
     }
 
     /// Pretty prints with the given options if options is not None.
     /// If options is None, default options will be used.
     /// If options is not None, float-based format handling is used and integer-based format handling is skipped.
-    pub fn pretty_print_with_options(self, options: Option<FmtFloatConfig>) -> String {
+    pub fn pretty_print_with_options(self, options: Option<FmtFloatConfig>) -> CompactString {
         let number = self.0;
 
         // 64-bit floats can accurately represent integers up to 2^52 [1],
@@ -61,10 +62,13 @@ impl Number {
                 .build()
                 .unwrap();
 
+            // TODO: this is pretty wasteful. formatted numbers should be small enough
+            // to fit in a CompactString without first going to the heap
             number
                 .to_i64()
                 .expect("small enough integers are representable as i64")
                 .to_formatted_string(&format)
+                .to_compact_string()
         } else {
             use pretty_dtoa::dtoa;
 
@@ -89,14 +93,14 @@ impl Number {
                 };
 
                 if formatted_number.ends_with('.') {
-                    format!("{formatted_number}0")
+                    format_compact!("{formatted_number}0")
                 } else {
-                    formatted_number.to_string()
+                    formatted_number.to_compact_string()
                 }
             } else if formatted_number.contains('e') && !formatted_number.contains("e-") {
-                formatted_number.replace('e', "e+")
+                formatted_number.replace('e', "e+").to_compact_string()
             } else {
-                formatted_number
+                formatted_number.to_compact_string()
             }
         }
     }

+ 13 - 9
numbat/src/parser.rs

@@ -70,10 +70,11 @@ use crate::ast::{
 use crate::decorator::{self, Decorator};
 use crate::number::Number;
 use crate::prefix_parser::AcceptsPrefix;
-use crate::resolver::ModulePath;
+use crate::resolver::ModulePathBorrowed;
 use crate::span::Span;
 use crate::tokenizer::{Token, TokenKind, TokenizerError, TokenizerErrorKind};
 
+use compact_str::{CompactString, ToCompactString};
 use num_traits::{CheckedDiv, FromPrimitive, Zero};
 use thiserror::Error;
 
@@ -897,11 +898,11 @@ impl<'a> Parser<'a> {
         let mut span = self.peek(tokens).span;
 
         if let Some(identifier) = self.match_exact(tokens, TokenKind::Identifier) {
-            let mut module_path = vec![identifier.lexeme.to_owned()];
+            let mut module_path = vec![identifier.lexeme];
 
             while self.match_exact(tokens, TokenKind::DoubleColon).is_some() {
                 if let Some(identifier) = self.match_exact(tokens, TokenKind::Identifier) {
-                    module_path.push(identifier.lexeme.to_owned());
+                    module_path.push(identifier.lexeme);
                 } else {
                     return Err(ParseError {
                         kind: ParseErrorKind::ExpectedModuleNameAfterDoubleColon,
@@ -911,7 +912,10 @@ impl<'a> Parser<'a> {
             }
             span = span.extend(&self.last(tokens).unwrap().span);
 
-            Ok(Statement::ModuleImport(span, ModulePath(module_path)))
+            Ok(Statement::ModuleImport(
+                span,
+                ModulePathBorrowed(module_path),
+            ))
         } else {
             Err(ParseError {
                 kind: ParseErrorKind::ExpectedModulePathAfterUse,
@@ -1897,7 +1901,7 @@ impl<'a> Parser<'a> {
             let span = self.last(tokens).unwrap().span;
             Ok(TypeExpression::TypeIdentifier(
                 span,
-                token.lexeme.to_owned(),
+                token.lexeme.to_compact_string(),
             ))
         } else if let Some(number) = self.match_exact(tokens, TokenKind::Number) {
             let span = self.last(tokens).unwrap().span;
@@ -1998,10 +2002,10 @@ impl<'a> Parser<'a> {
     }
 }
 
-fn strip_and_escape(s: &str) -> String {
+fn strip_and_escape(s: &str) -> CompactString {
     let trimmed = &s[1..(s.len() - 1)];
 
-    let mut result = String::with_capacity(trimmed.len());
+    let mut result = CompactString::with_capacity(trimmed.len());
     let mut escaped = false;
     for c in trimmed.chars() {
         if escaped {
@@ -3432,7 +3436,7 @@ mod tests {
                         "foo",
                         TypeAnnotation::TypeExpression(TypeExpression::TypeIdentifier(
                             Span::dummy(),
-                            "Scalar".to_owned(),
+                            CompactString::const_new("Scalar"),
                         )),
                     ),
                     (
@@ -3440,7 +3444,7 @@ mod tests {
                         "bar",
                         TypeAnnotation::TypeExpression(TypeExpression::TypeIdentifier(
                             Span::dummy(),
-                            "Scalar".to_owned(),
+                            CompactString::const_new("Scalar"),
                         )),
                     ),
                 ],

+ 2 - 1
numbat/src/plot.rs

@@ -1,3 +1,4 @@
+use compact_str::CompactString;
 use plotly::{
     color::Rgb,
     common::{Font, Line},
@@ -30,7 +31,7 @@ pub fn line_plot(xs: Vec<f64>, ys: Vec<f64>, x_label: &str, y_label: &str) -> Pl
     plot
 }
 
-pub fn bar_chart(values: Vec<f64>, x_labels: Vec<String>, value_label: &str) -> Plot {
+pub fn bar_chart(values: Vec<f64>, x_labels: Vec<CompactString>, value_label: &str) -> Plot {
     let mut plot = Plot::new();
 
     let trace = Bar::new(x_labels, values);

+ 88 - 86
numbat/src/prefix.rs

@@ -1,3 +1,5 @@
+use compact_str::{format_compact, CompactString};
+
 use crate::number::Number;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -102,91 +104,91 @@ impl Prefix {
         matches!(self, Prefix::Binary(_))
     }
 
-    pub fn as_string_short(&self) -> String {
-        match self {
-            Prefix::Metric(-30) => "q".into(),
-            Prefix::Metric(-27) => "r".into(),
-            Prefix::Metric(-24) => "y".into(),
-            Prefix::Metric(-21) => "z".into(),
-            Prefix::Metric(-18) => "a".into(),
-            Prefix::Metric(-15) => "f".into(),
-            Prefix::Metric(-12) => "p".into(),
-            Prefix::Metric(-9) => "n".into(),
-            Prefix::Metric(-6) => "µ".into(),
-            Prefix::Metric(-3) => "m".into(),
-            Prefix::Metric(-2) => "c".into(),
-            Prefix::Metric(-1) => "d".into(),
-            Prefix::Metric(0) => "".into(),
-            Prefix::Metric(1) => "da".into(),
-            Prefix::Metric(2) => "h".into(),
-            Prefix::Metric(3) => "k".into(),
-            Prefix::Metric(6) => "M".into(),
-            Prefix::Metric(9) => "G".into(),
-            Prefix::Metric(12) => "T".into(),
-            Prefix::Metric(15) => "P".into(),
-            Prefix::Metric(18) => "E".into(),
-            Prefix::Metric(21) => "Z".into(),
-            Prefix::Metric(24) => "Y".into(),
-            Prefix::Metric(27) => "R".into(),
-            Prefix::Metric(30) => "Q".into(),
-
-            Prefix::Metric(n) => format!("<prefix 10^{n}>"),
-
-            Prefix::Binary(0) => "".into(),
-            Prefix::Binary(10) => "Ki".into(),
-            Prefix::Binary(20) => "Mi".into(),
-            Prefix::Binary(30) => "Gi".into(),
-            Prefix::Binary(40) => "Ti".into(),
-            Prefix::Binary(50) => "Pi".into(),
-            Prefix::Binary(60) => "Ei".into(),
-            Prefix::Binary(70) => "Zi".into(),
-            Prefix::Binary(80) => "Yi".into(),
-
-            Prefix::Binary(n) => format!("<prefix 2^{n}>"),
-        }
-    }
-
-    pub fn as_string_long(&self) -> String {
-        match self {
-            Prefix::Metric(-30) => "quecto".into(),
-            Prefix::Metric(-27) => "ronto".into(),
-            Prefix::Metric(-24) => "yocto".into(),
-            Prefix::Metric(-21) => "zepto".into(),
-            Prefix::Metric(-18) => "atto".into(),
-            Prefix::Metric(-15) => "femto".into(),
-            Prefix::Metric(-12) => "pico".into(),
-            Prefix::Metric(-9) => "nano".into(),
-            Prefix::Metric(-6) => "micro".into(),
-            Prefix::Metric(-3) => "milli".into(),
-            Prefix::Metric(-2) => "centi".into(),
-            Prefix::Metric(-1) => "deci".into(),
-            Prefix::Metric(0) => "".into(),
-            Prefix::Metric(1) => "deca".into(),
-            Prefix::Metric(2) => "hecto".into(),
-            Prefix::Metric(3) => "kilo".into(),
-            Prefix::Metric(6) => "mega".into(),
-            Prefix::Metric(9) => "giga".into(),
-            Prefix::Metric(12) => "tera".into(),
-            Prefix::Metric(15) => "peta".into(),
-            Prefix::Metric(18) => "exa".into(),
-            Prefix::Metric(21) => "zetta".into(),
-            Prefix::Metric(24) => "yotta".into(),
-            Prefix::Metric(27) => "ronna".into(),
-            Prefix::Metric(30) => "quetta".into(),
-
-            Prefix::Metric(n) => format!("<prefix 10^{n}>"),
-
-            Prefix::Binary(0) => "".into(),
-            Prefix::Binary(10) => "kibi".into(),
-            Prefix::Binary(20) => "mebi".into(),
-            Prefix::Binary(30) => "gibi".into(),
-            Prefix::Binary(40) => "tebi".into(),
-            Prefix::Binary(50) => "pebi".into(),
-            Prefix::Binary(60) => "exbi".into(),
-            Prefix::Binary(70) => "zebi".into(),
-            Prefix::Binary(80) => "yobi".into(),
-
-            Prefix::Binary(n) => format!("<prefix 2^{n}>"),
-        }
+    pub fn as_string_short(&self) -> CompactString {
+        CompactString::const_new(match self {
+            Prefix::Metric(-30) => "q",
+            Prefix::Metric(-27) => "r",
+            Prefix::Metric(-24) => "y",
+            Prefix::Metric(-21) => "z",
+            Prefix::Metric(-18) => "a",
+            Prefix::Metric(-15) => "f",
+            Prefix::Metric(-12) => "p",
+            Prefix::Metric(-9) => "n",
+            Prefix::Metric(-6) => "µ",
+            Prefix::Metric(-3) => "m",
+            Prefix::Metric(-2) => "c",
+            Prefix::Metric(-1) => "d",
+            Prefix::Metric(0) => "",
+            Prefix::Metric(1) => "da",
+            Prefix::Metric(2) => "h",
+            Prefix::Metric(3) => "k",
+            Prefix::Metric(6) => "M",
+            Prefix::Metric(9) => "G",
+            Prefix::Metric(12) => "T",
+            Prefix::Metric(15) => "P",
+            Prefix::Metric(18) => "E",
+            Prefix::Metric(21) => "Z",
+            Prefix::Metric(24) => "Y",
+            Prefix::Metric(27) => "R",
+            Prefix::Metric(30) => "Q",
+
+            Prefix::Metric(n) => return format_compact!("<prefix 10^{n}>"),
+
+            Prefix::Binary(0) => "",
+            Prefix::Binary(10) => "Ki",
+            Prefix::Binary(20) => "Mi",
+            Prefix::Binary(30) => "Gi",
+            Prefix::Binary(40) => "Ti",
+            Prefix::Binary(50) => "Pi",
+            Prefix::Binary(60) => "Ei",
+            Prefix::Binary(70) => "Zi",
+            Prefix::Binary(80) => "Yi",
+
+            Prefix::Binary(n) => return format_compact!("<prefix 2^{n}>"),
+        })
+    }
+
+    pub fn as_string_long(&self) -> CompactString {
+        CompactString::const_new(match self {
+            Prefix::Metric(-30) => "quecto",
+            Prefix::Metric(-27) => "ronto",
+            Prefix::Metric(-24) => "yocto",
+            Prefix::Metric(-21) => "zepto",
+            Prefix::Metric(-18) => "atto",
+            Prefix::Metric(-15) => "femto",
+            Prefix::Metric(-12) => "pico",
+            Prefix::Metric(-9) => "nano",
+            Prefix::Metric(-6) => "micro",
+            Prefix::Metric(-3) => "milli",
+            Prefix::Metric(-2) => "centi",
+            Prefix::Metric(-1) => "deci",
+            Prefix::Metric(0) => "",
+            Prefix::Metric(1) => "deca",
+            Prefix::Metric(2) => "hecto",
+            Prefix::Metric(3) => "kilo",
+            Prefix::Metric(6) => "mega",
+            Prefix::Metric(9) => "giga",
+            Prefix::Metric(12) => "tera",
+            Prefix::Metric(15) => "peta",
+            Prefix::Metric(18) => "exa",
+            Prefix::Metric(21) => "zetta",
+            Prefix::Metric(24) => "yotta",
+            Prefix::Metric(27) => "ronna",
+            Prefix::Metric(30) => "quetta",
+
+            Prefix::Metric(n) => return format_compact!("<prefix 10^{n}>"),
+
+            Prefix::Binary(0) => "",
+            Prefix::Binary(10) => "kibi",
+            Prefix::Binary(20) => "mebi",
+            Prefix::Binary(30) => "gibi",
+            Prefix::Binary(40) => "tebi",
+            Prefix::Binary(50) => "pebi",
+            Prefix::Binary(60) => "exbi",
+            Prefix::Binary(70) => "zebi",
+            Prefix::Binary(80) => "yobi",
+
+            Prefix::Binary(n) => return format_compact!("<prefix 2^{n}>"),
+        })
     }
 }

+ 8 - 7
numbat/src/prefix_parser.rs

@@ -1,3 +1,4 @@
+use compact_str::{CompactString, ToCompactString};
 use indexmap::IndexMap;
 use std::collections::HashMap;
 use std::sync::OnceLock;
@@ -11,7 +12,7 @@ static PREFIXES: OnceLock<Vec<(&'static str, &'static [&'static str], Prefix)>>
 pub enum PrefixParserResult<'a> {
     Identifier(&'a str),
     /// Span, prefix, unit name in source (e.g. 'm'), full unit name (e.g. 'meter')
-    UnitIdentifier(Span, Prefix, String, String),
+    UnitIdentifier(Span, Prefix, CompactString, CompactString),
 }
 
 type Result<T> = std::result::Result<T, NameResolutionError>;
@@ -77,14 +78,14 @@ struct UnitInfo {
     accepts_prefix: AcceptsPrefix,
     metric_prefixes: bool,
     binary_prefixes: bool,
-    full_name: String,
+    full_name: CompactString,
 }
 
 #[derive(Debug, Clone)]
 pub struct PrefixParser {
-    units: IndexMap<String, UnitInfo>,
+    units: IndexMap<CompactString, UnitInfo>,
 
-    other_identifiers: HashMap<String, Span>,
+    other_identifiers: HashMap<CompactString, Span>,
 
     reserved_identifiers: &'static [&'static str],
 }
@@ -250,7 +251,7 @@ impl PrefixParser {
             return PrefixParserResult::UnitIdentifier(
                 info.definition_span,
                 Prefix::none(),
-                input.to_string(),
+                input.to_compact_string(),
                 info.full_name.clone(),
             );
         }
@@ -267,7 +268,7 @@ impl PrefixParser {
                 if info.accepts_prefix.long
                     && (is_metric && info.metric_prefixes || is_binary && info.binary_prefixes)
                     && input.starts_with(prefix_long)
-                    && &input[prefix_long.len()..] == unit_name
+                    && input[prefix_long.len()..] == unit_name
                 {
                     return PrefixParserResult::UnitIdentifier(
                         info.definition_span,
@@ -280,7 +281,7 @@ impl PrefixParser {
                 if info.accepts_prefix.short
                     && (is_metric && info.metric_prefixes || is_binary && info.binary_prefixes)
                     && prefixes_short.iter().any(|prefix_short| {
-                        input.starts_with(prefix_short) && &input[prefix_short.len()..] == unit_name
+                        input.starts_with(prefix_short) && input[prefix_short.len()..] == unit_name
                     })
                 {
                     return PrefixParserResult::UnitIdentifier(

+ 93 - 159
numbat/src/prefix_transformer.rs

@@ -1,3 +1,5 @@
+use compact_str::{CompactString, ToCompactString};
+
 use crate::{
     ast::{DefineVariable, Expression, Statement, StringPart},
     decorator::{self, Decorator},
@@ -12,10 +14,10 @@ type Result<T> = std::result::Result<T, NameResolutionError>;
 pub(crate) struct Transformer {
     pub prefix_parser: PrefixParser,
 
-    pub variable_names: Vec<String>,
-    pub function_names: Vec<String>,
-    pub unit_names: Vec<Vec<String>>,
-    pub dimension_names: Vec<String>,
+    pub variable_names: Vec<CompactString>,
+    pub function_names: Vec<CompactString>,
+    pub unit_names: Vec<Vec<CompactString>>,
+    pub dimension_names: Vec<CompactString>,
 }
 
 impl Transformer {
@@ -29,9 +31,9 @@ impl Transformer {
         }
     }
 
-    fn transform_expression<'a>(&self, expression: Expression<'a>) -> Expression<'a> {
+    fn transform_expression(&self, expression: &mut Expression) {
         match expression {
-            expr @ Expression::Scalar(..) => expr,
+            Expression::Scalar(..) | Expression::Boolean(_, _) | Expression::TypedHole(_) => {}
             Expression::Identifier(span, identifier) => {
                 if let PrefixParserResult::UnitIdentifier(
                     _definition_span,
@@ -40,91 +42,51 @@ impl Transformer {
                     full_name,
                 ) = self.prefix_parser.parse(identifier)
                 {
-                    Expression::UnitIdentifier(span, prefix, unit_name, full_name)
+                    *expression = Expression::UnitIdentifier(*span, prefix, unit_name, full_name);
                 } else {
-                    Expression::Identifier(span, identifier)
+                    *expression = Expression::Identifier(*span, identifier);
                 }
             }
             Expression::UnitIdentifier(_, _, _, _) => {
                 unreachable!("Prefixed identifiers should not exist prior to this stage")
             }
-            Expression::UnaryOperator { op, expr, span_op } => Expression::UnaryOperator {
-                op,
-                expr: Box::new(self.transform_expression(*expr)),
-                span_op,
-            },
-            Expression::BinaryOperator {
-                op,
-                lhs,
-                rhs,
-                span_op,
-            } => Expression::BinaryOperator {
-                op,
-                lhs: Box::new(self.transform_expression(*lhs)),
-                rhs: Box::new(self.transform_expression(*rhs)),
-                span_op,
-            },
-            Expression::FunctionCall(span, full_span, name, args) => Expression::FunctionCall(
-                span,
-                full_span,
-                name,
-                args.into_iter()
-                    .map(|arg| self.transform_expression(arg))
-                    .collect(),
-            ),
-            expr @ Expression::Boolean(_, _) => expr,
-            Expression::Condition(span, condition, then, else_) => Expression::Condition(
-                span,
-                Box::new(self.transform_expression(*condition)),
-                Box::new(self.transform_expression(*then)),
-                Box::new(self.transform_expression(*else_)),
-            ),
-            Expression::String(span, parts) => Expression::String(
-                span,
-                parts
-                    .into_iter()
-                    .map(|p| match p {
-                        f @ StringPart::Fixed(_) => f,
-                        StringPart::Interpolation {
-                            span,
-                            expr,
-                            format_specifiers,
-                        } => StringPart::Interpolation {
-                            span,
-                            expr: Box::new(self.transform_expression(*expr)),
-                            format_specifiers,
-                        },
-                    })
-                    .collect(),
-            ),
-            Expression::InstantiateStruct {
-                full_span,
-                ident_span,
-                name,
-                fields,
-            } => Expression::InstantiateStruct {
-                full_span,
-                ident_span,
-                name,
-                fields: fields
-                    .into_iter()
-                    .map(|(span, attr, arg)| (span, attr, self.transform_expression(arg)))
-                    .collect(),
-            },
-            Expression::AccessField(full_span, ident_span, expr, attr) => Expression::AccessField(
-                full_span,
-                ident_span,
-                Box::new(self.transform_expression(*expr)),
-                attr,
-            ),
-            Expression::List(span, elements) => Expression::List(
-                span,
-                elements
-                    .into_iter()
-                    .map(|e| self.transform_expression(e))
-                    .collect(),
-            ),
-            hole @ Expression::TypedHole(_) => hole,
+            Expression::UnaryOperator { expr, .. } => self.transform_expression(expr),
+
+            Expression::BinaryOperator { lhs, rhs, .. } => {
+                self.transform_expression(lhs);
+                self.transform_expression(rhs);
+            }
+            Expression::FunctionCall(_, _, _, args) => {
+                for arg in args {
+                    self.transform_expression(arg);
+                }
+            }
+            Expression::Condition(_, condition, then_expr, else_expr) => {
+                self.transform_expression(condition);
+                self.transform_expression(then_expr);
+                self.transform_expression(else_expr);
+            }
+            Expression::String(_, parts) => {
+                for p in parts {
+                    match p {
+                        StringPart::Fixed(_) => {}
+                        StringPart::Interpolation { expr, .. } => self.transform_expression(expr),
+                    }
+                }
+            }
+            Expression::InstantiateStruct { fields, .. } => {
+                for (_, _, arg) in fields {
+                    self.transform_expression(arg);
+                }
+            }
+            Expression::AccessField(_, _, expr, _) => {
+                self.transform_expression(expr);
+            }
+            Expression::List(_, elements) => {
+                for e in elements {
+                    self.transform_expression(e);
+                }
+            }
         }
     }
 
@@ -156,7 +118,7 @@ impl Transformer {
                     alias_span,
                 },
             )?;
-            unit_names.push(alias.to_string());
+            unit_names.push(alias.to_compact_string());
         }
 
         unit_names.sort();
@@ -165,73 +127,59 @@ impl Transformer {
         Ok(())
     }
 
-    fn transform_define_variable<'a>(
-        &mut self,
-        define_variable: DefineVariable<'a>,
-    ) -> Result<DefineVariable<'a>> {
+    fn transform_define_variable(&mut self, define_variable: &mut DefineVariable) -> Result<()> {
         let DefineVariable {
             identifier_span,
             identifier,
             expr,
-            type_annotation,
+            type_annotation: _,
             decorators,
         } = define_variable;
 
-        for (name, _) in decorator::name_and_aliases(identifier, &decorators) {
-            self.variable_names.push(name.to_owned());
+        for (name, _) in decorator::name_and_aliases(identifier, decorators) {
+            self.variable_names.push(name.to_compact_string());
         }
         self.prefix_parser
-            .add_other_identifier(identifier, identifier_span)?;
-        Ok(DefineVariable {
-            identifier_span,
-            identifier,
-            expr: self.transform_expression(expr),
-            type_annotation,
-            decorators,
-        })
+            .add_other_identifier(identifier, *identifier_span)?;
+        self.transform_expression(expr);
+
+        Ok(())
     }
 
-    fn transform_statement<'a>(&mut self, statement: Statement<'a>) -> Result<Statement<'a>> {
-        Ok(match statement {
-            Statement::Expression(expr) => Statement::Expression(self.transform_expression(expr)),
-            Statement::DefineBaseUnit(span, name, dexpr, decorators) => {
-                self.register_name_and_aliases(name, span, &decorators)?;
-                Statement::DefineBaseUnit(span, name, dexpr, decorators)
+    fn transform_statement(&mut self, statement: &mut Statement) -> Result<()> {
+        match statement {
+            Statement::DefineStruct { .. } | Statement::ModuleImport(_, _) => {}
+
+            Statement::Expression(expr) => {
+                self.transform_expression(expr);
+            }
+            Statement::DefineBaseUnit(span, name, _, decorators) => {
+                self.register_name_and_aliases(name, *span, decorators)?;
             }
             Statement::DefineDerivedUnit {
                 identifier_span,
                 identifier,
                 expr,
-                type_annotation_span,
-                type_annotation,
                 decorators,
+                ..
             } => {
-                self.register_name_and_aliases(identifier, identifier_span, &decorators)?;
-                Statement::DefineDerivedUnit {
-                    identifier_span,
-                    identifier,
-                    expr: self.transform_expression(expr),
-                    type_annotation_span,
-                    type_annotation,
-                    decorators,
-                }
+                self.register_name_and_aliases(identifier, *identifier_span, decorators)?;
+                self.transform_expression(expr);
             }
             Statement::DefineVariable(define_variable) => {
-                Statement::DefineVariable(self.transform_define_variable(define_variable)?)
+                self.transform_define_variable(define_variable)?
             }
             Statement::DefineFunction {
                 function_name_span,
                 function_name,
-                type_parameters,
                 parameters,
                 body,
                 local_variables,
-                return_type_annotation,
-                decorators,
+                ..
             } => {
-                self.function_names.push(function_name.to_owned());
+                self.function_names.push(function_name.to_compact_string());
                 self.prefix_parser
-                    .add_other_identifier(function_name, function_name_span)?;
+                    .add_other_identifier(function_name, *function_name_span)?;
 
                 // We create a clone of the full transformer for the purpose
                 // of checking/transforming the function body. The reason for this
@@ -242,48 +190,31 @@ impl Transformer {
                 //   fn foo(t: Time) -> Time = t    # not okay: shadows 't' for ton
                 //
                 let mut fn_body_transformer = self.clone();
-                for (param_span, param, _) in &parameters {
+                for (param_span, param, _) in &*parameters {
                     fn_body_transformer
                         .prefix_parser
                         .add_other_identifier(param, *param_span)?;
                 }
 
-                Statement::DefineFunction {
-                    function_name_span,
-                    function_name,
-                    type_parameters,
-                    parameters,
-                    body: body.map(|expr| self.transform_expression(expr)),
-                    local_variables: local_variables
-                        .into_iter()
-                        .map(|def| self.transform_define_variable(def))
-                        .collect::<Result<_>>()?,
-                    return_type_annotation,
-                    decorators,
+                if let Some(expr) = body {
+                    self.transform_expression(expr);
+                }
+
+                for def in local_variables {
+                    self.transform_define_variable(def)?;
                 }
             }
-            Statement::DefineStruct {
-                struct_name_span,
-                struct_name,
-                fields,
-            } => Statement::DefineStruct {
-                struct_name_span,
-                struct_name,
-                fields,
-            },
-            Statement::DefineDimension(name_span, name, dexprs) => {
-                self.dimension_names.push(name.to_owned());
-                Statement::DefineDimension(name_span, name, dexprs)
+            Statement::DefineDimension(_, name, _) => {
+                self.dimension_names.push(name.to_compact_string());
+            }
+            Statement::ProcedureCall(_, _, args) => {
+                for arg in args {
+                    self.transform_expression(arg);
+                }
             }
-            Statement::ProcedureCall(span, procedure, args) => Statement::ProcedureCall(
-                span,
-                procedure,
-                args.into_iter()
-                    .map(|arg| self.transform_expression(arg))
-                    .collect(),
-            ),
-            statement @ Statement::ModuleImport(_, _) => statement,
-        })
+        }
+
+        Ok(())
     }
 
     pub fn transform<'a>(
@@ -292,7 +223,10 @@ impl Transformer {
     ) -> Result<Vec<Statement<'a>>> {
         statements
             .into_iter()
-            .map(|statement| self.transform_statement(statement))
+            .map(|mut statement| {
+                self.transform_statement(&mut statement)?;
+                Ok(statement)
+            })
             .collect()
     }
 }

+ 23 - 10
numbat/src/pretty_print.rs

@@ -1,3 +1,5 @@
+use compact_str::CompactString;
+
 use crate::markup::Markup;
 
 pub trait PrettyPrint {
@@ -11,18 +13,29 @@ impl PrettyPrint for bool {
     }
 }
 
-pub fn escape_numbat_string(s: &str) -> String {
-    s.replace("\\", "\\\\")
-        .replace("\n", "\\n")
-        .replace("\r", "\\r")
-        .replace("\t", "\\t")
-        .replace("\"", "\\\"")
-        .replace("\0", "\\0")
-        .replace("{", "\\{")
-        .replace("}", "\\}")
+pub fn escape_numbat_string(s: &str) -> CompactString {
+    let mut out = CompactString::const_new("");
+    for c in s.chars() {
+        let replacement = match c {
+            '\\' => r"\\",
+            '\n' => r"\n",
+            '\r' => r"\r",
+            '\t' => r"\t",
+            '"' => r#"\""#,
+            '\0' => r"\0",
+            '{' => r"\{",
+            '}' => r"\}",
+            _ => {
+                out.push(c);
+                continue;
+            }
+        };
+        out.push_str(replacement);
+    }
+    out
 }
 
-impl PrettyPrint for String {
+impl PrettyPrint for CompactString {
     fn pretty_print(&self) -> Markup {
         crate::markup::operator("\"")
             + crate::markup::string(escape_numbat_string(self))

+ 8 - 10
numbat/src/product.rs

@@ -1,10 +1,8 @@
-use std::{
-    fmt::Display,
-    ops::{Div, Mul},
-};
+use std::ops::{Div, Mul};
 
 use crate::arithmetic::{Exponent, Power};
 use crate::markup::{self as m, Formatter, PlainTextFormatter};
+use compact_str::{CompactString, ToCompactString};
 use itertools::Itertools;
 use num_rational::Ratio;
 use num_traits::Signed;
@@ -22,7 +20,7 @@ pub struct Product<Factor, const CANONICALIZE: bool = false> {
     factors: Vec<Factor>,
 }
 
-impl<Factor: Power + Clone + Canonicalize + Ord + Display, const CANONICALIZE: bool>
+impl<Factor: Power + Clone + Canonicalize + Ord + ToCompactString, const CANONICALIZE: bool>
     Product<Factor, CANONICALIZE>
 {
     /// The last argument controls how the factor is formated.
@@ -54,13 +52,13 @@ impl<Factor: Power + Clone + Canonicalize + Ord + Display, const CANONICALIZE: b
                     + m::Markup::from(m::FormattedString(
                         m::OutputType::Normal,
                         format_type,
-                        factor.to_string().into(),
+                        factor.to_compact_string().into(),
                     ))
                     + if i == num_factors - 1 {
                         m::empty()
                     } else {
                         separator_padding.clone()
-                            + m::operator(times_separator.to_string())
+                            + m::operator(times_separator.to_compact_string())
                             + separator_padding.clone()
                     };
             }
@@ -85,14 +83,14 @@ impl<Factor: Power + Clone + Canonicalize + Ord + Display, const CANONICALIZE: b
             (positive, [single_negative]) => {
                 to_string(positive)
                     + separator_padding.clone()
-                    + m::operator(over_separator.to_string())
+                    + m::operator(over_separator.to_compact_string())
                     + separator_padding.clone()
                     + to_string(&[single_negative.clone().invert()])
             }
             (positive, negative) => {
                 to_string(positive)
                     + separator_padding.clone()
-                    + m::operator(over_separator.to_string())
+                    + m::operator(over_separator.to_compact_string())
                     + separator_padding.clone()
                     + m::operator("(")
                     + to_string(&negative.iter().map(|f| f.clone().invert()).collect_vec())
@@ -107,7 +105,7 @@ impl<Factor: Power + Clone + Canonicalize + Ord + Display, const CANONICALIZE: b
         times_separator: char,
         over_separator: char,
         separator_padding: bool,
-    ) -> String
+    ) -> CompactString
     where
         GetExponent: Fn(&Factor) -> Exponent,
     {

+ 32 - 6
numbat/src/quantity.rs

@@ -3,6 +3,7 @@ use crate::number::Number;
 use crate::pretty_print::PrettyPrint;
 use crate::unit::{is_multiple_of, Unit, UnitFactor};
 
+use compact_str::{format_compact, CompactString, ToCompactString};
 use itertools::Itertools;
 use num_rational::Ratio;
 use num_traits::{FromPrimitive, Zero};
@@ -331,8 +332,6 @@ impl PartialEq for Quantity {
     }
 }
 
-impl Eq for Quantity {}
-
 impl PartialOrd for Quantity {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
         let other_converted = other.convert_to(self.unit()).ok()?;
@@ -346,7 +345,32 @@ impl PrettyPrint for Quantity {
     }
 }
 
+pub(crate) enum QuantityOrdering {
+    IncompatibleUnits,
+    NanOperand,
+    Ok(std::cmp::Ordering),
+}
+
 impl Quantity {
+    /// partial_cmp that encodes whether comparison fails because its arguments have
+    /// incompatible units, or because one of them is NaN
+    pub(crate) fn partial_cmp_preserve_nan(&self, other: &Self) -> QuantityOrdering {
+        if self.value.to_f64().is_nan() || other.value.to_f64().is_nan() {
+            return QuantityOrdering::NanOperand;
+        }
+
+        let Ok(other_converted) = other.convert_to(self.unit()) else {
+            return QuantityOrdering::IncompatibleUnits;
+        };
+
+        let cmp = self
+            .value
+            .partial_cmp(&other_converted.value)
+            .expect("unexpectedly got a None partial_cmp from non-NaN arguments");
+
+        QuantityOrdering::Ok(cmp)
+    }
+
     /// Pretty prints with the given options.
     /// If options is None, default options will be used.
     fn pretty_print_with_options(&self, options: Option<FmtFloatConfig>) -> crate::markup::Markup {
@@ -354,7 +378,7 @@ impl Quantity {
 
         let formatted_number = self.unsafe_value().pretty_print_with_options(options);
 
-        let unit_str = format!("{}", self.unit());
+        let unit_str = format_compact!("{}", self.unit());
 
         markup::value(formatted_number)
             + if unit_str == "°" || unit_str == "′" || unit_str == "″" || unit_str.is_empty() {
@@ -377,8 +401,8 @@ impl Quantity {
         self.pretty_print_with_options(Some(options))
     }
 
-    pub fn unsafe_value_as_string(&self) -> String {
-        self.unsafe_value().to_string()
+    pub fn unsafe_value_as_string(&self) -> CompactString {
+        self.unsafe_value().to_compact_string()
     }
 }
 
@@ -394,6 +418,8 @@ impl std::fmt::Display for Quantity {
 
 #[cfg(test)]
 mod tests {
+    use compact_str::CompactString;
+
     use crate::{prefix::Prefix, prefix_parser::AcceptsPrefix, unit::CanonicalName};
 
     use super::*;
@@ -417,7 +443,7 @@ mod tests {
 
         let meter = Unit::meter();
         let foot = Unit::new_derived(
-            "foot",
+            CompactString::const_new("foot"),
             CanonicalName::new("ft", AcceptsPrefix::none()),
             Number::from_f64(0.3048),
             meter.clone(),

+ 13 - 12
numbat/src/registry.rs

@@ -1,5 +1,6 @@
 use std::{collections::HashMap, fmt::Display};
 
+use compact_str::{CompactString, ToCompactString};
 use itertools::Itertools;
 use num_traits::Zero;
 use thiserror::Error;
@@ -22,7 +23,7 @@ pub enum RegistryError {
 
 pub type Result<T> = std::result::Result<T, RegistryError>;
 
-pub type BaseEntry = String;
+pub type BaseEntry = CompactString;
 
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
 pub struct BaseRepresentationFactor(pub BaseEntry, pub Exponent);
@@ -81,8 +82,8 @@ impl PrettyPrint for BaseRepresentation {
 
 #[derive(Debug, Clone)]
 pub struct Registry<Metadata> {
-    base_entries: Vec<(String, Metadata)>,
-    derived_entries: HashMap<String, (BaseRepresentation, Metadata)>,
+    base_entries: Vec<(CompactString, Metadata)>,
+    derived_entries: HashMap<CompactString, (BaseRepresentation, Metadata)>,
 }
 
 impl<T> Default for Registry<T> {
@@ -99,7 +100,7 @@ impl<Metadata: Clone> Registry<Metadata> {
         if self.contains(name) {
             return Err(RegistryError::EntryExists(name.to_owned()));
         }
-        self.base_entries.push((name.to_owned(), metadata));
+        self.base_entries.push((name.to_compact_string(), metadata));
 
         Ok(())
     }
@@ -107,11 +108,11 @@ impl<Metadata: Clone> Registry<Metadata> {
     pub fn get_derived_entry_names_for(
         &self,
         base_representation: &BaseRepresentation,
-    ) -> Vec<String> {
+    ) -> Vec<CompactString> {
         self.derived_entries
             .iter()
             .filter(|(_, (br, _))| br == base_representation)
-            .map(|(name, _)| name.clone())
+            .map(|(name, _)| name.to_compact_string())
             .sorted_unstable()
             .collect()
     }
@@ -127,7 +128,7 @@ impl<Metadata: Clone> Registry<Metadata> {
         }
 
         self.derived_entries
-            .insert(name.to_owned(), (base_representation, metadata));
+            .insert(name.to_compact_string(), (base_representation, metadata));
 
         Ok(())
     }
@@ -148,7 +149,7 @@ impl<Metadata: Clone> Registry<Metadata> {
         {
             Ok((
                 BaseRepresentation::from_factor(BaseRepresentationFactor(
-                    name.to_owned(),
+                    name.to_compact_string(),
                     Rational::from_integer(1),
                 )),
                 metadata.clone(),
@@ -160,8 +161,8 @@ impl<Metadata: Clone> Registry<Metadata> {
                     let suggestion = suggestion::did_you_mean(
                         self.base_entries
                             .iter()
-                            .map(|(id, _)| id.to_string())
-                            .chain(self.derived_entries.keys().map(|s| s.to_string())),
+                            .map(|(id, _)| id)
+                            .chain(self.derived_entries.keys()),
                         name,
                     );
                     RegistryError::UnknownEntry(name.to_owned(), suggestion)
@@ -170,11 +171,11 @@ impl<Metadata: Clone> Registry<Metadata> {
         }
     }
 
-    pub fn iter_base_entries(&self) -> impl Iterator<Item = String> + '_ {
+    pub fn iter_base_entries(&self) -> impl Iterator<Item = CompactString> + '_ {
         self.base_entries.iter().map(|(name, _)| name.clone())
     }
 
-    pub fn iter_derived_entries(&self) -> impl Iterator<Item = String> + '_ {
+    pub fn iter_derived_entries(&self) -> impl Iterator<Item = CompactString> + '_ {
         self.derived_entries.keys().cloned()
     }
 }

+ 12 - 6
numbat/src/resolver.rs

@@ -5,10 +5,11 @@ use crate::{
 };
 
 use codespan_reporting::files::SimpleFiles;
+use compact_str::{CompactString, ToCompactString};
 use thiserror::Error;
 
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
-pub struct ModulePath(pub Vec<String>);
+pub struct ModulePath(pub Vec<CompactString>);
 
 impl std::fmt::Display for ModulePath {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -16,6 +17,9 @@ impl std::fmt::Display for ModulePath {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct ModulePathBorrowed<'a>(pub Vec<&'a str>);
+
 #[derive(Debug, Clone)]
 pub enum CodeSource {
     /// User input from the command line or a REPL
@@ -105,13 +109,15 @@ impl Resolver {
 
         for statement in program {
             match statement {
-                Statement::ModuleImport(span, module_path) => {
-                    if !self.imported_modules.contains(module_path) {
-                        if let Some((code, filesystem_path)) = self.importer.import(module_path) {
-                            let code: &'static str = Box::leak(code.into_boxed_str());
+                Statement::ModuleImport(span, ModulePathBorrowed(module_path)) => {
+                    if !self.imported_modules.iter().any(|m| &m.0 == module_path) {
+                        let module_path =
+                            ModulePath(module_path.iter().map(|s| s.to_compact_string()).collect());
+                        if let Some((code, filesystem_path)) = self.importer.import(&module_path) {
+                            let code: &'static str = Box::leak(code.to_string().into_boxed_str());
                             self.imported_modules.push(module_path.clone());
                             let code_source_id = self.add_code_source(
-                                CodeSource::Module(module_path.clone(), filesystem_path),
+                                CodeSource::Module(module_path, filesystem_path),
                                 code,
                             );
 

+ 7 - 6
numbat/src/session_history.rs

@@ -1,3 +1,4 @@
+use compact_str::CompactString;
 use std::{fs, io, path::Path};
 
 use crate::RuntimeError;
@@ -6,7 +7,7 @@ pub type ParseEvaluationResult = Result<(), ()>;
 
 #[derive(Debug)]
 struct SessionHistoryItem {
-    input: String,
+    input: CompactString,
     result: ParseEvaluationResult,
 }
 
@@ -26,7 +27,7 @@ pub struct SessionHistoryOptions {
 }
 
 impl SessionHistory {
-    pub fn push(&mut self, input: String, result: ParseEvaluationResult) {
+    pub fn push(&mut self, input: CompactString, result: ParseEvaluationResult) {
         self.0.push(SessionHistoryItem { input, result });
     }
 
@@ -80,10 +81,10 @@ mod test {
         let mut sh = SessionHistory::new();
 
         // arbitrary non-ascii characters
-        sh.push("  a→  ".to_owned(), Ok(()));
-        sh.push("  b × c  ".to_owned(), Err(()));
-        sh.push("  d ♔ e ⚀ f  ".to_owned(), Err(()));
-        sh.push("  g ☼ h ▶︎ i ❖ j  ".to_owned(), Ok(()));
+        sh.push(CompactString::const_new("  a→  "), Ok(()));
+        sh.push(CompactString::const_new("  b × c  "), Err(()));
+        sh.push(CompactString::const_new("  d ♔ e ⚀ f  "), Err(()));
+        sh.push(CompactString::const_new("  g ☼ h ▶︎ i ❖ j  "), Ok(()));
 
         let test_cases = [
             (

+ 3 - 1
numbat/src/type_variable.rs

@@ -1,6 +1,8 @@
+use compact_str::CompactString;
+
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
 pub enum TypeVariable {
-    Named(String),
+    Named(CompactString),
     Quantified(usize),
 }
 

+ 17 - 7
numbat/src/typechecker/constraints.rs

@@ -1,3 +1,5 @@
+use compact_str::{format_compact, CompactString};
+
 use super::substitutions::{ApplySubstitution, Substitution, SubstitutionError};
 use crate::type_variable::TypeVariable;
 use crate::typed_ast::{DType, DTypeFactor, Type};
@@ -66,6 +68,14 @@ impl ConstraintSet {
         result
     }
 
+    pub(crate) fn add_equal_constraint(&mut self, lhs: &Type, rhs: &Type) -> TrivialResolution {
+        self.add(Constraint::Equal(lhs.clone(), rhs.clone()))
+    }
+
+    pub(crate) fn add_dtype_constraint(&mut self, type_: &Type) -> TrivialResolution {
+        self.add(Constraint::IsDType(type_.clone()))
+    }
+
     pub fn clear(&mut self) {
         self.constraints.clear();
     }
@@ -123,7 +133,7 @@ impl ConstraintSet {
                 remaining_constraints
                     .iter()
                     .map(|c| c.pretty_print())
-                    .collect::<Vec<String>>()
+                    .collect::<Vec<CompactString>>()
                     .join("\n"),
             ));
         }
@@ -179,7 +189,7 @@ pub enum Constraint {
     Equal(Type, Type),
     IsDType(Type),
     EqualScalar(DType),
-    HasField(Type, String, Type),
+    HasField(Type, CompactString, Type),
 }
 
 impl Constraint {
@@ -326,15 +336,15 @@ impl Constraint {
         }
     }
 
-    fn pretty_print(&self) -> String {
+    fn pretty_print(&self) -> CompactString {
         match self {
             Constraint::Equal(t1, t2) => {
-                format!("  {t1} ~ {t2}")
+                format_compact!("  {t1} ~ {t2}")
             }
-            Constraint::IsDType(t) => format!("  {t}: DType"),
-            Constraint::EqualScalar(d) => format!("  {d} = Scalar"),
+            Constraint::IsDType(t) => format_compact!("  {t}: DType"),
+            Constraint::EqualScalar(d) => format_compact!("  {d} = Scalar"),
             Constraint::HasField(struct_type, field_name, field_type) => {
-                format!("HasField({struct_type}, \"{field_name}\", {field_type})")
+                format_compact!("HasField({struct_type}, \"{field_name}\", {field_type})")
             }
         }
     }

+ 23 - 9
numbat/src/typechecker/environment.rs

@@ -1,3 +1,5 @@
+use compact_str::CompactString;
+
 use crate::ast::{TypeAnnotation, TypeParameterBound};
 use crate::dimension::DimensionRegistry;
 use crate::pretty_print::PrettyPrint;
@@ -10,15 +12,15 @@ use super::map_stack::MapStack;
 use super::substitutions::{ApplySubstitution, Substitution, SubstitutionError};
 use super::type_scheme::TypeScheme;
 
-type Identifier = String;
+type Identifier = CompactString;
 
 #[derive(Clone, Debug)]
 pub struct FunctionSignature {
-    pub name: String,
+    pub name: CompactString,
     pub definition_span: Span,
     #[allow(dead_code)]
-    pub type_parameters: Vec<(Span, String, Option<TypeParameterBound>)>,
-    pub parameters: Vec<(Span, String, Option<TypeAnnotation>)>,
+    pub type_parameters: Vec<(Span, CompactString, Option<TypeParameterBound>)>,
+    pub parameters: Vec<(Span, CompactString, Option<TypeAnnotation>)>,
     pub return_type_annotation: Option<TypeAnnotation>,
     pub fn_type: TypeScheme,
 }
@@ -64,10 +66,10 @@ impl FunctionSignature {
 
 #[derive(Clone, Debug)]
 pub struct FunctionMetadata {
-    pub name: Option<String>,
-    pub url: Option<String>,
-    pub description: Option<String>,
-    pub examples: Vec<(String, Option<String>)>,
+    pub name: Option<CompactString>,
+    pub url: Option<CompactString>,
+    pub description: Option<CompactString>,
+    pub examples: Vec<(CompactString, Option<CompactString>)>,
 }
 
 #[derive(Clone, Debug)]
@@ -119,7 +121,7 @@ impl Environment {
 
     pub(crate) fn add_function(
         &mut self,
-        v: String,
+        v: CompactString,
         signature: FunctionSignature,
         metadata: FunctionMetadata,
     ) {
@@ -181,6 +183,18 @@ impl Environment {
             }
         }
     }
+
+    pub(crate) fn get_proper_function_reference<'a>(
+        &self,
+        expr: &crate::ast::Expression<'a>,
+    ) -> Option<(&'a str, &FunctionSignature)> {
+        match expr {
+            crate::ast::Expression::Identifier(_, name) => self
+                .get_function_info(name)
+                .map(|(signature, _)| (*name, signature)),
+            _ => None,
+        }
+    }
 }
 
 impl ApplySubstitution for Environment {

+ 2 - 1
numbat/src/typechecker/error.rs

@@ -4,6 +4,7 @@ use crate::span::Span;
 use crate::typed_ast::BinaryOperator;
 use crate::{BaseRepresentation, NameResolutionError, Type};
 
+use compact_str::CompactString;
 use thiserror::Error;
 
 use super::substitutions::SubstitutionError;
@@ -126,7 +127,7 @@ pub enum TypeCheckError {
     UnknownFieldAccess(Span, Span, String, Type),
 
     #[error("Missing fields in struct instantiation")]
-    MissingFieldsInStructInstantiation(Span, Span, Vec<(String, Type)>),
+    MissingFieldsInStructInstantiation(Span, Span, Vec<(CompactString, Type)>),
 
     #[error("Incompatible types in list: expected '{1}', got '{3}' instead")]
     IncompatibleTypesInList(Span, Type, Span, Type),

+ 20 - 15
numbat/src/typechecker/incompatible_dimensions.rs

@@ -4,6 +4,7 @@ use crate::arithmetic::{pretty_exponent, Exponent, Rational};
 use crate::registry::{BaseRepresentation, BaseRepresentationFactor};
 use crate::span::Span;
 
+use compact_str::{format_compact, CompactString, ToCompactString};
 use itertools::Itertools;
 use num_traits::Zero;
 use unicode_width::UnicodeWidthStr;
@@ -11,16 +12,16 @@ use unicode_width::UnicodeWidthStr;
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct IncompatibleDimensionsError {
     pub span_operation: Span,
-    pub operation: String,
+    pub operation: CompactString,
     pub span_expected: Span,
     pub expected_name: &'static str,
     pub expected_type: BaseRepresentation,
-    pub expected_dimensions: Vec<String>,
+    pub expected_dimensions: Vec<CompactString>,
     pub span_actual: Span,
     pub actual_name: &'static str,
     pub actual_name_for_fix: &'static str,
     pub actual_type: BaseRepresentation,
-    pub actual_dimensions: Vec<String>,
+    pub actual_dimensions: Vec<CompactString>,
 }
 
 fn pad(a: &str, b: &str) -> (String, String) {
@@ -33,11 +34,11 @@ fn suggested_fix(
     expected_type: &BaseRepresentation,
     actual_type: &BaseRepresentation,
     expression_to_change: &str,
-) -> Option<String> {
+) -> Option<CompactString> {
     // Heuristic 1: if actual_type == 1 / expected_type, suggest
     // to invert the 'actual' expression:
     if actual_type == &expected_type.clone().invert() {
-        return Some(format!("invert the {expression_to_change}"));
+        return Some(format_compact!("invert the {expression_to_change}"));
     }
 
     // Heuristic 2: compute the "missing" factor between the expected
@@ -58,7 +59,7 @@ fn suggested_fix(
         ("divide", delta_type.invert())
     };
 
-    Some(format!(
+    Some(format_compact!(
         "{action} the {expression_to_change} by a `{delta_type}` factor"
     ))
 }
@@ -74,16 +75,16 @@ impl fmt::Display for IncompatibleDimensionsError {
             || (self.expected_type.iter().count() == 1 && self.actual_type.iter().count() == 1)
         {
             pad(
-                &self.expected_type.to_string(),
-                &self.actual_type.to_string(),
+                &self.expected_type.to_compact_string(),
+                &self.actual_type.to_compact_string(),
             )
         } else {
             let format_factor =
                 |name: &str, exponent: &Exponent| format!(" × {name}{}", pretty_exponent(exponent));
 
-            let mut shared_factors = HashMap::<&String, (Exponent, Exponent)>::new();
-            let mut expected_factors = HashMap::<&String, Exponent>::new();
-            let mut actual_factors = HashMap::<&String, Exponent>::new();
+            let mut shared_factors = HashMap::<&CompactString, (Exponent, Exponent)>::new();
+            let mut expected_factors = HashMap::<&CompactString, Exponent>::new();
+            let mut actual_factors = HashMap::<&CompactString, Exponent>::new();
 
             for BaseRepresentationFactor(name, expected_exponent) in self.expected_type.iter() {
                 if let Some(BaseRepresentationFactor(_, actual_exponent)) =
@@ -143,13 +144,17 @@ impl fmt::Display for IncompatibleDimensionsError {
         };
 
         if !self.expected_dimensions.is_empty() {
-            expected_result_string
-                .push_str(&format!("    [= {}]", self.expected_dimensions.join(", ")));
+            expected_result_string.push_str(&format_compact!(
+                "    [= {}]",
+                self.expected_dimensions.join(", ")
+            ));
         }
 
         if !self.actual_dimensions.is_empty() {
-            actual_result_string
-                .push_str(&format!("    [= {}]", self.actual_dimensions.join(", ")));
+            actual_result_string.push_str(&format_compact!(
+                "    [= {}]",
+                self.actual_dimensions.join(", ")
+            ));
         }
 
         write!(

+ 182 - 167
numbat/src/typechecker/mod.rs

@@ -29,6 +29,7 @@ use crate::type_variable::TypeVariable;
 use crate::typed_ast::{self, DType, DTypeFactor, Expression, StructInfo, Type};
 use crate::{decorator, ffi, suggestion};
 
+use compact_str::{format_compact, CompactString, ToCompactString};
 use const_evaluation::evaluate_const_expr;
 use constraints::{Constraint, ConstraintSet, ConstraintSolverError, TrivialResolution};
 use environment::{Environment, FunctionMetadata, FunctionSignature};
@@ -52,9 +53,134 @@ fn dtype(e: &Expression) -> Result<DType> {
     }
 }
 
+struct ProperFunctionCallArgs<'a, 'b> {
+    registry: &'b DimensionRegistry,
+    constraints: &'b mut ConstraintSet,
+    name_generator: &'b mut NameGenerator,
+    span: &'b Span,
+    full_span: &'b Span,
+    function_name: &'a str,
+    signature: &'b FunctionSignature,
+    arguments: Vec<typed_ast::Expression<'a>>,
+    argument_types: Vec<Type>,
+}
+
+fn proper_function_call<'a>(
+    ProperFunctionCallArgs {
+        registry,
+        constraints,
+        name_generator,
+        span,
+        full_span,
+        function_name,
+        signature,
+        arguments,
+        argument_types,
+    }: ProperFunctionCallArgs<'a, '_>,
+) -> Result<typed_ast::Expression<'a>> {
+    let FunctionSignature {
+        name: _,
+        definition_span,
+        type_parameters: _,
+        parameters,
+        return_type_annotation: _,
+        fn_type,
+    } = signature;
+
+    let fn_type = match fn_type {
+        TypeScheme::Concrete(t) => {
+            // This branch is needed for recursive functions, where the type of the function
+            // is not yet known (and not yet quantified).
+            t.clone()
+        }
+        TypeScheme::Quantified(_, _) => {
+            let qt = fn_type.instantiate(name_generator);
+
+            for Bound::IsDim(t) in qt.bounds.iter() {
+                constraints.add_dtype_constraint(t).ok();
+            }
+
+            qt.inner
+        }
+    };
+
+    let Type::Fn(parameter_types, return_type) = fn_type else {
+        unreachable!("Expected function type, got {:#?}", fn_type);
+    };
+
+    let arity_range = parameters.len()..=parameters.len();
+
+    if !arity_range.contains(&arguments.len()) {
+        return Err(Box::new(TypeCheckError::WrongArity {
+            callable_span: *span,
+            callable_name: function_name.to_owned(),
+            callable_definition_span: Some(*definition_span),
+            arity: arity_range,
+            num_args: arguments.len(),
+        }));
+    }
+
+    for (idx, ((parameter_span, parameter_type), argument_type)) in parameters
+        .iter()
+        .map(|p| p.0)
+        .zip(parameter_types.iter())
+        .zip(argument_types)
+        .enumerate()
+    {
+        if constraints
+            .add_equal_constraint(parameter_type, &argument_type)
+            .is_trivially_violated()
+        {
+            match (parameter_type, &argument_type) {
+                (Type::Dimension(parameter_dtype), Type::Dimension(argument_dtype)) => {
+                    return Err(Box::new(TypeCheckError::IncompatibleDimensions(
+                        IncompatibleDimensionsError {
+                            span_operation: *span,
+                            operation: format_compact!(
+                                "argument {num} of function call to '{name}'",
+                                num = idx + 1,
+                                name = function_name
+                            ),
+                            span_expected: parameter_span,
+                            expected_name: "parameter type",
+                            expected_dimensions: registry.get_derived_entry_names_for(
+                                &parameter_dtype.to_base_representation(),
+                            ),
+                            expected_type: parameter_dtype.to_base_representation(),
+                            span_actual: arguments[idx].full_span(),
+                            actual_name: " argument type",
+                            actual_name_for_fix: "function argument",
+                            actual_dimensions: registry.get_derived_entry_names_for(
+                                &argument_dtype.to_base_representation(),
+                            ),
+                            actual_type: argument_dtype.to_base_representation(),
+                        },
+                    )));
+                }
+                _ => {
+                    return Err(Box::new(TypeCheckError::IncompatibleTypesInFunctionCall(
+                        Some(parameter_span),
+                        parameter_type.clone(),
+                        arguments[idx].full_span(),
+                        argument_type.clone(),
+                    )));
+                }
+            }
+        }
+    }
+
+    Ok(typed_ast::Expression::FunctionCall(
+        *span,
+        *full_span,
+        function_name,
+        arguments,
+        TypeScheme::concrete(return_type.as_ref().clone()),
+    ))
+}
+
 #[derive(Clone, Default)]
 pub struct TypeChecker {
-    structs: HashMap<String, StructInfo>,
+    structs: HashMap<CompactString, StructInfo>,
     registry: DimensionRegistry,
 
     type_namespace: Namespace,
@@ -83,12 +209,11 @@ impl TypeChecker {
     }
 
     fn add_equal_constraint(&mut self, lhs: &Type, rhs: &Type) -> TrivialResolution {
-        self.constraints
-            .add(Constraint::Equal(lhs.clone(), rhs.clone()))
+        self.constraints.add_equal_constraint(lhs, rhs)
     }
 
     fn add_dtype_constraint(&mut self, type_: &Type) -> TrivialResolution {
-        self.constraints.add(Constraint::IsDType(type_.clone()))
+        self.constraints.add_dtype_constraint(type_)
     }
 
     fn enforce_dtype(&mut self, type_: &Type, span: Span) -> Result<()> {
@@ -173,128 +298,6 @@ impl TypeChecker {
         })?)
     }
 
-    fn get_proper_function_reference<'a>(
-        &self,
-        expr: &ast::Expression<'a>,
-    ) -> Option<(&'a str, &FunctionSignature)> {
-        match expr {
-            ast::Expression::Identifier(_, name) => self
-                .env
-                .get_function_info(name)
-                .map(|(signature, _)| (*name, signature)),
-            _ => None,
-        }
-    }
-
-    fn proper_function_call<'a>(
-        &mut self,
-        span: &Span,
-        full_span: &Span,
-        function_name: &'a str,
-        signature: &FunctionSignature,
-        arguments: Vec<typed_ast::Expression<'a>>,
-        argument_types: Vec<Type>,
-    ) -> Result<typed_ast::Expression<'a>> {
-        let FunctionSignature {
-            name: _,
-            definition_span,
-            type_parameters: _,
-            parameters,
-            return_type_annotation: _,
-            fn_type,
-        } = signature;
-
-        let fn_type = match fn_type {
-            TypeScheme::Concrete(t) => {
-                // This branch is needed for recursive functions, where the type of the function
-                // is not yet known (and not yet quantified).
-                t.clone()
-            }
-            TypeScheme::Quantified(_, _) => {
-                let qt = fn_type.instantiate(&mut self.name_generator);
-
-                for Bound::IsDim(t) in qt.bounds.iter() {
-                    self.add_dtype_constraint(t).ok();
-                }
-
-                qt.inner
-            }
-        };
-
-        let Type::Fn(parameter_types, return_type) = fn_type else {
-            unreachable!("Expected function type, got {:#?}", fn_type);
-        };
-
-        let arity_range = parameters.len()..=parameters.len();
-
-        if !arity_range.contains(&arguments.len()) {
-            return Err(Box::new(TypeCheckError::WrongArity {
-                callable_span: *span,
-                callable_name: function_name.to_owned(),
-                callable_definition_span: Some(*definition_span),
-                arity: arity_range,
-                num_args: arguments.len(),
-            }));
-        }
-
-        for (idx, ((parameter_span, parameter_type), argument_type)) in parameters
-            .iter()
-            .map(|p| p.0)
-            .zip(parameter_types.iter())
-            .zip(argument_types)
-            .enumerate()
-        {
-            if self
-                .add_equal_constraint(parameter_type, &argument_type)
-                .is_trivially_violated()
-            {
-                match (parameter_type, &argument_type) {
-                    (Type::Dimension(parameter_dtype), Type::Dimension(argument_dtype)) => {
-                        return Err(Box::new(TypeCheckError::IncompatibleDimensions(
-                            IncompatibleDimensionsError {
-                                span_operation: *span,
-                                operation: format!(
-                                    "argument {num} of function call to '{name}'",
-                                    num = idx + 1,
-                                    name = function_name
-                                ),
-                                span_expected: parameter_span,
-                                expected_name: "parameter type",
-                                expected_dimensions: self.registry.get_derived_entry_names_for(
-                                    &parameter_dtype.to_base_representation(),
-                                ),
-                                expected_type: parameter_dtype.to_base_representation(),
-                                span_actual: arguments[idx].full_span(),
-                                actual_name: " argument type",
-                                actual_name_for_fix: "function argument",
-                                actual_dimensions: self.registry.get_derived_entry_names_for(
-                                    &argument_dtype.to_base_representation(),
-                                ),
-                                actual_type: argument_dtype.to_base_representation(),
-                            },
-                        )));
-                    }
-                    _ => {
-                        return Err(Box::new(TypeCheckError::IncompatibleTypesInFunctionCall(
-                            Some(parameter_span),
-                            parameter_type.clone(),
-                            arguments[idx].full_span(),
-                            argument_type.clone(),
-                        )));
-                    }
-                }
-            }
-        }
-
-        Ok(typed_ast::Expression::FunctionCall(
-            *span,
-            *full_span,
-            function_name,
-            arguments,
-            TypeScheme::concrete(return_type.as_ref().clone()),
-        ))
-    }
-
     fn elaborate_expression<'a>(
         &mut self,
         ast: &ast::Expression<'a>,
@@ -777,18 +780,20 @@ impl TypeChecker {
                 // to a (proper) function, or it can be an arbitrary complicated expression
                 // that evaluates to a function "pointer".
 
-                if let Some((name, signature)) = self.get_proper_function_reference(callable) {
-                    // TODO: there is probably a better way to get around borrowing issues here
-                    let signature = signature.clone();
-
-                    self.proper_function_call(
+                if let Some((function_name, signature)) =
+                    self.env.get_proper_function_reference(callable)
+                {
+                    proper_function_call(ProperFunctionCallArgs {
+                        registry: &mut self.registry,
+                        constraints: &mut self.constraints,
+                        name_generator: &mut self.name_generator,
                         span,
                         full_span,
-                        name,
-                        &signature,
-                        arguments_checked,
+                        function_name,
+                        signature,
+                        arguments: arguments_checked,
                         argument_types,
-                    )?
+                    })?
                 } else {
                     let callable_checked = self.elaborate_expression(callable)?;
                     let callable_type = callable_checked.get_type();
@@ -968,7 +973,7 @@ impl TypeChecker {
                             *span,
                             struct_info.definition_span,
                             field.to_string(),
-                            struct_info.name.clone(),
+                            struct_info.name.to_string(),
                         )));
                     };
 
@@ -1042,7 +1047,7 @@ impl TypeChecker {
                     self.constraints
                         .add(Constraint::HasField(
                             type_.clone(),
-                            field_name.to_owned(),
+                            field_name.to_compact_string(),
                             field_type.clone(),
                         ))
                         .ok();
@@ -1207,7 +1212,7 @@ impl TypeChecker {
 
         for (name, _) in decorator::name_and_aliases(identifier, decorators) {
             self.env.add(
-                name.to_owned(),
+                name.to_compact_string(),
                 type_deduced.clone(),
                 *identifier_span,
                 false,
@@ -1215,9 +1220,9 @@ impl TypeChecker {
 
             self.value_namespace
                 .add_identifier_allow_override(
-                    name.to_owned(),
+                    name.to_compact_string(),
                     *identifier_span,
-                    "constant".to_owned(),
+                    CompactString::const_new("constant"),
                 )
                 .map_err(|err| Box::new(err.into()))?;
         }
@@ -1280,7 +1285,7 @@ impl TypeChecker {
                 };
                 for (name, _) in decorator::name_and_aliases(unit_name, decorators) {
                     self.env.add(
-                        name.to_string(),
+                        name.to_compact_string(),
                         Type::Dimension(type_specified.clone()),
                         *span,
                         true,
@@ -1317,7 +1322,7 @@ impl TypeChecker {
 
                 for (name, _) in decorator::name_and_aliases(identifier, decorators) {
                     self.env.add(
-                        name.to_string(),
+                        name.to_compact_string(),
                         type_deduced.clone(),
                         *identifier_span,
                         true,
@@ -1345,17 +1350,17 @@ impl TypeChecker {
                 if body.is_none() {
                     self.value_namespace
                         .add_identifier(
-                            function_name.to_string(),
+                            function_name.to_compact_string(),
                             *function_name_span,
-                            "foreign function".to_owned(),
+                            CompactString::const_new("foreign function"),
                         )
                         .map_err(|err| Box::new(err.into()))?;
                 } else {
                     self.value_namespace
                         .add_identifier_allow_override(
-                            function_name.to_string(),
+                            function_name.to_compact_string(),
                             *function_name_span,
-                            "function".to_owned(),
+                            CompactString::const_new("function"),
                         )
                         .map_err(|err| Box::new(err.into()))?;
                 }
@@ -1378,22 +1383,24 @@ impl TypeChecker {
 
                     self.type_namespace
                         .add_identifier(
-                            type_parameter.to_string(),
+                            type_parameter.to_compact_string(),
                             *span,
-                            "type parameter".to_owned(),
+                            CompactString::const_new("type parameter"),
                         )
                         .ok(); // TODO: is this call even correct?
 
                     self.registry.introduced_type_parameters.push((
                         *span,
-                        type_parameter.to_string(),
+                        type_parameter.to_compact_string(),
                         bound.clone(),
                     ));
 
                     match bound {
                         Some(TypeParameterBound::Dim) => {
-                            self.add_dtype_constraint(&Type::TPar(type_parameter.to_string()))
-                                .ok();
+                            self.add_dtype_constraint(&Type::TPar(
+                                type_parameter.to_compact_string(),
+                            ))
+                            .ok();
                         }
                         None => {}
                     }
@@ -1421,7 +1428,7 @@ impl TypeChecker {
                     }
 
                     self.env.add_scheme(
-                        parameter.to_string(),
+                        parameter.to_compact_string(),
                         TypeScheme::make_quantified(parameter_type.clone()),
                         *parameter_span,
                         false,
@@ -1459,24 +1466,26 @@ impl TypeChecker {
                     TypeScheme::Concrete(Type::Fn(parameter_types, Box::new(return_type.clone())));
 
                 self.env.add_function(
-                    function_name.to_string(),
+                    function_name.to_compact_string(),
                     FunctionSignature {
-                        name: function_name.to_string(),
+                        name: function_name.to_compact_string(),
                         definition_span: *function_name_span,
                         type_parameters: type_parameters
                             .iter()
-                            .map(|(span, name, tpb)| (*span, name.to_string(), tpb.clone()).clone())
+                            .map(|(span, name, tpb)| {
+                                (*span, name.to_compact_string(), tpb.clone()).clone()
+                            })
                             .collect(),
                         parameters: parameters
                             .into_iter()
-                            .map(|(span, s, o)| (span, s.to_string(), o))
+                            .map(|(span, s, o)| (span, s.to_compact_string(), o))
                             .collect(),
                         return_type_annotation: return_type_annotation.clone(),
                         fn_type: fn_type.clone(),
                     },
                     FunctionMetadata {
-                        name: crate::decorator::name(decorators).map(ToOwned::to_owned),
-                        url: crate::decorator::url(decorators).map(ToOwned::to_owned),
+                        name: crate::decorator::name(decorators).map(CompactString::from),
+                        url: crate::decorator::url(decorators).map(CompactString::from),
                         description: crate::decorator::description(decorators),
                         examples: crate::decorator::examples(decorators),
                     },
@@ -1581,7 +1590,7 @@ impl TypeChecker {
                 self.type_namespace.restore();
                 self.env.restore();
                 self.env.add_function(
-                    function_name.to_string(),
+                    function_name.to_compact_string(),
                     signature.clone(),
                     metadata.clone(),
                 );
@@ -1613,7 +1622,11 @@ impl TypeChecker {
             }
             ast::Statement::DefineDimension(name_span, name, dexprs) => {
                 self.type_namespace
-                    .add_identifier(name.to_string(), *name_span, "dimension".to_owned())
+                    .add_identifier(
+                        name.to_compact_string(),
+                        *name_span,
+                        CompactString::const_new("dimension"),
+                    )
                     .map_err(|err| Box::new(err.into()))?;
 
                 if let Some(dexpr) = dexprs.first() {
@@ -1747,9 +1760,9 @@ impl TypeChecker {
             } => {
                 self.type_namespace
                     .add_identifier(
-                        struct_name.to_string(),
+                        struct_name.to_compact_string(),
                         *struct_name_span,
-                        "struct".to_owned(),
+                        CompactString::const_new("struct"),
                     )
                     .map_err(|err| Box::new(err.into()))?;
 
@@ -1769,16 +1782,19 @@ impl TypeChecker {
 
                 let struct_info = StructInfo {
                     definition_span: *struct_name_span,
-                    name: struct_name.to_string(),
+                    name: struct_name.to_compact_string(),
                     fields: fields
                         .iter()
                         .map(|(span, name, type_)| {
-                            Ok((name.to_string(), (*span, self.type_from_annotation(type_)?)))
+                            Ok((
+                                name.to_compact_string(),
+                                (*span, self.type_from_annotation(type_)?),
+                            ))
                         })
                         .collect::<Result<_>>()?,
                 };
                 self.structs
-                    .insert(struct_name.to_string(), struct_info.clone());
+                    .insert(struct_name.to_compact_string(), struct_info.clone());
 
                 typed_ast::Statement::DefineStruct(struct_info)
             }
@@ -1891,8 +1907,7 @@ impl TypeChecker {
                     .iter_relevant_matches()
                     .filter(|(_, t)| t == &type_of_hole)
                     .take(10)
-                    .map(|(n, _)| n)
-                    .cloned()
+                    .map(|(n, _)| n.to_string())
                     .collect(),
             )));
         }

+ 4 - 2
numbat/src/typechecker/type_scheme.rs

@@ -1,3 +1,5 @@
+use compact_str::ToCompactString;
+
 use super::name_generator::NameGenerator;
 use super::qualified_type::{Bound, Bounds, QualifiedType};
 use super::substitutions::{ApplySubstitution, Substitution, SubstitutionError};
@@ -123,7 +125,7 @@ impl TypeScheme {
 
             for type_parameter in &type_parameters {
                 markup += m::space();
-                markup += m::type_identifier(type_parameter.unsafe_name().to_string());
+                markup += m::type_identifier(type_parameter.unsafe_name().to_compact_string());
 
                 if instantiated_type.bounds.is_dtype_bound(type_parameter) {
                     markup += m::operator(":");
@@ -219,7 +221,7 @@ impl PrettyPrint for TypeScheme {
                 for type_parameter in &type_parameters {
                     markup += m::keyword("forall");
                     markup += m::space();
-                    markup += m::type_identifier(type_parameter.unsafe_name().to_string());
+                    markup += m::type_identifier(type_parameter.unsafe_name().to_compact_string());
 
                     if instantiated_type.bounds.is_dtype_bound(type_parameter) {
                         markup += m::operator(":");

+ 35 - 31
numbat/src/typed_ast.rs

@@ -1,3 +1,4 @@
+use compact_str::{format_compact, CompactString, ToCompactString};
 use indexmap::IndexMap;
 use itertools::Itertools;
 
@@ -21,8 +22,8 @@ use crate::{markup as m, BaseRepresentation, BaseRepresentationFactor};
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum DTypeFactor {
     TVar(TypeVariable),
-    TPar(String),
-    BaseDimension(String),
+    TPar(CompactString),
+    BaseDimension(CompactString),
 }
 
 impl DTypeFactor {
@@ -75,14 +76,14 @@ impl DType {
         let mut names = vec![];
 
         if self.factors.len() == 1 && self.factors[0].1 == Exponent::from_integer(1) {
-            names.push(self.factors[0].0.name().to_string());
+            names.push(self.factors[0].0.name().to_compact_string());
         }
 
         let base_representation = self.to_base_representation();
         names.extend(registry.get_derived_entry_names_for(&base_representation));
         match &names[..] {
             [] => self.pretty_print(),
-            [single] => m::type_identifier(single.to_string()),
+            [single] => m::type_identifier(single.to_compact_string()),
             multiple => Itertools::intersperse(
                 multiple.iter().cloned().map(m::type_identifier),
                 m::dimmed(" or "),
@@ -102,7 +103,7 @@ impl DType {
         DType::from_factors(vec![(DTypeFactor::TVar(v), Exponent::from_integer(1))])
     }
 
-    pub fn from_type_parameter(name: String) -> DType {
+    pub fn from_type_parameter(name: CompactString) -> DType {
         DType::from_factors(vec![(DTypeFactor::TPar(name), Exponent::from_integer(1))])
     }
 
@@ -277,14 +278,14 @@ impl From<BaseRepresentation> for DType {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct StructInfo {
     pub definition_span: Span,
-    pub name: String,
-    pub fields: IndexMap<String, (Span, Type)>,
+    pub name: CompactString,
+    pub fields: IndexMap<CompactString, (Span, Type)>,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum Type {
     TVar(TypeVariable),
-    TPar(String),
+    TPar(CompactString),
     Dimension(DType),
     Boolean,
     String,
@@ -314,7 +315,7 @@ impl std::fmt::Display for Type {
                 )
             }
             Type::Struct(info) => {
-                let StructInfo { name, fields, .. } = &**info;
+                let StructInfo { name, fields, .. } = info.as_ref();
                 write!(
                     f,
                     "{name} {{{}}}",
@@ -332,7 +333,7 @@ impl std::fmt::Display for Type {
 impl PrettyPrint for Type {
     fn pretty_print(&self) -> Markup {
         match self {
-            Type::TVar(TypeVariable::Named(name)) => m::type_identifier(name.clone()),
+            Type::TVar(TypeVariable::Named(name)) => m::type_identifier(name.to_compact_string()),
             Type::TVar(TypeVariable::Quantified(_)) => {
                 unreachable!("Quantified types should not be printed")
             }
@@ -459,7 +460,7 @@ impl Type {
 
 #[derive(Debug, Clone, PartialEq)]
 pub enum StringPart<'a> {
-    Fixed(String),
+    Fixed(CompactString),
     Interpolation {
         span: Span,
         expr: Box<Expression<'a>>,
@@ -479,7 +480,7 @@ impl PrettyPrint for StringPart<'_> {
                 let mut markup = m::operator("{") + expr.pretty_print();
 
                 if let Some(format_specifiers) = format_specifiers {
-                    markup += m::text(format_specifiers.to_string());
+                    markup += m::text(format_specifiers.to_compact_string());
                 }
 
                 markup += m::operator("}");
@@ -500,7 +501,7 @@ impl PrettyPrint for &Vec<StringPart<'_>> {
 pub enum Expression<'a> {
     Scalar(Span, Number, TypeScheme),
     Identifier(Span, &'a str, TypeScheme),
-    UnitIdentifier(Span, Prefix, String, String, TypeScheme),
+    UnitIdentifier(Span, Prefix, CompactString, CompactString, TypeScheme),
     UnaryOperator(Span, UnaryOperator, Box<Expression<'a>>, TypeScheme),
     BinaryOperator(
         Option<Span>,
@@ -858,7 +859,8 @@ fn decorator_markup(decorators: &Vec<Decorator>) -> Markup {
                         + m::operator("(")
                         + Itertools::intersperse(
                             names.iter().map(|(name, accepts_prefix, _)| {
-                                m::unit(name.to_string()) + accepts_prefix_markup(accepts_prefix)
+                                m::unit(name.to_compact_string())
+                                    + accepts_prefix_markup(accepts_prefix)
                             }),
                             m::operator(", "),
                         )
@@ -918,7 +920,7 @@ pub fn pretty_print_function_signature<'a>(
         m::operator("<")
             + Itertools::intersperse(
                 type_parameters.iter().map(|tv| {
-                    m::type_identifier(tv.unsafe_name().to_string())
+                    m::type_identifier(tv.unsafe_name().to_compact_string())
                         + if fn_type.bounds.is_dtype_bound(tv) {
                             m::operator(":") + m::space() + m::type_identifier("Dim")
                         } else {
@@ -933,7 +935,7 @@ pub fn pretty_print_function_signature<'a>(
 
     let markup_parameters = Itertools::intersperse(
         parameters.map(|(name, parameter_type)| {
-            m::identifier(name.to_string()) + m::operator(":") + m::space() + parameter_type
+            m::identifier(name.to_compact_string()) + m::operator(":") + m::space() + parameter_type
         }),
         m::operator(", "),
     )
@@ -944,7 +946,7 @@ pub fn pretty_print_function_signature<'a>(
 
     m::keyword("fn")
         + m::space()
-        + m::identifier(function_name.to_string())
+        + m::identifier(function_name.to_compact_string())
         + markup_type_parameters
         + m::operator("(")
         + markup_parameters
@@ -965,7 +967,7 @@ impl PrettyPrint for Statement<'_> {
             )) => {
                 m::keyword("let")
                     + m::space()
-                    + m::identifier(identifier.to_string())
+                    + m::identifier(identifier.to_compact_string())
                     + m::operator(":")
                     + m::space()
                     + readable_type.clone()
@@ -1011,7 +1013,7 @@ impl PrettyPrint for Statement<'_> {
                         plv += m::nl()
                             + introducer_keyword
                             + m::space()
-                            + m::identifier(identifier.to_string())
+                            + m::identifier(identifier.to_compact_string())
                             + m::operator(":")
                             + m::space()
                             + readable_type.clone()
@@ -1039,12 +1041,14 @@ impl PrettyPrint for Statement<'_> {
             }
             Statement::Expression(expr) => expr.pretty_print(),
             Statement::DefineDimension(identifier, dexprs) if dexprs.is_empty() => {
-                m::keyword("dimension") + m::space() + m::type_identifier(identifier.to_string())
+                m::keyword("dimension")
+                    + m::space()
+                    + m::type_identifier(identifier.to_compact_string())
             }
             Statement::DefineDimension(identifier, dexprs) => {
                 m::keyword("dimension")
                     + m::space()
-                    + m::type_identifier(identifier.to_string())
+                    + m::type_identifier(identifier.to_compact_string())
                     + m::space()
                     + m::operator("=")
                     + m::space()
@@ -1058,7 +1062,7 @@ impl PrettyPrint for Statement<'_> {
                 decorator_markup(decorators)
                     + m::keyword("unit")
                     + m::space()
-                    + m::unit(identifier.to_string())
+                    + m::unit(identifier.to_compact_string())
                     + m::operator(":")
                     + m::space()
                     + annotation
@@ -1077,7 +1081,7 @@ impl PrettyPrint for Statement<'_> {
                 decorator_markup(decorators)
                     + m::keyword("unit")
                     + m::space()
-                    + m::unit(identifier.to_string())
+                    + m::unit(identifier.to_compact_string())
                     + m::operator(":")
                     + m::space()
                     + readable_type.clone()
@@ -1181,11 +1185,11 @@ fn pretty_print_binop(op: &BinaryOperator, lhs: &Expression, rhs: &Expression) -
                 // Fuse multiplication of a scalar and a unit to a quantity
                 pretty_scalar(*s)
                     + m::space()
-                    + m::unit(format!("{}{}", prefix.as_string_long(), full_name))
+                    + m::unit(format_compact!("{}{}", prefix.as_string_long(), full_name))
             }
             (Expression::Scalar(_, s, _), Expression::Identifier(_, name, _type)) => {
                 // Fuse multiplication of a scalar and identifier
-                pretty_scalar(*s) + m::space() + m::identifier(name.to_string())
+                pretty_scalar(*s) + m::space() + m::identifier(name.to_compact_string())
             }
             _ => {
                 let add_parens_if_needed = |expr: &Expression| {
@@ -1275,9 +1279,9 @@ impl PrettyPrint for Expression<'_> {
 
         match self {
             Scalar(_, n, _) => pretty_scalar(*n),
-            Identifier(_, name, _type) => m::identifier(name.to_string()),
+            Identifier(_, name, _type) => m::identifier(name.to_compact_string()),
             UnitIdentifier(_, prefix, _name, full_name, _type) => {
-                m::unit(format!("{}{}", prefix.as_string_long(), full_name))
+                m::unit(format_compact!("{}{}", prefix.as_string_long(), full_name))
             }
             UnaryOperator(_, self::UnaryOperator::Negate, expr, _type) => {
                 m::operator("-") + with_parens(expr)
@@ -1291,7 +1295,7 @@ impl PrettyPrint for Expression<'_> {
             BinaryOperator(_, op, lhs, rhs, _type) => pretty_print_binop(op, lhs, rhs),
             BinaryOperatorForDate(_, op, lhs, rhs, _type) => pretty_print_binop(op, lhs, rhs),
             FunctionCall(_, _, name, args, _type) => {
-                m::identifier(name.to_string())
+                m::identifier(name.to_compact_string())
                     + m::operator("(")
                     + itertools::Itertools::intersperse(
                         args.iter().map(|e| e.pretty_print()),
@@ -1335,7 +1339,7 @@ impl PrettyPrint for Expression<'_> {
                         m::space()
                             + itertools::Itertools::intersperse(
                                 exprs.iter().map(|(n, e)| {
-                                    m::identifier(n.to_string())
+                                    m::identifier(n.to_compact_string())
                                         + m::operator(":")
                                         + m::space()
                                         + e.pretty_print()
@@ -1348,7 +1352,7 @@ impl PrettyPrint for Expression<'_> {
                     + m::operator("}")
             }
             AccessField(_, _, expr, attr, _, _) => {
-                expr.pretty_print() + m::operator(".") + m::identifier(attr.to_string())
+                expr.pretty_print() + m::operator(".") + m::identifier(attr.to_compact_string())
             }
             List(_, elements, _) => {
                 m::operator("[")
@@ -1444,7 +1448,7 @@ mod tests {
             .clone()
     }
 
-    fn pretty_print(stmt: &Statement) -> String {
+    fn pretty_print(stmt: &Statement) -> CompactString {
         let markup = stmt.pretty_print();
 
         (PlainTextFormatter {}).format(&markup, false)

+ 38 - 31
numbat/src/unit.rs

@@ -1,5 +1,6 @@
 use std::{fmt::Display, ops::Div};
 
+use compact_str::{CompactString, ToCompactString};
 use itertools::Itertools;
 use num_traits::{ToPrimitive, Zero};
 
@@ -23,7 +24,7 @@ pub enum UnitKind {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct CanonicalName {
-    pub name: String,
+    pub name: CompactString,
     pub accepts_prefix: AcceptsPrefix,
 }
 
@@ -38,7 +39,7 @@ impl CanonicalName {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct UnitIdentifier {
-    pub name: String,
+    pub name: CompactString,
     pub canonical_name: CanonicalName,
     kind: UnitKind,
 }
@@ -61,7 +62,7 @@ impl UnitIdentifier {
     pub fn unit_and_factor(&self) -> BaseUnitAndFactor {
         match &self.kind {
             UnitKind::Base => BaseUnitAndFactor(
-                Unit::new_base(&self.name, self.canonical_name.clone()),
+                Unit::new_base(self.name.to_compact_string(), self.canonical_name.clone()),
                 Number::from_f64(1.0),
             ),
             UnitKind::Derived(factor, defining_unit) => {
@@ -73,7 +74,7 @@ impl UnitIdentifier {
     pub fn base_unit_and_factor(&self) -> BaseUnitAndFactor {
         match &self.kind {
             UnitKind::Base => BaseUnitAndFactor(
-                Unit::new_base(&self.name, self.canonical_name.clone()),
+                Unit::new_base(self.name.to_compact_string(), self.canonical_name.clone()),
                 Number::from_f64(1.0),
             ),
             UnitKind::Derived(factor, defining_unit) => {
@@ -102,7 +103,7 @@ impl UnitIdentifier {
         }
     }
 
-    pub fn sort_key(&self) -> Vec<(String, Exponent)> {
+    pub fn sort_key(&self) -> Vec<(CompactString, Exponent)> {
         use num_integer::Integer;
 
         // TODO: this is more or less a hack. instead of properly sorting by physical
@@ -231,11 +232,11 @@ impl Unit {
         self == &Self::scalar()
     }
 
-    pub fn new_base(name: &str, canonical_name: CanonicalName) -> Self {
+    pub fn new_base(name: CompactString, canonical_name: CanonicalName) -> Self {
         Unit::from_factor(UnitFactor {
             prefix: Prefix::none(),
             unit_id: UnitIdentifier {
-                name: name.into(),
+                name,
                 canonical_name,
                 kind: UnitKind::Base,
             },
@@ -244,7 +245,7 @@ impl Unit {
     }
 
     pub fn new_derived(
-        name: &str,
+        name: CompactString,
         canonical_name: CanonicalName,
         factor: ConversionFactor,
         base_unit: Unit,
@@ -252,7 +253,7 @@ impl Unit {
         Unit::from_factor(UnitFactor {
             prefix: Prefix::none(),
             unit_id: UnitIdentifier {
-                name: name.into(),
+                name,
                 canonical_name,
                 kind: UnitKind::Derived(factor, base_unit),
             },
@@ -295,7 +296,7 @@ impl Unit {
     #[cfg(test)]
     pub fn meter() -> Self {
         Self::new_base(
-            "meter",
+            CompactString::const_new("meter"),
             CanonicalName::new("m", AcceptsPrefix::only_short()),
         )
     }
@@ -303,7 +304,7 @@ impl Unit {
     #[cfg(test)]
     pub fn centimeter() -> Self {
         Self::new_base(
-            "meter",
+            CompactString::const_new("meter"),
             CanonicalName::new("m", AcceptsPrefix::only_short()),
         )
         .with_prefix(Prefix::centi())
@@ -312,7 +313,7 @@ impl Unit {
     #[cfg(test)]
     pub fn millimeter() -> Self {
         Self::new_base(
-            "meter",
+            CompactString::const_new("meter"),
             CanonicalName::new("m", AcceptsPrefix::only_short()),
         )
         .with_prefix(Prefix::milli())
@@ -321,7 +322,7 @@ impl Unit {
     #[cfg(test)]
     pub fn kilometer() -> Self {
         Self::new_base(
-            "meter",
+            CompactString::const_new("meter"),
             CanonicalName::new("m", AcceptsPrefix::only_short()),
         )
         .with_prefix(Prefix::kilo())
@@ -330,14 +331,17 @@ impl Unit {
     #[cfg(test)]
     pub fn second() -> Self {
         Self::new_base(
-            "second",
+            CompactString::const_new("second"),
             CanonicalName::new("s", AcceptsPrefix::only_short()),
         )
     }
 
     #[cfg(test)]
     pub fn gram() -> Self {
-        Self::new_base("gram", CanonicalName::new("g", AcceptsPrefix::only_short()))
+        Self::new_base(
+            CompactString::const_new("gram"),
+            CanonicalName::new("g", AcceptsPrefix::only_short()),
+        )
     }
 
     #[cfg(test)]
@@ -348,7 +352,7 @@ impl Unit {
     #[cfg(test)]
     pub fn kelvin() -> Self {
         Self::new_base(
-            "kelvin",
+            CompactString::const_new("kelvin"),
             CanonicalName::new("K", AcceptsPrefix::only_short()),
         )
     }
@@ -356,7 +360,7 @@ impl Unit {
     #[cfg(test)]
     pub fn radian() -> Self {
         Self::new_derived(
-            "radian",
+            CompactString::const_new("radian"),
             CanonicalName::new("rad", AcceptsPrefix::only_long()),
             Number::from_f64(1.0),
             Self::meter() / Self::meter(),
@@ -366,7 +370,7 @@ impl Unit {
     #[cfg(test)]
     pub fn degree() -> Self {
         Self::new_derived(
-            "degree",
+            CompactString::const_new("degree"),
             CanonicalName::new("°", AcceptsPrefix::none()),
             Number::from_f64(std::f64::consts::PI / 180.0),
             Self::radian(),
@@ -376,7 +380,7 @@ impl Unit {
     #[cfg(test)]
     pub fn percent() -> Self {
         Self::new_derived(
-            "percent",
+            CompactString::const_new("percent"),
             CanonicalName::new("%", AcceptsPrefix::none()),
             Number::from_f64(1e-2),
             Self::scalar(),
@@ -386,7 +390,7 @@ impl Unit {
     #[cfg(test)]
     pub fn hertz() -> Self {
         Self::new_derived(
-            "hertz",
+            CompactString::const_new("hertz"),
             CanonicalName::new("Hz", AcceptsPrefix::only_short()),
             Number::from_f64(1.0),
             Unit::second().powi(-1),
@@ -396,7 +400,7 @@ impl Unit {
     #[cfg(test)]
     pub fn newton() -> Self {
         Self::new_derived(
-            "newton",
+            CompactString::const_new("newton"),
             CanonicalName::new("N", AcceptsPrefix::only_short()),
             Number::from_f64(1.0),
             Unit::kilogram() * Unit::meter() / Unit::second().powi(2),
@@ -406,7 +410,7 @@ impl Unit {
     #[cfg(test)]
     pub fn minute() -> Self {
         Self::new_derived(
-            "minute",
+            CompactString::const_new("minute"),
             CanonicalName::new("min", AcceptsPrefix::none()),
             Number::from_f64(60.0),
             Self::second(),
@@ -416,7 +420,7 @@ impl Unit {
     #[cfg(test)]
     pub fn hour() -> Self {
         Self::new_derived(
-            "hour",
+            CompactString::const_new("hour"),
             CanonicalName::new("h", AcceptsPrefix::none()),
             Number::from_f64(60.0),
             Self::minute(),
@@ -426,7 +430,7 @@ impl Unit {
     #[cfg(test)]
     pub fn kph() -> Self {
         Self::new_derived(
-            "kilometer_per_hour",
+            CompactString::const_new("kilometer_per_hour"),
             CanonicalName::new("kph", AcceptsPrefix::none()),
             Number::from_f64(1.0),
             Self::kilometer() / Self::hour(),
@@ -436,7 +440,7 @@ impl Unit {
     #[cfg(test)]
     pub fn inch() -> Self {
         Self::new_derived(
-            "inch",
+            CompactString::const_new("inch"),
             CanonicalName::new("in", AcceptsPrefix::none()),
             Number::from_f64(0.0254),
             Self::meter(),
@@ -446,7 +450,7 @@ impl Unit {
     #[cfg(test)]
     pub fn gallon() -> Self {
         Self::new_derived(
-            "gallon",
+            CompactString::const_new("gallon"),
             CanonicalName::new("gal", AcceptsPrefix::none()),
             Number::from_f64(231.0),
             Self::inch().powi(3),
@@ -456,7 +460,7 @@ impl Unit {
     #[cfg(test)]
     pub fn foot() -> Self {
         Self::new_derived(
-            "foot",
+            CompactString::const_new("foot"),
             CanonicalName::new("ft", AcceptsPrefix::none()),
             Number::from_f64(12.0),
             Self::inch(),
@@ -466,7 +470,7 @@ impl Unit {
     #[cfg(test)]
     pub fn yard() -> Self {
         Self::new_derived(
-            "yard",
+            CompactString::const_new("yard"),
             CanonicalName::new("yd", AcceptsPrefix::none()),
             Number::from_f64(3.0),
             Self::foot(),
@@ -476,7 +480,7 @@ impl Unit {
     #[cfg(test)]
     pub fn mile() -> Self {
         Self::new_derived(
-            "mile",
+            CompactString::const_new("mile"),
             CanonicalName::new("mi", AcceptsPrefix::none()),
             Number::from_f64(1760.0),
             Self::yard(),
@@ -485,13 +489,16 @@ impl Unit {
 
     #[cfg(test)]
     pub fn bit() -> Self {
-        Self::new_base("bit", CanonicalName::new("bit", AcceptsPrefix::only_long()))
+        Self::new_base(
+            CompactString::const_new("bit"),
+            CanonicalName::new("bit", AcceptsPrefix::only_long()),
+        )
     }
 
     #[cfg(test)]
     pub fn byte() -> Self {
         Self::new_derived(
-            "byte",
+            CompactString::const_new("byte"),
             CanonicalName::new("B", AcceptsPrefix::only_short()),
             Number::from_f64(8.0),
             Self::bit(),

+ 5 - 4
numbat/src/unit_registry.rs

@@ -4,6 +4,7 @@ use crate::registry::{BaseRepresentation, BaseRepresentationFactor, Registry, Re
 use crate::typed_ast::Type;
 use crate::unit::{CanonicalName, Unit};
 
+use compact_str::CompactString;
 use thiserror::Error;
 
 #[derive(Clone, Error, Debug, PartialEq, Eq)]
@@ -18,11 +19,11 @@ pub type Result<T> = std::result::Result<T, UnitRegistryError>;
 pub struct UnitMetadata {
     pub type_: Type,
     pub readable_type: Markup,
-    pub aliases: Vec<(String, AcceptsPrefix)>,
-    pub name: Option<String>,
+    pub aliases: Vec<(CompactString, AcceptsPrefix)>,
+    pub name: Option<CompactString>,
     pub canonical_name: CanonicalName,
-    pub url: Option<String>,
-    pub description: Option<String>,
+    pub url: Option<CompactString>,
+    pub description: Option<CompactString>,
     pub binary_prefixes: bool,
     pub metric_prefixes: bool,
 }

+ 9 - 8
numbat/src/value.rs

@@ -1,5 +1,6 @@
 use std::sync::Arc;
 
+use compact_str::{CompactString, ToCompactString};
 use itertools::Itertools;
 use jiff::Zoned;
 
@@ -9,10 +10,10 @@ use crate::{
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum FunctionReference {
-    Foreign(String),
-    Normal(String),
+    Foreign(CompactString),
+    Normal(CompactString),
     // TODO: We can get rid of this variant once we implement closures:
-    TzConversion(String),
+    TzConversion(CompactString),
 }
 
 impl std::fmt::Display for FunctionReference {
@@ -27,15 +28,15 @@ impl std::fmt::Display for FunctionReference {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq)]
 pub enum Value {
     Quantity(Quantity),
     Boolean(bool),
-    String(String),
+    String(CompactString),
     /// A DateTime with an associated offset used when pretty printing
     DateTime(Zoned),
     FunctionReference(FunctionReference),
-    FormatSpecifiers(Option<String>),
+    FormatSpecifiers(Option<CompactString>),
     StructInstance(Arc<StructInfo>, Vec<Value>),
     List(NumbatList<Value>),
 }
@@ -60,7 +61,7 @@ impl Value {
     }
 
     #[track_caller]
-    pub fn unsafe_as_string(self) -> String {
+    pub fn unsafe_as_string(self) -> CompactString {
         if let Value::String(s) = self {
             s
         } else {
@@ -155,7 +156,7 @@ impl PrettyPrint for Value {
             Value::Boolean(b) => b.pretty_print(),
             Value::String(s) => s.pretty_print(),
             Value::DateTime(dt) => crate::markup::string(crate::datetime::to_string(dt)),
-            Value::FunctionReference(r) => crate::markup::string(r.to_string()),
+            Value::FunctionReference(r) => crate::markup::string(r.to_compact_string()),
             Value::FormatSpecifiers(Some(s)) => crate::markup::string(s.clone()),
             Value::FormatSpecifiers(None) => crate::markup::empty(),
             Value::StructInstance(struct_info, values) => {

+ 50 - 35
numbat/src/vm.rs

@@ -1,7 +1,8 @@
 use std::collections::{HashMap, VecDeque};
+use std::fmt::Display;
 use std::sync::Arc;
-use std::{cmp::Ordering, fmt::Display};
 
+use compact_str::{CompactString, ToCompactString};
 use indexmap::IndexMap;
 use num_traits::ToPrimitive;
 
@@ -210,9 +211,9 @@ pub enum Constant {
     Scalar(f64),
     Unit(Unit),
     Boolean(bool),
-    String(String),
+    String(CompactString),
     FunctionReference(FunctionReference),
-    FormatSpecifiers(Option<String>),
+    FormatSpecifiers(Option<CompactString>),
 }
 
 impl Constant {
@@ -273,7 +274,7 @@ pub struct ExecutionContext<'a> {
 pub struct Vm {
     /// The actual code of the program, structured by function name. The code
     /// for the global scope is at index 0 under the function name `<main>`.
-    bytecode: Vec<(String, Vec<u8>)>,
+    bytecode: Vec<(CompactString, Vec<u8>)>,
 
     /// An index into the `bytecode` vector referring to the function which is
     /// currently being compiled.
@@ -283,7 +284,7 @@ pub struct Vm {
     pub constants: Vec<Constant>,
 
     /// struct metadata, used so we can display struct fields at runtime
-    struct_infos: IndexMap<String, Arc<StructInfo>>,
+    struct_infos: IndexMap<CompactString, Arc<StructInfo>>,
 
     /// Unit prefixes in use
     prefixes: Vec<Prefix>,
@@ -295,7 +296,7 @@ pub struct Vm {
     /// - Unit name
     /// - Canonical name
     /// - Metadata
-    unit_information: Vec<(String, Option<String>, UnitMetadata)>,
+    unit_information: Vec<(CompactString, Option<CompactString>, UnitMetadata)>,
 
     /// Result of the last expression
     last_result: Option<Value>,
@@ -429,8 +430,8 @@ impl Vm {
         }
 
         self.unit_information.push((
-            unit_name.to_owned(),
-            canonical_unit_name.map(|s| s.to_owned()),
+            unit_name.to_compact_string(),
+            canonical_unit_name.map(|s| s.to_compact_string()),
             metadata,
         ));
         assert!(self.unit_information.len() <= u16::MAX as usize);
@@ -513,8 +514,8 @@ impl Vm {
 
                 let operands_str = operands
                     .iter()
-                    .map(u16::to_string)
-                    .collect::<Vec<String>>()
+                    .map(u16::to_compact_string)
+                    .collect::<Vec<_>>()
                     .join(" ");
 
                 eprint!(
@@ -664,7 +665,7 @@ impl Vm {
                         .map_err(RuntimeError::UnitRegistryError)?;
 
                     self.constants[constant_idx as usize] = Constant::Unit(Unit::new_derived(
-                        &unit_information.0,
+                        unit_information.0.to_compact_string(),
                         unit_information.2.canonical_name.clone(),
                         *conversion_value.unsafe_value(),
                         defining_unit.clone(),
@@ -752,22 +753,31 @@ impl Vm {
                     self.push(ret);
                 }
                 op @ (Op::LessThan | Op::GreaterThan | Op::LessOrEqual | Op::GreatorOrEqual) => {
+                    use crate::quantity::QuantityOrdering;
+                    use std::cmp::Ordering;
+
                     let rhs = self.pop_quantity();
                     let lhs = self.pop_quantity();
 
-                    let result = lhs.partial_cmp(&rhs).ok_or_else(|| {
-                        RuntimeError::QuantityError(QuantityError::IncompatibleUnits(
-                            lhs.unit().clone(),
-                            rhs.unit().clone(),
-                        ))
-                    })?;
-
-                    let result = match op {
-                        Op::LessThan => result == Ordering::Less,
-                        Op::GreaterThan => result == Ordering::Greater,
-                        Op::LessOrEqual => result != Ordering::Greater,
-                        Op::GreatorOrEqual => result != Ordering::Less,
-                        _ => unreachable!(),
+                    let result = match lhs.partial_cmp_preserve_nan(&rhs) {
+                        QuantityOrdering::IncompatibleUnits => {
+                            return Err(Box::new(RuntimeError::QuantityError(
+                                QuantityError::IncompatibleUnits(
+                                    lhs.unit().clone(),
+                                    rhs.unit().clone(),
+                                ),
+                            )))
+                        }
+                        QuantityOrdering::NanOperand => false,
+                        QuantityOrdering::Ok(Ordering::Less) => {
+                            matches!(op, Op::LessThan | Op::LessOrEqual)
+                        }
+                        QuantityOrdering::Ok(Ordering::Equal) => {
+                            matches!(op, Op::LessOrEqual | Op::GreatorOrEqual)
+                        }
+                        QuantityOrdering::Ok(Ordering::Greater) => {
+                            matches!(op, Op::GreaterThan | Op::GreatorOrEqual)
+                        }
                     };
 
                     self.push(Value::Boolean(result));
@@ -908,7 +918,7 @@ impl Vm {
                             let dt = self.pop_datetime();
 
                             let tz = jiff::tz::TimeZone::get(&tz_name)
-                                .map_err(|_| RuntimeError::UnknownTimezone(tz_name))?;
+                                .map_err(|_| RuntimeError::UnknownTimezone(tz_name.to_string()))?;
 
                             let dt = dt.with_time_zone(tz);
 
@@ -923,15 +933,15 @@ impl Vm {
                 }
                 Op::JoinString => {
                     let num_parts = self.read_u16() as usize;
-                    let mut joined = String::new();
+                    let mut joined = CompactString::with_capacity(num_parts);
                     let to_str = |value| match value {
-                        Value::Quantity(q) => q.full_simplify().to_string(),
-                        Value::Boolean(b) => b.to_string(),
-                        Value::String(s) => s,
+                        Value::Quantity(q) => q.full_simplify().to_compact_string(),
+                        Value::Boolean(b) => b.to_compact_string(),
+                        Value::String(s) => s.to_compact_string(),
                         Value::DateTime(dt) => crate::datetime::to_string(&dt),
-                        Value::FunctionReference(r) => r.to_string(),
-                        s @ Value::StructInstance(..) => s.to_string(),
-                        l @ Value::List(_) => l.to_string(),
+                        Value::FunctionReference(r) => r.to_compact_string(),
+                        s @ Value::StructInstance(..) => s.to_compact_string(),
+                        l @ Value::List(_) => l.to_compact_string(),
                         Value::FormatSpecifiers(_) => unreachable!(),
                     };
 
@@ -950,13 +960,17 @@ impl Vm {
                                     let q = q.full_simplify();
 
                                     let mut vars = HashMap::new();
-                                    vars.insert("value".to_string(), q.unsafe_value().to_f64());
+                                    vars.insert(
+                                        CompactString::const_new("value"),
+                                        q.unsafe_value().to_f64(),
+                                    );
 
                                     let mut str =
                                         strfmt::strfmt(&format!("{{value{specifiers}}}"), &vars)
+                                            .map(CompactString::from)
                                             .map_err(map_strfmt_error_to_runtime_error)?;
 
-                                    let unit_str = q.unit().to_string();
+                                    let unit_str = q.unit().to_compact_string();
 
                                     if !unit_str.is_empty() {
                                         str += " ";
@@ -967,9 +981,10 @@ impl Vm {
                                 }
                                 value => {
                                     let mut vars = HashMap::new();
-                                    vars.insert("value".to_string(), to_str(value));
+                                    vars.insert("value".to_owned(), to_str(value).to_string());
 
                                     strfmt::strfmt(&format!("{{value{specifiers}}}"), &vars)
+                                        .map(CompactString::from)
                                         .map_err(map_strfmt_error_to_runtime_error)?
                                 }
                             },

+ 54 - 5
numbat/tests/interpreter.rs

@@ -2,6 +2,7 @@ mod common;
 
 use common::get_test_context;
 
+use compact_str::CompactString;
 use insta::assert_snapshot;
 use numbat::markup::{Formatter, PlainTextFormatter};
 use numbat::resolver::CodeSource;
@@ -23,7 +24,7 @@ fn expect_output_with_context(ctx: &mut Context, code: &str, expected_output: im
 }
 
 #[track_caller]
-fn succeed(code: &str) -> String {
+fn succeed(code: &str) -> CompactString {
     let mut ctx = get_test_context();
     let ret = ctx.interpret(code, CodeSource::Internal);
     match ret {
@@ -33,7 +34,7 @@ fn succeed(code: &str) -> String {
                 let fmt = PlainTextFormatter {};
                 fmt.format(&val.pretty_print(), false)
             } else {
-                String::new()
+                CompactString::const_new("")
             }
         }
     }
@@ -574,6 +575,34 @@ fn test_comparisons() {
     expect_output("2 >= 2", "true");
     expect_output("2 >= 2.1", "false");
 
+    // NaN comparison; all false
+
+    expect_output("NaN < NaN", "false");
+    expect_output("NaN < 0", "false");
+    expect_output("NaN < 0m", "false");
+    expect_output("0 < NaN", "false");
+    expect_output("0m < NaN", "false");
+
+    expect_output("NaN <= NaN", "false");
+    expect_output("NaN <= 0", "false");
+    expect_output("NaN <= 0m", "false");
+    expect_output("0 <= NaN", "false");
+    expect_output("0m <= NaN", "false");
+
+    expect_output("NaN > NaN", "false");
+    expect_output("NaN > 0", "false");
+    expect_output("NaN > 0m", "false");
+    expect_output("0 > NaN", "false");
+    expect_output("0m > NaN", "false");
+
+    expect_output("NaN >= NaN", "false");
+    expect_output("NaN >= 0", "false");
+    expect_output("NaN >= 0m", "false");
+    expect_output("0 >= NaN", "false");
+    expect_output("0m >= NaN", "false");
+
+    // equality
+
     expect_output("200 cm == 2 m", "true");
     expect_output("201 cm == 2 m", "false");
 
@@ -740,8 +769,12 @@ fn test_datetime_runtime_errors() {
     );
     expect_failure(
         "format_datetime(\"%Y-%m-%dT%H%:M\", now())",
-        "Error in datetime format",
-    )
+        "strftime formatting failed: found unrecognized directive %M following %:.",
+    );
+    expect_failure(
+        "format_datetime(\"%Y %;\", now())",
+        "strftime formatting failed: found unrecognized specifier directive %;.",
+    );
 }
 
 #[test]
@@ -855,7 +888,7 @@ fn test_statement_pretty_printing() {
 mod tests {
     use super::*;
     #[cfg(test)]
-    mod assert_eq_3 {
+    mod assert_eq {
         use super::*;
 
         #[test]
@@ -959,5 +992,21 @@ mod tests {
               -77.0089°
             "###);
         }
+
+        #[test]
+        fn test_floating_point_warning() {
+            insta::assert_snapshot!(fail("assert_eq(2+ 2, 2 + 1)"), @r###"
+            Assertion failed because the following two values are not the same:
+              4
+              3
+            "###);
+            insta::assert_snapshot!(fail("assert_eq(2 + 2e-12, 2 + 1e-12)"), @r###"
+            Assertion failed because the following two values are not the same:
+              2.0
+              2.0
+            Note: The two printed values appear to be the same, this may be due to floating point precision errors.
+                  For dimension types you may want to test approximate equality instead: assert_eq(q1, q2, ε).
+            "###);
+        }
     }
 }