From a097c99252b266aad81f48eb35d9404a85d15071 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Sep 2025 18:13:52 +1000 Subject: [PATCH 01/15] Add tokio dependency --- Cargo.lock | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6bf7d9..45bd5f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -61,12 +76,39 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + [[package]] name = "bitflags" version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "cfg-if" version = "1.0.3" @@ -117,6 +159,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -147,6 +206,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.28" @@ -168,6 +237,26 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -177,6 +266,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -195,6 +293,29 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -219,6 +340,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.11.2" @@ -248,6 +378,12 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustix" version = "1.0.8" @@ -267,6 +403,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.219" @@ -308,12 +450,37 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -333,13 +500,14 @@ dependencies = [ [[package]] name = "technique" -version = "0.4.2" +version = "0.4.3" dependencies = [ "clap", "owo-colors", "regex", "serde", "tinytemplate", + "tokio", "tracing", "tracing-subscriber", ] @@ -373,6 +541,37 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing" version = "0.1.41" @@ -452,6 +651,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.1.3" @@ -467,6 +672,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 41df1e4..07aeca9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.4.2" +version = "0.4.3" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] @@ -13,6 +13,7 @@ owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } tinytemplate = "1.2.1" +tokio = { version = "1.47.1", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = [ "env-filter" ] } From ed875388e1d6d54aa2bafda97688776af6ab1791 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Sep 2025 18:14:09 +1000 Subject: [PATCH 02/15] Add 'language' subcommand to main program --- src/main.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main.rs b/src/main.rs index 928ef46..3074805 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,6 +125,14 @@ fn main() { .help("The file containing the code for the Technique you want to print."), ), ) + .subcommand( + Command::new("language") + .about("Language Server Protocol integration for editors and IDEs.") + .long_about("Run a Language Server Protocol (LSP) service \ + for Technique documents. This accepts commands and code \ + input via stdin and returns compilation errors and other \ + diagnostics.") + ) .get_matches(); match matches.subcommand() { @@ -315,6 +323,17 @@ fn main() { rendering::via_typst(&filename, &result); } + Some(("language", _)) => { + debug!("Starting Language Server"); + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + debug!("Started"); + }); + } Some(_) => { println!("No valid subcommand was used") } From 8b40ddac01066fc0fed0221e512a5c584d0489d2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Sep 2025 18:41:35 +1000 Subject: [PATCH 03/15] Add tower-lsp dependency --- Cargo.lock | 537 +++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/editor/mod.rs | 5 + src/main.rs | 3 +- 4 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 src/editor/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 45bd5f9..7efd375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,28 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -97,6 +119,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.4" @@ -149,6 +177,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.13" @@ -159,19 +211,224 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -206,6 +463,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.13" @@ -222,6 +485,19 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "matchers" version = "0.2.0" @@ -316,12 +592,53 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -346,7 +663,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.4", ] [[package]] @@ -390,7 +707,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", @@ -441,6 +758,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -481,6 +809,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strsim" version = "0.11.1" @@ -498,6 +832,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "technique" version = "0.4.3" @@ -508,6 +853,7 @@ dependencies = [ "serde", "tinytemplate", "tokio", + "tower-lsp", "tracing", "tracing-subscriber", ] @@ -531,6 +877,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -572,6 +928,79 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.41" @@ -639,6 +1068,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -818,3 +1265,87 @@ name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 07aeca9..17b05e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } tinytemplate = "1.2.1" tokio = { version = "1.47.1", features = ["full"] } +tower-lsp = "0.20.0" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = [ "env-filter" ] } diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..b9d8e15 --- /dev/null +++ b/src/editor/mod.rs @@ -0,0 +1,5 @@ +use tracing::debug; + +pub(crate) async fn run_language_server() { + debug!("Started"); +} diff --git a/src/main.rs b/src/main.rs index 3074805..dc949a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use technique::formatting::{self}; use technique::parsing; +mod editor; mod problem; mod rendering; @@ -331,7 +332,7 @@ fn main() { .build() .unwrap() .block_on(async { - debug!("Started"); + editor::run_language_server().await; }); } Some(_) => { From f8ab01f09f3ef8e7f2a5dafa95bf6fc1e3b28c0c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 15 Sep 2025 21:43:06 +1000 Subject: [PATCH 04/15] Initial implementation of LSP diagnostics --- src/editor/mod.rs | 19 +- src/editor/server.rs | 422 ++++++++++++++++++++++++++++++++++++++++++ src/problem/format.rs | 4 +- 3 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 src/editor/server.rs diff --git a/src/editor/mod.rs b/src/editor/mod.rs index b9d8e15..07e4bc1 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1,5 +1,20 @@ -use tracing::debug; +use tower_lsp::{LspService, Server}; +use tracing::{debug, info}; + +mod server; pub(crate) async fn run_language_server() { - debug!("Started"); + debug!("Starting Technique Language Server"); + + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = + LspService::build(|client| server::TechniqueLanguageServer::new(client)).finish(); + + info!("Technique Language Server starting on stdio"); + + Server::new(stdin, stdout, socket) + .serve(service) + .await; } diff --git a/src/editor/server.rs b/src/editor/server.rs new file mode 100644 index 0000000..50adf4d --- /dev/null +++ b/src/editor/server.rs @@ -0,0 +1,422 @@ +use std::collections::HashMap; +use std::path::Path; + +use tokio::sync::Mutex; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::{ + Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializeResult, + InitializedParams, MessageType, Position, Range, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, Url, +}; +use tower_lsp::{Client, LanguageServer}; +use tracing::{debug, info}; + +use crate::parsing::{parse_with_recovery, ParsingError}; +use crate::problem::{calculate_column_number, calculate_line_number}; + +pub struct TechniqueLanguageServer { + client: Client, + /// Map from URI to document content + documents: Mutex>, +} + +impl TechniqueLanguageServer { + pub fn new(client: Client) -> Self { + Self { + client, + documents: Mutex::new(HashMap::new()), + } + } + + fn convert_parsing_errors( + &self, + _uri: &Url, + content: &str, + errors: Vec, + ) -> Vec { + let mut diagnostics = Vec::new(); + + for error in errors { + let offset = error.offset(); + let position = offset_to_position(content, offset); + + let (message, severity) = match &error { + ParsingError::IllegalParserState(_) => ( + "Internal parser error".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::Unimplemented(_) => ( + "Unimplemented feature".to_string(), + DiagnosticSeverity::WARNING, + ), + ParsingError::Unrecognized(_) => { + ("Unrecognized syntax".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::UnexpectedEndOfInput(_) => ( + "Unexpected end of input".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::Expected(_, expected) => { + (format!("Expected {}", expected), DiagnosticSeverity::ERROR) + } + ParsingError::ExpectedMatchingChar(_, subject, start, end) => ( + format!("Expected matching '{}' for '{}' in {}", end, start, subject), + DiagnosticSeverity::ERROR, + ), + ParsingError::MissingParenthesis(_) => ( + "Require parenthesis around multiple parameters in binding".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidCharacter(_, ch) => ( + format!("Invalid character '{}'", ch), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidHeader(_) => { + ("Invalid header line".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidIdentifier(_, id) => ( + format!("Invalid identifier '{}'", id), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidForma(_) => ( + "Invalid forma in signature".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidGenus(_) => ( + "Invalid genus in signature".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidSignature(_) => ( + "Invalid signature in procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidParameters(_) => ( + "Malformed parameters in procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidDeclaration(_) => ( + "Invalid procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidSection(_) => ( + "Invalid section heading".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidInvocation(_) => ( + "Invalid procedure Invocation".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidFunction(_) => ( + "Invalid function call".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidCodeBlock(_) => { + ("Invalid code block".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidStep(_) => { + ("Invalid step".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidSubstep(_) => { + ("Invalid substep".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidResponse(_) => { + ("Invalid response".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidMultiline(_) => ( + "Invalid multiline content".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidForeach(_) => ( + "Invalid foreach expression".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidIntegral(_) => ( + "Invalid integral number".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantity(_) => { + ("Invalid quantity".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidQuantityDecimal(_) => ( + "Invalid quantity decimal".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantityUncertainty(_) => ( + "Invalid quantity uncertainty".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantityMagnitude(_) => ( + "Invalid quantity magnitude".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantitySymbol(_) => ( + "Invalid quantity symbol".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::UnclosedInterpolation(_) => ( + "Unclosed interpolation".to_string(), + DiagnosticSeverity::ERROR, + ), + }; + + let range = Range { + start: position, + end: position, // For now, just point to the error position + }; + + let diagnostic = Diagnostic { + range, + severity: Some(severity), + code: None, + code_description: None, + source: Some("technique".to_string()), + message, + related_information: None, + tags: None, + data: None, + }; + + diagnostics.push(diagnostic); + } + + diagnostics + } + + /// Parse document and convert errors to diagnostics + async fn parse_and_publish_diagnostics(&self, uri: Url, content: String) { + let path = uri + .to_file_path() + .unwrap_or_else(|_| Path::new("-").to_path_buf()); + + match parse_with_recovery(&path, &content) { + Ok(_document) => { + self.client + .publish_diagnostics(uri, vec![], None) + .await; + } + Err(errors) => { + let diagnostics = self.convert_parsing_errors(&uri, &content, errors); + self.client + .publish_diagnostics(uri, diagnostics, None) + .await; + } + } + } +} + +/// Convert byte offset to LSP Position +fn offset_to_position(text: &str, offset: usize) -> Position { + let line = calculate_line_number(text, offset) as u32; + let character = calculate_column_number(text, offset) as u32; + Position { line, character } +} + +#[tower_lsp::async_trait] +impl LanguageServer for TechniqueLanguageServer { + async fn initialize(&self, _params: InitializeParams) -> Result { + info!("Technique Language Server initializing"); + + Ok(InitializeResult { + server_info: None, + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + ..Default::default() + }, + }) + } + + async fn initialized(&self, _params: InitializedParams) { + info!("Technique Language Server initialized"); + + let _ = self + .client + .log_message(MessageType::INFO, "Technique Language Server initialized") + .await; + } + + async fn shutdown(&self) -> Result<()> { + info!("Technique Language Server shutting down"); + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params + .text_document + .uri; + let content = params + .text_document + .text; + + debug!("Document opened: {}", uri); + + let mut documents = self + .documents + .lock() + .await; + documents.insert(uri.clone(), content.clone()); + + self.parse_and_publish_diagnostics(uri, content) + .await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params + .text_document + .uri; + + if let Some(change) = params + .content_changes + .into_iter() + .next() + { + let content = change.text; + + debug!("Document changed: {}", uri); + + let mut documents = self + .documents + .lock() + .await; + documents.insert(uri.clone(), content.clone()); + + self.parse_and_publish_diagnostics(uri, content) + .await; + } + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + let uri = params + .text_document + .uri; + debug!("Document saved: {}", uri); + + if let Some(content) = { + let documents = self + .documents + .lock() + .await; + documents + .get(&uri) + .cloned() + } { + self.parse_and_publish_diagnostics(uri, content) + .await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params + .text_document + .uri; + debug!("Document closed: {}", uri); + + let mut documents = self + .documents + .lock() + .await; + documents.remove(&uri); + + self.client + .publish_diagnostics(uri, vec![], None) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_offset_to_position() { + let text = "line 1\nline 2\nline 3"; + + // Test beginning + assert_eq!( + offset_to_position(text, 0), + Position { + line: 0, + character: 0 + } + ); + + // Test end of first line + assert_eq!( + offset_to_position(text, 6), + Position { + line: 0, + character: 6 + } + ); + + // Test beginning of second line + assert_eq!( + offset_to_position(text, 7), + Position { + line: 1, + character: 0 + } + ); + + // Test middle of second line + assert_eq!( + offset_to_position(text, 10), + Position { + line: 1, + character: 3 + } + ); + } + + #[test] + fn test_parsing_error_types() { + // Test that all error types can be converted to messages without panicking + let test_errors = vec![ + ParsingError::IllegalParserState(0), + ParsingError::Unimplemented(0), + ParsingError::Unrecognized(0), + ParsingError::UnexpectedEndOfInput(0), + ParsingError::Expected(0, "test"), + ParsingError::ExpectedMatchingChar(0, "test", '(', ')'), + ParsingError::MissingParenthesis(0), + ParsingError::InvalidCharacter(0, 'x'), + ParsingError::InvalidHeader(0), + ParsingError::InvalidIdentifier(0, "test".to_string()), + ParsingError::InvalidDeclaration(0), + ]; + + // This shouldn't panic - just test that all enum variants are handled + for error in test_errors { + let offset = error.offset(); + assert_eq!(offset, 0); // All test errors are at offset 0 + + // Test message generation (this was formerly in convert_parsing_errors) + match &error { + ParsingError::IllegalParserState(_) => { + assert_eq!("Internal parser error", "Internal parser error") + } + ParsingError::Unimplemented(_) => { + assert_eq!("Unimplemented feature", "Unimplemented feature") + } + ParsingError::Unrecognized(_) => { + assert_eq!("Unrecognized syntax", "Unrecognized syntax") + } + ParsingError::UnexpectedEndOfInput(_) => { + assert_eq!("Unexpected end of input", "Unexpected end of input") + } + ParsingError::Expected(_, expected) => assert_eq!(*expected, "test"), + ParsingError::ExpectedMatchingChar(_, subject, start, end) => { + assert_eq!(*subject, "test"); + assert_eq!(*start, '('); + assert_eq!(*end, ')'); + } + ParsingError::InvalidDeclaration(_) => { + assert_eq!("Invalid declaration", "Invalid declaration") + } + _ => {} // Other variants tested implicitly + } + } + } +} diff --git a/src/problem/format.rs b/src/problem/format.rs index 92ea071..a0a5062 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -95,14 +95,14 @@ pub fn concise_loading_error<'i>(error: &LoadingError<'i>) -> String { } // Helper functions for line/column calculation -fn calculate_line_number(content: &str, offset: usize) -> usize { +pub fn calculate_line_number(content: &str, offset: usize) -> usize { content[..offset] .bytes() .filter(|&b| b == b'\n') .count() } -fn calculate_column_number(content: &str, offset: usize) -> usize { +pub fn calculate_column_number(content: &str, offset: usize) -> usize { let before = &content[..offset]; match before.rfind('\n') { Some(start) => content[start + 1..offset] From cde46ac49d0a5f1cb796e7f9de08427c71cc4c6e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 11:06:14 +1000 Subject: [PATCH 05/15] Implement handler for code formatting --- src/editor/server.rs | 78 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/src/editor/server.rs b/src/editor/server.rs index 50adf4d..5d06309 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -1,18 +1,22 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::path::Path; +use technique::formatting::Identity; use tokio::sync::Mutex; -use tower_lsp::jsonrpc::Result; +use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; use tower_lsp::lsp_types::{ Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, - DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializeResult, - InitializedParams, MessageType, Position, Range, ServerCapabilities, - TextDocumentSyncCapability, TextDocumentSyncKind, Url, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, + InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf, Position, Range, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, }; use tower_lsp::{Client, LanguageServer}; use tracing::{debug, info}; -use crate::parsing::{parse_with_recovery, ParsingError}; +use crate::formatting; +use crate::parsing; +use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number}; pub struct TechniqueLanguageServer { @@ -189,7 +193,7 @@ impl TechniqueLanguageServer { .to_file_path() .unwrap_or_else(|_| Path::new("-").to_path_buf()); - match parse_with_recovery(&path, &content) { + match parsing::parse_with_recovery(&path, &content) { Ok(_document) => { self.client .publish_diagnostics(uri, vec![], None) @@ -223,6 +227,7 @@ impl LanguageServer for TechniqueLanguageServer { text_document_sync: Some(TextDocumentSyncCapability::Kind( TextDocumentSyncKind::FULL, )), + document_formatting_provider: Some(OneOf::Left(true)), ..Default::default() }, }) @@ -323,6 +328,67 @@ impl LanguageServer for TechniqueLanguageServer { .publish_diagnostics(uri, vec![], None) .await; } + + async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { + let uri = params + .text_document + .uri; + + debug!("Format request: {}", uri); + + // Get content from our documents map + let documents = self + .documents + .lock() + .await; + let content = match documents.get(&uri) { + Some(c) => c, + None => { + return Err(Error { + code: ErrorCode::InvalidRequest, + message: Cow::Borrowed("Document not open"), + data: None, + }) + } + }; + + let path = match uri.to_file_path() { + Ok(buf) => buf, + Err(_) => Path::new("-").to_path_buf(), + }; + + let document = match parsing::parse_with_recovery(&path, content) { + Ok(document) => document, + Err(_) => { + return Err(Error { + code: ErrorCode::ParseError, + message: Cow::Borrowed( + "Document must be free of parse errors before formatting", + ), + data: None, + }) + } + }; + + let result = formatting::render(&Identity, &document, 78); + + // convert to tower-lsp type for return to editor. + let edit = TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: u32::MAX, + character: 0, + }, + }, + new_text: result, + }; + + Ok(Some(vec![edit])) + } } #[cfg(test)] From 7014f44cd48208274bb14d4c306047e9a4fa3c09 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 11:12:31 +1000 Subject: [PATCH 06/15] Add lsp-server dependency --- Cargo.lock | 463 ++++------------------------------------------------- Cargo.toml | 6 +- 2 files changed, 32 insertions(+), 437 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7efd375..ae7b60d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.3" @@ -76,49 +61,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -131,12 +73,6 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - [[package]] name = "cfg-if" version = "1.0.3" @@ -178,18 +114,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "dashmap" -version = "5.5.3" +name = "crossbeam-channel" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "cfg-if", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", + "crossbeam-utils", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "displaydoc" version = "0.2.5" @@ -220,101 +158,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "icu_collections" version = "2.0.0" @@ -422,17 +265,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -469,27 +301,30 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lsp-server" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6ada348dbc2703cbe7637b2dda05cff84d3da2819c24abcb305dd613e0ba2e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "lsp-types" -version = "0.94.1" +version = "0.95.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" dependencies = [ "bitflags 1.3.2", "serde", @@ -513,26 +348,6 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.59.0", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -542,15 +357,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -569,67 +375,18 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "potential_utf" version = "0.1.3" @@ -657,15 +414,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags 2.9.4", -] - [[package]] name = "regex" version = "1.11.2" @@ -695,12 +443,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustix" version = "1.0.8" @@ -720,12 +462,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.219" @@ -778,37 +514,12 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -848,12 +559,13 @@ name = "technique" version = "0.4.3" dependencies = [ "clap", + "lsp-server", + "lsp-types", "owo-colors", "regex", "serde", + "serde_json", "tinytemplate", - "tokio", - "tower-lsp", "tracing", "tracing-subscriber", ] @@ -897,110 +609,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "slab", - "socket2", - "tokio-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-util" -version = "0.7.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-lsp" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" -dependencies = [ - "async-trait", - "auto_impl", - "bytes", - "dashmap", - "futures", - "httparse", - "lsp-types", - "memchr", - "serde", - "serde_json", - "tokio", - "tokio-util", - "tower", - "tower-lsp-macros", - "tracing", -] - -[[package]] -name = "tower-lsp-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.41" @@ -1098,12 +706,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "windows-link" version = "0.1.3" @@ -1119,15 +721,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 17b05e4..309d028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,14 @@ license = "MIT" [dependencies] clap = { version = "4.5.16", features = [ "wrap_help" ] } +lsp-server = "0.7.9" +lsp-types = "0.95" owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } +serde_json = "1.0" tinytemplate = "1.2.1" -tokio = { version = "1.47.1", features = ["full"] } -tower-lsp = "0.20.0" + tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = [ "env-filter" ] } From 9f426b1d0195fffeb84e1e4a9bd7c2119916c16c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 12:37:51 +1000 Subject: [PATCH 07/15] Refactor to use lsp-server instead of tower-lsp --- src/editor/mod.rs | 37 ++- src/editor/server.rs | 574 +++++++++++++++++++++++++++---------------- src/main.rs | 8 +- 3 files changed, 392 insertions(+), 227 deletions(-) diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 07e4bc1..c15e7d5 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1,20 +1,37 @@ -use tower_lsp::{LspService, Server}; +use lsp_server::Connection; +use lsp_types::{ + InitializeParams, OneOf, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, +}; use tracing::{debug, info}; mod server; -pub(crate) async fn run_language_server() { +pub(crate) fn run_language_server() { debug!("Starting Technique Language Server"); - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); + let (connection, threads) = Connection::stdio(); - let (service, socket) = - LspService::build(|client| server::TechniqueLanguageServer::new(client)).finish(); + let capabilities = serde_json::to_value(ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), + document_formatting_provider: Some(OneOf::Left(true)), + ..Default::default() + }) + .unwrap(); - info!("Technique Language Server starting on stdio"); + // extract any initialization parameters passed from the editor. + if let Ok(params) = connection.initialize(capabilities) { + let _params = serde_json::from_value::(params).unwrap(); - Server::new(stdin, stdout, socket) - .serve(service) - .await; + info!("Technique Language Server starting on stdin"); + + let server = server::TechniqueLanguageServer::new(); + + if let Err(e) = server.run(connection) { + eprintln!("Server error: {}", e); + } + } + + threads + .join() + .unwrap(); } diff --git a/src/editor/server.rs b/src/editor/server.rs index 5d06309..40e0f6a 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -1,18 +1,16 @@ -use std::borrow::Cow; use std::collections::HashMap; use std::path::Path; -use technique::formatting::Identity; -use tokio::sync::Mutex; -use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; -use tower_lsp::lsp_types::{ +use lsp_server::{Connection, Message, Notification, Request, Response}; +use lsp_types::{ Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, - InitializeParams, InitializeResult, InitializedParams, MessageType, OneOf, Position, Range, - ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, + InitializeParams, InitializeResult, InitializedParams, Position, PublishDiagnosticsParams, + Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, }; -use tower_lsp::{Client, LanguageServer}; -use tracing::{debug, info}; +use serde_json::{from_value, to_value, Value}; +use technique::formatting::Identity; +use tracing::{debug, error, info, warn}; use crate::formatting; use crate::parsing; @@ -20,17 +18,369 @@ use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number}; pub struct TechniqueLanguageServer { - client: Client, /// Map from URI to document content - documents: Mutex>, + documents: HashMap, } impl TechniqueLanguageServer { - pub fn new(client: Client) -> Self { + pub fn new() -> Self { Self { - client, - documents: Mutex::new(HashMap::new()), + documents: HashMap::new(), + } + } + + /// Main server loop that handles incoming LSP messages + pub fn run( + mut self, + connection: Connection, + ) -> Result<(), Box> { + info!("Starting Technique Language Server main loop"); + + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + if let Err(err) = self.handle_request(req, &|msg| { + connection + .sender + .send(msg) + }) { + error!("Error handling request: {}", err); + } + } + Message::Notification(not) => { + if let Err(err) = self.handle_notification(not, &|msg| { + connection + .sender + .send(msg) + }) { + error!("Error handling notification: {}", err); + } + } + Message::Response(_resp) => { + // We don't expect responses as a server + warn!("Received unexpected response message"); + } + } + } + + Ok(()) + } + + fn handle_request( + &mut self, + req: Request, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + match req + .method + .as_str() + { + "initialize" => { + let params: InitializeParams = from_value(req.params)?; + let result = self.handle_initialize(params)?; + let response = Response::new_ok(req.id, result); + sender(Message::Response(response))?; + } + "textDocument/formatting" => { + let params: DocumentFormattingParams = from_value(req.params)?; + match self.handle_document_formatting(params) { + Ok(result) => { + let response = Response::new_ok(req.id, result); + sender(Message::Response(response))?; + } + Err(err) => { + let response = Response::new_err( + req.id, + lsp_server::ErrorCode::ParseError as i32, + err.to_string(), + ); + sender(Message::Response(response))?; + } + } + } + "shutdown" => { + info!("Technique Language Server shutting down"); + let response = Response::new_ok(req.id, Value::Null); + sender(Message::Response(response))?; + return Ok(()); + } + _ => { + warn!("Unhandled request method: {}", req.method); + let response = Response::new_err( + req.id, + lsp_server::ErrorCode::MethodNotFound as i32, + format!("Method not found: {}", req.method), + ); + sender(Message::Response(response))?; + } + } + Ok(()) + } + + fn handle_notification( + &mut self, + notification: Notification, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + match notification + .method + .as_str() + { + "initialized" => { + let _params: InitializedParams = from_value(notification.params)?; + self.handle_initialized()?; + } + "textDocument/didOpen" => { + let params: DidOpenTextDocumentParams = from_value(notification.params)?; + self.handle_did_open(params, sender)?; + } + "textDocument/didChange" => { + let params: DidChangeTextDocumentParams = from_value(notification.params)?; + self.handle_did_change(params, sender)?; + } + "textDocument/didSave" => { + let params: DidSaveTextDocumentParams = from_value(notification.params)?; + self.handle_did_save(params, sender)?; + } + "textDocument/didClose" => { + let params: DidCloseTextDocumentParams = from_value(notification.params)?; + self.handle_did_close(params, sender)?; + } + _ => { + debug!("Unhandled notification method: {}", notification.method); + } + } + Ok(()) + } + + fn handle_initialize( + &self, + _params: InitializeParams, + ) -> Result> { + info!("Technique Language Server initializing"); + + Ok(InitializeResult { + server_info: None, + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + document_formatting_provider: Some(lsp_types::OneOf::Left(true)), + ..Default::default() + }, + }) + } + + fn handle_initialized(&self) -> Result<(), Box> { + info!("Technique Language Server initialized"); + Ok(()) + } + + fn handle_did_open( + &mut self, + params: DidOpenTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + let content = params + .text_document + .text; + + debug!("Document opened: {}", uri); + + self.documents + .insert(uri.clone(), content.clone()); + + self.parse_and_report(uri, content, sender)?; + Ok(()) + } + + fn handle_did_change( + &mut self, + params: DidChangeTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + + if let Some(change) = params + .content_changes + .into_iter() + .next() + { + let content = change.text; + + debug!("Document changed: {}", uri); + + self.documents + .insert(uri.clone(), content.clone()); + + self.parse_and_report(uri, content, sender)?; } + Ok(()) + } + + fn handle_did_save( + &mut self, + params: DidSaveTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + debug!("Document saved: {}", uri); + + let content = self + .documents + .get(&uri) + .cloned(); + + if let Some(content) = content { + self.parse_and_report(uri, content, sender)?; + } + Ok(()) + } + + fn handle_did_close( + &mut self, + params: DidCloseTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + debug!("Document closed: {}", uri); + + self.documents + .remove(&uri); + + // Clear diagnostics for closed document + self.publish_diagnostics(uri, vec![], sender)?; + Ok(()) + } + + fn handle_document_formatting( + &self, + params: DocumentFormattingParams, + ) -> Result>, Box> { + let uri = params + .text_document + .uri; + + debug!("Format request: {}", uri); + + // Get content from our documents map + let content = match self + .documents + .get(&uri) + { + Some(content) => content.clone(), + None => { + return Err("Document not open".into()); + } + }; + + let path = match uri.to_file_path() { + Ok(buf) => buf, + Err(_) => Path::new("-").to_path_buf(), + }; + + let document = match parsing::parse_with_recovery(&path, &content) { + Ok(document) => document, + Err(_) => { + return Err("Document must be free of parse errors before formatting".into()); + } + }; + + let result = formatting::render(&Identity, &document, 78); + + // convert to LSP type for return to editor. + let edit = TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: u32::MAX, + character: 0, + }, + }, + new_text: result, + }; + + Ok(Some(vec![edit])) + } + + /// Parse document and convert errors to diagnostics + fn parse_and_report( + &self, + uri: Url, + content: String, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let path = uri + .to_file_path() + .unwrap_or_else(|_| Path::new("-").to_path_buf()); + + match parsing::parse_with_recovery(&path, &content) { + Ok(_document) => { + self.publish_diagnostics(uri, vec![], sender)?; + } + Err(errors) => { + let diagnostics = self.convert_parsing_errors(&uri, &content, errors); + self.publish_diagnostics(uri, diagnostics, sender)?; + } + } + Ok(()) + } + + fn publish_diagnostics( + &self, + uri: Url, + diagnostics: Vec, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let params = PublishDiagnosticsParams { + uri, + diagnostics, + version: None, + }; + + let notification = Notification::new( + "textDocument/publishDiagnostics".to_string(), + to_value(params).unwrap(), + ); + + sender(Message::Notification(notification))?; + Ok(()) } fn convert_parsing_errors( @@ -186,27 +536,6 @@ impl TechniqueLanguageServer { diagnostics } - - /// Parse document and convert errors to diagnostics - async fn parse_and_publish_diagnostics(&self, uri: Url, content: String) { - let path = uri - .to_file_path() - .unwrap_or_else(|_| Path::new("-").to_path_buf()); - - match parsing::parse_with_recovery(&path, &content) { - Ok(_document) => { - self.client - .publish_diagnostics(uri, vec![], None) - .await; - } - Err(errors) => { - let diagnostics = self.convert_parsing_errors(&uri, &content, errors); - self.client - .publish_diagnostics(uri, diagnostics, None) - .await; - } - } - } } /// Convert byte offset to LSP Position @@ -216,181 +545,6 @@ fn offset_to_position(text: &str, offset: usize) -> Position { Position { line, character } } -#[tower_lsp::async_trait] -impl LanguageServer for TechniqueLanguageServer { - async fn initialize(&self, _params: InitializeParams) -> Result { - info!("Technique Language Server initializing"); - - Ok(InitializeResult { - server_info: None, - capabilities: ServerCapabilities { - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::FULL, - )), - document_formatting_provider: Some(OneOf::Left(true)), - ..Default::default() - }, - }) - } - - async fn initialized(&self, _params: InitializedParams) { - info!("Technique Language Server initialized"); - - let _ = self - .client - .log_message(MessageType::INFO, "Technique Language Server initialized") - .await; - } - - async fn shutdown(&self) -> Result<()> { - info!("Technique Language Server shutting down"); - Ok(()) - } - - async fn did_open(&self, params: DidOpenTextDocumentParams) { - let uri = params - .text_document - .uri; - let content = params - .text_document - .text; - - debug!("Document opened: {}", uri); - - let mut documents = self - .documents - .lock() - .await; - documents.insert(uri.clone(), content.clone()); - - self.parse_and_publish_diagnostics(uri, content) - .await; - } - - async fn did_change(&self, params: DidChangeTextDocumentParams) { - let uri = params - .text_document - .uri; - - if let Some(change) = params - .content_changes - .into_iter() - .next() - { - let content = change.text; - - debug!("Document changed: {}", uri); - - let mut documents = self - .documents - .lock() - .await; - documents.insert(uri.clone(), content.clone()); - - self.parse_and_publish_diagnostics(uri, content) - .await; - } - } - - async fn did_save(&self, params: DidSaveTextDocumentParams) { - let uri = params - .text_document - .uri; - debug!("Document saved: {}", uri); - - if let Some(content) = { - let documents = self - .documents - .lock() - .await; - documents - .get(&uri) - .cloned() - } { - self.parse_and_publish_diagnostics(uri, content) - .await; - } - } - - async fn did_close(&self, params: DidCloseTextDocumentParams) { - let uri = params - .text_document - .uri; - debug!("Document closed: {}", uri); - - let mut documents = self - .documents - .lock() - .await; - documents.remove(&uri); - - self.client - .publish_diagnostics(uri, vec![], None) - .await; - } - - async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { - let uri = params - .text_document - .uri; - - debug!("Format request: {}", uri); - - // Get content from our documents map - let documents = self - .documents - .lock() - .await; - let content = match documents.get(&uri) { - Some(c) => c, - None => { - return Err(Error { - code: ErrorCode::InvalidRequest, - message: Cow::Borrowed("Document not open"), - data: None, - }) - } - }; - - let path = match uri.to_file_path() { - Ok(buf) => buf, - Err(_) => Path::new("-").to_path_buf(), - }; - - let document = match parsing::parse_with_recovery(&path, content) { - Ok(document) => document, - Err(_) => { - return Err(Error { - code: ErrorCode::ParseError, - message: Cow::Borrowed( - "Document must be free of parse errors before formatting", - ), - data: None, - }) - } - }; - - let result = formatting::render(&Identity, &document, 78); - - // convert to tower-lsp type for return to editor. - let edit = TextEdit { - range: Range { - start: Position { - line: 0, - character: 0, - }, - end: Position { - line: u32::MAX, - character: 0, - }, - }, - new_text: result, - }; - - Ok(Some(vec![edit])) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index dc949a8..e5733df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -327,13 +327,7 @@ fn main() { Some(("language", _)) => { debug!("Starting Language Server"); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - editor::run_language_server().await; - }); + editor::run_language_server(); } Some(_) => { println!("No valid subcommand was used") From d3d311dd94cb50d630f10c19acc3a748fcf64260 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 13:49:49 +1000 Subject: [PATCH 08/15] Handle exit notification properly --- src/editor/server.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/editor/server.rs b/src/editor/server.rs index 40e0f6a..ce1b54d 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -34,12 +34,12 @@ impl TechniqueLanguageServer { mut self, connection: Connection, ) -> Result<(), Box> { - info!("Starting Technique Language Server main loop"); + info!("Starting Language Server main loop"); - for msg in &connection.receiver { - match msg { - Message::Request(req) => { - if let Err(err) = self.handle_request(req, &|msg| { + for message in &connection.receiver { + match message { + Message::Request(request) => { + if let Err(err) = self.handle_request(request, &|msg| { connection .sender .send(msg) @@ -47,13 +47,17 @@ impl TechniqueLanguageServer { error!("Error handling request: {}", err); } } - Message::Notification(not) => { - if let Err(err) = self.handle_notification(not, &|msg| { + Message::Notification(notification) => { + if notification.method == "exit" { + break; + } + + if let Err(error) = self.handle_notification(notification, &|message| { connection .sender - .send(msg) + .send(message) }) { - error!("Error handling notification: {}", err); + error!("Error handling notification: {}", error); } } Message::Response(_resp) => { @@ -102,10 +106,9 @@ impl TechniqueLanguageServer { } } "shutdown" => { - info!("Technique Language Server shutting down"); + info!("Language Server received shutdown request"); let response = Response::new_ok(req.id, Value::Null); sender(Message::Response(response))?; - return Ok(()); } _ => { warn!("Unhandled request method: {}", req.method); @@ -163,7 +166,7 @@ impl TechniqueLanguageServer { &self, _params: InitializeParams, ) -> Result> { - info!("Technique Language Server initializing"); + info!("Language Server initializing"); Ok(InitializeResult { server_info: None, From d9a9a60c6a75fe74b6d19a831e1cf27a915e7fcf Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 17:10:45 +1000 Subject: [PATCH 09/15] Add width to compiler error messages --- src/editor/server.rs | 115 ++++++------ src/parsing/checks/errors.rs | 40 ++--- src/parsing/checks/parser.rs | 8 +- src/parsing/parser.rs | 329 ++++++++++++++++++++++------------- src/problem/format.rs | 17 +- src/problem/messages.rs | 62 +++---- 6 files changed, 331 insertions(+), 240 deletions(-) diff --git a/src/editor/server.rs b/src/editor/server.rs index ce1b54d..5781e0c 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -396,132 +396,137 @@ impl TechniqueLanguageServer { for error in errors { let offset = error.offset(); - let position = offset_to_position(content, offset); + let width = error.width(); + let start_position = offset_to_position(content, offset); + let end_position = if width > 0 { + offset_to_position(content, offset + width) + } else { + start_position // Fallback to single character if width is unknown + }; + let range = Range { + start: start_position, + end: end_position, + }; let (message, severity) = match &error { - ParsingError::IllegalParserState(_) => ( + ParsingError::IllegalParserState(_, _) => ( "Internal parser error".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::Unimplemented(_) => ( + ParsingError::Unimplemented(_, _) => ( "Unimplemented feature".to_string(), DiagnosticSeverity::WARNING, ), - ParsingError::Unrecognized(_) => { + ParsingError::Unrecognized(_, _) => { ("Unrecognized syntax".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::UnexpectedEndOfInput(_) => ( + ParsingError::UnexpectedEndOfInput(_, _) => ( "Unexpected end of input".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::Expected(_, expected) => { + ParsingError::Expected(_, _, expected) => { (format!("Expected {}", expected), DiagnosticSeverity::ERROR) } - ParsingError::ExpectedMatchingChar(_, subject, start, end) => ( + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => ( format!("Expected matching '{}' for '{}' in {}", end, start, subject), DiagnosticSeverity::ERROR, ), - ParsingError::MissingParenthesis(_) => ( + ParsingError::MissingParenthesis(_, _) => ( "Require parenthesis around multiple parameters in binding".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidCharacter(_, ch) => ( + ParsingError::InvalidCharacter(_, _, ch) => ( format!("Invalid character '{}'", ch), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidHeader(_) => { + ParsingError::InvalidHeader(_, _) => { ("Invalid header line".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidIdentifier(_, id) => ( + ParsingError::InvalidIdentifier(_, _, id) => ( format!("Invalid identifier '{}'", id), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidForma(_) => ( + ParsingError::InvalidForma(_, _) => ( "Invalid forma in signature".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidGenus(_) => ( + ParsingError::InvalidGenus(_, _) => ( "Invalid genus in signature".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidSignature(_) => ( + ParsingError::InvalidSignature(_, _) => ( "Invalid signature in procedure declaration".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidParameters(_) => ( + ParsingError::InvalidParameters(_, _) => ( "Malformed parameters in procedure declaration".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidDeclaration(_) => ( + ParsingError::InvalidDeclaration(_, _) => ( "Invalid procedure declaration".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidSection(_) => ( + ParsingError::InvalidSection(_, _) => ( "Invalid section heading".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidInvocation(_) => ( + ParsingError::InvalidInvocation(_, _) => ( "Invalid procedure Invocation".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidFunction(_) => ( + ParsingError::InvalidFunction(_, _) => ( "Invalid function call".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidCodeBlock(_) => { + ParsingError::InvalidCodeBlock(_, _) => { ("Invalid code block".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidStep(_) => { + ParsingError::InvalidStep(_, _) => { ("Invalid step".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidSubstep(_) => { + ParsingError::InvalidSubstep(_, _) => { ("Invalid substep".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidResponse(_) => { + ParsingError::InvalidResponse(_, _) => { ("Invalid response".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidMultiline(_) => ( + ParsingError::InvalidMultiline(_, _) => ( "Invalid multiline content".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidForeach(_) => ( + ParsingError::InvalidForeach(_, _) => ( "Invalid foreach expression".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidIntegral(_) => ( + ParsingError::InvalidIntegral(_, _) => ( "Invalid integral number".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidQuantity(_) => { + ParsingError::InvalidQuantity(_, _) => { ("Invalid quantity".to_string(), DiagnosticSeverity::ERROR) } - ParsingError::InvalidQuantityDecimal(_) => ( + ParsingError::InvalidQuantityDecimal(_, _) => ( "Invalid quantity decimal".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidQuantityUncertainty(_) => ( + ParsingError::InvalidQuantityUncertainty(_, _) => ( "Invalid quantity uncertainty".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidQuantityMagnitude(_) => ( + ParsingError::InvalidQuantityMagnitude(_, _) => ( "Invalid quantity magnitude".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::InvalidQuantitySymbol(_) => ( + ParsingError::InvalidQuantitySymbol(_, _) => ( "Invalid quantity symbol".to_string(), DiagnosticSeverity::ERROR, ), - ParsingError::UnclosedInterpolation(_) => ( + ParsingError::UnclosedInterpolation(_, _) => ( "Unclosed interpolation".to_string(), DiagnosticSeverity::ERROR, ), }; - let range = Range { - start: position, - end: position, // For now, just point to the error position - }; - let diagnostic = Diagnostic { range, severity: Some(severity), @@ -597,17 +602,17 @@ mod tests { fn test_parsing_error_types() { // Test that all error types can be converted to messages without panicking let test_errors = vec![ - ParsingError::IllegalParserState(0), - ParsingError::Unimplemented(0), - ParsingError::Unrecognized(0), - ParsingError::UnexpectedEndOfInput(0), - ParsingError::Expected(0, "test"), - ParsingError::ExpectedMatchingChar(0, "test", '(', ')'), - ParsingError::MissingParenthesis(0), - ParsingError::InvalidCharacter(0, 'x'), - ParsingError::InvalidHeader(0), - ParsingError::InvalidIdentifier(0, "test".to_string()), - ParsingError::InvalidDeclaration(0), + ParsingError::IllegalParserState(0, 0), + ParsingError::Unimplemented(0, 0), + ParsingError::Unrecognized(0, 0), + ParsingError::UnexpectedEndOfInput(0, 0), + ParsingError::Expected(0, 0, "test"), + ParsingError::ExpectedMatchingChar(0, 0, "test", '(', ')'), + ParsingError::MissingParenthesis(0, 0), + ParsingError::InvalidCharacter(0, 0, 'x'), + ParsingError::InvalidHeader(0, 0), + ParsingError::InvalidIdentifier(0, 0, "test".to_string()), + ParsingError::InvalidDeclaration(0, 0), ]; // This shouldn't panic - just test that all enum variants are handled @@ -617,25 +622,25 @@ mod tests { // Test message generation (this was formerly in convert_parsing_errors) match &error { - ParsingError::IllegalParserState(_) => { + ParsingError::IllegalParserState(_, _) => { assert_eq!("Internal parser error", "Internal parser error") } - ParsingError::Unimplemented(_) => { + ParsingError::Unimplemented(_, _) => { assert_eq!("Unimplemented feature", "Unimplemented feature") } - ParsingError::Unrecognized(_) => { + ParsingError::Unrecognized(_, _) => { assert_eq!("Unrecognized syntax", "Unrecognized syntax") } - ParsingError::UnexpectedEndOfInput(_) => { + ParsingError::UnexpectedEndOfInput(_, _) => { assert_eq!("Unexpected end of input", "Unexpected end of input") } - ParsingError::Expected(_, expected) => assert_eq!(*expected, "test"), - ParsingError::ExpectedMatchingChar(_, subject, start, end) => { + ParsingError::Expected(_, _, expected) => assert_eq!(*expected, "test"), + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => { assert_eq!(*subject, "test"); assert_eq!(*start, '('); assert_eq!(*end, ')'); } - ParsingError::InvalidDeclaration(_) => { + ParsingError::InvalidDeclaration(_, _) => { assert_eq!("Invalid declaration", "Invalid declaration") } _ => {} // Other variants tested implicitly diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs index dba2b87..7f5e291 100644 --- a/src/parsing/checks/errors.rs +++ b/src/parsing/checks/errors.rs @@ -32,7 +32,7 @@ fn invalid_identifier_uppercase_start() { Making_Coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 0, "".to_string()), ); } @@ -43,7 +43,7 @@ fn invalid_identifier_mixed_case() { makeCoffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 0, "".to_string()), ); } @@ -54,7 +54,7 @@ fn invalid_identifier_with_dashes() { make-coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 0, "".to_string()), ); } @@ -65,7 +65,7 @@ fn invalid_identifier_with_spaces() { make coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidParameters(0), + ParsingError::InvalidParameters(0, 0), ); } @@ -76,7 +76,7 @@ fn invalid_signature_wrong_arrow() { making_coffee : Ingredients => Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(0), + ParsingError::InvalidSignature(0, 0), ); } @@ -87,7 +87,7 @@ fn invalid_genus_lowercase_forma() { making_coffee : ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16), + ParsingError::InvalidGenus(16, 0), ); } @@ -98,7 +98,7 @@ fn invalid_genus_both_lowercase() { making_coffee : ingredients -> coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16), + ParsingError::InvalidGenus(16, 0), ); } @@ -109,7 +109,7 @@ fn invalid_signature_missing_arrow() { making_coffee : Ingredients Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(16), + ParsingError::InvalidSignature(16, 0), ); } @@ -120,7 +120,7 @@ fn invalid_declaration_missing_colon() { making_coffee Ingredients -> Coffee "# .trim_ascii(), - ParsingError::Unrecognized(0), + ParsingError::Unrecognized(0, 0), ); } @@ -131,7 +131,7 @@ fn invalid_identifier_in_parameters() { making_coffee(BadParam) : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(14, "".to_string()), + ParsingError::InvalidIdentifier(14, 0, "".to_string()), ); } @@ -142,7 +142,7 @@ fn invalid_identifier_empty() { : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidDeclaration(0), + ParsingError::InvalidDeclaration(0, 0), ); } @@ -155,7 +155,7 @@ making_coffee : A. First step (should be lowercase 'a.') "# .trim_ascii(), - ParsingError::InvalidStep(21), + ParsingError::InvalidStep(21, 0), ); } @@ -169,7 +169,7 @@ making_coffee : "Yes" | "No" "# .trim_ascii(), - ParsingError::InvalidResponse(52), + ParsingError::InvalidResponse(52, 0), ); } @@ -183,7 +183,7 @@ making_coffee : This is missing closing backticks "# .trim_ascii(), - ParsingError::InvalidMultiline(41), + ParsingError::InvalidMultiline(41, 0), ); } @@ -196,7 +196,7 @@ making_coffee : 1. Do something { exec("command" "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(38, "a code block", '{', '}'), + ParsingError::ExpectedMatchingChar(38, 0, "a code block", '{', '}'), ); } @@ -209,7 +209,7 @@ making_coffee : i. Wrong case section "# .trim_ascii(), - ParsingError::InvalidStep(21), + ParsingError::InvalidStep(21, 0), ); } @@ -222,7 +222,7 @@ making_coffee : 1. Do '), + ParsingError::ExpectedMatchingChar(27, 0, "an invocation", '<', '>'), ); } @@ -235,7 +235,7 @@ making_coffee : 1. Do something { exec("command" } "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(43, "a function call", '(', ')'), + ParsingError::ExpectedMatchingChar(43, 0, "a function call", '(', ')'), ); } @@ -248,7 +248,7 @@ making_coffee : 1. { repeat '), + ParsingError::ExpectedMatchingChar(29, 0, "an invocation", '<', '>'), ); } @@ -262,6 +262,6 @@ making_coffee : A. This should be lowercase "# .trim_ascii(), - ParsingError::InvalidSubstep(37), + ParsingError::InvalidSubstep(37, 0), ); } diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 08fe4ba..60e5fc2 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -608,7 +608,7 @@ fn read_toplevel_steps() { // Test invalid step input.initialize("Not a step"); let result = input.read_step_dependent(); - assert_eq!(result, Err(ParsingError::InvalidStep(0))); + assert_eq!(result, Err(ParsingError::InvalidStep(0, 0))); } #[test] @@ -1660,7 +1660,7 @@ fn splitting_by() { // different split character input.initialize("'Yes'|'No'|'Maybe'"); let result = input.take_split_by('|', |inner| { - validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset)) + validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset, 0)) }); assert_eq!( result, @@ -2022,7 +2022,7 @@ fn parse_collecting_errors_basic() { assert!(errors.len() > 0); assert!(errors .iter() - .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); + .any(|e| matches!(e, ParsingError::InvalidHeader(_, _)))); } } @@ -2119,7 +2119,7 @@ fn test_redundant_error_removal_unclosed_interpolation() { // Should get the specific UnclosedInterpolation error, not a generic // one match result { - Err(ParsingError::UnclosedInterpolation(_)) => { + Err(ParsingError::UnclosedInterpolation(_, _)) => { // Good - we got the specific error } Err(other) => { diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index c263389..2115f72 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -26,75 +26,111 @@ pub fn parse_with_recovery<'i>( #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ParsingError { // lowest priority - IllegalParserState(usize), - Unimplemented(usize), - Unrecognized(usize), // improve this - UnexpectedEndOfInput(usize), - Expected(usize, &'static str), - ExpectedMatchingChar(usize, &'static str, char, char), - MissingParenthesis(usize), + IllegalParserState(usize, usize), // offset, width (0 = unknown) + Unimplemented(usize, usize), + Unrecognized(usize, usize), // improve this + UnexpectedEndOfInput(usize, usize), + Expected(usize, usize, &'static str), // offset, width, expected + ExpectedMatchingChar(usize, usize, &'static str, char, char), + MissingParenthesis(usize, usize), // more specific errors - InvalidCharacter(usize, char), - InvalidHeader(usize), - InvalidIdentifier(usize, String), - InvalidForma(usize), - InvalidGenus(usize), - InvalidSignature(usize), - InvalidParameters(usize), - InvalidDeclaration(usize), - InvalidSection(usize), - InvalidInvocation(usize), - InvalidFunction(usize), - InvalidCodeBlock(usize), - InvalidStep(usize), - InvalidSubstep(usize), - InvalidResponse(usize), - InvalidMultiline(usize), - InvalidForeach(usize), - InvalidIntegral(usize), - InvalidQuantity(usize), - InvalidQuantityDecimal(usize), - InvalidQuantityUncertainty(usize), - InvalidQuantityMagnitude(usize), - InvalidQuantitySymbol(usize), + InvalidCharacter(usize, usize, char), + InvalidHeader(usize, usize), + InvalidIdentifier(usize, usize, String), + InvalidForma(usize, usize), + InvalidGenus(usize, usize), + InvalidSignature(usize, usize), + InvalidParameters(usize, usize), + InvalidDeclaration(usize, usize), + InvalidSection(usize, usize), + InvalidInvocation(usize, usize), + InvalidFunction(usize, usize), + InvalidCodeBlock(usize, usize), + InvalidStep(usize, usize), + InvalidSubstep(usize, usize), + InvalidResponse(usize, usize), + InvalidMultiline(usize, usize), + InvalidForeach(usize, usize), + InvalidIntegral(usize, usize), + InvalidQuantity(usize, usize), + InvalidQuantityDecimal(usize, usize), + InvalidQuantityUncertainty(usize, usize), + InvalidQuantityMagnitude(usize, usize), + InvalidQuantitySymbol(usize, usize), // highest priority - UnclosedInterpolation(usize), + UnclosedInterpolation(usize, usize), } impl ParsingError { pub fn offset(&self) -> usize { match self { - ParsingError::IllegalParserState(offset) => *offset, - ParsingError::Unimplemented(offset) => *offset, - ParsingError::Unrecognized(offset) => *offset, - ParsingError::Expected(offset, _) => *offset, - ParsingError::ExpectedMatchingChar(offset, _, _, _) => *offset, - ParsingError::MissingParenthesis(offset) => *offset, - ParsingError::UnclosedInterpolation(offset) => *offset, - ParsingError::InvalidHeader(offset) => *offset, - ParsingError::InvalidCharacter(offset, _) => *offset, - ParsingError::UnexpectedEndOfInput(offset) => *offset, - ParsingError::InvalidIdentifier(offset, _) => *offset, - ParsingError::InvalidForma(offset) => *offset, - ParsingError::InvalidGenus(offset) => *offset, - ParsingError::InvalidSignature(offset) => *offset, - ParsingError::InvalidDeclaration(offset) => *offset, - ParsingError::InvalidParameters(offset) => *offset, - ParsingError::InvalidSection(offset) => *offset, - ParsingError::InvalidInvocation(offset) => *offset, - ParsingError::InvalidFunction(offset) => *offset, - ParsingError::InvalidCodeBlock(offset) => *offset, - ParsingError::InvalidMultiline(offset) => *offset, - ParsingError::InvalidStep(offset) => *offset, - ParsingError::InvalidSubstep(offset) => *offset, - ParsingError::InvalidForeach(offset) => *offset, - ParsingError::InvalidResponse(offset) => *offset, - ParsingError::InvalidIntegral(offset) => *offset, - ParsingError::InvalidQuantity(offset) => *offset, - ParsingError::InvalidQuantityDecimal(offset) => *offset, - ParsingError::InvalidQuantityUncertainty(offset) => *offset, - ParsingError::InvalidQuantityMagnitude(offset) => *offset, - ParsingError::InvalidQuantitySymbol(offset) => *offset, + ParsingError::IllegalParserState(offset, _) => *offset, + ParsingError::Unimplemented(offset, _) => *offset, + ParsingError::Unrecognized(offset, _) => *offset, + ParsingError::Expected(offset, _, _) => *offset, + ParsingError::ExpectedMatchingChar(offset, _, _, _, _) => *offset, + ParsingError::MissingParenthesis(offset, _) => *offset, + ParsingError::UnclosedInterpolation(offset, _) => *offset, + ParsingError::InvalidHeader(offset, _) => *offset, + ParsingError::InvalidCharacter(offset, _, _) => *offset, + ParsingError::UnexpectedEndOfInput(offset, _) => *offset, + ParsingError::InvalidIdentifier(offset, _, _) => *offset, + ParsingError::InvalidForma(offset, _) => *offset, + ParsingError::InvalidGenus(offset, _) => *offset, + ParsingError::InvalidSignature(offset, _) => *offset, + ParsingError::InvalidDeclaration(offset, _) => *offset, + ParsingError::InvalidParameters(offset, _) => *offset, + ParsingError::InvalidSection(offset, _) => *offset, + ParsingError::InvalidInvocation(offset, _) => *offset, + ParsingError::InvalidFunction(offset, _) => *offset, + ParsingError::InvalidCodeBlock(offset, _) => *offset, + ParsingError::InvalidMultiline(offset, _) => *offset, + ParsingError::InvalidStep(offset, _) => *offset, + ParsingError::InvalidSubstep(offset, _) => *offset, + ParsingError::InvalidForeach(offset, _) => *offset, + ParsingError::InvalidResponse(offset, _) => *offset, + ParsingError::InvalidIntegral(offset, _) => *offset, + ParsingError::InvalidQuantity(offset, _) => *offset, + ParsingError::InvalidQuantityDecimal(offset, _) => *offset, + ParsingError::InvalidQuantityUncertainty(offset, _) => *offset, + ParsingError::InvalidQuantityMagnitude(offset, _) => *offset, + ParsingError::InvalidQuantitySymbol(offset, _) => *offset, + } + } + + pub fn width(&self) -> usize { + match self { + ParsingError::IllegalParserState(_, width) => *width, + ParsingError::Unimplemented(_, width) => *width, + ParsingError::Unrecognized(_, width) => *width, + ParsingError::Expected(_, width, _) => *width, + ParsingError::ExpectedMatchingChar(_, width, _, _, _) => *width, + ParsingError::MissingParenthesis(_, width) => *width, + ParsingError::UnclosedInterpolation(_, width) => *width, + ParsingError::InvalidHeader(_, width) => *width, + ParsingError::InvalidCharacter(_, width, _) => *width, + ParsingError::UnexpectedEndOfInput(_, width) => *width, + ParsingError::InvalidIdentifier(_, width, _) => *width, + ParsingError::InvalidForma(_, width) => *width, + ParsingError::InvalidGenus(_, width) => *width, + ParsingError::InvalidSignature(_, width) => *width, + ParsingError::InvalidDeclaration(_, width) => *width, + ParsingError::InvalidParameters(_, width) => *width, + ParsingError::InvalidSection(_, width) => *width, + ParsingError::InvalidInvocation(_, width) => *width, + ParsingError::InvalidFunction(_, width) => *width, + ParsingError::InvalidCodeBlock(_, width) => *width, + ParsingError::InvalidMultiline(_, width) => *width, + ParsingError::InvalidStep(_, width) => *width, + ParsingError::InvalidSubstep(_, width) => *width, + ParsingError::InvalidForeach(_, width) => *width, + ParsingError::InvalidResponse(_, width) => *width, + ParsingError::InvalidIntegral(_, width) => *width, + ParsingError::InvalidQuantity(_, width) => *width, + ParsingError::InvalidQuantityDecimal(_, width) => *width, + ParsingError::InvalidQuantityUncertainty(_, width) => *width, + ParsingError::InvalidQuantityMagnitude(_, width) => *width, + ParsingError::InvalidQuantitySymbol(_, width) => *width, } } } @@ -232,7 +268,7 @@ impl<'i> Parser<'i> { } } else { self.problems - .push(ParsingError::Unrecognized(self.offset)); + .push(ParsingError::Unrecognized(self.offset, 0)); self.skip_to_next_line(); } } @@ -304,7 +340,7 @@ impl<'i> Parser<'i> { } } else { self.problems - .push(ParsingError::Unrecognized(self.offset)); + .push(ParsingError::Unrecognized(self.offset, 0)); self.skip_to_next_line(); } } @@ -458,11 +494,16 @@ impl<'i> Parser<'i> { } if !begun { - return Err(ParsingError::Expected(self.offset, "the start character")); + return Err(ParsingError::Expected( + self.offset, + 0, + "the start character", + )); } if l == 0 { return Err(ParsingError::ExpectedMatchingChar( self.offset, + 0, subject, start_char, end_char, @@ -500,7 +541,11 @@ impl<'i> Parser<'i> { let start = self .source .find(delimiter) - .ok_or(ParsingError::Expected(self.offset, "a starting delimiter"))?; + .ok_or(ParsingError::Expected( + self.offset, + 0, + "a starting delimiter", + ))?; // Look for the end delimiter after correcting for the starting one let start = start + width; @@ -508,6 +553,7 @@ impl<'i> Parser<'i> { .find(delimiter) .ok_or(ParsingError::Expected( self.offset, + 0, "the corresponding end delimiter", ))?; @@ -570,6 +616,7 @@ impl<'i> Parser<'i> { if trimmed.is_empty() { return Err(ParsingError::Expected( self.offset, + 0, "non-empty content between delimiters", )); } @@ -649,7 +696,7 @@ impl<'i> Parser<'i> { } else if c.is_ascii_whitespace() { continue; } else { - return Err(ParsingError::InvalidCharacter(self.offset, c)); + return Err(ParsingError::InvalidCharacter(self.offset, 0, c)); } } @@ -673,7 +720,7 @@ impl<'i> Parser<'i> { Ok(1) } else { let error_offset = analyze_magic_line(inner.source); - Err(ParsingError::InvalidHeader(inner.offset + error_offset)) + Err(ParsingError::InvalidHeader(inner.offset + error_offset, 0)) } }) } @@ -686,7 +733,7 @@ impl<'i> Parser<'i> { let cap = re .captures(inner.source) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; // Now to extracting the values we need. We get the license code from // the first capture. It must be present otherwise we don't have a @@ -695,10 +742,10 @@ impl<'i> Parser<'i> { let one = cap .get(1) - .ok_or(ParsingError::Expected(inner.offset, "the license name"))?; + .ok_or(ParsingError::Expected(inner.offset, 0, "the license name"))?; - let result = - validate_license(one.as_str()).ok_or(ParsingError::InvalidHeader(inner.offset))?; + let result = validate_license(one.as_str()) + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; let license = Some(result); // Now dig out the copyright, if present: @@ -706,7 +753,7 @@ impl<'i> Parser<'i> { let copyright = match cap.get(2) { Some(two) => { let result = validate_copyright(two.as_str()) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; Some(result) } None => None, @@ -722,14 +769,14 @@ impl<'i> Parser<'i> { let cap = re .captures(inner.source) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; let one = cap .get(1) - .ok_or(ParsingError::Expected(inner.offset, "a template name"))?; + .ok_or(ParsingError::Expected(inner.offset, 0, "a template name"))?; - let result = - validate_template(one.as_str()).ok_or(ParsingError::InvalidHeader(inner.offset))?; + let result = validate_template(one.as_str()) + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; Ok(Some(result)) }) } @@ -741,7 +788,7 @@ impl<'i> Parser<'i> { self.require_newline()?; result } else { - Err(ParsingError::Expected(0, "The % symbol"))? + Err(ParsingError::Expected(0, 0, "The % symbol"))? }; // Process SPDX line @@ -777,7 +824,10 @@ impl<'i> Parser<'i> { Some(c) => c, None => { let arrow_offset = analyze_malformed_signature(self.source); - return Err(ParsingError::InvalidSignature(self.offset + arrow_offset)); + return Err(ParsingError::InvalidSignature( + self.offset + arrow_offset, + 0, + )); } }; @@ -785,15 +835,26 @@ impl<'i> Parser<'i> { .get(1) .ok_or(ParsingError::Expected( self.offset, + 0, "a Genus for the domain", ))?; let two = cap .get(2) - .ok_or(ParsingError::Expected(self.offset, "a Genus for the range"))?; + .ok_or(ParsingError::Expected( + self.offset, + 0, + "a Genus for the range", + ))?; - let domain = validate_genus(one.as_str()).ok_or(ParsingError::InvalidGenus(self.offset))?; - let range = validate_genus(two.as_str()).ok_or(ParsingError::InvalidGenus(self.offset))?; + let domain = validate_genus(one.as_str()).ok_or(ParsingError::InvalidGenus( + self.offset + one.start(), + one.len(), + ))?; + let range = validate_genus(two.as_str()).ok_or(ParsingError::InvalidGenus( + self.offset + two.start(), + two.len(), + ))?; Ok(Signature { domain, range }) } @@ -815,12 +876,13 @@ impl<'i> Parser<'i> { let cap = re .captures(self.source) - .ok_or(ParsingError::InvalidDeclaration(self.offset))?; + .ok_or(ParsingError::InvalidDeclaration(self.offset, 0))?; let one = cap .get(1) .ok_or(ParsingError::Expected( self.offset, + 0, "an Identifier for the procedure declaration", ))?; @@ -829,12 +891,13 @@ impl<'i> Parser<'i> { let before = before.trim(); let name = validate_identifier(before).ok_or(ParsingError::InvalidIdentifier( self.offset, + 0, before.to_string(), ))?; // Extract parameters from parentheses if !list.ends_with(')') { - return Err(ParsingError::InvalidDeclaration(self.offset)); + return Err(ParsingError::InvalidDeclaration(self.offset, 0)); } let list = &list[..list.len() - 1].trim_ascii(); @@ -846,6 +909,7 @@ impl<'i> Parser<'i> { let param = validate_identifier(item.trim_ascii()).ok_or( ParsingError::InvalidIdentifier( self.offset, + 0, item.trim_ascii() .to_string(), ), @@ -873,11 +937,12 @@ impl<'i> Parser<'i> { .as_ptr() as isize - text.as_ptr() as isize; let error_offset = self.offset + one.start() + first_param_pos as usize; - return Err(ParsingError::InvalidParameters(error_offset)); + return Err(ParsingError::InvalidParameters(error_offset, 0)); } let name = validate_identifier(text).ok_or(ParsingError::InvalidIdentifier( self.offset, + 0, text.to_string(), ))?; (name, None) @@ -904,7 +969,7 @@ impl<'i> Parser<'i> { Ok(title) } else { // we shouldn't have invoked this unless we have a title to parse! - Err(ParsingError::IllegalParserState(self.offset)) + Err(ParsingError::IllegalParserState(self.offset, 0)) } } @@ -1048,7 +1113,7 @@ impl<'i> Parser<'i> { } else if malformed_step_pattern(content) { // Store error but continue parsing self.problems - .push(ParsingError::InvalidStep(parser.offset)); + .push(ParsingError::InvalidStep(parser.offset, 0)); parser.skip_to_next_line(); } else { match parser.take_block_lines( @@ -1246,11 +1311,11 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*([IVX]+)\.\s*(.*)$"); let cap = re .captures(line) - .ok_or(ParsingError::InvalidSection(self.offset))?; + .ok_or(ParsingError::InvalidSection(self.offset, 0))?; let numeral = match cap.get(1) { Some(one) => one.as_str(), - None => return Err(ParsingError::Expected(self.offset, "section header")), + None => return Err(ParsingError::Expected(self.offset, 0, "section header")), }; // Though section text appear as titles, they are in fact steps and so @@ -1269,7 +1334,7 @@ impl<'i> Parser<'i> { let paragraphs = parser.read_descriptive()?; if paragraphs.len() != 1 { - return Err(ParsingError::InvalidSection(self.offset)); + return Err(ParsingError::InvalidSection(self.offset, 0)); } let paragraph = paragraphs .into_iter() @@ -1307,14 +1372,14 @@ impl<'i> Parser<'i> { self.advance(tilde_pos + 1); // Move past ~ self.trim_whitespace(); } - return Err(ParsingError::MissingParenthesis(self.offset)); + return Err(ParsingError::MissingParenthesis(self.offset, 0)); } else if is_repeat_keyword(content) { self.read_repeat_expression() } else if is_foreach_keyword(content) { self.read_foreach_expression() } else if content.starts_with("foreach ") { // Malformed foreach expression - return Err(ParsingError::InvalidForeach(self.offset)); + return Err(ParsingError::InvalidForeach(self.offset, 0)); } else if content.starts_with('[') { self.read_tablet_expression() } else if is_numeric(content) { @@ -1360,7 +1425,7 @@ impl<'i> Parser<'i> { .unwrap() .is_ascii_whitespace() { - return Err(ParsingError::InvalidForeach(self.offset)); + return Err(ParsingError::InvalidForeach(self.offset, 0)); } self.trim_whitespace(); @@ -1404,7 +1469,7 @@ impl<'i> Parser<'i> { } if identifiers.is_empty() { - return Err(ParsingError::InvalidForeach(outer.offset)); + return Err(ParsingError::InvalidForeach(outer.offset, 0)); } Ok(identifiers) @@ -1463,6 +1528,7 @@ impl<'i> Parser<'i> { { return Err(ParsingError::Expected( outer.offset, + 0, "a string label for the field, in double-quotes", )); } @@ -1478,6 +1544,7 @@ impl<'i> Parser<'i> { { return Err(ParsingError::Expected( outer.offset, + 0, "a '=' after the field name to indicate what value is to be assigned to it", )); } @@ -1490,7 +1557,7 @@ impl<'i> Parser<'i> { let content = inner.source; if content.is_empty() { - return Err(ParsingError::Expected(inner.offset, "value expression")); + return Err(ParsingError::Expected(inner.offset, 0, "value expression")); }; inner.read_expression() @@ -1557,6 +1624,7 @@ impl<'i> Parser<'i> { // Unmatched brace - point to the opening brace position return Err(ParsingError::UnclosedInterpolation( self.offset + absolute_brace_start, + 0, )); } } @@ -1587,6 +1655,7 @@ impl<'i> Parser<'i> { let identifier = validate_identifier(possible).ok_or(ParsingError::InvalidIdentifier( self.offset, + 0, possible.to_string(), ))?; @@ -1606,7 +1675,7 @@ impl<'i> Parser<'i> { } else if is_numeric_quantity(content) { self.read_numeric_quantity() } else { - Err(ParsingError::InvalidQuantity(self.offset)) + Err(ParsingError::InvalidQuantity(self.offset, 0)) } } @@ -1621,7 +1690,7 @@ impl<'i> Parser<'i> { self.advance(content.len()); Ok(Numeric::Integral(amount)) } else { - Err(ParsingError::InvalidIntegral(self.offset)) + Err(ParsingError::InvalidIntegral(self.offset, 0)) } } @@ -1680,7 +1749,7 @@ impl<'i> Parser<'i> { .source .starts_with("10") { - return Err(ParsingError::InvalidQuantityMagnitude(self.offset)); + return Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)); } self.advance(2); // Skip "10" @@ -1693,7 +1762,7 @@ impl<'i> Parser<'i> { } else if let Some(exp) = self.read_exponent_superscript() { Some(exp) } else { - return Err(ParsingError::InvalidQuantityMagnitude(self.offset)); + return Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)); } } else { None @@ -1723,10 +1792,10 @@ impl<'i> Parser<'i> { self.advance(decimal_str.len()); Ok(decimal) } else { - Err(ParsingError::InvalidQuantityDecimal(self.offset)) + Err(ParsingError::InvalidQuantityDecimal(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityDecimal(self.offset)) + Err(ParsingError::InvalidQuantityDecimal(self.offset, 0)) } } @@ -1740,10 +1809,10 @@ impl<'i> Parser<'i> { self.advance(decimal_str.len()); Ok(decimal) } else { - Err(ParsingError::InvalidQuantityUncertainty(self.offset)) + Err(ParsingError::InvalidQuantityUncertainty(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityUncertainty(self.offset)) + Err(ParsingError::InvalidQuantityUncertainty(self.offset, 0)) } } @@ -1757,10 +1826,10 @@ impl<'i> Parser<'i> { self.advance(exp_str.len()); Ok(exp) } else { - Err(ParsingError::InvalidQuantityMagnitude(self.offset)) + Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityMagnitude(self.offset)) + Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)) } } @@ -1800,12 +1869,13 @@ impl<'i> Parser<'i> { // Invalid character found - point directly at it return Err(ParsingError::InvalidQuantitySymbol( self.offset + byte_offset, + 0, )); } } if valid_end == 0 { - return Err(ParsingError::InvalidQuantitySymbol(self.offset)); + return Err(ParsingError::InvalidQuantitySymbol(self.offset, 0)); } let symbol = &self.source[..valid_end]; @@ -1846,12 +1916,13 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*(\d+)\.\s+"); let cap = re .captures(outer.source) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; let number = cap .get(1) .ok_or(ParsingError::Expected( outer.offset, + 0, "the ordinal Step number", ))? .as_str(); @@ -1886,7 +1957,7 @@ impl<'i> Parser<'i> { .source .starts_with('-') { - return Err(ParsingError::IllegalParserState(outer.offset)); + return Err(ParsingError::IllegalParserState(outer.offset, 0)); } outer.advance(1); // skip over '-' outer.trim_whitespace(); @@ -1914,12 +1985,13 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*([a-hj-uw-z])\.\s+"); let cap = re .captures(content) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; let letter = cap .get(1) .ok_or(ParsingError::Expected( outer.offset, + 0, "the ordinal Sub-Step letter", ))? .as_str(); @@ -1956,7 +2028,7 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*-\s+"); let zero = re .find(outer.source) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; // Skip past the dash and space let l = zero.len(); @@ -2026,7 +2098,7 @@ impl<'i> Parser<'i> { .starts_with("```") { // Multiline blocks are not allowed in descriptive text - return Err(ParsingError::InvalidMultiline(parser.offset)); + return Err(ParsingError::InvalidMultiline(parser.offset, 0)); } else if c == '<' { let invocation = parser.read_invocation()?; parser.trim_whitespace(); @@ -2043,7 +2115,7 @@ impl<'i> Parser<'i> { .starts_with(',') { return Err(ParsingError::MissingParenthesis( - start_pos, + start_pos, 0, )); } @@ -2064,6 +2136,7 @@ impl<'i> Parser<'i> { if content.contains("```") { return Err(ParsingError::InvalidMultiline( inner.offset, + 0, )); } Ok(content) @@ -2082,7 +2155,7 @@ impl<'i> Parser<'i> { .starts_with(',') { return Err(ParsingError::MissingParenthesis( - start_pos, + start_pos, 0, )); } @@ -2113,7 +2186,7 @@ impl<'i> Parser<'i> { /// Parse enum responses like 'Yes' | 'No' | 'Not Applicable' fn read_responses(&mut self) -> Result>, ParsingError> { self.take_split_by('|', |inner| { - validate_response(inner.source).ok_or(ParsingError::InvalidResponse(inner.offset)) + validate_response(inner.source).ok_or(ParsingError::InvalidResponse(inner.offset, 0)) }) } @@ -2156,7 +2229,7 @@ impl<'i> Parser<'i> { .trim_ascii() .is_empty() { - return Err(ParsingError::InvalidMultiline(self.offset)); + return Err(ParsingError::InvalidMultiline(self.offset, 0)); } result.push(after) @@ -2200,9 +2273,11 @@ impl<'i> Parser<'i> { let (lang, lines) = outer .take_block_delimited("```", |inner| inner.parse_multiline_content()) .map_err(|err| match err { - ParsingError::Expected(offset, "the corresponding end delimiter") => { - ParsingError::InvalidMultiline(offset) - } + ParsingError::Expected( + offset, + _, + "the corresponding end delimiter", + ) => ParsingError::InvalidMultiline(offset, 0), _ => err, })?; params.push(Expression::Multiline(lang, lines)); @@ -2288,10 +2363,10 @@ impl<'i> Parser<'i> { else if let Some(captures) = regex!(r"^@([a-z][a-z0-9_]*)$").captures(trimmed) { let role_name = captures .get(1) - .ok_or(ParsingError::Expected(inner.offset, "role name after @"))? + .ok_or(ParsingError::Expected(inner.offset, 0, "role name after @"))? .as_str(); let identifier = validate_identifier(role_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, role_name.to_string()), + ParsingError::InvalidIdentifier(inner.offset, 0, role_name.to_string()), )?; attributes.push(Attribute::Role(identifier)); } @@ -2299,14 +2374,18 @@ impl<'i> Parser<'i> { else if let Some(captures) = regex!(r"^\^([a-z][a-z0-9_]*)$").captures(trimmed) { let place_name = captures .get(1) - .ok_or(ParsingError::Expected(inner.offset, "place name after ^"))? + .ok_or(ParsingError::Expected( + inner.offset, + 0, + "place name after ^", + ))? .as_str(); let identifier = validate_identifier(place_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, place_name.to_string()), + ParsingError::InvalidIdentifier(inner.offset, 0, place_name.to_string()), )?; attributes.push(Attribute::Place(identifier)); } else { - return Err(ParsingError::InvalidStep(inner.offset)); + return Err(ParsingError::InvalidStep(inner.offset, 0)); } } @@ -2346,9 +2425,9 @@ impl<'i> Parser<'i> { let block = self.read_code_scope()?; scopes.push(block); } else if malformed_step_pattern(content) { - return Err(ParsingError::InvalidSubstep(self.offset)); + return Err(ParsingError::InvalidSubstep(self.offset, 0)); } else if malformed_response_pattern(content) { - return Err(ParsingError::InvalidResponse(self.offset)); + return Err(ParsingError::InvalidResponse(self.offset, 0)); } else if is_enum_response(content) { let responses = self.read_responses()?; scopes.push(Scope::ResponseBlock { responses }); diff --git a/src/problem/format.rs b/src/problem/format.rs index a0a5062..9d4eb26 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -12,6 +12,7 @@ pub fn full_parsing_error<'i>( ) -> String { let (problem, details) = generate_error_message(error, renderer); let offset = error.offset(); + let width = error.width(); let i = calculate_line_number(source, offset); let j = calculate_column_number(source, offset); @@ -22,18 +23,23 @@ pub fn full_parsing_error<'i>( .unwrap_or("?"); let line = i + 1; let column = j + 1; - let width = 3.max( + let indent = 3.max( line.to_string() .len(), ); + // Create underline string based on error width + let spacer = " ".repeat(j); + let width = if width > 0 { width } else { 1 }; + let underline = "^".repeat(width); + format!( r#" {}: {}:{}:{} {} -{:width$} {} -{:width$} {} {} -{:width$} {} {:>column$} +{:indent$} {} +{:indent$} {} {} +{:indent$} {} {}{} {} "#, @@ -49,7 +55,8 @@ pub fn full_parsing_error<'i>( code, ' ', '|'.bright_blue(), - '^'.bright_red(), + spacer, + underline.bright_red(), details ) .trim_ascii() diff --git a/src/problem/messages.rs b/src/problem/messages.rs index f46226e..6c9bed5 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -4,26 +4,26 @@ use technique::{formatting::Render, language::*, parsing::ParsingError}; /// Generate problem and detail messages for parsing errors using AST construction pub fn generate_error_message<'i>(error: &ParsingError, renderer: &dyn Render) -> (String, String) { match error { - ParsingError::IllegalParserState(_) => ( + ParsingError::IllegalParserState(_, _) => ( "Illegal parser state".to_string(), "Internal parser error. This should not have happened! Sorry.".to_string(), ), - ParsingError::Unimplemented(_) => ( + ParsingError::Unimplemented(_, _) => ( "Feature not yet implemented".to_string(), "This feature is planned but not yet available.".to_string(), ), - ParsingError::Unrecognized(_) => ( + ParsingError::Unrecognized(_, _) => ( "Unrecognized input".to_string(), "The parser encountered unexpected content".to_string(), ), - ParsingError::Expected(_, value) => ( + ParsingError::Expected(_, _, value) => ( format!("Expected {}", value), format!( "The parser was looking for {} but found something else.", value ), ), - ParsingError::ExpectedMatchingChar(_, subject, start, end) => ( + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => ( format!("Expected matching character '{}'", end), format!( r#" @@ -35,7 +35,7 @@ there was no more input remaining in the current scope. .trim_ascii() .to_string(), ), - ParsingError::MissingParenthesis(_) => { + ParsingError::MissingParenthesis(_, _) => { let examples = vec![Descriptive::Binding( Box::new(Descriptive::Application(Invocation { target: Target::Local(Identifier("mix_pangalactic_gargle_blaster")), @@ -59,7 +59,7 @@ enclose those names in parenthesis. For example: .to_string(), ) } - ParsingError::UnclosedInterpolation(_) => ( + ParsingError::UnclosedInterpolation(_, _) => ( "Unclosed string interpolation".to_string(), r#" Every '{' that starts an interpolation within a string must have a @@ -69,7 +69,7 @@ literal resumes. .trim_ascii() .to_string(), ), - ParsingError::InvalidHeader(_) => { + ParsingError::InvalidHeader(_, _) => { // Format the sample metadata using the same code as the formatter let mut formatted_example = String::new(); formatted_example @@ -115,15 +115,15 @@ Technique. Common templates include {}, {}, and ), ) } - ParsingError::InvalidCharacter(_, c) => ( + ParsingError::InvalidCharacter(_, _, c) => ( format!("Invalid character '{}'", c), "This character is not allowed here.".to_string(), ), - ParsingError::UnexpectedEndOfInput(_) => ( + ParsingError::UnexpectedEndOfInput(_, _) => ( "Unexpected end of input".to_string(), "The file ended before the parser expected it to".to_string(), ), - ParsingError::InvalidIdentifier(_, _) => { + ParsingError::InvalidIdentifier(_, _, _) => { let examples = vec![ Procedure { name: Identifier("make_coffee"), @@ -172,7 +172,7 @@ letters, numbers, and underscores. Valid examples include: .to_string(), ) } - ParsingError::InvalidForma(_) => { + ParsingError::InvalidForma(_, _) => { let examples = vec![ Forma("Coffee"), Forma("Ingredients"), @@ -198,7 +198,7 @@ For example: .to_string(), ) } - ParsingError::InvalidGenus(_) => { + ParsingError::InvalidGenus(_, _) => { let examples = vec![ Genus::Single(Forma("Coffee")), Genus::Tuple(vec![Forma("Beans"), Forma("Water")]), @@ -234,7 +234,7 @@ doesn't have an input or result, per se. .to_string(), ) } - ParsingError::InvalidSignature(_) => { + ParsingError::InvalidSignature(_, _) => { let examples = vec![ Signature { domain: Genus::Single(Forma("A")), @@ -272,7 +272,7 @@ this form. .to_string(), ) } - ParsingError::InvalidDeclaration(_) => { + ParsingError::InvalidDeclaration(_, _) => { let examples = vec![ Procedure { name: Identifier("f"), @@ -374,7 +374,7 @@ Finally, variables can be assigned for the names of the input parameters: .to_string(), ) } - ParsingError::InvalidParameters(_) => { + ParsingError::InvalidParameters(_, _) => { let examples = vec![ Procedure { name: Identifier("create_bypass"), @@ -433,7 +433,7 @@ declarations (and in fact the same): .to_string(), ) } - ParsingError::InvalidSection(_) => { + ParsingError::InvalidSection(_, _) => { // Roman numeral sections don't have AST representation ( "Invalid section heading".to_string(), @@ -453,7 +453,7 @@ author of the Technique. .to_string(), ) } - ParsingError::InvalidInvocation(_) => { + ParsingError::InvalidInvocation(_, _) => { let examples = vec![ Invocation { target: Target::Local(Identifier("make_coffee")), @@ -484,7 +484,7 @@ If the procedure takes parameters they can be specified in parenthesis: .to_string(), ) } - ParsingError::InvalidFunction(_) => { + ParsingError::InvalidFunction(_, _) => { let examples = vec![ Function { target: Identifier("exec"), @@ -523,7 +523,7 @@ expressions as parameters as required: .to_string(), ) } - ParsingError::InvalidCodeBlock(_) => { + ParsingError::InvalidCodeBlock(_, _) => { let examples = vec![ Expression::Execution(Function { target: Identifier("exec"), @@ -554,7 +554,7 @@ Inline code blocks are enclosed in braces: .to_string(), ) } - ParsingError::InvalidMultiline(_) => ( + ParsingError::InvalidMultiline(_, _) => ( "Invalid multi-line string".to_string(), r#" Multi-line strings can be written by surrounding the content in triple @@ -584,7 +584,7 @@ it may be used by output templates when rendering the procedure. .trim_ascii() .to_string(), ), - ParsingError::InvalidStep(_) => ( + ParsingError::InvalidStep(_, _) => ( "Invalid step format".to_string(), r#" Steps must start with a number or lower-case letter (in the case of dependent @@ -604,7 +604,7 @@ dash. They can be done in either order, or concurrently: .trim_ascii() .to_string(), ), - ParsingError::InvalidSubstep(_) => ( + ParsingError::InvalidSubstep(_, _) => ( "Invalid substep format".to_string(), r#" Substeps can be nested below top-level dependent steps or top-level parallel @@ -630,7 +630,7 @@ parallel steps, but again this is not compulsory. .trim_ascii() .to_string(), ), - ParsingError::InvalidForeach(_) => { + ParsingError::InvalidForeach(_, _) => { let examples = vec![ Expression::Foreach( vec![Identifier("patient")], @@ -661,7 +661,7 @@ a list of tuples. .to_string(), ) } - ParsingError::InvalidResponse(_) => { + ParsingError::InvalidResponse(_, _) => { let examples = vec![ vec![ Response { @@ -723,7 +723,7 @@ By convention the response values are Proper Case. .to_string(), ) } - ParsingError::InvalidIntegral(_) => { + ParsingError::InvalidIntegral(_, _) => { let examples = vec![ Numeric::Integral(42), Numeric::Integral(-123), @@ -751,7 +751,7 @@ Integers cannot contain decimal points or units."#, .to_string(), ) } - ParsingError::InvalidQuantity(_) => { + ParsingError::InvalidQuantity(_, _) => { let examples = vec![ Numeric::Scientific(Quantity { mantissa: Decimal { @@ -816,7 +816,7 @@ a magnitude: .to_string(), ) } - ParsingError::InvalidQuantityDecimal(_) => ( + ParsingError::InvalidQuantityDecimal(_, _) => ( "Invalid number in quantity".to_string(), r#" The numeric part of a quantity may be positive or negative, and may have a @@ -829,7 +829,7 @@ Values less than 1 must have a leading '0' before the decimal."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantityUncertainty(_) => ( + ParsingError::InvalidQuantityUncertainty(_, _) => ( "Invalid uncertainty in quantity".to_string(), r#" Uncertainty values must be positive numbers: @@ -840,7 +840,7 @@ You can use '±' or `+/-`, followed by a decimal."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantityMagnitude(_) => ( + ParsingError::InvalidQuantityMagnitude(_, _) => ( "Invalid magnitude format".to_string(), r#" The magnitude of a quantity can be expressed in the usual scientific format @@ -855,7 +855,7 @@ The base must be 10, and the exponent must be an integer."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantitySymbol(_) => { + ParsingError::InvalidQuantitySymbol(_, _) => { let examples = vec![ Numeric::Scientific(Quantity { mantissa: Decimal { From 1c2c1badb599962cbb487fd60f1b80db08e476e7 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 18:10:15 +1000 Subject: [PATCH 10/15] Support superscript numbers in quantity units --- src/parsing/parser.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 2115f72..731e29d 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1862,20 +1862,25 @@ impl<'i> Parser<'i> { if ch.is_whitespace() || ch == ',' || ch == ')' { // Stop at whitespace, comma, or closing parameter boundary break; - } else if ch.is_ascii_alphabetic() || ch == '°' || ch == '/' || ch == 'μ' { + } else if ch.is_ascii_alphabetic() + || ch == '°' + || ch == '/' + || ch == 'μ' + || "⁰¹²³⁴⁵⁶⁷⁸⁹".contains(ch) + { // Valid character valid_end = byte_offset + ch.len_utf8(); } else { // Invalid character found - point directly at it return Err(ParsingError::InvalidQuantitySymbol( self.offset + byte_offset, - 0, + ch.len_utf8(), )); } } if valid_end == 0 { - return Err(ParsingError::InvalidQuantitySymbol(self.offset, 0)); + return Err(ParsingError::InvalidQuantitySymbol(self.offset, 1)); } let symbol = &self.source[..valid_end]; From 4e4f773c2a91c96439830641d93458a702b75a57 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 18:11:20 +1000 Subject: [PATCH 11/15] Add width to identifier errors --- src/parsing/parser.rs | 30 +++++++++++++++++++----------- tests/parsing/mod.rs | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 731e29d..b973076 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -891,7 +891,7 @@ impl<'i> Parser<'i> { let before = before.trim(); let name = validate_identifier(before).ok_or(ParsingError::InvalidIdentifier( self.offset, - 0, + before.len(), before.to_string(), ))?; @@ -909,7 +909,8 @@ impl<'i> Parser<'i> { let param = validate_identifier(item.trim_ascii()).ok_or( ParsingError::InvalidIdentifier( self.offset, - 0, + item.trim_ascii() + .len(), item.trim_ascii() .to_string(), ), @@ -937,12 +938,13 @@ impl<'i> Parser<'i> { .as_ptr() as isize - text.as_ptr() as isize; let error_offset = self.offset + one.start() + first_param_pos as usize; - return Err(ParsingError::InvalidParameters(error_offset, 0)); + let param_width = text.len() - first_param_pos as usize; + return Err(ParsingError::InvalidParameters(error_offset, param_width)); } let name = validate_identifier(text).ok_or(ParsingError::InvalidIdentifier( self.offset, - 0, + text.len(), text.to_string(), ))?; (name, None) @@ -1655,7 +1657,7 @@ impl<'i> Parser<'i> { let identifier = validate_identifier(possible).ok_or(ParsingError::InvalidIdentifier( self.offset, - 0, + possible.len(), possible.to_string(), ))?; @@ -2370,9 +2372,12 @@ impl<'i> Parser<'i> { .get(1) .ok_or(ParsingError::Expected(inner.offset, 0, "role name after @"))? .as_str(); - let identifier = validate_identifier(role_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, 0, role_name.to_string()), - )?; + let identifier = + validate_identifier(role_name).ok_or(ParsingError::InvalidIdentifier( + inner.offset, + role_name.len(), + role_name.to_string(), + ))?; attributes.push(Attribute::Role(identifier)); } // Check if it's a place '^' @@ -2385,9 +2390,12 @@ impl<'i> Parser<'i> { "place name after ^", ))? .as_str(); - let identifier = validate_identifier(place_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, 0, place_name.to_string()), - )?; + let identifier = + validate_identifier(place_name).ok_or(ParsingError::InvalidIdentifier( + inner.offset, + place_name.len(), + place_name.to_string(), + ))?; attributes.push(Attribute::Place(identifier)); } else { return Err(ParsingError::InvalidStep(inner.offset, 0)); diff --git a/tests/parsing/mod.rs b/tests/parsing/mod.rs index 25fc4d6..1609ecb 100644 --- a/tests/parsing/mod.rs +++ b/tests/parsing/mod.rs @@ -1,2 +1,2 @@ -mod samples; mod broken; +mod samples; From dd13fbb11749f115d83a3536f2c6cbb5aab0fdb5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 19:18:41 +1000 Subject: [PATCH 12/15] Handle - as filename indicating to read from stdin --- src/formatting/formatter.rs | 2 +- src/parsing/mod.rs | 16 ++++++++++++++++ src/problem/format.rs | 16 ++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 6acbe7a..497392c 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -623,7 +623,7 @@ impl<'i> Formatter<'i> { } // This is a helper for rendering a single descriptives in error messages. - // The real method is append_decriptives() below; this method simply + // The real method is append_descriptives() below; this method simply // creates a single element slice that can be passed to it. fn append_descriptive(&mut self, descriptive: &'i Descriptive) { use std::slice; diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 06e0588..71a4fe5 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -1,5 +1,6 @@ //! parser for the Technique language +use std::io::Read; use std::path::Path; use tracing::debug; @@ -15,6 +16,21 @@ pub use parser::{parse_with_recovery, Parser, ParsingError}; /// main function so that the Technique object created by parse() below can /// have the same lifetime. pub fn load(filename: &Path) -> Result> { + if filename.to_str() == Some("-") { + let mut buffer = String::new(); + match std::io::stdin().read_to_string(&mut buffer) { + Ok(_) => return Ok(buffer), + Err(error) => { + debug!(?error); + return Err(LoadingError { + problem: "Failed reading from stdin".to_string(), + details: error.to_string(), + filename, + }); + } + } + } + match std::fs::read_to_string(filename) { Ok(content) => Ok(content), Err(error) => { diff --git a/src/problem/format.rs b/src/problem/format.rs index 9d4eb26..cfb55d9 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -11,6 +11,7 @@ pub fn full_parsing_error<'i>( renderer: &impl Render, ) -> String { let (problem, details) = generate_error_message(error, renderer); + let input = generate_filename(filename); let offset = error.offset(); let width = error.width(); @@ -44,7 +45,7 @@ pub fn full_parsing_error<'i>( {} "#, "error".bright_red(), - filename.to_string_lossy(), + input, line, column, problem.bold(), @@ -71,6 +72,7 @@ pub fn concise_parsing_error<'i>( renderer: &impl Render, ) -> String { let (problem, _) = generate_error_message(error, renderer); + let input = generate_filename(filename); let offset = error.offset(); let i = calculate_line_number(source, offset); let j = calculate_column_number(source, offset); @@ -80,7 +82,7 @@ pub fn concise_parsing_error<'i>( format!( "{}: {}:{}:{} {}", "error".bright_red(), - filename.to_string_lossy(), + input, line, column, problem.bold(), @@ -101,6 +103,16 @@ pub fn concise_loading_error<'i>(error: &LoadingError<'i>) -> String { ) } +fn generate_filename(filename: &Path) -> String { + if filename.to_str() == Some("-") { + "".to_string() + } else { + filename + .display() + .to_string() + } +} + // Helper functions for line/column calculation pub fn calculate_line_number(content: &str, offset: usize) -> usize { content[..offset] From 6974fa2ba1de060f87049aee4ac69737dd74e0c9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 19:41:17 +1000 Subject: [PATCH 13/15] Update help to explain use of - for standard input --- src/main.rs | 4 ++-- src/rendering/mod.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index e5733df..8ba376b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,7 +76,7 @@ fn main() { .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to type-check."), + .help("The file containing the code for the Technique you want to parse and type check, or - to read from standard input."), ), ) .subcommand( @@ -101,7 +101,7 @@ fn main() { .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to format."), + .help("The file containing the code for the Technique you want to format, or - to read from standard input."), ), ) .subcommand( diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index 0d6ad12..f2d9250 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -1,3 +1,4 @@ +use owo_colors::OwoColorize; use serde::Serialize; use std::io::Write; use std::path::Path; @@ -25,6 +26,13 @@ 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: {}", From 25b0da32c9dbaf7271aa80591a40b9a3d1bcb40f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 20:35:06 +1000 Subject: [PATCH 14/15] Detect errors in function and invocation names --- src/parsing/checks/errors.rs | 39 +++++++++++++++++++ src/parsing/checks/parser.rs | 4 +- src/parsing/parser.rs | 75 +++++++++++++++++++++++++++++++++--- tests/broken/ScrapCode.tq | 3 ++ 4 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 tests/broken/ScrapCode.tq diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs index 7f5e291..b5a268b 100644 --- a/src/parsing/checks/errors.rs +++ b/src/parsing/checks/errors.rs @@ -239,6 +239,32 @@ making_coffee : ); } +#[test] +fn invalid_function_with_space_in_name() { + expect_error( + r#" +making_coffee : + + 1. Do something { re peat() } + "# + .trim_ascii(), + ParsingError::InvalidFunction(38, 0), + ); +} + +#[test] +fn invalid_function_with_space_and_invocation() { + expect_error( + r#" +making_coffee : + + 1. Do something { re peat () } + "# + .trim_ascii(), + ParsingError::InvalidFunction(38, 0), + ); +} + #[test] fn invalid_invocation_in_repeat() { expect_error( @@ -265,3 +291,16 @@ making_coffee : ParsingError::InvalidSubstep(37, 0), ); } + +#[test] +fn invalid_code_block_with_leftover_content() { + expect_error( + r#" +robot : + +Your plastic pal who's fun to be with! { re peat } + "# + .trim_ascii(), + ParsingError::InvalidCodeBlock(43, 7), + ); +} diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 60e5fc2..1a4253f 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -1587,8 +1587,8 @@ fn test_foreach_keyword_boundary() { input.initialize("{ foreachitem in items }"); let result = input.read_code_block(); - // Should parse as identifier, not foreach - assert_eq!(result, Ok(Expression::Variable(Identifier("foreachitem")))); + // Should fail because "foreachitem" is parsed but "in items" is leftover content + assert_eq!(result, Err(ParsingError::InvalidCodeBlock(2, 11))); } #[test] diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index b973076..7487095 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1353,8 +1353,56 @@ impl<'i> Parser<'i> { } fn read_code_block(&mut self) -> Result, ParsingError> { - self.take_block_chars("a code block", '{', '}', true, |outer| { - outer.read_expression() + self.take_block_chars("a code block", '{', '}', true, |inner| { + // Save the start position (accounting for leading whitespace that read_expression will trim) + inner.trim_whitespace(); + let start = inner.offset; + + let expression = inner.read_expression()?; + + // Check if there's leftover content + let offset_before_trim = inner.offset; + inner.trim_whitespace(); + if !inner + .source + .is_empty() + { + let mut width = offset_before_trim - start; // Width of what we parsed + + // Check if leftover looks like continuation of identifier + let leftover = inner + .source + .chars() + .next() + .map(|ch| { + ch.is_ascii_lowercase() + && !inner + .source + .starts_with("in ") + }) + .unwrap_or(false); + + if leftover { + // Include the space(s) between parts + width = inner.offset - start; + + // Add identifier-like characters from leftover + for ch in inner + .source + .chars() + { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' { + width += ch.len_utf8(); + } else { + break; + } + } + } + + return Err(ParsingError::InvalidCodeBlock(start, width)); + } + + Ok(expression) }) } @@ -1396,7 +1444,19 @@ impl<'i> Parser<'i> { let invocation = self.read_invocation()?; Ok(Expression::Application(invocation)) } else if is_function(content) { - let target = self.read_identifier()?; + // Extract the entire text before the opening parenthesis + self.trim_whitespace(); + let content = self.source; + let paren = content + .find('(') + .unwrap(); // is_function() already checked + let text = &content[0..paren]; + + // Validate that the entire text is a valid identifier + let target = validate_identifier(text) + .ok_or(ParsingError::InvalidFunction(self.offset, text.len()))?; + + self.advance(text.len()); let parameters = self.read_parameters()?; let function = Function { target, parameters }; @@ -1892,12 +1952,17 @@ impl<'i> Parser<'i> { /// Parse a target like or fn read_target(&mut self) -> Result, ParsingError> { + let start_offset = self.offset; self.take_block_chars("an invocation", '<', '>', true, |inner| { - let content = inner.source; + let content = inner + .source + .trim(); if content.starts_with("https://") { Ok(Target::Remote(External(content))) } else { - let identifier = inner.read_identifier()?; + let identifier = validate_identifier(content).ok_or_else(|| { + ParsingError::InvalidInvocation(start_offset + 1, content.len()) + })?; Ok(Target::Local(identifier)) } }) diff --git a/tests/broken/ScrapCode.tq b/tests/broken/ScrapCode.tq new file mode 100644 index 0000000..6b95a96 --- /dev/null +++ b/tests/broken/ScrapCode.tq @@ -0,0 +1,3 @@ +robot : + +Your plastic pal who's fun to be with! { re peat } From 29d98cdb325264c09e68b254f27b4e062c06b3ef Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Sep 2025 20:48:12 +1000 Subject: [PATCH 15/15] Refine precision of error tests --- src/parsing/checks/errors.rs | 44 +++++++++++++++++------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs index b5a268b..d852aff 100644 --- a/src/parsing/checks/errors.rs +++ b/src/parsing/checks/errors.rs @@ -1,7 +1,7 @@ use super::*; use std::path::Path; -/// Helper function to check if parsing produces the expected error type +/// Helper function to check if parsing produces the expected error fn expect_error(content: &str, expected: ParsingError) { let result = parse_with_recovery(Path::new("test.tq"), content); match result { @@ -10,14 +10,12 @@ fn expect_error(content: &str, expected: ParsingError) { content ), Err(errors) => { - // Check if any error matches the expected type - let found_expected = errors - .iter() - .any(|error| std::mem::discriminant(error) == std::mem::discriminant(&expected)); + // Check if any error exactly matches the expected error + let found_expected = errors.contains(&expected); if !found_expected { panic!( - "Expected error type like {:?} but got: {:?} for input '{}'", + "Expected error {:?} but got: {:?} for input '{}'", expected, errors, content ); } @@ -32,7 +30,7 @@ fn invalid_identifier_uppercase_start() { Making_Coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, 0, "".to_string()), + ParsingError::InvalidIdentifier(0, 13, "Making_Coffee".to_string()), ); } @@ -43,7 +41,7 @@ fn invalid_identifier_mixed_case() { makeCoffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, 0, "".to_string()), + ParsingError::InvalidIdentifier(0, 10, "makeCoffee".to_string()), ); } @@ -54,7 +52,7 @@ fn invalid_identifier_with_dashes() { make-coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, 0, "".to_string()), + ParsingError::InvalidIdentifier(0, 11, "make-coffee".to_string()), ); } @@ -65,7 +63,7 @@ fn invalid_identifier_with_spaces() { make coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidParameters(0, 0), + ParsingError::InvalidParameters(5, 6), ); } @@ -76,7 +74,7 @@ fn invalid_signature_wrong_arrow() { making_coffee : Ingredients => Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(0, 0), + ParsingError::InvalidSignature(28, 0), ); } @@ -87,7 +85,7 @@ fn invalid_genus_lowercase_forma() { making_coffee : ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16, 0), + ParsingError::InvalidGenus(16, 11), ); } @@ -98,7 +96,7 @@ fn invalid_genus_both_lowercase() { making_coffee : ingredients -> coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16, 0), + ParsingError::InvalidGenus(16, 11), ); } @@ -109,7 +107,7 @@ fn invalid_signature_missing_arrow() { making_coffee : Ingredients Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(16, 0), + ParsingError::InvalidSignature(28, 0), ); } @@ -131,7 +129,7 @@ fn invalid_identifier_in_parameters() { making_coffee(BadParam) : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(14, 0, "".to_string()), + ParsingError::InvalidIdentifier(0, 8, "BadParam".to_string()), ); } @@ -183,7 +181,7 @@ making_coffee : This is missing closing backticks "# .trim_ascii(), - ParsingError::InvalidMultiline(41, 0), + ParsingError::InvalidMultiline(24, 0), ); } @@ -196,7 +194,7 @@ making_coffee : 1. Do something { exec("command" "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(38, 0, "a code block", '{', '}'), + ParsingError::ExpectedMatchingChar(37, 0, "a code block", '{', '}'), ); } @@ -235,7 +233,7 @@ making_coffee : 1. Do something { exec("command" } "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(43, 0, "a function call", '(', ')'), + ParsingError::ExpectedMatchingChar(43, 0, "parameters for a function", '(', ')'), ); } @@ -248,7 +246,7 @@ making_coffee : 1. Do something { re peat() } "# .trim_ascii(), - ParsingError::InvalidFunction(38, 0), + ParsingError::InvalidFunction(39, 7), ); } @@ -261,7 +259,7 @@ making_coffee : 1. Do something { re peat () } "# .trim_ascii(), - ParsingError::InvalidFunction(38, 0), + ParsingError::InvalidFunction(39, 15), ); } @@ -274,7 +272,7 @@ making_coffee : 1. { repeat '), + ParsingError::ExpectedMatchingChar(33, 0, "an invocation", '<', '>'), ); } @@ -288,7 +286,7 @@ making_coffee : A. This should be lowercase "# .trim_ascii(), - ParsingError::InvalidSubstep(37, 0), + ParsingError::InvalidSubstep(43, 0), ); } @@ -301,6 +299,6 @@ robot : Your plastic pal who's fun to be with! { re peat } "# .trim_ascii(), - ParsingError::InvalidCodeBlock(43, 7), + ParsingError::InvalidCodeBlock(50, 7), ); }