diff --git a/.gitignore b/.gitignore index 05923927..13c79d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /target .DS_Store + +# Build files when the Zed plugin is installed as a local dev extension +/zed-werk/grammars/werk diff --git a/Cargo.lock b/Cargo.lock index 23098e5a..0b01f206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -56,11 +56,12 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "annotate-snippets" -version = "0.12.10" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15580ece6ea97cbf832d60ba19c021113469480852c6a2a6beb0db28f097bf1f" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", + "memchr", "unicode-width", ] @@ -71,7 +72,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -81,9 +97,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -94,6 +110,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" @@ -116,9 +141,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-channel" @@ -134,9 +159,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -177,9 +202,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -217,9 +242,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -253,15 +278,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block2" @@ -297,9 +316,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cast" @@ -309,9 +328,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.49" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -360,9 +379,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -370,11 +389,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim", @@ -382,9 +401,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.61" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" +checksum = "e3e962dae2b1e5007fe9e3db363ddc43a8bf25546d279f7a8a4401204690e80c" dependencies = [ "clap", "clap_lex", @@ -394,9 +413,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -406,9 +425,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clio" @@ -426,9 +445,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "concurrent-queue" @@ -441,11 +460,12 @@ dependencies = [ [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -467,9 +487,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "criterion" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ "alloca", "anes", @@ -492,9 +512,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools", @@ -539,9 +559,9 @@ checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", @@ -550,20 +570,20 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "libc", "objc2", @@ -586,12 +606,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" @@ -631,9 +645,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "file-id" @@ -646,9 +660,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "foldhash" @@ -676,9 +696,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -691,9 +711,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -701,15 +721,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -718,9 +738,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -737,9 +757,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -748,21 +768,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -772,7 +792,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -784,17 +803,30 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "wasip2", + "wasip3", ] [[package]] name = "git2" -version = "0.20.3" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", "libgit2-sys", "log", @@ -825,6 +857,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -833,9 +874,15 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -856,9 +903,9 @@ checksum = "71a816c97c42258aa5834d07590b718b4c9a598944cd39a52dc25b351185d678" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -880,12 +927,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -893,9 +941,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -906,9 +954,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -920,15 +968,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -940,15 +988,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -959,6 +1007,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -972,9 +1026,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -998,23 +1052,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.10.0", + "bitflags", "inotify-sys", "libc", ] @@ -1071,9 +1125,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1081,20 +1135,37 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kqueue" version = "1.1.1" @@ -1107,11 +1178,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" dependencies = [ - "bitflags 1.3.2", + "bitflags", "libc", ] @@ -1121,17 +1192,23 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.4+1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" dependencies = [ "cc", "libc", @@ -1141,9 +1218,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "libc", @@ -1159,18 +1236,18 @@ checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a" [[package]] name = "linkme" -version = "0.3.35" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" dependencies = [ "linkme-impl", ] [[package]] name = "linkme-impl" -version = "0.3.35" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" dependencies = [ "proc-macro2", "quote", @@ -1179,15 +1256,21 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "literator" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d06dd66c5769aeb40ce5ad0240920146283f50c6fa72e3b62643106af7656fb" [[package]] name = "lock_api" @@ -1231,15 +1314,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1249,11 +1332,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -1265,7 +1348,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "fsevent-sys", "inotify", "kqueue", @@ -1292,9 +1375,12 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] [[package]] name = "nu-ansi-term" @@ -1307,9 +1393,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -1341,9 +1427,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1356,9 +1442,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1374,9 +1460,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "page_size" @@ -1431,21 +1517,15 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -1454,9 +1534,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plotters" @@ -1502,9 +1582,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1515,20 +1595,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1539,11 +1629,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1565,14 +1661,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1582,9 +1678,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1593,9 +1689,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustc-stable-hash" @@ -1605,11 +1701,11 @@ checksum = "781442f29170c5c93b7185ad559492601acdc71d5bb0706f5868094f45cfcd08" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1622,12 +1718,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "same-file" version = "1.0.6" @@ -1643,6 +1733,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1675,31 +1771,31 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "shadow-rs" -version = "1.4.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d18183cef626bce22836103349c7050d73db799be0171386b80947d157ae32" +checksum = "3c798acfc78a69c7b038adde44084d8df875555b091da42c90ae46257cdcc41a" dependencies = [ "const_format", "git2", @@ -1725,18 +1821,19 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1797,7 +1894,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3367599ca3efb8bbf1f7cda51e9dba9c7a8f020ec966737640ae453239e52ea" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "serde", ] @@ -1809,9 +1906,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1831,12 +1928,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1844,14 +1941,15 @@ dependencies = [ [[package]] name = "tests" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ahash", - "anstream", + "anstream 0.6.21", "anyhow", "criterion", "futures", "iai", + "literator", "macro_rules_attribute", "parking_lot", "regex", @@ -1861,27 +1959,29 @@ dependencies = [ "toml_edit", "tracing", "tracing-subscriber", + "werk-eval", "werk-fs", "werk-parser", + "werk-planner", "werk-runner", "werk-util", - "winnow", + "winnow 1.0.2", ] [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1899,9 +1999,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -1909,22 +2009,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -1932,9 +2032,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1950,20 +2050,35 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.9" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "serde_core", @@ -1971,29 +2086,29 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2013,9 +2128,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2034,9 +2149,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -2052,9 +2167,9 @@ dependencies = [ [[package]] name = "tz-rs" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14eff19b8dc1ace5bf7e4d920b2628ae3837f422ff42210cb1567cbf68b5accf" +checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" [[package]] name = "tzdb" @@ -2069,18 +2184,24 @@ dependencies = [ [[package]] name = "tzdb_data" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42302a846dea7ab786f42dc5f519387069045acff793e1178d9368414168fe95" +checksum = "125a0a63c4bd75c73f61863463cb400db4b1aa5039b203b0ee1d628a7e3dabb2" dependencies = [ "tz-rs", ] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -2096,9 +2217,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2154,18 +2275,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2176,9 +2306,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2186,9 +2316,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2199,18 +2329,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -2218,11 +2382,11 @@ dependencies = [ [[package]] name = "werk-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ahash", "annotate-snippets", - "anstream", + "anstream 0.6.21", "anstyle", "anstyle-query", "anyhow", @@ -2246,30 +2410,62 @@ dependencies = [ "toml_edit", "tracing", "tracing-subscriber", + "werk-eval", "werk-fs", "werk-parser", + "werk-planner", "werk-runner", "werk-util", "windows-sys 0.61.2", ] +[[package]] +name = "werk-depfile" +version = "0.2.0" +dependencies = [ + "thiserror", + "winnow 1.0.2", +] + +[[package]] +name = "werk-eval" +version = "0.2.0" +dependencies = [ + "ahash", + "annotate-snippets", + "bitflags", + "futures", + "globset", + "ignore", + "indexmap", + "regex", + "regex-syntax", + "stringleton", + "thiserror", + "tracing", + "werk-fs", + "werk-parser", + "werk-util", + "which", +] + [[package]] name = "werk-fs" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "stringleton", "thiserror", "werk-util", - "winnow", + "winnow 1.0.2", ] [[package]] name = "werk-parser" -version = "0.1.0" +version = "0.2.0" dependencies = [ "annotate-snippets", - "anstream", + "anstream 0.6.21", "indexmap", "regex", "serde", @@ -2278,17 +2474,38 @@ dependencies = [ "thiserror", "unicode-ident", "werk-util", - "winnow", + "winnow 1.0.2", +] + +[[package]] +name = "werk-planner" +version = "0.2.0" +dependencies = [ + "ahash", + "annotate-snippets", + "globset", + "indexmap", + "literator", + "stringleton", + "thiserror", + "tinyvec", + "tracing", + "werk-depfile", + "werk-eval", + "werk-fs", + "werk-parser", + "werk-util", + "which", ] [[package]] name = "werk-runner" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ahash", "annotate-snippets", "anyhow", - "bitflags 2.10.0", + "bitflags", "futures", "globset", "ignore", @@ -2306,34 +2523,38 @@ dependencies = [ "toml_edit", "tracing", "walkdir", + "werk-depfile", + "werk-eval", "werk-fs", "werk-parser", + "werk-planner", "werk-util", "which", - "winnow", + "winnow 1.0.2", ] [[package]] name = "werk-util" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ahash", "annotate-snippets", - "hashbrown", + "event-listener", "indexmap", "parking_lot", + "pin-project-lite", + "rustc-stable-hash", "serde", + "unicode-segmentation", ] [[package]] name = "which" -version = "8.0.0" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -2568,36 +2789,127 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] [[package]] -name = "winsafe" -version = "0.0.19" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2606,9 +2918,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2618,18 +2930,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2638,18 +2950,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2659,9 +2971,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2670,9 +2982,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2681,11 +2993,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 230d34e9..2baf5ba2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,40 +1,55 @@ [workspace] members = [ - "werk-parser", + "tests", "werk-cli", - "werk-runner", + "werk-depfile", + "werk-eval", "werk-fs", - "tests", + "werk-parser", + "werk-planner", + "werk-runner", "werk-util", ] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.0" rust-version = "1.85" edition = "2024" license = "MIT OR Apache-2.0" [workspace.dependencies] ahash = "0.8.11" -werk-runner.path = "werk-runner" -werk-parser.path = "werk-parser" -werk-fs.path = "werk-fs" -werk-util.path = "werk-util" -winnow = "0.7.0" -thiserror = "2.0" -tracing = "0.1.40" +annotate-snippets = "0.12.10" +anstream = "0.6.18" +bitflags = "2.8.0" +event-listener = "5.4.1" +futures = "0.3.31" +globset = "0.4.15" +ignore = "0.4.23" indexmap = { version = "2.6.0", features = ["serde"] } +literator = "0.4.0" parking_lot = "0.12.3" +pin-project-lite = "0.2.17" regex = "1.11.1" regex-syntax = "0.8.5" +rustc-stable-hash = "0.1.0" serde = { version = "1.0.215", features = ["derive"] } smol = "2.0.2" -toml_edit = "0.23.9" -futures = "0.3.31" -annotate-snippets = "0.12.10" -anstream = "0.6.18" stringleton = { version = "0.2.1", features = ["serde"] } +thiserror = "2.0" +tinyvec = { version = "1.11.0", features = ["alloc"] } +toml_edit = "0.23.9" +tracing = "0.1.40" +werk-depfile.path = "werk-depfile" +werk-eval.path = "werk-eval" +werk-fs.path = "werk-fs" +werk-parser.path = "werk-parser" +werk-planner.path = "werk-planner" +werk-runner.path = "werk-runner" +werk-util.path = "werk-util" +which = "8.0.0" +winnow = "1.0.2" [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index b63f6b02..3b4bf94c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ Rust and Cargo installed. point it to the path to the `werk-vscode` directory within this repository. +### Language Support for Zed + +* Clone this repository +* Install the extension from `zed-werk`: + * From within Zed: Run "zed: install dev extension" and + point it to the path to the `zed-werk` directory within this + repository. + ## Features and limitations See [Features and limitations](https://simonask.github.io/werk/features.html). @@ -55,10 +63,10 @@ See [Features and limitations](https://simonask.github.io/werk/features.html). * `werk` will probably never be fastest. 1. User friendliness is always higher priority. 2. Reporting "no changes" quickly is specifically not a goal. Use Ninja if - this is important to you. Typically, `werk` is invoked when the user has - actually made changes. + this is important to you. Typically, `werk` is invoked when the user has + actually made changes. 3. That said, `werk` does try to be reasonably fast, and is implemented in - Rust using best practices. + Rust using best practices. * `werk` will probably never support all use cases. 1. It is designed to support the use cases that are important to me, the author. @@ -77,3 +85,24 @@ See [Examples](./examples). * [ ] IDE integration to run individual tasks. * [ ] WASM host support. + +## LLM transparency statement + +Werk is designed and written by humans, with very limited use of LLMs. + +1. Design and implementation of all major components is done by humans. +2. LLMs have been used at the "auto-complete" level within the main components, + and all LLM output has been carefully reviewed. +3. LLMs have been used to give feedback about and review certain aspects of the + code. Humans have verified and reacted to the feedback. +4. Certain minor components have been developed by LLMs, especially around + terminal output formatting. +5. The Zed extension and tree-sitter parser has been fully authored by Claude + Opus 4.7 based on Werk's native parser, and carefully reviewed before being + published. + +If any piece of this project violates the copyright of any author through the +use of LLMs and their unattributed training data, such violation is +unintentional and will be fixed as soon as possible, either through attribution +or removal of the offending code. Please don't hesitate to report any such +violations. diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 59f40118..efbfc3c9 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -38,6 +38,7 @@ - [Command-line reference](command_line.md) - [Completion](command_line_completion.md) - [Environment variables](env.md) +- [Migrating to v0.2](./migrating_v0_2.md) ----------------------------- diff --git a/book/src/build_config.md b/book/src/build_config.md index eb1fd43f..79b6371c 100644 --- a/book/src/build_config.md +++ b/book/src/build_config.md @@ -13,8 +13,8 @@ arguments take precedence over `default` statements. Reference: ```werk -# Set the output directory, relative to the workspace root. Default is "target". -default out-dir = "output-directory" +# Set the cache directory, relative to the workspace root. +default cache-dir = "output-directory" # Set the default recipe to run when werk is run without arguments. default target = "recipe-name" diff --git a/book/src/build_recipes.md b/book/src/build_recipes.md index 93d85a36..332e7cfb 100644 --- a/book/src/build_recipes.md +++ b/book/src/build_recipes.md @@ -19,10 +19,6 @@ When a target is outdated, it and all of its dependents will be rebuilt. See the [outdatedness](./outdatedness.md) chapter for the detailed rules governing when targets are rebuilt. -Build recipes should always place their output in the output directory. This can -be achieved by using [path interpolation](./language/strings.md#paths) -(`"<...>"`) when passing files as arguments to external commands. - ## Reference This example builds an `.o` object file from a `.c` source file. See diff --git a/book/src/command_line_help.txt b/book/src/command_line_help.txt index f9ec2d44..b7375d66 100644 --- a/book/src/command_line_help.txt +++ b/book/src/command_line_help.txt @@ -29,7 +29,10 @@ Options: Override the workspace directory. Defaults to the directory containing Werkfile --output-dir - Use the output directory instead of the default + Deprecated: Migrate to `--cache-dir` + + --cache-dir + The directory in which to place the `.werk-cache` file -D, --define Override global variable. This takes the form `name=value` @@ -60,20 +63,26 @@ Output options: Shorthand for `--explain --print-commands --print-fresh --no-capture --loud` --color - [default: auto] - Possible values: - auto: Probe the current terminal and environment variables for color support - always: Force color output, even if the command is not running in a terminal - never: Do not use color output + + [default: auto] --output-format - [default: ansi] - Possible values: - ansi: Provide friendly user feedback assuming an ANSI terminal - log: Emit the progress as log statements (assuming `WERK_LOG` is set to a value) - json: Report progress as JSON to stdout. This also disables color output + + [default: ansi] + + --dot + Write the execution plan to a Graphviz file in the DOT format. Combine with `--dry-run` to inspect the plan without executing commands + + --dot-include-all-files + Include input file targets in the DOT output (files whose existence is checked). Warning: This can produce a very large graph --log [] Enable debug logging to stdout. diff --git a/book/src/examples/c/Werkfile b/book/src/examples/c/Werkfile index 45562282..9b663583 100644 --- a/book/src/examples/c/Werkfile +++ b/book/src/examples/c/Werkfile @@ -1,4 +1,5 @@ default target = "build" +default cache-dir = "target" # Path to clang let cc = which "clang" @@ -17,9 +18,9 @@ let cflags = profile | match { } # Build rule for object files -build "%.o" { +build "target/%.o" { from "%.c" - depfile "%.c.d" + depfile "target/%.c.d" let include-path = "src" let flags = [cflags, "-I"] @@ -29,7 +30,7 @@ build "%.o" { } # Build rule for the main executable -build "my-program{EXE_SUFFIX}" { +build "target/my-program{EXE_SUFFIX}" { # Include all .c files in the build from glob "src/**/*.c" | map "{:.c=.o}" @@ -37,12 +38,12 @@ build "my-program{EXE_SUFFIX}" { } task build { - build "my-program{EXE_SUFFIX}" + build "target/my-program{EXE_SUFFIX}" info "Build complete!" } task run { - let executable = "my-program{EXE_SUFFIX}" + let executable = "target/my-program{EXE_SUFFIX}" build executable run "" } diff --git a/book/src/examples/cargo/Werkfile b/book/src/examples/cargo/Werkfile index 34e52b2f..cf96e111 100644 --- a/book/src/examples/cargo/Werkfile +++ b/book/src/examples/cargo/Werkfile @@ -1,5 +1,5 @@ default target = "build" -default out-dir = "target" +default cache-dir = "target" let cargo = which "cargo" @@ -11,13 +11,13 @@ let cargo-profile = profile | match { } # This rule matches the output path of Cargo. -build "{profile}/test-project{EXE_SUFFIX}" { +build "target/{profile}/test-project{EXE_SUFFIX}" { # This file is implicitly generated by Cargo. - depfile "{profile}/test-project.d" + depfile "target/{profile}/test-project.d" run "cargo build -p test-project --profile={cargo-profile}" } task build { - build "{profile}/test-project{EXE_SUFFIX}" + build "target/{profile}/test-project{EXE_SUFFIX}" } diff --git a/book/src/examples/complex/Werkfile b/book/src/examples/complex/Werkfile index 3419d452..7a9bf92f 100644 --- a/book/src/examples/complex/Werkfile +++ b/book/src/examples/complex/Werkfile @@ -1,4 +1,5 @@ default target = "build" +default cache-dir = "target" let cargo = which "cargo" let glslc = which "glslc" @@ -18,12 +19,12 @@ let cargo-wasm-profile = wasm-profile | match { } # Rule to build a WASM target with Cargo. -build "{wasm-tuple}/{wasm-profile}/%.wasm" { +build "target/{wasm-tuple}/{wasm-profile}/%.wasm" { # Cargo uses dashes in package names and underscores in build artifacts, so # use a regex to replace it. let package-name = "{%:s/_/-/}" - depfile "{wasm-tuple}/{wasm-profile}/%.d" + depfile "target/{wasm-tuple}/{wasm-profile}/%.d" run "{cargo} build --target={wasm-tuple} --profile={cargo-wasm-profile} @@ -31,34 +32,34 @@ build "{wasm-tuple}/{wasm-profile}/%.wasm" { } # Rule to build a SPIR-V shader with glslc. -build "%.(frag|vert|comp).spv" { +build "target/%.{frag|vert|comp}.spv" { from "%.{0}" - depfile "%.{0}.d" + depfile "target/%.{0}.d" run "{glslc} -MD -MF -o " } let wasm-targets = ["plugin1", "plugin2"] | map "{wasm-tuple}/{wasm-profile}/{}.wasm" -build "assets.tar.gz" { +build "target/assets.tar.gz" { from [ wasm-targets, glob "assets/**/*.png", - glob "shaders/**/*.\{frag,vert,comp\}" | map "{}.spv" + glob "shaders/**/*.\{frag,vert,comp\}" | map "target/{}.spv" ] run "tar -zcf " } # Rule to build the main program. -build "{profile}/program{EXE_SUFFIX}" { - depfile "{profile}/program.d" +build "target/{profile}/program{EXE_SUFFIX}" { + depfile "target/{profile}/program.d" run "cargo build -p program --profile={cargo-profile}" } # Task to build everything. task build { - build ["{profile}/program{EXE_SUFFIX}", "assets.tar.gz"] + build ["target/{profile}/program{EXE_SUFFIX}", "target/assets.tar.gz"] } # Task that just runs `cargo clean`. This deletes `target/`, so also removes diff --git a/book/src/examples/shaders/Werkfile b/book/src/examples/shaders/Werkfile index 8071bdde..c0746574 100644 --- a/book/src/examples/shaders/Werkfile +++ b/book/src/examples/shaders/Werkfile @@ -1,20 +1,21 @@ default target = "build" +default cache-dir = "target" let glslc = which "glslc" let tar = which "tar" -build "%.(frag|vert|comp).spv" { +build "target/%.(frag|vert|comp).spv" { from "%.{0}" - depfile "%.{0}.d" + depfile "target/%.{0}.d" run "{glslc} -MD -MF -o " } -build "shaders.tar.gz" { +build "target/shaders.tar.gz" { # Note: Using "native" glob syntax. from glob "*.\{frag,vert,comp\}" | map "{}.spv" run "{tar} -zcf " } task build { - build "shaders.tar.gz" + build "target/shaders.tar.gz" } diff --git a/book/src/features.md b/book/src/features.md index 190619e1..7e619d4e 100644 --- a/book/src/features.md +++ b/book/src/features.md @@ -19,10 +19,6 @@ compare file modification timestamps. Metadata is cached between runs to support additional sources of "outdatedness". -- **Separate output directory:** All files produces by `werk` are put in a - separate output directory, which is always what you want. This is hard to - achieve with Make. - - **[Globbing:](./language/operations.md#glob)** Filesystem glob patterns work out of the box, and can be used reliably in dependencies. `werk` caches a hash of the glob result between builds, so file deletion is detected as a change. @@ -63,10 +59,6 @@ supported in the model), consider using a more advanced solution like GNU make, CMake, ninja-build, scons, cargo-script, etc. -- **Separate output directory:** It is not possible to put output files next to - input files, and files in the output directory are assumed to be generated by - `werk`. - - **Multiple recipes matching the same pattern:** As opposed to Make, build recipes are not disambiguated by their inputs. This means that for example it is not possible to have two recipes that match `%.o`, where one takes `%.c` as diff --git a/book/src/first_example.md b/book/src/first_example.md index 7fa60c0a..a3bcf540 100644 --- a/book/src/first_example.md +++ b/book/src/first_example.md @@ -7,6 +7,7 @@ Create a `Werkfile` in a directory with the following contents: ```werk default target = "hello" +default cache-dir = "target" task hello { info "Hello, World!" diff --git a/book/src/introduction.md b/book/src/introduction.md index 8d2288be..9ef0e287 100644 --- a/book/src/introduction.md +++ b/book/src/introduction.md @@ -12,10 +12,10 @@ Cargo or NPM. It can also replace `just` in most cases. The motivating use case is an asset building pipeline for a video game, which must perform a series of expensive steps to produce an asset archive that can be hot-reloaded by a game engine, but it can build anything, including C/C++ -binaries, or integrate with external build systems, like Cargo. +binaries, or integrate with external build systems and compilers, such as Cargo. Werk is [limited and opinionated](./features.md#limitations). It is not suited -for all use cases, and it can not replace more advanced solutions, like CMake or +for all use cases, and it cannot replace more advanced solutions, like CMake or scons. However, it _is_ suited to work together with such systems, and can be used to invoke them in a convenient way. See the [Depfile support](./depfile_support.md) chapter for more details. diff --git a/book/src/language.md b/book/src/language.md index 849e6162..e30daff5 100644 --- a/book/src/language.md +++ b/book/src/language.md @@ -86,8 +86,8 @@ default = Example: ```werk -# Set the output directory for the workspace. -default out-dir = "path/to/output/directory" +# Set the cache directory for the workspace. +default cache-dir = "path/to/output/directory" # Set the recipe to run when `werk` is run without arguments. default target = "build" diff --git a/book/src/language/path_resolution.md b/book/src/language/path_resolution.md index 0cf1cc88..3887c45a 100644 --- a/book/src/language/path_resolution.md +++ b/book/src/language/path_resolution.md @@ -25,10 +25,10 @@ Consider the following Werkfile: # dir\ # foo.txt -default out-dir = "target" +default cache-dir = "target" let input = "foo.txt" -let output = "bar.txt" +let output = "target/bar.txt" let dir = "dir" let input-path = "" # c:\workspace\foo.txt @@ -38,19 +38,9 @@ let output-filename = "{output-path:filename}" # foo.txt let output-path = "" # ERROR: Double path resolution ``` -- `""` resolves to `c:\workspace\foo.txt`, because `foo.txt` exists in - the workspace. -- `""` resolves to `c:\workspace\target\bar.txt`, because `bar.txt` - does not exist in the workspace. -- `""` resolves to `c:\workspace\target\foo.txt`, because it is - explicitly requested. -- `""` resolves to `c:\workspace\bar.txt`, because it is - explicitly requested, even though the file does not exist in the workspace. +- `""` resolves to `c:\workspace\foo.txt`. +- `""` resolves to `c:\workspace\target\bar.txt`. - `""` resolves to `c:\workspace\dir`, even though it is a directory. -- When an `<...>` interpolation would match a file in the workspace, but also - matches a build recipe, `werk` fails with an error describing the ambiguity. - The path can be disambiguated by using `:out-dir` or `:workspace` to - disambiguate path resolution. - Since they contain `<...>` interpolations, `input-path` and `output-path` are marked as "pathy", and those variables cannot be used in further `<...>` interpolations. diff --git a/book/src/language/recipe_commands.md b/book/src/language/recipe_commands.md index 67a19d9b..d68a7f97 100644 --- a/book/src/language/recipe_commands.md +++ b/book/src/language/recipe_commands.md @@ -210,14 +210,10 @@ build "message.txt" { ## `copy` -Copy files to a file or directory path in the output directory. +Copy file(s). The destination path is an [abstract path](../paths.md) that will be resolved -relative to the output directory. This command will never overwrite any file -outside of the output directory. - -The source path may refer to files or directories in the output directory or in -the workspace. +relative to the workspace directory. **Note:** The source-path does _not_ automatically participate in [outdatedness checks](../outdatedness.md) for the recipe. The dependency must be established diff --git a/book/src/language/strings.md b/book/src/language/strings.md index 6978f202..e40f4ccb 100644 --- a/book/src/language/strings.md +++ b/book/src/language/strings.md @@ -104,12 +104,6 @@ they are applied in order. produces the file-without-directory part of the path. - `{...:ext}`: When the stem refers to an [abstract path](../paths.md), produces the file extension (without the `.`) of the path. -- `<...:out-dir>`: Disambiguate [native path resolution](./path_resolution.md) - to produce a path in the output directory. Does nothing in `{...}` - interpolations. -- `<...:workspace>`: Disambiguate [native path resolution](./path_resolution.md) - to produce a path in the workspace directory. Does nothing in `{...}` - interpolations. ## String interpolation example @@ -117,5 +111,5 @@ they are applied in order. let input-files = ["foo.c", "main.c"] info "{input-files ,*:.c=.o}" # Prints "foo.o, main.o" -info "" # Prints "c:\workspace\output\foo.o c:\workspace\output\main.o" +info "" # Prints "c:\workspace\foo.o c:\workspace\main.o" ``` diff --git a/book/src/language/variables.md b/book/src/language/variables.md index dd0a1af2..427519f3 100644 --- a/book/src/language/variables.md +++ b/book/src/language/variables.md @@ -23,21 +23,22 @@ let foo = "b" # valid let bar = foo # == "b" ``` -## Global variables are public +## Config variables are public -Variables defined at the global scope (i.e., outside of any recipe) are public, -and will appear in the output of `werk --list`. They can be overridden by -passing `-Dkey=value` on the command-line. Comments immediately preceding a -global variable will appear in the output as documentation for that variable. +Variables defined with the `config` keyword instead of `let` at the global scope +(i.e., outside of any recipe) are public, and will appear in the output of `werk +--list`. They can be overridden by passing `-Dkey=value` on the command-line. +Comments immediately preceding a global variable will appear in the output as +documentation for that variable. ```werk # Set the build profile. -let profile = "debug" +config profile = "debug" ``` ```sh $ werk --list -Global variables: +Config variables: profile = "debug" # Set the build profile. ``` diff --git a/book/src/migrating_v0_2.md b/book/src/migrating_v0_2.md new file mode 100644 index 00000000..aff7edb4 --- /dev/null +++ b/book/src/migrating_v0_2.md @@ -0,0 +1,33 @@ +# Migrating to v0.2 + +Major behavioral changes in v0.2: + +1. Werk no longer distinguishes between "workspace" and "out-dir" directories. + Instead, all paths are relative to the workspace directory. The motivation + for this change is to make it easier to implement manually initiated + workflows that generate output files that are committed to source control, + such as `bindgen` output and the like. But it means that Werkfiles must now + explicitly put build artifacts in the desired output directory (`/target` for + Rust projects etc.). This change also means that the string interpolation ops + `<...:out-dir>` and `<...:workspace>` no longer work, and will cause errors. + +2. Werk no longer implicitly uses `/target` as the location for the cache + when no other directory is configured in the Werkfile or on the command-line. + +3. All recipes are now fully evaluated before any build commands are executed. + This catches errors earlier, and allows for an internal "planning" step that + can produce more interesting output (like a `.dot` dependency graph), but it + also removes most chances of having recipes interfere with each other's + evaluation. + +`werk` will fatally error if it detects a v0.1-style Werkfile containing a +`default out-dir = ...` directive, or if the `output-dir` option is passed on +the command-line. + +Steps to migrate: + +1. Change all build recipes to place their outputs in the desired output + directory (`/target` for Rust projects etc.). + +2. Change `default out-dir = ...` directives in your Werkfile to `default + cache-dir = ...` to place the `.werk-cache` in the same location. diff --git a/book/src/outdatedness.md b/book/src/outdatedness.md index b7b37f94..1d724c3c 100644 --- a/book/src/outdatedness.md +++ b/book/src/outdatedness.md @@ -5,7 +5,7 @@ tools, enabling many cases that would traditionally require a full rebuild to be selectively rebuilt instead. This is made possible by placing a [`.werk-cache`](./werk_cache.md) file in the -project's [output directory](./workspace.md#output-directory) that tracks +project's [cache directory](./workspace.md#cache-directory) that tracks outdatedness information between builds. `werk` tracks outdatedness in extremely high detail. Any variable or expression diff --git a/book/src/paths.md b/book/src/paths.md index 05509296..598d4037 100644 --- a/book/src/paths.md +++ b/book/src/paths.md @@ -4,9 +4,9 @@ File and directory paths in Werk are not normal paths as understood by the operating system. This is because one of the primary goals of Werk is to work on all platforms, and especially equal treatment of poor, maligned Windows. -Paths in Werk are always relative to the workspace root or the output directory. -Files outside of the workspace cannot be treated as inputs to or outputs of -build recipes. Werk is designed to only write to the output directory. +Paths in Werk are always relative to the workspace root, and always use the +Unix-style `/` separator. Files outside of the workspace cannot be treated as +inputs to or outputs of build recipes. However, invoking external commands often requires passing native OS paths. Using the [special string interpolation @@ -14,19 +14,8 @@ syntax](./language/strings.md#string-interpolation) `""`, the abstract path stored in `var` will be converted to a native absolute path within the workspace. -[Native path resolution](./language/path_resolution.md) may resolve to either an -input file in the workspace or a generated file in the output directory. This -check is based on existence: If the file is found in the workspace, it resolves -to the file inside the workspace. Otherwise, it is assumed that a build recipe -will generate the file in the output directory, and it resolves to an absolute -path inside the output directory, mirroring the directory structure of the -workspace. - -In general, build recipes should take care to not clobber the workspace and only -generate files with paths that coincide with paths in the workspace. - -Logically, the workspace is an "overlay" of the output directory - it always -takes precedence when a file exists, and the output directory is a "fallback". +[Native path resolution](./language/path_resolution.md) converts a +workspace-relative path to a native absolute path. Consider this directory structure: @@ -48,12 +37,9 @@ Path resolution will then work like this: workspace. - `/foo.c` will resolve to `c:\workspace\main.c` because it exists in the workspace. -- `/main.o` will resolve to `c:\workspace\output\main.o` because it does not - exist in the workspace. -- `/foo.o` will resolve to `c:\workspace\output\foo.o` because it does not exist - in the workspace. -- `/other.c` will resolve to `c:\workspace\output\other.c` because it does not - exist in the workspace. +- `/output/main.o` will resolve to `c:\workspace\output\main.o`. +- `/output/foo.o` will resolve to `c:\workspace\output\foo.o`. +- `/output/generated.c` will resolve to `c:\workspace\output\generated.c`. ## Virtual path rules @@ -76,12 +62,12 @@ Path resolution will then work like this: ## Illegal characters -The following characters are illegal in abstract paths paths, and it is a -superset of disallowed paths on Unix-like systems and Windows: +The following characters are illegal in abstract paths, and it is a superset of +disallowed paths on Unix-like systems and Windows: - Shell operators: `<` and `>` and `|` - Quotation marks: `"` and `'` -- Slashes: `/` and `\` +- Backslash: `\` - Special punctuation characters: `:` and `?` and `*` ## Windows rules diff --git a/book/src/werk_cache.md b/book/src/werk_cache.md index 35425259..2fcd37f3 100644 --- a/book/src/werk_cache.md +++ b/book/src/werk_cache.md @@ -1,7 +1,7 @@ # `.werk-cache` -This is a special file created by `werk` in the [output -directory](./workspace.md#output-directory). +This is a special file created by `werk` in the [cache +directory](./workspace.md#cache-directory). It is a TOML document containing metadata used during [outdatedness checks](./outdatedness.md), including [glob](./language/operations.md#glob) @@ -18,5 +18,5 @@ environment, but the hash is not cryptographically secure. It can't be: since the hash must be stable between runs, using a random seed would defeat the purpose. -`.werk-cache` can be safely deleted by the user, but doing so may cause the next +`.werk-cache` can be safely deleted by the user, but doing so will cause the next build to rebuild more than necessary. diff --git a/book/src/workspace.md b/book/src/workspace.md index 4592382b..ae042bd4 100644 --- a/book/src/workspace.md +++ b/book/src/workspace.md @@ -8,14 +8,10 @@ recipe may be references to files within the workspace, or they may be referencing the output of another recipe, which will exist in the output directory. -## Output directory +## Cache directory -The output directory is where files produced by `werk` will be placed. The -default path is `$WORKSPACE/target` (same as Cargo), but this can be overridden -in two ways: +The cache directory is where Werk stores its internal cache. It can be set in +two ways: -* From within the Werkfile: `default out-dir = ".."` -* From the command-like: `werk --output-dir=..` - -If `werk` detects that an output directory is included in the workspace (i.e., -it is not covered by `.gitignore`), it will emit a hard error. +* From within the Werkfile: `default cache-dir = ".."` +* From the command-line: `werk --cache-dir=..` diff --git a/examples-c.dot b/examples-c.dot new file mode 100644 index 00000000..5d86fcc2 --- /dev/null +++ b/examples-c.dot @@ -0,0 +1,23 @@ +digraph { +label="Werk Plan" +rankdir="TB" +node [fontname="Helvetica" labeljust=l nojustify=true] +edge [fontname="Helvetica"] +Task0 [labeljust=l label=<{build|info "Build done for profile \'debug\'"
}> shape=record] +Task1 [labeljust=l label=<{/debug/example|/usr/bin/clang -o /home/simon/code/werk/target/examples/c/d…
}> shape=record] +Task2 [labeljust=l label=<{/debug/foo.o|/usr/bin/clang -c -g -O0 -fdiagnostics-color=always -fcolor…
info "Built /debug/foo.o"
}> shape=record] +Task3 [labeljust=l label=<{/debug/main.o|/usr/bin/clang -c -g -O0 -fdiagnostics-color=always -fcolor…
info "Built /debug/main.o"
}> shape=record] +Task4 [labeljust=l label=<{/main.c|check-exists}> shape=record] +Task5 [labeljust=l label=<{/debug/main.c.d|/usr/bin/clang -MM -MT /home/simon/code/werk/examples/c/mai…
}> shape=record] +Task6 [labeljust=l label=<{/foo.c|check-exists}> shape=record] +Task7 [labeljust=l label=<{/debug/foo.c.d|/usr/bin/clang -MM -MT /home/simon/code/werk/examples/c/foo…
}> shape=record] +Task1 -> Task0 +Task2 -> Task1 +Task3 -> Task1 +Task6 -> Task2 +Task7 -> Task2 +Task4 -> Task3 +Task5 -> Task3 +Task4 -> Task5 +Task6 -> Task7 +} diff --git a/examples-c.svg b/examples-c.svg new file mode 100644 index 00000000..5e515f1c --- /dev/null +++ b/examples-c.svg @@ -0,0 +1,133 @@ + + + + + + + +Werk Plan + + +Task0 + +build + +info "Build done for profile \'debug\'" + + + +Task1 + +/debug/example + +/usr/bin/clang -o /home/simon/code/werk/target/examples/c/d… + + + +Task1->Task0 + + + + + +Task2 + +/debug/foo.o + +/usr/bin/clang -c -g -O0 -fdiagnostics-color=always -fcolor… +info "Built /debug/foo.o" + + + +Task2->Task1 + + + + + +Task3 + +/debug/main.o + +/usr/bin/clang -c -g -O0 -fdiagnostics-color=always -fcolor… +info "Built /debug/main.o" + + + +Task3->Task1 + + + + + +Task4 + +/main.c + +check-exists + + + +Task4->Task3 + + + + + +Task5 + +/debug/main.c.d + +/usr/bin/clang -MM -MT /home/simon/code/werk/examples/c/mai… + + + +Task4->Task5 + + + + + +Task5->Task3 + + + + + +Task6 + +/foo.c + +check-exists + + + +Task6->Task2 + + + + + +Task7 + +/debug/foo.c.d + +/usr/bin/clang -MM -MT /home/simon/code/werk/examples/c/foo… + + + +Task6->Task7 + + + + + +Task7->Task2 + + + + + diff --git a/examples/c/Werkfile b/examples/c/Werkfile index 269d32f9..fcbb32e5 100644 --- a/examples/c/Werkfile +++ b/examples/c/Werkfile @@ -1,4 +1,4 @@ -default out-dir = "../../target/examples/c" +default cache-dir = "../../target/examples/c" default target = "build" # Path to the C compiler. @@ -14,22 +14,23 @@ let cflags = profile | match { } # Build an object file from a C source file. -build "{profile}/%.o" { +build "target/{profile}/%.o" { from "%.c" - depfile "/{profile}/%.c.d" + depfile "target/{profile}/%.c.d" run "{cc} -c {cflags*} -o " + info "Built {out}" } # Build the depfile for an object file. -build "{profile}/%.c.d" { +build "target/{profile}/%.c.d" { from "%.c" run "{cc} -MM -MT -MF " } # Build the executable. -build "{executable}" { +build "target/{executable}" { from glob "*.c" | match { - "%.c" => "/{profile}%.o" + "%.c" => "target/{profile}%.o" } run "{ld} -o " } @@ -47,10 +48,9 @@ task run { } task clean { - let object-files = glob "*.c" | map "{profile}{:.c=.o}" + let object-files = glob "*.c" | map "target/{profile}{:.c=.o}" run { delete object-files delete executable } } - diff --git a/examples/demo/Werkfile b/examples/demo/Werkfile index 03f67ee2..c5bc795c 100644 --- a/examples/demo/Werkfile +++ b/examples/demo/Werkfile @@ -1,12 +1,12 @@ -default out-dir = "../../target/examples/demo" +default cache-dir = "../../target/examples/demo" let cc = which "clang" let ld = cc -build "%.o" { +build "target/%.o" { run "{cc} -c -o -MF -MM -MT " } task build { - build "program{EXE_SUFFIX}" + build "target/program{EXE_SUFFIX}" } diff --git a/examples/include/Werkfile b/examples/include/Werkfile index 731a970f..0650cadf 100644 --- a/examples/include/Werkfile +++ b/examples/include/Werkfile @@ -1,4 +1,4 @@ -default out-dir = "../../target/examples/c" +default cache-dir = "../../target/examples/c" default target = "build" include "config.werk" @@ -7,9 +7,9 @@ include "recipes.werk" let executable = "{profile}/example{EXE_SUFFIX}" # Build the executable. -build "{executable}" { +build "target/{executable}" { from glob "*.c" | match { - "%.c" => "/{profile}%.o" + "%.c" => "target/{profile}%.o" } run "{ld} -o " } @@ -27,10 +27,9 @@ task run { } task clean { - let object-files = glob "*.c" | map "{profile}{:.c=.o}" + let object-files = glob "*.c" | map "target/{profile}{:.c=.o}" run { delete object-files delete executable } } - diff --git a/examples/include/recipes.werk b/examples/include/recipes.werk index 15c3ae82..95242408 100644 --- a/examples/include/recipes.werk +++ b/examples/include/recipes.werk @@ -5,14 +5,14 @@ let cflags = profile | match { } # Build an object file from a C source file. -build "{profile}/%.o" { +build "target/{profile}/%.o" { from "%.c" - depfile "/{profile}/%.c.d" + depfile "target/{profile}/%.c.d" run "{cc} -c {cflags*} -o " } # Build the depfile for an object file. -build "{profile}/%.c.d" { +build "target/{profile}/%.c.d" { from "%.c" run "{cc} -MM -MT -MF " } diff --git a/examples/issue-41/Werkfile b/examples/issue-41/Werkfile index 804f2146..c3131b5d 100644 --- a/examples/issue-41/Werkfile +++ b/examples/issue-41/Werkfile @@ -1,4 +1,4 @@ -default out-dir = "../../target/examples/issue-41" +default cache-dir = "../../target/examples/issue-41" default target = "build" build "foo" { diff --git a/examples/shaders/Werkfile b/examples/shaders/Werkfile index db27d049..6c4ffaca 100644 --- a/examples/shaders/Werkfile +++ b/examples/shaders/Werkfile @@ -3,16 +3,16 @@ default target = "build" let tar = which "tar" -build "%.(frag|vert|comp).spv" { +build "target/%.(frag|vert|comp).spv" { from "{%}.{0}" run "glslc -o " } -build "assets.tar.gz" { +build "target/assets.tar.gz" { from glob "*.\{frag,vert,comp\}" | map "{}.spv" run "{tar} -zcf " } task build { - build "assets.tar.gz" + build "target/assets.tar.gz" } diff --git a/examples/write/Werkfile b/examples/write/Werkfile index 3641ec3f..9cf767c7 100644 --- a/examples/write/Werkfile +++ b/examples/write/Werkfile @@ -2,7 +2,7 @@ # of any expression can be written to a file. In this case, a list of strings is # written as lines. -default out-dir = "../../target/examples/write" +default cache-dir = "../../target/examples/write" default target = "lines.txt" let list = ["a", "b", "c"] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 00876e43..7bf8ae3a 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -10,6 +10,8 @@ license.workspace = true werk-runner.workspace = true werk-parser.workspace = true werk-fs.workspace = true +werk-eval.workspace = true +werk-planner.workspace = true anyhow = "1" parking_lot.workspace = true ahash.workspace = true @@ -26,6 +28,7 @@ anstream.workspace = true # Hijacking winnow for the Offset trait winnow.workspace = true stringleton.workspace = true +literator.workspace = true [dev-dependencies] criterion = "0.8.1" diff --git a/tests/cases/copy.werk b/tests/cases/copy.werk index a3a55bf8..aa6395ed 100644 --- a/tests/cases/copy.werk +++ b/tests/cases/copy.werk @@ -1,6 +1,6 @@ -default target = "bar" +default target = "target/bar" -build "bar" { +build "target/bar" { from "foo" run { copy "{in}" to "{out}" @@ -8,4 +8,4 @@ build "bar" { } #!file foo=hello -#!assert-file bar=hello +#!assert-file target/bar=hello diff --git a/tests/fail/ambiguous_path_resolution.txt b/tests/fail/ambiguous_path_resolution.txt index 7908b7b2..9bc7e1c9 100644 --- a/tests/fail/ambiguous_path_resolution.txt +++ b/tests/fail/ambiguous_path_resolution.txt @@ -1,12 +1 @@ -error[E0032]: ambiguous path resolution: /bar exists in the workspace, but also matches a build recipe - --> /INPUT:6:10 - | -6 | info "" - | ^^^^^^^ ambiguous path resolution: /bar exists in the workspace, but also matches a build recipe - | - ::: /INPUT:5:7 - | -5 | build "bar" { - | ----- matched this build recipe - | - = help: use `<...:out-dir>` or `<...:workspace>` to disambiguate between paths in the workspace and the output directory +error[R0005]: dependency failed: build: build recipe target is a directory: /bar diff --git a/tests/fail/include_missing.txt b/tests/fail/include_missing.txt index 66ef5bfd..7d1e8737 100644 --- a/tests/fail/include_missing.txt +++ b/tests/fail/include_missing.txt @@ -1,5 +1,5 @@ -error[E0027]: error including '/does-not-exist.werk': file not found +error[E0027]: error including '/does-not-exist.werk': /workspace/does-not-exist.werk: file not found --> /INPUT:1:1 | 1 | include "does-not-exist.werk" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error including '/does-not-exist.werk': file not found + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error including '/does-not-exist.werk': /workspace/does-not-exist.werk: file not found diff --git a/tests/lib.rs b/tests/lib.rs index 4f7c5c44..883240ab 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -1,4 +1,21 @@ +use werk_runner::BuildStatus; +use werk_util::Annotated; + // This file intentionally left blank. pub mod mock_io; stringleton::enable!(werk_runner); + +pub async fn plan_build_and_get_status( + workspace: &werk_runner::Workspace, + goal: &str, +) -> Result { + let mut planner = werk_planner::Planner::new(&workspace.manifest); + let task_id = planner + .add_goal_by_name(goal) + .map_err(Annotated::into_inner)?; + let task_graph = planner.plan(workspace).map_err(Annotated::into_inner)?; + let runner = werk_runner::Runner::new(workspace); + runner.run(task_graph).await.map_err(|e| e.error)?; + runner.get_status(task_id).expect("not built") +} diff --git a/tests/mock_io.rs b/tests/mock_io.rs index afcf940e..eab6a10c 100644 --- a/tests/mock_io.rs +++ b/tests/mock_io.rs @@ -7,15 +7,17 @@ use std::{ time::SystemTime, }; +use literator::Literator as _; use parking_lot::Mutex; +use werk_eval::{DirEntry, EvalError, Io as _, Metadata, ShellCommandLine, TaskName, Warning}; use werk_fs::Absolute; +use werk_planner::PlannerError; use werk_runner::{ - BuildStatus, DirEntry, Env, Error, EvalError, GlobSettings, Io, Metadata, Outdatedness, - ShellCommandLine, TaskId, Warning, WhichError, Workspace, WorkspaceSettings, globset, + BuildStatus, Error, Outdatedness, WhichError, Workspace, WorkspaceSettings, globset, }; use werk_util::{ - Annotated, AsDiagnostic as _, DiagnosticFileId, DiagnosticSource, DiagnosticSourceMap, Offset, - Span, + Annotated, AsDiagnostic as _, DiagnosticFileId, DiagnosticSource, DiagnosticSourceMap, IoError, + Offset, Span, }; use winnow::stream::Offset as _; @@ -45,7 +47,7 @@ pub struct TestBuilder<'a> { /// Note: Path in the mocked filesystem, not the actual filesystem. /// `canonicalize()` won't work etc. pub workspace_dir: Absolute, - pub output_dir: Absolute, + pub cache_dir: Absolute, pub defines: Vec<(String, String)>, pub default_filesystem: bool, pub create_workspace_dir: bool, @@ -71,7 +73,7 @@ impl Default for TestBuilder<'_> { fn default() -> Self { Self { workspace_dir: native_path(&["workspace"]), - output_dir: native_path(&["workspace", "output"]), + cache_dir: native_path(&["workspace", "target"]), defines: Vec::new(), default_filesystem: true, create_workspace_dir: true, @@ -87,8 +89,8 @@ impl<'a> TestBuilder<'a> { self } - pub fn output_dir(&mut self, path: &[&str]) -> &mut Self { - self.output_dir = native_path(path); + pub fn cache_dir(&mut self, path: &[&str]) -> &mut Self { + self.cache_dir = native_path(path); self } @@ -107,7 +109,7 @@ impl<'a> TestBuilder<'a> { io.set_env("PROFILE", "debug"); if self.default_filesystem { create_dirs(&mut io.filesystem.lock(), &self.workspace_dir).unwrap(); - create_dirs(&mut io.filesystem.lock(), &self.output_dir).unwrap(); + create_dirs(&mut io.filesystem.lock(), &self.cache_dir).unwrap(); io.set_program("clang", program_path("clang"), |_cmd, _fs, _env| { Ok(empty_program_output()) @@ -175,7 +177,7 @@ impl<'a> TestBuilder<'a> { io, render, workspace_dir: self.workspace_dir.clone(), - output_dir: self.output_dir.clone(), + cache_dir: self.cache_dir.clone(), source: self.werkfile, pragma_check_files: vec![], defines: self.defines.clone(), @@ -185,14 +187,14 @@ impl<'a> TestBuilder<'a> { } fn workspace_settings( - output_dir: &Absolute, + cache_dir: &Absolute, defines: impl IntoIterator, ) -> werk_runner::WorkspaceSettings { - let mut settings = WorkspaceSettings::new(output_dir.to_owned()); + let mut settings = WorkspaceSettings::new(cache_dir.to_owned()); // Normally this would be covered by `.gitignore`, but we don't have that, // so just use a manual ignore pattern. - let ignore_pattern = output_dir.join("**").unwrap(); + let ignore_pattern = cache_dir.join("**").unwrap(); settings.ignore_explicitly( globset::GlobSet::builder() .add(ignore_pattern.to_string_lossy().parse().unwrap()) @@ -210,7 +212,7 @@ pub struct Test<'a> { pub io: Arc, pub render: Arc, pub workspace_dir: Absolute, - pub output_dir: Absolute, + pub cache_dir: Absolute, pub source: &'a str, pub defines: Vec<(String, String)>, workspace: Option, @@ -237,22 +239,24 @@ impl<'a> Test<'a> { TestBuilder::default().werkfile(source).build() } - pub fn create_workspace( - &mut self, - ) -> Result<&mut Workspace, Annotated> { + pub fn create_workspace<'b>( + &'b mut self, + ) -> Result<&'b mut Workspace, Annotated<'b, werk_runner::Error>> { let ast = match werk_parser::parse_werk(self.source) { Ok(ast) => ast, Err(err) => { - return Err(Error::Eval(EvalError::Parse(werk_parser::ErrorInFile { - file: DiagnosticFileId(0), - error: err, - })) + return Err(Error::Planner(PlannerError::Evaluation(EvalError::Parse( + werk_parser::ErrorInFile { + file: DiagnosticFileId(0), + error: err, + }, + ))) .into_diagnostic_error(&*self as _)); } }; self.reload_test_pragmas(&ast); - let settings = workspace_settings(&self.output_dir, self.defines.iter().cloned()); + let settings = workspace_settings(&self.cache_dir, self.defines.iter().cloned()); let workspace = match werk_runner::Workspace::new( self.io.clone(), self.render.clone(), @@ -274,18 +278,19 @@ impl<'a> Test<'a> { ) { Ok(_) => (), Err(err) => { - return Err(Error::Eval(err).into_diagnostic_error(&workspace.manifest as _)); + return Err(Error::Planner(PlannerError::Evaluation(err)) + .into_diagnostic_error(&workspace.manifest as _)); } } Ok(workspace) } - pub fn reload( - &mut self, + pub fn reload<'w>( + &'w mut self, source: &'a str, defines: &[(&str, &str)], - ) -> Result<&mut Workspace, werk_util::Annotated> { + ) -> Result<&'w mut Workspace, werk_util::Annotated<'w, Error>> { self.source = source; self.defines = defines .iter() @@ -361,13 +366,13 @@ impl<'a> Test<'a> { } } - pub fn run_pragma_tests(&self) -> Result<(), werk_runner::EvalError> { + pub fn run_pragma_tests(&self) -> Result<(), EvalError> { let fs = self.io.filesystem.lock(); for (span, filename, expected) in &self.pragma_check_files { - let out_file = self.output_path(filename.split('/')); + let out_file = self.workspace_path(filename.split('/')); let (_entry, actual) = read_fs(&fs, &out_file).unwrap(); if actual != expected { - return Err(werk_runner::EvalError::AssertCustomFailed( + return Err(EvalError::AssertCustomFailed( DiagnosticFileId(0).span(*span), format!( "contents of output file `{filename}` do not match\nexpected: {expected:?}\n actual: {actual:?}" @@ -399,26 +404,6 @@ impl<'a> Test<'a> { .into_owned() } - pub fn output_path( - &self, - path: impl IntoIterator>, - ) -> Absolute { - let mut output_path = self.output_dir.clone(); - for c in path { - output_path - .push(std::path::Component::Normal(c.as_ref())) - .unwrap(); - } - output_path - } - - pub fn output_path_str(&self, path: impl IntoIterator>) -> String { - self.output_path(path) - .into_inner() - .to_string_lossy() - .into_owned() - } - pub fn set_workspace_file( &self, path: &[&str], @@ -447,7 +432,7 @@ impl<'a> Test<'a> { contents: impl AsRef<[u8]>, ) -> std::io::Result<()> { let mut fs = self.io.filesystem.lock(); - let path = self.output_path(path); + let path = self.workspace_path(path); create_parent_dirs(&mut fs, &path).unwrap(); insert_fs( &mut fs, @@ -503,7 +488,7 @@ impl<'a> Test<'a> { } pub fn did_read_output_file(&self, path: &[&str]) -> bool { - let workspace_file = self.output_path(path); + let workspace_file = self.workspace_path(path); self.io .oplog .lock() @@ -512,7 +497,7 @@ impl<'a> Test<'a> { } pub fn did_write_output_file(&self, path: &[&str]) -> bool { - let path = self.output_path(path); + let path = self.workspace_path(path); self.io .oplog .lock() @@ -572,28 +557,55 @@ pub struct MockRender { #[derive(Debug, PartialEq)] pub enum MockRenderEvent { - WillBuild(TaskId, usize, Outdatedness), - DidBuild(TaskId, Result), - WillExecute(TaskId, ShellCommandLine, usize, usize), + WillBuild(TaskName, usize, Outdatedness), + DidBuild(TaskName, Result), + WillExecute(TaskName, ShellCommandLine, usize, usize), DidExecute( - TaskId, + TaskName, ShellCommandLine, Result, usize, usize, ), - Message(Option, String), - Warning(Option, String), + Message(Option, String), + Warning(Option, String), } impl MockRender { pub fn did_see(&self, event: &MockRenderEvent) -> bool { self.log.lock().iter().any(|e| e == event) } + + #[track_caller] + pub fn assert_did_see(&self, event: &MockRenderEvent) { + assert!( + self.did_see(event), + "event not seen: {event:?}\nevent log:\n{:?}", + self.log.lock().iter().surround_each(" ", '\n') + ); + } +} + +impl werk_eval::Messenger for MockRender { + fn message(&self, task_id: Option, message: &str) { + tracing::trace!( + "info({}) {message}", + task_id.map(|t| t.to_string()).unwrap_or_default() + ); + self.log + .lock() + .push(MockRenderEvent::Message(task_id, message.to_string())); + } + + fn warning(&self, task_id: Option, warning: &Warning) { + self.log + .lock() + .push(MockRenderEvent::Warning(task_id, warning.to_string())); + } } impl werk_runner::Render for MockRender { - fn will_build(&self, task_id: TaskId, num_steps: usize, outdatedness: &Outdatedness) { + fn will_build(&self, task_id: TaskName, num_steps: usize, outdatedness: &Outdatedness) { self.log.lock().push(MockRenderEvent::WillBuild( task_id, num_steps, @@ -601,7 +613,7 @@ impl werk_runner::Render for MockRender { )); } - fn did_build(&self, task_id: TaskId, result: &Result) { + fn did_build(&self, task_id: TaskName, result: &Result) { self.log .lock() .push(MockRenderEvent::DidBuild(task_id, result.clone())); @@ -609,7 +621,7 @@ impl werk_runner::Render for MockRender { fn will_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, step: usize, num_steps: usize, @@ -624,7 +636,7 @@ impl werk_runner::Render for MockRender { fn did_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, result: &Result, step: usize, @@ -638,41 +650,26 @@ impl werk_runner::Render for MockRender { num_steps, )); } - - fn message(&self, task_id: Option, message: &str) { - tracing::trace!( - "info({}) {message}", - task_id.map(|t| t.to_string()).unwrap_or_default() - ); - self.log - .lock() - .push(MockRenderEvent::Message(task_id, message.to_string())); - } - - fn warning(&self, task_id: Option, warning: &Warning) { - self.log - .lock() - .push(MockRenderEvent::Warning(task_id, warning.to_string())); - } } pub type MockDir = HashMap; pub type ProgramResult = std::io::Result; -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum MockDirEntry { File(Metadata, Vec), Dir(MockDir), } -pub type Program = Box ProgramResult + Send>; +pub type Program = + Box ProgramResult + Send>; #[derive(Default)] pub struct MockIo { pub filesystem: Mutex, pub which: Mutex>>, pub programs: Mutex, Program>>, - pub env: Mutex, + pub env: Mutex, pub oplog: Mutex>, pub now: AtomicU64, } @@ -1012,7 +1009,9 @@ impl MockIo { &self, program: impl Into, path: Absolute, - fun: impl FnMut(&ShellCommandLine, &mut MockDir, &Env) -> ProgramResult + Send + 'static, + fun: impl FnMut(&ShellCommandLine, &mut MockDir, &werk_eval::Env) -> ProgramResult + + Send + + 'static, ) -> &Self { let program = program.into(); self.which.lock().insert(program.clone(), path.clone()); @@ -1066,7 +1065,7 @@ struct MockChild { status: Option>>>>, } -impl werk_runner::Child for MockChild { +impl werk_eval::Child for MockChild { fn stdin( self: std::pin::Pin<&mut Self>, ) -> Option> { @@ -1108,14 +1107,14 @@ impl werk_runner::Child for MockChild { } } -impl werk_runner::Io for MockIo { +impl werk_eval::Io for MockIo { fn run_recipe_command( &self, command_line: &ShellCommandLine, _working_dir: &Absolute, - env: &Env, + env: &werk_eval::Env, forward_stdout: bool, - ) -> std::io::Result> { + ) -> Result, IoError> { tracing::trace!("run during build: {}", command_line); self.oplog .lock() @@ -1123,9 +1122,9 @@ impl werk_runner::Io for MockIo { let mut programs = self.programs.lock(); let Some(program) = programs.get_mut(&command_line.program) else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "program not found", + return Err(IoError::new( + &command_line.program, + std::io::Error::new(std::io::ErrorKind::NotFound, "program not found"), )); }; let mut fs = self.filesystem.lock(); @@ -1135,7 +1134,8 @@ impl werk_runner::Io for MockIo { status, stderr, stdout, - } = program(command_line, &mut fs, &global_env)?; + } = program(command_line, &mut fs, &global_env) + .map_err(|e| IoError::new(&command_line.program, e))?; Ok(Box::new(MockChild { stderr: Some(Box::pin(futures::io::Cursor::new(stderr))), @@ -1152,23 +1152,23 @@ impl werk_runner::Io for MockIo { &self, command_line: &ShellCommandLine, _working_dir: &Absolute, - command_env: &werk_runner::Env, - ) -> Result { + command_env: &werk_eval::Env, + ) -> Result { self.oplog .lock() .push(MockIoOp::RunDuringEval(command_line.clone())); let mut programs = self.programs.lock(); let Some(program) = programs.get_mut(&*command_line.program) else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "program not found", + return Err(IoError::new( + &command_line.program, + std::io::Error::new(std::io::ErrorKind::NotFound, "program not found"), )); }; let mut fs = self.filesystem.lock(); let mut env = self.env.lock().clone(); env.merge_from(command_env); - program(command_line, &mut fs, &env) + program(command_line, &mut fs, &env).map_err(|e| IoError::new(&command_line.program, e)) } fn which(&self, command: &str) -> Result, WhichError> { @@ -1183,40 +1183,31 @@ impl werk_runner::Io for MockIo { .ok_or(WhichError::CannotFindBinaryPath) } - fn glob_workspace( + fn walk_directory( &self, path: &Absolute, - settings: &GlobSettings, - ) -> Result, Error> { + settings: werk_eval::GlobSettings, + visit: &(dyn Fn(&Absolute) + Send + Sync), + ) -> Result<(), werk_eval::GlobError> { fn glob( path: &Absolute, dir: &MockDir, - results: &mut Vec, + visit: &(dyn Fn(&Absolute) + Send + Sync), ignore_explicitly: &globset::GlobSet, - ) -> Result<(), Error> { + ) -> Result<(), werk_eval::GlobError> { for (name, entry) in dir { let entry_path = path.join(name).unwrap(); match entry { - MockDirEntry::File(metadata, _) => { + MockDirEntry::File(_, _) => { if !ignore_explicitly.is_match(&*entry_path) { - results.push(DirEntry { - path: entry_path, - metadata: *metadata, - }); + visit(&entry_path) } } MockDirEntry::Dir(subdir) => { if !ignore_explicitly.is_match(&*entry_path) { - results.push(DirEntry { - path: entry_path.clone(), - metadata: Metadata { - mtime: SystemTime::UNIX_EPOCH, - is_file: false, - is_symlink: false, - }, - }); + visit(&entry_path); } - glob(&entry_path, subdir, results, ignore_explicitly)?; + glob(&entry_path, subdir, visit, ignore_explicitly)?; } } } @@ -1225,7 +1216,8 @@ impl werk_runner::Io for MockIo { } tracing::trace!("glob workspace: {}", path.display()); - let fs = self.filesystem.lock(); + // Cloning because the callback may invoke filesystem methods. + let fs = self.filesystem.lock().clone(); let MockDirEntry::Dir(workspace) = get_fs(&fs, path).expect("workspace path does not exist") @@ -1233,38 +1225,32 @@ impl werk_runner::Io for MockIo { panic!("workspace path is a file"); }; - let mut results = Vec::new(); - let result = glob(path, workspace, &mut results, &settings.ignore_explicitly); - - result.map(move |()| results) + glob(path, workspace, visit, &settings.ignore_explicitly)?; + Ok(()) } - fn metadata(&self, path: &Absolute) -> Result { + fn metadata(&self, path: &Absolute) -> Result { let fs = self.filesystem.lock(); read_fs(&fs, path) .map(|(entry, _)| entry.metadata) - .map_err(Into::into) + .map_err(|e| IoError::new(path, e)) } - fn read_file(&self, path: &Absolute) -> Result, std::io::Error> { + fn read_file(&self, path: &Absolute) -> Result, IoError> { self.oplog.lock().push(MockIoOp::ReadFile(path.to_owned())); let fs = self.filesystem.lock(); - let (entry, data) = read_fs(&fs, path)?; + let (entry, data) = read_fs(&fs, path).map_err(|e| IoError::new(path, e))?; if !entry.metadata.is_file { - return Err(std::io::Error::new( - std::io::ErrorKind::IsADirectory, - "is a directory", + return Err(IoError::new( + path, + std::io::Error::new(std::io::ErrorKind::IsADirectory, "is a directory"), )); } let data = Vec::from(data); Ok(data) } - fn write_file( - &self, - path: &Absolute, - data: &[u8], - ) -> Result<(), std::io::Error> { + fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), IoError> { let path = path.to_path_buf(); self.oplog.lock().push(MockIoOp::WriteFile(path.clone())); @@ -1281,42 +1267,43 @@ impl werk_runner::Io for MockIo { Vec::from(data), ), ) + .map_err(|e| IoError::new(path, e)) } fn copy_file( &self, from: &Absolute, to: &Absolute, - ) -> Result<(), std::io::Error> { + ) -> Result<(), IoError> { self.oplog .lock() .push(MockIoOp::CopyFile(from.to_path_buf(), to.to_path_buf())); let mut fs = self.filesystem.lock(); - copy_fs(&mut fs, from, to) + copy_fs(&mut fs, from, to).map_err(|e| IoError::new(from, e)) } - fn delete_file(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn delete_file(&self, path: &Absolute) -> Result<(), IoError> { let path = path.to_path_buf(); self.oplog.lock().push(MockIoOp::DeleteFile(path.clone())); let mut fs = self.filesystem.lock(); - remove_fs(&mut fs, &path) + remove_fs(&mut fs, &path).map_err(|e| IoError::new(path, e)) } - fn touch(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn touch(&self, path: &Absolute) -> Result<(), IoError> { let mut fs = self.filesystem.lock(); let now = self.now(); - touch_fs(&mut fs, path, now) + touch_fs(&mut fs, path, now).map_err(|e| IoError::new(path, e)) } - fn create_parent_dirs(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn create_parent_dirs(&self, path: &Absolute) -> Result<(), IoError> { self.oplog .lock() .push(MockIoOp::CreateParentDirs(path.to_path_buf())); let mut fs = self.filesystem.lock(); - create_parent_dirs(&mut fs, path) + create_parent_dirs(&mut fs, path).map_err(|e| IoError::new(path, e)) } fn read_env(&self, name: &str) -> Option { diff --git a/tests/test_cases.rs b/tests/test_cases.rs index b8f3476f..d0d4ea93 100644 --- a/tests/test_cases.rs +++ b/tests/test_cases.rs @@ -1,4 +1,5 @@ use tests::mock_io::*; +use werk_planner::Planner; use werk_runner::Runner; fn strip_colors(s: &str) -> String { @@ -23,9 +24,16 @@ async fn evaluate_check(file: &std::path::Path) -> Result<(), anyhow::Error> { // Invoke the runner if there is a default target. if let Some(ref default_target) = workspace.default_target { + let mut planner = Planner::new(&workspace.manifest); + planner + .add_goal_by_name(default_target) + .map_err(|err| anyhow::Error::msg(err.to_string()))?; + let task_graph = planner + .plan(workspace) + .map_err(|err| anyhow::Error::msg(err.to_string()))?; let runner = Runner::new(workspace); runner - .build_or_run(default_target) + .run(task_graph) .await .map_err(|err| anyhow::Error::msg(err.to_string()))?; std::mem::drop(runner); @@ -110,7 +118,11 @@ error_case!(ambiguous_build_recipe); error_case!(ambiguous_path_resolution); error_case!(capture_group_out_of_bounds); error_case!(duplicate_config); + +// TODO: This is disabled because there are native paths in the error message. +#[cfg(not(target_family = "windows"))] error_case!(include_missing); + error_case!(include_with_error); error_case!(include_self); error_case!(include_twice); diff --git a/tests/test_eval.rs b/tests/test_eval.rs index 6a022ad6..fcfd17d9 100644 --- a/tests/test_eval.rs +++ b/tests/test_eval.rs @@ -1,6 +1,8 @@ use tests::mock_io::*; +use werk_eval::{EvalError, ShellCommandLine}; use werk_parser::parser::{Input, parse}; -use werk_runner::{Error, EvalError, ShellCommandLine, eval}; +use werk_planner::PlannerError; +use werk_runner::Error; use werk_util::DiagnosticFileId; use winnow::Parser as _; @@ -14,7 +16,7 @@ fn command_argument_splitting() { // Simple literal command. let expr = parse.parse(Input::new(r#""a""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -25,7 +27,7 @@ fn command_argument_splitting() { // Simple interpolation. let expr = parse.parse(Input::new(r#""{foo}""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -36,7 +38,7 @@ fn command_argument_splitting() { // One literal argument let expr = parse.parse(Input::new(r#""a b""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -47,7 +49,7 @@ fn command_argument_splitting() { // Expand list without expansion as the first entry. let expr = parse.parse(Input::new(r#""a {abc}""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -58,7 +60,7 @@ fn command_argument_splitting() { // Expand list with expansion as separate arguments. let expr = parse.parse(Input::new(r#""a {abc*}""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -69,7 +71,7 @@ fn command_argument_splitting() { // ... unless there is a join separator let expr = parse.parse(Input::new(r#""a {abc,*}""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -80,7 +82,7 @@ fn command_argument_splitting() { // ... or the argument is quoted. let expr = parse.parse(Input::new(r#""a \"{abc*}\"""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -91,7 +93,7 @@ fn command_argument_splitting() { // Support single-quoting, for things like `sh -c 'foo bar'`. let expr = parse.parse(Input::new(r#""a -c 'a b'""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -102,7 +104,7 @@ fn command_argument_splitting() { // Argument expansion within single quotes let expr = parse.parse(Input::new(r#""a -c '{abc*}'""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -113,7 +115,7 @@ fn command_argument_splitting() { // Quotes in interpolated variables do not terminate a quoted argument. let expr = parse.parse(Input::new(r#""a \"{q}\"""#)).unwrap(); - let cmd = eval::eval_shell_command(workspace, &expr, file).unwrap(); + let cmd = werk_eval::eval_shell_command(workspace, &expr, file).unwrap(); assert_eq!( cmd.value, ShellCommandLine { @@ -133,7 +135,7 @@ let baz = "" let mut test = Test::new(WERK).unwrap(); match test.create_workspace().map_err(|err| err.error) { Ok(_) => panic!("expected error"), - Err(Error::Eval(EvalError::DoubleResolvePath(_))) => {} + Err(Error::Planner(PlannerError::Evaluation(EvalError::DoubleResolvePath(_)))) => {} Err(err) => panic!("unexpected error: {err}"), } } diff --git a/tests/test_expressions.rs b/tests/test_expressions.rs index 1791a1db..f51968e0 100644 --- a/tests/test_expressions.rs +++ b/tests/test_expressions.rs @@ -1,4 +1,4 @@ -use werk_runner::Value; +use werk_eval::Value; use stringleton::Symbol; use tests::mock_io::*; diff --git a/tests/test_outdatedness.rs b/tests/test_outdatedness.rs index 35964878..4d5ddd55 100644 --- a/tests/test_outdatedness.rs +++ b/tests/test_outdatedness.rs @@ -1,10 +1,11 @@ use macro_rules_attribute::apply; -use tests::mock_io; +use tests::{mock_io, plan_build_and_get_status}; use mock_io::*; use stringleton::Symbol; -use werk_fs::{Absolute, Path}; -use werk_runner::{BuildStatus, Outdatedness, Reason, ShellCommandLine, TaskId}; +use werk_eval::{ShellCommandLine, TaskName}; +use werk_fs::Absolute; +use werk_runner::{BuildStatus, Outdatedness, Reason}; static WERK: &str = r#" config profile = env "PROFILE" @@ -74,24 +75,18 @@ async fn test_outdated_env() -> anyhow::Result<()> { _ = tracing_subscriber::fmt::try_init(); let mut test = Test::new(WERK)?; - let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("env-dep")?) - .await - .map_err(anyhow_msg)?; - - assert_eq!( - status, - BuildStatus::Complete( - TaskId::try_build("/env-dep").unwrap(), - Outdatedness::missing(Absolute::try_from("/env-dep")?) - ) - ); - // Write .werk-cache. - workspace.finalize().await.unwrap(); - std::mem::drop(runner); + { + let workspace = test.create_workspace().map_err(anyhow_msg)?; + assert_eq!( + plan_build_and_get_status(workspace, "env-dep").await, + Ok(BuildStatus::Complete( + TaskName::try_build("/env-dep").unwrap(), + Outdatedness::missing(Absolute::try_from("/env-dep")?) + )) + ); + // Write .werk-cache. + workspace.finalize().await.unwrap(); + } // println!("oplog = {:#?}", &*io.oplog.lock()); assert!(test.did_read_env("PROFILE")); @@ -100,19 +95,19 @@ async fn test_outdated_env() -> anyhow::Result<()> { program: program_path("write"), arguments: vec![ "debug".into(), - test.output_path(["env-dep"]).display().to_string() + test.workspace_path(["env-dep"]).display().to_string() ], })); // println!("oplog = {:#?}", &*io.oplog.lock()); - assert!(test.did_write_output_file(&[".werk-cache"])); + assert!(test.did_write_output_file(&["target", ".werk-cache"])); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path([".werk-cache"]) + &test.workspace_path(["target", ".werk-cache"]) )); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path(["env-dep"]) + &test.workspace_path(["env-dep"]) )); // Change the environment! @@ -121,19 +116,13 @@ async fn test_outdated_env() -> anyhow::Result<()> { // Initialize a new workspace. let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("env-dep")?) - .await - .map_err(anyhow_msg)?; // println!("oplog = {:#?}", &*io.oplog.lock()); assert_eq!( - status, - BuildStatus::Complete( - TaskId::try_build("/env-dep").unwrap(), + plan_build_and_get_status(workspace, "env-dep").await, + Ok(BuildStatus::Complete( + TaskName::try_build("/env-dep").unwrap(), Outdatedness::new([Reason::Env(Symbol::from("PROFILE")),]) - ) + )) ); Ok(()) @@ -145,23 +134,15 @@ async fn test_outdated_which() -> anyhow::Result<()> { let mut test = Test::new(WERK)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("which-dep")?) - .await - .map_err(anyhow_msg)?; - assert_eq!( - status, - BuildStatus::Complete( - TaskId::try_build("/which-dep").unwrap(), + plan_build_and_get_status(workspace, "which-dep").await, + Ok(BuildStatus::Complete( + TaskName::try_build("/which-dep").unwrap(), Outdatedness::missing(Absolute::try_from("/which-dep")?) - ) + )) ); // Write .werk-cache. workspace.finalize().await.unwrap(); - std::mem::drop(runner); // println!("oplog = {:#?}", &*io.oplog.lock()); assert!(test.did_which("clang")); @@ -171,11 +152,11 @@ async fn test_outdated_which() -> anyhow::Result<()> { })); // println!("oplog = {:#?}", &*io.oplog.lock()); - assert!(test.did_write_output_file(&[".werk-cache"])); + assert!(test.did_write_output_file(&["target", ".werk-cache"])); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path([".werk-cache"]) + &test.workspace_path(["target", ".werk-cache"]) )); // Change the environment! @@ -191,13 +172,7 @@ async fn test_outdated_which() -> anyhow::Result<()> { // Initialize a new workspace. let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("which-dep")?) - .await - .map_err(anyhow_msg)?; - std::mem::drop(runner); + let status = plan_build_and_get_status(workspace, "which-dep").await; assert!(test.did_run_during_build(&ShellCommandLine { program: program_path("path/to/clang"), @@ -207,13 +182,13 @@ async fn test_outdated_which() -> anyhow::Result<()> { // println!("oplog = {:#?}", &*io.oplog.lock()); assert_eq!( status, - BuildStatus::Complete( - TaskId::try_build("/which-dep").unwrap(), + Ok(BuildStatus::Complete( + TaskName::try_build("/which-dep").unwrap(), Outdatedness::new([ Reason::missing(Absolute::try_from("/which-dep")?), Reason::Which(Symbol::from("clang")) ]) - ) + )) ); Ok(()) @@ -225,23 +200,15 @@ async fn test_outdated_recipe_changed() -> anyhow::Result<()> { let mut test = Test::new(WERK)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("which-dep")?) - .await - .map_err(anyhow_msg)?; - assert_eq!( - status, - BuildStatus::Complete( - TaskId::try_build("/which-dep").unwrap(), + plan_build_and_get_status(workspace, "which-dep").await, + Ok(BuildStatus::Complete( + TaskName::try_build("/which-dep").unwrap(), Outdatedness::new([Reason::missing(Absolute::try_from("/which-dep")?),]) - ) + )) ); // Write .werk-cache. workspace.finalize().await.unwrap(); - std::mem::drop(runner); // println!("oplog = {:#?}", &*io.oplog.lock()); assert!(test.did_which("clang")); @@ -250,11 +217,11 @@ async fn test_outdated_recipe_changed() -> anyhow::Result<()> { arguments: vec![], })); // println!("oplog = {:#?}", &*io.oplog.lock()); - assert!(test.did_write_output_file(&[".werk-cache"])); + assert!(test.did_write_output_file(&["target", ".werk-cache"])); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path([".werk-cache"]), + &test.workspace_path(["target", ".werk-cache"]), )); // Change the environment! @@ -263,32 +230,26 @@ async fn test_outdated_recipe_changed() -> anyhow::Result<()> { // Initialize a new workspace. test.reload(WERK_RECIPE_CHANGED, &[]).map_err(anyhow_msg)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("which-dep")?) - .await - .map_err(anyhow_msg)?; - std::mem::drop(runner); + let status = plan_build_and_get_status(workspace, "which-dep").await; assert!(test.did_run_during_build(&ShellCommandLine { program: program_path("clang"), arguments: vec![ String::from("-o"), - test.output_path(["which-dep"]).display().to_string() + test.workspace_path(["which-dep"]).display().to_string() ], })); // println!("oplog = {:#?}", &*io.oplog.lock()); assert_eq!( status, - BuildStatus::Complete( - TaskId::try_build("/which-dep").unwrap(), + Ok(BuildStatus::Complete( + TaskName::try_build("/which-dep").unwrap(), Outdatedness::new([ Reason::missing(Absolute::try_from("/which-dep")?), Reason::RecipeChanged ]) - ) + )) ); Ok(()) @@ -303,23 +264,18 @@ async fn test_outdated_glob() -> anyhow::Result<()> { test.set_workspace_file(&["b.c"], "int main() { return 0; }\n") .unwrap(); let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(Path::new("glob-dep")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "glob-dep").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::try_build("/glob-dep").unwrap(), + Ok(BuildStatus::Complete( + TaskName::try_build("/glob-dep").unwrap(), Outdatedness::new([Reason::missing(Absolute::try_from("/glob-dep")?),]) - ) + )) ); // Write .werk-cache. workspace.finalize().await.unwrap(); - std::mem::drop(runner); // println!("oplog = {:#?}", &*io.oplog.lock()); assert!(test.did_which("clang")); @@ -332,9 +288,12 @@ async fn test_outdated_glob() -> anyhow::Result<()> { })); // println!("oplog = {:#?}", &*io.oplog.lock()); - assert!(test.did_write_output_file(&[".werk-cache"])); + assert!(test.did_write_output_file(&["target", ".werk-cache"])); - assert!(test.io.contains_file(test.output_path([".werk-cache"]))); + assert!( + test.io + .contains_file(test.workspace_path(["target", ".werk-cache"])) + ); // Change the environment! test.io.delete_file(test.workspace_path(["b.c"])).unwrap(); @@ -342,13 +301,7 @@ async fn test_outdated_glob() -> anyhow::Result<()> { // Initialize a new workspace. let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("glob-dep")?) - .await - .map_err(anyhow_msg)?; - std::mem::drop(runner); + let status = plan_build_and_get_status(workspace, "glob-dep").await; assert!(test.did_run_during_build(&ShellCommandLine { program: program_path("clang"), @@ -358,13 +311,13 @@ async fn test_outdated_glob() -> anyhow::Result<()> { // println!("oplog = {:#?}", &*io.oplog.lock()); assert_eq!( status, - BuildStatus::Complete( - TaskId::try_build("/glob-dep").unwrap(), + Ok(BuildStatus::Complete( + TaskName::try_build("/glob-dep").unwrap(), Outdatedness::new([ Reason::missing(Absolute::try_from("/glob-dep")?), Reason::Glob(Symbol::from("/*.c")) ]) - ) + )) ); Ok(()) @@ -376,23 +329,17 @@ async fn test_outdated_define() -> anyhow::Result<()> { let mut test = Test::new(WERK)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - - let status = runner - .build_file(Path::new("env-dep")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "env-dep").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::try_build("/env-dep").unwrap(), + Ok(BuildStatus::Complete( + TaskName::try_build("/env-dep").unwrap(), Outdatedness::new([Reason::missing(Absolute::try_from("/env-dep")?),]) - ) + )) ); // Write .werk-cache. workspace.finalize().await.unwrap(); - std::mem::drop(runner); // println!("oplog = {:#?}", &*io.oplog.lock()); assert!(test.did_read_env("PROFILE")); @@ -401,18 +348,18 @@ async fn test_outdated_define() -> anyhow::Result<()> { program: program_path("write"), arguments: vec![ "debug".into(), - test.output_path(["env-dep"]).display().to_string() + test.workspace_path(["env-dep"]).display().to_string() ], })); - assert!(test.did_write_output_file(&[".werk-cache"])); + assert!(test.did_write_output_file(&["target", ".werk-cache"])); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path(&[".werk-cache"]) + &test.workspace_path(&["target", ".werk-cache"]) )); assert!(contains_file( &test.io.filesystem.lock(), - &test.output_path(&["env-dep"]) + &test.workspace_path(&["env-dep"]) )); // Override the `profile` variable manually. @@ -422,21 +369,16 @@ async fn test_outdated_define() -> anyhow::Result<()> { let workspace = test .reload(WERK, &[("profile", "release")]) .map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(Path::new("env-dep")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "env-dep").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::build(Absolute::try_from("/env-dep").unwrap()), + Ok(BuildStatus::Complete( + TaskName::build(Absolute::try_from("/env-dep").unwrap()), Outdatedness::new([Reason::Define(Symbol::from("profile")),]) - ) + )) ); // Write .werk-cache. workspace.finalize().await.unwrap(); - std::mem::drop(runner); // Because the variable was overridden, the expression should not be evaluated. assert!(!test.did_read_env("PROFILE")); @@ -446,17 +388,13 @@ async fn test_outdated_define() -> anyhow::Result<()> { let workspace = test .reload(WERK, &[("profile", "release")]) .map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(Path::new("env-dep")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "env-dep").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::build(Absolute::try_from("/env-dep").unwrap()), + Ok(BuildStatus::Complete( + TaskName::build(Absolute::try_from("/env-dep").unwrap()), Outdatedness::unchanged() - ) + )) ); Ok(()) @@ -468,41 +406,32 @@ async fn test_outdated_global_constant() -> anyhow::Result<()> { let mut test = Test::new(WERK_GLOBAL)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(Path::new("output")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "output").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::build(Absolute::try_from("/output").unwrap()), + Ok(BuildStatus::Complete( + TaskName::build(Absolute::try_from("/output").unwrap()), Outdatedness::new([Reason::Missing(Absolute::symbolicate(Absolute::try_from( "/output" )?)),]) - ) + )) ); workspace.finalize().await?; - std::mem::drop(runner); test.reload(WERK_GLOBAL_CHANGED, &[]).map_err(anyhow_msg)?; let workspace = test.create_workspace().map_err(anyhow_msg)?; - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(Path::new("output")?) - .await - .map_err(anyhow_msg)?; + let status = plan_build_and_get_status(workspace, "output").await; assert_eq!( status, - BuildStatus::Complete( - TaskId::build(Absolute::try_from("/output").unwrap()), + Ok(BuildStatus::Complete( + TaskName::build(Absolute::try_from("/output").unwrap()), Outdatedness::new([ Reason::GlobalChanged(Symbol::from("arg")), Reason::GlobalChanged(Symbol::from("args")) ]) - ) + )) ); Ok(()) @@ -523,11 +452,11 @@ build "binary" { let depfile = format!( "{}: {}", - test.output_path(["binary"]).display(), + test.workspace_path(["binary"]).display(), // File in the output directory should be considered as a dependency, // but the fact that it does not exist in the workspace should not force // the target to become outdated. - test.output_path(["nonexistent_source.rs"]).display() + test.workspace_path(["nonexistent_source.rs"]).display() ); // The binary exists and is up to date. test.set_output_file(&["binary"], "foo").unwrap(); @@ -535,12 +464,8 @@ build "binary" { test.set_output_file(&["depfile.d"], &depfile).unwrap(); let workspace = test.create_workspace().unwrap(); - let runner = werk_runner::Runner::new(workspace); - let status = runner - .build_file(werk_fs::Path::new("binary").unwrap()) - .await - .unwrap(); - match status { + let status = plan_build_and_get_status(workspace, "binary").await; + match status.unwrap() { BuildStatus::Complete(_task_id, outdatedness) => { assert!( outdatedness.is_unchanged(), @@ -566,11 +491,8 @@ build "binary" { let mut test = Test::new(WERK_DEPFILE).unwrap(); let mtime = test.io.tick(); let workspace = test.create_workspace().unwrap(); - let runner = werk_runner::Runner::new(workspace); - runner - .build_file(werk_fs::Path::new("binary").unwrap()) + plan_build_and_get_status(workspace, "binary") .await .unwrap(); - std::mem::drop(runner); - test.did_touch(test.output_path(["binary"]), mtime); + test.did_touch(test.workspace_path(["binary"]), mtime); } diff --git a/tests/test_path_resolution.rs b/tests/test_path_resolution.rs index 1ac624c2..e8d3ba3e 100644 --- a/tests/test_path_resolution.rs +++ b/tests/test_path_resolution.rs @@ -1,8 +1,9 @@ use macro_rules_attribute::apply; use stringleton::sym; -use tests::mock_io::*; +use tests::{mock_io::*, plan_build_and_get_status}; +use werk_eval::{TaskName, Value}; use werk_fs::Absolute; -use werk_runner::{Runner, TaskId, Value}; +use werk_planner::{Planner, PlannerError}; use werk_util::Annotated; stringleton::enable!(tests); @@ -14,12 +15,7 @@ let exists = "foo"; let exists-not = "bar"; let exists-resolved = "" -let exists-explicit-out-dir = "" -let exists-explicit-workspace = "" - let exists-not-resolved = "" -let exists-not-explicit-out-dir = "" -let exists-not-explicit-workspace = "" "#; _ = tracing_subscriber::fmt::try_init(); @@ -34,9 +30,7 @@ let exists-not-explicit-workspace = "" test.set_workspace_file(&["foo"], "foo").unwrap(); let foo_workspace = test.workspace_path_str(["foo"]); - let foo_output = test.output_path_str(["foo"]); - let bar_workspace = test.workspace_path_str(["bar"]); - let bar_output = test.output_path_str(["bar"]); + let bar_output = test.workspace_path_str(["bar"]); let workspace = match test.create_workspace() { Ok(workspace) => workspace, @@ -50,42 +44,17 @@ let exists-not-explicit-workspace = "" globals.get(&sym!("exists-resolved")).unwrap().value, Value::from(foo_workspace.clone()) ); - assert_eq!( - globals.get(&sym!("exists-explicit-out-dir")).unwrap().value, - Value::from(foo_output) - ); - assert_eq!( - globals - .get(&sym!("exists-explicit-workspace")) - .unwrap() - .value, - Value::from(foo_workspace.clone()) - ); assert_eq!( globals.get(&sym!("exists-not-resolved")).unwrap().value, Value::from(bar_output.clone()) ); - assert_eq!( - globals - .get(&sym!("exists-not-explicit-out-dir")) - .unwrap() - .value, - Value::from(bar_output.clone()) - ); - assert_eq!( - globals - .get(&sym!("exists-not-explicit-workspace")) - .unwrap() - .value, - Value::from(bar_workspace) - ); } /// When a build recipe depends on a file with the same name, that is not /// representable, and should cause a circular dependency error. -#[apply(smol_macros::test)] -async fn test_circular_dependency() { +#[test] +fn test_circular_dependency() { static WERK: &str = r#" build "explicit" { from "explicit" @@ -97,77 +66,37 @@ build "explicit" { "#; let mut test = Test::new(WERK).unwrap(); let workspace = test.create_workspace().unwrap(); - let runner = Runner::new(workspace); - match runner.build_or_run("explicit").await { + let mut planner = Planner::new(&workspace.manifest); + planner.add_goal_by_name("explicit").unwrap(); + match planner.plan(workspace).map_err(Annotated::into_inner) { Ok(_) => panic!("expected circular dependency error"), - Err(Annotated { - error: werk_runner::Error::CircularDependency(chain), - .. - }) => { - let id = TaskId::try_build("/explicit").unwrap(); - let chain = chain.into_inner(); - assert_eq!(chain, [id, id]); + Err(PlannerError::CircularDependency(err)) => { + let chain = err.chain; + assert_eq!( + chain, + [String::from("/explicit"), String::from("/explicit")] + ); } Err(err) => panic!("unexpected error: {:?}", err), } } -/// When a directory in the workspace has the same name as the target of a build -/// recipe, werk should not report a circular dependency -#[apply(smol_macros::test)] -async fn test_directory_name_collision() { - static WERK: &str = r#" -# Directories should not participate in the lookup that happens as part -# of build recipe matching. -build "bar" { - info "" -} -build "foo" { - info "" -} - -task build { - build ["foo", "bar"] -} -"#; - - _ = tracing_subscriber::fmt::try_init(); - - let mut test = Test::new(WERK).unwrap(); - test.set_workspace_dir(&["bar"]).unwrap(); - assert!(test.io.contains_dir(test.workspace_path(["bar"]))); - let workspace = test.create_workspace().unwrap(); - let runner = Runner::new(workspace); - match runner.build_or_run("build").await { - Ok(_) => panic!("expected error"), - Err(Annotated { - error: werk_runner::Error::Eval(werk_runner::EvalError::AmbiguousPathResolution(_, err)), - .. - }) => { - assert_eq!(err.path, Absolute::try_from("/bar").unwrap()); - } - Err(err) => panic!("unexpected error: {err}"), - } -} - #[apply(smol_macros::test)] async fn test_empty_out_dir() { static WERK: &str = r#" build "bar" { - info "" + info "" } "#; _ = tracing_subscriber::fmt::try_init(); let mut test = Test::new(WERK).unwrap(); - let expected_message = test.output_path_str(None::<&std::ffi::OsStr>); + let expected_message = test.workspace_path_str(["bar"]); let workspace = test.create_workspace().unwrap(); - let runner = Runner::new(workspace); - runner.build_or_run("bar").await.unwrap(); - std::mem::drop(runner); - assert!(test.render.did_see(&MockRenderEvent::Message( - Some(TaskId::build(Absolute::try_from("/bar").unwrap())), + plan_build_and_get_status(workspace, "bar").await.unwrap(); + test.render.assert_did_see(&MockRenderEvent::Message( + Some(TaskName::build(Absolute::try_from("/bar").unwrap())), expected_message, - ))); + )); } diff --git a/tests/test_pattern_match.rs b/tests/test_pattern_match.rs index 0b8582ad..c123a119 100644 --- a/tests/test_pattern_match.rs +++ b/tests/test_pattern_match.rs @@ -1,11 +1,12 @@ use tests::mock_io::*; +use werk_eval::{Pattern, PatternMatchData}; use werk_parser::parser::{Input, pattern_expr_inside_quotes}; -use werk_runner::{Pattern, PatternMatchData, Workspace}; +use werk_runner::Workspace; use werk_util::DiagnosticFileId; fn parse_and_compile_pattern(workspace: &Workspace, pattern: &str) -> Pattern { let expr = pattern_expr_inside_quotes(&mut Input::new(pattern)).unwrap(); - werk_runner::eval::eval_pattern(workspace, &expr, DiagnosticFileId(0)) + werk_eval::eval_pattern(workspace, &expr, DiagnosticFileId(0)) .unwrap() .value } diff --git a/werk-cli/Cargo.toml b/werk-cli/Cargo.toml index 7a35695d..05a40bad 100644 --- a/werk-cli/Cargo.toml +++ b/werk-cli/Cargo.toml @@ -14,33 +14,35 @@ path = "main.rs" ahash.workspace = true annotate-snippets.workspace = true anstream.workspace = true +anstyle = "1.0.10" anstyle-query = "1.1.2" anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive", "string"] } +clap_complete = { version = "4.5.44", features = ["unstable-dynamic"] } clio = "0.3.5" +ctrlc = { version = "3.4.5", features = ["termination"] } +futures.workspace = true indexmap.workspace = true +libc = "0.2.169" line-span = "0.1.5" +notify-debouncer-full = "0.6.0" num_cpus = "1.16.0" owo-colors = "4.1.0" parking_lot.workspace = true +serde.workspace = true +serde_json = "1.0.137" shadow-rs = "1.4.0" smol.workspace = true thiserror.workspace = true toml_edit.workspace = true tracing-subscriber = { version = "0.3.18", features = ["std", "env-filter"] } tracing.workspace = true +werk-eval.workspace = true werk-fs.workspace = true werk-parser.workspace = true +werk-planner.workspace = true werk-runner.workspace = true werk-util.workspace = true -anstyle = "1.0.10" -serde.workspace = true -serde_json = "1.0.137" -notify-debouncer-full = "0.6.0" -ctrlc = { version = "3.4.5", features = ["termination"] } -futures.workspace = true -libc = "0.2.169" -clap_complete = { version = "4.5.44", features = ["unstable-dynamic"] } [target.'cfg(windows)'.dependencies] # Needed to get terminal width. diff --git a/werk-cli/complete.rs b/werk-cli/complete.rs index 66fcc9bc..2b945265 100644 --- a/werk-cli/complete.rs +++ b/werk-cli/complete.rs @@ -29,7 +29,7 @@ fn with_werk(f: impl FnOnce(Workspace) -> Result + let source_code = std::fs::read_to_string(&werkfile)?; let ast = werk_parser::parse_werk(&source_code)?; - let config = werk_runner::ir::Defaults::new(&ast, DiagnosticFileId(0))?; + let config = werk_runner::Defaults::new(&ast, DiagnosticFileId(0))?; let io = Arc::new(DryRun::new()); let renderer = Arc::new(NullRender); diff --git a/werk-cli/dry_run.rs b/werk-cli/dry_run.rs index fb2c639b..a2bb9f6e 100644 --- a/werk-cli/dry_run.rs +++ b/werk-cli/dry_run.rs @@ -1,7 +1,8 @@ use std::{future::Future, pin::Pin}; +use werk_eval::{Child, Env, ShellCommandLine}; use werk_fs::Absolute; -use werk_runner::{Child, DirEntry, Env, Error, ShellCommandLine}; +use werk_util::IoError; pub struct DryRun(werk_runner::RealSystem); @@ -60,14 +61,14 @@ impl Child for DryRunChild { } } -impl werk_runner::Io for DryRun { +impl werk_eval::Io for DryRun { fn run_recipe_command( &self, command_line: &ShellCommandLine, _working_dir: &Absolute, _env: &Env, _forward_stdout: bool, - ) -> std::io::Result> { + ) -> Result, IoError> { tracing::info!("[DRY-RUN] Would run: {}", command_line); Ok(Box::new(DryRunChild::default())) } @@ -77,7 +78,7 @@ impl werk_runner::Io for DryRun { command_line: &ShellCommandLine, working_dir: &Absolute, env: &Env, - ) -> Result { + ) -> Result { tracing::warn!( "[DRY-MODE] Running executable, despite dry-run mode: {}", command_line @@ -92,27 +93,24 @@ impl werk_runner::Io for DryRun { self.0.which(command) } - fn glob_workspace( + fn walk_directory( &self, path: &Absolute, - settings: &werk_runner::GlobSettings, - ) -> Result, Error> { - self.0.glob_workspace(path, settings) + settings: werk_eval::GlobSettings, + visit: &(dyn Fn(&Absolute) + Send + Sync), + ) -> Result<(), werk_eval::GlobError> { + self.0.walk_directory(path, settings, visit) } - fn metadata(&self, path: &Absolute) -> Result { + fn metadata(&self, path: &Absolute) -> Result { self.0.metadata(path) } - fn read_file(&self, path: &Absolute) -> Result, std::io::Error> { + fn read_file(&self, path: &Absolute) -> Result, IoError> { self.0.read_file(path) } - fn write_file( - &self, - path: &Absolute, - data: &[u8], - ) -> Result<(), std::io::Error> { + fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), IoError> { tracing::info!( "[DRY-RUN] Would write file '{}' ({} bytes)", path.display(), @@ -125,7 +123,7 @@ impl werk_runner::Io for DryRun { &self, from: &Absolute, to: &Absolute, - ) -> Result<(), std::io::Error> { + ) -> Result<(), IoError> { tracing::info!( "[DRY-RUN] Would copy file '{}' to '{}'", from.display(), @@ -134,17 +132,17 @@ impl werk_runner::Io for DryRun { Ok(()) } - fn delete_file(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn delete_file(&self, path: &Absolute) -> Result<(), IoError> { tracing::info!("[DRY-RUN] Would delete file '{}'", path.display()); Ok(()) } - fn touch(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn touch(&self, path: &Absolute) -> Result<(), IoError> { tracing::info!("[DRY-RUN] Would touch file '{}'", path.display()); Ok(()) } - fn create_parent_dirs(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn create_parent_dirs(&self, path: &Absolute) -> Result<(), IoError> { tracing::info!( "[DRY-RUN] Would create parent directories for '{}'", path.display() diff --git a/werk-cli/main.rs b/werk-cli/main.rs index 84340301..126457b7 100644 --- a/werk-cli/main.rs +++ b/werk-cli/main.rs @@ -2,7 +2,7 @@ mod complete; pub mod dry_run; mod render; -use std::{borrow::Cow, path::Path, sync::Arc}; +use std::{borrow::Cow, io::Write as _, path::Path, sync::Arc}; use ahash::HashSet; use clap::{CommandFactory, Parser}; @@ -11,9 +11,11 @@ use futures::future::{self, Either}; use notify_debouncer_full::notify; use owo_colors::OwoColorize as _; use render::{AutoStream, ColorOutputKind}; +use werk_eval::{Scope, Warning}; use werk_fs::{Absolute, Normalize as _, PathError}; -use werk_runner::{BuildStatus, Runner, Warning, Workspace, WorkspaceSettings}; -use werk_util::{Annotated, AsDiagnostic, DiagnosticFileId, DiagnosticSource, DiagnosticSourceMap}; +use werk_planner::{Planner, TaskGraph, TaskGraphToDotSettings}; +use werk_runner::{Runner, Workspace, WorkspaceSettings}; +use werk_util::{Annotated, AsDiagnostic, DiagnosticFileId, DiagnosticSource}; shadow_rs::shadow!(build); @@ -63,6 +65,16 @@ pub struct OutputArgs { #[clap(long, default_value = "ansi")] pub output_format: OutputChoice, + /// Write the execution plan to a Graphviz file in the DOT format. Combine + /// with `--dry-run` to inspect the plan without executing commands. + #[clap(long)] + pub dot: Option, + + /// Include input file targets in the DOT output (files whose existence is + /// checked). Warning: This can produce a very large graph. + #[clap(long)] + pub dot_include_all_files: bool, + /// Enable debug logging to stdout. /// /// This takes a logging directive like `RUST_LOG`. @@ -109,10 +121,14 @@ pub struct Args { #[clap(long)] pub workspace_dir: Option, - /// Use the output directory instead of the default. + /// Deprecated: Migrate to `--cache-dir`. #[clap(long)] pub output_dir: Option, + /// The directory in which to place the `.werk-cache` file. + #[clap(long)] + pub cache_dir: Option, + /// Override global variable. This takes the form `name=value`. #[clap(long, short = 'D', add = ArgValueCandidates::new(complete::defines))] pub define: Vec, @@ -155,8 +171,12 @@ pub enum Error { Runner, #[error("Invalid workspace directory '{0}': {1}")] WorkspaceDirectory(String, std::io::Error), - #[error("Invalid output directory '{0}': {1}")] - OutputDirectory(String, PathError), + #[error( + "No cache directory specified. Pass `--cache-dir`, or set `config cache-dir=\"...\"` in the Werkfile" + )] + NoCacheDirectory, + #[error("Invalid cache directory '{0}': {1}")] + CacheDirectory(String, PathError), #[error("Werkfile not found in this directory or any parent directory")] NoWerkfile, #[error("Invalid define (must take the form `key=value`): {0}")] @@ -217,21 +237,38 @@ async fn try_main(args: Args) -> Result<(), Error> { let ast = werk_parser::parse_werk(&source_code).map_err(|err| { print_parse_error( err.with_file(main_file_id) - .into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code)), + .into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code)), ) })?; // Read the `default` statements from the AST. - let defaults = werk_runner::ir::Defaults::new(&ast, main_file_id).map_err(|err| { - print_eval_error(err.into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code))) + let defaults = werk_runner::Defaults::new(&ast, main_file_id).map_err(|err| { + print_eval_error(err.into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code))) })?; + if args.output_dir.is_some() || defaults.deprecated_output_directory.is_some() { + anstream::eprintln!( + "{} {}", + "FATAL:".bright_red().bold(), + if args.output_dir.is_some() { + "The `--output-dir` option is deprecated. Migrate to `--cache-dir`." + } else { + "The `out-dir` config option is deprecated. Migrate to `cache-dir`." + } + ); + anstream::eprintln!( + "{} Please consult https://simonask.github.io/werk/migrating_v0_2.html for more information.", + "MIGRATION:".bright_yellow().bold() + ); + std::process::exit(1); + } + let settings = get_workspace_settings(&defaults, &args, &workspace_dir, color_stdout)?; tracing::info!("Project directory: {}", workspace_dir.display()); - tracing::info!("Output directory: {}", settings.output_directory.display()); + tracing::info!("Cache directory: {}", settings.cache_dir.display()); - let io: Arc = if args.dry_run || args.list { + let io: Arc = if args.dry_run || args.list { Arc::new(dry_run::DryRun::new()) } else { Arc::new(werk_runner::RealSystem::new()) @@ -250,12 +287,12 @@ async fn try_main(args: Args) -> Result<(), Error> { &settings, ) .map_err(|err| { - print_error(err.into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code))) + print_error(err.into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code))) })?; let werkfile_abstract_path = workspace.unresolve_path(&werkfile).unwrap(); workspace .add_werkfile_parsed(&werkfile_abstract_path, &source_code, ast) - .map_err(|err| print_eval_error(err.into_diagnostic_error(&workspace)))?; + .map_err(|err| print_eval_error(err.into_diagnostic_error(&workspace.manifest)))?; if args.list { let mut output = AutoStream::new(std::io::stdout(), color_stdout); @@ -293,20 +330,53 @@ async fn try_main(args: Args) -> Result<(), Error> { let ctrlc_recv = ctrlc_receiver.recv(); smol::pin!(ctrlc_recv); - let (runner, result) = run(&workspace, &target).await; - result?; + let task_graph = build_task_graph(&workspace, &[&target]).map_err(print_error)?; + + if let Some(dot) = args.output.dot.as_deref() { + let mut dot_source = String::new(); + task_graph + .to_dot( + &mut dot_source, + &TaskGraphToDotSettings { + all_files: args.output.dot_include_all_files, + }, + ) + .unwrap(); + let mut dot_file = std::fs::File::create(dot)?; + dot_file.write_all(dot_source.as_bytes())?; + } + + let runner = Runner::new(&workspace); + let result = runner.run(task_graph).await; + let write_cache = match result { + Ok(_) => { + let wait = runner.wait_for_long_running_tasks(); + smol::pin!(wait); + true + } + Err(ref err) => { + runner.stop(std::time::Duration::from_secs(1)).await; + err.error.should_still_write_werk_cache() + } + }; + + if write_cache { + if let Err(err) = workspace.finalize().await { + eprintln!("Error writing `.werk-cache`: {err}"); + } + } + let wait = runner.wait_for_long_running_tasks(); smol::pin!(wait); - match future::select(wait, ctrlc_recv).await { - Either::Left(_) => Ok(()), + Either::Left(_) => (), Either::Right(_) => { // Stop children, giving child processes a chance to finish // cleanly (and giving us a chance to read their stdout/stderr). runner.stop(std::time::Duration::from_millis(100)).await; - Ok(()) } } + result.map_err(print_error) } } @@ -331,13 +401,6 @@ async fn autowatch_loop( let watch_manifest = HashSet::from_iter([werkfile.clone()]); let mut watch_set = watch_manifest.clone(); - watch_set.extend(workspace.workspace_files().filter_map(|(_, entry)| { - if entry.metadata.is_file { - Some(entry.path.clone()) - } else { - None - } - })); let workspace_dir = workspace.project_root().to_path_buf(); let mut settings = settings.clone(); @@ -352,17 +415,39 @@ async fn autowatch_loop( watch_set = watch_manifest.clone(); } - // Start the notifier. + // Start the notifier before running the graph. let notifier = make_notifier_for_files(&watch_set, notification_sender.clone(), timeout)?; let notification_recv = notification_receiver.recv(); // Build or rebuild the target! - let runner_and_result = if let Some(target) = target.as_ref() { - Some(run(&workspace, target).await) + let result = if let Some(target) = target.as_ref() { + match build_task_graph(&workspace, &[target]) { + Ok(task_graph) => { + watch_set.extend( + task_graph + .get_input_files() + .map(|p| workspace.resolve_path(p.as_path())), + ); + let runner = Runner::new(&workspace); + let result = runner.run(task_graph).await; + (Some(runner), result) + } + Err(err) => (None, Err(err.inner_into())), + } } else { - None + (None, Ok(())) }; + let write_cache = match result.1 { + Ok(_) => true, + Err(ref err) => err.error.should_still_write_werk_cache(), + }; + if write_cache { + if let Err(err) = workspace.finalize().await { + eprintln!("Error writing `.werk-cache`: {err}") + } + } + if watch_set == watch_manifest { render.runner_message("Watching manifest for changes, press Ctrl-C to stop"); } else { @@ -389,11 +474,11 @@ async fn autowatch_loop( // If there are any long-running tasks, give them a chance to finish. // Note that `run()` automatically does this if the target could not be // built. - if let Some((ref runner, Ok(_))) = runner_and_result { + if let (Some(ref runner), Ok(_)) = result { runner.stop(std::time::Duration::from_millis(100)).await; } - std::mem::drop(runner_and_result); + std::mem::drop(result); // Stop the notifier again immediately. TODO: Consider if it makes sense to reuse it. notifier.stop(); @@ -419,7 +504,7 @@ async fn autowatch_loop( Err(err) => { print_parse_error( err.with_file(main_file_id) - .into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code)), + .into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code)), ); watch_set = watch_manifest.clone(); continue; @@ -427,39 +512,36 @@ async fn autowatch_loop( }; // Reload config. - let defaults = match werk_runner::ir::Defaults::new(&ast, main_file_id) { + let defaults = match werk_runner::Defaults::new(&ast, main_file_id) { Ok(config) => config, Err(err) => { print_eval_error( - err.into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code)), + err.into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code)), ); watch_set = watch_manifest.clone(); continue; } }; - let out_dir = match find_output_directory( + let cache_dir = match find_cache_directory( &workspace_dir, output_directory_from_args, - defaults.output_directory.as_deref(), + defaults.cache_dir.as_deref(), ) { - Ok(out_dir) => out_dir, + Ok(cache_dir) => cache_dir, Err(err) => { - render.runner_message(&format!("Error finding output directory: {err}")); + render.runner_message(&format!("Error finding cache directory: {err}")); watch_set = watch_manifest.clone(); continue; } }; - if out_dir != settings.output_directory { + if cache_dir != settings.cache_dir { render.warning( None, - &Warning::OutputDirectoryChanged( - settings.output_directory.clone(), - out_dir.clone(), - ), + &Warning::CacheDirectoryChanged(settings.cache_dir.clone(), cache_dir.clone()), ); - settings.output_directory = out_dir; + settings.cache_dir = cache_dir; } workspace = @@ -467,7 +549,7 @@ async fn autowatch_loop( Ok(workspace) => workspace, Err(err) => { print_error( - err.into_diagnostic_error(DiagnosticSource::new(&werkfile, &source_code)), + err.into_diagnostic_error(&DiagnosticSource::new(&werkfile, &source_code)), ); // Workspace evaluation may depend on other files, so just keep // the current watchset. @@ -478,7 +560,7 @@ async fn autowatch_loop( match workspace.add_werkfile_parsed(&werkfile_abstract_path, &source_code, ast) { Ok(_) => (), Err(err) => { - print_eval_error(err.into_diagnostic_error(&workspace)); + print_eval_error(err.into_diagnostic_error(&workspace.manifest)); // Workspace evaluation may depend on other files, so just keep // the current watchset. continue; @@ -492,38 +574,20 @@ async fn autowatch_loop( // Update the watchset. watch_set.clear(); watch_set.extend(watch_manifest.iter().cloned()); - watch_set.extend(workspace.workspace_files().filter_map(|(_, entry)| { - if entry.metadata.is_file { - Some(entry.path.clone()) - } else { - None - } - })); } } -async fn run<'w>( +fn build_task_graph<'w>( workspace: &'w Workspace, - target: &str, -) -> (Runner<'w>, Result) { - let runner = Runner::new(workspace); - let result = runner.build_or_run(target).await; - - let write_cache = match result { - Ok(_) => true, - Err(ref err) => { - runner.stop(std::time::Duration::from_secs(1)).await; - err.error.should_still_write_werk_cache() - } - }; - - if write_cache { - if let Err(err) = workspace.finalize().await { - eprintln!("Error writing `.werk-cache`: {err}") - } + targets: &[&str], +) -> Result, Annotated<'w, werk_runner::Error>> { + let mut planner = Planner::new(&workspace.manifest); + for target in targets { + planner + .add_goal_by_name(target) + .map_err(Annotated::inner_into)?; } - - (runner, result.map_err(print_error)) + planner.plan(workspace).map_err(Annotated::inner_into) } fn make_notifier_for_files( @@ -549,7 +613,7 @@ fn make_notifier_for_files( Ok(notifier) } -pub fn print_list(manifest: &werk_runner::ir::Manifest, out: &mut dyn std::io::Write) { +pub fn print_list(manifest: &werk_planner::Manifest, out: &mut dyn std::io::Write) { let configs = manifest .config_variables .iter() @@ -707,20 +771,20 @@ pub fn get_workspace_dir<'a>( } pub fn get_workspace_settings( - defaults: &werk_runner::ir::Defaults, + defaults: &werk_runner::Defaults, args: &Args, workspace_dir: &Absolute, color_stdout: ColorOutputKind, ) -> Result { - let out_dir = find_output_directory( + let cache_dir = find_cache_directory( workspace_dir, args.output_dir.as_deref(), - defaults.output_directory.as_deref(), + defaults.cache_dir.as_deref(), )?; let mut settings = WorkspaceSettings::new(workspace_dir.to_owned()); settings.jobs = args.jobs.or(defaults.jobs).unwrap_or_else(num_cpus::get); - settings.output_directory = out_dir; + settings.cache_dir = cache_dir; for def in &args.define { let Some((key, value)) = def.split_once('=') else { return Err(Error::InvalidDefineArg(def.clone())); @@ -737,7 +801,7 @@ pub fn get_workspace_settings( Ok(settings) } -fn find_output_directory( +fn find_cache_directory( workspace_dir: &Absolute, from_args: Option<&std::path::Path>, from_config: Option<&str>, @@ -745,32 +809,32 @@ fn find_output_directory( if let Some(from_args) = from_args { workspace_dir .join(from_args) - .map_err(|err| Error::OutputDirectory(from_args.display().to_string(), err.into())) + .map_err(|err| Error::CacheDirectory(from_args.display().to_string(), err.into())) } else if let Some(from_config) = from_config { workspace_dir .join(from_config) - .map_err(|err| Error::OutputDirectory(from_config.to_owned(), err.into())) + .map_err(|err| Error::CacheDirectory(from_config.to_owned(), err.into())) } else { - Ok(workspace_dir.join("target").unwrap()) + Err(Error::NoCacheDirectory) } } -fn print_error(err: Annotated) -> Error { +fn print_error(err: Annotated<'_, E>) -> Error { print_diagnostic(err); Error::Runner } -fn print_eval_error(err: Annotated) -> Error { +fn print_eval_error(err: Annotated<'_, E>) -> Error { print_diagnostic(err); Error::Eval } -fn print_parse_error(err: Annotated) -> Error { +fn print_parse_error(err: Annotated<'_, E>) -> Error { print_diagnostic(err); Error::Parse } -fn print_diagnostic(err: Annotated) { +fn print_diagnostic(err: Annotated<'_, E>) { use annotate_snippets::renderer::DEFAULT_TERM_WIDTH; let renderer = annotate_snippets::Renderer::styled().term_width( render::stderr_width() diff --git a/werk-cli/render.rs b/werk-cli/render.rs index a37da527..89bd6954 100644 --- a/werk-cli/render.rs +++ b/werk-cli/render.rs @@ -28,7 +28,7 @@ pub struct OutputSettings { impl OutputSettings { pub fn from_args_and_defaults( args: &crate::Args, - defaults: &werk_runner::ir::Defaults, + defaults: &werk_runner::Defaults, color_stderr: ColorOutputKind, ) -> Self { let verbose = args.output.verbose | defaults.verbose.unwrap_or(false); diff --git a/werk-cli/render/ansi.rs b/werk-cli/render/ansi.rs index 365472d5..7d4e17f0 100644 --- a/werk-cli/render/ansi.rs +++ b/werk-cli/render/ansi.rs @@ -5,7 +5,8 @@ use anstream::stream::IsTerminal; use indexmap::IndexMap; use owo_colors::OwoColorize as _; use parking_lot::Mutex; -use werk_runner::{BuildStatus, Error, Outdatedness, ShellCommandLine, TaskId, Warning}; +use werk_eval::{ShellCommandLine, TaskName, Warning}; +use werk_runner::{BuildStatus, Error, Outdatedness}; use werk_util::{AsDiagnostic as _, DiagnosticSecondarySourceMap}; use std::{io::Write, sync::Arc}; @@ -131,7 +132,7 @@ impl Renderer { } struct RenderState { - current_tasks: IndexMap, + current_tasks: IndexMap, num_tasks: usize, num_completed_tasks: usize, progress: Option, @@ -165,7 +166,7 @@ impl Renderer { } impl Renderer { - pub fn will_build(&mut self, task_id: TaskId, num_steps: usize, outdatedness: &Outdatedness) { + pub fn will_build(&mut self, task_id: TaskName, num_steps: usize, outdatedness: &Outdatedness) { self.state .current_tasks .insert(task_id, TaskStatus::new(num_steps)); @@ -199,7 +200,7 @@ impl Renderer { }); } - fn did_build(&mut self, task_id: TaskId, result: &Result) { + fn did_build(&mut self, task_id: TaskName, result: &Result) { let Some(finished) = self.state.current_tasks.shift_remove(&task_id) else { return; }; @@ -244,7 +245,7 @@ impl Renderer { fn will_execute( &mut self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, step: usize, num_steps: usize, @@ -275,7 +276,7 @@ impl Renderer { fn on_child_process_stderr_line( &mut self, - task_id: TaskId, + task_id: TaskName, _command: &ShellCommandLine, line_without_eol: &[u8], quiet: bool, @@ -300,7 +301,7 @@ impl Renderer { fn on_child_process_stdout_line( &mut self, - _task_id: TaskId, + _task_id: TaskName, _command: &ShellCommandLine, line_without_eol: &[u8], ) { @@ -314,7 +315,7 @@ impl Renderer { fn did_execute( &mut self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, result: &Result, step: usize, @@ -346,12 +347,12 @@ impl Renderer { } } - fn message(&mut self, _task_id: Option, message: &str) { + fn message(&mut self, _task_id: Option, message: &str) { _ = self .render_lines(|out, _status| writeln!(out, "{} {}", "[info]".bright_green(), message)); } - fn warning(&mut self, _task_id: Option, warning: &Warning) { + fn warning(&mut self, _task_id: Option, warning: &Warning) { // TODO: Dedup warnings based on span and ID. // TODO: Get the term width. let renderer = annotate_snippets::Renderer::styled(); @@ -385,20 +386,30 @@ impl Renderer { } } +impl werk_eval::Messenger for TerminalRenderer { + fn message(&self, task_id: Option, message: &str) { + self.inner.lock().message(task_id, message) + } + + fn warning(&self, task_id: Option, warning: &Warning) { + self.inner.lock().warning(task_id, warning) + } +} + impl werk_runner::Render for TerminalRenderer { - fn will_build(&self, task_id: TaskId, num_steps: usize, outdatedness: &Outdatedness) { + fn will_build(&self, task_id: TaskName, num_steps: usize, outdatedness: &Outdatedness) { self.inner .lock() .will_build(task_id, num_steps, outdatedness); } - fn did_build(&self, task_id: TaskId, result: &Result) { + fn did_build(&self, task_id: TaskName, result: &Result) { self.inner.lock().did_build(task_id, result); } fn will_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, step: usize, num_steps: usize, @@ -410,7 +421,7 @@ impl werk_runner::Render for TerminalRenderer { fn did_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, status: &std::io::Result, step: usize, @@ -421,21 +432,13 @@ impl werk_runner::Render for TerminalRenderer { .did_execute(task_id, command, status, step, num_steps); } - fn message(&self, task_id: Option, message: &str) { - self.inner.lock().message(task_id, message) - } - - fn warning(&self, task_id: Option, warning: &Warning) { - self.inner.lock().warning(task_id, warning) - } - fn runner_message(&self, message: &str) { self.inner.lock().runner_message(message); } fn on_child_process_stderr_line( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, line_without_eol: &[u8], quiet: bool, @@ -447,7 +450,7 @@ impl werk_runner::Render for TerminalRenderer { fn on_child_process_stdout_line( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, line_without_eol: &[u8], ) { diff --git a/werk-cli/render/ansi/progress.rs b/werk-cli/render/ansi/progress.rs index 0d4d25c1..3fa0c6b7 100644 --- a/werk-cli/render/ansi/progress.rs +++ b/werk-cli/render/ansi/progress.rs @@ -1,7 +1,7 @@ use std::{fmt::Write as _, io::Write, time::Instant}; use owo_colors::OwoColorize; -use werk_runner::TaskId; +use werk_eval::TaskName; use crate::render::TtyWidth; @@ -52,7 +52,7 @@ impl Progress { pub fn render<'a, W, I>(&mut self, out: &mut W, tasks: I) -> std::io::Result<()> where W: Write, - I: IntoIterator, + I: IntoIterator, { match self.style { ProgressStyle::Spinner => self.render_spinner(out, tasks), @@ -62,7 +62,7 @@ impl Progress { fn render_spinner<'a, W, I>(&mut self, out: &mut W, tasks: I) -> std::io::Result<()> where W: Write, - I: IntoIterator, + I: IntoIterator, { let Some(term_width) = self.term_width.progress_max_width() else { // Don't render anything if we don't have a terminal width (not a TTY). @@ -97,7 +97,7 @@ impl Progress { #[cfg(test)] fn render_spinner_to_string<'a, I>(&mut self, tasks: I) -> std::io::Result where - I: IntoIterator, + I: IntoIterator, { let mut buffer = Vec::new(); let mut stream = crate::render::strip::StripStream::new(&mut buffer); @@ -117,7 +117,7 @@ fn draw_spinner<'a, W, I>( ) -> Result where W: Write, - I: ExactSizeIterator, + I: ExactSizeIterator, { if term_width < 3 { return Ok(0); @@ -195,7 +195,7 @@ mod tests { progress.set_progress(0, 1); let one_task = [( - &TaskId::try_build("/target_name.o").unwrap(), + &TaskName::try_build("/target_name.o").unwrap(), &TaskStatus { progress: 0, num_steps: 1, @@ -254,7 +254,7 @@ mod tests { let two = [ ( - &TaskId::try_build("/target1.o").unwrap(), + &TaskName::try_build("/target1.o").unwrap(), &TaskStatus { progress: 0, num_steps: 1, @@ -262,7 +262,7 @@ mod tests { }, ), ( - &TaskId::try_build("/target2.o").unwrap(), + &TaskName::try_build("/target2.o").unwrap(), &TaskStatus { progress: 0, num_steps: 1, diff --git a/werk-cli/render/json.rs b/werk-cli/render/json.rs index 2abf431e..ab5622ef 100644 --- a/werk-cli/render/json.rs +++ b/werk-cli/render/json.rs @@ -6,10 +6,48 @@ impl JsonWatcher { } } +impl werk_eval::Messenger for JsonWatcher { + fn message(&self, task_id: Option, message: &str) { + #[derive(serde::Serialize)] + #[serde(tag = "type")] + struct Warning<'a> { + task: Option<&'a str>, + message: &'a str, + } + serde_json::to_writer( + std::io::stdout(), + &Warning { + task: task_id.map(|id| id.as_str()), + message, + }, + ) + .unwrap(); + println!(); + } + + fn warning(&self, task_id: Option, message: &werk_eval::Warning) { + #[derive(serde::Serialize)] + #[serde(tag = "type")] + struct Warning<'a> { + task: Option<&'a str>, + message: String, + } + serde_json::to_writer( + std::io::stdout(), + &Warning { + task: task_id.map(|id| id.as_str()), + message: message.to_string(), + }, + ) + .unwrap(); + println!(); + } +} + impl werk_runner::Render for JsonWatcher { fn will_build( &self, - task_id: werk_runner::TaskId, + task_id: werk_eval::TaskName, num_steps: usize, _outdatedness: &werk_runner::Outdatedness, ) { @@ -32,7 +70,7 @@ impl werk_runner::Render for JsonWatcher { fn did_build( &self, - task_id: werk_runner::TaskId, + task_id: werk_eval::TaskName, result: &Result, ) { #[derive(serde::Serialize)] @@ -59,8 +97,8 @@ impl werk_runner::Render for JsonWatcher { fn will_execute( &self, - task_id: werk_runner::TaskId, - command: &werk_runner::ShellCommandLine, + task_id: werk_eval::TaskName, + command: &werk_eval::ShellCommandLine, step: usize, num_steps: usize, ) { @@ -87,8 +125,8 @@ impl werk_runner::Render for JsonWatcher { fn did_execute( &self, - task_id: werk_runner::TaskId, - command: &werk_runner::ShellCommandLine, + task_id: werk_eval::TaskName, + command: &werk_eval::ShellCommandLine, status: &std::io::Result, step: usize, num_steps: usize, @@ -124,40 +162,4 @@ impl werk_runner::Render for JsonWatcher { .unwrap(); println!(); } - - fn message(&self, task_id: Option, message: &str) { - #[derive(serde::Serialize)] - #[serde(tag = "type")] - struct Warning<'a> { - task: Option<&'a str>, - message: &'a str, - } - serde_json::to_writer( - std::io::stdout(), - &Warning { - task: task_id.map(|id| id.as_str()), - message, - }, - ) - .unwrap(); - println!(); - } - - fn warning(&self, task_id: Option, message: &werk_runner::Warning) { - #[derive(serde::Serialize)] - #[serde(tag = "type")] - struct Warning<'a> { - task: Option<&'a str>, - message: String, - } - serde_json::to_writer( - std::io::stdout(), - &Warning { - task: task_id.map(|id| id.as_str()), - message: message.to_string(), - }, - ) - .unwrap(); - println!(); - } } diff --git a/werk-cli/render/log.rs b/werk-cli/render/log.rs index 813e93f8..7d7da6d0 100644 --- a/werk-cli/render/log.rs +++ b/werk-cli/render/log.rs @@ -1,4 +1,4 @@ -use werk_runner::Warning; +use werk_eval::Warning; use super::OutputSettings; @@ -15,10 +15,20 @@ impl LogWatcher { } } +impl werk_eval::Messenger for LogWatcher { + fn message(&self, task_id: Option, message: &str) { + tracing::info!(task_id = ?task_id, "Message: {message}"); + } + + fn warning(&self, task_id: Option, message: &Warning) { + tracing::warn!(task_id = ?task_id, "Warning: {message}"); + } +} + impl werk_runner::Render for LogWatcher { fn will_build( &self, - task_id: werk_runner::TaskId, + task_id: werk_eval::TaskName, num_steps: usize, outdatedness: &werk_runner::Outdatedness, ) { @@ -36,7 +46,7 @@ impl werk_runner::Render for LogWatcher { fn did_build( &self, - task_id: werk_runner::TaskId, + task_id: werk_eval::TaskName, result: &Result, ) { match result { @@ -53,8 +63,8 @@ impl werk_runner::Render for LogWatcher { fn will_execute( &self, - task_id: werk_runner::TaskId, - command: &werk_runner::ShellCommandLine, + task_id: werk_eval::TaskName, + command: &werk_eval::ShellCommandLine, step: usize, _num_steps: usize, ) { @@ -65,8 +75,8 @@ impl werk_runner::Render for LogWatcher { fn did_execute( &self, - task_id: werk_runner::TaskId, - command: &werk_runner::ShellCommandLine, + task_id: werk_eval::TaskName, + command: &werk_eval::ShellCommandLine, status: &std::io::Result, step: usize, _num_steps: usize, @@ -84,12 +94,4 @@ impl werk_runner::Render for LogWatcher { } } } - - fn message(&self, task_id: Option, message: &str) { - tracing::info!(task_id = ?task_id, "Message: {message}"); - } - - fn warning(&self, task_id: Option, message: &Warning) { - tracing::warn!(task_id = ?task_id, "Warning: {message}"); - } } diff --git a/werk-cli/render/null.rs b/werk-cli/render/null.rs index 80f5c630..7b562076 100644 --- a/werk-cli/render/null.rs +++ b/werk-cli/render/null.rs @@ -1,24 +1,26 @@ -use werk_runner::{BuildStatus, Outdatedness, Render, ShellCommandLine, TaskId}; +use werk_eval::{Messenger, TaskName}; +use werk_runner::{BuildStatus, Outdatedness, Render}; pub struct NullRender; +impl Messenger for NullRender { + fn message(&self, _: Option, _: &str) {} + + fn warning(&self, _: Option, _: &werk_eval::Warning) {} +} impl Render for NullRender { - fn will_build(&self, _: TaskId, _: usize, _: &Outdatedness) {} + fn will_build(&self, _: TaskName, _: usize, _: &Outdatedness) {} - fn did_build(&self, _: TaskId, _: &Result) {} + fn did_build(&self, _: TaskName, _: &Result) {} - fn will_execute(&self, _: TaskId, _: &ShellCommandLine, _: usize, _: usize) {} + fn will_execute(&self, _: TaskName, _: &werk_eval::ShellCommandLine, _: usize, _: usize) {} fn did_execute( &self, - _: TaskId, - _: &ShellCommandLine, + _: TaskName, + _: &werk_eval::ShellCommandLine, _: &Result, _: usize, _: usize, ) { } - - fn message(&self, _: Option, _: &str) {} - - fn warning(&self, _: Option, _: &werk_runner::Warning) {} } diff --git a/werk-depfile/Cargo.toml b/werk-depfile/Cargo.toml new file mode 100644 index 00000000..134ef6ed --- /dev/null +++ b/werk-depfile/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "werk-depfile" +version.workspace = true +rust-version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +winnow.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/werk-runner/depfile.rs b/werk-depfile/lib.rs similarity index 99% rename from werk-runner/depfile.rs rename to werk-depfile/lib.rs index 7b3837c3..e7b1db8a 100644 --- a/werk-runner/depfile.rs +++ b/werk-depfile/lib.rs @@ -1,3 +1,5 @@ +//! Depfile parser for Werk + use std::{path::PathBuf, sync::Arc}; use winnow::{ diff --git a/werk-eval/Cargo.toml b/werk-eval/Cargo.toml new file mode 100644 index 00000000..f6321b27 --- /dev/null +++ b/werk-eval/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "werk-eval" +version.workspace = true +rust-version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +werk-parser.workspace = true +werk-util.workspace = true +werk-fs.workspace = true +indexmap.workspace = true +ahash.workspace = true +stringleton.workspace = true +bitflags.workspace = true +regex-syntax.workspace = true +thiserror.workspace = true +globset.workspace = true +annotate-snippets.workspace = true +regex.workspace = true +which.workspace = true +futures.workspace = true +ignore.workspace = true + +# TODO: Remove this dependency (use the diagnostics system instead) +tracing.workspace = true + +[lints] +workspace = true diff --git a/werk-eval/command_line.rs b/werk-eval/command_line.rs new file mode 100644 index 00000000..070f4e90 --- /dev/null +++ b/werk-eval/command_line.rs @@ -0,0 +1,30 @@ +use werk_fs::Absolute; + +#[derive(Clone, PartialEq)] +pub struct ShellCommandLine { + /// The name of the program to run. Should be an absolute path, either from + /// a `which` expression or an `` interpolation when running an + /// executable produced by another recipe. + pub program: Absolute, + pub arguments: Vec, +} + +impl std::fmt::Display for ShellCommandLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.program.display())?; + for arg in &self.arguments { + write!(f, " {}", werk_util::DisplayArg(arg))?; + } + Ok(()) + } +} + +impl std::fmt::Debug for ShellCommandLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.program)?; + for arg in &self.arguments { + write!(f, " {}", werk_util::DisplayArgQuoted(arg))?; + } + Ok(()) + } +} diff --git a/werk-eval/constants.rs b/werk-eval/constants.rs new file mode 100644 index 00000000..69231067 --- /dev/null +++ b/werk-eval/constants.rs @@ -0,0 +1,145 @@ +use ahash::HashMap; +use stringleton::{Symbol, sym}; + +use crate::Value; + +pub fn default_global_constants() -> &'static HashMap { + static GLOBAL_CONSTANTS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + GLOBAL_CONSTANTS.get_or_init(|| { + let mut map = HashMap::default(); + map.extend([ + (sym!(EMPTY), Value::from(String::new())), + (sym!(EXE_SUFFIX), Value::from(exe_suffix().to_owned())), + (sym!(DYLIB_PREFIX), Value::from(dylib_prefix().to_owned())), + (sym!(DYLIB_SUFFIX), Value::from(dylib_suffix().to_owned())), + ( + sym!(STATICLIB_PREFIX), + Value::from(staticlib_prefix().to_owned()), + ), + ( + sym!(STATICLIB_SUFFIX), + Value::from(staticlib_suffix().to_owned()), + ), + (sym!(OS), Value::from(current_os().to_owned())), + (sym!(OS_FAMILY), Value::from(current_os_family().to_owned())), + (sym!(ARCH), Value::from(current_arch().to_owned())), + ( + sym!(ARCH_FAMILY), + Value::from(current_arch_family().to_owned()), + ), + ]); + map + }) +} + +#[must_use] +pub const fn current_os() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "ios") { + "ios" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "android") { + "android" + } else if cfg!(target_os = "freebsd") { + "freebsd" + } else if cfg!(target_os = "dragonfly") { + "dragonfly" + } else if cfg!(target_os = "openbsd") { + "openbsd" + } else if cfg!(target_os = "netbsd") { + "netbsd" + } else if cfg!(target_family = "wasm") { + "wasm-wasi" + } else { + "none" + } +} + +#[must_use] +pub const fn current_os_family() -> &'static str { + if cfg!(target_family = "unix") { + "unix" + } else if cfg!(target_family = "windows") { + "windows" + } else if cfg!(target_family = "wasm") { + "wasm" + } else { + "none" + } +} + +#[must_use] +pub const fn current_arch() -> &'static str { + if cfg!(target_arch = "x86") { + "x86" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "mips") { + "mips" + } else if cfg!(target_arch = "powerpc") { + "powerpc" + } else if cfg!(target_arch = "powerpc64") { + "powerpc64" + } else if cfg!(target_arch = "arm") { + "arm" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_family = "wasm") { + "wasm" + } else { + "none" + } +} + +#[must_use] +pub const fn current_arch_family() -> &'static str { + if cfg!(any(target_arch = "x86", target_arch = "x86_64")) { + "x86" + } else if cfg!(target_arch = "mips") { + "mips" + } else if cfg!(any(target_arch = "powerpc", target_arch = "powerpc64")) { + "powerpc" + } else if cfg!(any(target_arch = "arm", target_arch = "aarch64")) { + "arm" + } else if cfg!(target_family = "wasm") { + "wasm" + } else { + "none" + } +} + +#[must_use] +pub const fn exe_suffix() -> &'static str { + if cfg!(windows) { ".exe" } else { "" } +} + +#[must_use] +pub const fn dylib_prefix() -> &'static str { + if cfg!(windows) { "" } else { "lib" } +} + +#[must_use] +pub const fn dylib_suffix() -> &'static str { + if cfg!(windows) { + ".dll" + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + ".dylib" + } else { + ".so" + } +} + +#[must_use] +pub const fn staticlib_prefix() -> &'static str { + if cfg!(windows) { "" } else { "lib" } +} + +#[must_use] +pub const fn staticlib_suffix() -> &'static str { + if cfg!(windows) { ".lib" } else { ".a" } +} diff --git a/werk-runner/shell.rs b/werk-eval/env.rs similarity index 76% rename from werk-runner/shell.rs rename to werk-eval/env.rs index 38be10f9..45186aad 100644 --- a/werk-runner/shell.rs +++ b/werk-eval/env.rs @@ -3,37 +3,6 @@ use std::{ ffi::{OsStr, OsString}, }; -use werk_fs::Absolute; - -#[derive(Clone, PartialEq)] -pub struct ShellCommandLine { - /// The name of the program to run. Should be an absolute path, either from - /// a `which` expression or an `` interpolation when running an - /// executable produced by another recipe. - pub program: Absolute, - pub arguments: Vec, -} - -impl std::fmt::Display for ShellCommandLine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.program.display())?; - for arg in &self.arguments { - write!(f, " {}", werk_util::DisplayArg(arg))?; - } - Ok(()) - } -} - -impl std::fmt::Debug for ShellCommandLine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.program)?; - for arg in &self.arguments { - write!(f, " {}", werk_util::DisplayArgQuoted(arg))?; - } - Ok(()) - } -} - #[derive(Default, Debug, Clone, PartialEq)] pub struct Env { pub env: BTreeMap, diff --git a/werk-eval/error.rs b/werk-eval/error.rs new file mode 100644 index 00000000..5b473bc7 --- /dev/null +++ b/werk-eval/error.rs @@ -0,0 +1,302 @@ +use std::sync::Arc; + +use annotate_snippets::{AnnotationKind, Snippet}; +use werk_fs::Absolute; +use werk_util::{DiagnosticFileId, DiagnosticSourceMap, DiagnosticSpan, IoError, Level}; + +use crate::{GlobError, ShellCommandLine, Value}; + +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum EvalError { + #[error("invalid edition identifier; expected `v1`")] + InvalidEdition(DiagnosticSpan), + #[error("expected a string value")] + ExpectedConfigString(DiagnosticSpan), + #[error("expected a boolean value")] + ExpectedConfigBool(DiagnosticSpan), + #[error("unknown config key")] + UnknownConfigKey(DiagnosticSpan), + #[error("no pattern in scope that contains a pattern stem `%`")] + NoPatternStem(DiagnosticSpan), + #[error("one-of patterns not allowed in this context")] + IllegalOneOfPattern(DiagnosticSpan), + #[error("duplicate pattern")] + DuplicatePattern(DiagnosticSpan, DiagnosticSpan), + #[error("duplicate config statement")] + DuplicateConfigStatement(DiagnosticSpan, DiagnosticSpan), + #[error( + "no implied interpolation value in this context; provide an identifier or a capture group index" + )] + NoImpliedValue(DiagnosticSpan), + #[error("capture group with index {1} is out of bounds in the current scope")] + NoSuchCaptureGroup(DiagnosticSpan, u32), + #[error("no identifier with name {1}")] + NoSuchIdentifier(DiagnosticSpan, String), + #[error("unexpected list; perhaps a join operation `{{var*}}` is missing?")] + UnexpectedList(DiagnosticSpan), + #[error("pattern stems `{{%}}` cannot be interpolated in patterns")] + PatternStemInterpolationInPattern(DiagnosticSpan), + #[error( + "path resolution `<...>` of a string that already contains resolved paths, but is not a resolved path" + )] + DoubleResolvePath(DiagnosticSpan), + #[error("join interpolations `{{...*}}` cannot be used in patterns")] + JoinInPattern(DiagnosticSpan), + #[error("unexpected list in pattern")] + ListInPattern(DiagnosticSpan), + #[error("invalid path interpolation within quotes; path arguments are automatically quoted")] + PathWithinQuotes(DiagnosticSpan), + #[error("empty command")] + EmptyCommand(DiagnosticSpan), + #[error("empty list")] + EmptyList(DiagnosticSpan), + #[error("unterminated quote in shell argument")] + UnterminatedQuote(DiagnosticSpan), + #[error("`{1}` expressions are not allowed in this context")] + UnexpectedExpressionType(DiagnosticSpan, &'static str), + #[error("expected an integer value, got \"{}\"", .1.escape_default())] + ExpectedInt(DiagnosticSpan, String), + #[error("index out of bounds: got {1}, and the array length is {2}")] + IndexOutOfBounds(DiagnosticSpan, i32, usize), + #[error("command not found: {1}: {2}")] + CommandNotFound(DiagnosticSpan, String, which::Error), + #[error("`which` expression resulted in a non-UTF-8 path: {}", .1.display())] + NonUtf8Which(DiagnosticSpan, std::path::PathBuf), + #[error("`read` failed because file is not valid UTF-8: {}", .1.display())] + NonUtf8Read(DiagnosticSpan, std::path::PathBuf), + #[error("resolved file path is not valid UTF-8: {}", .1.display())] + NonUtf8Path(DiagnosticSpan, std::path::PathBuf), + #[error("{1}")] + Glob(DiagnosticSpan, Arc), + /// Shell command failed during evaluation. Note: This error is not reported + /// when executing commands as part of a rule, only when executing commands + /// during evaluation (settings variables etc.) + #[error("{1}")] + Shell(DiagnosticSpan, Arc), + #[error("{1}")] + Path(DiagnosticSpan, werk_fs::PathError), + #[error("I/O error during evaluation: {1}")] + Io(DiagnosticSpan, IoError), + #[error("{1}")] + ErrorExpression(DiagnosticSpan, String), + #[error("assertion failed: {} != {}", .1 .0, .1 .1)] + AssertEqFailed(DiagnosticSpan, Box<(Value, Value)>), + #[error("assertion failed: \"{}\" does not match the pattern '{}'", .1 .0.escape_default(), .1 .1)] + AssertMatchFailed(DiagnosticSpan, Box<(String, String)>), + #[error("assertion failed: {1}")] + AssertCustomFailed(DiagnosticSpan, String), + #[error("{1}")] + PathResolution(DiagnosticSpan, ResolvePathError), + #[error("error including '{1}': {2}")] + IncludeIoError(DiagnosticSpan, String, IoError), + #[error("{1}")] + IncludeError(DiagnosticSpan, Box), + #[error("same file included twice: {2}")] + IncludeDuplicate( + DiagnosticSpan, + Option, + Absolute, + ), + #[error("`default` statements are not allowed in included files")] + DefaultInInclude(DiagnosticSpan), + #[error("{}", .0.error)] + Parse(werk_parser::ErrorInFile), +} + +/// Error in an `include` statement. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum IncludeError { + #[error("{1}")] + Parse(DiagnosticFileId, werk_parser::Error), + #[error(transparent)] + Eval(#[from] EvalError), +} + +impl EvalError { + #[inline] + fn span(&self) -> DiagnosticSpan { + match self { + EvalError::InvalidEdition(span) + | EvalError::ExpectedConfigString(span) + | EvalError::ExpectedConfigBool(span) + | EvalError::UnknownConfigKey(span) + | EvalError::NoPatternStem(span) + | EvalError::IllegalOneOfPattern(span) + | EvalError::DuplicatePattern(span, _) + | EvalError::DuplicateConfigStatement(span, _) + | EvalError::NoImpliedValue(span) + | EvalError::NoSuchCaptureGroup(span, _) + | EvalError::NoSuchIdentifier(span, _) + | EvalError::UnexpectedList(span) + | EvalError::PatternStemInterpolationInPattern(span) + | EvalError::DoubleResolvePath(span) + | EvalError::JoinInPattern(span) + | EvalError::ListInPattern(span) + | EvalError::PathWithinQuotes(span) + | EvalError::EmptyCommand(span) + | EvalError::EmptyList(span) + | EvalError::UnterminatedQuote(span) + | EvalError::UnexpectedExpressionType(span, _) + | EvalError::ExpectedInt(span, _) + | EvalError::IndexOutOfBounds(span, _, _) + | EvalError::CommandNotFound(span, _, _) + | EvalError::NonUtf8Which(span, _) + | EvalError::NonUtf8Read(span, _) + | EvalError::NonUtf8Path(span, _) + | EvalError::Glob(span, _) + | EvalError::Shell(span, _) + | EvalError::Path(span, _) + | EvalError::Io(span, _) + | EvalError::ErrorExpression(span, _) + | EvalError::AssertEqFailed(span, _) + | EvalError::AssertMatchFailed(span, _) + | EvalError::AssertCustomFailed(span, _) + | EvalError::PathResolution(span, _) + | EvalError::IncludeIoError(span, ..) + | EvalError::IncludeError(span, ..) + | EvalError::IncludeDuplicate(span, ..) + | EvalError::DefaultInInclude(span) => *span, + EvalError::Parse(err) => err.file.span(err.error.span()), + } + } +} + +impl werk_util::AsDiagnostic for EvalError { + fn as_diagnostic<'a>( + &'a self, + source_map: &'a dyn DiagnosticSourceMap, + ) -> Vec> { + let span = self.span(); + let source = source_map.get_source(span.file).expect("valid file ID"); + + let id = match self { + EvalError::InvalidEdition(..) => "E0001", + EvalError::ExpectedConfigString(..) => "E0002", + EvalError::ExpectedConfigBool(..) => "E0003", + EvalError::UnknownConfigKey(..) => "E0004", + EvalError::NoPatternStem(..) => "E0005", + EvalError::IllegalOneOfPattern(..) => "E0006", + EvalError::DuplicatePattern(..) => "E0007", + EvalError::DuplicateConfigStatement(..) => "E0033", + EvalError::NoImpliedValue(..) => "E0008", + EvalError::NoSuchCaptureGroup(..) => "E0009", + EvalError::NoSuchIdentifier(..) => "E0010", + EvalError::UnexpectedList(..) => "E0011", + EvalError::PatternStemInterpolationInPattern(..) => "E0012", + EvalError::PathResolution(_, ResolvePathError::Illegal) => "E0013", + EvalError::DoubleResolvePath(..) => "E0034", + EvalError::JoinInPattern(..) => "E0014", + EvalError::ListInPattern(..) => "E0015", + EvalError::PathWithinQuotes(..) => "E0016", + EvalError::EmptyCommand(..) => "E0017", + EvalError::EmptyList(..) => "E0018", + EvalError::UnterminatedQuote(..) => "E0019", + EvalError::UnexpectedExpressionType(..) => "E0020", + EvalError::ExpectedInt(..) => "E0028", + EvalError::IndexOutOfBounds(..) => "E0037", + EvalError::CommandNotFound(..) => "E0021", + EvalError::NonUtf8Which(..) => "E0022", + EvalError::NonUtf8Read(..) => "E0023", + EvalError::NonUtf8Path(..) => "E0040", + EvalError::Glob(..) => "E0024", + EvalError::Shell(..) => "E0025", + EvalError::Path(..) => "E0026", + EvalError::Io(..) | EvalError::IncludeIoError(..) => "E0027", + EvalError::ErrorExpression(..) => "E9999", + EvalError::AssertEqFailed(..) => "E0029", + EvalError::AssertMatchFailed(..) => "E0030", + EvalError::AssertCustomFailed(..) => "E0031", + EvalError::IncludeError(_, err) => { + let mut groups = err.as_diagnostic(source_map); + groups.push( + Level::NOTE.secondary_title("included here").element( + Snippet::source(source.source) + .path(source.file) + .annotation(AnnotationKind::Primary.span(span.span.into())), + ), + ); + return groups; + } + EvalError::IncludeDuplicate(..) => "E0035", + EvalError::DefaultInInclude(..) => "E0036", + EvalError::Parse(err) => { + return err.as_diagnostic(source_map); + } + }; + + let title = self.to_string(); + + let diag = Level::ERROR + .primary_title(title.clone()) // Use the `Display` implementation from thiserror. + .id(id) + .element( + Snippet::source(source.source) + .path(source.file) + .annotation(AnnotationKind::Primary.span(span.span.into()).label(title)), + ); + + // Help messages and additional context. + let diag = match self { + EvalError::NoSuchCaptureGroup(..) => diag.element(Level::HELP.message( + "pattern capture groups are zero-indexed, starting from 0", + )), + EvalError::DuplicateConfigStatement(_, previous_span) => { + let previous_definition_file = source_map.get_source(previous_span.file).expect("invalid file ID"); + diag + .element(Snippet::source(previous_definition_file.source).path(previous_definition_file.file).annotation(AnnotationKind::Context.span(previous_span.span.into()).label("previous config statement here"))) + }, + EvalError::ExpectedInt(..) => diag.element(Level::HELP.message("integers are stringly typed in array index operations, so \"0\" is the first element")), + EvalError::IndexOutOfBounds(..) => diag.element(Level::HELP.message("arrays are indexed from zero, and negative indices refer to elements from the end of the array")), + EvalError::IncludeDuplicate(_, Some(previous_span), _) => { + let previous_definition_file = source_map.get_source(previous_span.file).expect("invalid file ID"); + diag + .element(Snippet::source(previous_definition_file.source).path(previous_definition_file.file).annotation(AnnotationKind::Context.span(previous_span.span.into()).label("already included here"))) + }, + EvalError::DefaultInInclude(_) => diag.element(Level::HELP.message("move `default` statements to the top-level Werkfile")), + _ => diag, + }; + + vec![diag] + } +} + +#[derive(Debug, Clone)] +pub struct ShellError { + pub command: ShellCommandLine, + pub result: Arc>, +} + +impl PartialEq for ShellError { + fn eq(&self, other: &Self) -> bool { + self.command == other.command + && match (&*self.result, &*other.result) { + (Ok(l), Ok(r)) => l == r, + (Err(l), Err(r)) => l == r, + _ => false, + } + } +} + +impl std::error::Error for ShellError {} + +impl std::fmt::Display for ShellError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "command failed: {}", self.command.program.display())?; + match &*self.result { + Ok(output) => { + if !output.stderr.is_empty() { + write!(f, "\nstderr:\n{}", String::from_utf8_lossy(&output.stderr))?; + } + } + Err(err) => writeln!(f, "\nerror: {err}")?, + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ResolvePathError { + #[error("path resolution `<...>` interpolations cannot be used in patterns")] + Illegal, +} diff --git a/werk-runner/eval.rs b/werk-eval/eval.rs similarity index 84% rename from werk-runner/eval.rs rename to werk-eval/eval.rs index 342bface..7ce89618 100644 --- a/werk-runner/eval.rs +++ b/werk-eval/eval.rs @@ -1,131 +1,19 @@ -mod string; -mod used; -pub(crate) use string::*; use stringleton::Symbol; -pub use used::*; use werk_fs::Absolute; -use werk_util::{DiagnosticFileId, Spanned as _}; +use werk_util::{DiagnosticFileId, DiagnosticSpan, Spanned as _}; use std::sync::Arc; use werk_parser::ast; use crate::{ - BuildRecipeScope, Env, EvalError, Lookup, LookupValue, MatchScope, Pattern, RunCommand, Scope, - ShellCommandLine, ShellError, StringFlags, StringValue, SubexprScope, TaskRecipeScope, Value, - Warning, + BuildTaskScope, CommandLineBuilder, Env, Eval, EvalError, Lookup, LookupValue, MatchScope, + Pattern, PatternBuilder, RunCommand, Scope, ScopeMut, ShellCommandLine, ShellError, + StringBuilder, StringFlags, StringValue, SubexprScope, Used, UsedVariable, Value, Warning, + dedup_recursive, flat_join, }; -/// Evaluated value, which keeps track of "outdatedness" with respect to cached -/// build variables. Build recipes may use the outdatedness information to -/// determine if some expression changed in a way that should cause a rebuild to -/// occur, like the result of a glob pattern, or the result of a `which` -/// expression (which is outdated if executable program path changed between -/// runs). -#[derive(Clone, Debug)] -pub struct Eval { - pub value: T, - /// When the evaluated value can impact outdatedness (like a glob - /// expression), this is the variables used during evaluation. - pub used: Used, -} - -impl Eval { - /// An inherent value that does not depend on any external variables. - pub const fn inherent(value: T) -> Self { - Self { - value, - used: Used::none(), - } - } - - pub fn using_var(value: T, used: UsedVariable) -> Self { - Self::using_vars(value, [used]) - } - - pub fn using_vars(value: T, used: impl IntoIterator) -> Self { - Self { - value, - used: Used::from_iter(used), - } - } - - pub fn map(self, f: impl FnOnce(T) -> U) -> Eval { - Eval { - value: f(self.value), - used: self.used, - } - } - - pub fn as_deref(&self) -> &T::Target - where - T: std::ops::Deref, - { - &self.value - } -} - -impl AsRef for Eval { - fn as_ref(&self) -> &T { - &self.value - } -} - -impl Default for Eval { - fn default() -> Self { - Self { - value: Default::default(), - used: Used::none(), - } - } -} - -impl Eval<&T> { - #[must_use] - pub fn cloned(&self) -> Eval - where - T: Clone, - { - Eval { - value: self.value.clone(), - used: self.used.clone(), - } - } -} - -impl Eval> { - pub fn transpose(self) -> Option> { - self.value.map(|value| Eval { - value, - used: self.used, - }) - } -} - -impl Eval> { - pub fn transpose_result(self) -> Result, E> { - self.value.map(|value| Eval { - value, - used: self.used, - }) - } -} - -impl std::ops::Deref for Eval { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.value - } -} - -impl std::ops::DerefMut for Eval { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.value - } -} - pub fn eval( scope: &dyn Scope, expr: &ast::Expr, @@ -144,23 +32,20 @@ pub fn eval( mut used, } = eval_string_expr(scope, &expr.param, file)?; - let (which, hash) = scope.workspace().which(&string).map_err(|e| { + let eval_path = scope.which(&string).map_err(|e| { EvalError::CommandNotFound(file.span(expr.span), string.string.clone(), e) })?; // Note: TryFrom sets the correct StringFlags. - let which: StringValue = match which.into_owned().try_into() { + let which: StringValue = match eval_path.value.try_into() { Ok(value) => value, Err(path) => { return Err(EvalError::NonUtf8Which( file.span(expr.span), - path.into_inner(), + Absolute::into_inner(path), )); } }; - - if let Some(hash) = hash { - used.insert(UsedVariable::Which(Symbol::new(&string), hash)); - } + used.vars.extend(eval_path.used); Ok(Eval { value: Value::String(which), @@ -172,10 +57,10 @@ pub fn eval( value: name, mut used, } = eval_string_expr(scope, &expr.param, file)?; - let (env, hash) = scope.workspace().env(&name); - used.insert(UsedVariable::Env(Symbol::new(&name), hash)); + let eval_env = scope.env(&name); + used.vars.extend(eval_env.used); Ok(Eval { - value: Value::String(env.into()), + value: Value::String(eval_env.value.unwrap_or_default().into()), used, }) } @@ -270,16 +155,16 @@ pub fn eval_op( ast::ExprOp::Info(expr) => { let scope = SubexprScope::new(scope, ¶m); let message = eval_string_expr(&scope, &expr.param, file)?; - scope.render().message(scope.task_id(), &message.value); + scope.message(&message.value); Ok(param) } ast::ExprOp::Warn(expr) => { let scope = SubexprScope::new(scope, ¶m); let message = eval_string_expr(&scope, &expr.param, file)?; - scope.render().warning( - scope.task_id(), - &Warning::WarningExpression(file.span(expr.span), message.value.string), - ); + scope.warning(&Warning::WarningExpression( + file.span(expr.span), + message.value.string, + )); Ok(param) } ast::ExprOp::Error(error_expr) => { @@ -525,26 +410,26 @@ fn eval_split_lines(_scope: &dyn Scope, param: Eval) -> Eval { } } -pub(crate) fn eval_pattern_builder<'a, P: Scope + ?Sized>( - scope: &'a P, +pub fn eval_pattern_builder<'a>( + scope: &'a dyn Scope, expr: &'a ast::PatternExpr, file: DiagnosticFileId, -) -> Result, EvalError> { +) -> Result, EvalError> { let mut pattern_builder = PatternBuilder::new(scope, file.span(expr.span)); pattern_builder.eval(file, expr)?; Ok(pattern_builder) } -pub fn eval_pattern( - scope: &P, +pub fn eval_pattern( + scope: &dyn Scope, expr: &ast::PatternExpr, file: DiagnosticFileId, ) -> Result, EvalError> { Ok(eval_pattern_builder(scope, expr, file)?.build()) } -pub fn eval_string_expr( - scope: &P, +pub fn eval_string_expr( + scope: &dyn Scope, expr: &ast::StringExpr, file: DiagnosticFileId, ) -> Result, EvalError> { @@ -592,14 +477,14 @@ pub fn eval_index_expr( } #[expect(clippy::too_many_lines)] -pub(crate) fn eval_run_exprs( - scope: &S, +pub fn eval_run_exprs( + scope: &dyn Scope, expr: &ast::RunExpr, commands: &mut Vec, file: DiagnosticFileId, ) -> Result { - fn eval_run_exprs_recursively( - scope: &S, + fn eval_run_exprs_recursively( + scope: &dyn Scope, expr: &ast::RunExpr, commands: &mut Vec, used: &mut Used, @@ -622,8 +507,9 @@ pub(crate) fn eval_run_exprs( return Err(EvalError::UnexpectedList(file.span(expr.path.span()))); }; let dest_path = werk_fs::Path::new(&dest_path) - .and_then(|path| scope.workspace().get_output_file_path(path)) - .map_err(|err| EvalError::Path(file.span(expr.span), err))?; + .and_then(|path| path.absolutize(werk_fs::Path::ROOT)) + .map_err(|err| EvalError::Path(file.span(expr.span), err)) + .map(|path| scope.resolve_path(&path))?; let data = eval(scope, &expr.value, file)?; let write_used = destination.used | data.used; let Value::String(data) = data.value else { @@ -643,8 +529,9 @@ pub(crate) fn eval_run_exprs( }) .map_err(|err| EvalError::Path(file.span(expr.src.span), err))?; let to_path = werk_fs::Path::new(&to) - .and_then(|path| scope.workspace().get_output_file_path(path)) - .map_err(|err| EvalError::Path(file.span(expr.dest.span), err))?; + .and_then(|path| path.absolutize(werk_fs::Path::ROOT)) + .map_err(|err| EvalError::Path(file.span(expr.dest.span), err)) + .map(|path| scope.resolve_path(&path))?; let copy_used = from.used | to.used; *used |= copy_used; commands.push(RunCommand::Copy(from_path, to_path)); @@ -745,8 +632,8 @@ pub(crate) fn eval_run_exprs( Ok(used) } -pub fn eval_shell_command( - scope: &P, +pub fn eval_shell_command( + scope: &dyn Scope, expr: &ast::StringExpr, file: DiagnosticFileId, ) -> Result, EvalError> { @@ -755,20 +642,8 @@ pub fn eval_shell_command( builder.build() } -#[derive(Clone, Copy, PartialEq, Eq)] -enum ResolvePathMode { - /// Infer from whether or not the path exists in the workspace. - Infer, - /// The `:out-dir` operation was present. - OutDir, - /// The `:workspace` operation was present. - Workspace, - /// Cannot resolve paths here (e.g., in patterns) - Illegal, -} - -pub fn eval_shell( - scope: &P, +pub fn eval_shell( + scope: &dyn Scope, expr: &ast::StringExpr, file: DiagnosticFileId, ) -> Result, EvalError> { @@ -780,7 +655,7 @@ pub fn eval_shell( let output = match scope .io() - .run_during_eval(&command, scope.workspace().project_root(), &env) + .run_during_eval(&command, scope.current_working_directory(), &env) { Ok(output) => output, Err(e) => { @@ -813,8 +688,8 @@ pub fn eval_shell( }) } -pub fn eval_read( - scope: &P, +pub fn eval_read( + scope: &dyn Scope, expr: &ast::StringExpr, file: DiagnosticFileId, ) -> Result, EvalError> { @@ -824,26 +699,19 @@ pub fn eval_read( let path = werk_fs::Path::new(&path).map_err(path_err)?; let path = path.absolutize(werk_fs::Path::ROOT).map_err(path_err)?; - let Some(fs_entry) = scope.workspace().get_project_file(&path) else { - return Err(EvalError::Io( - file.span(expr.span), - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("file not found during `read`: {path}"), - ) - .into(), - )); - }; + let fs_entry = scope + .stat_file(&path) + .map_err(|err| EvalError::Io(file.span(expr.span), err))?; let contents = scope .io() .read_file(&fs_entry.path) - .map_err(|err| EvalError::Io(file.span(expr.span), err.into()))?; + .map_err(|err| EvalError::Io(file.span(expr.span), err))?; let Ok(string) = String::from_utf8(contents) else { return Err(EvalError::NonUtf8Read( file.span(expr.span), - fs_entry.path.clone().into_inner(), + fs_entry.path.into_inner(), )); }; @@ -868,12 +736,15 @@ pub fn eval_glob( if !glob_pattern_string.starts_with('/') { glob_pattern_string.string.insert(0, '/'); } - let (matches, hash) = scope - .workspace() + let eval_glob_files = scope .glob_workspace_files(&glob_pattern_string) .map_err(|err| EvalError::Glob(file.span(expr.span), Arc::new(err)))?; - used.insert(UsedVariable::Glob(Symbol::new(&glob_pattern_string), hash)); - let matches = matches.into_iter().map(|p| p.into_inner().into()).collect(); + used.vars.extend(eval_glob_files.used.vars); + let matches = eval_glob_files + .value + .into_iter() + .map(|p| Value::from(p.into_inner().into_string())) + .collect(); Ok(Eval { value: matches, @@ -881,19 +752,22 @@ pub fn eval_glob( }) } -pub(crate) struct EvaluatedBuildRecipe { +pub struct EvaluatedBuildRecipe { + pub span: DiagnosticSpan, pub explicit_dependencies: Vec, pub depfile: Option, pub commands: Vec, pub env: Env, } -pub(crate) fn eval_build_recipe_statements( - scope: &mut BuildRecipeScope<'_>, +pub fn eval_build_recipe_statements( + scope: &mut dyn BuildTaskScope, + span: DiagnosticSpan, body: &[ast::BodyStmt], file: DiagnosticFileId, ) -> Result, EvalError> { let mut evaluated = EvaluatedBuildRecipe { + span, explicit_dependencies: Vec::new(), depfile: None, commands: Vec::new(), @@ -905,7 +779,7 @@ pub(crate) fn eval_build_recipe_statements( match stmt.statement { ast::BuildRecipeStmt::Let(ref let_stmt) => { let value = eval_chain(scope, &let_stmt.value, file)?; - scope.set(let_stmt.ident.ident, value); + scope.set_local(file.span(let_stmt.span), let_stmt.ident.ident, value); } ast::BuildRecipeStmt::From(ref expr) => { let value = eval_chain(scope, &expr.param, file)?; @@ -919,7 +793,8 @@ pub(crate) fn eval_build_recipe_statements( scope.push_input_files( evaluated.explicit_dependencies[offset..] .iter() - .map(|s| s.string.clone()), + .map(|s| s.string.clone()) + .collect(), ); } ast::BuildRecipeStmt::Depfile(ref expr) => { @@ -928,7 +803,7 @@ pub(crate) fn eval_build_recipe_statements( match value.value { Value::String(ref depfile) => { evaluated.depfile = Some(depfile.clone()); - scope.set(Symbol::from("depfile"), value); + scope.set_local(file.span(expr.span), Symbol::from("depfile"), value); } Value::List(_) => { return Err(EvalError::UnexpectedList(file.span(expr.span))); @@ -981,14 +856,14 @@ pub(crate) fn eval_build_recipe_statements( }) } -pub(crate) struct EvaluatedTaskRecipe { +pub struct EvaluatedTaskRecipe { pub build: Vec, pub commands: Vec, pub env: Env, } -pub(crate) fn eval_task_recipe_statements( - scope: &mut TaskRecipeScope<'_>, +pub fn eval_task_recipe_statements( + scope: &mut dyn ScopeMut, body: &[ast::BodyStmt], file: DiagnosticFileId, ) -> Result { @@ -1002,7 +877,11 @@ pub(crate) fn eval_task_recipe_statements( match stmt.statement { ast::TaskRecipeStmt::Let(ref let_stmt) => { let value = eval_chain(scope, &let_stmt.value, file)?; - scope.set(let_stmt.ident.ident, Eval::inherent(value.value)); + scope.set_local( + file.span(let_stmt.span), + let_stmt.ident.ident, + Eval::inherent(value.value), + ); } ast::TaskRecipeStmt::Build(ref expr) => { let value = eval_chain(scope, &expr.param, file)?; diff --git a/werk-runner/eval/string.rs b/werk-eval/eval_string.rs similarity index 84% rename from werk-runner/eval/string.rs rename to werk-eval/eval_string.rs index a6b207c0..a08cf5f1 100644 --- a/werk-runner/eval/string.rs +++ b/werk-eval/eval_string.rs @@ -1,29 +1,28 @@ -use std::{borrow::Cow, fmt::Write as _, sync::Arc}; +use std::{borrow::Cow, fmt::Write as _}; use indexmap::IndexMap; -use stringleton::Symbol; use werk_fs::Absolute; use werk_parser::ast; use werk_util::{DiagnosticFileId, DiagnosticSpan}; use crate::{ - AmbiguousPatternError, EvalError, Lookup, LookupValue, Pattern, PatternMatcher, PatternRegex, - PatternRegexCaptures, Scope, ShellCommandLine, StringFlags, StringValue, Value, Warning, - Workspace, + Eval, EvalError, Lookup, LookupValue, Pattern, PatternMatcher, PatternRegex, + PatternRegexCaptures, ResolvePathError, Scope, ShellCommandLine, StringFlags, StringValue, + Value, Warning, }; -use super::{Eval, ResolvePathMode, Used, UsedVariable}; +use super::Used; /// Normal string builder, not sensitive to argument quoting. -pub(crate) struct StringBuilder<'a, P: ?Sized> { - scope: &'a P, +pub(crate) struct StringBuilder<'a> { + scope: &'a dyn Scope, string: String, flags: StringFlags, used: Used, } -impl<'a, P: Scope + ?Sized> StringBuilder<'a, P> { - pub fn new(scope: &'a P) -> Self { +impl<'a> StringBuilder<'a> { + pub fn new(scope: &'a dyn Scope) -> Self { Self { scope, string: String::new(), @@ -54,7 +53,7 @@ impl<'a, P: Scope + ?Sized> StringBuilder<'a, P> { self.scope, span, interp, - ResolvePathMode::Infer, + true, // allow path resolution &mut self.string, &mut self.used, )?; @@ -86,8 +85,8 @@ impl<'a, P: Scope + ?Sized> StringBuilder<'a, P> { } } -pub(crate) struct PatternBuilder<'a, P: ?Sized> { - scope: &'a P, +pub struct PatternBuilder<'a> { + scope: &'a dyn Scope, fragments: Vec, used: Used, span: DiagnosticSpan, @@ -99,8 +98,8 @@ enum PatternFragment { OneOf(Vec), } -impl<'a, P: Scope + ?Sized> PatternBuilder<'a, P> { - pub fn new(scope: &'a P, span: DiagnosticSpan) -> Self { +impl<'a> PatternBuilder<'a> { + pub fn new(scope: &'a dyn Scope, span: DiagnosticSpan) -> Self { Self { scope, fragments: vec![PatternFragment::Literal(String::new())], @@ -170,13 +169,16 @@ impl<'a, P: Scope + ?Sized> PatternBuilder<'a, P> { self.scope, self.span, interp, - ResolvePathMode::Illegal, + false, // disallow path resolution in patterns buf, &mut self.used, )?; if flags.contains(StringFlags::CONTAINS_PATHS) { - return Err(EvalError::ResolvePathInPattern(self.span)); + return Err(EvalError::PathResolution( + self.span, + ResolvePathError::Illegal, + )); } } } @@ -239,6 +241,7 @@ impl<'a, P: Scope + ?Sized> PatternBuilder<'a, P> { } } + #[must_use] pub fn build_partial_regex(self) -> Eval { let value = self.build_partial_regex_inner(); Eval { @@ -249,6 +252,7 @@ impl<'a, P: Scope + ?Sized> PatternBuilder<'a, P> { /// Build the pattern either as a literal-match pattern or a regex pattern /// that matches the whole string (if it contains stems or capture groups) + #[must_use] pub fn build(self) -> Eval { let span = self.span; @@ -282,7 +286,7 @@ impl<'a, P: Scope + ?Sized> PatternBuilder<'a, P> { } } -impl std::fmt::Display for PatternBuilder<'_, P> { +impl std::fmt::Display for PatternBuilder<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for fragment in &self.fragments { match fragment { @@ -306,8 +310,8 @@ impl std::fmt::Display for PatternBuilder<'_, P> { } } -pub(crate) struct CommandLineBuilder<'a, P: ?Sized> { - scope: &'a P, +pub(crate) struct CommandLineBuilder<'a> { + scope: &'a dyn Scope, in_quotes: Option, escape: bool, parts: Vec, @@ -320,8 +324,8 @@ enum InQuotes { Double, } -impl<'a, P: Scope + ?Sized> CommandLineBuilder<'a, P> { - pub fn new(scope: &'a P, span: DiagnosticSpan) -> Self { +impl<'a> CommandLineBuilder<'a> { + pub fn new(scope: &'a dyn Scope, span: DiagnosticSpan) -> Self { Self { scope, in_quotes: None, @@ -417,7 +421,7 @@ impl<'a, P: Scope + ?Sized> CommandLineBuilder<'a, P> { self.scope, self.span, interp, - ResolvePathMode::Infer, + true, // allow path resolution buf, &mut self.used, )?; @@ -432,8 +436,7 @@ impl<'a, P: Scope + ?Sized> CommandLineBuilder<'a, P> { self.span, value.into_value(), interp.options.as_deref().map_or(&[], |ops| &*ops.ops), - self.scope.workspace(), - ResolvePathMode::Infer, + self.scope, )?; // If we just saw an unquoted space that started a new argument, @@ -478,18 +481,15 @@ impl<'a, P: Scope + ?Sized> CommandLineBuilder<'a, P> { return Err(EvalError::EmptyCommand(self.span)); }; - let (program_path, hash) = self + let program_path = self .scope - .workspace() .which(&program) .map_err(|err| EvalError::CommandNotFound(self.span, program.clone(), err))?; - self.used - .vars - .extend(hash.map(|hash| UsedVariable::Which(Symbol::new(&program), hash))); + self.used.vars.extend(program_path.used.vars); Ok(Eval { value: ShellCommandLine { - program: program_path.into_owned(), + program: program_path.value, arguments: parts.collect(), }, used: self.used, @@ -498,11 +498,11 @@ impl<'a, P: Scope + ?Sized> CommandLineBuilder<'a, P> { } } -fn eval_string_interpolation( - scope: &P, +fn eval_string_interpolation( + scope: &dyn Scope, span: DiagnosticSpan, interp: &ast::Interpolation, - resolve_mode: ResolvePathMode, + allow_path_resolution: bool, buf: &mut String, used: &mut Used, ) -> Result { @@ -516,8 +516,7 @@ fn eval_string_interpolation( value.into_value(), buf, options, - scope.workspace(), - resolve_mode, + scope, ); } } @@ -547,8 +546,8 @@ fn eval_string_interpolation( let (s, flags) = find_first_string(value); buf.push_str(s); - if flags.contains(StringFlags::CONTAINS_PATHS) && resolve_mode == ResolvePathMode::Illegal { - return Err(EvalError::ResolvePathInPattern(span)); + if flags.contains(StringFlags::CONTAINS_PATHS) && !allow_path_resolution { + return Err(EvalError::PathResolution(span, ResolvePathError::Illegal)); } Ok(flags) @@ -585,11 +584,9 @@ fn eval_string_interpolation_ops_and_join( mut value: Value, buf: &mut String, options: &ast::InterpolationOptions, - workspace: &Workspace, - default_resolve_mode: ResolvePathMode, + scope: &dyn Scope, ) -> Result { - value = - eval_string_interpolation_ops(span, value, &options.ops, workspace, default_resolve_mode)?; + value = eval_string_interpolation_ops(span, value, &options.ops, scope)?; if let (Value::List(_), Some(sep)) = (&value, options.join.as_deref()) { Ok(recursive_join_push(&value, sep, buf)) @@ -604,11 +601,8 @@ fn eval_string_interpolation_ops( span: DiagnosticSpan, mut value: Value, options: &[ast::InterpolationOp], - workspace: &Workspace, - default_resolve_mode: ResolvePathMode, + scope: &dyn Scope, ) -> Result { - let mut resolve_mode = default_resolve_mode; - for op in options { match op { ast::InterpolationOp::Dedup => { @@ -631,20 +625,8 @@ fn eval_string_interpolation_ops( ast::InterpolationOp::RegexReplace(r) => { recursive_regex_replace(&mut value, &r.regex, &r.replacer); } - ast::InterpolationOp::ResolveOsPath => { - recursive_resolve_path( - span, - &mut value, - werk_fs::Path::ROOT, - workspace, - resolve_mode, - )?; - } - ast::InterpolationOp::ResolveOutDir => { - resolve_mode = ResolvePathMode::OutDir; - } - ast::InterpolationOp::ResolveWorkspace => { - resolve_mode = ResolvePathMode::Workspace; + ast::InterpolationOp::Resolve => { + recursive_resolve_path(span, &mut value, werk_fs::Path::ROOT, scope)?; } } } @@ -702,8 +684,7 @@ fn recursive_resolve_path( span: DiagnosticSpan, value: &mut Value, working_dir: &Absolute, - workspace: &Workspace, - resolve_mode: ResolvePathMode, + scope: &dyn Scope, ) -> Result<(), EvalError> { value.try_visit_mut(&mut |string: &mut StringValue| -> Result<(), EvalError> { if string.flags.contains(StringFlags::CONTAINS_PATHS) { @@ -711,15 +692,12 @@ fn recursive_resolve_path( } let path = werk_fs::Path::new(string).map_err(|err| EvalError::Path(span, err))?; - let path = path.absolutize(working_dir).map_err(|err| EvalError::Path(span, err))?; - let path = match resolve_mode { - ResolvePathMode::Infer => resolve_path_infer(span, &path, workspace)?, - ResolvePathMode::OutDir => path.resolve(workspace.output_directory()), - ResolvePathMode::Workspace => path.resolve(workspace.project_root()), - ResolvePathMode::Illegal => return Err(EvalError::ResolvePathInPattern(span)), - }; - let Some(path) = path.to_str() else { - panic!("Path resolution produced a non-UTF8 path; probably the project root path is non-UTF8") + let path = path + .absolutize(working_dir) + .map_err(|err| EvalError::Path(span, err))?; + let resolved = scope.resolve_path(&path); + let Some(path) = resolved.to_str() else { + return Err(EvalError::NonUtf8Path(span, resolved.into_inner())); }; path.clone_into(&mut string.string); @@ -728,34 +706,34 @@ fn recursive_resolve_path( }) } -fn resolve_path_infer( - span: DiagnosticSpan, - path: &Absolute, - workspace: &Workspace, -) -> Result, EvalError> { - if let Some(workspace_file) = workspace.get_project_file(path) { - // Check if the path also matches a build recipe, and must be disambiguated. - match workspace.manifest.match_build_recipe(path) { - Ok(Some(recipe)) => Err(EvalError::AmbiguousPathResolution( - span, - Arc::new(crate::AmbiguousPathError { - path: path.to_path_buf(), - build_recipe: recipe.recipe.pattern.span, - }), - )), - Err(AmbiguousPatternError { pattern1, .. }) => Err(EvalError::AmbiguousPathResolution( - span, - Arc::new(crate::AmbiguousPathError { - path: path.to_path_buf(), - build_recipe: pattern1, - }), - )), - Ok(None) => Ok(workspace_file.path.clone()), - } - } else { - Ok(path.resolve(workspace.output_directory())) - } -} +// fn resolve_path_infer( +// span: DiagnosticSpan, +// path: &Absolute, +// scope: &dyn Scope, +// ) -> Result, EvalError> { +// if let Some(workspace_file) = workspace.get_project_file(path) { +// // Check if the path also matches a build recipe, and must be disambiguated. +// match workspace.manifest.match_build_recipe(path) { +// Ok(Some(recipe)) => Err(EvalError::AmbiguousPathResolution( +// span, +// Arc::new(crate::AmbiguousPathError { +// path: path.to_path_buf(), +// build_recipe: recipe.recipe.pattern.span, +// }), +// )), +// Err(AmbiguousPatternError { pattern1, .. }) => Err(EvalError::AmbiguousPathResolution( +// span, +// Arc::new(crate::AmbiguousPathError { +// path: path.to_path_buf(), +// build_recipe: pattern1, +// }), +// )), +// Ok(None) => Ok(workspace_file.path.clone()), +// } +// } else { +// Ok(path.resolve(workspace.output_directory())) +// } +// } fn recursive_replace_extension(value: &mut Value, from: &str, to: &str) { value.visit_mut(&mut |s: &mut StringValue| { diff --git a/werk-eval/evaluated.rs b/werk-eval/evaluated.rs new file mode 100644 index 00000000..dbf97a78 --- /dev/null +++ b/werk-eval/evaluated.rs @@ -0,0 +1,110 @@ +use crate::{Used, UsedVariable, Value}; + +/// Evaluated value, which keeps track of "outdatedness" with respect to cached +/// build variables. Build recipes may use the outdatedness information to +/// determine if some expression changed in a way that should cause a rebuild to +/// occur, like the result of a glob pattern, or the result of a `which` +/// expression (which is outdated if executable program path changed between +/// runs). +#[derive(Clone, Debug)] +pub struct Eval { + pub value: T, + /// When the evaluated value can impact outdatedness (like a glob + /// expression), this is the variables used during evaluation. + pub used: Used, +} + +impl Eval { + /// An inherent value that does not depend on any external variables. + pub const fn inherent(value: T) -> Self { + Self { + value, + used: Used::none(), + } + } + + pub fn using_var(value: T, used: UsedVariable) -> Self { + Self::using_vars(value, [used]) + } + + pub fn using_vars(value: T, used: impl IntoIterator) -> Self { + Self { + value, + used: Used::from_iter(used), + } + } + + pub fn map(self, f: impl FnOnce(T) -> U) -> Eval { + Eval { + value: f(self.value), + used: self.used, + } + } + + pub fn as_deref(&self) -> &T::Target + where + T: std::ops::Deref, + { + &self.value + } +} + +impl AsRef for Eval { + fn as_ref(&self) -> &T { + &self.value + } +} + +impl Default for Eval { + fn default() -> Self { + Self { + value: Default::default(), + used: Used::none(), + } + } +} + +impl Eval<&T> { + #[must_use] + pub fn cloned(&self) -> Eval + where + T: Clone, + { + Eval { + value: self.value.clone(), + used: self.used.clone(), + } + } +} + +impl Eval> { + pub fn transpose(self) -> Option> { + self.value.map(|value| Eval { + value, + used: self.used, + }) + } +} + +impl Eval> { + pub fn transpose_result(self) -> Result, E> { + self.value.map(|value| Eval { + value, + used: self.used, + }) + } +} + +impl std::ops::Deref for Eval { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl std::ops::DerefMut for Eval { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.value + } +} diff --git a/werk-eval/glob_settings.rs b/werk-eval/glob_settings.rs new file mode 100644 index 00000000..a060c775 --- /dev/null +++ b/werk-eval/glob_settings.rs @@ -0,0 +1,37 @@ +#[derive(Clone, Debug)] +#[allow(clippy::struct_excessive_bools)] +pub struct GlobSettings { + /// Read the workspace directory's `.gitignore`. Enabled by default. + pub git_ignore: bool, + /// Read a global gitignore file, specified in the `core.excludeFiles` + /// config option. Enabled by default. + pub git_ignore_global: bool, + /// Read `.git/info/exclude`. Enabled by default. + pub git_ignore_exclude: bool, + /// Read `.gitignore` from parent directoryes. Enabled by default. + pub git_ignore_from_parents: bool, + /// Enables reading `.ignore` files, supported by `ripgrep` and The Silver Searcher. Enabled by default. + pub dot_ignore: bool, + /// Explicit file name patterns to ignore in addition to gitignore and .ignore files. + pub ignore_explicitly: globset::GlobSet, +} + +impl Default for GlobSettings { + #[inline] + fn default() -> Self { + Self { + git_ignore: true, + git_ignore_global: true, + git_ignore_exclude: true, + git_ignore_from_parents: true, + dot_ignore: true, + ignore_explicitly: globset::GlobSet::empty(), + } + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum GlobError { + #[error(transparent)] + Glob(#[from] globset::Error), +} diff --git a/werk-eval/io.rs b/werk-eval/io.rs new file mode 100644 index 00000000..48e04d55 --- /dev/null +++ b/werk-eval/io.rs @@ -0,0 +1,144 @@ +use std::{ + path::{Path, PathBuf}, + pin::Pin, + time::SystemTime, +}; + +use futures::{AsyncRead, AsyncWrite}; +use werk_fs::Absolute; +use werk_util::IoError; + +use crate::{Env, GlobError, GlobSettings, ShellCommandLine}; + +/// Abstract interface to the file system and OS. +/// +/// All interactions with the file system and OS should go through this, which +/// in particular means that things like methods on `std::path::Path` should +/// avoid accessing the filesystem. For example, methods like `canonicalize()` +/// or `metadata()` do access the filesystem, and should be avoided. +/// +/// This abstraction exists to allow for testing the runner in a controlled +/// environment. +pub trait Io: Send + Sync + 'static { + /// Run a command as part of a recipe. This will do nothing in dry-run mode. + fn run_recipe_command( + &self, + command_line: &ShellCommandLine, + working_dir: &Absolute, + env: &Env, + forward_stdout: bool, + ) -> Result, IoError>; + + /// Run a command as part of evaluating the contents of a Werkfile. This + /// might still do something in dry-run mode. + fn run_during_eval( + &self, + command_line: &ShellCommandLine, + working_dir: &Absolute, + env: &Env, + ) -> Result; + + /// Determine the absolute filesystem path to a program. + fn which(&self, command: &str) -> Result, which::Error>; + + /// Glob the workspace directory, adhering to the glob settings. + /// + /// If this function produces a path to a `.werk-cache` file, the + /// `Workspace` constructor will fail. + fn walk_directory( + &self, + path: &Absolute, + settings: GlobSettings, + visit: &(dyn Fn(&Absolute) + Send + Sync), + ) -> Result<(), GlobError>; + + /// Query the metadata of a filesystem path. + fn metadata(&self, path: &Absolute) -> Result; + + /// Read a file from the filesystem. + fn read_file(&self, path: &Absolute) -> Result, IoError>; + + /// Write a file to the filesystem. + fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), IoError>; + + /// Copy one file to another on the file system. Must do nothing in dry-run. + /// May do nothing if the paths are equal. + fn copy_file(&self, from: &Absolute, to: &Absolute) -> Result<(), IoError>; + + /// Delete a file from the filesystem. Must do nothing in dry-run. + fn delete_file(&self, path: &Absolute) -> Result<(), IoError>; + + /// Create a file, or update an existing file's mtime. (Equivalent to UNIX `touch`.) + fn touch(&self, path: &Absolute) -> Result<(), IoError>; + + /// Create the parent directories of `path`, recursively. + fn create_parent_dirs(&self, path: &Absolute) -> Result<(), IoError>; + + /// Read environment variable. + fn read_env(&self, name: &str) -> Option; + + /// Is this object actually executing commands or not? The return value + /// should be used for diagnostic purposes only, because the actual behavior + /// of the runner is not affected by this. + fn is_dry_run(&self) -> bool; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Metadata { + pub mtime: SystemTime, + pub is_file: bool, + pub is_symlink: bool, +} + +impl Metadata { + #[inline] + #[must_use] + pub fn is_dir(&self) -> bool { + !self.is_file + } +} + +impl TryFrom for Metadata { + type Error = std::io::Error; + + fn try_from(metadata: std::fs::Metadata) -> Result { + Ok(Metadata { + mtime: metadata.modified()?, + is_file: metadata.is_file(), + is_symlink: metadata.file_type().is_symlink(), + }) + } +} + +pub trait Child: Send + Sync + Unpin { + fn stdin(self: Pin<&mut Self>) -> Option>; + fn stderr(self: Pin<&mut Self>) -> Option>; + + fn take_stdin(&mut self) -> Option>>; + fn take_stdout(&mut self) -> Option>>; + fn take_stderr(&mut self) -> Option>>; + + /// Wait for the process to exit. Does NOT drop the stdin handle. + fn status( + &mut self, + ) -> Pin> + Send>>; + + fn kill(&mut self) -> std::io::Result<()>; +} + +#[derive(Debug, Clone)] +pub struct DirEntry { + pub path: Absolute, + pub metadata: Metadata, +} + +impl TryFrom for DirEntry { + type Error = ignore::Error; + + fn try_from(value: ignore::DirEntry) -> Result { + Ok(Self { + path: Absolute::new_unchecked(value.path().to_path_buf()), + metadata: value.metadata().unwrap().try_into()?, + }) + } +} diff --git a/werk-eval/lib.rs b/werk-eval/lib.rs new file mode 100644 index 00000000..fddec24e --- /dev/null +++ b/werk-eval/lib.rs @@ -0,0 +1,37 @@ +mod command_line; +mod constants; +mod env; +mod error; +mod eval; +mod eval_string; +mod evaluated; +mod glob_settings; +mod io; +mod messenger; +mod pattern; +mod run_command; +mod scope; +mod task_name; +mod used; +mod value; +mod warning; + +pub use command_line::*; +pub use constants::*; +pub use env::*; +pub use error::*; +pub use eval::*; +pub use eval_string::*; +pub use evaluated::*; +pub use glob_settings::*; +pub use io::*; +pub use messenger::*; +pub use pattern::*; +pub use run_command::*; +pub use scope::*; +pub use task_name::*; +pub use used::*; +pub use value::*; +pub use warning::*; + +stringleton::enable!(); diff --git a/werk-eval/messenger.rs b/werk-eval/messenger.rs new file mode 100644 index 00000000..aa089c41 --- /dev/null +++ b/werk-eval/messenger.rs @@ -0,0 +1,10 @@ +use crate::{TaskName, Warning}; + +pub trait Messenger { + /// Emit a message from the user, typically from the `info` expression in + /// the manifest. + fn message(&self, task_id: Option, message: &str); + /// Emit a warning from the user, typically from the `warn` expression in + /// the manifest. + fn warning(&self, task_id: Option, warning: &Warning); +} diff --git a/werk-runner/pattern.rs b/werk-eval/pattern.rs similarity index 87% rename from werk-runner/pattern.rs rename to werk-eval/pattern.rs index ddfda430..23bf4402 100644 --- a/werk-runner/pattern.rs +++ b/werk-eval/pattern.rs @@ -1,6 +1,6 @@ use werk_util::DiagnosticSpan; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq)] pub struct Pattern { /// The source span for the pattern. pub span: DiagnosticSpan, @@ -9,26 +9,41 @@ pub struct Pattern { pub(crate) matcher: PatternMatcher, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum PatternMatcher { Literal(String), Regex(PatternRegex), } -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct PatternRegexCaptures { +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct PatternRegexCaptures { pub stem_capture_index: Option, pub num_normal_capture_groups: usize, } #[derive(Debug, Clone)] -pub(crate) struct PatternRegex { +pub struct PatternRegex { /// The regular expression used to match this pattern. pub regex: regex::Regex, /// Information about the capture groups in the regex. pub captures: PatternRegexCaptures, } +impl PartialEq for PatternRegex { + fn eq(&self, other: &Self) -> bool { + self.regex.as_str() == other.regex.as_str() && self.captures == other.captures + } +} + +impl Eq for PatternRegex {} + +impl std::hash::Hash for PatternRegex { + fn hash(&self, state: &mut H) { + self.regex.as_str().hash(state); + self.captures.hash(state); + } +} + impl PartialEq for Pattern { #[inline] fn eq(&self, other: &Self) -> bool { diff --git a/werk-runner/runner/command.rs b/werk-eval/run_command.rs similarity index 100% rename from werk-runner/runner/command.rs rename to werk-eval/run_command.rs diff --git a/werk-eval/scope.rs b/werk-eval/scope.rs new file mode 100644 index 00000000..9177770b --- /dev/null +++ b/werk-eval/scope.rs @@ -0,0 +1,315 @@ +use stringleton::Symbol; +use werk_fs::{Absolute, PathError}; +use werk_util::{DiagnosticSpan, IoError}; + +use crate::{DirEntry, Eval, GlobError, Io, Messenger, PatternMatchData, Used, Value, Warning}; + +pub type LocalVariables = indexmap::IndexMap>; + +pub struct ConfigVar { + /// Snapshot of the value when the `config` statement was evaluated + /// (unshadowed). + pub value: Value, + /// Doc comment + pub comment: String, + /// The span of the `config` statement. + pub span: DiagnosticSpan, +} + +pub trait Scope: Send + Sync { + fn get(&self, name: Lookup) -> Option>; + fn io(&self) -> &dyn Io; + fn messenger(&self) -> &dyn Messenger; + fn message(&self, message: &str); + fn warning(&self, warning: &Warning); + fn which(&self, program_name: &str) + -> Result>, which::Error>; + fn env(&self, variable_name: &str) -> Eval>; + fn resolve_path(&self, path: &Absolute) -> Absolute; + fn unresolve_path( + &self, + path: &Absolute, + ) -> Result, PathError>; + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError>; + fn current_working_directory(&self) -> &Absolute; + + fn stat_file_if_exists( + &self, + path: &Absolute, + ) -> Result, IoError> { + match self.stat_file(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(err) if err.error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } + + fn stat_file(&self, path: &Absolute) -> Result { + let os_path = self.resolve_path(path); + self.io().metadata(&os_path).map(|metadata| DirEntry { + path: os_path, + metadata: crate::Metadata { + mtime: metadata.mtime, + is_file: metadata.is_file, + is_symlink: metadata.is_symlink, + }, + }) + } +} + +pub trait ScopeMut: Scope { + fn set_local(&mut self, span: DiagnosticSpan, name: Symbol, value: Eval); +} + +pub trait BuildTaskScope: ScopeMut { + fn push_input_files(&mut self, files: Vec); +} + +pub struct SubexprScope<'a> { + parent: &'a dyn Scope, + /// The value in the current scope that will be used in stemless `{}` string + /// interpolations. + pub implied_value: &'a Eval, +} + +pub struct MatchScope<'a> { + parent: &'a dyn Scope, + pattern_match: &'a PatternMatchData, + /// The matched string. + pub implied_value: &'a Eval, +} + +/// Look up a variable in a scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lookup { + /// The scope's implied value (forwarded from another expression). An empty + /// stem in a string interpolation. + Implied, + /// The stem of the current pattern. `{%}` in string interpolation. + PatternStem, + /// The captured value of a one-of pattern in the current pattern. `{1}` in + /// string interpolation. + CaptureGroup(u32), + /// Lookup by identifier. `{ident}` in string interpolation. + Ident(Symbol), + /// The `^` special variable in build recipes. Cannot be shadowed. + InputFile, + /// The `@` special variable in build recipes. Cannot be shadowed. + OutputFile, +} + +#[derive(Debug, Clone)] +pub enum LookupValue<'a> { + Owned(Eval), + EvalRef(&'a Eval), + ValueRef(Eval<&'a Value>), + Ref(&'a Value, &'a Used), +} + +impl LookupValue<'_> { + #[inline] + #[must_use] + pub fn used(&self) -> &Used { + match self { + LookupValue::Owned(value) => &value.used, + LookupValue::EvalRef(value) => &value.used, + LookupValue::ValueRef(value) => &value.used, + LookupValue::Ref(_, used) => used, + } + } + + #[must_use] + pub fn into_value(self) -> Value { + match self { + LookupValue::Owned(eval) => eval.value, + LookupValue::EvalRef(eval) => eval.value.clone(), + LookupValue::ValueRef(eval) => eval.value.clone(), + LookupValue::Ref(value, _) => value.clone(), + } + } + + #[must_use] + pub fn into_owned(self) -> Eval { + match self { + LookupValue::Owned(eval) => eval, + LookupValue::EvalRef(eval) => eval.clone(), + LookupValue::ValueRef(eval) => Eval { + value: eval.value.clone(), + used: eval.used, + }, + LookupValue::Ref(value, used) => Eval { + value: value.clone(), + used: used.clone(), + }, + } + } +} + +impl std::ops::Deref for LookupValue<'_> { + type Target = Value; + + #[inline] + fn deref(&self) -> &Self::Target { + match self { + LookupValue::Owned(value) => value, + LookupValue::EvalRef(value) => value, + LookupValue::ValueRef(value) => value, + LookupValue::Ref(value, _) => value, + } + } +} + +impl<'a> SubexprScope<'a> { + #[inline] + pub fn new(parent: &'a dyn Scope, implied_value: &'a Eval) -> Self { + SubexprScope { + parent, + implied_value, + } + } +} + +impl<'a> MatchScope<'a> { + #[inline] + pub fn new( + parent: &'a dyn Scope, + pattern_match: &'a PatternMatchData, + matched_string: &'a Eval, + ) -> Self { + MatchScope { + parent, + pattern_match, + implied_value: matched_string, + } + } +} + +impl Scope for SubexprScope<'_> { + #[inline] + fn get(&self, lookup: Lookup) -> Option> { + match lookup { + Lookup::Implied => Some(LookupValue::EvalRef(self.implied_value)), + _ => self.parent.get(lookup), + } + } + + fn io(&self) -> &dyn Io { + self.parent.io() + } + + fn messenger(&self) -> &dyn Messenger { + self.parent.messenger() + } + + fn message(&self, message: &str) { + self.parent.message(message); + } + + fn warning(&self, warning: &Warning) { + self.parent.warning(warning); + } + + fn which( + &self, + program_name: &str, + ) -> Result>, which::Error> { + self.parent.which(program_name) + } + + fn env(&self, variable_name: &str) -> Eval> { + self.parent.env(variable_name) + } + + fn resolve_path(&self, path: &Absolute) -> Absolute { + self.parent.resolve_path(path) + } + + fn unresolve_path( + &self, + path: &Absolute, + ) -> Result, PathError> { + self.parent.unresolve_path(path) + } + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError> { + self.parent.glob_workspace_files(pattern_string) + } + + fn current_working_directory(&self) -> &Absolute { + self.parent.current_working_directory() + } +} + +impl Scope for MatchScope<'_> { + #[inline] + fn get(&self, lookup: Lookup) -> Option> { + match lookup { + Lookup::PatternStem => { + let stem = self.pattern_match.stem()?; + Some(LookupValue::Owned(Eval::inherent(Value::from(stem)))) + } + Lookup::CaptureGroup(index) => { + let group = self.pattern_match.capture_group(index as usize)?; + Some(LookupValue::Owned(Eval::inherent(Value::from(group)))) + } + Lookup::Implied => Some(LookupValue::EvalRef(self.implied_value)), + _ => self.parent.get(lookup), + } + } + + fn io(&self) -> &dyn Io { + self.parent.io() + } + + fn messenger(&self) -> &dyn Messenger { + self.parent.messenger() + } + + fn message(&self, message: &str) { + self.parent.message(message); + } + + fn warning(&self, warning: &Warning) { + self.parent.warning(warning); + } + + fn which( + &self, + program_name: &str, + ) -> Result>, which::Error> { + self.parent.which(program_name) + } + + fn env(&self, variable_name: &str) -> Eval> { + self.parent.env(variable_name) + } + + fn resolve_path(&self, path: &Absolute) -> Absolute { + self.parent.resolve_path(path) + } + + fn unresolve_path( + &self, + path: &Absolute, + ) -> Result, PathError> { + self.parent.unresolve_path(path) + } + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError> { + self.parent.glob_workspace_files(pattern_string) + } + + fn current_working_directory(&self) -> &Absolute { + self.parent.current_working_directory() + } +} diff --git a/werk-eval/task_name.rs b/werk-eval/task_name.rs new file mode 100644 index 00000000..2dc01646 --- /dev/null +++ b/werk-eval/task_name.rs @@ -0,0 +1,82 @@ +use stringleton::Symbol; +use werk_fs::{Absolute, SymPath}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TaskName { + Task(Symbol), + // TODO: When recipes can build multiple files, this needs to change to some + // ID that encapsulates the "recipe instance" rather than the path of a + // single target. + Build(Absolute), +} + +impl TaskName { + pub fn command(s: impl Into) -> Self { + let name = s.into(); + debug_assert!(!name.as_str().starts_with('/')); + TaskName::Task(name) + } + + pub fn build(p: impl AsRef>) -> Self { + TaskName::Build(Absolute::symbolicate(p)) + } + + pub fn try_build

(p: P) -> Result + where + P: TryInto>, + { + let path = p.try_into()?; + Ok(TaskName::build(path)) + } + + #[inline] + #[must_use] + pub fn is_command(&self) -> bool { + matches!(self, TaskName::Task(_)) + } + + #[inline] + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + TaskName::Task(task) => task.as_str(), + TaskName::Build(build) => build.as_inner().as_str(), + } + } + + #[inline] + #[must_use] + pub fn as_path(&self) -> Option<&Absolute> { + if let TaskName::Build(build) = self { + Some(build.as_path()) + } else { + None + } + } + + #[inline] + #[must_use] + pub fn short_name(&self) -> &'static str { + match self { + TaskName::Task(task) => task.as_str(), + TaskName::Build(path) => { + let Some((_prefix, filename)) = path + .as_inner() + .as_str() + .rsplit_once(werk_fs::Path::SEPARATOR) + else { + // The path is absolute. + unreachable!() + }; + filename + } + } + } +} + +impl std::fmt::Display for TaskName { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/werk-runner/eval/used.rs b/werk-eval/used.rs similarity index 99% rename from werk-runner/eval/used.rs rename to werk-eval/used.rs index beaa267a..af4e5a84 100644 --- a/werk-runner/eval/used.rs +++ b/werk-eval/used.rs @@ -5,8 +5,7 @@ use std::{ use stringleton::Symbol; use werk_fs::{Absolute, SymPath}; - -use crate::cache::Hash128; +use werk_util::hash128::Hash128; /// The external variables used when evaluating an expression. #[derive(Debug, Clone, Default)] diff --git a/werk-runner/value.rs b/werk-eval/value.rs similarity index 100% rename from werk-runner/value.rs rename to werk-eval/value.rs diff --git a/werk-runner/warning.rs b/werk-eval/warning.rs similarity index 83% rename from werk-runner/warning.rs rename to werk-eval/warning.rs index fb4d915f..d7905570 100644 --- a/werk-runner/warning.rs +++ b/werk-eval/warning.rs @@ -1,4 +1,5 @@ use annotate_snippets::{AnnotationKind, Snippet}; +use stringleton::Symbol; use werk_fs::Absolute; use werk_util::{DiagnosticSourceMap, DiagnosticSpan, Level}; @@ -22,13 +23,22 @@ pub enum Warning { WarningExpression(DiagnosticSpan, String), #[error("unused define `{0}`")] UnusedDefine(String), - #[error("output directory changed; was `{0}`, is now `{1}`")] - OutputDirectoryChanged(Absolute, Absolute), + #[error("cache directory changed; was `{0}`, is now `{1}`")] + CacheDirectoryChanged(Absolute, Absolute), #[error("one or more child processes did not stop when asked, and may be left as zombies")] ZombieChild, + #[error("shadowing global constant: `{1}`")] + ShadowingGlobalConstant(DiagnosticSpan, Symbol), + #[error("{1}")] + Custom(Option, String), } impl Warning { + #[allow(clippy::needless_pass_by_value)] + pub fn custom(span: Option, message: impl ToString) -> Self { + Warning::Custom(span, message.to_string()) + } + #[must_use] pub fn id(&self) -> &'static str { match self { @@ -39,10 +49,11 @@ impl Warning { Warning::IgnoringPathOutsideOutputDirectory(_, _) => "W0022", Warning::IgnoringFileNotFound(_, _) => "W0023", Warning::DepfileNotGenerated(_, _) => "W0030", - Warning::WarningExpression(_, _) => "W9999", Warning::UnusedDefine(_) => "W1000", - Warning::OutputDirectoryChanged(..) => "W1001", + Warning::CacheDirectoryChanged(..) => "W1001", Warning::ZombieChild => "W1002", + Warning::ShadowingGlobalConstant(..) => "W1003", + Warning::WarningExpression(_, _) | Warning::Custom(..) => "W9999", } } @@ -56,9 +67,11 @@ impl Warning { | Warning::IgnoringPathOutsideOutputDirectory(span, _) | Warning::IgnoringFileNotFound(span, _) | Warning::DepfileNotGenerated(span, _) - | Warning::WarningExpression(span, _) => Some(*span), + | Warning::WarningExpression(span, _) + | Warning::ShadowingGlobalConstant(span, _) => Some(*span), + Warning::Custom(span, _) => *span, Warning::UnusedDefine(_) - | Warning::OutputDirectoryChanged(..) + | Warning::CacheDirectoryChanged(..) | Warning::ZombieChild => None, } } @@ -105,7 +118,7 @@ impl werk_util::AsDiagnostic for Warning { .element(Level::HELP.message("maybe a `let` statement should be changed to a `config` statement?")) } - Warning::OutputDirectoryChanged(..) | Warning::ZombieChild => annotate_snippets::Group::with_title(title), + Warning::CacheDirectoryChanged(..) | Warning::ZombieChild | Warning::ShadowingGlobalConstant(..) | Warning::Custom(..) => annotate_snippets::Group::with_title(title), }; vec![group] diff --git a/werk-fs/absolute.rs b/werk-fs/absolute.rs index 866037e0..5ee27e51 100644 --- a/werk-fs/absolute.rs +++ b/werk-fs/absolute.rs @@ -82,7 +82,7 @@ where } impl Absolute

{ - pub(crate) const fn new_ref_unchecked(path: &P) -> &Self { + pub const fn new_ref_unchecked(path: &P) -> &Self { unsafe { // SAFETY: #[repr(transparent)] &*(std::ptr::from_ref(path) as *const Self) @@ -430,7 +430,9 @@ impl Absolute { root: &Absolute, ) -> Result, crate::PathError> { let Ok(tail) = self.path.strip_prefix(root) else { - return Err(crate::PathError::UnresolveBeyondRoot); + return Err(crate::PathError::UnresolveBeyondRoot( + self.as_inner().to_owned(), + )); }; let mut buf = crate::Path::ROOT.to_owned(); diff --git a/werk-fs/path.rs b/werk-fs/path.rs index 815bafca..75dc96a9 100644 --- a/werk-fs/path.rs +++ b/werk-fs/path.rs @@ -82,7 +82,7 @@ pub enum PathError { #[error("path would contain too many parents (would go above the root)")] TooManyParents, #[error("path is outside the workspace")] - UnresolveBeyondRoot, + UnresolveBeyondRoot(std::path::PathBuf), #[error("illegal character in path: {0}")] IllegalChar(char), #[error("illegal filename stem (reserved on Windows): {0}")] @@ -585,6 +585,12 @@ impl PathBuf { PathBuf { path } } + #[inline] + #[must_use] + pub fn into_string(self) -> String { + self.path + } + pub fn from_utf8(path: Vec) -> Result { match String::from_utf8(path) { Ok(path) => { diff --git a/werk-parser/ast.rs b/werk-parser/ast.rs index 1d98cb10..f111a99a 100644 --- a/werk-parser/ast.rs +++ b/werk-parser/ast.rs @@ -1,4 +1,4 @@ -use std::hash::Hash as _; +use std::hash::Hash; use werk_util::{SemanticHash, Span, Spanned, hash_is_semantic}; @@ -13,7 +13,7 @@ use stringleton::Symbol; /// Whitespace and comments within statements and expressions (not doc /// comments). -#[derive(Default, PartialEq, Clone, Copy)] +#[derive(Default, PartialEq, Clone, Copy, Eq)] #[must_use] pub struct Whitespace(pub Span); @@ -34,22 +34,30 @@ pub struct Trailing { pub token: Option, } -impl PartialEq for Trailing> { +impl PartialEq for Trailing { fn eq(&self, other: &Self) -> bool { self.ws == other.ws - && match (self.token, other.token) { + && match (self.token.as_ref(), other.token.as_ref()) { (None, None) => true, // If the left-hand side does not have a token, but the // whitespace is ignored, consider self.token as ignored also. - (None, Some(rhs)) => self.ws.0.is_ignored() || rhs.0.is_ignored(), + (None, Some(rhs)) => self.ws.0.is_ignored() || rhs.span().is_ignored(), // If the right-hand side does not have a token, but the // whitespace is ignored, consider other.token as ignored also. - (Some(lhs), None) => other.ws.0.is_ignored() || lhs.0.is_ignored(), + (Some(lhs), None) => other.ws.0.is_ignored() || lhs.span().is_ignored(), (Some(lhs), Some(rhs)) => lhs == rhs, } } } +impl Eq for Trailing {} + +impl Hash for Trailing { + fn hash(&self, state: &mut H) { + self.token.hash(state); + } +} + #[inline] pub fn ws(span: std::ops::Range) -> Whitespace { Whitespace(werk_util::span(span)) @@ -127,7 +135,8 @@ pub type IncludeStmt = KwExpr; #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum DefaultStmt { Target(DefaultStmtEntry), - OutDir(DefaultStmtEntry), + CacheDir(DefaultStmtEntry), + DeprecatedOutDir(DefaultStmtEntry), PrintCommands(DefaultStmtEntry), PrintFresh(DefaultStmtEntry), Quiet(DefaultStmtEntry), @@ -144,7 +153,8 @@ impl Spanned for DefaultStmt { fn span(&self) -> Span { match self { DefaultStmt::Target(stmt) => stmt.span, - DefaultStmt::OutDir(stmt) => stmt.span, + DefaultStmt::CacheDir(stmt) => stmt.span, + DefaultStmt::DeprecatedOutDir(stmt) => stmt.span, DefaultStmt::PrintCommands(stmt) => stmt.span, DefaultStmt::PrintFresh(stmt) => stmt.span, DefaultStmt::Quiet(stmt) => stmt.span, @@ -229,7 +239,7 @@ impl Spanned for ConfigBool { } } -#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct Ident { #[serde(skip, default)] @@ -338,7 +348,7 @@ impl SemanticHash for BuildRecipe { } /// A `{...}` block. -#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct Body { #[serde(skip, default)] @@ -363,7 +373,7 @@ impl SemanticHash for Body { } } -#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct BodyStmt { #[serde(skip, default)] diff --git a/werk-parser/ast/keyword.rs b/werk-parser/ast/keyword.rs index c318d17b..7d977e6e 100644 --- a/werk-parser/ast/keyword.rs +++ b/werk-parser/ast/keyword.rs @@ -34,7 +34,7 @@ fn end_of_keyword(input: &mut Input) -> PResult<()> { macro_rules! def_keyword { ($t:ident, $s:literal) => { #[doc = concat!("`", $s, "`")] - #[derive(Clone, Copy, Default, PartialEq)] + #[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct $t(pub Offset); impl Keyword for $t { const TOKEN: &'static str = $s; @@ -149,7 +149,8 @@ def_keyword!(Tail, "tail"); // `default` keys (CLI flag defaults) def_keyword!(Target, "target"); -def_keyword!(OutDir, "out-dir"); +def_keyword!(CacheDir, "cache-dir"); +def_keyword!(OutDir, "out-dir"); // deprecated def_keyword!(PrintCommands, "print-commands"); def_keyword!(PrintFresh, "print-fresh"); def_keyword!(Quiet, "quiet"); diff --git a/werk-parser/ast/string.rs b/werk-parser/ast/string.rs index 1e8ae580..c539e156 100644 --- a/werk-parser/ast/string.rs +++ b/werk-parser/ast/string.rs @@ -188,7 +188,7 @@ impl Interpolation { if options .ops .iter() - .any(|op| matches!(&op, InterpolationOp::ResolveOsPath)) + .any(|op| matches!(&op, InterpolationOp::Resolve)) { return true; } @@ -247,7 +247,7 @@ impl std::fmt::Display for Interpolation { let mut has_colon = false; let mut is_first = true; for op in &options.ops { - if let InterpolationOp::ResolveOsPath = op { + if let InterpolationOp::Resolve = op { continue; } if !has_colon { @@ -271,13 +271,11 @@ impl std::fmt::Display for Interpolation { regex_interpolation_op.regex, regex_interpolation_op.replacer )?, - InterpolationOp::ResolveOsPath => unreachable!(), InterpolationOp::Dedup => f.write_str("dedup")?, InterpolationOp::Filename => f.write_str("filename")?, InterpolationOp::Dirname => f.write_str("dir")?, InterpolationOp::Ext => f.write_str("ext")?, - InterpolationOp::ResolveOutDir => f.write_str("out-dir")?, - InterpolationOp::ResolveWorkspace => f.write_str("workspace")?, + InterpolationOp::Resolve => unreachable!(), } } } @@ -361,9 +359,7 @@ pub enum InterpolationOp { RegexReplace(RegexInterpolationOp), // Interpret the string as an OS path and resolve it. This is the `<..>` // interpolation syntax. - ResolveOsPath, - ResolveOutDir, - ResolveWorkspace, + Resolve, } impl SemanticHash for InterpolationOp { @@ -381,9 +377,7 @@ impl SemanticHash for InterpolationOp { | InterpolationOp::Filename | InterpolationOp::Dirname | InterpolationOp::Ext - | InterpolationOp::ResolveOsPath - | InterpolationOp::ResolveOutDir - | InterpolationOp::ResolveWorkspace => (), + | InterpolationOp::Resolve => (), } } } diff --git a/werk-parser/ast/token.rs b/werk-parser/ast/token.rs index c521894f..5c96593f 100644 --- a/werk-parser/ast/token.rs +++ b/werk-parser/ast/token.rs @@ -7,7 +7,7 @@ use crate::{ parser::{Input, PResult, Parse, Parser as _}, }; -#[derive(Clone, Copy, Default, PartialEq)] +#[derive(Clone, Copy, Default, PartialEq, Eq)] pub struct Token(pub Offset); impl Token { #[inline] diff --git a/werk-parser/parser.rs b/werk-parser/parser.rs index 8a0944de..400cb5f0 100644 --- a/werk-parser/parser.rs +++ b/werk-parser/parser.rs @@ -99,10 +99,10 @@ pub fn parse_werk(source_code: &str) -> Result { .map_err(winnow::error::ParseError::into_inner) } -pub fn parse_werk_with_diagnostics( +pub fn parse_werk_with_diagnostics( file_id: DiagnosticFileId, - source_map: &S, -) -> Result, &S>> { + source_map: &dyn DiagnosticSourceMap, +) -> Result>> { let source = source_map .get_source(file_id) .expect("file ID not found in source map"); @@ -272,18 +272,20 @@ impl Parse for ast::DefaultStmt { alt(( entry(token, ws_1).map(ast::DefaultStmt::Target), - entry(token, ws_1).map(ast::DefaultStmt::OutDir), + entry(token, ws_1).map(ast::DefaultStmt::CacheDir), + entry(token, ws_1).map(ast::DefaultStmt::DeprecatedOutDir), entry(token, ws_1).map(ast::DefaultStmt::PrintCommands), entry(token, ws_1).map(ast::DefaultStmt::PrintFresh), entry(token, ws_1).map(ast::DefaultStmt::Quiet), entry(token, ws_1).map(ast::DefaultStmt::Loud), entry(token, ws_1).map(ast::DefaultStmt::Explain), - entry(token, ws_1).map(ast::DefaultStmt::Verbose), - entry(token, ws_1).map(ast::DefaultStmt::WatchDelay), - entry(token, ws_1).map(ast::DefaultStmt::Jobs), - entry(token, ws_1).map(ast::DefaultStmt::Edition), - fatal(Failure::Expected(&"valid key for `default` statement")).help("one of `target`, `out-dir`, `print-commands`, `print-fresh`, `quiet`, `loud`, `explain`, `verbose`, `watch-delay`, `jobs`, or `edition`"), - )) + alt(( + entry(token, ws_1).map(ast::DefaultStmt::Verbose), + entry(token, ws_1).map(ast::DefaultStmt::WatchDelay), + entry(token, ws_1).map(ast::DefaultStmt::Jobs), + entry(token, ws_1).map(ast::DefaultStmt::Edition), + fatal(Failure::Expected(&"valid key for `default` statement")).help("one of `target`, `out-dir`, `print-commands`, `print-fresh`, `quiet`, `loud`, `explain`, `verbose`, `watch-delay`, `jobs`, or `edition`"), + )))) .parse_next(input) } } @@ -341,11 +343,11 @@ impl Parse for ast::TaskRecipeStmt { parse.map(ast::TaskRecipeStmt::Env), parse.map(ast::TaskRecipeStmt::Info), parse.map(ast::TaskRecipeStmt::Warn), - parse.map(ast::TaskRecipeStmt::SetCapture), + alt((parse.map(ast::TaskRecipeStmt::SetCapture), parse.map(ast::TaskRecipeStmt::SetNoCapture), fatal(Failure::Expected(&"task recipe statement")).help( "could be one of `let`, `from`, `build`, `depfile`, `run`, `spawn`, `info`, or `warn` statement", - ), + ))), )) .parse_next(input) } @@ -382,11 +384,13 @@ impl Parse for ast::BuildRecipeStmt { parse.map(ast::BuildRecipeStmt::Env), parse.map(ast::BuildRecipeStmt::Info), parse.map(ast::BuildRecipeStmt::Warn), - parse.map(ast::BuildRecipeStmt::SetCapture), - parse.map(ast::BuildRecipeStmt::SetNoCapture), - fatal(Failure::Expected(&"build recipe statement")).help( - "could be one of `let`, `from`, `build`, `depfile`, `run`, or `echo` statement", - ), + alt(( + parse.map(ast::BuildRecipeStmt::SetCapture), + parse.map(ast::BuildRecipeStmt::SetNoCapture), + fatal(Failure::Expected(&"build recipe statement")).help( + "could be one of `let`, `from`, `build`, `depfile`, `run`, or `echo` statement", + ), + )), )) .parse_next(input) } @@ -537,10 +541,10 @@ impl Parse for ast::Expr { parse.map(ast::Expr::Which), parse.map(ast::Expr::Env), parse.map(ast::Expr::Error), - parse.map(ast::Expr::Ident), - parse.map(ast::Expr::SubExpr), - fatal(Failure::Expected(&"expression")) - .help("expressions must start with a value, or an `env`, `glob`, `which`, or `shell` operation") + alt((parse.map(ast::Expr::Ident), + parse.map(ast::Expr::SubExpr), + fatal(Failure::Expected(&"expression")) + .help("expressions must start with a value, or an `env`, `glob`, `which`, or `shell` operation"))) )) .parse_next(input)?; @@ -664,16 +668,22 @@ fn expression_chain_op(input: &mut Input) -> PResult { parse.map(ast::ExprOp::Map), parse.map(ast::ExprOp::Flatten), parse.map(ast::ExprOp::Filter), + )), + alt(( parse.map(ast::ExprOp::FilterMatch), parse.map(ast::ExprOp::Discard), parse.map(ast::ExprOp::Join), parse.map(ast::ExprOp::Split), - parse.map(ast::ExprOp::Dedup))), - alt((parse.map(ast::ExprOp::Lines), + parse.map(ast::ExprOp::Dedup), + )), + alt(( + parse.map(ast::ExprOp::Lines), parse.map(ast::ExprOp::Len), parse.map(ast::ExprOp::First), parse.map(ast::ExprOp::Last), parse.map(ast::ExprOp::Tail), + )), + alt(( parse.map(ast::ExprOp::Info), parse.map(ast::ExprOp::Warn), parse.map(ast::ExprOp::Error), @@ -706,15 +716,16 @@ fn parse_run_expr(input: &mut Input<'_>) -> PResult.map(ast::RunExpr::Block), - fatal(Failure::Expected(&"a run expression")) - .help(if ALLOW_SPAWN { - "one of `shell`, `spawn`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block" - } else { "one of `shell`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block"} ) + alt((parse.map(ast::RunExpr::Delete), + parse.map(ast::RunExpr::Touch), + parse.map(ast::RunExpr::EnvRemove), + parse.map(ast::RunExpr::Env), + parse_run_expr_block::.map(ast::RunExpr::Block), + fatal(Failure::Expected(&"a run expression")) + .help(if ALLOW_SPAWN { + "one of `shell`, `spawn`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block" + } else { "one of `shell`, `info`, `warn`, `write`, `copy`, `delete`, `env`, `env-remove`, a string literal, a list, or a block"} ) + )) )).parse_next(input) } @@ -1193,7 +1204,7 @@ mod tests { statements: vec![ ast::BodyStmt { ws_pre: ws_ignore(), - statement: ast::RootStmt::Default(ast::DefaultStmt::OutDir( + statement: ast::RootStmt::Default(ast::DefaultStmt::DeprecatedOutDir( ast::DefaultStmtEntry { span: span(0..29), token: keyword::Default(Offset(0)), diff --git a/werk-parser/parser/string.rs b/werk-parser/parser/string.rs index b80bc895..c9df48f8 100644 --- a/werk-parser/parser/string.rs +++ b/werk-parser/parser/string.rs @@ -347,7 +347,7 @@ fn path_interpolation(input: &mut Input) -> PResult { .options .get_or_insert_default() .ops - .push(ast::InterpolationOp::ResolveOsPath); + .push(ast::InterpolationOp::Resolve); Ok(interp) } @@ -431,7 +431,7 @@ fn interpolation_op(input: &mut Input) -> PResult { to: to.to_owned(), }), interpolation_op_regex_replace.map(ast::InterpolationOp::RegexReplace), - interpolation_op_kw, + interpolation_op_kw.or_fail(Failure::InvalidInterpolationOp), )) .parse_next(input) } @@ -444,8 +444,6 @@ fn interpolation_op_kw(input: &mut Input) -> PResult { "filename" => Ok(ast::InterpolationOp::Filename), "dir" => Ok(ast::InterpolationOp::Dirname), "ext" => Ok(ast::InterpolationOp::Ext), - "out-dir" => Ok(ast::InterpolationOp::ResolveOutDir), - "workspace" => Ok(ast::InterpolationOp::ResolveWorkspace), _ => Err(ModalErr::Error(Error::new( Offset(location as u32), Failure::InvalidInterpolationOp, @@ -566,7 +564,7 @@ mod tests { from: ".ext1".into(), to: ".ext2".into(), }, - ast::InterpolationOp::ResolveOsPath, + ast::InterpolationOp::Resolve, ], join: None, })), diff --git a/werk-parser/tests/succeed/c.json b/werk-parser/tests/succeed/c.json index d6d7b682..5730761d 100644 --- a/werk-parser/tests/succeed/c.json +++ b/werk-parser/tests/succeed/c.json @@ -1,346 +1,346 @@ [ - { - "Default": { - "OutDir": "../../target/examples/c" - } - }, - { - "Default": { - "Target": "build" - } - }, - { - "Let": { - "ident": "cc", - "expr": { - "type": "Which", - "value": "clang" - }, - "ops": [] + { + "Default": { + "DeprecatedOutDir": "../../target/examples/c" + } + }, + { + "Default": { + "Target": "build" + } + }, + { + "Let": { + "ident": "cc", + "expr": { + "type": "Which", + "value": "clang" + }, + "ops": [] + } + }, + { + "Let": { + "ident": "ld", + "expr": { + "type": "Ident", + "value": "cc" + }, + "ops": [] + } + }, + { + "Let": { + "ident": "profile", + "expr": { + "type": "StringExpr", + "value": "debug" + }, + "ops": [] + } + }, + { + "Let": { + "ident": "executable", + "expr": { + "type": "StringExpr", + "value": "{profile}/example{EXE_SUFFIX}" + }, + "ops": [] + } + }, + { + "Let": { + "ident": "cflags", + "expr": { + "type": "Ident", + "value": "profile" + }, + "ops": [ + { + "Match": { + "Braced": [ + { + "pattern": "debug", + "expr": { + "expr": { + "type": "List", + "value": { + "items": [ + { + "expr": { + "type": "StringExpr", + "value": "-g" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-O0" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fdiagnostics-color=always" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fcolor-diagnostics" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fansi-escape-codes" + }, + "ops": [] + } + ] + } + }, + "ops": [] + } + }, + { + "pattern": "release", + "expr": { + "expr": { + "type": "List", + "value": { + "items": [ + { + "expr": { + "type": "StringExpr", + "value": "-O3" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fdiagnostics-color=always" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fcolor-diagnostics" + }, + "ops": [] + }, + { + "expr": { + "type": "StringExpr", + "value": "-fansi-escape-codes" + }, + "ops": [] + } + ] + } + }, + "ops": [] + } + }, + { + "pattern": "%", + "expr": { + "expr": { + "type": "Error", + "value": "Unknown profile '{}'; valid options are 'debug' and 'release'" + }, + "ops": [] + } + } + ] + } } - }, - { - "Let": { - "ident": "ld", + ] + } + }, + { + "Build": { + "pattern": "{profile}/%.o", + "body": [ + { + "From": { "expr": { - "type": "Ident", - "value": "cc" + "type": "StringExpr", + "value": "{%}.c" }, "ops": [] - } - }, - { - "Let": { - "ident": "profile", + } + }, + { + "Depfile": { "expr": { - "type": "StringExpr", - "value": "debug" + "type": "StringExpr", + "value": "/{profile}/{%}.c.d" }, "ops": [] + } + }, + { + "Run": { + "type": "Shell", + "value": "{cc} -c {cflags*} -o " + } } - }, - { - "Let": { - "ident": "executable", + ] + } + }, + { + "Build": { + "pattern": "{profile}/%.c.d", + "body": [ + { + "From": { "expr": { - "type": "StringExpr", - "value": "{profile}/example{EXE_SUFFIX}" + "type": "StringExpr", + "value": "{%}.c" }, "ops": [] + } + }, + { + "Run": { + "type": "Shell", + "value": "{cc} -MM -MT -MF " + } } - }, - { - "Let": { - "ident": "cflags", + ] + } + }, + { + "Build": { + "pattern": "{executable}", + "body": [ + { + "From": { "expr": { - "type": "Ident", - "value": "profile" + "type": "Glob", + "value": "*.c" }, "ops": [ - { - "Match": { - "Braced": [ - { - "pattern": "debug", - "expr": { - "expr": { - "type": "List", - "value": { - "items": [ - { - "expr": { - "type": "StringExpr", - "value": "-g" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-O0" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fdiagnostics-color=always" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fcolor-diagnostics" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fansi-escape-codes" - }, - "ops": [] - } - ] - } - }, - "ops": [] - } - }, - { - "pattern": "release", - "expr": { - "expr": { - "type": "List", - "value": { - "items": [ - { - "expr": { - "type": "StringExpr", - "value": "-O3" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fdiagnostics-color=always" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fcolor-diagnostics" - }, - "ops": [] - }, - { - "expr": { - "type": "StringExpr", - "value": "-fansi-escape-codes" - }, - "ops": [] - } - ] - } - }, - "ops": [] - } - }, - { - "pattern": "%", - "expr": { - "expr": { - "type": "Error", - "value": "Unknown profile '{}'; valid options are 'debug' and 'release'" - }, - "ops": [] - } - } - ] - } - } - ] - } - }, - { - "Build": { - "pattern": "{profile}/%.o", - "body": [ - { - "From": { + { + "Match": { + "Braced": [ + { + "pattern": "%.c", + "expr": { "expr": { - "type": "StringExpr", - "value": "{%}.c" + "type": "StringExpr", + "value": "/{profile}{%}.o" }, "ops": [] + } } - }, - { - "Depfile": { - "expr": { - "type": "StringExpr", - "value": "/{profile}/{%}.c.d" - }, - "ops": [] - } - }, - { - "Run": { - "type": "Shell", - "value": "{cc} -c {cflags*} -o " - } + ] } + } ] + } + }, + { + "Run": { + "type": "Shell", + "value": "{ld} -o " + } } - }, - { - "Build": { - "pattern": "{profile}/%.c.d", - "body": [ - { - "From": { - "expr": { - "type": "StringExpr", - "value": "{%}.c" - }, - "ops": [] - } - }, - { - "Run": { - "type": "Shell", - "value": "{cc} -MM -MT -MF " - } - } - ] + ] + } + }, + { + "Task": { + "name": "build", + "body": [ + { + "Build": { + "expr": { + "type": "Ident", + "value": "executable" + }, + "ops": [] + } + }, + { + "Info": "Build done for profile '{profile}'" } - }, - { - "Build": { - "pattern": "{executable}", - "body": [ - { - "From": { - "expr": { - "type": "Glob", - "value": "*.c" - }, - "ops": [ - { - "Match": { - "Braced": [ - { - "pattern": "%.c", - "expr": { - "expr": { - "type": "StringExpr", - "value": "/{profile}{%}.o" - }, - "ops": [] - } - } - ] - } - } - ] - } - }, - { - "Run": { - "type": "Shell", - "value": "{ld} -o " - } - } - ] + ] + } + }, + { + "Task": { + "name": "run", + "body": [ + { + "Build": { + "expr": { + "type": "StringExpr", + "value": "build" + }, + "ops": [] + } + }, + { + "Run": { + "type": "Shell", + "value": "" + } } - }, - { - "Task": { - "name": "build", - "body": [ - { - "Build": { - "expr": { - "type": "Ident", - "value": "executable" - }, - "ops": [] - } - }, - { - "Info": "Build done for profile '{profile}'" + ] + } + }, + { + "Task": { + "name": "clean", + "body": [ + { + "Let": { + "ident": "object-files", + "expr": { + "type": "Glob", + "value": "*.c" + }, + "ops": [ + { + "Map": { + "type": "StringExpr", + "value": "{profile}{:.c=.o}" } + } ] - } - }, - { - "Task": { - "name": "run", - "body": [ - { - "Build": { - "expr": { - "type": "StringExpr", - "value": "build" - }, - "ops": [] - } - }, - { - "Run": { - "type": "Shell", - "value": "" - } + } + }, + { + "Run": { + "type": "Block", + "value": [ + { + "type": "Delete", + "value": { + "type": "Ident", + "value": "object-files" } - ] - } - }, - { - "Task": { - "name": "clean", - "body": [ - { - "Let": { - "ident": "object-files", - "expr": { - "type": "Glob", - "value": "*.c" - }, - "ops": [ - { - "Map": { - "type": "StringExpr", - "value": "{profile}{:.c=.o}" - } - } - ] - } - }, - { - "Run": { - "type": "Block", - "value": [ - { - "type": "Delete", - "value": { - "type": "Ident", - "value": "object-files" - } - }, - { - "type": "Delete", - "value": { - "type": "Ident", - "value": "executable" - } - } - ] - } + }, + { + "type": "Delete", + "value": { + "type": "Ident", + "value": "executable" } + } ] + } } + ] } + } ] diff --git a/werk-parser/tests/succeed/config.json b/werk-parser/tests/succeed/config.json index 971bee8b..d96d3158 100644 --- a/werk-parser/tests/succeed/config.json +++ b/werk-parser/tests/succeed/config.json @@ -1,12 +1,12 @@ [ - { - "Default": { - "OutDir": "../../target/examples/c" - } - }, - { - "Default": { - "Target": "build" - } + { + "Default": { + "DeprecatedOutDir": "../../target/examples/c" } + }, + { + "Default": { + "Target": "build" + } + } ] diff --git a/werk-parser/tests/test_cases.rs b/werk-parser/tests/test_cases.rs index f60ba2ed..af58485d 100644 --- a/werk-parser/tests/test_cases.rs +++ b/werk-parser/tests/test_cases.rs @@ -39,7 +39,10 @@ macro_rules! error_case { let rendered = err .with_file(werk_util::DiagnosticFileId::default()) - .into_diagnostic_error(DiagnosticSource::new(std::path::Path::new("INPUT"), &input)) + .into_diagnostic_error(&DiagnosticSource::new( + std::path::Path::new("INPUT"), + &input, + )) .to_string(); let rendered_stripped = fix_newlines(&strip_colors(&rendered)); @@ -79,7 +82,7 @@ macro_rules! success_case { Err(err) => { let rendered = err .with_file(werk_util::DiagnosticFileId::default()) - .into_diagnostic_error(DiagnosticSource::new( + .into_diagnostic_error(&DiagnosticSource::new( std::path::Path::new("INPUT"), &input, )) diff --git a/werk-planner/Cargo.toml b/werk-planner/Cargo.toml new file mode 100644 index 00000000..ec7b66d7 --- /dev/null +++ b/werk-planner/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "werk-planner" +version.workspace = true +rust-version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ahash.workspace = true +annotate-snippets.workspace = true +globset.workspace = true +indexmap.workspace = true +literator.workspace = true +stringleton.workspace = true +thiserror.workspace = true +tinyvec.workspace = true +tracing.workspace = true +werk-depfile.workspace = true +werk-eval.workspace = true +werk-fs.workspace = true +werk-parser.workspace = true +werk-util.workspace = true +which.workspace = true + +[lib] +path = "lib.rs" + +[lints] +workspace = true diff --git a/werk-planner/error.rs b/werk-planner/error.rs new file mode 100644 index 00000000..5e5ca5b3 --- /dev/null +++ b/werk-planner/error.rs @@ -0,0 +1,75 @@ +use annotate_snippets::{AnnotationKind, Snippet}; +use literator::Literator as _; +use werk_eval::EvalError; +use werk_util::{AsDiagnostic, DiagnosticSpan, Level}; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum PlannerError { + #[error(transparent)] + AmbiguousPattern(#[from] AmbiguousPatternError), + #[error("no rule to build target: {0}")] + NoRuleToBuildTarget(String), + #[error(transparent)] + CircularDependency(#[from] CircularDependencyError), + #[error(transparent)] + Evaluation(#[from] EvalError), +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +#[error("ambiguous pattern match: {path}")] +pub struct AmbiguousPatternError { + pub pattern1: DiagnosticSpan, + pub pattern2: DiagnosticSpan, + pub path: String, +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +#[error("circular dependency: {}", .chain.iter().join(" -> "))] +pub struct CircularDependencyError { + pub chain: Vec, +} + +impl AsDiagnostic for PlannerError { + fn as_diagnostic<'a>( + &'a self, + source_map: &'a dyn werk_util::DiagnosticSourceMap, + ) -> Vec> { + let id = match self { + PlannerError::AmbiguousPattern(..) => "R0011", + PlannerError::NoRuleToBuildTarget(_) => "R0003", + PlannerError::CircularDependency(..) => "R0004", + PlannerError::Evaluation(eval_error) => return eval_error.as_diagnostic(source_map), + }; + + // Use the Display impl from thiserror. + let mut diag = vec![annotate_snippets::Group::with_title( + Level::ERROR.primary_title(self.to_string()).id(id), + )]; + + // Additional context and help + if let PlannerError::AmbiguousPattern(err) = self { + let first_source = source_map + .get_source(err.pattern1.file) + .expect("valid file ID"); + let second_source = source_map + .get_source(err.pattern2.file) + .expect("valid file ID"); + diag.push( + Level::NOTE.secondary_title("first pattern here").element( + Snippet::source(first_source.source) + .path(first_source.file) + .annotation(AnnotationKind::Context.span(err.pattern1.span.into())), + ), + ); + diag.push( + Level::NOTE.secondary_title("second pattern here").element( + Snippet::source(second_source.source) + .path(second_source.file) + .annotation(AnnotationKind::Context.span(err.pattern2.span.into())), + ), + ); + } + + diag + } +} diff --git a/werk-planner/lib.rs b/werk-planner/lib.rs new file mode 100644 index 00000000..f1f04344 --- /dev/null +++ b/werk-planner/lib.rs @@ -0,0 +1,19 @@ +mod error; +mod manifest; +mod planner; +mod recipe; +mod recipe_scopes; +mod task_graph; +mod task_id; +mod task_spec; + +pub use error::*; +pub use manifest::*; +pub use planner::*; +pub use recipe::*; +pub use recipe_scopes::*; +pub use task_graph::*; +pub use task_id::*; +pub use task_spec::*; + +stringleton::enable!(werk_eval); diff --git a/werk-planner/manifest.rs b/werk-planner/manifest.rs new file mode 100644 index 00000000..5fd62fc4 --- /dev/null +++ b/werk-planner/manifest.rs @@ -0,0 +1,159 @@ +use indexmap::IndexMap; +use stringleton::Symbol; +use werk_eval::{ConfigVar, EvalError, LocalVariables, Value}; +use werk_fs::Absolute; +use werk_util::{DiagnosticMainSourceMap, DiagnosticSpan}; + +use crate::{AmbiguousPatternError, BuildRecipe, BuildRecipeMatch, RecipeMatch, TaskRecipe}; + +type Result = std::result::Result; + +/// Representation of the `Werkfile` manifest, partially evaluated. +/// +/// - Global variables are fully evaluated. +/// - Recipe patterns are fully evaluated. +/// - Doc comments for all global items are gathered. +/// - Recipe bodies are *not* evaluated, and refer directly into the AST. +#[derive(Default)] +pub struct Manifest { + /// `config` variables and their value at the point of declaration, + /// collected across all included source files. + pub config_variables: IndexMap, + /// `let` statements in the global scope. + pub global_variables: LocalVariables, + pub task_recipes: IndexMap<&'static str, TaskRecipe>, + pub build_recipes: Vec, + /// Populated by `include` statements during evaluation of the root scope in + /// the werkfile. + pub source_map: DiagnosticMainSourceMap, +} + +impl Manifest { + pub fn set_config( + &mut self, + name: Symbol, + value: Value, + span: DiagnosticSpan, + comment: String, + ) -> Result<(), EvalError> { + if let Some(previous_value) = self.config_variables.insert( + name, + ConfigVar { + value, + comment, + span, + }, + ) { + return Err(EvalError::DuplicateConfigStatement( + span, + previous_value.span, + )); + } + Ok(()) + } + + #[inline] + #[must_use] + pub fn match_task_recipe(&self, name: &str) -> Option<&TaskRecipe> { + self.task_recipes.get(name) + } + + pub fn match_build_recipe<'b>( + &'b self, + path: &Absolute, + ) -> Result>, AmbiguousPatternError> { + let matches = self.build_recipes.iter().filter_map(|recipe| { + recipe + .pattern + .match_whole_path(path) + .map(|match_data| (recipe, match_data)) + }); + + let mut best_match = None; + + for (candidate_recipe, candidate_pattern_match) in matches { + match best_match { + None => { + // No match yet, pick this candidate. + best_match = Some((candidate_recipe, candidate_pattern_match)); + } + Some((best_recipe, ref best_data)) => { + match (best_data.stem(), candidate_pattern_match.stem()) { + (None, Some(_)) => { + // Best match is exact, do nothing. + } + (Some(_), None) => { + // Candidate is exact, do nothing. + best_match = Some((candidate_recipe, candidate_pattern_match)); + } + (Some(best_stem), Some(candidate_stem)) + if candidate_stem.len() < best_stem.len() => + { + // Candidate has a shorter stem, so it's better. + best_match = Some((candidate_recipe, candidate_pattern_match)); + } + (Some(best_stem), Some(candidate_stem)) + if candidate_stem.len() > best_stem.len() => + { + // Candidate has a longer stem, so it's worse; do nothing. + } + _ => { + return Err(AmbiguousPatternError { + pattern1: best_recipe.pattern.span, + pattern2: candidate_recipe.pattern.span, + path: path.to_string(), + }); + } + } + } + } + } + + Ok(best_match.map(|(recipe, match_data)| BuildRecipeMatch { + recipe, + match_data, + target_file: Absolute::symbolicate(path), + })) + } + + pub fn match_recipe_by_name<'b>( + &'b self, + name: &str, + ) -> Result>, AmbiguousPatternError> { + let task = self.match_task_recipe(name); + + if let Ok(path) = werk_fs::Path::new(name) { + if let Ok(path) = path.normalize() { + if let Some(build_recipe_match) = self.match_build_recipe(&path)? { + if let Some(task) = task { + return Err(AmbiguousPatternError { + pattern1: build_recipe_match.recipe.pattern.span, + pattern2: task.ast.name.span.with_file(task.span.file), + path: name.to_owned(), + }); + } + + return Ok(Some(RecipeMatch::Build(build_recipe_match))); + } + } + } + + Ok(task.map(RecipeMatch::Task)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Edition { + #[default] + V1, +} + +impl werk_util::DiagnosticSourceMap for Manifest { + #[inline] + fn get_source( + &self, + id: werk_util::DiagnosticFileId, + ) -> Option> { + self.source_map.get_source(id) + } +} diff --git a/werk-planner/planner.rs b/werk-planner/planner.rs new file mode 100644 index 00000000..20de361c --- /dev/null +++ b/werk-planner/planner.rs @@ -0,0 +1,325 @@ +use ahash::HashSet; +use stringleton::sym; +use werk_depfile::Depfile; +use werk_eval::{Eval, Scope, TaskName, Value, Warning}; +use werk_fs::{Absolute, Normalize}; +use werk_util::{Annotated, AsDiagnostic, DiagnosticSpan, Spanned as _}; + +use crate::{ + AmbiguousPatternError, BuildRecipeMatch, BuildRecipeScope, EvaluatedTask, Manifest, + PlannerError, RecipeMatch, TaskGraph, TaskId, TaskRecipe, TaskRecipeScope, TaskSpec, +}; + +pub struct Planner<'a> { + graph: TaskGraph<'a>, + manifest: &'a Manifest, + + pending_goals: Vec, + /// Dependencies discovered through depfiles, which shouldn't raise errors + /// in case of cycles or other reasons. + tentative_dependencies: Vec<(TaskId, TaskId)>, +} + +impl<'a> Planner<'a> { + #[must_use] + pub fn new(manifest: &'a Manifest) -> Self { + Self { + graph: TaskGraph::default(), + manifest, + pending_goals: vec![], + tentative_dependencies: vec![], + } + } + + pub fn add_goal_by_name(&mut self, name: &str) -> Result> { + let task_recipe_match = self.manifest.match_task_recipe(name); + + if let Ok(path) = werk_fs::Path::new(name).and_then(|p| p.absolutize(werk_fs::Path::ROOT)) { + if let Some(build_recipe_match) = + self.manifest.match_build_recipe(&path).map_err(|e| { + PlannerError::AmbiguousPattern(e).into_diagnostic_error(self.manifest) + })? + { + // Check for name collisions between task and build recipes. + if let Some(task_recipe) = task_recipe_match { + return Err(PlannerError::AmbiguousPattern(AmbiguousPatternError { + pattern1: task_recipe.span, + pattern2: build_recipe_match.recipe.span, + path: path.to_string(), + }) + .into_diagnostic_error(self.manifest)); + } + + return Ok( + self.add_goal_by_spec(TaskSpec::Recipe(RecipeMatch::Build(build_recipe_match))) + ); + } else if task_recipe_match.is_none() { + return Ok( + self.add_goal_by_spec(TaskSpec::CheckExists(Absolute::symbolicate(path))) + ); + } + } + + match task_recipe_match { + Some(task_recipe) => { + Ok(self.add_goal_by_spec(TaskSpec::Recipe(RecipeMatch::Task(task_recipe)))) + } + // This branch only gets hit if the name was not also a valid path. + None => Err(PlannerError::NoRuleToBuildTarget(name.to_string()) + .into_diagnostic_error(self.manifest)), + } + } + + fn add_goal_by_spec(&mut self, spec: TaskSpec<'a>) -> TaskId { + let task_id = self.graph.get_or_insert_task_spec(spec); + self.pending_goals.push(task_id); + task_id + } + + /// Discovers all dependencies between tasks and builds a task graph. + /// + /// This fully evaluates all statements in all tasks, but does not execute commands. + pub fn plan( + mut self, + global_scope: &dyn Scope, + ) -> Result, Annotated<'a, PlannerError>> { + while let Some(task_id) = self.pending_goals.pop() { + if self.graph.is_evaluated(task_id) { + continue; + } + + let spec = self.graph.get_task_spec(task_id).clone(); + match spec { + TaskSpec::Recipe(RecipeMatch::Build(build_recipe_match)) => { + tracing::trace!("Planning build recipe: {}", build_recipe_match.target_file); + self.evaluate_and_plan_build_recipe( + task_id, + &build_recipe_match, + global_scope, + )?; + } + TaskSpec::Recipe(RecipeMatch::Task(task_recipe_match)) => { + tracing::trace!("Planning task recipe: {}", task_recipe_match.name); + self.evaluate_and_plan_task_recipe(task_id, task_recipe_match, global_scope)?; + } + TaskSpec::CheckExists(path) | TaskSpec::CheckExistsRelaxed(path) => { + tracing::trace!("Planning check-exists: {path}"); + self.graph + .set_evaluated(task_id, EvaluatedTask::CheckExists); + } + } + } + + self.graph.check_circular_dependencies().map_err(|e| { + PlannerError::CircularDependency(e).into_diagnostic_error(self.manifest) + })?; + + let mut tentative_dependency_scratch = HashSet::default(); + for (task_id, potential_dependency) in self.tentative_dependencies.drain(..) { + if self.graph.would_cause_circular_dependency( + task_id, + potential_dependency, + &mut tentative_dependency_scratch, + ) { + global_scope.warning(&Warning::custom(None, format_args!("Ignoring dependency discovered via depfile, because it would cause a circular dependency: {} -> {}", self.graph.get_task_spec(task_id).name(), self.graph.get_task_spec(potential_dependency).name()))); + } else { + self.graph.add_dependency(task_id, potential_dependency); + } + } + + Ok(self.graph) + } + + fn evaluate_and_plan_task_recipe( + &mut self, + task_id: TaskId, + task_recipe_match: &'a TaskRecipe, + global_scope: &dyn Scope, + ) -> Result<(), Annotated<'a, PlannerError>> { + let mut scope = TaskRecipeScope::new(global_scope, TaskName::Task(task_recipe_match.name)); + let evaluated = werk_eval::eval_task_recipe_statements( + &mut scope, + &task_recipe_match.ast.body.statements, + task_recipe_match.span.file, + ) + .map_err(|e| PlannerError::Evaluation(e).into_diagnostic_error(self.manifest))?; + // Discover dependencies and create new tasks for them. + for dep in &evaluated.build { + let dep_id = self.add_goal_by_name(&dep.string)?; + self.graph.add_dependency(task_id, dep_id); + } + + self.graph + .set_evaluated(task_id, EvaluatedTask::Task(evaluated)); + Ok(()) + } + + fn evaluate_and_plan_build_recipe( + &mut self, + task_id: TaskId, + build_recipe_match: &BuildRecipeMatch<'a>, + global_scope: &dyn Scope, + ) -> Result<(), Annotated<'a, PlannerError>> { + let mut scope = BuildRecipeScope::new( + global_scope, + TaskName::Build(build_recipe_match.target_file), + build_recipe_match, + ); + scope.set( + sym!(out), + Eval::inherent(Value::from(build_recipe_match.target_file.to_string())), + ); + + let evaluated = werk_eval::eval_build_recipe_statements( + &mut scope, + build_recipe_match.recipe.span, + &build_recipe_match.recipe.ast.body.statements, + build_recipe_match.recipe.span.file, + ) + .map_err(|e| PlannerError::Evaluation(e).into_diagnostic_error(self.manifest))?; + // Discover dependencies and create new tasks for them. + for dep in &evaluated.explicit_dependencies { + let dep_id = self.add_goal_by_name(&dep.string)?; + self.graph.add_dependency(task_id, dep_id); + } + if let Some(depfile) = evaluated.depfile.as_ref() { + // For diagnostics, find the `depfile` statement. + let depfile_span = build_recipe_match + .recipe + .ast + .body + .statements + .iter() + .find_map(|stmt| match stmt.statement { + werk_parser::ast::BuildRecipeStmt::Depfile(ref kw_expr) => Some(kw_expr.span), + _ => None, + }) + .unwrap_or(build_recipe_match.recipe.ast.token_build.span()); + + self.evaluate_build_recipe_depfile( + task_id, + depfile_span.with_file(build_recipe_match.recipe.span.file), + depfile, + &scope, + ); + } + self.graph + .set_evaluated(task_id, EvaluatedTask::Build(evaluated)); + Ok(()) + } + + fn evaluate_build_recipe_depfile( + &mut self, + task_id: TaskId, + span: DiagnosticSpan, + depfile_path: &str, + scope: &BuildRecipeScope<'_>, + ) { + let Ok(depfile_path) = werk_fs::Path::new(depfile_path) else { + scope.warning(&Warning::custom( + Some(span), + format_args!("invalid depfile path '{depfile_path}'; ignoring"), + )); + return; + }; + let depfile_path = match depfile_path.absolutize(werk_fs::Path::ROOT) { + Ok(depfile_path) => depfile_path, + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!( + "error absolutizing depfile path '{depfile_path}': {err}; ignoring" + ), + )); + return; + } + }; + + match self.manifest.match_build_recipe(&depfile_path) { + Ok(Some(recipe_match)) => { + let depfile_task_id = + self.add_goal_by_spec(TaskSpec::Recipe(RecipeMatch::Build(recipe_match))); + self.graph.add_dependency(task_id, depfile_task_id); + } + Ok(None) => (), + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!("ambiguous depfile recipe: {err}; ignoring"), + )); + } + } + + let depfile_os_path = scope.resolve_path(&depfile_path); + + let depfile_contents = match scope.io().read_file(&depfile_os_path) { + Ok(depfile_contents) => depfile_contents, + Err(err) if err.error.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!("error reading depfile '{depfile_path}': {err}; ignoring"), + )); + return; + } + }; + + let depfile = match Depfile::parse(&depfile_contents) { + Ok(depfile) => depfile, + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!("error parsing depfile '{depfile_path}': {err}; ignoring"), + )); + return; + } + }; + + for dep in &depfile.deps { + let dep_os_path = match dep.as_path().normalize() { + Ok(dep_path) => dep_path, + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!( + "error normalizing depfile path '{}': {err}; ignoring", + dep.display() + ), + )); + continue; + } + }; + let dep_path = match scope.unresolve_path(&dep_os_path) { + Ok(dep_path) => dep_path, + Err(err) => { + scope.warning(&Warning::custom( + Some(span), + format_args!( + "error converting depfile path to workspace path '{}': {err}; ignoring", + dep.display() + ), + )); + continue; + } + }; + + match self.manifest.match_build_recipe(&dep_path) { + Ok(Some(dep_task_match)) => { + let dep_task_id = + self.add_goal_by_spec(TaskSpec::Recipe(RecipeMatch::Build(dep_task_match))); + self.tentative_dependencies.push((task_id, dep_task_id)); + } + Ok(None) => { + let dep_task_id = self.add_goal_by_spec(TaskSpec::CheckExistsRelaxed( + Absolute::symbolicate(dep_path), + )); + self.tentative_dependencies.push((task_id, dep_task_id)); + } + Err(err) => scope.warning(&Warning::custom( + Some(span), + format_args!("ambiguous dependency '{dep_path}' in depfile: {err}; ignoring"), + )), + } + } + } +} diff --git a/werk-planner/recipe.rs b/werk-planner/recipe.rs new file mode 100644 index 00000000..0646c84d --- /dev/null +++ b/werk-planner/recipe.rs @@ -0,0 +1,78 @@ +use stringleton::Symbol; +use werk_eval::{Pattern, PatternMatchData}; +use werk_fs::{Absolute, SymPath}; +use werk_parser::ast; +use werk_util::{DiagnosticSpan, hash128::Hash128}; + +#[derive(Clone, Debug)] +pub enum RecipeMatch<'a> { + Task(&'a TaskRecipe), + Build(BuildRecipeMatch<'a>), +} + +impl PartialEq for RecipeMatch<'_> { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Task(l0), Self::Task(r0)) => std::ptr::eq(l0, r0), + (Self::Build(l0), Self::Build(r0)) => l0 == r0, + _ => false, + } + } +} + +impl Eq for RecipeMatch<'_> {} + +impl std::hash::Hash for RecipeMatch<'_> { + fn hash(&self, state: &mut H) { + core::mem::discriminant(self).hash(state); + match self { + RecipeMatch::Task(task_recipe) => { + std::ptr::from_ref::(*task_recipe).hash(state); + } + RecipeMatch::Build(build_recipe_match) => build_recipe_match.hash(state), + } + } +} + +#[derive(Clone, Debug)] +pub struct BuildRecipeMatch<'a> { + pub recipe: &'a BuildRecipe, + pub match_data: PatternMatchData, + pub target_file: Absolute, +} + +impl PartialEq for BuildRecipeMatch<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.recipe, other.recipe) + && self.match_data == other.match_data + && self.target_file == other.target_file + } +} + +impl Eq for BuildRecipeMatch<'_> {} + +impl std::hash::Hash for BuildRecipeMatch<'_> { + fn hash(&self, state: &mut H) { + std::ptr::from_ref::(self.recipe).hash(state); + self.match_data.hash(state); + self.target_file.hash(state); + } +} + +#[derive(Debug, PartialEq)] +pub struct TaskRecipe { + pub span: DiagnosticSpan, + pub name: Symbol, + pub doc_comment: String, + pub ast: ast::TaskRecipe, + pub hash: Hash128, +} + +#[derive(PartialEq, Debug)] +pub struct BuildRecipe { + pub span: DiagnosticSpan, + pub pattern: Pattern, + pub doc_comment: String, + pub ast: ast::BuildRecipe, + pub hash: Hash128, +} diff --git a/werk-planner/recipe_scopes.rs b/werk-planner/recipe_scopes.rs new file mode 100644 index 00000000..b88f4763 --- /dev/null +++ b/werk-planner/recipe_scopes.rs @@ -0,0 +1,240 @@ +use stringleton::{Symbol, sym}; +use werk_eval::{ + Eval, GlobError, Io, LocalVariables, Lookup, LookupValue, Messenger, Scope, ScopeMut, TaskName, + Value, Warning, default_global_constants, +}; +use werk_fs::{Absolute, PathError}; + +use crate::BuildRecipeMatch; + +pub struct BuildRecipeScope<'a> { + global_scope: &'a dyn Scope, + vars: LocalVariables, + task_id: TaskName, + recipe_match: &'a BuildRecipeMatch<'a>, + input_files: Value, + output_file: Value, +} + +impl<'a> BuildRecipeScope<'a> { + #[inline] + #[must_use] + pub fn new( + global_scope: &'a dyn Scope, + task_id: TaskName, + recipe_match: &'a BuildRecipeMatch<'a>, + ) -> Self { + Self { + global_scope, + vars: LocalVariables::new(), + task_id, + recipe_match, + input_files: Value::List(Vec::new()), + output_file: recipe_match.target_file.to_string().into(), + } + } + + pub fn set(&mut self, local_name: Symbol, value: Eval) { + self.vars.insert(local_name, value); + } + + pub fn push_input_file(&mut self, name: String) { + let Value::List(ref mut input_files) = self.input_files else { + unreachable!() + }; + input_files.push(name.into()); + } +} + +impl Scope for BuildRecipeScope<'_> { + #[inline] + fn get(&self, lookup: Lookup) -> Option> { + match lookup { + Lookup::Implied => None, + Lookup::PatternStem => { + let stem = self.recipe_match.match_data.stem()?; + Some(LookupValue::Owned(Eval::inherent(Value::from(stem)))) + } + Lookup::CaptureGroup(index) => { + let group = self.recipe_match.match_data.capture_group(index as usize)?; + Some(LookupValue::Owned(Eval::inherent(Value::from(group)))) + } + Lookup::InputFile => Some(LookupValue::ValueRef(Eval::inherent(&self.input_files))), + Lookup::OutputFile => Some(LookupValue::ValueRef(Eval::inherent(&self.output_file))), + Lookup::Ident(name) => { + if name == sym!(in) { + return Some(LookupValue::ValueRef(Eval::inherent(&self.input_files))); + } else if name == sym!(out) { + return Some(LookupValue::ValueRef(Eval::inherent(&self.output_file))); + } + + let Some(local) = self.vars.get(&name) else { + return self.global_scope.get(lookup); + }; + Some(LookupValue::EvalRef(local)) + } + } + } + + fn io(&self) -> &dyn Io { + self.global_scope.io() + } + + fn messenger(&self) -> &dyn Messenger { + self.global_scope.messenger() + } + + fn message(&self, message: &str) { + self.messenger().message(Some(self.task_id), message); + } + + fn warning(&self, warning: &Warning) { + self.messenger().warning(Some(self.task_id), warning); + } + + fn which( + &self, + program_name: &str, + ) -> Result>, which::Error> { + self.global_scope.which(program_name) + } + + fn env(&self, variable_name: &str) -> Eval> { + self.global_scope.env(variable_name) + } + + fn resolve_path(&self, path: &Absolute) -> Absolute { + self.global_scope.resolve_path(path) + } + + fn unresolve_path( + &self, + path: &Absolute, + ) -> Result, PathError> { + self.global_scope.unresolve_path(path) + } + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError> { + self.global_scope.glob_workspace_files(pattern_string) + } + + fn current_working_directory(&self) -> &Absolute { + self.global_scope.current_working_directory() + } +} + +impl ScopeMut for BuildRecipeScope<'_> { + fn set_local(&mut self, span: werk_util::DiagnosticSpan, name: Symbol, value: Eval) { + if default_global_constants().contains_key(&name) { + self.warning(&Warning::ShadowingGlobalConstant(span, name)); + } + self.vars.insert(name, value); + } +} + +impl werk_eval::BuildTaskScope for BuildRecipeScope<'_> { + fn push_input_files(&mut self, files: Vec) { + let Value::List(ref mut input_files) = self.input_files else { + unreachable!() + }; + input_files.extend(files.into_iter().map(Into::into)); + } +} + +pub struct TaskRecipeScope<'a> { + global_scope: &'a dyn Scope, + vars: LocalVariables, + task_id: TaskName, +} + +impl<'a> TaskRecipeScope<'a> { + #[inline] + #[must_use] + pub fn new(global_scope: &'a dyn Scope, task_id: TaskName) -> Self { + Self { + global_scope, + vars: LocalVariables::new(), + task_id, + } + } + + pub fn set(&mut self, local_name: Symbol, value: Eval) { + self.vars.insert(local_name, value); + } +} + +impl Scope for TaskRecipeScope<'_> { + #[inline] + fn get(&self, lookup: Lookup) -> Option> { + let Lookup::Ident(name) = lookup else { + return None; + }; + + let Some(local) = self.vars.get(&name) else { + return self.global_scope.get(lookup); + }; + + Some(LookupValue::Ref(&local.value, &local.used)) + } + + fn io(&self) -> &dyn Io { + self.global_scope.io() + } + + fn messenger(&self) -> &dyn Messenger { + self.global_scope.messenger() + } + + fn message(&self, message: &str) { + self.messenger().message(Some(self.task_id), message); + } + + fn warning(&self, warning: &Warning) { + self.messenger().warning(Some(self.task_id), warning); + } + + fn which( + &self, + program_name: &str, + ) -> Result>, which::Error> { + self.global_scope.which(program_name) + } + + fn env(&self, variable_name: &str) -> Eval> { + self.global_scope.env(variable_name) + } + + fn resolve_path(&self, path: &Absolute) -> Absolute { + self.global_scope.resolve_path(path) + } + + fn unresolve_path( + &self, + path: &Absolute, + ) -> Result, PathError> { + self.global_scope.unresolve_path(path) + } + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError> { + self.global_scope.glob_workspace_files(pattern_string) + } + + fn current_working_directory(&self) -> &Absolute { + self.global_scope.current_working_directory() + } +} + +impl ScopeMut for TaskRecipeScope<'_> { + fn set_local(&mut self, span: werk_util::DiagnosticSpan, name: Symbol, value: Eval) { + if default_global_constants().contains_key(&name) { + self.warning(&Warning::ShadowingGlobalConstant(span, name)); + } + self.vars.insert(name, value); + } +} diff --git a/werk-planner/task_graph.rs b/werk-planner/task_graph.rs new file mode 100644 index 00000000..3e336bb6 --- /dev/null +++ b/werk-planner/task_graph.rs @@ -0,0 +1,284 @@ +use std::collections::hash_map; + +use ahash::{HashMap, HashSet}; +use literator::Literator as _; +use tinyvec::TinyVec; +use werk_eval::{Eval, EvaluatedBuildRecipe, EvaluatedTaskRecipe}; +use werk_util::ellipsize::{self, ellipsize}; + +use crate::{CircularDependencyError, TaskId, TaskSpec}; + +type IdList = TinyVec<[Id; 4]>; + +fn insert_ordered(list: &mut IdList, id: Id) -> usize { + match list.binary_search(&id) { + Ok(index) => index, + Err(index) => { + list.insert(index, id); + index + } + } +} + +#[derive(Default)] +pub struct TaskGraph<'a> { + tasks: HashMap, TaskId>, + // Indices in these vecs are TaskId. + task_specs: Vec>, + dependency_lists: Vec>, + dependents_lists: Vec>, + evaluated: Vec>, +} + +pub enum EvaluatedTask { + Build(Eval), + Task(EvaluatedTaskRecipe), + CheckExists, +} + +impl std::fmt::Display for EvaluatedTask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Build(build_recipe) => { + write!( + f, + "{}", + build_recipe + .commands + .iter() + .suffix_each("
") + ) + } + Self::Task(task_recipe) => { + write!( + f, + "{}", + task_recipe + .commands + .iter() + .suffix_each("
") + ) + } + Self::CheckExists => write!(f, "check-exists"), + } + } +} + +impl<'a> TaskGraph<'a> { + #[must_use] + pub fn num_tasks(&self) -> usize { + self.task_specs.len() + } + + pub fn get_or_insert_task_spec(&mut self, spec: TaskSpec<'a>) -> TaskId { + match self.tasks.entry(spec) { + hash_map::Entry::Occupied(occupied_entry) => { + tracing::debug!("TASK ALREADY IN GRAPH: {:?}", occupied_entry.key()); + *occupied_entry.get() + } + hash_map::Entry::Vacant(vacant_entry) => { + let id = TaskId::from_index(self.task_specs.len()); + self.task_specs.push(vacant_entry.key().clone()); + self.dependency_lists.push(IdList::default()); + self.dependents_lists.push(IdList::default()); + self.evaluated.push(None); + vacant_entry.insert(id); + id + } + } + } + + /// Get all files with no dependencies, meaning that they are input files. + /// This includes files discovered through depfiles. + pub fn get_input_files(&self) -> impl Iterator> { + self.task_specs.iter().filter_map(|spec| match spec { + TaskSpec::Recipe(_) => None, + TaskSpec::CheckExists(path) | TaskSpec::CheckExistsRelaxed(path) => Some(path), + }) + } + + pub fn add_dependency(&mut self, task: TaskId, depends_on: TaskId) { + let index = task.index(); + insert_ordered(&mut self.dependency_lists[index], depends_on); + insert_ordered(&mut self.dependents_lists[depends_on.index()], task); + } + + #[must_use] + pub fn get_task_spec(&self, task_id: TaskId) -> &TaskSpec<'a> { + &self.task_specs[task_id.index()] + } + + #[must_use] + pub fn get_task_dependencies(&self, task_id: TaskId) -> &[TaskId] { + &self.dependency_lists[task_id.index()] + } + + #[must_use] + pub fn is_evaluated(&self, task_id: TaskId) -> bool { + self.evaluated[task_id.index()].is_some() + } + + pub fn take_task(&mut self, task_id: TaskId) -> EvaluatedTask { + self.evaluated[task_id.index()] + .take() + .expect("Task graph is not fully evaluated; this is a planner bug") + } + + pub(crate) fn set_evaluated(&mut self, task_id: TaskId, evaluated: EvaluatedTask) { + self.evaluated[task_id.index()] = Some(evaluated); + } + + pub fn would_cause_circular_dependency( + &self, + task_id: TaskId, + potential_dependency: TaskId, + scratch: &mut HashSet, + ) -> bool { + scratch.clear(); + scratch.insert(potential_dependency); + self.collect_dependencies_transitively(potential_dependency, scratch); + scratch.contains(&task_id) + } + + fn collect_dependencies_transitively(&self, task_id: TaskId, scratch: &mut HashSet) { + for dependency in self.dependency_lists[task_id.index()].iter().copied() { + if !scratch.contains(&dependency) { + scratch.insert(dependency); + self.collect_dependencies_transitively(dependency, scratch); + } + } + } + + pub fn check_circular_dependencies(&self) -> Result<(), CircularDependencyError> { + let mut stack = Vec::with_capacity(16); + let mut checked = HashSet::default(); + for (index, dependencies) in self.dependency_lists.iter().enumerate() { + let task_id = TaskId::from_index(index); + stack.push(task_id); + self.check_circular_dependency(dependencies, &mut stack, &mut checked)?; + stack.pop(); + } + + Ok(()) + } + + fn check_circular_dependency( + &self, + dependencies: &[TaskId], + stack: &mut Vec, + checked: &mut HashSet, + ) -> Result<(), CircularDependencyError> { + for dependency in dependencies.iter().copied() { + if checked.contains(&dependency) { + continue; + } + + if let Some(index) = stack.iter().position(|id| *id == dependency) { + stack.push(dependency); + return Err(CircularDependencyError { + chain: stack[index..] + .iter() + .map(|id| self.get_task_spec(*id).name().to_string()) + .collect(), + }); + } + + stack.push(dependency); + self.check_circular_dependency( + &self.dependency_lists[dependency.index()], + stack, + checked, + )?; + stack.pop(); + checked.insert(dependency); + } + + Ok(()) + } + + pub fn to_dot( + &self, + out: &mut W, + settings: &TaskGraphToDotSettings, + ) -> std::fmt::Result { + writeln!(out, "digraph {{")?; + writeln!(out, "label=\"Werk Plan\"")?; + writeln!(out, r#"rankdir="TB""#)?; + writeln!(out, "graph [overlap=false]")?; + writeln!( + out, + r#"node [fontname="Helvetica" labeljust=l nojustify=true]"# + )?; + writeln!(out, r#"edge [fontname="Helvetica"]"#)?; + + // Write nodes + for (index, task) in self.task_specs.iter().enumerate() { + let commands = match self.evaluated[index].as_ref().unwrap() { + EvaluatedTask::Build(Eval { + value: EvaluatedBuildRecipe { commands, .. }, + .. + }) + | EvaluatedTask::Task(EvaluatedTaskRecipe { commands, .. }) => commands + .iter() + .format_each_with(|item, w| { + ellipsize( + w, + &item.to_string(), + ellipsize::StringBreakMode::GraphemeCluster, + 60, + ) + }) + .suffix_each("
") + .to_string(), + EvaluatedTask::CheckExists => { + if settings.all_files { + String::from("check-exists") + } else { + continue; + } + } + }; + let commands = if commands.is_empty() { + String::from("(no commands)") + } else { + commands + }; + + writeln!( + out, + "Task{index} [labeljust=l label=<{{{}|{commands}}}> shape=record]", + task.name() + )?; + } + + // Write edges + for (index, dependencies) in self.dependency_lists.iter().enumerate() { + if !settings.all_files + && self.evaluated[index] + .as_ref() + .is_some_and(|t| matches!(t, EvaluatedTask::CheckExists)) + { + continue; + } + + for &dep in dependencies { + if !settings.all_files + && self.evaluated[dep.index()] + .as_ref() + .is_some_and(|dep| matches!(dep, EvaluatedTask::CheckExists)) + { + continue; + } + writeln!(out, "Task{} -> Task{}", dep.index(), index)?; + } + } + + writeln!(out, "}}") + } +} + +#[derive(Debug, Clone, Default)] +pub struct TaskGraphToDotSettings { + /// When true, includes "check-exists" tasks in the output. + pub all_files: bool, +} diff --git a/werk-planner/task_id.rs b/werk-planner/task_id.rs new file mode 100644 index 00000000..a621b8dc --- /dev/null +++ b/werk-planner/task_id.rs @@ -0,0 +1,19 @@ +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TaskId { + /// Index plus one. + index: u32, +} + +impl TaskId { + #[must_use] + pub fn index(self) -> usize { + self.index as usize + } + + #[must_use] + pub fn from_index(index: usize) -> Self { + Self { + index: u32::try_from(index).expect("too many tasks!"), + } + } +} diff --git a/werk-planner/task_spec.rs b/werk-planner/task_spec.rs new file mode 100644 index 00000000..5e1407aa --- /dev/null +++ b/werk-planner/task_spec.rs @@ -0,0 +1,36 @@ +use werk_eval::TaskName; +use werk_fs::Absolute; + +use crate::RecipeMatch; + +#[derive(Clone, Hash, PartialEq, Eq, Debug)] +pub enum TaskSpec<'a> { + /// The task name matched a build recipe. + Recipe(RecipeMatch<'a>), + /// The task name did not match a build recipe, so check if the file exists. + CheckExists(Absolute), + /// Check if the file exists, but don't emit an error if it doesn't. This + /// applies to dependencies discovered through depfiles, where the depfile + /// may be outdated (from a previous build). + /// + /// If the file does not exist, or if it is newer than the task output, the + /// task will be considered outdated. + CheckExistsRelaxed(Absolute), +} + +impl TaskSpec<'_> { + #[must_use] + pub fn name(&self) -> TaskName { + match self { + TaskSpec::Recipe(recipe_match) => match recipe_match { + RecipeMatch::Task(task_recipe) => TaskName::Task(task_recipe.name), + RecipeMatch::Build(build_recipe_match) => { + TaskName::Build(build_recipe_match.target_file) + } + }, + TaskSpec::CheckExists(absolute) | TaskSpec::CheckExistsRelaxed(absolute) => { + TaskName::Build(*absolute) + } + } + } +} diff --git a/werk-runner/Cargo.toml b/werk-runner/Cargo.toml index 37fa6cd9..95ff42d0 100644 --- a/werk-runner/Cargo.toml +++ b/werk-runner/Cargo.toml @@ -9,32 +9,35 @@ license.workspace = true path = "lib.rs" [dependencies] -werk-parser.workspace = true -werk-fs.workspace = true -thiserror.workspace = true -tracing.workspace = true -parking_lot.workspace = true -indexmap.workspace = true +ahash = "0.8.11" +annotate-snippets.workspace = true +anyhow = "1.0.93" +bitflags.workspace = true futures.workspace = true -which = "8.0.0" globset = "0.4.15" -walkdir = "2.5.0" -ahash = "0.8.11" ignore = "0.4.23" -regex.workspace = true +indexmap.workspace = true +memchr = "2.7.4" +parking_lot.workspace = true +pin-project-lite = "0.2.16" regex-syntax.workspace = true -anyhow = "1.0.93" +regex.workspace = true +rustc-stable-hash.workspace = true serde.workspace = true -toml_edit = { workspace = true, features = ["serde"] } -rustc-stable-hash = "0.1.0" -winnow.workspace = true smol.workspace = true -pin-project-lite = "0.2.16" -memchr = "2.7.4" -annotate-snippets.workspace = true -werk-util.workspace = true -bitflags = "2.8.0" stringleton.workspace = true +thiserror.workspace = true +toml_edit = { workspace = true, features = ["serde"] } +tracing.workspace = true +walkdir = "2.5.0" +werk-depfile.workspace = true +werk-eval.workspace = true +werk-fs.workspace = true +werk-parser.workspace = true +werk-planner.workspace = true +werk-util.workspace = true +which = "8.0.0" +winnow.workspace = true [lints] workspace = true diff --git a/werk-runner/cache.rs b/werk-runner/cache.rs index 8838eab6..d6a92c2f 100644 --- a/werk-runner/cache.rs +++ b/werk-runner/cache.rs @@ -1,7 +1,11 @@ use std::collections::BTreeMap; use stringleton::Symbol; +use werk_eval::Io; use werk_fs::Absolute; +use werk_util::{IoError, hash128::Hash128}; + +pub const WERK_CACHE_FILENAME: &str = ".werk-cache"; /// The contents of `.werk-cache`. #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] @@ -11,6 +15,99 @@ pub struct WerkCache { pub build: BTreeMap, TargetOutdatednessCache>, } +impl WerkCache { + pub fn read(io: &dyn Io, output_dir: &Absolute) -> WerkCache { + let werk_cache_path = output_dir.join(WERK_CACHE_FILENAME).unwrap(); + tracing::debug!("trying to read .werk-cache: {}", werk_cache_path.display()); + let data = match io.read_file(&werk_cache_path) { + Ok(data) => data, + Err(err) => { + if err.error.kind() != std::io::ErrorKind::NotFound { + tracing::error!("Failed to read workspace cache, even though it exists: {err}"); + } + tracing::debug!(".werk-cache does not exist"); + return WerkCache::default(); + } + }; + + if data.is_empty() { + tracing::debug!(".werk-cache is empty"); + return WerkCache::default(); + } + + match toml_edit::de::from_slice(&data) { + Ok(cache) => { + tracing::trace!(".werk-cache contents: {cache:#?}"); + cache + } + Err(err) => { + tracing::error!("Failed to parse workspace cache: {err}"); + WerkCache::default() + } + } + } + + pub fn write( + &self, + io: &dyn Io, + output_dir: &Absolute, + ) -> Result<(), IoError> { + fn make_table(item: &mut toml_edit::Item) -> Option<&mut toml_edit::Table> { + match std::mem::take(item).into_table() { + Ok(table) => { + *item = toml_edit::Item::Table(table); + if let toml_edit::Item::Table(table) = item { + Some(table) + } else { + unreachable!() + } + } + Err(not_table) => { + *item = not_table; + None + } + } + } + + let mut doc = match toml_edit::ser::to_document(self) { + Ok(data) => data, + Err(err) => { + tracing::error!("Serialization error writing .werk-cache: {err}"); + panic!("Serialization error writing .werk-cache: {err}"); + } + }; + + if let Some(build) = doc.get_mut("build") { + let build = make_table(build).expect("build is not a table"); + build.set_implicit(true); + for (_target, target_info) in build.iter_mut() { + make_table(target_info); + } + } + + let toml = format!("# Generated by werk. It can be safely deleted.\n\n{doc}"); + + let path = output_dir.join(WERK_CACHE_FILENAME).unwrap(); + tracing::debug!("writing .werk-cache to {}", path.display()); + + if let Err(err) = io.create_parent_dirs(&path) { + tracing::error!( + "Error creating parent directory for .werk-cache '{}': {err}", + output_dir.display() + ); + return Err(err); + } + + match io.write_file(&path, toml.as_bytes()) { + Ok(()) => Ok(()), + Err(err) => { + tracing::error!("Error writing .werk-cache: {err}"); + Err(err) + } + } + } +} + /// Per-target cache of used outdatedness information. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TargetOutdatednessCache { @@ -74,44 +171,3 @@ impl TargetOutdatednessCache { .is_none_or(|old_hash| *old_hash != new_hash) } } - -impl rustc_stable_hash::FromStableHash for Hash128 { - type Hash = rustc_stable_hash::SipHasher128Hash; - - fn from(hash: Self::Hash) -> Self { - let hi = u128::from(hash.0[0]) << 64; - let lo = u128::from(hash.0[1]); - Hash128(hi | lo) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[repr(transparent)] -pub struct Hash128(pub u128); -impl From for Hash128 { - #[inline] - fn from(n: u128) -> Self { - Hash128(n) - } -} - -impl serde::Serialize for Hash128 { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - // Serialize as hex string. Also, TOML doesn't support 64-bit integers. - serializer.serialize_str(&format!("{:016x}", self.0)) - } -} - -impl<'de> serde::Deserialize<'de> for Hash128 { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let n = u128::from_str_radix(&s, 16).map_err(serde::de::Error::custom)?; - Ok(Hash128(n)) - } -} diff --git a/werk-runner/defaults.rs b/werk-runner/defaults.rs new file mode 100644 index 00000000..0469cb44 --- /dev/null +++ b/werk-runner/defaults.rs @@ -0,0 +1,60 @@ +use werk_eval::EvalError; +use werk_parser::ast; +use werk_util::DiagnosticFileId; + +use crate::Edition; + +#[derive(Debug, Default, PartialEq)] +pub struct Defaults { + pub cache_dir: Option, + pub deprecated_output_directory: Option, + pub print_commands: Option, + pub print_fresh: Option, + pub quiet: Option, + pub loud: Option, + pub explain: Option, + pub verbose: Option, + pub watch_delay: Option, + pub jobs: Option, + pub edition: Edition, +} + +impl Defaults { + pub fn new(ast: &ast::Root, file: DiagnosticFileId) -> Result { + let mut defaults = Self::default(); + for stmt in &ast.statements { + let ast::RootStmt::Default(ref stmt) = stmt.statement else { + continue; + }; + + match stmt { + ast::DefaultStmt::Target(_) => {} // Evaluated on workspace creation. + ast::DefaultStmt::CacheDir(entry) => { + defaults.cache_dir = Some(entry.value.1.clone()); + } + ast::DefaultStmt::DeprecatedOutDir(entry) => { + defaults.deprecated_output_directory = Some(entry.value.1.clone()); + } + ast::DefaultStmt::PrintCommands(entry) => { + defaults.print_commands = Some(entry.value.1); + } + ast::DefaultStmt::PrintFresh(entry) => defaults.print_fresh = Some(entry.value.1), + ast::DefaultStmt::Quiet(entry) => defaults.quiet = Some(entry.value.1), + ast::DefaultStmt::Loud(entry) => defaults.loud = Some(entry.value.1), + ast::DefaultStmt::Explain(entry) => defaults.explain = Some(entry.value.1), + ast::DefaultStmt::Verbose(entry) => defaults.verbose = Some(entry.value.1), + ast::DefaultStmt::WatchDelay(entry) => defaults.watch_delay = Some(entry.value.1), + ast::DefaultStmt::Jobs(entry) => defaults.jobs = entry.value.1.try_into().ok(), + ast::DefaultStmt::Edition(entry) => { + if entry.value.1 == "v1" { + defaults.edition = Edition::V1; + } else { + return Err(EvalError::InvalidEdition(file.span(entry.value.0))); + } + } + } + } + + Ok(defaults) + } +} diff --git a/werk-runner/error.rs b/werk-runner/error.rs index c3dcf0ea..60b3628a 100644 --- a/werk-runner/error.rs +++ b/werk-runner/error.rs @@ -1,27 +1,21 @@ -use std::sync::Arc; - use annotate_snippets::{AnnotationKind, Snippet}; -use werk_fs::Absolute; -use werk_util::{DiagnosticFileId, DiagnosticSourceMap, DiagnosticSpan, Level}; - -use crate::{OwnedDependencyChain, ShellCommandLine, TaskId, Value, depfile::DepfileError}; +use std::sync::Arc; +use werk_eval::TaskName; +use werk_planner::PlannerError; +use werk_util::{DiagnosticSourceMap, DiagnosticSpan, IoError, Level}; #[derive(Debug, Clone, thiserror::Error)] pub enum Error { #[error(transparent)] - Io(#[from] Arc), + Io(#[from] IoError), + #[error(transparent)] + Planner(#[from] PlannerError), #[error("command not found: {0}: {1}")] CommandNotFound(String, which::Error), - #[error("no rule to build target: {0}")] - NoRuleToBuildTarget(String), - #[error("circular dependency: {0}")] - CircularDependency(OwnedDependencyChain), #[error("dependency failed: {0}: {1}")] - DependencyFailed(TaskId, Arc), + DependencyFailed(TaskName, Arc), #[error("task was cancelled: {0}")] - Cancelled(TaskId), - #[error("eval error: {0}")] - Eval(#[from] EvalError), + Cancelled(TaskName), #[error(transparent)] Walk(Arc), #[error(transparent)] @@ -30,29 +24,34 @@ pub enum Error { DuplicateCommand(String), #[error("duplicate pattern: {0}")] DuplicateTarget(String), - #[error(transparent)] - AmbiguousPattern(Arc), /// A shell command failed while executing a rule. Note that the /// stdout/stderr is a UI concern and only available through the /// `TrackRunner` interface. #[error("command failed: {0}")] CommandFailed(std::process::ExitStatus), - #[error( - "cannot convert abstract paths to native OS paths yet; output directory has not been set in the [global] scope" - )] - OutputDirectoryNotAvailable, - #[error( - "depfile was not found: '{0}'; perhaps the rule to generate it writes to the wrong location?" - )] - DepfileNotFound(werk_fs::PathBuf), - #[error(transparent)] - DepfileError(#[from] DepfileError), - #[error(".werk-cache file found in workspace; please add its directory to .gitignore")] - ClobberedWorkspace(std::path::PathBuf), + #[error("build recipe target is a directory: {path}")] + TargetIsADirectory { + span: DiagnosticSpan, + path: werk_fs::PathBuf, + }, + #[error("cannot {op} because the path is protected: {}", path.display())] + ProtectedPath { + span: Option, + op: &'static str, + path: std::path::PathBuf, + }, + #[error("cannot {op} beyond the root of the workspace: {}", path.display())] + WriteBeyondRoot { + span: Option, + op: &'static str, + path: std::path::PathBuf, + }, #[error("invalid target path `{0}`: {1}")] InvalidTargetPath(String, werk_fs::PathError), #[error("invalid path in depfile `{0}`: {1}")] InvalidPathInDepfile(String, werk_fs::PathError), + #[error("{0}")] + MustMigrate(String), #[error(transparent)] Custom(Arc), } @@ -62,6 +61,11 @@ impl Error { Self::Custom(Arc::new(anyhow::Error::new(err))) } + #[must_use] + pub fn eval(err: werk_eval::EvalError) -> Self { + Self::Planner(PlannerError::Evaluation(err)) + } + /// True when, even though an error occurred, the `.werk-cache` file should /// still be written. #[must_use] @@ -69,23 +73,20 @@ impl Error { match self { Error::Io(_) | Error::CommandNotFound(..) - | Error::NoRuleToBuildTarget(_) - | Error::CircularDependency(_) | Error::DependencyFailed(..) | Error::CommandFailed(_) - | Error::DepfileNotFound(_) - | Error::DepfileError(_) | Error::Cancelled(_) => true, - Error::Eval(_) - | Error::Walk(_) + Error::Walk(_) | Error::Glob(_) | Error::DuplicateCommand(_) | Error::DuplicateTarget(_) - | Error::AmbiguousPattern(_) - | Error::OutputDirectoryNotAvailable - | Error::ClobberedWorkspace(_) + | Error::Planner(_) + | Error::TargetIsADirectory { .. } | Error::InvalidTargetPath(..) | Error::InvalidPathInDepfile(..) + | Error::MustMigrate(..) + | Error::ProtectedPath { .. } + | Error::WriteBeyondRoot { .. } | Error::Custom(_) => false, } } @@ -94,22 +95,26 @@ impl Error { impl PartialEq for Error { fn eq(&self, other: &Self) -> bool { match (self, other) { - (Self::Io(l0), Self::Io(r0)) => l0.kind() == r0.kind(), + (Self::Io(l0), Self::Io(r0)) => l0 == r0, + (Self::Planner(l0), Self::Planner(r0)) => l0 == r0, (Self::CommandNotFound(l0, l1), Self::CommandNotFound(r0, r1)) => l0 == r0 && l1 == r1, - (Self::CircularDependency(l0), Self::CircularDependency(r0)) => l0 == r0, (Self::DependencyFailed(l0, l1), Self::DependencyFailed(r0, r1)) => { l0 == r0 && l1 == r1 } (Self::Cancelled(l0), Self::Cancelled(r0)) => l0 == r0, - (Self::Eval(l0), Self::Eval(r0)) => l0 == r0, (Self::Walk(l0), Self::Walk(r0)) => l0.to_string() == r0.to_string(), (Self::Glob(l0), Self::Glob(r0)) => l0 == r0, - (Self::NoRuleToBuildTarget(l0), Self::NoRuleToBuildTarget(r0)) - | (Self::DuplicateCommand(l0), Self::DuplicateCommand(r0)) + (Self::DuplicateCommand(l0), Self::DuplicateCommand(r0)) | (Self::DuplicateTarget(l0), Self::DuplicateTarget(r0)) => l0 == r0, - (Self::AmbiguousPattern(l0), Self::AmbiguousPattern(r0)) => l0 == r0, (Self::CommandFailed(l0), Self::CommandFailed(r0)) => l0 == r0, - (Self::ClobberedWorkspace(l0), Self::ClobberedWorkspace(r0)) => l0 == r0, + ( + Self::TargetIsADirectory { path: lhs, .. }, + Self::TargetIsADirectory { path: rhs, .. }, + ) => lhs == rhs, + (Self::ProtectedPath { path: lhs, .. }, Self::ProtectedPath { path: rhs, .. }) + | (Self::WriteBeyondRoot { path: lhs, .. }, Self::WriteBeyondRoot { path: rhs, .. }) => { + lhs == rhs + } (Self::Custom(l0), Self::Custom(r0)) => l0.to_string() == r0.to_string(), _ => core::mem::discriminant(self) == core::mem::discriminant(other), } @@ -123,17 +128,10 @@ impl From for Error { } } -impl From for Error { +impl From for Error { #[inline] - fn from(err: std::io::Error) -> Self { - Self::Io(Arc::new(err)) - } -} - -impl From for Error { - #[inline] - fn from(err: AmbiguousPatternError) -> Self { - Self::AmbiguousPattern(Arc::new(err)) + fn from(err: werk_planner::AmbiguousPatternError) -> Self { + Self::Planner(werk_planner::PlannerError::AmbiguousPattern(err)) } } @@ -151,420 +149,58 @@ impl From for Error { } } +impl From for Error { + fn from(value: werk_eval::GlobError) -> Self { + match value { + werk_eval::GlobError::Glob(error) => error.into(), + } + } +} + impl werk_util::AsDiagnostic for Error { fn as_diagnostic<'a>( &'a self, source_map: &'a dyn DiagnosticSourceMap, ) -> Vec> { let id = match self { - Error::Eval(eval_error) => return eval_error.as_diagnostic(source_map), Error::Io(..) => "R0001", Error::CommandNotFound(..) => "R0002", - Error::NoRuleToBuildTarget(..) => "R0003", - Error::CircularDependency(..) => "R0004", + Error::Planner(planner_error) => return planner_error.as_diagnostic(source_map), Error::DependencyFailed(..) => "R0005", Error::Cancelled(..) => "R0006", Error::Walk(..) => "R0007", Error::Glob(..) => "R0008", Error::DuplicateCommand(..) => "R0009", Error::DuplicateTarget(..) => "R0010", - Error::AmbiguousPattern(..) => "R0011", Error::CommandFailed(..) => "R0012", - Error::OutputDirectoryNotAvailable => "R0013", - Error::DepfileNotFound(..) => "R0014", - Error::DepfileError(..) => "R0015", - Error::ClobberedWorkspace(..) => "R0016", + Error::TargetIsADirectory { .. } => "R0052", Error::InvalidTargetPath(..) => "R0017", Error::InvalidPathInDepfile(..) => "R0018", - Error::Custom(..) => "R9999", + Error::Custom(..) | Error::MustMigrate(_) => "R9999", + Error::ProtectedPath { .. } => "R0050", + Error::WriteBeyondRoot { .. } => "R0051", }; // Use the Display impl from thiserror. - let mut diag = vec![annotate_snippets::Group::with_title( + let diag = annotate_snippets::Group::with_title( Level::ERROR.primary_title(self.to_string()).id(id), - )]; - - // Additional context and help - if let Error::AmbiguousPattern(err) = self { - let first_source = source_map - .get_source(err.pattern1.file) - .expect("valid file ID"); - let second_source = source_map - .get_source(err.pattern2.file) - .expect("valid file ID"); - diag.push( - Level::NOTE.secondary_title("first pattern here").element( - Snippet::source(first_source.source) - .path(first_source.file) - .annotation(AnnotationKind::Context.span(err.pattern1.span.into())), - ), - ); - diag.push( - Level::NOTE.secondary_title("second pattern here").element( - Snippet::source(second_source.source) - .path(second_source.file) - .annotation(AnnotationKind::Context.span(err.pattern2.span.into())), - ), - ); - } - - diag - } -} - -#[derive(Debug, thiserror::Error, PartialEq)] -#[error("ambiguous pattern match: {path}")] -pub struct AmbiguousPatternError { - pub pattern1: DiagnosticSpan, - pub pattern2: DiagnosticSpan, - pub path: String, -} - -#[derive(Debug, thiserror::Error, PartialEq)] -#[error( - "ambiguous path resolution: {path} exists in the workspace, but also matches a build recipe" -)] -pub struct AmbiguousPathError { - pub path: Absolute, - pub build_recipe: DiagnosticSpan, -} - -#[derive(Debug, Clone)] -pub struct ShellError { - pub command: ShellCommandLine, - pub result: Arc>, -} - -impl PartialEq for ShellError { - fn eq(&self, other: &Self) -> bool { - self.command == other.command - && match (&*self.result, &*other.result) { - (Ok(l), Ok(r)) => l == r, - (Err(l), Err(r)) => l.kind() == r.kind(), - _ => false, - } - } -} - -impl std::error::Error for ShellError {} - -impl std::fmt::Display for ShellError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "command failed: {}", self.command.program.display())?; - match &*self.result { - Ok(output) => { - if !output.stderr.is_empty() { - write!(f, "\nstderr:\n{}", String::from_utf8_lossy(&output.stderr))?; + ); + + let diag = + match self { + Error::TargetIsADirectory { span, path } => { + let source_file = source_map.get_source(span.file).expect("invalid file ID"); + diag.element( + Snippet::source(source_file.source) + .path(source_file.file) + .annotation(AnnotationKind::Context.span(span.span.into()).label( + format!("this build recipe target matches the directory '{path}'"), + )), + ) } - } - Err(err) => writeln!(f, "\nerror: {err}")?, - } - - Ok(()) - } -} - -#[derive(Debug, Clone, thiserror::Error, PartialEq)] -pub enum EvalError { - #[error("invalid edition identifier; expected `v1`")] - InvalidEdition(DiagnosticSpan), - #[error("expected a string value")] - ExpectedConfigString(DiagnosticSpan), - #[error("expected a boolean value")] - ExpectedConfigBool(DiagnosticSpan), - #[error("unknown config key")] - UnknownConfigKey(DiagnosticSpan), - #[error("no pattern in scope that contains a pattern stem `%`")] - NoPatternStem(DiagnosticSpan), - #[error("one-of patterns not allowed in this context")] - IllegalOneOfPattern(DiagnosticSpan), - #[error("duplicate pattern")] - DuplicatePattern(DiagnosticSpan, DiagnosticSpan), - #[error("duplicate config statement")] - DuplicateConfigStatement(DiagnosticSpan, DiagnosticSpan), - #[error( - "no implied interpolation value in this context; provide an identifier or a capture group index" - )] - NoImpliedValue(DiagnosticSpan), - #[error("capture group with index {1} is out of bounds in the current scope")] - NoSuchCaptureGroup(DiagnosticSpan, u32), - #[error("no identifier with name {1}")] - NoSuchIdentifier(DiagnosticSpan, String), - #[error("unexpected list; perhaps a join operation `{{var*}}` is missing?")] - UnexpectedList(DiagnosticSpan), - #[error("pattern stems `{{%}}` cannot be interpolated in patterns")] - PatternStemInterpolationInPattern(DiagnosticSpan), - #[error("path resolution `<...>` interpolations cannot be used in patterns")] - ResolvePathInPattern(DiagnosticSpan), - #[error( - "path resolution `<...>` of a string that already contains resolved paths, but is not a resolved path" - )] - DoubleResolvePath(DiagnosticSpan), - #[error("join interpolations `{{...*}}` cannot be used in patterns")] - JoinInPattern(DiagnosticSpan), - #[error("unexpected list in pattern")] - ListInPattern(DiagnosticSpan), - #[error("invalid path interpolation within quotes; path arguments are automatically quoted")] - PathWithinQuotes(DiagnosticSpan), - #[error("empty command")] - EmptyCommand(DiagnosticSpan), - #[error("empty list")] - EmptyList(DiagnosticSpan), - #[error("unterminated quote in shell argument")] - UnterminatedQuote(DiagnosticSpan), - #[error("`{1}` expressions are not allowed in this context")] - UnexpectedExpressionType(DiagnosticSpan, &'static str), - #[error("expected an integer value, got \"{}\"", .1.escape_default())] - ExpectedInt(DiagnosticSpan, String), - #[error("index out of bounds: got {1}, and the array length is {2}")] - IndexOutOfBounds(DiagnosticSpan, i32, usize), - #[error("command not found: {1}: {2}")] - CommandNotFound(DiagnosticSpan, String, which::Error), - #[error("`which` expression resulted in a non-UTF-8 path: {}", .1.display())] - NonUtf8Which(DiagnosticSpan, std::path::PathBuf), - #[error("`read` failed because file is not valid UTF-8: {}", .1.display())] - NonUtf8Read(DiagnosticSpan, std::path::PathBuf), - #[error("{1}")] - Glob(DiagnosticSpan, Arc), - /// Shell command failed during evaluation. Note: This error is not reported - /// when executing commands as part of a rule, only when executing commands - /// during evaluation (settings variables etc.) - #[error("{1}")] - Shell(DiagnosticSpan, Arc), - #[error("{1}")] - Path(DiagnosticSpan, werk_fs::PathError), - #[error("I/O error during evaluation: {1}")] - Io(DiagnosticSpan, IoError), - #[error("{1}")] - ErrorExpression(DiagnosticSpan, String), - #[error("assertion failed: {} != {}", .1 .0, .1 .1)] - AssertEqFailed(DiagnosticSpan, Box<(Value, Value)>), - #[error("assertion failed: \"{}\" does not match the pattern '{}'", .1 .0.escape_default(), .1 .1)] - AssertMatchFailed(DiagnosticSpan, Box<(String, String)>), - #[error("assertion failed: {1}")] - AssertCustomFailed(DiagnosticSpan, String), - #[error("{1}")] - AmbiguousPathResolution(DiagnosticSpan, Arc), - #[error("error including '{1}': {2}")] - IncludeIoError(DiagnosticSpan, String, IoError), - #[error("{1}")] - IncludeError(DiagnosticSpan, Box), - #[error("same file included twice: {2}")] - IncludeDuplicate( - DiagnosticSpan, - Option, - Absolute, - ), - #[error("`default` statements are not allowed in included files")] - DefaultInInclude(DiagnosticSpan), - #[error("{}", .0.error)] - Parse(werk_parser::ErrorInFile), -} - -/// Error in an `include` statement. -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum IncludeError { - #[error("{1}")] - Parse(DiagnosticFileId, werk_parser::Error), - #[error(transparent)] - Eval(#[from] EvalError), -} - -impl EvalError { - #[inline] - fn span(&self) -> DiagnosticSpan { - match self { - EvalError::InvalidEdition(span) - | EvalError::ExpectedConfigString(span) - | EvalError::ExpectedConfigBool(span) - | EvalError::UnknownConfigKey(span) - | EvalError::NoPatternStem(span) - | EvalError::IllegalOneOfPattern(span) - | EvalError::DuplicatePattern(span, _) - | EvalError::DuplicateConfigStatement(span, _) - | EvalError::NoImpliedValue(span) - | EvalError::NoSuchCaptureGroup(span, _) - | EvalError::NoSuchIdentifier(span, _) - | EvalError::UnexpectedList(span) - | EvalError::PatternStemInterpolationInPattern(span) - | EvalError::ResolvePathInPattern(span) - | EvalError::DoubleResolvePath(span) - | EvalError::JoinInPattern(span) - | EvalError::ListInPattern(span) - | EvalError::PathWithinQuotes(span) - | EvalError::EmptyCommand(span) - | EvalError::EmptyList(span) - | EvalError::UnterminatedQuote(span) - | EvalError::UnexpectedExpressionType(span, _) - | EvalError::ExpectedInt(span, _) - | EvalError::IndexOutOfBounds(span, _, _) - | EvalError::CommandNotFound(span, _, _) - | EvalError::NonUtf8Which(span, _) - | EvalError::NonUtf8Read(span, _) - | EvalError::Glob(span, _) - | EvalError::Shell(span, _) - | EvalError::Path(span, _) - | EvalError::Io(span, _) - | EvalError::ErrorExpression(span, _) - | EvalError::AssertEqFailed(span, _) - | EvalError::AssertMatchFailed(span, _) - | EvalError::AssertCustomFailed(span, _) - | EvalError::AmbiguousPathResolution(span, _) - | EvalError::IncludeIoError(span, ..) - | EvalError::IncludeError(span, ..) - | EvalError::IncludeDuplicate(span, ..) - | EvalError::DefaultInInclude(span) => *span, - EvalError::Parse(err) => err.file.span(err.error.span()), - } - } -} - -impl werk_util::AsDiagnostic for EvalError { - fn as_diagnostic<'a>( - &'a self, - source_map: &'a dyn DiagnosticSourceMap, - ) -> Vec> { - let span = self.span(); - let source = source_map.get_source(span.file).expect("valid file ID"); - - let id = match self { - EvalError::InvalidEdition(..) => "E0001", - EvalError::ExpectedConfigString(..) => "E0002", - EvalError::ExpectedConfigBool(..) => "E0003", - EvalError::UnknownConfigKey(..) => "E0004", - EvalError::NoPatternStem(..) => "E0005", - EvalError::IllegalOneOfPattern(..) => "E0006", - EvalError::DuplicatePattern(..) => "E0007", - EvalError::DuplicateConfigStatement(..) => "E0033", - EvalError::NoImpliedValue(..) => "E0008", - EvalError::NoSuchCaptureGroup(..) => "E0009", - EvalError::NoSuchIdentifier(..) => "E0010", - EvalError::UnexpectedList(..) => "E0011", - EvalError::PatternStemInterpolationInPattern(..) => "E0012", - EvalError::ResolvePathInPattern(..) => "E0013", - EvalError::DoubleResolvePath(..) => "E0034", - EvalError::JoinInPattern(..) => "E0014", - EvalError::ListInPattern(..) => "E0015", - EvalError::PathWithinQuotes(..) => "E0016", - EvalError::EmptyCommand(..) => "E0017", - EvalError::EmptyList(..) => "E0018", - EvalError::UnterminatedQuote(..) => "E0019", - EvalError::UnexpectedExpressionType(..) => "E0020", - EvalError::ExpectedInt(..) => "E0028", - EvalError::IndexOutOfBounds(..) => "E0037", - EvalError::CommandNotFound(..) => "E0021", - EvalError::NonUtf8Which(..) => "E0022", - EvalError::NonUtf8Read(..) => "E0023", - EvalError::Glob(..) => "E0024", - EvalError::Shell(..) => "E0025", - EvalError::Path(..) => "E0026", - EvalError::Io(..) | EvalError::IncludeIoError(..) => "E0027", - EvalError::ErrorExpression(..) => "E9999", - EvalError::AssertEqFailed(..) => "E0029", - EvalError::AssertMatchFailed(..) => "E0030", - EvalError::AssertCustomFailed(..) => "E0031", - EvalError::AmbiguousPathResolution(..) => "E0032", - EvalError::IncludeError(_, err) => { - let mut groups = err.as_diagnostic(source_map); - groups.push( - Level::NOTE.secondary_title("included here").element( - Snippet::source(source.source) - .path(source.file) - .annotation(AnnotationKind::Primary.span(span.span.into())), - ), - ); - return groups; - } - EvalError::IncludeDuplicate(..) => "E0035", - EvalError::DefaultInInclude(..) => "E0036", - EvalError::Parse(err) => { - return err.as_diagnostic(source_map); - } - }; - - let title = self.to_string(); - - let diag = Level::ERROR - .primary_title(title.clone()) // Use the `Display` implementation from thiserror. - .id(id) - .element( - Snippet::source(source.source) - .path(source.file) - .annotation(AnnotationKind::Primary.span(span.span.into()).label(title)), - ); - - // Help messages and additional context. - let diag = match self { - EvalError::NoSuchCaptureGroup(..) => diag.element(Level::HELP.message( - "pattern capture groups are zero-indexed, starting from 0", - )), - EvalError::AmbiguousPathResolution(_, err) => { - let previous_definition_file = source_map.get_source(err.build_recipe.file).expect("invalid file ID"); - diag - .element( - Snippet::source(previous_definition_file.source) - .path(previous_definition_file.file) - .annotation(AnnotationKind::Context.span(err.build_recipe.span.into()).label("matched this build recipe"))) - .element(Level::HELP.message("use `<...:out-dir>` or `<...:workspace>` to disambiguate between paths in the workspace and the output directory")) - }, - EvalError::DuplicateConfigStatement(_, previous_span) => { - let previous_definition_file = source_map.get_source(previous_span.file).expect("invalid file ID"); - diag - .element(Snippet::source(previous_definition_file.source).path(previous_definition_file.file).annotation(AnnotationKind::Context.span(previous_span.span.into()).label("previous config statement here"))) - }, - EvalError::ExpectedInt(..) => diag.element(Level::HELP.message("integers are stringly typed in array index operations, so \"0\" is the first element")), - EvalError::IndexOutOfBounds(..) => diag.element(Level::HELP.message("arrays are indexed from zero, and negative indices refer to elements from the end of the array")), - EvalError::IncludeDuplicate(_, Some(previous_span), _) => { - let previous_definition_file = source_map.get_source(previous_span.file).expect("invalid file ID"); - diag - .element(Snippet::source(previous_definition_file.source).path(previous_definition_file.file).annotation(AnnotationKind::Context.span(previous_span.span.into()).label("already included here"))) - }, - EvalError::DefaultInInclude(_) => diag.element(Level::HELP.message("move `default` statements to the top-level Werkfile")), - _ => diag, - }; + _ => diag, + }; vec![diag] } } - -/// Clonable `std::io::Error` -#[derive(Clone)] -pub struct IoError { - pub error: Arc, -} - -impl From for IoError { - #[inline] - fn from(error: std::io::Error) -> Self { - Self { - error: Arc::new(error), - } - } -} - -impl std::fmt::Debug for IoError { - #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(&*self.error, f) - } -} - -impl std::fmt::Display for IoError { - #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(&*self.error, f) - } -} - -impl PartialEq for IoError { - #[inline] - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.error, &other.error) || self.error.kind() == other.error.kind() - } -} - -impl std::error::Error for IoError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.error.source() - } -} diff --git a/werk-runner/io.rs b/werk-runner/io.rs index b7f0dc80..d9888493 100644 --- a/werk-runner/io.rs +++ b/werk-runner/io.rs @@ -1,152 +1,12 @@ -use std::{ - path::{Path, PathBuf}, - time::SystemTime, -}; +use std::path::{Path, PathBuf}; pub use ignore::WalkState; -use parking_lot::Mutex; +use werk_eval::{Child, Env, GlobSettings, Metadata, ShellCommandLine}; use werk_fs::{Absolute, Normalize as _}; -use crate::{Env, Error, GlobSettings, ShellCommandLine}; - mod child; pub use child::*; - -/// Abstract interface to the file system and OS. -/// -/// All interactions with the file system and OS should go through this, which -/// in particular means that things like methods on `std::path::Path` should -/// avoid accessing the filesystem. For example, methods like `canonicalize()` -/// or `metadata()` do access the filesystem, and should be avoided. -/// -/// This abstraction exists to allow for testing the runner in a controlled -/// environment. -pub trait Io: Send + Sync + 'static { - /// Run a command as part of a recipe. This will do nothing in dry-run mode. - fn run_recipe_command( - &self, - command_line: &ShellCommandLine, - working_dir: &Absolute, - env: &Env, - forward_stdout: bool, - ) -> Result, std::io::Error>; - - /// Run a command as part of evaluating the contents of a Werkfile. This - /// might still do something in dry-run mode. - fn run_during_eval( - &self, - command_line: &ShellCommandLine, - working_dir: &Absolute, - env: &Env, - ) -> Result; - - /// Determine the absolute filesystem path to a program. - fn which(&self, command: &str) -> Result, which::Error>; - - /// Glob the workspace directory, adhering to the glob settings. - /// - /// If this function produces a path to a `.werk-cache` file, the - /// `Workspace` constructor will fail. - fn glob_workspace( - &self, - path: &Absolute, - settings: &GlobSettings, - ) -> Result, Error>; - - /// Query the metadata of a filesystem path. - fn metadata(&self, path: &Absolute) -> Result; - - /// Read a file from the filesystem. - fn read_file(&self, path: &Absolute) -> Result, std::io::Error>; - - /// Write a file to the filesystem. - fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), std::io::Error>; - - /// Copy one file to another on the file system. Must do nothing in dry-run. - /// May do nothing if the paths are equal. - fn copy_file(&self, from: &Absolute, to: &Absolute) -> Result<(), std::io::Error>; - - /// Delete a file from the filesystem. Must do nothing in dry-run. - fn delete_file(&self, path: &Absolute) -> Result<(), std::io::Error>; - - /// Create a file, or update an existing file's mtime. (Equivalent to UNIX `touch`.) - fn touch(&self, path: &Absolute) -> Result<(), std::io::Error>; - - /// Create the parent directories of `path`, recursively. - fn create_parent_dirs(&self, path: &Absolute) -> Result<(), std::io::Error>; - - /// Read environment variable. - fn read_env(&self, name: &str) -> Option; - - /// Is this object actually executing commands or not? The return value - /// should be used for diagnostic purposes only, because the actual behavior - /// of the runner is not affected by this. - fn is_dry_run(&self) -> bool; -} - -#[derive(Debug, Clone)] -pub struct DirEntry { - pub path: Absolute, - pub metadata: Metadata, -} - -impl TryFrom for DirEntry { - type Error = ignore::Error; - - #[inline] - fn try_from(entry: ignore::DirEntry) -> Result { - let metadata = entry.metadata()?.try_into()?; - let path = entry.into_path(); - - // `ignore` claims that this is always true. - assert!(path.is_absolute()); - let path = path.normalize()?; - - Ok(DirEntry { path, metadata }) - } -} - -impl TryFrom for DirEntry { - type Error = std::io::Error; - - #[inline] - fn try_from(entry: std::fs::DirEntry) -> Result { - let metadata = entry.metadata()?.try_into()?; - let path = entry.path(); - - assert!(path.is_absolute()); - let path = path.normalize()?; - - Ok(DirEntry { path, metadata }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Metadata { - pub mtime: SystemTime, - pub is_file: bool, - pub is_symlink: bool, -} - -impl Metadata { - #[inline] - #[must_use] - pub fn is_dir(&self) -> bool { - !self.is_file - } -} - -impl TryFrom for Metadata { - type Error = std::io::Error; - - fn try_from(metadata: std::fs::Metadata) -> Result { - Ok(Metadata { - mtime: metadata.modified()?, - is_file: metadata.is_file(), - is_symlink: metadata.file_type().is_symlink(), - }) - } -} +use werk_util::IoError; #[derive(Default)] pub struct RealSystem(()); @@ -159,14 +19,14 @@ impl RealSystem { } } -impl Io for RealSystem { +impl werk_eval::Io for RealSystem { fn run_recipe_command( &self, command_line: &ShellCommandLine, working_dir: &Absolute, env: &Env, forward_stdout: bool, - ) -> Result, std::io::Error> { + ) -> Result, IoError> { let mut command = smol::process::Command::new(&command_line.program); command .args( @@ -193,8 +53,10 @@ impl Io for RealSystem { command.envs(&env.env); tracing::trace!("spawning {command:?}"); - let child = command.spawn()?; - Ok(Box::new(child)) + let child = command + .spawn() + .map_err(|err| IoError::new(&command_line.program, err))?; + Ok(Box::new(ChildProcess(child))) } fn run_during_eval( @@ -202,7 +64,7 @@ impl Io for RealSystem { command_line: &ShellCommandLine, working_dir: &Absolute, env: &Env, - ) -> Result { + ) -> Result { let mut command = std::process::Command::new(&*command_line.program); command .args( @@ -223,8 +85,12 @@ impl Io for RealSystem { command.envs(&env.env); tracing::trace!("spawning {command:?}"); - let child = command.spawn()?; - let output = child.wait_with_output()?; + let child = command + .spawn() + .map_err(|err| IoError::new(&command_line.program, err))?; + let output = child + .wait_with_output() + .map_err(|err| IoError::new(&command_line.program, err))?; Ok(output) } @@ -234,117 +100,87 @@ impl Io for RealSystem { Ok(normalized) } - fn glob_workspace( + fn walk_directory( &self, path: &Absolute, - settings: &GlobSettings, - ) -> Result, Error> { - struct Builder<'s>(&'s Mutex, Error>>); + settings: GlobSettings, + visit: &(dyn Fn(&Absolute) + Send + Sync), + ) -> Result<(), werk_eval::GlobError> { + struct Builder<'s>(&'s (dyn Fn(&Absolute) + Send + Sync)); impl<'s> ignore::ParallelVisitorBuilder<'s> for Builder<'s> { fn build(&mut self) -> Box { - Box::new(Visitor(Ok(Vec::new()), self.0)) + Box::new(Visitor(self.0)) } } - struct Visitor<'s>( - Result, Error>, - &'s Mutex, Error>>, - ); + struct Visitor<'s>(&'s (dyn Fn(&Absolute) + Send + Sync)); impl ignore::ParallelVisitor for Visitor<'_> { fn visit(&mut self, entry: Result) -> WalkState { - let Ok(ref mut entries) = self.0 else { - // Already errored. - return WalkState::Quit; - }; - - match entry.and_then(TryInto::try_into) { + match entry { Ok(entry) => { - entries.push(entry); + (self.0)(Absolute::new_ref_unchecked(entry.path())); WalkState::Continue } - Err(err) => { - self.0 = Err(err.into()); - WalkState::Quit - } + Err(_) => WalkState::Continue, } } } - impl Drop for Visitor<'_> { - fn drop(&mut self) { - let mut results = self.1.lock(); - let Ok(entries) = &mut *results else { - // Already errored. - return; - }; - - match std::mem::replace(&mut self.0, Ok(Vec::new())) { - Ok(new_entries) => entries.extend(new_entries), - Err(err) => *results = Err(err), - } - } - } - - let GlobSettings { - git_ignore, - git_ignore_global, - git_ignore_exclude, - git_ignore_from_parents, - dot_ignore, - ignore_explicitly, - } = settings.clone(); let mut walker = ignore::WalkBuilder::new(path); walker - .git_ignore(git_ignore) - .git_global(git_ignore_global) - .git_exclude(git_ignore_exclude) - .ignore(dot_ignore) - .parents(git_ignore_from_parents); - - walker.filter_entry(move |entry| !ignore_explicitly.is_match(entry.path())); + .git_ignore(settings.git_ignore) + .git_global(settings.git_ignore_global) + .git_exclude(settings.git_ignore_exclude) + .ignore(settings.dot_ignore) + .parents(settings.git_ignore_from_parents); + walker.filter_entry(move |entry| !settings.ignore_explicitly.is_match(entry.path())); let walker = walker.build_parallel(); - - let results = Mutex::new(Ok(Vec::new())); - walker.visit(&mut Builder(&results)); - results.into_inner().map_err(Error::custom) + walker.visit(&mut Builder(visit)); + Ok(()) } - fn metadata(&self, path: &Absolute) -> Result { - path.metadata()?.try_into().map_err(Into::into) + fn metadata(&self, path: &Absolute) -> Result { + path.metadata() + .and_then(Metadata::try_from) + .map_err(|err| IoError::new(path, err)) } - fn read_file(&self, path: &Absolute) -> Result, std::io::Error> { - std::fs::read(path) + fn read_file(&self, path: &Absolute) -> Result, IoError> { + std::fs::read(path).map_err(|err| IoError::new(path, err)) } - fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), std::io::Error> { - std::fs::write(path, data) + fn write_file(&self, path: &Absolute, data: &[u8]) -> Result<(), IoError> { + std::fs::write(path, data).map_err(|err| IoError::new(path, err)) } - fn copy_file(&self, from: &Absolute, to: &Absolute) -> Result<(), std::io::Error> { - std::fs::copy(from, to).map(|_| ()) + fn copy_file(&self, from: &Absolute, to: &Absolute) -> Result<(), IoError> { + std::fs::copy(from, to) + .map(|_| ()) + .map_err(|err| IoError::new(from, err)) } - fn delete_file(&self, path: &Absolute) -> Result<(), std::io::Error> { - std::fs::remove_file(path) + fn delete_file(&self, path: &Absolute) -> Result<(), IoError> { + std::fs::remove_file(path).map_err(|err| IoError::new(path, err)) } - fn touch(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn touch(&self, path: &Absolute) -> Result<(), IoError> { let file = std::fs::OpenOptions::new() // Ensure that we can write to the file. .append(true) // Create the file if it does not exist. .create(true) - .open(path)?; + .open(path) + .map_err(|err| IoError::new(path, err))?; let now = std::time::SystemTime::now(); file.set_modified(now) + .map_err(|err| IoError::new(path, err)) } - fn create_parent_dirs(&self, path: &Absolute) -> Result<(), std::io::Error> { + fn create_parent_dirs(&self, path: &Absolute) -> Result<(), IoError> { let parent = path.parent().unwrap(); let did_exist = parent.is_dir(); - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent).map_err(|err| IoError::new(path, err))?; if !did_exist { tracing::info!("Created directory: {}", parent.display()); } diff --git a/werk-runner/io/child.rs b/werk-runner/io/child.rs index d3777cdd..7a196de1 100644 --- a/werk-runner/io/child.rs +++ b/werk-runner/io/child.rs @@ -8,67 +8,54 @@ use std::{ use futures::{ AsyncBufRead, AsyncRead, AsyncWrite, FutureExt as _, Stream, StreamExt, io::BufReader, ready, }; +use werk_eval::Child; -pub trait Child: Send + Sync + Unpin { - fn stdin(self: Pin<&mut Self>) -> Option>; - fn stderr(self: Pin<&mut Self>) -> Option>; - - fn take_stdin(&mut self) -> Option>>; - fn take_stdout(&mut self) -> Option>>; - fn take_stderr(&mut self) -> Option>>; - - /// Wait for the process to exit. Does NOT drop the stdin handle. - fn status( - &mut self, - ) -> Pin> + Send>>; - - fn kill(&mut self) -> std::io::Result<()>; +pub enum ChildCaptureOutput { + /// stderr was available. + Stderr(Vec), + /// Will only be produced when the child was spawned with `forward_stdout = + /// true`. + Stdout(Vec), + Exit(std::process::ExitStatus), } -impl Child for smol::process::Child { +pub struct ChildProcess(pub smol::process::Child); + +impl Child for ChildProcess { fn stdin(self: Pin<&mut Self>) -> Option> { - let stdin = Pin::new(self.get_mut().stdin.as_mut()?); + let stdin = Pin::new(self.get_mut().0.stdin.as_mut()?); Some(stdin as _) } fn stderr(self: Pin<&mut Self>) -> Option> { - let stderr = Pin::new(self.get_mut().stderr.as_mut()?); + let stderr = Pin::new(self.get_mut().0.stderr.as_mut()?); Some(stderr as _) } fn take_stdin(&mut self) -> Option>> { - self.stdin.take().map(|s| Box::pin(s) as _) + self.0.stdin.take().map(|s| Box::pin(s) as _) } fn take_stdout(&mut self) -> Option>> { - self.stdout.take().map(|s| Box::pin(s) as _) + self.0.stdout.take().map(|s| Box::pin(s) as _) } fn take_stderr(&mut self) -> Option>> { - self.stderr.take().map(|s| Box::pin(s) as _) + self.0.stderr.take().map(|s| Box::pin(s) as _) } fn status( &mut self, ) -> Pin> + Send>> { - Box::pin(self.status()) + Box::pin(self.0.status()) } fn kill(&mut self) -> std::io::Result<()> { - self.kill() + self.0.kill() } } -pub enum ChildCaptureOutput { - /// stderr was available. - Stderr(Vec), - /// Will only be produced when the child was spawned with `forward_stdout = - /// true`. - Stdout(Vec), - Exit(std::process::ExitStatus), -} - /// Read lines of the output from a child process, capturing stderr /// (informational output). /// diff --git a/werk-runner/lib.rs b/werk-runner/lib.rs index 5e8c7adf..c062b451 100644 --- a/werk-runner/lib.rs +++ b/werk-runner/lib.rs @@ -1,29 +1,18 @@ mod cache; -pub mod depfile; +mod defaults; mod error; -pub mod eval; mod io; -pub mod ir; mod outdatedness; -mod pattern; mod render; mod runner; -mod scope; -mod shell; -mod value; -mod warning; mod workspace; +pub use defaults::*; pub use error::*; pub use io::*; pub use outdatedness::*; -pub use pattern::*; pub use render::*; pub use runner::*; -pub use scope::*; -pub use shell::*; -pub use value::*; -pub use warning::*; pub use workspace::*; pub use which::Error as WhichError; @@ -31,4 +20,10 @@ pub use which::Error as WhichError; #[doc(no_inline)] pub use globset; -stringleton::enable!(); +stringleton::enable!(werk_eval); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Edition { + #[default] + V1, +} diff --git a/werk-runner/ir.rs b/werk-runner/manifest.rs similarity index 62% rename from werk-runner/ir.rs rename to werk-runner/manifest.rs index 153da82b..f0186379 100644 --- a/werk-runner/ir.rs +++ b/werk-runner/manifest.rs @@ -1,13 +1,9 @@ use indexmap::IndexMap; use stringleton::Symbol; +use werk_eval::{AmbiguousPatternError, ConfigVar, EvalError, LocalVariables, Value}; use werk_fs::Absolute; -use werk_parser::ast; -use werk_util::{DiagnosticFileId, DiagnosticMainSourceMap, DiagnosticSpan}; - -use crate::{ - AmbiguousPatternError, ConfigVar, EvalError, LocalVariables, Pattern, PatternMatchData, Value, - cache::Hash128, -}; +use werk_planner::{BuildRecipe, BuildRecipeMatch, RecipeMatch, TaskRecipe}; +use werk_util::{DiagnosticMainSourceMap, DiagnosticSpan}; type Result = std::result::Result; @@ -115,7 +111,7 @@ impl Manifest { Ok(best_match.map(|(recipe, match_data)| BuildRecipeMatch { recipe, match_data, - target_file: path.to_owned().into_boxed_path(), + target_file: Absolute::symbolicate(path), })) } @@ -129,7 +125,7 @@ impl Manifest { if let Ok(path) = path.normalize() { if let Some(build_recipe_match) = self.match_build_recipe(&path)? { if let Some(task) = task { - return Err(crate::AmbiguousPatternError { + return Err(AmbiguousPatternError { pattern1: build_recipe_match.recipe.pattern.span, pattern2: task.ast.name.span.with_file(task.span.file), path: name.to_owned(), @@ -146,92 +142,6 @@ impl Manifest { } } -pub enum RecipeMatch<'a> { - Task(&'a TaskRecipe), - Build(BuildRecipeMatch<'a>), -} - -pub struct BuildRecipeMatch<'a> { - pub recipe: &'a BuildRecipe, - pub match_data: PatternMatchData, - pub target_file: Box>, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Edition { - #[default] - V1, -} - -#[derive(Debug)] -pub struct TaskRecipe { - pub span: DiagnosticSpan, - pub name: Symbol, - pub doc_comment: String, - pub ast: ast::TaskRecipe, - pub hash: Hash128, -} - -#[derive(Debug)] -pub struct BuildRecipe { - pub span: DiagnosticSpan, - pub pattern: Pattern, - pub doc_comment: String, - pub ast: ast::BuildRecipe, - pub hash: Hash128, -} - -#[derive(Debug, Default, PartialEq)] -pub struct Defaults { - pub output_directory: Option, - pub print_commands: Option, - pub print_fresh: Option, - pub quiet: Option, - pub loud: Option, - pub explain: Option, - pub verbose: Option, - pub watch_delay: Option, - pub jobs: Option, - pub edition: Edition, -} - -impl Defaults { - pub fn new(ast: &ast::Root, file: DiagnosticFileId) -> Result { - let mut defaults = Self::default(); - for stmt in &ast.statements { - let ast::RootStmt::Default(ref stmt) = stmt.statement else { - continue; - }; - - match stmt { - ast::DefaultStmt::Target(_) => {} // Evaluated on workspace creation. - ast::DefaultStmt::OutDir(entry) => { - defaults.output_directory = Some(entry.value.1.clone()); - } - ast::DefaultStmt::PrintCommands(entry) => { - defaults.print_commands = Some(entry.value.1); - } - ast::DefaultStmt::PrintFresh(entry) => defaults.print_fresh = Some(entry.value.1), - ast::DefaultStmt::Quiet(entry) => defaults.quiet = Some(entry.value.1), - ast::DefaultStmt::Loud(entry) => defaults.loud = Some(entry.value.1), - ast::DefaultStmt::Explain(entry) => defaults.explain = Some(entry.value.1), - ast::DefaultStmt::Verbose(entry) => defaults.verbose = Some(entry.value.1), - ast::DefaultStmt::WatchDelay(entry) => defaults.watch_delay = Some(entry.value.1), - ast::DefaultStmt::Jobs(entry) => defaults.jobs = entry.value.1.try_into().ok(), - ast::DefaultStmt::Edition(entry) => { - if entry.value.1 == "v1" { - defaults.edition = Edition::V1; - } else { - return Err(EvalError::InvalidEdition(file.span(entry.value.0))); - } - } - } - } - - Ok(defaults) - } -} - impl werk_util::DiagnosticSourceMap for Manifest { #[inline] fn get_source( diff --git a/werk-runner/outdatedness.rs b/werk-runner/outdatedness.rs index cc46b1a6..66d15273 100644 --- a/werk-runner/outdatedness.rs +++ b/werk-runner/outdatedness.rs @@ -4,14 +4,10 @@ use std::{ }; use stringleton::Symbol; +use werk_eval::{TaskName, Used, UsedVariable}; use werk_fs::{Absolute, SymPath}; -use crate::{ - TaskId, Workspace, - cache::TargetOutdatednessCache, - eval::{Used, UsedVariable}, - ir, -}; +use crate::{Workspace, cache::TargetOutdatednessCache}; /// A reason why a variable or recipe is "outdated". #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -33,7 +29,7 @@ pub enum Reason { /// Manual define changed. Define(Symbol), /// The recipe has a dependency that was rebuilt. - Rebuilt(TaskId), + Rebuilt(TaskName), } impl Reason { @@ -170,7 +166,7 @@ impl<'a> OutdatednessTracker<'a> { pub fn new( workspace: &'a Workspace, cache: Option<&'a TargetOutdatednessCache>, - recipe: &ir::BuildRecipe, + recipe: &werk_planner::BuildRecipe, target_mtime: Option, ) -> Self { let mut outdatedness = Outdatedness::unchanged(); diff --git a/werk-runner/render.rs b/werk-runner/render.rs index 9712ef54..c57a9a9c 100644 --- a/werk-runner/render.rs +++ b/werk-runner/render.rs @@ -1,17 +1,18 @@ +use werk_eval::{ShellCommandLine, TaskName}; use werk_util::DiagnosticFileId; -use crate::{BuildStatus, Error, Outdatedness, ShellCommandLine, TaskId, Warning}; +use crate::{BuildStatus, Error, Outdatedness}; -pub trait Render: Send + Sync { +pub trait Render: werk_eval::Messenger + Send + Sync { /// Build task is about to start. - fn will_build(&self, task_id: TaskId, num_steps: usize, outdatedness: &Outdatedness); + fn will_build(&self, task_id: TaskName, num_steps: usize, outdatedness: &Outdatedness); /// Build task finished (all steps have been completed). - fn did_build(&self, task_id: TaskId, result: &Result); + fn did_build(&self, task_id: TaskName, result: &Result); /// Run command is about to be executed. fn will_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, step: usize, num_steps: usize, @@ -19,7 +20,7 @@ pub trait Render: Send + Sync { fn on_child_process_stderr_line( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, line_without_eol: &[u8], quiet: bool, @@ -29,7 +30,7 @@ pub trait Render: Send + Sync { fn on_child_process_stdout_line( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, line_without_eol: &[u8], ) { @@ -44,21 +45,13 @@ pub trait Render: Send + Sync { /// function, fn did_execute( &self, - task_id: TaskId, + task_id: TaskName, command: &ShellCommandLine, status: &std::io::Result, step: usize, num_steps: usize, ); - /// Emit a message from the user, typically from the `info` expression in - /// the manifest. - fn message(&self, task_id: Option, message: &str); - - /// Emit a warning from the user, typically from the `warn` expression in - /// the manifest. - fn warning(&self, task_id: Option, warning: &Warning); - /// Emit an informational message from the runtime, typically the `werk` /// binary wants to tell the user about something that happened. /// diff --git a/werk-runner/runner.rs b/werk-runner/runner.rs index d52c45f8..84f1edf1 100644 --- a/werk-runner/runner.rs +++ b/werk-runner/runner.rs @@ -1,46 +1,28 @@ -use std::{future::Future, sync::Arc, time::SystemTime}; +use std::{sync::Arc, time::SystemTime}; -use futures::{StreamExt, channel::oneshot, future}; -use indexmap::{IndexMap, map::Entry}; +use futures::{StreamExt, future, pin_mut}; use parking_lot::Mutex; -use stringleton::sym; -use werk_fs::{Absolute, Normalize as _, Path, SymPath}; -use werk_util::{Annotated, AsDiagnostic, DiagnosticSpan, cancel}; +use stringleton::Symbol; +use werk_eval::{ + Env, Eval, EvalError, EvaluatedBuildRecipe, EvaluatedTaskRecipe, RunCommand, Scope, + ShellCommandLine, TaskName, Warning, +}; +use werk_fs::{Absolute, PathError, SymPath}; +use werk_planner::{EvaluatedTask, PlannerError, RecipeMatch, TaskGraph, TaskId, TaskSpec}; +use werk_util::{Annotated, AsDiagnostic, DiagnosticSpan, IoError, broadcast_one, cancel}; use crate::{ - AmbiguousPatternError, BuildRecipeScope, ChildCaptureOutput, ChildLinesStream, Env, Error, - Outdatedness, OutdatednessTracker, Reason, Scope as _, ShellCommandLine, StringValue, - TaskRecipeScope, Value, Warning, Workspace, WorkspaceSettings, - depfile::Depfile, - eval::{self, Eval}, - ir, + ChildCaptureOutput, ChildLinesStream, Error, Outdatedness, OutdatednessTracker, Reason, + Workspace, }; -mod command; -mod dep_chain; -mod task; - -pub use command::*; -pub use dep_chain::*; -pub use task::*; - -/// Workspace-wide runner state. -pub(crate) struct RunnerState { - concurrency_limit: smol::lock::Semaphore, - tasks: Mutex>, -} - -impl RunnerState { - pub fn new(jobs: usize) -> Self { - Self { - concurrency_limit: smol::lock::Semaphore::new(jobs.max(1)), - tasks: Mutex::new(IndexMap::default()), - } - } -} - pub struct Runner<'a> { inner: Arc>, + state: Mutex>, +} + +struct RunState { + status_receivers: Vec>>, } struct Inner<'a> { @@ -49,19 +31,14 @@ struct Inner<'a> { /// cancellation signal, or deadlocks can occur. executor: smol::Executor<'a>, cancel: cancel::Sender, -} - -#[derive(Clone)] -pub struct Settings { - pub glob: WorkspaceSettings, - pub dry_run: bool, + concurrency_limit: smol::lock::Semaphore, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum BuildStatus { /// Target was built, along with the outdatedness. If the outdatedness is /// empty, the target was determined to be up-to-date. - Complete(TaskId, Outdatedness), + Complete(TaskName, Outdatedness), /// Target is a dependency that exists in the filesystem, along with its /// last modification time. Exists(Absolute, SystemTime), @@ -95,6 +72,32 @@ impl BuildStatus { BuildStatus::Ignore(_) => None, } } + + /// Given an output file modification time, return the outdatedness of the + /// target. If the target is up-to-date, the outdatedness will be empty. If + /// an output mtime is not available, returns empty outdatedness. + #[must_use] + pub(crate) fn as_outdated_reason(&self, output_mtime: Option) -> Option { + match self { + BuildStatus::Complete(task_id, outdatedness) => { + if outdatedness.is_outdated() { + Some(Reason::Rebuilt(*task_id)) + } else { + None + } + } + BuildStatus::Exists(path_buf, system_time) => { + let output_mtime = output_mtime?; + + if output_mtime <= *system_time { + Some(Reason::Modified(*path_buf, *system_time)) + } else { + None + } + } + BuildStatus::Ignore(_) => None, + } + } } impl<'a> Runner<'a> { @@ -104,66 +107,77 @@ impl<'a> Runner<'a> { workspace, executor: smol::Executor::new(), cancel: cancel::Sender::new(), + concurrency_limit: smol::lock::Semaphore::new(workspace.max_concurrent_jobs.max(1)), }), + state: Mutex::new(None), } } - pub async fn build_file( - &self, - target: &Path, - ) -> Result> { - let target = target - .absolutize(werk_fs::Path::ROOT) - .map_err(|err| Error::InvalidTargetPath(target.to_string(), err)) - .map_err(|err| err.into_diagnostic_error(self.inner.workspace))?; - tracing::debug!("Build: {target}"); - let spec = self - .inner - .get_build_spec(&target) - .map_err(|err| err.into_diagnostic_error(self.inner.workspace))?; - let inner = self.inner.clone(); - // TODO: Run the executor with multiple threads. - self.inner - .executor - .run(async move { inner.run_task(spec, DepChain::Empty).await }) - .await - .map_err(|err| err.into_diagnostic_error(self.inner.workspace)) + pub fn get_status(&self, task_id: TaskId) -> Option> { + self.state.lock().as_ref().and_then(|run_state| { + run_state + .status_receivers + .get(task_id.index()) + .and_then(|receiver| { + receiver.try_recv().map(|r| match r { + Ok(task_result) => task_result.clone(), + Err(broadcast_one::Disconnected) => Err(Error::Cancelled(TaskName::Task( + Symbol::new("cancelled task"), + ))), + }) + }) + }) } - pub async fn run_command( - &self, - target: &str, - ) -> Result> { - tracing::debug!("Run: {target}"); - let spec = self - .inner - .get_command_spec(target) - .map_err(|err| err.into_diagnostic_error(self.inner.workspace))?; - let inner = self.inner.clone(); - // TODO: Run the executor with multiple threads. - self.inner - .executor - .run(async move { inner.run_task(spec, DepChain::Empty).await }) - .await - .map_err(|err| err.into_diagnostic_error(self.inner.workspace)) - } + pub async fn run(&self, mut task_graph: TaskGraph<'a>) -> Result<(), Annotated<'a, Error>> { + let tasks = { + let mut run_state = self.state.lock(); + assert!( + !run_state.is_some(), + "Runner is already running; reset it first" + ); + let mut tasks = Vec::with_capacity(task_graph.num_tasks()); + let mut status_senders = Vec::with_capacity(task_graph.num_tasks()); + let mut status_receivers = Vec::with_capacity(task_graph.num_tasks()); + + for _ in 0..task_graph.num_tasks() { + let (send, recv) = broadcast_one::channel(); + status_senders.push(Some(send)); + status_receivers.push(recv); + } + + for index in 0..task_graph.num_tasks() { + let task_id = TaskId::from_index(index); + let evaluated_task = task_graph.take_task(task_id); + let task_spec = task_graph.get_task_spec(task_id).clone(); + let result_sender = status_senders[task_id.index()].take().unwrap(); + let wait_for_dependencies = task_graph + .get_task_dependencies(task_id) + .iter() + .map(|dep_id| status_receivers[dep_id.index()].clone()) + .collect::>(); + tasks.push(self.inner.executor.spawn(self.inner.clone().run_task( + self.inner.cancel.receiver(), + task_spec, + evaluated_task, + wait_for_dependencies, + result_sender, + ))); + } + *run_state = Some(RunState { status_receivers }); + tasks + }; - pub async fn build_or_run( - &self, - target: &str, - ) -> Result> { - tracing::debug!("Build or run: {target}"); - let spec = self - .inner - .get_build_or_command_spec(target) - .map_err(|err| err.into_diagnostic_error(self.inner.workspace))?; - let inner = self.inner.clone(); - // TODO: Run the executor with multiple threads. self.inner .executor - .run(async move { inner.run_task(spec, DepChain::Empty).await }) + .run(async move { + for task in tasks { + task.await?; + } + Ok::<(), Error>(()) + }) .await - .map_err(|err| err.into_diagnostic_error(self.inner.workspace)) + .map_err(|err| err.into_diagnostic_error(&self.inner.workspace.manifest)) } /// Stop long-running child processes and wait for them to finish. @@ -190,207 +204,151 @@ impl<'a> Runner<'a> { self.inner.executor.tick().await; } } -} - -impl<'a> Inner<'a> { - fn get_build_spec(&self, target: &Absolute) -> Result, Error> { - let recipe_match = self.workspace.manifest.match_build_recipe(target)?; - Ok(if let Some(recipe_match) = recipe_match { - TaskSpec::Recipe(ir::RecipeMatch::Build(recipe_match)) - } else { - TaskSpec::CheckExists(target.to_owned()) - }) - } - fn get_build_spec_relaxed(&self, target: &Absolute) -> Result, Error> { - let recipe_match = self.workspace.manifest.match_build_recipe(target)?; - Ok(if let Some(recipe_match) = recipe_match { - TaskSpec::Recipe(ir::RecipeMatch::Build(recipe_match)) - } else { - TaskSpec::CheckExistsRelaxed(target.to_owned()) - }) - } - - fn get_depfile_build_spec(&self, target: &Absolute) -> Result, Error> { - let Some(recipe_match) = self.workspace.manifest.match_build_recipe(target)? else { - return Ok(DepfileSpec::ImplicitlyGenerated(target.to_owned())); - }; - Ok(DepfileSpec::Recipe(recipe_match)) - } - - fn get_command_spec(&self, target: &str) -> Result, Error> { - let recipe_match = self - .workspace - .manifest - .match_task_recipe(target) - .ok_or_else(|| Error::NoRuleToBuildTarget(target.to_owned()))?; - Ok(TaskSpec::Recipe(ir::RecipeMatch::Task(recipe_match))) + pub async fn reset(&self) { + self.wait_for_long_running_tasks().await; + *self.state.lock() = None; } +} - fn get_build_or_command_spec(&self, target: &str) -> Result, Error> { - let task_recipe_match = self.workspace.manifest.match_task_recipe(target); - - if let Ok(path) = werk_fs::Path::new(target) { - let path = path - .absolutize(werk_fs::Path::ROOT) - .map_err(|err| Error::InvalidTargetPath(path.to_string(), err))?; - if let Some(build_recipe_match) = self.workspace.manifest.match_build_recipe(&path)? { - if let Some(task_recipe) = task_recipe_match { - return Err(AmbiguousPatternError { - pattern1: task_recipe.span.file.span(task_recipe.ast.name.span), - pattern2: build_recipe_match.recipe.pattern.span, - path: path.to_string(), +impl<'a> Inner<'a> { + /// Build the task, waiting for dependencies and signaling dependents. + async fn run_task( + self: Arc, + cancel: cancel::Receiver, + spec: TaskSpec<'a>, + task: EvaluatedTask, + wait_for_dependencies: Vec>>, + result_sender: broadcast_one::Sender>, + ) -> Result<(), Error> { + match task { + EvaluatedTask::Build(recipe) => { + match self + .execute_build_recipe(spec, recipe, wait_for_dependencies, cancel) + .await + { + Ok(status) => { + result_sender.send(Ok(status)); + Ok(()) + } + Err(err) => { + result_sender.send(Err(err.clone())); + Err(err) } - .into()); } - - return Ok(TaskSpec::Recipe(ir::RecipeMatch::Build(build_recipe_match))); - } else if task_recipe_match.is_none() { - return Ok(TaskSpec::CheckExists(path.into_owned())); } - } - - match task_recipe_match { - Some(task_recipe_match) => { - Ok(TaskSpec::Recipe(ir::RecipeMatch::Task(task_recipe_match))) + EvaluatedTask::Task(recipe) => { + match self + .execute_task_recipe(spec, recipe, wait_for_dependencies, cancel) + .await + { + Ok(status) => { + result_sender.send(Ok(status)); + Ok(()) + } + Err(err) => { + result_sender.send(Err(err.clone())); + Err(err) + } + } + } + EvaluatedTask::CheckExists => { + debug_assert!(wait_for_dependencies.is_empty()); + match spec { + TaskSpec::Recipe(_) => unreachable!(), + TaskSpec::CheckExists(path) => { + let result = self.check_exists(path.as_path())?; + result_sender.send(Ok(result.clone())); + Ok(()) + } + TaskSpec::CheckExistsRelaxed(path) => { + let result = self.check_exists_relaxed(path.as_path()); + result_sender.send(Ok(result.clone())); + Ok(()) + } + } } - None => Err(Error::NoRuleToBuildTarget(target.to_owned())), } } - /// Build the task, coordinating dependencies and rebuilds. The `invoker` - /// is the chain of dependencies that triggered this build, not including - /// this task. - async fn run_task( + async fn execute_task_recipe( self: Arc, spec: TaskSpec<'a>, - dep_chain: DepChain<'_>, + recipe: EvaluatedTaskRecipe, + wait_for_dependencies: Vec>>, + cancel: cancel::Receiver, ) -> Result { - enum Scheduling<'a> { - Done(Result), - Pending(oneshot::Receiver>), - BuildNow(TaskSpec<'a>), - } - - fn schedule<'a>(this: &RunnerState, spec: TaskSpec<'a>) -> Scheduling<'a> { - match this.tasks.lock().entry(spec.to_task_id()) { - Entry::Occupied(mut entry) => match entry.get_mut() { - TaskStatus::Built(result) => Scheduling::Done(result.clone()), - TaskStatus::Pending(waiters) => { - let (send, recv) = oneshot::channel(); - waiters.push(send); - Scheduling::Pending(recv) - } - }, - Entry::Vacant(entry) => { - entry.insert(TaskStatus::Pending(Vec::new())); - Scheduling::BuildNow(spec) + let TaskSpec::Recipe(RecipeMatch::Task(recipe_match)) = spec else { + unreachable!() + }; + let task_name = TaskName::Task(recipe_match.name); + + // Wait for dependencies. + for dep_status in wait_for_dependencies { + match dep_status.recv().await { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + return Err(Error::DependencyFailed(task_name, Arc::new(err.clone()))); } + Err(broadcast_one::Disconnected) => return Err(Error::Cancelled(task_name)), } } - fn finish_built(this: &RunnerState, task_id: TaskId, result: &Result) { - // Notify dependents - let mut tasks = this.tasks.lock(); - let status = tasks.get_mut(&task_id).expect("task not registered"); - // Set the task status as complete. - let TaskStatus::Pending(waiters) = - std::mem::replace(status, TaskStatus::Built(result.clone())) - else { - panic!("Task built multiple times: {task_id}"); - }; - std::mem::drop(tasks); - - // Notify dependents that the task completed. - for waiter in waiters { - _ = waiter.send(result.clone()); - } - } - - let task_id = spec.to_task_id(); - - // Perform the circular dependency check regardless of how the task was - // triggered. Otherwise the check would depend on scheduling, which is - // nondeterministic. - if dep_chain.contains(task_id) { - let dep_chain = dep_chain.push(task_id); - return Err(Error::CircularDependency(dep_chain.collect())); - } - - match schedule(&self.workspace.runner_state, spec) { - Scheduling::Done(result) => result, - Scheduling::Pending(receiver) => { - receiver.await.map_err(|_| Error::Cancelled(task_id))? - } - Scheduling::BuildNow(task_spec) => { - let cancel = self.cancel.receiver(); - let result = self - .clone() - .rebuild_spec(task_id, task_spec, &cancel, dep_chain) - .await; - finish_built(&self.workspace.runner_state, task_id, &result); - result - } - } - } - - fn check_exists(&self, path: &Absolute) -> Result { - let Some(entry) = self.workspace.get_project_file(path) else { - return Err(Error::NoRuleToBuildTarget(path.to_string())); - }; - let mtime = entry.metadata.mtime; - tracing::debug!("Check file mtime `{path}`: {mtime:?}"); - Ok(BuildStatus::Exists(Absolute::symbolicate(path), mtime)) - } + let outdated = Outdatedness::outdated(Reason::Rebuilt(task_name)); + self.workspace + .render + .will_build(task_name, recipe.commands.len(), &outdated); - /// Existence check used for dependencies discovered in depfiles. - /// - /// 1. If the file exists in the workspace, use the workspace file's mtime. - /// 2. Otherwise, if the file exists in the output directory, use its mtime. - /// 3. When the file neither exists in the filesystem, nor in the output - /// directory, nor is there a build recipe to produce it, ignore it. - fn check_exists_relaxed(&self, path: &Absolute) -> BuildStatus { - let mtime = if let Some(entry) = self.workspace.get_project_file(path) { - Some(entry.metadata.mtime) - } else if let Ok(Some(entry)) = self.workspace.get_existing_output_file(path) { - Some(entry.metadata.mtime) + let result = if outdated.is_outdated() { + tracing::debug!("Rebuilding"); + tracing::trace!("Reasons: {:?}", outdated); + self.execute_recipe_commands( + task_name, + cancel, + recipe.commands, + recipe.env, + true, + false, + ) + .await + .map(|()| BuildStatus::Complete(task_name, outdated)) } else { - None + tracing::debug!("Up to date"); + Ok(BuildStatus::Complete(task_name, outdated)) }; - if let Some(mtime) = mtime { - tracing::debug!("Check file mtime `{path}`: {mtime:?}"); - BuildStatus::Exists(Absolute::symbolicate(path), mtime) - } else { - // The dependency could not be found anywhere, so just ignore it. - tracing::debug!("Depfile dependency not found anywhere, ignoring it: {path}"); - BuildStatus::Ignore(Absolute::symbolicate(path)) - } + self.workspace.render.did_build(task_name, &result); + result } - #[tracing::instrument(level = "debug", skip_all, fields(target_file))] - #[allow(clippy::too_many_lines)] async fn execute_build_recipe( - self: &Arc, - task_id: TaskId, - recipe_match: ir::BuildRecipeMatch<'_>, - cancel: &cancel::Receiver, - dep_chain: DepChainEntry<'_>, + self: Arc, + spec: TaskSpec<'a>, + recipe: Eval, + wait_for_dependencies: Vec>>, + cancel: cancel::Receiver, ) -> Result { - let mut scope = BuildRecipeScope::new(self.workspace, task_id, &recipe_match); - scope.set( - sym!("out"), - Eval::inherent(Value::from(recipe_match.target_file.to_string())), - ); - + let TaskSpec::Recipe(RecipeMatch::Build(recipe_match)) = spec else { + unreachable!() + }; + let task_name = TaskName::Build(recipe_match.target_file); let cache = self .workspace - .take_build_target_cache(&recipe_match.target_file); - // Check the target's mtime. - let out_mtime = scope - .workspace() - .get_existing_output_file(&recipe_match.target_file)? - .map(|entry| entry.metadata.mtime); + .take_build_target_cache(recipe_match.target_file); + let out_mtime = match self + .workspace + .stat_file_if_exists(recipe_match.target_file.as_path()) + { + Ok(Some(entry)) => Some(entry.metadata.mtime), + Ok(None) => None, + Err(err) if err.error.kind() == std::io::ErrorKind::IsADirectory => { + return Err(Error::TargetIsADirectory { + span: recipe_match.recipe.span, + path: recipe_match.target_file.as_path().to_owned().into_inner(), + }); + } + Err(err) => return Err(err.into()), + }; let mut outdatedness = OutdatednessTracker::new( self.workspace, @@ -399,67 +357,52 @@ impl<'a> Inner<'a> { out_mtime, ); - // Evaluate recipe body (`out` is available and in scope). - let evaluated = eval::eval_build_recipe_statements( - &mut scope, - &recipe_match.recipe.ast.body.statements, - recipe_match.recipe.span.file, - )?; - outdatedness.did_use(evaluated.used); - let evaluated = evaluated.value; - - let mut explicit_dependency_specs = evaluated - .explicit_dependencies - .iter() - .map(|s| self.get_build_or_command_spec(s)) - .collect::, Error>>()?; + // Include changes to the recipe or config variables as reasons for outdatedness. + outdatedness.did_use(recipe.used); // Rebuild if the target does not exist. if let Some(mtime) = out_mtime { tracing::debug!("Output exists, mtime: {mtime:?}"); } else { tracing::debug!("Output file missing, target is outdated"); - outdatedness.missing(Absolute::symbolicate(&recipe_match.target_file)); + outdatedness.missing(recipe_match.target_file); } - let mut check_implicit_depfile_was_generated = None; - if let Some(depfile) = evaluated.depfile { - check_implicit_depfile_was_generated = self - .build_or_check_depfile( - &mut scope, - &depfile, - &mut explicit_dependency_specs, - dep_chain, - out_mtime, - &mut outdatedness, - ) - .await?; + // Wait for dependencies. + let mut reasons = Vec::with_capacity(wait_for_dependencies.len()); + for dep_status in wait_for_dependencies { + match dep_status.recv().await { + Ok(Ok(dep_status)) => { + reasons.extend(dep_status.as_outdated_reason(out_mtime)); + } + Ok(Err(err)) => { + return Err(Error::DependencyFailed(task_name, Arc::new(err.clone()))); + } + Err(broadcast_one::Disconnected) => return Err(Error::Cancelled(task_name)), + } } - // Build dependencies! - let dep_reasons = self - .build_dependencies(explicit_dependency_specs, dep_chain, out_mtime) - .await?; - outdatedness.add_reasons(dep_reasons); + // Consider this target outdated if any of the dependencies were rebuilt. + outdatedness.add_reasons(reasons); // Create the parent directory for the target file if it doesn't exist. - scope - .workspace() - .create_output_parent_dirs(&recipe_match.target_file)?; + self.workspace + .create_parent_dirs(recipe_match.target_file.as_path())?; let (outdated, new_cache) = outdatedness.finish(); self.workspace - .store_build_target_cache(recipe_match.target_file.to_path_buf(), new_cache); + .store_build_target_cache(recipe_match.target_file, new_cache); + let evaluated = recipe.value; self.workspace .render - .will_build(task_id, evaluated.commands.len(), &outdated); + .will_build(task_name, evaluated.commands.len(), &outdated); let result = if outdated.is_outdated() { tracing::debug!("Rebuilding"); tracing::trace!("Reasons: {:?}", outdated); self.execute_recipe_commands( - task_id, + task_name, cancel, evaluated.commands, evaluated.env, @@ -467,174 +410,55 @@ impl<'a> Inner<'a> { false, ) .await - .map(|()| BuildStatus::Complete(task_id, outdated)) + .map(|()| BuildStatus::Complete(task_name, outdated)) } else { tracing::debug!("Up to date"); - Ok(BuildStatus::Complete(task_id, outdated)) + Ok(BuildStatus::Complete(task_name, outdated)) }; - // Check if the implicit depfile was actually generated, and emit a warning if not. - if let Some(ref implicit_depfile_path) = check_implicit_depfile_was_generated { - if !self.workspace.io.is_dry_run() { - if let Ok(None) | Err(_) = self - .workspace - .get_existing_output_file(implicit_depfile_path) - { - self.workspace.render.warning( - Some(task_id), - &Warning::DepfileNotGenerated( - recipe_match.recipe.pattern.span, - self.workspace - .get_output_file_path(implicit_depfile_path) - .unwrap(), - ), - ); - } - } - } - - self.workspace.render.did_build(task_id, &result); + self.workspace.render.did_build(task_name, &result); result } - async fn build_or_check_depfile( - self: &Arc, - scope: &mut BuildRecipeScope<'_>, - depfile: &StringValue, - explicit_dependency_specs: &mut Vec>, - dep_chain: DepChainEntry<'_>, - out_mtime: Option, - outdatedness: &mut OutdatednessTracker<'_>, - ) -> Result>, Error> { - let mut check_implicit_depfile_was_generated = None; - - let depfile_path = werk_fs::Path::new(depfile) - .and_then(|p| p.absolutize(werk_fs::Path::ROOT)) - .map_err(|err| Error::InvalidTargetPath(depfile.string.clone(), err))?; - let dep = self.get_depfile_build_spec(&depfile_path)?; - - // Make the `depfile` variable available to the recipe body. - scope.set( - sym!("depfile"), - Eval::inherent(Value::String(depfile.clone())), - ); - - match dep { - DepfileSpec::Recipe(depfile_recipe_match_data) => { - tracing::debug!( - "Building depfile with recipe: {}", - depfile_recipe_match_data.recipe.pattern - ); - // Depfile is explicitly generated by a recipe - build it. - let dep_reasons = self - .clone() - .build_dependencies( - vec![TaskSpec::Recipe(ir::RecipeMatch::Build( - depfile_recipe_match_data, - ))], - dep_chain, - out_mtime, - ) - .await?; - outdatedness.add_reasons(dep_reasons); - } - DepfileSpec::ImplicitlyGenerated(path_buf) => { - tracing::debug!("Assuming implicitly generated depfile"); - check_implicit_depfile_was_generated = Some(path_buf); - } - } - let is_implicit_depfile = check_implicit_depfile_was_generated.is_some(); - - if let Some(depfile_entry) = self.workspace.get_existing_output_file(&depfile_path)? { - // The depfile was generated, either by a build recipe or by a - // previous run of this recipe. Parse it and add its - // dependencies! - tracing::debug!("Parsing depfile: {}", depfile_entry.path.display()); - let depfile_contents = self.workspace.io.read_file(&depfile_entry.path)?; - let depfile = Depfile::parse(&depfile_contents)?; - for dep in &depfile.deps { - // Translate the filesystem path produced by the compiler into an - // abstract path within the workspace. Normally this will be a file - // inside the output directory. - let dep = dep.as_path().normalize()?; - let abstract_path = self - .workspace - .unresolve_path(&dep) - .map_err(|err| Error::InvalidPathInDepfile(dep.display().to_string(), err))?; - tracing::debug!("Discovered depfile dependency: {abstract_path}"); - explicit_dependency_specs.push(self.get_build_spec_relaxed(&abstract_path)?); - } - } else if is_implicit_depfile { - // The implicit depfile was not generated yet, in this run - // or a previous run. Add that as a reason to rebuild the - // main recipe. Note that this causes the main recipe to - // always be outdated if it fails to generate the depfile! - outdatedness.add_reason(Reason::Missing(Absolute::symbolicate(depfile_path))); - } else { - // If the depfile is generated by a rule, it is an error if that - // rule did not generate the depfile. If it is implicit, it's - // fine if it doesn't exist yet. - if !self.workspace.io.is_dry_run() { - return Err(Error::DepfileNotFound( - depfile_path.into_owned().into_inner(), - )); - } - } - - Ok(check_implicit_depfile_was_generated) + fn check_exists(&self, path: &Absolute) -> Result { + let Some(entry) = self.workspace.stat_file_if_exists(path)? else { + return Err(Error::Planner(PlannerError::NoRuleToBuildTarget( + path.to_string(), + ))); + }; + let mtime = entry.metadata.mtime; + tracing::debug!("Check file mtime `{path}`: {mtime:?}"); + Ok(BuildStatus::Exists(Absolute::symbolicate(path), mtime)) } - async fn execute_task_recipe( - self: &Arc, - task_id: TaskId, - recipe: &ir::TaskRecipe, - cancel: &cancel::Receiver, - dep_chain: DepChainEntry<'_>, - ) -> Result { - let mut scope = TaskRecipeScope::new(self.workspace, task_id); - - // Evaluate dependencies (`out` is not available in commands). - - let evaluated = eval::eval_task_recipe_statements( - &mut scope, - &recipe.ast.body.statements, - recipe.span.file, - )?; - let dependency_specs = evaluated - .build - .iter() - .map(|s| self.get_build_or_command_spec(s)) - .collect::, _>>()?; - - // Note: We don't care about the status of dependencies. - self.build_dependencies(dependency_specs, dep_chain, None) - .await?; - - let outdated = Outdatedness::outdated(Reason::Rebuilt(task_id)); - self.workspace - .render - .will_build(task_id, evaluated.commands.len(), &outdated); - - let result = self - .execute_recipe_commands( - task_id, - cancel, - evaluated.commands, - evaluated.env, - false, - true, - ) - .await - .map(|()| BuildStatus::Complete(task_id, outdated)); + /// Existence check used for dependencies discovered in depfiles. + /// + /// 1. If the file exists in the workspace, use the workspace file's mtime. + /// 2. Otherwise, if the file exists in the output directory, use its mtime. + /// 3. When the file neither exists in the filesystem, nor in the output + /// directory, nor is there a build recipe to produce it, ignore it. + fn check_exists_relaxed(&self, path: &Absolute) -> BuildStatus { + let mtime = self + .workspace + .stat_file_if_exists(path) + .ok() + .flatten() + .map(|e| e.metadata.mtime); - self.workspace.render.did_build(task_id, &result); - result + if let Some(mtime) = mtime { + tracing::debug!("Check file mtime `{path}`: {mtime:?}"); + BuildStatus::Exists(Absolute::symbolicate(path), mtime) + } else { + // The dependency could not be found anywhere, so just ignore it. + tracing::debug!("Depfile dependency not found, ignoring it: {path}"); + BuildStatus::Ignore(Absolute::symbolicate(path)) + } } async fn execute_recipe_commands( &self, - task_id: TaskId, - cancel: &cancel::Receiver, + task_id: TaskName, + cancel: cancel::Receiver, run_commands: Vec, mut env: Env, silent_by_default: bool, @@ -646,12 +470,7 @@ impl<'a> Inner<'a> { } // Ensure that only the desired number of jobs are running. - let _limit_concurrency = self - .workspace - .runner_state - .concurrency_limit - .acquire() - .await; + let _limit_concurrency = self.concurrency_limit.acquire().await; if self.workspace.force_color { env.set_force_color(); @@ -676,7 +495,7 @@ impl<'a> Inner<'a> { step, num_steps, forward_stdout, - cancel, + cancel.clone(), ) .await?; } @@ -689,29 +508,21 @@ impl<'a> Inner<'a> { step, num_steps, forward_stdout, - cancel, + cancel.clone(), )?; } RunCommand::Write(path_buf, vec) => { self.workspace.io.write_file(&path_buf, &vec)?; } RunCommand::Copy(from, to) => { - let Some(src_entry) = - self.workspace.get_existing_project_or_output_file(&from)? - else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "`copy` source file not found in workspace or output directory", - ) - .into()); - }; + let src_entry = self.workspace.stat_file(&from)?; self.workspace.io.copy_file(&src_entry.path, &to)?; } RunCommand::Delete(span, paths) => { self.execute_recipe_delete_command(task_id, &paths, silent, span)?; } RunCommand::Touch(span, paths) => { - self.execute_recipe_touch_command(task_id, &paths, silent, span)?; + self.execute_recipe_touch_command(&paths, span)?; } RunCommand::Info(_span, message) => { self.workspace.render.message(Some(task_id), &message); @@ -733,7 +544,7 @@ impl<'a> Inner<'a> { } if let Some(delay) = self.workspace.artificial_delay { - future::select(cancel, smol::Timer::after(delay)).await; + future::select(cancel.clone(), smol::Timer::after(delay)).await; } } @@ -743,15 +554,17 @@ impl<'a> Inner<'a> { #[expect(clippy::too_many_arguments)] async fn execute_recipe_run_command( &self, - task_id: TaskId, + task_id: TaskName, command_line: &ShellCommandLine, env: &Env, capture: bool, step: usize, num_steps: usize, forward_stdout: bool, - cancel: &cancel::Receiver, + cancel: cancel::Receiver, ) -> Result<(), Error> { + pin_mut!(cancel); + self.workspace .render .will_execute(task_id, command_line, step, num_steps); @@ -767,7 +580,7 @@ impl<'a> Inner<'a> { let mut reader = ChildLinesStream::new(&mut *child, true); let result = loop { let next = reader.next(); - match future::select(cancel, next).await { + match future::select(cancel.as_mut(), next).await { future::Either::Left((_canceled, _)) => { _ = child.kill(); _ = reader.wait().await; @@ -801,7 +614,7 @@ impl<'a> Inner<'a> { self.workspace .render .did_execute(task_id, command_line, &result, step, num_steps); - let status = result?; + let status = result.map_err(|e| IoError::new(&command_line.program, e))?; if !status.success() { return Err(Error::CommandFailed(status)); } @@ -811,14 +624,14 @@ impl<'a> Inner<'a> { #[expect(clippy::too_many_arguments)] fn execute_recipe_spawn_command( &self, - task_id: TaskId, + task_id: TaskName, command_line: &ShellCommandLine, env: &Env, capture: bool, step: usize, num_steps: usize, forward_stdout: bool, - cancel: &cancel::Receiver, + cancel: cancel::Receiver, ) -> Result<(), Error> { self.workspace .render @@ -832,14 +645,14 @@ impl<'a> Inner<'a> { let render = self.workspace.render.clone(); let command_line = command_line.clone(); - let cancel = cancel.clone(); self.executor .spawn(async move { + pin_mut!(cancel); let mut reader = ChildLinesStream::new(&mut *child, true); let result = loop { let next = reader.next(); - match future::select(&cancel, next).await { + match future::select(cancel.as_mut(), next).await { future::Either::Left((_canceled, _)) => { render.message( Some(task_id), @@ -879,32 +692,28 @@ impl<'a> Inner<'a> { fn execute_recipe_delete_command( &self, - task_id: TaskId, + task_id: TaskName, paths: &[Absolute], silent: bool, span: DiagnosticSpan, ) -> Result<(), Error> { for path in paths { - if self.workspace.is_in_output_directory(path) { - match self.workspace.io.delete_file(path) { - Ok(()) => (), - Err(err) => match err.kind() { - std::io::ErrorKind::NotFound => { - if !silent { - self.workspace.render.warning( - Some(task_id), - &Warning::IgnoringFileNotFound(span, path.clone()), - ); - } + self.check_protected(path, span, "delete")?; + } + for path in paths { + match self.workspace.io.delete_file(path) { + Ok(()) => (), + Err(err) => match err.error.kind() { + std::io::ErrorKind::NotFound => { + if !silent { + self.workspace.render.warning( + Some(task_id), + &Warning::IgnoringFileNotFound(span, path.clone()), + ); } - _ => return Err(err.into()), - }, - } - } else if !silent { - self.workspace.render.warning( - Some(task_id), - &Warning::IgnoringPathOutsideOutputDirectory(span, path.clone()), - ); + } + _ => return Err(err.into()), + }, } } @@ -913,128 +722,46 @@ impl<'a> Inner<'a> { fn execute_recipe_touch_command( &self, - task_id: TaskId, paths: &[Absolute], - silent: bool, span: DiagnosticSpan, ) -> Result<(), Error> { for path in paths { - if self.workspace.is_in_output_directory(path) { - self.workspace.io.touch(path)?; - } else if !silent { - self.workspace.render.warning( - Some(task_id), - &Warning::IgnoringPathOutsideOutputDirectory(span, path.clone()), - ); - } + self.check_protected(path, span, "touch")?; + } + + for path in paths { + self.workspace.io.touch(path)?; } Ok(()) } - async fn build_dependencies( - self: &Arc, - mut dependencies: Vec>, - dependent: DepChainEntry<'_>, - output_mtime: Option, - ) -> Result, Error> { - // Can't use raw `async fn` because of https://github.com/rust-lang/rust/issues/134101. - #[expect(clippy::manual_async_fn)] - fn build_multiple<'a>( - this: Arc>, - dependencies: Vec>, - dependent: OwnedDependencyChain, - output_mtime: Option, - ) -> impl Future, Error>> + Send + 'a { - async move { - let mut tasks = Vec::with_capacity(dependencies.len()); - for dep in dependencies { - let parent = dependent.clone(); - let this2 = this.clone(); - let task = this - .executor - .spawn(async move { this2.run_task(dep, DepChain::Owned(&parent)).await }); - tasks.push(task); - } - - let mut reasons = Vec::new(); - let mut first_error = None; - for task in tasks.drain(..) { - match task.await { - Ok(status) => { - if let Some(reason) = status.into_outdated_reason(output_mtime) { - reasons.push(reason); - } - } - Err(err) => { - // Don't interrupt other tasks if one fails. - first_error.get_or_insert(err); - } - } - } - if let Some(first_error) = first_error { - Err(first_error) - } else { - Ok(reasons) - } + fn check_protected( + &self, + path: &Absolute, + span: DiagnosticSpan, + operation: &'static str, + ) -> Result<(), Error> { + let workspace_path = match self.workspace.unresolve_path(path) { + Ok(path) => path, + Err(PathError::UnresolveBeyondRoot(resolved)) => { + return Err(Error::WriteBeyondRoot { + span: Some(span), + op: operation, + path: resolved, + }); } - } + Err(err) => return Err(Error::eval(EvalError::Path(span, err))), + }; - if dependencies.len() == 1 { - let dependency = dependencies.pop().unwrap(); - let this = self.clone(); - // Boxing because of recursion. - Box::pin(async move { - this.run_task(dependency, DepChain::Ref(&dependent)) - .await - .map(|status| { - status - .into_outdated_reason(output_mtime) - .into_iter() - .collect() - }) - }) - .await - } else if !dependencies.is_empty() { - let parent = dependent.collect(); - let this = self.clone(); - - // Boxing for recursion. - Box::pin(build_multiple( - this.clone(), - dependencies, - parent, - output_mtime, - )) - .await - } else { - Ok(vec![]) + if self.workspace.is_path_protected(&workspace_path) { + return Err(Error::ProtectedPath { + span: Some(span), + op: operation, + path: path.as_inner().to_owned(), + }); } - } - - /// Unconditionally run the task if it is outdated. - async fn rebuild_spec( - self: &Arc, - task_id: TaskId, - spec: TaskSpec<'a>, - cancel: &cancel::Receiver, - dep_chain: DepChain<'_>, - ) -> Result { - let dep_chain_entry = dep_chain.push(task_id); - match spec { - TaskSpec::Recipe(recipe) => match recipe { - ir::RecipeMatch::Task(recipe) => { - self.execute_task_recipe(task_id, recipe, cancel, dep_chain_entry) - .await - } - ir::RecipeMatch::Build(recipe_match) => { - self.execute_build_recipe(task_id, recipe_match, cancel, dep_chain_entry) - .await - } - }, - TaskSpec::CheckExists(path) => self.check_exists(&path), - TaskSpec::CheckExistsRelaxed(path) => Ok(self.check_exists_relaxed(&path)), - } + Ok(()) } } diff --git a/werk-runner/runner/dep_chain.rs b/werk-runner/runner/dep_chain.rs deleted file mode 100644 index 659da8b9..00000000 --- a/werk-runner/runner/dep_chain.rs +++ /dev/null @@ -1,94 +0,0 @@ -use super::TaskId; - -#[derive(Debug, Clone, Copy)] -pub struct DepChainEntry<'a> { - parent: DepChain<'a>, - this: TaskId, -} - -#[derive(Debug, Clone, Copy)] -pub enum DepChain<'a> { - Empty, - Owned(&'a OwnedDependencyChain), - Ref(&'a DepChainEntry<'a>), -} - -impl<'a> DepChain<'a> { - fn collect_vec(&self) -> Vec { - match self { - DepChain::Empty => Vec::new(), - DepChain::Owned(owned) => owned.vec.clone(), - DepChain::Ref(parent) => parent.collect_vec(), - } - } - - #[must_use] - pub fn contains(&self, task: TaskId) -> bool { - match self { - DepChain::Empty => false, - DepChain::Owned(owned) => owned.vec.contains(&task), - DepChain::Ref(parent) => parent.contains(task), - } - } - - #[must_use] - pub fn push<'b>(self, task: TaskId) -> DepChainEntry<'b> - where - 'a: 'b, - { - DepChainEntry { - parent: self, - this: task, - } - } -} - -impl DepChainEntry<'_> { - #[must_use] - pub fn collect(&self) -> OwnedDependencyChain { - OwnedDependencyChain { - vec: self.collect_vec(), - } - } - - #[must_use] - pub fn collect_vec(&self) -> Vec { - let mut vec = self.parent.collect_vec(); - vec.push(self.this); - vec - } - - #[must_use] - pub fn contains(&self, task_id: TaskId) -> bool { - if self.this == task_id { - true - } else { - self.parent.contains(task_id) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OwnedDependencyChain { - vec: Vec, -} - -impl OwnedDependencyChain { - #[inline] - #[must_use] - pub fn into_inner(self) -> Vec { - self.vec - } -} - -impl std::fmt::Display for OwnedDependencyChain { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for (i, task_id) in self.vec.iter().enumerate() { - if i > 0 { - write!(f, " -> ")?; - } - write!(f, "{task_id}")?; - } - Ok(()) - } -} diff --git a/werk-runner/runner/task.rs b/werk-runner/runner/task.rs deleted file mode 100644 index 24c1ad1f..00000000 --- a/werk-runner/runner/task.rs +++ /dev/null @@ -1,127 +0,0 @@ -use futures::channel::oneshot; -use stringleton::Symbol; -use werk_fs::{Absolute, SymPath}; - -use crate::{Error, ir}; - -use super::BuildStatus; - -pub enum TaskStatus { - Built(Result), - Pending(Vec>>), -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TaskId { - Task(Symbol), - // TODO: When recipes can build multiple files, this needs to change to some - // ID that encapsulates the "recipe instance" rather than the path of a - // single target. - Build(Absolute), -} - -impl TaskId { - pub fn command(s: impl Into) -> Self { - let name = s.into(); - debug_assert!(!name.as_str().starts_with('/')); - TaskId::Task(name) - } - - pub fn build(p: impl AsRef>) -> Self { - TaskId::Build(Absolute::symbolicate(p)) - } - - pub fn try_build

(p: P) -> Result - where - P: TryInto>, - { - let path = p.try_into()?; - Ok(TaskId::build(path)) - } - - #[inline] - #[must_use] - pub fn is_command(&self) -> bool { - matches!(self, TaskId::Task(_)) - } - - #[inline] - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - TaskId::Task(task) => task.as_str(), - TaskId::Build(build) => build.as_inner().as_str(), - } - } - - #[inline] - #[must_use] - pub fn as_path(&self) -> Option<&Absolute> { - if let TaskId::Build(build) = self { - Some(build.as_path()) - } else { - None - } - } - - #[inline] - #[must_use] - pub fn short_name(&self) -> &'static str { - match self { - TaskId::Task(task) => task.as_str(), - TaskId::Build(path) => { - let Some((_prefix, filename)) = path - .as_inner() - .as_str() - .rsplit_once(werk_fs::Path::SEPARATOR) - else { - // The path is absolute. - unreachable!() - }; - filename - } - } - } -} - -impl std::fmt::Display for TaskId { - #[inline] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -pub enum TaskSpec<'a> { - Recipe(ir::RecipeMatch<'a>), - CheckExists(Absolute), - /// Check if the file exists, but don't emit an error if it doesn't. This - /// applies to dependencies discovered through depfiles, where the depfile - /// may be outdated (from a previous build). - /// - /// If the file does not exist, the task will be considered outdated. - CheckExistsRelaxed(Absolute), -} - -impl TaskSpec<'_> { - #[must_use] - pub fn to_task_id(&self) -> TaskId { - match self { - TaskSpec::Recipe(ir::RecipeMatch::Build(build_recipe_match)) => { - TaskId::build(build_recipe_match.target_file.clone()) - } - TaskSpec::Recipe(ir::RecipeMatch::Task(task_recipe_match)) => { - TaskId::command(task_recipe_match.name) - } - TaskSpec::CheckExists(path_buf) | TaskSpec::CheckExistsRelaxed(path_buf) => { - TaskId::build(path_buf.clone().into_boxed_path()) - } - } - } -} - -pub enum DepfileSpec<'a> { - /// The depfile is explicitly generated by a recipe. - Recipe(ir::BuildRecipeMatch<'a>), - /// The depfile is implicitly generated by the associated command recipe. - ImplicitlyGenerated(Absolute), -} diff --git a/werk-runner/scope.rs b/werk-runner/scope.rs deleted file mode 100644 index b1112e4c..00000000 --- a/werk-runner/scope.rs +++ /dev/null @@ -1,556 +0,0 @@ -use ahash::HashMap; -use stringleton::{Symbol, sym}; -use werk_util::DiagnosticSpan; - -use crate::{ - Io, PatternMatchData, Render, TaskId, Value, Warning, Workspace, - eval::{Eval, Used}, - ir, -}; - -pub type LocalVariables = indexmap::IndexMap>; - -pub struct ConfigVar { - /// Snapshot of the value when the `config` statement was evaluated - /// (unshadowed). - pub value: Value, - /// Doc comment - pub comment: String, - /// The span of the `config` statement. - pub span: DiagnosticSpan, -} - -pub struct TaskRecipeScope<'a> { - workspace: &'a Workspace, - vars: LocalVariables, - task_id: TaskId, -} - -pub struct BuildRecipeScope<'a> { - workspace: &'a Workspace, - vars: LocalVariables, - task_id: TaskId, - recipe_match: &'a ir::BuildRecipeMatch<'a>, - input_files: Value, - output_file: Value, -} - -pub struct SubexprScope<'a> { - parent: &'a dyn Scope, - /// The value in the current scope that will be used in stemless `{}` string - /// interpolations. - pub implied_value: &'a Eval, -} - -pub struct MatchScope<'a> { - parent: &'a dyn Scope, - pattern_match: &'a PatternMatchData, - /// The matched string. - pub implied_value: &'a Eval, -} - -/// Look up a variable in a scope. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Lookup { - /// The scope's implied value (forwarded from another expression). An empty - /// stem in a string interpolation. - Implied, - /// The stem of the current pattern. `{%}` in string interpolation. - PatternStem, - /// The captured value of a one-of pattern in the current pattern. `{1}` in - /// string interpolation. - CaptureGroup(u32), - /// Lookup by identifier. `{ident}` in string interpolation. - Ident(Symbol), - /// The `^` special variable in build recipes. Cannot be shadowed. - InputFile, - /// The `@` special variable in build recipes. Cannot be shadowed. - OutputFile, -} - -#[derive(Debug, Clone)] -pub enum LookupValue<'a> { - Owned(Eval), - EvalRef(&'a Eval), - ValueRef(Eval<&'a Value>), - Ref(&'a Value, &'a Used), -} - -impl LookupValue<'_> { - #[inline] - #[must_use] - pub fn used(&self) -> &Used { - match self { - LookupValue::Owned(value) => &value.used, - LookupValue::EvalRef(value) => &value.used, - LookupValue::ValueRef(value) => &value.used, - LookupValue::Ref(_, used) => used, - } - } - - #[must_use] - pub fn into_value(self) -> Value { - match self { - LookupValue::Owned(eval) => eval.value, - LookupValue::EvalRef(eval) => eval.value.clone(), - LookupValue::ValueRef(eval) => eval.value.clone(), - LookupValue::Ref(value, _) => value.clone(), - } - } - - #[must_use] - pub fn into_owned(self) -> Eval { - match self { - LookupValue::Owned(eval) => eval, - LookupValue::EvalRef(eval) => eval.clone(), - LookupValue::ValueRef(eval) => Eval { - value: eval.value.clone(), - used: eval.used, - }, - LookupValue::Ref(value, used) => Eval { - value: value.clone(), - used: used.clone(), - }, - } - } -} - -impl std::ops::Deref for LookupValue<'_> { - type Target = Value; - - #[inline] - fn deref(&self) -> &Self::Target { - match self { - LookupValue::Owned(value) => value, - LookupValue::EvalRef(value) => value, - LookupValue::ValueRef(value) => value, - LookupValue::Ref(value, _) => value, - } - } -} - -pub trait Scope: Send + Sync { - fn get(&self, name: Lookup) -> Option>; - fn workspace(&self) -> &Workspace; - - fn task_id(&self) -> Option; - fn render(&self) -> &dyn Render; - - fn io(&self) -> &dyn Io { - self.workspace().io() - } - - fn warning(&self, warning: &Warning) { - self.render().warning(self.task_id(), warning); - } -} - -impl<'a> TaskRecipeScope<'a> { - #[inline] - #[must_use] - pub fn new(workspace: &'a Workspace, task_id: TaskId) -> Self { - Self { - workspace, - vars: LocalVariables::new(), - task_id, - } - } - - pub fn set(&mut self, name: Symbol, value: Eval) { - if default_global_constants().contains_key(&name) { - // TODO: Convert to real warning. - tracing::warn!("Shadowing built-in constant `{}`", name); - } - self.vars.insert(name, value); - } -} - -impl<'a> BuildRecipeScope<'a> { - #[inline] - #[must_use] - pub fn new( - workspace: &'a Workspace, - task_id: TaskId, - recipe_match: &'a ir::BuildRecipeMatch<'a>, - ) -> Self { - Self { - workspace, - vars: LocalVariables::new(), - task_id, - recipe_match, - input_files: Value::List(Vec::new()), - output_file: recipe_match.target_file.to_string().into(), - } - } - - pub fn set(&mut self, name: Symbol, value: Eval) { - if default_global_constants().contains_key(&name) { - tracing::warn!("Shadowing built-in constant `{}`", name); - } - self.vars.insert(name, value); - } - - pub fn push_input_file(&mut self, name: String) { - let Value::List(ref mut input_files) = self.input_files else { - unreachable!() - }; - input_files.push(name.into()); - } - - pub fn push_input_files(&mut self, names: impl IntoIterator) { - let Value::List(ref mut input_files) = self.input_files else { - unreachable!() - }; - input_files.extend(names.into_iter().map(Into::into)); - } -} - -impl<'a> SubexprScope<'a> { - #[inline] - pub fn new(parent: &'a dyn Scope, implied_value: &'a Eval) -> Self { - SubexprScope { - parent, - implied_value, - } - } -} - -impl<'a> MatchScope<'a> { - #[inline] - pub fn new( - parent: &'a dyn Scope, - pattern_match: &'a PatternMatchData, - matched_string: &'a Eval, - ) -> Self { - MatchScope { - parent, - pattern_match, - implied_value: matched_string, - } - } -} - -#[must_use] -pub const fn current_os() -> &'static str { - if cfg!(target_os = "windows") { - "windows" - } else if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "ios") { - "ios" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "android") { - "android" - } else if cfg!(target_os = "freebsd") { - "freebsd" - } else if cfg!(target_os = "dragonfly") { - "dragonfly" - } else if cfg!(target_os = "openbsd") { - "openbsd" - } else if cfg!(target_os = "netbsd") { - "netbsd" - } else if cfg!(target_family = "wasm") { - "wasm-wasi" - } else { - "none" - } -} - -#[must_use] -pub const fn current_os_family() -> &'static str { - if cfg!(target_family = "unix") { - "unix" - } else if cfg!(target_family = "windows") { - "windows" - } else if cfg!(target_family = "wasm") { - "wasm" - } else { - "none" - } -} - -#[must_use] -pub const fn current_arch() -> &'static str { - if cfg!(target_arch = "x86") { - "x86" - } else if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "mips") { - "mips" - } else if cfg!(target_arch = "powerpc") { - "powerpc" - } else if cfg!(target_arch = "powerpc64") { - "powerpc64" - } else if cfg!(target_arch = "arm") { - "arm" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else if cfg!(target_family = "wasm") { - "wasm" - } else { - "none" - } -} - -#[must_use] -pub const fn current_arch_family() -> &'static str { - if cfg!(any(target_arch = "x86", target_arch = "x86_64")) { - "x86" - } else if cfg!(target_arch = "mips") { - "mips" - } else if cfg!(any(target_arch = "powerpc", target_arch = "powerpc64")) { - "powerpc" - } else if cfg!(any(target_arch = "arm", target_arch = "aarch64")) { - "arm" - } else if cfg!(target_family = "wasm") { - "wasm" - } else { - "none" - } -} - -#[must_use] -pub const fn exe_suffix() -> &'static str { - if cfg!(windows) { ".exe" } else { "" } -} - -#[must_use] -pub const fn dylib_prefix() -> &'static str { - if cfg!(windows) { "" } else { "lib" } -} - -#[must_use] -pub const fn dylib_suffix() -> &'static str { - if cfg!(windows) { - ".dll" - } else if cfg!(any(target_os = "macos", target_os = "ios")) { - ".dylib" - } else { - ".so" - } -} - -#[must_use] -pub const fn staticlib_prefix() -> &'static str { - if cfg!(windows) { "" } else { "lib" } -} - -#[must_use] -pub const fn staticlib_suffix() -> &'static str { - if cfg!(windows) { ".lib" } else { ".a" } -} - -pub fn default_global_constants() -> &'static HashMap { - static GLOBAL_CONSTANTS: std::sync::OnceLock> = - std::sync::OnceLock::new(); - GLOBAL_CONSTANTS.get_or_init(|| { - let mut map = HashMap::default(); - map.extend([ - (sym!(EMPTY), Value::from(String::new())), - (sym!(EXE_SUFFIX), Value::from(exe_suffix().to_owned())), - (sym!(DYLIB_PREFIX), Value::from(dylib_prefix().to_owned())), - (sym!(DYLIB_SUFFIX), Value::from(dylib_suffix().to_owned())), - ( - sym!(STATICLIB_PREFIX), - Value::from(staticlib_prefix().to_owned()), - ), - ( - sym!(STATICLIB_SUFFIX), - Value::from(staticlib_suffix().to_owned()), - ), - (sym!(OS), Value::from(current_os().to_owned())), - (sym!(OS_FAMILY), Value::from(current_os_family().to_owned())), - (sym!(ARCH), Value::from(current_arch().to_owned())), - ( - sym!(ARCH_FAMILY), - Value::from(current_arch_family().to_owned()), - ), - ]); - map - }) -} - -impl Scope for Workspace { - fn get(&self, name: Lookup) -> Option> { - let Lookup::Ident(name) = name else { - return None; - }; - - if let Some(var) = self - .manifest - .global_variables - .get(&name) - .map(LookupValue::EvalRef) - { - return Some(var); - } - - // Global build-time constants. - if let Some(global_constant) = default_global_constants() - .get(&name) - .map(Eval::inherent) - .map(LookupValue::ValueRef) - { - return Some(global_constant); - } - - // Runtime constants. - if name == sym!(COLOR) { - return Some(LookupValue::Owned(Eval::inherent(Value::from( - if self.force_color { "1" } else { "0" }.to_owned(), - )))); - } - - None - } - - #[inline] - fn workspace(&self) -> &Workspace { - self - } - - #[inline] - fn task_id(&self) -> Option { - None - } - - #[inline] - fn render(&self) -> &dyn Render { - &*self.render - } -} - -impl Scope for TaskRecipeScope<'_> { - #[inline] - fn get(&self, lookup: Lookup) -> Option> { - let Lookup::Ident(name) = lookup else { - return None; - }; - - let Some(local) = self.vars.get(&name) else { - return self.workspace.get(lookup); - }; - - Some(LookupValue::Ref(&local.value, &local.used)) - } - - #[inline] - fn workspace(&self) -> &Workspace { - self.workspace - } - - #[inline] - fn task_id(&self) -> Option { - Some(self.task_id) - } - - #[inline] - fn render(&self) -> &dyn Render { - &*self.workspace.render - } -} - -impl Scope for BuildRecipeScope<'_> { - #[inline] - fn get(&self, lookup: Lookup) -> Option> { - match lookup { - Lookup::Implied => None, - Lookup::PatternStem => { - let stem = self.recipe_match.match_data.stem()?; - Some(LookupValue::Owned(Eval::inherent(Value::from(stem)))) - } - Lookup::CaptureGroup(index) => { - let group = self.recipe_match.match_data.capture_group(index as usize)?; - Some(LookupValue::Owned(Eval::inherent(Value::from(group)))) - } - Lookup::InputFile => Some(LookupValue::ValueRef(Eval::inherent(&self.input_files))), - Lookup::OutputFile => Some(LookupValue::ValueRef(Eval::inherent(&self.output_file))), - Lookup::Ident(name) => { - if name == sym!(in) { - return Some(LookupValue::ValueRef(Eval::inherent(&self.input_files))); - } else if name == sym!(out) { - return Some(LookupValue::ValueRef(Eval::inherent(&self.output_file))); - } - - let Some(local) = self.vars.get(&name) else { - return self.workspace.get(lookup); - }; - Some(LookupValue::EvalRef(local)) - } - } - } - - #[inline] - fn workspace(&self) -> &Workspace { - self.workspace - } - - #[inline] - fn task_id(&self) -> Option { - Some(self.task_id) - } - - #[inline] - fn render(&self) -> &dyn Render { - &*self.workspace.render - } -} - -impl Scope for SubexprScope<'_> { - #[inline] - fn get(&self, lookup: Lookup) -> Option> { - match lookup { - Lookup::Implied => Some(LookupValue::EvalRef(self.implied_value)), - _ => self.parent.get(lookup), - } - } - - #[inline] - fn workspace(&self) -> &Workspace { - self.parent.workspace() - } - - #[inline] - fn task_id(&self) -> Option { - self.parent.task_id() - } - - #[inline] - fn render(&self) -> &dyn Render { - self.parent.render() - } -} - -impl Scope for MatchScope<'_> { - #[inline] - fn get(&self, lookup: Lookup) -> Option> { - match lookup { - Lookup::PatternStem => { - let stem = self.pattern_match.stem()?; - Some(LookupValue::Owned(Eval::inherent(Value::from(stem)))) - } - Lookup::CaptureGroup(index) => { - let group = self.pattern_match.capture_group(index as usize)?; - Some(LookupValue::Owned(Eval::inherent(Value::from(group)))) - } - Lookup::Implied => Some(LookupValue::EvalRef(self.implied_value)), - _ => self.parent.get(lookup), - } - } - - #[inline] - fn workspace(&self) -> &Workspace { - self.parent.workspace() - } - - #[inline] - fn task_id(&self) -> Option { - self.parent.task_id() - } - - #[inline] - fn render(&self) -> &dyn Render { - self.parent.render() - } -} diff --git a/werk-runner/workspace.rs b/werk-runner/workspace.rs index 19b6205e..75535bf3 100644 --- a/werk-runner/workspace.rs +++ b/werk-runner/workspace.rs @@ -1,22 +1,24 @@ -use ahash::HashMap; -use indexmap::IndexMap; +use ahash::{HashMap, HashSet}; use parking_lot::Mutex; use std::{borrow::Cow, collections::hash_map, sync::Arc}; -use stringleton::Symbol; -use werk_fs::{Absolute, Normalize as _, PathError}; +use stringleton::{Symbol, sym}; +use werk_eval::{ + Eval, EvalError, GlobError, GlobSettings, Io, Lookup, LookupValue, Scope, UsedVariable, Value, + Warning, +}; +use werk_fs::{Absolute, Normalize as _, PathError, SymPath}; use werk_parser::ast; -use werk_util::{DiagnosticFileId, DiagnosticSpan}; +use werk_planner::{BuildRecipe, Manifest, TaskRecipe}; +use werk_util::{DiagnosticFileId, DiagnosticSpan, IoError, hash128::Hash128}; use crate::{ - DirEntry, Error, EvalError, Io, Render, Value, Warning, - cache::{Hash128, TargetOutdatednessCache, WerkCache}, - eval::{self, Eval, UsedVariable}, - ir::{self, BuildRecipe, TaskRecipe}, + Error, Render, + cache::{TargetOutdatednessCache, WERK_CACHE_FILENAME, WerkCache}, }; #[derive(Clone)] pub struct WorkspaceSettings { - pub output_directory: Absolute, + pub cache_dir: Absolute, /// Settings for globbing the workspace directory. Note that the /// `output_directory` is not automatically ignored, and must either be /// present in `.gitignore` or explicitly ignored here. @@ -37,9 +39,9 @@ pub struct WorkspaceSettings { impl WorkspaceSettings { #[must_use] - pub fn new(output_dir: Absolute) -> Self { + pub fn new(cache_dir: Absolute) -> Self { WorkspaceSettings { - output_directory: output_dir, + cache_dir, glob: GlobSettings::default(), defines: HashMap::default(), force_color: false, @@ -49,38 +51,6 @@ impl WorkspaceSettings { } } -#[derive(Clone, Debug)] -#[allow(clippy::struct_excessive_bools)] -pub struct GlobSettings { - /// Read the workspace directory's `.gitignore`. Enabled by default. - pub git_ignore: bool, - /// Read a global gitignore file, specified in the `core.excludeFiles` - /// config option. Enabled by default. - pub git_ignore_global: bool, - /// Read `.git/info/exclude`. Enabled by default. - pub git_ignore_exclude: bool, - /// Read `.gitignore` from parent directoryes. Enabled by default. - pub git_ignore_from_parents: bool, - /// Enables reading `.ignore` files, supported by `ripgrep` and The Silver Searcher. Enabled by default. - pub dot_ignore: bool, - /// Explicit file name patterns to ignore in addition to gitignore and .ignore files. - pub ignore_explicitly: globset::GlobSet, -} - -impl Default for GlobSettings { - #[inline] - fn default() -> Self { - Self { - git_ignore: true, - git_ignore_global: true, - git_ignore_exclude: true, - git_ignore_from_parents: true, - dot_ignore: true, - ignore_explicitly: globset::GlobSet::empty(), - } - } -} - impl WorkspaceSettings { /// Override a global variable in the root scope. pub fn define(&mut self, key: impl Into, value: impl Into) -> &mut Self { @@ -95,15 +65,14 @@ impl WorkspaceSettings { } pub struct Workspace { - pub manifest: ir::Manifest, + pub manifest: Manifest, + protected_paths: HashSet>, // Project root - note that the workspace only accesses this directory // through the `Io` trait, and never directly. project_root: Absolute, // Project root - note that the workspace only accesses this directory // through the `Io` trait, and never directly. - output_directory: Absolute, - // Using IndexMap to ensure that the ordering of glob results is well-defined. - files: IndexMap, DirEntry, ahash::RandomState>, + cache_dir: Absolute, /// The contents of `/.werk-cache.toml`. werk_cache: Mutex, /// Caches of expensive runtime values (glob, which, env). @@ -114,8 +83,9 @@ pub struct Workspace { pub force_color: bool, pub io: Arc, pub render: Arc, - pub(crate) runner_state: crate::runner::RunnerState, pub(crate) artificial_delay: Option, + pub max_concurrent_jobs: usize, + glob_settings: GlobSettings, } #[derive(Default)] @@ -126,8 +96,6 @@ struct Caches { build_recipe_hashes: HashMap, } -pub const WERK_CACHE_FILENAME: &str = ".werk-cache"; - impl Workspace { pub fn new( io: Arc, @@ -135,42 +103,21 @@ impl Workspace { project_root: Absolute, settings: &WorkspaceSettings, ) -> Result { - let werk_cache = read_workspace_cache(&*io, &settings.output_directory); - - let mut workspace_files = - IndexMap::with_capacity_and_hasher(1024, ahash::RandomState::default()); - - for entry in io.glob_workspace(&project_root, &settings.glob)? { - if entry.path.file_name() == Some(WERK_CACHE_FILENAME.as_ref()) { - return Err(Error::ClobberedWorkspace(entry.path.into_inner())); - } + let werk_cache = WerkCache::read(&*io, &settings.cache_dir); - let path_in_project = match entry.path.unresolve(&project_root) { - Ok(path_in_project) => path_in_project, - // This should not be possible. - Err(err @ PathError::UnresolveBeyondRoot) => { - return Err(Error::InvalidTargetPath( - entry.path.display().to_string(), - err, - )); - } - Err(_) => continue, - }; - tracing::trace!("Workspace file: {path_in_project}"); - workspace_files.insert(path_in_project, entry); - } - - // Deterministic workspace order to preserve the ordering of glob - // results. - workspace_files.sort_unstable_keys(); - - let manifest = ir::Manifest::default(); + let manifest = Manifest::default(); let workspace = Self { manifest, + protected_paths: HashSet::from_iter( + settings + .cache_dir + .join(WERK_CACHE_FILENAME) + .ok() + .and_then(|p| p.unresolve(&project_root).ok()), + ), project_root, - output_directory: settings.output_directory.clone(), - files: workspace_files, + cache_dir: settings.cache_dir.clone(), werk_cache: Mutex::new(werk_cache), runtime_caches: Mutex::new(Caches { glob_cache: HashMap::default(), @@ -187,21 +134,33 @@ impl Workspace { force_color: settings.force_color, io, render, - runner_state: crate::RunnerState::new(settings.jobs), artificial_delay: settings.artificial_delay, + max_concurrent_jobs: settings.jobs, + glob_settings: settings.glob.clone(), }; Ok(workspace) } + /// Add arbitrary workspace paths to the protected set. + pub fn protect_paths(&mut self, paths: impl IntoIterator>) { + self.protected_paths.extend(paths); + } + + /// Returns whether the given path is protected from being modified by recipes. + pub fn is_path_protected(&self, path: &Absolute) -> bool { + self.protected_paths.contains(path) + } + fn register_werkfile_source( &mut self, path: &Absolute, source: &str, included_from: Option, ) -> Result { + let path = path.to_owned(); let id = match self.manifest.source_map.insert_check_duplicate( - path.to_owned().into_inner().into(), + path.clone().into_inner().into(), source.to_owned(), included_from, ) { @@ -212,12 +171,13 @@ impl Workspace { werk_util::Span::from_offset_and_len(werk_util::Offset(0), 0), )), previously_included_from, - path.to_owned(), + path.clone(), )); } }; self.render.add_source_file(id, path.as_str(), source); + self.protected_paths.insert(path); Ok(id) } @@ -302,7 +262,7 @@ impl Workspace { file: DiagnosticFileId, stmt: &ast::IncludeStmt, ) -> Result<(), EvalError> { - let value = eval::eval_chain(self, &stmt.param, file)?; + let value = werk_eval::eval_chain(self, &stmt.param, file)?; let mut files = vec![]; value.value.try_visit(|include_file| { let path = werk_fs::PathBuf::new(include_file.string) @@ -312,20 +272,16 @@ impl Workspace { Ok::<_, EvalError>(()) })?; for included_file in files { - let file_entry = self.get_project_file(&included_file).ok_or_else(|| { - EvalError::IncludeIoError( - file.span(stmt.span), - included_file.to_string(), - std::io::Error::new(std::io::ErrorKind::NotFound, "file not found").into(), - ) + let file_entry = self.stat_file(&included_file).map_err(|err| { + EvalError::IncludeIoError(file.span(stmt.span), included_file.to_string(), err) })?; + // Note: The included file's mtime is not included in outdatedness + // calculations, because variables and recipes are tracked using + // fine-grained hashing. + let source = self.io.read_file(&file_entry.path).map_err(|err| { - EvalError::IncludeIoError( - file.span(stmt.span), - included_file.to_string(), - err.into(), - ) + EvalError::IncludeIoError(file.span(stmt.span), included_file.to_string(), err) })?; let source = String::from_utf8(source).map_err(|_| { @@ -363,10 +319,10 @@ impl Workspace { return Err(EvalError::DefaultInInclude(file.span(stmt.span()))); } - // Note: Other types of `default` statements are handled upstream, while + // Note: Other types of `default` statements are handled upstream while // parsing `Defaults`, which happens prior to creating the workspace. if let ast::DefaultStmt::Target(stmt) = stmt { - let value = eval::eval_string_expr(self, &stmt.value, file)?; + let value = werk_eval::eval_string_expr(self, &stmt.value, file)?; self.default_target = Some(value.value.string); } @@ -403,7 +359,7 @@ impl Workspace { .global_variables .insert(stmt.ident.ident, evaluated); } else { - let mut value = eval::eval_chain(self, &stmt.value, file)?; + let mut value = werk_eval::eval_chain(self, &stmt.value, file)?; value .used .insert(UsedVariable::Global(stmt.ident.ident, hash)); @@ -424,7 +380,7 @@ impl Workspace { fn eval_let(&mut self, file: DiagnosticFileId, stmt: &ast::LetStmt) -> Result<(), EvalError> { let hash = compute_stable_semantic_hash(&stmt.value); - let mut value = eval::eval_chain(self, &stmt.value, file)?; + let mut value = werk_eval::eval_chain(self, &stmt.value, file)?; value .used .insert(UsedVariable::Global(stmt.ident.ident, hash)); @@ -461,7 +417,8 @@ impl Workspace { build_recipe: ast::BuildRecipe, ) -> Result<(), EvalError> { let hash = compute_stable_semantic_hash(&build_recipe); - let mut pattern_builder = eval::eval_pattern_builder(self, &build_recipe.pattern, file)?; + let mut pattern_builder = + werk_eval::eval_pattern_builder(self, &build_recipe.pattern, file)?; // TODO: Consider if it isn't better to do this while matching recipes. pattern_builder.ensure_absolute_path(); @@ -483,15 +440,9 @@ impl Workspace { /// Write outdatedness cache (`which` and `glob`) to "/.werk-cache". #[expect(clippy::unused_async)] // Preserving `async` for future-proofing. - pub async fn finalize(&self) -> std::io::Result<()> { + pub async fn finalize(&self) -> Result<(), IoError> { let cache = self.werk_cache.lock(); - write_workspace_cache(&*self.io, &self.output_directory, &cache) - } - - pub fn workspace_files( - &self, - ) -> impl ExactSizeIterator, &DirEntry)> + '_ { - self.files.iter() + cache.write(&*self.io, &self.cache_dir) } #[inline] @@ -500,60 +451,12 @@ impl Workspace { } #[inline] - pub fn output_directory(&self) -> &Absolute { - &self.output_directory + pub fn cache_dir(&self) -> &Absolute { + &self.cache_dir } - pub fn is_in_output_directory(&self, path: &Absolute) -> bool { - path.starts_with(&*self.output_directory) - } - - pub fn get_project_file(&self, path: &Absolute) -> Option<&DirEntry> { - self.files.get(path) - } - - pub fn get_existing_project_or_output_file( - &self, - path: &Absolute, - ) -> Result, Error> { - if let Some(dir_entry) = self.get_project_file(path) { - return Ok(Some(dir_entry.clone())); - } - - self.get_existing_output_file(path) - } - - pub fn get_existing_output_file( - &self, - path: &Absolute, - ) -> Result, Error> { - let fs_path = path.resolve(&self.output_directory); - match self.io.metadata(&fs_path) { - Ok(metadata) => Ok(Some(DirEntry { - path: fs_path, - metadata, - })), - Err(Error::Io(err)) => { - if err.kind() == std::io::ErrorKind::NotFound { - Ok(None) - } else { - Err(err.into()) - } - } - Err(err) => Err(err), - } - } - - /// Get the output file path for this abstract path. The file may or may not exist, but must be a valid path. - pub fn get_output_file_path( - &self, - path: &werk_fs::Path, - ) -> Result, PathError> { - path.resolve(&self.output_directory) - } - - pub fn create_output_parent_dirs(&self, path: &Absolute) -> Result<(), Error> { - let fs_path = path.resolve(&self.output_directory); + pub fn create_parent_dirs(&self, path: &Absolute) -> Result<(), Error> { + let fs_path = path.resolve(self.project_root()); self.io.create_parent_dirs(&fs_path).map_err(Into::into) } @@ -561,18 +464,13 @@ impl Workspace { &self, path: &Absolute, ) -> Result, PathError> { - match path.unresolve(&self.output_directory) { - Ok(path) => Ok(path), - // The path is not in the output directory, try the project root. - Err(werk_fs::PathError::UnresolveBeyondRoot) => path.unresolve(&self.project_root), - Err(err) => Err(err), - } + path.unresolve(&self.project_root) } pub fn glob_workspace_files( &self, pattern: &str, - ) -> Result<(Vec>, Hash128), globset::Error> { + ) -> Result<(Vec>, Hash128), GlobError> { let mut state = self.runtime_caches.lock(); let state = &mut *state; match state.glob_cache.entry(pattern.to_owned()) { @@ -582,21 +480,25 @@ impl Workspace { } hash_map::Entry::Vacant(entry) => { let glob = globset::Glob::new(pattern)?; + let matches = Mutex::new(vec![]); let matcher = glob.compile_matcher(); - - // Note: Workspace files are already sorted. - let matches = self - .files - .iter() - .filter_map(|(path, entry)| { - if entry.metadata.is_file && matcher.is_match(path.as_os_path()) { - Some(path.clone()) - } else { - None + self.io.walk_directory( + &self.project_root, + self.glob_settings.clone(), + &|path| { + if let Some(unresolved_path) = self.unresolve_path(path).ok() + && let Ok(metadata) = self.io.metadata(path) + && metadata.is_file + && matcher.is_match(unresolved_path.as_os_path()) + { + matches.lock().push(unresolved_path); } - }) - .collect::>(); + }, + )?; + let mut matches = matches.into_inner(); + // Sorting the matches for consistent hashing. + matches.sort(); let hash = compute_glob_hash(&matches); entry.insert((matches.clone(), hash)); @@ -654,7 +556,7 @@ impl Workspace { } } - pub fn register_used_recipe_hash(&self, recipe: &ir::BuildRecipe) -> Hash128 { + pub fn register_used_recipe_hash(&self, recipe: &BuildRecipe) -> Hash128 { let mut state = self.runtime_caches.lock(); let state = &mut *state; match state @@ -672,17 +574,20 @@ impl Workspace { pub(crate) fn take_build_target_cache( &self, - path: &Absolute, + path: Absolute, ) -> Option { - self.werk_cache.lock().build.remove(path) + self.werk_cache.lock().build.remove(path.as_path()) } pub(crate) fn store_build_target_cache( &self, - path: Absolute, + path: Absolute, cache: TargetOutdatednessCache, ) { - self.werk_cache.lock().build.insert(path, cache); + self.werk_cache + .lock() + .build + .insert(path.as_path().to_path_buf(), cache); } } @@ -704,103 +609,109 @@ fn compute_glob_hash(files: &[Absolute]) -> Hash128 { compute_stable_hash(files) } -fn read_workspace_cache(io: &dyn Io, output_dir: &Absolute) -> WerkCache { - let werk_cache_path = output_dir.join(WERK_CACHE_FILENAME).unwrap(); - tracing::debug!("trying to read .werk-cache: {}", werk_cache_path.display()); - let data = match io.read_file(&werk_cache_path) { - Ok(data) => data, - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - tracing::error!("Failed to read workspace cache, even though it exists: {err}"); - } - tracing::debug!(".werk-cache does not exist"); - return WerkCache::default(); - } - }; - - if data.is_empty() { - tracing::debug!(".werk-cache is empty"); - return WerkCache::default(); +impl werk_util::DiagnosticSourceMap for &Workspace { + #[inline] + fn get_source( + &self, + id: werk_util::DiagnosticFileId, + ) -> Option> { + self.manifest.get_source(id) } +} + +impl werk_eval::Scope for Workspace { + fn get(&self, name: Lookup) -> Option> { + let Lookup::Ident(name) = name else { + return None; + }; - match toml_edit::de::from_slice(&data) { - Ok(cache) => { - tracing::trace!(".werk-cache contents: {cache:#?}"); - cache + if let Some(var) = self + .manifest + .global_variables + .get(&name) + .map(LookupValue::EvalRef) + { + return Some(var); } - Err(err) => { - tracing::error!("Failed to parse workspace cache: {err}"); - WerkCache::default() + + // Global build-time constants. + if let Some(global_constant) = werk_eval::default_global_constants() + .get(&name) + .map(Eval::inherent) + .map(LookupValue::ValueRef) + { + return Some(global_constant); } - } -} -fn write_workspace_cache( - io: &dyn Io, - output_dir: &Absolute, - cache: &WerkCache, -) -> std::io::Result<()> { - fn make_table(item: &mut toml_edit::Item) -> Option<&mut toml_edit::Table> { - match std::mem::take(item).into_table() { - Ok(table) => { - *item = toml_edit::Item::Table(table); - if let toml_edit::Item::Table(table) = item { - Some(table) - } else { - unreachable!() - } - } - Err(not_table) => { - *item = not_table; - None - } + // Runtime constants. + if name == sym!(COLOR) { + return Some(LookupValue::Owned(Eval::inherent(Value::from( + if self.force_color { "1" } else { "0" }.to_owned(), + )))); } + + None } - let mut doc = match toml_edit::ser::to_document(cache) { - Ok(data) => data, - Err(err) => { - tracing::error!("Serialization error writing .werk-cache: {err}"); - panic!("Serialization error writing .werk-cache: {err}"); - } - }; + fn io(&self) -> &dyn Io { + &*self.io + } - if let Some(build) = doc.get_mut("build") { - let build = make_table(build).expect("build is not a table"); - build.set_implicit(true); - for (_target, target_info) in build.iter_mut() { - make_table(target_info); - } + fn messenger(&self) -> &dyn werk_eval::Messenger { + &*self.render } - let toml = format!("# Generated by werk. It can be safely deleted.\n\n{doc}"); + fn message(&self, message: &str) { + self.render.message(None, message); + } - let path = output_dir.join(WERK_CACHE_FILENAME).unwrap(); - tracing::debug!("writing .werk-cache to {}", path.display()); + fn warning(&self, warning: &werk_eval::Warning) { + self.render.warning(None, warning); + } - if let Err(err) = io.create_parent_dirs(&path) { - tracing::error!( - "Error creating parent directory for .werk-cache '{}': {err}", - output_dir.display() - ); - return Err(err); + fn which( + &self, + program_name: &str, + ) -> Result>, which::Error> { + let (path, hash) = Workspace::which(self, program_name)?; + let path = path.into_owned(); + Ok(Eval::using_vars( + path, + hash.map(|hash| UsedVariable::Which(Symbol::new(program_name), hash)), + )) } - match io.write_file(&path, toml.as_bytes()) { - Ok(()) => Ok(()), - Err(err) => { - tracing::error!("Error writing .werk-cache: {err}"); - Err(err) - } + fn env(&self, variable_name: &str) -> Eval> { + let (value, hash) = Workspace::env(self, variable_name); + Eval::using_var( + Some(value), + UsedVariable::Env(Symbol::new(variable_name), hash), + ) } -} -impl werk_util::DiagnosticSourceMap for &Workspace { - #[inline] - fn get_source( + fn resolve_path(&self, path: &Absolute) -> Absolute { + path.resolve(self.current_working_directory()) + } + + fn unresolve_path( &self, - id: werk_util::DiagnosticFileId, - ) -> Option> { - self.manifest.get_source(id) + path: &Absolute, + ) -> Result, PathError> { + Workspace::unresolve_path(self, path) + } + + fn glob_workspace_files( + &self, + pattern_string: &str, + ) -> Result>>, GlobError> { + let (results, hash) = Workspace::glob_workspace_files(self, pattern_string)?; + Ok(Eval::using_var( + results, + UsedVariable::Glob(Symbol::new(pattern_string), hash), + )) + } + + fn current_working_directory(&self) -> &Absolute { + self.project_root() } } diff --git a/werk-util/Cargo.toml b/werk-util/Cargo.toml index 9a38b54d..48a05526 100644 --- a/werk-util/Cargo.toml +++ b/werk-util/Cargo.toml @@ -10,11 +10,14 @@ path = "lib.rs" [dependencies] ahash.workspace = true -hashbrown = "0.16.1" -parking_lot.workspace = true -serde.workspace = true annotate-snippets.workspace = true +event-listener.workspace = true indexmap.workspace = true +parking_lot.workspace = true +pin-project-lite.workspace = true +rustc-stable-hash.workspace = true +serde.workspace = true +unicode-segmentation = "1.13.2" [lints] workspace = true diff --git a/werk-util/broadcast_one.rs b/werk-util/broadcast_one.rs new file mode 100644 index 00000000..bc5a4214 --- /dev/null +++ b/werk-util/broadcast_one.rs @@ -0,0 +1,129 @@ +use std::{ + mem::ManuallyDrop, + pin::Pin, + sync::{Arc, OnceLock}, + task::{Context, Poll}, +}; + +use event_listener::{Event, EventListener}; + +#[derive(Clone, Copy, Debug)] +pub struct Disconnected; +impl std::fmt::Display for Disconnected { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "disconnected") + } +} +impl std::error::Error for Disconnected {} + +#[must_use] +pub fn channel() -> (Sender, Receiver) { + let shared = Arc::new(Shared { + event: Event::new(), + data: OnceLock::new(), + }); + ( + Sender { + shared: ManuallyDrop::new(Arc::clone(&shared)), + }, + Receiver { shared }, + ) +} + +pub struct Sender { + shared: ManuallyDrop>>, +} + +impl Sender { + pub fn send(mut self, value: T) { + self.finish(Ok(value)); + std::mem::forget(self); + } + + #[must_use] + pub fn receiver(&self) -> Receiver { + Receiver { + shared: Arc::clone(&self.shared), + } + } + + fn finish(&mut self, result: Result) { + self.shared.data.get_or_init(|| result); + self.shared.event.notify(usize::MAX); + unsafe { + ManuallyDrop::drop(&mut self.shared); + } + } +} + +impl Drop for Sender { + fn drop(&mut self) { + self.finish(Err(Disconnected)); + } +} + +pub struct Receiver { + shared: Arc>, +} + +struct Shared { + event: Event, + data: OnceLock>, +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + Self { + shared: self.shared.clone(), + } + } +} + +impl Receiver { + #[must_use] + pub fn recv(&self) -> RecvFut<'_, T> { + RecvFut { + shared: &self.shared, + listener: self.shared.event.listen(), + } + } + + #[must_use] + pub fn try_recv(&self) -> Option> { + self.shared.data.get().map(|result| match result { + Ok(data) => Ok(data), + Err(Disconnected) => Err(Disconnected), + }) + } +} + +pin_project_lite::pin_project! { + pub struct RecvFut<'a, T> { + shared: &'a Shared, + #[pin] + listener: EventListener, + } +} + +impl<'a, T> Future for RecvFut<'a, T> { + type Output = Result<&'a T, Disconnected>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if let Some(data) = self.shared.data.get() { + return Poll::Ready(match data { + Ok(data) => Ok(data), + Err(Disconnected) => Err(Disconnected), + }); + } + + let this = self.project(); + match this.listener.poll(cx) { + Poll::Ready(()) => match this.shared.data.get() { + Some(Ok(data)) => Poll::Ready(Ok(data)), + Some(Err(Disconnected)) => Poll::Ready(Err(Disconnected)), + None => unreachable!(), + }, + Poll::Pending => Poll::Pending, + } + } +} diff --git a/werk-util/cancel.rs b/werk-util/cancel.rs index 66c9b243..846e3191 100644 --- a/werk-util/cancel.rs +++ b/werk-util/cancel.rs @@ -3,23 +3,35 @@ use std::{ pin::Pin, sync::{Arc, atomic::AtomicBool}, - task::{Context, Poll, Waker}, + task::{Context, Poll}, }; -use parking_lot::Mutex; +use event_listener::{Event, EventListener}; pub struct Sender { state: Arc, } -#[derive(Clone)] -pub struct Receiver { - state: Arc, +pin_project_lite::pin_project! { + pub struct Receiver { + state: Arc, + #[pin] + listener: EventListener, + } +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + listener: self.state.event.listen(), + } + } } struct State { cancelled: AtomicBool, - wakers: Mutex>, + event: Event, } impl Sender { @@ -29,7 +41,7 @@ impl Sender { Sender { state: Arc::new(State { cancelled: AtomicBool::new(false), - wakers: Mutex::new(Vec::new()), + event: Event::new(), }), } } @@ -39,6 +51,7 @@ impl Sender { pub fn receiver(&self) -> Receiver { Receiver { state: self.state.clone(), + listener: self.state.event.listen(), } } @@ -57,11 +70,7 @@ impl Sender { { return; } - - let mut wakers = self.state.wakers.lock(); - for waker in wakers.drain(..) { - waker.wake(); - } + self.state.event.notify(usize::MAX); } } @@ -79,9 +88,13 @@ impl Drop for Sender { } } -impl Receiver { - fn poll_pinned(&self, cx: &mut Context<'_>) -> Poll<()> { - if self +impl Future for Receiver { + type Output = (); + + #[inline] + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + if this .state .cancelled .load(std::sync::atomic::Ordering::SeqCst) @@ -89,26 +102,7 @@ impl Receiver { return Poll::Ready(()); } - let mut wakers = self.state.wakers.lock(); - wakers.push(cx.waker().clone()); + _ = this.listener.poll(cx); Poll::Pending } } - -impl Future for Receiver { - type Output = (); - - #[inline] - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.poll_pinned(cx) - } -} - -impl Future for &Receiver { - type Output = (); - - #[inline] - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.poll_pinned(cx) - } -} diff --git a/werk-util/diagnostic.rs b/werk-util/diagnostic.rs index db563749..a9a9031d 100644 --- a/werk-util/diagnostic.rs +++ b/werk-util/diagnostic.rs @@ -4,13 +4,24 @@ use indexmap::{IndexMap, map::Entry}; use crate::DiagnosticSpan; #[derive(Clone, Copy)] -pub struct Annotated { - pub repository: R, +pub struct Annotated<'a, T> { + pub repository: &'a dyn DiagnosticSourceMap, pub error: T, } -impl Annotated { - pub fn map_err U, U>(self, f: F) -> Annotated { +impl<'a, T> Annotated<'a, T> { + pub fn into_inner(self) -> T { + self.error + } + + pub fn inner_into>(self) -> Annotated<'a, U> { + Annotated { + repository: self.repository, + error: self.error.into(), + } + } + + pub fn map_err U, U>(self, f: F) -> Annotated<'a, U> { Annotated { repository: self.repository, error: f(self.error), @@ -18,7 +29,7 @@ impl Annotated { } } -impl Annotated { +impl Annotated<'_, T> { pub fn display<'a>( &'a self, renderer: &'a annotate_snippets::Renderer, @@ -27,13 +38,13 @@ impl Annotated { } } -impl std::fmt::Debug for Annotated { +impl std::fmt::Debug for Annotated<'_, T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.error.fmt(f) } } -impl std::fmt::Display for Annotated { +impl std::fmt::Display for Annotated<'_, T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let renderer = annotate_snippets::Renderer::styled(); let display = self.display(&renderer); @@ -41,10 +52,9 @@ impl std::fmt::Display for Annotated std::error::Error for Annotated +impl std::error::Error for Annotated<'_, T> where T: AsDiagnostic + std::error::Error, - R: DiagnosticSourceMap, { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.error.source() @@ -223,7 +233,7 @@ pub trait AsDiagnostic { source_map: &'a dyn DiagnosticSourceMap, ) -> Vec>; - fn into_diagnostic_error(self, source_files: R) -> Annotated + fn into_diagnostic_error(self, source_files: &dyn DiagnosticSourceMap) -> Annotated<'_, Self> where Self: Sized, { diff --git a/werk-util/ellipsize.rs b/werk-util/ellipsize.rs new file mode 100644 index 00000000..a0f0c494 --- /dev/null +++ b/werk-util/ellipsize.rs @@ -0,0 +1,815 @@ +use std::collections::VecDeque; + +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Copy, Debug, Default)] +pub enum StringBreakMode<'a> { + /// Breaks on sentence boundaries using unicode segmentation. + Sentence, + /// Breaks on word boundaries using unicode segmentation. + Word, + /// Breaks on grapheme cluster boundaries using unicode segmentation. + #[default] + GraphemeCluster, + /// Splits the string into substrings using the given pattern (calling + /// [`std::str::split`]). + SplitPattern(&'a str), +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum ListBreakMode<'a> { + #[default] + BetweenItems, + InsideItems(StringBreakMode<'a>), +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum Location { + Start, + Middle, + #[default] + End, +} + +pub const UNICODE_ELLIPSIS: &str = "…"; +pub const ASCII_ELLIPSIS: &str = "..."; + +pub trait Ellipsis { + fn width_for_remaining_items(&self, remaining: usize) -> usize; + fn write_ellipsis(&self, w: &mut dyn std::fmt::Write, remaining: usize) -> std::fmt::Result; +} + +impl Ellipsis for str { + fn width_for_remaining_items(&self, _: usize) -> usize { + self.graphemes(true).count() + } + + fn write_ellipsis(&self, w: &mut dyn std::fmt::Write, _: usize) -> std::fmt::Result { + w.write_str(self) + } +} + +pub struct AndMoreEllipsis; + +impl Ellipsis for AndMoreEllipsis { + fn width_for_remaining_items(&self, remaining: usize) -> usize { + // 11 fixed chars in "(and more)" + the decimal digit count of `remaining`. + let digits = remaining.checked_ilog10().map_or(1, |d| d as usize + 1); + 11 + digits + } + + fn write_ellipsis(&self, w: &mut dyn std::fmt::Write, remaining: usize) -> std::fmt::Result { + write!(w, "(and {remaining} more)") + } +} + +pub fn ellipsize_with( + output: &mut W, + input: &str, + mode: StringBreakMode, + max_width: usize, + ellipsis: &E, + location: Location, +) -> std::fmt::Result +where + W: std::fmt::Write, + E: Ellipsis + ?Sized, +{ + if max_width == 0 { + return Ok(()); + } + + let mut segments: VecDeque<&str> = match mode { + StringBreakMode::Sentence => input.unicode_sentences().collect(), + StringBreakMode::Word => input.unicode_words().collect(), + StringBreakMode::GraphemeCluster => input.graphemes(true).collect(), + StringBreakMode::SplitPattern(pattern) => input.split(pattern).collect(), + }; + let mut current_width = input.graphemes(true).count(); + + // If the input already fits, write it verbatim — no ellipsis is needed. + if current_width <= max_width { + for segment in &segments { + output.write_str(segment)?; + } + return Ok(()); + } + + // Otherwise, drop segments until the surviving content plus the ellipsis + // fits within max_width, or until we run out of segments. + let mut num_removed = 0; + while !segments.is_empty() + && current_width + ellipsis.width_for_remaining_items(num_removed) > max_width + { + let removed = match location { + Location::Start => segments.pop_front().unwrap(), + Location::Middle => { + let index = segments.len() / 2; + segments.remove(index).unwrap() + } + Location::End => segments.pop_back().unwrap(), + }; + current_width -= removed.graphemes(true).count(); + num_removed += 1; + } + + // If the ellipsis itself can't fit even after draining the input, write + // nothing rather than overflowing max_width. + if ellipsis.width_for_remaining_items(num_removed) > max_width { + return Ok(()); + } + + match location { + Location::Start => { + ellipsis.write_ellipsis(output, num_removed)?; + for segment in &segments { + output.write_str(segment)?; + } + Ok(()) + } + Location::Middle => { + if segments.is_empty() { + ellipsis.write_ellipsis(output, num_removed)?; + } else { + let mid = segments.len() / 2; + for (index, segment) in segments.iter().enumerate() { + if index == mid { + ellipsis.write_ellipsis(output, num_removed)?; + } + output.write_str(segment)?; + } + } + Ok(()) + } + Location::End => { + for segment in &segments { + output.write_str(segment)?; + } + ellipsis.write_ellipsis(output, num_removed)?; + Ok(()) + } + } +} + +pub fn ellipsize( + output: &mut W, + input: &str, + mode: StringBreakMode, + max_length: usize, +) -> std::fmt::Result +where + W: std::fmt::Write, +{ + ellipsize_with( + output, + input, + mode, + max_length, + UNICODE_ELLIPSIS, + Location::End, + ) +} + +/// Concatenates a list of items into a single string, with an ellipsis if the +/// total width exceeds `max_length`. +/// +/// The mode controls how sequences of graphemes are removed from the output. +pub fn ellipsize_list( + output: &mut W, + input: impl ExactSizeIterator, + mode: ListBreakMode, + max_length: usize, + ellipsis: &E, + location: Location, + joiner: &str, +) -> std::fmt::Result +where + W: std::fmt::Write, + E: Ellipsis + ?Sized, +{ + let item_count = input.len(); + + if item_count == 0 || max_length == 0 { + return Ok(()); + } + + match mode { + ListBreakMode::BetweenItems => { + ellipsize_list_between_items(output, input, max_length, ellipsis, location, joiner) + } + ListBreakMode::InsideItems(string_break_mode) => { + let items: Vec = input.map(|item| item.to_string()).collect(); + let joiner_width = joiner.graphemes(true).count(); + + // Keep all items visible but truncate long ones individually. + // Budget the available width evenly across items after reserving + // space for the joiners between them. + let joiner_total = joiner_width * (item_count - 1); + let available = max_length.saturating_sub(joiner_total); + let budget_per_item = available / item_count; + + for (i, item) in items.iter().enumerate() { + if i > 0 { + output.write_str(joiner)?; + } + ellipsize_with( + output, + item, + string_break_mode, + budget_per_item, + UNICODE_ELLIPSIS, + location, + )?; + } + Ok(()) + } + } +} + +fn ellipsize_list_between_items( + output: &mut W, + input: impl ExactSizeIterator, + max_length: usize, + ellipsis: &E, + location: Location, + joiner: &str, +) -> std::fmt::Result +where + W: std::fmt::Write, + E: Ellipsis + ?Sized, +{ + let item_count = input.len(); + let joiner_width = joiner.graphemes(true).count(); + let items: Vec = input.map(|item| item.to_string()).collect(); + + let widths: Vec = items.iter().map(|s| s.graphemes(true).count()).collect(); + let mut item_widths_sum: usize = widths.iter().sum(); + + // Fast path: everything fits without truncation. + let no_ellipsis_width = item_widths_sum + joiner_width * (item_count - 1); + if no_ellipsis_width <= max_length { + for (i, item) in items.iter().enumerate() { + if i > 0 { + output.write_str(joiner)?; + } + output.write_str(item)?; + } + return Ok(()); + } + + // Drop items (from the requested location) until the kept items plus + // the ellipsis — treated as an extra list element with a joiner on + // each side that has a neighbour — fit within max_length. + // + // Width formula: item_widths_sum + kept * joiner_width + e_width + // kept == 0 → just e_width (no joiners) + let mut indices: VecDeque = (0..item_count).collect(); + let mut num_removed = 0usize; + + while !indices.is_empty() { + let kept = indices.len(); + let e_width = ellipsis.width_for_remaining_items(num_removed); + let total = item_widths_sum + kept * joiner_width + e_width; + if total <= max_length { + break; + } + let removed_idx = match location { + Location::Start => indices.pop_front().unwrap(), + Location::Middle => { + let mid = indices.len() / 2; + indices.remove(mid).unwrap() + } + Location::End => indices.pop_back().unwrap(), + }; + item_widths_sum -= widths[removed_idx]; + num_removed += 1; + } + + // Guard: the ellipsis alone won't fit either. + if ellipsis.width_for_remaining_items(num_removed) > max_length { + return Ok(()); + } + + // Write kept items with the ellipsis inserted at the right edge. + // The ellipsis is separated from its neighbours by the joiner, just + // like any other list element. + match location { + Location::Start => { + ellipsis.write_ellipsis(output, num_removed)?; + for &idx in &indices { + output.write_str(joiner)?; + output.write_str(&items[idx])?; + } + } + Location::Middle => { + if indices.is_empty() { + ellipsis.write_ellipsis(output, num_removed)?; + } else { + let mid = indices.len() / 2; + for (i, &idx) in indices.iter().enumerate() { + if i > 0 { + output.write_str(joiner)?; + } + if i == mid { + ellipsis.write_ellipsis(output, num_removed)?; + output.write_str(joiner)?; + } + output.write_str(&items[idx])?; + } + } + } + Location::End => { + for (i, &idx) in indices.iter().enumerate() { + if i > 0 { + output.write_str(joiner)?; + } + output.write_str(&items[idx])?; + } + if !indices.is_empty() { + output.write_str(joiner)?; + } + ellipsis.write_ellipsis(output, num_removed)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run( + input: &str, + mode: StringBreakMode, + max_width: usize, + ellipsis: &(impl Ellipsis + ?Sized), + location: Location, + ) -> String { + let mut out = String::new(); + ellipsize_with(&mut out, input, mode, max_width, ellipsis, location).unwrap(); + out + } + + fn run_default(input: &str, mode: StringBreakMode, max_width: usize) -> String { + let mut out = String::new(); + ellipsize(&mut out, input, mode, max_width).unwrap(); + out + } + + fn graphemes_in(s: &str) -> usize { + s.graphemes(true).count() + } + + // ------------------------------------------------------------------------- + // Degenerate / edge inputs + // ------------------------------------------------------------------------- + + #[test] + fn max_width_zero_yields_empty() { + assert_eq!( + run_default("hello", StringBreakMode::GraphemeCluster, 0), + "" + ); + } + + #[test] + fn empty_input_yields_empty() { + assert_eq!(run_default("", StringBreakMode::GraphemeCluster, 10), ""); + } + + #[test] + fn max_width_smaller_than_ellipsis_yields_empty() { + // ASCII_ELLIPSIS is 3 graphemes. With max_width=2 there's no room even + // for the ellipsis itself, so nothing should be written. + let out = run( + "hello world", + StringBreakMode::GraphemeCluster, + 2, + ASCII_ELLIPSIS, + Location::End, + ); + assert_eq!(out, ""); + } + + // ------------------------------------------------------------------------- + // Truncation at the end (the default location) + // ------------------------------------------------------------------------- + + #[test] + fn truncates_at_end_with_unicode_ellipsis() { + // 4 surviving graphemes + 1 ellipsis = 5. + let out = run_default("hello world", StringBreakMode::GraphemeCluster, 5); + assert_eq!(out, "hell…"); + assert_eq!(graphemes_in(&out), 5); + } + + #[test] + fn truncates_at_end_with_ascii_ellipsis() { + // ASCII "..." is 3 graphemes, leaving room for 2 from the input. + let out = run( + "hello world", + StringBreakMode::GraphemeCluster, + 5, + ASCII_ELLIPSIS, + Location::End, + ); + assert_eq!(out, "he..."); + assert_eq!(graphemes_in(&out), 5); + } + + #[test] + fn never_exceeds_max_width() { + // Output must never exceed the requested max_width (in graphemes), + // for any width within a wide range. + for max in 0..=30 { + let out = run_default( + "the quick brown fox jumps over the lazy dog", + StringBreakMode::GraphemeCluster, + max, + ); + assert!( + graphemes_in(&out) <= max, + "max={max}: got {out:?} ({} graphemes)", + graphemes_in(&out), + ); + } + } + + // ------------------------------------------------------------------------- + // Truncation at the start + // ------------------------------------------------------------------------- + + #[test] + fn truncates_at_start() { + let out = run( + "hello world", + StringBreakMode::GraphemeCluster, + 5, + UNICODE_ELLIPSIS, + Location::Start, + ); + // Ellipsis followed by the trailing 4 graphemes. + assert_eq!(out, "…orld"); + assert_eq!(graphemes_in(&out), 5); + } + + // ------------------------------------------------------------------------- + // Truncation in the middle + // ------------------------------------------------------------------------- + + #[test] + fn truncates_in_middle() { + let out = run( + "abcdefghij", + StringBreakMode::GraphemeCluster, + 5, + UNICODE_ELLIPSIS, + Location::Middle, + ); + // The exact split point is implementation-defined, but the result must: + // - fit within max_width + // - contain the ellipsis + // - start with a prefix of the input + // - end with a suffix of the input + assert!(graphemes_in(&out) <= 5, "{out:?} too wide"); + assert!(out.contains(UNICODE_ELLIPSIS), "{out:?} missing ellipsis"); + assert!( + out.starts_with('a'), + "expected output to start with input prefix, got {out:?}", + ); + assert!( + out.ends_with('j'), + "expected output to end with input suffix, got {out:?}", + ); + } + + // ------------------------------------------------------------------------- + // Word and sentence break modes + // ------------------------------------------------------------------------- + + #[test] + fn truncates_on_word_boundary() { + let out = run( + "the quick brown fox", + StringBreakMode::Word, + 12, + UNICODE_ELLIPSIS, + Location::End, + ); + assert!(graphemes_in(&out) <= 12); + assert!(out.ends_with(UNICODE_ELLIPSIS)); + // NOTE: word mode joins segments without separators, since + // unicode-segmentation's `unicode_words` iterator yields only the + // word tokens (whitespace is dropped). Output for this input is + // "thequick…" rather than "the quick …". This is current behavior; + // word-mode ellipsization is meaningful for delimiters like ", ", not + // for prose where re-inserting separators would matter. + } + + #[test] + fn truncates_on_sentence_boundary() { + let out = run( + "First sentence. Second sentence. Third sentence.", + StringBreakMode::Sentence, + 20, + UNICODE_ELLIPSIS, + Location::End, + ); + assert!(graphemes_in(&out) <= 20); + assert!(out.ends_with(UNICODE_ELLIPSIS)); + let prefix = out.trim_end_matches(UNICODE_ELLIPSIS); + assert!( + "First sentence. Second sentence. Third sentence.".starts_with(prefix), + "non-prefix surfaced: {out:?}", + ); + } + + // ------------------------------------------------------------------------- + // Unicode handling + // ------------------------------------------------------------------------- + + #[test] + fn counts_grapheme_clusters_not_bytes() { + // Each emoji is one grapheme cluster but several bytes. With max=3 and + // a 1-grapheme ellipsis, two of the five should survive. + let out = run( + "🦀🦀🦀🦀🦀", + StringBreakMode::GraphemeCluster, + 3, + UNICODE_ELLIPSIS, + Location::End, + ); + assert_eq!(graphemes_in(&out), 3); + assert!(out.ends_with(UNICODE_ELLIPSIS)); + } + + #[test] + fn handles_combining_characters() { + // "é" expressed as e + combining acute is one grapheme cluster, so + // "éllo world" is 10 graphemes (not 11 codepoints). + let input = "e\u{0301}llo world"; + assert_eq!(graphemes_in(input), 10); + let out = run_default(input, StringBreakMode::GraphemeCluster, 5); + assert_eq!(graphemes_in(&out), 5); + // The 'é' must not be split between its base and combining mark. + assert!(out.starts_with("e\u{0301}") || !out.contains('\u{0301}')); + } + + // ------------------------------------------------------------------------- + // AndMoreEllipsis + // ------------------------------------------------------------------------- + + #[test] + fn and_more_ellipsis_renders_count() { + let out = run( + "abcdefghijklmnopqrstuvwxyz", + StringBreakMode::GraphemeCluster, + 20, + &AndMoreEllipsis, + Location::End, + ); + assert!(graphemes_in(&out) <= 20, "{out:?} too wide"); + assert!(out.contains("(and "), "{out:?} missing 'and …'"); + assert!(out.contains(" more)"), "{out:?} missing ' more)'"); + } + + #[test] + fn and_more_ellipsis_reported_width_matches_rendered() { + // The trait contract: reported width must equal the actual rendered + // grapheme count, otherwise the truncation loop's accounting drifts. + for remaining in [0usize, 1, 9, 10, 99, 100, 999] { + let mut rendered = String::new(); + AndMoreEllipsis + .write_ellipsis(&mut rendered, remaining) + .unwrap(); + assert_eq!( + graphemes_in(&rendered), + AndMoreEllipsis.width_for_remaining_items(remaining), + "mismatch for remaining={remaining}: rendered={rendered:?}", + ); + } + } + + // ------------------------------------------------------------------------- + // Input that already fits within max_width + // ------------------------------------------------------------------------- + + #[test] + fn input_shorter_than_max_width_is_unchanged() { + let out = run_default("hi", StringBreakMode::GraphemeCluster, 100); + assert_eq!(out, "hi"); + } + + #[test] + fn input_at_exactly_max_width_is_unchanged() { + let out = run_default("hello", StringBreakMode::GraphemeCluster, 5); + assert_eq!(out, "hello"); + } + + // ------------------------------------------------------------------------- + // Regression: the middle writer used to consume the segment at the split + // point without writing it, dropping a character from the output. + // ------------------------------------------------------------------------- + + #[test] + fn middle_writer_preserves_all_kept_segments() { + let out = run( + "abcdef", + StringBreakMode::GraphemeCluster, + 4, + UNICODE_ELLIPSIS, + Location::Middle, + ); + assert_eq!(graphemes_in(&out), 4); + // 4-grapheme budget = 1 ellipsis + 3 surviving graphemes; none may + // be silently dropped on the way to output. + let visible = out.replace(UNICODE_ELLIPSIS, ""); + assert_eq!(graphemes_in(&visible), 3, "kept segments lost: {out:?}"); + for grapheme in visible.graphemes(true) { + assert!( + "abcdef".contains(grapheme), + "unknown grapheme {grapheme:?} in {out:?}", + ); + } + } + + // ------------------------------------------------------------------------- + // ellipsize_list — BetweenItems mode + // ------------------------------------------------------------------------- + + fn run_list( + items: &[&str], + mode: ListBreakMode, + max_width: usize, + ellipsis: &E, + location: Location, + joiner: &str, + ) -> String + where + E: Ellipsis + ?Sized, + { + let mut out = String::new(); + ellipsize_list( + &mut out, + items.iter().copied(), + mode, + max_width, + ellipsis, + location, + joiner, + ) + .unwrap(); + out + } + + #[test] + fn list_all_items_fit_written_verbatim() { + let out = run_list( + &["apple", "banana", "cherry"], + ListBreakMode::BetweenItems, + 30, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert_eq!(out, "apple, banana, cherry"); + } + + #[test] + fn list_end_truncation_drops_trailing_items() { + // "apple, banana, cherry" = 21 graphemes; max=15 forces truncation. + let out = run_list( + &["apple", "banana", "cherry"], + ListBreakMode::BetweenItems, + 15, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert!(graphemes_in(&out) <= 15, "{out:?} too wide"); + assert!(out.ends_with(UNICODE_ELLIPSIS), "{out:?} missing ellipsis"); + assert!(out.starts_with("apple"), "{out:?} wrong prefix"); + } + + #[test] + fn list_end_truncation_never_exceeds_max_width() { + let items = ["one", "two", "three", "four", "five"]; + for max in 0..=40 { + let out = run_list( + &items, + ListBreakMode::BetweenItems, + max, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert!( + graphemes_in(&out) <= max, + "max={max}: {out:?} ({} graphemes)", + graphemes_in(&out) + ); + } + } + + #[test] + fn list_start_truncation_drops_leading_items() { + let out = run_list( + &["apple", "banana", "cherry"], + ListBreakMode::BetweenItems, + 15, + UNICODE_ELLIPSIS, + Location::Start, + ", ", + ); + assert!(graphemes_in(&out) <= 15, "{out:?} too wide"); + assert!( + out.starts_with(UNICODE_ELLIPSIS), + "{out:?} missing leading ellipsis" + ); + assert!(out.ends_with("cherry"), "{out:?} wrong suffix"); + } + + #[test] + fn list_middle_truncation_keeps_prefix_and_suffix() { + let out = run_list( + &["a", "b", "c", "d", "e"], + ListBreakMode::BetweenItems, + 10, + UNICODE_ELLIPSIS, + Location::Middle, + ", ", + ); + assert!(graphemes_in(&out) <= 10, "{out:?} too wide"); + assert!(out.contains(UNICODE_ELLIPSIS), "{out:?} missing ellipsis"); + assert!(out.starts_with('a'), "{out:?} missing prefix"); + assert!(out.ends_with('e'), "{out:?} missing suffix"); + } + + #[test] + fn list_empty_input_yields_empty() { + let out = run_list( + &[], + ListBreakMode::BetweenItems, + 20, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert_eq!(out, ""); + } + + #[test] + fn list_and_more_ellipsis_end() { + let items = ["alpha", "beta", "gamma", "delta", "epsilon"]; + let out = run_list( + &items, + ListBreakMode::BetweenItems, + 25, + &AndMoreEllipsis, + Location::End, + ", ", + ); + assert!(graphemes_in(&out) <= 25, "{out:?} too wide"); + assert!( + out.contains("(and ") && out.contains(" more)"), + "{out:?} bad ellipsis" + ); + } + + // ------------------------------------------------------------------------- + // ellipsize_list — InsideItems mode + // ------------------------------------------------------------------------- + + #[test] + fn list_inside_items_all_short_unchanged() { + // Items are short enough that no per-item truncation is needed. + let out = run_list( + &["hi", "yo", "ok"], + ListBreakMode::InsideItems(StringBreakMode::GraphemeCluster), + 30, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert_eq!(out, "hi, yo, ok"); + } + + #[test] + fn list_inside_items_truncates_individually() { + // 3 items, joiner ", " (2 graphemes), max=15. + // Available = 15 - 2*2 = 11, budget per item = 11/3 = 3. + let out = run_list( + &["abcdef", "ghijkl", "mnopqr"], + ListBreakMode::InsideItems(StringBreakMode::GraphemeCluster), + 15, + UNICODE_ELLIPSIS, + Location::End, + ", ", + ); + assert!(graphemes_in(&out) <= 15, "{out:?} too wide"); + // Each segment must contain at least the ellipsis. + for part in out.split(", ") { + assert!(!part.is_empty(), "empty segment in {out:?}"); + } + } +} diff --git a/werk-util/hash128.rs b/werk-util/hash128.rs new file mode 100644 index 00000000..488a0e58 --- /dev/null +++ b/werk-util/hash128.rs @@ -0,0 +1,40 @@ +impl rustc_stable_hash::FromStableHash for Hash128 { + type Hash = rustc_stable_hash::SipHasher128Hash; + + fn from(hash: Self::Hash) -> Self { + let hi = u128::from(hash.0[0]) << 64; + let lo = u128::from(hash.0[1]); + Hash128(hi | lo) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct Hash128(pub u128); +impl From for Hash128 { + #[inline] + fn from(n: u128) -> Self { + Hash128(n) + } +} + +impl serde::Serialize for Hash128 { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Serialize as hex string. Also, TOML doesn't support 64-bit integers. + serializer.serialize_str(&format!("{:016x}", self.0)) + } +} + +impl<'de> serde::Deserialize<'de> for Hash128 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let n = u128::from_str_radix(&s, 16).map_err(serde::de::Error::custom)?; + Ok(Hash128(n)) + } +} diff --git a/werk-util/io_error.rs b/werk-util/io_error.rs new file mode 100644 index 00000000..00ef0df6 --- /dev/null +++ b/werk-util/io_error.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +/// Clonable `std::io::Error` +#[derive(Clone)] +pub struct IoError { + pub path: std::path::PathBuf, + pub error: Arc, +} + +impl IoError { + pub fn new(path: impl AsRef, error: std::io::Error) -> Self { + Self { + path: path.as_ref().to_path_buf(), + error: Arc::new(error), + } + } +} + +impl std::fmt::Debug for IoError { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&*self.error, f) + } +} + +impl std::fmt::Display for IoError { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.path.display(), self.error) + } +} + +impl PartialEq for IoError { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.path == other.path + && (Arc::ptr_eq(&self.error, &other.error) || self.error.kind() == other.error.kind()) + } +} + +impl std::error::Error for IoError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.error.source() + } +} diff --git a/werk-util/lib.rs b/werk-util/lib.rs index 62479d3c..31dbe403 100644 --- a/werk-util/lib.rs +++ b/werk-util/lib.rs @@ -1,10 +1,15 @@ +pub mod broadcast_one; pub mod cancel; mod diagnostic; +pub mod ellipsize; +pub mod hash128; +mod io_error; mod os_str; mod semantic_hash; mod span; pub use diagnostic::*; +pub use io_error::*; pub use os_str::*; pub use semantic_hash::*; pub use span::*; diff --git a/werk-util/span.rs b/werk-util/span.rs index 667c25a3..d1b22b11 100644 --- a/werk-util/span.rs +++ b/werk-util/span.rs @@ -2,7 +2,7 @@ use std::hash::Hash; use crate::{DiagnosticFileId, SemanticHash}; -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy, Default, Eq)] pub struct Span { pub start: Offset, pub end: Offset, @@ -106,7 +106,7 @@ impl From for Span { } } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Eq)] pub struct Offset(pub u32); impl Offset { @@ -226,7 +226,7 @@ impl From for std::ops::Range { } } -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DiagnosticSpan { pub file: DiagnosticFileId, pub span: Span,