From 59eae012de55275196a28c537d3aee02ebaac568 Mon Sep 17 00:00:00 2001 From: Ray Andrew Date: Sat, 13 Jun 2026 19:20:44 -0700 Subject: [PATCH] feat(lang): implement v2 language --- Cargo.lock | 1460 +-------------- Cargo.toml | 2 + crates/doot-cli/Cargo.toml | 2 +- crates/doot-cli/src/commands/apply.rs | 1513 ++++----------- crates/doot-cli/src/commands/check.rs | 25 +- crates/doot-cli/src/commands/decrypt.rs | 4 +- .../doot-cli/src/commands/decrypt_entries.rs | 14 +- crates/doot-cli/src/commands/decrypt_var.rs | 6 +- crates/doot-cli/src/commands/deploy_util.rs | 265 +++ crates/doot-cli/src/commands/diff.rs | 14 +- crates/doot-cli/src/commands/edit.rs | 30 +- crates/doot-cli/src/commands/encrypt.rs | 4 +- crates/doot-cli/src/commands/encrypt_var.rs | 6 +- crates/doot-cli/src/commands/fmt.rs | 222 +-- crates/doot-cli/src/commands/init.rs | 8 +- crates/doot-cli/src/commands/keygen.rs | 4 +- crates/doot-cli/src/commands/mod.rs | 111 +- crates/doot-cli/src/commands/package.rs | 21 +- crates/doot-cli/src/commands/plan.rs | 68 + crates/doot-cli/src/commands/reencrypt.rs | 10 +- crates/doot-cli/src/commands/rollback.rs | 4 +- crates/doot-cli/src/commands/snapshot.rs | 4 +- crates/doot-cli/src/commands/status.rs | 18 +- crates/doot-cli/src/commands/tui.rs | 21 +- crates/doot-cli/src/main.rs | 4 + crates/doot-cli/tests/e2e.rs | 77 +- crates/doot-core/Cargo.toml | 5 +- crates/doot-core/src/builtins/crypto.rs | 67 + crates/doot-core/src/builtins/mod.rs | 3 + crates/doot-core/src/config.rs | 6 +- crates/doot-core/src/deploy/linker.rs | 6 +- crates/doot-core/src/deploy/mod.rs | 18 +- crates/doot-core/src/deploy/template.rs | 7 +- crates/doot-core/src/evaluator.rs | 216 +++ crates/doot-core/src/hooks.rs | 2 +- crates/doot-core/src/lib.rs | 5 +- crates/doot-core/src/state/store.rs | 2 +- crates/doot-dotfile/Cargo.toml | 15 + crates/doot-dotfile/src/bridge.rs | 194 ++ crates/doot-dotfile/src/builtins.rs | 393 ++++ crates/doot-dotfile/src/exec.rs | 36 + crates/doot-dotfile/src/lib.rs | 851 +++++++++ crates/doot-dotfile/src/payload.rs | 109 ++ crates/doot-dotfile/src/reflect.rs | 219 +++ crates/doot-lang/Cargo.toml | 25 - crates/doot-lang/src/ast.rs | 357 ---- crates/doot-lang/src/builtins/async_ops.rs | 275 --- crates/doot-lang/src/builtins/collections.rs | 443 ----- crates/doot-lang/src/builtins/crypto.rs | 192 -- crates/doot-lang/src/builtins/io.rs | 428 ----- crates/doot-lang/src/builtins/mod.rs | 477 ----- crates/doot-lang/src/builtins/parallel.rs | 569 ------ crates/doot-lang/src/builtins/strings.rs | 156 -- crates/doot-lang/src/evaluator.rs | 1619 ----------------- crates/doot-lang/src/lang/ast.rs | 190 ++ crates/doot-lang/src/lang/check.rs | 822 +++++++++ crates/doot-lang/src/lang/diag.rs | 69 + crates/doot-lang/src/lang/engine.rs | 118 ++ crates/doot-lang/src/lang/eval.rs | 787 ++++++++ crates/doot-lang/src/lang/fmt.rs | 498 +++++ crates/doot-lang/src/lang/lexer.rs | 249 +++ crates/doot-lang/src/lang/mod.rs | 20 + crates/doot-lang/src/lang/parser.rs | 542 ++++++ crates/doot-lang/src/lang/plan.rs | 61 + crates/doot-lang/src/lexer.rs | 430 ----- crates/doot-lang/src/lib.rs | 31 +- crates/doot-lang/src/macros.rs | 227 --- crates/doot-lang/src/parser.rs | 1125 ------------ crates/doot-lang/src/planner/dag.rs | 192 -- crates/doot-lang/src/planner/mod.rs | 9 - crates/doot-lang/src/planner/scheduler.rs | 423 ----- crates/doot-lang/src/type_checker.rs | 953 ---------- crates/doot-lang/src/types.rs | 203 --- crates/doot-std/Cargo.toml | 7 + crates/doot-std/src/lib.rs | 153 ++ 75 files changed, 6569 insertions(+), 11152 deletions(-) create mode 100644 crates/doot-cli/src/commands/deploy_util.rs create mode 100644 crates/doot-cli/src/commands/plan.rs create mode 100644 crates/doot-core/src/builtins/crypto.rs create mode 100644 crates/doot-core/src/builtins/mod.rs create mode 100644 crates/doot-core/src/evaluator.rs create mode 100644 crates/doot-dotfile/Cargo.toml create mode 100644 crates/doot-dotfile/src/bridge.rs create mode 100644 crates/doot-dotfile/src/builtins.rs create mode 100644 crates/doot-dotfile/src/exec.rs create mode 100644 crates/doot-dotfile/src/lib.rs create mode 100644 crates/doot-dotfile/src/payload.rs create mode 100644 crates/doot-dotfile/src/reflect.rs delete mode 100644 crates/doot-lang/src/ast.rs delete mode 100644 crates/doot-lang/src/builtins/async_ops.rs delete mode 100644 crates/doot-lang/src/builtins/collections.rs delete mode 100644 crates/doot-lang/src/builtins/crypto.rs delete mode 100644 crates/doot-lang/src/builtins/io.rs delete mode 100644 crates/doot-lang/src/builtins/mod.rs delete mode 100644 crates/doot-lang/src/builtins/parallel.rs delete mode 100644 crates/doot-lang/src/builtins/strings.rs delete mode 100644 crates/doot-lang/src/evaluator.rs create mode 100644 crates/doot-lang/src/lang/ast.rs create mode 100644 crates/doot-lang/src/lang/check.rs create mode 100644 crates/doot-lang/src/lang/diag.rs create mode 100644 crates/doot-lang/src/lang/engine.rs create mode 100644 crates/doot-lang/src/lang/eval.rs create mode 100644 crates/doot-lang/src/lang/fmt.rs create mode 100644 crates/doot-lang/src/lang/lexer.rs create mode 100644 crates/doot-lang/src/lang/mod.rs create mode 100644 crates/doot-lang/src/lang/parser.rs create mode 100644 crates/doot-lang/src/lang/plan.rs delete mode 100644 crates/doot-lang/src/lexer.rs delete mode 100644 crates/doot-lang/src/macros.rs delete mode 100644 crates/doot-lang/src/parser.rs delete mode 100644 crates/doot-lang/src/planner/dag.rs delete mode 100644 crates/doot-lang/src/planner/mod.rs delete mode 100644 crates/doot-lang/src/planner/scheduler.rs delete mode 100644 crates/doot-lang/src/type_checker.rs delete mode 100644 crates/doot-lang/src/types.rs create mode 100644 crates/doot-std/Cargo.toml create mode 100644 crates/doot-std/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1baedf0..b647583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aead" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fc95d1bdb8e6666b2b217308eeeb09f2d6728d104be3e31916cc74d15420331" -dependencies = [ - "generic-array", -] - [[package]] name = "aead" version = "0.5.2" @@ -21,51 +12,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "aes" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" -dependencies = [ - "aes-soft", - "aesni", - "cipher 0.2.5", -] - -[[package]] -name = "aes-gcm" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5278b5fabbb9bd46e24aa69b2fdea62c99088e0a950a9be40e3e0101298f88da" -dependencies = [ - "aead 0.3.2", - "aes", - "cipher 0.2.5", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aes-soft" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" -dependencies = [ - "cipher 0.2.5", - "opaque-debug", -] - -[[package]] -name = "aesni" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" -dependencies = [ - "cipher 0.2.5", - "opaque-debug", -] - [[package]] name = "age" version = "0.10.1" @@ -73,20 +19,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77de71da1ca673855aacea507a7aed363beb8934cf61b62364fc4b479d2e8cda" dependencies = [ "age-core", - "base64 0.21.7", + "base64", "bech32", "chacha20poly1305", "cookie-factory", - "hmac 0.12.1", + "hmac", "i18n-embed", "i18n-embed-fl", "lazy_static", "nom", "pin-project", - "rand 0.8.5", + "rand", "rust-embed", "scrypt", - "sha2 0.10.9", + "sha2", "subtle", "x25519-dalek", "zeroize", @@ -98,27 +44,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5f11899bc2bbddd135edbc30c36b1924fa59d0746bb45beb5933fafe3fe509b" dependencies = [ - "base64 0.21.7", + "base64", "chacha20poly1305", "cookie-factory", - "hkdf 0.12.4", + "hkdf", "io_tee", "nom", - "rand 0.8.5", + "rand", "secrecy", - "sha2 0.10.9", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", + "sha2", ] [[package]] @@ -201,15 +135,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] - [[package]] name = "arc-swap" version = "1.8.1" @@ -219,16 +144,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "ariadne" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44055e597c674aef7cb903b2b9f6e4cba1277ed0d2d61dae7cd52d7ffa81f8e2" -dependencies = [ - "unicode-width 0.1.14", - "yansi", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -241,17 +156,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -272,8 +176,8 @@ checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.3.0", - "futures-lite 2.6.1", + "fastrand", + "futures-lite", "pin-project-lite", "slab", ] @@ -286,22 +190,7 @@ checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite 2.6.1", - "once_cell", + "futures-lite", ] [[package]] @@ -314,7 +203,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "parking", "polling", "rustix 1.1.3", @@ -328,7 +217,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener", "event-listener-strategy", "pin-project-lite", ] @@ -341,7 +230,7 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite 2.6.1", + "futures-lite", ] [[package]] @@ -350,29 +239,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", - "futures-lite 2.6.1", + "event-listener", + "futures-lite", "rustix 1.1.3", ] -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "async-signal" version = "0.2.13" @@ -391,49 +269,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite 2.6.1", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - [[package]] name = "async-task" version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -446,18 +287,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base-x" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -499,15 +328,6 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -532,10 +352,10 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-task", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "piper", ] @@ -545,18 +365,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytes" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - [[package]] name = "cassowary" version = "0.3.0" @@ -601,7 +409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures", ] @@ -611,32 +419,13 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead 0.5.2", + "aead", "chacha20", - "cipher 0.4.4", + "cipher", "poly1305", "zeroize", ] -[[package]] -name = "chumsky" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" -dependencies = [ - "hashbrown 0.14.5", - "stacker", -] - -[[package]] -name = "cipher" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" -dependencies = [ - "generic-array", -] - [[package]] name = "cipher" version = "0.4.4" @@ -730,35 +519,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "const_fn" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8a2ca5ac02d09563609681103aada9e1777d54fc57a5acd7a41404f9c93b6e" - [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "cookie" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951" -dependencies = [ - "aes-gcm", - "base64 0.13.1", - "hkdf 0.10.0", - "hmac 0.10.1", - "percent-encoding", - "rand 0.8.5", - "sha2 0.9.9", - "time", - "version_check", -] - [[package]] name = "cookie-factory" version = "0.3.3" @@ -777,12 +543,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpuid-bool" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba" - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -843,56 +603,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" -dependencies = [ - "generic-array", - "subtle", -] - -[[package]] -name = "ctr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb4a30d54f7443bf3d6191dcd486aca19e67cb3c49fa7a06a319966346707e7f" -dependencies = [ - "cipher 0.2.5", -] - -[[package]] -name = "curl" -version = "0.4.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" -dependencies = [ - "curl-sys", - "libc", - "openssl-probe", - "openssl-sys", - "schannel", - "socket2", - "windows-sys 0.59.0", -] - -[[package]] -name = "curl-sys" -version = "0.4.85+curl-8.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0efa6142b5ecc05f6d3eaa39e6af4888b9d3939273fb592c92b7088a8cf3fdb" -dependencies = [ - "cc", - "libc", - "libnghttp2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", - "windows-sys 0.59.0", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -903,7 +613,7 @@ dependencies = [ "cpufeatures", "curve25519-dalek-derive", "fiat-crypto", - "rustc_version 0.4.1", + "rustc_version", "subtle", "zeroize", ] @@ -966,32 +676,17 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "crypto-common", "subtle", ] -[[package]] -name = "discard" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" - [[package]] name = "dispatch2" version = "0.3.0" @@ -1023,7 +718,7 @@ dependencies = [ "clap", "crossterm", "doot-core", - "doot-lang", + "doot-dotfile", "doot-utils", "glob", "indexmap", @@ -1044,10 +739,10 @@ dependencies = [ "age", "anyhow", "blake3", - "doot-lang", "doot-utils", "glob", "hostname", + "indexmap", "indicatif", "minijinja", "os_info", @@ -1056,6 +751,7 @@ dependencies = [ "serde", "serde_json", "similar", + "tempfile", "thiserror 2.0.18", "toml 0.8.23", "tracing", @@ -1064,32 +760,27 @@ dependencies = [ ] [[package]] -name = "doot-lang" +name = "doot-dotfile" version = "0.1.0" dependencies = [ - "age", - "anyhow", - "ariadne", - "async-recursion", - "blake3", - "chumsky", + "doot-core", + "doot-lang", + "doot-std", "doot-utils", - "futures-lite 2.6.1", - "glob", - "hostname", "indexmap", - "ordered-float", "os_info", - "rayon", - "serde", - "serde_json", - "smol", - "surf", "tempfile", - "thiserror 2.0.18", - "toml 0.8.23", - "tracing", - "walkdir", +] + +[[package]] +name = "doot-lang" +version = "0.1.0" + +[[package]] +name = "doot-std" +version = "0.1.0" +dependencies = [ + "doot-lang", ] [[package]] @@ -1108,15 +799,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "env_home" version = "0.1.0" @@ -1139,12 +821,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.4.1" @@ -1162,19 +838,10 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener", "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1246,38 +913,12 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "flume" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132" -dependencies = [ - "futures-core", - "futures-sink", - "spinning_top", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures" version = "0.3.31" @@ -1326,28 +967,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.3.0", + "fastrand", "futures-core", "futures-io", "parking", @@ -1405,17 +1031,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1424,7 +1039,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1439,43 +1054,17 @@ dependencies = [ "wasip2", ] -[[package]] -name = "ghash" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97304e4cd182c3846f7575ced3890c53012ce534ad9114046b0a9e00bb30a375" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -1506,33 +1095,13 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hkdf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ab2f639c231793c5f6114bdb9bbe50a7dbbfcd7c7c6bd8475dec2d991e964f" -dependencies = [ - "digest 0.9.0", - "hmac 0.10.1", -] - [[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hmac" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" -dependencies = [ - "crypto-mac", - "digest 0.9.0", + "hmac", ] [[package]] @@ -1541,7 +1110,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1555,53 +1124,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes 1.11.1", - "fnv", - "itoa", -] - -[[package]] -name = "http-client" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5" -dependencies = [ - "async-std", - "async-trait", - "cfg-if", - "http-types", - "isahc", - "log", -] - -[[package]] -name = "http-types" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" -dependencies = [ - "anyhow", - "async-channel 1.9.0", - "async-std", - "base64 0.13.1", - "cookie", - "futures-lite 1.13.0", - "infer", - "pin-project-lite", - "rand 0.7.3", - "serde", - "serde_json", - "serde_qs", - "serde_urlencoded", - "url", -] - [[package]] name = "i18n-config" version = "0.4.8" @@ -1671,114 +1193,12 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -1811,12 +1231,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "infer" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" - [[package]] name = "inout" version = "0.1.4" @@ -1839,15 +1253,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "intl-memoizer" version = "0.5.3" @@ -1879,29 +1284,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "isahc" -version = "0.9.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2948a0ce43e2c2ef11d7edf6816508998d99e13badd1150be0914205df9388a" -dependencies = [ - "bytes 0.5.6", - "crossbeam-utils", - "curl", - "curl-sys", - "flume", - "futures-lite 1.13.0", - "http", - "log", - "once_cell", - "slab", - "sluice", - "tracing", - "tracing-futures", - "url", - "waker-fn", -] - [[package]] name = "itertools" version = "0.13.0" @@ -1927,15 +1309,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1948,28 +1321,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libnghttp2-sys" -version = "0.1.11+1.64.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6c24e48a7167cffa7119da39d577fa482e66c688a4aac016bee862e1a713c4" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1982,12 +1333,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "lock_api" version = "0.4.14" @@ -2002,9 +1347,6 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] [[package]] name = "lru" @@ -2030,22 +1372,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minijinja" version = "2.15.1" @@ -2069,7 +1395,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2104,15 +1430,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "number_prefix" version = "0.4.0" @@ -2278,15 +1595,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2305,33 +1613,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "ordered-float" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" -dependencies = [ - "num-traits", -] - [[package]] name = "os_info" version = "3.14.0" @@ -2389,16 +1670,10 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "digest", + "hmac", ] -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project" version = "1.1.10" @@ -2438,16 +1713,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand 2.3.0", + "fastrand", "futures-io", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "polling" version = "3.11.0" @@ -2470,18 +1739,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "polyval" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc4aa140b9abd2bc40d9c3f7ccec842679cd79045ac3a7ac698c1a064b7cd" -dependencies = [ - "cpuid-bool", - "opaque-debug", - "universal-hash 0.4.0", + "universal-hash", ] [[package]] @@ -2490,15 +1748,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2532,12 +1781,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2547,16 +1790,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "psm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" -dependencies = [ - "ar_archive_writer", - "cc", -] - [[package]] name = "quote" version = "1.0.44" @@ -2572,19 +1805,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - [[package]] name = "rand" version = "0.8.5" @@ -2592,18 +1812,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "rand_chacha", + "rand_core", ] [[package]] @@ -2613,16 +1823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", + "rand_core", ] [[package]] @@ -2634,15 +1835,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "ratatui" version = "0.29.0" @@ -2746,7 +1938,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2 0.10.9", + "sha2", "walkdir", ] @@ -2762,22 +1954,13 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", + "semver", ] [[package]] @@ -2824,7 +2007,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -2836,15 +2019,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2859,7 +2033,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -2886,27 +2060,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "serde" version = "1.0.228" @@ -2950,17 +2109,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_qs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -2970,46 +2118,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" -dependencies = [ - "sha1_smol", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.10.9" @@ -3018,7 +2126,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -3079,17 +2187,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "sluice" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" -dependencies = [ - "async-channel 1.9.0", - "futures-core", - "futures-io", -] - [[package]] name = "smallvec" version = "1.15.1" @@ -3102,7 +2199,7 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-executor", "async-fs", "async-io", @@ -3110,54 +2207,7 @@ dependencies = [ "async-net", "async-process", "blocking", - "futures-lite 2.6.1", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "spinning_top" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" -dependencies = [ - "lock_api", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacker" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", -] - -[[package]] -name = "standback" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" -dependencies = [ - "version_check", + "futures-lite", ] [[package]] @@ -3166,55 +2216,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stdweb" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" -dependencies = [ - "discard", - "rustc_version 0.2.3", - "stdweb-derive", - "stdweb-internal-macros", - "stdweb-internal-runtime", - "wasm-bindgen", -] - -[[package]] -name = "stdweb-derive" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_derive", - "syn 1.0.109", -] - -[[package]] -name = "stdweb-internal-macros" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" -dependencies = [ - "base-x", - "proc-macro2", - "quote", - "serde", - "serde_derive", - "serde_json", - "sha1", - "syn 1.0.109", -] - -[[package]] -name = "stdweb-internal-runtime" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" - [[package]] name = "strsim" version = "0.10.0" @@ -3255,29 +2256,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "surf" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "718b1ae6b50351982dedff021db0def601677f2120938b070eadb10ba4038dd7" -dependencies = [ - "async-std", - "async-trait", - "cfg-if", - "encoding_rs", - "futures-util", - "getrandom 0.2.17", - "http-client", - "http-types", - "log", - "mime_guess", - "once_cell", - "pin-project-lite", - "serde", - "serde_json", - "web-sys", -] - [[package]] name = "syn" version = "1.0.109" @@ -3285,7 +2263,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -3300,24 +2277,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "tempfile" version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "fastrand 2.3.0", + "fastrand", "getrandom 0.3.4", "once_cell", "rustix 1.1.3", @@ -3373,44 +2339,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" -dependencies = [ - "const_fn", - "libc", - "standback", - "stdweb", - "time-macros", - "version_check", - "winapi", -] - -[[package]] -name = "time-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" -dependencies = [ - "proc-macro-hack", - "time-macros-impl", -] - -[[package]] -name = "time-macros-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" -dependencies = [ - "proc-macro-hack", - "proc-macro2", - "quote", - "standback", - "syn 1.0.109", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -3478,7 +2406,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3505,16 +2432,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3591,12 +2508,6 @@ dependencies = [ "tinystr", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.22" @@ -3632,16 +2543,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" -[[package]] -name = "universal-hash" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402" -dependencies = [ - "generic-array", - "subtle", -] - [[package]] name = "universal-hash" version = "0.5.1" @@ -3652,25 +2553,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -3683,30 +2565,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "walkdir" version = "2.5.0" @@ -3717,12 +2581,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3751,20 +2609,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -3797,16 +2641,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" @@ -3872,16 +2706,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3899,31 +2724,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3932,96 +2740,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" @@ -4043,12 +2803,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4056,40 +2810,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core 0.6.4", + "rand_core", "serde", "zeroize", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.38" @@ -4115,21 +2840,6 @@ name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure", -] [[package]] name = "zeroize" @@ -4151,17 +2861,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - [[package]] name = "zerovec" version = "0.11.5" @@ -4169,20 +2868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "serde", - "yoke", "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6705791..b1ef855 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ repository = "https://github.com/rayandrew/doot" [workspace.dependencies] doot-utils = { path = "crates/doot-utils" } doot-lang = { path = "crates/doot-lang" } +doot-std = { path = "crates/doot-std" } +doot-dotfile = { path = "crates/doot-dotfile" } doot-core = { path = "crates/doot-core" } chumsky = "0.9" diff --git a/crates/doot-cli/Cargo.toml b/crates/doot-cli/Cargo.toml index cdc076f..06bbd61 100644 --- a/crates/doot-cli/Cargo.toml +++ b/crates/doot-cli/Cargo.toml @@ -9,7 +9,7 @@ path = "src/main.rs" [dependencies] doot-utils.workspace = true -doot-lang.workspace = true +doot-dotfile.workspace = true doot-core.workspace = true clap.workspace = true serde.workspace = true diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index 776072f..3c5bbe8 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -1,1206 +1,407 @@ -use super::{decrypt_encrypted_vars_with_source_dir, find_config_file, parse_config, type_check}; +//! DAG-driven apply: deploy dotfiles, install packages, and run hooks. +//! +//! Executes the inferred [`ExecPlan`] layer by layer: layers run sequentially, +//! and independent tasks within a layer run concurrently (dotfiles via the +//! Deployer's parallel batches, hooks via scoped threads). Hook stages and +//! `needs` edges both flow into the layering, so ordering is honored across kinds. + +use super::{decrypt_encrypted_vars_with_source_dir, find_config_file, hook_env}; +use crate::commands::deploy_util::{ + directory_target_collisions, expand_dotfile_patterns, merge_specializations, template_outdated, +}; use doot_core::deploy::TemplateEngine; -use doot_core::state::{StateStore, SyncStatus}; -use doot_core::{Config, DeployAction, Deployer}; -use doot_lang::ast::HookStage; -use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig}; -use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets}; -use indicatif::{ProgressBar, ProgressStyle}; -use std::collections::HashSet; -use std::io::{self, Write}; +use doot_core::evaluator::{DotfileConfig, EvalResult, PackageConfig}; +use doot_core::state::StateStore; +use doot_core::{Deployer, Settings}; +use doot_dotfile::Task; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Instant; -/// Applies the dotfile configuration, deploying files and installing packages. -#[tracing::instrument(skip_all, fields(dry_run, prune))] +#[tracing::instrument(skip_all)] pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow::Result<()> { - let start = Instant::now(); let path = find_config_file(config_path)?; + let source_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); let source = std::fs::read_to_string(&path)?; - tracing::debug!(path = %path.display(), "parsing config"); - - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let mut result = evaluator.eval_sync(&program)?; - - // Get environment variables to expose to hook scripts - let hook_env = evaluator.get_hook_env(); - - // Warn about likely-mistaken directory targets before expanding globs, while - // result.dotfiles still holds only the explicit (non-glob) blocks. - for warning in directory_target_collisions(&result.dotfiles, &result.dotfile_patterns) { - eprintln!("warning: {warning}"); - } - - // Expand glob patterns from dotfiles: blocks - let glob_count = - expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir); - - // Merge specializations (explicit dotfile blocks override glob-expanded entries) - if glob_count > 0 { - merge_specializations(&mut result.dotfiles, glob_count); - } - - let _total_items = result.dotfiles.len() + result.packages.len(); - println!( - "config parsed: {} dotfiles, {} packages", - result.dotfiles.len(), - result.packages.len() - ); - - // Validate dotfile targets and get proper execution order - let validation = validate_dotfile_targets(&result.dotfiles, &source_dir); - - // Handle errors - if !validation.errors.is_empty() { - tracing::error!("dotfile configuration errors detected"); - for error in &validation.errors { - match error { - DotfileConflict::Duplicate { index_a, index_b } => { - let a = &result.dotfiles[*index_a]; - let b = &result.dotfiles[*index_b]; - tracing::error!( - source = %a.source.display(), - target = %a.target.display(), - index_a = index_a + 1, - index_b = index_b + 1, - "duplicate entry" - ); - let _ = b; // silence unused warning - } - DotfileConflict::RedundantOverlap { - parent_index, - child_index, - } => { - let parent = &result.dotfiles[*parent_index]; - let child = &result.dotfiles[*child_index]; - tracing::error!( - parent_source = %parent.source.display(), - child_source = %child.source.display(), - parent_index = parent_index + 1, - child_index = child_index + 1, - "redundant overlap" - ); - let _ = child; // silence unused warning - } - } + let (plan, errors) = doot_dotfile::compile_exec_plan(&source); + if !errors.is_empty() { + for e in &errors { + eprintln!("{}:{}", path.display(), e.render(&source)); } - anyhow::bail!("fix configuration errors before deploying"); + anyhow::bail!("{} error(s) found", errors.len()); } - // Show warnings - if !validation.warnings.is_empty() { - for warning in &validation.warnings { - tracing::warn!(message = %warning.message, "dotfile configuration warning"); - } - } + let settings = Settings::new(source_dir.clone()).dry_run(dry_run); + let _ = settings.ensure_dirs(); + let state_file = settings.state_file.clone(); - let config = Config::new(source_dir.clone()).dry_run(dry_run); + // Decrypt encrypted entries (the setup layer) into the template variables. + let mut template_vars = plan.template_vars.clone(); + let enc = collect_encrypted(&plan); + decrypt_encrypted_vars_with_source_dir(&enc, &settings, &mut template_vars, Some(&source_dir))?; - let state_file = config.state_file.clone(); + let hook_env = hook_env(&template_vars, &source_dir); + + // Preview engine + a state snapshot, to flag templates whose rendered output + // changed because their inputs (e.g. env) changed, even when the source did not. + let mut preview = TemplateEngine::new(); + preview.set_doot_variables(&template_vars); let state = StateStore::new(&state_file); - // Prepare template variables early for use in both deployer and preview - let mut template_vars = evaluator.get_template_variables(); - decrypt_encrypted_vars_with_source_dir( - &result, - &config, - &mut template_vars, - Some(&source_dir), - )?; + let deployer = Deployer::new(settings, false, Some(&template_vars)); - // Initialize preview TemplateEngine - let mut preview_engine = TemplateEngine::new(); - preview_engine.set_doot_variables(&template_vars); + let prefix = if dry_run { "[dry-run] " } else { "" }; + println!( + "{prefix}executing {} task(s) across {} layer(s)", + plan.len(), + plan.layers.len() + ); - // Check for conflicts before deploying (track by original index) - let mut deploy_set: HashSet = HashSet::new(); - let mut conflicts: Vec<(usize, SyncStatus)> = Vec::new(); - // Track per-file conflicts for directories (file_path, source, target, status) - let mut file_conflicts: Vec<(PathBuf, PathBuf, PathBuf, SyncStatus)> = Vec::new(); - - for &idx in &validation.ordered_indices { - let dotfile = &result.dotfiles[idx]; - let full_source = source_dir.join(&dotfile.source); - let status = state.check_sync_status_with_config( - &full_source, - &dotfile.target, - Some(dotfile.template), - None, - Some(&dotfile.permissions), - dotfile.owner.as_deref(), - ); - - // For directories, check individual files for smarter merging - if full_source.is_dir() { - let permissions = if dotfile.permissions.is_empty() { - None - } else { - Some(dotfile.permissions.as_slice()) - }; - let changed_files = state.get_changed_files_in_dir_with_permissions( - &full_source, - &dotfile.target, - permissions, - ); - - // Filter out excluded files before checking for changes - let changed_files: Vec<_> = changed_files - .into_iter() - .filter(|(src, tgt, _)| { - if dotfile - .exclude_paths - .iter() - .any(|ex| tgt.starts_with(ex) || ex == tgt) - { - return false; - } - if dotfile - .exclude_sources - .iter() - .any(|ex| src.strip_prefix(&full_source) == Ok(ex.as_path())) - { - return false; - } - true - }) - .collect(); - - let mut has_real_conflicts = false; - let mut has_changes = false; - - for (src, tgt, file_status) in changed_files { - match file_status { - SyncStatus::Synced => {} - SyncStatus::NotDeployed - | SyncStatus::TargetMissing - | SyncStatus::SourceChanged => { - // Can auto-merge: just copy from source - has_changes = true; - tracing::debug!(source = %src.display(), "source changed"); - } - SyncStatus::PermissionsChanged => { - has_changes = true; - tracing::debug!(target = %tgt.display(), "permissions changed"); - } - SyncStatus::TargetChanged => { - // Target changed but source didn't - keep target, will update state - has_changes = true; - tracing::debug!(target = %tgt.display(), "target changed, keeping"); - } - SyncStatus::Conflict => { - // Real conflict - both sides changed this file - has_real_conflicts = true; - file_conflicts.push((tgt.clone(), src, tgt, file_status)); - } - SyncStatus::SourceMissing => { - has_changes = true; - tracing::debug!(target = %tgt.display(), "removed from source"); - } - } - } - - if has_real_conflicts { - conflicts.push((idx, SyncStatus::Conflict)); - } else if has_changes { - deploy_set.insert(idx); - } else { - tracing::debug!(target = %dotfile.target.display(), "synced"); - } - } else { - // Single file handling - // Check for template rendering drift when sync status is Synced and file is a template - let mut final_status = status; - if status == SyncStatus::Synced && dotfile.template { - match template_outdated(&state, &preview_engine, &full_source, &dotfile.target) { - Ok(true) => final_status = SyncStatus::SourceChanged, - Ok(false) => {} - // A render error must not abort the whole apply. Warn and force a - // redeploy so the deploy phase surfaces the per-file error instead - // of silently leaving the stale file in place. - Err(e) => { - eprintln!( - "warning: template check failed for {}: {e}", - dotfile.target.display() - ); - final_status = SyncStatus::SourceChanged; - } - } - } - - match final_status { - SyncStatus::Synced => { - tracing::debug!(target = %dotfile.target.display(), "synced"); - } - SyncStatus::NotDeployed | SyncStatus::TargetMissing => { - deploy_set.insert(idx); - } - SyncStatus::SourceChanged => { - println!( - " [source changed] {} -> {}", - dotfile.source.display(), - dotfile.target.display() - ); - deploy_set.insert(idx); - } - SyncStatus::PermissionsChanged => { - println!( - " [permissions changed] {} -> {}", - dotfile.source.display(), - dotfile.target.display() - ); - deploy_set.insert(idx); - } - SyncStatus::TargetChanged => { - conflicts.push((idx, status)); - } - SyncStatus::Conflict => { - conflicts.push((idx, status)); - } - SyncStatus::SourceMissing => { - tracing::error!(source = %dotfile.source.display(), "source missing"); - } - } - } - } - - // Handle conflicts - if !conflicts.is_empty() { - println!("\nConflicts detected:"); - for &(idx, ref status) in &conflicts { - let dotfile = &result.dotfiles[idx]; - let status_str = match status { - SyncStatus::TargetChanged => "target changed", - SyncStatus::Conflict => "both changed", - _ => "conflict", - }; - println!( - " [{}] {} -> {}", - status_str, - dotfile.source.display(), - dotfile.target.display() - ); - - // Show per-file conflicts for directories - let full_source = source_dir.join(&dotfile.source); - if full_source.is_dir() { - for (file_path, _, _, _) in &file_conflicts { - if file_path.starts_with(&dotfile.target) { - let relative = file_path.strip_prefix(&dotfile.target).unwrap_or(file_path); - println!(" - {}", relative.display()); - } - } - } - } - - if dry_run { - println!("\nRun without --dry-run to resolve conflicts."); - } else { - println!("\nHow to resolve conflicts?"); - println!(" [s] Use source (overwrite target)"); - println!(" [t] Keep target (skip these files)"); - println!(" [i] Interactive (ask for each)"); - println!(" [a] Abort"); - print!("\nChoice [s/t/i/a]: "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - match input.trim().to_lowercase().as_str() { - "s" => { - for (idx, _) in conflicts { - deploy_set.insert(idx); - } - } - "t" => { - println!("Skipping conflicted files."); - } - "i" => { - for (idx, status) in conflicts { - let dotfile = &result.dotfiles[idx]; - let status_str = match status { - SyncStatus::TargetChanged => "target changed", - SyncStatus::Conflict => "both changed", - _ => "conflict", - }; - println!( - "\n[{}] {} -> {}", - status_str, - dotfile.source.display(), - dotfile.target.display() - ); - println!( - " [s] Use source [t] Keep target [d] Show diff [m] Merge in editor" - ); - print!(" Choice [s/t/d/m]: "); - io::stdout().flush()?; - - let mut choice = String::new(); - io::stdin().read_line(&mut choice)?; - - match choice.trim().to_lowercase().as_str() { - "s" => { - deploy_set.insert(idx); - } - "d" => { - let full_source = source_dir.join(&dotfile.source); - if full_source.is_dir() { - // Show diffs for individual conflicted files - let relevant: Vec<_> = file_conflicts - .iter() - .filter(|(file_path, _, _, _)| { - file_path.starts_with(&dotfile.target) - }) - .collect(); - if relevant.is_empty() { - println!(" (no file-level diffs to show)"); - } else { - for (file_path, src, tgt, _) in &relevant { - let relative = file_path - .strip_prefix(&dotfile.target) - .unwrap_or(file_path); - println!("\n diff: {}", relative.display()); - show_diff(src, tgt); - } - } - - print!( - " Use source for all {} conflicted file{}? [y/n]: ", - relevant.len(), - if relevant.len() == 1 { "" } else { "s" } - ); - } else { - show_diff(&full_source, &dotfile.target); - print!(" Use source? [y/n]: "); - } - - io::stdout().flush()?; - let mut confirm = String::new(); - io::stdin().read_line(&mut confirm)?; - if confirm.trim().to_lowercase() == "y" { - deploy_set.insert(idx); - } - } - "m" => { - let full_source = source_dir.join(&dotfile.source); - if full_source.is_dir() { - // Merge individual conflicted files - let relevant: Vec<_> = file_conflicts - .iter() - .filter(|(file_path, _, _, _)| { - file_path.starts_with(&dotfile.target) - }) - .collect(); - if relevant.is_empty() { - println!(" (no file-level conflicts to merge)"); - } else { - let mut all_merged = true; - for (file_path, src, tgt, _) in &relevant { - let relative = file_path - .strip_prefix(&dotfile.target) - .unwrap_or(file_path); - println!("\n Merging {}...", relative.display()); - if !merge_in_editor(src, tgt)? { - println!( - " Merge cancelled for {}.", - relative.display() - ); - all_merged = false; - } - } - if all_merged { - deploy_set.insert(idx); - } else { - print!( - " Some files not merged. Deploy anyway? [y/n]: " - ); - io::stdout().flush()?; - let mut confirm = String::new(); - io::stdin().read_line(&mut confirm)?; - if confirm.trim().to_lowercase() == "y" { - deploy_set.insert(idx); - } - } - } - } else if merge_in_editor(&full_source, &dotfile.target)? { - // Source was updated with merged content, deploy it - deploy_set.insert(idx); - } else { - println!(" Merge cancelled, keeping target."); - } - } - _ => { - println!(" Keeping target."); - } - } - } - } - _ => { - println!("Aborted."); - return Ok(()); - } - } - } // else (not dry_run) - } - - let dry_prefix = if dry_run { "[dry-run] " } else { "" }; - - // Run before_deploy hooks - if !dry_run { - run_hooks(&result.hooks, HookStage::BeforeDeploy, &hook_env)?; - } - - if deploy_set.is_empty() { - println!("\n{}all dotfiles synced, nothing to deploy", dry_prefix); - } else { - // Filter parallel batches to only include items in deploy_set - let filtered_batches: Vec> = validation - .parallel_batches - .iter() - .map(|batch| { - batch - .iter() - .copied() - .filter(|i| deploy_set.contains(i)) - .collect::>() - }) - .filter(|batch| !batch.is_empty()) - .collect(); - - let deployer = Deployer::new(config, result.sandbox, Some(&template_vars)); - - let progress = if !dry_run { - let pb = ProgressBar::new(deploy_set.len() as u64); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() - .progress_chars("=>-"), - ); - pb.set_message("deploying dotfiles"); - Some(pb) - } else { - None - }; - - let deploy_result = - deployer.deploy_batches(&result.dotfiles, &filtered_batches, progress.as_ref())?; - - if let Some(pb) = progress { - pb.finish_with_message("done"); - } - - if dry_run { - // Dry-run: show what the conflict-check decided needs deploying - println!("\n{}would deploy:", dry_prefix); - for &idx in &deploy_set { - let dotfile = &result.dotfiles[idx]; - println!( - " {} -> {}", - dotfile.source.display(), - dotfile.target.display() - ); - } - - if !deploy_result.errors.is_empty() { - println!("\n{}errors:", dry_prefix); - for error in &deploy_result.errors { - println!(" {} ({})", error.target.display(), error.error); - } - } - } else { - let active: Vec<_> = deploy_result - .deployed - .iter() - .filter(|d| !matches!(d.action, DeployAction::Unchanged)) - .collect(); - - println!("\ndeployment complete:"); - println!(" deployed: {}", active.len()); - println!(" skipped: {}", deploy_result.skipped.len()); - println!(" errors: {}", deploy_result.errors.len()); - - for deployed in &deploy_result.deployed { - tracing::debug!( - source = %deployed.source.display(), - target = %deployed.target.display(), - "deployed" - ); - } - - for skipped in &deploy_result.skipped { - println!(" [skip] {} ({})", skipped.target.display(), skipped.reason); - } - - for error in &deploy_result.errors { - tracing::error!( - source = %error.source.display(), - target = %error.target.display(), - error = %error.error, - "deployment failed" - ); - } - } - } - - // Run after_deploy hooks - if !dry_run { - run_hooks(&result.hooks, HookStage::AfterDeploy, &hook_env)?; - } - - // Package handling - let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty(); - if !result.packages.is_empty() || has_brew_extras { - if !dry_run { - run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; - } - - if let Some(manager) = doot_core::package::detect_package_manager() { - let is_brew = manager.name() == "brew"; - - // Register taps first (no-op on non-brew managers). - if !result.brew_taps.is_empty() { - if dry_run { - println!("\n{}would tap:", dry_prefix); - for t in &result.brew_taps { - println!(" {t}"); - } - } else { - let _ = manager.add_taps(&result.brew_taps); - } - } - - // Resolve packages into formulae (install) and casks (install_casks). - // The `cask` channel only applies on brew; elsewhere a cask package - // falls back to its default/apt/... name like any other package. - let mut formulae = Vec::new(); - let mut casks = Vec::new(); - let mut already_installed = Vec::new(); - - for pkg in &result.packages { - if is_brew && pkg.cask.is_some() { - let name = pkg.cask.clone().unwrap(); - match manager.is_installed(&name) { - Ok(true) => already_installed.push(name), - _ => casks.push(name), - } - continue; - } - let name = match manager.name() { - "brew" => pkg.brew.clone().or_else(|| pkg.default.clone()), - "apt" => pkg.apt.clone().or_else(|| pkg.default.clone()), - "pacman" => pkg.pacman.clone().or_else(|| pkg.default.clone()), - "yay" => pkg.yay.clone().or_else(|| pkg.default.clone()), - "xbps" => pkg.xbps.clone().or_else(|| pkg.default.clone()), - _ => pkg.default.clone(), - }; - if let Some(name) = name { - match manager.is_installed(&name) { - Ok(true) => already_installed.push(name), - _ => formulae.push(name), - } - } - } - - // Brew-only formulae from `brew:` blocks (ignored on other managers). - if is_brew { - for name in &result.brew_formulae { - match manager.is_installed(name) { - Ok(true) => already_installed.push(name.clone()), - _ => formulae.push(name.clone()), - } - } - } - - let total_to_install = formulae.len() + casks.len(); - - if !already_installed.is_empty() && !dry_run { - tracing::debug!( - count = already_installed.len(), - "packages already installed" - ); - } - - if total_to_install == 0 { - if !dry_run { - println!( - "\nall {} packages already installed", - already_installed.len() - ); - } - } else if dry_run { - println!("\n{}would install:", dry_prefix); - for pkg in formulae.iter().chain(casks.iter()) { - println!(" {pkg}"); - } - } else { - println!("\ninstalling {total_to_install} packages..."); - // Resilient install: a failure for one package shouldn't abort apply. - // Warn, then keep only the ones that actually landed so state records - // successes (not failures). - if !formulae.is_empty() { - if let Err(e) = manager.install(&formulae) { - eprintln!("warning: {e}"); - } - formulae.retain(|pkg| manager.is_installed(pkg).unwrap_or(false)); - } - if !casks.is_empty() { - if let Err(e) = manager.install_casks(&casks) { - eprintln!("warning: {e}"); - } - casks.retain(|pkg| manager.is_installed(pkg).unwrap_or(false)); - } - println!("installed {} packages", formulae.len() + casks.len()); - } - - if !dry_run { - let mut state = StateStore::new(&state_file); - let manager_name = manager.name(); - for pkg in formulae - .iter() - .chain(casks.iter()) - .chain(already_installed.iter()) - { - state.record_package(pkg, manager_name); - } - state.save()?; - } - } else { - println!("no supported package manager found"); - } - - if !dry_run { - run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?; - } - } - - // Prune packages removed from config + // lint explicit-vs-glob target collisions across the whole plan { - let mgr_name = doot_core::package::detect_package_manager() - .map(|m| m.name().to_string()) - .unwrap_or_default(); - let mut configured_names: std::collections::HashSet = result - .packages - .iter() - .filter_map(|p| match mgr_name.as_str() { - // On brew a cask-only package has no brew/default name, so include - // the cask name too — otherwise it would look "removed" and be pruned. - "brew" => p - .cask - .clone() - .or_else(|| p.brew.clone()) - .or_else(|| p.default.clone()), - "apt" => p.apt.clone().or_else(|| p.default.clone()), - "pacman" => p.pacman.clone().or_else(|| p.default.clone()), - "yay" => p.yay.clone().or_else(|| p.default.clone()), - "xbps" => p.xbps.clone().or_else(|| p.default.clone()), - _ => p.default.clone(), - }) - .collect(); + let mut explicit = Vec::new(); + let mut patterns = Vec::new(); + for layer in &plan.layers { + for task in layer { + match task { + Task::Dotfile(d) => explicit.push(d.clone()), + Task::DotfilePattern(p) => patterns.push(p.clone()), + _ => {} + } + } + } + for w in directory_target_collisions(&explicit, &patterns) { + eprintln!("warning: {w}"); + } + } - // Brew-only formulae from `brew:` blocks are configured packages too. - if mgr_name == "brew" { - configured_names.extend(result.brew_formulae.iter().cloned()); + // accumulated across layers, for pruning packages removed from config + let mut all_packages: Vec = Vec::new(); + let mut all_formulae: Vec = Vec::new(); + + for (li, layer) in plan.layers.iter().enumerate() { + let mut dotfiles: Vec = Vec::new(); + let mut patterns = Vec::new(); + let mut packages = Vec::new(); + let mut formulae = Vec::new(); + let mut hooks = Vec::new(); + let mut taps = Vec::new(); + for task in layer { + match task { + Task::Dotfile(d) => dotfiles.push(d.clone()), + Task::DotfilePattern(p) => patterns.push(p.clone()), + Task::Package(p) => packages.push(p.clone()), + Task::Formula(n) => formulae.push(n.clone()), + Task::Hook(h) => hooks.push(h.clone()), + Task::Tap(n) => taps.push(n.clone()), + // secrets are not file-deployed here; encrypted entries are + // already folded into template_vars above. + Task::Secret(_) | Task::EncVar { .. } | Task::EncFile { .. } => {} + } + } + let glob_count = expand_dotfile_patterns(&mut dotfiles, &patterns, &source_dir); + merge_specializations(&mut dotfiles, glob_count); + + if dotfiles.is_empty() + && packages.is_empty() + && formulae.is_empty() + && hooks.is_empty() + && taps.is_empty() + { + continue; + } + tracing::debug!(layer = li, "executing layer"); + + if !taps.is_empty() { + if dry_run { + for t in &taps { + println!(" {prefix}tap {t}"); + } + } else if let Some(m) = doot_core::package::detect_package_manager() { + let _ = m.add_taps(&taps); + } } - let state_for_prune = StateStore::new(&state_file); - let to_prune: Vec<(String, String)> = state_for_prune - .get_all_packages() - .iter() - .filter(|(name, _)| !configured_names.contains(*name)) - .map(|(name, rec)| (name.clone(), rec.manager.clone())) - .collect(); - - if !to_prune.is_empty() { + if !dotfiles.is_empty() { if dry_run { - println!("\n{}would uninstall removed packages:", dry_prefix); - for (name, _) in &to_prune { - println!(" {}", name); + for d in &dotfiles { + println!( + " {prefix}deploy {} -> {}", + d.source.display(), + d.target.display() + ); } } else { - println!("\n{} package(s) removed from config:", to_prune.len()); - for (name, _) in &to_prune { - println!(" {}", name); - } - - let mut uninstalled = Vec::new(); - for (name, mgr_name) in &to_prune { - let should_uninstall = if prune { true } else { prompt_uninstall(name)? }; - - if should_uninstall { - if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { - match mgr.is_installed(name) { - Ok(true) => { - mgr.uninstall(std::slice::from_ref(name))?; - println!("uninstalled {}", name); - } - Ok(false) => { - println!("{} already removed from system", name); - } - Err(e) => { - tracing::warn!( - package = %name, - error = %e, - "could not check if package is installed, skipping uninstall" - ); - } - } - uninstalled.push(name.clone()); - } else { - tracing::warn!( - package = %name, manager = %mgr_name, - "cannot uninstall: package manager not available" + // flag templates whose rendered output changed (e.g. env change) + for d in &dotfiles { + if d.template { + let full = source_dir.join(&d.source); + if template_outdated(&state, &preview, &full, &d.target).unwrap_or(false) { + println!( + " [source changed] {} -> {}", + d.source.display(), + d.target.display() ); } } } + // one batch = the whole layer; the Deployer runs it in parallel + let batch: Vec = (0..dotfiles.len()).collect(); + let r = deployer.deploy_batches(&dotfiles, &[batch], None)?; + println!( + " layer {li}: deployed {}, skipped {}, errors {}", + r.deployed.len(), + r.skipped.len(), + r.errors.len() + ); + } + } - if !uninstalled.is_empty() { - let mut state = StateStore::new(&state_file); - for name in &uninstalled { - state.remove_package(name); - } - state.save()?; + if !packages.is_empty() || !formulae.is_empty() { + install_packages(&packages, &formulae, dry_run, prefix, &state_file); + } + all_packages.extend(packages); + all_formulae.extend(formulae); + + run_hooks(&hooks, &hook_env, dry_run, prefix)?; + } + + prune_packages( + &all_packages, + &all_formulae, + prune, + dry_run, + &state_file, + prefix, + )?; + + Ok(()) +} + +/// Gather encrypted entries from the plan into an `EvalResult` for decryption. +fn collect_encrypted(plan: &doot_dotfile::ExecPlan) -> EvalResult { + let mut r = EvalResult::default(); + for layer in &plan.layers { + for task in layer { + match task { + Task::EncVar { key, value } => { + r.encrypted_vars.insert(key.clone(), value.clone()); } + Task::EncFile { key, path } => { + r.encrypted_files.insert(key.clone(), path.clone()); + } + _ => {} + } + } + } + r +} + +/// Resolve packages to the active manager's name for this manager (cask channel +/// only on brew). Returns `None` if no name applies. +fn resolve_name(manager_name: &str, pkg: &PackageConfig) -> Option { + match manager_name { + "brew" => pkg.brew.clone().or_else(|| pkg.default.clone()), + "apt" => pkg.apt.clone().or_else(|| pkg.default.clone()), + "pacman" => pkg.pacman.clone().or_else(|| pkg.default.clone()), + "yay" => pkg.yay.clone().or_else(|| pkg.default.clone()), + "xbps" => pkg.xbps.clone().or_else(|| pkg.default.clone()), + _ => pkg.default.clone(), + } +} + +/// Resolve packages to the active manager's names and install the missing ones, +/// recording every configured package in state (for later pruning). +fn install_packages( + packages: &[PackageConfig], + brew_formulae: &[String], + dry_run: bool, + prefix: &str, + state_file: &Path, +) { + let Some(manager) = doot_core::package::detect_package_manager() else { + return; + }; + let is_brew = manager.name() == "brew"; + let mut formulae = Vec::new(); + let mut casks = Vec::new(); + let mut already = Vec::new(); + + for pkg in packages { + if is_brew && pkg.cask.is_some() { + let name = pkg.cask.clone().unwrap(); + if manager.is_installed(&name).unwrap_or(false) { + already.push(name); + } else { + casks.push(name); + } + continue; + } + if let Some(name) = resolve_name(manager.name(), pkg) { + if manager.is_installed(&name).unwrap_or(false) { + already.push(name); + } else { + formulae.push(name); + } + } + } + if is_brew { + for name in brew_formulae { + if manager.is_installed(name).unwrap_or(false) { + already.push(name.clone()); + } else { + formulae.push(name.clone()); } } } - let elapsed = start.elapsed(); - println!("\nfinished in {:.2?}", elapsed); + if dry_run { + for p in formulae.iter().chain(casks.iter()) { + println!(" {prefix}install {p}"); + } + return; + } + if !formulae.is_empty() + && let Err(e) = manager.install(&formulae) + { + tracing::warn!(error = %e, "package install failed"); + } + if !casks.is_empty() + && let Err(e) = manager.install_casks(&casks) + { + tracing::warn!(error = %e, "cask install failed"); + } + + // record every configured package so prune can detect removals later + let mut state = StateStore::new(state_file); + let mgr = manager.name(); + for name in formulae.iter().chain(casks.iter()).chain(already.iter()) { + state.record_package(name, mgr); + } + let _ = state.save(); +} + +/// Uninstall packages recorded in state that are no longer in the config. +fn prune_packages( + packages: &[PackageConfig], + brew_formulae: &[String], + prune: bool, + dry_run: bool, + state_file: &Path, + prefix: &str, +) -> anyhow::Result<()> { + let mgr_name = doot_core::package::detect_package_manager() + .map(|m| m.name().to_string()) + .unwrap_or_default(); + let mut configured: std::collections::HashSet = packages + .iter() + .filter_map(|p| { + if mgr_name == "brew" { + p.cask + .clone() + .or_else(|| p.brew.clone()) + .or_else(|| p.default.clone()) + } else { + resolve_name(&mgr_name, p) + } + }) + .collect(); + if mgr_name == "brew" { + configured.extend(brew_formulae.iter().cloned()); + } + + let state = StateStore::new(state_file); + let to_prune: Vec<(String, String)> = state + .get_all_packages() + .iter() + .filter(|(name, _)| !configured.contains(*name)) + .map(|(name, rec)| (name.clone(), rec.manager.clone())) + .collect(); + if to_prune.is_empty() { + return Ok(()); + } + + if dry_run { + println!("\n{prefix}would uninstall removed packages:"); + for (name, _) in &to_prune { + println!(" {name}"); + } + return Ok(()); + } + + println!("\n{} package(s) removed from config:", to_prune.len()); + let mut uninstalled = Vec::new(); + for (name, mgr) in &to_prune { + let go = if prune { true } else { prompt_uninstall(name)? }; + if !go { + continue; + } + if let Some(m) = doot_core::package::get_package_manager(mgr) { + if m.is_installed(name).unwrap_or(false) { + m.uninstall(std::slice::from_ref(name))?; + println!("uninstalled {name}"); + } + uninstalled.push(name.clone()); + } + } + if !uninstalled.is_empty() { + let mut state = StateStore::new(state_file); + for name in &uninstalled { + state.remove_package(name); + } + state.save()?; + } Ok(()) } fn prompt_uninstall(package: &str) -> anyhow::Result { - print!("Uninstall {}? [y/N] ", package); - io::stdout().flush()?; + use std::io::Write; + print!("Uninstall {package}? [y/N] "); + std::io::stdout().flush()?; let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes")) + std::io::stdin().read_line(&mut input)?; + Ok(matches!(input.trim(), "y" | "Y" | "yes")) } -#[tracing::instrument(skip_all)] -fn show_diff(source: &PathBuf, target: &PathBuf) { - use std::process::Command; - - if !source.is_file() { - println!(" (source does not exist: {})", source.display()); - return; - } - if !target.is_file() { - println!(" (target does not exist: {})", target.display()); - return; - } - - match Command::new("diff") - .arg("--color=always") - .arg("-u") - .arg(target) - .arg(source) - .output() - { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - if stdout.trim().is_empty() { - println!(" (no content differences)"); - } else { - println!("{}", stdout); - } - } - Err(e) => { - println!(" (failed to run diff: {})", e); - } - } -} - -#[tracing::instrument(skip_all)] +/// Run a layer's hooks concurrently (they are independent within a layer). fn run_hooks( - hooks: &[HookConfig], - stage: HookStage, - env_vars: &std::collections::HashMap, + hooks: &[doot_core::evaluator::HookConfig], + env: &HashMap, + dry_run: bool, + prefix: &str, ) -> anyhow::Result<()> { - let stage_hooks: Vec<_> = hooks.iter().filter(|h| h.stage == stage).collect(); - - if stage_hooks.is_empty() { + if hooks.is_empty() { return Ok(()); } - - let stage_name = match stage { - HookStage::BeforeDeploy => "before_deploy", - HookStage::AfterDeploy => "after_deploy", - HookStage::BeforePackage => "before_package", - HookStage::AfterPackage => "after_package", - }; - - tracing::debug!(stage = stage_name, "running hooks"); - - for hook in stage_hooks { - tracing::debug!(command = %hook.run, "executing hook"); - - let status = Command::new("sh") - .arg("-c") - .arg(&hook.run) - .envs(env_vars) - .status()?; - - if !status.success() { - anyhow::bail!("hook failed: {}", hook.run); + if dry_run { + for h in hooks { + let first = h.run.lines().next().unwrap_or("").trim(); + println!(" {prefix}hook {first}"); } + return Ok(()); + } + let failures: Vec = std::thread::scope(|s| { + let handles: Vec<_> = hooks + .iter() + .map(|h| { + s.spawn(move || { + let status = Command::new("sh").arg("-c").arg(&h.run).envs(env).status(); + match status { + Ok(st) if st.success() => None, + _ => Some(h.run.clone()), + } + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().unwrap()) + .collect() + }); + if !failures.is_empty() { + anyhow::bail!("{} hook(s) failed", failures.len()); } - Ok(()) } - -#[tracing::instrument(skip_all)] -fn merge_in_editor(source: &PathBuf, target: &PathBuf) -> anyhow::Result { - use std::process::Command; - - if !source.is_file() || !target.is_file() { - println!(" (merge not available for directories)"); - return Ok(false); - } - - // Try vimdiff first - let editor = std::env::var("VISUAL") - .or_else(|_| std::env::var("EDITOR")) - .unwrap_or_else(|_| "vim".to_string()); - - // Create temp file for merged result - let temp_dir = std::env::temp_dir(); - let merged_path = temp_dir.join(format!( - "doot-merge-{}", - source.file_name().unwrap_or_default().to_string_lossy() - )); - - // Copy target to temp (start with target's content as base) - std::fs::copy(target, &merged_path)?; - - // Try vimdiff-style merge if using vim/nvim - if editor.contains("vim") { - println!(" Opening vimdiff (left=target, right=source)..."); - println!(" Edit left pane, then :wqa to save and quit"); - - let status = Command::new(&editor) - .arg("-d") - .arg(&merged_path) // target (editable) - .arg(source) // source (reference) - .status()?; - - if !status.success() { - let _ = std::fs::remove_file(&merged_path); - return Ok(false); - } - } else { - // For other editors, show diff and open merged file - println!(" Opening {} with target content...", editor); - println!(" Reference source: {}", source.display()); - - let status = Command::new(&editor).arg(&merged_path).status()?; - - if !status.success() { - let _ = std::fs::remove_file(&merged_path); - return Ok(false); - } - } - - // Ask if user wants to use the merged result - print!(" Save merged result to source? [y/n]: "); - io::stdout().flush()?; - let mut confirm = String::new(); - io::stdin().read_line(&mut confirm)?; - - if confirm.trim().to_lowercase() == "y" { - // Copy merged result back to source - std::fs::copy(&merged_path, source)?; - let _ = std::fs::remove_file(&merged_path); - println!(" Source updated with merged content."); - Ok(true) - } else { - let _ = std::fs::remove_file(&merged_path); - Ok(false) - } -} - -/// Extracts the directory prefix before any wildcard in a glob pattern. -/// "config/*" → "config", "a/b/**/*.rs" → "a/b", "*" → "" -fn glob_base_dir(pattern: &str) -> PathBuf { - let wildcard_pos = pattern.find(['*', '?', '[']); - let prefix = match wildcard_pos { - Some(pos) => &pattern[..pos], - None => pattern, - }; - match prefix.rfind('/') { - Some(pos) => PathBuf::from(&prefix[..pos]), - None => PathBuf::new(), - } -} - -/// Finds the common path prefix of a list of paths. -fn common_path_prefix(paths: &[PathBuf]) -> PathBuf { - if paths.is_empty() { - return PathBuf::new(); - } - let mut prefix = paths[0].parent().unwrap_or(Path::new("")).to_path_buf(); - for path in &paths[1..] { - while !path.starts_with(&prefix) { - if !prefix.pop() { - return PathBuf::new(); - } - } - } - prefix -} - -/// Renders a template file and returns its BLAKE3 hash. -fn rendered_template_hash( - engine: &TemplateEngine, - source_path: &Path, -) -> anyhow::Result> { - if !source_path.is_file() { - return Ok(None); - } - let content = std::fs::read_to_string(source_path)?; - let rendered = engine - .render(&content) - .map_err(|e| anyhow::anyhow!("template render error: {}", e))?; - let hash = blake3::hash(rendered.as_bytes()).to_hex().to_string(); - Ok(Some(hash)) -} - -/// Returns true if the rendered template output differs from the last deployed content. -pub(crate) fn template_outdated( - state: &StateStore, - engine: &TemplateEngine, - source_path: &Path, - target_path: &Path, -) -> anyhow::Result { - if !source_path.is_file() { - return Ok(false); - } - - let Some(record) = state.get_deployment(target_path) else { - return Ok(false); - }; - - if !record.template { - return Ok(false); - } - - if let Some(rendered_hash) = rendered_template_hash(engine, source_path)? { - return Ok(rendered_hash != record.target_hash); - } - - Ok(false) -} - -/// Returns warnings for explicit dotfile blocks whose target is exactly a glob -/// pattern's directory target. For a directory target, doot appends the source -/// filename at deploy time, so the explicit entry lands on the *same path* the -/// glob already produces — they collide instead of the explicit block overriding -/// the glob. Targeting the specific file (`… / ""`) is what makes the -/// override fire. (`validate_dotfile_targets` can't catch this: the raw target -/// fields differ — `…/bin` vs `…/bin/wb` — only the deployed paths coincide.) -fn directory_target_collisions( - dotfiles: &[DotfileConfig], - patterns: &[DotfilesPattern], -) -> Vec { - let mut warnings = Vec::new(); - for df in dotfiles { - for pat in patterns { - if df.target == pat.target_base { - let file = df - .source - .file_name() - .map(|f| f.to_string_lossy().into_owned()) - .unwrap_or_else(|| "".to_string()); - warnings.push(format!( - "dotfile '{}' targets the directory '{}', which is also a glob target. \ - It will collide with the glob instead of overriding it. \ - Did you mean: target = ... / \"{}\"", - df.source.display(), - df.target.display(), - file, - )); - } - } - } - warnings -} - -/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries. -/// Returns the number of entries added. -fn expand_dotfile_patterns( - dotfiles: &mut Vec, - patterns: &[DotfilesPattern], - source_dir: &Path, -) -> usize { - let before = dotfiles.len(); - for pattern in patterns { - let (sources, base) = match &pattern.source { - DotfilesSource::Pattern(pat) => { - let base_rel = glob_base_dir(pat); - let full_pattern = source_dir.join(pat); - let paths: Vec = glob::glob(&full_pattern.to_string_lossy()) - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .collect(); - (paths, source_dir.join(&base_rel)) - } - DotfilesSource::Paths(paths) => { - let base = common_path_prefix(paths); - (paths.clone(), base) - } - }; - - for source_path in sources { - let rel_to_source = source_path.strip_prefix(source_dir).unwrap_or(&source_path); - let suffix = source_path.strip_prefix(&base).unwrap_or(&source_path); - let target = pattern.target_base.join(suffix); - - dotfiles.push(DotfileConfig { - source: rel_to_source.to_path_buf(), - target, - template: pattern.template, - permissions: pattern.permissions.clone(), - owner: pattern.owner.clone(), - deploy: pattern.deploy, - link_patterns: pattern.link_patterns.clone(), - copy_patterns: pattern.copy_patterns.clone(), - exclude_paths: vec![], - exclude_sources: vec![], - }); - } - } - dotfiles.len() - before -} - -/// Merges explicit dotfile blocks into glob-expanded entries. -/// -/// Three merge cases: -/// 1. Same target: explicit replaces glob-expanded entry entirely. -/// 2. Target inside directory target: adds the file's target to exclude_paths. -/// 3. Source inside directory source: adds the file's source to exclude_sources -/// (handles cases where the explicit block targets a different location). -fn merge_specializations(dotfiles: &mut Vec, glob_count: usize) { - let total = dotfiles.len(); - let explicit_end = total - glob_count; - let glob_start = explicit_end; - - let mut glob_to_remove: HashSet = HashSet::new(); - - // First pass: find same-target replacements - for exp_idx in 0..explicit_end { - for glob_idx in glob_start..total { - if glob_to_remove.contains(&glob_idx) { - continue; - } - - let exp_target = &dotfiles[exp_idx].target; - let glob_target = &dotfiles[glob_idx].target; - - if exp_target == glob_target { - glob_to_remove.insert(glob_idx); - } - } - } - - // Second pass: collect exclusions for directory entries - for exp_idx in 0..explicit_end { - for glob_idx in glob_start..total { - if glob_to_remove.contains(&glob_idx) { - continue; - } - - let exp_target = dotfiles[exp_idx].target.clone(); - let glob_target = &dotfiles[glob_idx].target; - - // Target-based exclusion: explicit target is inside glob target directory - if exp_target.starts_with(glob_target) && exp_target != *glob_target { - dotfiles[glob_idx].exclude_paths.push(exp_target); - } - - // Source-based exclusion: explicit source is inside glob source directory - // Store path relative to the directory source so it matches after strip_prefix - let exp_source = dotfiles[exp_idx].source.clone(); - let glob_source = dotfiles[glob_idx].source.clone(); - - if exp_source.starts_with(&glob_source) - && exp_source != glob_source - && let Ok(relative) = exp_source.strip_prefix(&glob_source) - { - dotfiles[glob_idx] - .exclude_sources - .push(relative.to_path_buf()); - } - } - } - - // Remove replaced glob entries (reverse order to preserve indices) - let mut remove_sorted: Vec = glob_to_remove.into_iter().collect(); - remove_sorted.sort_unstable_by(|a, b| b.cmp(a)); - for idx in remove_sorted { - dotfiles.remove(idx); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use doot_lang::evaluator::{DeployMode, DotfilesSource}; - - fn explicit(source: &str, target: &str) -> DotfileConfig { - DotfileConfig { - source: PathBuf::from(source), - target: PathBuf::from(target), - template: false, - permissions: vec![], - owner: None, - deploy: DeployMode::default(), - link_patterns: vec![], - copy_patterns: vec![], - exclude_paths: vec![], - exclude_sources: vec![], - } - } - - fn glob_pattern(pattern: &str, target_base: &str) -> DotfilesPattern { - DotfilesPattern { - source: DotfilesSource::Pattern(pattern.to_string()), - target_base: PathBuf::from(target_base), - template: false, - permissions: vec![], - owner: None, - deploy: DeployMode::default(), - link_patterns: vec![], - copy_patterns: vec![], - } - } - - #[test] - fn warns_when_explicit_target_is_glob_directory() { - // explicit `bin/wb` -> ~/.local/bin (the directory) collides with `bin/*`. - let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin")]; - let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")]; - let warnings = directory_target_collisions(&dotfiles, &patterns); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("wb")); - assert!(warnings[0].contains("collide")); - } - - #[test] - fn no_warning_for_specific_file_target() { - // explicit `bin/wb` -> ~/.local/bin/wb (a file) overrides correctly. - let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin/wb")]; - let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")]; - assert!(directory_target_collisions(&dotfiles, &patterns).is_empty()); - } -} diff --git a/crates/doot-cli/src/commands/check.rs b/crates/doot-cli/src/commands/check.rs index fdcf2d1..97ceff2 100644 --- a/crates/doot-cli/src/commands/check.rs +++ b/crates/doot-cli/src/commands/check.rs @@ -1,22 +1,25 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{find_config_file, load}; use std::path::PathBuf; -/// Validates a config file (parse + type check). +/// Validates a config file (parse + type check + evaluate). #[tracing::instrument(skip_all)] pub fn run(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; tracing::debug!(path = %path.display(), "checking config"); - let program = parse_config(&path)?; - println!("syntax: ok"); - - type_check(&program, &source, &path.display().to_string())?; - println!("types: ok"); - - println!("\nconfig is valid"); - println!(" statements: {}", program.statements.len()); + let (result, _vars) = load(&path)?; + println!("config is valid"); + println!( + " dotfiles: {}", + result.dotfiles.len() + result.dotfile_patterns.len() + ); + println!(" packages: {}", result.packages.len()); + println!(" hooks: {}", result.hooks.len()); + println!( + " secrets: {}", + result.secrets.len() + result.encrypted_files.len() + ); Ok(()) } diff --git a/crates/doot-cli/src/commands/decrypt.rs b/crates/doot-cli/src/commands/decrypt.rs index 99d7244..9a8c7b6 100644 --- a/crates/doot-cli/src/commands/decrypt.rs +++ b/crates/doot-cli/src/commands/decrypt.rs @@ -1,4 +1,4 @@ -use doot_core::{Config, encryption::AgeEncryption}; +use doot_core::{Settings, encryption::AgeEncryption}; use std::io::Write; use std::path::PathBuf; @@ -17,7 +17,7 @@ pub fn run( identity: Option, output: Option, ) -> anyhow::Result<()> { - let config = Config::default(); + let config = Settings::default(); let identity_raw = if let Some(path) = identity { std::fs::read_to_string(&path)? } else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") { diff --git a/crates/doot-cli/src/commands/decrypt_entries.rs b/crates/doot-cli/src/commands/decrypt_entries.rs index d56ca7b..656d7b7 100644 --- a/crates/doot-cli/src/commands/decrypt_entries.rs +++ b/crates/doot-cli/src/commands/decrypt_entries.rs @@ -1,7 +1,6 @@ -use super::{find_config_file, parse_config}; -use doot_core::Config; -use doot_lang::Evaluator; -use doot_lang::builtins::crypto::base64_decode; +use super::{find_config_file, load}; +use doot_core::Settings; +use doot_core::builtins::crypto::base64_decode; use std::io::Read; use std::path::PathBuf; @@ -34,11 +33,8 @@ pub fn run( identity: Option, ) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let program = parse_config(&path)?; let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; + let (result, _vars) = load(&path)?; if result.encrypted_vars.is_empty() && result.encrypted_files.is_empty() { println!("no encrypted entries found"); @@ -46,7 +42,7 @@ pub fn run( } // Resolve identity - let config = Config::new(source_dir.clone()); + let config = Settings::new(source_dir.clone()); let identity_raw: String = if let Some(ref path) = identity { std::fs::read_to_string(path)? } else if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") { diff --git a/crates/doot-cli/src/commands/decrypt_var.rs b/crates/doot-cli/src/commands/decrypt_var.rs index a3534e3..e68b74a 100644 --- a/crates/doot-cli/src/commands/decrypt_var.rs +++ b/crates/doot-cli/src/commands/decrypt_var.rs @@ -1,5 +1,5 @@ -use doot_core::Config; -use doot_lang::builtins::crypto::base64_decode; +use doot_core::Settings; +use doot_core::builtins::crypto::base64_decode; use std::io::{self, Read}; use std::path::PathBuf; @@ -10,7 +10,7 @@ fn resolve_identity(identity: Option) -> anyhow::Result "config", "a/b/**/*.rs" -> "a/b", "*" -> "" +fn glob_base_dir(pattern: &str) -> PathBuf { + let wildcard_pos = pattern.find(['*', '?', '[']); + let prefix = match wildcard_pos { + Some(pos) => &pattern[..pos], + None => pattern, + }; + match prefix.rfind('/') { + Some(pos) => PathBuf::from(&prefix[..pos]), + None => PathBuf::new(), + } +} + +/// Finds the common path prefix of a list of paths. +fn common_path_prefix(paths: &[PathBuf]) -> PathBuf { + if paths.is_empty() { + return PathBuf::new(); + } + let mut prefix = paths[0].parent().unwrap_or(Path::new("")).to_path_buf(); + for path in &paths[1..] { + while !path.starts_with(&prefix) { + if !prefix.pop() { + return PathBuf::new(); + } + } + } + prefix +} + +/// Renders a template file and returns its BLAKE3 hash. +fn rendered_template_hash( + engine: &TemplateEngine, + source_path: &Path, +) -> anyhow::Result> { + if !source_path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(source_path)?; + let rendered = engine + .render(&content) + .map_err(|e| anyhow::anyhow!("template render error: {}", e))?; + let hash = blake3::hash(rendered.as_bytes()).to_hex().to_string(); + Ok(Some(hash)) +} + +/// Returns true if the rendered template output differs from the last deployed content. +pub fn template_outdated( + state: &StateStore, + engine: &TemplateEngine, + source_path: &Path, + target_path: &Path, +) -> anyhow::Result { + if !source_path.is_file() { + return Ok(false); + } + let Some(record) = state.get_deployment(target_path) else { + return Ok(false); + }; + if !record.template { + return Ok(false); + } + if let Some(rendered_hash) = rendered_template_hash(engine, source_path)? { + return Ok(rendered_hash != record.target_hash); + } + Ok(false) +} + +/// Returns warnings for explicit dotfile blocks whose target is exactly a glob +/// pattern's directory target. For a directory target, doot appends the source +/// filename at deploy time, so the explicit entry lands on the *same path* the +/// glob already produces - they collide instead of the explicit block overriding +/// the glob. Targeting the specific file (`... / ""`) is what makes the +/// override fire. +pub fn directory_target_collisions( + dotfiles: &[DotfileConfig], + patterns: &[DotfilesPattern], +) -> Vec { + let mut warnings = Vec::new(); + for df in dotfiles { + for pat in patterns { + if df.target == pat.target_base { + let file = df + .source + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()); + warnings.push(format!( + "dotfile '{}' targets the directory '{}', which is also a glob target. \ + It will collide with the glob instead of overriding it. \ + Did you mean: target = ... / \"{}\"", + df.source.display(), + df.target.display(), + file, + )); + } + } + } + warnings +} + +/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig +/// entries. Returns the number of entries added. +pub fn expand_dotfile_patterns( + dotfiles: &mut Vec, + patterns: &[DotfilesPattern], + source_dir: &Path, +) -> usize { + let before = dotfiles.len(); + for pattern in patterns { + let (sources, base) = match &pattern.source { + DotfilesSource::Pattern(pat) => { + let base_rel = glob_base_dir(pat); + let full_pattern = source_dir.join(pat); + let paths: Vec = glob::glob(&full_pattern.to_string_lossy()) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .collect(); + (paths, source_dir.join(&base_rel)) + } + DotfilesSource::Paths(paths) => { + let base = common_path_prefix(paths); + (paths.clone(), base) + } + }; + + for source_path in sources { + let rel_to_source = source_path.strip_prefix(source_dir).unwrap_or(&source_path); + let suffix = source_path.strip_prefix(&base).unwrap_or(&source_path); + let target = pattern.target_base.join(suffix); + + dotfiles.push(DotfileConfig { + source: rel_to_source.to_path_buf(), + target, + template: pattern.template, + permissions: pattern.permissions.clone(), + owner: pattern.owner.clone(), + deploy: pattern.deploy, + link_patterns: pattern.link_patterns.clone(), + copy_patterns: pattern.copy_patterns.clone(), + exclude_paths: vec![], + exclude_sources: vec![], + }); + } + } + dotfiles.len() - before +} + +/// Merges explicit dotfile blocks into glob-expanded entries. +/// +/// Three merge cases: +/// 1. Same target: explicit replaces glob-expanded entry entirely. +/// 2. Target inside directory target: adds the file's target to exclude_paths. +/// 3. Source inside directory source: adds the file's source to exclude_sources. +pub fn merge_specializations(dotfiles: &mut Vec, glob_count: usize) { + let total = dotfiles.len(); + let explicit_end = total - glob_count; + let glob_start = explicit_end; + + let mut glob_to_remove: HashSet = HashSet::new(); + + // First pass: find same-target replacements + for exp_idx in 0..explicit_end { + for glob_idx in glob_start..total { + if glob_to_remove.contains(&glob_idx) { + continue; + } + if dotfiles[exp_idx].target == dotfiles[glob_idx].target { + glob_to_remove.insert(glob_idx); + } + } + } + + // Second pass: collect exclusions for directory entries + for exp_idx in 0..explicit_end { + for glob_idx in glob_start..total { + if glob_to_remove.contains(&glob_idx) { + continue; + } + let exp_target = dotfiles[exp_idx].target.clone(); + let glob_target = &dotfiles[glob_idx].target; + if exp_target.starts_with(glob_target) && exp_target != *glob_target { + dotfiles[glob_idx].exclude_paths.push(exp_target); + } + let exp_source = dotfiles[exp_idx].source.clone(); + let glob_source = dotfiles[glob_idx].source.clone(); + if exp_source.starts_with(&glob_source) + && exp_source != glob_source + && let Ok(relative) = exp_source.strip_prefix(&glob_source) + { + dotfiles[glob_idx] + .exclude_sources + .push(relative.to_path_buf()); + } + } + } + + let mut remove_sorted: Vec = glob_to_remove.into_iter().collect(); + remove_sorted.sort_unstable_by(|a, b| b.cmp(a)); + for idx in remove_sorted { + dotfiles.remove(idx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use doot_core::evaluator::{DeployMode, DotfilesSource}; + + fn explicit(source: &str, target: &str) -> DotfileConfig { + DotfileConfig { + source: PathBuf::from(source), + target: PathBuf::from(target), + template: false, + permissions: vec![], + owner: None, + deploy: DeployMode::default(), + link_patterns: vec![], + copy_patterns: vec![], + exclude_paths: vec![], + exclude_sources: vec![], + } + } + + fn glob_pattern(pattern: &str, target_base: &str) -> DotfilesPattern { + DotfilesPattern { + source: DotfilesSource::Pattern(pattern.to_string()), + target_base: PathBuf::from(target_base), + template: false, + permissions: vec![], + owner: None, + deploy: DeployMode::default(), + link_patterns: vec![], + copy_patterns: vec![], + } + } + + #[test] + fn warns_when_explicit_target_is_glob_directory() { + let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin")]; + let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")]; + let warnings = directory_target_collisions(&dotfiles, &patterns); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("wb")); + assert!(warnings[0].contains("collide")); + } + + #[test] + fn no_warning_for_specific_file_target() { + let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin/wb")]; + let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")]; + assert!(directory_target_collisions(&dotfiles, &patterns).is_empty()); + } +} diff --git a/crates/doot-cli/src/commands/diff.rs b/crates/doot-cli/src/commands/diff.rs index f10570a..f6cd951 100644 --- a/crates/doot-cli/src/commands/diff.rs +++ b/crates/doot-cli/src/commands/diff.rs @@ -1,23 +1,15 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{find_config_file, load}; use doot_core::deploy::DiffDisplay; +use doot_core::evaluator::PermissionRule; use doot_core::state::{expected_mode_for_file, get_file_mode}; -use doot_lang::Evaluator; -use doot_lang::evaluator::PermissionRule; use std::path::{Path, PathBuf}; /// Shows diffs between source and deployed dotfiles. #[tracing::instrument(skip_all, fields(all))] pub fn run(config_path: Option, all: bool) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; + let (result, _vars) = load(&path)?; let mut has_changes = false; diff --git a/crates/doot-cli/src/commands/edit.rs b/crates/doot-cli/src/commands/edit.rs index 16c1b7e..a887d09 100644 --- a/crates/doot-cli/src/commands/edit.rs +++ b/crates/doot-cli/src/commands/edit.rs @@ -1,10 +1,9 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{find_config_file, load}; use doot_core::{ - Config, + Settings, deploy::{Linker, TemplateEngine}, state::{DeployMode, StateStore}, }; -use doot_lang::Evaluator; use std::collections::HashMap; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -19,16 +18,9 @@ pub fn run( skip_prompt: bool, ) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; - let mut template_vars = evaluator.get_template_variables(); - let config = Config::new(source_dir.clone()); + let (result, mut template_vars) = load(&path)?; + let config = Settings::new(source_dir.clone()); super::decrypt_encrypted_vars_with_source_dir( &result, @@ -107,13 +99,13 @@ fn hash_file(path: &PathBuf) -> String { fn apply_single( source: &PathBuf, target: &PathBuf, - dotfile: &doot_lang::evaluator::DotfileConfig, - config: &Config, - template_vars: &HashMap, + dotfile: &doot_core::evaluator::DotfileConfig, + config: &Settings, + template_vars: &HashMap, ) -> anyhow::Result<()> { let deploy_mode = match dotfile.deploy { - doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, - doot_lang::evaluator::DeployMode::Link => DeployMode::Link, + doot_core::evaluator::DeployMode::Copy => DeployMode::Copy, + doot_core::evaluator::DeployMode::Link => DeployMode::Link, }; let mut state = StateStore::new(&config.state_file); @@ -204,10 +196,10 @@ fn expand_tilde(path: &str) -> PathBuf { #[tracing::instrument(skip_all)] fn find_source_and_dotfile<'a>( target: &PathBuf, - dotfiles: &'a [doot_lang::evaluator::DotfileConfig], + dotfiles: &'a [doot_core::evaluator::DotfileConfig], source_dir: &Path, state: &StateStore, -) -> anyhow::Result<(PathBuf, Option<&'a doot_lang::evaluator::DotfileConfig>)> { +) -> anyhow::Result<(PathBuf, Option<&'a doot_core::evaluator::DotfileConfig>)> { // Exact match with dotfile targets for df in dotfiles { if &df.target == target { diff --git a/crates/doot-cli/src/commands/encrypt.rs b/crates/doot-cli/src/commands/encrypt.rs index dfc3e9f..a77374f 100644 --- a/crates/doot-cli/src/commands/encrypt.rs +++ b/crates/doot-cli/src/commands/encrypt.rs @@ -1,4 +1,4 @@ -use doot_core::{Config, encryption::AgeEncryption}; +use doot_core::{Settings, encryption::AgeEncryption}; use std::path::PathBuf; /// Encrypts a file using age encryption with multi-recipient support. @@ -12,7 +12,7 @@ pub fn run(file: PathBuf, recipients: Vec) -> anyhow::Result<()> { .filter(|l| !l.is_empty()) .collect() } else { - let key_file = Config::default_config_dir().join("recipient.txt"); + let key_file = Settings::default_config_dir().join("recipient.txt"); if key_file.exists() { std::fs::read_to_string(&key_file)? .lines() diff --git a/crates/doot-cli/src/commands/encrypt_var.rs b/crates/doot-cli/src/commands/encrypt_var.rs index dda7201..d2e09d8 100644 --- a/crates/doot-cli/src/commands/encrypt_var.rs +++ b/crates/doot-cli/src/commands/encrypt_var.rs @@ -1,5 +1,5 @@ -use doot_core::Config; -use doot_lang::builtins::crypto::base64_encode; +use doot_core::Settings; +use doot_core::builtins::crypto::base64_encode; use std::io::{self, Read, Write}; /// Resolves recipient keys from CLI flags, env var, or recipient.txt (supports multiple keys). @@ -12,7 +12,7 @@ fn resolve_recipients(recipients: Vec) -> anyhow::Result, check: bool) -> anyhow::Result<()> { let path = find_config_file(config_path)?; let source = std::fs::read_to_string(&path)?; - let formatted = format_source(&source); + let formatted = match doot_dotfile::format(&source) { + Ok(s) => s, + Err(diags) => { + for d in &diags { + eprintln!("{}:{}", path.display(), d.render(&source)); + } + anyhow::bail!("cannot format: {} parse error(s)", diags.len()); + } + }; if check { if formatted != source { let diff = DiffDisplay::diff_strings(&source, &formatted); eprintln!("{}\n{}", path.display(), diff); std::process::exit(1); - } else { - println!("{} is formatted correctly", path.display()); } + println!("{} is formatted correctly", path.display()); } else if formatted != source { std::fs::write(&path, &formatted)?; println!("formatted {}", path.display()); @@ -27,208 +36,3 @@ pub fn run(config_path: Option, check: bool) -> anyhow::Result<()> { Ok(()) } - -#[tracing::instrument(level = "trace", skip_all)] -fn format_source(source: &str) -> String { - // Use an indent stack to track nesting levels from raw whitespace. - // When indentation increases, push a new level; when it decreases, - // pop back. This handles files with inconsistent indent widths. - let mut result = String::new(); - let mut prev_was_blank = false; - let mut indent_stack: Vec = vec![0]; // raw whitespace widths - - for line in source.lines() { - let trimmed = line.trim(); - - if trimmed.is_empty() { - if !prev_was_blank { - result.push('\n'); - prev_was_blank = true; - } - continue; - } - prev_was_blank = false; - - let leading = line.len() - line.trim_start().len(); - - if leading > *indent_stack.last().unwrap() { - // Deeper nesting - indent_stack.push(leading); - } else if leading < *indent_stack.last().unwrap() { - // Dedent: pop until we find a level <= current - while indent_stack.len() > 1 && *indent_stack.last().unwrap() > leading { - indent_stack.pop(); - } - } - - let level = indent_stack.len() - 1; - result.push_str(&" ".repeat(level)); - result.push_str(trimmed); - result.push('\n'); - } - - // Trim trailing blank lines - while result.ends_with("\n\n") { - result.pop(); - } - - if !result.ends_with('\n') { - result.push('\n'); - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_preserves_top_level_blocks() { - let input = "\ -dotfile: - src: fish - dst: ~/.config/fish - -dotfile: - src: nvim - dst: ~/.config/nvim - -dotfile: - src: git - dst: ~/.config/git -"; - let result = format_source(input); - assert_eq!(result, input); - } - - #[test] - fn test_normalizes_4space_to_2space() { - let input = "\ -dotfile: - src: fish - dst: ~/.config/fish -"; - let expected = "\ -dotfile: - src: fish - dst: ~/.config/fish -"; - assert_eq!(format_source(input), expected); - } - - #[test] - fn test_nested_blocks() { - let input = "\ -dotfile: - src: fish - if os == \"linux\": - package: apt - else: - package: brew -"; - let result = format_source(input); - assert_eq!(result, input); - } - - #[test] - fn test_collapses_consecutive_blank_lines() { - let input = "\ -dotfile: - src: fish - - - dst: ~/.config/fish -"; - let expected = "\ -dotfile: - src: fish - - dst: ~/.config/fish -"; - assert_eq!(format_source(input), expected); - } - - #[test] - fn test_trailing_blank_lines_trimmed() { - let input = "\ -dotfile: - src: fish - - -"; - let expected = "\ -dotfile: - src: fish -"; - assert_eq!(format_source(input), expected); - } - - #[test] - fn test_comments_preserve_indent() { - let input = "\ -# top-level comment -dotfile: - # nested comment - src: fish -"; - let result = format_source(input); - assert_eq!(result, input); - } - - #[test] - fn test_no_indented_lines() { - let input = "\ -one -two -three -"; - let result = format_source(input); - assert_eq!(result, input); - } - - #[test] - fn test_mixed_indent_normalizes() { - let input = "\ -dotfile: - src: fish - if cond: - package: apt -"; - let expected = "\ -dotfile: - src: fish - if cond: - package: apt -"; - assert_eq!(format_source(input), expected); - } - - #[test] - fn test_inconsistent_indent_widths() { - // Mix of 6-space, 2-space, and 4-space indentation (GCD = 2) - let input = "\ -dotfile: - source = \"config/*\" - target = config_dir() - -if cond: - package: \"fish\" - -if other: - package: \"bat\" -"; - let expected = "\ -dotfile: - source = \"config/*\" - target = config_dir() - -if cond: - package: \"fish\" - -if other: - package: \"bat\" -"; - assert_eq!(format_source(input), expected); - } -} diff --git a/crates/doot-cli/src/commands/init.rs b/crates/doot-cli/src/commands/init.rs index 72a7309..4f366c9 100644 --- a/crates/doot-cli/src/commands/init.rs +++ b/crates/doot-cli/src/commands/init.rs @@ -1,12 +1,12 @@ -use doot_core::Config; +use doot_core::Settings; use std::path::{Path, PathBuf}; /// Initializes a new doot project directory structure. #[tracing::instrument(skip_all)] pub fn run(path: Option) -> anyhow::Result<()> { - let source_dir = path.unwrap_or_else(Config::default_source_dir); - let config = Config::new(source_dir.clone()); - let is_default = source_dir == Config::default_config_dir(); + let source_dir = path.unwrap_or_else(Settings::default_source_dir); + let config = Settings::new(source_dir.clone()); + let is_default = source_dir == Settings::default_config_dir(); tracing::debug!( config_dir = %config.config_dir.display(), diff --git a/crates/doot-cli/src/commands/keygen.rs b/crates/doot-cli/src/commands/keygen.rs index b3d16ca..bdd3bfc 100644 --- a/crates/doot-cli/src/commands/keygen.rs +++ b/crates/doot-cli/src/commands/keygen.rs @@ -1,11 +1,11 @@ -use doot_core::{Config, encryption::AgeEncryption}; +use doot_core::{Settings, encryption::AgeEncryption}; use std::io::{self, Write}; use std::path::PathBuf; /// Generates an age keypair, writing identity to identity.txt and appending public key to recipient.txt. #[tracing::instrument(skip_all)] pub fn run(output: Option, force: bool) -> anyhow::Result<()> { - let config_dir = Config::default_config_dir(); + let config_dir = Settings::default_config_dir(); let identity_file = output.unwrap_or_else(|| config_dir.join("identity.txt")); let recipient_file = identity_file .parent() diff --git a/crates/doot-cli/src/commands/mod.rs b/crates/doot-cli/src/commands/mod.rs index 0ca0eb6..4aa91ed 100644 --- a/crates/doot-cli/src/commands/mod.rs +++ b/crates/doot-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod check; pub mod decrypt; pub mod decrypt_entries; pub mod decrypt_var; +pub mod deploy_util; pub mod diff; pub mod edit; pub mod encrypt; @@ -14,19 +15,59 @@ pub mod init; pub mod keygen; pub mod lsp; pub mod package; +pub mod plan; pub mod reencrypt; pub mod rollback; pub mod snapshot; pub mod status; pub mod tui; -use doot_core::Config; -use doot_lang::evaluator::{EvalResult, Value}; -use doot_lang::{Lexer, Parser, TypeChecker}; +use doot_core::Settings; +use doot_core::evaluator::{EvalResult, Value}; use indexmap::IndexMap; use std::collections::HashMap; use std::path::PathBuf; +/// Load a config file: parse, type-check, and evaluate to an +/// `EvalResult` plus template variables. Type errors abort. +#[tracing::instrument(skip_all, fields(path = %path.display()))] +pub fn load(path: &PathBuf) -> anyhow::Result<(EvalResult, HashMap)> { + let source = std::fs::read_to_string(path)?; + let (result, vars, errors) = doot_dotfile::compile_eval_result(&source); + if !errors.is_empty() { + for e in &errors { + eprintln!("{}:{}", path.display(), e.render(&source)); + } + anyhow::bail!("{} error(s) found", errors.len()); + } + Ok((result, vars)) +} + +/// Build the environment exposed to hook scripts: string-valued template vars +/// plus the DOOT_* globals. +pub fn hook_env( + vars: &HashMap, + source_dir: &std::path::Path, +) -> HashMap { + let mut env = HashMap::new(); + for (k, v) in vars { + if let Value::Str(s) = v { + env.insert(k.clone(), s.clone()); + } + } + env.insert( + "DOOT_HOME".to_string(), + std::env::var("HOME").unwrap_or_default(), + ); + env.insert( + "DOOT_CONFIG_DIR".to_string(), + source_dir.display().to_string(), + ); + env.insert("DOOT_OS".to_string(), std::env::consts::OS.to_string()); + env.insert("DOOT_ARCH".to_string(), std::env::consts::ARCH.to_string()); + env +} + /// Resolves the config file path, checking the given path or default locations. /// Always returns an absolute path so source_dir is correct regardless of CWD. #[tracing::instrument(skip_all)] @@ -38,7 +79,7 @@ pub fn find_config_file(base: Option) -> anyhow::Result { anyhow::bail!("config file not found: {}", path.display()); } - let candidates = vec![PathBuf::from("doot.doot"), Config::default_config_file()]; + let candidates = vec![PathBuf::from("doot.doot"), Settings::default_config_file()]; for candidate in candidates { if candidate.exists() { @@ -48,70 +89,14 @@ pub fn find_config_file(base: Option) -> anyhow::Result { anyhow::bail!( "no config file found. searched:\n - ./doot.doot\n - {}", - Config::default_config_file().display() + Settings::default_config_file().display() ) } -fn byte_offset_to_line(source: &str, offset: usize) -> usize { - source[..offset.min(source.len())] - .chars() - .filter(|&c| c == '\n') - .count() - + 1 -} - -/// Parses a `.doot` config file into a program AST. -#[tracing::instrument(skip_all, fields(path = %path.display()))] -pub fn parse_config(path: &PathBuf) -> anyhow::Result { - let source = std::fs::read_to_string(path)?; - let tokens = Lexer::lex(&source).map_err(|errs| { - let msg = errs - .iter() - .map(|e| { - let line = byte_offset_to_line(&source, e.span().start); - format!("{}:{}: {}", path.display(), line, e) - }) - .collect::>() - .join("\n"); - anyhow::anyhow!("lexer errors:\n{}", msg) - })?; - - let program = Parser::parse(tokens).map_err(|errs| { - let msg = errs - .iter() - .map(|e| { - let line = byte_offset_to_line(&source, e.span().start); - format!("{}:{}: {}", path.display(), line, e) - }) - .collect::>() - .join("\n"); - anyhow::anyhow!("parser errors:\n{}", msg) - })?; - - Ok(program) -} - -/// Runs the type checker on a parsed program. -#[tracing::instrument(skip_all)] -pub fn type_check( - program: &doot_lang::Program, - source: &str, - filename: &str, -) -> anyhow::Result<()> { - let mut checker = TypeChecker::new(); - if let Err(errors) = checker.check(program) { - for error in &errors { - error.report(source, filename); - } - anyhow::bail!("{} type error(s) found", errors.len()); - } - Ok(()) -} - /// Decrypts encrypted vars and files, resolving file paths relative to `source_dir`. pub fn decrypt_encrypted_vars_with_source_dir( result: &EvalResult, - config: &Config, + config: &Settings, template_vars: &mut HashMap, source_dir: Option<&std::path::Path>, ) -> anyhow::Result<()> { @@ -145,7 +130,7 @@ pub fn decrypt_encrypted_vars_with_source_dir( // Decrypt inline vars for (name, ciphertext_b64) in &result.encrypted_vars { - let encrypted_bytes = doot_lang::builtins::crypto::base64_decode(ciphertext_b64) + let encrypted_bytes = doot_core::builtins::crypto::base64_decode(ciphertext_b64) .map_err(|e| anyhow::anyhow!("invalid base64 for encrypted var '{}': {}", name, e))?; let decryptor = match age::Decryptor::new(&encrypted_bytes[..]) diff --git a/crates/doot-cli/src/commands/package.rs b/crates/doot-cli/src/commands/package.rs index 5a3cf1a..177a21f 100644 --- a/crates/doot-cli/src/commands/package.rs +++ b/crates/doot-cli/src/commands/package.rs @@ -1,19 +1,11 @@ -use super::{find_config_file, parse_config, type_check}; -use doot_lang::Evaluator; +use super::{find_config_file, load}; use std::path::PathBuf; /// Installs packages defined in the config. #[tracing::instrument(skip_all)] pub fn install(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - - let mut evaluator = Evaluator::new().with_source_dir(source_dir); - let result = evaluator.eval_sync(&program)?; + let (result, _vars) = load(&path)?; if result.packages.is_empty() { println!("no packages configured"); @@ -72,14 +64,7 @@ pub fn update() -> anyhow::Result<()> { #[tracing::instrument(skip_all)] pub fn list(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - - let mut evaluator = Evaluator::new().with_source_dir(source_dir); - let result = evaluator.eval_sync(&program)?; + let (result, _vars) = load(&path)?; if result.packages.is_empty() { println!("no packages configured"); diff --git a/crates/doot-cli/src/commands/plan.rs b/crates/doot-cli/src/commands/plan.rs new file mode 100644 index 0000000..982ee80 --- /dev/null +++ b/crates/doot-cli/src/commands/plan.rs @@ -0,0 +1,68 @@ +use super::find_config_file; +use doot_core::evaluator::DotfilesSource; +use doot_dotfile::Task; +use std::path::PathBuf; + +/// Show the inferred dependency DAG: tasks grouped into topological layers, where +/// each layer is independent (parallelizable) and runs only after earlier layers. +/// Read-only; performs no filesystem changes. +#[tracing::instrument(skip_all)] +pub fn run(config_path: Option) -> anyhow::Result<()> { + let path = find_config_file(config_path)?; + let source = std::fs::read_to_string(&path)?; + let (plan, errors) = doot_dotfile::compile_exec_plan(&source); + if !errors.is_empty() { + for e in &errors { + eprintln!("{}:{}", path.display(), e.render(&source)); + } + anyhow::bail!("{} error(s) found", errors.len()); + } + + println!( + "execution plan: {} tasks in {} layer(s)", + plan.len(), + plan.layers.len() + ); + for (i, layer) in plan.layers.iter().enumerate() { + println!("\nlayer {i} ({} parallel):", layer.len()); + for task in layer { + println!(" {}", describe(task)); + } + } + Ok(()) +} + +fn describe(task: &Task) -> String { + match task { + Task::Dotfile(d) => format!("dotfile {} -> {}", d.source.display(), d.target.display()), + Task::DotfilePattern(p) => { + let src = match &p.source { + DotfilesSource::Pattern(s) => s.clone(), + DotfilesSource::Paths(ps) => format!("{} path(s)", ps.len()), + }; + format!("dotfiles {} -> {}", src, p.target_base.display()) + } + Task::Package(p) => { + let name = p + .default + .clone() + .or_else(|| p.brew.clone()) + .or_else(|| p.cask.clone()) + .or_else(|| p.apt.clone()) + .or_else(|| p.pacman.clone()) + .or_else(|| p.yay.clone()) + .or_else(|| p.xbps.clone()) + .unwrap_or_else(|| "?".into()); + format!("package {name}") + } + Task::Hook(h) => { + let first = h.run.lines().next().unwrap_or("").trim(); + format!("hook [{:?}] {}", h.stage, first) + } + Task::Secret(s) => format!("secret {}", s.target.display()), + Task::Tap(n) => format!("tap {n}"), + Task::Formula(n) => format!("formula {n}"), + Task::EncVar { key, .. } => format!("enc-var {key}"), + Task::EncFile { key, .. } => format!("enc-file {key}"), + } +} diff --git a/crates/doot-cli/src/commands/reencrypt.rs b/crates/doot-cli/src/commands/reencrypt.rs index 24cbba1..3170d3d 100644 --- a/crates/doot-cli/src/commands/reencrypt.rs +++ b/crates/doot-cli/src/commands/reencrypt.rs @@ -1,4 +1,4 @@ -use doot_core::{Config, encryption::AgeEncryption}; +use doot_core::{Settings, encryption::AgeEncryption}; use std::path::PathBuf; /// Re-encrypts all .age files in the secrets directory with current recipients. @@ -7,7 +7,7 @@ use std::path::PathBuf; pub fn run(config_path: Option, recipients: Vec) -> anyhow::Result<()> { let path = super::find_config_file(config_path)?; let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - let config = Config::new(source_dir.clone()); + let config = Settings::new(source_dir.clone()); // Resolve identity for decryption let identity_raw = if let Ok(key) = std::env::var("DOOT_AGE_IDENTITY") { @@ -36,7 +36,7 @@ pub fn run(config_path: Option, recipients: Vec) -> anyhow::Res .filter(|l| !l.is_empty()) .collect() } else { - let key_file = Config::default_config_dir().join("recipient.txt"); + let key_file = Settings::default_config_dir().join("recipient.txt"); if key_file.exists() { std::fs::read_to_string(&key_file)? .lines() @@ -56,9 +56,7 @@ pub fn run(config_path: Option, recipients: Vec) -> anyhow::Res } // Also re-encrypt encrypted vars from the doot config - let program = super::parse_config(&path)?; - let mut evaluator = doot_lang::Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; + let (result, _vars) = super::load(&path)?; let mut count = 0; diff --git a/crates/doot-cli/src/commands/rollback.rs b/crates/doot-cli/src/commands/rollback.rs index 074d923..55ce386 100644 --- a/crates/doot-cli/src/commands/rollback.rs +++ b/crates/doot-cli/src/commands/rollback.rs @@ -1,5 +1,5 @@ use doot_core::{ - Config, + Settings, state::{DeployMode, Snapshot}, }; use std::path::PathBuf; @@ -7,7 +7,7 @@ use std::path::PathBuf; /// Rolls back to a previous snapshot. #[tracing::instrument(skip_all)] pub fn run(_config_path: Option, snapshot_name: Option) -> anyhow::Result<()> { - let config = Config::default(); + let config = Settings::default(); let name = if let Some(n) = snapshot_name { if n == "last" || n == "latest" { diff --git a/crates/doot-cli/src/commands/snapshot.rs b/crates/doot-cli/src/commands/snapshot.rs index 7e5cf9f..4906a6c 100644 --- a/crates/doot-cli/src/commands/snapshot.rs +++ b/crates/doot-cli/src/commands/snapshot.rs @@ -1,5 +1,5 @@ use doot_core::{ - Config, + Settings, state::{Snapshot, StateStore}, }; use std::path::PathBuf; @@ -7,7 +7,7 @@ use std::path::PathBuf; /// Creates a named snapshot of the current deployment state. #[tracing::instrument(skip_all, fields(name = %name))] pub fn run(_config_path: Option, name: String) -> anyhow::Result<()> { - let config = Config::default(); + let config = Settings::default(); config.ensure_dirs()?; let mut state = StateStore::new(&config.state_file); diff --git a/crates/doot-cli/src/commands/status.rs b/crates/doot-cli/src/commands/status.rs index 6449ef6..21a3c9b 100644 --- a/crates/doot-cli/src/commands/status.rs +++ b/crates/doot-cli/src/commands/status.rs @@ -1,33 +1,23 @@ use super::{ - apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file, - parse_config, type_check, + decrypt_encrypted_vars_with_source_dir, deploy_util::template_outdated, find_config_file, load, }; -use doot_core::Config; +use doot_core::Settings; use doot_core::deploy::TemplateEngine; use doot_core::state::{StateStore, SyncStatus}; -use doot_lang::Evaluator; use std::path::PathBuf; /// Shows the deployment status of managed dotfiles. #[tracing::instrument(skip_all)] pub fn run(config_path: Option) -> anyhow::Result<()> { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; + let (result, mut template_vars) = load(&path)?; // Prepare template variables early for preview rendering - let config = Config::new(source_dir.clone()); + let config = Settings::new(source_dir.clone()); let state_file = config.state_file.clone(); let state = StateStore::new(&state_file); - let mut template_vars = evaluator.get_template_variables(); decrypt_encrypted_vars_with_source_dir( &result, &config, diff --git a/crates/doot-cli/src/commands/tui.rs b/crates/doot-cli/src/commands/tui.rs index 88d788b..ef94053 100644 --- a/crates/doot-cli/src/commands/tui.rs +++ b/crates/doot-cli/src/commands/tui.rs @@ -1,13 +1,12 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{find_config_file, load}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use doot_core::config::Config; +use doot_core::config::Settings; use doot_core::deploy::Linker; use doot_core::state::{DeployMode, StateStore}; -use doot_lang::Evaluator; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, @@ -113,16 +112,10 @@ impl App { #[tracing::instrument(skip_all)] fn new(config_path: Option) -> anyhow::Result { let path = find_config_file(config_path)?; - let source = std::fs::read_to_string(&path)?; - let program = parse_config(&path)?; - type_check(&program, &source, &path.display().to_string())?; - let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + let (result, _vars) = load(&path)?; - let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); - let result = evaluator.eval_sync(&program)?; - - let config = Config::new(source_dir.clone()); + let config = Settings::new(source_dir.clone()); let state = StateStore::new(&config.state_file); let dotfiles: Vec = result @@ -131,8 +124,8 @@ impl App { .map(|d| { let full_source = source_dir.join(&d.source); let deploy_mode = match d.deploy { - doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, - doot_lang::evaluator::DeployMode::Link => DeployMode::Link, + doot_core::evaluator::DeployMode::Copy => DeployMode::Copy, + doot_core::evaluator::DeployMode::Link => DeployMode::Link, }; let status = if !full_source.exists() { @@ -440,7 +433,7 @@ impl App { self.apply_progress = 0; // Apply dotfiles - let config = Config::new(self.source_dir.clone()); + let config = Settings::new(self.source_dir.clone()); let linker = Linker::new(config.clone()); let mut state = StateStore::new(&config.state_file); diff --git a/crates/doot-cli/src/main.rs b/crates/doot-cli/src/main.rs index 95edb7e..1795841 100644 --- a/crates/doot-cli/src/main.rs +++ b/crates/doot-cli/src/main.rs @@ -96,6 +96,9 @@ enum Commands { /// Validate config (parse + type check): `doot check` Check, + /// Show the inferred dependency DAG and execution order: `doot plan` + Plan, + /// Format config file: `doot fmt [--check]` Fmt { /// Check formatting without modifying (exits 1 if unformatted) @@ -290,6 +293,7 @@ fn main() -> anyhow::Result<()> { Commands::Diff { all } => commands::diff::run(cli.config, all), Commands::Status => commands::status::run(cli.config), Commands::Check => commands::check::run(cli.config), + Commands::Plan => commands::plan::run(cli.config), Commands::Fmt { check } => commands::fmt::run(cli.config, check), Commands::Rollback { snapshot } => commands::rollback::run(cli.config, snapshot), Commands::Snapshot { name } => commands::snapshot::run(cli.config, name), diff --git a/crates/doot-cli/tests/e2e.rs b/crates/doot-cli/tests/e2e.rs index 36d0909..d5656ec 100644 --- a/crates/doot-cli/tests/e2e.rs +++ b/crates/doot-cli/tests/e2e.rs @@ -101,12 +101,7 @@ fn test_init_creates_structure() { #[test] fn test_check_valid_config() { let sandbox = Sandbox::new("check-valid"); - sandbox.write_config( - r#" -package: "ripgrep" -package: "fd" -"#, - ); + sandbox.write_config(r#"Config { packages = [ (package "ripgrep") (package "fd") ]; }"#); let output = sandbox.run(&["check"]); assert!(output.status.success(), "check failed: {:?}", output); @@ -116,11 +111,7 @@ package: "fd" fn test_apply_dry_run() { let sandbox = Sandbox::new("apply-dry"); sandbox.write_config( - r#" -dotfile: - source = "config/test.conf" - target = "~/.config/test/test.conf" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/test.conf"; target = "~/.config/test/test.conf"; }) ]; }"#, ); sandbox.write_source("config/test.conf", "test content"); @@ -135,12 +126,7 @@ dotfile: fn test_apply_creates_symlink() { let sandbox = Sandbox::new("apply-symlink"); sandbox.write_config( - r#" -dotfile: - source = "config/app.conf" - target = "~/.config/app/app.conf" - deploy = "link" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; deploy = "link"; }) ]; }"#, ); sandbox.write_source("config/app.conf", "app config content"); @@ -162,7 +148,7 @@ dotfile: fn test_apply_unchanged_on_rerun() { let sandbox = Sandbox::new("apply-unchanged"); sandbox.write_config( - "dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n", + "Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; deploy = \"link\"; }) ]; }", ); sandbox.write_source("config/app.conf", "content"); @@ -184,11 +170,7 @@ fn test_apply_unchanged_on_rerun() { fn test_apply_creates_copy() { let sandbox = Sandbox::new("apply-copy"); sandbox.write_config( - r#" -dotfile: - source = "config/app.conf" - target = "~/.config/app/app.conf" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#, ); sandbox.write_source("config/app.conf", "app config content"); @@ -210,7 +192,7 @@ dotfile: fn test_apply_copy_unchanged_on_rerun() { let sandbox = Sandbox::new("apply-copy-unchanged"); sandbox.write_config( - "dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n", + "Config { dotfiles = [ (dotfile { source = \"config/app.conf\"; target = \"~/.config/app/app.conf\"; }) ]; }", ); sandbox.write_source("config/app.conf", "content"); @@ -229,12 +211,7 @@ fn test_apply_copy_unchanged_on_rerun() { fn test_template_redeploys_when_env_changes() { let sandbox = Sandbox::new("template-env-change"); sandbox.write_config( - r#" -dotfile: - source = "templates/app.conf" - target = "~/.config/app/app.conf" - template = true -"#, + r#"Config { dotfiles = [ (dotfile { source = "templates/app.conf"; target = "~/.config/app/app.conf"; template = true; }) ]; }"#, ); sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n"); @@ -274,11 +251,7 @@ dotfile: fn test_status_shows_state() { let sandbox = Sandbox::new("status"); sandbox.write_config( - r#" -dotfile: - source = "config/app.conf" - target = "~/.config/app/app.conf" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#, ); sandbox.write_source("config/app.conf", "content"); sandbox.run(&["apply"]); @@ -291,11 +264,7 @@ dotfile: fn test_snapshot_and_rollback() { let sandbox = Sandbox::new("snapshot"); sandbox.write_config( - r#" -dotfile: - source = "config/app.conf" - target = "~/.config/app/app.conf" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#, ); sandbox.write_source("config/app.conf", "v1"); sandbox.run(&["apply"]); @@ -316,11 +285,7 @@ fn test_dotfile_with_when_condition() { let sandbox = Sandbox::new("conditional"); // Test that 'when' condition works - only deploy if condition is true - let config = r#"dotfile: - source = "config/test.conf" - target = "~/.config/test.conf" - when = true -"#; + let config = r#"Config { dotfiles = optionals true [ (dotfile { source = "config/test.conf"; target = "~/.config/test.conf"; }) ]; }"#; sandbox.write_config(config); sandbox.write_source("config/test.conf", "test content"); @@ -338,11 +303,7 @@ fn test_dotfile_with_when_condition() { fn test_dotfile_when_false_skips() { let sandbox = Sandbox::new("when-false"); - let config = r#"dotfile: - source = "config/skip.conf" - target = "~/.config/skip.conf" - when = false -"#; + let config = r#"Config { dotfiles = optionals false [ (dotfile { source = "config/skip.conf"; target = "~/.config/skip.conf"; }) ]; }"#; sandbox.write_config(config); sandbox.write_source("config/skip.conf", "should not deploy"); @@ -360,11 +321,7 @@ fn test_dotfile_when_false_skips() { fn test_diff_shows_changes() { let sandbox = Sandbox::new("diff"); sandbox.write_config( - r#" -dotfile: - source = "config/app.conf" - target = "~/.config/app/app.conf" -"#, + r#"Config { dotfiles = [ (dotfile { source = "config/app.conf"; target = "~/.config/app/app.conf"; }) ]; }"#, ); sandbox.write_source("config/app.conf", "new content"); @@ -696,11 +653,7 @@ fn test_reencrypt_age_files() { ) .unwrap(); - sandbox.write_config( - r#" -package: "git" -"#, - ); + sandbox.write_config(r#"Config { packages = [ (package "git") ]; }"#); // Write the first identity back (reencrypt needs it to decrypt) std::fs::write(sandbox.config_dir().join("identity.txt"), &identity_content).unwrap(); @@ -746,7 +699,7 @@ package: "git" #[test] fn test_reencrypt_no_identity_fails() { let sandbox = Sandbox::new("reencrypt-no-id"); - sandbox.write_config("package: \"git\"\n"); + sandbox.write_config("Config { packages = [ (package \"git\") ]; }"); let output = sandbox.run(&[ "reencrypt", @@ -769,7 +722,7 @@ fn test_reencrypt_no_age_files_reports_zero() { .trim() .to_string(); - sandbox.write_config("package: \"git\"\n"); + sandbox.write_config("Config { packages = [ (package \"git\") ]; }"); let output = sandbox.run(&["reencrypt", "--recipient", &pubkey]); assert!(output.status.success(), "reencrypt failed: {:?}", output); diff --git a/crates/doot-core/Cargo.toml b/crates/doot-core/Cargo.toml index a0e523f..3429414 100644 --- a/crates/doot-core/Cargo.toml +++ b/crates/doot-core/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true [dependencies] doot-utils.workspace = true -doot-lang.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true @@ -20,7 +19,11 @@ anyhow.workspace = true hostname = "0.4" regex-lite = "0.1" glob = "0.3" +indexmap = "2" rayon.workspace = true minijinja = { version = "2", features = ["builtins"] } which = "7" tracing.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/doot-core/src/builtins/crypto.rs b/crates/doot-core/src/builtins/crypto.rs new file mode 100644 index 0000000..db18e14 --- /dev/null +++ b/crates/doot-core/src/builtins/crypto.rs @@ -0,0 +1,67 @@ +//! Pure base64 helpers used by the CLI for encrypted-var handling. + +pub fn base64_encode(data: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + + for chunk in data.chunks(3) { + let b0 = chunk[0] as usize; + let b1 = chunk.get(1).copied().unwrap_or(0) as usize; + let b2 = chunk.get(2).copied().unwrap_or(0) as usize; + + result.push(ALPHABET[b0 >> 2] as char); + result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char); + + if chunk.len() > 1 { + result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char); + } else { + result.push('='); + } + + if chunk.len() > 2 { + result.push(ALPHABET[b2 & 0x3f] as char); + } else { + result.push('='); + } + } + + result +} + +pub fn base64_decode(s: &str) -> Result, String> { + const DECODE: [i8; 256] = { + let mut table = [-1i8; 256]; + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut i = 0; + while i < 64 { + table[alphabet[i] as usize] = i as i8; + i += 1; + } + table + }; + + let s = s.trim_end_matches('='); + let mut result = Vec::with_capacity(s.len() * 3 / 4); + + let chars: Vec = s.bytes().collect(); + for chunk in chars.chunks(4) { + let mut buf = [0u8; 4]; + for (i, &c) in chunk.iter().enumerate() { + let val = DECODE[c as usize]; + if val < 0 { + return Err(format!("invalid base64 character: {}", c as char)); + } + buf[i] = val as u8; + } + + result.push((buf[0] << 2) | (buf[1] >> 4)); + if chunk.len() > 2 { + result.push((buf[1] << 4) | (buf[2] >> 2)); + } + if chunk.len() > 3 { + result.push((buf[2] << 6) | buf[3]); + } + } + + Ok(result) +} diff --git a/crates/doot-core/src/builtins/mod.rs b/crates/doot-core/src/builtins/mod.rs new file mode 100644 index 0000000..1a7e5ac --- /dev/null +++ b/crates/doot-core/src/builtins/mod.rs @@ -0,0 +1,3 @@ +//! Pure utility helpers retained from the language runtime. + +pub mod crypto; diff --git a/crates/doot-core/src/config.rs b/crates/doot-core/src/config.rs index 84f9a11..a6016c2 100644 --- a/crates/doot-core/src/config.rs +++ b/crates/doot-core/src/config.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; /// Doot runtime configuration. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { +pub struct Settings { /// Directory containing dotfile sources. pub source_dir: PathBuf, /// Doot configuration directory. @@ -26,7 +26,7 @@ pub struct Config { pub verbose: bool, } -impl Config { +impl Settings { /// Creates a new config with the given source directory. #[tracing::instrument(skip_all, fields(source_dir = %source_dir.display()))] pub fn new(source_dir: PathBuf) -> Self { @@ -101,7 +101,7 @@ impl Config { } } -impl Default for Config { +impl Default for Settings { fn default() -> Self { Self::new(Self::default_source_dir()) } diff --git a/crates/doot-core/src/deploy/linker.rs b/crates/doot-core/src/deploy/linker.rs index eff0728..dfe3468 100644 --- a/crates/doot-core/src/deploy/linker.rs +++ b/crates/doot-core/src/deploy/linker.rs @@ -1,18 +1,18 @@ //! Symlink management. use super::{DeployAction, DeployError}; -use crate::config::Config; +use crate::config::Settings; use std::path::PathBuf; /// Creates and manages symlinks. pub struct Linker { - config: Config, + config: Settings, } impl Linker { /// Creates a new linker. #[tracing::instrument(skip_all)] - pub fn new(config: Config) -> Self { + pub fn new(config: Settings) -> Self { Self { config } } diff --git a/crates/doot-core/src/deploy/mod.rs b/crates/doot-core/src/deploy/mod.rs index 5fe4874..492a8c0 100644 --- a/crates/doot-core/src/deploy/mod.rs +++ b/crates/doot-core/src/deploy/mod.rs @@ -4,10 +4,10 @@ pub mod diff; pub mod linker; pub mod template; -use crate::config::Config; +use crate::config::Settings; +use crate::evaluator::DotfileConfig; use crate::state::StateStore; use crate::state::store::DeployMode; -use doot_lang::evaluator::DotfileConfig; use glob::Pattern; use indicatif::ProgressBar; use rayon::prelude::*; @@ -96,7 +96,7 @@ pub struct DeployErrorInfo { /// Handles dotfile deployment. pub struct Deployer { - config: Arc, + config: Arc, linker: Arc, template_engine: Arc, state: Arc>, @@ -107,9 +107,9 @@ impl Deployer { /// Creates a new deployer. #[tracing::instrument(skip_all)] pub fn new( - config: Config, + config: Settings, sandbox: bool, - template_vars: Option<&std::collections::HashMap>, + template_vars: Option<&std::collections::HashMap>, ) -> Self { let state = StateStore::new(&config.state_file); let linker = Linker::new(config.clone()); @@ -132,7 +132,7 @@ impl Deployer { return Ok(()); } - let home = crate::config::Config::home_dir(); + let home = crate::config::Settings::home_dir(); let target_canonical = target .canonicalize() .unwrap_or_else(|_| target.to_path_buf()); @@ -497,8 +497,8 @@ impl Deployer { .to_string(); let base_mode = match dotfile.deploy { - doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, - doot_lang::evaluator::DeployMode::Link => DeployMode::Link, + crate::evaluator::DeployMode::Copy => DeployMode::Copy, + crate::evaluator::DeployMode::Link => DeployMode::Link, }; tracing::trace!(mode = ?base_mode, "resolved deploy mode"); @@ -637,7 +637,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -use doot_lang::evaluator::PermissionRule; +use crate::evaluator::PermissionRule; #[tracing::instrument(level = "trace", skip_all)] fn apply_permissions(target: &Path, rules: &[PermissionRule]) -> Result<(), DeployError> { diff --git a/crates/doot-core/src/deploy/template.rs b/crates/doot-core/src/deploy/template.rs index 9a82bcc..755c438 100644 --- a/crates/doot-core/src/deploy/template.rs +++ b/crates/doot-core/src/deploy/template.rs @@ -31,7 +31,7 @@ impl TemplateEngine { } /// Sets multiple variables from doot evaluator values. - pub fn set_doot_variables(&mut self, vars: &HashMap) { + pub fn set_doot_variables(&mut self, vars: &HashMap) { for (key, value) in vars { self.variables .insert(key.clone(), doot_value_to_minijinja(value)); @@ -444,8 +444,8 @@ fn json_to_minijinja(json: &serde_json::Value) -> Value { } /// Converts a doot evaluator Value to a minijinja Value. -fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value { - use doot_lang::evaluator::Value as DootValue; +fn doot_value_to_minijinja(val: &crate::evaluator::Value) -> Value { + use crate::evaluator::Value as DootValue; match val { DootValue::Int(n) => Value::from(*n), DootValue::Float(n) => Value::from(*n), @@ -465,7 +465,6 @@ fn doot_value_to_minijinja(val: &doot_lang::evaluator::Value) -> Value { } DootValue::Enum(_, variant) => Value::from(variant.as_str()), DootValue::None => Value::UNDEFINED, - _ => Value::UNDEFINED, // Function, Lambda, Future } } diff --git a/crates/doot-core/src/evaluator.rs b/crates/doot-core/src/evaluator.rs new file mode 100644 index 0000000..a847e63 --- /dev/null +++ b/crates/doot-core/src/evaluator.rs @@ -0,0 +1,216 @@ +//! Shared evaluated-config data types: the target the language compiles to, +//! consumed by the deploy layer. + +use indexmap::IndexMap; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Hook execution stage. +#[derive(Clone, Debug, PartialEq)] +pub enum HookStage { + BeforeDeploy, + AfterDeploy, + BeforePackage, + AfterPackage, +} + +/// Runtime value (template/data values exposed to the deploy layer). +#[derive(Clone, Debug)] +pub enum Value { + Int(i64), + Float(f64), + Str(String), + Bool(bool), + Path(PathBuf), + List(Vec), + Struct(String, IndexMap), + Enum(String, String), + None, +} + +impl Value { + /// Returns the type name as a string. + pub fn type_name(&self) -> &'static str { + match self { + Value::Int(_) => "int", + Value::Float(_) => "float", + Value::Str(_) => "str", + Value::Bool(_) => "bool", + Value::Path(_) => "path", + Value::List(_) => "list", + Value::Struct(_, _) => "struct", + Value::Enum(_, _) => "enum", + Value::None => "none", + } + } + + /// Returns true for truthy values in conditionals. + pub fn is_truthy(&self) -> bool { + match self { + Value::Bool(b) => *b, + Value::Int(n) => *n != 0, + Value::Float(n) => *n != 0.0, + Value::Str(s) => !s.is_empty(), + Value::List(l) => !l.is_empty(), + Value::None => false, + _ => true, + } + } + + /// Converts the value to a string suitable for environment variables. + pub fn to_env_string(&self) -> String { + match self { + Value::Int(n) => n.to_string(), + Value::Float(n) => n.to_string(), + Value::Str(s) => s.clone(), + Value::Bool(b) => if *b { "1" } else { "0" }.to_string(), + Value::Path(p) => p.display().to_string(), + Value::List(items) => items + .iter() + .map(|v| v.to_env_string()) + .collect::>() + .join(":"), + Value::None => String::new(), + _ => self.to_string_repr(), + } + } + + pub fn to_string_repr(&self) -> String { + match self { + Value::Int(n) => n.to_string(), + Value::Float(n) => n.to_string(), + Value::Str(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Path(p) => p.display().to_string(), + Value::List(items) => { + let parts: Vec = items.iter().map(|v| v.to_string_repr()).collect(); + format!("[{}]", parts.join(", ")) + } + Value::Struct(name, fields) => { + let parts: Vec = fields + .iter() + .map(|(k, v)| format!("{} = {}", k, v.to_string_repr())) + .collect(); + format!("{} {{ {} }}", name, parts.join(", ")) + } + Value::Enum(ty, variant) => format!("{}::{}", ty, variant), + Value::None => "none".to_string(), + } + } +} + +/// Deploy mode for dotfiles. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum DeployMode { + #[default] + Copy, + Link, +} + +/// Permission rule for deployed files. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum PermissionRule { + Single(u32), + Pattern { pattern: String, mode: u32 }, +} + +/// Source for a dotfiles glob block. +#[derive(Debug, Clone)] +pub enum DotfilesSource { + /// Glob pattern string to expand later (e.g. "config/*"). + Pattern(String), + /// Pre-expanded list of paths (e.g. from glob() function call). + Paths(Vec), +} + +/// Unexpanded dotfiles pattern from a glob source. +#[derive(Debug, Clone)] +pub struct DotfilesPattern { + pub source: DotfilesSource, + pub target_base: PathBuf, + pub template: bool, + pub permissions: Vec, + pub owner: Option, + pub deploy: DeployMode, + pub link_patterns: Vec, + pub copy_patterns: Vec, +} + +/// Evaluated dotfile configuration. +#[derive(Clone, Debug)] +pub struct DotfileConfig { + pub source: PathBuf, + pub target: PathBuf, + pub template: bool, + pub permissions: Vec, + pub owner: Option, + pub deploy: DeployMode, + pub link_patterns: Vec, + pub copy_patterns: Vec, + /// Target paths to skip during directory deploy. + pub exclude_paths: Vec, + /// Source paths to skip during directory deploy. + pub exclude_sources: Vec, +} + +/// Evaluated package configuration. +#[derive(Clone, Debug)] +pub struct PackageConfig { + pub default: Option, + pub brew: Option, + /// Homebrew cask name (macOS); installed via `brew install --cask`. + pub cask: Option, + pub apt: Option, + pub pacman: Option, + pub yay: Option, + pub xbps: Option, +} + +/// Evaluated secret file configuration. +#[derive(Clone, Debug)] +pub struct SecretConfig { + pub source: PathBuf, + pub target: PathBuf, + pub mode: Option, +} + +/// Evaluated hook configuration. +#[derive(Clone, Debug)] +pub struct HookConfig { + pub stage: HookStage, + pub run: String, +} + +/// Result of evaluating a doot program. +#[derive(Clone)] +pub struct EvalResult { + pub dotfiles: Vec, + pub dotfile_patterns: Vec, + pub packages: Vec, + /// Homebrew taps to register, in declaration order. + pub brew_taps: Vec, + /// Brew-only formulae to install. + pub brew_formulae: Vec, + pub secrets: Vec, + pub hooks: Vec, + pub encrypted_vars: HashMap, + pub encrypted_files: HashMap, + pub sandbox: bool, +} + +impl Default for EvalResult { + fn default() -> Self { + Self { + dotfiles: Vec::new(), + dotfile_patterns: Vec::new(), + packages: Vec::new(), + brew_taps: Vec::new(), + brew_formulae: Vec::new(), + secrets: Vec::new(), + hooks: Vec::new(), + encrypted_vars: HashMap::new(), + encrypted_files: HashMap::new(), + sandbox: true, + } + } +} diff --git a/crates/doot-core/src/hooks.rs b/crates/doot-core/src/hooks.rs index 51070cc..6f9258f 100644 --- a/crates/doot-core/src/hooks.rs +++ b/crates/doot-core/src/hooks.rs @@ -1,6 +1,6 @@ //! Lifecycle hook execution. -use doot_lang::HookStage; +use crate::evaluator::HookStage; use std::process::Command; use thiserror::Error; diff --git a/crates/doot-core/src/lib.rs b/crates/doot-core/src/lib.rs index be3109b..3ed137f 100644 --- a/crates/doot-core/src/lib.rs +++ b/crates/doot-core/src/lib.rs @@ -3,17 +3,20 @@ //! Provides configuration, deployment, encryption, package management, //! and state tracking. +pub mod builtins; pub mod config; pub mod deploy; pub mod encryption; +pub mod evaluator; pub mod hooks; pub mod os; pub mod package; pub mod state; -pub use config::Config; +pub use config::Settings; pub use deploy::{DeployAction, DeployResult, Deployer}; pub use encryption::AgeEncryption; +pub use evaluator::HookStage; pub use hooks::HookRunner; pub use os::OsInfo; pub use package::PackageManager; diff --git a/crates/doot-core/src/state/store.rs b/crates/doot-core/src/state/store.rs index 485e1c0..4b9457d 100644 --- a/crates/doot-core/src/state/store.rs +++ b/crates/doot-core/src/state/store.rs @@ -1,6 +1,6 @@ //! State persistence for doot. -use doot_lang::evaluator::PermissionRule; +use crate::evaluator::PermissionRule; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/crates/doot-dotfile/Cargo.toml b/crates/doot-dotfile/Cargo.toml new file mode 100644 index 0000000..c83e92e --- /dev/null +++ b/crates/doot-dotfile/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "doot-dotfile" +version.workspace = true +edition.workspace = true + +[dependencies] +doot-lang.workspace = true +doot-std.workspace = true +doot-core.workspace = true +doot-utils.workspace = true +os_info.workspace = true +indexmap = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/doot-dotfile/src/bridge.rs b/crates/doot-dotfile/src/bridge.rs new file mode 100644 index 0000000..06f6fe1 --- /dev/null +++ b/crates/doot-dotfile/src/bridge.rs @@ -0,0 +1,194 @@ +//! Maps a [`Plan`] onto the deploy layer's [`EvalResult`]. + +use std::path::PathBuf; + +use doot_core::HookStage; +use doot_core::evaluator::{ + DeployMode, DotfileConfig, DotfilesPattern, DotfilesSource, EvalResult, HookConfig, + PackageConfig, PermissionRule, SecretConfig, +}; +use doot_lang::lang::plan::Plan; + +use crate::payload::{Deploy, Perm, Stage, TaskData}; + +/// A single effect, converted from a plan node's [`TaskData`] payload into the +/// deploy layer's config types. Used both to build a flat [`EvalResult`] and to +/// drive DAG-ordered execution (see [`crate::exec`]). +pub enum Task { + Dotfile(DotfileConfig), + DotfilePattern(DotfilesPattern), + Package(PackageConfig), + Hook(HookConfig), + Secret(SecretConfig), + Tap(String), + Formula(String), + EncVar { key: String, value: String }, + EncFile { key: String, path: PathBuf }, +} + +/// The execution phase a node belongs to, mirroring the fixed deploy order so +/// hook stages can be turned into ordering edges: a node in a higher phase +/// depends on every node in a lower phase. `before_deploy(0) < dotfiles/secrets(1) +/// < after_deploy(2) < before_package(3) < taps(4) < packages/formulae(5) < +/// after_package(6)`. Encrypted entries carry no ordering (phase 0). +pub fn phase_of(data: &TaskData) -> u8 { + match data { + TaskData::Hook { stage, .. } => match stage { + Stage::BeforeDeploy => 0, + Stage::AfterDeploy => 2, + Stage::BeforePackage => 3, + Stage::AfterPackage => 6, + }, + TaskData::EncVar { .. } | TaskData::EncFile { .. } => 0, + TaskData::Dotfile { .. } | TaskData::Secret { .. } => 1, + TaskData::Tap { .. } => 4, + TaskData::Package { .. } | TaskData::Formula { .. } => 5, + } +} + +/// Convert one node payload into a typed [`Task`]. Glob dotfile sources become +/// `DotfilePattern` (expanded at deploy); concrete sources become `Dotfile`. +pub fn task_of(data: &TaskData) -> Task { + match data { + TaskData::Dotfile { + source, + target, + template, + permissions, + owner, + deploy, + link_patterns, + copy_patterns, + } => { + let permissions: Vec = permissions.iter().map(to_perm_rule).collect(); + let deploy = match deploy { + Deploy::Copy => DeployMode::Copy, + Deploy::Link => DeployMode::Link, + }; + if is_glob(source) { + Task::DotfilePattern(DotfilesPattern { + source: DotfilesSource::Pattern(source.clone()), + target_base: tilde(target), + template: *template, + permissions, + owner: owner.clone(), + deploy, + link_patterns: link_patterns.clone(), + copy_patterns: copy_patterns.clone(), + }) + } else { + Task::Dotfile(DotfileConfig { + source: tilde(source), + target: tilde(target), + template: *template, + permissions, + owner: owner.clone(), + deploy, + link_patterns: link_patterns.clone(), + copy_patterns: copy_patterns.clone(), + exclude_paths: Vec::new(), + exclude_sources: Vec::new(), + }) + } + } + TaskData::Package { + default, + brew, + cask, + apt, + pacman, + yay, + xbps, + } => Task::Package(PackageConfig { + default: default.clone(), + brew: brew.clone(), + cask: cask.clone(), + apt: apt.clone(), + pacman: pacman.clone(), + yay: yay.clone(), + xbps: xbps.clone(), + }), + TaskData::Hook { run, stage } => Task::Hook(HookConfig { + stage: match stage { + Stage::BeforeDeploy => HookStage::BeforeDeploy, + Stage::AfterDeploy => HookStage::AfterDeploy, + Stage::BeforePackage => HookStage::BeforePackage, + Stage::AfterPackage => HookStage::AfterPackage, + }, + run: run.clone(), + }), + TaskData::Secret { + source, + target, + mode, + } => Task::Secret(SecretConfig { + source: tilde(source), + target: tilde(target), + mode: *mode, + }), + TaskData::Tap { name } => Task::Tap(name.clone()), + TaskData::Formula { name } => Task::Formula(name.clone()), + TaskData::EncVar { key, value } => Task::EncVar { + key: key.clone(), + value: value.clone(), + }, + TaskData::EncFile { key, path } => Task::EncFile { + key: key.clone(), + path: PathBuf::from(path), + }, + } +} + +/// Route a typed [`Task`] into the flat [`EvalResult`] collections. +fn push_task(r: &mut EvalResult, task: Task) { + match task { + Task::Dotfile(d) => r.dotfiles.push(d), + Task::DotfilePattern(p) => r.dotfile_patterns.push(p), + Task::Package(p) => r.packages.push(p), + Task::Hook(h) => r.hooks.push(h), + Task::Secret(s) => r.secrets.push(s), + Task::Tap(name) => r.brew_taps.push(name), + Task::Formula(name) => r.brew_formulae.push(name), + Task::EncVar { key, value } => { + r.encrypted_vars.insert(key, value); + } + Task::EncFile { key, path } => { + r.encrypted_files.insert(key, path); + } + } +} + +pub fn to_eval_result(plan: &Plan) -> EvalResult { + let mut r = EvalResult::default(); + for node in &plan.nodes { + if let Some(data) = node.data.downcast_ref::() { + push_task(&mut r, task_of(data)); + } + } + r +} + +pub(crate) fn is_glob(s: &str) -> bool { + s.contains('*') || s.contains('?') || s.contains('[') +} + +// expand a leading `~` to the home directory (DOOT_HOME-aware); deploy expects +// absolute target paths +pub(crate) fn tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix('~') { + let rest = rest.strip_prefix('/').unwrap_or(rest); + doot_utils::xdg::home_dir().join(rest) + } else { + PathBuf::from(s) + } +} + +fn to_perm_rule(p: &Perm) -> PermissionRule { + match p { + Perm::Mode(n) => PermissionRule::Single(*n), + Perm::Pattern { pattern, mode } => PermissionRule::Pattern { + pattern: pattern.clone(), + mode: *mode, + }, + } +} diff --git a/crates/doot-dotfile/src/builtins.rs b/crates/doot-dotfile/src/builtins.rs new file mode 100644 index 0000000..dafc84d --- /dev/null +++ b/crates/doot-dotfile/src/builtins.rs @@ -0,0 +1,393 @@ +//! The dotfile vocabulary: effect builtins, host facts, and the `Config`/`Os` +//! schema, registered into an [`Engine`]. Each effect builtin takes an attrset +//! (or string) and yields a `Task` node carrying a [`TaskData`] payload. + +use std::collections::BTreeMap; +use std::rc::Rc; + +use doot_lang::lang::ast::{EnumDecl, Type}; +use doot_lang::lang::engine::{BuiltinScheme, Engine}; +use doot_lang::lang::eval::{ + Interp, Thunk, Value, as_int, as_str, empty_list, forced, list_from_vec, +}; + +use crate::payload::{Deploy, FileRef, Perm, Stage, TaskData, config_struct}; + +/// Register the dotfile vocabulary into `engine`. +pub fn register_dotfile(e: &mut Engine) { + let var = Type::Var; + let fun = |a: Type, b: Type| Type::Fun(Box::new(a), Box::new(b)); + let task = || Type::Task(Box::new(Type::Dyn)); + let effect = || BuiltinScheme::poly(1, fun(var(0), task())); + + e.register_builtin("pkg", effect(), 1, |i, a| b_pkg(i, &a[0])); + e.register_builtin("package", effect(), 1, |i, a| b_pkg(i, &a[0])); + e.register_builtin("apt", effect(), 1, |i, a| { + pkg_task(i, one_pkg("apt", as_str(&i.force(&a[0])))) + }); + e.register_builtin("pacman", effect(), 1, |i, a| { + pkg_task(i, one_pkg("pacman", as_str(&i.force(&a[0])))) + }); + e.register_builtin("yay", effect(), 1, |i, a| { + pkg_task(i, one_pkg("yay", as_str(&i.force(&a[0])))) + }); + e.register_builtin("xbps", effect(), 1, |i, a| { + pkg_task(i, one_pkg("xbps", as_str(&i.force(&a[0])))) + }); + e.register_builtin("brew", effect(), 1, |i, a| b_brew(i, &a[0])); + e.register_builtin("dotfile", effect(), 1, |i, a| b_dotfile(i, &a[0])); + e.register_builtin("hook", effect(), 1, |i, a| b_hook(i, &a[0])); + e.register_builtin("secret", effect(), 1, |i, a| b_secret(i, &a[0])); + e.register_builtin("tap", effect(), 1, |i, a| { + let name = as_str(&i.force(&a[0])); + Value::Task(i.make_task( + format!("tap:{name}"), + Rc::new(TaskData::Tap { name }), + &empty_list(), + )) + }); + e.register_builtin("formula", effect(), 1, |i, a| { + let name = as_str(&i.force(&a[0])); + Value::Task(i.make_task( + format!("formula:{name}"), + Rc::new(TaskData::Formula { name }), + &empty_list(), + )) + }); + e.register_builtin( + "file", + BuiltinScheme::mono(fun(Type::Str, Type::Dyn)), + 1, + |i, a| Value::Foreign(Rc::new(FileRef(as_str(&i.force(&a[0]))))), + ); + e.register_builtin( + "encrypted", + BuiltinScheme::poly(1, fun(var(0), Type::List(Box::new(task())))), + 1, + |i, a| b_encrypted(i, &a[0]), + ); + + // host facts, exposed as plain string values + let s = |v: String| Value::Str(Rc::new(v)); + let home = || doot_utils::xdg::home_dir().to_string_lossy().into_owned(); + let conf = || { + doot_utils::xdg::config_home() + .to_string_lossy() + .into_owned() + }; + e.register_value("home_dir", BuiltinScheme::mono(Type::Str), s(home())); + e.register_value("config_dir", BuiltinScheme::mono(Type::Str), s(conf())); + e.register_value("os", BuiltinScheme::mono(Type::Str), s(current_os())); + e.register_value("distro", BuiltinScheme::mono(Type::Str), s(detect_distro())); + + // host context record: host.os : Os, host.distro/configDir/homeDir : Str + let os_variant = match current_os().as_str() { + "macos" => "MacOS", + "linux" => "Linux", + _ => "Other", + }; + let host_fields: BTreeMap = BTreeMap::from([ + ( + "os".to_string(), + forced(Value::Enum( + Rc::new("Os".into()), + Rc::new(os_variant.into()), + )), + ), + ("distro".to_string(), forced(s(detect_distro()))), + ("configDir".to_string(), forced(s(conf()))), + ("homeDir".to_string(), forced(s(home()))), + ]); + let host_ty = Type::Record(BTreeMap::from([ + ("os".to_string(), Type::Enum("Os".to_string())), + ("distro".to_string(), Type::Str), + ("configDir".to_string(), Type::Str), + ("homeDir".to_string(), Type::Str), + ])); + e.register_value( + "host", + BuiltinScheme::mono(host_ty), + Value::Attr(Some(Rc::new("Host".into())), Rc::new(host_fields)), + ); + + // the built-in Config schema and Os enum + e.register_struct(config_struct()); + e.register_enum(EnumDecl { + name: "Os".to_string(), + variants: vec!["Linux".into(), "MacOS".into(), "Other".into()], + methods: Vec::new(), + span: doot_lang::lang::diag::Span::point(0), + }); +} + +fn pkg_task(i: &Interp, data: TaskData) -> Value { + let name = match &data { + TaskData::Package { + default, + brew, + cask, + apt, + pacman, + yay, + xbps, + } => default + .clone() + .or_else(|| brew.clone()) + .or_else(|| cask.clone()) + .or_else(|| apt.clone()) + .or_else(|| pacman.clone()) + .or_else(|| yay.clone()) + .or_else(|| xbps.clone()) + .unwrap_or_else(|| "pkg".into()), + _ => "pkg".into(), + }; + Value::Task(i.make_task(format!("pkg:{name}"), Rc::new(data), &empty_list())) +} + +// `package "name"` shorthand or `package { default = ..; xbps = ..; }` +fn b_pkg(i: &Interp, arg: &Thunk) -> Value { + let arg = i.force(arg); + let data = match &arg { + Value::Str(s) => TaskData::Package { + default: Some((**s).clone()), + brew: None, + cask: None, + apt: None, + pacman: None, + yay: None, + xbps: None, + }, + Value::Attr(_, m) => TaskData::Package { + default: field_str(i, m, "default"), + brew: field_str(i, m, "brew"), + cask: field_str(i, m, "cask"), + apt: field_str(i, m, "apt"), + pacman: field_str(i, m, "pacman"), + yay: field_str(i, m, "yay"), + xbps: field_str(i, m, "xbps"), + }, + _ => panic!("package expects a string or attrset"), + }; + pkg_task(i, data) +} + +// `brew "x"` -> formula; `brew { package = "x"; cask = true; }` -> cask +fn b_brew(i: &Interp, arg: &Thunk) -> Value { + let v = i.force(arg); + let data = match &v { + Value::Str(s) => one_pkg("brew", (**s).clone()), + Value::Attr(_, m) => { + let name = field_str(i, m, "package").unwrap_or_default(); + let cask = field_bool(i, m, "cask").unwrap_or(false); + one_pkg(if cask { "cask" } else { "brew" }, name) + } + _ => panic!("brew expects a string or attrset"), + }; + pkg_task(i, data) +} + +fn b_dotfile(i: &Interp, arg: &Thunk) -> Value { + let arg = i.force(arg); + let m = as_attr(&arg); + let source = field_str(i, &m, "source").unwrap_or_default(); + let target = field_str(i, &m, "target").unwrap_or_default(); + let template = field_bool(i, &m, "template").unwrap_or(false); + let owner = field_str(i, &m, "owner"); + let deploy = match field_str(i, &m, "deploy").as_deref() { + Some("link") => Deploy::Link, + _ => Deploy::Copy, + }; + let link_patterns = field_str_list(i, &m, "link_patterns"); + let copy_patterns = field_str_list(i, &m, "copy_patterns"); + let permissions = field_perms(i, &m, "permissions"); + let label = format!("dotfile:{target}"); + let data = TaskData::Dotfile { + source, + target, + template, + permissions, + owner, + deploy, + link_patterns, + copy_patterns, + }; + Value::Task(i.make_task(label, Rc::new(data), &arg)) +} + +fn b_hook(i: &Interp, arg: &Thunk) -> Value { + let arg = i.force(arg); + let m = as_attr(&arg); + let run = field_str(i, &m, "run").unwrap_or_default(); + let stage = match field_str(i, &m, "stage").as_deref() { + Some("before_deploy") => Stage::BeforeDeploy, + Some("before_package") => Stage::BeforePackage, + Some("after_package") => Stage::AfterPackage, + _ => Stage::AfterDeploy, + }; + let short: String = run.chars().take(28).collect(); + Value::Task(i.make_task( + format!("hook:{short}"), + Rc::new(TaskData::Hook { run, stage }), + &arg, + )) +} + +fn b_secret(i: &Interp, arg: &Thunk) -> Value { + let arg = i.force(arg); + let m = as_attr(&arg); + let source = field_str(i, &m, "source").unwrap_or_default(); + let target = field_str(i, &m, "target").unwrap_or_default(); + let mode = field_int(i, &m, "mode").map(|n| n as u32); + let label = format!("secret:{target}"); + Value::Task(i.make_task( + label, + Rc::new(TaskData::Secret { + source, + target, + mode, + }), + &arg, + )) +} + +// `encrypted { K = "b64"; K2 = file("p"); }` -> one node per entry +fn b_encrypted(i: &Interp, arg: &Thunk) -> Value { + let arg = i.force(arg); + let m = as_attr(&arg); + let mut out = Vec::new(); + for (k, t) in m.iter() { + let v = i.force(t); + let data = match &v { + Value::Str(s) => TaskData::EncVar { + key: k.clone(), + value: (**s).clone(), + }, + Value::Foreign(a) if a.downcast_ref::().is_some() => TaskData::EncFile { + key: k.clone(), + path: a.downcast_ref::().unwrap().0.clone(), + }, + _ => panic!("encrypted `{k}` must be a string or file(...)"), + }; + let id = i.make_task(format!("enc:{k}"), Rc::new(data), &empty_list()); + out.push(forced(Value::Task(id))); + } + list_from_vec(out) +} + +fn field_str(i: &Interp, m: &BTreeMap, k: &str) -> Option { + m.get(k).and_then(|t| match i.force(t) { + Value::Str(s) => Some((*s).clone()), + _ => None, + }) +} + +fn field_bool(i: &Interp, m: &BTreeMap, k: &str) -> Option { + m.get(k).and_then(|t| match i.force(t) { + Value::Bool(b) => Some(b), + _ => None, + }) +} + +fn field_int(i: &Interp, m: &BTreeMap, k: &str) -> Option { + m.get(k).and_then(|t| match i.force(t) { + Value::Int(n) => Some(n), + _ => None, + }) +} + +fn field_str_list(i: &Interp, m: &BTreeMap, k: &str) -> Vec { + match m.get(k).map(|t| i.force(t)) { + Some(v @ (Value::Nil | Value::Cons(_, _))) => i + .list_to_vec(&v) + .iter() + .map(|t| as_str(&i.force(t))) + .collect(), + _ => Vec::new(), + } +} + +/// `permissions` is either a single mode int, or a list of `[pattern, mode]`. +fn field_perms(i: &Interp, m: &BTreeMap, k: &str) -> Vec { + match m.get(k).map(|t| i.force(t)) { + Some(Value::Int(n)) => vec![Perm::Mode(n as u32)], + Some(v @ (Value::Nil | Value::Cons(_, _))) => i + .list_to_vec(&v) + .iter() + .map(|t| { + let pair = i.list_to_vec(&i.force(t)); + if pair.len() != 2 { + panic!("permission entry must be [pattern, mode]"); + } + Perm::Pattern { + pattern: as_str(&i.force(&pair[0])), + mode: as_int(i.force(&pair[1])) as u32, + } + }) + .collect(), + _ => Vec::new(), + } +} + +fn as_attr(v: &Value) -> Rc> { + match v { + Value::Attr(_, m) => m.clone(), + _ => panic!("expected attrset"), + } +} + +// build a Package payload with a single manager field set +fn one_pkg(field: &str, name: String) -> TaskData { + let mut p: [Option; 7] = Default::default(); + let idx = match field { + "brew" => 1, + "cask" => 2, + "apt" => 3, + "pacman" => 4, + "yay" => 5, + "xbps" => 6, + _ => 0, // default + }; + p[idx] = Some(name); + let [default, brew, cask, apt, pacman, yay, xbps] = p; + TaskData::Package { + default, + brew, + cask, + apt, + pacman, + yay, + xbps, + } +} + +pub fn current_os() -> String { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "other" + } + .to_string() +} + +pub fn detect_distro() -> String { + // custom environments first (by config-dir presence), then os_info + if doot_utils::xdg::config_home().join("omarchy").exists() { + return "omarchy".to_string(); + } + let raw = os_info::get().os_type().to_string().to_lowercase(); + match raw.as_str() { + "arch linux" => "arch", + "ubuntu linux" | "ubuntu" => "ubuntu", + "debian gnu/linux" | "debian linux" => "debian", + "fedora linux" => "fedora", + "manjaro linux" => "manjaro", + "void linux" => "void", + "nixos" => "nixos", + "alpine linux" => "alpine", + "macos" | "mac os" | "mac os x" => "macos", + other => other, + } + .to_string() +} diff --git a/crates/doot-dotfile/src/exec.rs b/crates/doot-dotfile/src/exec.rs new file mode 100644 index 0000000..bf87755 --- /dev/null +++ b/crates/doot-dotfile/src/exec.rs @@ -0,0 +1,36 @@ +//! The inferred dependency DAG exposed as typed tasks in topological layers, so +//! deploy can honor dependencies across task kinds (e.g. a hook that `needs` a +//! set of dotfiles runs strictly after them, regardless of stage). + +use std::collections::HashMap; + +use doot_core::evaluator::Value as TemplateValue; + +use crate::bridge::Task; + +/// A DAG-ordered execution plan. +pub struct ExecPlan { + /// Topologically ordered layers. Tasks within a layer are mutually + /// independent (safe to run concurrently); layer `N` runs only after every + /// task in layers `0..N` has completed. + pub layers: Vec>, + pub template_vars: HashMap, +} + +impl ExecPlan { + pub fn empty() -> Self { + ExecPlan { + layers: Vec::new(), + template_vars: HashMap::new(), + } + } + + /// Total task count across all layers. + pub fn len(&self) -> usize { + self.layers.iter().map(|l| l.len()).sum() + } + + pub fn is_empty(&self) -> bool { + self.layers.iter().all(|l| l.is_empty()) + } +} diff --git a/crates/doot-dotfile/src/lib.rs b/crates/doot-dotfile/src/lib.rs new file mode 100644 index 0000000..827666c --- /dev/null +++ b/crates/doot-dotfile/src/lib.rs @@ -0,0 +1,851 @@ +//! The dotfile domain layer: registers the dotfile vocabulary into the language +//! engine, evaluates a config, and reflects the result into the deploy layer's +//! `EvalResult`. This is the only crate that knows both the language and the +//! deploy backend. + +pub mod bridge; +pub mod builtins; +pub mod exec; +pub mod payload; +pub mod reflect; + +use std::collections::HashMap; + +use doot_core::evaluator::{EvalResult, Value as TemplateValue}; +use doot_lang::lang::ast::Program; +use doot_lang::lang::check::Checker; +use doot_lang::lang::diag::Diagnostic; +use doot_lang::lang::engine::Engine; +use doot_lang::lang::parser::parse; +use doot_lang::lang::plan::Plan; + +use crate::payload::TaskData; + +pub use bridge::{Task, to_eval_result}; +pub use doot_lang::lang::diag::{Diagnostic as Diag, Span}; +pub use exec::ExecPlan; + +/// The full engine: the general standard library plus the dotfile vocabulary. +pub fn engine() -> Engine { + let mut e = Engine::default(); + doot_std::register(&mut e); + builtins::register_dotfile(&mut e); + e +} + +/// Parse against `engine`'s registered nominal names. +fn parse_program(src: &str, engine: &Engine) -> Result { + parse(src, &engine.struct_names(), &engine.enum_names()) +} + +/// Parse and pretty-print a config to canonical source (the `doot fmt` formatter). +/// Preserves comments, integer literal forms, and multiline strings. +pub fn format(src: &str) -> Result> { + let eng = engine(); + let prog = parse_program(src, &eng).map_err(|d| vec![d])?; + Ok(doot_lang::lang::fmt::format(&prog)) +} + +/// Parse + type-check, returning any diagnostics (empty = valid). +pub fn check(src: &str) -> Vec { + let eng = engine(); + let prog = match parse_program(src, &eng) { + Ok(p) => p, + Err(d) => return vec![d], + }; + let mut c = Checker::with_engine(&prog, &eng); + c.check(&prog.body); + c.errors.into_iter().map(Diagnostic::message).collect() +} + +/// Parse, type-check, and evaluate to a [`Plan`]. Diagnostics are returned +/// alongside the plan rather than aborting (a parse error yields an empty plan). +pub fn compile(src: &str) -> (Plan, Vec) { + let eng = engine(); + let prog = match parse_program(src, &eng) { + Ok(p) => p, + Err(d) => return (Plan::default(), vec![d]), + }; + let mut c = Checker::with_engine(&prog, &eng); + c.check(&prog.body); + let diags = c.errors.into_iter().map(Diagnostic::message).collect(); + (reflect::build_plan(&prog, &eng), diags) +} + +/// The full bridge output the CLI needs: an `EvalResult` plus template variables. +pub fn compile_eval_result( + src: &str, +) -> (EvalResult, HashMap, Vec) { + let eng = engine(); + let prog = match parse_program(src, &eng) { + Ok(p) => p, + Err(d) => return (EvalResult::default(), HashMap::new(), vec![d]), + }; + let mut c = Checker::with_engine(&prog, &eng); + c.check(&prog.body); + let diags = c.errors.into_iter().map(Diagnostic::message).collect(); + let s = reflect::compile_sections(&prog, &eng); + let mut r = to_eval_result(&s.plan); + r.brew_taps.extend(s.taps); + r.encrypted_vars.extend(s.encrypted_vars); + r.encrypted_files.extend(s.encrypted_files); + (r, s.template_vars, diags) +} + +/// Compile to a DAG-ordered [`ExecPlan`]: the inferred dependency graph as typed +/// tasks in topological layers. Cross-kind dependencies (a hook that `needs` +/// dotfiles) and hook stages (turned into ordering edges) are both honored by +/// the layering. `Config { ... }` section taps/encrypted (not graph nodes) form +/// the first, dependency-free setup layer. +pub fn compile_exec_plan(src: &str) -> (ExecPlan, Vec) { + let eng = engine(); + let prog = match parse_program(src, &eng) { + Ok(p) => p, + Err(d) => return (ExecPlan::empty(), vec![d]), + }; + let mut c = Checker::with_engine(&prog, &eng); + c.check(&prog.body); + let diags = c.errors.into_iter().map(Diagnostic::message).collect(); + let s = reflect::compile_sections(&prog, &eng); + + // Turn hook stages into ordering edges: a node in a higher phase depends on + // every node in a lower phase, so e.g. after_package hooks run strictly after + // packages. Combined with the inferred `needs` edges already in the plan. + let mut plan = s.plan; + let phases: Vec> = plan + .nodes + .iter() + .map(|n| n.data.downcast_ref::().map(bridge::phase_of)) + .collect(); + for i in 0..plan.nodes.len() { + for j in 0..plan.nodes.len() { + if let (Some(pi), Some(pj)) = (phases[i], phases[j]) + && pi > pj + { + plan.edges.push((i, j)); + } + } + } + + let mut layers: Vec> = plan + .parallel_layers() + .iter() + .map(|layer| { + layer + .iter() + .filter_map(|&i| { + plan.nodes[i] + .data + .downcast_ref::() + .map(bridge::task_of) + }) + .collect() + }) + .collect(); + + // `Config { ... }` taps/encrypted are read from sections, not graph nodes. + // They are setup with no dependencies (and taps must precede package + // installs), so they form the first layer. + let mut extras: Vec = Vec::new(); + extras.extend(s.taps.into_iter().map(Task::Tap)); + extras.extend( + s.encrypted_vars + .into_iter() + .map(|(key, value)| Task::EncVar { key, value }), + ); + extras.extend( + s.encrypted_files + .into_iter() + .map(|(key, path)| Task::EncFile { key, path }), + ); + if !extras.is_empty() { + layers.insert(0, extras); + } + + ( + ExecPlan { + layers, + template_vars: s.template_vars, + }, + diags, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Edges fall out of `needs`/value references - no `after` written anywhere. + #[test] + fn edges_inferred_from_data() { + let src = r#" +let + fonts = map (\n -> dotfile { source = n; target = "/f" / n; }) + [ "a.ttf" "b.ttf" ]; + pkgs = map pkg [ "git" "fd" ]; + fc = hook { run = "fc-cache"; needs = fonts; }; +in { dotfiles = fonts; packages = pkgs; hooks = [ fc ]; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "unexpected errors: {errs:?}"); + + let fc = plan + .nodes + .iter() + .position(|n| n.label.starts_with("hook:")) + .unwrap(); + // fc-cache depends on both font dotfiles + assert_eq!(plan.deps_of(fc).len(), 2); + + let layers = plan.parallel_layers(); + // dotfiles + packages run first (parallel), the hook strictly after. + let fc_layer = layers.iter().position(|l| l.contains(&fc)).unwrap(); + assert!(fc_layer > 0); + assert!(layers[0].len() >= 4); + } + + #[test] + fn typed_merge_accepts_valid() { + let src = r#" +struct Host { name : Str; port : Int = 22; } +let + web : Host = { name = "web"; port = 8080; }; + prod = web // { port = 443; }; +in prod +"#; + assert!(check(src).is_empty()); + } + + #[test] + fn typed_merge_rejects_bad_override_and_construction() { + let src = r#" +struct Host { name : Str; port : Int = 22; } +let + web : Host = { name = "web"; }; + bad1 = web // { port = "x"; }; + bad2 = web // { prot = 9; }; + bad3 : Host = { port = 1; }; +in web +"#; + let errs = check(src); + assert_eq!(errs.len(), 3, "got: {errs:?}"); + } + + #[test] + fn bridges_plan_to_eval_result() { + let src = r#" +let + fonts = map (\n -> dotfile { source = n; target = "/f" / n; template = true; }) + [ "a.ttf" "b.ttf" ]; + pkgs = map pkg [ "git" "fd" ]; + fc = hook { run = "fc-cache"; needs = fonts; }; + t = tap "homebrew/cask-fonts"; +in { dotfiles = fonts; packages = pkgs; hooks = [ fc ]; taps = [ t ]; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + let r = to_eval_result(&plan); + assert_eq!(r.dotfiles.len(), 2); + assert_eq!(r.packages.len(), 2); + assert_eq!(r.hooks.len(), 1); + assert_eq!(r.brew_taps, vec!["homebrew/cask-fonts".to_string()]); + assert!(r.dotfiles.iter().all(|d| d.template)); + assert_eq!(r.packages[0].default.as_deref(), Some("git")); + } + + #[test] + fn domain_parity_dotfile_and_package_fields() { + use doot_core::evaluator::{DeployMode, PermissionRule}; + let src = r#" +let + ssh = dotfile { source = "config"/"ssh"; target = home_dir / ".ssh/config"; permissions = 0o600; deploy = "link"; }; + svc = dotfile { source = "config"/"service"; target = config_dir / "service"; + permissions = [ [ "*/run" 0o755 ] [ "*/log/run" 0o755 ] ]; }; + yz = package { default = "yazi"; yay = "yazi-nightly-bin"; }; + rg = package "ripgrep"; + linux = optionals (os == "linux") (map package [ "brightnessctl" ]); +in { dotfiles = [ ssh svc ]; packages = [ yz rg ] ++ linux; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + let r = to_eval_result(&plan); + + assert_eq!(r.dotfiles.len(), 2); + assert_eq!(r.dotfiles[0].deploy, DeployMode::Link); + assert_eq!( + r.dotfiles[0].permissions, + vec![PermissionRule::Single(0o600)] + ); + assert!(r.dotfiles[0].target.ends_with(".ssh/config")); + assert_eq!(r.dotfiles[1].permissions.len(), 2); + + let yz = &r.packages[0]; + assert_eq!(yz.default.as_deref(), Some("yazi")); + assert_eq!(yz.yay.as_deref(), Some("yazi-nightly-bin")); + assert_eq!(r.packages[1].default.as_deref(), Some("ripgrep")); + } + + #[test] + fn domain_parity_secrets_hooks_brew_encrypted_globs() { + use doot_core::HookStage; + let src = r#" +let + glob = dotfile { source = "config"/"*"; target = config_dir; }; + conf = dotfile { source = "config"/"ssh"; target = home_dir / ".ssh"; }; + sec = secret { source = "secrets"/"id"; target = home_dir / ".ssh/id"; mode = 0o600; }; + post = hook { run = "fc-cache"; stage = "after_package"; }; + enc = encrypted { API = "base64data"; WB = file ("secrets"/"wb.age"); }; + forms = [ (formula "bun") ]; +in { dotfiles = [ glob conf ]; secrets = [ sec ]; hooks = [ post ]; enc = enc; brew = forms; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + let r = to_eval_result(&plan); + + assert_eq!(r.dotfile_patterns.len(), 1); // the glob + assert_eq!(r.dotfiles.len(), 1); // the concrete one + assert_eq!(r.secrets.len(), 1); + assert_eq!(r.secrets[0].mode, Some(0o600)); + assert_eq!(r.hooks[0].stage, HookStage::AfterPackage); + assert_eq!(r.brew_formulae, vec!["bun".to_string()]); + assert_eq!( + r.encrypted_vars.get("API").map(String::as_str), + Some("base64data") + ); + assert!(r.encrypted_files.contains_key("WB")); + } + + #[test] + fn exposes_template_variables() { + use doot_core::evaluator::Value as V; + let src = r##" +struct Colors { base00 : Str; base0D : Str; } +let + colors = Colors { base00 = "#232136"; base0D = "#c4a7e7"; }; + name = "ray"; + fonts = map (\n -> dotfile { source = n; target = "/f"/n; }) [ "a" ]; +in { dotfiles = fonts; } +"##; + let (_r, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("name"), Some(V::Str(s)) if s == "ray")); + match vars.get("colors") { + Some(V::Struct(_, m)) => { + assert_eq!(m.len(), 2); + assert!(matches!(m.get("base0D"), Some(V::Str(s)) if s == "#c4a7e7")); + } + _ => panic!("expected colors struct"), + } + } + + #[test] + fn free_functions_named_and_multiparam() { + // `let f a b = ...` sugar and `\a b -> ...` multi-param lambda + let src = r#" +let + cfg name tmpl = dotfile { source = name; target = "/c" / name; template = tmpl; }; + pair = \a b -> [ a b ]; +in { dotfiles = [ (cfg "nvim" true) ] ++ map (\n -> cfg n false) (pair "ghostty" "tmux"); } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + let r = to_eval_result(&plan); + assert_eq!(r.dotfiles.len(), 3); + assert!(r.dotfiles.iter().filter(|d| d.template).count() == 1); + } + + #[test] + fn recursion_self_and_mutual() { + // `let` is recursive: a function sees itself and its siblings. + let src = r#" +let + f x = if x then [ "a" ] else f true; # self-recursion (1 level) + isEven n = if n then true else isOdd n; # mutual recursion + isOdd n = if n then false else isEven n; +in { dotfiles = map (\name -> dotfile { source = name; target = name; }) (f false); ok = isEven true; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + assert_eq!(to_eval_result(&plan).dotfiles.len(), 1); + } + + #[test] + fn arithmetic_precedence_and_path_overload() { + // 1 + 2*3 - 4 = 3 ; 2**3**2 = 512 (right-assoc) ; 17 % 5 = 2 ; 10/2 = 5 + let src = r#" +let + a = 1 + 2 * 3 - 4; + b = 2 ** 3 ** 2; + c = 17 % 5; + d = 10 / 2; + p = "etc" / "ssh"; +in { a = a; b = b; c = c; d = d; p = p; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + use doot_core::evaluator::Value as V; + let int = |k: &str| match vars.get(k) { + Some(V::Int(n)) => *n, + other => panic!("{k} = {other:?}"), + }; + assert_eq!(int("a"), 3); + assert_eq!(int("b"), 512); + assert_eq!(int("c"), 2); + assert_eq!(int("d"), 5); + assert!(matches!(vars.get("p"), Some(V::Str(s)) if s == "etc/ssh")); + } + + #[test] + fn infinite_lists_are_lazy() { + use doot_core::evaluator::Value as V; + // self-referential infinite list, an infinite generator, and lazy map + // over it - consumed safely with `take`. Infinite lists are kept in inner + // lets so only the finite results are exposed as template vars. + let src = r#" +let + a = let ones = cons 1 ones; in take 3 ones; + b = let gen = \n -> cons n (gen (n + 1)); in take 4 (gen 10); + c = let gen = \n -> cons n (gen (n + 1)); in take 3 (map (\x -> x * x) (gen 1)); +in { a = a; b = b; c = c; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + let ints = |k: &str| match vars.get(k) { + Some(V::List(xs)) => xs + .iter() + .map(|v| match v { + V::Int(n) => *n, + other => panic!("{other:?}"), + }) + .collect::>(), + other => panic!("{k} = {other:?}"), + }; + assert_eq!(ints("a"), vec![1, 1, 1]); + assert_eq!(ints("b"), vec![10, 11, 12, 13]); + assert_eq!(ints("c"), vec![1, 4, 9]); + } + + #[test] + fn tail_calls_do_not_overflow() { + use doot_core::evaluator::Value as V; + // 100k-deep tail recursion: loops via the trampoline, would blow the + // (small) test-thread stack without TCO. + let src = r#" +let + countdown n = if n == 0 then "done" else countdown (n - 1); + result = countdown 100000; +in { x = result; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("result"), Some(V::Str(s)) if s == "done")); + } + + #[test] + fn strict_foldl_constant_space() { + use doot_core::evaluator::Value as V; + // strict foldl keeps the accumulator forced each step (no accumulator + // thunk-chain); the iterative Drop lets the long cons spine be freed too. + let src = r#" +let + ones = cons 1 ones; + total = foldl (\acc x -> acc + x) 0 (take 100000 ones); +in { t = total; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("total"), Some(V::Int(n)) if *n == 100000)); + } + + #[test] + fn infinite_top_level_binding_does_not_hang() { + // an infinite top-level binding is skipped as a template var (budgeted), + // not hung on; finite siblings still resolve. + use doot_core::evaluator::Value as V; + let src = r#" +let + ones = cons 1 ones; + name = "ok"; +in { x = take 2 ones; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("name"), Some(V::Str(s)) if s == "ok")); + assert!(!vars.contains_key("ones")); // skipped, not hung + } + + #[test] + fn hm_let_polymorphism() { + // a single `id` used at two different types: classic let-polymorphism + let src = r#" +let id = \x -> x; +in { a = id 1; b = id "x"; c = id (cons 1 nil); } +"#; + assert!(check(src).is_empty(), "{:?}", check(src)); + } + + #[test] + fn hm_catches_type_errors() { + // each of these is a real type error HM now catches (Dyn used to miss them) + assert!(!check("let x = 1 + \"s\"; in x").is_empty()); // int + str + assert!(!check("let x = head 5; in x").is_empty()); // head wants a list + assert!(!check("let x = map 5 (cons 1 nil); in x").is_empty()); // map wants a fn + assert!(!check("let x = if 1 then 2 else 3; in x").is_empty()); // cond not Bool + } + + #[test] + fn deep_non_tail_recursion_via_cek() { + use doot_core::evaluator::Value as V; + // `head xs + sum (tail xs)` is NOT tail-recursive (the add happens after the + // call returns). 100k deep: the CEK machine keeps the depth on its heap + // continuation stack, so Rust's stack does not overflow. + let src = r#" +let + ones = cons 1 ones; + sum = \xs -> if empty xs then 0 else head xs + sum (tail xs); + total = sum (take 100000 ones); +in { t = total; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("total"), Some(V::Int(n)) if *n == 100000)); + } + + #[test] + fn nested_list_value_drops_iteratively() { + // A large List (List Int) - built, walked by collect_tasks, and freed by + // the iterative Drop, none recursing on Rust's stack. (Self-nesting trees + // are rejected by HM as infinite types, so long/nested lists are the only + // deep well-typed shapes.) + let src = r#" +let + ones = cons 1 ones; + grid = map (\row -> take 300 ones) (take 300 ones); +in { x = grid; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + assert_eq!(plan.nodes.len(), 0); + } + + #[test] + fn long_task_list_bridge_is_iterative() { + // 50k tasks in one list: collect_tasks walks the whole plan without + // recursing (a recursive walk would overflow at this length). + let src = r#" +let mk = \n -> if n == 0 then nil else cons (dotfile { source = "s"; target = "t"; }) (mk (n - 1)); +in { dotfiles = mk 50000; } +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + assert_eq!(plan.nodes.len(), 50000); + } + + #[test] + fn long_list_to_template_var_is_iterative() { + // converting a 50k-element list to a template value does not recurse + use doot_core::evaluator::Value as V; + let src = r#" +let big = let ones = cons 1 ones; in take 50000 ones; +in { x = big; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + match vars.get("big") { + Some(V::List(xs)) => assert_eq!(xs.len(), 50000), + other => panic!("{other:?}"), + } + } + + #[test] + fn enums_variants_and_equality() { + use doot_core::evaluator::Value as V; + let src = r#" +enum Os { Linux, MacOS, Other } +let + cur = Os.Linux; + isLinux = cur == Os.Linux; + isMac = cur == Os.MacOS; +in { a = isLinux; b = isMac; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("isLinux"), Some(V::Bool(true)))); + assert!(matches!(vars.get("isMac"), Some(V::Bool(false)))); + // unknown variant is a type error + assert!(!check("enum E { A, B } let x = E.C; in x == E.A").is_empty()); + } + + #[test] + fn struct_and_enum_methods() { + use doot_core::evaluator::Value as V; + let src = r##" +struct Host { + name : Str; + port : Int = 22; + fn url self = "https://" ++ self.name; + fn bumped self n = self.port + n; +} +enum Os { + Linux, MacOS, Other, + fn isLinux self = self == Os.Linux; +} +let + h = Host { name = "web"; port = 8080; }; + u = h.url; # method, self bound + b = h.bumped 100; # method with an extra arg + ml = Os.Linux.isLinux; # enum method + mm = Os.MacOS.isLinux; +in { u = u; b = b; ml = ml; mm = mm; } +"##; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("u"), Some(V::Str(s)) if s == "https://web")); + assert!(matches!(vars.get("b"), Some(V::Int(8180)))); + assert!(matches!(vars.get("ml"), Some(V::Bool(true)))); + assert!(matches!(vars.get("mm"), Some(V::Bool(false)))); + // typo: no such field or method + assert!(!check("struct A { x : Int; } let a = A { x = 1; }; in a.nope").is_empty()); + } + + #[test] + fn type_classes_dispatch_and_safety() { + use doot_core::evaluator::Value as V; + let src = r#" +class Show a { show : a -> Str; } +enum Os { Linux, MacOS, Other } +impl Show for Bool { show = \b -> if b then "yes" else "no"; } +impl Show for Os { show = \o -> if o == Os.Linux then "linux" else "other"; } +let + a = show true; # Bool instance (free function) + b = show Os.Linux; # Os instance + c = Os.MacOS.show; # . sugar -> show Os.MacOS +in { a = a; b = b; c = c; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("a"), Some(V::Str(s)) if s == "yes")); + assert!(matches!(vars.get("b"), Some(V::Str(s)) if s == "linux")); + assert!(matches!(vars.get("c"), Some(V::Str(s)) if s == "other")); + + // no instance for Int -> type error (the safety you wanted) + let bad = check("class Show a { show : a -> Str; } let x = show 5; in x"); + assert!( + bad.iter().any(|e| e.message.contains("no instance")), + "{bad:?}" + ); + + // coherence: duplicate instance is an error + let dup = check( + "class Show a { show : a -> Str; } impl Show for Bool { show = \\b -> \"x\"; } impl Show for Bool { show = \\b -> \"y\"; } let x = show true; in x", + ); + assert!( + dup.iter().any(|e| e.message.contains("duplicate instance")), + "{dup:?}" + ); + } + + #[test] + fn host_record_and_builtin_os() { + use doot_core::evaluator::Value as V; + // host.os is an Os enum (built-in), host.distro/configDir/homeDir are Str + let src = r#" +let + onLinux = host.os == Os.Linux; + d = host.distro; +in { a = onLinux; b = d; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("onLinux"), Some(V::Bool(_)))); + assert!(matches!(vars.get("d"), Some(V::Str(_)))); + } + + #[test] + fn manager_constructors() { + let src = r#" +{ packages = + map package [ "ripgrep" "fd" ] + ++ map xbps [ "swayfx" ] + ++ [ (brew "bun") (brew { package = "dockdoor"; cask = true; }) (yay "yazi-nightly-bin") ]; +} +"#; + let (plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + let r = to_eval_result(&plan); + assert_eq!(r.packages.len(), 6); + assert!( + r.packages + .iter() + .any(|p| p.default.as_deref() == Some("ripgrep")) + ); + assert!( + r.packages + .iter() + .any(|p| p.xbps.as_deref() == Some("swayfx")) + ); + assert!(r.packages.iter().any(|p| p.brew.as_deref() == Some("bun"))); + assert!( + r.packages + .iter() + .any(|p| p.cask.as_deref() == Some("dockdoor")) + ); + assert!( + r.packages + .iter() + .any(|p| p.yay.as_deref() == Some("yazi-nightly-bin")) + ); + } + + #[test] + fn config_schema_sections() { + use doot_core::evaluator::Value as V; + let src = r##" +struct Colors { base0D : Str; } +let + colors = Colors { base0D = "#c4a7e7"; }; +in Config { + vars = { colors = colors; }; + dotfiles = [ (dotfile { source = "a"; target = "b"; }) ]; + packages = map package [ "git" "fd" ] ++ [ (brew "bun") ]; + encrypted = { API = "base64data"; WB = file "secrets/wb.age"; }; + brew = { taps = [ "oven-sh/bun" ]; }; +} +"##; + let (r, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert_eq!(r.dotfiles.len(), 1); + assert_eq!(r.packages.len(), 3); + assert_eq!(r.brew_taps, vec!["oven-sh/bun".to_string()]); + assert_eq!( + r.encrypted_vars.get("API").map(String::as_str), + Some("base64data") + ); + assert!(r.encrypted_files.contains_key("WB")); + assert!(matches!(vars.get("colors"), Some(V::Struct(_, _)))); + // section-name typo is a type error + assert!(!check("Config { dotflies = nil; }").is_empty()); + } + + #[test] + fn operator_overloading() { + use doot_core::evaluator::Value as V; + // `/` overloaded: Int division, Str path-join (built-in), and a user Path + let src = r#" +struct Path { p : Str; } +impl Div for Path { div = \a b -> Path { p = a.p ++ "/" ++ b.p; }; } +let + n = 10 / 2; + s = "etc" / "ssh"; + pp = (Path { p = "a"; }) / (Path { p = "b"; }); + joined = pp.p; +in { x = n; } +"#; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("n"), Some(V::Int(5)))); + assert!(matches!(vars.get("s"), Some(V::Str(t)) if t == "etc/ssh")); + assert!(matches!(vars.get("joined"), Some(V::Str(t)) if t == "a/b")); + // `+` on a type with no Add instance is a type error + assert!( + !check("struct P { x : Int; } let a = P { x = 1; } + P { x = 2; }; in a").is_empty() + ); + } + + #[test] + fn indented_multiline_strings() { + use doot_core::evaluator::Value as V; + // ''...'' strips the common indentation and the blank first/last lines + let src = "let s = ''\n line one\n line two\n ''; in { x = s; }"; + let (_p, vars, errs) = compile_eval_result(src); + assert!(errs.is_empty(), "{errs:?}"); + assert!(matches!(vars.get("s"), Some(V::Str(t)) if t == "line one\nline two")); + } + + #[test] + fn defaults_are_filled_in_plan() { + // construction with a default still evaluates fine + let src = r#" +struct Spec { name : Str; opt : Bool = false; } +let s = Spec { name = "x"; }; +in { v = s; } +"#; + let (_plan, errs) = compile(src); + assert!(errs.is_empty(), "{errs:?}"); + } + + #[test] + fn fmt_preserves_comments_literals_and_is_idempotent() { + let src = "# top comment\nlet\n # a note\n perm = 0o755;\n mask = 0xff;\n pkgs = [ (package \"a\") (package \"b\") ];\nin { packages = pkgs; }\n"; + let once = format(src).expect("format ok"); + let twice = format(&once).expect("format ok"); + assert_eq!(once, twice, "formatter is not idempotent:\n{once}"); + assert!( + once.contains("# top comment"), + "dropped top comment:\n{once}" + ); + assert!(once.contains("# a note"), "dropped inner comment:\n{once}"); + assert!(once.contains("0o755"), "octal not preserved:\n{once}"); + assert!(once.contains("0xff"), "hex not preserved:\n{once}"); + // still type-checks after formatting + assert!(check(&once).is_empty(), "formatted output has errors"); + } + + #[test] + fn parse_and_lex_errors_are_located_diagnostics() { + // a parse error carries a span and does not panic + let errs = check("let x = ; in x"); + assert_eq!(errs.len(), 1, "{errs:?}"); + assert!(errs[0].span.is_some()); + assert!(errs[0].message.contains("unexpected")); + // a lexical error is reported the same way + let errs = check("let x = 1 @ 2; in x"); + assert!(errs[0].message.contains("unexpected character")); + assert!(errs[0].span.is_some()); + // the rendered form points at the source line with a caret + let rendered = errs[0].render("let x = 1 @ 2; in x"); + assert!(rendered.contains("^"), "{rendered}"); + } + + #[test] + fn exec_plan_orders_cross_kind_dependencies() { + // a hook that `needs` font dotfiles must land in a strictly later layer, + // regardless of kind - the inferred DAG edges drive the ordering. + let src = r#" +let + fonts = map (\n -> dotfile { source = n; target = "/f"/n; }) [ "a" "b" ]; + fc = hook { run = "fc-cache"; needs = fonts; }; +in { dotfiles = fonts; hooks = [ fc ]; } +"#; + let (plan, errs) = compile_exec_plan(src); + assert!(errs.is_empty(), "{errs:?}"); + assert_eq!(plan.len(), 3); // two dotfiles + one hook + let layer_of = + |want: fn(&Task) -> bool| plan.layers.iter().position(|l| l.iter().any(want)); + let dot = layer_of(|t| matches!(t, Task::Dotfile(_))).unwrap(); + let hook = layer_of(|t| matches!(t, Task::Hook(_))).unwrap(); + assert!(hook > dot, "hook layer {hook} should follow dotfiles {dot}"); + } + + #[test] + fn exec_plan_turns_stages_into_edges() { + // an after_package hook must land in a later layer than packages, even + // with no explicit `needs` - the stage becomes an ordering edge. + let src = r#" +let + pkgs = map package [ "a" "b" ]; + post = hook { run = "rebuild"; stage = "after_package"; }; +in { packages = pkgs; hooks = [ post ]; } +"#; + let (plan, errs) = compile_exec_plan(src); + assert!(errs.is_empty(), "{errs:?}"); + let layer_of = + |want: fn(&Task) -> bool| plan.layers.iter().position(|l| l.iter().any(want)); + let pkg = layer_of(|t| matches!(t, Task::Package(_))).unwrap(); + let hook = layer_of(|t| matches!(t, Task::Hook(_))).unwrap(); + assert!( + hook > pkg, + "after_package hook layer {hook} must follow packages {pkg}" + ); + } +} diff --git a/crates/doot-dotfile/src/payload.rs b/crates/doot-dotfile/src/payload.rs new file mode 100644 index 0000000..9eb855c --- /dev/null +++ b/crates/doot-dotfile/src/payload.rs @@ -0,0 +1,109 @@ +//! The dotfile vocabulary's node payloads. These are the domain-specific data +//! an effect node carries; the resource graph treats them opaquely. + +use std::rc::Rc; + +use doot_lang::lang::ast::{Expr, FieldDecl, StructDecl, Type}; + +/// A file-mode rule: a single mode or a glob-pattern -> mode mapping. +#[derive(Debug, Clone)] +pub enum Perm { + Mode(u32), + Pattern { pattern: String, mode: u32 }, +} + +#[derive(Debug, Clone, Copy)] +pub enum Deploy { + Copy, + Link, +} + +#[derive(Debug, Clone, Copy)] +pub enum Stage { + BeforeDeploy, + AfterDeploy, + BeforePackage, + AfterPackage, +} + +/// Structured payload of an effect node. Stored opaquely in the plan and +/// downcast back by the bridge. +#[derive(Debug, Clone)] +pub enum TaskData { + Dotfile { + source: String, + target: String, + template: bool, + permissions: Vec, + owner: Option, + deploy: Deploy, + link_patterns: Vec, + copy_patterns: Vec, + }, + Package { + default: Option, + brew: Option, + cask: Option, + apt: Option, + pacman: Option, + yay: Option, + xbps: Option, + }, + Hook { + run: String, + stage: Stage, + }, + Secret { + source: String, + target: String, + mode: Option, + }, + Tap { + name: String, + }, + /// brew-only formula (from a `brew:` block) + Formula { + name: String, + }, + /// inline encrypted value: `KEY = "base64..."` + EncVar { + key: String, + value: String, + }, + /// encrypted file reference: `KEY = file("path.age")` + EncFile { + key: String, + path: String, + }, +} + +/// The value `file("path")` evaluates to: a foreign marker distinguishing a file +/// reference from an inline string. Carried in `Value::Foreign`. +pub struct FileRef(pub String); + +/// The built-in `Config` schema. All sections default (empty), so a config only +/// writes the ones it uses. Section values are `Dyn` (permissive), but section +/// *names* are checked - a typo like `dotflies` is a type error. +pub fn config_struct() -> StructDecl { + let rec = || Some(Rc::new(Expr::Record(Vec::new()))); + let nil = || Some(Rc::new(Expr::Var("nil".to_string()))); + let f = |name: &str, default: Option>| FieldDecl { + name: name.to_string(), + ty: Type::Dyn, + default, + }; + StructDecl { + name: "Config".to_string(), + fields: vec![ + f("vars", rec()), + f("dotfiles", nil()), + f("packages", nil()), + f("hooks", nil()), + f("secrets", nil()), + f("encrypted", rec()), + f("brew", rec()), + ], + methods: Vec::new(), + span: doot_lang::lang::diag::Span::point(0), + } +} diff --git a/crates/doot-dotfile/src/reflect.rs b/crates/doot-dotfile/src/reflect.rs new file mode 100644 index 0000000..efe99fd --- /dev/null +++ b/crates/doot-dotfile/src/reflect.rs @@ -0,0 +1,219 @@ +//! Reflecting an evaluated program into the deploy layer: build its plan, read a +//! `Config { ... }` body's non-task sections, and convert data values to the +//! deploy-layer template `Value`. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::rc::Rc; + +use indexmap::IndexMap; + +use doot_core::evaluator::Value as TemplateValue; +use doot_lang::lang::ast::{Expr, Program}; +use doot_lang::lang::engine::Engine; +use doot_lang::lang::eval::{Interp, Thunk, Value, as_str, interp_with_engine}; +use doot_lang::lang::plan::Plan; + +use crate::builtins::{current_os, detect_distro}; +use crate::payload::FileRef; + +/// Evaluate a program to its plan. (Run the checker first to surface type errors.) +pub fn build_plan(program: &Program, engine: &Engine) -> Plan { + let interp = interp_with_engine(program, engine); + let result = interp.eval(&program.body, &interp.global_scope()); + // "Realize": force the plan deeply so every Task node is instantiated and its + // edges inferred. (Pure eval already ran; this only walks the data.) + let mut roots = Vec::new(); + interp.collect_tasks(&result, &mut roots); + interp.into_plan() +} + +/// Data read from a `Config { ... }` body's non-task sections, plus the plan. +pub struct Sections { + pub plan: Plan, + pub taps: Vec, + pub encrypted_vars: HashMap, + pub encrypted_files: HashMap, + pub template_vars: HashMap, +} + +/// Evaluate a program and extract its plan + `Config` sections. If the body is a +/// `Config { ... }`, `vars`/`encrypted`/`brew.taps` are read from the sections; +/// otherwise template variables fall back to harvesting top-level `let` bindings. +pub fn compile_sections(program: &Program, engine: &Engine) -> Sections { + let interp = interp_with_engine(program, engine); + let config = interp.eval(&program.body, &interp.global_scope()); + let mut roots = Vec::new(); + interp.collect_tasks(&config, &mut roots); // forces everything -> builds the plan + + let mut taps = Vec::new(); + let mut encrypted_vars = HashMap::new(); + let mut encrypted_files = HashMap::new(); + let mut tvars = HashMap::new(); + + let is_config = matches!(&config, Value::Attr(Some(n), _) if n.as_str() == "Config"); + if let (true, Value::Attr(_, m)) = (is_config, &config) { + if let Some(Value::Attr(_, vm)) = m.get("vars").map(|t| interp.force(t)) { + for (k, vt) in vm.iter() { + if let Some(cv) = to_template_value(&interp, &interp.force(vt)) { + tvars.insert(k.clone(), cv); + } + } + } + if let Some(Value::Attr(_, em)) = m.get("encrypted").map(|t| interp.force(t)) { + for (k, vt) in em.iter() { + match interp.force(vt) { + Value::Str(s) => { + encrypted_vars.insert(k.clone(), (*s).clone()); + } + Value::Foreign(a) => { + if let Some(f) = a.downcast_ref::() { + encrypted_files.insert(k.clone(), PathBuf::from(f.0.clone())); + } + } + _ => {} + } + } + } + if let Some(Value::Attr(_, bm)) = m.get("brew").map(|t| interp.force(t)) + && let Some(lv) = bm.get("taps").map(|t| interp.force(t)) + { + for el in interp.list_to_vec(&lv) { + taps.push(as_str(&interp.force(&el))); + } + } + } else { + tvars = template_vars(program, engine); // legacy: harvest let bindings + } + + // Expose host facts to templates, matching the `Os` enum form ("MacOS"/ + // "Linux"/"Other") that configs compare against. User `vars` win on collision. + let os_variant = match current_os().as_str() { + "macos" => "MacOS", + "linux" => "Linux", + _ => "Other", + }; + for (k, v) in [ + ("os", os_variant.to_string()), + ("distro", detect_distro()), + ( + "home_dir", + doot_utils::xdg::home_dir().to_string_lossy().into_owned(), + ), + ( + "config_dir", + doot_utils::xdg::config_home() + .to_string_lossy() + .into_owned(), + ), + ] { + tvars.entry(k.to_string()).or_insert(TemplateValue::Str(v)); + } + + Sections { + plan: interp.into_plan(), + taps, + encrypted_vars, + encrypted_files, + template_vars: tvars, + } +} + +/// Top-level `let` bindings that are plain data, converted to deploy-layer values +/// for the template engine. +pub fn template_vars(program: &Program, engine: &Engine) -> HashMap { + let interp = interp_with_engine(program, engine); + let mut out = HashMap::new(); + if let Expr::Let(binds, _) = &*program.body { + for (name, val) in interp.harvest_bindings(binds) { + // non-data (functions) and cyclic lists are skipped - not template values + if let Some(v) = to_template_value(&interp, &val) { + out.insert(name, v); + } + } + } + out +} + +/// Convert a data value to the deploy-layer `Value` for templating. Iterative (no +/// Rust recursion) so deeply-nested values are fine. Non-data (tasks, functions, +/// file refs) yields `None`; a self-referential (cyclic) list also yields `None` - +/// it cannot be a finite template value. Genuinely productive-infinite values are +/// non-materializable by definition (as in Nix). +pub fn to_template_value(interp: &Interp, root: &Value) -> Option { + use TemplateValue as V1; + enum W { + Eval(Value), + MakeList(usize), + MakeAttr(Option>, Vec), + } + let mut work = vec![W::Eval(root.clone())]; + let mut out: Vec> = Vec::new(); + while let Some(w) = work.pop() { + match w { + W::Eval(v) => match v { + Value::Int(n) => out.push(Some(V1::Int(n))), + Value::Str(s) => out.push(Some(V1::Str((*s).clone()))), + Value::Bool(b) => out.push(Some(V1::Bool(b))), + Value::Enum(e, v) => out.push(Some(V1::Enum((*e).clone(), (*v).clone()))), + Value::Nil => out.push(Some(V1::List(Vec::new()))), + Value::Cons(_, _) => { + // materialize this spine, detecting self-reference + let mut elems = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut cur = v; + loop { + match cur { + Value::Nil => break, + Value::Cons(h, t) => { + if !seen.insert(Rc::as_ptr(&t) as usize) { + return None; // cyclic list: not a template value + } + elems.push(h); + cur = interp.force(&t); + } + _ => break, + } + } + work.push(W::MakeList(elems.len())); + for h in elems.into_iter().rev() { + work.push(W::Eval(interp.force(&h))); + } + } + Value::Attr(name, m) => { + let keys: Vec = m.keys().cloned().collect(); + let entries: Vec = m.values().cloned().collect(); + work.push(W::MakeAttr(name, keys)); + for t in entries.into_iter().rev() { + work.push(W::Eval(interp.force(&t))); + } + } + _ => out.push(None), // task / lambda / native / foreign + }, + W::MakeList(n) => { + let mut items: Vec> = Vec::with_capacity(n); + for _ in 0..n { + items.push(out.pop().unwrap()); + } + items.reverse(); + out.push(Some(V1::List(items.into_iter().flatten().collect()))); + } + W::MakeAttr(name, keys) => { + let mut vals: Vec> = Vec::with_capacity(keys.len()); + for _ in 0..keys.len() { + vals.push(out.pop().unwrap()); + } + vals.reverse(); + let mut map = IndexMap::new(); + for (k, v) in keys.into_iter().zip(vals) { + if let Some(cv) = v { + map.insert(k, cv); + } + } + let tag = name.map(|n| (*n).clone()).unwrap_or_default(); + out.push(Some(V1::Struct(tag, map))); + } + } + } + out.pop().flatten() +} diff --git a/crates/doot-lang/Cargo.toml b/crates/doot-lang/Cargo.toml index 365b4dd..f250877 100644 --- a/crates/doot-lang/Cargo.toml +++ b/crates/doot-lang/Cargo.toml @@ -4,28 +4,3 @@ version.workspace = true edition.workspace = true [dependencies] -doot-utils.workspace = true -chumsky.workspace = true -ariadne.workspace = true -serde.workspace = true -serde_json.workspace = true -toml.workspace = true -smol.workspace = true -async-recursion.workspace = true -futures-lite.workspace = true -surf.workspace = true -rayon.workspace = true -walkdir.workspace = true -blake3.workspace = true -os_info.workspace = true -thiserror.workspace = true -anyhow.workspace = true -indexmap = "2" -glob = "0.3" -hostname = "0.4" -age = "0.10" -ordered-float = "5" -tracing.workspace = true - -[dev-dependencies] -tempfile = "3" diff --git a/crates/doot-lang/src/ast.rs b/crates/doot-lang/src/ast.rs deleted file mode 100644 index 415962b..0000000 --- a/crates/doot-lang/src/ast.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Abstract syntax tree definitions for the doot language. - -use crate::lexer::Span; -use std::collections::HashMap; - -/// Identifier type alias. -pub type Ident = String; - -/// A parsed doot program. -#[derive(Clone, Debug, PartialEq)] -pub struct Program { - pub statements: Vec>, -} - -/// Wraps a node with source location information. -#[derive(Clone, Debug, PartialEq)] -pub struct Spanned { - pub node: T, - pub span: Span, -} - -impl Spanned { - /// Creates a new spanned node. - pub fn new(node: T, span: Span) -> Self { - Self { node, span } - } -} - -/// Top-level statement types. -#[derive(Clone, Debug, PartialEq)] -pub enum Statement { - VarDecl(VarDecl), - FnDecl(FnDecl), - StructDecl(StructDecl), - EnumDecl(EnumDecl), - TypeAlias(TypeAlias), - Import(Import), - Dotfile(Box), - Package(Box), - Brew(BrewConfig), - Secret(Secret), - Encrypted(EncryptedVars), - Hook(Hook), - MacroDecl(MacroDecl), - MacroCall(MacroCall), - ForLoop(ForLoop), - If(IfStatement), - Match(MatchStatement), - Expr(Expr), - Return(Option), -} - -/// Variable declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct VarDecl { - pub name: Ident, - pub ty: Option, - pub value: Expr, -} - -/// Function declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct FnDecl { - pub name: Ident, - pub is_async: bool, - pub params: Vec, - pub return_type: Option, - pub body: Vec>, -} - -/// Function parameter. -#[derive(Clone, Debug, PartialEq)] -pub struct FnParam { - pub name: Ident, - pub ty: TypeAnnotation, - pub default: Option, -} - -/// Struct type declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct StructDecl { - pub name: Ident, - pub fields: Vec, - pub methods: Vec, -} - -/// Struct field definition. -#[derive(Clone, Debug, PartialEq)] -pub struct StructField { - pub name: Ident, - pub ty: TypeAnnotation, - pub default: Option, -} - -/// Enum type declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct EnumDecl { - pub name: Ident, - pub variants: Vec, -} - -/// Enum variant definition. -#[derive(Clone, Debug, PartialEq)] -pub struct EnumVariant { - pub name: Ident, - pub fields: Option>, -} - -/// Type alias declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct TypeAlias { - pub name: Ident, - pub ty: TypeAnnotation, -} - -/// Module import statement. -#[derive(Clone, Debug, PartialEq)] -pub struct Import { - pub path: String, - pub alias: Option, -} - -/// Deploy mode for dotfiles. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum DeployMode { - #[default] - Copy, - Link, -} - -/// Permission rule - either a single mode or pattern-based. -#[derive(Clone, Debug, PartialEq)] -pub enum PermissionRule { - Single(u32), - Pattern { pattern: String, mode: u32 }, -} - -/// Dotfile deployment declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct Dotfile { - pub source: Expr, - pub target: Expr, - pub when: Option, - pub template: Option, - pub permissions: Vec, - pub owner: Option, - pub deploy: DeployMode, - pub link_patterns: Vec, - pub copy_patterns: Vec, - /// Span of the source expression (for error reporting). - pub source_span: Option, - /// Span of the target expression (for error reporting). - pub target_span: Option, - /// Span of the when expression (for error reporting). - pub when_span: Option, -} - -/// Package installation declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct Package { - pub default: Option, - pub brew: Option, - /// Homebrew cask (macOS GUI app); installed via `brew install --cask`. - pub cask: Option, - pub apt: Option, - pub pacman: Option, - pub yay: Option, - pub xbps: Option, - pub when: Option, -} - -/// Package manager-specific specification. -#[derive(Clone, Debug, PartialEq)] -pub struct PackageSpec { - pub name: Expr, -} - -/// Homebrew-specific configuration (`brew:` block): taps and brew-only formulae. -/// macOS-only; ignored on other platforms. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct BrewConfig { - /// Repositories to register via `brew tap` (list expression). - pub taps: Option, - /// Brew-only formulae to install (list expression). - pub formulae: Option, -} - -/// Encrypted secret file declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct Secret { - pub source: Expr, - pub target: Expr, - pub mode: Option, -} - -/// Entry in an `encrypted:` block — either an inline var or a file reference. -#[derive(Clone, Debug, PartialEq)] -pub enum EncryptedEntry { - /// Inline base64-encoded encrypted value: `KEY = "base64..."` - Var(Ident, Expr), - /// Encrypted file reference: `KEY = file("path/to/file.age")` - File(Ident, Expr), -} - -/// Encrypted variable declarations (for template use). -#[derive(Clone, Debug, PartialEq)] -pub struct EncryptedVars { - pub entries: Vec, -} - -/// Lifecycle hook declaration. -#[derive(Clone, Debug, PartialEq)] -pub struct Hook { - pub stage: HookStage, - pub run: Expr, - pub when: Option, -} - -/// Hook execution stage. -#[derive(Clone, Debug, PartialEq)] -pub enum HookStage { - BeforeDeploy, - AfterDeploy, - BeforePackage, - AfterPackage, -} - -/// Macro definition. -#[derive(Clone, Debug, PartialEq)] -pub struct MacroDecl { - pub name: Ident, - pub params: Vec, - pub body: Vec>, -} - -/// Macro invocation. -#[derive(Clone, Debug, PartialEq)] -pub struct MacroCall { - pub name: Ident, - pub args: Vec, -} - -/// For loop statement. -#[derive(Clone, Debug, PartialEq)] -pub struct ForLoop { - pub var: Ident, - pub iter: Expr, - pub body: Vec>, -} - -/// Conditional statement. -#[derive(Clone, Debug, PartialEq)] -pub struct IfStatement { - pub condition: Expr, - pub then_body: Vec>, - pub else_body: Option>>, -} - -/// Pattern matching statement. -#[derive(Clone, Debug, PartialEq)] -pub struct MatchStatement { - pub expr: Expr, - pub arms: Vec, -} - -/// Single arm in a match statement. -#[derive(Clone, Debug, PartialEq)] -pub struct MatchArm { - pub pattern: Pattern, - pub body: Expr, -} - -/// Match pattern types. -#[derive(Clone, Debug, PartialEq)] -pub enum Pattern { - Literal(Literal), - Ident(Ident), - EnumVariant { ty: Ident, variant: Ident }, - Wildcard, -} - -/// Expression types. -#[derive(Clone, Debug, PartialEq)] -pub enum Expr { - Literal(Literal), - Ident(Ident), - Path(Box, Box), - Binary(Box, BinOp, Box), - Unary(UnaryOp, Box), - Call(Box, Vec), - MethodCall(Box, Ident, Vec), - Index(Box, Box), - Field(Box, Ident), - EnumVariant(Ident, Ident), - StructInit(Ident, HashMap), - List(Vec), - If(Box, Box, Option>), - Lambda(Vec, Box), - Await(Box), - Interpolated(Vec), - HomePath(Box), -} - -/// Part of an interpolated string. -#[derive(Clone, Debug, PartialEq)] -pub enum InterpolatedPart { - Literal(String), - Expr(Expr), -} - -/// Literal value types. -#[derive(Clone, Debug, PartialEq)] -pub enum Literal { - Int(i64), - Float(f64), - Str(String), - Bool(bool), - None, -} - -/// Binary operators. -#[derive(Clone, Debug, PartialEq)] -pub enum BinOp { - Add, - Sub, - Mul, - Div, - Mod, - Eq, - NotEq, - Lt, - Gt, - LtEq, - GtEq, - And, - Or, - PathJoin, - NullCoalesce, -} - -/// Unary operators. -#[derive(Clone, Debug, PartialEq)] -pub enum UnaryOp { - Neg, - Not, -} - -/// Type annotation in source code. -#[derive(Clone, Debug, PartialEq)] -pub enum TypeAnnotation { - Simple(Ident), - List(Box), - Optional(Box), - Function(Vec, Box), - Union(Vec), - Literal(Literal), -} diff --git a/crates/doot-lang/src/builtins/async_ops.rs b/crates/doot-lang/src/builtins/async_ops.rs deleted file mode 100644 index 9b63ec2..0000000 --- a/crates/doot-lang/src/builtins/async_ops.rs +++ /dev/null @@ -1,275 +0,0 @@ -use crate::evaluator::{AsyncValue, EvalError, Value}; - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn all(args: &[Value]) -> Result { - let mut results = Vec::with_capacity(args.len()); - for arg in args { - match arg { - Value::Future(av) => { - let task = - av.0.lock() - .map_err(|e| EvalError::AsyncError(e.to_string()))? - .take() - .ok_or_else(|| EvalError::AsyncError("future already consumed".into()))?; - results.push(task.await?); - } - other => results.push(other.clone()), - } - } - Ok(Value::List(results)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn race(args: &[Value]) -> Result { - let mut tasks = Vec::new(); - for arg in args { - match arg { - Value::Future(av) => { - let task = - av.0.lock() - .map_err(|e| EvalError::AsyncError(e.to_string()))? - .take() - .ok_or_else(|| EvalError::AsyncError("future already consumed".into()))?; - tasks.push(task); - } - other => return Ok(other.clone()), // Non-future wins immediately - } - } - match tasks.len() { - 0 => Ok(Value::None), - 1 => tasks.remove(0).await, - _ => { - let mut combined = tasks.remove(0); - for t in tasks { - combined = smol::spawn(futures_lite::future::race(combined, t)); - } - combined.await - } - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn fetch(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "fetch expects a URL string".to_string(), - )); - } - }; - - let task = smol::spawn(async move { - let mut response = surf::get(&url) - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let body = response - .body_string() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - Ok(Value::Str(body)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn fetch_json(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "fetch_json expects a URL string".to_string(), - )); - } - }; - - let task = smol::spawn(async move { - let mut response = surf::get(&url) - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let json: serde_json::Value = response - .body_json() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - Ok(json_to_value(&json)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn fetch_bytes(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "fetch_bytes expects a URL string".to_string(), - )); - } - }; - - let task = smol::spawn(async move { - let mut response = surf::get(&url) - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let bytes = response - .body_bytes() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let values: Vec = bytes.iter().map(|b| Value::Int(*b as i64)).collect(); - Ok(Value::List(values)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn post(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "post expects a URL string".to_string(), - )); - } - }; - - let body = match args.get(1) { - Some(Value::Str(s)) => s.clone(), - _ => String::new(), - }; - - let task = smol::spawn(async move { - let mut response = surf::post(&url) - .body(body) - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let result = response - .body_string() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - Ok(Value::Str(result)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn post_json(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "post_json expects a URL string".to_string(), - )); - } - }; - - let data = args.get(1).unwrap_or(&Value::None); - let json = value_to_json(data); - - let task = smol::spawn(async move { - let mut response = surf::post(&url) - .body_json(&json) - .map_err(|e| EvalError::AsyncError(e.to_string()))? - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let result: serde_json::Value = response - .body_json() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - Ok(json_to_value(&result)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub async fn download(args: &[Value]) -> Result { - let url = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "download expects a URL string".to_string(), - )); - } - }; - - let path = match args.get(1) { - Some(Value::Path(p)) => p.clone(), - Some(Value::Str(s)) => std::path::PathBuf::from(s), - _ => { - return Err(EvalError::TypeError( - "download requires destination path".to_string(), - )); - } - }; - - let task = smol::spawn(async move { - let mut response = surf::get(&url) - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - let bytes = response - .body_bytes() - .await - .map_err(|e| EvalError::AsyncError(e.to_string()))?; - - std::fs::write(&path, bytes)?; - Ok(Value::Bool(true)) - }); - Ok(Value::Future(AsyncValue::new(task))) -} - -fn json_to_value(json: &serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::None, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Int(i) - } else if let Some(f) = n.as_f64() { - Value::Float(f) - } else { - Value::None - } - } - serde_json::Value::String(s) => Value::Str(s.clone()), - serde_json::Value::Array(arr) => Value::List(arr.iter().map(json_to_value).collect()), - serde_json::Value::Object(obj) => { - let fields: indexmap::IndexMap = obj - .iter() - .map(|(k, v)| (k.clone(), json_to_value(v))) - .collect(); - Value::Struct("object".to_string(), fields) - } - } -} - -fn value_to_json(val: &Value) -> serde_json::Value { - match val { - Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)), - Value::Float(n) => serde_json::Number::from_f64(*n) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - Value::Str(s) => serde_json::Value::String(s.clone()), - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Path(p) => serde_json::Value::String(p.display().to_string()), - Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()), - Value::Struct(_, fields) => { - let map: serde_json::Map = fields - .iter() - .map(|(k, v)| (k.clone(), value_to_json(v))) - .collect(); - serde_json::Value::Object(map) - } - Value::None => serde_json::Value::Null, - _ => serde_json::Value::Null, - } -} diff --git a/crates/doot-lang/src/builtins/collections.rs b/crates/doot-lang/src/builtins/collections.rs deleted file mode 100644 index 7aeb3b2..0000000 --- a/crates/doot-lang/src/builtins/collections.rs +++ /dev/null @@ -1,443 +0,0 @@ -use crate::ast::Expr; -use crate::evaluator::{EvalError, Evaluator, Value}; -use async_recursion::async_recursion; - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn map( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - Some(v) => { - return Err(EvalError::TypeError(format!( - "map expects list, got {}", - v.type_name() - ))); - } - None => { - return Err(EvalError::TypeError( - "map requires a list argument".to_string(), - )); - } - }; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let mut results = Vec::new(); - for item in list { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item); - } - let result = eval.eval_in_env(body, local_env).await?; - results.push(result); - } - Ok(Value::List(results)) - } - Some(Value::Function(func, func_env)) => { - let mut results = Vec::new(); - for item in list { - let result = eval.call_fn(func, func_env, &[item]).await?; - results.push(result); - } - Ok(Value::List(results)) - } - _ => Err(EvalError::TypeError("map requires a function".to_string())), - } -} - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn filter( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - Some(v) => { - return Err(EvalError::TypeError(format!( - "filter expects list, got {}", - v.type_name() - ))); - } - None => { - return Err(EvalError::TypeError( - "filter requires a list argument".to_string(), - )); - } - }; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let mut results = Vec::new(); - for item in list { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item.clone()); - } - let result = eval.eval_in_env(body, local_env).await?; - if result.is_truthy() { - results.push(item); - } - } - Ok(Value::List(results)) - } - Some(Value::Function(func, func_env)) => { - let mut results = Vec::new(); - for item in list { - let result = eval - .call_fn(func, func_env, std::slice::from_ref(&item)) - .await?; - if result.is_truthy() { - results.push(item); - } - } - Ok(Value::List(results)) - } - _ => Err(EvalError::TypeError( - "filter requires a function".to_string(), - )), - } -} - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn fold( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - Some(v) => { - return Err(EvalError::TypeError(format!( - "fold expects list, got {}", - v.type_name() - ))); - } - None => { - return Err(EvalError::TypeError( - "fold requires a list argument".to_string(), - )); - } - }; - - let init = args.get(1).cloned().unwrap_or(Value::None); - - match args.get(2) { - Some(Value::Lambda(params, body, env)) => { - let mut acc = init; - for item in list { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(acc_param) = params.first() { - local_env.define(acc_param.name.clone(), acc.clone()); - } - if let Some(item_param) = params.get(1) { - local_env.define(item_param.name.clone(), item); - } - acc = eval.eval_in_env(body, local_env).await?; - } - Ok(acc) - } - Some(Value::Function(func, func_env)) => { - let mut acc = init; - for item in list { - acc = eval.call_fn(func, func_env, &[acc, item]).await?; - } - Ok(acc) - } - _ => Err(EvalError::TypeError("fold requires a function".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn flatten(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items, - _ => return Err(EvalError::TypeError("flatten expects a list".to_string())), - }; - - let mut result = Vec::new(); - for item in list { - match item { - Value::List(inner) => result.extend(inner.clone()), - v => result.push(v.clone()), - } - } - Ok(Value::List(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn concat(args: &[Value]) -> Result { - let mut result = Vec::new(); - for arg in args { - match arg { - Value::List(items) => result.extend(items.clone()), - v => result.push(v.clone()), - } - } - Ok(Value::List(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn zip(args: &[Value]) -> Result { - if args.len() < 2 { - return Err(EvalError::TypeError( - "zip requires at least 2 lists".to_string(), - )); - } - - let lists: Result>, _> = args - .iter() - .map(|a| match a { - Value::List(items) => Ok(items), - _ => Err(EvalError::TypeError("zip expects lists".to_string())), - }) - .collect(); - let lists = lists?; - - let min_len = lists.iter().map(|l| l.len()).min().unwrap_or(0); - let mut result = Vec::new(); - - for i in 0..min_len { - let tuple: Vec = lists.iter().map(|l| l[i].clone()).collect(); - result.push(Value::List(tuple)); - } - - Ok(Value::List(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn enumerate(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items, - _ => return Err(EvalError::TypeError("enumerate expects a list".to_string())), - }; - - let result: Vec = list - .iter() - .enumerate() - .map(|(i, v)| Value::List(vec![Value::Int(i as i64), v.clone()])) - .collect(); - - Ok(Value::List(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn first(args: &[Value]) -> Result { - match args.first() { - Some(Value::List(items)) => Ok(items.first().cloned().unwrap_or(Value::None)), - _ => Err(EvalError::TypeError("first expects a list".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn last(args: &[Value]) -> Result { - match args.first() { - Some(Value::List(items)) => Ok(items.last().cloned().unwrap_or(Value::None)), - _ => Err(EvalError::TypeError("last expects a list".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn len(args: &[Value]) -> Result { - match args.first() { - Some(Value::List(items)) => Ok(Value::Int(items.len() as i64)), - Some(Value::Str(s)) => Ok(Value::Int(s.len() as i64)), - _ => Err(EvalError::TypeError( - "len expects a list or string".to_string(), - )), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn contains(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items, - _ => return Err(EvalError::TypeError("contains expects a list".to_string())), - }; - - let needle = args.get(1).unwrap_or(&Value::None); - Ok(Value::Bool(list.iter().any(|v| values_equal(v, needle)))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn unique(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items, - _ => return Err(EvalError::TypeError("unique expects a list".to_string())), - }; - - let mut seen = Vec::new(); - let mut result = Vec::new(); - - for item in list { - if !seen.iter().any(|s| values_equal(s, item)) { - seen.push(item.clone()); - result.push(item.clone()); - } - } - - Ok(Value::List(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn sort(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - _ => return Err(EvalError::TypeError("sort expects a list".to_string())), - }; - - let mut sortable: Vec<(Value, String)> = list - .into_iter() - .map(|v| { - let key = match &v { - Value::Int(n) => format!("{:020}", n), - Value::Float(n) => format!("{:020.10}", n), - Value::Str(s) => s.clone(), - _ => v.to_string_repr(), - }; - (v, key) - }) - .collect(); - - sortable.sort_by(|a, b| a.1.cmp(&b.1)); - Ok(Value::List(sortable.into_iter().map(|(v, _)| v).collect())) -} - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn sort_by( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - _ => return Err(EvalError::TypeError("sort_by expects a list".to_string())), - }; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let mut keyed: Vec<(Value, String)> = Vec::new(); - for item in list { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item.clone()); - } - let key = eval.eval_in_env(body, local_env).await?; - keyed.push((item, key.to_string_repr())); - } - keyed.sort_by(|a, b| a.1.cmp(&b.1)); - Ok(Value::List(keyed.into_iter().map(|(v, _)| v).collect())) - } - _ => Err(EvalError::TypeError( - "sort_by requires a function".to_string(), - )), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn reverse(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - _ => return Err(EvalError::TypeError("reverse expects a list".to_string())), - }; - - let mut reversed = list; - reversed.reverse(); - Ok(Value::List(reversed)) -} - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn seq( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - _ => return Err(EvalError::TypeError("seq expects a list".to_string())), - }; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let mut results = Vec::new(); - for item in list { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item); - } - let result = eval.eval_in_env(body, local_env).await?; - results.push(result); - } - Ok(Value::List(results)) - } - _ => Err(EvalError::TypeError("seq requires a function".to_string())), - } -} - -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all)] -pub async fn batch( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items.clone(), - _ => return Err(EvalError::TypeError("batch expects a list".to_string())), - }; - - let batch_size = match args.get(1) { - Some(Value::Int(n)) => *n as usize, - _ => { - return Err(EvalError::TypeError( - "batch requires batch size".to_string(), - )); - } - }; - - match args.get(2) { - Some(Value::Lambda(params, body, env)) => { - let mut results = Vec::new(); - for chunk in list.chunks(batch_size) { - for item in chunk { - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item.clone()); - } - let result = eval.eval_in_env(body, local_env).await?; - results.push(result); - } - } - Ok(Value::List(results)) - } - _ => Err(EvalError::TypeError( - "batch requires a function".to_string(), - )), - } -} - -fn values_equal(a: &Value, b: &Value) -> bool { - match (a, b) { - (Value::Int(x), Value::Int(y)) => x == y, - (Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON, - (Value::Str(x), Value::Str(y)) => x == y, - (Value::Bool(x), Value::Bool(y)) => x == y, - (Value::None, Value::None) => true, - (Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2, - _ => false, - } -} diff --git a/crates/doot-lang/src/builtins/crypto.rs b/crates/doot-lang/src/builtins/crypto.rs deleted file mode 100644 index 2f27953..0000000 --- a/crates/doot-lang/src/builtins/crypto.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::evaluator::{EvalError, Value}; -use std::path::PathBuf; - -#[tracing::instrument(level = "trace", skip_all)] -pub fn hash_file(args: &[Value]) -> Result { - let path = match args.first() { - Some(Value::Path(p)) => p.clone(), - Some(Value::Str(s)) => PathBuf::from(s), - _ => return Err(EvalError::TypeError("hash_file expects a path".to_string())), - }; - - let content = std::fs::read(&path)?; - let hash = blake3::hash(&content); - Ok(Value::Str(hash.to_hex().to_string())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn hash_str(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "hash_str expects a string".to_string(), - )); - } - }; - - let hash = blake3::hash(s.as_bytes()); - Ok(Value::Str(hash.to_hex().to_string())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn encrypt_age(args: &[Value]) -> Result { - let content = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "encrypt_age expects content string".to_string(), - )); - } - }; - - let recipient = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "encrypt_age requires recipient public key".to_string(), - )); - } - }; - - let recipient = recipient - .parse::() - .map_err(|e| EvalError::TypeError(format!("invalid recipient: {}", e)))?; - - let encryptor = age::Encryptor::with_recipients(vec![Box::new(recipient)]) - .expect("failed to create encryptor"); - - let mut encrypted = vec![]; - let mut writer = encryptor - .wrap_output(&mut encrypted) - .map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?; - - use std::io::Write; - writer - .write_all(content.as_bytes()) - .map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?; - writer - .finish() - .map_err(|e| EvalError::TypeError(format!("encryption error: {}", e)))?; - - Ok(Value::Str(base64_encode(&encrypted))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn decrypt_age(args: &[Value]) -> Result { - let encrypted = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "decrypt_age expects encrypted string".to_string(), - )); - } - }; - - let identity_str = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "decrypt_age requires identity".to_string(), - )); - } - }; - - let identity = identity_str - .parse::() - .map_err(|e| EvalError::TypeError(format!("invalid identity: {}", e)))?; - - let encrypted_bytes = base64_decode(encrypted) - .map_err(|e| EvalError::TypeError(format!("invalid base64: {}", e)))?; - - let decryptor = match age::Decryptor::new(&encrypted_bytes[..]) - .map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))? - { - age::Decryptor::Recipients(d) => d, - _ => { - return Err(EvalError::TypeError( - "unexpected decryptor type".to_string(), - )); - } - }; - - let mut decrypted = vec![]; - let mut reader = decryptor - .decrypt(std::iter::once(&identity as &dyn age::Identity)) - .map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))?; - - use std::io::Read; - reader - .read_to_end(&mut decrypted) - .map_err(|e| EvalError::TypeError(format!("decryption error: {}", e)))?; - - Ok(Value::Str(String::from_utf8(decrypted).map_err(|e| { - EvalError::TypeError(format!("invalid UTF-8: {}", e)) - })?)) -} - -pub fn base64_encode(data: &[u8]) -> String { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut result = String::new(); - - for chunk in data.chunks(3) { - let b0 = chunk[0] as usize; - let b1 = chunk.get(1).copied().unwrap_or(0) as usize; - let b2 = chunk.get(2).copied().unwrap_or(0) as usize; - - result.push(ALPHABET[b0 >> 2] as char); - result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char); - - if chunk.len() > 1 { - result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char); - } else { - result.push('='); - } - - if chunk.len() > 2 { - result.push(ALPHABET[b2 & 0x3f] as char); - } else { - result.push('='); - } - } - - result -} - -pub fn base64_decode(s: &str) -> Result, String> { - const DECODE: [i8; 256] = { - let mut table = [-1i8; 256]; - let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut i = 0; - while i < 64 { - table[alphabet[i] as usize] = i as i8; - i += 1; - } - table - }; - - let s = s.trim_end_matches('='); - let mut result = Vec::with_capacity(s.len() * 3 / 4); - - let chars: Vec = s.bytes().collect(); - for chunk in chars.chunks(4) { - let mut buf = [0u8; 4]; - for (i, &c) in chunk.iter().enumerate() { - let val = DECODE[c as usize]; - if val < 0 { - return Err(format!("invalid base64 character: {}", c as char)); - } - buf[i] = val as u8; - } - - result.push((buf[0] << 2) | (buf[1] >> 4)); - if chunk.len() > 2 { - result.push((buf[1] << 4) | (buf[2] >> 2)); - } - if chunk.len() > 3 { - result.push((buf[2] << 6) | buf[3]); - } - } - - Ok(result) -} diff --git a/crates/doot-lang/src/builtins/io.rs b/crates/doot-lang/src/builtins/io.rs deleted file mode 100644 index c899425..0000000 --- a/crates/doot-lang/src/builtins/io.rs +++ /dev/null @@ -1,428 +0,0 @@ -use crate::evaluator::{EvalError, Value}; -use std::path::PathBuf; -use std::process::Command; -use walkdir::WalkDir; - -#[tracing::instrument(level = "trace", skip_all)] -pub fn read_file(args: &[Value]) -> Result { - let path = get_path(args)?; - let content = std::fs::read_to_string(&path)?; - Ok(Value::Str(content)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn read_file_lines(args: &[Value]) -> Result { - let path = get_path(args)?; - let content = std::fs::read_to_string(&path)?; - let lines: Vec = content.lines().map(|l| Value::Str(l.to_string())).collect(); - Ok(Value::List(lines)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn write_file(args: &[Value]) -> Result { - let path = get_path(args)?; - let content = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "write_file requires content string".to_string(), - )); - } - }; - std::fs::write(&path, content)?; - Ok(Value::Bool(true)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn copy_file(args: &[Value]) -> Result { - let src = get_path(args)?; - let dst = match args.get(1) { - Some(Value::Path(p)) => p.clone(), - Some(Value::Str(s)) => expand_path(s), - _ => { - return Err(EvalError::TypeError( - "copy_file requires destination path".to_string(), - )); - } - }; - std::fs::copy(&src, &dst)?; - Ok(Value::Bool(true)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn delete_file(args: &[Value]) -> Result { - let path = get_path(args)?; - std::fs::remove_file(&path)?; - Ok(Value::Bool(true)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn file_exists(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Bool(path.is_file())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn dir_exists(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Bool(path.is_dir())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn create_dir_all(args: &[Value]) -> Result { - let path = get_path(args)?; - std::fs::create_dir_all(&path)?; - Ok(Value::Bool(true)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn list_dir(args: &[Value]) -> Result { - let path = get_path(args)?; - let entries: Vec = std::fs::read_dir(&path)? - .filter_map(|e| e.ok()) - .map(|e| Value::Path(e.path())) - .collect(); - Ok(Value::List(entries)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn walk_dir(args: &[Value]) -> Result { - let path = get_path(args)?; - let entries: Vec = WalkDir::new(&path) - .into_iter() - .filter_map(|e| e.ok()) - .map(|e| Value::Path(e.path().to_path_buf())) - .collect(); - Ok(Value::List(entries)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn temp_dir() -> Result { - Ok(Value::Path(std::env::temp_dir())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn temp_file(args: &[Value]) -> Result { - let prefix = match args.first() { - Some(Value::Str(s)) => s.as_str(), - _ => "doot", - }; - let suffix = match args.get(1) { - Some(Value::Str(s)) => s.as_str(), - _ => "", - }; - let path = std::env::temp_dir().join(format!("{}_{}{}", prefix, uuid_simple(), suffix)); - Ok(Value::Path(path)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn is_symlink(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Bool(path.is_symlink())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn read_link(args: &[Value]) -> Result { - let path = get_path(args)?; - let target = std::fs::read_link(&path)?; - Ok(Value::Path(target)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn path_join(args: &[Value]) -> Result { - let mut result = PathBuf::new(); - for arg in args { - match arg { - Value::Path(p) => result.push(p), - Value::Str(s) => result.push(s), - _ => { - return Err(EvalError::TypeError( - "path_join expects paths or strings".to_string(), - )); - } - } - } - Ok(Value::Path(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn path_parent(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Path( - path.parent().map(|p| p.to_path_buf()).unwrap_or_default(), - )) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn path_filename(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Str( - path.file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(), - )) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn path_extension(args: &[Value]) -> Result { - let path = get_path(args)?; - Ok(Value::Str( - path.extension() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(), - )) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn home_dir() -> Result { - Ok(Value::Path(doot_utils::xdg::home_dir())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn config_dir() -> Result { - Ok(Value::Path(doot_utils::xdg::config_home())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn data_dir() -> Result { - Ok(Value::Path(doot_utils::xdg::data_home())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn cache_dir() -> Result { - Ok(Value::Path(doot_utils::xdg::cache_home())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn exec(args: &[Value]) -> Result { - let cmd = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "exec expects a command string".to_string(), - )); - } - }; - - let output = Command::new("sh").arg("-c").arg(cmd).output()?; - - Ok(Value::Str( - String::from_utf8_lossy(&output.stdout).to_string(), - )) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn exec_with_status(args: &[Value]) -> Result { - let cmd = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "exec_with_status expects a command string".to_string(), - )); - } - }; - - let status = Command::new("sh").arg("-c").arg(cmd).status()?; - - Ok(Value::Int(status.code().unwrap_or(-1) as i64)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn shell(args: &[Value]) -> Result { - exec(args) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn which(args: &[Value]) -> Result { - let cmd = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "which expects a command name".to_string(), - )); - } - }; - - let output = Command::new("which").arg(cmd).output()?; - - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(Value::Path(PathBuf::from(path))) - } else { - Ok(Value::None) - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn to_json(args: &[Value]) -> Result { - let val = args.first().unwrap_or(&Value::None); - let json = value_to_json(val); - Ok(Value::Str(json.to_string())) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn from_json(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "from_json expects a string".to_string(), - )); - } - }; - - let json: serde_json::Value = serde_json::from_str(s) - .map_err(|e| EvalError::TypeError(format!("invalid JSON: {}", e)))?; - - Ok(json_to_value(&json)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn to_toml(args: &[Value]) -> Result { - let val = args.first().unwrap_or(&Value::None); - let toml_val = value_to_toml(val); - let s = toml::to_string(&toml_val) - .map_err(|e| EvalError::TypeError(format!("TOML serialization error: {}", e)))?; - Ok(Value::Str(s)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn from_toml(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "from_toml expects a string".to_string(), - )); - } - }; - - let toml_val: toml::Value = - toml::from_str(s).map_err(|e| EvalError::TypeError(format!("invalid TOML: {}", e)))?; - - Ok(toml_to_value(&toml_val)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn to_yaml(args: &[Value]) -> Result { - let val = args.first().unwrap_or(&Value::None); - let json = value_to_json(val); - Ok(Value::Str( - serde_json::to_string_pretty(&json).unwrap_or_default(), - )) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn from_yaml(args: &[Value]) -> Result { - from_json(args) -} - -fn get_path(args: &[Value]) -> Result { - match args.first() { - Some(Value::Path(p)) => Ok(p.clone()), - Some(Value::Str(s)) => Ok(expand_path(s)), - _ => Err(EvalError::TypeError("expected path or string".to_string())), - } -} - -fn expand_path(s: &str) -> PathBuf { - if let Some(stripped) = s.strip_prefix('~') { - let home = doot_utils::xdg::home_dir(); - home.join(stripped.strip_prefix('/').unwrap_or(stripped)) - } else { - PathBuf::from(s) - } -} - -fn uuid_simple() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{:x}", nanos) -} - -fn value_to_json(val: &Value) -> serde_json::Value { - match val { - Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)), - Value::Float(n) => serde_json::Number::from_f64(*n) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - Value::Str(s) => serde_json::Value::String(s.clone()), - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Path(p) => serde_json::Value::String(p.display().to_string()), - Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()), - Value::Struct(_, fields) => { - let map: serde_json::Map = fields - .iter() - .map(|(k, v)| (k.clone(), value_to_json(v))) - .collect(); - serde_json::Value::Object(map) - } - Value::None => serde_json::Value::Null, - _ => serde_json::Value::Null, - } -} - -fn json_to_value(json: &serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::None, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Int(i) - } else if let Some(f) = n.as_f64() { - Value::Float(f) - } else { - Value::None - } - } - serde_json::Value::String(s) => Value::Str(s.clone()), - serde_json::Value::Array(arr) => Value::List(arr.iter().map(json_to_value).collect()), - serde_json::Value::Object(obj) => { - let fields: indexmap::IndexMap = obj - .iter() - .map(|(k, v)| (k.clone(), json_to_value(v))) - .collect(); - Value::Struct("object".to_string(), fields) - } - } -} - -fn value_to_toml(val: &Value) -> toml::Value { - match val { - Value::Int(n) => toml::Value::Integer(*n), - Value::Float(n) => toml::Value::Float(*n), - Value::Str(s) => toml::Value::String(s.clone()), - Value::Bool(b) => toml::Value::Boolean(*b), - Value::Path(p) => toml::Value::String(p.display().to_string()), - Value::List(items) => toml::Value::Array(items.iter().map(value_to_toml).collect()), - Value::Struct(_, fields) => { - let map: toml::map::Map = fields - .iter() - .map(|(k, v)| (k.clone(), value_to_toml(v))) - .collect(); - toml::Value::Table(map) - } - _ => toml::Value::String(String::new()), - } -} - -fn toml_to_value(toml: &toml::Value) -> Value { - match toml { - toml::Value::Boolean(b) => Value::Bool(*b), - toml::Value::Integer(i) => Value::Int(*i), - toml::Value::Float(f) => Value::Float(*f), - toml::Value::String(s) => Value::Str(s.clone()), - toml::Value::Array(arr) => Value::List(arr.iter().map(toml_to_value).collect()), - toml::Value::Table(table) => { - let fields: indexmap::IndexMap = table - .iter() - .map(|(k, v)| (k.clone(), toml_to_value(v))) - .collect(); - Value::Struct("table".to_string(), fields) - } - toml::Value::Datetime(dt) => Value::Str(dt.to_string()), - } -} diff --git a/crates/doot-lang/src/builtins/mod.rs b/crates/doot-lang/src/builtins/mod.rs deleted file mode 100644 index ea04e9a..0000000 --- a/crates/doot-lang/src/builtins/mod.rs +++ /dev/null @@ -1,477 +0,0 @@ -//! Built-in functions for the doot language. - -pub mod async_ops; -pub mod collections; -pub mod crypto; -pub mod io; -pub mod parallel; -pub mod strings; - -use crate::ast::Expr; -use crate::evaluator::{EvalError, Evaluator, Value}; -use async_recursion::async_recursion; - -/// Dispatches a built-in function call. -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all, fields(name))] -pub async fn call_builtin( - eval: &mut Evaluator, - name: &str, - args: &[Value], - arg_exprs: &[Expr], -) -> Result { - match name { - // Collections (async - take &mut Evaluator) - "map" => collections::map(eval, args, arg_exprs).await, - "filter" => collections::filter(eval, args, arg_exprs).await, - "fold" => collections::fold(eval, args, arg_exprs).await, - "sort_by" => collections::sort_by(eval, args, arg_exprs).await, - "seq" => collections::seq(eval, args, arg_exprs).await, - "batch" => collections::batch(eval, args, arg_exprs).await, - - // Collections (sync) - "flatten" => collections::flatten(args), - "concat" => collections::concat(args), - "zip" => collections::zip(args), - "enumerate" => collections::enumerate(args), - "first" => collections::first(args), - "last" => collections::last(args), - "len" => collections::len(args), - "contains" => collections::contains(args), - "unique" => collections::unique(args), - "sort" => collections::sort(args), - "reverse" => collections::reverse(args), - - // Strings - "join" => strings::join(args), - "split" => strings::split(args), - "upper" => strings::upper(args), - "lower" => strings::lower(args), - "trim" => strings::trim(args), - "replace" => strings::replace(args), - "starts_with" => strings::starts_with(args), - "ends_with" => strings::ends_with(args), - "format" => strings::format(args), - - // Options - "unwrap" => options_unwrap(args), - "unwrap_or" => options_unwrap_or(args), - "is_some" => options_is_some(args), - "is_none" => options_is_none(args), - - // I/O - "read_file" => io::read_file(args), - "read_file_lines" => io::read_file_lines(args), - "write_file" => io::write_file(args), - "copy_file" => io::copy_file(args), - "delete_file" => io::delete_file(args), - "file_exists" => io::file_exists(args), - "dir_exists" => io::dir_exists(args), - "create_dir_all" => io::create_dir_all(args), - "list_dir" => io::list_dir(args), - "walk_dir" => io::walk_dir(args), - "temp_dir" => io::temp_dir(), - "temp_file" => io::temp_file(args), - "is_symlink" => io::is_symlink(args), - "read_link" => io::read_link(args), - - // Paths - "path_join" => io::path_join(args), - "path_parent" => io::path_parent(args), - "path_filename" => io::path_filename(args), - "path_extension" => io::path_extension(args), - "home_dir" => io::home_dir(), - "config_dir" => io::config_dir(), - "data_dir" => io::data_dir(), - "cache_dir" => io::cache_dir(), - - // Process - "exec" => io::exec(args), - "exec_with_status" => io::exec_with_status(args), - "shell" => io::shell(args), - "which" => io::which(args), - - // Serialization - "to_json" => io::to_json(args), - "from_json" => io::from_json(args), - "to_toml" => io::to_toml(args), - "from_toml" => io::from_toml(args), - "to_yaml" => io::to_yaml(args), - "from_yaml" => io::from_yaml(args), - - // Crypto - "hash_file" => crypto::hash_file(args), - "hash_str" => crypto::hash_str(args), - "encrypt_age" => crypto::encrypt_age(args), - "decrypt_age" => crypto::decrypt_age(args), - - // Parallel (rayon) - "par_map" => parallel::par_map(eval, args, arg_exprs), - "par_filter" => parallel::par_filter(eval, args, arg_exprs), - "par_sort_by" => parallel::par_sort_by(eval, args, arg_exprs), - "par_batch" => parallel::par_batch(eval, args, arg_exprs), - "par_flat_map" => parallel::par_flat_map(eval, args, arg_exprs), - "par_any" => parallel::par_any(eval, args, arg_exprs), - "par_all" => parallel::par_all(eval, args, arg_exprs), - "par_find" => parallel::par_find(eval, args, arg_exprs), - "par_partition" => parallel::par_partition(eval, args, arg_exprs), - "par_reduce" => parallel::par_reduce(eval, args, arg_exprs), - "par_min_by" => parallel::par_min_by(eval, args, arg_exprs), - "par_max_by" => parallel::par_max_by(eval, args, arg_exprs), - "par_for_each" => parallel::par_for_each(eval, args, arg_exprs), - - // Async - "all" => async_ops::all(args).await, - "race" => async_ops::race(args).await, - - // Network - "fetch" => async_ops::fetch(args).await, - "fetch_json" => async_ops::fetch_json(args).await, - "fetch_bytes" => async_ops::fetch_bytes(args).await, - "post" => async_ops::post(args).await, - "post_json" => async_ops::post_json(args).await, - "download" => async_ops::download(args).await, - - // Environment - "env" => env_get(args), - - // Debug - "print" => print_values(args), - "println" => println_values(args), - "dbg" => dbg_values(args), - - _ => Err(EvalError::UndefinedFunction(name.to_string())), - } -} - -/// Dispatches a method call on a value. -#[async_recursion(?Send)] -#[tracing::instrument(level = "trace", skip_all, fields(method))] -pub async fn call_method( - eval: &mut Evaluator, - obj: &Value, - method: &str, - args: &[Value], - arg_exprs: &[Expr], -) -> Result { - match obj { - Value::List(items) => match method { - "len" => Ok(Value::Int(items.len() as i64)), - "first" => Ok(items.first().cloned().unwrap_or(Value::None)), - "last" => Ok(items.last().cloned().unwrap_or(Value::None)), - "contains" => { - if let Some(needle) = args.first() { - Ok(Value::Bool(items.iter().any(|v| values_equal(v, needle)))) - } else { - Ok(Value::Bool(false)) - } - } - "map" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - collections::map(eval, &all_args, arg_exprs).await - } - "filter" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - collections::filter(eval, &all_args, arg_exprs).await - } - "fold" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - collections::fold(eval, &all_args, arg_exprs).await - } - "join" => { - let sep = args - .first() - .map(|v| match v { - Value::Str(s) => s.as_str(), - _ => "", - }) - .unwrap_or(""); - let result = items - .iter() - .map(|v| v.to_string_repr()) - .collect::>() - .join(sep); - Ok(Value::Str(result)) - } - "sort" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - collections::sort(&all_args) - } - "reverse" => { - let mut reversed = items.clone(); - reversed.reverse(); - Ok(Value::List(reversed)) - } - "unique" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - collections::unique(&all_args) - } - "par_map" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_map(eval, &all_args, arg_exprs) - } - "par_filter" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_filter(eval, &all_args, arg_exprs) - } - "par_flat_map" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_flat_map(eval, &all_args, arg_exprs) - } - "par_sort_by" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_sort_by(eval, &all_args, arg_exprs) - } - "par_any" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_any(eval, &all_args, arg_exprs) - } - "par_all" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_all(eval, &all_args, arg_exprs) - } - "par_find" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_find(eval, &all_args, arg_exprs) - } - "par_partition" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_partition(eval, &all_args, arg_exprs) - } - "par_reduce" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_reduce(eval, &all_args, arg_exprs) - } - "par_min_by" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_min_by(eval, &all_args, arg_exprs) - } - "par_max_by" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_max_by(eval, &all_args, arg_exprs) - } - "par_batch" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_batch(eval, &all_args, arg_exprs) - } - "par_for_each" => { - let all_args = std::iter::once(obj.clone()) - .chain(args.iter().cloned()) - .collect::>(); - parallel::par_for_each(eval, &all_args, arg_exprs) - } - _ => Err(EvalError::UndefinedFunction(format!("list.{}", method))), - }, - - Value::Str(s) => match method { - "len" => Ok(Value::Int(s.len() as i64)), - "upper" => Ok(Value::Str(s.to_uppercase())), - "lower" => Ok(Value::Str(s.to_lowercase())), - "trim" => Ok(Value::Str(s.trim().to_string())), - "split" => { - let sep = args - .first() - .map(|v| match v { - Value::Str(s) => s.as_str(), - _ => " ", - }) - .unwrap_or(" "); - let parts: Vec = s.split(sep).map(|p| Value::Str(p.to_string())).collect(); - Ok(Value::List(parts)) - } - "replace" => { - if args.len() >= 2 - && let (Value::Str(from), Value::Str(to)) = (&args[0], &args[1]) - { - return Ok(Value::Str(s.replace(from, to))); - } - Ok(Value::Str(s.clone())) - } - "starts_with" => { - if let Some(Value::Str(prefix)) = args.first() { - Ok(Value::Bool(s.starts_with(prefix))) - } else { - Ok(Value::Bool(false)) - } - } - "ends_with" => { - if let Some(Value::Str(suffix)) = args.first() { - Ok(Value::Bool(s.ends_with(suffix))) - } else { - Ok(Value::Bool(false)) - } - } - "contains" => { - if let Some(Value::Str(needle)) = args.first() { - Ok(Value::Bool(s.contains(needle))) - } else { - Ok(Value::Bool(false)) - } - } - _ => Err(EvalError::UndefinedFunction(format!("str.{}", method))), - }, - - Value::Path(p) => match method { - "parent" => Ok(Value::Path( - p.parent().map(|p| p.to_path_buf()).unwrap_or_default(), - )), - "filename" => Ok(Value::Str( - p.file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(), - )), - "extension" => Ok(Value::Str( - p.extension() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(), - )), - "exists" => Ok(Value::Bool(p.exists())), - "is_file" => Ok(Value::Bool(p.is_file())), - "is_dir" => Ok(Value::Bool(p.is_dir())), - "join" => { - if let Some(Value::Str(other)) = args.first() { - Ok(Value::Path(p.join(other))) - } else if let Some(Value::Path(other)) = args.first() { - Ok(Value::Path(p.join(other))) - } else { - Ok(Value::Path(p.clone())) - } - } - _ => Err(EvalError::UndefinedFunction(format!("path.{}", method))), - }, - - Value::Struct(name, fields) => { - if let Some(decl) = eval.env().get_struct(name).cloned() { - for m in &decl.methods { - if m.name == method { - let mut method_args = vec![obj.clone()]; - method_args.extend(args.iter().cloned()); - let env_clone = eval.env().clone(); - return eval.call_function(m, &env_clone, &method_args).await; - } - } - } - if let Some(field) = fields.get(method) - && let Value::Function(func, env) = field - { - return eval.call_function(func, env, args).await; - } - Err(EvalError::FieldNotFound { - ty: name.clone(), - field: method.to_string(), - }) - } - - _ => Err(EvalError::TypeError(format!( - "cannot call method {} on {}", - method, - obj.type_name() - ))), - } -} - -fn values_equal(a: &Value, b: &Value) -> bool { - match (a, b) { - (Value::Int(x), Value::Int(y)) => x == y, - (Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON, - (Value::Str(x), Value::Str(y)) => x == y, - (Value::Bool(x), Value::Bool(y)) => x == y, - (Value::None, Value::None) => true, - (Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2, - _ => false, - } -} - -fn options_unwrap(args: &[Value]) -> Result { - match args.first() { - Some(Value::None) => Err(EvalError::TypeError("unwrap called on none".to_string())), - Some(v) => Ok(v.clone()), - None => Err(EvalError::TypeError( - "unwrap requires an argument".to_string(), - )), - } -} - -fn options_unwrap_or(args: &[Value]) -> Result { - match args.first() { - Some(Value::None) => Ok(args.get(1).cloned().unwrap_or(Value::None)), - Some(v) => Ok(v.clone()), - None => Ok(args.get(1).cloned().unwrap_or(Value::None)), - } -} - -fn options_is_some(args: &[Value]) -> Result { - Ok(Value::Bool(!matches!( - args.first(), - Some(Value::None) | None - ))) -} - -fn options_is_none(args: &[Value]) -> Result { - Ok(Value::Bool(matches!( - args.first(), - Some(Value::None) | None - ))) -} - -fn env_get(args: &[Value]) -> Result { - if let Some(Value::Str(key)) = args.first() { - Ok(std::env::var(key).map(Value::Str).unwrap_or(Value::None)) - } else { - Ok(Value::None) - } -} - -fn print_values(args: &[Value]) -> Result { - let output: Vec = args.iter().map(|v| v.to_string_repr()).collect(); - print!("{}", output.join(" ")); - Ok(Value::None) -} - -fn println_values(args: &[Value]) -> Result { - let output: Vec = args.iter().map(|v| v.to_string_repr()).collect(); - println!("{}", output.join(" ")); - Ok(Value::None) -} - -fn dbg_values(args: &[Value]) -> Result { - for (i, arg) in args.iter().enumerate() { - eprintln!("[dbg {}] {:?}", i, arg); - } - // Return the last argument (or None) for easy chaining - Ok(args.last().cloned().unwrap_or(Value::None)) -} diff --git a/crates/doot-lang/src/builtins/parallel.rs b/crates/doot-lang/src/builtins/parallel.rs deleted file mode 100644 index 701ffd4..0000000 --- a/crates/doot-lang/src/builtins/parallel.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Parallel collection builtins using rayon. -//! -//! Each function clones the Evaluator per rayon task. Side effects (env mutations) -//! inside parallel callbacks are isolated per clone and lost after execution. -//! I/O side effects (file writes, exec, etc.) still happen. - -use crate::ast::Expr; -use crate::evaluator::{EvalError, Evaluator, Value}; -use rayon::prelude::*; - -/// Helper: evaluate a lambda body with one parameter bound, using a cloned evaluator. -fn eval_lambda_sync( - eval: &Evaluator, - params: &[crate::ast::FnParam], - body: &Expr, - env: &crate::evaluator::Env, - item: Value, -) -> Result { - let mut local_eval = eval.clone(); - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(param) = params.first() { - local_env.define(param.name.clone(), item); - } - smol::block_on(local_eval.eval_in_env(body, local_env)) -} - -/// Helper: call a named function with args, using a cloned evaluator. -fn call_fn_sync( - eval: &Evaluator, - func: &crate::ast::FnDecl, - func_env: &crate::evaluator::Env, - args: &[Value], -) -> Result { - let mut local_eval = eval.clone(); - smol::block_on(local_eval.call_fn(func, func_env, args)) -} - -/// Extract a list from the first argument, or return a TypeError. -fn extract_list(args: &[Value], fn_name: &str) -> Result, EvalError> { - match args.first() { - Some(Value::List(items)) => Ok(items.clone()), - Some(v) => Err(EvalError::TypeError(format!( - "{} expects list, got {}", - fn_name, - v.type_name() - ))), - None => Err(EvalError::TypeError(format!( - "{} requires a list argument", - fn_name - ))), - } -} - -// --------------------------------------------------------------------------- -// par_map -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_map( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_map")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let results: Result, EvalError> = list - .into_par_iter() - .map(|item| eval_lambda_sync(eval, params, body, env, item)) - .collect(); - Ok(Value::List(results?)) - } - Some(Value::Function(func, func_env)) => { - let results: Result, EvalError> = list - .into_par_iter() - .map(|item| call_fn_sync(eval, func, func_env, &[item])) - .collect(); - Ok(Value::List(results?)) - } - _ => Err(EvalError::TypeError( - "par_map requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_filter -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_filter( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_filter")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let results: Result, EvalError> = list - .into_par_iter() - .map(|item| { - let keep = eval_lambda_sync(eval, params, body, env, item.clone())?; - Ok((item, keep.is_truthy())) - }) - .collect::, _>>() - .map(|pairs| { - pairs - .into_iter() - .filter(|(_, keep)| *keep) - .map(|(v, _)| v) - .collect() - }); - Ok(Value::List(results?)) - } - Some(Value::Function(func, func_env)) => { - let results: Result, EvalError> = list - .into_par_iter() - .map(|item| { - let keep = call_fn_sync(eval, func, func_env, std::slice::from_ref(&item))?; - Ok((item, keep.is_truthy())) - }) - .collect::, _>>() - .map(|pairs| { - pairs - .into_iter() - .filter(|(_, keep)| *keep) - .map(|(v, _)| v) - .collect() - }); - Ok(Value::List(results?)) - } - _ => Err(EvalError::TypeError( - "par_filter requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_sort_by -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_sort_by( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_sort_by")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // Compute sort keys in parallel - let keyed: Result, EvalError> = list - .into_par_iter() - .map(|item| { - let key = eval_lambda_sync(eval, params, body, env, item.clone())?; - Ok((item, key.to_string_repr())) - }) - .collect(); - let mut keyed = keyed?; - // Sort sequentially (fast, already have keys) - keyed.sort_by(|a, b| a.1.cmp(&b.1)); - Ok(Value::List(keyed.into_iter().map(|(v, _)| v).collect())) - } - _ => Err(EvalError::TypeError( - "par_sort_by requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_batch -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_batch( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_batch")?; - - let batch_size = match args.get(1) { - Some(Value::Int(n)) => *n as usize, - _ => { - return Err(EvalError::TypeError( - "par_batch requires batch size".to_string(), - )); - } - }; - - match args.get(2) { - Some(Value::Lambda(params, body, env)) => { - let mut all_results = Vec::new(); - // Process chunks sequentially, items within each chunk in parallel - for chunk in list.chunks(batch_size) { - let chunk_results: Result, EvalError> = chunk - .into_par_iter() - .map(|item| eval_lambda_sync(eval, params, body, env, item.clone())) - .collect(); - all_results.extend(chunk_results?); - } - Ok(Value::List(all_results)) - } - _ => Err(EvalError::TypeError( - "par_batch requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_flat_map -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_flat_map( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_flat_map")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let results: Result>, EvalError> = list - .into_par_iter() - .map(|item| { - let val = eval_lambda_sync(eval, params, body, env, item)?; - match val { - Value::List(inner) => Ok(inner), - v => Ok(vec![v]), - } - }) - .collect(); - Ok(Value::List(results?.into_iter().flatten().collect())) - } - Some(Value::Function(func, func_env)) => { - let results: Result>, EvalError> = list - .into_par_iter() - .map(|item| { - let val = call_fn_sync(eval, func, func_env, &[item])?; - match val { - Value::List(inner) => Ok(inner), - v => Ok(vec![v]), - } - }) - .collect(); - Ok(Value::List(results?.into_iter().flatten().collect())) - } - _ => Err(EvalError::TypeError( - "par_flat_map requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_any -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_any( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_any")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // Use find_any for early exit on first match - let found = list.into_par_iter().find_any(|item| { - eval_lambda_sync(eval, params, body, env, item.clone()) - .map(|v| v.is_truthy()) - .unwrap_or(false) - }); - Ok(Value::Bool(found.is_some())) - } - Some(Value::Function(func, func_env)) => { - let found = list.into_par_iter().find_any(|item| { - call_fn_sync(eval, func, func_env, std::slice::from_ref(item)) - .map(|v| v.is_truthy()) - .unwrap_or(false) - }); - Ok(Value::Bool(found.is_some())) - } - _ => Err(EvalError::TypeError( - "par_any requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_all -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_all( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_all")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // find_any that does NOT match = early exit on first failure - let failed = list.into_par_iter().find_any(|item| { - eval_lambda_sync(eval, params, body, env, item.clone()) - .map(|v| !v.is_truthy()) - .unwrap_or(true) // error counts as failure - }); - Ok(Value::Bool(failed.is_none())) - } - Some(Value::Function(func, func_env)) => { - let failed = list.into_par_iter().find_any(|item| { - call_fn_sync(eval, func, func_env, std::slice::from_ref(item)) - .map(|v| !v.is_truthy()) - .unwrap_or(true) - }); - Ok(Value::Bool(failed.is_none())) - } - _ => Err(EvalError::TypeError( - "par_all requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_find -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_find( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_find")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let found = list.into_par_iter().find_first(|item| { - eval_lambda_sync(eval, params, body, env, item.clone()) - .map(|v| v.is_truthy()) - .unwrap_or(false) - }); - Ok(found.unwrap_or(Value::None)) - } - Some(Value::Function(func, func_env)) => { - let found = list.into_par_iter().find_first(|item| { - call_fn_sync(eval, func, func_env, std::slice::from_ref(item)) - .map(|v| v.is_truthy()) - .unwrap_or(false) - }); - Ok(found.unwrap_or(Value::None)) - } - _ => Err(EvalError::TypeError( - "par_find requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_partition -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_partition( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_partition")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - let (matches, rest): (Vec, Vec) = list - .into_par_iter() - .map(|item| { - let keep = eval_lambda_sync(eval, params, body, env, item.clone()) - .map(|v| v.is_truthy()) - .unwrap_or(false); - (item, keep) - }) - .partition_map(|(item, keep)| { - if keep { - rayon::iter::Either::Left(item) - } else { - rayon::iter::Either::Right(item) - } - }); - Ok(Value::List(vec![Value::List(matches), Value::List(rest)])) - } - Some(Value::Function(func, func_env)) => { - let (matches, rest): (Vec, Vec) = list - .into_par_iter() - .map(|item| { - let keep = call_fn_sync(eval, func, func_env, std::slice::from_ref(&item)) - .map(|v| v.is_truthy()) - .unwrap_or(false); - (item, keep) - }) - .partition_map(|(item, keep)| { - if keep { - rayon::iter::Either::Left(item) - } else { - rayon::iter::Either::Right(item) - } - }); - Ok(Value::List(vec![Value::List(matches), Value::List(rest)])) - } - _ => Err(EvalError::TypeError( - "par_partition requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_reduce -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_reduce( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_reduce")?; - - if list.is_empty() { - return Ok(Value::None); - } - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // rayon reduce requires the op to be associative. - // We evaluate the lambda with (acc, item) params in parallel. - let result = list.into_par_iter().reduce_with(|acc, item| { - let mut local_eval = eval.clone(); - let mut local_env = env.clone(); - local_env.push_scope(); - if let Some(acc_param) = params.first() { - local_env.define(acc_param.name.clone(), acc); - } - if let Some(item_param) = params.get(1) { - local_env.define(item_param.name.clone(), item); - } - smol::block_on(local_eval.eval_in_env(body, local_env)).unwrap_or(Value::None) - }); - Ok(result.unwrap_or(Value::None)) - } - Some(Value::Function(func, func_env)) => { - let result = list.into_par_iter().reduce_with(|acc, item| { - call_fn_sync(eval, func, func_env, &[acc, item]).unwrap_or(Value::None) - }); - Ok(result.unwrap_or(Value::None)) - } - _ => Err(EvalError::TypeError( - "par_reduce requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_min_by -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_min_by( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_min_by")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // Compute keys in parallel, then find min - let keyed: Result, EvalError> = list - .into_par_iter() - .map(|item| { - let key = eval_lambda_sync(eval, params, body, env, item.clone())?; - Ok((item, key.to_string_repr())) - }) - .collect(); - let keyed = keyed?; - let min = keyed.into_iter().min_by(|a, b| a.1.cmp(&b.1)); - Ok(min.map(|(v, _)| v).unwrap_or(Value::None)) - } - _ => Err(EvalError::TypeError( - "par_min_by requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_max_by -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_max_by( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_max_by")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // Compute keys in parallel, then find max - let keyed: Result, EvalError> = list - .into_par_iter() - .map(|item| { - let key = eval_lambda_sync(eval, params, body, env, item.clone())?; - Ok((item, key.to_string_repr())) - }) - .collect(); - let keyed = keyed?; - let max = keyed.into_iter().max_by(|a, b| a.1.cmp(&b.1)); - Ok(max.map(|(v, _)| v).unwrap_or(Value::None)) - } - _ => Err(EvalError::TypeError( - "par_max_by requires a function".to_string(), - )), - } -} - -// --------------------------------------------------------------------------- -// par_for_each -// --------------------------------------------------------------------------- - -#[tracing::instrument(level = "trace", skip_all)] -pub fn par_for_each( - eval: &mut Evaluator, - args: &[Value], - _arg_exprs: &[Expr], -) -> Result { - let list = extract_list(args, "par_for_each")?; - - match args.get(1) { - Some(Value::Lambda(params, body, env)) => { - // Collect errors from parallel execution - let errors: Vec = list - .into_par_iter() - .filter_map(|item| eval_lambda_sync(eval, params, body, env, item).err()) - .collect(); - if let Some(err) = errors.into_iter().next() { - return Err(err); - } - Ok(Value::None) - } - Some(Value::Function(func, func_env)) => { - let errors: Vec = list - .into_par_iter() - .filter_map(|item| call_fn_sync(eval, func, func_env, &[item]).err()) - .collect(); - if let Some(err) = errors.into_iter().next() { - return Err(err); - } - Ok(Value::None) - } - _ => Err(EvalError::TypeError( - "par_for_each requires a function".to_string(), - )), - } -} diff --git a/crates/doot-lang/src/builtins/strings.rs b/crates/doot-lang/src/builtins/strings.rs deleted file mode 100644 index dc484f2..0000000 --- a/crates/doot-lang/src/builtins/strings.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::evaluator::{EvalError, Value}; - -#[tracing::instrument(level = "trace", skip_all)] -pub fn join(args: &[Value]) -> Result { - let list = match args.first() { - Some(Value::List(items)) => items, - _ => return Err(EvalError::TypeError("join expects a list".to_string())), - }; - - let sep = match args.get(1) { - Some(Value::Str(s)) => s.as_str(), - _ => "", - }; - - let result = list - .iter() - .map(|v| v.to_string_repr()) - .collect::>() - .join(sep); - - Ok(Value::Str(result)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn split(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => return Err(EvalError::TypeError("split expects a string".to_string())), - }; - - let sep = match args.get(1) { - Some(Value::Str(s)) => s.as_str(), - _ => " ", - }; - - let parts: Vec = s.split(sep).map(|p| Value::Str(p.to_string())).collect(); - Ok(Value::List(parts)) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn upper(args: &[Value]) -> Result { - match args.first() { - Some(Value::Str(s)) => Ok(Value::Str(s.to_uppercase())), - _ => Err(EvalError::TypeError("upper expects a string".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn lower(args: &[Value]) -> Result { - match args.first() { - Some(Value::Str(s)) => Ok(Value::Str(s.to_lowercase())), - _ => Err(EvalError::TypeError("lower expects a string".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn trim(args: &[Value]) -> Result { - match args.first() { - Some(Value::Str(s)) => Ok(Value::Str(s.trim().to_string())), - _ => Err(EvalError::TypeError("trim expects a string".to_string())), - } -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn replace(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => return Err(EvalError::TypeError("replace expects a string".to_string())), - }; - - let from = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "replace requires from string".to_string(), - )); - } - }; - - let to = match args.get(2) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "replace requires to string".to_string(), - )); - } - }; - - Ok(Value::Str(s.replace(from.as_str(), to.as_str()))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn starts_with(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "starts_with expects a string".to_string(), - )); - } - }; - - let prefix = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "starts_with requires prefix".to_string(), - )); - } - }; - - Ok(Value::Bool(s.starts_with(prefix.as_str()))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn ends_with(args: &[Value]) -> Result { - let s = match args.first() { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "ends_with expects a string".to_string(), - )); - } - }; - - let suffix = match args.get(1) { - Some(Value::Str(s)) => s, - _ => { - return Err(EvalError::TypeError( - "ends_with requires suffix".to_string(), - )); - } - }; - - Ok(Value::Bool(s.ends_with(suffix.as_str()))) -} - -#[tracing::instrument(level = "trace", skip_all)] -pub fn format(args: &[Value]) -> Result { - let template = match args.first() { - Some(Value::Str(s)) => s.clone(), - _ => { - return Err(EvalError::TypeError( - "format expects a template string".to_string(), - )); - } - }; - - let mut result = template; - for (i, arg) in args.iter().skip(1).enumerate() { - let placeholder = format!("{{{}}}", i); - result = result.replace(&placeholder, &arg.to_string_repr()); - } - - Ok(Value::Str(result)) -} diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs deleted file mode 100644 index ef362ce..0000000 --- a/crates/doot-lang/src/evaluator.rs +++ /dev/null @@ -1,1619 +0,0 @@ -//! Runtime evaluator for the doot language. - -use crate::ast::*; -use crate::builtins; -use async_recursion::async_recursion; -use indexmap::IndexMap; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::OnceLock; -use thiserror::Error; - -/// Cached system information (never changes during runtime). -struct SystemInfo { - os: &'static str, - distro: String, - pkg_manager: String, - hostname: String, - arch: &'static str, -} - -static SYSTEM_INFO: OnceLock = OnceLock::new(); - -fn get_system_info() -> &'static SystemInfo { - SYSTEM_INFO.get_or_init(|| { - let os = std::env::consts::OS; - let distro = detect_distro(); - let pkg_manager = detect_pkg_manager(); - let hostname = hostname::get() - .map(|h| h.to_string_lossy().to_string()) - .unwrap_or_default(); - let arch = std::env::consts::ARCH; - - SystemInfo { - os, - distro, - pkg_manager, - hostname, - arch, - } - }) -} - -/// Runtime evaluation errors. -#[derive(Error, Debug)] -pub enum EvalError { - #[error("undefined variable: {0}")] - UndefinedVariable(String), - - #[error("undefined function: {0}")] - UndefinedFunction(String), - - #[error("type error: {0}")] - TypeError(String), - - #[error("division by zero")] - DivisionByZero, - - #[error("index out of bounds: {index} for list of length {len}")] - IndexOutOfBounds { index: i64, len: usize }, - - #[error("field not found: {field} on {ty}")] - FieldNotFound { ty: String, field: String }, - - #[error("cannot iterate over {0}")] - NotIterable(String), - - #[error("io error: {0}")] - IoError(#[from] std::io::Error), - - #[error("async error: {0}")] - AsyncError(String), -} - -/// Wrapper for an async task result. Cloning shares the same task handle. -/// The inner Option is taken on first await; subsequent awaits return an error. -#[derive(Clone, Debug)] -#[allow(clippy::type_complexity)] -pub struct AsyncValue(pub Arc>>>>); - -impl AsyncValue { - pub fn new(task: smol::Task>) -> Self { - Self(Arc::new(std::sync::Mutex::new(Some(task)))) - } -} - -/// Runtime value types. -#[derive(Clone, Debug)] -pub enum Value { - Int(i64), - Float(f64), - Str(String), - Bool(bool), - Path(PathBuf), - List(Vec), - Struct(String, IndexMap), - Enum(String, String), - Function(FnDecl, Env), - Lambda(Vec, Expr, Env), - Future(AsyncValue), - None, -} - -impl Value { - /// Returns the type name as a string. - pub fn type_name(&self) -> &'static str { - match self { - Value::Int(_) => "int", - Value::Float(_) => "float", - Value::Str(_) => "str", - Value::Bool(_) => "bool", - Value::Path(_) => "path", - Value::List(_) => "list", - Value::Struct(_, _) => "struct", - Value::Enum(_, _) => "enum", - Value::Function(_, _) => "function", - Value::Lambda(_, _, _) => "lambda", - Value::Future(_) => "future", - Value::None => "none", - } - } - - /// Returns true for truthy values in conditionals. - pub fn is_truthy(&self) -> bool { - match self { - Value::Bool(b) => *b, - Value::Int(n) => *n != 0, - Value::Float(n) => *n != 0.0, - Value::Str(s) => !s.is_empty(), - Value::List(l) => !l.is_empty(), - Value::None => false, - _ => true, - } - } - - /// Converts the value to a display string. - /// Converts value to a string suitable for environment variables. - pub fn to_env_string(&self) -> String { - match self { - Value::Int(n) => n.to_string(), - Value::Float(n) => n.to_string(), - Value::Str(s) => s.clone(), - Value::Bool(b) => if *b { "1" } else { "0" }.to_string(), - Value::Path(p) => p.display().to_string(), - Value::List(items) => { - // Join list items with colon (PATH-style) - items - .iter() - .map(|v| v.to_env_string()) - .collect::>() - .join(":") - } - Value::Future(_) => "".to_string(), - Value::None => String::new(), - _ => self.to_string_repr(), - } - } - - pub fn to_string_repr(&self) -> String { - match self { - Value::Int(n) => n.to_string(), - Value::Float(n) => n.to_string(), - Value::Str(s) => s.clone(), - Value::Bool(b) => b.to_string(), - Value::Path(p) => p.display().to_string(), - Value::List(items) => { - let parts: Vec = items.iter().map(|v| v.to_string_repr()).collect(); - format!("[{}]", parts.join(", ")) - } - Value::Struct(name, fields) => { - let parts: Vec = fields - .iter() - .map(|(k, v)| format!("{} = {}", k, v.to_string_repr())) - .collect(); - format!("{} {{ {} }}", name, parts.join(", ")) - } - Value::Enum(ty, variant) => format!("{}::{}", ty, variant), - Value::Function(f, _) => format!("", f.name), - Value::Lambda(_, _, _) => "".to_string(), - Value::Future(_) => "".to_string(), - Value::None => "none".to_string(), - } - } -} - -/// Runtime environment with variable bindings. -#[derive(Clone, Debug, Default)] -pub struct Env { - scopes: Vec>, - functions: HashMap, - structs: HashMap, - enums: HashMap, - macros: HashMap, -} - -impl Env { - /// Creates a new empty environment. - pub fn new() -> Self { - Self { - scopes: vec![HashMap::new()], - functions: HashMap::new(), - structs: HashMap::new(), - enums: HashMap::new(), - macros: HashMap::new(), - } - } - - pub fn push_scope(&mut self) { - self.scopes.push(HashMap::new()); - } - - pub fn pop_scope(&mut self) { - self.scopes.pop(); - } - - pub fn define(&mut self, name: String, value: Value) { - if let Some(scope) = self.scopes.last_mut() { - scope.insert(name, value); - } - } - - pub fn get(&self, name: &str) -> Option<&Value> { - for scope in self.scopes.iter().rev() { - if let Some(v) = scope.get(name) { - return Some(v); - } - } - None - } - - pub fn define_function(&mut self, name: String, func: FnDecl, env: Env) { - self.functions.insert(name, (func, env)); - } - - pub fn get_function(&self, name: &str) -> Option<&(FnDecl, Env)> { - self.functions.get(name) - } - - pub fn define_struct(&mut self, name: String, decl: StructDecl) { - self.structs.insert(name, decl); - } - - pub fn get_struct(&self, name: &str) -> Option<&StructDecl> { - self.structs.get(name) - } - - pub fn define_enum(&mut self, name: String, decl: EnumDecl) { - self.enums.insert(name, decl); - } - - pub fn define_macro(&mut self, name: String, decl: MacroDecl) { - self.macros.insert(name, decl); - } - - pub fn get_macro(&self, name: &str) -> Option<&MacroDecl> { - self.macros.get(name) - } - - /// Returns all variables as string key-value pairs for use as environment variables. - pub fn get_all_variables(&self) -> HashMap { - let mut vars = HashMap::new(); - for scope in &self.scopes { - for (name, value) in scope { - vars.insert( - format!("DOOT_{}", name.to_uppercase()), - value.to_env_string(), - ); - } - } - vars - } - - /// Returns all variables as raw Values (for template engine integration). - pub fn get_raw_variables(&self) -> HashMap { - let mut vars = HashMap::new(); - for scope in &self.scopes { - for (name, value) in scope { - vars.insert(name.clone(), value.clone()); - } - } - vars - } -} - -/// Deploy mode for dotfiles. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum DeployMode { - #[default] - Copy, - Link, -} - -/// Permission rule for deployed files. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] -pub enum PermissionRule { - Single(u32), - Pattern { pattern: String, mode: u32 }, -} - -/// Source for a dotfiles glob block. -#[derive(Debug, Clone)] -pub enum DotfilesSource { - /// Glob pattern string to expand later (e.g. "config/*"). - Pattern(String), - /// Pre-expanded list of paths (e.g. from glob() function call). - Paths(Vec), -} - -/// Unexpanded dotfiles pattern from a `dotfiles:` block. -#[derive(Debug, Clone)] -pub struct DotfilesPattern { - pub source: DotfilesSource, - pub target_base: PathBuf, - pub template: bool, - pub permissions: Vec, - pub owner: Option, - pub deploy: DeployMode, - pub link_patterns: Vec, - pub copy_patterns: Vec, -} - -/// Evaluated dotfile configuration. -#[derive(Clone, Debug)] -pub struct DotfileConfig { - pub source: PathBuf, - pub target: PathBuf, - pub template: bool, - pub permissions: Vec, - pub owner: Option, - pub deploy: DeployMode, - pub link_patterns: Vec, - pub copy_patterns: Vec, - /// Target paths to skip during directory deploy (specialized by explicit dotfile blocks). - pub exclude_paths: Vec, - /// Source paths to skip during directory deploy (when explicit block targets elsewhere). - pub exclude_sources: Vec, -} - -/// Evaluated package configuration. -#[derive(Clone, Debug)] -pub struct PackageConfig { - pub default: Option, - pub brew: Option, - /// Homebrew cask name (macOS); installed via `brew install --cask`. - pub cask: Option, - pub apt: Option, - pub pacman: Option, - pub yay: Option, - pub xbps: Option, -} - -/// Evaluated secret file configuration. -#[derive(Clone, Debug)] -pub struct SecretConfig { - pub source: PathBuf, - pub target: PathBuf, - pub mode: Option, -} - -/// Evaluated hook configuration. -#[derive(Clone, Debug)] -pub struct HookConfig { - pub stage: HookStage, - pub run: String, -} - -/// Result of evaluating a doot program. -#[derive(Clone)] -pub struct EvalResult { - pub dotfiles: Vec, - pub dotfile_patterns: Vec, - pub packages: Vec, - /// Homebrew taps to register (from `brew:` blocks), in declaration order. - pub brew_taps: Vec, - /// Brew-only formulae to install (from `brew:` blocks). - pub brew_formulae: Vec, - pub secrets: Vec, - pub hooks: Vec, - pub encrypted_vars: HashMap, - pub encrypted_files: HashMap, - pub sandbox: bool, -} - -impl Default for EvalResult { - fn default() -> Self { - Self { - dotfiles: Vec::new(), - dotfile_patterns: Vec::new(), - packages: Vec::new(), - brew_taps: Vec::new(), - brew_formulae: Vec::new(), - secrets: Vec::new(), - hooks: Vec::new(), - encrypted_vars: HashMap::new(), - encrypted_files: HashMap::new(), - sandbox: true, - } - } -} - -/// Evaluates doot AST and collects configuration. -#[derive(Clone)] -pub struct Evaluator { - env: Env, - result: EvalResult, - /// Base directory of the config file. Used to resolve relative glob patterns - /// so they expand correctly regardless of the current working directory. - source_dir: Option, -} - -impl Evaluator { - /// Creates a new evaluator with built-in bindings. - #[tracing::instrument(level = "trace")] - pub fn new() -> Self { - let mut env = Env::new(); - Self::init_builtins(&mut env); - Self { - env, - result: EvalResult::default(), - source_dir: None, - } - } - - /// Sets the config source directory so relative glob patterns are resolved - /// relative to the config file rather than the current working directory. - pub fn with_source_dir(mut self, dir: std::path::PathBuf) -> Self { - self.source_dir = Some(dir); - self - } - - #[tracing::instrument(level = "trace", skip_all)] - fn init_builtins(env: &mut Env) { - // Register the Os enum so Os::Linux, Os::MacOS, etc. can be used - env.define_enum( - "Os".to_string(), - EnumDecl { - name: "Os".to_string(), - variants: vec![ - EnumVariant { - name: "Linux".to_string(), - fields: None, - }, - EnumVariant { - name: "MacOS".to_string(), - fields: None, - }, - EnumVariant { - name: "Windows".to_string(), - fields: None, - }, - ], - }, - ); - - // Use cached system info - let sys = get_system_info(); - - let os_val = match sys.os { - "linux" => Value::Enum("Os".to_string(), "Linux".to_string()), - "macos" => Value::Enum("Os".to_string(), "MacOS".to_string()), - "windows" => Value::Enum("Os".to_string(), "Windows".to_string()), - _ => Value::Enum("Os".to_string(), "Linux".to_string()), - }; - env.define("os".to_string(), os_val); - env.define("distro".to_string(), Value::Str(sys.distro.clone())); - env.define( - "pkg_manager".to_string(), - Value::Str(sys.pkg_manager.clone()), - ); - env.define("hostname".to_string(), Value::Str(sys.hostname.clone())); - env.define("arch".to_string(), Value::Str(sys.arch.to_string())); - } - - /// Evaluates the program and returns collected configuration. - #[tracing::instrument(level = "trace", skip_all)] - pub async fn eval(&mut self, program: &Program) -> Result { - for stmt in &program.statements { - self.eval_statement(&stmt.node).await?; - } - Ok(std::mem::take(&mut self.result)) - } - - /// Synchronous entry point. Runs the async evaluator on smol's executor. - pub fn eval_sync(&mut self, program: &Program) -> Result { - smol::block_on(self.eval(program)) - } - - /// Returns all variables as environment variables for hooks. - #[tracing::instrument(level = "trace", skip(self))] - pub fn get_hook_env(&self) -> std::collections::HashMap { - let mut vars = self.env.get_all_variables(); - - // Add doot global variables - vars.insert( - "DOOT_HOME".to_string(), - Self::home_dir().display().to_string(), - ); - vars.insert( - "DOOT_CONFIG_DIR".to_string(), - doot_utils::xdg::config_home() - .join("doot") - .display() - .to_string(), - ); - vars.insert("DOOT_OS".to_string(), std::env::consts::OS.to_string()); - vars.insert("DOOT_ARCH".to_string(), std::env::consts::ARCH.to_string()); - - vars - } - - /// Returns all variables as raw Values for template rendering. - pub fn get_template_variables(&self) -> HashMap { - self.env.get_raw_variables() - } - - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all)] - async fn eval_statement(&mut self, stmt: &Statement) -> Result, EvalError> { - match stmt { - Statement::VarDecl(decl) => { - tracing::trace!(name = %decl.name, "eval var declaration"); - let value = self.eval_expr(&decl.value).await?; - - // Handle special config variables - if decl.name == "sandbox" - && let Value::Bool(b) = &value - { - self.result.sandbox = *b; - } - - self.env.define(decl.name.clone(), value); - Ok(None) - } - - Statement::FnDecl(decl) => { - tracing::trace!(name = %decl.name, "eval fn declaration"); - self.env - .define_function(decl.name.clone(), decl.clone(), self.env.clone()); - Ok(None) - } - - Statement::StructDecl(decl) => { - self.env.define_struct(decl.name.clone(), decl.clone()); - Ok(None) - } - - Statement::EnumDecl(decl) => { - self.env.define_enum(decl.name.clone(), decl.clone()); - Ok(None) - } - - Statement::MacroDecl(decl) => { - self.env.define_macro(decl.name.clone(), decl.clone()); - Ok(None) - } - - Statement::MacroCall(call) => { - if let Some(macro_decl) = self.env.get_macro(&call.name).cloned() { - self.env.push_scope(); - for (param, arg) in macro_decl.params.iter().zip(call.args.iter()) { - let value = self.eval_expr(arg).await?; - self.env.define(param.clone(), value); - } - for body_stmt in ¯o_decl.body { - self.eval_statement(&body_stmt.node).await?; - } - self.env.pop_scope(); - } - Ok(None) - } - - Statement::ForLoop(for_loop) => { - let iter_val = self.eval_expr(&for_loop.iter).await?; - let items = match iter_val { - Value::List(items) => items, - Value::Str(s) => s.chars().map(|c| Value::Str(c.to_string())).collect(), - _ => return Err(EvalError::NotIterable(iter_val.type_name().to_string())), - }; - - for item in items { - self.env.push_scope(); - self.env.define(for_loop.var.clone(), item); - for body_stmt in &for_loop.body { - if let Some(v) = self.eval_statement(&body_stmt.node).await? { - self.env.pop_scope(); - return Ok(Some(v)); - } - } - self.env.pop_scope(); - } - Ok(None) - } - - Statement::If(if_stmt) => { - let cond = self.eval_expr(&if_stmt.condition).await?; - if cond.is_truthy() { - self.env.push_scope(); - for body_stmt in &if_stmt.then_body { - if let Some(v) = self.eval_statement(&body_stmt.node).await? { - self.env.pop_scope(); - return Ok(Some(v)); - } - } - self.env.pop_scope(); - } else if let Some(ref else_body) = if_stmt.else_body { - self.env.push_scope(); - for body_stmt in else_body { - if let Some(v) = self.eval_statement(&body_stmt.node).await? { - self.env.pop_scope(); - return Ok(Some(v)); - } - } - self.env.pop_scope(); - } - Ok(None) - } - - Statement::Match(match_stmt) => { - let value = self.eval_expr(&match_stmt.expr).await?; - for arm in &match_stmt.arms { - if self.pattern_matches(&arm.pattern, &value) { - let result = self.eval_expr(&arm.body).await?; - return Ok(Some(result)); - } - } - Ok(None) - } - - Statement::Dotfile(dotfile) => { - tracing::trace!("eval dotfile"); - if let Some(ref when) = dotfile.when { - let cond = self.eval_expr(when).await?; - if !cond.is_truthy() { - return Ok(None); - } - } - - let source_val = self.eval_expr(&dotfile.source).await?; - - let deploy = match dotfile.deploy { - crate::ast::DeployMode::Copy => DeployMode::Copy, - crate::ast::DeployMode::Link => DeployMode::Link, - }; - - let permissions = dotfile - .permissions - .iter() - .map(|p| match p { - crate::ast::PermissionRule::Single(mode) => PermissionRule::Single(*mode), - crate::ast::PermissionRule::Pattern { pattern, mode } => { - PermissionRule::Pattern { - pattern: pattern.clone(), - mode: *mode, - } - } - }) - .collect::>(); - - // Detect glob patterns or lists and store as DotfilesPattern - let is_glob = |s: &str| s.contains('*') || s.contains('?') || s.contains('['); - - match &source_val { - Value::Str(s) if is_glob(s) => { - let target_base = self.eval_to_path(&dotfile.target).await?; - self.result.dotfile_patterns.push(DotfilesPattern { - source: DotfilesSource::Pattern(s.clone()), - target_base, - template: dotfile.template.unwrap_or(false), - permissions, - owner: dotfile.owner.clone(), - deploy, - link_patterns: dotfile.link_patterns.clone(), - copy_patterns: dotfile.copy_patterns.clone(), - }); - } - Value::Path(p) if is_glob(&p.display().to_string()) => { - let target_base = self.eval_to_path(&dotfile.target).await?; - self.result.dotfile_patterns.push(DotfilesPattern { - source: DotfilesSource::Pattern(p.display().to_string()), - target_base, - template: dotfile.template.unwrap_or(false), - permissions, - owner: dotfile.owner.clone(), - deploy, - link_patterns: dotfile.link_patterns.clone(), - copy_patterns: dotfile.copy_patterns.clone(), - }); - } - Value::List(items) => { - let paths = items - .iter() - .filter_map(|v| match v { - Value::Path(p) => Some(p.clone()), - Value::Str(s) => Some(PathBuf::from(s)), - _ => None, - }) - .collect(); - let target_base = self.eval_to_path(&dotfile.target).await?; - self.result.dotfile_patterns.push(DotfilesPattern { - source: DotfilesSource::Paths(paths), - target_base, - template: dotfile.template.unwrap_or(false), - permissions, - owner: dotfile.owner.clone(), - deploy, - link_patterns: dotfile.link_patterns.clone(), - copy_patterns: dotfile.copy_patterns.clone(), - }); - } - _ => { - let source = Self::value_to_path(&source_val)?; - let target = self.eval_to_path(&dotfile.target).await?; - self.result.dotfiles.push(DotfileConfig { - source, - target, - template: dotfile.template.unwrap_or(false), - permissions, - owner: dotfile.owner.clone(), - deploy, - link_patterns: dotfile.link_patterns.clone(), - copy_patterns: dotfile.copy_patterns.clone(), - exclude_paths: vec![], - exclude_sources: vec![], - }); - } - } - Ok(None) - } - - Statement::Package(pkg) => { - tracing::trace!("eval package"); - if let Some(ref when) = pkg.when { - let cond = self.eval_expr(when).await?; - if !cond.is_truthy() { - return Ok(None); - } - } - - let default = if let Some(ref d) = pkg.default { - Some(self.eval_to_string(d).await?) - } else { - None - }; - - let brew = if let Some(ref s) = pkg.brew { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - let cask = if let Some(ref s) = pkg.cask { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - let apt = if let Some(ref s) = pkg.apt { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - let pacman = if let Some(ref s) = pkg.pacman { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - let yay = if let Some(ref s) = pkg.yay { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - let xbps = if let Some(ref s) = pkg.xbps { - Some(self.eval_to_string(&s.name).await?) - } else { - None - }; - - self.result.packages.push(PackageConfig { - default, - brew, - cask, - apt, - pacman, - yay, - xbps, - }); - Ok(None) - } - - Statement::Brew(cfg) => { - tracing::trace!("eval brew block"); - if let Some(ref taps) = cfg.taps { - let taps = self.eval_to_string_list(taps).await?; - self.result.brew_taps.extend(taps); - } - if let Some(ref formulae) = cfg.formulae { - let formulae = self.eval_to_string_list(formulae).await?; - self.result.brew_formulae.extend(formulae); - } - Ok(None) - } - - Statement::Secret(secret) => { - let source = self.eval_to_path(&secret.source).await?; - let target = self.eval_to_path(&secret.target).await?; - - self.result.secrets.push(SecretConfig { - source, - target, - mode: secret.mode, - }); - Ok(None) - } - - Statement::Encrypted(encrypted) => { - for entry in &encrypted.entries { - match entry { - crate::ast::EncryptedEntry::Var(name, expr) => { - let val = self.eval_expr(expr).await?; - match val { - Value::Str(s) => { - self.result.encrypted_vars.insert(name.clone(), s); - } - _ => { - return Err(EvalError::TypeError(format!( - "encrypted var '{}' must be a string, got {}", - name, - val.type_name() - ))); - } - } - } - crate::ast::EncryptedEntry::File(name, expr) => { - let path = self.eval_to_path(expr).await?; - self.result.encrypted_files.insert(name.clone(), path); - } - } - } - Ok(None) - } - - Statement::Hook(hook) => { - tracing::trace!("eval hook"); - if let Some(ref when) = hook.when { - let cond = self.eval_expr(when).await?; - if !cond.is_truthy() { - return Ok(None); - } - } - - let run = self.eval_to_string(&hook.run).await?; - self.result.hooks.push(HookConfig { - stage: hook.stage.clone(), - run, - }); - Ok(None) - } - - Statement::Return(expr) => { - let value = if let Some(e) = expr { - self.eval_expr(e).await? - } else { - Value::None - }; - Ok(Some(value)) - } - - Statement::Expr(expr) => { - self.eval_expr(expr).await?; - Ok(None) - } - - _ => Ok(None), - } - } - - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all)] - async fn eval_expr(&mut self, expr: &Expr) -> Result { - match expr { - Expr::Literal(lit) => Ok(match lit { - Literal::Int(n) => Value::Int(*n), - Literal::Float(n) => Value::Float(*n), - Literal::Str(s) => Value::Str(s.clone()), - Literal::Bool(b) => Value::Bool(*b), - Literal::None => Value::None, - }), - - Expr::Ident(name) => { - if let Some(v) = self.env.get(name) { - Ok(v.clone()) - } else if self.env.get_function(name).is_some() { - let (func, env) = self.env.get_function(name).unwrap().clone(); - Ok(Value::Function(func, env)) - } else { - Err(EvalError::UndefinedVariable(name.clone())) - } - } - - Expr::Binary(left, op, right) => { - let left_val = self.eval_expr(left).await?; - let right_val = self.eval_expr(right).await?; - self.eval_binary_op(&left_val, op, &right_val) - } - - Expr::Unary(op, expr) => { - let val = self.eval_expr(expr).await?; - match op { - UnaryOp::Neg => match val { - Value::Int(n) => Ok(Value::Int(-n)), - Value::Float(n) => Ok(Value::Float(-n)), - _ => Err(EvalError::TypeError(format!( - "cannot negate {}", - val.type_name() - ))), - }, - UnaryOp::Not => Ok(Value::Bool(!val.is_truthy())), - } - } - - Expr::Call(callee, args) => { - // Check for built-in functions first (before evaluating callee) - if let Expr::Ident(name) = callee.as_ref() { - // First check if it's defined in the environment - if self.env.get(name).is_none() && self.env.get_function(name).is_none() { - // Not in env, try as a builtin - let mut arg_vals = Vec::with_capacity(args.len()); - for a in args { - arg_vals.push(self.eval_expr(a).await?); - } - - // Try calling as builtin - if it succeeds, return the result - match self.call_builtin(name, &arg_vals, args).await { - Ok(result) => return Ok(result), - Err(EvalError::UndefinedFunction(_)) => { - // Not a builtin either, fall through to report undefined variable - return Err(EvalError::UndefinedVariable(name.clone())); - } - Err(e) => return Err(e), - } - } - } - - let callee_val = self.eval_expr(callee).await?; - let mut arg_vals = Vec::with_capacity(args.len()); - for a in args { - arg_vals.push(self.eval_expr(a).await?); - } - - match callee_val { - Value::Function(func, func_env) => { - self.call_function(&func, &func_env, &arg_vals).await - } - Value::Lambda(params, body, lambda_env) => { - self.call_lambda(¶ms, &body, &lambda_env, &arg_vals) - .await - } - _ => Err(EvalError::TypeError(format!( - "cannot call {}", - callee_val.type_name() - ))), - } - } - - Expr::MethodCall(obj, method, args) => { - let obj_val = self.eval_expr(obj).await?; - let mut arg_vals = Vec::with_capacity(args.len()); - for a in args { - arg_vals.push(self.eval_expr(a).await?); - } - - self.call_method(&obj_val, method, &arg_vals, args).await - } - - Expr::Field(obj, field) => { - let obj_val = self.eval_expr(obj).await?; - match obj_val { - Value::Struct(name, fields) => { - fields - .get(field) - .cloned() - .ok_or_else(|| EvalError::FieldNotFound { - ty: name, - field: field.clone(), - }) - } - _ => Err(EvalError::TypeError(format!( - "cannot access field on {}", - obj_val.type_name() - ))), - } - } - - Expr::Index(obj, idx) => { - let obj_val = self.eval_expr(obj).await?; - let idx_val = self.eval_expr(idx).await?; - - match (obj_val, idx_val) { - (Value::List(items), Value::Int(i)) => { - let index = if i < 0 { items.len() as i64 + i } else { i }; - items - .get(index as usize) - .cloned() - .ok_or(EvalError::IndexOutOfBounds { - index: i, - len: items.len(), - }) - } - (Value::Str(s), Value::Int(i)) => { - let index = if i < 0 { s.len() as i64 + i } else { i }; - s.chars() - .nth(index as usize) - .map(|c| Value::Str(c.to_string())) - .ok_or(EvalError::IndexOutOfBounds { - index: i, - len: s.len(), - }) - } - _ => Err(EvalError::TypeError("invalid index operation".to_string())), - } - } - - Expr::List(items) => { - let mut values = Vec::with_capacity(items.len()); - for i in items { - values.push(self.eval_expr(i).await?); - } - Ok(Value::List(values)) - } - - Expr::StructInit(name, fields) => { - let mut values = IndexMap::new(); - - if let Some(decl) = self.env.get_struct(name).cloned() { - for field in &decl.fields { - if let Some(expr) = fields.get(&field.name) { - values.insert(field.name.clone(), self.eval_expr(expr).await?); - } else if let Some(ref default) = field.default { - values.insert(field.name.clone(), self.eval_expr(default).await?); - } - } - } else { - for (k, v) in fields { - values.insert(k.clone(), self.eval_expr(v).await?); - } - } - - Ok(Value::Struct(name.clone(), values)) - } - - Expr::EnumVariant(ty, variant) => Ok(Value::Enum(ty.clone(), variant.clone())), - - Expr::If(cond, then_expr, else_expr) => { - let cond_val = self.eval_expr(cond).await?; - if cond_val.is_truthy() { - self.eval_expr(then_expr).await - } else if let Some(else_e) = else_expr { - self.eval_expr(else_e).await - } else { - Ok(Value::None) - } - } - - Expr::Lambda(params, body, ..) => Ok(Value::Lambda( - params.clone(), - *body.clone(), - self.env.clone(), - )), - - Expr::Await(expr) => { - let val = self.eval_expr(expr).await?; - match val { - Value::Future(async_val) => { - let task = async_val - .0 - .lock() - .map_err(|e| EvalError::AsyncError(e.to_string()))? - .take() - .ok_or_else(|| { - EvalError::AsyncError("future already consumed".into()) - })?; - task.await - } - other => Ok(other), // Non-futures pass through - } - } - - Expr::Path(left, right) => { - let left_val = self.eval_expr(left).await?; - let right_val = self.eval_expr(right).await?; - - let has_glob = |s: &str| s.contains('*') || s.contains('?') || s.contains('['); - - // Resolve a glob pattern to an absolute base using source_dir when - // available, so patterns like "config" / "*" expand relative to the - // config file rather than the current working directory. - let resolve_glob_base = |p: &std::path::Path| -> std::path::PathBuf { - if p.is_relative() - && let Some(ref sd) = self.source_dir - { - return sd.join(p); - } - p.to_path_buf() - }; - - // If left is a list (from a previous glob), map over it - if let Value::List(items) = left_val { - let right_path = Self::value_to_path(&right_val)?; - let mut results = Vec::with_capacity(items.len()); - for item in items { - let item_path = Self::value_to_path(&item)?; - let joined = item_path.join(&right_path); - let joined_str = joined.to_string_lossy(); - if has_glob(&joined_str) { - let effective = resolve_glob_base(&joined); - for entry in glob::glob(&effective.to_string_lossy()) - .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? - .flatten() - { - results.push(Value::Path(entry)); - } - } else { - results.push(Value::Path(joined)); - } - } - return Ok(Value::List(results)); - } - - let left_path = Self::value_to_path(&left_val)?; - let right_path = Self::value_to_path(&right_val)?; - let joined = left_path.join(right_path); - - // Expand glob wildcards using source_dir as the base for relative - // patterns, so "config" / "*" resolves from the config directory - // rather than from wherever the user ran doot. - let joined_str = joined.to_string_lossy(); - if has_glob(&joined_str) { - let effective = resolve_glob_base(&joined); - let paths: Vec = glob::glob(&effective.to_string_lossy()) - .map_err(|e| EvalError::TypeError(format!("invalid glob: {}", e)))? - .filter_map(|e| e.ok()) - .map(Value::Path) - .collect(); - Ok(Value::List(paths)) - } else { - Ok(Value::Path(joined)) - } - } - - Expr::HomePath(path) => { - let home = Self::home_dir(); - let path_val = self.eval_expr(path).await?; - match path_val { - Value::Str(s) if s.is_empty() => Ok(Value::Path(home)), - Value::Str(s) => Ok(Value::Path(home.join(s))), - Value::Path(p) => Ok(Value::Path(home.join(p))), - _ => Ok(Value::Path(home)), - } - } - - Expr::Interpolated(parts) => { - let mut result = String::new(); - for part in parts { - match part { - InterpolatedPart::Literal(s) => result.push_str(s), - InterpolatedPart::Expr(e) => { - let val = self.eval_expr(e).await?; - result.push_str(&val.to_string_repr()); - } - } - } - Ok(Value::Str(result)) - } - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn eval_binary_op(&self, left: &Value, op: &BinOp, right: &Value) -> Result { - match op { - BinOp::Add => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Int(a + b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)), - (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)), - (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)), - (Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{}{}", a, b))), - _ => Err(EvalError::TypeError(format!( - "cannot add {} and {}", - left.type_name(), - right.type_name() - ))), - }, - - BinOp::Sub => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Int(a - b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a - b)), - (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 - b)), - (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a - *b as f64)), - _ => Err(EvalError::TypeError(format!( - "cannot subtract {} and {}", - left.type_name(), - right.type_name() - ))), - }, - - BinOp::Mul => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Int(a * b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a * b)), - (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 * b)), - (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a * *b as f64)), - _ => Err(EvalError::TypeError(format!( - "cannot multiply {} and {}", - left.type_name(), - right.type_name() - ))), - }, - - BinOp::Div => match (left, right) { - (Value::Int(a), Value::Int(b)) => { - if *b == 0 { - Err(EvalError::DivisionByZero) - } else { - Ok(Value::Int(a / b)) - } - } - (Value::Float(a), Value::Float(b)) => { - if *b == 0.0 { - Err(EvalError::DivisionByZero) - } else { - Ok(Value::Float(a / b)) - } - } - (Value::Int(a), Value::Float(b)) => { - if *b == 0.0 { - Err(EvalError::DivisionByZero) - } else { - Ok(Value::Float(*a as f64 / b)) - } - } - (Value::Float(a), Value::Int(b)) => { - if *b == 0 { - Err(EvalError::DivisionByZero) - } else { - Ok(Value::Float(a / *b as f64)) - } - } - _ => Err(EvalError::TypeError(format!( - "cannot divide {} and {}", - left.type_name(), - right.type_name() - ))), - }, - - BinOp::Mod => match (left, right) { - (Value::Int(a), Value::Int(b)) => { - if *b == 0 { - Err(EvalError::DivisionByZero) - } else { - Ok(Value::Int(a % b)) - } - } - _ => Err(EvalError::TypeError(format!( - "cannot modulo {} and {}", - left.type_name(), - right.type_name() - ))), - }, - - BinOp::Eq => Ok(Value::Bool(self.values_equal(left, right))), - BinOp::NotEq => Ok(Value::Bool(!self.values_equal(left, right))), - - BinOp::Lt => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a < b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a < b)), - (Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a < b)), - _ => Err(EvalError::TypeError("cannot compare".to_string())), - }, - - BinOp::Gt => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a > b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a > b)), - (Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a > b)), - _ => Err(EvalError::TypeError("cannot compare".to_string())), - }, - - BinOp::LtEq => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a <= b)), - (Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a <= b)), - _ => Err(EvalError::TypeError("cannot compare".to_string())), - }, - - BinOp::GtEq => match (left, right) { - (Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a >= b)), - (Value::Float(a), Value::Float(b)) => Ok(Value::Bool(a >= b)), - (Value::Str(a), Value::Str(b)) => Ok(Value::Bool(a >= b)), - _ => Err(EvalError::TypeError("cannot compare".to_string())), - }, - - BinOp::And => Ok(Value::Bool(left.is_truthy() && right.is_truthy())), - BinOp::Or => Ok(Value::Bool(left.is_truthy() || right.is_truthy())), - - BinOp::PathJoin => { - let left_path = match left { - Value::Path(p) => p.clone(), - Value::Str(s) => PathBuf::from(s), - _ => return Err(EvalError::TypeError("expected path".to_string())), - }; - let right_path = match right { - Value::Path(p) => p.clone(), - Value::Str(s) => PathBuf::from(s), - _ => return Err(EvalError::TypeError("expected path".to_string())), - }; - Ok(Value::Path(left_path.join(right_path))) - } - - BinOp::NullCoalesce => { - if matches!(left, Value::None) { - Ok(right.clone()) - } else { - Ok(left.clone()) - } - } - } - } - - fn values_equal(&self, left: &Value, right: &Value) -> bool { - match (left, right) { - (Value::Int(a), Value::Int(b)) => a == b, - (Value::Float(a), Value::Float(b)) => (a - b).abs() < f64::EPSILON, - (Value::Str(a), Value::Str(b)) => a == b, - (Value::Bool(a), Value::Bool(b)) => a == b, - (Value::Path(a), Value::Path(b)) => a == b, - (Value::None, Value::None) => true, - (Value::Enum(t1, v1), Value::Enum(t2, v2)) => t1 == t2 && v1 == v2, - (Value::List(a), Value::List(b)) => { - a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| self.values_equal(x, y)) - } - _ => false, - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn pattern_matches(&self, pattern: &Pattern, value: &Value) -> bool { - match pattern { - Pattern::Wildcard => true, - Pattern::Literal(lit) => match (lit, value) { - (Literal::Int(a), Value::Int(b)) => *a == *b, - (Literal::Float(a), Value::Float(b)) => (*a - *b).abs() < f64::EPSILON, - (Literal::Str(a), Value::Str(b)) => a == b, - (Literal::Bool(a), Value::Bool(b)) => *a == *b, - (Literal::None, Value::None) => true, - _ => false, - }, - Pattern::Ident(_) => true, - Pattern::EnumVariant { ty, variant } => match value { - Value::Enum(t, v) => ty == t && variant == v, - _ => false, - }, - } - } - - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all, fields(name = %func.name))] - pub async fn call_function( - &mut self, - func: &FnDecl, - func_env: &Env, - args: &[Value], - ) -> Result { - let mut new_env = func_env.clone(); - new_env.push_scope(); - - for (param, arg) in func.params.iter().zip(args.iter()) { - new_env.define(param.name.clone(), arg.clone()); - } - - let old_env = std::mem::replace(&mut self.env, new_env); - let mut result = Value::None; - - for stmt in &func.body { - if let Some(v) = self.eval_statement(&stmt.node).await? { - result = v; - break; - } - } - - self.env = old_env; - Ok(result) - } - - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all)] - async fn call_lambda( - &mut self, - params: &[FnParam], - body: &Expr, - lambda_env: &Env, - args: &[Value], - ) -> Result { - let mut new_env = lambda_env.clone(); - new_env.push_scope(); - - for (param, arg) in params.iter().zip(args.iter()) { - new_env.define(param.name.clone(), arg.clone()); - } - - let old_env = std::mem::replace(&mut self.env, new_env); - let result = self.eval_expr(body).await?; - self.env = old_env; - - Ok(result) - } - - #[tracing::instrument(level = "trace", skip_all, fields(name))] - async fn call_builtin( - &mut self, - name: &str, - args: &[Value], - arg_exprs: &[Expr], - ) -> Result { - builtins::call_builtin(self, name, args, arg_exprs).await - } - - #[tracing::instrument(level = "trace", skip_all, fields(method))] - async fn call_method( - &mut self, - obj: &Value, - method: &str, - args: &[Value], - arg_exprs: &[Expr], - ) -> Result { - builtins::call_method(self, obj, method, args, arg_exprs).await - } - - /// Converts a Value to a PathBuf without needing an expression. - fn value_to_path(val: &Value) -> Result { - match val { - Value::Path(p) => Ok(p.clone()), - Value::Str(s) => { - if let Some(stripped) = s.strip_prefix('~') { - let home = Self::home_dir(); - Ok(home.join(stripped.strip_prefix('/').unwrap_or(stripped))) - } else { - Ok(PathBuf::from(s)) - } - } - _ => Err(EvalError::TypeError(format!( - "expected path, got {}", - val.type_name() - ))), - } - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn eval_to_path(&mut self, expr: &Expr) -> Result { - let val = self.eval_expr(expr).await?; - Self::value_to_path(&val) - } - - /// Returns DOOT_HOME if set, otherwise the real home directory. - #[tracing::instrument(level = "trace")] - fn home_dir() -> PathBuf { - doot_utils::xdg::home_dir() - } - - #[tracing::instrument(level = "trace", skip_all)] - async fn eval_to_string(&mut self, expr: &Expr) -> Result { - let val = self.eval_expr(expr).await?; - Ok(val.to_string_repr()) - } - - /// Evaluates an expression to a list of strings. A list yields each element's - /// string form; a single (non-list) value yields a one-element list. - #[tracing::instrument(level = "trace", skip_all)] - async fn eval_to_string_list(&mut self, expr: &Expr) -> Result, EvalError> { - match self.eval_expr(expr).await? { - Value::List(items) => Ok(items.iter().map(|v| v.to_string_repr()).collect()), - other => Ok(vec![other.to_string_repr()]), - } - } - - pub fn env(&self) -> &Env { - &self.env - } - - pub fn env_mut(&mut self) -> &mut Env { - &mut self.env - } -} - -impl Default for Evaluator { - fn default() -> Self { - Self::new() - } -} - -impl Evaluator { - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all)] - pub async fn eval_in_env(&mut self, expr: &Expr, env: Env) -> Result { - let old_env = std::mem::replace(&mut self.env, env); - let result = self.eval_expr(expr).await; - self.env = old_env; - result - } - - #[async_recursion(?Send)] - #[tracing::instrument(level = "trace", skip_all, fields(name = %func.name))] - pub async fn call_fn( - &mut self, - func: &FnDecl, - func_env: &Env, - args: &[Value], - ) -> Result { - self.call_function(func, func_env, args).await - } -} - -/// Cache for command_exists checks. -static COMMAND_CACHE: OnceLock>> = OnceLock::new(); - -/// Checks if a command exists in PATH or common bin directories (cached). -fn command_exists(cmd: &str) -> bool { - let cache = COMMAND_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); - let mut cache = cache.lock().unwrap(); - - if let Some(&exists) = cache.get(cmd) { - return exists; - } - - // Check PATH first using `which` - let exists = if std::process::Command::new("which") - .arg(cmd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { - true - } else { - // Fallback to hardcoded paths - let paths = ["/usr/bin/", "/usr/local/bin/", "/bin/"]; - paths - .iter() - .any(|p| std::path::Path::new(&format!("{}{}", p, cmd)).exists()) - }; - - cache.insert(cmd.to_string(), exists); - exists -} - -/// Detects the package manager. -fn detect_pkg_manager() -> String { - match std::env::consts::OS { - "macos" => "brew".to_string(), - "linux" => { - // Check AUR helpers first (they wrap pacman) - if command_exists("yay") { - "yay" - } else if command_exists("paru") { - "paru" - } else if command_exists("pacman") { - "pacman" - } else if command_exists("apt") { - "apt" - } else if command_exists("dnf") { - "dnf" - } else if command_exists("nix") { - "nix" - } else { - "unknown" - } - .to_string() - } - _ => "unknown".to_string(), - } -} - -/// Detects the current distro/environment. -/// Checks for custom distros first, then falls back to os_info. -fn detect_distro() -> String { - // Check for custom distros/environments first (by config directory presence) - let config_dir = doot_utils::xdg::config_home(); - - // Omarchy - Arch-based custom environment - if config_dir.join("omarchy").exists() { - return "omarchy".to_string(); - } - - // Add more custom distro checks here as needed: - // if config_dir.join("some-custom-distro").exists() { - // return "some-custom-distro".to_string(); - // } - - // Fall back to os_info detection with normalization - let info = os_info::get(); - let distro_raw = info.os_type().to_string().to_lowercase(); - - normalize_distro_name(&distro_raw).to_string() -} - -/// Normalizes distro names for easier matching. -fn normalize_distro_name(distro_raw: &str) -> &str { - match distro_raw { - "arch linux" => "arch", - "ubuntu linux" | "ubuntu" => "ubuntu", - "debian gnu/linux" | "debian linux" => "debian", - "fedora linux" => "fedora", - "centos linux" => "centos", - "red hat enterprise linux" | "rhel" => "rhel", - "linux mint" => "mint", - "pop!_os" | "pop os" => "pop_os", - "manjaro linux" => "manjaro", - "opensuse" | "opensuse leap" | "opensuse tumbleweed" => "opensuse", - "nixos" => "nixos", - "void linux" => "void", - "gentoo" | "gentoo linux" => "gentoo", - "alpine linux" => "alpine", - "macos" | "mac os" | "mac os x" => "macos", - other => other, - } -} diff --git a/crates/doot-lang/src/lang/ast.rs b/crates/doot-lang/src/lang/ast.rs new file mode 100644 index 0000000..52f5c7b --- /dev/null +++ b/crates/doot-lang/src/lang/ast.rs @@ -0,0 +1,190 @@ +//! AST and surface types. A program is one expression evaluating to a plan. + +use std::collections::BTreeMap; +use std::rc::Rc; + +use super::diag::Span; + +/// How an integer literal was written, so the formatter can round-trip it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Radix { + Dec, + Oct, + Hex, +} + +/// A surface type as written in annotations and struct fields. +#[derive(Debug, Clone, PartialEq)] +pub enum Type { + Int, + Str, + Bool, + List(Box), + /// Anonymous structural record. + Record(BTreeMap), + /// Nominal struct, by name. + Struct(String), + /// Nominal enum, by name. + Enum(String), + /// Function type `arg -> ret` (curried). + Fun(Box, Box), + /// An effect node yielding `T` when realized. + Task(Box), + /// Hindley-Milner unification variable. + Var(u32), + /// Gradual "top": unifies with anything. Escape hatch for records/merge and + /// effect builtins that aren't fully inferred yet. + Dyn, +} + +impl Type { + pub fn show(&self) -> String { + match self { + Type::Int => "Int".into(), + Type::Str => "Str".into(), + Type::Bool => "Bool".into(), + Type::List(t) => format!("[{}]", t.show()), + Type::Struct(n) => n.clone(), + Type::Enum(n) => n.clone(), + Type::Fun(a, b) => format!("({} -> {})", a.show(), b.show()), + Type::Task(t) => format!("Task {}", t.show()), + Type::Var(id) => format!("t{id}"), + Type::Dyn => "?".into(), + Type::Record(m) => { + let inner: Vec = m + .iter() + .map(|(k, t)| format!("{k} : {}", t.show())) + .collect(); + format!("{{ {} }}", inner.join("; ")) + } + } + } +} + +/// Binary operators. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BinOp { + /// `/` path join for strings, integer division for ints. + Slash, + /// `++` list or string concatenation. + Concat, + /// `==` structural equality. + Eq, + /// `&&` logical and (short-circuit). + And, + /// `||` logical or (short-circuit). + Or, + Add, + Sub, + Mul, + /// `%` integer remainder. + Mod, + /// `**` integer power. + Pow, +} + +/// Expressions. Everything in a program is an expression. +#[derive(Debug)] +pub enum Expr { + Int(i64, Radix), + Str(String), + Bool(bool), + Var(String), + List(Vec>), + /// `{ a = ..; b = ..; }` - anonymous record. + Record(Vec<(String, Rc)>), + /// `Name { a = ..; }` - nominal struct construction (disambiguated at parse time + /// from function application by the set of declared struct names). + Construct(String, Vec<(String, Rc)>), + /// `Enum.Variant` - a nominal enum variant. + EnumVariant(String, String), + /// `\x -> body` + Lam(String, Rc), + /// `f x` (juxtaposition). + App(Rc, Rc), + /// `e.field` + Select(Rc, String), + /// `a // b` - right-biased merge (type-aware over structs). + Merge(Rc, Rc), + /// `let name (: Type)? = expr; ... in body` + Let(Vec, Rc), + /// `if c then a else b` + If(Rc, Rc, Rc), + Bin(BinOp, Rc, Rc), +} + +/// A single `let` binding, with optional type annotation. +#[derive(Debug)] +pub struct Binding { + pub name: String, + pub ann: Option, + pub value: Rc, + /// source span of the binding start (for attaching leading comments in fmt) + pub span: Span, +} + +/// A field in a struct declaration. +#[derive(Debug)] +pub struct FieldDecl { + pub name: String, + pub ty: Type, + pub default: Option>, +} + +/// An inherent method: `fn name self p1 ... = body;`. `params[0]` is `self`. +#[derive(Debug)] +pub struct MethodDecl { + pub name: String, + pub params: Vec, + pub body: Rc, +} + +/// `struct Name { field : Type (= default)?; fn m self ... = ..; ... }` +#[derive(Debug)] +pub struct StructDecl { + pub name: String, + pub fields: Vec, + pub methods: Vec, + pub span: Span, +} + +/// `enum Name { Variant, ...; fn m self ... = ..; }` +#[derive(Debug)] +pub struct EnumDecl { + pub name: String, + pub variants: Vec, + pub methods: Vec, + pub span: Span, +} + +/// `class Name a { method : Type; ... }` - the param `a` appears in the sigs. +#[derive(Debug)] +pub struct ClassDecl { + pub name: String, + pub param: String, + pub methods: Vec<(String, Type)>, + pub span: Span, +} + +/// `impl Class for Type { method = expr; ... }` +#[derive(Debug)] +pub struct ImplDecl { + pub class: String, + pub type_name: String, + pub methods: Vec<(String, Rc)>, + pub span: Span, +} + +/// A whole program: declarations followed by a body expression. +#[derive(Debug)] +pub struct Program { + pub structs: Vec>, + pub enums: Vec>, + pub classes: Vec>, + pub impls: Vec>, + pub body: Rc, + /// span of the body's first token (for placing comments before the body) + pub body_span: Span, + /// source comments `(span, text-without-#)`, in source order, for fmt + pub comments: Vec<(Span, String)>, +} diff --git a/crates/doot-lang/src/lang/check.rs b/crates/doot-lang/src/lang/check.rs new file mode 100644 index 0000000..a6d4c4e --- /dev/null +++ b/crates/doot-lang/src/lang/check.rs @@ -0,0 +1,822 @@ +//! Hindley-Milner type inference (Algorithm W style) with let-polymorphism. +//! +//! Lambdas and application are fully inferred; list builtins carry polymorphic +//! schemes. Nominal structs, their construction and `//` merge keep their concrete +//! checks. `Type::Dyn` is a gradual top that unifies with anything - used for +//! records-meeting-vars and effect builtins (`pkg`/`dotfile`/...), which take +//! attrsets dynamically. Heterogeneous list literals (used as tuples, e.g. +//! permission pairs) degrade to `[?]` rather than erroring. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::rc::Rc; + +use super::ast::*; +use super::engine::{BuiltinScheme, Engine}; + +#[derive(Clone)] +struct Scheme { + vars: Vec, + /// type-class constraints `(class, var)` (var is one of `vars`) + constraints: Vec<(String, u32)>, + ty: Type, +} + +fn mono(ty: Type) -> Scheme { + Scheme { + vars: Vec::new(), + constraints: Vec::new(), + ty, + } +} +fn fun(a: Type, b: Type) -> Type { + Type::Fun(Box::new(a), Box::new(b)) +} +fn list(a: Type) -> Type { + Type::List(Box::new(a)) +} + +pub struct Checker { + structs: BTreeMap>, + enums: BTreeMap>, + /// (type name, method name) inherent methods that exist + method_names: HashSet<(String, String)>, + /// class method name -> class name (for `x.method` class-method sugar) + class_methods: HashMap, + /// (class, type head) instances that exist (coherence + resolution) + instances: HashSet<(String, String)>, + /// unresolved type-class constraints (class, type) + pending: Vec<(String, Type)>, + subst: Vec>, + env: Vec<(String, Scheme)>, + pub errors: Vec, +} + +impl Checker { + /// Build a checker for `program` against `engine`'s registered surface + /// (builtins, values, structs/enums, classes, instances). + pub fn with_engine(program: &Program, engine: &Engine) -> Self { + let mut structs: BTreeMap> = program + .structs + .iter() + .map(|d| (d.name.clone(), d.clone())) + .collect(); + for d in &engine.structs { + structs.entry(d.name.clone()).or_insert_with(|| d.clone()); + } + let mut enums: BTreeMap> = program + .enums + .iter() + .map(|d| (d.name.clone(), d.clone())) + .collect(); + for d in &engine.enums { + enums.entry(d.name.clone()).or_insert_with(|| d.clone()); + } + let mut c = Checker { + structs, + enums, + method_names: HashSet::new(), + class_methods: HashMap::new(), + instances: HashSet::new(), + pending: Vec::new(), + subst: Vec::new(), + env: Vec::new(), + errors: Vec::new(), + }; + c.install_engine(engine); + // built-in classes (engine) + user classes + let mut classes = engine.classes.clone(); + classes.extend(program.classes.iter().cloned()); + c.install_classes(&classes, &program.impls); + for (cl, head) in &engine.instances { + c.instances.insert((cl.clone(), head.clone())); + } + c.check_methods(); + c + } + + /// Register method names and type-check each method body with `self` bound to + /// its nominal type, so errors inside methods are caught. + fn check_methods(&mut self) { + // (nominal type, params, body) collected owned to avoid borrow conflicts + let mut jobs: Vec<(Type, Vec, Rc)> = Vec::new(); + for d in self.structs.values() { + for m in &d.methods { + self.method_names.insert((d.name.clone(), m.name.clone())); + jobs.push(( + Type::Struct(d.name.clone()), + m.params.clone(), + m.body.clone(), + )); + } + } + for d in self.enums.values() { + for m in &d.methods { + self.method_names.insert((d.name.clone(), m.name.clone())); + jobs.push((Type::Enum(d.name.clone()), m.params.clone(), m.body.clone())); + } + } + for (nominal, params, body) in jobs { + let mark = self.env.len(); + if let Some(self_param) = params.first() { + self.env.push((self_param.clone(), mono(nominal))); + } + for p in params.iter().skip(1) { + let v = self.fresh(); + self.env.push((p.clone(), mono(v))); + } + let _ = self.infer(&body); + self.env.truncate(mark); + } + } + + fn install_classes(&mut self, classes: &[Rc], impls: &[Rc]) { + // register each class method as a constrained polymorphic function + for c in classes { + let pid = match self.fresh() { + Type::Var(i) => i, + _ => unreachable!(), + }; + for (mname, sig) in &c.methods { + let ty = subst_param(sig, &c.param, &Type::Var(pid)); + let scheme = Scheme { + vars: vec![pid], + constraints: vec![(c.name.clone(), pid)], + ty, + }; + self.env.push((mname.clone(), scheme)); + self.class_methods.insert(mname.clone(), c.name.clone()); + } + } + // register instances (coherence) and type-check their method bodies + for im in impls { + if !self + .instances + .insert((im.class.clone(), im.type_name.clone())) + { + self.errors.push(format!( + "duplicate instance `{} for {}`", + im.class, im.type_name + )); + } + let class = match classes.iter().find(|c| c.name == im.class) { + Some(c) => c.clone(), + None => { + self.errors.push(format!("unknown class `{}`", im.class)); + continue; + } + }; + let inst_ty = self.nominal_of(&im.type_name); + for (mname, sig) in &class.methods { + match im.methods.iter().find(|(n, _)| n == mname) { + Some((_, body)) => { + let expected = subst_param(sig, &class.param, &inst_ty); + // peel lambda params, binding each to its expected arg type, + // so the body sees `self`-like params at the instance type + let mark = self.env.len(); + let mut e = body.as_ref(); + let mut ty = expected.clone(); + while let (Expr::Lam(p, inner), Type::Fun(arg, ret)) = (e, ty.clone()) { + self.env.push((p.clone(), mono(*arg))); + e = inner; + ty = *ret; + } + let got = self.infer(e); + if self.unify(&got, &ty).is_err() { + self.errors.push(format!( + "impl `{} for {}`: `{mname}` : expected {}, got {}", + im.class, + im.type_name, + self.resolve(&ty).show(), + self.resolve(&got).show() + )); + } + self.env.truncate(mark); + } + None => self.errors.push(format!( + "impl `{} for {}` is missing method `{mname}`", + im.class, im.type_name + )), + } + } + } + } + + /// `x.method` where `method` is a class method desugars to `method x`. + fn class_method_select(&mut self, recv: Type, field: &str) -> Option { + if !self.class_methods.contains_key(field) { + return None; + } + let scheme = self.lookup(field)?; + let mty = self.instantiate(&scheme); // Fun(arg, ret); pushes the constraint + let ret = self.fresh(); + let _ = self.unify(&mty, &fun(recv, ret.clone())); + Some(ret) + } + + fn nominal_of(&self, name: &str) -> Type { + match name { + "Int" => Type::Int, + "Str" => Type::Str, + "Bool" => Type::Bool, + _ if self.enums.contains_key(name) => Type::Enum(name.to_string()), + _ => Type::Struct(name.to_string()), + } + } + + /// Discharge collected constraints: a concrete type must have an instance. + /// Constraints still on a type variable are left (polymorphic / unused). + fn resolve_pending(&mut self) { + let pending = std::mem::take(&mut self.pending); + for (class, ty) in pending { + if let Some(head) = type_head(&self.resolve(&ty)) + && !self.instances.contains(&(class.clone(), head.clone())) + { + self.errors + .push(format!("no instance `{class} for {head}`")); + } + } + } + + /// Install the engine's builtins and global values into the environment, + /// each as a fresh-instantiated scheme. + fn install_engine(&mut self, engine: &Engine) { + for b in &engine.builtins { + let s = self.lower_scheme(&b.scheme); + self.env.push((b.name.clone(), s)); + } + for v in &engine.values { + let s = self.lower_scheme(&v.scheme); + self.env.push((v.name.clone(), s)); + } + } + + /// Turn a [`BuiltinScheme`] (bound vars written as `Var(0..quantified)`) into + /// a real [`Scheme`] by allocating that many fresh inference vars and + /// substituting them in, so its quantified vars never collide with inference. + fn lower_scheme(&mut self, bs: &BuiltinScheme) -> Scheme { + let fresh: Vec = (0..bs.quantified) + .map(|_| match self.fresh() { + Type::Var(i) => i, + _ => unreachable!(), + }) + .collect(); + Scheme { + vars: fresh.clone(), + constraints: bs + .constraints + .iter() + .map(|(c, i)| (c.clone(), fresh[*i as usize])) + .collect(), + ty: lower_type(&bs.ty, &fresh), + } + } + + // ---- type-variable plumbing ------------------------------------------- + + fn fresh(&mut self) -> Type { + let id = self.subst.len() as u32; + self.subst.push(None); + Type::Var(id) + } + + fn prune(&self, t: &Type) -> Type { + match t { + Type::Var(id) => match self.subst.get(*id as usize).and_then(|o| o.clone()) { + Some(u) => self.prune(&u), + None => t.clone(), + }, + _ => t.clone(), + } + } + + /// Deeply follow substitutions (for generalization and display). + fn resolve(&self, t: &Type) -> Type { + match self.prune(t) { + Type::List(x) => list(self.resolve(&x)), + Type::Task(x) => Type::Task(Box::new(self.resolve(&x))), + Type::Fun(x, y) => fun(self.resolve(&x), self.resolve(&y)), + Type::Record(m) => Type::Record( + m.iter() + .map(|(k, v)| (k.clone(), self.resolve(v))) + .collect(), + ), + other => other, + } + } + + fn occurs(&self, id: u32, t: &Type) -> bool { + match self.prune(t) { + Type::Var(j) => id == j, + Type::List(x) | Type::Task(x) => self.occurs(id, &x), + Type::Fun(x, y) => self.occurs(id, &x) || self.occurs(id, &y), + Type::Record(m) => m.values().any(|v| self.occurs(id, v)), + _ => false, + } + } + + fn bind(&mut self, id: u32, t: &Type) -> Result<(), String> { + if let Type::Var(j) = t + && *j == id + { + return Ok(()); + } + if self.occurs(id, t) { + return Err(format!( + "infinite type: t{id} occurs in {}", + self.resolve(t).show() + )); + } + self.subst[id as usize] = Some(t.clone()); + Ok(()) + } + + fn unify(&mut self, a: &Type, b: &Type) -> Result<(), String> { + let a = self.prune(a); + let b = self.prune(b); + match (&a, &b) { + (Type::Dyn, _) | (_, Type::Dyn) => Ok(()), + (Type::Var(i), Type::Var(j)) if i == j => Ok(()), + (Type::Var(i), _) => self.bind(*i, &b), + (_, Type::Var(j)) => self.bind(*j, &a), + (Type::Int, Type::Int) | (Type::Str, Type::Str) | (Type::Bool, Type::Bool) => Ok(()), + (Type::List(x), Type::List(y)) => self.unify(x, y), + (Type::Task(x), Type::Task(y)) => self.unify(x, y), + (Type::Fun(a1, r1), Type::Fun(a2, r2)) => { + self.unify(a1, a2)?; + self.unify(r1, r2) + } + (Type::Struct(n), Type::Struct(m)) if n == m => Ok(()), + (Type::Enum(n), Type::Enum(m)) if n == m => Ok(()), + (Type::Record(m1), Type::Record(m2)) if m1.keys().eq(m2.keys()) => { + for (k, v1) in m1 { + self.unify(v1, &m2[k])?; + } + Ok(()) + } + _ => Err(format!("expected {}, got {}", a.show(), b.show())), + } + } + + fn want(&mut self, a: &Type, b: &Type) { + if let Err(e) = self.unify(a, b) { + self.errors.push(e); + } + } + + fn instantiate(&mut self, s: &Scheme) -> Type { + let mapping: HashMap = s.vars.iter().map(|v| (*v, self.fresh())).collect(); + // each instantiation of a constrained scheme adds a pending constraint + for (class, v) in &s.constraints { + if let Some(t) = mapping.get(v) { + self.pending.push((class.clone(), t.clone())); + } + } + fn go(t: &Type, m: &HashMap) -> Type { + match t { + Type::Var(id) => m.get(id).cloned().unwrap_or(Type::Var(*id)), + Type::List(x) => list(go(x, m)), + Type::Task(x) => Type::Task(Box::new(go(x, m))), + Type::Fun(x, y) => fun(go(x, m), go(y, m)), + Type::Record(r) => { + Type::Record(r.iter().map(|(k, v)| (k.clone(), go(v, m))).collect()) + } + other => other.clone(), + } + } + go(&s.ty, &mapping) + } + + fn generalize(&self, t: &Type) -> Scheme { + let t = self.resolve(t); + let mut env_fv: HashSet = HashSet::new(); + for (_, s) in &self.env { + let rt = self.resolve(&s.ty); + let mut fv = Vec::new(); + free_vars(&rt, &mut fv); + for id in fv { + if !s.vars.contains(&id) { + env_fv.insert(id); + } + } + } + let mut tv = Vec::new(); + free_vars(&t, &mut tv); + let mut vars = Vec::new(); + for id in tv { + if !env_fv.contains(&id) && !vars.contains(&id) { + vars.push(id); + } + } + Scheme { + vars, + constraints: Vec::new(), + ty: t, + } + } + + fn lookup(&self, n: &str) -> Option { + self.env + .iter() + .rev() + .find(|(k, _)| k == n) + .map(|(_, s)| s.clone()) + } + + fn struct_fields(&self, name: &str) -> Option> { + self.structs.get(name).map(|d| { + d.fields + .iter() + .map(|f| (f.name.clone(), f.ty.clone())) + .collect() + }) + } + + // ---- inference --------------------------------------------------------- + + pub fn check(&mut self, e: &Expr) -> Type { + let t = self.infer(e); + self.resolve_pending(); + t + } + + fn infer(&mut self, e: &Expr) -> Type { + match e { + Expr::Int(..) => Type::Int, + Expr::Str(_) => Type::Str, + Expr::Bool(_) => Type::Bool, + Expr::Var(n) => match self.lookup(n) { + Some(s) => self.instantiate(&s), + None => { + self.errors.push(format!("unbound variable `{n}`")); + Type::Dyn + } + }, + Expr::Lam(p, body) => { + let pv = self.fresh(); + self.env.push((p.clone(), mono(pv.clone()))); + let bt = self.infer(body); + self.env.pop(); + fun(pv, bt) + } + Expr::App(f, a) => { + let ft = self.infer(f); + let at = self.infer(a); + let rv = self.fresh(); + let expected = fun(at, rv.clone()); + if let Err(e) = self.unify(&ft, &expected) { + self.errors.push(format!("application: {e}")); + return Type::Dyn; + } + rv + } + // list literal: homogeneous -> [t]; heterogeneous (tuple-like) -> [?] + Expr::List(es) => { + let ev = self.fresh(); + let mut homogeneous = true; + for e in es { + let t = self.infer(e); + if self.unify(&ev, &t).is_err() { + homogeneous = false; + } + } + if homogeneous { + list(ev) + } else { + list(Type::Dyn) + } + } + Expr::Record(fields) => { + let mut m = BTreeMap::new(); + for (k, e) in fields { + let t = self.infer(e); + m.insert(k.clone(), t); + } + Type::Record(m) + } + Expr::Construct(name, fields) => self.check_construct(name, fields), + Expr::EnumVariant(name, variant) => { + match self.enums.get(name) { + Some(d) if d.variants.iter().any(|v| v == variant) => {} + Some(_) => self + .errors + .push(format!("enum `{name}` has no variant `{variant}`")), + None => self.errors.push(format!("unknown enum `{name}`")), + } + Type::Enum(name.clone()) + } + Expr::Select(obj, field) => { + let ot = self.infer(obj); + match self.prune(&ot) { + Type::Record(m) => m.get(field).cloned().unwrap_or_else(|| { + self.errors + .push(format!("no field `{field}` on {}", ot.show())); + Type::Dyn + }), + // field, then inherent method, then class-method (`x.m` == `m x`) + Type::Struct(n) => { + if let Some(ft) = self.struct_fields(&n).and_then(|m| m.get(field).cloned()) + { + ft + } else if self.method_names.contains(&(n.clone(), field.clone())) { + Type::Dyn + } else if let Some(t) = + self.class_method_select(Type::Struct(n.clone()), field) + { + t + } else { + self.errors + .push(format!("no field or method `{field}` on `{n}`")); + Type::Dyn + } + } + Type::Enum(n) => { + if self.method_names.contains(&(n.clone(), field.clone())) { + Type::Dyn + } else if let Some(t) = + self.class_method_select(Type::Enum(n.clone()), field) + { + t + } else { + self.errors.push(format!("no method `{field}` on `{n}`")); + Type::Dyn + } + } + _ => Type::Dyn, // var/dyn: cannot resolve statically + } + } + Expr::Merge(l, r) => { + let lt = self.infer(l); + let rt = self.infer(r); + self.infer_merge(lt, rt) + } + Expr::If(c, t, e) => { + let ct = self.infer(c); + self.want(&ct, &Type::Bool); + let tt = self.infer(t); + let et = self.infer(e); + self.want(&tt, &et); + tt + } + Expr::Bin(op, l, r) => self.infer_bin(*op, l, r), + Expr::Let(binds, body) => { + let mark = self.env.len(); + // recursive: pre-bind each name to a fresh monomorphic var + let mut vars = Vec::new(); + for b in binds { + let v = self.fresh(); + vars.push(v.clone()); + self.env.push((b.name.clone(), mono(v))); + } + for (i, b) in binds.iter().enumerate() { + let t = self.check_binding(b); + self.want(&vars[i].clone(), &t); + } + // generalize for the body (let-polymorphism) + self.env.truncate(mark); + for (i, b) in binds.iter().enumerate() { + let s = self.generalize(&vars[i].clone()); + self.env.push((b.name.clone(), s)); + } + let bt = self.infer(body); + self.env.truncate(mark); + bt + } + } + } + + fn infer_bin(&mut self, op: BinOp, l: &Expr, r: &Expr) -> Type { + // arithmetic and `/` dispatch through operator classes (Add/Sub/.../Div), + // so `a op b : a` requires an instance for `a` (built-in for Int/Str). + if let Some((class, _)) = op_class(op) { + let lt = self.infer(l); + let rt = self.infer(r); + self.want(<, &rt); + let t = self.prune(<); + self.pending.push((class.to_string(), t.clone())); + return t; + } + match op { + BinOp::Eq => { + let lt = self.infer(l); + let rt = self.infer(r); + self.want(<, &rt); + Type::Bool + } + BinOp::And | BinOp::Or => { + let lt = self.infer(l); + let rt = self.infer(r); + self.want(<, &Type::Bool); + self.want(&rt, &Type::Bool); + Type::Bool + } + // `++` is string concat for strings, else list append + BinOp::Concat => { + let lt = self.infer(l); + let rt = self.infer(r); + if matches!(self.prune(<), Type::Str) { + self.want(&rt, &Type::Str); + Type::Str + } else { + let ev = self.fresh(); + self.want(<, &list(ev.clone())); + self.want(&rt, &list(ev.clone())); + list(ev) + } + } + _ => unreachable!("op-class operators handled above"), + } + } + + fn infer_merge(&mut self, lt: Type, rt: Type) -> Type { + let overrides = match self.prune(&rt) { + Type::Record(m) => m, + Type::Dyn => return self.prune(<), + other => { + self.errors.push(format!( + "right of `//` must be a record, got {}", + other.show() + )); + return self.prune(<); + } + }; + match self.prune(<) { + Type::Struct(name) => { + if let Some(schema) = self.struct_fields(&name) { + for (k, vt) in &overrides { + match schema.get(k) { + Some(ft) => { + if self.unify(ft, vt).is_err() { + self.errors.push(format!( + "`{name} // {{ {k} = .. }}` : `{name}.{k}` is {}, got {}", + ft.show(), + self.resolve(vt).show() + )); + } + } + None => self + .errors + .push(format!("`{name}` has no field `{k}` to override")), + } + } + } + Type::Struct(name) + } + Type::Record(base) => { + let mut m = base; + for (k, v) in overrides { + m.insert(k, v); + } + Type::Record(m) + } + other => { + self.errors.push(format!( + "left of `//` must be a record/struct, got {}", + other.show() + )); + other + } + } + } + + fn check_construct(&mut self, name: &str, fields: &[(String, Rc)]) -> Type { + let decl = match self.structs.get(name) { + Some(d) => d.clone(), + None => { + self.errors.push(format!("unknown struct `{name}`")); + return Type::Struct(name.into()); + } + }; + let mut given: BTreeMap = BTreeMap::new(); + for (k, e) in fields { + let t = self.infer(e); + given.insert(k.clone(), t); + } + for f in &decl.fields { + match given.get(&f.name) { + Some(gt) => { + if self.unify(gt, &f.ty).is_err() { + self.errors.push(format!( + "`{name}.{}` : expected {}, got {}", + f.name, + f.ty.show(), + self.resolve(gt).show() + )); + } + } + None if f.default.is_some() => {} + None => self + .errors + .push(format!("`{name}` missing required field `{}`", f.name)), + } + } + for k in given.keys() { + if !decl.fields.iter().any(|f| &f.name == k) { + self.errors.push(format!("`{name}` has no field `{k}`")); + } + } + Type::Struct(name.into()) + } + + fn check_binding(&mut self, b: &Binding) -> Type { + match (&b.ann, &*b.value) { + (Some(Type::Struct(name)), Expr::Record(fields)) => self.check_construct(name, fields), + (Some(ann), _) => { + let got = self.infer(&b.value); + if self.unify(&got, ann).is_err() { + self.errors.push(format!( + "`{}` : annotated {}, got {}", + b.name, + ann.show(), + self.resolve(&got).show() + )); + } + ann.clone() + } + (None, _) => self.infer(&b.value), + } + } +} + +/// The operator class + method an arithmetic/`/` operator desugars to. +pub fn op_class(op: BinOp) -> Option<(&'static str, &'static str)> { + match op { + BinOp::Add => Some(("Add", "add")), + BinOp::Sub => Some(("Sub", "sub")), + BinOp::Mul => Some(("Mul", "mul")), + BinOp::Slash => Some(("Div", "div")), + BinOp::Mod => Some(("Mod", "mod")), + BinOp::Pow => Some(("Pow", "pow")), + _ => None, + } +} + +/// The nominal head of a type (for instance lookup), if it has one. +fn type_head(t: &Type) -> Option { + match t { + Type::Int => Some("Int".into()), + Type::Str => Some("Str".into()), + Type::Bool => Some("Bool".into()), + Type::List(_) => Some("List".into()), + Type::Struct(n) | Type::Enum(n) => Some(n.clone()), + _ => None, + } +} + +/// Replace the class parameter (parsed as `Struct(param)`) with `repl` in a sig. +fn subst_param(t: &Type, param: &str, repl: &Type) -> Type { + match t { + Type::Struct(n) if n == param => repl.clone(), + Type::List(x) => Type::List(Box::new(subst_param(x, param, repl))), + Type::Task(x) => Type::Task(Box::new(subst_param(x, param, repl))), + Type::Fun(x, y) => Type::Fun( + Box::new(subst_param(x, param, repl)), + Box::new(subst_param(y, param, repl)), + ), + Type::Record(m) => Type::Record( + m.iter() + .map(|(k, v)| (k.clone(), subst_param(v, param, repl))) + .collect(), + ), + other => other.clone(), + } +} + +/// Rewrite a [`BuiltinScheme`]'s local bound vars `Var(0..)` to allocated `fresh` ids. +fn lower_type(t: &Type, fresh: &[u32]) -> Type { + match t { + Type::Var(id) => Type::Var(fresh[*id as usize]), + Type::List(x) => Type::List(Box::new(lower_type(x, fresh))), + Type::Task(x) => Type::Task(Box::new(lower_type(x, fresh))), + Type::Fun(x, y) => Type::Fun( + Box::new(lower_type(x, fresh)), + Box::new(lower_type(y, fresh)), + ), + Type::Record(m) => Type::Record( + m.iter() + .map(|(k, v)| (k.clone(), lower_type(v, fresh))) + .collect(), + ), + other => other.clone(), + } +} + +fn free_vars(t: &Type, out: &mut Vec) { + match t { + Type::Var(id) => { + if !out.contains(id) { + out.push(*id); + } + } + Type::List(x) | Type::Task(x) => free_vars(x, out), + Type::Fun(x, y) => { + free_vars(x, out); + free_vars(y, out); + } + Type::Record(m) => { + for v in m.values() { + free_vars(v, out); + } + } + _ => {} + } +} diff --git a/crates/doot-lang/src/lang/diag.rs b/crates/doot-lang/src/lang/diag.rs new file mode 100644 index 0000000..e4e8ed8 --- /dev/null +++ b/crates/doot-lang/src/lang/diag.rs @@ -0,0 +1,69 @@ +//! Source spans and diagnostics for parse/type errors. + +/// A half-open range of char offsets into the source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Span { start, end } + } + /// A zero-width span at `at` (for "unexpected end" style errors). + pub fn point(at: usize) -> Self { + Span { start: at, end: at } + } +} + +/// A diagnostic with an optional source location. +#[derive(Debug, Clone)] +pub struct Diagnostic { + pub message: String, + pub span: Option, +} + +impl Diagnostic { + pub fn new(message: impl Into, span: Span) -> Self { + Diagnostic { + message: message.into(), + span: Some(span), + } + } + /// A diagnostic with no source location (e.g. a type error not yet tied to a span). + pub fn message(message: impl Into) -> Self { + Diagnostic { + message: message.into(), + span: None, + } + } + + /// Render the diagnostic against `src`: `line:col: message`, and when a span + /// is present, the offending source line with a caret underline. + pub fn render(&self, src: &str) -> String { + let Some(span) = self.span else { + return self.message.clone(); + }; + let chars: Vec = src.chars().collect(); + // line/col (1-based) of the span start + let mut line = 1; + let mut col = 1; + for &c in chars.iter().take(span.start.min(chars.len())) { + if c == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + // extract the source line text + let line_text: String = src.lines().nth(line - 1).unwrap_or("").to_string(); + let width = (span.end.saturating_sub(span.start)).max(1); + let caret = format!("{}{}", " ".repeat(col - 1), "^".repeat(width)); + format!( + "{line}:{col}: {}\n {}\n {}", + self.message, line_text, caret + ) + } +} diff --git a/crates/doot-lang/src/lang/engine.rs b/crates/doot-lang/src/lang/engine.rs new file mode 100644 index 0000000..998bf25 --- /dev/null +++ b/crates/doot-lang/src/lang/engine.rs @@ -0,0 +1,118 @@ +//! The registration surface that decouples the language core from its vocabulary. +//! +//! An [`Engine`] holds the built-in definitions a program is checked and +//! evaluated against: functions (each with a type scheme and a native impl), +//! plain global values, and nominal structs/enums/classes/instances. The core +//! (`check`, `eval`) is built *from* an `Engine` rather than hardcoding any of +//! these, so a standard library or a domain layer registers its own surface +//! instead of editing the evaluator. + +use std::rc::Rc; + +use super::ast::{ClassDecl, EnumDecl, StructDecl, Type}; +use super::eval::{Interp, Thunk}; +use super::eval::{NativeDef, Value}; + +/// A polymorphic built-in type. Bound variables are written as `Type::Var(0)`, +/// `Type::Var(1)`, ... up to `quantified`; the checker allocates that many fresh +/// inference variables and substitutes them in when installing the scheme. +pub struct BuiltinScheme { + pub quantified: usize, + /// type-class constraints `(class, bound-var index)` + pub constraints: Vec<(String, u32)>, + pub ty: Type, +} + +impl BuiltinScheme { + pub fn mono(ty: Type) -> Self { + BuiltinScheme { + quantified: 0, + constraints: Vec::new(), + ty, + } + } + pub fn poly(quantified: usize, ty: Type) -> Self { + BuiltinScheme { + quantified, + constraints: Vec::new(), + ty, + } + } +} + +pub struct BuiltinReg { + pub name: String, + pub scheme: BuiltinScheme, + pub native: Rc, +} + +pub struct ValueReg { + pub name: String, + pub scheme: BuiltinScheme, + pub value: Value, +} + +/// The set of built-ins a program is checked and evaluated against. +#[derive(Default)] +pub struct Engine { + pub builtins: Vec, + pub values: Vec, + pub structs: Vec>, + pub enums: Vec>, + pub classes: Vec>, + /// built-in instance heads `(class, type head)` for coherence/resolution + pub instances: Vec<(String, String)>, +} + +impl Engine { + /// Register a native function under `name` with its type scheme and impl. + pub fn register_builtin( + &mut self, + name: &str, + scheme: BuiltinScheme, + arity: usize, + func: impl Fn(&Interp, &[Thunk]) -> Value + 'static, + ) { + self.builtins.push(BuiltinReg { + name: name.to_string(), + scheme, + native: Rc::new(NativeDef::new(arity, func)), + }); + } + + /// Register a plain global value (a constant, not a function). + pub fn register_value(&mut self, name: &str, scheme: BuiltinScheme, value: Value) { + self.values.push(ValueReg { + name: name.to_string(), + scheme, + value, + }); + } + + pub fn register_struct(&mut self, decl: StructDecl) { + self.structs.push(Rc::new(decl)); + } + + pub fn register_enum(&mut self, decl: EnumDecl) { + self.enums.push(Rc::new(decl)); + } + + pub fn register_class(&mut self, decl: ClassDecl) { + self.classes.push(Rc::new(decl)); + } + + pub fn register_instance(&mut self, class: &str, type_head: &str) { + self.instances + .push((class.to_string(), type_head.to_string())); + } + + /// Names of registered structs (for the parser's construction disambiguation). + pub fn struct_names(&self) -> Vec { + self.structs.iter().map(|d| d.name.clone()).collect() + } + + /// Names of registered enums (for the parser's `Enum.Variant` disambiguation). + pub fn enum_names(&self) -> Vec { + self.enums.iter().map(|d| d.name.clone()).collect() + } +} diff --git a/crates/doot-lang/src/lang/eval.rs b/crates/doot-lang/src/lang/eval.rs new file mode 100644 index 0000000..5776ca1 --- /dev/null +++ b/crates/doot-lang/src/lang/eval.rs @@ -0,0 +1,787 @@ +//! Lazy, pure evaluator. Produces a [`Plan`]; no side effects. Bindings and +//! record/list fields are force-once thunks. Constructing a `Task` records an edge +//! to every other `Task` reachable in its data - dependencies are never hand-written. + +use std::any::Any; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; + +use super::ast::*; +use super::engine::Engine; +use super::plan::{Node, Plan}; + +// values, thunks, environment + +pub type Thunk = Rc>; + +#[derive(Clone)] +pub enum ThunkState { + Expr(Rc, Env), + Val(Value), + /// deferred Rust computation (lazy list ops); receives the interpreter at force + Native(Rc Value>), + Black, +} + +fn black_thunk() -> Thunk { + Rc::new(RefCell::new(ThunkState::Black)) +} + +// Strip a thunk-state's direct child thunks into `work`, replacing them so the +// state itself drops shallowly. Covers list cells (head + tail) and owned +// attrsets, which is where deep value structure lives. +fn take_children(st: &mut ThunkState, work: &mut Vec) { + if let ThunkState::Val(v) = st { + match v { + Value::Cons(h, t) => { + work.push(std::mem::replace(h, black_thunk())); + work.push(std::mem::replace(t, black_thunk())); + } + Value::Attr(_, m) => { + if let Some(map) = Rc::get_mut(m) { + for t in map.values_mut() { + work.push(std::mem::replace(t, black_thunk())); + } + } + } + _ => {} + } + } +} + +// Dismantle nested thunks iteratively so dropping a deep value (a long list +// spine OR a deeply head-nested tree OR a nested attrset) never recurses on +// Rust's stack. +impl Drop for ThunkState { + fn drop(&mut self) { + let mut work: Vec = Vec::new(); + take_children(self, &mut work); + while let Some(t) = work.pop() { + if let Ok(cell) = Rc::try_unwrap(t) { + let mut st = cell.into_inner(); + take_children(&mut st, &mut work); + // `st` is now childless, so its own drop is shallow + } + } + } +} + +#[derive(Clone)] +pub enum Value { + Int(i64), + Str(Rc), + Bool(bool), + /// lazy lists: empty, or a head thunk and a tail thunk (tail can be infinite) + Nil, + Cons(Thunk, Thunk), + /// attrset; the optional name is the nominal struct name (None = bare record) + Attr(Option>, Rc>), + Lam(String, Rc, Env), + /// a native function: its definition plus the args gathered so far (currying) + Native(Native), + /// reference to a plan node (an effect to realize) + Task(usize), + /// an opaque foreign value (e.g. a domain marker like `file("path")`) + Foreign(Rc), + /// nominal enum variant: (enum name, variant name) + Enum(Rc, Rc), + /// a class method awaiting its receiver: (class, method) + ClassMethod(Rc, Rc), +} + +/// A native (Rust-implemented) function. `func` receives its args as thunks once +/// `arity` of them are gathered, and forces only what it needs - this is what +/// keeps builtins like `optionals`/`cons`/`map` lazy. Partial application +/// accumulates args in `args` and returns a new `Native`. +pub type NativeFn = dyn Fn(&Interp, &[Thunk]) -> Value; + +#[derive(Clone)] +pub struct Native { + def: Rc, + args: Rc>, +} + +pub struct NativeDef { + arity: usize, + func: Box, +} + +impl NativeDef { + pub fn new(arity: usize, func: impl Fn(&Interp, &[Thunk]) -> Value + 'static) -> Self { + NativeDef { + arity, + func: Box::new(func), + } + } +} + +/// Wrap a registered native definition as a global binding with no args gathered. +pub fn native_global(def: Rc) -> Value { + Value::Native(Native { + def, + args: Rc::new(Vec::new()), + }) +} + +pub type Env = Rc; + +pub struct Scope { + vars: RefCell>, + parent: Option, +} + +impl Scope { + fn lookup(&self, name: &str) -> Option { + if let Some(t) = self.vars.borrow().get(name) { + return Some(t.clone()); + } + self.parent.as_ref().and_then(|p| p.lookup(name)) + } +} + +pub fn forced(v: Value) -> Thunk { + Rc::new(RefCell::new(ThunkState::Val(v))) +} +fn thunk_expr(e: &Rc, env: &Env) -> Thunk { + Rc::new(RefCell::new(ThunkState::Expr(e.clone(), env.clone()))) +} +pub fn native Value + 'static>(f: F) -> Thunk { + Rc::new(RefCell::new(ThunkState::Native(Rc::new(f)))) +} +fn child(parent: &Env, name: String, t: Thunk) -> Env { + let mut vars = HashMap::new(); + vars.insert(name, t); + Rc::new(Scope { + vars: RefCell::new(vars), + parent: Some(parent.clone()), + }) +} + +// CEK machine: control + heap continuation stack. Recursion depth (deep force +// chains, non-tail recursion) lives on the `Vec`, not Rust's call stack. +enum Ctrl { + Eval(Rc, Env), + Force(Thunk), +} + +enum Cont { + Update(Thunk), // memoize a forced value back into its thunk + AppArg(Thunk), // applied a function value to this (lazy) arg + IfK(Rc, Rc, Env), // chose branch after the condition + SelectK(String), + AndR(Rc, Env), + OrR(Rc, Env), + MergeR(Rc, Env), + MergeOp(Value), + BinR(BinOp, Rc, Env), + BinOp2(BinOp, Value), +} + +// interpreter + +pub struct Interp { + structs: BTreeMap>, + /// (type name, method name) -> the method as a `\self -> ...` lambda expr + methods: BTreeMap<(String, String), Rc>, + /// (class, type head, method) -> the instance body (a lambda expr) + instances: BTreeMap<(String, String, String), Rc>, + plan: RefCell, + globals: Env, +} + +/// Fold a method's params into a curried `\self -> \p1 -> ... body` lambda. +fn fold_method(m: &MethodDecl) -> Rc { + let mut e = m.body.clone(); + for p in m.params.iter().rev() { + e = Rc::new(Expr::Lam(p.clone(), e)); + } + e +} + +pub fn interp_with_engine(program: &Program, engine: &Engine) -> Interp { + let mut structs: BTreeMap> = program + .structs + .iter() + .map(|d| (d.name.clone(), d.clone())) + .collect(); + for d in &engine.structs { + structs.entry(d.name.clone()).or_insert_with(|| d.clone()); + } + + let mut methods: BTreeMap<(String, String), Rc> = BTreeMap::new(); + for d in program.structs.iter().chain(&engine.structs) { + for m in &d.methods { + methods.insert((d.name.clone(), m.name.clone()), fold_method(m)); + } + } + for d in program.enums.iter().chain(&engine.enums) { + for m in &d.methods { + methods.insert((d.name.clone(), m.name.clone()), fold_method(m)); + } + } + + // class methods (name -> class) and instance bodies ((class, head, method) -> lam) + let mut class_of: BTreeMap = BTreeMap::new(); + for c in &program.classes { + for (m, _) in &c.methods { + class_of.insert(m.clone(), c.name.clone()); + } + } + let mut instances: BTreeMap<(String, String, String), Rc> = BTreeMap::new(); + for im in &program.impls { + for (m, body) in &im.methods { + instances.insert( + (im.class.clone(), im.type_name.clone(), m.clone()), + body.clone(), + ); + } + } + + let mut g = HashMap::new(); + for (m, c) in &class_of { + g.insert( + m.clone(), + forced(Value::ClassMethod(Rc::new(c.clone()), Rc::new(m.clone()))), + ); + } + for b in &engine.builtins { + g.insert(b.name.clone(), forced(native_global(b.native.clone()))); + } + for v in &engine.values { + g.insert(v.name.clone(), forced(v.value.clone())); + } + let globals: Env = Rc::new(Scope { + vars: RefCell::new(g), + parent: None, + }); + Interp { + structs, + methods, + instances, + plan: RefCell::new(Plan::default()), + globals, + } +} + +/// The default engine: the general stdlib plus the dotfile vocabulary. (These +/// two registration passes are what later split into separate crates.) +impl Interp { + pub fn make_task(&self, label: String, data: Rc, deps: &Value) -> usize { + let id = { + let mut p = self.plan.borrow_mut(); + let id = p.nodes.len(); + p.nodes.push(Node { label, data }); + id + }; + let mut found = Vec::new(); + self.collect_tasks(deps, &mut found); + let mut p = self.plan.borrow_mut(); + for d in found { + if d != id { + p.edges.push((id, d)); + } + } + id + } + + // iterative worklist: depth/spine length lives on the heap `stack` + pub fn collect_tasks(&self, v: &Value, out: &mut Vec) { + let mut stack = vec![v.clone()]; + while let Some(val) = stack.pop() { + match val { + Value::Task(id) => out.push(id), + Value::Cons(h, t) => { + stack.push(self.force(&t)); + stack.push(self.force(&h)); + } + Value::Attr(_, m) => { + for t in m.values() { + stack.push(self.force(t)); + } + } + _ => {} + } + } + } + + pub fn force(&self, t: &Thunk) -> Value { + if let ThunkState::Val(v) = &*t.borrow() { + return v.clone(); + } + self.run(Ctrl::Force(t.clone()), Vec::new()) + } + + pub fn eval(&self, e: &Rc, env: &Env) -> Value { + self.run(Ctrl::Eval(e.clone(), env.clone()), Vec::new()) + } + + /// The global scope (for the domain layer to evaluate the program body). + pub fn global_scope(&self) -> Env { + self.globals.clone() + } + + /// Consume the interpreter, returning the accumulated plan. + pub fn into_plan(self) -> Plan { + self.plan.into_inner() + } + + /// Evaluate top-level `let` bindings to WHNF in a recursive scope (so each + /// binding sees its siblings). Returns each name with its forced value; the + /// domain layer decides which are materializable template values. + pub fn harvest_bindings(&self, binds: &[Binding]) -> Vec<(String, Value)> { + let scope = Rc::new(Scope { + vars: RefCell::new(HashMap::new()), + parent: Some(self.globals.clone()), + }); + for b in binds { + scope + .vars + .borrow_mut() + .insert(b.name.clone(), thunk_expr(&b.value, &scope)); + } + binds + .iter() + .map(|b| { + let t = scope.vars.borrow().get(&b.name).cloned().unwrap(); + (b.name.clone(), self.force(&t)) + }) + .collect() + } + + /// The CEK loop. Reduces `ctrl` to a value, then feeds it through the + /// continuation stack `k`. All recursion depth lives on `k` (heap), so deep + /// non-tail recursion and deep force chains do not grow Rust's stack. + fn run(&self, ctrl0: Ctrl, mut k: Vec) -> Value { + let mut ctrl = ctrl0; + loop { + // reduce control to a value, pushing continuations + let mut value = loop { + match ctrl { + Ctrl::Eval(expr, env) => match &*expr { + Expr::Int(n, _) => break Value::Int(*n), + Expr::Str(s) => break Value::Str(Rc::new(s.clone())), + Expr::Bool(b) => break Value::Bool(*b), + Expr::Lam(p, b) => break Value::Lam(p.clone(), b.clone(), env.clone()), + Expr::EnumVariant(e, v) => { + break Value::Enum(Rc::new(e.clone()), Rc::new(v.clone())); + } + Expr::List(es) => { + let mut acc = Value::Nil; + for e in es.iter().rev() { + acc = Value::Cons(thunk_expr(e, &env), forced(acc)); + } + break acc; + } + Expr::Record(fields) => { + break Value::Attr(None, Rc::new(self.record_thunks(fields, &env))); + } + Expr::Construct(name, fields) => { + let mut m = self.record_thunks(fields, &env); + if let Some(decl) = self.structs.get(name) { + for f in &decl.fields { + if !m.contains_key(&f.name) + && let Some(def) = &f.default + { + m.insert(f.name.clone(), thunk_expr(def, &self.globals)); + } + } + } + break Value::Attr(Some(Rc::new(name.clone())), Rc::new(m)); + } + Expr::Var(n) => { + let t = env + .lookup(n) + .unwrap_or_else(|| panic!("unbound variable: {n}")); + ctrl = Ctrl::Force(t); + } + Expr::App(f, a) => { + k.push(Cont::AppArg(thunk_expr(a, &env))); + ctrl = Ctrl::Eval(f.clone(), env.clone()); + } + Expr::If(c, t, e) => { + k.push(Cont::IfK(t.clone(), e.clone(), env.clone())); + ctrl = Ctrl::Eval(c.clone(), env.clone()); + } + Expr::Select(o, fld) => { + k.push(Cont::SelectK(fld.clone())); + ctrl = Ctrl::Eval(o.clone(), env.clone()); + } + Expr::Merge(l, r) => { + k.push(Cont::MergeR(r.clone(), env.clone())); + ctrl = Ctrl::Eval(l.clone(), env.clone()); + } + Expr::Let(binds, body) => { + let scope = Rc::new(Scope { + vars: RefCell::new(HashMap::new()), + parent: Some(env.clone()), + }); + for b in binds { + scope + .vars + .borrow_mut() + .insert(b.name.clone(), thunk_expr(&b.value, &scope)); + } + ctrl = Ctrl::Eval(body.clone(), scope); + } + Expr::Bin(BinOp::And, l, r) => { + k.push(Cont::AndR(r.clone(), env.clone())); + ctrl = Ctrl::Eval(l.clone(), env.clone()); + } + Expr::Bin(BinOp::Or, l, r) => { + k.push(Cont::OrR(r.clone(), env.clone())); + ctrl = Ctrl::Eval(l.clone(), env.clone()); + } + Expr::Bin(op, l, r) => { + k.push(Cont::BinR(*op, r.clone(), env.clone())); + ctrl = Ctrl::Eval(l.clone(), env.clone()); + } + }, + Ctrl::Force(t) => { + // match by ref: ThunkState has a Drop impl, so we cannot move out + let st = t.borrow().clone(); + match &st { + ThunkState::Val(v) => break v.clone(), + ThunkState::Black => panic!("infinite recursion (black hole)"), + ThunkState::Expr(e, env) => { + *t.borrow_mut() = ThunkState::Black; + k.push(Cont::Update(t.clone())); + ctrl = Ctrl::Eval(e.clone(), env.clone()); + } + // native ops compute one WHNF cell (re-enters bounded) + ThunkState::Native(f) => { + let f = f.clone(); + *t.borrow_mut() = ThunkState::Black; + let v = f(self); + *t.borrow_mut() = ThunkState::Val(v.clone()); + break v; + } + } + } + } + }; + // feed the value through the continuation stack + loop { + match k.pop() { + None => return value, + Some(Cont::Update(t)) => { + *t.borrow_mut() = ThunkState::Val(value.clone()); + } + Some(Cont::AppArg(arg)) => match value { + Value::Lam(p, body, lenv) => { + ctrl = Ctrl::Eval(body, child(&lenv, p, arg)); + break; + } + Value::Native(n) => value = self.apply_native(n, arg), + // class methods (and any other callable) dispatch via apply + other => value = self.apply(other, arg), + }, + Some(Cont::IfK(t, e, env)) => { + let branch = match value { + Value::Bool(true) => t, + Value::Bool(false) => e, + _ => panic!("if: condition not a bool"), + }; + ctrl = Ctrl::Eval(branch, env); + break; + } + // field, then inherent method, then class method (`x.m` sugar) + Some(Cont::SelectK(fld)) => match value { + Value::Attr(name, m) => { + if let Some(th) = m.get(&fld) { + ctrl = Ctrl::Force(th.clone()); + break; + } + let recv = Value::Attr(name.clone(), m.clone()); + let f = self + .method(name.as_deref().map(|s| s.as_str()), &fld) + .or_else(|| self.class_method_value(&fld)); + match f { + Some(f) => value = self.apply(f, forced(recv)), + None => panic!("no field or method `{fld}`"), + } + } + Value::Enum(en, var) => { + let recv = Value::Enum(en.clone(), var.clone()); + let f = self + .method(Some(en.as_str()), &fld) + .or_else(|| self.class_method_value(&fld)); + match f { + Some(f) => value = self.apply(f, forced(recv)), + None => panic!("no method `{fld}` on enum `{en}`"), + } + } + _ => panic!("select on non-attrset"), + }, + Some(Cont::AndR(r, env)) => { + if matches!(value, Value::Bool(true)) { + ctrl = Ctrl::Eval(r, env); + break; + } + value = Value::Bool(false); + } + Some(Cont::OrR(r, env)) => { + if !matches!(value, Value::Bool(true)) { + ctrl = Ctrl::Eval(r, env); + break; + } + value = Value::Bool(true); + } + Some(Cont::MergeR(r, env)) => { + k.push(Cont::MergeOp(value)); + ctrl = Ctrl::Eval(r, env); + break; + } + Some(Cont::MergeOp(lv)) => { + value = match (lv, value) { + // keep the left operand's nominal name (struct // record : struct) + (Value::Attr(n, a), Value::Attr(_, b)) => { + let mut m = (*a).clone(); + for (key, v) in b.iter() { + m.insert(key.clone(), v.clone()); + } + Value::Attr(n, Rc::new(m)) + } + _ => panic!("// expects two attrsets"), + }; + } + Some(Cont::BinR(op, r, env)) => { + k.push(Cont::BinOp2(op, value)); + ctrl = Ctrl::Eval(r, env); + break; + } + Some(Cont::BinOp2(op, lv)) => value = self.combine(op, lv, value), + } + } + } + } + + fn combine(&self, op: BinOp, l: Value, r: Value) -> Value { + if let BinOp::Concat = op { + return match (l, r) { + (Value::Str(a), Value::Str(b)) => Value::Str(Rc::new(format!("{a}{b}"))), + (l, r) => self.append(l, r), + }; + } + // operator classes: built-in Int (and Str for `/`), else dispatch the + // user instance for the operand type + if let Some((class, method)) = crate::lang::check::op_class(op) { + let native = matches!(&l, Value::Int(_)) + || (matches!(op, BinOp::Slash) && matches!(&l, Value::Str(_))); + if !native { + let head = runtime_head(&l) + .unwrap_or_else(|| panic!("no `{class}` instance for this value")); + let body = self + .instances + .get(&(class.to_string(), head.clone(), method.to_string())) + .cloned() + .unwrap_or_else(|| panic!("no instance `{class} for {head}`")); + let f = self.eval(&body, &self.globals); + let partial = self.apply(f, forced(l)); + return self.apply(partial, forced(r)); + } + } + eval_bin(op, l, r) + } + + fn record_thunks(&self, fields: &[(String, Rc)], env: &Env) -> BTreeMap { + let mut m = BTreeMap::new(); + for (k, e) in fields { + m.insert(k.clone(), thunk_expr(e, env)); + } + m + } + + /// Gather one more arg into a native function; fire it once `arity` is reached. + fn apply_native(&self, n: Native, arg: Thunk) -> Value { + let mut args = (*n.args).clone(); + args.push(arg); + if args.len() == n.def.arity { + (n.def.func)(self, &args) + } else { + Value::Native(Native { + def: n.def, + args: Rc::new(args), + }) + } + } + + pub fn apply(&self, f: Value, arg: Thunk) -> Value { + match f { + Value::Lam(p, body, env) => self.run(Ctrl::Eval(body, child(&env, p, arg)), Vec::new()), + Value::Native(n) => self.apply_native(n, arg), + // type-class dispatch: pick the instance by the receiver's runtime type + Value::ClassMethod(class, method) => { + let recv = self.force(&arg); + let head = runtime_head(&recv) + .unwrap_or_else(|| panic!("no instance `{class}` for this value")); + let body = self + .instances + .get(&((*class).clone(), head.clone(), (*method).clone())) + .cloned() + .unwrap_or_else(|| panic!("no instance `{class} for {head}`")); + let f = self.eval(&body, &self.globals); + self.apply(f, forced(recv)) + } + _ => panic!("apply: not a function"), + } + } + + /// Look up an inherent method and evaluate it to a `\self -> ...` closure. + fn method(&self, type_name: Option<&str>, name: &str) -> Option { + let lam = self + .methods + .get(&(type_name?.to_string(), name.to_string()))? + .clone(); + Some(self.eval(&lam, &self.globals)) + } + + /// If `name` is a class method, return its `ClassMethod` value (for `x.m` sugar). + fn class_method_value(&self, name: &str) -> Option { + match self.force(&self.globals.lookup(name)?) { + v @ Value::ClassMethod(_, _) => Some(v), + _ => None, + } + } + + // lazy map: head and tail are deferred, so map over an infinite list is fine + pub fn map_list(&self, f: Value, xs: Value) -> Value { + match xs { + Value::Nil => Value::Nil, + Value::Cons(h, t) => { + let f_head = f.clone(); + let head = native(move |i| i.apply(f_head.clone(), h.clone())); + let tail = native(move |i| { + let tv = i.force(&t); + i.map_list(f.clone(), tv) + }); + Value::Cons(head, tail) + } + _ => panic!("map expects a list"), + } + } + + pub fn take_list(&self, n: i64, xs: Value) -> Value { + if n <= 0 { + return Value::Nil; + } + match xs { + Value::Nil => Value::Nil, + Value::Cons(h, t) => { + let tail = native(move |i| { + let tv = i.force(&t); + i.take_list(n - 1, tv) + }); + Value::Cons(h, tail) + } + _ => panic!("take expects a list"), + } + } + + // lazy append: only the left spine is walked as it is demanded + fn append(&self, l: Value, r: Value) -> Value { + match l { + Value::Nil => r, + Value::Cons(h, t) => { + let tail = native(move |i| { + let tv = i.force(&t); + i.append(tv, r.clone()) + }); + Value::Cons(h, tail) + } + _ => panic!("++ expects two lists or two strings"), + } + } + + // materialize a finite list spine into a Vec of element thunks + pub fn list_to_vec(&self, v: &Value) -> Vec { + let mut out = Vec::new(); + let mut cur = v.clone(); + loop { + match cur { + Value::Nil => break, + Value::Cons(h, t) => { + out.push(h); + cur = self.force(&t); + } + _ => panic!("expected list"), + } + } + out + } +} + +// runtime nominal head of a value, for type-class instance lookup +fn runtime_head(v: &Value) -> Option { + match v { + Value::Int(_) => Some("Int".into()), + Value::Str(_) => Some("Str".into()), + Value::Bool(_) => Some("Bool".into()), + Value::Nil | Value::Cons(_, _) => Some("List".into()), + Value::Enum(n, _) => Some((**n).clone()), + Value::Attr(Some(n), _) => Some((**n).clone()), + _ => None, + } +} + +/// Structural equality of two values (the `==` semantics), for stdlib `elem`. +pub fn value_eq(a: &Value, b: &Value) -> bool { + matches!(eval_bin(BinOp::Eq, a.clone(), b.clone()), Value::Bool(true)) +} + +fn eval_bin(op: BinOp, l: Value, r: Value) -> Value { + match op { + // `/` is path join for strings, integer division for ints + BinOp::Slash => match (l, r) { + (Value::Str(a), Value::Str(b)) => Value::Str(Rc::new(format!("{a}/{b}"))), + (Value::Int(a), Value::Int(b)) => Value::Int(a / b), + _ => panic!("/ expects two strings or two ints"), + }, + BinOp::Add => Value::Int(as_int(l) + as_int(r)), + BinOp::Sub => Value::Int(as_int(l) - as_int(r)), + BinOp::Mul => Value::Int(as_int(l) * as_int(r)), + BinOp::Mod => Value::Int(as_int(l) % as_int(r)), + BinOp::Pow => Value::Int(as_int(l).pow(as_int(r) as u32)), + BinOp::Eq => Value::Bool(match (l, r) { + (Value::Int(a), Value::Int(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Str(a), Value::Str(b)) => a == b, + (Value::Enum(e1, v1), Value::Enum(e2, v2)) => e1 == e2 && v1 == v2, + _ => false, + }), + BinOp::Concat | BinOp::And | BinOp::Or => { + unreachable!("handled in eval (string concat / short-circuit / lazy append)") + } + } +} + +// build a Package payload with a single manager field set + +pub fn empty_list() -> Value { + Value::Nil +} +pub fn list_from_vec(items: Vec) -> Value { + let mut acc = Value::Nil; + for t in items.into_iter().rev() { + acc = Value::Cons(t, forced(acc)); + } + acc +} +pub fn as_str(v: &Value) -> String { + match v { + Value::Str(s) => (**s).clone(), + _ => panic!("expected string"), + } +} +pub fn as_bool(v: Value) -> bool { + match v { + Value::Bool(b) => b, + _ => panic!("expected bool"), + } +} +pub fn as_int(v: Value) -> i64 { + match v { + Value::Int(n) => n, + _ => panic!("expected int"), + } +} diff --git a/crates/doot-lang/src/lang/fmt.rs b/crates/doot-lang/src/lang/fmt.rs new file mode 100644 index 0000000..c015259 --- /dev/null +++ b/crates/doot-lang/src/lang/fmt.rs @@ -0,0 +1,498 @@ +//! AST pretty-printer for `doot fmt`. Reprints a parsed [`Program`] in a +//! canonical layout, preserving comments (via their source spans), integer +//! literal forms (`0o600`/`0x1f`), and multiline `''...''` strings. A node is +//! laid out flat when it fits within [`WIDTH`], otherwise broken across lines. + +use std::rc::Rc; + +use super::ast::*; +use super::diag::Span; + +const WIDTH: usize = 100; + +/// Pretty-print a program to canonical source. +pub fn format(prog: &Program) -> String { + let mut p = Printer { + comments: &prog.comments, + cursor: 0, + out: String::new(), + }; + p.program(prog); + p.out +} + +struct Printer<'a> { + comments: &'a [(Span, String)], + cursor: usize, + out: String, +} + +impl Printer<'_> { + fn pad(&mut self, ind: usize) { + for _ in 0..ind { + self.out.push_str(" "); + } + } + + /// Emit every pending comment whose span starts before `before`, each on its + /// own line at indentation `ind`. + fn flush_before(&mut self, before: usize, ind: usize) { + while self.cursor < self.comments.len() && self.comments[self.cursor].0.start < before { + let text = &self.comments[self.cursor].1; + self.pad(ind); + if text.is_empty() { + self.out.push('#'); + } else { + self.out.push_str("# "); + self.out.push_str(text); + } + self.out.push('\n'); + self.cursor += 1; + } + } + + fn program(&mut self, prog: &Program) { + // declarations in source order (the AST groups them by kind) + let mut decls: Vec<(Span, Decl)> = Vec::new(); + decls.extend(prog.structs.iter().map(|d| (d.span, Decl::Struct(d)))); + decls.extend(prog.enums.iter().map(|d| (d.span, Decl::Enum(d)))); + decls.extend(prog.classes.iter().map(|d| (d.span, Decl::Class(d)))); + decls.extend(prog.impls.iter().map(|d| (d.span, Decl::Impl(d)))); + decls.sort_by_key(|(s, _)| s.start); + + for (span, decl) in &decls { + self.flush_before(span.start, 0); + decl.print(self); + self.out.push('\n'); + } + // blank line between declarations and the body + if !decls.is_empty() { + self.out.push('\n'); + } + + self.flush_before(prog.body_span.start, 0); + self.block(&prog.body, 0); + self.out.push('\n'); + + // any trailing comments + self.flush_before(usize::MAX, 0); + } + + /// Emit an expression, flat if it fits on one line, otherwise broken. + fn block(&mut self, e: &Expr, ind: usize) { + if let Expr::Str(s) = e + && s.contains('\n') + { + self.multiline_str(s, ind); + return; + } + let flat = flat(e); + if !flat.contains('\n') && ind * 2 + flat.len() <= WIDTH { + self.out.push_str(&flat); + return; + } + match e { + Expr::Record(fields) => self.record(None, fields, ind), + Expr::Construct(name, fields) => self.record(Some(name), fields, ind), + // a list of plain scalars stays on one line even if long + Expr::List(items) if items.iter().all(|i| is_scalar(i)) => self.out.push_str(&flat), + Expr::List(items) => self.list(items, ind), + Expr::App(_, _) => self.app(e, ind), + Expr::Bin(_, _, _) | Expr::Merge(_, _) => self.bin_chain(e, ind), + Expr::Let(binds, body) => self.let_in(binds, body, ind), + Expr::If(c, t, el) => { + self.out.push_str("if "); + self.out.push_str(&flat_p(c, 0)); + self.out.push_str(" then\n"); + self.pad(ind + 1); + self.block(t, ind + 1); + self.out.push('\n'); + self.pad(ind); + self.out.push_str("else\n"); + self.pad(ind + 1); + self.block(el, ind + 1); + } + // operators/app/select that overflow stay on one (long) line + _ => self.out.push_str(&flat), + } + } + + fn record(&mut self, name: Option<&str>, fields: &[(String, Rc)], ind: usize) { + if let Some(n) = name { + self.out.push_str(n); + self.out.push(' '); + } + self.out.push_str("{\n"); + for (k, v) in fields { + self.pad(ind + 1); + self.out.push_str(k); + self.out.push_str(" = "); + self.block(v, ind + 1); + self.out.push_str(";\n"); + } + self.pad(ind); + self.out.push('}'); + } + + fn list(&mut self, items: &[Rc], ind: usize) { + self.out.push_str("[\n"); + for it in items { + self.pad(ind + 1); + // list elements are juxtaposed, so non-atoms must be parenthesized + if is_atom(it) { + self.block(it, ind + 1); + } else { + self.out.push('('); + self.block(it, ind + 1); + self.out.push(')'); + } + self.out.push('\n'); + } + self.pad(ind); + self.out.push(']'); + } + + /// A function application `f a1 a2 ...` whose flat form overflows: print the + /// head and leading args flat, and break the final argument (typically a + /// record/list, possibly containing a multiline string) onto its own lines. + fn app(&mut self, e: &Expr, ind: usize) { + let mut spine: Vec<&Expr> = Vec::new(); + let mut cur = e; + while let Expr::App(f, a) = cur { + spine.push(a); + cur = f; + } + spine.reverse(); + self.out.push_str(&flat_p(cur, 8)); + for (i, a) in spine.iter().enumerate() { + self.out.push(' '); + if i + 1 == spine.len() { + // last argument: allow it to break; parenthesize if not postfix-safe + if is_atom(a) { + self.block(a, ind); + } else { + self.out.push('('); + self.block(a, ind); + self.out.push(')'); + } + } else { + self.out.push_str(&flat_p(a, 9)); + } + } + } + + /// A binary-operator chain (`a ++ b ++ c`, `x // y`) that overflows: print + /// the first operand inline, then each subsequent operand on its own line + /// with the operator leading it, aligned at `ind`. + fn bin_chain(&mut self, e: &Expr, ind: usize) { + let sym = chain_sym(e); + let ctx = chain_prec(e) + 1; + let oi = ind + 1; // operands and operators sit one level in + let mut operands: Vec<&Expr> = Vec::new(); + collect_chain(e, sym, &mut operands); + self.bin_operand(operands[0], ctx, oi); + for o in &operands[1..] { + self.out.push('\n'); + self.pad(oi); + self.out.push_str(sym); + self.out.push(' '); + self.bin_operand(o, ctx, oi); + } + } + + fn bin_operand(&mut self, e: &Expr, ctx: u8, ind: usize) { + let f = flat_p(e, ctx); + if !f.contains('\n') && ind * 2 + f.len() <= WIDTH { + self.out.push_str(&f); + } else { + self.block(e, ind); + } + } + + fn let_in(&mut self, binds: &[Binding], body: &Expr, ind: usize) { + self.out.push_str("let\n"); + for b in binds { + self.flush_before(b.span.start, ind + 1); + self.pad(ind + 1); + self.out.push_str(&b.name); + if let Some(ann) = &b.ann { + self.out.push_str(" : "); + self.out.push_str(&ty(ann)); + } + self.out.push_str(" = "); + self.block(&b.value, ind + 1); + self.out.push_str(";\n"); + } + self.pad(ind); + self.out.push_str("in "); + self.block(body, ind); + } + + /// Emit a multiline string as `''` ... `''`, indenting content one level. The + /// dedent on reparse strips that indent, so the value round-trips. + fn multiline_str(&mut self, s: &str, ind: usize) { + self.out.push_str("''\n"); + for line in s.split('\n') { + if line.is_empty() { + self.out.push('\n'); + } else { + self.pad(ind + 1); + self.out.push_str(line); + self.out.push('\n'); + } + } + self.pad(ind + 1); + self.out.push_str("''"); + } +} + +enum Decl<'a> { + Struct(&'a StructDecl), + Enum(&'a EnumDecl), + Class(&'a ClassDecl), + Impl(&'a ImplDecl), +} + +impl Decl<'_> { + fn print(&self, p: &mut Printer) { + match self { + Decl::Struct(d) => { + p.out.push_str(&format!("struct {} {{\n", d.name)); + for f in &d.fields { + p.out.push_str(&format!(" {} : {}", f.name, ty(&f.ty))); + if let Some(def) = &f.default { + p.out.push_str(&format!(" = {}", flat(def))); + } + p.out.push_str(";\n"); + } + for m in &d.methods { + p.out.push_str(&format!(" {}\n", method(m))); + } + p.out.push('}'); + } + Decl::Enum(d) => { + p.out.push_str(&format!("enum {} {{\n", d.name)); + if !d.variants.is_empty() { + p.out.push_str(&format!(" {}", d.variants.join(", "))); + p.out + .push_str(if d.methods.is_empty() { "\n" } else { ",\n" }); + } + for m in &d.methods { + p.out.push_str(&format!(" {}\n", method(m))); + } + p.out.push('}'); + } + Decl::Class(d) => { + p.out + .push_str(&format!("class {} {} {{\n", d.name, d.param)); + for (name, sig) in &d.methods { + p.out.push_str(&format!(" {} : {};\n", name, ty(sig))); + } + p.out.push('}'); + } + Decl::Impl(d) => { + p.out + .push_str(&format!("impl {} for {} {{\n", d.class, d.type_name)); + for (name, body) in &d.methods { + p.out.push_str(&format!(" {} = {};\n", name, flat(body))); + } + p.out.push('}'); + } + } + } +} + +fn method(m: &MethodDecl) -> String { + let params = if m.params.is_empty() { + String::new() + } else { + format!(" {}", m.params.join(" ")) + }; + format!("fn {}{} = {};", m.name, params, flat(&m.body)) +} + +/// A surface type, rendered for source. +fn ty(t: &Type) -> String { + match t { + Type::Int => "Int".into(), + Type::Str => "Str".into(), + Type::Bool => "Bool".into(), + Type::List(x) => format!("[{}]", ty(x)), + Type::Struct(n) | Type::Enum(n) => n.clone(), + Type::Fun(a, b) => format!("{} -> {}", ty(a), ty(b)), + Type::Task(x) => format!("Task {}", ty(x)), + Type::Record(m) => { + let inner: Vec = m.iter().map(|(k, v)| format!("{k} : {}", ty(v))).collect(); + format!("{{ {} }}", inner.join("; ")) + } + Type::Var(i) => format!("t{i}"), + Type::Dyn => "?".into(), + } +} + +/// A plain scalar literal/name (a list of these stays on one line). +fn is_scalar(e: &Expr) -> bool { + matches!( + e, + Expr::Int(..) | Expr::Str(_) | Expr::Bool(_) | Expr::Var(_) | Expr::EnumVariant(_, _) + ) +} + +/// The operator symbol of a binary chain's top node. +fn chain_sym(e: &Expr) -> &'static str { + match e { + Expr::Bin(op, _, _) => binop_info(*op).1, + Expr::Merge(_, _) => "//", + _ => "", + } +} + +/// The precedence of a binary chain's top node. +fn chain_prec(e: &Expr) -> u8 { + match e { + Expr::Bin(op, _, _) => binop_info(*op).0, + Expr::Merge(_, _) => 4, + _ => 0, + } +} + +/// Flatten a left-associative run of the same operator `sym` into its operands. +fn collect_chain<'a>(e: &'a Expr, sym: &str, out: &mut Vec<&'a Expr>) { + match e { + Expr::Bin(op, l, r) if binop_info(*op).1 == sym => { + collect_chain(l, sym, out); + out.push(r); + } + Expr::Merge(l, r) if sym == "//" => { + collect_chain(l, sym, out); + out.push(r); + } + _ => out.push(e), + } +} + +/// Postfix-safe expressions can appear as a bare list element / juxtaposition arg. +fn is_atom(e: &Expr) -> bool { + matches!( + e, + Expr::Int(..) + | Expr::Str(_) + | Expr::Bool(_) + | Expr::Var(_) + | Expr::EnumVariant(_, _) + | Expr::List(_) + | Expr::Record(_) + | Expr::Construct(_, _) + | Expr::Select(_, _) + ) +} + +/// Render an expression on a single line (with precedence parenthesization). +fn flat(e: &Expr) -> String { + flat_p(e, 0) +} + +fn flat_p(e: &Expr, ctx: u8) -> String { + let (prec, s) = match e { + Expr::Int(n, Radix::Dec) => (9, n.to_string()), + Expr::Int(n, Radix::Oct) => (9, format!("0o{n:o}")), + Expr::Int(n, Radix::Hex) => (9, format!("0x{n:x}")), + Expr::Str(s) => (9, str_lit(s)), + Expr::Bool(b) => (9, b.to_string()), + Expr::Var(n) => (9, n.clone()), + Expr::EnumVariant(en, v) => (9, format!("{en}.{v}")), + Expr::List(items) => ( + 9, + if items.is_empty() { + "[]".into() + } else { + let parts: Vec = items.iter().map(|x| flat_p(x, 9)).collect(); + format!("[ {} ]", parts.join(" ")) + }, + ), + Expr::Record(fields) => (9, record_flat(None, fields)), + Expr::Construct(name, fields) => (9, record_flat(Some(name), fields)), + Expr::Select(o, f) => (9, format!("{}.{}", flat_p(o, 9), f)), + Expr::App(f, a) => (8, format!("{} {}", flat_p(f, 8), flat_p(a, 9))), + Expr::Lam(_, _) => (0, lam_flat(e)), + Expr::Merge(l, r) => (4, format!("{} // {}", flat_p(l, 4), flat_p(r, 5))), + Expr::If(c, t, el) => ( + 0, + format!( + "if {} then {} else {}", + flat_p(c, 0), + flat_p(t, 0), + flat_p(el, 0) + ), + ), + Expr::Let(binds, body) => (0, let_flat(binds, body)), + Expr::Bin(op, l, r) => { + let (p, sym, rassoc) = binop_info(*op); + let (lc, rc) = if rassoc { (p + 1, p) } else { (p, p + 1) }; + (p, format!("{} {} {}", flat_p(l, lc), sym, flat_p(r, rc))) + } + }; + if prec < ctx { format!("({s})") } else { s } +} + +fn record_flat(name: Option<&str>, fields: &[(String, Rc)]) -> String { + let prefix = name.map(|n| format!("{n} ")).unwrap_or_default(); + if fields.is_empty() { + return format!("{prefix}{{}}"); + } + let parts: Vec = fields + .iter() + .map(|(k, v)| format!("{k} = {}", flat_p(v, 0))) + .collect(); + format!("{prefix}{{ {}; }}", parts.join("; ")) +} + +fn lam_flat(e: &Expr) -> String { + let mut params = Vec::new(); + let mut cur = e; + while let Expr::Lam(p, body) = cur { + params.push(p.clone()); + cur = body; + } + format!("\\{} -> {}", params.join(" "), flat_p(cur, 0)) +} + +fn let_flat(binds: &[Binding], body: &Expr) -> String { + let bs: Vec = binds + .iter() + .map(|b| { + let ann = b + .ann + .as_ref() + .map(|a| format!(" : {}", ty(a))) + .unwrap_or_default(); + format!("{}{} = {};", b.name, ann, flat_p(&b.value, 0)) + }) + .collect(); + format!("let {} in {}", bs.join(" "), flat_p(body, 0)) +} + +fn str_lit(s: &str) -> String { + if s.contains('\n') { + // forces block mode (contains a newline); block() renders the `''` form + format!("''{s}''") + } else { + format!("\"{s}\"") + } +} + +/// `(precedence, symbol, right-associative)` for a binary operator. +fn binop_info(op: BinOp) -> (u8, &'static str, bool) { + match op { + BinOp::Or => (1, "||", false), + BinOp::And => (2, "&&", false), + BinOp::Eq => (3, "==", false), + BinOp::Concat => (4, "++", false), + BinOp::Add => (5, "+", false), + BinOp::Sub => (5, "-", false), + BinOp::Mul => (6, "*", false), + BinOp::Slash => (6, "/", false), + BinOp::Mod => (6, "%", false), + BinOp::Pow => (7, "**", true), + } +} diff --git a/crates/doot-lang/src/lang/lexer.rs b/crates/doot-lang/src/lang/lexer.rs new file mode 100644 index 0000000..44b7ca1 --- /dev/null +++ b/crates/doot-lang/src/lang/lexer.rs @@ -0,0 +1,249 @@ +//! Tokenizer. + +use super::ast::Radix; +use super::diag::{Diagnostic, Span}; + +/// A token paired with its source span. +pub type Spanned = (Tok, Span); + +/// Tokens plus the source comments (span + text without `#`), in source order. +pub struct Lexed { + pub tokens: Vec, + pub comments: Vec<(Span, String)>, +} + +/// A lexical token. +#[derive(Debug, Clone, PartialEq)] +pub enum Tok { + Int(i64, Radix), + Str(String), + Ident(String), + // keywords + Let, + In, + If, + Then, + Else, + True, + False, + Struct, + Enum, + Fn, + Class, + Impl, + For, + // punctuation / operators + Assign, // = + EqEq, // == + Colon, // : + Semi, // ; + Comma, // , + Dot, // . + Slash, // / + Slashes, // // + Concat, // ++ + OrOr, // || + AndAnd, // && + Plus, // + + Minus, // - + Star, // * + StarStar, // ** + Percent, // % + Backslash, // \ + Arrow, // -> + LParen, + RParen, + LBracket, + RBracket, + LBrace, + RBrace, +} + +/// Tokenize source into spanned tokens + comments, or the first lexical error. +pub fn lex(src: &str) -> Result { + let b: Vec = src.chars().collect(); + let mut i = 0; + let mut out = Vec::new(); + let mut comments = Vec::new(); + while i < b.len() { + let c = b[i]; + if c.is_whitespace() { + i += 1; + continue; + } + if c == '#' { + let start = i; + i += 1; + let text_start = i; + while i < b.len() && b[i] != '\n' { + i += 1; + } + let text: String = b[text_start..i].iter().collect(); + comments.push((Span::new(start, i), text.trim().to_string())); + continue; + } + let start = i; + // two-char operators first, then single-char, then literals/idents + let two = |x: char, y: char| c == x && i + 1 < b.len() && b[i + 1] == y; + let tok = if two('/', '/') { + i += 2; + Tok::Slashes + } else if two('+', '+') { + i += 2; + Tok::Concat + } else if two('|', '|') { + i += 2; + Tok::OrOr + } else if two('&', '&') { + i += 2; + Tok::AndAnd + } else if two('-', '>') { + i += 2; + Tok::Arrow + } else if two('=', '=') { + i += 2; + Tok::EqEq + } else if two('*', '*') { + i += 2; + Tok::StarStar + } else if two('\'', '\'') { + // `''...''` indented multiline string (common leading indent stripped) + i += 2; + let mut raw = String::new(); + while i + 1 < b.len() && !(b[i] == '\'' && b[i + 1] == '\'') { + raw.push(b[i]); + i += 1; + } + if i + 1 >= b.len() { + return Err(Diagnostic::new( + "unterminated multiline string", + Span::new(start, b.len()), + )); + } + i += 2; // closing '' + Tok::Str(dedent(&raw)) + } else if c == '"' { + i += 1; + let mut s = String::new(); + while i < b.len() && b[i] != '"' { + s.push(b[i]); + i += 1; + } + if i >= b.len() { + return Err(Diagnostic::new( + "unterminated string", + Span::new(start, b.len()), + )); + } + i += 1; // closing quote + Tok::Str(s) + } else if c.is_ascii_digit() { + lex_int(&b, &mut i, start)? + } else if c.is_alphabetic() || c == '_' { + let mut s = String::new(); + while i < b.len() && (b[i].is_alphanumeric() || b[i] == '_') { + s.push(b[i]); + i += 1; + } + keyword_or_ident(s) + } else { + i += 1; + match c { + '(' => Tok::LParen, + ')' => Tok::RParen, + '[' => Tok::LBracket, + ']' => Tok::RBracket, + '{' => Tok::LBrace, + '}' => Tok::RBrace, + ';' => Tok::Semi, + ',' => Tok::Comma, + ':' => Tok::Colon, + '.' => Tok::Dot, + '\\' => Tok::Backslash, + '/' => Tok::Slash, + '=' => Tok::Assign, + '+' => Tok::Plus, + '-' => Tok::Minus, + '*' => Tok::Star, + '%' => Tok::Percent, + other => { + return Err(Diagnostic::new( + format!("unexpected character {other:?}"), + Span::new(start, i), + )); + } + } + }; + out.push((tok, Span::new(start, i))); + } + Ok(Lexed { + tokens: out, + comments, + }) +} + +/// Lex an integer literal (decimal, `0o`, `0x`). +fn lex_int(b: &[char], i: &mut usize, start: usize) -> Result { + let (radix, kind, alnum) = + if b[*i] == '0' && *i + 1 < b.len() && (b[*i + 1] == 'o' || b[*i + 1] == 'x') { + let oct = b[*i + 1] == 'o'; + *i += 2; + if oct { + (8, Radix::Oct, true) + } else { + (16, Radix::Hex, true) + } + } else { + (10, Radix::Dec, false) + }; + let mut n = String::new(); + while *i < b.len() && (b[*i].is_ascii_digit() || (alnum && b[*i].is_ascii_alphanumeric())) { + n.push(b[*i]); + *i += 1; + } + i64::from_str_radix(&n, radix) + .map(|v| Tok::Int(v, kind)) + .map_err(|_| Diagnostic::new("invalid integer literal", Span::new(start, *i))) +} + +fn keyword_or_ident(s: String) -> Tok { + match s.as_str() { + "let" => Tok::Let, + "in" => Tok::In, + "if" => Tok::If, + "then" => Tok::Then, + "else" => Tok::Else, + "true" => Tok::True, + "false" => Tok::False, + "struct" => Tok::Struct, + "enum" => Tok::Enum, + "fn" => Tok::Fn, + "class" => Tok::Class, + "impl" => Tok::Impl, + "for" => Tok::For, + _ => Tok::Ident(s), + } +} + +/// Strip the common leading indentation from a `''...''` string and drop the +/// blank first/last lines, so multiline literals can be indented in source. +fn dedent(s: &str) -> String { + let mut lines: Vec<&str> = s.split('\n').collect(); + if lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + if lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + let indent = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + lines + .iter() + .map(|l| if l.len() >= indent { &l[indent..] } else { *l }) + .collect::>() + .join("\n") +} diff --git a/crates/doot-lang/src/lang/mod.rs b/crates/doot-lang/src/lang/mod.rs new file mode 100644 index 0000000..118481f --- /dev/null +++ b/crates/doot-lang/src/lang/mod.rs @@ -0,0 +1,20 @@ +//! A lazy, pure, typed expression language whose evaluation produces +//! a dependency DAG of effects. + +pub mod ast; +pub mod check; +pub mod diag; +pub mod engine; +pub mod eval; +pub mod fmt; +pub mod lexer; +pub mod parser; +pub mod plan; + +pub use ast::{Program, Type}; +pub use check::Checker; +pub use diag::{Diagnostic, Span}; +pub use engine::{BuiltinScheme, Engine}; +pub use eval::{Interp, Value}; +pub use parser::parse; +pub use plan::{Node, Plan}; diff --git a/crates/doot-lang/src/lang/parser.rs b/crates/doot-lang/src/lang/parser.rs new file mode 100644 index 0000000..59b7518 --- /dev/null +++ b/crates/doot-lang/src/lang/parser.rs @@ -0,0 +1,542 @@ +//! Recursive-descent parser. +//! +//! Struct declarations are parsed first so that `Name { ... }` can be +//! disambiguated from function application `f { ... }`: if `Name` is a declared +//! (or host-registered) struct it is construction, otherwise it is application +//! of `Name` to a record. The set of host-registered struct/enum names is passed +//! in so the language core stays free of any specific vocabulary. + +use std::collections::HashSet; +use std::rc::Rc; + +use super::ast::*; +use super::diag::{Diagnostic, Span}; +use super::lexer::{Spanned, Tok, lex}; + +type PResult = Result; + +struct Parser { + t: Vec, + i: usize, + structs: HashSet, + enums: HashSet, +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.t.get(self.i).map(|(t, _)| t) + } + + /// The span of the current token, or a point at end-of-input. + fn cur_span(&self) -> Span { + match self.t.get(self.i) { + Some((_, s)) => *s, + None => self + .t + .last() + .map(|(_, s)| Span::point(s.end)) + .unwrap_or(Span::point(0)), + } + } + + fn err(&self, msg: impl Into) -> PResult { + Err(Diagnostic::new(msg, self.cur_span())) + } + + fn next(&mut self) -> PResult { + match self.t.get(self.i) { + Some((tok, _)) => { + let tok = tok.clone(); + self.i += 1; + Ok(tok) + } + None => self.err("unexpected end of input"), + } + } + + fn eat(&mut self, t: &Tok) -> PResult<()> { + let sp = self.cur_span(); + let got = self.next()?; + if &got == t { + Ok(()) + } else { + Err(Diagnostic::new(format!("expected {t:?}, got {got:?}"), sp)) + } + } + + fn ident(&mut self) -> PResult { + let sp = self.cur_span(); + match self.next()? { + Tok::Ident(s) => Ok(s), + other => Err(Diagnostic::new( + format!("expected identifier, got {other:?}"), + sp, + )), + } + } + + // types: `T -> T` is right-associative + fn ty(&mut self) -> PResult { + let base = self.ty_atom()?; + if matches!(self.peek(), Some(Tok::Arrow)) { + self.eat(&Tok::Arrow)?; + let ret = self.ty()?; + Ok(Type::Fun(Box::new(base), Box::new(ret))) + } else { + Ok(base) + } + } + + fn ty_atom(&mut self) -> PResult { + let sp = self.cur_span(); + match self.next()? { + Tok::LBracket => { + let inner = self.ty()?; + self.eat(&Tok::RBracket)?; + Ok(Type::List(Box::new(inner))) + } + Tok::LParen => { + let t = self.ty()?; + self.eat(&Tok::RParen)?; + Ok(t) + } + Tok::Ident(s) => Ok(match s.as_str() { + "Int" => Type::Int, + "Str" => Type::Str, + "Bool" => Type::Bool, + _ if self.enums.contains(&s) => Type::Enum(s), + _ => Type::Struct(s), + }), + other => Err(Diagnostic::new(format!("expected type, got {other:?}"), sp)), + } + } + + fn enum_decl(&mut self) -> PResult { + let span = self.cur_span(); + self.eat(&Tok::Enum)?; + let name = self.ident()?; + self.enums.insert(name.clone()); // visible inside its own methods + self.eat(&Tok::LBrace)?; + let mut variants = Vec::new(); + let mut methods = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBrace)) { + if matches!(self.peek(), Some(Tok::Fn)) { + methods.push(self.method_decl()?); + } else { + variants.push(self.ident()?); + if matches!(self.peek(), Some(Tok::Comma)) { + self.eat(&Tok::Comma)?; + } + } + } + self.eat(&Tok::RBrace)?; + Ok(EnumDecl { + name, + variants, + methods, + span, + }) + } + + // `class Name a { method : Type; ... }` + fn class_decl(&mut self) -> PResult { + let span = self.cur_span(); + self.eat(&Tok::Class)?; + let name = self.ident()?; + let param = self.ident()?; + self.eat(&Tok::LBrace)?; + let mut methods = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBrace)) { + let mname = self.ident()?; + self.eat(&Tok::Colon)?; + let sig = self.ty()?; + self.eat(&Tok::Semi)?; + methods.push((mname, sig)); + } + self.eat(&Tok::RBrace)?; + Ok(ClassDecl { + name, + param, + methods, + span, + }) + } + + // `impl Class for Type { method = expr; ... }` + fn impl_decl(&mut self) -> PResult { + let span = self.cur_span(); + self.eat(&Tok::Impl)?; + let class = self.ident()?; + self.eat(&Tok::For)?; + let type_name = self.ident()?; + self.eat(&Tok::LBrace)?; + let mut methods = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBrace)) { + let mname = self.ident()?; + self.eat(&Tok::Assign)?; + let body = self.expr()?; + self.eat(&Tok::Semi)?; + methods.push((mname, body)); + } + self.eat(&Tok::RBrace)?; + Ok(ImplDecl { + class, + type_name, + methods, + span, + }) + } + + // `fn name self p1 ... = body;` (params[0] is self) + fn method_decl(&mut self) -> PResult { + self.eat(&Tok::Fn)?; + let name = self.ident()?; + let mut params = Vec::new(); + while matches!(self.peek(), Some(Tok::Ident(_))) { + params.push(self.ident()?); + } + self.eat(&Tok::Assign)?; + let body = self.expr()?; + self.eat(&Tok::Semi)?; + Ok(MethodDecl { name, params, body }) + } + + // struct decls + fn struct_decl(&mut self) -> PResult { + let span = self.cur_span(); + self.eat(&Tok::Struct)?; + let name = self.ident()?; + self.structs.insert(name.clone()); // visible inside its own methods + self.eat(&Tok::LBrace)?; + let mut fields = Vec::new(); + let mut methods = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBrace)) { + if matches!(self.peek(), Some(Tok::Fn)) { + methods.push(self.method_decl()?); + continue; + } + let fname = self.ident()?; + self.eat(&Tok::Colon)?; + let fty = self.ty()?; + let default = if matches!(self.peek(), Some(Tok::Assign)) { + self.eat(&Tok::Assign)?; + Some(self.expr()?) + } else { + None + }; + self.eat(&Tok::Semi)?; + fields.push(FieldDecl { + name: fname, + ty: fty, + default, + }); + } + self.eat(&Tok::RBrace)?; + Ok(StructDecl { + name, + fields, + methods, + span, + }) + } + + // expressions + // precedence, low -> high: || < && < == < { // / ++ } < application + fn expr(&mut self) -> PResult> { + self.or_level() + } + + fn or_level(&mut self) -> PResult> { + let mut lhs = self.and_level()?; + while matches!(self.peek(), Some(Tok::OrOr)) { + self.i += 1; + let rhs = self.and_level()?; + lhs = Rc::new(Expr::Bin(BinOp::Or, lhs, rhs)); + } + Ok(lhs) + } + + fn and_level(&mut self) -> PResult> { + let mut lhs = self.eq_level()?; + while matches!(self.peek(), Some(Tok::AndAnd)) { + self.i += 1; + let rhs = self.eq_level()?; + lhs = Rc::new(Expr::Bin(BinOp::And, lhs, rhs)); + } + Ok(lhs) + } + + fn eq_level(&mut self) -> PResult> { + let mut lhs = self.concat_level()?; + while matches!(self.peek(), Some(Tok::EqEq)) { + self.i += 1; + let rhs = self.concat_level()?; + lhs = Rc::new(Expr::Bin(BinOp::Eq, lhs, rhs)); + } + Ok(lhs) + } + + /// `++` (concat) and `//` (merge), left-assoc. + fn concat_level(&mut self) -> PResult> { + let mut lhs = self.additive()?; + loop { + let merge = matches!(self.peek(), Some(Tok::Slashes)); + let concat = matches!(self.peek(), Some(Tok::Concat)); + if !merge && !concat { + break; + } + self.i += 1; + let rhs = self.additive()?; + lhs = Rc::new(if merge { + Expr::Merge(lhs, rhs) + } else { + Expr::Bin(BinOp::Concat, lhs, rhs) + }); + } + Ok(lhs) + } + + fn additive(&mut self) -> PResult> { + let mut lhs = self.multiplicative()?; + loop { + let op = match self.peek() { + Some(Tok::Plus) => BinOp::Add, + Some(Tok::Minus) => BinOp::Sub, + _ => break, + }; + self.i += 1; + let rhs = self.multiplicative()?; + lhs = Rc::new(Expr::Bin(op, lhs, rhs)); + } + Ok(lhs) + } + + /// `*`, `/` (path join / division), `%`, left-assoc. + fn multiplicative(&mut self) -> PResult> { + let mut lhs = self.power()?; + loop { + let op = match self.peek() { + Some(Tok::Star) => BinOp::Mul, + Some(Tok::Slash) => BinOp::Slash, + Some(Tok::Percent) => BinOp::Mod, + _ => break, + }; + self.i += 1; + let rhs = self.power()?; + lhs = Rc::new(Expr::Bin(op, lhs, rhs)); + } + Ok(lhs) + } + + /// `**` power, right-assoc. + fn power(&mut self) -> PResult> { + let lhs = self.app()?; + if matches!(self.peek(), Some(Tok::StarStar)) { + self.i += 1; + let rhs = self.power()?; + Ok(Rc::new(Expr::Bin(BinOp::Pow, lhs, rhs))) + } else { + Ok(lhs) + } + } + + fn starts_atom(&self) -> bool { + matches!( + self.peek(), + Some( + Tok::Int(..) + | Tok::Str(_) + | Tok::Ident(_) + | Tok::True + | Tok::False + | Tok::LParen + | Tok::LBracket + | Tok::LBrace + | Tok::Backslash + ) + ) + } + + fn app(&mut self) -> PResult> { + let mut f = self.postfix()?; + while self.starts_atom() { + let arg = self.postfix()?; + f = Rc::new(Expr::App(f, arg)); + } + Ok(f) + } + + fn postfix(&mut self) -> PResult> { + let mut e = self.atom()?; + while matches!(self.peek(), Some(Tok::Dot)) { + self.eat(&Tok::Dot)?; + let field = self.ident()?; + e = Rc::new(Expr::Select(e, field)); + } + Ok(e) + } + + fn record_block(&mut self) -> PResult)>> { + self.eat(&Tok::LBrace)?; + let mut fs = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBrace)) { + let k = self.ident()?; + self.eat(&Tok::Assign)?; + let v = self.expr()?; + self.eat(&Tok::Semi)?; + fs.push((k, v)); + } + self.eat(&Tok::RBrace)?; + Ok(fs) + } + + fn atom(&mut self) -> PResult> { + let sp = self.cur_span(); + Ok(match self.next()? { + Tok::Int(n, r) => Rc::new(Expr::Int(n, r)), + Tok::Str(s) => Rc::new(Expr::Str(s)), + Tok::True => Rc::new(Expr::Bool(true)), + Tok::False => Rc::new(Expr::Bool(false)), + Tok::Ident(s) => { + if self.enums.contains(&s) && matches!(self.peek(), Some(Tok::Dot)) { + // `Enum.Variant` + self.eat(&Tok::Dot)?; + Rc::new(Expr::EnumVariant(s, self.ident()?)) + } else if self.structs.contains(&s) && matches!(self.peek(), Some(Tok::LBrace)) { + // `Name { ... }` construction (a struct); otherwise a plain `{ ... }` + // following a function is a separate argument handled by `app`. + Rc::new(Expr::Construct(s, self.record_block()?)) + } else { + Rc::new(Expr::Var(s)) + } + } + Tok::LParen => { + let e = self.expr()?; + self.eat(&Tok::RParen)?; + e + } + Tok::LBracket => { + // elements are postfix-atoms (Nix style): `[ f x ]` is two elements; + // write `[ (f x) ]` to apply. Optional commas allowed. + let mut items = Vec::new(); + while !matches!(self.peek(), Some(Tok::RBracket)) { + items.push(self.postfix()?); + if matches!(self.peek(), Some(Tok::Comma)) { + self.eat(&Tok::Comma)?; + } + } + self.eat(&Tok::RBracket)?; + Rc::new(Expr::List(items)) + } + Tok::LBrace => { + self.i -= 1; // hand the brace back to record_block + Rc::new(Expr::Record(self.record_block()?)) + } + Tok::Backslash => { + // multi-param lambda `\a b c -> body` desugars to curried lambdas + let mut params = vec![self.ident()?]; + while matches!(self.peek(), Some(Tok::Ident(_))) { + params.push(self.ident()?); + } + self.eat(&Tok::Arrow)?; + let mut body = self.expr()?; + for p in params.into_iter().rev() { + body = Rc::new(Expr::Lam(p, body)); + } + body + } + Tok::Let => { + let mut binds = Vec::new(); + while !matches!(self.peek(), Some(Tok::In)) { + let bspan = self.cur_span(); + let name = self.ident()?; + // `let f a b = body;` is sugar for `f = \a b -> body`. + // Params (bare idents) and a `: Type` annotation are mutually exclusive. + let mut params = Vec::new(); + while matches!(self.peek(), Some(Tok::Ident(_))) { + params.push(self.ident()?); + } + let ann = if params.is_empty() && matches!(self.peek(), Some(Tok::Colon)) { + self.eat(&Tok::Colon)?; + Some(self.ty()?) + } else { + None + }; + self.eat(&Tok::Assign)?; + let mut value = self.expr()?; + for p in params.into_iter().rev() { + value = Rc::new(Expr::Lam(p, value)); + } + self.eat(&Tok::Semi)?; + binds.push(Binding { + name, + ann, + value, + span: bspan, + }); + } + self.eat(&Tok::In)?; + let body = self.expr()?; + Rc::new(Expr::Let(binds, body)) + } + Tok::If => { + let c = self.expr()?; + self.eat(&Tok::Then)?; + let t = self.expr()?; + self.eat(&Tok::Else)?; + let e = self.expr()?; + Rc::new(Expr::If(c, t, e)) + } + other => return Err(Diagnostic::new(format!("unexpected token {other:?}"), sp)), + }) + } +} + +/// Parse a full program. `structs`/`enums` are host-registered nominal names (in +/// addition to those declared in the source) used to disambiguate `Name { ... }` +/// construction and `Enum.Variant`. +pub fn parse(src: &str, structs: &[String], enums: &[String]) -> PResult { + let lexed = lex(src)?; + let mut p = Parser { + t: lexed.tokens, + i: 0, + structs: structs.iter().cloned().collect(), + enums: enums.iter().cloned().collect(), + }; + let mut structs = Vec::new(); + let mut enums = Vec::new(); + let mut classes = Vec::new(); + let mut impls = Vec::new(); + // declarations may interleave + loop { + match p.peek() { + Some(Tok::Struct) => { + let d = p.struct_decl()?; + p.structs.insert(d.name.clone()); + structs.push(Rc::new(d)); + } + Some(Tok::Enum) => { + let d = p.enum_decl()?; + p.enums.insert(d.name.clone()); + enums.push(Rc::new(d)); + } + Some(Tok::Class) => classes.push(Rc::new(p.class_decl()?)), + Some(Tok::Impl) => impls.push(Rc::new(p.impl_decl()?)), + _ => break, + } + } + let body_span = p.cur_span(); + let body = p.expr()?; + if p.peek().is_some() { + return p.err("unexpected trailing tokens"); + } + Ok(Program { + structs, + enums, + classes, + impls, + body, + body_span, + comments: lexed.comments, + }) +} diff --git a/crates/doot-lang/src/lang/plan.rs b/crates/doot-lang/src/lang/plan.rs new file mode 100644 index 0000000..53423c8 --- /dev/null +++ b/crates/doot-lang/src/lang/plan.rs @@ -0,0 +1,61 @@ +//! The realization plan: the dependency DAG produced by evaluating a program. +//! +//! The graph is domain-agnostic: each node carries an opaque `Rc` +//! payload that a domain layer supplies and later downcasts. Edges are inferred +//! from value references, never written by hand. + +use std::any::Any; +use std::collections::HashSet; +use std::rc::Rc; + +/// A single effect node. `data` is an opaque payload (the dotfile layer stores a +/// `TaskData` and downcasts it back at the bridge). +#[derive(Clone)] +pub struct Node { + pub label: String, + pub data: Rc, +} + +/// The inferred dependency DAG. +#[derive(Default)] +pub struct Plan { + pub nodes: Vec, + /// `(from, to)` means `from` depends on `to` (so `to` is realized first). + pub edges: Vec<(usize, usize)>, +} + +impl Plan { + /// Tasks that `id` directly depends on. + pub fn deps_of(&self, id: usize) -> Vec { + self.edges + .iter() + .filter(|(f, _)| *f == id) + .map(|(_, t)| *t) + .collect() + } + + /// Topologically sort into layers; every node in a layer is independent of the + /// others and can be realized concurrently. Panics on a cycle. + pub fn parallel_layers(&self) -> Vec> { + let n = self.nodes.len(); + let mut deps: Vec> = vec![HashSet::new(); n]; + for &(from, to) in &self.edges { + if from != to { + deps[from].insert(to); + } + } + let mut done: HashSet = HashSet::new(); + let mut layers = Vec::new(); + while done.len() < n { + let layer: Vec = (0..n) + .filter(|id| !done.contains(id) && deps[*id].iter().all(|d| done.contains(d))) + .collect(); + assert!(!layer.is_empty(), "cycle in dependency graph"); + for id in &layer { + done.insert(*id); + } + layers.push(layer); + } + layers + } +} diff --git a/crates/doot-lang/src/lexer.rs b/crates/doot-lang/src/lexer.rs deleted file mode 100644 index 8f6408b..0000000 --- a/crates/doot-lang/src/lexer.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! Lexer for the doot language. - -use chumsky::prelude::*; -use ordered_float::OrderedFloat; -use std::fmt; - -/// Token types produced by the lexer. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum Token { - // Literals - Int(i64), - Float(OrderedFloat), - Str(String), - Bool(bool), - - // Identifiers and keywords - Ident(String), - - // Keywords - Let, - Fn, - AsyncFn, - If, - Else, - Then, - For, - In, - Match, - Struct, - Enum, - Type, - Import, - As, - Dotfile, - Package, - Brew, - Secret, - Encrypted, - Hook, - BeforeDeploy, - AfterDeploy, - BeforePackage, - AfterPackage, - Macro, - Await, - Return, - When, - - // Operators - Plus, - Minus, - Star, - Slash, - Percent, - Eq, - EqEq, - NotEq, - Lt, - Gt, - LtEq, - GtEq, - And, - Or, - Not, - Pipe, - DoublePipe, - DoubleColon, - Arrow, - FatArrow, - Dot, - DotDot, - QuestionQuestion, - - // Delimiters - LParen, - RParen, - LBracket, - RBracket, - LBrace, - RBrace, - Comma, - Colon, - Semicolon, - Newline, - - // Special - Tilde, - At, - Hash, - Bang, - Indent(usize), - Dedent, -} - -impl fmt::Display for Token { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Token::Int(n) => write!(f, "{}", n), - Token::Float(n) => write!(f, "{}", n), - Token::Str(s) => write!(f, "\"{}\"", s), - Token::Bool(b) => write!(f, "{}", b), - Token::Ident(s) => write!(f, "{}", s), - Token::Let => write!(f, "let"), - Token::Fn => write!(f, "fn"), - Token::AsyncFn => write!(f, "async fn"), - Token::If => write!(f, "if"), - Token::Else => write!(f, "else"), - Token::Then => write!(f, "then"), - Token::For => write!(f, "for"), - Token::In => write!(f, "in"), - Token::Match => write!(f, "match"), - Token::Struct => write!(f, "struct"), - Token::Enum => write!(f, "enum"), - Token::Type => write!(f, "type"), - Token::Import => write!(f, "import"), - Token::As => write!(f, "as"), - Token::Dotfile => write!(f, "dotfile"), - Token::Package => write!(f, "package"), - Token::Brew => write!(f, "brew"), - Token::Secret => write!(f, "secret"), - Token::Encrypted => write!(f, "encrypted"), - Token::Hook => write!(f, "hook"), - Token::BeforeDeploy => write!(f, "before_deploy"), - Token::AfterDeploy => write!(f, "after_deploy"), - Token::BeforePackage => write!(f, "before_package"), - Token::AfterPackage => write!(f, "after_package"), - Token::Macro => write!(f, "macro"), - Token::Await => write!(f, "await"), - Token::Return => write!(f, "return"), - Token::When => write!(f, "when"), - Token::Plus => write!(f, "+"), - Token::Minus => write!(f, "-"), - Token::Star => write!(f, "*"), - Token::Slash => write!(f, "/"), - Token::Percent => write!(f, "%"), - Token::Eq => write!(f, "="), - Token::EqEq => write!(f, "=="), - Token::NotEq => write!(f, "!="), - Token::Lt => write!(f, "<"), - Token::Gt => write!(f, ">"), - Token::LtEq => write!(f, "<="), - Token::GtEq => write!(f, ">="), - Token::And => write!(f, "&&"), - Token::Or => write!(f, "||"), - Token::Not => write!(f, "!"), - Token::Pipe => write!(f, "|"), - Token::DoublePipe => write!(f, "||"), - Token::DoubleColon => write!(f, "::"), - Token::Arrow => write!(f, "->"), - Token::FatArrow => write!(f, "=>"), - Token::Dot => write!(f, "."), - Token::DotDot => write!(f, ".."), - Token::QuestionQuestion => write!(f, "??"), - Token::LParen => write!(f, "("), - Token::RParen => write!(f, ")"), - Token::LBracket => write!(f, "["), - Token::RBracket => write!(f, "]"), - Token::LBrace => write!(f, "{{"), - Token::RBrace => write!(f, "}}"), - Token::Comma => write!(f, ","), - Token::Colon => write!(f, ":"), - Token::Semicolon => write!(f, ";"), - Token::Newline => write!(f, "\\n"), - Token::Tilde => write!(f, "~"), - Token::At => write!(f, "@"), - Token::Hash => write!(f, "#"), - Token::Bang => write!(f, "!"), - Token::Indent(n) => write!(f, "", n), - Token::Dedent => write!(f, ""), - } - } -} - -/// Source location range. -pub type Span = std::ops::Range; - -/// Token with source location. -#[derive(Clone, Debug)] -pub struct Spanned { - pub node: T, - pub span: Span, -} - -impl Spanned { - /// Creates a new spanned token. - pub fn new(node: T, span: Span) -> Self { - Self { node, span } - } -} - -/// Tokenizes doot source code. -pub struct Lexer; - -impl Lexer { - /// Returns the token parser combinator. - pub fn lexer() -> impl chumsky::Parser>, Error = Simple> { - let octal = just("0o") - .ignore_then(text::digits(8)) - .map(|s: String| Token::Int(i64::from_str_radix(&s, 8).unwrap_or(0))); - - let hex = just("0x") - .ignore_then(text::digits(16)) - .map(|s: String| Token::Int(i64::from_str_radix(&s, 16).unwrap_or(0))); - - let decimal = text::int(10).map(|s: String| Token::Int(s.parse().unwrap())); - - let int = octal.or(hex).or(decimal); - - let float = text::int(10).then(just('.').then(text::digits(10))).map( - |(a, (_, b)): (String, (char, String))| { - let f: f64 = format!("{}.{}", a, b).parse().unwrap(); - Token::Float(OrderedFloat(f)) - }, - ); - - let escape = just('\\').ignore_then( - just('\\') - .or(just('/')) - .or(just('"')) - .or(just('n').to('\n')) - .or(just('r').to('\r')) - .or(just('t').to('\t')), - ); - - let string = just('"') - .ignore_then(filter(|c| *c != '\\' && *c != '"').or(escape).repeated()) - .then_ignore(just('"')) - .collect::() - .map(Token::Str); - - // Heredoc: >>>...<<< - let heredoc = - just(">>>") - .ignore_then(take_until(just("<<<"))) - .map(|(chars, _): (Vec, _)| { - let s: String = chars.into_iter().collect(); - // Trim leading newline if present - let s = s.strip_prefix('\n').unwrap_or(&s); - Token::Str(s.to_string()) - }); - - let keyword_or_ident = text::ident().map(|s: String| match s.as_str() { - "let" => Token::Let, - "fn" => Token::Fn, - "async" => Token::Ident("async".to_string()), - "if" => Token::If, - "else" => Token::Else, - "then" => Token::Then, - "for" => Token::For, - "in" => Token::In, - "match" => Token::Match, - "struct" => Token::Struct, - "enum" => Token::Enum, - "type" => Token::Type, - "import" => Token::Import, - "as" => Token::As, - "dotfile" => Token::Dotfile, - "package" => Token::Package, - "brew" => Token::Brew, - "secret" => Token::Secret, - "encrypted" => Token::Encrypted, - "hook" => Token::Hook, - "before_deploy" => Token::BeforeDeploy, - "after_deploy" => Token::AfterDeploy, - "before_package" => Token::BeforePackage, - "after_package" => Token::AfterPackage, - "macro" => Token::Macro, - "await" => Token::Await, - "return" => Token::Return, - "when" => Token::When, - "true" => Token::Bool(true), - "false" => Token::Bool(false), - _ => Token::Ident(s), - }); - - let op = choice(( - just("??").to(Token::QuestionQuestion), - just("=>").to(Token::FatArrow), - just("->").to(Token::Arrow), - just("::").to(Token::DoubleColon), - just("..").to(Token::DotDot), - just("==").to(Token::EqEq), - just("!=").to(Token::NotEq), - just("<=").to(Token::LtEq), - just(">=").to(Token::GtEq), - just("&&").to(Token::And), - just("||").to(Token::Or), - just('+').to(Token::Plus), - just('-').to(Token::Minus), - just('*').to(Token::Star), - just('/').to(Token::Slash), - just('%').to(Token::Percent), - just('=').to(Token::Eq), - just('<').to(Token::Lt), - just('>').to(Token::Gt), - just('!').to(Token::Bang), - just('|').to(Token::Pipe), - just('.').to(Token::Dot), - )); - - let delim = choice(( - just('(').to(Token::LParen), - just(')').to(Token::RParen), - just('[').to(Token::LBracket), - just(']').to(Token::RBracket), - just('{').to(Token::LBrace), - just('}').to(Token::RBrace), - just(',').to(Token::Comma), - just(':').to(Token::Colon), - just(';').to(Token::Semicolon), - just('~').to(Token::Tilde), - just('@').to(Token::At), - just('#').to(Token::Hash), - )); - - let comment = just('#').then(none_of("\n").repeated()).ignored(); - - let whitespace = just(' ').or(just('\t')).repeated().at_least(1).ignored(); - - let newline = just('\n').to(Token::Newline); - - let token = choice(( - float, - int, - heredoc, - string, - keyword_or_ident, - op, - delim, - newline, - )) - .map_with_span(Spanned::new); - - token - .padded_by(comment.repeated()) - .padded_by(whitespace.repeated()) - .repeated() - .then_ignore(end()) - } - - /// Tokenizes the input string with indentation processing. - #[tracing::instrument(skip_all)] - pub fn lex(input: &str) -> Result>, Vec>> { - let tokens = Self::lexer().parse(input)?; - Ok(Self::process_indentation(tokens)) - } - - /// Converts whitespace into indent/dedent tokens. - #[tracing::instrument(level = "trace", skip_all)] - fn process_indentation(tokens: Vec>) -> Vec> { - let mut result = Vec::new(); - let mut indent_stack = vec![0usize]; - let mut at_line_start = true; - let mut line_start_pos = 0; - - for token in tokens { - match &token.node { - Token::Newline => { - result.push(token.clone()); - at_line_start = true; - line_start_pos = token.span.end; - } - _ if at_line_start => { - let span_start = token.span.start; - let current_indent = span_start.saturating_sub(line_start_pos); - let last_indent = *indent_stack.last().unwrap(); - - if current_indent > last_indent { - indent_stack.push(current_indent); - result.push(Spanned::new( - Token::Indent(current_indent), - span_start..span_start, - )); - } else { - while indent_stack.len() > 1 - && current_indent < *indent_stack.last().unwrap() - { - indent_stack.pop(); - result.push(Spanned::new(Token::Dedent, span_start..span_start)); - } - } - - at_line_start = false; - result.push(token); - } - _ => { - result.push(token); - } - } - } - - let end = result.last().map(|t| t.span.end).unwrap_or(0); - while indent_stack.len() > 1 { - indent_stack.pop(); - result.push(Spanned::new(Token::Dedent, end..end)); - } - - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_basic_tokens() { - let input = "let x = 42"; - let tokens = Lexer::lex(input).unwrap(); - assert!(matches!(tokens[0].node, Token::Let)); - assert!(matches!(tokens[1].node, Token::Ident(ref s) if s == "x")); - assert!(matches!(tokens[2].node, Token::Eq)); - assert!(matches!(tokens[3].node, Token::Int(42))); - } - - #[test] - fn test_string_literal() { - let input = r#""hello world""#; - let tokens = Lexer::lex(input).unwrap(); - assert!(matches!(tokens[0].node, Token::Str(ref s) if s == "hello world")); - } - - #[test] - fn test_operators() { - let input = "a ?? b => c"; - let tokens = Lexer::lex(input).unwrap(); - assert!(matches!(tokens[1].node, Token::QuestionQuestion)); - assert!(matches!(tokens[3].node, Token::FatArrow)); - } -} diff --git a/crates/doot-lang/src/lib.rs b/crates/doot-lang/src/lib.rs index bac204d..108c799 100644 --- a/crates/doot-lang/src/lib.rs +++ b/crates/doot-lang/src/lib.rs @@ -1,27 +1,6 @@ -//! Doot language implementation. -//! -//! This crate provides the lexer, parser, type checker, and evaluator -//! for the doot configuration language. +//! The doot configuration language: a lazy, pure, typed expression language. +//! Lexer, parser, Hindley-Milner type checker, lazy CEK evaluator, and an +//! `Engine` registration API for embedding a standard library and domain +//! vocabulary. Domain-free: it knows nothing about dotfiles. -// chumsky 0.9's `Simple` error type is inherently large (~152 bytes) and -// is fixed by the parser-combinator API, so it cannot be boxed at the `select!` -// call sites. This lint is unactionable here. -#![allow(clippy::result_large_err)] - -pub mod ast; -pub mod builtins; -pub mod evaluator; -pub mod lexer; -pub mod macros; -pub mod parser; -pub mod planner; -pub mod type_checker; -pub mod types; - -pub use ast::*; -pub use evaluator::Evaluator; -pub use lexer::Lexer; -pub use parser::Parser; -pub use planner::{DotfileConflict, DotfileValidation, DotfileWarning, validate_dotfile_targets}; -pub use type_checker::TypeChecker; -pub use types::Type; +pub mod lang; diff --git a/crates/doot-lang/src/macros.rs b/crates/doot-lang/src/macros.rs deleted file mode 100644 index b49b81f..0000000 --- a/crates/doot-lang/src/macros.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Macro expansion for doot. - -use crate::ast::*; -use std::collections::HashMap; - -/// Expands macros in the AST. -pub struct MacroExpander { - macros: HashMap, -} - -impl MacroExpander { - /// Creates a new macro expander. - #[tracing::instrument(level = "trace")] - pub fn new() -> Self { - Self { - macros: HashMap::new(), - } - } - - /// Registers a macro definition. - #[tracing::instrument(level = "trace", skip(self), fields(name = %decl.name))] - pub fn register(&mut self, decl: MacroDecl) { - self.macros.insert(decl.name.clone(), decl); - } - - /// Expands a macro call into statements. - #[tracing::instrument(level = "trace", skip(self), fields(name = %call.name))] - pub fn expand(&self, call: &MacroCall) -> Option>> { - let decl = self.macros.get(&call.name)?; - - let mut substitutions: HashMap = HashMap::new(); - for (param, arg) in decl.params.iter().zip(call.args.iter()) { - substitutions.insert(param.clone(), arg); - } - - let expanded: Vec> = decl - .body - .iter() - .map(|stmt| { - Spanned::new( - self.substitute_statement(&stmt.node, &substitutions), - stmt.span.clone(), - ) - }) - .collect(); - - Some(expanded) - } - - #[tracing::instrument(level = "trace", skip_all)] - fn substitute_statement(&self, stmt: &Statement, subs: &HashMap) -> Statement { - match stmt { - Statement::VarDecl(decl) => Statement::VarDecl(VarDecl { - name: decl.name.clone(), - ty: decl.ty.clone(), - value: self.substitute_expr(&decl.value, subs), - }), - - Statement::Dotfile(dotfile) => Statement::Dotfile(Box::new(Dotfile { - source: self.substitute_expr(&dotfile.source, subs), - target: self.substitute_expr(&dotfile.target, subs), - when: dotfile.when.as_ref().map(|e| self.substitute_expr(e, subs)), - template: dotfile.template, - permissions: dotfile.permissions.clone(), - owner: dotfile.owner.clone(), - deploy: dotfile.deploy, - link_patterns: dotfile.link_patterns.clone(), - copy_patterns: dotfile.copy_patterns.clone(), - source_span: dotfile.source_span.clone(), - target_span: dotfile.target_span.clone(), - when_span: dotfile.when_span.clone(), - })), - - Statement::Package(pkg) => Statement::Package(Box::new(Package { - default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)), - brew: pkg.brew.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - cask: pkg.cask.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - apt: pkg.apt.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - pacman: pkg.pacman.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - yay: pkg.yay.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - xbps: pkg.xbps.as_ref().map(|s| PackageSpec { - name: self.substitute_expr(&s.name, subs), - }), - when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)), - })), - - Statement::ForLoop(for_loop) => Statement::ForLoop(ForLoop { - var: for_loop.var.clone(), - iter: self.substitute_expr(&for_loop.iter, subs), - body: for_loop - .body - .iter() - .map(|s| Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone())) - .collect(), - }), - - Statement::If(if_stmt) => Statement::If(IfStatement { - condition: self.substitute_expr(&if_stmt.condition, subs), - then_body: if_stmt - .then_body - .iter() - .map(|s| Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone())) - .collect(), - else_body: if_stmt.else_body.as_ref().map(|body| { - body.iter() - .map(|s| { - Spanned::new(self.substitute_statement(&s.node, subs), s.span.clone()) - }) - .collect() - }), - }), - - Statement::Expr(expr) => Statement::Expr(self.substitute_expr(expr, subs)), - - other => other.clone(), - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn substitute_expr(&self, expr: &Expr, subs: &HashMap) -> Expr { - match expr { - Expr::Ident(name) => { - if let Some(&replacement) = subs.get(name) { - replacement.clone() - } else { - expr.clone() - } - } - - Expr::Binary(left, op, right) => Expr::Binary( - Box::new(self.substitute_expr(left, subs)), - op.clone(), - Box::new(self.substitute_expr(right, subs)), - ), - - Expr::Unary(op, inner) => { - Expr::Unary(op.clone(), Box::new(self.substitute_expr(inner, subs))) - } - - Expr::Call(callee, args) => Expr::Call( - Box::new(self.substitute_expr(callee, subs)), - args.iter().map(|a| self.substitute_expr(a, subs)).collect(), - ), - - Expr::MethodCall(obj, method, args) => Expr::MethodCall( - Box::new(self.substitute_expr(obj, subs)), - method.clone(), - args.iter().map(|a| self.substitute_expr(a, subs)).collect(), - ), - - Expr::Field(obj, field) => { - Expr::Field(Box::new(self.substitute_expr(obj, subs)), field.clone()) - } - - Expr::Index(obj, idx) => Expr::Index( - Box::new(self.substitute_expr(obj, subs)), - Box::new(self.substitute_expr(idx, subs)), - ), - - Expr::List(items) => Expr::List( - items - .iter() - .map(|i| self.substitute_expr(i, subs)) - .collect(), - ), - - Expr::StructInit(name, fields) => Expr::StructInit( - name.clone(), - fields - .iter() - .map(|(k, v)| (k.clone(), self.substitute_expr(v, subs))) - .collect(), - ), - - Expr::If(cond, then_expr, else_expr) => Expr::If( - Box::new(self.substitute_expr(cond, subs)), - Box::new(self.substitute_expr(then_expr, subs)), - else_expr - .as_ref() - .map(|e| Box::new(self.substitute_expr(e, subs))), - ), - - Expr::Lambda(params, body) => { - Expr::Lambda(params.clone(), Box::new(self.substitute_expr(body, subs))) - } - - Expr::Await(inner) => Expr::Await(Box::new(self.substitute_expr(inner, subs))), - - Expr::Path(left, right) => Expr::Path( - Box::new(self.substitute_expr(left, subs)), - Box::new(self.substitute_expr(right, subs)), - ), - - Expr::HomePath(path) => Expr::HomePath(Box::new(self.substitute_expr(path, subs))), - - Expr::Interpolated(parts) => Expr::Interpolated( - parts - .iter() - .map(|p| match p { - InterpolatedPart::Literal(s) => InterpolatedPart::Literal(s.clone()), - InterpolatedPart::Expr(e) => { - InterpolatedPart::Expr(self.substitute_expr(e, subs)) - } - }) - .collect(), - ), - - other => other.clone(), - } - } -} - -impl Default for MacroExpander { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/doot-lang/src/parser.rs b/crates/doot-lang/src/parser.rs deleted file mode 100644 index 4a23706..0000000 --- a/crates/doot-lang/src/parser.rs +++ /dev/null @@ -1,1125 +0,0 @@ -//! Parser for the doot language. - -use crate::ast::*; -use crate::lexer::Token; -use chumsky::Parser as _; -use chumsky::prelude::*; -use std::collections::HashMap; - -/// Parses tokens into an AST. -pub struct Parser; - -type ParserInput = crate::lexer::Spanned; - -impl Parser { - /// Parses a token stream into a program AST. - #[tracing::instrument(skip_all)] - pub fn parse(tokens: Vec) -> Result>> { - let stream = tokens - .into_iter() - .map(|t| (t.node, t.span)) - .collect::>(); - let len = stream.last().map(|(_, s)| s.end).unwrap_or(0); - let stream = chumsky::Stream::from_iter(len..len + 1, stream.into_iter()); - Self::program_parser().parse(stream) - } - - fn program_parser() -> impl chumsky::Parser> { - Self::statement_parser() - .repeated() - .map(|statements| Program { statements }) - .then_ignore(end()) - } - - fn statement_parser() -> impl chumsky::Parser, Error = Simple> - { - recursive(|stmt| { - let whitespace = just(Token::Newline).repeated(); - - let var_decl = Self::var_decl_parser().map(Statement::VarDecl); - let fn_decl = Self::fn_decl_parser(stmt.clone()).map(Statement::FnDecl); - let struct_decl = Self::struct_decl_parser(stmt.clone()).map(Statement::StructDecl); - let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl); - let type_alias = Self::type_alias_parser().map(Statement::TypeAlias); - let import = Self::import_parser().map(Statement::Import); - let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d))); - let package = Self::package_parser().map(|p| Statement::Package(Box::new(p))); - let brew = Self::brew_parser().map(Statement::Brew); - let secret = Self::secret_parser().map(Statement::Secret); - let encrypted = Self::encrypted_parser().map(Statement::Encrypted); - let hook = Self::hook_parser().map(Statement::Hook); - let simple_hook = Self::simple_hook_parser().map(Statement::Hook); - let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl); - let macro_call = Self::macro_call_parser().map(Statement::MacroCall); - let for_loop = Self::for_loop_parser(stmt.clone()).map(Statement::ForLoop); - let if_stmt = Self::if_parser(stmt.clone()).map(Statement::If); - let match_stmt = Self::match_parser().map(Statement::Match); - let return_stmt = just(Token::Return) - .ignore_then(Self::expr_parser().or_not()) - .map(Statement::Return); - let expr_stmt = Self::expr_parser().map(Statement::Expr); - - choice(( - fn_decl, - struct_decl, - enum_decl, - type_alias, - import, - dotfile, - package, - brew, - secret, - encrypted, - hook, - simple_hook, - macro_decl, - macro_call, - for_loop, - if_stmt, - match_stmt, - return_stmt, - var_decl, - expr_stmt, - )) - .map_with_span(Spanned::new) - .padded_by(whitespace) - }) - } - - fn var_decl_parser() -> impl chumsky::Parser> { - Self::ident_parser() - .then( - just(Token::Colon) - .ignore_then(Self::type_annotation_parser()) - .or_not(), - ) - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()) - .map(|((name, ty), value)| VarDecl { name, ty, value }) - } - - fn fn_decl_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser> { - let is_async = select! { Token::Ident(s) if s == "async" => true } - .or_not() - .map(|a| a.is_some()); - - is_async - .then_ignore(just(Token::Fn)) - .then(Self::ident_parser()) - .then(Self::fn_params_parser()) - .then( - just(Token::Arrow) - .ignore_then(Self::type_annotation_parser()) - .or_not(), - ) - .then_ignore(just(Token::Colon)) - .then(Self::block_parser(stmt)) - .map(|((((is_async, name), params), return_type), body)| FnDecl { - name, - is_async, - params, - return_type, - body, - }) - } - - fn fn_params_parser() -> impl chumsky::Parser, Error = Simple> { - let param = Self::ident_parser() - .then_ignore(just(Token::Colon)) - .then(Self::type_annotation_parser()) - .then(just(Token::Eq).ignore_then(Self::expr_parser()).or_not()) - .map(|((name, ty), default)| FnParam { name, ty, default }); - - param - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)) - } - - fn struct_decl_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser> { - let field = Self::ident_parser() - .then_ignore(just(Token::Colon)) - .then(Self::type_annotation_parser()) - .then(just(Token::Eq).ignore_then(Self::expr_parser()).or_not()) - .map(|((name, ty), default)| StructField { name, ty, default }); - - let method = Self::fn_decl_parser(stmt); - - just(Token::Struct) - .ignore_then(Self::ident_parser()) - .then_ignore(just(Token::Colon)) - .then_ignore(just(Token::Newline).repeated()) - .then_ignore(Self::indent_parser()) - .then( - choice((field.map(Either::Left), method.map(Either::Right))) - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated(), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|(name, members)| { - let mut fields = Vec::new(); - let mut methods = Vec::new(); - for m in members { - match m { - Either::Left(f) => fields.push(f), - Either::Right(m) => methods.push(m), - } - } - StructDecl { - name, - fields, - methods, - } - }) - } - - fn enum_decl_parser() -> impl chumsky::Parser> { - let variant = Self::ident_parser() - .then( - Self::type_annotation_parser() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)) - .or_not(), - ) - .map(|(name, fields)| EnumVariant { name, fields }); - - just(Token::Enum) - .ignore_then(Self::ident_parser()) - .then_ignore(just(Token::Colon)) - .then_ignore(just(Token::Newline).repeated()) - .then( - variant - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|(name, variants)| EnumDecl { name, variants }) - } - - fn type_alias_parser() -> impl chumsky::Parser> { - just(Token::Type) - .ignore_then(Self::ident_parser()) - .then_ignore(just(Token::Eq)) - .then(Self::type_annotation_parser()) - .map(|(name, ty)| TypeAlias { name, ty }) - } - - fn import_parser() -> impl chumsky::Parser> { - just(Token::Import) - .ignore_then(select! { Token::Str(s) => s }) - .then(just(Token::As).ignore_then(Self::ident_parser()).or_not()) - .map(|(path, alias)| Import { path, alias }) - } - - fn indent_parser() -> impl chumsky::Parser> + Clone { - select! { Token::Indent(_) => () }.or_not().ignored() - } - - fn field_name_parser() -> impl chumsky::Parser> + Clone { - Self::ident_parser() - .or(just(Token::When).to("when".to_string())) - // `brew` is a keyword (for the `brew:` block) but is also a valid - // package-manager field name inside `package:` blocks. - .or(just(Token::Brew).to("brew".to_string())) - } - - fn dotfile_parser() -> impl chumsky::Parser> { - let field = Self::field_name_parser() - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser().map_with_span(|expr, span| (expr, span))); - - just(Token::Dotfile) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - field - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|fields| { - let mut dotfile = Dotfile { - source: Expr::Literal(Literal::None), - target: Expr::Literal(Literal::None), - when: None, - template: None, - permissions: Vec::new(), - owner: None, - deploy: DeployMode::default(), - link_patterns: Vec::new(), - copy_patterns: Vec::new(), - source_span: None, - target_span: None, - when_span: None, - }; - for (name, (value, span)) in fields { - match name.as_str() { - "source" => { - dotfile.source = value; - dotfile.source_span = Some(span); - } - "target" => { - dotfile.target = value; - dotfile.target_span = Some(span); - } - "when" => { - dotfile.when = Some(value); - dotfile.when_span = Some(span); - } - "template" => { - if let Expr::Literal(Literal::Bool(b)) = value { - dotfile.template = Some(b); - } - } - "permissions" => { - dotfile.permissions = expr_to_permission_rules(&value); - } - "deploy" => { - if let Expr::Literal(Literal::Str(s)) = value { - dotfile.deploy = match s.as_str() { - "link" => DeployMode::Link, - _ => DeployMode::Copy, - }; - } - } - "link" => { - dotfile.link_patterns = expr_to_string_list(&value); - } - "copy" => { - dotfile.copy_patterns = expr_to_string_list(&value); - } - "owner" => { - if let Expr::Literal(Literal::Str(s)) = value { - dotfile.owner = Some(s); - } - } - _ => {} - } - } - dotfile - }) - } - - fn package_parser() -> impl chumsky::Parser> { - let inline = just(Token::Package) - .ignore_then(just(Token::Colon)) - .ignore_then(Self::expr_parser()) - .map(|name| Package { - default: Some(name), - brew: None, - cask: None, - apt: None, - pacman: None, - yay: None, - xbps: None, - when: None, - }); - - let field = Self::field_name_parser() - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()); - - let block = just(Token::Package) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - field - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|fields| { - let mut pkg = Package { - default: None, - brew: None, - cask: None, - apt: None, - pacman: None, - yay: None, - xbps: None, - when: None, - }; - for (name, value) in fields { - match name.as_str() { - "default" => pkg.default = Some(value), - "brew" => pkg.brew = Some(PackageSpec { name: value }), - "cask" => pkg.cask = Some(PackageSpec { name: value }), - "apt" => pkg.apt = Some(PackageSpec { name: value }), - "pacman" => pkg.pacman = Some(PackageSpec { name: value }), - "yay" => pkg.yay = Some(PackageSpec { name: value }), - "xbps" => pkg.xbps = Some(PackageSpec { name: value }), - "when" => pkg.when = Some(value), - _ => {} - } - } - pkg - }); - - inline.or(block) - } - - /// Parses a `brew:` block holding brew-only configuration (`taps`, `formulae`). - fn brew_parser() -> impl chumsky::Parser> { - let field = Self::field_name_parser() - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()); - - just(Token::Brew) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - field - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|fields| { - let mut cfg = BrewConfig::default(); - for (name, value) in fields { - match name.as_str() { - "taps" => cfg.taps = Some(value), - "formulae" => cfg.formulae = Some(value), - _ => {} - } - } - cfg - }) - } - - fn secret_parser() -> impl chumsky::Parser> { - let field = Self::field_name_parser() - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()); - - just(Token::Secret) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - field - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|fields| { - let mut secret = Secret { - source: Expr::Literal(Literal::None), - target: Expr::Literal(Literal::None), - mode: None, - }; - for (name, value) in fields { - match name.as_str() { - "source" => secret.source = value, - "target" => secret.target = value, - "mode" => { - if let Expr::Literal(Literal::Int(m)) = value { - secret.mode = Some(m as u32); - } - } - _ => {} - } - } - secret - }) - } - - fn encrypted_parser() -> impl chumsky::Parser> { - // file("path") syntax - let file_entry = Self::ident_parser() - .then_ignore(just(Token::Eq)) - .then( - select! { Token::Ident(s) if s == "file" => () }.ignore_then( - Self::expr_parser().delimited_by(just(Token::LParen), just(Token::RParen)), - ), - ) - .map(|(name, path_expr)| EncryptedEntry::File(name, path_expr)); - - // Plain inline var: KEY = "base64..." - let var_entry = Self::ident_parser() - .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()) - .map(|(name, expr)| EncryptedEntry::Var(name, expr)); - - let entry = file_entry.or(var_entry); - - just(Token::Encrypted) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - entry - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|entries| EncryptedVars { entries }) - } - - fn hook_parser() -> impl chumsky::Parser> { - let stage = Self::ident_parser().map(|s| match s.as_str() { - "BeforeDeploy" => HookStage::BeforeDeploy, - "AfterDeploy" => HookStage::AfterDeploy, - "BeforePackage" => HookStage::BeforePackage, - "AfterPackage" => HookStage::AfterPackage, - _ => HookStage::AfterDeploy, - }); - - let field = Self::field_name_parser() - .then_ignore(just(Token::Eq)) - .then(choice(( - stage.map(Either::Left), - Self::expr_parser().map(Either::Right), - ))); - - just(Token::Hook) - .ignore_then(just(Token::Colon)) - .ignore_then(just(Token::Newline).repeated()) - .ignore_then(Self::indent_parser()) - .ignore_then( - field - .padded_by(Self::indent_parser()) - .padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|fields| { - let mut hook = Hook { - stage: HookStage::AfterDeploy, - run: Expr::Literal(Literal::None), - when: None, - }; - for (name, value) in fields { - match (name.as_str(), value) { - ("stage", Either::Left(s)) => hook.stage = s, - ("run", Either::Right(e)) => hook.run = e, - ("when", Either::Right(e)) => hook.when = Some(e), - _ => {} - } - } - hook - }) - } - - fn simple_hook_parser() -> impl chumsky::Parser> { - let stage_token = choice(( - just(Token::BeforeDeploy).to(HookStage::BeforeDeploy), - just(Token::AfterDeploy).to(HookStage::AfterDeploy), - just(Token::BeforePackage).to(HookStage::BeforePackage), - just(Token::AfterPackage).to(HookStage::AfterPackage), - )); - - stage_token - .then_ignore(just(Token::Colon)) - .then(Self::expr_parser()) - .map(|(stage, run)| Hook { - stage, - run, - when: None, - }) - } - - fn macro_decl_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser> { - just(Token::Macro) - .ignore_then(Self::ident_parser()) - .then_ignore(just(Token::Bang)) - .then( - Self::ident_parser() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)), - ) - .then_ignore(just(Token::Colon)) - .then(Self::block_parser(stmt)) - .map(|((name, params), body)| MacroDecl { name, params, body }) - } - - fn macro_call_parser() -> impl chumsky::Parser> { - Self::ident_parser() - .then_ignore(just(Token::Bang)) - .then( - Self::expr_parser() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)), - ) - .map(|(name, args)| MacroCall { name, args }) - } - - fn for_loop_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser> { - just(Token::For) - .ignore_then(Self::ident_parser()) - .then_ignore(just(Token::In)) - .then(Self::expr_parser()) - .then_ignore(just(Token::Colon)) - .then(Self::block_parser(stmt)) - .map(|((var, iter), body)| ForLoop { var, iter, body }) - } - - fn if_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser> { - just(Token::If) - .ignore_then(Self::expr_parser()) - .then_ignore(just(Token::Colon)) - .then(Self::block_parser(stmt.clone())) - .then( - just(Token::Else) - .ignore_then(just(Token::Colon)) - .ignore_then(Self::block_parser(stmt)) - .or_not(), - ) - .map(|((condition, then_body), else_body)| IfStatement { - condition, - then_body, - else_body, - }) - } - - fn match_parser() -> impl chumsky::Parser> { - let pattern = choice(( - select! { - Token::Int(n) => Pattern::Literal(Literal::Int(n)), - Token::Float(n) => Pattern::Literal(Literal::Float(n.into_inner())), - Token::Str(s) => Pattern::Literal(Literal::Str(s)), - Token::Bool(b) => Pattern::Literal(Literal::Bool(b)), - }, - Self::ident_parser() - .then_ignore(just(Token::DoubleColon)) - .then(Self::ident_parser()) - .map(|(ty, variant)| Pattern::EnumVariant { ty, variant }), - select! { Token::Ident(s) if s == "_" => Pattern::Wildcard }, - Self::ident_parser().map(Pattern::Ident), - )); - - let arm = pattern - .then_ignore(just(Token::FatArrow)) - .then(Self::expr_parser()) - .map(|(pattern, body)| MatchArm { pattern, body }); - - Self::ident_parser() - .then_ignore(just(Token::Eq)) - .then_ignore(just(Token::Match)) - .then(Self::expr_parser()) - .then_ignore(just(Token::Colon)) - .then_ignore(just(Token::Newline).repeated()) - .then( - arm.padded_by(just(Token::Newline).repeated()) - .repeated() - .at_least(1), - ) - .then_ignore(just(Token::Dedent).or_not()) - .map(|((_, expr), arms)| MatchStatement { expr, arms }) - } - - fn block_parser( - stmt: impl chumsky::Parser, Error = Simple> + Clone, - ) -> impl chumsky::Parser>, Error = Simple> { - just(Token::Newline) - .repeated() - .ignore_then(filter(|t| matches!(t, Token::Indent(_)))) - .ignore_then(stmt.repeated().at_least(1)) - .then_ignore(just(Token::Dedent).or_not()) - } - - fn type_annotation_parser() -> impl chumsky::Parser> - { - recursive(|ty| { - let simple = Self::ident_parser().map(TypeAnnotation::Simple); - - let list = ty - .clone() - .delimited_by(just(Token::LBracket), just(Token::RBracket)) - .map(|t| TypeAnnotation::List(Box::new(t))); - - let literal_str = select! { Token::Str(s) => TypeAnnotation::Literal(Literal::Str(s)) }; - - let base = choice((list, literal_str, simple)); - - let optional = base - .clone() - .then(select! { Token::Ident(s) if s == "?" => () }.or_not()) - .map(|(t, opt)| { - if opt.is_some() { - TypeAnnotation::Optional(Box::new(t)) - } else { - t - } - }); - - optional - .clone() - .then(just(Token::Pipe).ignore_then(optional.clone()).repeated()) - .map(|(first, rest)| { - if rest.is_empty() { - first - } else { - let mut types = vec![first]; - types.extend(rest); - TypeAnnotation::Union(types) - } - }) - }) - } - - fn expr_parser() -> impl chumsky::Parser> { - recursive(|expr| { - let literal = select! { - Token::Int(n) => Expr::Literal(Literal::Int(n)), - Token::Float(n) => Expr::Literal(Literal::Float(n.into_inner())), - Token::Str(s) => { - if s.contains('{') && s.contains('}') { - Self::parse_interpolated(&s) - } else { - Expr::Literal(Literal::Str(s)) - } - }, - Token::Bool(b) => Expr::Literal(Literal::Bool(b)), - }; - - let ident = Self::ident_parser().map(Expr::Ident); - - let list = expr - .clone() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LBracket), just(Token::RBracket)) - .map(Expr::List); - - // Allow newlines/indent/dedent inside struct braces for multi-line init - let brace_ws = just(Token::Newline) - .or(filter(|t: &Token| matches!(t, Token::Indent(_)))) - .or(just(Token::Dedent)) - .repeated(); - - let struct_init = Self::ident_parser() - .then( - just(Token::LBrace) - .ignore_then(brace_ws.clone()) - .ignore_then( - Self::ident_parser() - .then_ignore(just(Token::Eq)) - .then(expr.clone()) - .separated_by(just(Token::Comma).then_ignore(brace_ws.clone())) - .allow_trailing(), - ) - .then_ignore(brace_ws) - .then_ignore(just(Token::RBrace)), - ) - .map(|(name, fields)| { - let map: HashMap<_, _> = fields.into_iter().collect(); - Expr::StructInit(name, map) - }); - - let enum_variant = Self::ident_parser() - .then_ignore(just(Token::DoubleColon)) - .then(Self::ident_parser()) - .map(|(ty, variant)| Expr::EnumVariant(ty, variant)); - - let home_path = just(Token::Tilde) - .ignore_then(just(Token::Slash).ignore_then(expr.clone()).or_not()) - .map(|path| { - Expr::HomePath(Box::new( - path.unwrap_or(Expr::Literal(Literal::Str(String::new()))), - )) - }); - - let paren = expr - .clone() - .delimited_by(just(Token::LParen), just(Token::RParen)); - - let lambda = just(Token::Pipe) - .ignore_then( - Self::ident_parser() - .then( - just(Token::Colon) - .ignore_then(Self::type_annotation_parser()) - .or_not(), - ) - .map(|(name, ty)| FnParam { - name, - ty: ty.unwrap_or(TypeAnnotation::Simple("any".to_string())), - default: None, - }) - .separated_by(just(Token::Comma)), - ) - .then_ignore(just(Token::Pipe)) - .then(expr.clone()) - .map(|(params, body)| Expr::Lambda(params, Box::new(body))); - - let if_expr = just(Token::If) - .ignore_then(expr.clone()) - .then_ignore(just(Token::Then)) - .then(expr.clone()) - .then(just(Token::Else).ignore_then(expr.clone()).or_not()) - .map(|((cond, then_expr), else_expr)| { - Expr::If(Box::new(cond), Box::new(then_expr), else_expr.map(Box::new)) - }); - - let await_expr = just(Token::Await) - .ignore_then(expr.clone()) - .map(|e| Expr::Await(Box::new(e))); - - let atom = choice(( - await_expr, - if_expr, - lambda, - home_path, - struct_init, - enum_variant, - list, - literal, - ident, - paren, - )); - - let call_or_access = atom - .then( - choice(( - expr.clone() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)) - .map(CallOrAccess::Call), - just(Token::Dot) - .ignore_then(Self::ident_parser()) - .then( - expr.clone() - .separated_by(just(Token::Comma)) - .allow_trailing() - .delimited_by(just(Token::LParen), just(Token::RParen)) - .or_not(), - ) - .map(|(name, args)| { - if let Some(args) = args { - CallOrAccess::MethodCall(name, args) - } else { - CallOrAccess::Field(name) - } - }), - expr.clone() - .delimited_by(just(Token::LBracket), just(Token::RBracket)) - .map(CallOrAccess::Index), - )) - .repeated(), - ) - .foldl(|e, access| match access { - CallOrAccess::Call(args) => Expr::Call(Box::new(e), args), - CallOrAccess::MethodCall(name, args) => { - Expr::MethodCall(Box::new(e), name, args) - } - CallOrAccess::Field(name) => Expr::Field(Box::new(e), name), - CallOrAccess::Index(idx) => Expr::Index(Box::new(e), Box::new(idx)), - }); - - let unary_ops = choice(( - just(Token::Minus).to(UnaryOp::Neg), - just(Token::Bang).to(UnaryOp::Not), - )) - .repeated() - .collect::>(); - - let unary = unary_ops - .then(call_or_access) - .map(|(ops, expr)| { - ops.into_iter() - .rev() - .fold(expr, |e, op| Expr::Unary(op, Box::new(e))) - }) - .boxed(); - - let path_op = unary - .clone() - .then(just(Token::Slash).ignore_then(unary.clone()).repeated()) - .foldl(|a, b| Expr::Path(Box::new(a), Box::new(b))) - .boxed(); - - let product = path_op - .clone() - .then( - choice(( - just(Token::Star).to(BinOp::Mul), - just(Token::Percent).to(BinOp::Mod), - )) - .then(path_op.clone()) - .repeated(), - ) - .foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b))) - .boxed(); - - let sum = product - .clone() - .then( - choice(( - just(Token::Plus).to(BinOp::Add), - just(Token::Minus).to(BinOp::Sub), - )) - .then(product.clone()) - .repeated(), - ) - .foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b))) - .boxed(); - - let comparison = sum - .clone() - .then( - choice(( - just(Token::EqEq).to(BinOp::Eq), - just(Token::NotEq).to(BinOp::NotEq), - just(Token::LtEq).to(BinOp::LtEq), - just(Token::GtEq).to(BinOp::GtEq), - just(Token::Lt).to(BinOp::Lt), - just(Token::Gt).to(BinOp::Gt), - )) - .then(sum.clone()) - .repeated(), - ) - .foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b))) - .boxed(); - - let and_expr = comparison - .clone() - .then(just(Token::And).ignore_then(comparison.clone()).repeated()) - .foldl(|a, b| Expr::Binary(Box::new(a), BinOp::And, Box::new(b))) - .boxed(); - - let or_expr = and_expr - .clone() - .then(just(Token::Or).ignore_then(and_expr.clone()).repeated()) - .foldl(|a, b| Expr::Binary(Box::new(a), BinOp::Or, Box::new(b))) - .boxed(); - - or_expr - .clone() - .then( - just(Token::QuestionQuestion) - .ignore_then(or_expr.clone()) - .repeated(), - ) - .foldl(|a, b| Expr::Binary(Box::new(a), BinOp::NullCoalesce, Box::new(b))) - }) - } - - fn ident_parser() -> impl chumsky::Parser> + Clone { - select! { Token::Ident(s) => s } - } - - fn parse_interpolated(s: &str) -> Expr { - let mut parts = Vec::new(); - let mut current = String::new(); - let mut in_expr = false; - let mut expr_depth = 0; - let mut expr_str = String::new(); - - for c in s.chars() { - if in_expr { - if c == '{' { - expr_depth += 1; - expr_str.push(c); - } else if c == '}' { - if expr_depth == 0 { - in_expr = false; - parts.push(InterpolatedPart::Expr(Expr::Ident(expr_str.clone()))); - expr_str.clear(); - } else { - expr_depth -= 1; - expr_str.push(c); - } - } else { - expr_str.push(c); - } - } else if c == '{' { - if !current.is_empty() { - parts.push(InterpolatedPart::Literal(current.clone())); - current.clear(); - } - in_expr = true; - } else { - current.push(c); - } - } - - if !current.is_empty() { - parts.push(InterpolatedPart::Literal(current)); - } - - if parts.len() == 1 - && let InterpolatedPart::Literal(s) = &parts[0] - { - return Expr::Literal(Literal::Str(s.clone())); - } - - Expr::Interpolated(parts) - } -} - -enum CallOrAccess { - Call(Vec), - MethodCall(String, Vec), - Field(String), - Index(Expr), -} - -enum Either { - Left(L), - Right(R), -} - -fn expr_to_string_list(expr: &Expr) -> Vec { - match expr { - Expr::List(items) => items - .iter() - .filter_map(|e| { - if let Expr::Literal(Literal::Str(s)) = e { - Some(s.clone()) - } else { - None - } - }) - .collect(), - Expr::Literal(Literal::Str(s)) => vec![s.clone()], - _ => Vec::new(), - } -} - -fn expr_to_permission_rules(expr: &Expr) -> Vec { - match expr { - // Single mode: permissions = 0o755 - Expr::Literal(Literal::Int(mode)) => { - vec![PermissionRule::Single(*mode as u32)] - } - // Array of rules: permissions = [["*.sh", 0o755], ["secret/*", 0o600]] - Expr::List(items) => items - .iter() - .filter_map(|e| { - match e { - // [pattern, mode] pair - Expr::List(pair) if pair.len() == 2 => { - if let ( - Expr::Literal(Literal::Str(pattern)), - Expr::Literal(Literal::Int(mode)), - ) = (&pair[0], &pair[1]) - { - Some(PermissionRule::Pattern { - pattern: pattern.clone(), - mode: *mode as u32, - }) - } else { - None - } - } - // Single mode in array (less common but supported) - Expr::Literal(Literal::Int(mode)) => Some(PermissionRule::Single(*mode as u32)), - _ => None, - } - }) - .collect(), - _ => Vec::new(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::lexer::Lexer; - - fn parse_source(src: &str) -> Program { - let tokens = Lexer::lex(src).expect("lexer failed"); - Parser::parse(tokens).expect("parser failed") - } - - #[test] - fn test_encrypted_inline_vars() { - let src = - "encrypted:\n API_KEY = \"base64ciphertext\"\n DB_PASS = \"anotherciphertext\"\n"; - let program = parse_source(src); - assert_eq!(program.statements.len(), 1); - if let Statement::Encrypted(enc) = &program.statements[0].node { - assert_eq!(enc.entries.len(), 2); - assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY")); - assert!(matches!(&enc.entries[1], EncryptedEntry::Var(name, _) if name == "DB_PASS")); - } else { - panic!("expected Encrypted statement"); - } - } - - #[test] - fn test_brew_block_and_cask_field() { - // `brew:` block with taps + formulae lists. - let program = parse_source( - "brew:\n taps = [\"homebrew/cask-fonts\"]\n formulae = [\"mas\", \"trash\"]\n", - ); - assert_eq!(program.statements.len(), 1); - match &program.statements[0].node { - Statement::Brew(cfg) => { - assert!(cfg.taps.is_some()); - assert!(cfg.formulae.is_some()); - } - other => panic!("expected Brew statement, got {other:?}"), - } - - // `cask` is a package field; `brew` still works as a field name despite - // being a keyword now. - let program = parse_source("package:\n brew = \"ripgrep\"\n cask = \"firefox\"\n"); - match &program.statements[0].node { - Statement::Package(pkg) => { - assert!(pkg.brew.is_some()); - assert!(pkg.cask.is_some()); - } - other => panic!("expected Package statement, got {other:?}"), - } - } - - #[test] - fn test_encrypted_file_entries() { - let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n"; - let program = parse_source(src); - assert_eq!(program.statements.len(), 1); - if let Statement::Encrypted(enc) = &program.statements[0].node { - assert_eq!(enc.entries.len(), 2); - assert!(matches!(&enc.entries[0], EncryptedEntry::File(name, _) if name == "SSH_KEY")); - assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "CONFIG")); - } else { - panic!("expected Encrypted statement"); - } - } - - #[test] - fn test_encrypted_mixed_entries() { - let src = "encrypted:\n API_KEY = \"base64ciphertext\"\n SSH_KEY = file(\"secrets/id_rsa.age\")\n TOKEN = \"anotherbase64\"\n"; - let program = parse_source(src); - assert_eq!(program.statements.len(), 1); - if let Statement::Encrypted(enc) = &program.statements[0].node { - assert_eq!(enc.entries.len(), 3); - assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY")); - assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "SSH_KEY")); - assert!(matches!(&enc.entries[2], EncryptedEntry::Var(name, _) if name == "TOKEN")); - } else { - panic!("expected Encrypted statement"); - } - } -} diff --git a/crates/doot-lang/src/planner/dag.rs b/crates/doot-lang/src/planner/dag.rs deleted file mode 100644 index a56584b..0000000 --- a/crates/doot-lang/src/planner/dag.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Dependency graph for task ordering. - -use std::collections::{HashMap, HashSet}; - -/// Directed acyclic graph of task dependencies. -#[derive(Debug, Clone)] -pub struct DependencyGraph { - nodes: HashMap, - edges: HashMap>, -} - -/// A node in the dependency graph. -#[derive(Debug, Clone)] -pub struct Node { - pub id: String, - pub task_type: TaskType, - pub data: TaskData, -} - -/// Task category. -#[derive(Debug, Clone)] -pub enum TaskType { - Dotfile, - Package, - Secret, - Hook, - Custom, -} - -/// Task-specific data. -#[derive(Debug, Clone)] -pub enum TaskData { - Dotfile { - source: std::path::PathBuf, - target: std::path::PathBuf, - template: bool, - }, - Package { - name: String, - manager: String, - }, - Secret { - source: std::path::PathBuf, - target: std::path::PathBuf, - }, - Hook { - command: String, - }, - Custom(String), -} - -impl DependencyGraph { - /// Creates an empty dependency graph. - pub fn new() -> Self { - Self { - nodes: HashMap::new(), - edges: HashMap::new(), - } - } - - /// Adds a task node. - pub fn add_node(&mut self, id: String, task_type: TaskType, data: TaskData) { - self.nodes.insert( - id.clone(), - Node { - id: id.clone(), - task_type, - data, - }, - ); - self.edges.entry(id).or_default(); - } - - /// Adds a dependency edge (from depends on to). - pub fn add_edge(&mut self, from: &str, to: &str) { - self.edges - .entry(from.to_string()) - .or_default() - .insert(to.to_string()); - } - - /// Returns tasks in dependency order. - pub fn topological_sort(&self) -> Result, String> { - let mut in_degree: HashMap = HashMap::new(); - let mut reverse_edges: HashMap> = HashMap::new(); - - for id in self.nodes.keys() { - in_degree.insert(id.clone(), 0); - reverse_edges.insert(id.clone(), HashSet::new()); - } - - for (from, tos) in &self.edges { - for to in tos { - *in_degree.entry(to.clone()).or_default() += 1; - reverse_edges - .entry(from.clone()) - .or_default() - .insert(to.clone()); - } - } - - let mut queue: Vec = in_degree - .iter() - .filter(|(_, deg)| **deg == 0) - .map(|(id, _)| id.clone()) - .collect(); - - let mut result = Vec::new(); - - while let Some(node) = queue.pop() { - result.push(node.clone()); - - if let Some(deps) = self.edges.get(&node) { - for dep in deps { - if let Some(deg) = in_degree.get_mut(dep) { - *deg -= 1; - if *deg == 0 { - queue.push(dep.clone()); - } - } - } - } - } - - if result.len() != self.nodes.len() { - return Err("cycle detected in dependency graph".to_string()); - } - - Ok(result) - } - - /// Groups tasks into parallelizable batches. - pub fn get_parallel_batches(&self) -> Result>, String> { - let mut in_degree: HashMap = HashMap::new(); - let mut remaining = self.nodes.keys().cloned().collect::>(); - - for id in self.nodes.keys() { - in_degree.insert(id.clone(), 0); - } - - for tos in self.edges.values() { - for to in tos { - *in_degree.entry(to.clone()).or_default() += 1; - } - } - - let mut batches = Vec::new(); - - while !remaining.is_empty() { - let batch: Vec = remaining - .iter() - .filter(|id| in_degree.get(*id).copied().unwrap_or(0) == 0) - .cloned() - .collect(); - - if batch.is_empty() { - return Err("cycle detected in dependency graph".to_string()); - } - - for node in &batch { - remaining.remove(node); - if let Some(deps) = self.edges.get(node) { - for dep in deps { - if let Some(deg) = in_degree.get_mut(dep) { - *deg -= 1; - } - } - } - } - - batches.push(batch); - } - - Ok(batches) - } - - /// Gets a node by ID. - pub fn get_node(&self, id: &str) -> Option<&Node> { - self.nodes.get(id) - } - - /// Iterates over all nodes. - pub fn nodes(&self) -> impl Iterator { - self.nodes.values() - } -} - -impl Default for DependencyGraph { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/doot-lang/src/planner/mod.rs b/crates/doot-lang/src/planner/mod.rs deleted file mode 100644 index d3f779f..0000000 --- a/crates/doot-lang/src/planner/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Task planning and execution. - -pub mod dag; -pub mod scheduler; - -pub use dag::DependencyGraph; -pub use scheduler::{ - DotfileConflict, DotfileValidation, DotfileWarning, Scheduler, validate_dotfile_targets, -}; diff --git a/crates/doot-lang/src/planner/scheduler.rs b/crates/doot-lang/src/planner/scheduler.rs deleted file mode 100644 index b9af741..0000000 --- a/crates/doot-lang/src/planner/scheduler.rs +++ /dev/null @@ -1,423 +0,0 @@ -//! Task scheduling from evaluation results. - -use super::dag::{DependencyGraph, TaskData, TaskType}; -use crate::evaluator::{DotfileConfig, EvalResult}; -use std::path::Path; - -/// Builds a dependency graph from evaluation results. -pub struct Scheduler { - graph: DependencyGraph, -} - -impl Scheduler { - /// Creates an empty scheduler. - #[tracing::instrument(level = "trace")] - pub fn new() -> Self { - Self { - graph: DependencyGraph::new(), - } - } - - /// Creates a scheduler from evaluation results. - #[tracing::instrument(skip_all)] - pub fn from_eval_result(result: &EvalResult) -> Self { - let mut scheduler = Self::new(); - - for (i, dotfile) in result.dotfiles.iter().enumerate() { - let id = format!("dotfile_{}", i); - scheduler.graph.add_node( - id, - TaskType::Dotfile, - TaskData::Dotfile { - source: dotfile.source.clone(), - target: dotfile.target.clone(), - template: dotfile.template, - }, - ); - } - - for (i, package) in result.packages.iter().enumerate() { - let id = format!("package_{}", i); - let name = package.default.clone().unwrap_or_default(); - scheduler.graph.add_node( - id, - TaskType::Package, - TaskData::Package { - name, - manager: "default".to_string(), - }, - ); - } - - for (i, secret) in result.secrets.iter().enumerate() { - let id = format!("secret_{}", i); - scheduler.graph.add_node( - id, - TaskType::Secret, - TaskData::Secret { - source: secret.source.clone(), - target: secret.target.clone(), - }, - ); - } - - for (i, hook) in result.hooks.iter().enumerate() { - let id = format!("hook_{}", i); - scheduler.graph.add_node( - id, - TaskType::Hook, - TaskData::Hook { - command: hook.run.clone(), - }, - ); - } - - scheduler - } - - /// Returns the built dependency graph. - pub fn build_graph(self) -> DependencyGraph { - self.graph - } - - /// Returns task IDs in execution order. - #[tracing::instrument(level = "trace", skip(self))] - pub fn get_execution_order(&self) -> Result, String> { - self.graph.topological_sort() - } - - /// Returns tasks grouped into parallel batches. - #[tracing::instrument(level = "trace", skip(self))] - pub fn get_parallel_batches(&self) -> Result>, String> { - self.graph.get_parallel_batches() - } -} - -impl Default for Scheduler { - fn default() -> Self { - Self::new() - } -} - -/// Conflict detected between dotfile entries. -#[derive(Debug, Clone)] -pub enum DotfileConflict { - /// Same source and target (duplicate entry). - Duplicate { index_a: usize, index_b: usize }, - /// Overlapping directories with no distinguishing settings (likely redundant). - RedundantOverlap { - parent_index: usize, - child_index: usize, - }, -} - -/// Warning about dotfile configuration. -#[derive(Debug, Clone)] -pub struct DotfileWarning { - pub message: String, - pub index_a: usize, - pub index_b: usize, -} - -/// Result of validating dotfile targets. -#[derive(Debug)] -pub struct DotfileValidation { - /// Indices in dependency order (respecting target relationships). - pub ordered_indices: Vec, - /// Batches of indices that can be deployed in parallel. - pub parallel_batches: Vec>, - /// Errors that prevent deployment. - pub errors: Vec, - /// Warnings that should be shown to user. - pub warnings: Vec, -} - -/// Validates dotfile targets and returns proper execution order. -/// -/// Detects: -/// - Duplicate entries (same source + same target) → Error -/// - Same target with different source → OK, add dependency (later depends on earlier) -/// - Overlapping directories (both dirs, one target is ancestor) with same settings → Warning -/// - Overlapping directories with different settings → OK, add dependency -/// - Directory + file inside → OK, add dependency -#[tracing::instrument(skip_all)] -pub fn validate_dotfile_targets( - dotfiles: &[DotfileConfig], - source_dir: &Path, -) -> DotfileValidation { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - let mut graph = DependencyGraph::new(); - - // Add all dotfiles as nodes - for (i, dotfile) in dotfiles.iter().enumerate() { - let id = format!("dotfile_{}", i); - graph.add_node( - id, - TaskType::Dotfile, - TaskData::Dotfile { - source: dotfile.source.clone(), - target: dotfile.target.clone(), - template: dotfile.template, - }, - ); - } - - // Check all pairs for conflicts - for i in 0..dotfiles.len() { - for j in (i + 1)..dotfiles.len() { - let a = &dotfiles[i]; - let b = &dotfiles[j]; - - let target_a = &a.target; - let target_b = &b.target; - - // Check for same exact target - if target_a == target_b { - if a.source == b.source { - // Same source + same target = duplicate - errors.push(DotfileConflict::Duplicate { - index_a: i, - index_b: j, - }); - } else { - // Different source + same target = override, j depends on i. - // Layering is supported, but two sources fighting over one file - // is usually a mistake, so surface it as a warning (last wins). - graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j)); - warnings.push(DotfileWarning { - message: format!( - "'{}' and '{}' both deploy to '{}'; the later entry wins", - a.source.display(), - b.source.display(), - target_a.display() - ), - index_a: i, - index_b: j, - }); - } - continue; - } - - // Check if one target is ancestor of the other - let a_is_ancestor = target_b.starts_with(target_a) && target_a != target_b; - let b_is_ancestor = target_a.starts_with(target_b) && target_a != target_b; - - if a_is_ancestor { - // a's target is ancestor of b's target, so a must run first - let full_source_a = source_dir.join(&a.source); - let full_source_b = source_dir.join(&b.source); - let both_dirs = full_source_a.is_dir() && full_source_b.is_dir(); - - if both_dirs && is_redundant_overlap(a, b) { - warnings.push(DotfileWarning { - message: format!( - "overlapping directories with same settings: '{}' contains '{}'", - a.source.display(), - b.source.display() - ), - index_a: i, - index_b: j, - }); - } - - // Add edge: a runs before b - graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j)); - } else if b_is_ancestor { - // b's target is ancestor of a's target, so b must run first - let full_source_a = source_dir.join(&a.source); - let full_source_b = source_dir.join(&b.source); - let both_dirs = full_source_a.is_dir() && full_source_b.is_dir(); - - if both_dirs && is_redundant_overlap(b, a) { - warnings.push(DotfileWarning { - message: format!( - "overlapping directories with same settings: '{}' contains '{}'", - b.source.display(), - a.source.display() - ), - index_a: j, - index_b: i, - }); - } - - // Add edge: b runs before a - graph.add_edge(&format!("dotfile_{}", j), &format!("dotfile_{}", i)); - } - } - } - - // Get execution order via topological sort - let ordered_indices: Vec = match graph.topological_sort() { - Ok(ids) => ids - .into_iter() - .filter_map(|id| id.strip_prefix("dotfile_").and_then(|s| s.parse().ok())) - .collect(), - Err(_) => { - // Cycle detected - shouldn't happen with our edge rules, but fallback to original order - (0..dotfiles.len()).collect() - } - }; - - // Get parallel batches from the DAG - let parallel_batches = match graph.get_parallel_batches() { - Ok(batches) => batches - .into_iter() - .map(|batch| { - batch - .into_iter() - .filter_map(|id| id.strip_prefix("dotfile_").and_then(|s| s.parse().ok())) - .collect() - }) - .collect(), - Err(_) => ordered_indices.iter().map(|&i| vec![i]).collect(), - }; - - DotfileValidation { - ordered_indices, - parallel_batches, - errors, - warnings, - } -} - -/// Checks if the child dotfile has no distinguishing settings from parent. -fn is_redundant_overlap(parent: &DotfileConfig, child: &DotfileConfig) -> bool { - child.permissions.is_empty() - && child.owner.is_none() - && !child.template - && child.deploy == parent.deploy - && child.link_patterns.is_empty() - && child.copy_patterns.is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::evaluator::DeployMode; - use std::path::PathBuf; - use tempfile::TempDir; - - fn make_dotfile(source: &str, target: &str) -> DotfileConfig { - DotfileConfig { - source: PathBuf::from(source), - target: PathBuf::from(target), - template: false, - permissions: Vec::new(), - owner: None, - deploy: DeployMode::Copy, - link_patterns: Vec::new(), - copy_patterns: Vec::new(), - exclude_paths: Vec::new(), - exclude_sources: Vec::new(), - } - } - - #[test] - fn test_duplicate_entry_error() { - let temp = TempDir::new().unwrap(); - let dotfiles = vec![ - make_dotfile("config/app.conf", "/home/user/.config/app.conf"), - make_dotfile("config/app.conf", "/home/user/.config/app.conf"), - ]; - - let result = validate_dotfile_targets(&dotfiles, temp.path()); - - assert_eq!(result.errors.len(), 1); - match &result.errors[0] { - DotfileConflict::Duplicate { index_a, index_b } => { - assert_eq!(*index_a, 0); - assert_eq!(*index_b, 1); - } - _ => panic!("expected Duplicate error"), - } - } - - #[test] - fn test_same_target_different_source_ok() { - let temp = TempDir::new().unwrap(); - let dotfiles = vec![ - make_dotfile("config/app.conf", "/home/user/.config/app.conf"), - make_dotfile("templates/app.conf", "/home/user/.config/app.conf"), - ]; - - let result = validate_dotfile_targets(&dotfiles, temp.path()); - - assert!(result.errors.is_empty()); - // Second entry should come after first - assert_eq!(result.ordered_indices, vec![0, 1]); - // Two different sources hitting one target is allowed (last wins) but warns. - assert_eq!(result.warnings.len(), 1); - assert!(result.warnings[0].message.contains("both deploy to")); - } - - #[test] - fn test_directory_file_override_ordering() { - let temp = TempDir::new().unwrap(); - - // Create a directory to simulate real filesystem - std::fs::create_dir_all(temp.path().join("config/nvim")).unwrap(); - std::fs::write(temp.path().join("config/nvim/init.lua"), "").unwrap(); - - let mut file_dotfile = - make_dotfile("config/nvim/init.lua", "/home/user/.config/nvim/init.lua"); - file_dotfile.template = true; - - let dotfiles = vec![ - // File with template (declared first) - file_dotfile, - // Directory (declared second) - make_dotfile("config/nvim", "/home/user/.config/nvim"), - ]; - - let result = validate_dotfile_targets(&dotfiles, temp.path()); - - assert!(result.errors.is_empty()); - // Directory should run first (index 1), then file (index 0) - assert_eq!(result.ordered_indices, vec![1, 0]); - } - - #[test] - fn test_overlapping_dirs_with_different_settings_no_warning() { - let temp = TempDir::new().unwrap(); - - // Create directories - std::fs::create_dir_all(temp.path().join("config/nvim/lua")).unwrap(); - - let mut child_dotfile = make_dotfile("config/nvim/lua", "/home/user/.config/nvim/lua"); - child_dotfile.owner = Some("root".to_string()); - - let dotfiles = vec![ - make_dotfile("config/nvim", "/home/user/.config/nvim"), - child_dotfile, - ]; - - let result = validate_dotfile_targets(&dotfiles, temp.path()); - - assert!(result.errors.is_empty()); - assert!(result.warnings.is_empty()); // No warning because child has different settings - } - - #[test] - fn test_overlapping_dirs_same_settings_warning() { - let temp = TempDir::new().unwrap(); - - // Create directories - std::fs::create_dir_all(temp.path().join("config/nvim/lua")).unwrap(); - - let dotfiles = vec![ - make_dotfile("config/nvim", "/home/user/.config/nvim"), - make_dotfile("config/nvim/lua", "/home/user/.config/nvim/lua"), - ]; - - let result = validate_dotfile_targets(&dotfiles, temp.path()); - - assert!(result.errors.is_empty()); - assert_eq!(result.warnings.len(), 1); - assert!( - result.warnings[0] - .message - .contains("overlapping directories") - ); - } -} diff --git a/crates/doot-lang/src/type_checker.rs b/crates/doot-lang/src/type_checker.rs deleted file mode 100644 index ed3c6e8..0000000 --- a/crates/doot-lang/src/type_checker.rs +++ /dev/null @@ -1,953 +0,0 @@ -//! Static type checker for the doot language. - -use crate::ast::*; -use crate::types::*; -use ariadne::{Color, Label, Report, ReportKind, Source}; -use std::collections::HashMap; -use thiserror::Error; - -/// Type checking errors. -#[derive(Error, Debug)] -pub enum TypeError { - #[error("undefined variable: {0}")] - UndefinedVariable(String, std::ops::Range), - - #[error("undefined type: {0}")] - UndefinedType(String, std::ops::Range), - - #[error("type mismatch: expected {expected}, got {got}")] - TypeMismatch { - expected: String, - got: String, - span: std::ops::Range, - }, - - #[error("cannot call non-function type: {0}")] - NotCallable(String, std::ops::Range), - - #[error("field {field} not found on type {ty}")] - FieldNotFound { - ty: String, - field: String, - span: std::ops::Range, - }, - - #[error("wrong number of arguments: expected {expected}, got {got}")] - WrongArity { - expected: usize, - got: usize, - span: std::ops::Range, - }, - - #[error("await can only be used inside async functions")] - AwaitOutsideAsync(std::ops::Range), -} - -impl TypeError { - /// Prints a formatted error report to stderr. - pub fn report(&self, source: &str, filename: &str) { - let (msg, span) = match self { - TypeError::UndefinedVariable(name, span) => { - (format!("undefined variable: {}", name), span.clone()) - } - TypeError::UndefinedType(name, span) => { - (format!("undefined type: {}", name), span.clone()) - } - TypeError::TypeMismatch { - expected, - got, - span, - } => (format!("expected {}, got {}", expected, got), span.clone()), - TypeError::NotCallable(ty, span) => ( - format!("cannot call non-function type: {}", ty), - span.clone(), - ), - TypeError::FieldNotFound { ty, field, span } => { - (format!("field {} not found on {}", field, ty), span.clone()) - } - TypeError::WrongArity { - expected, - got, - span, - } => ( - format!("expected {} arguments, got {}", expected, got), - span.clone(), - ), - TypeError::AwaitOutsideAsync(span) => ( - "await can only be used inside async functions".to_string(), - span.clone(), - ), - }; - - Report::build(ReportKind::Error, filename, span.start) - .with_message(self.to_string()) - .with_label( - Label::new((filename, span)) - .with_message(msg) - .with_color(Color::Red), - ) - .finish() - .print((filename, Source::from(source))) - .ok(); - } -} - -/// Static type checker. -pub struct TypeChecker { - env: TypeEnv, - errors: Vec, - in_async_context: bool, -} - -impl TypeChecker { - /// Creates a new type checker with built-in types. - #[tracing::instrument(level = "trace")] - pub fn new() -> Self { - Self { - env: TypeEnv::new(), - errors: Vec::new(), - in_async_context: true, // top-level is implicitly async - } - } - - /// Type checks a program, returning errors if any. - #[tracing::instrument(level = "trace", skip_all)] - pub fn check(&mut self, program: &Program) -> Result<(), Vec> { - for stmt in &program.statements { - self.check_statement(stmt); - } - - if self.errors.is_empty() { - Ok(()) - } else { - Err(std::mem::take(&mut self.errors)) - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn check_statement(&mut self, stmt: &Spanned) { - match &stmt.node { - Statement::VarDecl(decl) => { - let inferred = self.infer_expr(&decl.value, &stmt.span); - if let Some(ref ty_ann) = decl.ty { - let expected = self.resolve_type(ty_ann); - if !expected.is_compatible(&inferred) { - self.errors.push(TypeError::TypeMismatch { - expected: expected.display(), - got: inferred.display(), - span: stmt.span.clone(), - }); - } - self.env.define(decl.name.clone(), expected); - } else { - self.env.define(decl.name.clone(), inferred); - } - } - - Statement::FnDecl(decl) => { - let params: Vec<(String, Type)> = decl - .params - .iter() - .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) - .collect(); - let return_type = decl - .return_type - .as_ref() - .map(|t| self.resolve_type(t)) - .unwrap_or(Type::None); - - self.env.define_function( - decl.name.clone(), - FunctionType { - params: params.clone(), - return_type: return_type.clone(), - is_async: decl.is_async, - }, - ); - - self.env.push_scope(); - for (name, ty) in params { - self.env.define(name, ty); - } - if decl.params.iter().any(|p| p.name == "self") { - // Method context - } - let old_async = self.in_async_context; - self.in_async_context = decl.is_async; - for body_stmt in &decl.body { - self.check_statement(body_stmt); - } - self.in_async_context = old_async; - self.env.pop_scope(); - } - - Statement::StructDecl(decl) => { - let mut fields = HashMap::new(); - for field in &decl.fields { - let ty = self.resolve_type(&field.ty); - fields.insert(field.name.clone(), ty); - } - - let mut methods = HashMap::new(); - for method in &decl.methods { - let params: Vec<(String, Type)> = method - .params - .iter() - .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) - .collect(); - let return_type = method - .return_type - .as_ref() - .map(|t| self.resolve_type(t)) - .unwrap_or(Type::None); - methods.insert( - method.name.clone(), - FunctionType { - params, - return_type, - is_async: method.is_async, - }, - ); - } - - self.env.define_struct( - decl.name.clone(), - StructType { - name: decl.name.clone(), - fields, - methods, - }, - ); - } - - Statement::EnumDecl(decl) => { - let mut variants = HashMap::new(); - for variant in &decl.variants { - let fields = variant - .fields - .as_ref() - .map(|fs| fs.iter().map(|t| self.resolve_type(t)).collect()); - variants.insert(variant.name.clone(), fields); - } - self.env.define_enum( - decl.name.clone(), - EnumType { - name: decl.name.clone(), - variants, - }, - ); - } - - Statement::TypeAlias(alias) => { - let ty = self.resolve_type(&alias.ty); - self.env.define(alias.name.clone(), ty); - } - - Statement::ForLoop(for_loop) => { - let iter_ty = self.infer_expr(&for_loop.iter, &stmt.span); - let elem_ty = match iter_ty { - Type::List(inner) => *inner, - Type::Str => Type::Str, - _ => Type::Any, - }; - - self.env.push_scope(); - self.env.define(for_loop.var.clone(), elem_ty); - for body_stmt in &for_loop.body { - self.check_statement(body_stmt); - } - self.env.pop_scope(); - } - - Statement::If(if_stmt) => { - let cond_ty = self.infer_expr(&if_stmt.condition, &stmt.span); - if !cond_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: cond_ty.display(), - span: stmt.span.clone(), - }); - } - - self.env.push_scope(); - for body_stmt in &if_stmt.then_body { - self.check_statement(body_stmt); - } - self.env.pop_scope(); - - if let Some(ref else_body) = if_stmt.else_body { - self.env.push_scope(); - for body_stmt in else_body { - self.check_statement(body_stmt); - } - self.env.pop_scope(); - } - } - - Statement::Dotfile(dotfile) => { - let source_span = dotfile.source_span.as_ref().unwrap_or(&stmt.span); - let source_ty = self.infer_expr(&dotfile.source, source_span); - // dotfile: source accepts path, str (pattern with wildcards), or list - if !matches!( - source_ty, - Type::Str | Type::Path | Type::List(_) | Type::Any | Type::Unknown - ) { - self.errors.push(TypeError::TypeMismatch { - expected: "path, str, or [path]".to_string(), - got: source_ty.display(), - span: source_span.clone(), - }); - } - let target_span = dotfile.target_span.as_ref().unwrap_or(&stmt.span); - let target_ty = self.infer_expr(&dotfile.target, target_span); - if matches!(target_ty, Type::List(_)) { - self.errors.push(TypeError::TypeMismatch { - expected: "path".to_string(), - got: target_ty.display(), - span: target_span.clone(), - }); - } - if let Some(ref when) = dotfile.when { - let when_span = dotfile.when_span.as_ref().unwrap_or(&stmt.span); - let when_ty = self.infer_expr(when, when_span); - if !when_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: when_ty.display(), - span: when_span.clone(), - }); - } - } - } - - Statement::Package(pkg) => { - // Package names are converted to strings at runtime, so skip type checking - // for the default value. Only check the 'when' condition if present. - if let Some(ref when) = pkg.when { - let when_ty = self.infer_expr(when, &stmt.span); - if !when_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: when_ty.display(), - span: stmt.span.clone(), - }); - } - } - } - - Statement::Expr(expr) => { - self.infer_expr(expr, &stmt.span); - } - - _ => {} - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn infer_expr(&mut self, expr: &Expr, span: &std::ops::Range) -> Type { - match expr { - Expr::Literal(lit) => match lit { - Literal::Int(_) => Type::Int, - Literal::Float(_) => Type::Float, - Literal::Str(_) => Type::Str, - Literal::Bool(_) => Type::Bool, - Literal::None => Type::None, - }, - - Expr::Ident(name) => { - if let Some(ty) = self.env.lookup(name) { - ty.clone() - } else if let Some(ft) = self.env.functions.get(name) { - Type::Function( - ft.params.iter().map(|(_, t)| t.clone()).collect(), - Box::new(ft.return_type.clone()), - ) - } else { - self.errors - .push(TypeError::UndefinedVariable(name.clone(), span.clone())); - Type::Unknown - } - } - - Expr::Binary(left, op, right) => { - let left_ty = self.infer_expr(left, span); - let right_ty = self.infer_expr(right, span); - - match op { - BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod => { - if left_ty.is_numeric() && right_ty.is_numeric() { - if matches!(left_ty, Type::Float) || matches!(right_ty, Type::Float) { - Type::Float - } else { - Type::Int - } - } else if matches!(op, BinOp::Add) - && (left_ty.is_compatible(&Type::Str) - || right_ty.is_compatible(&Type::Str)) - { - Type::Str - } else { - self.errors.push(TypeError::TypeMismatch { - expected: "numeric".to_string(), - got: format!("{} and {}", left_ty.display(), right_ty.display()), - span: span.clone(), - }); - Type::Unknown - } - } - - BinOp::Eq - | BinOp::NotEq - | BinOp::Lt - | BinOp::Gt - | BinOp::LtEq - | BinOp::GtEq => Type::Bool, - - BinOp::And | BinOp::Or => { - if !left_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: left_ty.display(), - span: span.clone(), - }); - } - if !right_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: right_ty.display(), - span: span.clone(), - }); - } - Type::Bool - } - - BinOp::PathJoin => Type::Path, - - BinOp::NullCoalesce => { - if let Type::Optional(inner) = left_ty { - if inner.is_compatible(&right_ty) { - *inner - } else { - right_ty - } - } else { - left_ty - } - } - } - } - - Expr::Unary(op, expr) => { - let ty = self.infer_expr(expr, span); - match op { - UnaryOp::Neg => { - if !ty.is_numeric() { - self.errors.push(TypeError::TypeMismatch { - expected: "numeric".to_string(), - got: ty.display(), - span: span.clone(), - }); - } - ty - } - UnaryOp::Not => { - if !ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: ty.display(), - span: span.clone(), - }); - } - Type::Bool - } - } - } - - Expr::Call(callee, args) => { - // Check for built-in functions first (before inferring callee type) - if let Expr::Ident(name) = callee.as_ref() { - let builtin_ty = self.infer_builtin_call(name, args, span); - if builtin_ty != Type::Unknown { - return builtin_ty; - } - } - - let callee_ty = self.infer_expr(callee, span); - match callee_ty { - Type::Function(params, ret) => { - if params.len() != args.len() { - self.errors.push(TypeError::WrongArity { - expected: params.len(), - got: args.len(), - span: span.clone(), - }); - } - for (arg, param_ty) in args.iter().zip(params.iter()) { - let arg_ty = self.infer_expr(arg, span); - if !arg_ty.is_compatible(param_ty) { - self.errors.push(TypeError::TypeMismatch { - expected: param_ty.display(), - got: arg_ty.display(), - span: span.clone(), - }); - } - } - *ret - } - Type::Unknown | Type::Any => Type::Any, - _ => { - self.errors - .push(TypeError::NotCallable(callee_ty.display(), span.clone())); - Type::Unknown - } - } - } - - Expr::MethodCall(obj, method, args) => { - let obj_ty = self.infer_expr(obj, span); - match obj_ty { - Type::Struct(ref st) => { - if let Some(ft) = st.methods.get(method) { - for (arg, (_, param_ty)) in args.iter().zip(ft.params.iter().skip(1)) { - let arg_ty = self.infer_expr(arg, span); - if !arg_ty.is_compatible(param_ty) { - self.errors.push(TypeError::TypeMismatch { - expected: param_ty.display(), - got: arg_ty.display(), - span: span.clone(), - }); - } - } - ft.return_type.clone() - } else { - self.errors.push(TypeError::FieldNotFound { - ty: st.name.clone(), - field: method.clone(), - span: span.clone(), - }); - Type::Unknown - } - } - Type::List(_) => self.infer_list_method(method, args, span), - Type::Str => self.infer_str_method(method, args, span), - _ => Type::Any, - } - } - - Expr::Field(obj, field) => { - let obj_ty = self.infer_expr(obj, span); - match obj_ty { - Type::Struct(st) => { - if let Some(field_ty) = st.fields.get(field) { - field_ty.clone() - } else { - self.errors.push(TypeError::FieldNotFound { - ty: st.name.clone(), - field: field.clone(), - span: span.clone(), - }); - Type::Unknown - } - } - _ => Type::Any, - } - } - - Expr::Index(obj, idx) => { - let obj_ty = self.infer_expr(obj, span); - let idx_ty = self.infer_expr(idx, span); - - match obj_ty { - Type::List(inner) => { - if !idx_ty.is_compatible(&Type::Int) { - self.errors.push(TypeError::TypeMismatch { - expected: "int".to_string(), - got: idx_ty.display(), - span: span.clone(), - }); - } - *inner - } - Type::Str => Type::Str, - _ => Type::Any, - } - } - - Expr::List(items) => { - if items.is_empty() { - Type::List(Box::new(Type::Any)) - } else { - let first_ty = self.infer_expr(&items[0], span); - for item in items.iter().skip(1) { - let item_ty = self.infer_expr(item, span); - if !item_ty.is_compatible(&first_ty) { - self.errors.push(TypeError::TypeMismatch { - expected: first_ty.display(), - got: item_ty.display(), - span: span.clone(), - }); - } - } - Type::List(Box::new(first_ty)) - } - } - - Expr::EnumVariant(enum_name, _variant) => { - if let Some(et) = self.env.enums.get(enum_name) { - Type::Enum(et.clone()) - } else { - self.errors - .push(TypeError::UndefinedType(enum_name.clone(), span.clone())); - Type::Unknown - } - } - - Expr::StructInit(struct_name, fields) => { - if let Some(st) = self.env.structs.get(struct_name).cloned() { - for (field_name, field_expr) in fields { - if let Some(expected_ty) = st.fields.get(field_name) { - let actual_ty = self.infer_expr(field_expr, span); - if !actual_ty.is_compatible(expected_ty) { - self.errors.push(TypeError::TypeMismatch { - expected: expected_ty.display(), - got: actual_ty.display(), - span: span.clone(), - }); - } - } else { - self.errors.push(TypeError::FieldNotFound { - ty: struct_name.clone(), - field: field_name.clone(), - span: span.clone(), - }); - } - } - Type::Struct(st) - } else { - self.errors - .push(TypeError::UndefinedType(struct_name.clone(), span.clone())); - Type::Unknown - } - } - - Expr::If(cond, then_expr, else_expr) => { - let cond_ty = self.infer_expr(cond, span); - if !cond_ty.is_compatible(&Type::Bool) { - self.errors.push(TypeError::TypeMismatch { - expected: "bool".to_string(), - got: cond_ty.display(), - span: span.clone(), - }); - } - let then_ty = self.infer_expr(then_expr, span); - if let Some(else_expr) = else_expr { - let else_ty = self.infer_expr(else_expr, span); - if then_ty.is_compatible(&else_ty) { - then_ty - } else { - Type::Union(vec![then_ty, else_ty]) - } - } else { - Type::Optional(Box::new(then_ty)) - } - } - - Expr::Lambda(params, body) => { - self.env.push_scope(); - let param_types: Vec = params - .iter() - .map(|p| { - let ty = self.resolve_type(&p.ty); - self.env.define(p.name.clone(), ty.clone()); - ty - }) - .collect(); - let return_ty = self.infer_expr(body, span); - self.env.pop_scope(); - Type::Function(param_types, Box::new(return_ty)) - } - - Expr::Await(expr) => { - if !self.in_async_context { - self.errors.push(TypeError::AwaitOutsideAsync(span.clone())); - } - self.infer_expr(expr, span) - } - - Expr::Path(left, right) => { - let left_ty = self.infer_expr(left, span); - self.infer_expr(right, span); - - // If left is already a list (chained glob), result is a list - if matches!(left_ty, Type::List(_)) { - return Type::List(Box::new(Type::Path)); - } - - // Check if either operand has literal wildcards - if Self::expr_has_glob_wildcards(left) || Self::expr_has_glob_wildcards(right) { - Type::List(Box::new(Type::Path)) - } else { - Type::Path - } - } - - Expr::HomePath(_) => Type::Path, - - Expr::Interpolated(parts) => { - for part in parts { - if let InterpolatedPart::Expr(expr) = part { - self.infer_expr(expr, span); - } - } - Type::Str - } - } - } - - #[tracing::instrument(level = "trace", skip_all, fields(name))] - fn infer_builtin_call( - &mut self, - name: &str, - args: &[Expr], - span: &std::ops::Range, - ) -> Type { - match name { - "map" | "filter" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(inner) = list_ty { - if name == "filter" { - return Type::List(inner); - } - return Type::List(Box::new(Type::Any)); - } - } - Type::List(Box::new(Type::Any)) - } - "fold" => Type::Any, - "len" => Type::Int, - "first" | "last" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(inner) = list_ty { - return Type::Optional(inner); - } - } - Type::Optional(Box::new(Type::Any)) - } - "contains" => Type::Bool, - "join" | "upper" | "lower" | "trim" | "replace" | "format" => Type::Str, - "split" => Type::List(Box::new(Type::Str)), - "starts_with" | "ends_with" => Type::Bool, - "read_file" | "read_file_lines" => Type::Str, - "file_exists" | "dir_exists" | "is_symlink" => Type::Bool, - "list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)), - "home_dir" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => { - Type::Path - } - "path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => { - Type::Path - } - "fetch" | "fetch_json" | "fetch_bytes" | "post" | "post_json" => Type::Any, - "download" => Type::Bool, - "exec" | "shell" => Type::Str, - "exec_with_status" => Type::Int, - "which" => Type::Optional(Box::new(Type::Path)), - "to_json" | "to_toml" | "to_yaml" => Type::Str, - "from_json" | "from_toml" | "from_yaml" => Type::Any, - "hash_file" | "hash_str" => Type::Str, - "encrypt_age" | "decrypt_age" => Type::Str, - "env" => Type::Optional(Box::new(Type::Str)), - "unwrap" => { - if !args.is_empty() { - let opt_ty = self.infer_expr(&args[0], span); - if let Type::Optional(inner) = opt_ty { - return *inner; - } - } - Type::Any - } - "unwrap_or" => { - if args.len() >= 2 { - self.infer_expr(&args[1], span) - } else { - Type::Any - } - } - "is_some" | "is_none" => Type::Bool, - "all" | "race" => Type::Any, - "seq" | "batch" => { - if !args.is_empty() { - self.infer_expr(&args[0], span) - } else { - Type::Any - } - } - "flatten" | "concat" | "unique" | "sort" | "reverse" => { - if !args.is_empty() { - self.infer_expr(&args[0], span) - } else { - Type::List(Box::new(Type::Any)) - } - } - "zip" | "enumerate" => Type::List(Box::new(Type::Any)), - "sort_by" => { - if !args.is_empty() { - self.infer_expr(&args[0], span) - } else { - Type::List(Box::new(Type::Any)) - } - } - // Parallel builtins - "par_map" | "par_filter" | "par_flat_map" | "par_sort_by" | "par_batch" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(inner) = list_ty { - if name == "par_filter" { - return Type::List(inner); - } - return Type::List(Box::new(Type::Any)); - } - } - Type::List(Box::new(Type::Any)) - } - "par_any" | "par_all" => Type::Bool, - "par_find" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(inner) = list_ty { - return Type::Optional(inner); - } - } - Type::Optional(Box::new(Type::Any)) - } - "par_partition" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(_) = list_ty { - return Type::List(Box::new(list_ty)); - } - } - Type::List(Box::new(Type::List(Box::new(Type::Any)))) - } - "par_reduce" => Type::Any, - "par_min_by" | "par_max_by" => { - if !args.is_empty() { - let list_ty = self.infer_expr(&args[0], span); - if let Type::List(inner) = list_ty { - return Type::Optional(inner); - } - } - Type::Optional(Box::new(Type::Any)) - } - "par_for_each" => Type::None, - // Debug/print functions return None - "print" | "println" => Type::None, - "dbg" => { - // dbg returns the last argument for chaining - if let Some(last) = args.last() { - self.infer_expr(last, span) - } else { - Type::None - } - } - // Not a builtin - return Unknown so normal lookup continues - _ => Type::Unknown, - } - } - - #[tracing::instrument(level = "trace", skip_all, fields(method))] - fn infer_list_method( - &mut self, - method: &str, - _args: &[Expr], - _span: &std::ops::Range, - ) -> Type { - match method { - "len" => Type::Int, - "first" | "last" => Type::Optional(Box::new(Type::Any)), - "contains" => Type::Bool, - "map" | "filter" | "sort" | "reverse" | "unique" => Type::List(Box::new(Type::Any)), - "par_map" | "par_filter" | "par_flat_map" | "par_sort_by" | "par_batch" => { - Type::List(Box::new(Type::Any)) - } - "par_any" | "par_all" => Type::Bool, - "par_find" | "par_min_by" | "par_max_by" => Type::Optional(Box::new(Type::Any)), - "par_partition" => Type::List(Box::new(Type::List(Box::new(Type::Any)))), - "par_reduce" => Type::Any, - "par_for_each" => Type::None, - "fold" => Type::Any, - "join" => Type::Str, - _ => Type::Any, - } - } - - #[tracing::instrument(level = "trace", skip_all, fields(method))] - fn infer_str_method( - &mut self, - method: &str, - _args: &[Expr], - _span: &std::ops::Range, - ) -> Type { - match method { - "len" => Type::Int, - "upper" | "lower" | "trim" | "replace" => Type::Str, - "split" => Type::List(Box::new(Type::Str)), - "starts_with" | "ends_with" | "contains" => Type::Bool, - _ => Type::Any, - } - } - - /// Checks if an expression is a string literal containing glob wildcards. - fn expr_has_glob_wildcards(expr: &Expr) -> bool { - match expr { - Expr::Literal(Literal::Str(s)) => s.contains('*') || s.contains('?') || s.contains('['), - _ => false, - } - } - - #[tracing::instrument(level = "trace", skip_all)] - fn resolve_type(&self, ty: &TypeAnnotation) -> Type { - match ty { - TypeAnnotation::Simple(name) => match name.as_str() { - "int" => Type::Int, - "float" => Type::Float, - "str" => Type::Str, - "bool" => Type::Bool, - "path" => Type::Path, - "any" => Type::Any, - _ => { - if let Some(st) = self.env.structs.get(name) { - Type::Struct(st.clone()) - } else if let Some(et) = self.env.enums.get(name) { - Type::Enum(et.clone()) - } else { - Type::Unknown - } - } - }, - TypeAnnotation::List(inner) => Type::List(Box::new(self.resolve_type(inner))), - TypeAnnotation::Optional(inner) => Type::Optional(Box::new(self.resolve_type(inner))), - TypeAnnotation::Function(params, ret) => Type::Function( - params.iter().map(|p| self.resolve_type(p)).collect(), - Box::new(self.resolve_type(ret)), - ), - TypeAnnotation::Union(types) => { - Type::Union(types.iter().map(|t| self.resolve_type(t)).collect()) - } - TypeAnnotation::Literal(lit) => match lit { - Literal::Str(_) => Type::Str, - Literal::Int(_) => Type::Int, - Literal::Float(_) => Type::Float, - Literal::Bool(_) => Type::Bool, - Literal::None => Type::None, - }, - } - } -} - -impl Default for TypeChecker { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/doot-lang/src/types.rs b/crates/doot-lang/src/types.rs deleted file mode 100644 index 9ef3cee..0000000 --- a/crates/doot-lang/src/types.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Type system for the doot language. - -use std::collections::HashMap; - -/// Runtime and static types in doot. -#[derive(Clone, Debug, PartialEq)] -pub enum Type { - Int, - Float, - Str, - Bool, - Path, - None, - List(Box), - Optional(Box), - Function(Vec, Box), - Struct(StructType), - Enum(EnumType), - Union(Vec), - Any, - Unknown, -} - -impl Type { - /// Returns true if this is an int or float type. - pub fn is_numeric(&self) -> bool { - matches!(self, Type::Int | Type::Float) - } - - /// Checks if this type can be used where `other` is expected. - pub fn is_compatible(&self, other: &Type) -> bool { - match (self, other) { - (Type::Any, _) | (_, Type::Any) => true, - (Type::Unknown, _) | (_, Type::Unknown) => true, - (Type::Int, Type::Int) => true, - (Type::Float, Type::Float) => true, - (Type::Int, Type::Float) | (Type::Float, Type::Int) => true, - (Type::Str, Type::Str) => true, - (Type::Str, Type::Path) | (Type::Path, Type::Str) => true, - (Type::Path, Type::Path) => true, - (Type::Bool, Type::Bool) => true, - (Type::None, Type::None) => true, - (Type::None, Type::Optional(_)) | (Type::Optional(_), Type::None) => true, - (Type::List(a), Type::List(b)) => a.is_compatible(b), - (Type::Optional(a), Type::Optional(b)) => a.is_compatible(b), - (Type::Optional(a), b) => a.is_compatible(b), - (a, Type::Optional(b)) => a.is_compatible(b), - (Type::Function(a_params, a_ret), Type::Function(b_params, b_ret)) => { - a_params.len() == b_params.len() - && a_params - .iter() - .zip(b_params.iter()) - .all(|(a, b)| a.is_compatible(b)) - && a_ret.is_compatible(b_ret) - } - (Type::Struct(a), Type::Struct(b)) => a.name == b.name, - (Type::Enum(a), Type::Enum(b)) => a.name == b.name, - (Type::Union(types), other) | (other, Type::Union(types)) => { - types.iter().any(|t| t.is_compatible(other)) - } - _ => false, - } - } - - /// Returns a human-readable representation of this type. - pub fn display(&self) -> String { - match self { - Type::Int => "int".to_string(), - Type::Float => "float".to_string(), - Type::Str => "str".to_string(), - Type::Bool => "bool".to_string(), - Type::Path => "path".to_string(), - Type::None => "none".to_string(), - Type::List(inner) => format!("[{}]", inner.display()), - Type::Optional(inner) => format!("{}?", inner.display()), - Type::Function(params, ret) => { - let params_str = params - .iter() - .map(|p| p.display()) - .collect::>() - .join(", "); - format!("fn({}) -> {}", params_str, ret.display()) - } - Type::Struct(s) => s.name.clone(), - Type::Enum(e) => e.name.clone(), - Type::Union(types) => types - .iter() - .map(|t| t.display()) - .collect::>() - .join(" | "), - Type::Any => "any".to_string(), - Type::Unknown => "unknown".to_string(), - } - } -} - -/// Struct type with fields and methods. -#[derive(Clone, Debug, PartialEq)] -pub struct StructType { - pub name: String, - pub fields: HashMap, - pub methods: HashMap, -} - -/// Enum type with named variants. -#[derive(Clone, Debug, PartialEq)] -pub struct EnumType { - pub name: String, - pub variants: HashMap>>, -} - -/// Function signature type. -#[derive(Clone, Debug, PartialEq)] -pub struct FunctionType { - pub params: Vec<(String, Type)>, - pub return_type: Type, - pub is_async: bool, -} - -/// Type environment with scoped bindings. -#[derive(Clone, Debug, Default)] -pub struct TypeEnv { - scopes: Vec>, - pub structs: HashMap, - pub enums: HashMap, - pub functions: HashMap, -} - -impl TypeEnv { - /// Creates a new type environment with built-in types. - pub fn new() -> Self { - let mut env = Self { - scopes: vec![HashMap::new()], - structs: HashMap::new(), - enums: HashMap::new(), - functions: HashMap::new(), - }; - env.register_builtins(); - env - } - - fn register_builtins(&mut self) { - let mut os_variants = HashMap::new(); - os_variants.insert("Linux".to_string(), None); - os_variants.insert("MacOS".to_string(), None); - os_variants.insert("Windows".to_string(), None); - self.enums.insert( - "Os".to_string(), - EnumType { - name: "Os".to_string(), - variants: os_variants, - }, - ); - - self.define("os".to_string(), Type::Enum(self.enums["Os"].clone())); - self.define("distro".to_string(), Type::Str); - self.define("pkg_manager".to_string(), Type::Str); - self.define("hostname".to_string(), Type::Str); - self.define("arch".to_string(), Type::Str); - } - - /// Enters a new scope. - pub fn push_scope(&mut self) { - self.scopes.push(HashMap::new()); - } - - /// Exits the current scope. - pub fn pop_scope(&mut self) { - self.scopes.pop(); - } - - /// Defines a variable in the current scope. - pub fn define(&mut self, name: String, ty: Type) { - if let Some(scope) = self.scopes.last_mut() { - scope.insert(name, ty); - } - } - - /// Looks up a variable by name through all scopes. - pub fn lookup(&self, name: &str) -> Option<&Type> { - for scope in self.scopes.iter().rev() { - if let Some(ty) = scope.get(name) { - return Some(ty); - } - } - None - } - - /// Registers a struct type. - pub fn define_struct(&mut self, name: String, st: StructType) { - self.structs.insert(name, st); - } - - /// Registers an enum type. - pub fn define_enum(&mut self, name: String, et: EnumType) { - self.enums.insert(name, et); - } - - /// Registers a function type. - pub fn define_function(&mut self, name: String, ft: FunctionType) { - self.functions.insert(name, ft); - } -} diff --git a/crates/doot-std/Cargo.toml b/crates/doot-std/Cargo.toml new file mode 100644 index 0000000..374d05d --- /dev/null +++ b/crates/doot-std/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "doot-std" +version.workspace = true +edition.workspace = true + +[dependencies] +doot-lang.workspace = true diff --git a/crates/doot-std/src/lib.rs b/crates/doot-std/src/lib.rs new file mode 100644 index 0000000..39fdfe7 --- /dev/null +++ b/crates/doot-std/src/lib.rs @@ -0,0 +1,153 @@ +//! The doot standard library: general-purpose builtins registered into an +//! [`Engine`]. Lazy lists, folds, and the arithmetic operator classes with +//! their built-in Int/Str instances. Knows nothing about dotfiles. + +use doot_lang::lang::ast::{ClassDecl, Type}; +use doot_lang::lang::engine::{BuiltinScheme, Engine}; +use doot_lang::lang::eval::{Value, as_bool, as_int, empty_list, value_eq}; + +/// Register the standard library into `engine`. +pub fn register(e: &mut Engine) { + let var = Type::Var; + let fun = |a: Type, b: Type| Type::Fun(Box::new(a), Box::new(b)); + let list = |a: Type| Type::List(Box::new(a)); + + // map : (a -> b) -> [a] -> [b] + e.register_builtin( + "map", + BuiltinScheme::poly(2, fun(fun(var(0), var(1)), fun(list(var(0)), list(var(1))))), + 2, + |i, a| { + let f = i.force(&a[0]); + let xs = i.force(&a[1]); + i.map_list(f, xs) + }, + ); + // body (a[1]) is only forced when the condition holds, so + // `optionals false (...)` creates no nodes + e.register_builtin( + "optionals", + BuiltinScheme::poly(1, fun(Type::Bool, fun(list(var(0)), list(var(0))))), + 2, + |i, a| { + if as_bool(i.force(&a[0])) { + i.force(&a[1]) + } else { + empty_list() + } + }, + ); + // both head and tail stay unforced -> supports infinite lists + e.register_builtin( + "cons", + BuiltinScheme::poly(1, fun(var(0), fun(list(var(0)), list(var(0))))), + 2, + |_, a| Value::Cons(a[0].clone(), a[1].clone()), + ); + e.register_builtin( + "head", + BuiltinScheme::poly(1, fun(list(var(0)), var(0))), + 1, + |i, a| match i.force(&a[0]) { + Value::Cons(h, _) => i.force(&h), + _ => panic!("head of empty/non-list"), + }, + ); + e.register_builtin( + "tail", + BuiltinScheme::poly(1, fun(list(var(0)), list(var(0)))), + 1, + |i, a| match i.force(&a[0]) { + Value::Cons(_, t) => i.force(&t), + _ => panic!("tail of empty/non-list"), + }, + ); + e.register_builtin( + "empty", + BuiltinScheme::poly(1, fun(list(var(0)), Type::Bool)), + 1, + |i, a| Value::Bool(matches!(i.force(&a[0]), Value::Nil)), + ); + e.register_builtin( + "take", + BuiltinScheme::poly(1, fun(Type::Int, fun(list(var(0)), list(var(0))))), + 2, + |i, a| { + let n = as_int(i.force(&a[0])); + let xs = i.force(&a[1]); + i.take_list(n, xs) + }, + ); + e.register_builtin( + "elem", + BuiltinScheme::poly(1, fun(var(0), fun(list(var(0)), Type::Bool))), + 2, + |i, a| { + let x = i.force(&a[0]); + let mut cur = i.force(&a[1]); + while let Value::Cons(h, t) = cur { + if value_eq(&x, &i.force(&h)) { + return Value::Bool(true); + } + cur = i.force(&t); + } + Value::Bool(false) + }, + ); + // seq a b: force a to WHNF, then return b + e.register_builtin( + "seq", + BuiltinScheme::poly(2, fun(var(0), fun(var(1), var(1)))), + 2, + |i, a| { + i.force(&a[0]); + i.force(&a[1]) + }, + ); + // strict left fold: forces the accumulator each step (constant space) + e.register_builtin( + "foldl", + BuiltinScheme::poly( + 2, + fun( + fun(var(1), fun(var(0), var(1))), + fun(var(1), fun(list(var(0)), var(1))), + ), + ), + 3, + |i, a| { + let ff = i.force(&a[0]); + let mut acc = i.force(&a[1]); + let mut cur = i.force(&a[2]); + while let Value::Cons(h, t) = cur { + let partial = i.apply(ff.clone(), doot_lang::lang::eval::forced(acc)); + acc = i.apply(partial, h); // a WHNF value -> no thunk chain + cur = i.force(&t); + } + acc + }, + ); + e.register_value("nil", BuiltinScheme::poly(1, list(var(0))), Value::Nil); + + // arithmetic operator classes (`class C a { m : a -> a -> a; }`) with built-in + // Int instances; `/` (Div) also has a Str instance for path join. + let a = || Type::Struct("a".to_string()); + let binop = || fun(a(), fun(a(), a())); + for (name, method) in [ + ("Add", "add"), + ("Sub", "sub"), + ("Mul", "mul"), + ("Div", "div"), + ("Mod", "mod"), + ("Pow", "pow"), + ] { + e.register_class(ClassDecl { + name: name.to_string(), + param: "a".to_string(), + methods: vec![(method.to_string(), binop())], + span: doot_lang::lang::diag::Span::point(0), + }); + e.register_instance(name, "Int"); + } + e.register_instance("Div", "Str"); +}