diff --git a/.gitignore b/.gitignore index 1b2a7b36..c4a6b556 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # rendered code fragments /*.pdf + +# documentation symlinks +/doc/references diff --git a/Cargo.lock b/Cargo.lock index 0b473f27..7e95063b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -43,22 +43,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -69,15 +69,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "serde", @@ -85,24 +85,24 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.5.48" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -164,7 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -178,9 +178,9 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -207,15 +207,15 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "lazy_static" @@ -225,21 +225,21 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[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 = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lsp-server" @@ -278,17 +278,17 @@ 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 = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -299,45 +299,45 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[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 = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -347,9 +347,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -358,29 +358,23 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[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.9.4", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] -[[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" @@ -422,15 +416,15 @@ 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]] @@ -467,9 +461,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -478,7 +472,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.4.6" +version = "0.5.1" dependencies = [ "clap", "ignore", @@ -524,9 +518,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -535,9 +529,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -546,9 +540,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -567,9 +561,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -585,9 +579,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" @@ -617,23 +611,14 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - -[[package]] -name = "windows-sys" -version = "0.52.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -641,143 +626,85 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.4", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 3136923f..c7a6b5b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.4.6" +version = "0.5.1" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/examples/prototype/AirlockPowerdown.tq b/examples/prototype/AirlockPowerdown.tq new file mode 100644 index 00000000..852c0593 --- /dev/null +++ b/examples/prototype/AirlockPowerdown.tq @@ -0,0 +1,39 @@ +% technique v1 +! PD; © 2003 National Aeronautics and Space Administration, Canadian Space Agency, European Space Agency, and Others +& nasa-flight-plan,v4.0 + +emergency_procedures : + +# ISS Powerdown and Recovery + + 1. + 2. + 3. + +rs_load_powerdown : + +# RS Load Powerdown + +ARCU deactivation is requested by MCC-H and performed after MCC-M concurrence. + +node1_htr_avail_16 : + +# Inhibiting Node 1 B HTRS (1 to 6) + + @pcs + { foreach node in seq(6) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' + +node1_htr_avail_79 : + +# Inhibiting Node 1 B HTRS (7 to 9) + + @pcs + { foreach node in seq(9) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' diff --git a/examples/prototype/DatabaseUpgrade.tq b/examples/prototype/DatabaseUpgrade.tq new file mode 100644 index 00000000..29013fe6 --- /dev/null +++ b/examples/prototype/DatabaseUpgrade.tq @@ -0,0 +1,75 @@ +% technique v1 +& procedure + +database_upgrade : + +# Production Database Upgrade + +In order to launch the next version of our e-commerce platform, we need to +upgrade the schema of the core database at the heart of the application. We +also have an outstanding requirement to upgrade the underlying database +software, as we have had trouble with several bugs therein which the vendor +reports fixed. + +I. Take site down + +site_down : + +# Take site down + +Before taking the database offline for its upgrade, we put the site into +maintenance mode and safely down the servers. The start time is critical due +to expected duration of the database schema upgrade scripts. + + 1. Enter maintenance mode + @fozzie + a. Put web site into maintenance mode (load balancer redirect to + alternate web servers with static pages) + @gonzo + b. Activate IVR maintenance mode + 2. Down services + @kermit + a. Stop all VMs + b. Stop GFS on database1, database2 + c. Ensure RAID filesystems still mounted + @gonzo + d. Stop Apache on web1, web2 + 3. Verification + @kermit + a. Verify maintenance mode is active + b. Verify all VMs down + c. GO / NO-GO FOR UPGRADE + +II. Database work + +software_update : + +# Database Software Upgrade + +Run an export of the database in order to ensure we have a good backup prior +to upgrading the database software and running the schema change scripts. +There is not much concurrent activity here, so those not directly involved in +database activity will head for breakfast. + + 4. Database safety + @beaker + a. Database to single user mode + b. Export database to secondary storage + c. Stop database + @gonzo + d. Run out to get coffees for everyone + 5. Software upgrade + @fozzie + a. Install database software upgrade + 6. Restart database + @beaker + a. Start database + 7. Preliminary database testing + @beaker + a. Run access check scripts + b. Run health check scripts + @fozzie + c. Restart database monitoring + 8. Schema upgrade + @beaker + a. Run database schema upgrade scripts diff --git a/src/domain/adapter.rs b/src/domain/adapter.rs new file mode 100644 index 00000000..cbeb7de1 --- /dev/null +++ b/src/domain/adapter.rs @@ -0,0 +1,12 @@ +//! Adapter trait for domain projections. + +use crate::language; + +/// Adapters project the AST into a domain-specific model. Each domain +/// defines its own model types (e.g. checklist::Document, +/// procedure::Document) reflecting how that domain thinks about the elements +/// of procedures as encoded in Technique. +pub trait Adapter { + type Model; + fn extract(&self, document: &language::Document) -> Self::Model; +} diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs new file mode 100644 index 00000000..a0cf2711 --- /dev/null +++ b/src/domain/checklist/adapter.rs @@ -0,0 +1,304 @@ +//! Projects the AST into the checklist domain model. +//! +//! This flattens the parser type hierarchy. Each procedure becomes a section, +//! role assignments are inherited by sub steps, and SectionChunks are +//! rendered as headings with their sub-procedures' steps as children. + +use crate::language; +use crate::domain::Adapter; + +use super::types::{Document, Response, Section, Step}; + +pub struct ChecklistAdapter; + +impl Adapter for ChecklistAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +fn extract(document: &language::Document) -> Document { + let mut extracted = Document::new(); + + for procedure in document.procedures() { + extract_procedure(&mut extracted, procedure); + } + + if extracted + .sections + .is_empty() + { + // Handle top-level SectionChunks (no procedures) + for scope in document.steps() { + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + let steps: Vec = match scope.body() { + Some(body) => body + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(), + None => Vec::new(), + }; + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: Some(numeral.to_string()), + heading, + steps, + }); + } + } + } + + // Handle bare top-level steps (no sections, no procedures) + if extracted + .sections + .is_empty() + { + let steps: Vec = document + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: None, + heading: None, + steps, + }); + } + } + } + + extracted +} + +fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { + let steps: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + content + .sections + .push(Section { + ordinal: None, + heading: procedure + .title() + .map(String::from), + steps, + }); + } +} + +fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { + if scope.is_step() { + return vec![step_from_scope(scope, inherited_role)]; + } + + // AttributeBlock — extract role and process children + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let role = roles + .first() + .copied(); + return scope + .children() + .flat_map(|s| steps_from_scope(s, role)) + .collect(); + } + + // SectionChunk + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + + let mut steps = vec![Step { + name: None, + ordinal: Some(numeral.to_string()), + title: heading, + body: Vec::new(), + role: None, + responses: Vec::new(), + children: Vec::new(), + }]; + + if let Some(body) = scope.body() { + for procedure in body.procedures() { + if let Some(title) = procedure.title() { + let children: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + steps.push(Step { + name: Some( + procedure + .name() + .to_string(), + ), + ordinal: None, + title: Some(title.to_string()), + body: Vec::new(), + role: None, + responses: Vec::new(), + children, + }); + } + } + } + + return steps; + } + + Vec::new() +} + +/// Convert a step-like scope into a Step. +fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { + let mut responses = Vec::new(); + let mut children = Vec::new(); + + for subscope in scope.children() { + for response in subscope.responses() { + responses.push(Response { + value: response + .value() + .to_string(), + condition: response + .condition() + .map(String::from), + }); + } + children.extend(steps_from_scope(subscope, inherited_role)); + } + + let paragraphs: Vec = scope + .description() + .map(|p| p.content()) + .collect(); + let (title, body) = match paragraphs.split_first() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + Step { + name: None, + ordinal: scope + .ordinal() + .map(String::from), + title, + body, + role: inherited_role.map(String::from), + responses, + children, + } +} + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::parsing; + use crate::domain::Adapter; + + use super::ChecklistAdapter; + + fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) + } + + fn extract(source: &str) -> super::Document { + let path = Path::new("test.tq"); + let doc = parsing::parse(path, source).unwrap(); + ChecklistAdapter.extract(&doc) + } + + #[test] + fn procedure_title_becomes_section_heading() { + let doc = extract(trim( + r#" +preflight : + +# Pre-flight Checks + + 1. Fasten seatbelt + "#, + )); + assert_eq!( + doc.sections + .len(), + 1 + ); + assert_eq!(doc.sections[0].heading, Some("Pre-flight Checks".into())); + } + + #[test] + fn role_flattened_onto_children() { + let doc = extract(trim( + r#" +checks : + + @surgeon + 1. Confirm identity + 2. Mark surgical site + "#, + )); + let steps = &doc.sections[0].steps; + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].role, Some("surgeon".into())); + assert_eq!(steps[1].role, Some("surgeon".into())); + } + + #[test] + fn responses_with_conditions() { + let doc = extract(trim( + r#" +checks : + + 1. Is the patient ready? + 'Yes' | 'No' if complications + "#, + )); + let step = &doc.sections[0].steps[0]; + assert_eq!( + step.responses + .len(), + 2 + ); + assert_eq!(step.responses[0].value, "Yes"); + assert_eq!(step.responses[0].condition, None); + assert_eq!(step.responses[1].value, "No"); + assert_eq!(step.responses[1].condition, Some("if complications".into())); + } + + #[test] + fn invocation_only_step_has_content() { + let doc = extract(trim( + r#" +main : + + 1. + +ensure_safety : + +# Safety First + + - Check exits + "#, + )); + let steps = &doc.sections[0].steps; + assert_eq!(steps[0].title, Some("ensure_safety".into())); + } +} diff --git a/src/domain/checklist/mod.rs b/src/domain/checklist/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/checklist/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs new file mode 100644 index 00000000..e2c7a3c8 --- /dev/null +++ b/src/domain/checklist/types.rs @@ -0,0 +1,84 @@ +//! Domain types for checklists +//! +//! A checklist is moderately structured and relatively flat: sections with +//! headings, steps with checkboxes, response options, and limited nesting. + +use crate::domain::typst::{Data, Render}; + +/// A checklist is a document of sections containing steps. +pub struct Document { + pub sections: Vec
, +} + +impl Document { + pub fn new() -> Self { + Document { + sections: Vec::new(), + } + } +} + +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.list("sections", &self.sections); + data.close(); + } +} + +/// A section within a checklist. +pub struct Section { + pub ordinal: Option, + pub heading: Option, + pub steps: Vec, +} + +impl Render for Section { + fn render(&self, data: &mut Data) { + data.open(); + data.field("ordinal", &self.ordinal); + data.field("heading", &self.heading); + data.list("steps", &self.steps); + data.close(); + } +} + +/// A step within a checklist section. +pub struct Step { + #[allow(dead_code)] + pub name: Option, + pub ordinal: Option, + pub title: Option, + pub body: Vec, + pub role: Option, + pub responses: Vec, + pub children: Vec, +} + +impl Render for Step { + fn render(&self, data: &mut Data) { + data.open(); + data.field("ordinal", &self.ordinal); + data.field("title", &self.title); + data.list("body", &self.body); + data.field("role", &self.role); + data.list("responses", &self.responses); + data.list("children", &self.children); + data.close(); + } +} + +/// A response option with an optional condition. +pub struct Response { + pub value: String, + pub condition: Option, +} + +impl Render for Response { + fn render(&self, data: &mut Data) { + data.open(); + data.field("value", &self.value); + data.field("condition", &self.condition); + data.close(); + } +} diff --git a/src/domain/engine.rs b/src/domain/engine.rs new file mode 100644 index 00000000..09a92a34 --- /dev/null +++ b/src/domain/engine.rs @@ -0,0 +1,401 @@ +//! Engine: accessor helpers over the parser's AST types. +//! +//! The Technique language parser deals with considerable complexity and +//! ambiguity in the surface language, and as a result the parser's AST is +//! somewhat tailored to the form of that surface language. This is fine for +//! compiling and code formatting, but contains too much internal detail for +//! someone writing an output renderer to deal with. +//! +//! This module thus provides convenient iteration methods on AST types so +//! that adapters can extract content without having to match on parser +//! internals directly. The types returned are still the parser's own types +//! (Scope, Paragraph, Response, etc.) — the "adapters" are responsible for +//! projecting these into domain-specific models. + +use crate::language::{ + Attribute, Descriptive, Document, Element, Paragraph, Procedure, Response, Scope, Technique, +}; + +impl<'i> Document<'i> { + /// Get all the procedures in the document as an iterator. + pub fn procedures(&self) -> impl Iterator> { + let slice: &[Procedure<'i>] = match &self.body { + Some(Technique::Procedures(procedures)) => procedures, + _ => &[], + }; + slice.iter() + } + + /// Get all the document's top-level steps as an iterator. + pub fn steps(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match &self.body { + Some(Technique::Steps(steps)) => steps, + _ => &[], + }; + slice.iter() + } +} + +impl<'i> Procedure<'i> { + // a title() method already exists in language/types.rs + + /// Returns an iterator over the procedure's top-level steps. + pub fn steps(&self) -> impl Iterator> { + self.elements + .iter() + .flat_map(|element| match element { + Element::Steps(steps) => steps.iter(), + _ => [].iter(), + }) + } + + /// Returns an iterator over the procedure's descriptive paragraphs. + pub fn description(&self) -> impl Iterator> { + self.elements + .iter() + .flat_map(|element| match element { + Element::Description(paragraphs) => paragraphs.iter(), + _ => [].iter(), + }) + } +} + +impl<'i> Scope<'i> { + /// Returns an iterator over all children. + pub fn children(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match self { + Scope::DependentBlock { subscopes, .. } => subscopes, + Scope::ParallelBlock { subscopes, .. } => subscopes, + Scope::AttributeBlock { subscopes, .. } => subscopes, + Scope::CodeBlock { subscopes, .. } => subscopes, + Scope::ResponseBlock { .. } => &[], + Scope::SectionChunk { .. } => &[], + }; + slice.iter() + } + + /// Returns an iterator over child steps only (DependentBlock, ParallelBlock). + /// Filters out ResponseBlock, CodeBlock, AttributeBlock, etc. + pub fn substeps(&self) -> impl Iterator> { + self.children() + .filter(|s| { + matches!( + s, + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } + ) + }) + } + + /// Returns the text content of this step (first paragraph). + pub fn text(&self) -> Option { + self.description() + .next() + .map(|p| p.text()) + } + + /// Returns an iterator over description paragraphs (for step-like scopes). + pub fn description(&self) -> impl Iterator> { + let slice: &[Paragraph<'i>] = match self { + Scope::DependentBlock { description, .. } => description, + Scope::ParallelBlock { description, .. } => description, + _ => &[], + }; + slice.iter() + } + + /// Returns the ordinal if this is a DependentBlock (numbered step). + pub fn ordinal(&self) -> Option<&'i str> { + match self { + Scope::DependentBlock { ordinal, .. } => Some(ordinal), + _ => None, + } + } + + /// Returns an iterator over responses if this is a ResponseBlock. + pub fn responses(&self) -> impl Iterator> { + let slice: &[Response<'i>] = match self { + Scope::ResponseBlock { responses } => responses, + _ => &[], + }; + slice.iter() + } + + /// Returns an iterator over role names if this is an AttributeBlock. + pub fn roles(&self) -> impl Iterator { + match self { + Scope::AttributeBlock { attributes, .. } => attributes + .iter() + .filter_map(|attr| match attr { + Attribute::Role(id) => Some(id.0), + _ => None, + }) + .collect::>() + .into_iter(), + _ => Vec::new().into_iter(), + } + } + + /// Returns true if this scope represents a step (dependent or parallel). + pub fn is_step(&self) -> bool { + matches!( + self, + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } + ) + } + + /// Returns section info (numeral, title) if this is a SectionChunk. + pub fn section_info(&self) -> Option<(&'i str, Option<&Paragraph<'i>>)> { + match self { + Scope::SectionChunk { numeral, title, .. } => Some((numeral, title.as_ref())), + _ => None, + } + } + + /// Returns the body of a SectionChunk. + pub fn body(&self) -> Option<&Technique<'i>> { + match self { + Scope::SectionChunk { body, .. } => Some(body), + _ => None, + } + } +} + +impl<'i> Technique<'i> { + /// Returns an iterator over procedures if this is a Procedures variant. + pub fn procedures(&self) -> impl Iterator> { + let slice: &[Procedure<'i>] = match self { + Technique::Procedures(procedures) => procedures, + _ => &[], + }; + slice.iter() + } + + /// Returns an iterator over steps if this is a Steps variant. + pub fn steps(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match self { + Technique::Steps(steps) => steps, + _ => &[], + }; + slice.iter() + } +} + +impl<'i> Procedure<'i> { + /// Returns the procedure name. + pub fn name(&self) -> &'i str { + self.name + .0 + } +} + +impl<'i> Response<'i> { + /// Returns the response value. + pub fn value(&self) -> &'i str { + self.value + } + + /// Returns the optional condition. + pub fn condition(&self) -> Option<&'i str> { + self.condition + } +} + +impl<'i> Paragraph<'i> { + /// Returns only the text content of this paragraph. + pub fn text(&self) -> String { + let mut result = String::new(); + for d in &self.0 { + Self::append_text(&mut result, d); + } + result + } + + /// Returns invocation target names from this paragraph. + pub fn invocations(&self) -> Vec<&'i str> { + let mut targets = Vec::new(); + for d in &self.0 { + Self::extract_invocations(&mut targets, d); + } + targets + } + + /// Returns text of the step body if present, otherwise (for the scenarion + /// where the step is a bare invocation or code expression) a readable + /// rendering of the first non-text element. + pub fn content(&self) -> String { + let text = self.text(); + if !text.is_empty() { + return text; + } + for descriptive in &self.0 { + let result = Self::descriptive_content(descriptive); + if !result.is_empty() { + return result; + } + } + String::new() + } + + fn descriptive_content(descriptive: &Descriptive<'i>) -> String { + match descriptive { + Descriptive::Application(inv) => Self::invocation_name(inv).to_string(), + Descriptive::CodeInline(expr) => Self::expression_content(expr), + Descriptive::Binding(inner, _) => Self::descriptive_content(inner), + _ => String::new(), + } + } + + fn expression_content(expr: &crate::language::Expression<'i>) -> String { + match expr { + crate::language::Expression::Application(invocation) => { + Self::invocation_name(invocation).to_string() + } + crate::language::Expression::Repeat(inner) => { + format!("repeat {}", Self::expression_content(inner)) + } + crate::language::Expression::Foreach(_, inner) => { + format!("foreach {}", Self::expression_content(inner)) + } + crate::language::Expression::Binding(inner, _) => Self::expression_content(inner), + _ => String::new(), + } + } + + fn append_text(result: &mut String, descriptive: &Descriptive<'i>) { + match descriptive { + Descriptive::Text(text) => { + if !result.is_empty() && !result.ends_with(' ') { + result.push(' '); + } + result.push_str(text); + } + Descriptive::Binding(inner, _) => Self::append_text(result, inner), + _ => {} + } + } + + fn extract_invocations(targets: &mut Vec<&'i str>, descriptive: &Descriptive<'i>) { + match descriptive { + Descriptive::Application(inv) => { + targets.push(Self::invocation_name(inv)); + } + Descriptive::CodeInline(expr) => { + Self::extract_expression_invocations(targets, expr); + } + Descriptive::Binding(inner, _) => { + Self::extract_invocations(targets, inner); + } + _ => {} + } + } + + fn extract_expression_invocations( + targets: &mut Vec<&'i str>, + expr: &crate::language::Expression<'i>, + ) { + match expr { + crate::language::Expression::Application(inv) => { + targets.push(Self::invocation_name(inv)); + } + crate::language::Expression::Repeat(inner) => { + Self::extract_expression_invocations(targets, inner); + } + crate::language::Expression::Foreach(_, inner) => { + Self::extract_expression_invocations(targets, inner); + } + crate::language::Expression::Binding(inner, _) => { + Self::extract_expression_invocations(targets, inner); + } + _ => {} + } + } + + fn invocation_name(inv: &crate::language::Invocation<'i>) -> &'i str { + match &inv.target { + crate::language::Target::Local(id) => id.0, + crate::language::Target::Remote(ext) => ext.0, + } + } +} + +#[cfg(test)] +mod check { + use crate::language::{Descriptive, Expression, Identifier, Invocation, Paragraph, Target}; + + fn local<'a>(name: &'a str) -> Invocation<'a> { + Invocation { + target: Target::Local(Identifier(name)), + parameters: None, + } + } + + // Pure text: "Ensure physical and digital safety" + #[test] + fn text_only_paragraph() { + let p = Paragraph(vec![Descriptive::Text( + "Ensure physical and digital safety", + )]); + assert_eq!(p.text(), "Ensure physical and digital safety"); + assert!(p + .invocations() + .is_empty()); + assert_eq!(p.content(), "Ensure physical and digital safety"); + } + + // Bare invocation: + #[test] + fn invocation_only_paragraph() { + let p = Paragraph(vec![Descriptive::Application(local("ensure_safety"))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["ensure_safety"]); + assert_eq!(p.content(), "ensure_safety"); + } + + // Mixed: Define Requirements (concept) + // Text is present so content() returns just the text. + #[test] + fn mixed_text_and_invocation() { + let p = Paragraph(vec![ + Descriptive::Text("Define Requirements"), + Descriptive::Application(local("define_requirements")), + ]); + assert_eq!(p.text(), "Define Requirements"); + assert_eq!(p.invocations(), vec!["define_requirements"]); + assert_eq!(p.content(), "Define Requirements"); + } + + // CodeInline with repeat: { repeat } + #[test] + fn repeat_expression() { + let p = Paragraph(vec![Descriptive::CodeInline(Expression::Repeat(Box::new( + Expression::Application(local("incident_action_cycle")), + )))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["incident_action_cycle"]); + assert_eq!(p.content(), "repeat incident_action_cycle"); + } + + // Binding wrapping an invocation: (s) ~ e + #[test] + fn binding_with_invocation() { + let p = Paragraph(vec![Descriptive::Binding( + Box::new(Descriptive::Application(local("observe"))), + vec![Identifier("e")], + )]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["observe"]); + assert_eq!(p.content(), "observe"); + } + + // CodeInline with foreach: { foreach design in designs } + #[test] + fn foreach_expression() { + let p = Paragraph(vec![Descriptive::CodeInline(Expression::Foreach( + vec![Identifier("design")], + Box::new(Expression::Application(local("implement"))), + ))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["implement"]); + assert_eq!(p.content(), "foreach implement"); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 00000000..434c77f9 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,19 @@ +//! Domain projections of Technique documents. +//! +//! A domain projection takes the parser's AST and transforms it into a +//! model suited to a particular kind of output. The **checklist** domain +//! flattens procedures into printable checklists; the **procedure** domain +//! preserves the full hierarchy; others are forthcoming. +//! +//! The **engine** module provides convenient accessors into the parser's AST +//! types so that adapters can extract content without needing to understand +//! the nuances of correclty matching directly on the internals types. + +mod adapter; +pub mod checklist; +pub mod engine; +pub mod procedure; +pub mod source; +pub mod typst; + +pub use adapter::Adapter; diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs new file mode 100644 index 00000000..c0fd4343 --- /dev/null +++ b/src/domain/procedure/adapter.rs @@ -0,0 +1,382 @@ +//! Projects the parser's AST into a domain model suitable for procedures. +//! +//! This is a recursive walk of the AST producing a tree of Nodes. The first +//! procedure provides document-level title and description; its steps (and +//! any SectionChunks within) become the body. Remaining top-level procedures +//! are appended as Procedure nodes. + +use crate::language; +use crate::domain::Adapter; + +use super::types::{Document, Node, Response}; + +pub struct ProcedureAdapter; + +impl Adapter for ProcedureAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +fn extract(document: &language::Document) -> Document { + let mut doc = Document::new(); + + let mut procedures = document.procedures(); + + if let Some(first) = procedures.next() { + doc.title = first + .title() + .map(String::from); + doc.description = first + .description() + .map(|p| p.content()) + .collect(); + + for scope in first.steps() { + doc.body + .extend(nodes_from_scope(scope)); + } + + for procedure in procedures { + doc.body + .push(node_from_procedure(procedure)); + } + } + + // Handle bare top-level steps (no procedures) + if doc + .body + .is_empty() + { + for scope in document.steps() { + doc.body + .extend(nodes_from_scope(scope)); + } + } + + doc +} + +fn node_from_procedure(procedure: &language::Procedure) -> Node { + let mut children = Vec::new(); + for scope in procedure.steps() { + children.extend(nodes_from_scope(scope)); + } + + Node::Procedure { + name: procedure + .name() + .to_string(), + title: procedure + .title() + .map(String::from), + description: procedure + .description() + .map(|p| p.content()) + .collect(), + children, + } +} + +/// Extract nodes from a scope, handling different scope types. +fn nodes_from_scope(scope: &language::Scope) -> Vec { + if scope.is_step() { + return vec![node_from_step(scope)]; + } + + // AttributeBlock — role group with children + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let name = roles.join(" + "); + let mut children = Vec::new(); + for child in scope.children() { + children.extend(nodes_from_scope(child)); + } + return vec![Node::Attribute { name, children }]; + } + + // SectionChunk + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + let mut children = Vec::new(); + + if let Some(body) = scope.body() { + for procedure in body.procedures() { + children.push(node_from_procedure(procedure)); + } + for step in body.steps() { + children.extend(nodes_from_scope(step)); + } + } + + return vec![Node::Section { + ordinal: numeral.to_string(), + heading, + children, + }]; + } + + Vec::new() +} + +/// Convert a step-like scope into a Sequential or Parallel node. +fn node_from_step(scope: &language::Scope) -> Node { + let mut responses = Vec::new(); + let mut children = Vec::new(); + + for subscope in scope.children() { + for response in subscope.responses() { + responses.push(Response { + value: response + .value() + .to_string(), + condition: response + .condition() + .map(String::from), + }); + } + children.extend(nodes_from_scope(subscope)); + } + + let paras: Vec<_> = scope + .description() + .collect(); + + let invocations: Vec = paras + .first() + .map(|p| { + p.invocations() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let paragraphs: Vec = paras + .iter() + .map(|p| p.content()) + .collect(); + let (title, body) = match paragraphs.split_first() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + match scope { + language::Scope::DependentBlock { .. } => Node::Sequential { + ordinal: scope + .ordinal() + .map(String::from) + .unwrap_or_default(), + title, + body, + invocations, + responses, + children, + }, + language::Scope::ParallelBlock { .. } => Node::Parallel { + title, + body, + invocations, + responses, + children, + }, + _ => panic!("node_from_step called with non-step scope"), + } +} + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::parsing; + use crate::domain::Adapter; + + use super::super::types::Node; + use super::ProcedureAdapter; + + fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) + } + + fn extract(source: &str) -> super::Document { + let path = Path::new("test.tq"); + let doc = parsing::parse(path, source).unwrap(); + ProcedureAdapter.extract(&doc) + } + + #[test] + fn procedure_title_becomes_document_title() { + let doc = extract(trim( + r#" +emergency : + +# Don't Panic + + 1. Stay calm + "#, + )); + assert_eq!(doc.title, Some("Don't Panic".into())); + } + + #[test] + fn role_preserved_as_group() { + let doc = extract(trim( + r#" +build : + + 1. Define Interfaces + @programmers + a. + "#, + )); + if let Node::Sequential { children, .. } = &doc.body[0] { + assert_eq!(children.len(), 1); + if let Node::Attribute { name, children } = &children[0] { + assert_eq!(name, "programmers"); + assert_eq!(children.len(), 1); + } else { + panic!("expected RoleGroup"); + } + } else { + panic!("expected Sequential"); + } + } + + #[test] + fn dependent_step_has_ordinal() { + let doc = extract(trim( + r#" +checks : + + 1. First step + 2. Second step + "#, + )); + assert_eq!( + doc.body + .len(), + 2 + ); + if let Node::Sequential { ordinal, .. } = &doc.body[0] { + assert_eq!(ordinal, "1"); + } else { + panic!("expected Sequential"); + } + } + + #[test] + fn parallel_step() { + let doc = extract(trim( + r#" +checks : + + - First item + - Second item + "#, + )); + assert_eq!( + doc.body + .len(), + 2 + ); + if let Node::Parallel { .. } = &doc.body[0] { + // ok + } else { + panic!("expected Parallel"); + } + } + + #[test] + fn invocation_only_step_has_content() { + let doc = extract(trim( + r#" +main : + + 1. + +ensure_safety : + +# Safety First + + - Check exits + "#, + )); + if let Node::Sequential { title, .. } = &doc.body[0] { + assert_eq!(*title, Some("ensure_safety".into())); + } else { + panic!("expected Sequential"); + } + } + + #[test] + fn sections_contain_their_procedures() { + let doc = extract(trim( + r#" +main : + +# Upgrade + + I. Preparation + +preparation : + + 1. Check systems + 2. Notify staff + + II. Execution + +execution : + + 3. Run scripts + 4. Verify + "#, + )); + assert_eq!( + doc.body + .len(), + 2 + ); + + if let Node::Section { + ordinal, + heading, + children, + } = &doc.body[0] + { + assert_eq!(ordinal, "I"); + assert_eq!(*heading, Some("Preparation".into())); + // Section contains a Procedure node with 2 steps + assert_eq!(children.len(), 1); + if let Node::Procedure { children, .. } = &children[0] { + assert_eq!(children.len(), 2); + } else { + panic!("expected Procedure in section"); + } + } else { + panic!("expected Section"); + } + + if let Node::Section { + ordinal, + heading, + children, + } = &doc.body[1] + { + assert_eq!(ordinal, "II"); + assert_eq!(*heading, Some("Execution".into())); + assert_eq!(children.len(), 1); + if let Node::Procedure { children, .. } = &children[0] { + assert_eq!(children.len(), 2); + } else { + panic!("expected Procedure in section"); + } + } else { + panic!("expected Section"); + } + } +} diff --git a/src/domain/procedure/mod.rs b/src/domain/procedure/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/procedure/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs new file mode 100644 index 00000000..1afcc7a6 --- /dev/null +++ b/src/domain/procedure/types.rs @@ -0,0 +1,136 @@ +//! Domain types for a procedure. +//! +//! A procedure is a recursive tree of nodes mirroring the structure of the +//! source Technique document. Sections, procedures, steps, role groups — +//! whatever the author wrote, the domain model preserves. + +use crate::domain::typst::{Data, Render}; + +/// A procedure document: title and description from the first procedure, +/// then a tree of nodes representing the body. +pub struct Document { + pub title: Option, + pub description: Vec, + pub body: Vec, +} + +impl Document { + pub fn new() -> Self { + Document { + title: None, + description: Vec::new(), + body: Vec::new(), + } + } +} + +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.field("title", &self.title); + data.list("description", &self.description); + data.list("body", &self.body); + data.close(); + } +} + +/// A node in the procedure tree. +pub enum Node { + Section { + ordinal: String, + heading: Option, + children: Vec, + }, + Procedure { + name: String, + title: Option, + description: Vec, + children: Vec, + }, + Sequential { + ordinal: String, + title: Option, + body: Vec, + invocations: Vec, + responses: Vec, + children: Vec, + }, + Parallel { + title: Option, + body: Vec, + invocations: Vec, + responses: Vec, + children: Vec, + }, + Attribute { + name: String, + children: Vec, + }, +} + +impl Render for Node { + fn render(&self, data: &mut Data) { + match self { + Node::Section { ordinal, heading, children } => { + data.open(); + data.tag("section"); + data.field("ordinal", ordinal); + data.field("heading", heading); + data.list("children", children); + data.close(); + } + Node::Procedure { name, title, description, children } => { + data.open(); + data.tag("procedure"); + data.field("name", name); + data.field("title", title); + data.list("description", description); + data.list("children", children); + data.close(); + } + Node::Sequential { ordinal, title, body, invocations, responses, children } => { + data.open(); + data.tag("sequential"); + data.field("ordinal", ordinal); + data.field("title", title); + data.list("body", body); + data.list("invocations", invocations); + data.list("responses", responses); + data.list("children", children); + data.close(); + } + Node::Parallel { title, body, invocations, responses, children } => { + data.open(); + data.tag("parallel"); + data.field("title", title); + data.list("body", body); + data.list("invocations", invocations); + data.list("responses", responses); + data.list("children", children); + data.close(); + } + Node::Attribute { name, children } => { + data.open(); + data.tag("attribute"); + data.field("name", name); + data.list("children", children); + data.close(); + } + } + } +} + +/// A response option with an optional condition. +pub struct Response { + pub value: String, + pub condition: Option, +} + +impl Render for Response { + fn render(&self, data: &mut Data) { + data.open(); + data.field("value", &self.value); + data.field("condition", &self.condition); + data.close(); + } +} diff --git a/src/domain/source/adapter.rs b/src/domain/source/adapter.rs new file mode 100644 index 00000000..cfd8b6e6 --- /dev/null +++ b/src/domain/source/adapter.rs @@ -0,0 +1,32 @@ +//! Projects the AST into the source domain model. +//! +//! This runs the code formatter to produce syntax-tagged fragments, +//! then wraps them as domain types for serialization. + +use crate::domain::Adapter; +use crate::formatting::formatter::format_with_renderer; +use crate::language; + +use super::types::{Document, Fragment}; + +const WIDTH: u8 = 70; + +pub struct SourceAdapter; + +impl Adapter for SourceAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + let fragments = format_with_renderer(document, WIDTH); + + Document { + fragments: fragments + .into_iter() + .map(|(syntax, content)| Fragment { + syntax: format!("{:?}", syntax), + content: content.into_owned(), + }) + .collect(), + } + } +} diff --git a/src/domain/source/mod.rs b/src/domain/source/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/source/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/source/types.rs b/src/domain/source/types.rs new file mode 100644 index 00000000..077031a4 --- /dev/null +++ b/src/domain/source/types.rs @@ -0,0 +1,33 @@ +//! Domain types for source code display. +//! +//! A source document is a flat sequence of syntax-tagged fragments, +//! produced by the code formatter. Each fragment carries a syntax tag +//! (e.g. "Declaration", "Keyword") and a content string. + +use crate::domain::typst::{Data, Render}; + +pub struct Document { + pub fragments: Vec, +} + +pub struct Fragment { + pub syntax: String, + pub content: String, +} + +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.list("fragments", &self.fragments); + data.close(); + } +} + +impl Render for Fragment { + fn render(&self, data: &mut Data) { + data.open(); + data.field("syntax", &self.syntax); + data.field("content", &self.content); + data.close(); + } +} diff --git a/src/domain/typst.rs b/src/domain/typst.rs new file mode 100644 index 00000000..eeba65f6 --- /dev/null +++ b/src/domain/typst.rs @@ -0,0 +1,192 @@ +//! Typst data literal builder. +//! +//! Domain types serialize themselves as Typst dictionary literals using the +//! `Data` builder. The `Render` trait is implemented by types that emit +//! themselves as complete dictionary entries; `Field` is for individual +//! key-value pairs within a dictionary. + +const INDENT: &str = " "; + +/// Stateful builder for accumulating Typst data literals. +pub struct Data { + out: String, + depth: usize, +} + +impl Data { + pub fn new() -> Self { + Data { + out: String::new(), + depth: 0, + } + } + + /// Consume the builder and return the data as a `#let technique = ...` + /// binding. The trailing comma from `close()` is stripped so the + /// top-level assignment is valid Typst. + pub fn finish(self) -> String { + let out = self + .out + .trim_end() + .trim_end_matches(','); + format!("#let technique = {}\n", out) + } + + fn pad(&mut self) { + for _ in 0..self.depth { + self.out + .push_str(INDENT); + } + } + + /// Open a dictionary: `(`, newline, and increase depth. + pub fn open(&mut self) { + self.pad(); + self.out + .push_str("(\n"); + self.depth += 1; + } + + /// Close a dictionary: decrease depth, closing `),` and newline. + pub fn close(&mut self) { + self.depth -= 1; + self.pad(); + self.out + .push_str("),\n"); + } + + /// Emit a `type: "name",` discriminator field and a newline. + pub fn tag(&mut self, name: &str) { + self.pad(); + self.out + .push_str(&format!("type: \"{}\",\n", name)); + } + + /// Emit a field whose value implements `Field`. + pub fn field(&mut self, key: &str, value: &(impl Field + ?Sized)) { + value.emit(self, key); + } + + /// Emit a list field, calling `Render::render` on each item. + pub fn list(&mut self, key: &str, items: &[T]) { + self.pad(); + self.out + .push_str(&format!("{}: (\n", key)); + self.depth += 1; + for item in items { + item.render(self); + } + self.depth -= 1; + self.pad(); + self.out + .push_str("),\n"); + } +} + +/// Emit a domain type as a Typst data literal. +pub trait Render { + fn render(&self, data: &mut Data); +} + +impl Render for String { + fn render(&self, data: &mut Data) { + data.pad(); + data.out + .push_str(&format!("\"{}\",\n", escape_string(self))); + } +} + +/// Any type that knows how to emit itself as a `key: value,` pair in a Typst +/// dictionary should implement Field, which can then be used by the Data +/// builder's field() method. +pub trait Field { + fn emit(&self, data: &mut Data, key: &str); +} + +impl Field for str { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + data.out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(self))); + } +} + +impl Field for String { + fn emit(&self, data: &mut Data, key: &str) { + self.as_str() + .emit(data, key); + } +} + +impl Field for Option { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + match self { + Some(v) => data + .out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(v))), + None => data + .out + .push_str(&format!("{}: none,\n", key)), + } + } +} + +/// Escape `\` and `"` for Typst string literals. +pub fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") +} + +#[cfg(test)] +mod check { + use super::*; + + #[test] + fn escape_string_backslash_and_quote() { + assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); + } + + #[test] + fn field_some() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &Some("Hello".into())); + assert_eq!(d.out, " title: \"Hello\",\n"); + } + + #[test] + fn field_none() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &None::); + assert_eq!(d.out, " title: none,\n"); + } + + #[test] + fn open_close_tracks_depth() { + let mut d = Data::new(); + d.open(); + assert_eq!(d.depth, 1); + d.close(); + assert_eq!(d.depth, 0); + assert_eq!(d.out, "(\n),\n"); + } + + #[test] + fn nested_dict() { + let mut d = Data::new(); + d.open(); + d.field("name", "outer"); + d.open(); + d.field("name", "inner"); + d.close(); + d.close(); + assert!(d + .out + .contains(" name: \"outer\",\n")); + assert!(d + .out + .contains(" name: \"inner\",\n")); + } +} diff --git a/src/editor/server.rs b/src/editor/server.rs index ca57b576..996df2c4 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -16,7 +16,7 @@ use technique::formatting::Identity; use technique::language::{Document, Technique}; use tracing::{debug, error, info, warn}; -use crate::formatting; +use crate::highlighting; use crate::parsing; use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number, Present}; @@ -353,7 +353,7 @@ impl TechniqueLanguageServer { } }; - let result = formatting::render(&Identity, &document, 78); + let result = highlighting::render(&Identity, &document, 78); // convert to LSP type for return to editor. let edit = TextEdit { diff --git a/src/formatting/mod.rs b/src/formatting/mod.rs index 0b6187fa..de6b8e55 100644 --- a/src/formatting/mod.rs +++ b/src/formatting/mod.rs @@ -1,6 +1,6 @@ pub mod formatter; -mod renderer; +mod syntax; // Re-export all public symbols pub use formatter::*; -pub use renderer::*; +pub use syntax::*; diff --git a/src/formatting/syntax.rs b/src/formatting/syntax.rs new file mode 100644 index 00000000..eb016ecf --- /dev/null +++ b/src/formatting/syntax.rs @@ -0,0 +1,47 @@ +//! Renderers for colourizing Technique language + +/// Types of content that can be rendered with different styles +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Syntax { + Neutral, // default + Indent, + Newline, + Header, + Declaration, + Description, + Forma, + StepItem, + CodeBlock, + Variable, + Section, + String, + Numeric, + Response, + Invocation, + Title, + Keyword, + Function, + Multiline, + Label, + Operator, + Quote, + Language, + Attribute, + Structure, +} + +/// Trait for different rendering backends (the no-op no-markup one, ANSI +/// escapes for terminal colouring, Typst markup for documents) +pub trait Render { + /// Apply styling to content with the specified syntax type + fn style(&self, content_type: Syntax, content: &str) -> String; +} + +/// Returns content unchanged, with no markup applied +pub struct Identity; + +impl Render for Identity { + fn style(&self, _syntax: Syntax, content: &str) -> String { + content.to_string() + } +} diff --git a/src/highlighting/mod.rs b/src/highlighting/mod.rs new file mode 100644 index 00000000..ec78eee8 --- /dev/null +++ b/src/highlighting/mod.rs @@ -0,0 +1,9 @@ +//! Rendering of Technique source code with syntax highlighting + +mod renderer; +mod terminal; +mod typst; + +pub use renderer::render; +pub use terminal::Terminal; +pub use typst::Typst; diff --git a/src/formatting/renderer.rs b/src/highlighting/renderer.rs similarity index 59% rename from src/formatting/renderer.rs rename to src/highlighting/renderer.rs index 0ec994ad..ef8ebfb8 100644 --- a/src/formatting/renderer.rs +++ b/src/highlighting/renderer.rs @@ -1,53 +1,8 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; use crate::language::*; -/// Types of content that can be rendered with different styles -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Syntax { - Neutral, // default - Indent, - Newline, - Header, - Declaration, - Description, - Forma, - StepItem, - CodeBlock, - Variable, - Section, - String, - Numeric, - Response, - Invocation, - Title, - Keyword, - Function, - Multiline, - Label, - Operator, - Quote, - Language, - Attribute, - Structure, -} - -/// Trait for different rendering backends (the no-op no-markup one, ANSI -/// escapes for terminal colouring, Typst markup for documents) -pub trait Render { - /// Apply styling to content with the specified syntax type - fn style(&self, content_type: Syntax, content: &str) -> String; -} - -/// Returns content unchanged, with no markup applied -pub struct Identity; - -impl Render for Identity { - fn style(&self, _syntax: Syntax, content: &str) -> String { - content.to_string() - } -} - /// We do the code formatting in two passes. First we convert from our /// Abstract Syntax Tree types into a Vec of "fragments" (Syntax tag, String /// pairs). Then second we apply the specified renderer to each pair to result diff --git a/src/rendering/terminal.rs b/src/highlighting/terminal.rs similarity index 99% rename from src/rendering/terminal.rs rename to src/highlighting/terminal.rs index bade4992..66fbeed3 100644 --- a/src/rendering/terminal.rs +++ b/src/highlighting/terminal.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; use owo_colors::OwoColorize; -use technique::formatting::*; /// Embellish fragments with ANSI escapes to create syntax highlighting in /// terminal output. diff --git a/src/rendering/typst.rs b/src/highlighting/typst.rs similarity index 99% rename from src/rendering/typst.rs rename to src/highlighting/typst.rs index 31dff068..6d92c555 100644 --- a/src/rendering/typst.rs +++ b/src/highlighting/typst.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; use std::borrow::Cow; -use technique::formatting::*; /// Add markup around syntactic elements for use when including /// Technique source in Typst documents. diff --git a/src/lib.rs b/src/lib.rs index 929e66c3..430aaa04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,7 @@ +pub mod domain; pub mod formatting; +pub mod highlighting; pub mod language; pub mod parsing; pub mod regex; +pub mod templating; diff --git a/src/main.rs b/src/main.rs index 434193d4..24702664 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,23 @@ use clap::value_parser; use clap::{Arg, ArgAction, Command}; use owo_colors::OwoColorize; -use rendering::{Terminal, Typst}; use std::io::IsTerminal; use std::path::Path; use tracing::debug; use tracing_subscriber::{self, EnvFilter}; -use technique::formatting::*; -use technique::formatting::{self}; +use technique::formatting::{self, Identity}; +use technique::highlighting::{self, Terminal}; use technique::parsing; +use technique::templating::{self, Checklist, Procedure, Source}; mod editor; +mod output; mod problem; -mod rendering; #[derive(Eq, Debug, PartialEq)] enum Output { Native, - Typst, Silent, } @@ -67,6 +66,7 @@ fn main() { Arg::new("output") .short('o') .long("output") + .value_name("type") .value_parser(["native", "none"]) .default_value("none") .action(ArgAction::Set) @@ -106,23 +106,40 @@ fn main() { .subcommand( Command::new("render") .about("Render the Technique document into a printable PDF.") - .long_about("Render the Technique document into a printable \ - PDF. By default this will highlight the source of the \ - input file for the purposes of reviewing the raw \ - procedure in code form.") + .long_about("Render the Technique document into a formatted \ + PDF using a template. This allows you to transform the code of \ + the procedure into the intended layout suitable to the \ + domain you're app.") .arg( Arg::new("output") .short('o') .long("output") - .value_parser(["typst", "none"]) - .default_value("none") + .value_name("type") + .value_parser(["pdf", "typst"]) + .default_value("pdf") + .action(ArgAction::Set) + .help("Whether to write PDF to a file on disk, or print the Typst markup that would be used to create that PDF (for debugging)."), + ) + .arg( + Arg::new("domain") + .short('d') + .long("domain") + .value_parser(["checklist", "procedure", "recipe", "source"]) .action(ArgAction::Set) - .help("Which kind of diagnostic output to print when rendering.") + .help("The kind of procedure this Technique document represents. By default the value specified in the input document's metadata will be used, falling back to source if unspecified."), + ) + .arg( + Arg::new("template") + .short('t') + .long("template") + .value_name("filename") + .action(ArgAction::Set) + .help("Path to a Typst template file for rendering."), ) .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to print."), + .help("The file containing the Technique you want to render."), ), ) .subcommand( @@ -251,9 +268,9 @@ fn main() { let result; if raw_output || std::io::stdout().is_terminal() { - result = formatting::render(&Terminal, &technique, wrap_width); + result = highlighting::render(&Terminal, &technique, wrap_width); } else { - result = formatting::render(&Identity, &technique, wrap_width); + result = highlighting::render(&Identity, &technique, wrap_width); } print!("{}", result); @@ -262,13 +279,8 @@ fn main() { let output = submatches .get_one::("output") .unwrap(); - let output = match output.as_str() { - "typst" => Output::Typst, - "none" => Output::Silent, - _ => panic!("Unrecognized --output value"), - }; - debug!(?output); + debug!(output); let filename = submatches .get_one::("filename") @@ -309,20 +321,70 @@ fn main() { } }; - let result = formatting::render(&Typst, &technique, 70); + // If present the value of the --domain option will override the + // document's metadata domain line. If neither is specified then + // the fallback default is "source". + + let domain = submatches.get_one::("domain"); + let domain: &str = match domain { + Some(value) => value, + None => technique + .header + .as_ref() + .and_then(|m| m.domain) + .unwrap_or("source"), + }; + + debug!(domain); - match output { - Output::Typst => { - print!("{}", result); + // Select domain + let template: &dyn templating::Template = match domain { + "source" => &Source, + "checklist" => &Checklist, + "procedure" => &Procedure, + other => { + eprintln!( + "{}: unrecognized domain \"{}\"", + "error".bright_red(), + other + ); + std::process::exit(1); + } + }; + + let data = template.data(&technique); + + // If --template is given, use the user-supplied file (expected to + // be a .typ file containing Typst template code) ; otherwise + // inline the built-in template. + let preamble: String = match submatches.get_one::("template") { + Some(path) => { + if !Path::new(path).exists() { + eprintln!( + "{}: template file not found: {}", + "error".bright_red(), + path + ); + std::process::exit(1); + } + format!("#import \"{}\": render", path) } - _ => { - // ignore; the default is to not output any intermediate - // representations and instead proceed to invoke the - // typesetter to generate the desired PDF. + None => template + .typst() + .to_string(), + }; + + match output.as_str() { + "typst" => { + println!("{}", preamble); + print!("{}", data); + println!("\n#render(technique)"); } + "pdf" => { + output::via_typst(filename, &preamble, &data); + } + _ => panic!("Unrecognized --output value"), } - - rendering::via_typst(&filename, &result); } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 00000000..55de3c07 --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,73 @@ +//! Output generation for the Technique CLI application + +use owo_colors::OwoColorize; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use tracing::{debug, info}; + +/// Compile a Typst document piped via stdin to a PDF file. +/// +/// The template content, data literal, and render call are written +/// sequentially to the process's stdin. +pub fn via_typst(filename: &Path, template: &str, data: &str) { + info!("Printing file: {}", filename.display()); + + if filename.to_str() == Some("-") { + eprintln!( + "{}: Unable to render to PDF from standard input.", + "error".bright_red() + ); + std::process::exit(1); + } + if !filename.exists() { + panic!( + "Supplied procedure file does not exist: {}", + filename.display() + ); + } + + let target = filename.with_extension("pdf"); + + let mut child = Command::new("typst") + .arg("compile") + .arg("-") + .arg(&target) + .stdin(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("{}: failed to start typst: {}", "error".bright_red(), e); + std::process::exit(1); + }); + + let mut stdin = child + .stdin + .take() + .unwrap(); + + stdin + .write_all(template.as_bytes()) + .expect("Failed attempting to write"); + stdin + .write_all(b"\n") + .expect("Failed attempting to write"); + stdin + .write_all(data.as_bytes()) + .expect("Failed attempting to write"); + stdin + .write_all(b"\n#render(technique)\n") + .expect("Failed attempting to write"); + + drop(stdin); + + let status = child + .wait() + .expect("Failed to wait for Typst process"); + + if !status.success() { + eprintln!("{}: typst compile failed", "error".bright_red()); + std::process::exit(1); + } + + debug!("Wrote {}", target.display()); +} diff --git a/src/problem/present.rs b/src/problem/present.rs index 87c1ba45..4535dca3 100644 --- a/src/problem/present.rs +++ b/src/problem/present.rs @@ -1,7 +1,4 @@ -use technique::{ - formatting::{formatter, Render}, - language::*, -}; +use technique::{formatting::*, language::*}; /// Trait for AST types that can present themselves via a renderer pub trait Present { diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs deleted file mode 100644 index f2d92501..00000000 --- a/src/rendering/mod.rs +++ /dev/null @@ -1,91 +0,0 @@ -use owo_colors::OwoColorize; -use serde::Serialize; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; -use tinytemplate::TinyTemplate; -use tracing::{debug, info}; - -mod terminal; -mod typst; - -pub use terminal::Terminal; -pub use typst::Typst; - -static TEMPLATE: &'static str = r#" -#show text: set text(font: "Inconsolata") -#show raw: set block(breakable: true) -"#; - -#[derive(Serialize)] -struct Context { - filename: String, -} - -pub(crate) fn via_typst(filename: &Path, markup: &str) { - info!("Printing file: {}", filename.display()); - - // Verify that the file actually exists - if filename.to_str() == Some("-") { - eprintln!( - "{}: Unable to render to PDF from standard input.", - "error".bright_red() - ); - std::process::exit(1); - } - if !filename.exists() { - panic!( - "Supplied procedure file does not exist: {}", - filename.display() - ); - } - - let target = filename.with_extension("pdf"); - - let mut child = Command::new("typst") - .arg("compile") - .arg("-") - .arg(target) - .stdin(Stdio::piped()) - .spawn() - .expect("Failed to start external Typst process"); - - // Write the file contents to the process's stdin - let mut stdin = child - .stdin - .take() - .unwrap(); - - let mut tt = TinyTemplate::new(); - tt.add_template("hello", TEMPLATE) - .unwrap(); - - let context = Context { - filename: filename - .to_string_lossy() - .to_string(), - }; - - let rendered = tt - .render("hello", &context) - .unwrap(); - stdin - .write(rendered.as_bytes()) - .expect("Write header to child process"); - - // write markup to stdin handle - - stdin - .write(markup.as_bytes()) - .expect("Write document to child process"); - - drop(stdin); - - // Wait for the process to complete - let output = child - .wait_with_output() - .expect("Failed to read stdout"); - - // Log the output - debug!("Process output: {:?}", output); -} diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs new file mode 100644 index 00000000..9f9dc782 --- /dev/null +++ b/src/templating/checklist.rs @@ -0,0 +1,29 @@ +//! Checklist domain — flattens procedures into printable checklists. +//! +//! The checklist domain model is relatively flat: sections with headings, +//! steps with checkboxes, response options, and limited nesting. Role +//! assignments are inherited downward (an `@surgeon` scope annotates its +//! child steps) rather than forming structural containers. + +use crate::domain::checklist::adapter::ChecklistAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; + +pub static TEMPLATE: &str = include_str!("checklist.typ"); + +pub struct Checklist; + +impl Template for Checklist { + fn data(&self, document: &language::Document) -> String { + let model = ChecklistAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() + } + + fn typst(&self) -> &str { + TEMPLATE + } +} diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ new file mode 100644 index 00000000..e41af98b --- /dev/null +++ b/src/templating/checklist.typ @@ -0,0 +1,55 @@ +// Built-in checklist template for Technique. +// +// Expects a `technique` dictionary with shape: +// (sections: ((ordinal, heading, steps: ((ordinal, title, body, role, +// responses, children), ...)), ...)) + +#let check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em) +#let small-check = box(stroke: 0.5pt, width: 0.6em, height: 0.6em) + +#let render-responses(responses) = { + for (i, r) in responses.enumerate() { + if i > 0 [ | ] + small-check + if r.condition != none [ _#r.value #r.condition _] + else [ _#r.value _] + } + if responses.len() > 0 { parbreak() } +} + +#let render-step(step) = { + if step.role != none { + text(weight: "bold")[#step.role] + parbreak() + } + check + if step.ordinal != none [ *#step.ordinal.* ] + if step.title != none [ #step.title] + parbreak() + for para in step.body { + [#para] + parbreak() + } + render-responses(step.responses) + for child in step.children { + render-step(child) + } +} + +#let render(technique) = [ + #set page(margin: 1.5cm) + #set text(size: 10pt) + + #for section in technique.sections [ + #if section.ordinal != none and section.heading != none [ + == #section.ordinal. #section.heading + ] else if section.ordinal != none [ + == #section.ordinal. + ] else if section.heading != none [ + == #section.heading + ] + #for step in section.steps { + render-step(step) + } + ] +] diff --git a/src/templating/mod.rs b/src/templating/mod.rs new file mode 100644 index 00000000..c76d8ff7 --- /dev/null +++ b/src/templating/mod.rs @@ -0,0 +1,21 @@ +//! Render Technique documents into formatted output. +//! +//! The **Template** trait provides `data()` for Typst data literals. Each +//! domain template composes an adapter from `crate::domain` internally. + +mod checklist; +mod procedure; +mod source; +mod template; + +pub use checklist::Checklist; +pub use procedure::Procedure; +pub use source::Source; +pub use template::Template; + +use crate::language; + +/// Serialize a Technique document as a Typst data literal. +pub fn data(template: &impl Template, document: &language::Document) -> String { + template.data(document) +} diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs new file mode 100644 index 00000000..070464db --- /dev/null +++ b/src/templating/procedure.rs @@ -0,0 +1,29 @@ +//! Procedure domain — preserves the full hierarchy described by the source +//! Technique document. +//! +//! Unlike the checklist (which flattens structure), the procedure domain +//! model preserves hierarchy. Sections with ordinals, role groups as +//! distinct items rather than step annotations, and nested children. + +use crate::domain::procedure::adapter::ProcedureAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; + +pub static TEMPLATE: &str = include_str!("procedure.typ"); + +pub struct Procedure; + +impl Template for Procedure { + fn data(&self, document: &language::Document) -> String { + let model = ProcedureAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() + } + + fn typst(&self) -> &str { + TEMPLATE + } +} diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ new file mode 100644 index 00000000..acafc20b --- /dev/null +++ b/src/templating/procedure.typ @@ -0,0 +1,149 @@ +// Built-in procedure template for Technique. +// +// Expects a `technique` dictionary with shape: +// (title, description, body: ((type: "section"|"procedure"|"sequential" +// |"parallel"|"attribute", ...children), ...)) + +#let render-responses(responses) = { + for r in responses { + if r.condition != none [- _#r.value #r.condition _] + else [- _#r.value _] + } + if responses.len() > 0 { parbreak() } +} + +#let render-invocations(invocations) = { + if invocations.len() > 0 { + text(size: 7pt)[`#invocations.join(", ")`] + linebreak() + } +} + +#let ordinal-start(children) = { + let first = children.at(0, default: none) + if first != none and first.type == "sequential" { + let o = first.at("ordinal", default: "a") + let c = o.codepoints().at(0, default: "a") + if "abcdefghijklmnopqrstuvwxyz".contains(c) { + "abcdefghijklmnopqrstuvwxyz".position(c) + 1 + } else { 1 } + } else { 1 } +} + +#let render-child(node) = { + if node.type == "sequential" or node.type == "parallel" { + if node.at("title", default: none) != none [+ #node.title] + } +} + +#let render-role(node) = { + [- *#node.name*] + if node.children.len() > 0 { + let start = ordinal-start(node.children) + pad(left: 20pt)[ + #set par(leading: 0.5em) + #set enum(numbering: "a.", start: start, spacing: 0.8em) + #for child in node.children { + if child.type == "attribute" { + render-role(child) + } else { + render-child(child) + } + } + ] + } +} + +#let render-node(node) = { + if node.type == "section" { + if node.at("heading", default: none) != none { + text(size: 14pt)[*#node.ordinal.* #h(8pt) *#node.heading*] + } else { + text(size: 14pt)[*#node.ordinal.*] + } + parbreak() + for child in node.children { render-node(child) } + + } else if node.type == "procedure" { + text(size: 7pt)[`#node.name`] + linebreak() + if node.at("title", default: none) != none { + text(size: 11pt)[*#node.title*] + parbreak() + } + for para in node.description { + [#para] + parbreak() + } + if node.children.len() > 0 { + pad(left: 8pt)[#for child in node.children { render-node(child) }] + } + + } else if node.type == "sequential" or node.type == "parallel" { + render-invocations(node.invocations) + let ordinal = if node.type == "sequential" { node.ordinal } else { none } + if ordinal != none and node.at("title", default: none) != none [ + *#ordinal.* #h(4pt) *#node.title* + ] else if ordinal != none [ + *#ordinal.* + ] else if node.at("title", default: none) != none [ + *#node.title* + ] + parbreak() + for para in node.body { + [#para] + parbreak() + } + render-responses(node.responses) + if node.children.len() > 0 { + pad(left: 16pt)[#for child in node.children { render-node(child) }] + } + + } else if node.type == "attribute" { + render-role(node) + } +} + +#let has-sections(body) = { + body.any(n => n.type == "section") +} + +#let render-outline(body) = { + grid(columns: (auto, 1fr), column-gutter: 6pt, row-gutter: 0.3em, + ..body.filter(n => n.type == "section").map(n => { + let heading = n.at("heading", default: none) + ([#n.ordinal.], [#if heading != none { heading }]) + }).flatten() + ) +} + +#let render(technique) = [ + #set page(margin: 1.5cm) + #set par(justify: false) + #show text: set text(size: 9pt, font: "TeX Gyre Heros") + + #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ + #if technique.at("title", default: none) != none [ + #text(size: 15pt)[*#technique.title*] + + ] + #if technique.description.len() > 0 or has-sections(technique.body) [ + _Overview_ + + #for para in technique.description [ + #para + ] + #if has-sections(technique.body) { + render-outline(technique.body) + } + ] + #block(width: 100%, fill: rgb("#006699"), inset: 5pt)[#text(fill: white)[*Procedure*]] + + #for (i, node) in technique.body.enumerate() { + render-node(node) + if i + 1 < technique.body.len() and node.type == "section" { + line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb("#003366"), dash: ("dot", 2pt, 4pt, 2pt))) + } + } + ] +] diff --git a/src/templating/source.rs b/src/templating/source.rs new file mode 100644 index 00000000..e7b5b68e --- /dev/null +++ b/src/templating/source.rs @@ -0,0 +1,28 @@ +//! Source domain — renders Technique source code with syntax highlighting. +//! +//! The source domain model is a flat sequence of syntax-tagged fragments +//! produced by the code formatter. The Typst template maps each syntax +//! tag to a colour and weight. + +use crate::domain::source::adapter::SourceAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; + +pub static TEMPLATE: &str = include_str!("source.typ"); + +pub struct Source; + +impl Template for Source { + fn data(&self, document: &language::Document) -> String { + let model = SourceAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() + } + + fn typst(&self) -> &str { + TEMPLATE + } +} diff --git a/src/templating/source.typ b/src/templating/source.typ new file mode 100644 index 00000000..1f8e9d68 --- /dev/null +++ b/src/templating/source.typ @@ -0,0 +1,49 @@ +// Built-in source template for Technique. +// +// Expects a `technique` dictionary with shape: +// (fragments: ((syntax, content), ...)) +// +// Each fragment carries a syntax tag and a content string. +// The syntax tag determines the colour and weight applied. + +#let palette = ( + Neutral: (c) => raw(c), + Indent: (c) => raw(c), + Newline: (_) => linebreak(), + Header: (c) => text(fill: rgb(0x75, 0x50, 0x7b), raw(c)), + Declaration: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), + Description: (c) => raw(c), + Forma: (c) => text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(c)), + StepItem: (c) => text(weight: "bold", raw(c)), + CodeBlock: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), + Variable: (c) => text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(c)), + Section: (c) => raw(c), + String: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), + Numeric: (c) => text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(c)), + Response: (c) => text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(c)), + Invocation: (c) => text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(c)), + Title: (c) => text(weight: "bold", raw(c)), + Keyword: (c) => text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(c)), + Function: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), + Multiline: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), + Label: (c) => text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(c)), + Operator: (c) => text(fill: red, raw(c)), + Quote: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), + Language: (c) => text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(c)), + Attribute: (c) => text(weight: "bold", raw(c)), + Structure: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), +) + +#let render-fragment(f) = { + let styler = palette.at(f.syntax, default: (c) => raw(c)) + styler(f.content) +} + +#let render(technique) = [ + #show text: set text(font: "Inconsolata") + #show raw: set block(breakable: true) + + #for f in technique.fragments { + render-fragment(f) + } +] diff --git a/src/templating/template.rs b/src/templating/template.rs new file mode 100644 index 00000000..f82c7691 --- /dev/null +++ b/src/templating/template.rs @@ -0,0 +1,17 @@ +//! Traits for the templating pipeline. + +use crate::language; + +/// A template transforms a Technique document into output. Internally this +/// is split into two phases: an adapter, which takes the AST from the parser +/// and converts it to domain types, and rendering which converts that domain +/// into output. Not all templates make this split; `Source` is a special case +/// that delegates directly to the code formatting logic. + +pub trait Template { + /// Serialize the document as a Typst data literal. + fn data(&self, document: &language::Document) -> String; + + /// Return the Typst source for this template. + fn typst(&self) -> &str; +} diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index ad7a48b8..11a0370c 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::Path; -use technique::formatting::*; +use technique::formatting::Identity; +use technique::highlighting::render; use technique::parsing; /// Golden test for the format command diff --git a/tests/integration.rs b/tests/integration.rs index c42e530c..797e39d2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,2 +1,3 @@ mod formatting; mod parsing; +mod templating; diff --git a/tests/templating/mod.rs b/tests/templating/mod.rs new file mode 100644 index 00000000..f5610f49 --- /dev/null +++ b/tests/templating/mod.rs @@ -0,0 +1 @@ +mod rendering; diff --git a/tests/templating/rendering/checklist.rs b/tests/templating/rendering/checklist.rs new file mode 100644 index 00000000..324997c4 --- /dev/null +++ b/tests/templating/rendering/checklist.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use technique::templating::Checklist; + +#[test] +fn ensure_render() { + super::check_directory(Path::new("examples/minimal/"), &Checklist); + super::check_directory(Path::new("examples/prototype/"), &Checklist); +} diff --git a/tests/templating/rendering/mod.rs b/tests/templating/rendering/mod.rs new file mode 100644 index 00000000..25270db1 --- /dev/null +++ b/tests/templating/rendering/mod.rs @@ -0,0 +1,57 @@ +use std::fs; +use std::path::Path; + +use technique::parsing; +use technique::templating; + +fn check_directory(dir: &Path, template: &impl templating::Template) { + assert!(dir.exists(), "directory missing: {:?}", dir); + + let entries = fs::read_dir(dir).expect("Failed to read directory"); + + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); + } + } + + assert!(!files.is_empty(), "No .tq files found in {:?}", dir); + + let mut failures = Vec::new(); + + for file in &files { + let source = + parsing::load(file).unwrap_or_else(|e| panic!("Failed to load {:?}: {:?}", file, e)); + + let doc = parsing::parse(file, &source) + .unwrap_or_else(|e| panic!("Failed to parse {:?}: {:?}", file, e)); + + let output = templating::data(template, &doc); + + if output.is_empty() { + failures.push(file.clone()); + } + } + + if !failures.is_empty() { + panic!( + "Template produced empty output for {} files: {:?}", + failures.len(), + failures + ); + } +} + +#[path = "procedure.rs"] +mod procedure; + +#[path = "checklist.rs"] +mod checklist; diff --git a/tests/templating/rendering/procedure.rs b/tests/templating/rendering/procedure.rs new file mode 100644 index 00000000..ffb11eec --- /dev/null +++ b/tests/templating/rendering/procedure.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use technique::templating::Procedure; + +#[test] +fn ensure_render() { + super::check_directory(Path::new("examples/minimal/"), &Procedure); + super::check_directory(Path::new("examples/prototype/"), &Procedure); +}