diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 928443d..13a15e0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,30 +1,52 @@ name: Cargo Build & Test on: - push: - branches: + push: + branches: - master pull_request: branches: - master -env: +env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: cls project - latest + # Formatting + clippy run once on stable; rustfmt/clippy output is toolchain-specific, so it is + # not gated on the MSRV toolchain. + lint: + name: Lint (fmt + clippy) runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup update stable && rustup default stable + - run: cargo fmt -- --check + - run: cargo clippy --locked --workspace --all-targets -- -D warnings + + # The extension ships linux + mac binaries (see client.ts bundledBinaryPath). Windows is not a + # supported target — Windows users run the server under WSL — so the test matrix covers linux + + # mac only. + test: + name: Test (stable) / ${{ matrix.os }} + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - toolchain: - - stable + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - run: rustup update stable && rustup default stable + - run: cargo build --locked --verbose + - run: cargo test --locked --verbose + # MSRV: build + test on the declared minimum Rust (workspace `rust-version`). Deterministic + # because Cargo.lock is committed; `--locked` refuses to drift above it. No fmt/clippy here — + # those are toolchain-specific and gated in the `lint` job. + msrv: + name: MSRV (1.71) + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo clippy - - run: cargo fmt -- --check - - run: cargo build --verbose - - run: cargo test --verbose - + - uses: actions/checkout@v4 + - run: rustup update 1.71.0 && rustup default 1.71.0 + - run: cargo build --locked --verbose + - run: cargo test --locked --verbose diff --git a/.gitignore b/.gitignore index 36094d3..febe3d4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ out node_modules .vscode-test /target -Cargo.lock assets *.new *.pending-snap diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..19da4e8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,601 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "ccls" +version = "0.1.0" +dependencies = [ + "anyhow", + "insta", + "lsp-server", + "lsp-types", + "parser", + "path-absolutize", + "rowan", + "serde", + "serde_json", + "syntax", + "vfs", + "walkdir", +] + +[[package]] +name = "console" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crossbeam-channel" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "insta" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" +dependencies = [ + "console", + "linked-hash-map", + "once_cell", + "pin-project", + "serde", + "similar", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "logos" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf8b031682c67a8e3d5446840f9573eb7fe26efe7ec8d195c9ac4c0647c502f1" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d849148dbaf9661a6151d1ca82b13bb4c4c128146a88d05253b38d4e2f496c" +dependencies = [ + "beef", + "fnv", + "proc-macro2", + "quote", + "regex-syntax", + "syn 1.0.109", +] + +[[package]] +name = "lsp-server" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248f65b78f6db5d8e1b1604b4098a28b43d21a8eb1deeca22b1c421b276c7095" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "parser" +version = "0.1.0" +dependencies = [ + "insta", + "logos", + "lsp-types", + "rowan", + "serde", +] + +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pin-project" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e2ec53ad785f4d35dac0adea7f7dc6f1bb277ad84a680c7afefeae05d1f5916" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "rowan" +version = "0.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a58fa8a7ccff2aec4f39cc45bf5f985cec7125ab271cf681c279fd00192b49" +dependencies = [ + "countme", + "hashbrown", + "memoffset", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntax" +version = "0.1.0" +dependencies = [ + "insta", + "lsp-types", + "parser", + "rowan", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "vfs" +version = "0.1.0" +dependencies = [ + "path-absolutize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xflags" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4554b580522d0ca238369c16b8f6ce34524d61dafe7244993754bbd05f2c2ea" +dependencies = [ + "xflags-macros", +] + +[[package]] +name = "xflags-macros" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58e7b3ca8977093aae6b87b6a7730216fc4c53a6530bab5c43a783cd810c1a8" + +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "xflags", + "xshell", +] diff --git a/crates/lsp/src/file_db.rs b/crates/lsp/src/file_db.rs index 3da10f2..d7ae89b 100644 --- a/crates/lsp/src/file_db.rs +++ b/crates/lsp/src/file_db.rs @@ -125,9 +125,9 @@ impl FileDB { #[cfg(test)] mod tests { - use std::path::Path; + use lsp_types::Position; - use lsp_types::{Position, Url}; + use crate::test_util::file_url; use super::{FileDB, FileId}; @@ -136,11 +136,7 @@ mod tests { // Source begins with a '\n', so line 0 is empty and line 1 is "one". let source = "\none\ntwo\nthree\n"; - let file_db = FileDB::new( - FileId(1), - source, - Url::from_file_path(Path::new("/tmp.txt")).unwrap(), - ); + let file_db = FileDB::new(FileId(1), source, file_url("tmp.txt")); // Line 1 ("one") starts at byte 1; character 1 -> byte 2 ('n'). assert_eq!(file_db.offset(Position::new(1, 1)), 2.into()); @@ -158,11 +154,7 @@ mod tests { "#; // newline byte offsets: 0, 12, 24 (the leading `\n` then the indented lines) - let file_db = FileDB::new( - FileId(1), - source, - Url::from_file_path(Path::new("/tmp.txt")).unwrap(), - ); + let file_db = FileDB::new(FileId(1), source, file_url("tmp.txt")); assert_eq!(Position::new(1, 1), file_db.position(2.into())); assert_eq!(Position::new(0, 0), file_db.position(0.into())); } @@ -173,11 +165,7 @@ mod tests { fn utf16_offset_round_trip_test() { // "é" is 2 bytes in UTF-8 / 1 UTF-16 unit; "😀" is 4 bytes / 2 UTF-16 units (surrogate pair). let source = "é😀x"; - let file_db = FileDB::new( - FileId(1), - source, - Url::from_file_path(Path::new("/tmp.txt")).unwrap(), - ); + let file_db = FileDB::new(FileId(1), source, file_url("tmp.txt")); // byte offsets: é=0, 😀=2, x=6. UTF-16 units: é=0, 😀=1, x=3. assert_eq!( @@ -208,11 +196,7 @@ mod tests { fn multibyte_before_newline_no_panic_test() { // Line 0 ends with `─` (U+2500, 3 bytes); line 1 is `ab`. Bytes: ─=0..3, \n=3, a=4, b=5. let source = "─\nab"; - let file_db = FileDB::new( - FileId(1), - source, - Url::from_file_path(Path::new("/tmp.txt")).unwrap(), - ); + let file_db = FileDB::new(FileId(1), source, file_url("tmp.txt")); // The newline is at byte 3, not char index 1. assert_eq!(file_db.newline_offsets, vec![3]); diff --git a/crates/lsp/src/global_state.rs b/crates/lsp/src/global_state.rs index 1d1ce8b..a9e1f3d 100644 --- a/crates/lsp/src/global_state.rs +++ b/crates/lsp/src/global_state.rs @@ -9,7 +9,8 @@ use lsp_types::request::{ HoverRequest, PrepareRenameRequest, References, Rename, Request as _, }; use lsp_types::{ - DidChangeTextDocumentParams, DidOpenTextDocumentParams, FileChangeType, Location, Range, Url, + Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidOpenTextDocumentParams, + FileChangeType, Location, Range, Url, }; use parser::token_kind::TokenKind; use rowan::ast::AstNode; @@ -145,6 +146,32 @@ impl GlobalState { }) } + /// LSP diagnostics for `uri` from its cached parse/lexer errors. Empty for unknown or + /// textless files (never panics — mirrors the `cursor_context` None-text guard). + pub fn diagnostics_for_uri(&self, uri: &Url) -> Vec { + let Some(id) = self.source_db.id_for_url(uri) else { + return Vec::new(); + }; + if self.source_db.vfs().file_text(id).is_none() { + return Vec::new(); + } + let file_db = self.source_db.file_db(id); + let errors = self.source_db.errors(id); + errors + .iter() + .map(|e| Diagnostic { + range: Range { + start: file_db.position(e.range.start()), + end: file_db.position(e.range.end()), + }, + severity: Some(DiagnosticSeverity::ERROR), + source: Some("circom".to_string()), + message: e.msg.clone(), + ..Default::default() + }) + .collect() + } + /// Dispatch an LSP request to its handler by method name; `Ok(None)` for unhandled methods. /// Add a request = one arm here + a handler module + a capability entry. pub fn handle_request(&self, req: Request) -> Result> { @@ -168,24 +195,35 @@ impl GlobalState { } } - /// Dispatch an LSP notification. Document open/change load includes; didClose drops open-doc - /// tracking; watched-file changes keep the project basename index fresh (created/deleted - /// `.circom` files); workspace-folder changes walk the newly-added roots. - pub fn handle_notification(&mut self, not: Notification) -> Result<()> { + /// Dispatch an LSP notification. Document open/change load includes and publish diagnostics; + /// didClose drops open-doc tracking and clears diagnostics; watched-file changes keep the + /// project basename index fresh (created/deleted `.circom` files); workspace-folder changes + /// walk the newly-added roots. Returns `(uri, diagnostics)` pairs for the main loop to publish. + pub fn handle_notification( + &mut self, + not: Notification, + ) -> Result)>> { + let mut to_publish = Vec::new(); match not.method.as_str() { DidOpenTextDocument::METHOD => { let params: DidOpenTextDocumentParams = serde_json::from_value(not.params)?; + let uri = params.text_document.uri.clone(); self.handle_update(TextDocument::from(params))?; + to_publish.push((uri.clone(), self.diagnostics_for_uri(&uri))); } DidChangeTextDocument::METHOD => { let params: DidChangeTextDocumentParams = serde_json::from_value(not.params)?; + let uri = params.text_document.uri.clone(); self.handle_update(TextDocument::from(params))?; + to_publish.push((uri.clone(), self.diagnostics_for_uri(&uri))); } DidCloseTextDocument::METHOD => { if let Ok(params) = serde_json::from_value::(not.params) { - self.open_documents.remove(¶ms.text_document.uri); + let uri = params.text_document.uri.clone(); + self.open_documents.remove(&uri); + to_publish.push((uri, Vec::new())); } } DidChangeWatchedFiles::METHOD => { @@ -202,7 +240,7 @@ impl GlobalState { } _ => {} } - Ok(()) + Ok(to_publish) } /// Apply one watched-file change to the index. `*.circom` only. Created → intern; Deleted → @@ -384,13 +422,10 @@ impl GlobalState { let Some(ast) = self.source_db.ast(origin.file_id) else { return Vec::new(); }; - ast.libs() + ast.include_paths() .into_iter() - .filter_map(|inc| inc.lib()) .filter_map(|path| { - let id = self - .source_db - .id_for_include(&origin.file_path, &path.value())?; + let id = self.source_db.id_for_include(&origin.file_path, &path)?; let has_text = self.source_db.vfs().file_text(id).is_some(); has_text.then_some(id) }) @@ -639,13 +674,8 @@ impl GlobalState { // Includes load from disk once then cache; symbol tables build lazily on first query. if let Some(ast) = self.source_db.ast(id) { - for include in ast.libs() { - let Some(include_path) = include.lib() else { - continue; - }; - let _ = self - .source_db - .load_include(&text_document.uri, &include_path.value()); + for path in ast.include_paths() { + let _ = self.source_db.load_include(&text_document.uri, &path); } } @@ -699,6 +729,8 @@ mod tests { use lsp_types::Url; + use crate::test_util::file_url; + use crate::source_db::SourceDatabase; use super::{GlobalState, TextDocument}; @@ -719,6 +751,47 @@ mod tests { Url::from_file_path(&path).unwrap() } + /// A clean program yields no diagnostics; a missing `;` yields an ERROR diagnostic whose + /// message references the expected token. + #[test] + fn diagnostics_for_clean_and_broken_docs_test() { + let mut state = GlobalState::new(Vec::new()); + + let clean = file_url("diag_clean.circom"); + state + .source_db + .set_document(&clean, "pragma circom 2.0.0;\n".to_string()); + assert!( + state.diagnostics_for_uri(&clean).is_empty(), + "clean file should have no diagnostics" + ); + + let broken = file_url("diag_broken.circom"); + state + .source_db + .set_document(&broken, "pragma circom 2.0.0".to_string()); + let diags = state.diagnostics_for_uri(&broken); + assert_eq!(diags.len(), 1, "missing semicolon: {diags:?}"); + assert_eq!( + diags[0].severity, + Some(lsp_types::DiagnosticSeverity::ERROR) + ); + assert_eq!(diags[0].source.as_deref(), Some("circom")); + assert!( + diags[0].message.contains("Semicolon"), + "{}", + diags[0].message + ); + } + + /// A textless/unknown file yields no diagnostics and never panics. + #[test] + fn diagnostics_for_unknown_uri_is_empty_test() { + let state = GlobalState::new(Vec::new()); + let unknown = file_url("nope.circom"); + assert!(state.diagnostics_for_uri(&unknown).is_empty()); + } + /// Editing the main file must never re-read or re-parse an unchanged `include`: /// `parse_count` for the lib stays at 1 across a main-file `didChange`. (Indexing is lazy — the /// lib parses on first query, triggered here by an explicit `ast` query.) @@ -1096,7 +1169,10 @@ mod tests { "pragma circom 2.0.0;\ntemplate Lib() { signal output o; o <== 0; }\n", ) .unwrap(); - let lib_url = Url::from_file_path(inner.join("lib.circom")).unwrap(); + // Canonicalize so the URL matches the canonicalized path the workspace walk interns the + // file under (on macOS `temp_dir()` lives under a symlinked `/private/var`). + let lib_path = inner.join("lib.circom").canonicalize().unwrap(); + let lib_url = Url::from_file_path(&lib_path).unwrap(); // Both the outer project and its nested sub-folder are roots. let mut state = GlobalState::new(vec![ diff --git a/crates/lsp/src/handler.rs b/crates/lsp/src/handler.rs index 972ec14..f75938d 100644 --- a/crates/lsp/src/handler.rs +++ b/crates/lsp/src/handler.rs @@ -2,8 +2,10 @@ //! the signature `fn handle(state: &GlobalState, params: P) -> Result>`. Dispatch lives in //! [`crate::global_state::GlobalState::handle_request`]. //! -//! `goto_definition` is fully implemented; the rest are placeholders that return `None`/empty until -//! their logic is filled in. +//! Implemented: `goto_definition`, `goto_implementation` (delegates to definition), `hover`, +//! `completion`, `references`, `rename` (+ `prepareRename`), `workspace_symbol`. Placeholders +//! returning `None`: `document_symbol`, `formatting`. Diagnostics are pushed via +//! `textDocument/publishDiagnostics` from the notification path (no request handler). pub mod completion; pub mod document_symbol; diff --git a/crates/lsp/src/handler/completion.rs b/crates/lsp/src/handler/completion.rs index ac9c304..a686314 100644 --- a/crates/lsp/src/handler/completion.rs +++ b/crates/lsp/src/handler/completion.rs @@ -9,38 +9,13 @@ use anyhow::Result; use lsp_types::{ CompletionItem, CompletionItemKind, CompletionList, CompletionParams, CompletionResponse, }; +use parser::token_kind::KEYWORDS; use rowan::TextSize; use crate::global_state::{CursorContext, GlobalState}; use crate::resolver::SymbolKind; use crate::source_db::SourceDatabase; -/// Reserved circom keywords (mirror the lexer keywords in `token_kind.rs`). A constant list keeps -/// completion allocation-free; drift is low (keywords change rarely). -const KEYWORDS: &[&str] = &[ - "pragma", - "include", - "template", - "function", - "bus", - "signal", - "input", - "output", - "component", - "var", - "parallel", - "custom", - "extern_c", - "custom_templates", - "return", - "for", - "while", - "if", - "else", - "log", - "assert", -]; - /// Entry point for `textDocument/completion`. Suggests in-scope body symbols + file top-level /// names + keywords, deduped by name. `None` for an unknown file. pub fn handle(state: &GlobalState, params: CompletionParams) -> Result> { @@ -182,7 +157,7 @@ mod tests { use crate::file_db::{FileDB, FileId}; use crate::global_state::GlobalState; - use crate::test_util::state_with; + use crate::test_util::{file_url, state_with}; use super::handle; use lsp_types::{ @@ -191,7 +166,7 @@ mod tests { /// Position at the byte offset just past the last occurrence of `needle`. fn position_after_last(source: &str, needle: &str) -> Position { - let file = FileDB::new(FileId(0), source, Url::from_file_path("/tmp/x").unwrap()); + let file = FileDB::new(FileId(0), source, file_url("x")); let idx = source.rfind(needle).unwrap_or(0) + needle.len(); file.position(TextSize::from(idx as u32)) } @@ -228,7 +203,7 @@ mod tests { fn completion_in_scope_includes_body_symbols_test() { let source = "pragma circom 2.0.0;\ntemplate T(a) {\n signal input b;\n signal output c;\n}\n"; - let url = Url::from_file_path("/tmp/c.circom").unwrap(); + let url = file_url("c.circom"); let state = state_with(&url, source); // Position inside the body (line 2, col 4 — past `{`). @@ -243,7 +218,7 @@ mod tests { #[test] fn completion_at_top_level_excludes_body_symbols_test() { let source = "pragma circom 2.0.0;\ntemplate T(a) {\n signal input b;\n}\n"; - let url = Url::from_file_path("/tmp/t.circom").unwrap(); + let url = file_url("t.circom"); let state = state_with(&url, source); // Position on the pragma line (before any template — top-level scope). @@ -261,7 +236,7 @@ mod tests { #[test] fn completion_offers_keywords_test() { let source = "pragma circom 2.0.0;\n"; - let url = Url::from_file_path("/tmp/k.circom").unwrap(); + let url = file_url("k.circom"); let state = state_with(&url, source); let got = labels(&state, &url, Position::new(0, 0)); @@ -274,7 +249,7 @@ mod tests { #[test] fn completion_dedups_test() { let source = "pragma circom 2.0.0;\ntemplate T() {\n signal input a;\n}\n"; - let url = Url::from_file_path("/tmp/d.circom").unwrap(); + let url = file_url("d.circom"); let state = state_with(&url, source); let items = match handle( @@ -306,7 +281,7 @@ mod tests { #[test] fn member_completion_offers_template_signals_test() { let source = "pragma circom 2.0.0;\ntemplate T() {\n signal input a;\n signal input b;\n signal output c;\n}\ntemplate Main() {\n component m = T();\n m.\n}\n"; - let url = Url::from_file_path("/tmp/m.circom").unwrap(); + let url = file_url("m.circom"); let state = state_with(&url, source); // Cursor right after the `m.` member-access dot. @@ -329,7 +304,7 @@ mod tests { #[test] fn member_completion_non_component_falls_through_test() { let source = "pragma circom 2.0.0;\ntemplate T() { signal input a; }\ntemplate Main() {\n var v = 0;\n v.\n}\n"; - let url = Url::from_file_path("/tmp/n.circom").unwrap(); + let url = file_url("n.circom"); let state = state_with(&url, source); let got = labels(&state, &url, position_after_last(source, "v.")); @@ -348,7 +323,7 @@ mod tests { #[test] fn completion_scope_is_per_template_test() { let source = "pragma circom 2.0.0;\ntemplate A() {\n signal input aa;\n}\ntemplate B() {\n signal input bb;\n}\n"; - let url = Url::from_file_path("/tmp/s.circom").unwrap(); + let url = file_url("s.circom"); let state = state_with(&url, source); let in_a = labels(&state, &url, Position::new(2, 4)); // inside A's body diff --git a/crates/lsp/src/handler/goto_definition.rs b/crates/lsp/src/handler/goto_definition.rs index 1eb3f4c..12a3ad6 100644 --- a/crates/lsp/src/handler/goto_definition.rs +++ b/crates/lsp/src/handler/goto_definition.rs @@ -74,7 +74,7 @@ mod tests { use crate::file_db::FileDB; use crate::global_state::GlobalState; use crate::source_db::SourceDatabase; - use crate::test_util::state_with; + use crate::test_util::{file_url, state_with}; use parser::token_kind::TokenKind; use super::token_at_offset; @@ -92,11 +92,7 @@ mod tests { fn goto_decl_test() { let file_path = "/src/test_files/handler/templates.circom"; let source = get_source_from_path(file_path); - let file_db = FileDB::new( - vfs::FileId(0), - &source, - Url::from_file_path(Path::new("/tmp")).unwrap(), - ); + let file_db = FileDB::new(vfs::FileId(0), &source, file_url("tmp")); let syntax_node = syntax_tree(&source); @@ -129,12 +125,11 @@ mod tests { #[test] fn url_test() { - let url = Url::from_file_path(Path::new("/hello/abc.tx")); - let binding = url.unwrap(); - let path = binding.path(); - let parent = Path::new(path).parent().unwrap().to_str().unwrap(); - - assert_eq!("/hello", parent); + // A file URL round-trips to a parent path on every platform (Windows path strings differ + // from Unix, so assert derivability rather than a hardcoded `/hello`). + let file = std::env::temp_dir().join("abc.tx"); + let url = Url::from_file_path(&file).unwrap(); + assert!(Path::new(url.path()).parent().is_some()); } /// `lookup_definition` for the `occurrence`-th `Identifier` token named `name`, using the db's @@ -200,7 +195,7 @@ mod tests { #[test] fn main_component_same_file_jump_test() { let source = "pragma circom 2.0.0;\ntemplate X() { signal output o; o <== 0; }\ncomponent main = X();\n"; - let url = Url::from_file_path("/tmp/mc_same.circom").unwrap(); + let url = file_url("mc_same.circom"); let state = state_with(&url, source); // The `X` usage in `component main = X()` is the 2nd `X` token (0th = the definition). @@ -265,7 +260,7 @@ mod tests { #[test] fn member_field_named_jump_test() { let source = "pragma circom 2.0.0;\ntemplate Multiplier2() {\n signal input in[2];\n signal output out;\n out <== in[0] * in[1];\n}\ntemplate Main() {\n component c = Multiplier2();\n signal output res;\n res <== c.out;\n}\n"; - let url = Url::from_file_path("/tmp/mf_named.circom").unwrap(); + let url = file_url("mf_named.circom"); let state = state_with(&url, source); // `out` occurrences: [0]=decl, [1]=usage in Multiplier2, [2]=the `c.out` field. @@ -286,7 +281,7 @@ mod tests { #[test] fn member_field_anonymous_jump_test() { let source = "pragma circom 2.0.0;\ntemplate Multiplier2() {\n signal input in[2];\n signal output out;\n out <== in[0] * in[1];\n}\ntemplate Main() {\n signal input a;\n signal input b;\n signal output c;\n c <== Multiplier2()([a, b]).out;\n}\n"; - let url = Url::from_file_path("/tmp/mf_anon.circom").unwrap(); + let url = file_url("mf_anon.circom"); let state = state_with(&url, source); // `out` occurrences: [0]=decl, [1]=usage in Multiplier2, [2]=the anonymous `.out` field. diff --git a/crates/lsp/src/handler/goto_implementation.rs b/crates/lsp/src/handler/goto_implementation.rs index f2bba59..6685416 100644 --- a/crates/lsp/src/handler/goto_implementation.rs +++ b/crates/lsp/src/handler/goto_implementation.rs @@ -29,7 +29,7 @@ mod tests { use syntax::tree::syntax_tree; use crate::source_db::SourceDatabase; - use crate::test_util::state_with; + use crate::test_util::{file_url, state_with}; /// Drive the real `textDocument/implementation` handler at the `occurrence`-th token whose /// kind+text match, returning the resolved `Location`s. @@ -77,7 +77,7 @@ mod tests { fn implementation_matches_definition_same_file_test() { let source = "pragma circom 2.0.0;\ntemplate X() { signal output o; o <== 0; }\ncomponent main = X();\n"; - let url = Url::from_file_path("/tmp/impl_same.circom").unwrap(); + let url = file_url("impl_same.circom"); let state = state_with(&url, source); // The `X` usage in `component main = X()` is the 2nd `X` token (0th = the definition). @@ -96,7 +96,7 @@ mod tests { #[test] fn implementation_on_include_string_test() { let source = "pragma circom 2.0.0;\ninclude \"lib.circom\";\ncomponent main = X();\n"; - let url = Url::from_file_path("/tmp/impl_inc.circom").unwrap(); + let url = file_url("impl_inc.circom"); // `state_with` is single-file; the include won't resolve to a real lib, but the handler // must still run without panicking and return an array (here empty, as the lib is absent). let state = state_with(&url, source); diff --git a/crates/lsp/src/handler/hover.rs b/crates/lsp/src/handler/hover.rs index 27a0fa7..4de0bd9 100644 --- a/crates/lsp/src/handler/hover.rs +++ b/crates/lsp/src/handler/hover.rs @@ -79,7 +79,7 @@ mod tests { use lsp_types::{Position, Url}; use crate::global_state::GlobalState; - use crate::test_util::{position_of, state_with}; + use crate::test_util::{file_url, position_of, state_with}; use super::handle; use lsp_types::{ @@ -109,7 +109,7 @@ mod tests { #[test] fn hover_signal_usage_shows_decl_test() { let source = "pragma circom 2.0.0;\ntemplate T() {\n signal input a;\n signal output c;\n c <== a;\n}\n"; - let url = Url::from_file_path("/tmp/h.circom").unwrap(); + let url = file_url("h.circom"); let state = state_with(&url, source); // Cursor on the `a` usage in `c <== a` (line 4, col ~10). @@ -123,7 +123,7 @@ mod tests { #[test] fn hover_template_name_shows_header_test() { let source = "pragma circom 2.0.0;\ntemplate Multiplier2(a, b) {\n signal input a;\n signal output c;\n}\n"; - let url = Url::from_file_path("/tmp/h2.circom").unwrap(); + let url = file_url("h2.circom"); let state = state_with(&url, source); let v = hover_value(&state, &url, Position::new(1, 12)).expect("hover present"); @@ -141,7 +141,7 @@ mod tests { #[test] fn hover_unresolved_is_none_test() { let source = "pragma circom 2.0.0;\ntemplate T() { signal input a; }\n"; - let url = Url::from_file_path("/tmp/h3.circom").unwrap(); + let url = file_url("h3.circom"); let state = state_with(&url, source); // Cursor on `template` (a keyword — identifier_at returns None). @@ -153,7 +153,7 @@ mod tests { #[test] fn hover_member_field_anonymous_test() { let source = "pragma circom 2.0.0;\ntemplate T() {\n signal output out;\n out <== 0;\n}\ntemplate Main() {\n signal output c;\n c <== T()().out;\n}\n"; - let url = Url::from_file_path("/tmp/hmem.circom").unwrap(); + let url = file_url("hmem.circom"); let state = state_with(&url, source); // `out` occurrences: [0]=decl, [1]=usage in T, [2]=the `.out` field. diff --git a/crates/lsp/src/handler/references.rs b/crates/lsp/src/handler/references.rs index ad164c6..20b5841 100644 --- a/crates/lsp/src/handler/references.rs +++ b/crates/lsp/src/handler/references.rs @@ -52,7 +52,6 @@ pub fn handle(state: &GlobalState, params: ReferenceParams) -> Result Result<(), Box> { /// Advertise the LSP features this server handles. /// -/// `definition` and `implementation` are fully implemented; `implementation` behaves identically -/// to `definition` (Circom has no separate implementation targets). `hover`/`completion`/ -/// `references`/`documentSymbol`/`formatting` are registered as placeholders — the client routes -/// them to the server, which currently returns an empty result until each is implemented in -/// `handler::*`. `rename` is fully implemented and advertises `prepareSupport` so the client -/// consults the server (not its own textual word check) before opening the rename box — -/// keywords/strings never become renamable. +/// All advertised providers are implemented: `definition`, `implementation` (identical to +/// definition — circom has no separate implementation targets), `hover`, `completion`, +/// `references`, `rename` (with `prepareSupport`), `workspace_symbol`. `documentSymbol` and +/// `formatting` are registered but currently return empty results. Diagnostics are sent via +/// `publishDiagnostics` (no capability needed). fn server_capabilities() -> ServerCapabilities { ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), @@ -130,7 +128,19 @@ fn main_loop( watcher_registered = true; register_watched_files_capability(&connection, ¶ms, &roots)?; } - state.handle_notification(not)?; + let to_publish = state.handle_notification(not)?; + for (uri, diagnostics) in to_publish { + let params = lsp_types::PublishDiagnosticsParams { + uri, + diagnostics, + version: None, + }; + let not = lsp_server::Notification::new( + lsp_types::notification::PublishDiagnostics::METHOD.to_string(), + serde_json::to_value(¶ms)?, + ); + connection.sender.send(Message::Notification(not))?; + } } } } diff --git a/crates/lsp/src/project_index.rs b/crates/lsp/src/project_index.rs index ecf46e2..b5bf4e9 100644 --- a/crates/lsp/src/project_index.rs +++ b/crates/lsp/src/project_index.rs @@ -50,6 +50,16 @@ pub(crate) fn collect_circom_files(roots: &[PathBuf]) -> Vec { pub(crate) fn collect_circom_files_with_content(roots: &[PathBuf]) -> Vec<(PathBuf, String)> { let mut out = Vec::new(); for canon in collect_circom_files(roots) { + if let Ok(meta) = std::fs::metadata(&canon) { + if meta.len() > crate::source_db::MAX_FILE_BYTES { + eprintln!( + "ccls: skipping {} ({} bytes > limit)", + canon.display(), + meta.len() + ); + continue; + } + } if let Ok(content) = std::fs::read_to_string(&canon) { out.push((canon, content)); } @@ -92,12 +102,7 @@ impl GlobalState { let libs: Vec = self .source_db .ast(id) - .map(|a| { - a.libs() - .into_iter() - .filter_map(|i| i.lib().map(|l| l.value())) - .collect() - }) + .map(|a| a.include_paths()) .unwrap_or_default(); for rel in libs { let _ = self.source_db.load_include(&uri, &rel); diff --git a/crates/lsp/src/resolver.rs b/crates/lsp/src/resolver.rs index f020050..3a114bc 100644 --- a/crates/lsp/src/resolver.rs +++ b/crates/lsp/src/resolver.rs @@ -176,11 +176,10 @@ pub fn occurrences_in( #[cfg(test)] mod tests { - use std::path::Path; - - use lsp_types::Url; use parser::token_kind::TokenKind; use rowan::ast::AstNode; + + use crate::test_util::file_url; use syntax::abstract_syntax_tree::{ AstCircomProgram, AstComponentCall, AstComponentDecl, AstInputSignalDecl, AstSignalDecl, AstVarDecl, @@ -195,11 +194,7 @@ mod tests { /// Build the (file_db, ast, symbol_table) triple from inline source. fn index(source: &str) -> (FileDB, AstCircomProgram, SymbolTable) { - let file = FileDB::new( - FileId(0), - source, - Url::from_file_path(Path::new("/tmp/test.circom")).unwrap(), - ); + let file = FileDB::new(FileId(0), source, file_url("test.circom")); let node = syntax_tree(source); let ast = AstCircomProgram::cast(node).expect("source should parse to a program"); let table = SymbolTable::build(&file, &ast); diff --git a/crates/lsp/src/source_db.rs b/crates/lsp/src/source_db.rs index 28011ed..1a40f58 100644 --- a/crates/lsp/src/source_db.rs +++ b/crates/lsp/src/source_db.rs @@ -15,12 +15,16 @@ use lsp_types::Url; use rowan::ast::AstNode; use syntax::abstract_syntax_tree::AstCircomProgram; use syntax::node::SyntaxNode; -use syntax::tree::syntax_tree; +use syntax::tree::{parse as parse_tree, SyntaxError}; use vfs::{ChangedFile, Vfs, VfsPath}; use crate::file_db::{FileDB, FileId}; use crate::symbol_table::SymbolTable; +/// Skip `.circom` files larger than this. Circom codegen can emit multi-MB files that would stall +/// the single-threaded server to parse/index. +pub(crate) const MAX_FILE_BYTES: u64 = 1 << 20; // 1 MiB + /// Source-level queries over open files, keyed by [`FileId`] — inputs (`file_text`) or /// content-derived values (`parse`/`ast`/`file_db`/`symbol_table`). All `&self` with memoization /// via interior mutability; callers get owned values (no borrow across a query). @@ -36,6 +40,8 @@ pub trait SourceDatabase { /// The lexical symbol table for `id` (memoized); built lazily on first query, dropped by the /// change-log invalidation on any edit. fn symbol_table(&self, id: FileId) -> Arc; + /// The syntax errors for `id` (memoized); lexer + parser errors aggregated at parse time. + fn errors(&self, id: FileId) -> Arc>; } /// Lazily-computed caches behind one `RefCell` so a query takes only one short-lived borrow (read @@ -46,6 +52,7 @@ struct Caches { parse: HashMap, file_db: HashMap, symbol_table: HashMap>, + errors: HashMap>>, /// Cache-miss parses per file (test-only memoization proof). Not touched by invalidation — it /// counts total parses, not cache state. parse_count: HashMap, @@ -72,19 +79,19 @@ impl ContentCacheDb { parse: HashMap::new(), file_db: HashMap::new(), symbol_table: HashMap::new(), + errors: HashMap::new(), parse_count: HashMap::new(), }), } } - /// Set the workspace roots confining `include` resolution (delegated to the [`Vfs`], which owns - /// them + the pure containment check). + /// Set the workspace roots scoping the project `.circom` walk (the basename-index source). + /// `include` resolution itself is circom-style (relative to the source file), not confined. pub fn set_workspace_roots(&mut self, roots: Vec) { self.vfs.set_workspace_roots(roots); } - /// Read-only [`Vfs`] handle (e.g. so `include_target_location` applies the same containment check as - /// `load_include`). + /// Read-only [`Vfs`] handle. pub(crate) fn vfs(&self) -> &Vfs { &self.vfs } @@ -150,7 +157,7 @@ impl ContentCacheDb { /// transitively — so goto-def/hover *inside* an include (e.g. one opened via peek/jump without a /// full didOpen) can still resolve across that include's own includes. Same-dir path first, then /// the project-wide basename fallback. `None` for non-`file:` URIs, missing/unreadable files, or - /// includes escaping the workspace roots. + /// absolute include paths. pub fn load_include(&mut self, parent_url: &Url, rel: &str) -> Option { let id = self.load_one_include(parent_url, rel)?; let mut visited = HashSet::new(); @@ -191,15 +198,7 @@ impl ContentCacheDb { let Ok(parent_url) = Url::from_file_path(path.as_path()) else { return; }; - let includes: Vec = self - .ast(id) - .map(|a| { - a.libs() - .into_iter() - .filter_map(|i| i.lib().map(|l| l.value())) - .collect() - }) - .unwrap_or_default(); + let includes: Vec = self.ast(id).map(|a| a.include_paths()).unwrap_or_default(); for rel in includes { if let Some(child) = self.load_one_include(&parent_url, &rel) { if visited.insert(child) { @@ -216,12 +215,21 @@ impl ContentCacheDb { /// ([`Vfs::find_include`]) only returns files the workspace walk already indexed. Serves a /// cached id when the text is already loaded. fn load_from_disk(&mut self, vpath: &VfsPath) -> Option { - // Serve the cached id if text is already loaded; a path-only id (from the walk) reads below. if let Some(id) = self.vfs.file_id(vpath) { if self.vfs.file_text(id).is_some() { return Some(id); } } + if let Ok(meta) = std::fs::metadata(vpath.as_path()) { + if meta.len() > MAX_FILE_BYTES { + eprintln!( + "ccls: skipping {} ({} bytes > {MAX_FILE_BYTES} limit)", + vpath.as_path().display(), + meta.len() + ); + return None; + } + } let src = std::fs::read_to_string(vpath.as_path()).ok()?; let id = self .vfs @@ -240,6 +248,7 @@ impl ContentCacheDb { caches.parse.remove(file_id); caches.file_db.remove(file_id); caches.symbol_table.remove(file_id); + caches.errors.remove(file_id); } } changes @@ -256,6 +265,20 @@ impl ContentCacheDb { .expect("VfsPath was validated as file: scheme at registration time") } + /// Lazily parse `id` once and cache both the tree and its errors. Shared by the `parse` and + /// `errors` queries so a single miss fills both caches. + fn ensure_parsed(&self, id: FileId) { + if self.caches.borrow().parse.contains_key(&id) { + return; + } + let text = self.file_text(id); + let parsed = parse_tree(&text); + let mut caches = self.caches.borrow_mut(); + caches.parse.insert(id, parsed.tree); + caches.errors.insert(id, Arc::new(parsed.errors)); + *caches.parse_count.entry(id).or_insert(0) += 1; + } + /// Test-only: how many cache-miss parses have run for `id` (0 = never parsed). #[cfg(test)] pub fn parse_count(&self, id: FileId) -> usize { @@ -276,20 +299,13 @@ impl SourceDatabase for ContentCacheDb { } fn parse(&self, id: FileId) -> SyntaxNode { - // Hit check under a short-lived shared borrow (dropped at block end). - { - let caches = self.caches.borrow(); - if let Some(cached) = caches.parse.get(&id) { - return cached.clone(); - } - } - - let text = self.file_text(id); - let tree = syntax_tree(&text); - let mut caches = self.caches.borrow_mut(); - caches.parse.insert(id, tree.clone()); - *caches.parse_count.entry(id).or_insert(0) += 1; - tree + self.ensure_parsed(id); + self.caches + .borrow() + .parse + .get(&id) + .cloned() + .expect("ensure_parsed caches the tree") } fn ast(&self, id: FileId) -> Option { @@ -336,6 +352,16 @@ impl SourceDatabase for ContentCacheDb { .insert(id, table.clone()); table } + + fn errors(&self, id: FileId) -> Arc> { + self.ensure_parsed(id); + self.caches + .borrow() + .errors + .get(&id) + .cloned() + .expect("ensure_parsed caches errors") + } } #[cfg(test)] @@ -345,7 +371,7 @@ mod tests { use super::*; fn url_for(name: &str) -> Url { - Url::from_file_path(format!("/tmp/ccls_test/{name}.circom")).unwrap() + Url::from_file_path(std::env::temp_dir().join(format!("{name}.circom"))).unwrap() } #[test] diff --git a/crates/lsp/src/test_util.rs b/crates/lsp/src/test_util.rs index 818d28d..bdc4bcd 100644 --- a/crates/lsp/src/test_util.rs +++ b/crates/lsp/src/test_util.rs @@ -10,6 +10,13 @@ use syntax::tree::syntax_tree; use crate::file_db::{FileDB, FileId}; use crate::global_state::GlobalState; +/// A valid absolute `file:` URL for a fixture `name`, cross-platform (the file need not exist — +/// these are opaque keys for the source DB). Replaces hardcoded `/tmp/...` paths that are invalid +/// on Windows. +pub(crate) fn file_url(name: &str) -> Url { + Url::from_file_path(std::env::temp_dir().join(name)).unwrap() +} + /// A `GlobalState` with one open document and no workspace roots (in-file only). pub(crate) fn state_with(url: &Url, source: &str) -> GlobalState { let mut state = GlobalState::new(Vec::new()); @@ -39,7 +46,7 @@ fn token_position( predicate: impl Fn(&SyntaxToken) -> bool, occurrence: usize, ) -> Option { - let file = FileDB::new(FileId(0), source, Url::from_file_path("/tmp/x").unwrap()); + let file = FileDB::new(FileId(0), source, file_url("x")); let node = syntax_tree(source); let mut count = 0; for t in node diff --git a/crates/parser/src/grammar/declaration.rs b/crates/parser/src/grammar/declaration.rs index 1310fea..6dd6693 100644 --- a/crates/parser/src/grammar/declaration.rs +++ b/crates/parser/src/grammar/declaration.rs @@ -273,24 +273,22 @@ pub(super) fn component_declaration(p: &mut Parser) { let m = p.open(); p.expect(ComponentKw); - // component identifier - // eg: comp[N - 1][10] + // `component c[N]` — the `[` follows the name. Detect before parsing so we can flag an + // initializer on an array component, which circom forbids. + let is_array_component = p.nth(1) == LBracket; + complex_identifier(p); - // do not assign for array components - // but we will not catch this error if p.at(Assign) { + if is_array_component { + p.error_report("array components cannot be initialized".to_string()); + } p.expect(Assign); - // TODO: support `parallel` tag - // eg: component comp = parallel NameTemplate(...){...} - - // template name let m_c = p.open(); p.expect(Identifier); p.close(m_c, TemplateName); - // template params let parameter_marker = p.open(); paren_list(p); p.close(parameter_marker, Call); diff --git a/crates/parser/src/grammar/statement.rs b/crates/parser/src/grammar/statement.rs index 69fd788..b424dc1 100644 --- a/crates/parser/src/grammar/statement.rs +++ b/crates/parser/src/grammar/statement.rs @@ -173,17 +173,20 @@ fn return_statement(p: &mut Parser) { fn assignment_statement(p: &mut Parser) { let open_marker = p.open(); - // left-hand expression / variable expression(p); - if p.at_assign_token() { - // + // Only label as an assignment when an assignment/inc-dec operator was actually consumed; + // otherwise this is a bare expression statement. + let close_kind = if p.at_assign_token() { p.advance(); expression(p); + AssignStatement } else if p.at(UnitInc) || p.at(UnitDec) { - // ++ / -- (postfix; prefix ++/-- is illegal in circom and errors elsewhere) p.advance(); - } + AssignStatement + } else { + ExpressionStatement + }; - p.close(open_marker, AssignStatement); + p.close(open_marker, close_kind); } diff --git a/crates/parser/src/lexer.rs b/crates/parser/src/lexer.rs index f4ceee7..dabfb7a 100644 --- a/crates/parser/src/lexer.rs +++ b/crates/parser/src/lexer.rs @@ -28,22 +28,24 @@ pub struct Token<'a> { pub range: Range, } -/// Tokenize `source` into a flat token sequence including trivia. -/// -/// The token stream is byte-identical to the legacy `Input::new` output: the same logos rules -/// produce the same kinds in the same order, block comments are coalesced with identical span -/// joining, and a stray `*/` (with no matching `/*`) becomes an `Error`. -pub fn tokenize<'a>(source: &'a str) -> Vec> { +/// A lexing error: byte range in `source` that matched no token rule, plus a message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LexError { + pub range: Range, + pub msg: String, +} + +/// Like [`tokenize`], but also returns lexer errors for surfacing as diagnostics. +pub fn tokenize_with_errors<'a>(source: &'a str) -> (Vec>, Vec) { let mut tokens: Vec> = Vec::new(); + let mut errors: Vec = Vec::new(); let mut lex = Lexer::::new(source); while let Some(kind) = lex.next() { let span = lex.span(); match kind { TokenKind::CommentBlockOpen => { - // Coalesce a block comment into one `BlockComment` token. circom comments do NOT - // nest, so consume up to (and including) the first `CommentBlockClose`; the joined - // span runs from the `/*` start to the `*/` end. + // Coalesce up to the first `*/`; circom comments do not nest. let mut closed = false; let mut join_span = span; while let Some(t) = lex.next() { @@ -54,6 +56,12 @@ pub fn tokenize<'a>(source: &'a str) -> Vec> { } } + if !closed { + errors.push(LexError { + range: join_span.clone(), + msg: "unterminated block comment".to_string(), + }); + } let coalesced = if closed { TokenKind::BlockComment } else { @@ -61,21 +69,32 @@ pub fn tokenize<'a>(source: &'a str) -> Vec> { }; tokens.push(token(source, coalesced, join_span)); } - // A stray `*/` with no matching `/*` — e.g. from nested comment markers - // (`/* a /* b */ c */`), since circom comments do NOT nest. The coalescing above - // consumes only up to the first `*/`, so a trailing `*/` would otherwise leak into the - // token stream as a `CommentBlockClose` the parser cannot consume. Treat it as a - // lexing error instead. TokenKind::CommentBlockClose => { + errors.push(LexError { + range: span.clone(), + msg: "unexpected `*/`".to_string(), + }); tokens.push(token(source, TokenKind::Error, span)); } + TokenKind::Error => { + errors.push(LexError { + range: span.clone(), + msg: format!("invalid token {:?}", &source[span.clone()]), + }); + tokens.push(token(source, kind, span)); + } _ => { tokens.push(token(source, kind, span)); } } } - tokens + (tokens, errors) +} + +/// Tokenize `source` into a flat token sequence including trivia. +pub fn tokenize<'a>(source: &'a str) -> Vec> { + tokenize_with_errors(source).0 } /// Build a `Token` from a logos-derived span. diff --git a/crates/parser/src/token_kind.rs b/crates/parser/src/token_kind.rs index 969992b..c2d48d7 100644 --- a/crates/parser/src/token_kind.rs +++ b/crates/parser/src/token_kind.rs @@ -221,6 +221,7 @@ pub enum TokenKind { LogStatement, ReturnStatement, AssignStatement, + ExpressionStatement, ForLoop, WhileLoop, // Program @@ -298,6 +299,60 @@ pub const BP_CMP: u16 = 111; pub const BP_BOOL_AND: u16 = 101; pub const BP_BOOL_OR: u16 = 91; +/// Single source of truth for circom keywords: each `*Kw` variant paired with its source text. +/// Generates [`TokenKind::keyword_text`], [`KEYWORDS`], and [`KEYWORD_VARIANTS`] from one +/// declaration. Adding a keyword means adding a `*Kw` variant (with its `#[token]`) AND an entry +/// here; `keyword_text` then returns its text and the lexer cross-check test enforces the match. +macro_rules! define_keywords { + ($($variant:ident => $text:expr),+ $(,)?) => { + impl TokenKind { + /// `Some(text)` if `self` is a circom keyword token, else `None`. The keyword text is + /// the same one completion offers ([`KEYWORDS`]). + #[must_use] + pub const fn keyword_text(self) -> Option<&'static str> { + match self { + $(TokenKind::$variant => Some($text),)+ + _ => None, + } + } + } + + /// Every circom keyword text, for autocompletion. Generated from the same list as + /// [`TokenKind::keyword_text`], so completion and keyword detection share one definition. + pub const KEYWORDS: &[&str] = &[$($text),+]; + + /// Every `(keyword token, source text)` pair, in declaration order. + pub const KEYWORD_VARIANTS: &[(TokenKind, &str)] = &[$((TokenKind::$variant, $text)),+]; + }; +} + +define_keywords! { + PragmaKw => "pragma", + Circom => "circom", + IncludeKw => "include", + TemplateKw => "template", + FunctionKw => "function", + ComponentKw => "component", + MainKw => "main", + PublicKw => "public", + SignalKw => "signal", + VarKw => "var", + LogKw => "log", + CustomKw => "custom", + CustomTemplatesKw => "custom_templates", + ExternCKw => "extern_c", + ParallelKw => "parallel", + BusKw => "bus", + InputKw => "input", + OutputKw => "output", + IfKw => "if", + ElseKw => "else", + ForKw => "for", + WhileKw => "while", + ReturnKw => "return", + AssertKw => "assert", +} + impl From for TokenKind { #[inline] fn from(d: u16) -> TokenKind { @@ -438,3 +493,35 @@ impl TokenKind { ) } } + +#[cfg(test)] +mod keyword_tests { + use super::{TokenKind, KEYWORD_VARIANTS}; + use logos::Lexer; + + /// Guard against drift between the `define_keywords!` list and the lexer's `#[token]` attrs: + /// each keyword text must lex as exactly its declared `*Kw` variant (a single token), and each + /// declared variant's `keyword_text` must round-trip to that text. + #[test] + fn keyword_list_matches_lexer() { + for &(variant, text) in KEYWORD_VARIANTS { + let mut lex = Lexer::::new(text); + let kind = lex.next(); + assert_eq!( + kind, + Some(variant), + "lexer does not recognize keyword text {text:?} as {variant:?}" + ); + assert_eq!( + lex.next(), + None, + "keyword text {text:?} must lex as a single token" + ); + assert_eq!( + variant.keyword_text(), + Some(text), + "keyword_text mismatch for {variant:?}" + ); + } + } +} diff --git a/crates/syntax/src/abstract_syntax_tree/definition.rs b/crates/syntax/src/abstract_syntax_tree/definition.rs index d38a672..b9c9066 100644 --- a/crates/syntax/src/abstract_syntax_tree/definition.rs +++ b/crates/syntax/src/abstract_syntax_tree/definition.rs @@ -1,6 +1,6 @@ //! Top-level definitions: `template`, `function`, and `bus`. These share the parser's //! `definition_body` grammar (`name (params)? block`), so their typed accessors are identical in -//! shape and live together here. +//! shape and generated by macros. use parser::token_kind::TokenKind; use parser::token_kind::TokenKind::*; @@ -11,113 +11,65 @@ use crate::node::{CircomLanguage, SyntaxNode}; use super::block::{AstBlock, AstStatementList}; use super::name::{AstIdentifier, AstParameterList, Named}; +macro_rules! impl_name_node { + ($name:ty) => { + impl $name { + pub fn name(&self) -> Option { + support::child(self.syntax()) + } + } + impl Named for $name { + fn identifier(&self) -> Option { + self.name() + } + } + }; +} + +macro_rules! impl_definition_body { + ($def:ty, $name:ty) => { + impl $def { + pub fn name(&self) -> Option<$name> { + support::child(self.syntax()) + } + pub fn body(&self) -> Option { + support::child(self.syntax()) + } + pub fn parameter_list(&self) -> Option { + support::child(self.syntax()) + } + pub fn statements(&self) -> Option { + self.body().and_then(|b| b.statement_list()) + } + } + impl Named for $def { + fn identifier(&self) -> Option { + self.name().and_then(|n| n.name()) + } + } + }; +} + // --- template ---------------------------------------------------------------- ast_node!(AstTemplateName, TemplateName); - -impl AstTemplateName { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } -} -impl Named for AstTemplateName { - fn identifier(&self) -> Option { - self.name() - } -} +impl_name_node!(AstTemplateName); ast_node!(AstTemplateDef, TemplateDef); - -impl AstTemplateDef { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } - pub fn body(&self) -> Option { - support::child(self.syntax()) - } - pub fn parameter_list(&self) -> Option { - support::child(self.syntax()) - } - pub fn statements(&self) -> Option { - self.body().and_then(|b| b.statement_list()) - } -} -impl Named for AstTemplateDef { - fn identifier(&self) -> Option { - self.name().and_then(|n| n.name()) - } -} +impl_definition_body!(AstTemplateDef, AstTemplateName); // --- function ---------------------------------------------------------------- ast_node!(AstFunctionName, FunctionName); - -impl AstFunctionName { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } -} -impl Named for AstFunctionName { - fn identifier(&self) -> Option { - self.name() - } -} +impl_name_node!(AstFunctionName); ast_node!(AstFunctionDef, FunctionDef); - -impl AstFunctionDef { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } - pub fn body(&self) -> Option { - support::child(self.syntax()) - } - pub fn parameter_list(&self) -> Option { - support::child(self.syntax()) - } - pub fn statements(&self) -> Option { - self.body().and_then(|b| b.statement_list()) - } -} -impl Named for AstFunctionDef { - fn identifier(&self) -> Option { - self.name().and_then(|n| n.name()) - } -} +impl_definition_body!(AstFunctionDef, AstFunctionName); // --- bus --------------------------------------------------------------------- ast_node!(AstBusName, BusName); - -impl AstBusName { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } -} -impl Named for AstBusName { - fn identifier(&self) -> Option { - self.name() - } -} +impl_name_node!(AstBusName); ast_node!(AstBusDef, BusDef); - -impl AstBusDef { - pub fn name(&self) -> Option { - support::child(self.syntax()) - } - pub fn body(&self) -> Option { - support::child(self.syntax()) - } - pub fn parameter_list(&self) -> Option { - support::child(self.syntax()) - } - pub fn statements(&self) -> Option { - self.body().and_then(|b| b.statement_list()) - } -} -impl Named for AstBusDef { - fn identifier(&self) -> Option { - self.name().and_then(|n| n.name()) - } -} +impl_definition_body!(AstBusDef, AstBusName); diff --git a/crates/syntax/src/abstract_syntax_tree/program.rs b/crates/syntax/src/abstract_syntax_tree/program.rs index 4acdbd6..9e3fef7 100644 --- a/crates/syntax/src/abstract_syntax_tree/program.rs +++ b/crates/syntax/src/abstract_syntax_tree/program.rs @@ -71,6 +71,13 @@ impl AstCircomProgram { pub fn libs(&self) -> Vec { support::children(self.syntax()).collect() } + /// The include path strings (`"…"` stripped) declared in this program. + pub fn include_paths(&self) -> Vec { + self.libs() + .into_iter() + .filter_map(|inc| inc.lib().map(|l| l.value())) + .collect() + } pub fn template_list(&self) -> Vec { support::children(self.syntax()).collect() } diff --git a/crates/syntax/src/tree.rs b/crates/syntax/src/tree.rs index a3b17ae..34bff97 100644 --- a/crates/syntax/src/tree.rs +++ b/crates/syntax/src/tree.rs @@ -1,6 +1,6 @@ use parser::event::Event; use parser::grammar::entry::Scope; -use parser::lexer::{tokenize, Token}; +use parser::lexer::{tokenize, tokenize_with_errors, Token}; use parser::parser::Parser; use parser::token_kind::TokenKind; use rowan::{GreenNodeBuilder, NodeCache}; @@ -12,98 +12,187 @@ pub use rowan::{ use crate::node::SyntaxNode; -/// Parse `source` as a whole circom program and build its syntax tree. -pub fn syntax_tree(source: &str) -> SyntaxNode { - let tokens = tokenize(source); +/// A syntax error: a source range plus a message. Collected during the build from the parser's +/// `ErrorReport` events (and the lexer's errors) and surfaced as LSP diagnostics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxError { + pub range: rowan::TextRange, + pub msg: String, +} + +/// The product of a parse: the lossless syntax tree plus the errors found in it. +#[derive(Debug, Clone)] +pub struct Parse { + pub tree: SyntaxNode, + pub errors: Vec, +} + +/// Parse `source` as a whole circom program, returning the tree and any errors. +pub fn parse(source: &str) -> Parse { + let (tokens, lex_errors) = tokenize_with_errors(source); let events = Parser::parse(&tokens); - build_syntax_node(&tokens, events) + let (tree, parse_errors) = build_syntax_node(&tokens, events); + + // Byte ranges the lexer already reported (stray `*/`, unrecognized bytes, unterminated + // comment). The offending span is also pushed into the token stream as a `TokenKind::Error`, + // so the parser re-flags it as an "unexpected token" — keep the specific lexer message and + // drop the redundant parser-side diagnostic at the identical span. + let lex_ranges: Vec = + lex_errors.iter().map(|le| text_range(&le.range)).collect(); + + let mut errors: Vec = lex_errors + .into_iter() + .map(|le| SyntaxError { + range: text_range(&le.range), + msg: le.msg, + }) + .collect(); + errors.extend( + parse_errors + .into_iter() + .filter(|pe| !lex_ranges.contains(&pe.range)), + ); + Parse { tree, errors } +} + +/// Parse `source` as a whole circom program and build its syntax tree (errors discarded). +pub fn syntax_tree(source: &str) -> SyntaxNode { + parse(source).tree } /// Parse `source` starting from a specific entry `scope` and build its syntax tree. pub fn syntax_node_from_source(source: &str, scope: Scope) -> SyntaxNode { let tokens = tokenize(source); let events = Parser::parse_with_scope(&tokens, scope); - build_syntax_node(&tokens, events) + build_syntax_node(&tokens, events).0 +} + +/// Convert a lexer byte range into a `rowan::TextRange`. +fn text_range(r: &std::ops::Range) -> rowan::TextRange { + rowan::TextRange::new( + rowan::TextSize::from(r.start as u32), + rowan::TextSize::from(r.end as u32), + ) } -fn build_syntax_node(tokens: &[Token], events: Vec) -> SyntaxNode { - // A fresh `NodeCache` per parse: identical tokens/subtrees are still deduplicated *within* a - // single parse, but nothing accumulates across parses. A process-global cache would leak - // every distinct identifier/literal ever parsed for the whole server lifetime (unbounded in a - // long LSP session); a per-parse cache bounds memory to one parse's working set. +fn build_syntax_node(tokens: &[Token], events: Vec) -> (SyntaxNode, Vec) { let mut cache = NodeCache::default(); let mut builder = GreenNodeBuilder::with_cache(&mut cache); - build_green(tokens, events, &mut builder); + let errors = build_green(tokens, events, &mut builder); let green = builder.finish(); - SyntaxNode::new_root(green) + (SyntaxNode::new_root(green), errors) +} + +/// Per-open-node state tracked during the build. `Err` frames record the first token they wrap +/// (if any) and any `ErrorReport` message, so on close they can be turned into a [`SyntaxError`]. +enum Frame { + Normal, + Err { + token: Option, + msg: Option, + }, } -/// Drive a `GreenNodeBuilder` straight from the parser's event stream, producing a tree -/// byte-identical to the previous `Output` → `build_rec` path. +/// Drive a `GreenNodeBuilder` from the parser's event stream, returning the syntax errors. /// -/// Robust against malformed streams (a stray `Close`, an unclosed `Open`, or a stream with no root -/// `Open`) so that `GreenNodeBuilder::finish` — which asserts exactly one top-level node — never -/// panics, even on a grammar bug. Real parser output is always single-rooted and balanced, so these -/// guards are defense-in-depth only. -fn build_green(tokens: &[Token], events: Vec, builder: &mut GreenNodeBuilder) { - // The first event must open the root node. An empty stream, or one whose first event is not an - // `Open` (neither can come from the real grammar), yields an empty `ParserError` root — - // mirroring `Output::from`'s empty-tree fallback and keeping `finish()` single-rooted. +/// Robust against malformed streams so `GreenNodeBuilder::finish` (which asserts exactly one +/// top-level node) never panics. +fn build_green( + tokens: &[Token], + events: Vec, + builder: &mut GreenNodeBuilder, +) -> Vec { + let mut errors: Vec = Vec::new(); + let mut next_idx: usize = 0; + let mut stack: Vec = Vec::new(); + let mut iter = events.into_iter(); let root_kind = match iter.next() { Some(Event::Open { kind }) => kind, _ => { builder.start_node(TokenKind::ParserError.into()); builder.finish_node(); - return; + return errors; } }; - // The root is held open (`open >= 1`) for the whole stream and closed by the tail loop, so a - // stray trailing `Close` can never pop below the root. builder.start_node(root_kind.into()); - let mut open: u32 = 1; + stack.push(Frame::Normal); + + let close_frame = |frame: Frame, errors: &mut Vec, next_idx: usize| { + if let Frame::Err { token, msg } = frame { + let range = match (token, msg.is_some()) { + (Some(r), _) => r, + (None, true) => match tokens.get(next_idx) { + Some(t) => text_range(&t.range), + None => { + let end = tokens.last().map(|t| t.range.end).unwrap_or(0); + TextRange::new(TextSize::from(end as u32), TextSize::from(end as u32)) + } + }, + (None, false) => return, + }; + let message = msg.unwrap_or_else(|| "unexpected token".to_string()); + errors.push(SyntaxError { + range, + msg: message, + }); + } + }; + for event in iter { match event { Event::Open { kind } => { builder.start_node(kind.into()); - open += 1; + stack.push(if kind == TokenKind::Error { + Frame::Err { + token: None, + msg: None, + } + } else { + Frame::Normal + }); } Event::Close => { - // Close an inner node; a stray `Close` at the root level (`open == 1`) is dropped - // rather than popping the root. - if open > 1 { + if stack.len() > 1 { + let frame = stack.pop().unwrap(); builder.finish_node(); - open -= 1; + close_frame(frame, &mut errors, next_idx); } } Event::Token(i) => { - // The parser emits a token index only for a token it consumed, so `i` is always in - // range; an out-of-range index (impossible for well-formed output) is dropped - // rather than panicking. Each token is wrapped in a single-child node of the same - // kind — this wrapping is load-bearing for `AstNode::cast`. if let Some(t) = tokens.get(i) { builder.start_node(t.kind.into()); builder.token(t.kind.into(), t.text); builder.finish_node(); + if let Some(Frame::Err { token, .. }) = stack.last_mut() { + if token.is_none() { + *token = Some(text_range(&t.range)); + } + } + if i + 1 > next_idx { + next_idx = i + 1; + } } } - Event::ErrorReport(_) => { - // A zero-width `Error` node marks the error position. The message is diagnostic - // metadata, NOT source text: emitting it as a token (`builder.token(Error, &msg)`) - // would make rowan size the node by the message length, inflating its byte range - // past EOF on terminal errors (e.g. `c.` at end of input) and breaking - // offset/range math. `has_error`/`parses_clean` key off the node kind, not text. + Event::ErrorReport(msg) => { builder.start_node(TokenKind::Error.into()); builder.finish_node(); + if let Some(Frame::Err { msg: slot, .. }) = stack.last_mut() { + *slot = Some(msg); + } } } } - // Close every node still open, the root last, so `finish()` always observes exactly one root. - for _ in 0..open { + while stack.len() > 1 { + let frame = stack.pop().unwrap(); builder.finish_node(); + close_frame(frame, &mut errors, next_idx); } + builder.finish_node(); + + errors } #[cfg(test)] @@ -351,3 +440,95 @@ mod build_green_tests { } } } + +#[cfg(test)] +mod parse_error_tests { + use super::parse; + + #[test] + fn clean_program_has_no_errors() { + let src = "pragma circom 2.0.0;\ntemplate T() { signal output o; o <== 0; }\n"; + let parsed = parse(src); + assert!( + parsed.errors.is_empty(), + "unexpected errors: {:?}", + parsed.errors + ); + } + + #[test] + fn missing_semicolon_yields_error() { + // `expect(Semicolon)` fails at EOF → one ErrorReport with a range at end of input. + let parsed = parse("pragma circom 2.0.0"); + assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); + assert!( + parsed.errors[0].msg.contains("Semicolon"), + "{:?}", + parsed.errors[0] + ); + } + + #[test] + fn unclosed_template_yields_error() { + let parsed = parse("template T() { signal output o; o <== 0;"); + assert!( + !parsed.errors.is_empty(), + "unclosed block should report an error" + ); + } + + #[test] + fn unterminated_block_comment_is_a_lexer_error() { + let parsed = parse("/* never closed"); + assert!( + parsed + .errors + .iter() + .any(|e| e.msg.contains("block comment")), + "{:?}", + parsed.errors + ); + } + + #[test] + fn stray_close_comment_is_a_lexer_error() { + let parsed = parse("a */ b"); + assert!( + parsed.errors.iter().any(|e| e.msg.contains("*/")), + "{:?}", + parsed.errors + ); + } + + #[test] + fn lexer_error_token_is_not_double_reported() { + // The stray `*/` is recorded as a `LexError` and also pushed as a `TokenKind::Error`, so + // without dedup the parser would add a second "unexpected token" at the same span. The + // specific lexer message must be the only diagnostic at that range. + let parsed = parse("a */ b"); + let star_slash_count = parsed + .errors + .iter() + .filter(|e| e.msg.contains("*/")) + .count(); + assert_eq!( + star_slash_count, 1, + "expected one lexer diagnostic for `*/`, got {star_slash_count}: {:?}", + parsed.errors + ); + } + + #[test] + fn error_ranges_are_within_source() { + let src = "pragma circom 2.0.0"; + let parsed = parse(src); + let end = rowan::TextSize::from(src.len() as u32); + for e in &parsed.errors { + assert!( + e.range.start() <= end && e.range.end() <= end, + "range out of bounds: {:?}", + e + ); + } + } +} diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 2205128..7521928 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -32,9 +32,11 @@ pub struct VfsPath(PathBuf); impl VfsPath { /// Absolutize `path` into a [`VfsPath`]. Returns `None` if absolutization fails (e.g. a /// non-existent root on platforms that canonicalize). The caller (LSP layer) has already - /// validated the path comes from a `file:` URL. + /// validated the path comes from a `file:` URL. Strips a Windows verbatim `\\?\` prefix first + /// (see [`trim_verbatim`]) so a `std::fs::canonicalize`d path and the same path round-tripped + /// through a `file:` URL intern to one key. pub fn from_abs_path(path: &Path) -> Option { - let abs = path.absolutize().ok()?.to_path_buf(); + let abs = trim_verbatim(path).absolutize().ok()?.to_path_buf(); Some(VfsPath(abs)) } @@ -43,6 +45,27 @@ impl VfsPath { } } +/// Strip a Windows verbatim/extended-length prefix so two representations of the same file collapse +/// to one [`VfsPath`] (and thus one [`FileId`]). `std::fs::canonicalize` yields `\\?\C:\...` +/// (`\\?\UNC\host\share\...` for UNC); a `file:` URL round-trip (`Url::from_file_path` → +/// `to_file_path`) drops it. Without normalization the workspace walk (canonicalized) and URL +/// lookups (`set_document`/`id_for_url`) would intern the same file twice on Windows — producing +/// duplicate workspace occurrences. A no-op when the prefix is absent (i.e. always on non-Windows). +fn trim_verbatim(path: &Path) -> PathBuf { + let Some(s) = path.as_os_str().to_str() else { + return path.to_path_buf(); + }; + let Some(rest) = s.strip_prefix(r"\\?\") else { + return path.to_path_buf(); + }; + // `\\?\UNC\host\share\...` -> `\\host\share\...`; otherwise just drop the `\\?\`. + if let Some(unc) = rest.strip_prefix(r"UNC\") { + PathBuf::from(format!(r"\\{unc}")) + } else { + PathBuf::from(rest) + } +} + /// What kind of mutation a [`ChangedFile`] records. `Delete` is forward-looking for the /// file-watcher (no deletions occur today) but idiomatic to include now. #[derive(Copy, Clone, Eq, PartialEq, Debug)] @@ -332,6 +355,36 @@ mod tests { assert_eq!(Some(id1), id2, "aliased paths must share one FileId"); } + #[test] + fn verbatim_prefix_collapses_to_one_id_test() { + // On Windows, `std::fs::canonicalize` yields `\\?\C:\...` while a `file:` URL round-trip + // drops the prefix. Both forms must intern to the same FileId — otherwise the workspace + // walk and open-doc lookups register one file twice, doubling its references/rename hits. + let mut vfs = Vfs::new(); + let id1 = vfs.set_file_contents( + VfsPath::from_abs_path(Path::new(r"\\?\C:\proj\main.circom")).unwrap(), + Some(Arc::from("x")), + ); + let id2 = vfs.file_id(&VfsPath::from_abs_path(Path::new(r"C:\proj\main.circom")).unwrap()); + assert_eq!( + Some(id1), + id2, + "verbatim and stripped forms must share one FileId" + ); + // UNC verbatim form: `\\?\UNC\host\share\m.circom` -> `\\host\share\m.circom`. + let id3 = vfs.set_file_contents( + VfsPath::from_abs_path(Path::new(r"\\?\UNC\host\share\lib.circom")).unwrap(), + Some(Arc::from("y")), + ); + let id4 = + vfs.file_id(&VfsPath::from_abs_path(Path::new(r"\\host\share\lib.circom")).unwrap()); + assert_eq!( + Some(id3), + id4, + "verbatim UNC and stripped UNC must share one FileId" + ); + } + #[test] fn create_then_modify_recorded_test() { let mut vfs = Vfs::new(); diff --git a/docs/architecture.md b/docs/architecture.md index 19eb570..592d0cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,17 +91,21 @@ Responsibilities: See [crates/vfs.md](./crates/vfs.md) for the full API. -## Sandboxed includes - -`include "…"` resolution is confined to workspace roots as a path-traversal defense: - -- `Vfs::set_workspace_roots` stores already-canonicalized root paths during the `initialize` - handshake. -- `Vfs::is_confined(canonical)` is a **pure** containment check — `canonical.starts_with(root)` - against the roots — with **no disk I/O**. The LSP layer canonicalizes the candidate - (resolving `..` and symlinks) before calling, which keeps the VFS I/O-free. -- The check is **fail-closed**: with no roots configured, nothing is confined, so the server - refuses to load any include rather than risk an arbitrary read. +## Include resolution + +`include "…"` resolution follows circom semantics: the path is resolved **relative to the +including source file**, the way the circom compiler resolves it. It is **not** confined to the +workspace roots. + +- `Vfs::set_workspace_roots` stores canonicalized root paths from the `initialize` handshake. + They scope the project `.circom` walk that feeds the basename index — an *indexing* scope, not + a confinement gate. +- An **absolute** include path is refused (`source_db::resolve_include`): `PathBuf::join` would + otherwise replace the base (`include "/etc/passwd"`), enabling an arbitrary local-file read. +- Relative and `..` includes resolve normally and **may read files outside the workspace roots**, + matching circom — so navigation works even when the editor points at the wrong/incomplete folder. +- The basename fallback (`Vfs::find_include`) only returns files the workspace walk already + indexed, so that path cannot escape the indexed set. ## Extension diff --git a/docs/features.md b/docs/features.md index 8420f25..34367bb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,6 +20,21 @@ library file from its `"path.circom"` string. - It uses `token_at_offset` (not `identifier_at`) because an include-path `CircomString` is also a valid jump target. +### Go to Implementation + +Behaves identically to Go to Definition — circom has no separate implementation concept (no +interfaces/traits distinct from a definition). Delegates to the definition handler so the two +cannot drift. + +- Handler: `crates/lsp/src/handler/goto_implementation.rs` + +### Workspace Symbol + +`workspace/symbol` lists every top-level template/function/bus across the workspace matching the +query (empty query ⇒ all), powered by the cached per-file symbol tables. + +- Handler: `crates/lsp/src/handler/workspace_symbol.rs` + ### Hover Shows the symbol kind and its declaration signature (header only for block-bodied definitions @@ -42,12 +57,12 @@ In-scope body symbols, file top-level names, reserved keywords, and **member com ### Find References -Every occurrence of a symbol, resolved *semantically* (not text-matched), so shadowing is -respected. +Every occurrence of a symbol across the workspace, resolved *semantically* (not text-matched), so +shadowing and same-name collisions across files are respected. - Handler: `crates/lsp/src/handler/references.rs` -- Returns the declaration plus every in-scope use as `Location`s. **In-file by design** — - cross-file references are a follow-up (see roadmap). +- Returns the declaration plus every reference as `Location`s. Workspace-wide: scans only files + that could reference the target (via the cached identifier index + include visibility). ### Rename @@ -56,7 +71,8 @@ illegal names, and unresolved member-access fields. - Handler: `crates/lsp/src/handler/rename.rs` - Occurrences are found by *resolving* each candidate (not text-matching), so shadowing is - correct. In-file (the symbol's defining file); cross-file rename is a follow-up. + correct. Workspace-wide: edits span every file referencing the symbol, grouped by URI into a + single `WorkspaceEdit`. - `prepareSupport` is advertised so the client consults the server (not its own textual word check) before opening the rename box. @@ -72,11 +88,19 @@ from disk once. A client resending identical text records no change and triggers See [architecture.md](./architecture.md#source-database) for the change-log-driven invalidation. -### Sandboxed includes +### Circom-style include resolution + +`include` paths resolve **relative to the including source file**, the way the circom compiler +resolves them — not confined to the workspace roots. Absolute include paths are refused (they +would enable an arbitrary local-file read); relative and `..` includes resolve normally and may +read files outside the workspace roots. The basename fallback is scoped to workspace-indexed +files. See [architecture.md](./architecture.md#include-resolution). + +### Diagnostics -`include` resolution is confined to workspace roots — path-traversal and symlink-safe. With no -roots configured the server refuses to load any include rather than risk an arbitrary file -read (fail-closed). See [architecture.md](./architecture.md#sandboxed-includes). +Syntax and lexer errors are reported via `textDocument/publishDiagnostics` as you type. The +error-recovering parser produces precise ranges and messages (e.g. `expect Semicolon but got +TemplateKw`); unterminated block comments and stray `*/` are surfaced as lexer errors. ## Registered but not yet implemented diff --git a/docs/roadmap.md b/docs/roadmap.md index bd85093..bf16185 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,8 +16,6 @@ These handlers exist but currently return `None`: ## Not yet implemented -- [ ] **Diagnostics** — syntax/semantic error reporting (`textDocument/publishDiagnostics`). - The parser already produces errors via `error_report()`; surface them to the client. - [ ] **Semantic Highlighting** — `textDocument/semanticTokens`. - [ ] **Signature Help** — `textDocument/signatureHelp`. - [ ] **Code Actions / Quick Fixes** — `textDocument/codeAction`. @@ -25,13 +23,12 @@ These handlers exist but currently return `None`: - [ ] **Document Highlight** — `textDocument/documentHighlight`. - [ ] **Selection Range** — `textDocument/selectionRange`. - [ ] **Inlay Hints** — `textDocument/inlayHint`. +- [ ] **Member-field references/rename** — `c.out` resolves for goto-def/hover, but Find + References and Rename of a component signal field are a no-op (they ride the flat + resolver). Needs per-template occurrence search. ## Existing features — follow-ups -- [ ] **Cross-file Rename & References** — both are currently in-file only. A workspace-wide - symbol graph is needed instead of name/`def_range` matching across files (which both - misses real cross-file usages and can collide when two files define a same-named symbol - at the same line:column). - [ ] **Doc-comment parsing** — richer hover derived from circom comments. - [ ] **Incremental sync** — document sync is currently `Full`; switch to incremental `didChange` ranges. diff --git a/editors/code/package.json b/editors/code/package.json index d34e53c..452b8be 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -6,7 +6,7 @@ "version": "0.0.8", "repository": { "type": "git", - "url": "https://github.com/vuvoth/circom-plus" + "url": "https://github.com/vuvoth/ccls" }, "publisher": "vuvoth", "categories": [