diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 2e084756..14e12490 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -54,5 +54,4 @@ jobs: env: AIR_LOG_LEVEL: trace # `--nocapture` to see our own `tracing` logs - # `--test-threads 1` to ensure `tracing` logs aren't interleaved - run: cargo test -- --nocapture --test-threads 1 + run: cargo test -- --nocapture diff --git a/.github/workflows/test-mac.yml b/.github/workflows/test-mac.yml index dbe44a94..b6fe1ca2 100644 --- a/.github/workflows/test-mac.yml +++ b/.github/workflows/test-mac.yml @@ -32,5 +32,4 @@ jobs: env: AIR_LOG_LEVEL: trace # `--nocapture` to see our own `tracing` logs - # `--test-threads 1` to ensure `tracing` logs aren't interleaved - run: cargo test -- --nocapture --test-threads 1 + run: cargo test -- --nocapture diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index fa95a5b2..ede3e23f 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -32,5 +32,4 @@ jobs: env: AIR_LOG_LEVEL: trace # `--nocapture` to see our own `tracing` logs - # `--test-threads 1` to ensure `tracing` logs aren't interleaved - run: cargo test -- --nocapture --test-threads 1 + run: cargo test -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index 2985a699..dbda3ff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,20 +33,18 @@ dependencies = [ "air_r_formatter", "air_r_parser", "anyhow", - "biome_console", - "biome_diagnostics", "biome_formatter", - "biome_parser", "clap", "fs", "ignore", "itertools", - "line_ending", - "lsp", + "lsp-server", + "server", + "source_file", "tempfile", "thiserror 2.0.5", - "tokio", "tracing", + "workspace", ] [[package]] @@ -59,10 +57,9 @@ dependencies = [ "biome_parser", "biome_rowan", "insta", - "line_ending", "serde", - "similar", "similar-asserts", + "source_file", ] [[package]] @@ -84,7 +81,6 @@ dependencies = [ "biome_parser", "biome_rowan", "itertools", - "line_ending", "tests_macros", "tracing", ] @@ -101,8 +97,8 @@ dependencies = [ "biome_rowan", "biome_unicode_table", "insta", - "line_ending", "serde", + "source_file", "tests_macros", "tracing", "tree-sitter", @@ -179,28 +175,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" -[[package]] -name = "async-trait" -version = "0.1.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - -[[package]] -name = "auto_impl" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - [[package]] name = "autocfg" version = "1.4.0" @@ -328,6 +302,8 @@ dependencies = [ "drop_bomb", "indexmap", "rustc-hash", + "schemars", + "serde", "tracing", "unicode-width", ] @@ -367,17 +343,6 @@ dependencies = [ "serde", ] -[[package]] -name = "biome_lsp_converters" -version = "0.1.0" -source = "git+https://github.com/biomejs/biome?rev=2648fa4201be4afd26f44eca1a4e77aac0a67272#2648fa4201be4afd26f44eca1a4e77aac0a67272" -dependencies = [ - "anyhow", - "biome_rowan", - "rustc-hash", - "tower-lsp 0.20.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "biome_markup" version = "0.5.7" @@ -492,12 +457,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bytes" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" - [[package]] name = "camino" version = "1.1.9" @@ -683,19 +642,6 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -834,95 +780,6 @@ dependencies = [ "path-absolutize", ] -[[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-executor", - "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-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[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 2.0.90", -] - -[[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 = "getrandom" version = "0.2.15" @@ -995,18 +852,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "httparse" -version = "1.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" - [[package]] name = "icu_collections" version = "1.5.0" @@ -1217,6 +1062,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jod-thread" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b23360e99b8717f20aaa4598f5a6541efbe30630039fbc7706cf954a87947ae" + [[package]] name = "json-strip-comments" version = "1.0.4" @@ -1262,13 +1113,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "line_ending" -version = "0.0.0" -dependencies = [ - "memchr", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1304,55 +1148,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] -name = "lsp" -version = "0.0.0" +name = "lsp-server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9462c4dc73e17f971ec1f171d44bfffb72e65a130117233388a0ebc7ec5656f9" dependencies = [ - "air_r_factory", - "air_r_formatter", - "air_r_parser", - "air_r_syntax", - "anyhow", - "assert_matches", - "biome_formatter", - "biome_lsp_converters", - "biome_parser", - "biome_rowan", - "biome_text_size", - "bytes", - "cargo_metadata", - "crossbeam", - "dissimilar", - "futures", - "futures-util", - "httparse", - "insta", - "itertools", - "line_ending", - "lsp_test", - "memchr", + "crossbeam-channel", + "log", "serde", + "serde_derive", "serde_json", - "struct-field-names-as-array", - "strum", - "tests_macros", - "time", - "tokio", - "tokio-util", - "tower-lsp 0.20.0 (git+https://github.com/lionel-/tower-lsp?branch=bugfix%2Fpatches)", - "tracing", - "tracing-subscriber", - "tree-sitter", - "tree-sitter-r", - "triomphe", - "url", - "uuid", ] [[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", @@ -1361,24 +1173,6 @@ dependencies = [ "url", ] -[[package]] -name = "lsp_test" -version = "0.0.0" -dependencies = [ - "bytes", - "futures", - "futures-util", - "httparse", - "memchr", - "serde", - "serde_json", - "tokio", - "tokio-util", - "tower-lsp 0.20.0 (git+https://github.com/lionel-/tower-lsp?branch=bugfix%2Fpatches)", - "tracing", - "url", -] - [[package]] name = "memchr" version = "2.7.4" @@ -1394,18 +1188,6 @@ dependencies = [ "adler2", ] -[[package]] -name = "mio" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" -dependencies = [ - "hermit-abi", - "libc", - "wasi", - "windows-sys 0.52.0", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1459,7 +1241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20bb345f290c46058ba650fef7ca2b579612cf2786b927ebad7b8bec0845a7" dependencies = [ "cfg-if", - "dashmap 6.1.0", + "dashmap", "dunce", "indexmap", "json-strip-comments", @@ -1472,16 +1254,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - [[package]] name = "parking_lot_core" version = "0.9.10" @@ -1519,38 +1291,12 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "pin-project" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - [[package]] name = "pin-project-lite" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.31" @@ -1672,9 +1418,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustix" @@ -1721,12 +1467,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustversion" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" - [[package]] name = "ryu" version = "1.0.18" @@ -1785,18 +1525,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", @@ -1849,6 +1589,60 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "server" +version = "0.1.1" +dependencies = [ + "air_r_factory", + "air_r_formatter", + "air_r_parser", + "air_r_syntax", + "anyhow", + "assert_matches", + "biome_formatter", + "biome_rowan", + "biome_text_size", + "cargo_metadata", + "crossbeam", + "dissimilar", + "insta", + "itertools", + "jod-thread", + "libc", + "lsp-server", + "lsp-types", + "rustc-hash", + "serde", + "serde_json", + "server_test", + "source_file", + "tracing", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-r", + "url", + "uuid", + "workspace", +] + +[[package]] +name = "server_test" +version = "0.0.0" +dependencies = [ + "lsp-server", + "lsp-types", + "url", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1864,15 +1658,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -1899,15 +1684,6 @@ dependencies = [ "similar", ] -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - [[package]] name = "smallvec" version = "1.13.2" @@ -1915,13 +1691,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] -name = "socket2" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +name = "source_file" +version = "0.0.0" dependencies = [ - "libc", - "windows-sys 0.52.0", + "biome_text_size", + "memchr", ] [[package]] @@ -1942,48 +1716,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "struct-field-names-as-array" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ba4bae771f9cc992c4f403636c54d2ef13acde6367583e99d06bb336674dd9" -dependencies = [ - "struct-field-names-as-array-derive", -] - -[[package]] -name = "struct-field-names-as-array-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2dbf8b57f3ce20e4bb171a11822b283bdfab6c4bb0fe64fa729f045f23a0938" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.90", -] - [[package]] name = "subtle" version = "2.6.1" @@ -2162,139 +1894,39 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.41.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-macros" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - -[[package]] -name = "tokio-util" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" +name = "toml" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 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 5.5.3", - "futures", - "httparse", - "lsp-types", - "memchr", "serde", - "serde_json", - "tokio", - "tokio-util", - "tower", - "tower-lsp-macros 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", - "tracing", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] -name = "tower-lsp" -version = "0.20.0" -source = "git+https://github.com/lionel-/tower-lsp?branch=bugfix%2Fpatches#49ef549eaa9f74b71e212cf513283af4ad748a81" +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ - "async-trait", - "auto_impl", - "bytes", - "dashmap 5.5.3", - "futures", - "httparse", - "lsp-types", - "memchr", "serde", - "serde_json", - "tokio", - "tokio-util", - "tower", - "tower-lsp-macros 0.9.0 (git+https://github.com/lionel-/tower-lsp?branch=bugfix%2Fpatches)", - "tracing", ] [[package]] -name = "tower-lsp-macros" -version = "0.9.0" +name = "toml_edit" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - -[[package]] -name = "tower-lsp-macros" -version = "0.9.0" -source = "git+https://github.com/lionel-/tower-lsp?branch=bugfix%2Fpatches#49ef549eaa9f74b71e212cf513283af4ad748a81" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", ] -[[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.40" @@ -2380,16 +2012,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "triomphe" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" -dependencies = [ - "serde", - "stable_deref_trait", -] - [[package]] name = "unicode-bom" version = "2.0.3" @@ -2637,6 +2259,33 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "workspace" +version = "0.1.0" +dependencies = [ + "air_r_formatter", + "anyhow", + "biome_formatter", + "fs", + "ignore", + "insta", + "rustc-hash", + "serde", + "source_file", + "tempfile", + "thiserror 2.0.5", + "toml", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index b6b56566..67391c8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,11 @@ air_r_parser = { path = "./crates/air_r_parser" } air_r_syntax = { path = "./crates/air_r_syntax" } biome_ungrammar = { path = "./crates/biome_ungrammar" } fs = { path = "./crates/fs" } -line_ending = { path = "./crates/line_ending" } -lsp = { path = "./crates/lsp" } -lsp_test = { path = "./crates/lsp_test" } tests_macros = { path = "./crates/tests_macros" } +server = { path = "./crates/server" } +server_test = { path = "./crates/server_test" } +source_file = { path = "./crates/source_file" } +workspace = { path = "./crates/workspace" } anyhow = "1.0.89" assert_matches = "1.5.0" @@ -44,36 +45,31 @@ biome_rowan = { git = "https://github.com/biomejs/biome", rev = "2648fa4201be4af biome_string_case = { git = "https://github.com/biomejs/biome", rev = "2648fa4201be4afd26f44eca1a4e77aac0a67272" } biome_text_size = { git = "https://github.com/biomejs/biome", rev = "2648fa4201be4afd26f44eca1a4e77aac0a67272" } biome_unicode_table = { git = "https://github.com/biomejs/biome", rev = "2648fa4201be4afd26f44eca1a4e77aac0a67272" } -bytes = "1.8.0" cargo_metadata = "0.19.1" clap = { version = "4.5.20", features = ["derive"] } crossbeam = "0.8.4" dissimilar = "1.0.9" -futures = "0.3.31" -futures-util = "0.3.31" -httparse = "1.9.5" ignore = "0.4.23" insta = "1.40.0" itertools = "0.13.0" -line-index = "0.1.2" +jod-thread = "0.1.2" +libc = "0.2.153" +lsp-server = "0.7.8" +lsp-types = "0.95.1" memchr = "2.7.4" path-absolutize = "3.1.1" proc-macro2 = "1.0.86" -serde = { version = "1.0.215", features = ["derive"] } +rustc-hash = "2.1.0" +serde = "1.0.215" serde_json = "1.0.132" -struct-field-names-as-array = "0.3.0" -strum = "0.26" +tempfile = "3.9.0" time = "0.3.37" thiserror = "2.0.5" -tokio = { version = "1.41.1" } -tokio-util = "0.7.12" -# For https://github.com/ebkalderon/tower-lsp/pull/428 -tower-lsp = { git = "https://github.com/lionel-/tower-lsp", branch = "bugfix/patches" } +toml = "0.8.19" tracing = { version = "0.1.40", default-features = false, features = ["std"] } tracing-subscriber = "0.3.19" tree-sitter = "0.23.0" tree-sitter-r = { git = "https://github.com/r-lib/tree-sitter-r", rev = "a0d3e3307489c3ca54da8c7b5b4e0c5f5fd6953a" } -triomphe = "0.1.14" url = "2.5.3" uuid = { version = "1.11.0", features = ["v4"] } @@ -124,7 +120,6 @@ unnecessary_join = "warn" unnested_or_patterns = "warn" unreadable_literal = "warn" verbose_bit_mask = "warn" -zero_sized_map_values = "warn" # restriction cfg_not_test = "warn" diff --git a/LICENSE b/LICENSE index b188949d..e1654192 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +The `server` crate is a fork of `ruff_server`: + +- URL: https://github.com/astral-sh/ruff/tree/main/crates/ruff_server +- Commit: aa429b413f8a7de9eeee14f8e54fdcb3b199b7b7 +- License: MIT + +MIT License + +Copyright (c) 2022 Charles Marsh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/air/Cargo.toml b/crates/air/Cargo.toml index e11590ad..83039d8b 100644 --- a/crates/air/Cargo.toml +++ b/crates/air/Cargo.toml @@ -15,22 +15,20 @@ rust-version.workspace = true air_r_formatter = { workspace = true } air_r_parser = { workspace = true } anyhow = { workspace = true } -biome_console = { workspace = true } -biome_diagnostics = { workspace = true } biome_formatter = { workspace = true } -biome_parser = { workspace = true } clap = { workspace = true, features = ["wrap_help"] } fs = { workspace = true } ignore = { workspace = true } itertools = { workspace = true } -line_ending = { workspace = true } -lsp = { workspace = true } +lsp-server = { workspace = true } +server = { workspace = true } +source_file = { workspace = true } thiserror = { workspace = true } -tokio = "1.41.1" tracing = { workspace = true } +workspace = { workspace = true } [dev-dependencies] -tempfile = "3.9.0" +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/air/src/commands/format.rs b/crates/air/src/commands/format.rs index 46c127a6..aeae2817 100644 --- a/crates/air/src/commands/format.rs +++ b/crates/air/src/commands/format.rs @@ -8,23 +8,41 @@ use std::path::PathBuf; use air_r_formatter::context::RFormatOptions; use air_r_parser::RParserOptions; use fs::relativize_path; -use ignore::DirEntry; use itertools::Either; use itertools::Itertools; -use line_ending::LineEnding; use thiserror::Error; +use workspace::discovery::discover_r_file_paths; +use workspace::discovery::discover_settings; +use workspace::discovery::DiscoveredSettings; +use workspace::resolve::PathResolver; +use workspace::settings::FormatSettings; +use workspace::settings::Settings; use crate::args::FormatCommand; use crate::ExitStatus; pub(crate) fn format(command: FormatCommand) -> anyhow::Result { let mode = FormatMode::from_command(&command); - let paths = resolve_paths(&command.paths); + + let paths = discover_r_file_paths(&command.paths); + + let mut resolver = PathResolver::new(Settings::default()); + + for DiscoveredSettings { + directory, + settings, + } in discover_settings(&command.paths)? + { + resolver.add(&directory, settings); + } let (actions, errors): (Vec<_>, Vec<_>) = paths .into_iter() .map(|path| match path { - Ok(path) => format_file(path, mode), + Ok(path) => { + let settings = resolver.resolve_or_fallback(&path); + format_file(path, mode, &settings.format) + } Err(err) => Err(err.into()), }) .partition_map(|result| match result { @@ -99,62 +117,6 @@ fn write_changed(actions: &[FormatFileAction], f: &mut impl Write) -> io::Result Ok(()) } -fn resolve_paths(paths: &[PathBuf]) -> Vec> { - let paths: Vec = paths.iter().map(fs::normalize_path).collect(); - - let (first_path, paths) = paths - .split_first() - .expect("Clap should ensure at least 1 path is supplied."); - - // TODO: Parallel directory visitor - let mut builder = ignore::WalkBuilder::new(first_path); - - for path in paths { - builder.add(path); - } - - let mut out = Vec::new(); - - for path in builder.build() { - match path { - Ok(entry) => { - if let Some(path) = is_valid_path(entry) { - out.push(Ok(path)); - } - } - Err(err) => { - out.push(Err(err)); - } - } - } - - out -} - -// Decide whether or not to accept an `entry` based on include/exclude rules. -fn is_valid_path(entry: DirEntry) -> Option { - // Ignore directories - if entry.file_type().map_or(true, |ft| ft.is_dir()) { - return None; - } - - // Accept all files that are passed-in directly, even non-R files - if entry.depth() == 0 { - let path = entry.into_path(); - return Some(path); - } - - // Otherwise check if we should accept this entry - // TODO: Many other checks based on user exclude/includes - let path = entry.into_path(); - - if !fs::has_r_extension(&path) { - return None; - } - - Some(path) -} - pub(crate) enum FormatFileAction { Formatted(PathBuf), Unchanged, @@ -166,20 +128,17 @@ impl FormatFileAction { } } -// TODO: Take workspace `FormatOptions` that get resolved to `RFormatOptions` -// for the formatter here. Respect user specified `LineEnding` option too, and -// only use inferred endings when `FormatOptions::LineEnding::Auto` is used. -fn format_file(path: PathBuf, mode: FormatMode) -> Result { +fn format_file( + path: PathBuf, + mode: FormatMode, + settings: &FormatSettings, +) -> Result { let source = std::fs::read_to_string(&path) .map_err(|err| FormatCommandError::Read(path.clone(), err))?; - let line_ending = match line_ending::infer(&source) { - LineEnding::Lf => biome_formatter::LineEnding::Lf, - LineEnding::Crlf => biome_formatter::LineEnding::Crlf, - }; - let options = RFormatOptions::default().with_line_ending(line_ending); + let options = settings.to_format_options(&source); - let source = line_ending::normalize(source); + let (source, _) = source_file::normalize_newlines(source); let formatted = match format_source(source.as_str(), options) { Ok(formatted) => formatted, Err(err) => return Err(FormatCommandError::Format(path.clone(), err)), diff --git a/crates/air/src/commands/language_server.rs b/crates/air/src/commands/language_server.rs index 30fd07f5..fd034f61 100644 --- a/crates/air/src/commands/language_server.rs +++ b/crates/air/src/commands/language_server.rs @@ -1,10 +1,20 @@ +use std::num::NonZeroUsize; + +use server::Server; + use crate::args::LanguageServerCommand; use crate::ExitStatus; -#[tokio::main] -pub(crate) async fn language_server(_command: LanguageServerCommand) -> anyhow::Result { - // Returns after shutdown - lsp::start_lsp(tokio::io::stdin(), tokio::io::stdout()).await; +pub(crate) fn language_server(_command: LanguageServerCommand) -> anyhow::Result { + let four = NonZeroUsize::new(4).unwrap(); + + // by default, we set the number of worker threads to `num_cpus`, with a maximum of 4. + let worker_threads = std::thread::available_parallelism() + .unwrap_or(four) + .max(four); + + let (connection, connection_threads) = lsp_server::Connection::stdio(); - Ok(ExitStatus::Success) + let server = Server::new(worker_threads, connection, Some(connection_threads))?; + server.run().map(|()| ExitStatus::Success) } diff --git a/crates/air_formatter_test/Cargo.toml b/crates/air_formatter_test/Cargo.toml index 86bbe764..895b4cc6 100644 --- a/crates/air_formatter_test/Cargo.toml +++ b/crates/air_formatter_test/Cargo.toml @@ -21,10 +21,9 @@ biome_formatter = { workspace = true } biome_parser = { workspace = true } biome_rowan = { workspace = true } insta = { workspace = true, features = ["glob"] } -line_ending = { workspace = true } serde = { workspace = true, features = ["derive"] } -similar = "2.6.0" similar-asserts = "1.6.0" +source_file = { workspace = true } [lints] workspace = true diff --git a/crates/air_formatter_test/src/spec.rs b/crates/air_formatter_test/src/spec.rs index 78e93112..fc82fe5c 100644 --- a/crates/air_formatter_test/src/spec.rs +++ b/crates/air_formatter_test/src/spec.rs @@ -29,7 +29,7 @@ impl<'a> SpecTestFile<'a> { let input_code = std::fs::read_to_string(input_file).unwrap(); // Normalize to Unix line endings - let input_code = line_ending::normalize(input_code); + let (input_code, _) = source_file::normalize_newlines(input_code); // For the whole file, not a specific range right now let range_start_index = None; diff --git a/crates/air_r_formatter/Cargo.toml b/crates/air_r_formatter/Cargo.toml index b64f0406..2b09fce6 100644 --- a/crates/air_r_formatter/Cargo.toml +++ b/crates/air_r_formatter/Cargo.toml @@ -24,7 +24,6 @@ tracing = { workspace = true } air_formatter_test = { workspace = true } air_r_parser = { workspace = true } biome_parser = { workspace = true } -line_ending = { workspace = true } tests_macros = { workspace = true } [lints] diff --git a/crates/air_r_formatter/src/context.rs b/crates/air_r_formatter/src/context.rs index 84acba35..a4956636 100644 --- a/crates/air_r_formatter/src/context.rs +++ b/crates/air_r_formatter/src/context.rs @@ -17,6 +17,7 @@ use biome_formatter::TransformSourceMap; use crate::comments::FormatRLeadingComment; use crate::comments::RCommentStyle; use crate::comments::RComments; +use crate::options::MagicLineBreak; pub struct RFormatContext { options: RFormatOptions, @@ -77,6 +78,10 @@ pub struct RFormatOptions { /// The max width of a line. Defaults to 80. line_width: LineWidth, + + // TODO: Actually use this internally! + /// The behavior of magic line breaks. + magic_line_break: MagicLineBreak, } impl RFormatOptions { @@ -106,6 +111,11 @@ impl RFormatOptions { self } + pub fn with_magic_line_break(mut self, magic_line_break: MagicLineBreak) -> Self { + self.magic_line_break = magic_line_break; + self + } + pub fn set_indent_style(&mut self, indent_style: IndentStyle) { self.indent_style = indent_style; } @@ -121,6 +131,10 @@ impl RFormatOptions { pub fn set_line_width(&mut self, line_width: LineWidth) { self.line_width = line_width; } + + pub fn set_magic_line_break(&mut self, magic_line_break: MagicLineBreak) { + self.magic_line_break = magic_line_break; + } } impl FormatOptions for RFormatOptions { diff --git a/crates/air_r_formatter/src/lib.rs b/crates/air_r_formatter/src/lib.rs index 93fff37f..760efe64 100644 --- a/crates/air_r_formatter/src/lib.rs +++ b/crates/air_r_formatter/src/lib.rs @@ -21,6 +21,7 @@ use crate::cst::FormatRSyntaxNode; pub mod comments; pub mod context; mod cst; +pub mod options; mod prelude; mod r; pub(crate) mod separated; diff --git a/crates/air_r_formatter/src/options.rs b/crates/air_r_formatter/src/options.rs new file mode 100644 index 00000000..7c04088a --- /dev/null +++ b/crates/air_r_formatter/src/options.rs @@ -0,0 +1,3 @@ +mod magic_line_break; + +pub use magic_line_break::*; diff --git a/crates/air_r_formatter/src/options/magic_line_break.rs b/crates/air_r_formatter/src/options/magic_line_break.rs new file mode 100644 index 00000000..ec331bf5 --- /dev/null +++ b/crates/air_r_formatter/src/options/magic_line_break.rs @@ -0,0 +1,44 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} diff --git a/crates/air_r_parser/Cargo.toml b/crates/air_r_parser/Cargo.toml index 6c41d910..0e4ab2d7 100644 --- a/crates/air_r_parser/Cargo.toml +++ b/crates/air_r_parser/Cargo.toml @@ -28,7 +28,7 @@ tree-sitter-r = { workspace = true } biome_console = { workspace = true } biome_diagnostics = { workspace = true } insta = { workspace = true } -line_ending = { workspace = true } +source_file = { workspace = true } tests_macros = { workspace = true } # cargo-workspaces metadata diff --git a/crates/air_r_parser/tests/spec_test.rs b/crates/air_r_parser/tests/spec_test.rs index d22860a9..c3fef862 100644 --- a/crates/air_r_parser/tests/spec_test.rs +++ b/crates/air_r_parser/tests/spec_test.rs @@ -53,7 +53,7 @@ pub fn run(test_case: &str, _snapshot_name: &str, test_directory: &str, outcome_ .expect("Expected test path to be a readable file in UTF8 encoding"); // Normalize to Unix line endings - let content = line_ending::normalize(content); + let (content, _) = source_file::normalize_newlines(content); let options = RParserOptions::default(); let parsed = parse(&content, options); diff --git a/crates/line_ending/src/lib.rs b/crates/line_ending/src/lib.rs deleted file mode 100644 index af3de4eb..00000000 --- a/crates/line_ending/src/lib.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! We maintain the invariant that all internal strings use `\n` as line separator. -//! This module does line ending conversion and detection (so that we can -//! convert back to `\r\n` on the way out as needed). - -use std::sync::LazyLock; - -use memchr::memmem; - -static FINDER: LazyLock = LazyLock::new(|| memmem::Finder::new(b"\r\n")); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum LineEnding { - /// Line Feed only (\n), common on Linux and macOS as well as inside git repos - Lf, - - /// Carriage Return + Line Feed characters (\r\n), common on Windows - Crlf, -} - -pub fn infer(x: &str) -> LineEnding { - match FINDER.find(x.as_bytes()) { - // Saw `\r\n` - Some(_) => LineEnding::Crlf, - // No `\r\n`, or empty file - None => LineEnding::Lf, - } -} - -/// Normalize line endings within a string -/// -/// We replace `\r\n` with `\n` in-place, which doesn't break utf-8 encoding. -/// While we *can* call `as_mut_vec` and do surgery on the live string -/// directly, let's rather steal the contents of `x`. This makes the code -/// safe even if a panic occurs. -/// -/// # Source -/// -/// --- -/// authors = ["rust-analyzer team"] -/// license = "MIT OR Apache-2.0" -/// origin = "https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/line_index.rs" -/// --- -pub fn normalize(x: String) -> String { - let mut buf = x.into_bytes(); - let mut gap_len = 0; - let mut tail = buf.as_mut_slice(); - let mut crlf_seen = false; - - loop { - let idx = match FINDER.find(&tail[gap_len..]) { - None if crlf_seen => tail.len(), - // SAFETY: buf is unchanged and therefore still contains utf8 data - None => return unsafe { String::from_utf8_unchecked(buf) }, - Some(idx) => { - crlf_seen = true; - idx + gap_len - } - }; - tail.copy_within(gap_len..idx, 0); - tail = &mut tail[idx - gap_len..]; - if tail.len() == gap_len { - break; - } - gap_len += 1; - } - - // Account for removed `\r`. - // After `set_len`, `buf` is guaranteed to contain utf-8 again. - unsafe { - let new_len = buf.len() - gap_len; - buf.set_len(new_len); - String::from_utf8_unchecked(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unix() { - let src = "a\nb\nc\n\n\n\n"; - assert_eq!(infer(src), LineEnding::Lf); - assert_eq!(normalize(src.to_string()), src); - } - - #[test] - fn dos() { - let src = "\r\na\r\n\r\nb\r\nc\r\n\r\n\r\n\r\n"; - assert_eq!(infer(src), LineEnding::Crlf); - assert_eq!(normalize(src.to_string()), "\na\n\nb\nc\n\n\n\n"); - } - - #[test] - fn mixed() { - let src = "a\r\nb\r\nc\r\n\n\r\n\n"; - assert_eq!(infer(src), LineEnding::Crlf); - assert_eq!(normalize(src.to_string()), "a\nb\nc\n\n\n\n"); - } - - #[test] - fn none() { - let src = "abc"; - assert_eq!(infer(src), LineEnding::Lf); - assert_eq!(normalize(src.to_string()), src); - } -} diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml deleted file mode 100644 index 3c726341..00000000 --- a/crates/lsp/Cargo.toml +++ /dev/null @@ -1,61 +0,0 @@ -[package] -name = "lsp" -version = "0.0.0" -publish = false -authors.workspace = true -categories.workspace = true -edition.workspace = true -homepage.workspace = true -keywords.workspace = true -license.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -air_r_factory.workspace = true -air_r_formatter.workspace = true -air_r_parser.workspace = true -air_r_syntax.workspace = true -anyhow.workspace = true -biome_formatter.workspace = true -biome_lsp_converters.workspace = true -biome_parser.workspace = true -biome_rowan.workspace = true -biome_text_size.workspace = true -crossbeam.workspace = true -dissimilar.workspace = true -futures.workspace = true -itertools.workspace = true -line_ending.workspace = true -memchr.workspace = true -serde.workspace = true -serde_json.workspace = true -struct-field-names-as-array.workspace = true -strum = { workspace = true, features = ["derive"] } -time = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tower-lsp.workspace = true -tracing.workspace = true -tracing-subscriber = { workspace = true, features = ["ansi", "local-time"] } -tree-sitter.workspace = true -tree-sitter-r.workspace = true -triomphe.workspace = true -url.workspace = true -uuid = { workspace = true, features = ["v4"] } - -[dev-dependencies] -assert_matches.workspace = true -bytes.workspace = true -futures-util.workspace = true -httparse.workspace = true -insta.workspace = true -lsp_test.workspace = true -memchr.workspace = true -tests_macros.workspace = true -tokio-util.workspace = true - -[build-dependencies] -cargo_metadata.workspace = true - -[lints] -workspace = true diff --git a/crates/lsp/src/config.rs b/crates/lsp/src/config.rs deleted file mode 100644 index 3c2fa069..00000000 --- a/crates/lsp/src/config.rs +++ /dev/null @@ -1,129 +0,0 @@ -// -// config.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use serde::Deserialize; -use serde::Serialize; -use struct_field_names_as_array::FieldNamesAsArray; - -/// Configuration of the LSP -#[derive(Clone, Debug, Default)] -pub(crate) struct LspConfig {} - -/// Configuration of a document. -/// -/// The naming follows where possible. -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct DocumentConfig { - pub indent: IndentationConfig, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct IndentationConfig { - /// Whether to insert spaces of tabs for one level of indentation. - pub indent_style: IndentStyle, - - /// The number of spaces for one level of indentation. - pub indent_size: usize, - - /// The width of a tab. There may be projects with an `indent_size` of 4 and - /// a `tab_width` of 8 (e.g. GNU R). - pub tab_width: usize, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub enum IndentStyle { - Tab, - Space, -} - -/// VS Code representation of a document configuration -#[derive(Serialize, Deserialize, FieldNamesAsArray, Clone, Debug)] -pub(crate) struct VscDocumentConfig { - // DEV NOTE: Update `section_from_key()` method after adding a field - pub insert_spaces: bool, - pub indent_size: VscIndentSize, - pub tab_size: usize, -} - -#[derive(Serialize, Deserialize, FieldNamesAsArray, Clone, Debug)] -pub(crate) struct VscDiagnosticsConfig { - // DEV NOTE: Update `section_from_key()` method after adding a field - pub enable: bool, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(untagged)] -pub(crate) enum VscIndentSize { - Alias(String), - Size(usize), -} - -impl Default for IndentationConfig { - fn default() -> Self { - Self { - indent_style: IndentStyle::Space, - indent_size: 2, - tab_width: 2, - } - } -} - -impl VscDocumentConfig { - pub(crate) fn section_from_key(key: &str) -> &str { - match key { - "insert_spaces" => "editor.insertSpaces", - "indent_size" => "editor.indentSize", - "tab_size" => "editor.tabSize", - _ => "unknown", // To be caught via downstream errors - } - } -} - -/// Convert from VS Code representation of a document config to our own -/// representation. Currently one-to-one. -impl From for DocumentConfig { - fn from(x: VscDocumentConfig) -> Self { - let indent_style = indent_style_from_lsp(x.insert_spaces); - - let indent_size = match x.indent_size { - VscIndentSize::Size(size) => size, - VscIndentSize::Alias(var) => { - if var == "tabSize" { - x.tab_size - } else { - tracing::warn!("Unknown indent alias {var}, using default"); - 2 - } - } - }; - - Self { - indent: IndentationConfig { - indent_style, - indent_size, - tab_width: x.tab_size, - }, - } - } -} - -impl VscDiagnosticsConfig { - pub(crate) fn section_from_key(key: &str) -> &str { - match key { - "enable" => "positron.r.diagnostics.enable", - _ => "unknown", // To be caught via downstream errors - } - } -} - -pub(crate) fn indent_style_from_lsp(insert_spaces: bool) -> IndentStyle { - if insert_spaces { - IndentStyle::Space - } else { - IndentStyle::Tab - } -} diff --git a/crates/lsp/src/crates.rs b/crates/lsp/src/crates.rs deleted file mode 100644 index e79951a1..00000000 --- a/crates/lsp/src/crates.rs +++ /dev/null @@ -1,3 +0,0 @@ -// Generates `AIR_CRATE_NAMES`, a const array of the crate names in the air workspace, -// see `lsp/src/build.rs` -include!(concat!(env!("OUT_DIR"), "/crates.rs")); diff --git a/crates/lsp/src/documents.rs b/crates/lsp/src/documents.rs deleted file mode 100644 index d2406dae..00000000 --- a/crates/lsp/src/documents.rs +++ /dev/null @@ -1,250 +0,0 @@ -// -// documents.rs -// -// Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. -// -// - -use biome_lsp_converters::{line_index, PositionEncoding}; -use line_ending::LineEnding; -use tower_lsp::lsp_types; - -use crate::config::DocumentConfig; -use crate::rust_analyzer::line_index::LineIndex; -use crate::rust_analyzer::utils::apply_document_changes; - -#[derive(Clone)] -pub struct Document { - /// The normalized current contents of the document. UTF-8 Rust string with - /// Unix line endings. - pub contents: String, - - /// Map of new lines in `contents`. Also contains line endings type in the - /// original document (we only store Unix lines) and the position encoding - /// type of the session. This provides all that is needed to send data back - /// to the client with positions in the correct coordinate space and - /// correctly formatted text. - pub line_index: LineIndex, - - /// We store the syntax tree in the document for now. - /// We will think about laziness and incrementality in the future. - pub parse: biome_parser::AnyParse, - - /// The version of the document we last synchronized with. - /// None if the document hasn't been synchronized yet. - pub version: Option, - - /// Configuration of the document, such as indentation settings. - pub config: DocumentConfig, -} - -impl std::fmt::Debug for Document { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Document") - .field("syntax", &self.parse) - .finish() - } -} - -impl Document { - pub fn new( - contents: String, - version: Option, - position_encoding: PositionEncoding, - ) -> Self { - // Detect existing endings - let endings = line_ending::infer(&contents); - - // Normalize to Unix line endings - let contents = match endings { - LineEnding::Lf => contents, - LineEnding::Crlf => line_ending::normalize(contents), - }; - - // TODO: Handle user requested line ending preference here - // by potentially overwriting `endings` if the user didn't - // select `LineEndings::Auto`, and then pass that to `LineIndex`. - - // Create line index to keep track of newline offsets - let line_index = LineIndex { - index: triomphe::Arc::new(line_index::LineIndex::new(&contents)), - endings, - encoding: position_encoding, - }; - - // Parse document immediately for now - let parse = air_r_parser::parse(&contents, Default::default()).into(); - - Self { - contents, - line_index, - parse, - version, - config: Default::default(), - } - } - - /// For unit tests - pub fn doodle(contents: &str) -> Self { - Self::new(contents.into(), None, PositionEncoding::Utf8) - } - - #[cfg(test)] - pub fn doodle_and_range(contents: &str) -> (Self, biome_text_size::TextRange) { - let (contents, range) = crate::test_utils::extract_marked_range(contents); - let doc = Self::new(contents, None, PositionEncoding::Utf8); - (doc, range) - } - - pub fn on_did_change(&mut self, mut params: lsp_types::DidChangeTextDocumentParams) { - let new_version = params.text_document.version; - - // Check for out-of-order change notifications - if let Some(old_version) = self.version { - // According to the spec, versions might not be consecutive but they must be monotonically - // increasing. If that's not the case this is a hard nope as we - // can't maintain our state integrity. Currently panicking but in - // principle we should shut down the LSP in an orderly fashion. - if new_version < old_version { - panic!( - "out-of-sync change notification: currently at {old_version}, got {new_version}" - ); - } - } - - // Normalize line endings. Changing the line length of inserted or - // replaced text can't invalidate the text change events, even those - // applied subsequently, since those changes are specified with [line, - // col] coordinates. - for event in &mut params.content_changes { - let text = std::mem::take(&mut event.text); - event.text = line_ending::normalize(text); - } - - let contents = apply_document_changes( - self.line_index.encoding, - &self.contents, - params.content_changes, - ); - - // No incrementality for now - let parse = air_r_parser::parse(&contents, Default::default()).into(); - - self.parse = parse; - self.contents = contents; - self.line_index.index = triomphe::Arc::new(line_index::LineIndex::new(&self.contents)); - self.version = Some(new_version); - } - - /// Convenient accessor that returns an annotated `SyntaxNode` type - pub fn syntax(&self) -> air_r_syntax::RSyntaxNode { - self.parse.syntax() - } -} - -#[cfg(test)] -mod tests { - use air_r_syntax::RSyntaxNode; - use biome_text_size::{TextRange, TextSize}; - - use crate::rust_analyzer::text_edit::TextEdit; - use crate::to_proto; - - use super::*; - - fn dummy_versioned_doc() -> lsp_types::VersionedTextDocumentIdentifier { - lsp_types::VersionedTextDocumentIdentifier { - uri: url::Url::parse("file:///foo").unwrap(), - version: 1, - } - } - - #[test] - fn test_document_starts_at_0_with_leading_whitespace() { - let document = Document::doodle("\n\n# hi there"); - let root = document.syntax(); - assert_eq!( - root.text_range(), - TextRange::new(TextSize::from(0), TextSize::from(12)) - ); - } - - #[test] - fn test_document_syntax() { - let mut doc = Document::doodle("foo(bar)"); - - let original_syntax: RSyntaxNode = doc.parse.syntax(); - insta::assert_debug_snapshot!(original_syntax); - - let edit = TextEdit::replace( - TextRange::new(TextSize::from(4_u32), TextSize::from(7)), - String::from("1 + 2"), - ); - let edits = to_proto::doc_edit_vec(&doc.line_index, edit).unwrap(); - - let params = lsp_types::DidChangeTextDocumentParams { - text_document: dummy_versioned_doc(), - content_changes: edits, - }; - doc.on_did_change(params); - - let updated_syntax: RSyntaxNode = doc.parse.syntax(); - insta::assert_debug_snapshot!(updated_syntax); - } - - #[test] - fn test_document_position_encoding() { - // Replace `b` after `𐐀` which is at position 5 in UTF-8 - let utf8_range = lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 5, - }, - end: lsp_types::Position { - line: 0, - character: 6, - }, - }; - - // `b` is at position 3 in UTF-16 - let utf16_range = lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 3, - }, - end: lsp_types::Position { - line: 0, - character: 4, - }, - }; - - let mut utf8_replace_params = lsp_types::DidChangeTextDocumentParams { - text_document: dummy_versioned_doc(), - content_changes: vec![], - }; - let mut utf16_replace_params = utf8_replace_params.clone(); - - utf8_replace_params.content_changes = vec![lsp_types::TextDocumentContentChangeEvent { - range: Some(utf8_range), - range_length: None, - text: String::from("bar"), - }]; - utf16_replace_params.content_changes = vec![lsp_types::TextDocumentContentChangeEvent { - range: Some(utf16_range), - range_length: None, - text: String::from("bar"), - }]; - - let mut document = Document::new("a𐐀b".into(), None, PositionEncoding::Utf8); - document.on_did_change(utf8_replace_params); - assert_eq!(document.contents, "a𐐀bar"); - - let mut document = Document::new( - "a𐐀b".into(), - None, - PositionEncoding::Wide(biome_lsp_converters::WideEncoding::Utf16), - ); - document.on_did_change(utf16_replace_params); - assert_eq!(document.contents, "a𐐀bar"); - } -} diff --git a/crates/lsp/src/encoding.rs b/crates/lsp/src/encoding.rs deleted file mode 100644 index be8062a6..00000000 --- a/crates/lsp/src/encoding.rs +++ /dev/null @@ -1,61 +0,0 @@ -// -// encoding.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -/// Converts a character offset into a particular line from UTF-16 to UTF-8 -fn convert_character_from_utf16_to_utf8(x: &str, character: usize) -> usize { - if x.is_ascii() { - // Fast pass - return character; - } - - // Initial check, since loop would skip this case - if character == 0 { - return character; - } - - let mut n = 0; - - // For each `u32` sized `char`, figure out the equivalent size in UTF-16 - // world of that `char`. Once we hit the requested number of `character`s, - // that means we have indexed into `x` to the correct position, at which - // point we can take the current bytes based `pos` that marks the start of - // this `char`, and add on its UTF-8 based size to return an adjusted column - // offset. We use `==` because I'm fairly certain they should always align - // exactly, and it would be good to log if that isn't the case. - for (pos, char) in x.char_indices() { - n += char.len_utf16(); - - if n == character { - return pos + char.len_utf8(); - } - } - - tracing::error!("Failed to locate UTF-16 offset of {character}. Line: '{x}'."); - 0 -} - -/// Converts a character offset into a particular line from UTF-8 to UTF-16 -fn convert_character_from_utf8_to_utf16(x: &str, character: usize) -> usize { - if x.is_ascii() { - // Fast pass - return character; - } - - // The UTF-8 -> UTF-16 case is slightly simpler. We just slice into `x` - // using our existing UTF-8 offset, reencode the slice as a UTF-16 based - // iterator, and count up the pieces. - match x.get(..character) { - Some(x) => x.encode_utf16().count(), - None => { - let n = x.len(); - tracing::error!( - "Tried to take UTF-8 character {character}, but only {n} characters exist. Line: '{x}'." - ); - 0 - } - } -} diff --git a/crates/lsp/src/from_proto.rs b/crates/lsp/src/from_proto.rs deleted file mode 100644 index a3329d30..00000000 --- a/crates/lsp/src/from_proto.rs +++ /dev/null @@ -1,37 +0,0 @@ -pub(crate) use biome_lsp_converters::from_proto::offset; -pub(crate) use biome_lsp_converters::from_proto::text_range; - -use tower_lsp::lsp_types; - -use crate::documents::Document; - -pub fn apply_text_edits( - doc: &Document, - mut edits: Vec, -) -> anyhow::Result { - let mut text = doc.contents.clone(); - - // Apply edits from bottom to top to avoid inserted newlines to invalidate - // positions in earlier parts of the doc (they are sent in reading order - // accorder to the LSP protocol) - edits.reverse(); - - for edit in edits { - let start: usize = offset( - &doc.line_index.index, - edit.range.start, - doc.line_index.encoding, - )? - .into(); - let end: usize = offset( - &doc.line_index.index, - edit.range.end, - doc.line_index.encoding, - )? - .into(); - - text.replace_range(start..end, &edit.new_text); - } - - Ok(text) -} diff --git a/crates/lsp/src/handlers.rs b/crates/lsp/src/handlers.rs deleted file mode 100644 index 53cb5ebc..00000000 --- a/crates/lsp/src/handlers.rs +++ /dev/null @@ -1,68 +0,0 @@ -// -// handlers.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use struct_field_names_as_array::FieldNamesAsArray; -use tower_lsp::lsp_types; -use tower_lsp::Client; -use tracing::Instrument; - -use crate::config::VscDiagnosticsConfig; -use crate::config::VscDocumentConfig; -use crate::main_loop::LspState; - -// Handlers that do not mutate the world state. They take a sharing reference or -// a clone of the state. - -pub(crate) async fn handle_initialized( - client: &Client, - lsp_state: &LspState, -) -> anyhow::Result<()> { - let span = tracing::info_span!("handle_initialized").entered(); - - // Register capabilities to the client - let mut regs: Vec = vec![]; - - if lsp_state.needs_registration.did_change_configuration { - // The `didChangeConfiguration` request instructs the client to send - // a notification when the tracked settings have changed. - // - // Note that some settings, such as editor indentation properties, may be - // changed by extensions or by the user without changing the actual - // underlying setting. Unfortunately we don't receive updates in that case. - let mut config_document_regs = collect_regs( - VscDocumentConfig::FIELD_NAMES_AS_ARRAY.to_vec(), - VscDocumentConfig::section_from_key, - ); - let mut config_diagnostics_regs: Vec = collect_regs( - VscDiagnosticsConfig::FIELD_NAMES_AS_ARRAY.to_vec(), - VscDiagnosticsConfig::section_from_key, - ); - - regs.append(&mut config_document_regs); - regs.append(&mut config_diagnostics_regs); - } - - client - .register_capability(regs) - .instrument(span.exit()) - .await?; - Ok(()) -} - -fn collect_regs( - fields: Vec<&str>, - into_section: impl Fn(&str) -> &str, -) -> Vec { - fields - .into_iter() - .map(|field| lsp_types::Registration { - id: uuid::Uuid::new_v4().to_string(), - method: String::from("workspace/didChangeConfiguration"), - register_options: Some(serde_json::json!({ "section": into_section(field) })), - }) - .collect() -} diff --git a/crates/lsp/src/handlers_ext.rs b/crates/lsp/src/handlers_ext.rs deleted file mode 100644 index 45125222..00000000 --- a/crates/lsp/src/handlers_ext.rs +++ /dev/null @@ -1,94 +0,0 @@ -use air_r_formatter::{context::RFormatOptions, format_node}; -use biome_formatter::{IndentStyle, LineWidth}; -use tower_lsp::lsp_types; - -use crate::state::WorldState; - -#[derive(Debug, Eq, PartialEq, Clone, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ViewFileParams { - /// From `lsp_types::TextDocumentPositionParams` - pub(crate) text_document: lsp_types::TextDocumentIdentifier, - pub(crate) position: lsp_types::Position, - - /// Viewer type - pub(crate) kind: ViewFileKind, -} - -#[derive(Debug, Eq, PartialEq, Clone, serde::Deserialize, serde::Serialize)] -pub(crate) enum ViewFileKind { - TreeSitter, - SyntaxTree, - FormatTree, -} - -pub(crate) fn view_file(params: ViewFileParams, state: &WorldState) -> anyhow::Result { - let doc = state.get_document(¶ms.text_document.uri)?; - - match params.kind { - ViewFileKind::TreeSitter => { - let mut parser = tree_sitter::Parser::new(); - parser - .set_language(&tree_sitter_r::LANGUAGE.into()) - .unwrap(); - - let ast = parser.parse(&doc.contents, None).unwrap(); - - if ast.root_node().has_error() { - return Ok(String::from("*Parse error*")); - } - - let mut output = String::new(); - let mut cursor = ast.root_node().walk(); - format_ts_node(&mut cursor, 0, &mut output); - - Ok(output) - } - - ViewFileKind::SyntaxTree => { - let syntax = doc.syntax(); - Ok(format!("{syntax:#?}")) - } - - ViewFileKind::FormatTree => { - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; - - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); - - let formatted = format_node(options.clone(), &doc.parse.syntax())?; - Ok(format!("{}", formatted.into_document())) - } - } -} - -fn format_ts_node(cursor: &mut tree_sitter::TreeCursor, depth: usize, output: &mut String) { - let node = cursor.node(); - let field_name = match cursor.field_name() { - Some(name) => format!("{name}: "), - None => String::new(), - }; - - let start = node.start_position(); - let end = node.end_position(); - let node_type = node.kind(); - - let indent = " ".repeat(depth * 4); - let start = format!("{}, {}", start.row, start.column); - let end = format!("{}, {}", end.row, end.column); - - output.push_str(&format!( - "{indent}{field_name}{node_type} [{start}] - [{end}]\n", - )); - - if cursor.goto_first_child() { - loop { - format_ts_node(cursor, depth + 1, output); - if !cursor.goto_next_sibling() { - break; - } - } - cursor.goto_parent(); - } -} diff --git a/crates/lsp/src/handlers_format.rs b/crates/lsp/src/handlers_format.rs deleted file mode 100644 index c9de94b3..00000000 --- a/crates/lsp/src/handlers_format.rs +++ /dev/null @@ -1,514 +0,0 @@ -// -// handlers_format.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use air_r_formatter::{context::RFormatOptions, format_node}; -use air_r_syntax::{RExpressionList, RSyntaxKind, RSyntaxNode, WalkEvent}; -use biome_formatter::{IndentStyle, LineWidth}; -use biome_rowan::{AstNode, Language, SyntaxElement}; -use biome_text_size::{TextRange, TextSize}; -use tower_lsp::lsp_types; - -use crate::state::WorldState; -use crate::{from_proto, to_proto}; - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn document_formatting( - params: lsp_types::DocumentFormattingParams, - state: &WorldState, -) -> anyhow::Result>> { - let doc = state.get_document(¶ms.text_document.uri)?; - - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; - - // TODO: Handle FormattingOptions - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); - - if doc.parse.has_errors() { - return Err(anyhow::anyhow!("Can't format when there are parse errors.")); - } - - let formatted = format_node(options.clone(), &doc.parse.syntax())?; - let output = formatted.print()?.into_code(); - - // Do we need to check that `doc` is indeed an R file? What about special - // files that don't have extensions like `NAMESPACE`, do we hard-code a - // list? What about unnamed temporary files? - - let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, &output)?; - Ok(Some(edits)) -} - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn document_range_formatting( - params: lsp_types::DocumentRangeFormattingParams, - state: &WorldState, -) -> anyhow::Result>> { - let doc = state.get_document(¶ms.text_document.uri)?; - - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; - let range = - from_proto::text_range(&doc.line_index.index, params.range, doc.line_index.encoding)?; - - // TODO: Handle FormattingOptions - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); - - let logical_lines = find_deepest_enclosing_logical_lines(doc.parse.syntax(), range); - if logical_lines.is_empty() { - tracing::warn!("Can't find logical line"); - return Ok(None); - }; - - // Find the overall formatting range by concatenating the ranges of the logical lines. - // We use the "non-whitespace-range" as that corresponds to what Biome will format. - let format_range = logical_lines - .iter() - .map(text_non_whitespace_range) - .reduce(|acc, new| acc.cover(new)) - .expect("`logical_lines` is non-empty"); - - // We need to wrap in an `RRoot` otherwise the comments get attached too - // deep in the tree. See `CommentsBuilderVisitor` in biome_formatter and the - // `is_root` logic. Note that `node` needs to be wrapped in at least two - // other nodes in order to fix this problem, and here we have an `RRoot` and - // `RExpressionList` that do the job. - // - // Since we only format logical lines, it is fine to wrap in an expression list. - let Some(exprs): Option> = logical_lines - .into_iter() - .map(air_r_syntax::AnyRExpression::cast) - .collect() - else { - tracing::warn!("Can't cast to `AnyRExpression`"); - return Ok(None); - }; - - let list = air_r_factory::r_expression_list(exprs); - let eof = air_r_syntax::RSyntaxToken::new_detached(RSyntaxKind::EOF, "", vec![], vec![]); - let root = air_r_factory::r_root(list, eof).build(); - - let format_info = biome_formatter::format_sub_tree( - root.syntax(), - air_r_formatter::RFormatLanguage::new(options), - )?; - - if format_info.range().is_none() { - // Happens in edge cases when biome returns a `Printed::new_empty()` - return Ok(None); - }; - - let mut format_text = format_info.into_code(); - - // Remove last hard break line from our artifical expression list - format_text.pop(); - let edits = to_proto::replace_range_edit(&doc.line_index, format_range, format_text)?; - - Ok(Some(edits)) -} - -// From biome_formatter -fn text_non_whitespace_range(elem: &E) -> TextRange -where - E: Into> + Clone, - L: Language, -{ - let elem: SyntaxElement = elem.clone().into(); - - let start = elem - .leading_trivia() - .into_iter() - .flat_map(|trivia| trivia.pieces()) - .find_map(|piece| { - if piece.is_whitespace() || piece.is_newline() { - None - } else { - Some(piece.text_range().start()) - } - }) - .unwrap_or_else(|| elem.text_trimmed_range().start()); - - let end = elem - .trailing_trivia() - .into_iter() - .flat_map(|trivia| trivia.pieces().rev()) - .find_map(|piece| { - if piece.is_whitespace() || piece.is_newline() { - None - } else { - Some(piece.text_range().end()) - } - }) - .unwrap_or_else(|| elem.text_trimmed_range().end()); - - TextRange::new(start, end) -} - -/// Finds consecutive logical lines. Currently that's only expressions at -/// top-level or in a braced list. -fn find_deepest_enclosing_logical_lines(node: RSyntaxNode, range: TextRange) -> Vec { - let start_lists = find_expression_lists(&node, range.start(), false); - let end_lists = find_expression_lists(&node, range.end(), true); - - // Both vectors of lists should have a common prefix, starting from the - // program's expression list. As soon as the lists diverge we stop. - let Some(list) = start_lists - .into_iter() - .zip(end_lists) - .take_while(|pair| pair.0 == pair.1) - .map(|pair| pair.0) - .last() - else { - // Should not happen as the range is always included in the program's expression list - tracing::warn!("Can't find common list parent"); - return vec![]; - }; - - let Some(list) = RExpressionList::cast(list) else { - tracing::warn!("Can't cast to expression list"); - return vec![]; - }; - - let iter = list.into_iter(); - - // We've chosen to be liberal about user selections and always widen the - // range to include the selection bounds. If we wanted to be conservative - // instead, we could use this `filter()` instead of the `skip_while()` and - // `take_while()`: - // - // ```rust - // .filter(|node| range.contains_range(node.text_trimmed_range())) - // ``` - let logical_lines: Vec = iter - .map(|expr| expr.into_syntax()) - .skip_while(|node| !node.text_range().contains(range.start())) - .take_while(|node| node.text_trimmed_range().start() <= range.end()) - .collect(); - - logical_lines -} - -fn find_expression_lists(node: &RSyntaxNode, offset: TextSize, end: bool) -> Vec { - let mut preorder = node.preorder(); - let mut nodes: Vec = vec![]; - - while let Some(event) = preorder.next() { - match event { - WalkEvent::Enter(node) => { - let Some(parent) = node.parent() else { - continue; - }; - - let is_contained = if end { - let trimmed_node_range = node.text_trimmed_range(); - trimmed_node_range.contains_inclusive(offset) - } else { - let node_range = node.text_range(); - node_range.contains(offset) - }; - - if !is_contained { - preorder.skip_subtree(); - continue; - } - - if parent.kind() == RSyntaxKind::R_EXPRESSION_LIST { - nodes.push(parent.clone()); - continue; - } - } - - WalkEvent::Leave(_) => {} - } - } - - nodes -} - -#[cfg(test)] -mod tests { - use crate::{ - documents::Document, tower_lsp::init_test_client, tower_lsp_test_client::TestClientExt, - }; - - #[tests_macros::lsp_test] - async fn test_format() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let doc = Document::doodle( -" -1 -2+2 -3 + 3 + -3", - ); - - let formatted = client.format_document(&doc).await; - insta::assert_snapshot!(formatted); - - client - } - - // https://github.com/posit-dev/air/issues/61 - #[tests_macros::lsp_test] - async fn test_format_minimal_diff() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let doc = Document::doodle( -"1 -2+2 -3 -", - ); - - let edits = client.format_document_edits(&doc).await.unwrap(); - assert!(edits.len() == 1); - - let edit = &edits[0]; - assert_eq!(edit.new_text, " + "); - - client - } - - #[tests_macros::lsp_test] - async fn test_format_range_none() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"<<>>", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"<< ->>", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"<<1 ->>", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - client - } - - #[tests_macros::lsp_test] - async fn test_format_range_logical_lines() { - let mut client = init_test_client().await; - - // 2+2 is the logical line to format - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -<<2+2>> -", - ); - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -# -<<2+2>> -", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - // The element in the braced expression is a logical line - // FIXME: Should this be the whole `{2+2}` instead? - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -{<<2+2>>} -", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -<<{2+2}>> -", - ); - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - // The deepest element in the braced expression is our target - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -{ - 2+2 - { - <<3+3>> - } -} -", - ); - - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - client - } - - #[tests_macros::lsp_test] - async fn test_format_range_mismatched_indent() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1 - <<2+2>> -", - ); - - // We don't change indentation when `2+2` is formatted - let output = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output); - - // Debatable: Should we make an effort to remove unneeded indentation - // when it's part of the range? - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1 -<< 2+2>> -", - ); - let output_wide = client.format_document_range(&doc, range).await; - assert_eq!(output, output_wide); - - client - } - - #[tests_macros::lsp_test] - async fn test_format_range_multiple_lines() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -<<# -2+2>> -", - ); - - let output1 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output1); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"<<1+1 -# -2+2>> -", - ); - let output2 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output2); - - client - } - - #[tests_macros::lsp_test] - async fn test_format_range_unmatched_lists() { - let mut client = init_test_client().await; - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"0+0 -<<1+1 -{ - 2+2>> -} -3+3 -", - ); - - let output1 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output1); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"0+0 -<<1+1 -{ ->> 2+2 -} -3+3 -", - ); - let output2 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output2); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"0+0 -<<1+1 -{ - 2+2 -} ->>3+3 -", - ); - let output3 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output3); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"0+0 -1+1 -{ -<< 2+2 -} ->>3+3 -", - ); - let output4 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output4); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"<<1+1>> -2+2 -", - ); - - let output5 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output5); - - #[rustfmt::skip] - let (doc, range) = Document::doodle_and_range( -"1+1 -<<2+2>> -", - ); - - let output6 = client.format_document_range(&doc, range).await; - insta::assert_snapshot!(output6); - - client - } -} diff --git a/crates/lsp/src/handlers_state.rs b/crates/lsp/src/handlers_state.rs deleted file mode 100644 index a570cf31..00000000 --- a/crates/lsp/src/handlers_state.rs +++ /dev/null @@ -1,326 +0,0 @@ -// -// handlers_state.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use anyhow::anyhow; -use biome_lsp_converters::PositionEncoding; -use serde_json::Value; -use struct_field_names_as_array::FieldNamesAsArray; -use tower_lsp::lsp_types; -use tower_lsp::lsp_types::ConfigurationItem; -use tower_lsp::lsp_types::DidChangeConfigurationParams; -use tower_lsp::lsp_types::DidChangeTextDocumentParams; -use tower_lsp::lsp_types::DidCloseTextDocumentParams; -use tower_lsp::lsp_types::DidOpenTextDocumentParams; -use tower_lsp::lsp_types::FormattingOptions; -use tower_lsp::lsp_types::InitializeParams; -use tower_lsp::lsp_types::InitializeResult; -use tower_lsp::lsp_types::OneOf; -use tower_lsp::lsp_types::ServerCapabilities; -use tower_lsp::lsp_types::ServerInfo; -use tower_lsp::lsp_types::TextDocumentSyncCapability; -use tower_lsp::lsp_types::TextDocumentSyncKind; -use tower_lsp::lsp_types::WorkspaceFoldersServerCapabilities; -use tower_lsp::lsp_types::WorkspaceServerCapabilities; -use tracing::Instrument; -use url::Url; - -use crate::config::indent_style_from_lsp; -use crate::config::DocumentConfig; -use crate::config::VscDiagnosticsConfig; -use crate::config::VscDocumentConfig; -use crate::documents::Document; -use crate::logging; -use crate::logging::LogMessageSender; -use crate::main_loop::LspState; -use crate::state::workspace_uris; -use crate::state::WorldState; - -// Handlers that mutate the world state - -/// Information sent from the kernel to the LSP after each top-level evaluation. -#[derive(Debug)] -pub struct ConsoleInputs { - /// List of console scopes, from innermost (global or debug) to outermost - /// scope. Currently the scopes are vectors of symbol names. TODO: In the - /// future, we should send structural information like search path, and let - /// the LSP query us for the contents so that the LSP can cache the - /// information. - pub console_scopes: Vec>, - - /// Packages currently installed in the library path. TODO: Should send - /// library paths instead and inspect and cache package information in the LSP. - pub installed_packages: Vec, -} - -// Handlers taking exclusive references to global state - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn initialize( - params: InitializeParams, - lsp_state: &mut LspState, - state: &mut WorldState, - log_tx: LogMessageSender, -) -> anyhow::Result { - // TODO: Get user specified options from `params.initialization_options` - let log_level = None; - let dependency_log_levels = None; - - logging::init_logging( - log_tx, - log_level, - dependency_log_levels, - params.client_info.as_ref(), - ); - - // Defaults to UTF-16 - let mut position_encoding = None; - - if let Some(caps) = params.capabilities.general { - // If the client supports UTF-8 we use that, even if it's not its - // preferred encoding (at position 0). Otherwise we use the mandatory - // UTF-16 encoding that all clients and servers must support, even if - // the client would have preferred UTF-32. Note that VSCode and Positron - // only support UTF-16. - if let Some(caps) = caps.position_encodings { - if caps.contains(&lsp_types::PositionEncodingKind::UTF8) { - lsp_state.position_encoding = PositionEncoding::Utf8; - position_encoding = Some(lsp_types::PositionEncodingKind::UTF8); - } - } - } - - // Take note of supported capabilities so we can register them in the - // `Initialized` handler - if let Some(ws_caps) = params.capabilities.workspace { - if matches!(ws_caps.did_change_configuration, Some(caps) if matches!(caps.dynamic_registration, Some(true))) - { - lsp_state.needs_registration.did_change_configuration = true; - } - } - - // Initialize the workspace folders - let mut folders: Vec = Vec::new(); - if let Some(workspace_folders) = params.workspace_folders { - for folder in workspace_folders.iter() { - state.workspace.folders.push(folder.uri.clone()); - if let Ok(path) = folder.uri.to_file_path() { - if let Some(path) = path.to_str() { - folders.push(path.to_string()); - } - } - } - } - - Ok(InitializeResult { - server_info: Some(ServerInfo { - name: "Air Language Server".to_string(), - version: Some(env!("CARGO_PKG_VERSION").to_string()), - }), - capabilities: ServerCapabilities { - position_encoding, - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::INCREMENTAL, - )), - workspace: Some(WorkspaceServerCapabilities { - workspace_folders: Some(WorkspaceFoldersServerCapabilities { - supported: Some(true), - change_notifications: Some(OneOf::Left(true)), - }), - file_operations: None, - }), - document_formatting_provider: Some(OneOf::Left(true)), - document_range_formatting_provider: Some(OneOf::Left(true)), - ..ServerCapabilities::default() - }, - }) -} - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn did_open( - params: DidOpenTextDocumentParams, - lsp_state: &LspState, - state: &mut WorldState, -) -> anyhow::Result<()> { - let contents = params.text_document.text; - let uri = params.text_document.uri; - let version = params.text_document.version; - - let document = Document::new(contents, Some(version), lsp_state.position_encoding); - state.documents.insert(uri, document); - - Ok(()) -} - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn did_change( - params: DidChangeTextDocumentParams, - state: &mut WorldState, -) -> anyhow::Result<()> { - let uri = ¶ms.text_document.uri; - let doc = state.get_document_mut(uri)?; - doc.on_did_change(params); - - Ok(()) -} - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn did_close( - params: DidCloseTextDocumentParams, - state: &mut WorldState, -) -> anyhow::Result<()> { - let uri = params.text_document.uri; - - // Publish empty set of diagnostics to clear them - // lsp::publish_diagnostics(uri.clone(), Vec::new(), None); - - state - .documents - .remove(&uri) - .ok_or(anyhow!("Failed to remove document for URI: {uri}"))?; - - Ok(()) -} - -pub(crate) async fn did_change_configuration( - _params: DidChangeConfigurationParams, - client: &tower_lsp::Client, - state: &mut WorldState, -) -> anyhow::Result<()> { - // The notification params sometimes contain data but it seems in practice - // we should just ignore it. Instead we need to pull the settings again for - // all URI of interest. - - update_config(workspace_uris(state), client, state) - .instrument(tracing::info_span!("did_change_configuration")) - .await -} - -#[tracing::instrument(level = "info", skip_all)] -pub(crate) fn did_change_formatting_options( - uri: &Url, - opts: &FormattingOptions, - state: &mut WorldState, -) { - let Ok(doc) = state.get_document_mut(uri) else { - return; - }; - - // The information provided in formatting requests is more up-to-date - // than the user settings because it also includes changes made to the - // configuration of particular editors. However the former is less rich - // than the latter: it does not allow the tab size to differ from the - // indent size, as in the R core sources. So we just ignore the less - // rich updates in this case. - if doc.config.indent.indent_size != doc.config.indent.tab_width { - return; - } - - doc.config.indent.indent_size = opts.tab_size as usize; - doc.config.indent.tab_width = opts.tab_size as usize; - doc.config.indent.indent_style = indent_style_from_lsp(opts.insert_spaces); - - // TODO: - // `trim_trailing_whitespace` - // `trim_final_newlines` - // `insert_final_newline` -} - -async fn update_config( - uris: Vec, - client: &tower_lsp::Client, - state: &mut WorldState, -) -> anyhow::Result<()> { - let mut items: Vec = vec![]; - - let diagnostics_keys = VscDiagnosticsConfig::FIELD_NAMES_AS_ARRAY; - let mut diagnostics_items: Vec = diagnostics_keys - .iter() - .map(|key| ConfigurationItem { - scope_uri: None, - section: Some(VscDiagnosticsConfig::section_from_key(key).into()), - }) - .collect(); - items.append(&mut diagnostics_items); - - // For document configs we collect all pairs of URIs and config keys of - // interest in a flat vector - let document_keys = VscDocumentConfig::FIELD_NAMES_AS_ARRAY; - let mut document_items: Vec = - itertools::iproduct!(uris.iter(), document_keys.iter()) - .map(|(uri, key)| ConfigurationItem { - scope_uri: Some(uri.clone()), - section: Some(VscDocumentConfig::section_from_key(key).into()), - }) - .collect(); - items.append(&mut document_items); - - let configs = client.configuration(items).await?; - - // We got the config items in a flat vector that's guaranteed to be - // ordered in the same way it was sent in. Be defensive and check that - // we've got the expected number of items before we process them chunk - // by chunk - let n_document_items = document_keys.len(); - let n_diagnostics_items = diagnostics_keys.len(); - let n_items = n_diagnostics_items + (n_document_items * uris.len()); - - if configs.len() != n_items { - return Err(anyhow!( - "Unexpected number of retrieved configurations: {}/{}", - configs.len(), - n_items - )); - } - - let mut configs = configs.into_iter(); - - // --- Diagnostics - let keys = diagnostics_keys.into_iter(); - let items: Vec = configs.by_ref().take(n_diagnostics_items).collect(); - - // Create a new `serde_json::Value::Object` manually to convert it - // to a `VscDocumentConfig` with `from_value()`. This way serde_json - // can type-check the dynamic JSON value we got from the client. - let mut map = serde_json::Map::new(); - std::iter::zip(keys, items).for_each(|(key, item)| { - map.insert(key.into(), item); - }); - - // TODO: Deserialise the VS Code configuration - // let config: VscDiagnosticsConfig = serde_json::from_value(serde_json::Value::Object(map))?; - // let config: DiagnosticsConfig = config.into(); - - // let changed = state.config.diagnostics != config; - // state.config.diagnostics = config; - - // if changed { - // lsp::spawn_diagnostics_refresh_all(state.clone()); - // } - - // --- Documents - // For each document, deserialise the vector of JSON values into a typed config - for uri in uris { - let keys = document_keys.into_iter(); - let items: Vec = configs.by_ref().take(n_document_items).collect(); - - let mut map = serde_json::Map::new(); - std::iter::zip(keys, items).for_each(|(key, item)| { - map.insert(key.into(), item); - }); - - // Deserialise the VS Code configuration - let config: VscDocumentConfig = serde_json::from_value(serde_json::Value::Object(map))?; - - // Now convert the VS Code specific type into our own type - let config: DocumentConfig = config.into(); - - // Finally, update the document's config - state.get_document_mut(&uri)?.config = config; - } - - Ok(()) -} diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs deleted file mode 100644 index 65451b9f..00000000 --- a/crates/lsp/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -// TODO: Remove this -#![allow(dead_code)] - -pub use tower_lsp::start_lsp; - -pub mod config; -pub mod crates; -pub mod documents; -pub mod encoding; -pub mod from_proto; -pub mod handlers; -pub mod handlers_ext; -pub mod handlers_format; -pub mod handlers_state; -pub mod logging; -pub mod main_loop; -pub mod rust_analyzer; -pub mod state; -pub mod to_proto; -pub mod tower_lsp; - -#[cfg(test)] -pub mod test_utils; -#[cfg(test)] -pub mod tower_lsp_test_client; diff --git a/crates/lsp/src/main_loop.rs b/crates/lsp/src/main_loop.rs deleted file mode 100644 index 780a635b..00000000 --- a/crates/lsp/src/main_loop.rs +++ /dev/null @@ -1,492 +0,0 @@ -// -// main_loop.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use std::collections::HashMap; -use std::future; -use std::pin::Pin; - -use anyhow::anyhow; -use biome_lsp_converters::PositionEncoding; -use biome_lsp_converters::WideEncoding; -use futures::StreamExt; -use tokio::sync::mpsc::unbounded_channel as tokio_unbounded_channel; -use tokio::task::JoinHandle; -use tower_lsp::lsp_types::Diagnostic; -use tower_lsp::Client; -use url::Url; - -use crate::handlers; -use crate::handlers_ext; -use crate::handlers_format; -use crate::handlers_state; -use crate::handlers_state::ConsoleInputs; -use crate::logging::LogMessageSender; -use crate::logging::LogState; -use crate::state::WorldState; -use crate::tower_lsp::LspMessage; -use crate::tower_lsp::LspNotification; -use crate::tower_lsp::LspRequest; -use crate::tower_lsp::LspResponse; - -pub(crate) type TokioUnboundedSender = tokio::sync::mpsc::UnboundedSender; -pub(crate) type TokioUnboundedReceiver = tokio::sync::mpsc::UnboundedReceiver; - -// This is the syntax for trait aliases until an official one is stabilised. -// This alias is for the future of a `JoinHandle>` -trait AnyhowJoinHandleFut: - future::Future, tokio::task::JoinError>> -{ -} -impl AnyhowJoinHandleFut for F where - F: future::Future, tokio::task::JoinError>> -{ -} - -// Alias for a list of join handle futures -type TaskList = futures::stream::FuturesUnordered + Send>>>; - -#[derive(Debug)] -pub(crate) enum Event { - Lsp(LspMessage), - Kernel(KernelNotification), -} - -#[derive(Debug)] -pub(crate) enum KernelNotification { - DidChangeConsoleInputs(ConsoleInputs), -} - -#[derive(Debug)] -pub(crate) enum AuxiliaryEvent { - PublishDiagnostics(Url, Vec, Option), - SpawnedTask(JoinHandle>>), -} - -#[derive(Debug, Clone)] -pub(crate) struct AuxiliaryEventSender { - inner: TokioUnboundedSender, -} - -impl AuxiliaryEventSender { - pub(crate) fn new(tx: TokioUnboundedSender) -> Self { - Self { inner: tx } - } - - /// Passthrough `send()` method to the underlying sender - pub(crate) fn send( - &self, - message: AuxiliaryEvent, - ) -> Result<(), tokio::sync::mpsc::error::SendError> { - self.inner.send(message) - } - - /// Spawn a blocking task - /// - /// This runs tasks that do semantic analysis on a separate thread pool to avoid - /// blocking the main loop. - /// - /// Can optionally return an event for the auxiliary loop (i.e. diagnostics publication). - pub(crate) fn spawn_blocking_task(&self, handler: Handler) - where - Handler: FnOnce() -> anyhow::Result>, - Handler: Send + 'static, - { - let handle = tokio::task::spawn_blocking(handler); - - // Send the join handle to the auxiliary loop so it can log any errors - // or panics - if let Err(err) = self.send(AuxiliaryEvent::SpawnedTask(handle)) { - tracing::warn!("Failed to send task to auxiliary loop due to {err}"); - } - } -} - -/// Global state for the main loop -/// -/// This is a singleton that fully owns the source of truth for `WorldState` -/// which contains the inputs of all LSP methods. The `main_loop()` method is -/// the heart of the LSP. The tower-lsp backend and the Jupyter kernel -/// communicate with the main loop through the `Event` channel that is passed on -/// construction. -pub(crate) struct GlobalState { - /// The global world state containing all inputs for LSP analysis lives - /// here. The dispatcher provides refs, exclusive refs, or snapshots - /// (clones) to handlers. - world: WorldState, - - /// The state containing LSP configuration and tree-sitter parsers for - /// documents contained in the `WorldState`. Only used in exclusive ref - /// handlers, and is not cloneable. - lsp_state: LspState, - - /// LSP client shared with tower-lsp and the log loop - client: Client, - - /// Event receiver channel for the main loop. The tower-lsp methods forward - /// notifications and requests here via `Event::Lsp`. We also receive - /// messages from the kernel via `Event::Kernel`, and from ourselves via - /// `Event::Task`. - events_rx: TokioUnboundedReceiver, - - /// Auxiliary state that gets moved to the auxiliary thread, - /// and our channel for communicating with that thread. - /// Used for sending latency sensitive events like tasks and diagnostics. - auxiliary_state: Option, - auxiliary_event_tx: AuxiliaryEventSender, - - /// Log state that gets moved to the log thread, - /// and a channel for communicating with that thread which we - /// pass on to `init_logging()` during `initialize()`. - log_state: Option, - log_tx: Option, -} - -/// Unlike `WorldState`, `ParserState` cannot be cloned and is only accessed by -/// exclusive handlers. -pub(crate) struct LspState { - /// The negociated encoding for document positions. Note that documents are - /// always stored as UTF-8 in Rust Strings. This encoding is only used to - /// translate UTF-16 positions sent by the client to UTF-8 ones. - pub(crate) position_encoding: PositionEncoding, - - /// The set of tree-sitter document parsers managed by the `GlobalState`. - pub(crate) parsers: HashMap, - - /// List of capabilities for which we need to send a registration request - /// when we get the `Initialized` notification. - pub(crate) needs_registration: ClientCaps, - // Add handle to aux loop here? -} - -impl Default for LspState { - fn default() -> Self { - Self { - // Default encoding specified in the LSP protocol - position_encoding: PositionEncoding::Wide(WideEncoding::Utf16), - parsers: Default::default(), - needs_registration: Default::default(), - } - } -} - -#[derive(Debug, Default)] -pub(crate) struct ClientCaps { - pub(crate) did_change_configuration: bool, -} - -enum LoopControl { - Shutdown, - None, -} - -/// State for the auxiliary loop -/// -/// The auxiliary loop handles latency-sensitive events such as log messages. A -/// main loop tick might takes many milliseconds and might have a lot of events -/// in queue, so it's not appropriate for events that need immediate handling. -/// -/// The auxiliary loop currently handles: -/// - Log messages. -/// - Joining of spawned blocking tasks to relay any errors or panics to the LSP log. -struct AuxiliaryState { - client: Client, - auxiliary_event_rx: TokioUnboundedReceiver, - tasks: TaskList>, -} - -impl GlobalState { - /// Create a new global state - /// - /// # Arguments - /// - /// * `client`: The tower-lsp client shared with the tower-lsp backend - /// and auxiliary loop. - pub(crate) fn new(client: Client) -> (Self, TokioUnboundedSender) { - // Transmission channel for the main loop events. Shared with the - // tower-lsp backend and the Jupyter kernel. - let (events_tx, events_rx) = tokio_unbounded_channel::(); - - let (log_state, log_tx) = LogState::new(client.clone()); - let (auxiliary_state, auxiliary_event_tx) = AuxiliaryState::new(client.clone()); - - let state = Self { - world: WorldState::default(), - lsp_state: LspState::default(), - client, - events_rx, - auxiliary_state: Some(auxiliary_state), - auxiliary_event_tx, - log_state: Some(log_state), - log_tx: Some(log_tx), - }; - - (state, events_tx) - } - - /// Start the main and auxiliary loops - /// - /// Returns a `JoinSet` that holds onto all tasks and state owned by the - /// event loop. Drop it to cancel everything and shut down the service. - pub(crate) fn start(self) -> tokio::task::JoinSet<()> { - let mut set = tokio::task::JoinSet::<()>::new(); - - // Spawn main loop - set.spawn(async move { self.main_loop().await }); - - set - } - - /// Run main loop - /// - /// This takes ownership of all global state and handles one by one LSP - /// requests, notifications, and other internal events. - async fn main_loop(mut self) { - // Spawn latency-sensitive auxiliary and log threads. - let mut set = tokio::task::JoinSet::<()>::new(); - - // Take ownership over `log_state` and start the log thread. - // Unwrap: `start()` should only be called once. - let log_state = self.log_state.take().unwrap(); - set.spawn(async move { log_state.start().await }); - - // Take ownership over `auxiliary_state` and start the auxiliary thread. - // Unwrap: `start()` should only be called once. - let auxiliary_state = self.auxiliary_state.take().unwrap(); - set.spawn(async move { auxiliary_state.start().await }); - - loop { - let event = self.next_event().await; - match self.handle_event(event).await { - Err(err) => tracing::error!("Failure while handling event:\n{err:?}"), - Ok(LoopControl::Shutdown) => break, - _ => {} - } - } - - tracing::trace!("Main loop closed. Shutting down auxiliary and log loop."); - set.shutdown().await; - } - - async fn next_event(&mut self) -> Event { - self.events_rx.recv().await.unwrap() - } - - #[rustfmt::skip] - /// Handle event of main loop - /// - /// The events are attached to _exclusive_, _sharing_, or _concurrent_ - /// handlers. - /// - /// - Exclusive handlers are passed an `&mut` to the world state so they can - /// update it. - /// - Sharing handlers are passed a simple reference. In principle we could - /// run these concurrently but we run these one handler at a time for simplicity. - /// - When concurrent handlers are needed for performance reason (one tick - /// of the main loop should be as fast as possible to increase throughput) - /// they are spawned on blocking threads and provided a snapshot (clone) of - /// the state. - async fn handle_event(&mut self, event: Event) -> anyhow::Result { - let loop_tick = std::time::Instant::now(); - let mut out = LoopControl::None; - - match event { - Event::Lsp(msg) => match msg { - LspMessage::Notification(notif) => { - match notif { - LspNotification::Initialized(_params) => { - handlers::handle_initialized(&self.client, &self.lsp_state).await?; - }, - LspNotification::DidChangeWorkspaceFolders(_params) => { - // TODO: Restart indexer with new folders. - }, - LspNotification::DidChangeConfiguration(params) => { - handlers_state::did_change_configuration(params, &self.client, &mut self.world).await?; - }, - LspNotification::DidChangeWatchedFiles(_params) => { - // TODO: Re-index the changed files. - }, - LspNotification::DidOpenTextDocument(params) => { - handlers_state::did_open(params, &self.lsp_state, &mut self.world)?; - }, - LspNotification::DidChangeTextDocument(params) => { - handlers_state::did_change(params, &mut self.world)?; - }, - LspNotification::DidSaveTextDocument(_params) => { - // Currently ignored - }, - LspNotification::DidCloseTextDocument(params) => { - handlers_state::did_close(params, &mut self.world)?; - }, - } - }, - - LspMessage::Request(request, tx) => { - match request { - LspRequest::Initialize(params) => { - // Unwrap: `Initialize` method should only be called once. - let log_tx = self.log_tx.take().unwrap(); - respond(tx, handlers_state::initialize(params, &mut self.lsp_state, &mut self.world, log_tx), LspResponse::Initialize)?; - }, - LspRequest::Shutdown => { - out = LoopControl::Shutdown; - respond(tx, Ok(()), LspResponse::Shutdown)?; - }, - LspRequest::DocumentFormatting(params) => { - respond(tx, handlers_format::document_formatting(params, &self.world), LspResponse::DocumentFormatting)?; - }, - LspRequest::DocumentRangeFormatting(params) => { - respond(tx, handlers_format::document_range_formatting(params, &self.world), LspResponse::DocumentRangeFormatting)?; - }, - LspRequest::AirViewFile(params) => { - respond(tx, handlers_ext::view_file(params, &self.world), LspResponse::AirViewFile)?; - }, - }; - }, - }, - - Event::Kernel(notif) => match notif { - KernelNotification::DidChangeConsoleInputs(_inputs) => { - // TODO - }, - }, - } - - // TODO Make this threshold configurable by the client - if loop_tick.elapsed() > std::time::Duration::from_millis(50) { - tracing::trace!("Handler took {}ms", loop_tick.elapsed().as_millis()); - } - - Ok(out) - } - - #[allow(dead_code)] // Currently unused - /// Spawn blocking thread for LSP request handler - /// - /// Use this for handlers that might take too long to handle on the main - /// loop and negatively affect throughput. - /// - /// The LSP protocol allows concurrent handling as long as it doesn't affect - /// correctness of responses. For instance handlers that only inspect the - /// world state could be run concurrently. On the other hand, handlers that - /// manipulate documents (e.g. formatting or refactoring) should not. - fn spawn_handler( - &self, - response_tx: TokioUnboundedSender>, - handler: Handler, - into_lsp_response: impl FnOnce(T) -> LspResponse + Send + 'static, - ) where - Handler: FnOnce() -> anyhow::Result, - Handler: Send + 'static, - { - self.auxiliary_event_tx.spawn_blocking_task(move || { - respond(response_tx, handler(), into_lsp_response).and(Ok(None)) - }); - } -} - -/// Respond to a request from the LSP -/// -/// We receive requests from the LSP client with a response channel. Once we -/// have a response, we send it to tower-lsp which will forward it to the -/// client. -/// -/// The response channel will be closed if the request has been cancelled on -/// the tower-lsp side. In that case the future of the async request method -/// has been dropped, along with the receiving side of this channel. It's -/// unclear whether we want to support this sort of client-side cancellation -/// better. We should probably focus on cancellation of expensive tasks -/// running on side threads when the world state has changed. -/// -/// # Arguments -/// -/// * - `response_tx`: A response channel for the tower-lsp request handler. -/// * - `response`: The response wrapped in a `anyhow::Result`. Errors are logged. -/// * - `into_lsp_response`: A constructor for the relevant `LspResponse` variant. -fn respond( - response_tx: TokioUnboundedSender>, - response: anyhow::Result, - into_lsp_response: impl FnOnce(T) -> LspResponse, -) -> anyhow::Result<()> { - let out = match response { - Ok(_) => Ok(()), - Err(ref err) => Err(anyhow!("Error while handling request:\n{err:?}")), - }; - - let response = response.map(into_lsp_response); - - // Ignore errors from a closed channel. This indicates the request has - // been cancelled on the tower-lsp side. - let _ = response_tx.send(response); - - out -} - -// Needed for spawning the loop -unsafe impl Sync for AuxiliaryState {} - -impl AuxiliaryState { - fn new(client: Client) -> (Self, AuxiliaryEventSender) { - // Channels for communication with the auxiliary loop - let (auxiliary_event_tx, auxiliary_event_rx) = tokio_unbounded_channel::(); - let auxiliary_event_tx = AuxiliaryEventSender::new(auxiliary_event_tx); - - // List of pending tasks for which we manage the lifecycle (mainly relay - // errors and panics) - let tasks = futures::stream::FuturesUnordered::new(); - - // Prevent the stream from ever being empty so that `tasks.next()` never - // resolves to `None` - let pending = - tokio::task::spawn(future::pending::>>()); - let pending = - Box::pin(pending) as Pin> + Send>>; - tasks.push(pending); - - let state = Self { - client, - auxiliary_event_rx, - tasks, - }; - - (state, auxiliary_event_tx) - } - - /// Start the auxiliary loop - /// - /// Takes ownership of auxiliary state and start the low-latency auxiliary - /// loop. - async fn start(mut self) -> ! { - loop { - match self.next_event().await { - AuxiliaryEvent::SpawnedTask(handle) => self.tasks.push(Box::pin(handle)), - AuxiliaryEvent::PublishDiagnostics(uri, diagnostics, version) => { - self.client - .publish_diagnostics(uri, diagnostics, version) - .await - } - } - } - } - - async fn next_event(&mut self) -> AuxiliaryEvent { - loop { - tokio::select! { - event = self.auxiliary_event_rx.recv() => return event.unwrap(), - - handle = self.tasks.next() => match handle.unwrap() { - // A joined task returned an event for us, handle it - Ok(Ok(Some(event))) => return event, - - // Otherwise relay any errors and loop back into select - Err(err) => tracing::error!("A task panicked:\n{err:?}"), - Ok(Err(err)) => tracing::error!("A task failed:\n{err:?}"), - _ => (), - }, - } - } - } -} diff --git a/crates/lsp/src/rust_analyzer/line_index.rs b/crates/lsp/src/rust_analyzer/line_index.rs deleted file mode 100644 index f1634d17..00000000 --- a/crates/lsp/src/rust_analyzer/line_index.rs +++ /dev/null @@ -1,19 +0,0 @@ -// --- source -// authors = ["rust-analyzer team"] -// license = "MIT OR Apache-2.0" -// origin = "https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/line_index.rs" -// --- - -//! Enhances `ide::LineIndex` with additional info required to convert offsets -//! into lsp positions. - -use biome_lsp_converters::line_index; -use line_ending::LineEnding; -use triomphe::Arc; - -#[derive(Debug, Clone)] -pub struct LineIndex { - pub index: Arc, - pub endings: LineEnding, - pub encoding: biome_lsp_converters::PositionEncoding, -} diff --git a/crates/lsp/src/rust_analyzer/mod.rs b/crates/lsp/src/rust_analyzer/mod.rs deleted file mode 100644 index bfb231cc..00000000 --- a/crates/lsp/src/rust_analyzer/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod diff; -pub mod line_index; -pub mod text_edit; -pub mod to_proto; -pub mod utils; diff --git a/crates/lsp/src/rust_analyzer/to_proto.rs b/crates/lsp/src/rust_analyzer/to_proto.rs deleted file mode 100644 index 1eab5351..00000000 --- a/crates/lsp/src/rust_analyzer/to_proto.rs +++ /dev/null @@ -1,60 +0,0 @@ -// --- source -// authors = ["rust-analyzer team"] -// license = "MIT OR Apache-2.0" -// origin = "https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/lsp/to_proto.rs" -// --- - -//! Conversion of rust-analyzer specific types to lsp_types equivalents. - -use super::{ - line_index::LineIndex, - text_edit::{Indel, TextEdit}, -}; -use line_ending::LineEnding; -use tower_lsp::lsp_types; - -pub(crate) fn text_edit( - line_index: &LineIndex, - indel: Indel, -) -> anyhow::Result { - let range = biome_lsp_converters::to_proto::range( - &line_index.index, - indel.delete, - line_index.encoding, - )?; - let new_text = match line_index.endings { - LineEnding::Lf => indel.insert, - LineEnding::Crlf => indel.insert.replace('\n', "\r\n"), - }; - Ok(lsp_types::TextEdit { range, new_text }) -} - -pub(crate) fn completion_text_edit( - line_index: &LineIndex, - insert_replace_support: Option, - indel: Indel, -) -> anyhow::Result { - let text_edit = text_edit(line_index, indel)?; - Ok(match insert_replace_support { - Some(cursor_pos) => lsp_types::InsertReplaceEdit { - new_text: text_edit.new_text, - insert: lsp_types::Range { - start: text_edit.range.start, - end: cursor_pos, - }, - replace: text_edit.range, - } - .into(), - None => text_edit.into(), - }) -} - -pub(crate) fn text_edit_vec( - line_index: &LineIndex, - text_edit: TextEdit, -) -> anyhow::Result> { - text_edit - .into_iter() - .map(|indel| self::text_edit(line_index, indel)) - .collect() -} diff --git a/crates/lsp/src/rust_analyzer/utils.rs b/crates/lsp/src/rust_analyzer/utils.rs deleted file mode 100644 index 0e7856d8..00000000 --- a/crates/lsp/src/rust_analyzer/utils.rs +++ /dev/null @@ -1,67 +0,0 @@ -// --- source -// authors = ["rust-analyzer team"] -// license = "MIT OR Apache-2.0" -// origin = "https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/lsp/utils.rs" -// --- - -use std::ops::Range; - -use biome_lsp_converters::line_index; -use line_ending::LineEnding; -use tower_lsp::lsp_types; -use triomphe::Arc; - -use crate::from_proto; - -use super::line_index::LineIndex; - -pub(crate) fn apply_document_changes( - encoding: biome_lsp_converters::PositionEncoding, - file_contents: &str, - mut content_changes: Vec, -) -> String { - // If at least one of the changes is a full document change, use the last - // of them as the starting point and ignore all previous changes. - let (mut text, content_changes) = match content_changes - .iter() - .rposition(|change| change.range.is_none()) - { - Some(idx) => { - let text = std::mem::take(&mut content_changes[idx].text); - (text, &content_changes[idx + 1..]) - } - None => (file_contents.to_owned(), &content_changes[..]), - }; - if content_changes.is_empty() { - return text; - } - - let mut line_index = LineIndex { - // the index will be overwritten in the bottom loop's first iteration - index: Arc::new(line_index::LineIndex::new(&text)), - // We don't care about line endings here. - endings: LineEnding::Lf, - encoding, - }; - - // The changes we got must be applied sequentially, but can cross lines so we - // have to keep our line index updated. - // Some clients (e.g. Code) sort the ranges in reverse. As an optimization, we - // remember the last valid line in the index and only rebuild it if needed. - // The VFS will normalize the end of lines to `\n`. - let mut index_valid = !0u32; - for change in content_changes { - // The None case can't happen as we have handled it above already - if let Some(range) = change.range { - if index_valid <= range.end.line { - *Arc::make_mut(&mut line_index.index) = line_index::LineIndex::new(&text); - } - index_valid = range.start.line; - if let Ok(range) = from_proto::text_range(&line_index.index, range, line_index.encoding) - { - text.replace_range(Range::::from(range), &change.text); - } - } - } - text -} diff --git a/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax-2.snap b/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax-2.snap deleted file mode 100644 index 035eeb75..00000000 --- a/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax-2.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: crates/lsp/src/documents.rs -expression: updated_syntax ---- -0: R_ROOT@0..10 - 0: (empty) - 1: R_EXPRESSION_LIST@0..10 - 0: R_CALL@0..10 - 0: R_IDENTIFIER@0..3 - 0: IDENT@0..3 "foo" [] [] - 1: R_CALL_ARGUMENTS@3..10 - 0: L_PAREN@3..4 "(" [] [] - 1: R_ARGUMENT_LIST@4..9 - 0: R_ARGUMENT@4..9 - 0: (empty) - 1: R_BINARY_EXPRESSION@4..9 - 0: R_DOUBLE_VALUE@4..5 - 0: R_DOUBLE_LITERAL@4..5 "1" [] [] - 1: PLUS@5..7 "+" [Whitespace(" ")] [] - 2: R_DOUBLE_VALUE@7..9 - 0: R_DOUBLE_LITERAL@7..9 "2" [Whitespace(" ")] [] - 2: R_PAREN@9..10 ")" [] [] - 2: EOF@10..10 "" [] [] diff --git a/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax.snap b/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax.snap deleted file mode 100644 index 2717648c..00000000 --- a/crates/lsp/src/snapshots/lsp__documents__tests__document_syntax.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: crates/lsp/src/documents.rs -expression: original_syntax ---- -0: R_ROOT@0..8 - 0: (empty) - 1: R_EXPRESSION_LIST@0..8 - 0: R_CALL@0..8 - 0: R_IDENTIFIER@0..3 - 0: IDENT@0..3 "foo" [] [] - 1: R_CALL_ARGUMENTS@3..8 - 0: L_PAREN@3..4 "(" [] [] - 1: R_ARGUMENT_LIST@4..7 - 0: R_ARGUMENT@4..7 - 0: (empty) - 1: R_IDENTIFIER@4..7 - 0: IDENT@4..7 "bar" [] [] - 2: R_PAREN@7..8 ")" [] [] - 2: EOF@8..8 "" [] [] diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format.snap deleted file mode 100644 index 8bd66ada..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: formatted ---- -1 -2 + 2 -3 + 3 + 3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-2.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-2.snap deleted file mode 100644 index ec60bfb9..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-2.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1+1 -# -2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-3.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-3.snap deleted file mode 100644 index f5bf150f..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-3.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1+1 -{2 + 2} diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap deleted file mode 100644 index cda77a86..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1+1 -{ - 2 + 2 -} diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-5.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-5.snap deleted file mode 100644 index df9e8931..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-5.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1+1 -{ - 2+2 - { - 3 + 3 - } -} diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines.snap deleted file mode 100644 index cf743f29..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1+1 -2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_mismatched_indent.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_mismatched_indent.snap deleted file mode 100644 index 9681b5cd..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_mismatched_indent.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1 - 2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines-2.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines-2.snap deleted file mode 100644 index cc0b2f04..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines-2.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output2 ---- -1 + 1 -# -2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines.snap deleted file mode 100644 index f63c36ef..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_multiple_lines.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output1 ---- -1+1 -# -2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-2.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-2.snap deleted file mode 100644 index f3257fac..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-2.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- - diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-3.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-3.snap deleted file mode 100644 index 0fb74fc7..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none-3.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- -1 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none.snap deleted file mode 100644 index f3257fac..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_none.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output ---- - diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap deleted file mode 100644 index 776dab17..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output2 ---- -0+0 -1 + 1 -{ - 2 + 2 -} -3+3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap deleted file mode 100644 index 5d9e27d0..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output3 ---- -0+0 -1 + 1 -{ - 2 + 2 -} -3 + 3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap deleted file mode 100644 index 4234f400..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output4 ---- -0+0 -1+1 -{ - 2 + 2 -} -3 + 3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-5.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-5.snap deleted file mode 100644 index 2055d4bb..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-5.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output5 ---- -1 + 1 -2+2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-6.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-6.snap deleted file mode 100644 index 2388991a..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-6.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output6 ---- -1+1 -2 + 2 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap deleted file mode 100644 index 33752478..00000000 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/lsp/src/handlers_format.rs -expression: output1 ---- -0+0 -1 + 1 -{ - 2 + 2 -} -3+3 diff --git a/crates/lsp/src/state.rs b/crates/lsp/src/state.rs deleted file mode 100644 index 1bd38433..00000000 --- a/crates/lsp/src/state.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::collections::HashMap; - -use anyhow::anyhow; -use url::Url; - -use crate::config::LspConfig; -use crate::documents::Document; - -#[derive(Clone, Default, Debug)] -/// The world state, i.e. all the inputs necessary for analysing or refactoring -/// code. This is a pure value. There is no interior mutability in this data -/// structure. It can be cloned and safely sent to other threads. -pub(crate) struct WorldState { - /// Watched documents - pub(crate) documents: HashMap, - - /// Watched folders - pub(crate) workspace: Workspace, - - /// The scopes for the console. This currently contains a list (outer `Vec`) - /// of names (inner `Vec`) within the environments on the search path, starting - /// from the global environment and ending with the base package. Eventually - /// this might also be populated with the scope for the current environment - /// in debug sessions (not implemented yet). - /// - /// This is currently one of the main sources of known symbols for - /// diagnostics. In the future we should better delineate interactive - /// contexts (e.g. the console, but scripts might also be treated as - /// interactive, which could be a user setting) and non-interactive ones - /// (e.g. a package). In non-interactive contexts, the lexical scopes - /// examined for diagnostics should be fully determined by variable bindings - /// and imports (code-first diagnostics). - /// - /// In the future this should probably become more complex with a list of - /// either symbol names (as is now the case) or named environments, such as - /// `pkg:ggplot2`. Storing named environments here will allow the LSP to - /// retrieve the symbols in a pull fashion (the whole console scopes are - /// currently pushed to the LSP), and cache the symbols with Salsa. The - /// performance is not currently an issue but this could change once we do - /// more analysis of symbols in the search path. - pub(crate) console_scopes: Vec>, - - /// Currently installed packages - pub(crate) installed_packages: Vec, - - pub(crate) config: LspConfig, -} - -#[derive(Clone, Default, Debug)] -pub(crate) struct Workspace { - pub folders: Vec, -} - -impl WorldState { - pub(crate) fn get_document(&self, uri: &Url) -> anyhow::Result<&Document> { - if let Some(doc) = self.documents.get(uri) { - Ok(doc) - } else { - Err(anyhow!("Can't find document for URI {uri}")) - } - } - - pub(crate) fn get_document_mut(&mut self, uri: &Url) -> anyhow::Result<&mut Document> { - if let Some(doc) = self.documents.get_mut(uri) { - Ok(doc) - } else { - Err(anyhow!("Can't find document for URI {uri}")) - } - } -} - -pub(crate) fn workspace_uris(state: &WorldState) -> Vec { - let uris: Vec = state.documents.iter().map(|elt| elt.0.clone()).collect(); - uris -} diff --git a/crates/lsp/src/to_proto.rs b/crates/lsp/src/to_proto.rs deleted file mode 100644 index 4b4c5f5e..00000000 --- a/crates/lsp/src/to_proto.rs +++ /dev/null @@ -1,51 +0,0 @@ -// -// to_proto.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -// Utilites for converting internal types to LSP types - -pub(crate) use rust_analyzer::to_proto::text_edit_vec; - -#[cfg(test)] -pub(crate) use biome_lsp_converters::to_proto::range; - -use crate::rust_analyzer::{self, line_index::LineIndex, text_edit::TextEdit}; -use biome_text_size::TextRange; -use tower_lsp::lsp_types; - -pub(crate) fn doc_edit_vec( - line_index: &LineIndex, - text_edit: TextEdit, -) -> anyhow::Result> { - let edits = text_edit_vec(line_index, text_edit)?; - - Ok(edits - .into_iter() - .map(|edit| lsp_types::TextDocumentContentChangeEvent { - range: Some(edit.range), - range_length: None, - text: edit.new_text, - }) - .collect()) -} - -pub(crate) fn replace_range_edit( - line_index: &LineIndex, - range: TextRange, - replace_with: String, -) -> anyhow::Result> { - let edit = TextEdit::replace(range, replace_with); - text_edit_vec(line_index, edit) -} - -pub(crate) fn replace_all_edit( - line_index: &LineIndex, - text: &str, - replace_with: &str, -) -> anyhow::Result> { - let edit = TextEdit::diff(text, replace_with); - text_edit_vec(line_index, edit) -} diff --git a/crates/lsp/src/tower_lsp.rs b/crates/lsp/src/tower_lsp.rs deleted file mode 100644 index ba114988..00000000 --- a/crates/lsp/src/tower_lsp.rs +++ /dev/null @@ -1,361 +0,0 @@ -// -// tower_lsp.rs -// -// Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. -// -// - -#![allow(deprecated)] - -use strum::IntoStaticStr; - -use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::sync::mpsc::unbounded_channel as tokio_unbounded_channel; -use tower_lsp::jsonrpc::Result; -use tower_lsp::lsp_types::*; -use tower_lsp::Client; -use tower_lsp::LanguageServer; -use tower_lsp::LspService; -use tower_lsp::{jsonrpc, ClientSocket}; - -use crate::handlers_ext::ViewFileParams; -use crate::main_loop::Event; -use crate::main_loop::GlobalState; -use crate::main_loop::TokioUnboundedSender; - -// Based on https://stackoverflow.com/a/69324393/1725177 -macro_rules! cast_response { - ($target:expr, $pat:path) => {{ - match $target { - Ok($pat(resp)) => Ok(resp), - Err(err) => Err(new_jsonrpc_error(format!("{err:?}"))), - _ => panic!("Unexpected variant while casting to {}", stringify!($pat)), - } - }}; -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -pub(crate) enum LspMessage { - Notification(LspNotification), - Request( - LspRequest, - TokioUnboundedSender>, - ), -} - -#[derive(Debug, IntoStaticStr)] -pub(crate) enum LspNotification { - Initialized(InitializedParams), - DidChangeWorkspaceFolders(DidChangeWorkspaceFoldersParams), - DidChangeConfiguration(DidChangeConfigurationParams), - DidChangeWatchedFiles(DidChangeWatchedFilesParams), - DidOpenTextDocument(DidOpenTextDocumentParams), - DidChangeTextDocument(DidChangeTextDocumentParams), - DidSaveTextDocument(DidSaveTextDocumentParams), - DidCloseTextDocument(DidCloseTextDocumentParams), -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, IntoStaticStr)] -pub(crate) enum LspRequest { - Initialize(InitializeParams), - DocumentFormatting(DocumentFormattingParams), - Shutdown, - DocumentRangeFormatting(DocumentRangeFormattingParams), - AirViewFile(ViewFileParams), -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug, IntoStaticStr)] -pub(crate) enum LspResponse { - Initialize(InitializeResult), - DocumentFormatting(Option>), - DocumentRangeFormatting(Option>), - Shutdown(()), - AirViewFile(String), -} - -impl std::fmt::Display for LspNotification { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.into()) - } -} -impl std::fmt::Display for LspRequest { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.into()) - } -} -impl std::fmt::Display for LspResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.into()) - } -} - -impl LspNotification { - fn trace(&self) -> TraceLspNotification { - TraceLspNotification { inner: self } - } -} -impl LspRequest { - fn trace(&self) -> TraceLspRequest { - TraceLspRequest { inner: self } - } -} -impl LspResponse { - fn trace(&self) -> TraceLspResponse { - TraceLspResponse { inner: self } - } -} - -struct TraceLspNotification<'a> { - inner: &'a LspNotification, -} -struct TraceLspRequest<'a> { - inner: &'a LspRequest, -} -struct TraceLspResponse<'a> { - inner: &'a LspResponse, -} - -impl std::fmt::Debug for TraceLspNotification<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.inner { - LspNotification::DidOpenTextDocument(params) => { - // Ignore the document itself in trace logs - f.debug_tuple(self.inner.into()) - .field(¶ms.text_document.uri) - .field(¶ms.text_document.version) - .field(¶ms.text_document.language_id) - .finish() - } - _ => std::fmt::Debug::fmt(self.inner, f), - } - } -} - -impl std::fmt::Debug for TraceLspRequest<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(self.inner, f) - } -} - -impl std::fmt::Debug for TraceLspResponse<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(self.inner, f) - } -} - -#[derive(Debug)] -struct Backend { - /// Channel for communication with the main loop. - events_tx: TokioUnboundedSender, - - /// Handle to main loop. Drop it to cancel the loop, all associated tasks, - /// and drop all owned state. - _main_loop: tokio::task::JoinSet<()>, -} - -impl Backend { - async fn request(&self, request: LspRequest) -> anyhow::Result { - tracing::info!("Incoming: {request}"); - tracing::trace!("Incoming (debug):\n{request:#?}", request = request.trace()); - - let (response_tx, mut response_rx) = - tokio_unbounded_channel::>(); - - // Relay request to main loop - self.events_tx - .send(Event::Lsp(LspMessage::Request(request, response_tx))) - .unwrap(); - - // Wait for response from main loop - let response = response_rx.recv().await.unwrap()?; - - tracing::info!("Outgoing: {response}"); - tracing::trace!( - "Outgoing (debug):\n{response:#?}", - response = response.trace() - ); - Ok(response) - } - - fn notify(&self, notif: LspNotification) { - tracing::info!("Incoming: {notif}"); - tracing::trace!("Incoming (debug):\n{notif:#?}", notif = notif.trace()); - - // Relay notification to main loop - self.events_tx - .send(Event::Lsp(LspMessage::Notification(notif))) - .unwrap(); - } - - async fn air_view_file(&self, params: ViewFileParams) -> tower_lsp::jsonrpc::Result { - cast_response!( - self.request(LspRequest::AirViewFile(params)).await, - LspResponse::AirViewFile - ) - } -} - -#[tower_lsp::async_trait] -impl LanguageServer for Backend { - async fn initialize(&self, params: InitializeParams) -> Result { - cast_response!( - self.request(LspRequest::Initialize(params)).await, - LspResponse::Initialize - ) - } - - async fn initialized(&self, params: InitializedParams) { - self.notify(LspNotification::Initialized(params)); - } - - async fn shutdown(&self) -> Result<()> { - cast_response!( - self.request(LspRequest::Shutdown).await, - LspResponse::Shutdown - ) - } - - async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { - self.notify(LspNotification::DidChangeWorkspaceFolders(params)); - } - - async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { - self.notify(LspNotification::DidChangeConfiguration(params)); - } - - async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { - self.notify(LspNotification::DidChangeWatchedFiles(params)); - } - - async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.notify(LspNotification::DidOpenTextDocument(params)); - } - - async fn did_change(&self, params: DidChangeTextDocumentParams) { - self.notify(LspNotification::DidChangeTextDocument(params)); - } - - async fn did_save(&self, params: DidSaveTextDocumentParams) { - self.notify(LspNotification::DidSaveTextDocument(params)); - } - - async fn did_close(&self, params: DidCloseTextDocumentParams) { - self.notify(LspNotification::DidCloseTextDocument(params)); - } - - async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { - cast_response!( - self.request(LspRequest::DocumentFormatting(params)).await, - LspResponse::DocumentFormatting - ) - } - - async fn range_formatting( - &self, - params: DocumentRangeFormattingParams, - ) -> Result>> { - cast_response!( - self.request(LspRequest::DocumentRangeFormatting(params)) - .await, - LspResponse::DocumentRangeFormatting - ) - } -} - -pub async fn start_lsp(read: I, write: O) -where - I: AsyncRead + Unpin, - O: AsyncWrite, -{ - let (service, socket) = new_lsp(); - let server = tower_lsp::Server::new(read, write, socket); - server.serve(service).await; -} - -fn new_lsp() -> (LspService, ClientSocket) { - let init = |client: Client| { - let (state, events_tx) = GlobalState::new(client); - - // Start main loop and hold onto the handle that keeps it alive - let main_loop = state.start(); - - Backend { - events_tx, - _main_loop: main_loop, - } - }; - - LspService::build(init) - .custom_method("air/viewFile", Backend::air_view_file) - .finish() -} - -fn new_jsonrpc_error(message: String) -> jsonrpc::Error { - jsonrpc::Error { - code: jsonrpc::ErrorCode::ServerError(-1), - message: message.into(), - data: None, - } -} - -#[cfg(test)] -pub(crate) async fn start_test_client() -> lsp_test::lsp_client::TestClient { - lsp_test::lsp_client::TestClient::new(|server_rx, client_tx| async { - start_lsp(server_rx, client_tx).await - }) -} - -#[cfg(test)] -pub(crate) async fn init_test_client() -> lsp_test::lsp_client::TestClient { - let mut client = start_test_client().await; - - client.initialize().await; - client.recv_response().await; - - client -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_matches::assert_matches; - use tower_lsp::lsp_types; - - #[tests_macros::lsp_test] - async fn test_init() { - let mut client = start_test_client().await; - - client.initialize().await; - - let value = client.recv_response().await; - let value: lsp_types::InitializeResult = - serde_json::from_value(value.result().unwrap().clone()).unwrap(); - - assert_matches!( - value, - lsp_types::InitializeResult { - capabilities, - server_info - } => { - assert_matches!(capabilities, ServerCapabilities { - position_encoding, - text_document_sync, - .. - } => { - assert_eq!(position_encoding, None); - assert_eq!(text_document_sync, Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::INCREMENTAL))); - }); - - assert_matches!(server_info, Some(ServerInfo { name, version }) => { - assert!(name.contains("Air Language Server")); - assert!(version.is_some()); - }); - } - ); - - client - } -} diff --git a/crates/lsp/src/tower_lsp_test_client.rs b/crates/lsp/src/tower_lsp_test_client.rs deleted file mode 100644 index cae23ed9..00000000 --- a/crates/lsp/src/tower_lsp_test_client.rs +++ /dev/null @@ -1,122 +0,0 @@ -use biome_text_size::TextRange; -use lsp_test::lsp_client::TestClient; -use tower_lsp::lsp_types; - -use crate::{documents::Document, from_proto, to_proto}; - -pub(crate) trait TestClientExt { - async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem; - - async fn format_document(&mut self, doc: &Document) -> String; - async fn format_document_range(&mut self, doc: &Document, range: TextRange) -> String; - async fn format_document_edits(&mut self, doc: &Document) -> Option>; - async fn format_document_range_edits( - &mut self, - doc: &Document, - range: TextRange, - ) -> Option>; -} - -impl TestClientExt for TestClient { - async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem { - let path = format!("test://{}", uuid::Uuid::new_v4()); - let uri = url::Url::parse(&path).unwrap(); - - let text_document = lsp_types::TextDocumentItem { - uri, - language_id: String::from("r"), - version: 0, - text: doc.contents.clone(), - }; - - let params = lsp_types::DidOpenTextDocumentParams { - text_document: text_document.clone(), - }; - self.did_open_text_document(params).await; - - text_document - } - - async fn format_document(&mut self, doc: &Document) -> String { - let edits = self.format_document_edits(doc).await.unwrap(); - from_proto::apply_text_edits(doc, edits).unwrap() - } - - async fn format_document_range(&mut self, doc: &Document, range: TextRange) -> String { - let Some(edits) = self.format_document_range_edits(doc, range).await else { - return doc.contents.clone(); - }; - from_proto::apply_text_edits(doc, edits).unwrap() - } - - async fn format_document_edits(&mut self, doc: &Document) -> Option> { - let lsp_doc = self.open_document(doc).await; - - let options = lsp_types::FormattingOptions { - tab_size: 4, - insert_spaces: false, - ..Default::default() - }; - - self.formatting(lsp_types::DocumentFormattingParams { - text_document: lsp_types::TextDocumentIdentifier { - uri: lsp_doc.uri.clone(), - }, - options, - work_done_progress_params: Default::default(), - }) - .await; - - let response = self.recv_response().await; - - if let Some(err) = response.error() { - panic!("Unexpected error: {}", err.message); - }; - - let value: Option> = - serde_json::from_value(response.result().unwrap().clone()).unwrap(); - - self.close_document(lsp_doc.uri).await; - - value - } - - async fn format_document_range_edits( - &mut self, - doc: &Document, - range: TextRange, - ) -> Option> { - let lsp_doc = self.open_document(doc).await; - - let options = lsp_types::FormattingOptions { - tab_size: 4, - insert_spaces: false, - ..Default::default() - }; - - let range = to_proto::range(&doc.line_index.index, range, doc.line_index.encoding).unwrap(); - - self.range_formatting(lsp_types::DocumentRangeFormattingParams { - text_document: lsp_types::TextDocumentIdentifier { - uri: lsp_doc.uri.clone(), - }, - range, - options, - work_done_progress_params: Default::default(), - }) - .await; - - let response = self.recv_response().await; - - if let Some(err) = response.error() { - panic!("Unexpected error: {}", err.message); - }; - - let value: Option> = - serde_json::from_value(response.result().unwrap().clone()).unwrap(); - - self.close_document(lsp_doc.uri).await; - - value - } -} diff --git a/crates/lsp_test/Cargo.toml b/crates/lsp_test/Cargo.toml deleted file mode 100644 index c76e8be8..00000000 --- a/crates/lsp_test/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "lsp_test" -version = "0.0.0" -publish = false -authors.workspace = true -categories.workspace = true -edition.workspace = true -homepage.workspace = true -keywords.workspace = true -license.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -bytes.workspace = true -futures.workspace = true -futures-util.workspace = true -httparse.workspace = true -memchr.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio = { workspace = true, features = ["full"] } -tokio-util.workspace = true -tower-lsp.workspace = true -tracing.workspace = true -url.workspace = true - -[lints] -workspace = true diff --git a/crates/lsp_test/src/lib.rs b/crates/lsp_test/src/lib.rs deleted file mode 100644 index 91bd341f..00000000 --- a/crates/lsp_test/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod lsp_client; - -pub(crate) mod tower_lsp; diff --git a/crates/lsp_test/src/lsp_client.rs b/crates/lsp_test/src/lsp_client.rs deleted file mode 100644 index dc6cd1da..00000000 --- a/crates/lsp_test/src/lsp_client.rs +++ /dev/null @@ -1,163 +0,0 @@ -// -// lsp_client.rs -// -// Copyright (C) 2024 Posit Software, PBC. All rights reserved. -// -// - -use futures::StreamExt; -use futures_util::sink::SinkExt; -use std::future::Future; -use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::io::{ReadHalf, SimplexStream, WriteHalf}; -use tokio_util::codec::{FramedRead, FramedWrite}; -use tower_lsp::lsp_types::ClientInfo; -use tower_lsp::{jsonrpc, lsp_types}; - -use crate::tower_lsp::codec::LanguageServerCodec; -use crate::tower_lsp::request::Request; - -pub struct TestClient { - pub rx: FramedRead, LanguageServerCodec>, - pub tx: FramedWrite, LanguageServerCodec>, - - server_handle: Option>, - id_counter: i64, - - init_params: Option, -} - -impl TestClient { - pub fn new(start: F) -> Self - where - F: FnOnce(Box, Box) -> Fut, - Fut: Future + Send + 'static, - { - let (client_rx, client_tx) = tokio::io::simplex(1024); - let (server_rx, server_tx) = tokio::io::simplex(1024); - - let server_handle = tokio::spawn(start(Box::new(server_rx), Box::new(client_tx))); - - let rx = FramedRead::new(client_rx, LanguageServerCodec::default()); - let tx = FramedWrite::new(server_tx, LanguageServerCodec::default()); - - Self { - rx, - tx, - server_handle: Some(server_handle), - id_counter: 0, - init_params: None, - } - } - - // `jsonrpc::Id` requires i64 IDs - fn id(&mut self) -> i64 { - let id = self.id_counter; - self.id_counter = id + 1; - id - } - - pub async fn recv_response(&mut self) -> jsonrpc::Response { - // Unwrap: Option (None if stream closed), then Result (Err if codec fails). - self.rx.next().await.unwrap().unwrap() - } - - pub async fn notify(&mut self, params: N::Params) - where - N: lsp_types::notification::Notification, - { - let not = Request::from_notification::(params); - - // Unwrap: For this test client it's fine to panic if we can't send - self.tx.send(not).await.unwrap(); - } - - pub async fn request(&mut self, params: R::Params) -> i64 - where - R: lsp_types::request::Request, - { - let id = self.id(); - let req = Request::from_request::(jsonrpc::Id::Number(id), params); - - // Unwrap: For this test client it's fine to panic if we can't send - self.tx.send(req).await.unwrap(); - - id - } - - pub async fn initialize(&mut self) -> i64 { - let params: Option = std::mem::take(&mut self.init_params); - let params = params.unwrap_or_default(); - let params = Self::with_client_info(params); - self.request::(params).await - } - - // Regardless of how we got the params, ensure the client name is set to - // `AirTestClient` so we can recognize it when we set up global logging. - fn with_client_info( - mut init_params: lsp_types::InitializeParams, - ) -> lsp_types::InitializeParams { - init_params.client_info = Some(ClientInfo { - name: String::from("AirTestClient"), - version: None, - }); - init_params - } - - pub fn with_initialize_params(&mut self, init_params: lsp_types::InitializeParams) { - self.init_params = Some(init_params); - } - - pub async fn close_document(&mut self, uri: url::Url) { - let params = lsp_types::DidCloseTextDocumentParams { - text_document: lsp_types::TextDocumentIdentifier { uri }, - }; - self.did_close_text_document(params).await; - } - - pub async fn shutdown(&mut self) { - // TODO: Check that no messages are incoming - - // Don't use `Request::from_request()`. It has a bug with undefined - // params (when `R::Params = ()`) which causes tower-lsp to not - // recognise the Shutdown request. - let req = Request::build("shutdown").id(self.id()).finish(); - - // Unwrap: For this test client it's fine to panic if we can't send - self.tx.send(req).await.unwrap(); - self.recv_response().await; - } - - pub async fn exit(&mut self) { - // Unwrap: Can only exit once - let handle = std::mem::take(&mut self.server_handle).unwrap(); - - self.notify::(()).await; - - // Now wait for the server task to complete. - // Unwrap: Panics if task can't shut down as expected - handle.await.unwrap(); - } - - pub async fn did_open_text_document(&mut self, params: lsp_types::DidOpenTextDocumentParams) { - self.notify::(params) - .await - } - - pub async fn did_close_text_document(&mut self, params: lsp_types::DidCloseTextDocumentParams) { - self.notify::(params) - .await - } - - pub async fn formatting(&mut self, params: lsp_types::DocumentFormattingParams) -> i64 { - self.request::(params).await - } - - pub async fn range_formatting( - &mut self, - params: lsp_types::DocumentRangeFormattingParams, - ) -> i64 { - self.request::(params) - .await - } -} diff --git a/crates/lsp_test/src/tower_lsp/codec.rs b/crates/lsp_test/src/tower_lsp/codec.rs deleted file mode 100644 index 38f2af83..00000000 --- a/crates/lsp_test/src/tower_lsp/codec.rs +++ /dev/null @@ -1,398 +0,0 @@ -// --- source -// authors = ["Eyal Kalderon "] -// origin = "https://github.com/ebkalderon/tower-lsp/blob/master/src/codec.r" -// license = "MIT OR Apache-2.0" -// --- - -//! Encoder and decoder for Language Server Protocol messages. - -use std::error::Error; -use std::fmt::{self, Display, Formatter}; -use std::io::{Error as IoError, Write}; -use std::marker::PhantomData; -use std::num::ParseIntError; -use std::str::Utf8Error; - -use bytes::buf::BufMut; -use bytes::{Buf, BytesMut}; -use memchr::memmem; -use serde::{de::DeserializeOwned, Serialize}; -use tracing::warn; - -use tokio_util::codec::{Decoder, Encoder}; - -/// Errors that can occur when processing an LSP message. -#[derive(Debug)] -pub enum ParseError { - /// Failed to parse the JSON body. - Body(serde_json::Error), - /// Failed to encode the response. - Encode(IoError), - /// Failed to parse headers. - Headers(httparse::Error), - /// The media type in the `Content-Type` header is invalid. - InvalidContentType, - /// The length value in the `Content-Length` header is invalid. - InvalidContentLength(ParseIntError), - /// Request lacks the required `Content-Length` header. - MissingContentLength, - /// Request contains invalid UTF8. - Utf8(Utf8Error), -} - -impl Display for ParseError { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - match *self { - ParseError::Body(ref e) => write!(f, "unable to parse JSON body: {e}"), - ParseError::Encode(ref e) => write!(f, "failed to encode response: {e}"), - ParseError::Headers(ref e) => write!(f, "failed to parse headers: {e}"), - ParseError::InvalidContentType => write!(f, "unable to parse content type"), - ParseError::InvalidContentLength(ref e) => { - write!(f, "unable to parse content length: {e}") - } - ParseError::MissingContentLength => { - write!(f, "missing required `Content-Length` header") - } - ParseError::Utf8(ref e) => write!(f, "request contains invalid UTF8: {e}"), - } - } -} - -impl Error for ParseError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match *self { - ParseError::Body(ref e) => Some(e), - ParseError::Encode(ref e) => Some(e), - ParseError::InvalidContentLength(ref e) => Some(e), - ParseError::Utf8(ref e) => Some(e), - _ => None, - } - } -} - -impl From for ParseError { - fn from(error: serde_json::Error) -> Self { - ParseError::Body(error) - } -} - -impl From for ParseError { - fn from(error: IoError) -> Self { - ParseError::Encode(error) - } -} - -impl From for ParseError { - fn from(error: httparse::Error) -> Self { - ParseError::Headers(error) - } -} - -impl From for ParseError { - fn from(error: ParseIntError) -> Self { - ParseError::InvalidContentLength(error) - } -} - -impl From for ParseError { - fn from(error: Utf8Error) -> Self { - ParseError::Utf8(error) - } -} - -/// Encodes and decodes Language Server Protocol messages. -pub struct LanguageServerCodec { - content_len: Option, - _marker: PhantomData, -} - -impl Default for LanguageServerCodec { - fn default() -> Self { - LanguageServerCodec { - content_len: None, - _marker: PhantomData, - } - } -} - -impl Encoder for LanguageServerCodec { - type Error = ParseError; - - fn encode(&mut self, item: T, dst: &mut BytesMut) -> Result<(), Self::Error> { - let msg = serde_json::to_string(&item)?; - // trace!("-> {}", msg); - - // Reserve just enough space to hold the `Content-Length: ` and `\r\n\r\n` constants, - // the length of the message, and the message body. - dst.reserve(msg.len() + number_of_digits(msg.len()) + 20); - let mut writer = dst.writer(); - write!(writer, "Content-Length: {}\r\n\r\n{}", msg.len(), msg)?; - writer.flush()?; - - Ok(()) - } -} - -fn number_of_digits(mut n: usize) -> usize { - let mut num_digits = 0; - - while n > 0 { - n /= 10; - num_digits += 1; - } - - num_digits -} - -impl Decoder for LanguageServerCodec { - type Item = T; - type Error = ParseError; - - fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { - if let Some(content_len) = self.content_len { - if src.len() < content_len { - return Ok(None); - } - - let bytes = &src[..content_len]; - let message = std::str::from_utf8(bytes)?; - - let result = if message.is_empty() { - Ok(None) - } else { - // trace!("<- {}", message); - match serde_json::from_str(message) { - Ok(parsed) => Ok(Some(parsed)), - Err(err) => Err(err.into()), - } - }; - - src.advance(content_len); - self.content_len = None; // Reset state in preparation for parsing next message. - - result - } else { - let mut dst = [httparse::EMPTY_HEADER; 2]; - - let (headers_len, headers) = match httparse::parse_headers(src, &mut dst)? { - httparse::Status::Complete(output) => output, - httparse::Status::Partial => return Ok(None), - }; - - match decode_headers(headers) { - Ok(content_len) => { - src.advance(headers_len); - self.content_len = Some(content_len); - self.decode(src) // Recurse right back in, now that `Content-Length` is known. - } - Err(err) => { - match err { - ParseError::MissingContentLength => {} - _ => src.advance(headers_len), - } - - // Skip any garbage bytes by scanning ahead for another potential message. - src.advance(memmem::find(src, b"Content-Length").unwrap_or_default()); - Err(err) - } - } - } - } -} - -fn decode_headers(headers: &[httparse::Header<'_>]) -> Result { - let mut content_len = None; - - for header in headers { - match header.name { - "Content-Length" => { - let string = std::str::from_utf8(header.value)?; - let parsed_len = string.parse()?; - content_len = Some(parsed_len); - } - "Content-Type" => { - let string = std::str::from_utf8(header.value)?; - let charset = string - .split(';') - .skip(1) - .map(|param| param.trim()) - .find_map(|param| param.strip_prefix("charset=")); - - match charset { - Some("utf-8" | "utf8") => {} - _ => return Err(ParseError::InvalidContentType), - } - } - other => warn!("encountered unsupported header: {:?}", other), - } - } - - if let Some(content_len) = content_len { - Ok(content_len) - } else { - Err(ParseError::MissingContentLength) - } -} - -#[cfg(test)] -mod tests { - use bytes::BytesMut; - use serde_json::Value; - - use super::*; - - macro_rules! assert_err { - ($expression:expr, $($pattern:tt)+) => { - match $expression { - $($pattern)+ => (), - ref e => panic!("expected `{}` but got `{:?}`", stringify!($($pattern)+), e), - } - } - } - - fn encode_message(content_type: Option<&str>, message: &str) -> String { - let content_type = content_type - .map(|ty| format!("\r\nContent-Type: {ty}")) - .unwrap_or_default(); - - format!( - "Content-Length: {}{}\r\n\r\n{}", - message.len(), - content_type, - message - ) - } - - #[test] - fn encode_and_decode() { - let decoded = r#"{"jsonrpc":"2.0","method":"exit"}"#; - let encoded = encode_message(None, decoded); - - let mut codec = LanguageServerCodec::default(); - let mut buffer = BytesMut::new(); - let item: Value = serde_json::from_str(decoded).unwrap(); - codec.encode(item, &mut buffer).unwrap(); - assert_eq!(buffer, BytesMut::from(encoded.as_str())); - - let mut buffer = BytesMut::from(encoded.as_str()); - let message = codec.decode(&mut buffer).unwrap(); - let decoded = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(decoded)); - } - - #[test] - fn decodes_optional_content_type() { - let decoded = r#"{"jsonrpc":"2.0","method":"exit"}"#; - let content_type = "application/vscode-jsonrpc; charset=utf-8"; - let encoded = encode_message(Some(content_type), decoded); - - let mut codec = LanguageServerCodec::default(); - let mut buffer = BytesMut::from(encoded.as_str()); - let message = codec.decode(&mut buffer).unwrap(); - let decoded_: Value = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(decoded_)); - - let content_type = "application/vscode-jsonrpc; charset=utf8"; - let encoded = encode_message(Some(content_type), decoded); - - let mut buffer = BytesMut::from(encoded.as_str()); - let message = codec.decode(&mut buffer).unwrap(); - let decoded_: Value = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(decoded_)); - - let content_type = "application/vscode-jsonrpc; charset=invalid"; - let encoded = encode_message(Some(content_type), decoded); - - let mut buffer = BytesMut::from(encoded.as_str()); - assert_err!( - codec.decode(&mut buffer), - Err(ParseError::InvalidContentType) - ); - - let content_type = "application/vscode-jsonrpc"; - let encoded = encode_message(Some(content_type), decoded); - - let mut buffer = BytesMut::from(encoded.as_str()); - assert_err!( - codec.decode(&mut buffer), - Err(ParseError::InvalidContentType) - ); - - let content_type = "this-mime-should-be-ignored; charset=utf8"; - let encoded = encode_message(Some(content_type), decoded); - - let mut buffer = BytesMut::from(encoded.as_str()); - let message = codec.decode(&mut buffer).unwrap(); - let decoded_: Value = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(decoded_)); - } - - #[test] - fn decodes_zero_length_message() { - let content_type = "application/vscode-jsonrpc; charset=utf-8"; - let encoded = encode_message(Some(content_type), ""); - - let mut codec = LanguageServerCodec::default(); - let mut buffer = BytesMut::from(encoded.as_str()); - let message: Option = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, None); - } - - #[test] - fn recovers_from_parse_error() { - let decoded = r#"{"jsonrpc":"2.0","method":"exit"}"#; - let encoded = encode_message(None, decoded); - let mixed = format!("foobar{encoded}Content-Length: foobar\r\n\r\n{encoded}"); - - let mut codec = LanguageServerCodec::default(); - let mut buffer = BytesMut::from(mixed.as_str()); - assert_err!( - codec.decode(&mut buffer), - Err(ParseError::MissingContentLength) - ); - - let message: Option = codec.decode(&mut buffer).unwrap(); - let first_valid = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(first_valid)); - assert_err!( - codec.decode(&mut buffer), - Err(ParseError::InvalidContentLength(_)) - ); - - let message = codec.decode(&mut buffer).unwrap(); - let second_valid = serde_json::from_str(decoded).unwrap(); - assert_eq!(message, Some(second_valid)); - - let message = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, None); - } - - #[test] - fn decodes_small_chunks() { - let decoded = r#"{"jsonrpc":"2.0","method":"exit"}"#; - let content_type = "application/vscode-jsonrpc; charset=utf-8"; - let encoded = encode_message(Some(content_type), decoded); - - let mut codec = LanguageServerCodec::default(); - let mut buffer = BytesMut::from(encoded.as_str()); - - let rest = buffer.split_off(40); - let message = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, None); - buffer.unsplit(rest); - - let rest = buffer.split_off(80); - let message = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, None); - buffer.unsplit(rest); - - let rest = buffer.split_off(16); - let message = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, None); - buffer.unsplit(rest); - - let decoded: Value = serde_json::from_str(decoded).unwrap(); - let message = codec.decode(&mut buffer).unwrap(); - assert_eq!(message, Some(decoded)); - } -} diff --git a/crates/lsp_test/src/tower_lsp/mod.rs b/crates/lsp_test/src/tower_lsp/mod.rs deleted file mode 100644 index 4733be92..00000000 --- a/crates/lsp_test/src/tower_lsp/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod codec; - -#[allow(dead_code)] -pub(crate) mod request; diff --git a/crates/lsp_test/src/tower_lsp/request.rs b/crates/lsp_test/src/tower_lsp/request.rs deleted file mode 100644 index d5c68773..00000000 --- a/crates/lsp_test/src/tower_lsp/request.rs +++ /dev/null @@ -1,217 +0,0 @@ -// --- source -// authors = ["Eyal Kalderon "] -// origin = "https://github.com/ebkalderon/tower-lsp/blob/master/src/jsonrpc/request.rs" -// license = "MIT OR Apache-2.0" -// --- - -use std::borrow::Cow; -use std::fmt::{self, Display, Formatter}; -use std::str::FromStr; - -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; - -// use super::{Id, Version}; -use tower_lsp::jsonrpc::Id; -use tower_lsp::lsp_types; - -#[derive(Clone, Debug, PartialEq)] -struct Version; - -impl<'de> Deserialize<'de> for Version { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct Inner<'a>(#[serde(borrow)] Cow<'a, str>); - - let Inner(ver) = Inner::deserialize(deserializer)?; - - match ver.as_ref() { - "2.0" => Ok(Version), - _ => Err(de::Error::custom("expected JSON-RPC version \"2.0\"")), - } - } -} - -impl Serialize for Version { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_str("2.0") - } -} - -fn deserialize_some<'de, T, D>(deserializer: D) -> Result, D::Error> -where - T: Deserialize<'de>, - D: Deserializer<'de>, -{ - T::deserialize(deserializer).map(Some) -} - -/// A JSON-RPC request or notification. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -pub struct Request { - jsonrpc: Version, - #[serde(default)] - method: Cow<'static, str>, - #[serde(default, deserialize_with = "deserialize_some")] - #[serde(skip_serializing_if = "Option::is_none")] - params: Option, - #[serde(default, deserialize_with = "deserialize_some")] - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, -} - -impl Request { - /// Starts building a JSON-RPC method call. - /// - /// Returns a `RequestBuilder`, which allows setting the `params` field or adding a request ID. - pub fn build(method: M) -> RequestBuilder - where - M: Into>, - { - RequestBuilder { - method: method.into(), - params: None, - id: None, - } - } - - /// Constructs a JSON-RPC request from its corresponding LSP type. - /// - /// # Panics - /// - /// Panics if `params` could not be serialized into a [`serde_json::Value`]. Since the - /// [`lsp_types::request::Request`] trait promises this invariant is upheld, this should never - /// happen in practice (unless the trait was implemented incorrectly). - pub(crate) fn from_request(id: Id, params: R::Params) -> Self - where - R: lsp_types::request::Request, - { - Request { - jsonrpc: Version, - method: R::METHOD.into(), - params: Some(serde_json::to_value(params).unwrap()), - id: Some(id), - } - } - - /// Constructs a JSON-RPC notification from its corresponding LSP type. - /// - /// # Panics - /// - /// Panics if `params` could not be serialized into a [`serde_json::Value`]. Since the - /// [`lsp_types::notification::Notification`] trait promises this invariant is upheld, this - /// should never happen in practice (unless the trait was implemented incorrectly). - pub(crate) fn from_notification(params: N::Params) -> Self - where - N: lsp_types::notification::Notification, - { - Request { - jsonrpc: Version, - method: N::METHOD.into(), - params: Some(serde_json::to_value(params).unwrap()), - id: None, - } - } - - /// Returns the name of the method to be invoked. - pub fn method(&self) -> &str { - self.method.as_ref() - } - - /// Returns the unique ID of this request, if present. - pub fn id(&self) -> Option<&Id> { - self.id.as_ref() - } - - /// Returns the `params` field, if present. - pub fn params(&self) -> Option<&Value> { - self.params.as_ref() - } - - /// Splits this request into the method name, request ID, and the `params` field, if present. - pub fn into_parts(self) -> (Cow<'static, str>, Option, Option) { - (self.method, self.id, self.params) - } -} - -impl Display for Request { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - use std::{io, str}; - - struct WriterFormatter<'a, 'b: 'a> { - inner: &'a mut Formatter<'b>, - } - - impl io::Write for WriterFormatter<'_, '_> { - fn write(&mut self, buf: &[u8]) -> io::Result { - fn io_error(_: E) -> io::Error { - // Error value does not matter because fmt::Display impl below just - // maps it to fmt::Error - io::Error::new(io::ErrorKind::Other, "fmt error") - } - let s = str::from_utf8(buf).map_err(io_error)?; - self.inner.write_str(s).map_err(io_error)?; - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - let mut w = WriterFormatter { inner: f }; - serde_json::to_writer(&mut w, self).map_err(|_| fmt::Error) - } -} - -impl FromStr for Request { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - serde_json::from_str(s) - } -} - -/// A builder to construct the properties of a `Request`. -/// -/// To construct a `RequestBuilder`, refer to [`Request::build`]. -#[derive(Debug)] -pub struct RequestBuilder { - method: Cow<'static, str>, - params: Option, - id: Option, -} - -impl RequestBuilder { - /// Sets the `id` member of the request to the given value. - /// - /// If this method is not called, the resulting `Request` will be assumed to be a notification. - pub fn id>(mut self, id: I) -> Self { - self.id = Some(id.into()); - self - } - - /// Sets the `params` member of the request to the given value. - /// - /// This member is omitted from the request by default. - pub fn params>(mut self, params: V) -> Self { - self.params = Some(params.into()); - self - } - - /// Constructs the JSON-RPC request and returns it. - pub fn finish(self) -> Request { - Request { - jsonrpc: Version, - method: self.method, - params: self.params, - id: self.id, - } - } -} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 00000000..e693f564 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "server" +version = "0.1.1" +publish = false +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[lib] + +[dependencies] +air_r_factory = { workspace = true } +air_r_formatter = { workspace = true } +air_r_parser = { workspace = true } +air_r_syntax = { workspace = true } +source_file = { workspace = true } +workspace = { workspace = true } + +anyhow = { workspace = true } +biome_formatter = { workspace = true } +biome_rowan = { workspace = true } +biome_text_size = { workspace = true } +crossbeam = { workspace = true } +dissimilar = { workspace = true } +itertools = { workspace = true } +jod-thread = { workspace = true } +lsp-server = { workspace = true } +lsp-types = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +server_test = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["ansi", "local-time"] } +tree-sitter = { workspace = true } +tree-sitter-r = { workspace = true } +url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +assert_matches = { workspace = true } +insta = { workspace = true } + +[target.'cfg(target_vendor = "apple")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cargo_metadata.workspace = true + +[lints] +workspace = true diff --git a/crates/lsp/build.rs b/crates/server/build.rs similarity index 79% rename from crates/lsp/build.rs rename to crates/server/build.rs index 17cde13a..025d58a7 100644 --- a/crates/lsp/build.rs +++ b/crates/server/build.rs @@ -18,10 +18,17 @@ fn write_workspace_crate_names() { cmd.no_deps(); let metadata = cmd.exec().unwrap(); - let packages: Vec = metadata + let mut packages: Vec = metadata .workspace_packages() - .iter() + .into_iter() .map(|package| package.name.clone()) + .collect(); + + // Sort for stability across `cargo metadata` versions + packages.sort(); + + let packages: Vec = packages + .into_iter() .map(|package| String::from("\"") + package.as_str() + "\",") .collect(); diff --git a/crates/server/src/crates.rs b/crates/server/src/crates.rs new file mode 100644 index 00000000..c6e4066b --- /dev/null +++ b/crates/server/src/crates.rs @@ -0,0 +1,13 @@ +// Generates `AIR_CRATE_NAMES`, a const array of the crate names in the air workspace, +// see `server/src/build.rs` +include!(concat!(env!("OUT_DIR"), "/crates.rs")); + +#[cfg(test)] +mod tests { + use crate::crates::AIR_CRATE_NAMES; + + #[test] + fn test_crate_names() { + insta::assert_debug_snapshot!(AIR_CRATE_NAMES); + } +} diff --git a/crates/server/src/document.rs b/crates/server/src/document.rs new file mode 100644 index 00000000..21572a4f --- /dev/null +++ b/crates/server/src/document.rs @@ -0,0 +1,13 @@ +//! Types and utilities for working with documents + +mod encoding; +mod key; +mod text_diff; +mod text_document; +mod text_edit; + +pub(crate) use encoding::PositionEncoding; +pub(crate) use key::DocumentKey; +pub(crate) use text_document::DocumentVersion; +pub(crate) use text_document::TextDocument; +pub(crate) use text_edit::{Indel, TextEdit}; diff --git a/crates/server/src/document/encoding.rs b/crates/server/src/document/encoding.rs new file mode 100644 index 00000000..6d0ddba6 --- /dev/null +++ b/crates/server/src/document/encoding.rs @@ -0,0 +1,42 @@ +use lsp_types::PositionEncodingKind; + +/// A convenient enumeration for supported [lsp_types::Position] encodings. Can be converted to [`lsp_types::PositionEncodingKind`]. +// Please maintain the order from least to greatest priority for the derived `Ord` impl. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum PositionEncoding { + /// UTF 16 is the encoding supported by all LSP clients. + #[default] + UTF16, + + /// Second choice because UTF32 uses a fixed 4 byte encoding for each character (makes conversion relatively easy) + UTF32, + + /// Air's preferred encoding + UTF8, +} + +impl From for lsp_types::PositionEncodingKind { + fn from(value: PositionEncoding) -> Self { + match value { + PositionEncoding::UTF8 => lsp_types::PositionEncodingKind::UTF8, + PositionEncoding::UTF16 => lsp_types::PositionEncodingKind::UTF16, + PositionEncoding::UTF32 => lsp_types::PositionEncodingKind::UTF32, + } + } +} + +impl TryFrom<&lsp_types::PositionEncodingKind> for PositionEncoding { + type Error = (); + + fn try_from(value: &PositionEncodingKind) -> Result { + Ok(if value == &PositionEncodingKind::UTF8 { + PositionEncoding::UTF8 + } else if value == &PositionEncodingKind::UTF16 { + PositionEncoding::UTF16 + } else if value == &PositionEncodingKind::UTF32 { + PositionEncoding::UTF32 + } else { + return Err(()); + }) + } +} diff --git a/crates/server/src/document/key.rs b/crates/server/src/document/key.rs new file mode 100644 index 00000000..d5665668 --- /dev/null +++ b/crates/server/src/document/key.rs @@ -0,0 +1,20 @@ +use url::Url; + +/// A unique document ID, derived from a URL passed as part of an LSP request. +/// This document ID currently always points to an R file, but eventually can also +/// point to a full notebook, or a cell within a notebook. +#[derive(Clone, Debug)] +pub(crate) enum DocumentKey { + Text(Url), + // If we ever want to support notebooks, start here: + // Notebook(Url), + // NotebookCell(Url), +} + +impl std::fmt::Display for DocumentKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text(url) => url.fmt(f), + } + } +} diff --git a/crates/lsp/src/rust_analyzer/diff.rs b/crates/server/src/document/text_diff.rs similarity index 95% rename from crates/lsp/src/rust_analyzer/diff.rs rename to crates/server/src/document/text_diff.rs index 6587def8..72900368 100644 --- a/crates/lsp/src/rust_analyzer/diff.rs +++ b/crates/server/src/document/text_diff.rs @@ -8,7 +8,7 @@ use biome_text_size::{TextRange, TextSize}; use super::text_edit::TextEdit; -pub(crate) fn diff(left: &str, right: &str) -> TextEdit { +pub(super) fn text_diff(left: &str, right: &str) -> TextEdit { use dissimilar::Chunk; let chunks = dissimilar::diff(left, right); diff --git a/crates/server/src/document/text_document.rs b/crates/server/src/document/text_document.rs new file mode 100644 index 00000000..a7f1371c --- /dev/null +++ b/crates/server/src/document/text_document.rs @@ -0,0 +1,244 @@ +use biome_rowan::TextRange; +use lsp_types::TextDocumentContentChangeEvent; +use source_file::LineEnding; +use source_file::SourceFile; + +use crate::document::PositionEncoding; +use crate::proto::TextRangeExt; + +pub(crate) type DocumentVersion = i32; + +/// The state of an individual document in the server. Stays up-to-date +/// with changes made by the user, including unsaved changes. +#[derive(Debug, Clone)] +pub struct TextDocument { + /// The source file containing the contents and line index for the document. + /// Line endings have been normalized to unix line endings here. + source: SourceFile, + /// The original line endings of the document. Used when sending changes back to the + /// LSP client. + ending: LineEnding, + /// The latest version of the document, set by the LSP client. The server will panic in + /// debug mode if we attempt to update the document with an 'older' version. + version: DocumentVersion, +} + +impl TextDocument { + pub fn new(contents: String, version: DocumentVersion) -> Self { + // Normalize to Unix line endings on the way in + let (contents, ending) = source_file::normalize_newlines(contents); + let source = SourceFile::new(contents); + Self { + source, + ending, + version, + } + } + + #[cfg(test)] + pub fn doodle(contents: &str) -> Self { + Self::new(contents.into(), 0) + } + + #[cfg(test)] + pub fn doodle_and_range(contents: &str) -> (Self, biome_text_size::TextRange) { + let (contents, range) = crate::test::extract_marked_range(contents); + let doc = Self::new(contents, 0); + (doc, range) + } + + pub fn contents(&self) -> &str { + self.source.contents() + } + + pub fn ending(&self) -> LineEnding { + self.ending + } + + pub fn source_file(&self) -> &SourceFile { + &self.source + } + + pub fn version(&self) -> DocumentVersion { + self.version + } + + pub fn apply_changes( + &mut self, + mut changes: Vec, + new_version: DocumentVersion, + encoding: PositionEncoding, + ) { + // Normalize line endings. Changing the line length of inserted or + // replaced text can't invalidate the text change events, even those + // applied subsequently, since those changes are specified with [line, + // col] coordinates. + for change in &mut changes.iter_mut() { + let text = std::mem::take(&mut change.text); + (change.text, _) = source_file::normalize_newlines(text); + } + + if let [lsp_types::TextDocumentContentChangeEvent { range: None, .. }] = changes.as_slice() + { + tracing::trace!("Fast path - replacing entire document"); + // Unwrap: If-let ensures there is exactly 1 change event + let change = changes.pop().unwrap(); + self.source = SourceFile::new(change.text); + self.update_version(new_version); + return; + } + + for TextDocumentContentChangeEvent { + range, + text: change, + .. + } in changes + { + if let Some(range) = range { + // Replace a range and rebuild the line index + let range = TextRange::from_proto(range, &self.source, encoding); + self.source.replace_range( + usize::from(range.start())..usize::from(range.end()), + &change, + ); + } else { + // Replace the whole file + self.source = SourceFile::new(change); + } + } + + self.update_version(new_version); + } + + fn update_version(&mut self, new_version: DocumentVersion) { + let old_version = self.version; + self.version = new_version; + debug_assert!(self.version >= old_version); + } +} + +#[cfg(test)] +mod tests { + use crate::document::{PositionEncoding, TextDocument}; + use lsp_types::{Position, TextDocumentContentChangeEvent}; + + #[test] + fn redo_edit() { + let mut document = TextDocument::new( + r#"""" +测试comment +一些测试内容 +""" +import click + + +@click.group() +def interface(): + pas +"# + .to_string(), + 0, + ); + + // Add an `s`, remove it again (back to the original code), and then re-add the `s` + document.apply_changes( + vec![ + TextDocumentContentChangeEvent { + range: Some(lsp_types::Range::new( + Position::new(9, 7), + Position::new(9, 7), + )), + range_length: Some(0), + text: "s".to_string(), + }, + TextDocumentContentChangeEvent { + range: Some(lsp_types::Range::new( + Position::new(9, 7), + Position::new(9, 8), + )), + range_length: Some(1), + text: String::new(), + }, + TextDocumentContentChangeEvent { + range: Some(lsp_types::Range::new( + Position::new(9, 7), + Position::new(9, 7), + )), + range_length: Some(0), + text: "s".to_string(), + }, + ], + 1, + PositionEncoding::UTF16, + ); + + assert_eq!( + document.contents(), + r#"""" +测试comment +一些测试内容 +""" +import click + + +@click.group() +def interface(): + pass +"# + ); + } + + #[test] + fn test_document_position_encoding() { + // Replace `b` after `𐐀` which is at position 5 in UTF-8 + let utf8_range = lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 5, + }, + end: lsp_types::Position { + line: 0, + character: 6, + }, + }; + + // `b` is at position 3 in UTF-16 + let utf16_range = lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 3, + }, + end: lsp_types::Position { + line: 0, + character: 4, + }, + }; + + let utf8_replace_params = vec![lsp_types::TextDocumentContentChangeEvent { + range: Some(utf8_range), + range_length: None, + text: String::from("bar"), + }]; + let utf16_replace_params = vec![lsp_types::TextDocumentContentChangeEvent { + range: Some(utf16_range), + range_length: None, + text: String::from("bar"), + }]; + + let mut document = TextDocument::new("a𐐀b".into(), 1); + document.apply_changes( + utf8_replace_params, + document.version + 1, + PositionEncoding::UTF8, + ); + assert_eq!(document.contents(), "a𐐀bar"); + + let mut document = TextDocument::new("a𐐀b".into(), 1); + document.apply_changes( + utf16_replace_params, + document.version + 1, + PositionEncoding::UTF16, + ); + assert_eq!(document.contents(), "a𐐀bar"); + } +} diff --git a/crates/lsp/src/rust_analyzer/text_edit.rs b/crates/server/src/document/text_edit.rs similarity index 99% rename from crates/lsp/src/rust_analyzer/text_edit.rs rename to crates/server/src/document/text_edit.rs index fa777e50..65b76c35 100644 --- a/crates/lsp/src/rust_analyzer/text_edit.rs +++ b/crates/server/src/document/text_edit.rs @@ -81,7 +81,7 @@ impl TextEdit { // --- Start Posit pub fn diff(text: &str, replace_with: &str) -> TextEdit { - super::diff::diff(text, replace_with) + super::text_diff::text_diff(text, replace_with) } // --- End Posit diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 00000000..24fd399f --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,19 @@ +//! ## The Air Language Server + +pub use server::Server; + +#[macro_use] +mod message; + +mod crates; +mod document; +mod logging; +mod proto; +mod server; +mod session; + +#[cfg(test)] +mod test; + +pub(crate) const SERVER_NAME: &str = "Air Language Server"; +pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/lsp/src/logging.rs b/crates/server/src/logging.rs similarity index 69% rename from crates/lsp/src/logging.rs rename to crates/server/src/logging.rs index 78db0373..ad203fb8 100644 --- a/crates/lsp/src/logging.rs +++ b/crates/server/src/logging.rs @@ -1,9 +1,3 @@ -// --- source -// authors = ["Charlie Marsh"] -// license = "MIT" -// origin = "https://github.com/astral-sh/ruff/blob/03fb2e5ac1481e498f84474800b42a966e9843e1/crates/ruff_server/src/trace.rs" -// --- - //! The logging system for `air lsp`. //! //! ## Air crate logs @@ -42,14 +36,16 @@ //! emit a `window/logMessage` message. Otherwise, logging will write to `stderr`, //! which should appear in the logs for most LSP clients. use core::str; +use lsp_server::Message; +use lsp_types::notification::LogMessage; +use lsp_types::notification::Notification; +use lsp_types::ClientInfo; +use lsp_types::LogMessageParams; +use lsp_types::MessageType; use serde::Deserialize; use std::fmt::Display; use std::io::{Error as IoError, ErrorKind, Write}; use std::str::FromStr; -use tokio::sync::mpsc::unbounded_channel; -use tower_lsp::lsp_types::ClientInfo; -use tower_lsp::lsp_types::MessageType; -use tower_lsp::Client; use tracing_subscriber::filter; use tracing_subscriber::fmt::time::LocalTime; use tracing_subscriber::fmt::TestWriter; @@ -60,6 +56,7 @@ use tracing_subscriber::{ }; use crate::crates; +use crate::server::ClientSender; // TODO: // - Add `air.logLevel` and `air.dependencyLogLevels` as VS Code extension options that set @@ -69,66 +66,33 @@ use crate::crates; const AIR_LOG_LEVEL: &str = "AIR_LOG_LEVEL"; const AIR_DEPENDENCY_LOG_LEVELS: &str = "AIR_DEPENDENCY_LOG_LEVELS"; -pub(crate) struct LogMessage { - contents: String, -} - -pub(crate) type LogMessageSender = tokio::sync::mpsc::UnboundedSender; -pub(crate) type LogMessageReceiver = tokio::sync::mpsc::UnboundedReceiver; - -pub(crate) struct LogState { - client: Client, - log_rx: LogMessageReceiver, -} - -// Needed for spawning the loop -unsafe impl Sync for LogState {} - -impl LogState { - pub(crate) fn new(client: Client) -> (Self, LogMessageSender) { - let (log_tx, log_rx) = unbounded_channel::(); - let state = Self { client, log_rx }; - (state, log_tx) - } - - /// Start the log loop - /// - /// Takes ownership of log state and start the low-latency log loop. - /// - /// We use `MessageType::LOG` to prevent the middleware from adding its own - /// timestamp and log level labels. We add that ourselves through tracing. - pub(crate) async fn start(mut self) { - while let Some(message) = self.log_rx.recv().await { - self.client - .log_message(MessageType::LOG, message.contents) - .await - } - - // Channel has been closed. - // All senders have been dropped or `close()` was called. - } -} - // A log writer that uses LSPs logMessage method. struct LogWriter<'a> { - log_tx: &'a LogMessageSender, + client_tx: &'a ClientSender, } impl<'a> LogWriter<'a> { - fn new(log_tx: &'a LogMessageSender) -> Self { - Self { log_tx } + fn new(client_tx: &'a ClientSender) -> Self { + Self { client_tx } } } impl Write for LogWriter<'_> { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let contents = str::from_utf8(buf).map_err(|e| IoError::new(ErrorKind::InvalidData, e))?; - let contents = contents.to_string(); - - // Forward the log message to the latency sensitive log thread, - // which is in charge of forwarding to the client in an async manner. - self.log_tx - .send(LogMessage { contents }) + let message = str::from_utf8(buf).map_err(|e| IoError::new(ErrorKind::InvalidData, e))?; + let message = message.to_string(); + + let params = serde_json::to_value(LogMessageParams { + typ: MessageType::LOG, + message, + }) + .map_err(|e| IoError::new(ErrorKind::Other, e))?; + + self.client_tx + .send(Message::Notification(lsp_server::Notification { + method: LogMessage::METHOD.to_owned(), + params, + })) .map_err(|e| IoError::new(ErrorKind::Other, e))?; Ok(buf.len()) @@ -140,12 +104,12 @@ impl Write for LogWriter<'_> { } struct LogWriterMaker { - log_tx: LogMessageSender, + client_tx: ClientSender, } impl LogWriterMaker { - fn new(log_tx: LogMessageSender) -> Self { - Self { log_tx } + fn new(client_tx: ClientSender) -> Self { + Self { client_tx } } } @@ -153,25 +117,25 @@ impl<'a> MakeWriter<'a> for LogWriterMaker { type Writer = LogWriter<'a>; fn make_writer(&'a self) -> Self::Writer { - LogWriter::new(&self.log_tx) + LogWriter::new(&self.client_tx) } } pub(crate) fn init_logging( - log_tx: LogMessageSender, + client_tx: ClientSender, log_level: Option, dependency_log_levels: Option, - client_info: Option<&ClientInfo>, + client_info: Option, ) { let log_level = resolve_log_level(log_level); let dependency_log_levels = resolve_dependency_log_levels(dependency_log_levels); - let writer = if client_info.is_some_and(|client_info| { + let writer = if client_info.as_ref().is_some_and(|client_info| { client_info.name.starts_with("Zed") || client_info.name.starts_with("Visual Studio Code") }) { // These IDEs are known to support `window/logMessage` well - BoxMakeWriter::new(LogWriterMaker::new(log_tx)) - } else if is_test_client(client_info) { + BoxMakeWriter::new(LogWriterMaker::new(client_tx)) + } else if is_test_client(client_info.as_ref()) { // Ensures a standard `cargo test` captures output unless `-- --nocapture` is used BoxMakeWriter::new(TestWriter::default()) } else { @@ -204,28 +168,22 @@ pub(crate) fn init_logging( let subscriber = tracing_subscriber::Registry::default().with(layer); - if is_test_client(client_info) { - // During parallel testing, `set_global_default()` gets called multiple times - // per process. That causes it to error, but we ignore this. - tracing::subscriber::set_global_default(subscriber).ok(); - } else { - tracing::subscriber::set_global_default(subscriber) - .expect("Should be able to set the global subscriber exactly once."); - } + tracing::subscriber::set_global_default(subscriber) + .expect("Should be able to set the global subscriber exactly once."); tracing::info!("Logging initialized with level: {log_level}"); } /// We use a special `TestWriter` during tests to be compatible with `cargo test`'s -/// typical output capturing behavior. -/// -/// Important notes: -/// - `cargo test` swallows all logs unless you use `-- --nocapture`. -/// - Tests run in parallel, so logs can be interleaved unless you run `--test-threads 1`. +/// typical output capturing behavior (even during integration tests!). /// -/// We use `cargo test -- --nocapture --test-threads 1` on CI because of all of this. +/// Importantly, note that `cargo test` swallows all logs unless you use `-- --nocapture`, +/// which is the correct expected behavior. We use `cargo test -- --nocapture` on CI +/// because of this. fn is_test_client(client_info: Option<&ClientInfo>) -> bool { - client_info.map_or(false, |client_info| client_info.name == "AirTestClient") + client_info.map_or(false, |client_info| { + client_info.name == server_test::TEST_CLIENT_NAME + }) } fn log_filter(log_level: LogLevel, dependency_log_levels: Option) -> filter::Targets { diff --git a/crates/server/src/message.rs b/crates/server/src/message.rs new file mode 100644 index 00000000..d8675800 --- /dev/null +++ b/crates/server/src/message.rs @@ -0,0 +1,55 @@ +use anyhow::Context; +use lsp_types::notification::Notification; +use std::sync::OnceLock; + +use crate::server::ClientSender; + +static MESSENGER: OnceLock = OnceLock::new(); + +pub(crate) fn init_messenger(client_sender: ClientSender) { + MESSENGER + .set(client_sender) + .expect("Messenger should only be initialized once"); +} + +pub(crate) fn show_message(message: String, message_type: lsp_types::MessageType) { + try_show_message(message, message_type).unwrap(); +} + +pub(super) fn try_show_message( + message: String, + message_type: lsp_types::MessageType, +) -> anyhow::Result<()> { + MESSENGER + .get() + .ok_or_else(|| anyhow::anyhow!("Messenger not initialized"))? + .send(lsp_server::Message::Notification( + lsp_server::Notification { + method: lsp_types::notification::ShowMessage::METHOD.into(), + params: serde_json::to_value(lsp_types::ShowMessageParams { + typ: message_type, + message, + })?, + }, + )) + .context("Failed to send message")?; + + Ok(()) +} + +/// Sends a request to display an error to the client with a formatted message. The error is sent +/// in a `window/showMessage` notification. +macro_rules! show_err_msg { + ($msg:expr$(, $($arg:tt),*)?) => { + crate::message::show_message(::core::format_args!($msg, $($($arg),*)?).to_string(), lsp_types::MessageType::ERROR) + }; +} + +/// Sends a request to display a warning to the client with a formatted message. The warning is +/// sent in a `window/showMessage` notification. +#[allow(unused_macros)] +macro_rules! show_warn_msg { + ($msg:expr$(, $($arg:tt),*)?) => { + crate::message::show_message(::core::format_args!($msg, $($($arg),*)?).to_string(), lsp_types::MessageType::WARNING) + }; +} diff --git a/crates/server/src/proto.rs b/crates/server/src/proto.rs new file mode 100644 index 00000000..28e08c0c --- /dev/null +++ b/crates/server/src/proto.rs @@ -0,0 +1,6 @@ +mod text_edit; +mod text_range; +mod text_size; + +pub(crate) use text_range::TextRangeExt; +pub(crate) use text_size::TextSizeExt; diff --git a/crates/server/src/proto/text_edit.rs b/crates/server/src/proto/text_edit.rs new file mode 100644 index 00000000..cc74a46b --- /dev/null +++ b/crates/server/src/proto/text_edit.rs @@ -0,0 +1,37 @@ +use source_file::LineEnding; +use source_file::SourceFile; + +use crate::document::Indel; +use crate::document::PositionEncoding; +use crate::document::TextEdit; +use crate::proto::TextRangeExt; + +impl TextEdit { + pub(crate) fn into_proto( + self, + source: &SourceFile, + encoding: PositionEncoding, + ending: LineEnding, + ) -> anyhow::Result> { + self.into_iter() + .map(|indel| indel.into_proto(source, encoding, ending)) + .collect() + } +} + +impl Indel { + fn into_proto( + self, + source: &SourceFile, + encoding: PositionEncoding, + ending: LineEnding, + ) -> anyhow::Result { + let range = self.delete.into_proto(source, encoding); + let new_text = match ending { + LineEnding::Lf => self.insert, + LineEnding::Crlf => self.insert.replace('\n', "\r\n"), + LineEnding::Cr => self.insert.replace('\n', "\r"), + }; + Ok(lsp_types::TextEdit { range, new_text }) + } +} diff --git a/crates/server/src/proto/text_range.rs b/crates/server/src/proto/text_range.rs new file mode 100644 index 00000000..17938863 --- /dev/null +++ b/crates/server/src/proto/text_range.rs @@ -0,0 +1,28 @@ +use crate::document::PositionEncoding; +use crate::proto::TextSizeExt; +use biome_text_size::{TextRange, TextSize}; +use lsp_types as types; +use source_file::SourceFile; + +// We don't own this type so we need a helper trait +pub(crate) trait TextRangeExt { + fn into_proto(self, source: &SourceFile, encoding: PositionEncoding) -> types::Range; + + fn from_proto(range: types::Range, source: &SourceFile, encoding: PositionEncoding) -> Self; +} + +impl TextRangeExt for TextRange { + fn into_proto(self, source: &SourceFile, encoding: PositionEncoding) -> types::Range { + types::Range { + start: self.start().into_proto(source, encoding), + end: self.end().into_proto(source, encoding), + } + } + + fn from_proto(range: types::Range, source: &SourceFile, encoding: PositionEncoding) -> Self { + TextRange::new( + TextSize::from_proto(range.start, source, encoding), + TextSize::from_proto(range.end, source, encoding), + ) + } +} diff --git a/crates/server/src/proto/text_size.rs b/crates/server/src/proto/text_size.rs new file mode 100644 index 00000000..9f38227d --- /dev/null +++ b/crates/server/src/proto/text_size.rs @@ -0,0 +1,48 @@ +use crate::document::PositionEncoding; +use biome_text_size::TextSize; +use lsp_types as types; +use source_file::LineNumber; +use source_file::LineOffset; +use source_file::LineOffsetEncoding; +use source_file::{SourceFile, SourceLocation}; + +// We don't own this type so we need a helper trait +pub(crate) trait TextSizeExt { + fn into_proto(self, source: &SourceFile, encoding: PositionEncoding) -> types::Position; + + fn from_proto( + position: types::Position, + source: &SourceFile, + encoding: PositionEncoding, + ) -> Self; +} + +impl TextSizeExt for TextSize { + fn into_proto(self, source: &SourceFile, encoding: PositionEncoding) -> types::Position { + let source_location = source.source_location(self, remap_encoding(encoding)); + types::Position { + line: source_location.line_number().into(), + character: source_location.line_offset().raw(), + } + } + + fn from_proto( + position: types::Position, + source: &SourceFile, + encoding: PositionEncoding, + ) -> Self { + let source_location = SourceLocation::new( + LineNumber::from(position.line), + LineOffset::new(position.character, remap_encoding(encoding)), + ); + source.offset(source_location) + } +} + +fn remap_encoding(encoding: PositionEncoding) -> LineOffsetEncoding { + match encoding { + PositionEncoding::UTF16 => LineOffsetEncoding::UTF16, + PositionEncoding::UTF32 => LineOffsetEncoding::UTF32, + PositionEncoding::UTF8 => LineOffsetEncoding::UTF8, + } +} diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs new file mode 100644 index 00000000..b5d2dd3e --- /dev/null +++ b/crates/server/src/server.rs @@ -0,0 +1,264 @@ +//! Scheduling, I/O, and API endpoints. + +use lsp_server as lsp; +use lsp_types as types; +use lsp_types::InitializeParams; +use std::num::NonZeroUsize; +use std::panic::PanicHookInfo; +use types::DidChangeWatchedFilesRegistrationOptions; +use types::FileSystemWatcher; +use types::OneOf; +use types::TextDocumentSyncCapability; +use types::TextDocumentSyncKind; +use types::TextDocumentSyncOptions; +use types::WorkspaceFoldersServerCapabilities; + +use self::connection::Connection; +use self::schedule::event_loop_thread; +use self::schedule::Scheduler; +use self::schedule::Task; +use crate::document::PositionEncoding; +use crate::message::try_show_message; +use crate::server::connection::ConnectionInitializer; +use crate::session::ResolvedClientCapabilities; +use crate::session::Session; + +mod api; +mod client; +mod connection; +mod schedule; + +pub(crate) use connection::ClientSender; + +pub(crate) type Result = std::result::Result; + +pub struct Server { + connection: Connection, + client_capabilities: ResolvedClientCapabilities, + worker_threads: NonZeroUsize, + session: Session, +} + +impl Server { + pub fn new( + worker_threads: NonZeroUsize, + connection: lsp::Connection, + connection_threads: Option, + ) -> anyhow::Result { + let initializer = ConnectionInitializer::new(connection, connection_threads); + + let (id, initialize_params) = initializer.initialize_start()?; + + let client_capabilities = initialize_params.capabilities; + let client_capabilities = ResolvedClientCapabilities::new(client_capabilities); + let position_encoding = Self::find_best_position_encoding(&client_capabilities); + let server_capabilities = Self::server_capabilities(position_encoding); + + let connection = initializer.initialize_finish( + id, + &server_capabilities, + crate::SERVER_NAME, + crate::SERVER_VERSION, + )?; + + let InitializeParams { + workspace_folders, + client_info, + .. + } = initialize_params; + + let workspace_folders = workspace_folders.unwrap_or_default(); + + // TODO: Get user specified options from `initialization_options` + let log_level = None; + let dependency_log_levels = None; + + crate::logging::init_logging( + connection.make_sender(), + log_level, + dependency_log_levels, + client_info, + ); + + crate::message::init_messenger(connection.make_sender()); + + Ok(Self { + connection, + worker_threads, + session: Session::new( + client_capabilities.clone(), + position_encoding, + workspace_folders, + )?, + client_capabilities, + }) + } + + pub fn run(self) -> anyhow::Result<()> { + // Unregister any previously registered panic hook. + // The hook will be restored when this function exits. + type PanicHook = Box) + 'static + Sync + Send>; + struct RestorePanicHook { + hook: Option, + } + impl Drop for RestorePanicHook { + fn drop(&mut self) { + if let Some(hook) = self.hook.take() { + std::panic::set_hook(hook); + } + } + } + let _ = RestorePanicHook { + hook: Some(std::panic::take_hook()), + }; + + // When we panic, try to notify the client. + std::panic::set_hook(Box::new(move |panic_info| { + use std::io::Write; + + let backtrace = std::backtrace::Backtrace::force_capture(); + tracing::error!("{panic_info}\n{backtrace}"); + + // we also need to print to stderr directly for when using `window/logMessage` because + // the message won't be sent to the client. + // But don't use `eprintln` because `eprintln` itself may panic if the pipe is broken. + let mut stderr = std::io::stderr().lock(); + writeln!(stderr, "{panic_info}\n{backtrace}").ok(); + + try_show_message( + "The Air language server exited with a panic. See the logs for more details." + .to_string(), + lsp_types::MessageType::ERROR, + ) + .ok(); + })); + + event_loop_thread(move || { + Self::event_loop( + &self.connection, + &self.client_capabilities, + self.session, + self.worker_threads, + )?; + self.connection.close()?; + Ok(()) + })? + .join() + } + + fn event_loop( + connection: &Connection, + resolved_client_capabilities: &ResolvedClientCapabilities, + mut session: Session, + worker_threads: NonZeroUsize, + ) -> anyhow::Result<()> { + let mut scheduler = + schedule::Scheduler::new(&mut session, worker_threads, connection.make_sender()); + + Self::try_register_capabilities(resolved_client_capabilities, &mut scheduler); + for msg in connection.incoming() { + if connection.handle_shutdown(&msg)? { + break; + } + let task = match msg { + lsp::Message::Request(req) => api::request(req), + lsp::Message::Notification(notification) => api::notification(notification), + lsp::Message::Response(response) => scheduler.response(response), + }; + scheduler.dispatch(task); + } + + Ok(()) + } + + fn try_register_capabilities( + resolved_client_capabilities: &ResolvedClientCapabilities, + scheduler: &mut Scheduler, + ) { + let _span = tracing::info_span!("try_register_capabilities").entered(); + + // Register capabilities to the client + let mut registrations: Vec = vec![]; + + if resolved_client_capabilities.dynamic_registration_for_did_change_watched_files { + // Watch for changes in `air.toml` files so we can react dynamically + let watch_air_toml_registration = lsp_types::Registration { + id: String::from("air-toml-watcher"), + method: "workspace/didChangeWatchedFiles".into(), + register_options: Some( + serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { + watchers: vec![FileSystemWatcher { + glob_pattern: lsp_types::GlobPattern::String("**/air.toml".into()), + kind: None, + }], + }) + .unwrap(), + ), + }; + + registrations.push(watch_air_toml_registration); + } else { + tracing::warn!("LSP client does not support watched files dynamic capability - automatic configuration reloading will not be available."); + } + + if !registrations.is_empty() { + let params = lsp_types::RegistrationParams { registrations }; + + let response_handler = |()| { + tracing::info!("Dynamic configuration successfully registered"); + Task::nothing() + }; + + if let Err(error) = scheduler + .request::(params, response_handler) + { + tracing::error!( + "An error occurred when trying to dynamically register capabilities: {error}" + ); + } + } + } + + fn find_best_position_encoding( + client_capabilities: &ResolvedClientCapabilities, + ) -> PositionEncoding { + // If the client supports UTF-8 we use that, even if it's not its + // preferred encoding (at position 0). Otherwise we use the mandatory + // UTF-16 encoding that all clients and servers must support, even if + // the client would have preferred UTF-32. Note that VSCode and Positron + // only support UTF-16. + if client_capabilities + .position_encodings + .contains(&lsp_types::PositionEncodingKind::UTF8) + { + PositionEncoding::UTF8 + } else { + PositionEncoding::UTF16 + } + } + + fn server_capabilities(position_encoding: PositionEncoding) -> types::ServerCapabilities { + types::ServerCapabilities { + position_encoding: Some(position_encoding.into()), + text_document_sync: Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + will_save: Some(false), + will_save_wait_until: Some(false), + ..Default::default() + }, + )), + workspace: Some(types::WorkspaceServerCapabilities { + workspace_folders: Some(WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Left(true)), + }), + file_operations: None, + }), + document_formatting_provider: Some(OneOf::Left(true)), + document_range_formatting_provider: Some(OneOf::Left(true)), + ..Default::default() + } + } +} diff --git a/crates/server/src/server/api.rs b/crates/server/src/server/api.rs new file mode 100644 index 00000000..b922cf46 --- /dev/null +++ b/crates/server/src/server/api.rs @@ -0,0 +1,241 @@ +use crate::{server::schedule::Task, session::Session}; +use lsp_server as server; + +mod notifications; +mod requests; +mod traits; + +use notifications as notification; +use requests as request; + +use self::traits::{NotificationHandler, RequestHandler}; + +use super::{client::Responder, schedule::BackgroundSchedule, Result}; + +pub(super) fn request<'a>(req: server::Request) -> Task<'a> { + let id = req.id.clone(); + + match req.method.as_str() { + request::Format::METHOD => { + background_request_task::(req, BackgroundSchedule::Fmt) + } + request::FormatRange::METHOD => { + background_request_task::(req, BackgroundSchedule::Fmt) + } + request::ViewFile::METHOD => { + background_request_task::(req, BackgroundSchedule::Fmt) + } + method => { + tracing::warn!("Received request {method} which does not have a handler"); + return Task::nothing(); + } + } + .unwrap_or_else(|err| { + tracing::error!("Encountered error when routing request with ID {id}: {err}"); + show_err_msg!( + "Air failed to handle a request from the editor. Check the logs for more details." + ); + let result: Result<()> = Err(err); + Task::immediate(id, result) + }) +} + +pub(super) fn notification<'a>(notif: server::Notification) -> Task<'a> { + match notif.method.as_str() { + notification::Cancel::METHOD => local_notification_task::(notif), + notification::DidChange::METHOD => { + local_notification_task::(notif) + } + notification::DidChangeConfiguration::METHOD => { + local_notification_task::(notif) + } + notification::DidChangeWatchedFiles::METHOD => { + local_notification_task::(notif) + } + notification::DidChangeWorkspace::METHOD => { + local_notification_task::(notif) + } + notification::DidClose::METHOD => local_notification_task::(notif), + notification::DidOpen::METHOD => local_notification_task::(notif), + notification::SetTrace::METHOD => local_notification_task::(notif), + method => { + tracing::warn!("Received notification {method} which does not have a handler."); + return Task::nothing(); + } + } + .unwrap_or_else(|err| { + tracing::error!("Encountered error when routing notification: {err}"); + show_err_msg!( + "Air failed to handle a notification from the editor. Check the logs for more details." + ); + Task::nothing() + }) +} + +#[allow(dead_code)] +fn local_request_task<'a, R: traits::SyncRequestHandler>( + req: server::Request, +) -> super::Result> { + let (id, params) = cast_request::(req)?; + Ok(Task::local(|session, notifier, requester, responder| { + let result = R::run(session, notifier, requester, params); + respond::(id, result, &responder); + })) +} + +fn background_request_task<'a, R: traits::BackgroundDocumentRequestHandler>( + req: server::Request, + schedule: BackgroundSchedule, +) -> super::Result> { + let (id, params) = cast_request::(req)?; + Ok(Task::background(schedule, move |session: &Session| { + // TODO: We should log an error if we can't take a snapshot. + let Some(snapshot) = session.take_snapshot(R::document_url(¶ms).into_owned()) else { + return Box::new(|_, _| {}); + }; + Box::new(move |notifier, responder| { + let result = R::run_with_snapshot(snapshot, notifier, params); + respond::(id, result, &responder); + }) + })) +} + +fn local_notification_task<'a, N: traits::SyncNotificationHandler>( + notif: server::Notification, +) -> super::Result> { + let (id, params) = cast_notification::(notif)?; + Ok(Task::local(move |session, notifier, requester, _| { + if let Err(err) = N::run(session, notifier, requester, params) { + tracing::error!("An error occurred while running {id}: {err}"); + show_err_msg!("Air encountered a problem. Check the logs for more details."); + } + })) +} + +#[allow(dead_code)] +fn background_notification_thread<'a, N: traits::BackgroundDocumentNotificationHandler>( + req: server::Notification, + schedule: BackgroundSchedule, +) -> super::Result> { + let (id, params) = cast_notification::(req)?; + Ok(Task::background(schedule, move |session: &Session| { + // TODO: We should log an error if we can't take a snapshot. + let Some(snapshot) = session.take_snapshot(N::document_url(¶ms).into_owned()) else { + return Box::new(|_, _| {}); + }; + Box::new(move |notifier, _| { + if let Err(err) = N::run_with_snapshot(snapshot, notifier, params) { + tracing::error!("An error occurred while running {id}: {err}"); + show_err_msg!("Air encountered a problem. Check the logs for more details."); + } + }) + })) +} + +/// Tries to cast a serialized request from the server into +/// a parameter type for a specific request handler. +/// It is *highly* recommended to not override this function in your +/// implementation. +fn cast_request( + request: server::Request, +) -> super::Result<( + server::RequestId, + <::RequestType as lsp_types::request::Request>::Params, +)> +where + Req: traits::RequestHandler, +{ + request + .extract(Req::METHOD) + .map_err(|err| match err { + json_err @ server::ExtractError::JsonError { .. } => { + anyhow::anyhow!("JSON parsing failure:\n{json_err}") + } + server::ExtractError::MethodMismatch(_) => { + unreachable!("A method mismatch should not be possible here unless you've used a different handler (`Req`) \ + than the one whose method name was matched against earlier.") + } + }) + .with_failure_code(server::ErrorCode::InternalError) +} + +/// Sends back a response to the server using a [`Responder`]. +fn respond( + id: server::RequestId, + result: crate::server::Result< + <::RequestType as lsp_types::request::Request>::Result, + >, + responder: &Responder, +) where + Req: traits::RequestHandler, +{ + if let Err(err) = &result { + tracing::error!("An error occurred with result ID {id}: {err}"); + show_err_msg!("Air encountered a problem. Check the logs for more details."); + } + if let Err(err) = responder.respond(id, result) { + tracing::error!("Failed to send response: {err}"); + } +} + +/// Tries to cast a serialized request from the server into +/// a parameter type for a specific request handler. +fn cast_notification( + notification: server::Notification, +) -> super::Result< + ( + &'static str, + <::NotificationType as lsp_types::notification::Notification>::Params, +)> where N: traits::NotificationHandler{ + Ok(( + N::METHOD, + notification + .extract(N::METHOD) + .map_err(|err| match err { + json_err @ server::ExtractError::JsonError { .. } => { + anyhow::anyhow!("JSON parsing failure:\n{json_err}") + } + server::ExtractError::MethodMismatch(_) => { + unreachable!("A method mismatch should not be possible here unless you've used a different handler (`N`) \ + than the one whose method name was matched against earlier.") + } + }) + .with_failure_code(server::ErrorCode::InternalError)?, + )) +} + +pub(crate) struct Error { + pub(crate) code: server::ErrorCode, + pub(crate) error: anyhow::Error, +} + +/// A trait to convert result types into the server result type, [`super::Result`]. +trait LSPResult { + fn with_failure_code(self, code: server::ErrorCode) -> super::Result; +} + +impl> LSPResult for core::result::Result { + fn with_failure_code(self, code: server::ErrorCode) -> super::Result { + self.map_err(|err| Error::new(err.into(), code)) + } +} + +impl Error { + pub(crate) fn new(err: anyhow::Error, code: server::ErrorCode) -> Self { + Self { code, error: err } + } +} + +// Right now, we treat the error code as invisible data that won't +// be printed. +impl std::fmt::Debug for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(f) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(f) + } +} diff --git a/crates/server/src/server/api/notifications.rs b/crates/server/src/server/api/notifications.rs new file mode 100644 index 00000000..2933a03d --- /dev/null +++ b/crates/server/src/server/api/notifications.rs @@ -0,0 +1,18 @@ +mod cancel; +mod did_change; +mod did_change_configuration; +mod did_change_watched_files; +mod did_change_workspace; +mod did_close; +mod did_open; +mod set_trace; + +use super::traits::{NotificationHandler, SyncNotificationHandler}; +pub(super) use cancel::Cancel; +pub(super) use did_change::DidChange; +pub(super) use did_change_configuration::DidChangeConfiguration; +pub(super) use did_change_watched_files::DidChangeWatchedFiles; +pub(super) use did_change_workspace::DidChangeWorkspace; +pub(super) use did_close::DidClose; +pub(super) use did_open::DidOpen; +pub(super) use set_trace::SetTrace; diff --git a/crates/server/src/server/api/notifications/cancel.rs b/crates/server/src/server/api/notifications/cancel.rs new file mode 100644 index 00000000..e0999c4a --- /dev/null +++ b/crates/server/src/server/api/notifications/cancel.rs @@ -0,0 +1,23 @@ +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct Cancel; + +impl super::NotificationHandler for Cancel { + type NotificationType = notif::Cancel; +} + +impl super::SyncNotificationHandler for Cancel { + fn run( + _session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + _params: types::CancelParams, + ) -> Result<()> { + // TODO: Handle this once we have task cancellation in the scheduler. + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/did_change.rs b/crates/server/src/server/api/notifications/did_change.rs new file mode 100644 index 00000000..ba51c1a9 --- /dev/null +++ b/crates/server/src/server/api/notifications/did_change.rs @@ -0,0 +1,37 @@ +use crate::server::api::LSPResult; +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_server::ErrorCode; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidChange; + +impl super::NotificationHandler for DidChange { + type NotificationType = notif::DidChangeTextDocument; +} + +impl super::SyncNotificationHandler for DidChange { + fn run( + session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + types::DidChangeTextDocumentParams { + text_document: + types::VersionedTextDocumentIdentifier { + uri, + version: new_version, + }, + content_changes, + }: types::DidChangeTextDocumentParams, + ) -> Result<()> { + let key = session.key_from_url(uri); + + session + .update_text_document(&key, content_changes, new_version) + .with_failure_code(ErrorCode::InternalError)?; + + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/did_change_configuration.rs b/crates/server/src/server/api/notifications/did_change_configuration.rs new file mode 100644 index 00000000..a67155b5 --- /dev/null +++ b/crates/server/src/server/api/notifications/did_change_configuration.rs @@ -0,0 +1,23 @@ +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidChangeConfiguration; + +impl super::NotificationHandler for DidChangeConfiguration { + type NotificationType = notif::DidChangeConfiguration; +} + +impl super::SyncNotificationHandler for DidChangeConfiguration { + fn run( + _session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + _params: types::DidChangeConfigurationParams, + ) -> Result<()> { + // TODO: get this wired up as a "signal" to pull new configuration + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/did_change_watched_files.rs b/crates/server/src/server/api/notifications/did_change_watched_files.rs new file mode 100644 index 00000000..e7fc83a6 --- /dev/null +++ b/crates/server/src/server/api/notifications/did_change_watched_files.rs @@ -0,0 +1,26 @@ +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidChangeWatchedFiles; + +impl super::NotificationHandler for DidChangeWatchedFiles { + type NotificationType = notif::DidChangeWatchedFiles; +} + +impl super::SyncNotificationHandler for DidChangeWatchedFiles { + fn run( + session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + params: types::DidChangeWatchedFilesParams, + ) -> Result<()> { + for change in ¶ms.changes { + session.reload_settings(&change.uri); + } + + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/did_change_workspace.rs b/crates/server/src/server/api/notifications/did_change_workspace.rs new file mode 100644 index 00000000..d44b00b9 --- /dev/null +++ b/crates/server/src/server/api/notifications/did_change_workspace.rs @@ -0,0 +1,28 @@ +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidChangeWorkspace; + +impl super::NotificationHandler for DidChangeWorkspace { + type NotificationType = notif::DidChangeWorkspaceFolders; +} + +impl super::SyncNotificationHandler for DidChangeWorkspace { + fn run( + session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + params: types::DidChangeWorkspaceFoldersParams, + ) -> Result<()> { + for types::WorkspaceFolder { uri, .. } in params.event.added { + session.open_workspace_folder(&uri); + } + for types::WorkspaceFolder { uri, .. } in params.event.removed { + session.close_workspace_folder(&uri); + } + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/did_close.rs b/crates/server/src/server/api/notifications/did_close.rs new file mode 100644 index 00000000..309b6f0e --- /dev/null +++ b/crates/server/src/server/api/notifications/did_close.rs @@ -0,0 +1,29 @@ +use crate::server::api::LSPResult; +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidClose; + +impl super::NotificationHandler for DidClose { + type NotificationType = notif::DidCloseTextDocument; +} + +impl super::SyncNotificationHandler for DidClose { + fn run( + session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + types::DidCloseTextDocumentParams { + text_document: types::TextDocumentIdentifier { uri }, + }: types::DidCloseTextDocumentParams, + ) -> Result<()> { + let key = session.key_from_url(uri); + + session + .close_document(&key) + .with_failure_code(lsp_server::ErrorCode::InternalError) + } +} diff --git a/crates/server/src/server/api/notifications/did_open.rs b/crates/server/src/server/api/notifications/did_open.rs new file mode 100644 index 00000000..943d0a78 --- /dev/null +++ b/crates/server/src/server/api/notifications/did_open.rs @@ -0,0 +1,32 @@ +use crate::document::TextDocument; +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct DidOpen; + +impl super::NotificationHandler for DidOpen { + type NotificationType = notif::DidOpenTextDocument; +} + +impl super::SyncNotificationHandler for DidOpen { + fn run( + session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + types::DidOpenTextDocumentParams { + text_document: + types::TextDocumentItem { + uri, text, version, .. + }, + }: types::DidOpenTextDocumentParams, + ) -> Result<()> { + let document = TextDocument::new(text, version); + + session.open_text_document(uri, document); + + Ok(()) + } +} diff --git a/crates/server/src/server/api/notifications/set_trace.rs b/crates/server/src/server/api/notifications/set_trace.rs new file mode 100644 index 00000000..9c3a5716 --- /dev/null +++ b/crates/server/src/server/api/notifications/set_trace.rs @@ -0,0 +1,26 @@ +use crate::server::client::{Notifier, Requester}; +use crate::server::Result; +use crate::session::Session; +use lsp_types as types; +use lsp_types::notification as notif; + +pub(crate) struct SetTrace; + +impl super::NotificationHandler for SetTrace { + type NotificationType = notif::SetTrace; +} + +impl super::SyncNotificationHandler for SetTrace { + fn run( + _session: &mut Session, + _notifier: Notifier, + _requester: &mut Requester, + params: types::SetTraceParams, + ) -> Result<()> { + // Clients always send this request on initialization, but we don't use + // log information from here. + let value = params.value; + tracing::trace!("Ignoring `$/setTrace` notification with value {value:?}"); + Ok(()) + } +} diff --git a/crates/server/src/server/api/requests.rs b/crates/server/src/server/api/requests.rs new file mode 100644 index 00000000..e05300c4 --- /dev/null +++ b/crates/server/src/server/api/requests.rs @@ -0,0 +1,8 @@ +mod format; +mod format_range; +mod view_file; + +use super::traits::{BackgroundDocumentRequestHandler, RequestHandler}; +pub(super) use format::Format; +pub(super) use format_range::FormatRange; +pub(super) use view_file::ViewFile; diff --git a/crates/server/src/server/api/requests/format.rs b/crates/server/src/server/api/requests/format.rs new file mode 100644 index 00000000..2c6bf45b --- /dev/null +++ b/crates/server/src/server/api/requests/format.rs @@ -0,0 +1,136 @@ +use air_r_parser::RParserOptions; +use biome_formatter::LineEnding; +use lsp_types::{self as types, request as req}; +use workspace::settings::FormatSettings; + +use crate::document::TextEdit; +use crate::document::{PositionEncoding, TextDocument}; +use crate::server::api::LSPResult; +use crate::server::{client::Notifier, Result}; +use crate::session::{DocumentQuery, DocumentSnapshot}; + +type FormatResponse = Option>; + +pub(crate) struct Format; + +impl super::RequestHandler for Format { + type RequestType = req::Formatting; +} + +impl super::BackgroundDocumentRequestHandler for Format { + fn document_url(params: &types::DocumentFormattingParams) -> std::borrow::Cow { + std::borrow::Cow::Borrowed(¶ms.text_document.uri) + } + + fn run_with_snapshot( + snapshot: DocumentSnapshot, + _notifier: Notifier, + _params: types::DocumentFormattingParams, + ) -> Result { + format_document(&snapshot) + } +} + +/// Formats a full text document +#[tracing::instrument(level = "info", skip_all)] +pub(super) fn format_document(snapshot: &DocumentSnapshot) -> Result { + let text_document = snapshot.query().as_single_document(); + let query = snapshot.query(); + format_text_document(text_document, query, snapshot.encoding()) +} + +fn format_text_document( + text_document: &TextDocument, + query: &DocumentQuery, + encoding: PositionEncoding, +) -> Result { + let document_settings = query.settings(); + let formatter_settings = &document_settings.format; + + let source = text_document.source_file(); + let text = source.contents(); + let ending = text_document.ending(); + + let new_text = format_source(text, formatter_settings) + .with_failure_code(lsp_server::ErrorCode::InternalError)?; + + let Some(new_text) = new_text else { + return Ok(None); + }; + + let text_edit = TextEdit::diff(text, &new_text); + + let edits = text_edit + .into_proto(source, encoding, ending) + .with_failure_code(lsp_server::ErrorCode::InternalError)?; + + Ok(Some(edits)) +} + +fn format_source( + source: &str, + formatter_settings: &FormatSettings, +) -> anyhow::Result> { + let parse = air_r_parser::parse(source, RParserOptions::default()); + + if parse.has_errors() { + return Err(anyhow::anyhow!("Can't format when there are parse errors.")); + } + + // Do we need to check that `doc` is indeed an R file? What about special + // files that don't have extensions like `NAMESPACE`, do we hard-code a + // list? What about unnamed temporary files? + + // Always use `Lf` line endings on the way out from the formatter since we + // internally store all LSP text documents with `Lf` endings + let format_options = formatter_settings + .to_format_options(source) + .with_line_ending(LineEnding::Lf); + + let formatted = air_r_formatter::format_node(format_options, &parse.syntax())?; + let code = formatted.print()?.into_code(); + + Ok(Some(code)) +} + +#[cfg(test)] +mod tests { + use crate::document::TextDocument; + use crate::{test::with_client, test::TestClientExt}; + + #[test] + fn test_format() { + with_client(|client| { + #[rustfmt::skip] + let doc = TextDocument::doodle( +"1 +2+2 +3 + 3 + +3", + ); + + let formatted = client.format_document(&doc); + insta::assert_snapshot!(formatted); + }); + } + + // https://github.com/posit-dev/air/issues/61 + #[test] + fn test_format_minimal_diff() { + with_client(|client| { + #[rustfmt::skip] + let doc = TextDocument::doodle( +"1 +2+2 +3 +", + ); + + let edits = client.format_document_edits(&doc).unwrap(); + assert_eq!(edits.len(), 1); + + let edit = &edits[0]; + assert_eq!(edit.new_text, " + "); + }); + } +} diff --git a/crates/server/src/server/api/requests/format_range.rs b/crates/server/src/server/api/requests/format_range.rs new file mode 100644 index 00000000..8564622e --- /dev/null +++ b/crates/server/src/server/api/requests/format_range.rs @@ -0,0 +1,499 @@ +use air_r_parser::RParserOptions; +use air_r_syntax::RExpressionList; +use air_r_syntax::RSyntaxKind; +use air_r_syntax::RSyntaxNode; +use biome_formatter::LineEnding; +use biome_rowan::AstNode; +use biome_rowan::Language; +use biome_rowan::SyntaxElement; +use biome_rowan::WalkEvent; +use biome_text_size::{TextRange, TextSize}; +use lsp_types::{self as types, request as req, Range}; +use workspace::settings::FormatSettings; + +use crate::document::TextEdit; +use crate::document::{PositionEncoding, TextDocument}; +use crate::proto::TextRangeExt; +use crate::server::api::LSPResult; +use crate::server::{client::Notifier, Result}; +use crate::session::{DocumentQuery, DocumentSnapshot}; + +type FormatRangeResponse = Option>; + +pub(crate) struct FormatRange; + +impl super::RequestHandler for FormatRange { + type RequestType = req::RangeFormatting; +} + +impl super::BackgroundDocumentRequestHandler for FormatRange { + fn document_url( + params: &types::DocumentRangeFormattingParams, + ) -> std::borrow::Cow { + std::borrow::Cow::Borrowed(¶ms.text_document.uri) + } + + fn run_with_snapshot( + snapshot: DocumentSnapshot, + _notifier: Notifier, + params: types::DocumentRangeFormattingParams, + ) -> Result { + format_document_range(&snapshot, params.range) + } +} + +/// Formats the specified [`Range`] in the [`DocumentSnapshot`]. +#[tracing::instrument(level = "info", skip_all)] +fn format_document_range(snapshot: &DocumentSnapshot, range: Range) -> Result { + let text_document = snapshot.query().as_single_document(); + let query = snapshot.query(); + format_text_document_range(text_document, range, query, snapshot.encoding()) +} + +/// Formats the specified [`Range`] in the [`TextDocument`]. +fn format_text_document_range( + text_document: &TextDocument, + range: Range, + query: &DocumentQuery, + encoding: PositionEncoding, +) -> Result { + let document_settings = query.settings(); + let formatter_settings = &document_settings.format; + + let ending = text_document.ending(); + let source = text_document.source_file(); + let text = source.contents(); + let range = TextRange::from_proto(range, source, encoding); + + let Some((new_text, new_range)) = format_source_range(text, formatter_settings, range) + .with_failure_code(lsp_server::ErrorCode::InternalError)? + else { + return Ok(None); + }; + + let text_edit = TextEdit::replace(new_range, new_text); + + let edits = text_edit + .into_proto(source, encoding, ending) + .with_failure_code(lsp_server::ErrorCode::InternalError)?; + + Ok(Some(edits)) +} + +fn format_source_range( + source: &str, + formatter_settings: &FormatSettings, + range: TextRange, +) -> anyhow::Result> { + let parse = air_r_parser::parse(source, RParserOptions::default()); + + if parse.has_errors() { + return Err(anyhow::anyhow!("Can't format when there are parse errors.")); + } + + // Always use `Lf` line endings on the way out from the formatter since we + // internally store all LSP text documents with `Lf` endings + let format_options = formatter_settings + .to_format_options(source) + .with_line_ending(LineEnding::Lf); + + let logical_lines = find_deepest_enclosing_logical_lines(parse.syntax(), range); + if logical_lines.is_empty() { + tracing::warn!("Can't find logical line"); + return Ok(None); + }; + + // Find the overall formatting range by concatenating the ranges of the logical lines. + // We use the "non-whitespace-range" as that corresponds to what Biome will format. + let new_range = logical_lines + .iter() + .map(text_non_whitespace_range) + .reduce(|acc, new| acc.cover(new)) + .expect("`logical_lines` is non-empty"); + + // We need to wrap in an `RRoot` otherwise the comments get attached too + // deep in the tree. See `CommentsBuilderVisitor` in biome_formatter and the + // `is_root` logic. Note that `node` needs to be wrapped in at least two + // other nodes in order to fix this problem, and here we have an `RRoot` and + // `RExpressionList` that do the job. + // + // Since we only format logical lines, it is fine to wrap in an expression list. + let Some(exprs): Option> = logical_lines + .into_iter() + .map(air_r_syntax::AnyRExpression::cast) + .collect() + else { + tracing::warn!("Can't cast to `AnyRExpression`"); + return Ok(None); + }; + + let list = air_r_factory::r_expression_list(exprs); + let eof = air_r_syntax::RSyntaxToken::new_detached(RSyntaxKind::EOF, "", vec![], vec![]); + let root = air_r_factory::r_root(list, eof).build(); + + let printed = biome_formatter::format_sub_tree( + root.syntax(), + air_r_formatter::RFormatLanguage::new(format_options), + )?; + + if printed.range().is_none() { + // Happens in edge cases when biome returns a `Printed::new_empty()` + return Ok(None); + }; + + let mut new_text = printed.into_code(); + + // Remove last hard break line from our artifical expression list + new_text.pop(); + + Ok(Some((new_text, new_range))) +} + +// From biome_formatter +fn text_non_whitespace_range(elem: &E) -> TextRange +where + E: Into> + Clone, + L: Language, +{ + let elem: SyntaxElement = elem.clone().into(); + + let start = elem + .leading_trivia() + .into_iter() + .flat_map(|trivia| trivia.pieces()) + .find_map(|piece| { + if piece.is_whitespace() || piece.is_newline() { + None + } else { + Some(piece.text_range().start()) + } + }) + .unwrap_or_else(|| elem.text_trimmed_range().start()); + + let end = elem + .trailing_trivia() + .into_iter() + .flat_map(|trivia| trivia.pieces().rev()) + .find_map(|piece| { + if piece.is_whitespace() || piece.is_newline() { + None + } else { + Some(piece.text_range().end()) + } + }) + .unwrap_or_else(|| elem.text_trimmed_range().end()); + + TextRange::new(start, end) +} + +/// Finds consecutive logical lines. Currently that's only expressions at +/// top-level or in a braced list. +fn find_deepest_enclosing_logical_lines(node: RSyntaxNode, range: TextRange) -> Vec { + let start_lists = find_expression_lists(&node, range.start(), false); + let end_lists = find_expression_lists(&node, range.end(), true); + + // Both vectors of lists should have a common prefix, starting from the + // program's expression list. As soon as the lists diverge we stop. + let Some(list) = start_lists + .into_iter() + .zip(end_lists) + .take_while(|pair| pair.0 == pair.1) + .map(|pair| pair.0) + .last() + else { + // Should not happen as the range is always included in the program's expression list + tracing::warn!("Can't find common list parent"); + return vec![]; + }; + + let Some(list) = RExpressionList::cast(list) else { + tracing::warn!("Can't cast to expression list"); + return vec![]; + }; + + let iter = list.into_iter(); + + // We've chosen to be liberal about user selections and always widen the + // range to include the selection bounds. If we wanted to be conservative + // instead, we could use this `filter()` instead of the `skip_while()` and + // `take_while()`: + // + // ```rust + // .filter(|node| range.contains_range(node.text_trimmed_range())) + // ``` + let logical_lines: Vec = iter + .map(|expr| expr.into_syntax()) + .skip_while(|node| !node.text_range().contains(range.start())) + .take_while(|node| node.text_trimmed_range().start() <= range.end()) + .collect(); + + logical_lines +} + +fn find_expression_lists(node: &RSyntaxNode, offset: TextSize, end: bool) -> Vec { + let mut preorder = node.preorder(); + let mut nodes: Vec = vec![]; + + while let Some(event) = preorder.next() { + match event { + WalkEvent::Enter(node) => { + let Some(parent) = node.parent() else { + continue; + }; + + let is_contained = if end { + let trimmed_node_range = node.text_trimmed_range(); + trimmed_node_range.contains_inclusive(offset) + } else { + let node_range = node.text_range(); + node_range.contains(offset) + }; + + if !is_contained { + preorder.skip_subtree(); + continue; + } + + if parent.kind() == RSyntaxKind::R_EXPRESSION_LIST { + nodes.push(parent.clone()); + continue; + } + } + + WalkEvent::Leave(_) => {} + } + } + + nodes +} + +#[cfg(test)] +mod tests { + use crate::document::TextDocument; + use crate::{test::with_client, test::TestClientExt}; + + #[test] + fn test_format_range_none() { + with_client(|client| { + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"<<>>", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"<< +>>", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"<<1 +>>", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + }); + } + + #[test] + fn test_format_range_logical_lines() { + with_client(|client| { + // 2+2 is the logical line to format + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +<<2+2>> +", + ); + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +# +<<2+2>> +", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + // The element in the braced expression is a logical line + // FIXME: Should this be the whole `{2+2}` instead? + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +{<<2+2>>} +", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +<<{2+2}>> +", + ); + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + // The deepest element in the braced expression is our target + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +{ + 2+2 + { + <<3+3>> + } +} +", + ); + + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + }); + } + + #[test] + fn test_format_range_mismatched_indent() { + with_client(|client| { + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1 + <<2+2>> +", + ); + + // We don't change indentation when `2+2` is formatted + let output = client.format_document_range(&doc, range); + insta::assert_snapshot!(output); + + // Debatable: Should we make an effort to remove unneeded indentation + // when it's part of the range? + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1 +<< 2+2>> +", + ); + let output_wide = client.format_document_range(&doc, range); + assert_eq!(output, output_wide); + }); + } + + #[test] + fn test_format_range_multiple_lines() { + with_client(|client| { + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +<<# +2+2>> +", + ); + + let output1 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output1); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"<<1+1 +# +2+2>> +", + ); + let output2 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output2); + }); + } + + #[test] + fn test_format_range_unmatched_lists() { + with_client(|client| { + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"0+0 +<<1+1 +{ + 2+2>> +} +3+3 +", + ); + + let output1 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output1); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"0+0 +<<1+1 +{ +>> 2+2 +} +3+3 +", + ); + let output2 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output2); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"0+0 +<<1+1 +{ + 2+2 +} +>>3+3 +", + ); + let output3 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output3); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"0+0 +1+1 +{ +<< 2+2 +} +>>3+3 +", + ); + let output4 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output4); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"<<1+1>> +2+2 +", + ); + + let output5 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output5); + + #[rustfmt::skip] + let (doc, range) = TextDocument::doodle_and_range( +"1+1 +<<2+2>> +", + ); + + let output6 = client.format_document_range(&doc, range); + insta::assert_snapshot!(output6); + }); + } +} diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format__tests__format.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format__tests__format.snap new file mode 100644 index 00000000..aee3cb44 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format__tests__format.snap @@ -0,0 +1,7 @@ +--- +source: crates/server/src/server/api/requests/format.rs +expression: formatted +--- +1 +2 + 2 +3 + 3 + 3 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-2.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-2.snap new file mode 100644 index 00000000..5862946a --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-2.snap @@ -0,0 +1,7 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1+1 +# +2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-3.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-3.snap new file mode 100644 index 00000000..faa5f5c4 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-3.snap @@ -0,0 +1,6 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1+1 +{2 + 2} diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-4.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-4.snap new file mode 100644 index 00000000..b27c75f2 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-4.snap @@ -0,0 +1,8 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1+1 +{ + 2 + 2 +} diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-5.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-5.snap new file mode 100644 index 00000000..6831d0d5 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines-5.snap @@ -0,0 +1,11 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1+1 +{ + 2+2 + { + 3 + 3 + } +} diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines.snap new file mode 100644 index 00000000..865744cb --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_logical_lines.snap @@ -0,0 +1,6 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1+1 +2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_mismatched_indent.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_mismatched_indent.snap new file mode 100644 index 00000000..9b7b4ea3 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_mismatched_indent.snap @@ -0,0 +1,6 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1 + 2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines-2.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines-2.snap new file mode 100644 index 00000000..3624c83e --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines-2.snap @@ -0,0 +1,7 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output2 +--- +1 + 1 +# +2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines.snap new file mode 100644 index 00000000..e807b492 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_multiple_lines.snap @@ -0,0 +1,7 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output1 +--- +1+1 +# +2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-2.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-2.snap new file mode 100644 index 00000000..41717d50 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-2.snap @@ -0,0 +1,5 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- + diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-3.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-3.snap new file mode 100644 index 00000000..2fe0f1bc --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none-3.snap @@ -0,0 +1,5 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- +1 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none.snap new file mode 100644 index 00000000..41717d50 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_none.snap @@ -0,0 +1,5 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output +--- + diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-2.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-2.snap new file mode 100644 index 00000000..0c907f96 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-2.snap @@ -0,0 +1,10 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output2 +--- +0+0 +1 + 1 +{ + 2 + 2 +} +3+3 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-3.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-3.snap new file mode 100644 index 00000000..1a161f44 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-3.snap @@ -0,0 +1,10 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output3 +--- +0+0 +1 + 1 +{ + 2 + 2 +} +3 + 3 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-4.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-4.snap new file mode 100644 index 00000000..ab9954f3 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-4.snap @@ -0,0 +1,10 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output4 +--- +0+0 +1+1 +{ + 2 + 2 +} +3 + 3 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-5.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-5.snap new file mode 100644 index 00000000..3b917aad --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-5.snap @@ -0,0 +1,6 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output5 +--- +1 + 1 +2+2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-6.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-6.snap new file mode 100644 index 00000000..88dd2ac0 --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists-6.snap @@ -0,0 +1,6 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output6 +--- +1+1 +2 + 2 diff --git a/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists.snap b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists.snap new file mode 100644 index 00000000..0257bd0b --- /dev/null +++ b/crates/server/src/server/api/requests/snapshots/server__server__api__requests__format_range__tests__format_range_unmatched_lists.snap @@ -0,0 +1,10 @@ +--- +source: crates/server/src/server/api/requests/format_range.rs +expression: output1 +--- +0+0 +1 + 1 +{ + 2 + 2 +} +3+3 diff --git a/crates/server/src/server/api/requests/view_file.rs b/crates/server/src/server/api/requests/view_file.rs new file mode 100644 index 00000000..fceb6c35 --- /dev/null +++ b/crates/server/src/server/api/requests/view_file.rs @@ -0,0 +1,140 @@ +use air_r_formatter::format_node; +use air_r_parser::RParserOptions; +use lsp_types::request::Request; +use serde::Deserialize; +use serde::Serialize; + +use crate::server::api::LSPResult; +use crate::server::client::Notifier; +use crate::server::Result; +use crate::session::DocumentSnapshot; + +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +pub(crate) enum ViewFileKind { + TreeSitter, + SyntaxTree, + FormatTree, +} + +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ViewFileParams { + /// From `lsp_types::TextDocumentPositionParams` + pub(crate) text_document: lsp_types::TextDocumentIdentifier, + pub(crate) position: lsp_types::Position, + + /// Viewer type + pub(crate) kind: ViewFileKind, +} + +#[derive(Debug)] +pub(crate) enum ViewFile {} + +impl Request for ViewFile { + type Params = ViewFileParams; + type Result = String; + const METHOD: &'static str = "air/viewFile"; +} + +impl super::RequestHandler for ViewFile { + type RequestType = ViewFile; +} + +impl super::BackgroundDocumentRequestHandler for ViewFile { + fn document_url(params: &ViewFileParams) -> std::borrow::Cow { + std::borrow::Cow::Borrowed(¶ms.text_document.uri) + } + + fn run_with_snapshot( + snapshot: DocumentSnapshot, + _notifier: Notifier, + params: ViewFileParams, + ) -> Result { + view_file(&snapshot, ¶ms) + } +} + +fn view_file(snapshot: &DocumentSnapshot, params: &ViewFileParams) -> Result { + let contents = snapshot.query().as_single_document().contents(); + let settings = snapshot.query().settings(); + + match params.kind { + ViewFileKind::TreeSitter => { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_r::LANGUAGE.into()) + .map_err(anyhow::Error::new) + .with_failure_code(lsp_server::ErrorCode::InternalError)?; + + let Some(ast) = parser.parse(contents, None) else { + return Err(anyhow::anyhow!("Internal error during document parsing.")) + .with_failure_code(lsp_server::ErrorCode::InternalError); + }; + + if ast.root_node().has_error() { + return Ok(String::from("*Parse error*")); + } + + let mut output = String::new(); + let mut cursor = ast.root_node().walk(); + format_ts_node(&mut cursor, 0, &mut output); + Ok(output) + } + + ViewFileKind::SyntaxTree => { + let parse = air_r_parser::parse(contents, RParserOptions::default()); + + if parse.has_errors() { + return Ok(String::from("*Parse error*")); + } + + Ok(format!("{syntax:#?}", syntax = parse.syntax())) + } + + ViewFileKind::FormatTree => { + let parse = air_r_parser::parse(contents, RParserOptions::default()); + + if parse.has_errors() { + return Ok(String::from("*Parse error*")); + } + + let format_options = settings.format.to_format_options(contents); + + let formatted = format_node(format_options, &parse.syntax()) + .map_err(anyhow::Error::new) + .with_failure_code(lsp_server::ErrorCode::InternalError)?; + + Ok(format!("{document}", document = formatted.into_document())) + } + } +} + +fn format_ts_node(cursor: &mut tree_sitter::TreeCursor, depth: usize, output: &mut String) { + let node = cursor.node(); + let field_name = match cursor.field_name() { + Some(name) => format!("{name}: "), + None => String::new(), + }; + + let start = node.start_position(); + let end = node.end_position(); + let node_type = node.kind(); + + let indent = " ".repeat(depth * 4); + let start = format!("{}, {}", start.row, start.column); + let end = format!("{}, {}", end.row, end.column); + + output.push_str(&format!( + "{indent}{field_name}{node_type} [{start}] - [{end}]\n", + )); + + if cursor.goto_first_child() { + loop { + format_ts_node(cursor, depth + 1, output); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } +} diff --git a/crates/server/src/server/api/traits.rs b/crates/server/src/server/api/traits.rs new file mode 100644 index 00000000..2d6198c0 --- /dev/null +++ b/crates/server/src/server/api/traits.rs @@ -0,0 +1,72 @@ +//! A stateful LSP implementation that calls into the Air API. + +use crate::server::client::{Notifier, Requester}; +use crate::session::{DocumentSnapshot, Session}; + +use lsp_types::notification::Notification as LSPNotification; +use lsp_types::request::Request; + +/// A supertrait for any server request handler. +pub(super) trait RequestHandler { + type RequestType: Request; + const METHOD: &'static str = <::RequestType as Request>::METHOD; +} + +/// A request handler that needs mutable access to the session. +/// This will block the main message receiver loop, meaning that no +/// incoming requests or notifications will be handled while `run` is +/// executing. Try to avoid doing any I/O or long-running computations. +pub(super) trait SyncRequestHandler: RequestHandler { + fn run( + session: &mut Session, + notifier: Notifier, + requester: &mut Requester, + params: <::RequestType as Request>::Params, + ) -> super::Result<<::RequestType as Request>::Result>; +} + +/// A request handler that can be run on a background thread. +pub(super) trait BackgroundDocumentRequestHandler: RequestHandler { + fn document_url( + params: &<::RequestType as Request>::Params, + ) -> std::borrow::Cow; + + fn run_with_snapshot( + snapshot: DocumentSnapshot, + notifier: Notifier, + params: <::RequestType as Request>::Params, + ) -> super::Result<<::RequestType as Request>::Result>; +} + +/// A supertrait for any server notification handler. +pub(super) trait NotificationHandler { + type NotificationType: LSPNotification; + const METHOD: &'static str = + <::NotificationType as LSPNotification>::METHOD; +} + +/// A notification handler that needs mutable access to the session. +/// This will block the main message receiver loop, meaning that no +/// incoming requests or notifications will be handled while `run` is +/// executing. Try to avoid doing any I/O or long-running computations. +pub(super) trait SyncNotificationHandler: NotificationHandler { + fn run( + session: &mut Session, + notifier: Notifier, + requester: &mut Requester, + params: <::NotificationType as LSPNotification>::Params, + ) -> super::Result<()>; +} + +/// A notification handler that can be run on a background thread. +pub(super) trait BackgroundDocumentNotificationHandler: NotificationHandler { + fn document_url( + params: &<::NotificationType as LSPNotification>::Params, + ) -> std::borrow::Cow; + + fn run_with_snapshot( + snapshot: DocumentSnapshot, + notifier: Notifier, + params: <::NotificationType as LSPNotification>::Params, + ) -> super::Result<()>; +} diff --git a/crates/server/src/server/client.rs b/crates/server/src/server/client.rs new file mode 100644 index 00000000..3923eb3a --- /dev/null +++ b/crates/server/src/server/client.rs @@ -0,0 +1,169 @@ +use std::any::TypeId; + +use lsp_server::{Notification, RequestId}; +use rustc_hash::FxHashMap; +use serde_json::Value; + +use super::{schedule::Task, ClientSender}; + +type ResponseBuilder<'s> = Box Task<'s>>; + +pub(crate) struct Client<'s> { + notifier: Notifier, + responder: Responder, + pub(super) requester: Requester<'s>, +} + +#[derive(Clone)] +pub(crate) struct Notifier(ClientSender); + +#[derive(Clone)] +pub(crate) struct Responder(ClientSender); + +pub(crate) struct Requester<'s> { + sender: ClientSender, + next_request_id: i32, + response_handlers: FxHashMap>, +} + +impl Client<'_> { + pub(super) fn new(sender: ClientSender) -> Self { + Self { + notifier: Notifier(sender.clone()), + responder: Responder(sender.clone()), + requester: Requester { + sender, + next_request_id: 1, + response_handlers: FxHashMap::default(), + }, + } + } + + pub(super) fn notifier(&self) -> Notifier { + self.notifier.clone() + } + + pub(super) fn responder(&self) -> Responder { + self.responder.clone() + } +} + +#[allow(dead_code)] // we'll need to use `Notifier` in the future to send notifs to the client +impl Notifier { + pub(crate) fn notify(&self, params: N::Params) -> anyhow::Result<()> + where + N: lsp_types::notification::Notification, + { + let method = N::METHOD.to_string(); + + let message = lsp_server::Message::Notification(Notification::new(method, params)); + + self.0.send(message) + } + + pub(crate) fn notify_method(&self, method: String) -> anyhow::Result<()> { + self.0 + .send(lsp_server::Message::Notification(Notification::new( + method, + Value::Null, + ))) + } +} + +impl Responder { + pub(crate) fn respond( + &self, + id: RequestId, + result: crate::server::Result, + ) -> anyhow::Result<()> + where + R: serde::Serialize, + { + self.0.send( + match result { + Ok(res) => lsp_server::Response::new_ok(id, res), + Err(crate::server::api::Error { code, error }) => { + lsp_server::Response::new_err(id, code as i32, format!("{error}")) + } + } + .into(), + ) + } +} + +impl<'s> Requester<'s> { + /// Sends a request of kind `R` to the client, with associated parameters. + /// The task provided by `response_handler` will be dispatched as soon as the response + /// comes back from the client. + pub(crate) fn request( + &mut self, + params: R::Params, + response_handler: impl Fn(R::Result) -> Task<'s> + 'static, + ) -> anyhow::Result<()> + where + R: lsp_types::request::Request, + { + let serialized_params = serde_json::to_value(params)?; + + self.response_handlers.insert( + self.next_request_id.into(), + Box::new(move |response: lsp_server::Response| { + match (response.error, response.result) { + (Some(err), _) => { + tracing::error!( + "Got an error from the client (code {}): {}", + err.code, + err.message + ); + Task::nothing() + } + (None, Some(response)) => match serde_json::from_value(response) { + Ok(response) => response_handler(response), + Err(error) => { + tracing::error!("Failed to deserialize response from server: {error}"); + Task::nothing() + } + }, + (None, None) => { + if TypeId::of::() == TypeId::of::<()>() { + // We can't call `response_handler(())` directly here, but + // since we _know_ the type expected is `()`, we can use + // `from_value(Value::Null)`. `R::Result` implements `DeserializeOwned`, + // so this branch works in the general case but we'll only + // hit it if the concrete type is `()`, so the `unwrap()` is safe here. + response_handler(serde_json::from_value(Value::Null).unwrap()); + } else { + tracing::error!( + "Server response was invalid: did not contain a result or error" + ); + } + Task::nothing() + } + } + }), + ); + + self.sender + .send(lsp_server::Message::Request(lsp_server::Request { + id: self.next_request_id.into(), + method: R::METHOD.into(), + params: serialized_params, + }))?; + + self.next_request_id += 1; + + Ok(()) + } + + pub(crate) fn pop_response_task(&mut self, response: lsp_server::Response) -> Task<'s> { + if let Some(handler) = self.response_handlers.remove(&response.id) { + handler(response) + } else { + tracing::error!( + "Received a response with ID {}, which was not expected", + response.id + ); + Task::nothing() + } + } +} diff --git a/crates/server/src/server/connection.rs b/crates/server/src/server/connection.rs new file mode 100644 index 00000000..17963816 --- /dev/null +++ b/crates/server/src/server/connection.rs @@ -0,0 +1,144 @@ +use lsp_server as lsp; +use lsp_types::{notification::Notification, request::Request}; +use std::sync::{Arc, Weak}; + +type ConnectionSender = crossbeam::channel::Sender; +type ConnectionReceiver = crossbeam::channel::Receiver; + +/// A builder for `Connection` that handles LSP initialization. +pub(super) struct ConnectionInitializer { + connection: lsp::Connection, + threads: Option, +} + +/// Handles inbound and outbound messages with the client. +pub(crate) struct Connection { + sender: Arc, + receiver: ConnectionReceiver, + threads: Option, +} + +impl ConnectionInitializer { + pub(super) fn new(connection: lsp::Connection, threads: Option) -> Self { + Self { + connection, + threads, + } + } + + /// Starts the initialization process with the client by listening for an initialization request. + /// Returns a request ID that should be passed into `initialize_finish` later, + /// along with the initialization parameters that were provided. + pub(super) fn initialize_start( + &self, + ) -> anyhow::Result<(lsp::RequestId, lsp_types::InitializeParams)> { + let (id, params) = self.connection.initialize_start()?; + Ok((id, serde_json::from_value(params)?)) + } + + /// Finishes the initialization process with the client, + /// returning an initialized `Connection`. + pub(super) fn initialize_finish( + self, + id: lsp::RequestId, + server_capabilities: &lsp_types::ServerCapabilities, + name: &str, + version: &str, + ) -> anyhow::Result { + self.connection.initialize_finish( + id, + serde_json::json!({ + "capabilities": server_capabilities, + "serverInfo": { + "name": name, + "version": version + } + }), + )?; + let Self { + connection: lsp::Connection { sender, receiver }, + threads, + } = self; + Ok(Connection { + sender: Arc::new(sender), + receiver, + threads, + }) + } +} + +impl Connection { + /// Make a new `ClientSender` for sending messages to the client. + pub(super) fn make_sender(&self) -> ClientSender { + ClientSender { + weak_sender: Arc::downgrade(&self.sender), + } + } + + /// An iterator over incoming messages from the client. + pub(super) fn incoming(&self) -> crossbeam::channel::Iter { + self.receiver.iter() + } + + /// Check and respond to any incoming shutdown requests; returns`true` if the server should be shutdown. + pub(super) fn handle_shutdown(&self, message: &lsp::Message) -> anyhow::Result { + match message { + lsp::Message::Request(lsp::Request { id, method, .. }) + if method == lsp_types::request::Shutdown::METHOD => + { + self.sender + .send(lsp::Response::new_ok(id.clone(), ()).into())?; + tracing::info!("Shutdown request received. Waiting for an exit notification..."); + match self.receiver.recv_timeout(std::time::Duration::from_secs(30))? { + lsp::Message::Notification(lsp::Notification { method, .. }) if method == lsp_types::notification::Exit::METHOD => { + tracing::info!("Exit notification received. Server shutting down..."); + Ok(true) + }, + message => anyhow::bail!("Server received unexpected message {message:?} while waiting for exit notification") + } + } + lsp::Message::Notification(lsp::Notification { method, .. }) + if method == lsp_types::notification::Exit::METHOD => + { + tracing::error!("Server received an exit notification before a shutdown request was sent. Exiting..."); + Ok(true) + } + _ => Ok(false), + } + } + + /// Join the I/O threads that underpin this connection. + /// This is guaranteed to be nearly immediate since + /// we close the only active channels to these threads prior + /// to joining them. + pub(super) fn close(self) -> anyhow::Result<()> { + std::mem::drop( + Arc::into_inner(self.sender) + .expect("the client sender shouldn't have more than one strong reference"), + ); + std::mem::drop(self.receiver); + if let Some(threads) = self.threads { + threads.join()?; + }; + Ok(()) + } +} + +/// A weak reference to an underlying sender channel, used for communication with the client. +/// If the `Connection` that created this `ClientSender` is dropped, any `send` calls will throw +/// an error. +#[derive(Clone, Debug)] +pub(crate) struct ClientSender { + weak_sender: Weak, +} + +// note: additional wrapper functions for senders may be implemented as needed. +impl ClientSender { + pub(crate) fn send(&self, msg: lsp::Message) -> anyhow::Result<()> { + let Some(sender) = self.weak_sender.upgrade() else { + anyhow::bail!("The connection with the client has been closed"); + }; + + Ok(sender.send(msg)?) + } +} diff --git a/crates/server/src/server/schedule.rs b/crates/server/src/server/schedule.rs new file mode 100644 index 00000000..dc1a8bc5 --- /dev/null +++ b/crates/server/src/server/schedule.rs @@ -0,0 +1,112 @@ +use std::num::NonZeroUsize; + +use crate::session::Session; + +mod task; +mod thread; + +pub(super) use task::{BackgroundSchedule, Task}; + +use self::{ + task::{BackgroundTaskBuilder, SyncTask}, + thread::ThreadPriority, +}; + +use super::{client::Client, ClientSender}; + +/// The event loop thread is actually a secondary thread that we spawn from the +/// _actual_ main thread. This secondary thread has a larger stack size +/// than some OS defaults (Windows, for example) and is also designated as +/// high-priority. +pub(crate) fn event_loop_thread( + func: impl FnOnce() -> anyhow::Result<()> + Send + 'static, +) -> anyhow::Result>> { + // Override OS defaults to avoid stack overflows on platforms with low stack size defaults. + const MAIN_THREAD_STACK_SIZE: usize = 2 * 1024 * 1024; + const MAIN_THREAD_NAME: &str = "air:main"; + Ok( + thread::Builder::new(thread::ThreadPriority::LatencySensitive) + .name(MAIN_THREAD_NAME.into()) + .stack_size(MAIN_THREAD_STACK_SIZE) + .spawn(func)?, + ) +} + +pub(crate) struct Scheduler<'s> { + session: &'s mut Session, + client: Client<'s>, + fmt_pool: thread::Pool, + background_pool: thread::Pool, +} + +impl<'s> Scheduler<'s> { + pub(super) fn new( + session: &'s mut Session, + worker_threads: NonZeroUsize, + sender: ClientSender, + ) -> Self { + const FMT_THREADS: usize = 1; + Self { + session, + fmt_pool: thread::Pool::new(NonZeroUsize::try_from(FMT_THREADS).unwrap()), + background_pool: thread::Pool::new(worker_threads), + client: Client::new(sender), + } + } + + /// Immediately sends a request of kind `R` to the client, with associated parameters. + /// The task provided by `response_handler` will be dispatched as soon as the response + /// comes back from the client. + pub(super) fn request( + &mut self, + params: R::Params, + response_handler: impl Fn(R::Result) -> Task<'s> + 'static, + ) -> anyhow::Result<()> + where + R: lsp_types::request::Request, + { + self.client.requester.request::(params, response_handler) + } + + /// Creates a task to handle a response from the client. + pub(super) fn response(&mut self, response: lsp_server::Response) -> Task<'s> { + self.client.requester.pop_response_task(response) + } + + /// Dispatches a `task` by either running it as a blocking function or + /// executing it on a background thread pool. + pub(super) fn dispatch(&mut self, task: task::Task<'s>) { + match task { + Task::Sync(SyncTask { func }) => { + let notifier = self.client.notifier(); + let responder = self.client.responder(); + func( + self.session, + notifier, + &mut self.client.requester, + responder, + ); + } + Task::Background(BackgroundTaskBuilder { + schedule, + builder: func, + }) => { + let static_func = func(self.session); + let notifier = self.client.notifier(); + let responder = self.client.responder(); + let task = move || static_func(notifier, responder); + match schedule { + BackgroundSchedule::Worker => { + self.background_pool.spawn(ThreadPriority::Worker, task); + } + BackgroundSchedule::LatencySensitive => self + .background_pool + .spawn(ThreadPriority::LatencySensitive, task), + BackgroundSchedule::Fmt => { + self.fmt_pool.spawn(ThreadPriority::LatencySensitive, task); + } + } + } + } + } +} diff --git a/crates/server/src/server/schedule/task.rs b/crates/server/src/server/schedule/task.rs new file mode 100644 index 00000000..beb8f4e1 --- /dev/null +++ b/crates/server/src/server/schedule/task.rs @@ -0,0 +1,97 @@ +use lsp_server::RequestId; +use serde::Serialize; + +use crate::{ + server::client::{Notifier, Requester, Responder}, + session::Session, +}; + +type LocalFn<'s> = Box; + +type BackgroundFn = Box; + +type BackgroundFnBuilder<'s> = Box BackgroundFn + 's>; + +/// Describes how the task should be run. +#[derive(Clone, Copy, Debug, Default)] +pub(in crate::server) enum BackgroundSchedule { + /// The task should be run on the background thread designated + /// for formatting actions. This is a high priority thread. + Fmt, + /// The task should be run on the general high-priority background + /// thread. + // TODO: Remove once we have some latency sensitive background request + #[allow(dead_code)] + LatencySensitive, + /// The task should be run on a regular-priority background thread. + #[default] + Worker, +} + +/// A [`Task`] is a future that has not yet started, and it is the job of +/// the [`super::Scheduler`] to make that happen, via [`super::Scheduler::dispatch`]. +/// A task can either run on the main thread (in other words, the same thread as the +/// scheduler) or it can run in a background thread. The main difference between +/// the two is that background threads only have a read-only snapshot of the session, +/// while local tasks have exclusive access and can modify it as they please. Keep in mind that +/// local tasks will **block** the main event loop, so only use local tasks if you **need** +/// mutable state access or you need the absolute lowest latency possible. +pub(in crate::server) enum Task<'s> { + Background(BackgroundTaskBuilder<'s>), + Sync(SyncTask<'s>), +} + +// The reason why this isn't just a 'static background closure +// is because we need to take a snapshot of the session before sending +// this task to the background, and the inner closure can't take the session +// as an immutable reference since it's used mutably elsewhere. So instead, +// a background task is built using an outer closure that borrows the session to take a snapshot, +// that the inner closure can capture. This builder closure has a lifetime linked to the scheduler. +// When the task is dispatched, the scheduler runs the synchronous builder, which takes the session +// as a reference, to create the inner 'static closure. That closure is then moved to a background task pool. +pub(in crate::server) struct BackgroundTaskBuilder<'s> { + pub(super) schedule: BackgroundSchedule, + pub(super) builder: BackgroundFnBuilder<'s>, +} + +pub(in crate::server) struct SyncTask<'s> { + pub(super) func: LocalFn<'s>, +} + +impl<'s> Task<'s> { + /// Creates a new background task. + pub(crate) fn background( + schedule: BackgroundSchedule, + func: impl FnOnce(&Session) -> Box + 's, + ) -> Self { + Self::Background(BackgroundTaskBuilder { + schedule, + builder: Box::new(func), + }) + } + /// Creates a new local task. + pub(crate) fn local( + func: impl FnOnce(&mut Session, Notifier, &mut Requester, Responder) + 's, + ) -> Self { + Self::Sync(SyncTask { + func: Box::new(func), + }) + } + /// Creates a local task that immediately + /// responds with the provided `request`. + pub(crate) fn immediate(id: RequestId, result: crate::server::Result) -> Self + where + R: Serialize + Send + 'static, + { + Self::local(move |_, _, _, responder| { + if let Err(err) = responder.respond(id, result) { + tracing::error!("Unable to send immediate response: {err}"); + } + }) + } + + /// Creates a local task that does nothing. + pub(crate) fn nothing() -> Self { + Self::local(move |_, _, _, _| {}) + } +} diff --git a/crates/server/src/server/schedule/thread.rs b/crates/server/src/server/schedule/thread.rs new file mode 100644 index 00000000..da3ea8c2 --- /dev/null +++ b/crates/server/src/server/schedule/thread.rs @@ -0,0 +1,109 @@ +// +------------------------------------------------------------+ +// | Code adopted from: | +// | Repository: https://github.com/rust-lang/rust-analyzer.git | +// | File: `crates/stdx/src/thread.rs` | +// | Commit: 03b3cb6be9f21c082f4206b35c7fe7f291c94eaa | +// +------------------------------------------------------------+ +//! A utility module for working with threads that automatically joins threads upon drop +//! and abstracts over operating system quality of service (QoS) APIs +//! through the concept of a “thread priority”. +//! +//! The priority of a thread is frozen at thread creation time, +//! i.e. there is no API to change the priority of a thread once it has been spawned. +//! +//! As a system, rust-analyzer should have the property that +//! old manual scheduling APIs are replaced entirely by QoS. +//! To maintain this invariant, we panic when it is clear that +//! old scheduling APIs have been used. +//! +//! Moreover, we also want to ensure that every thread has an priority set explicitly +//! to force a decision about its importance to the system. +//! Thus, [`ThreadPriority`] has no default value +//! and every entry point to creating a thread requires a [`ThreadPriority`] upfront. + +// Keeps us from getting warnings about the word `QoS` +#![allow(clippy::doc_markdown)] + +use std::fmt; + +mod pool; +mod priority; + +pub(super) use pool::Pool; +pub(super) use priority::ThreadPriority; + +pub(super) struct Builder { + priority: ThreadPriority, + inner: jod_thread::Builder, +} + +impl Builder { + pub(super) fn new(priority: ThreadPriority) -> Builder { + Builder { + priority, + inner: jod_thread::Builder::new(), + } + } + + pub(super) fn name(self, name: String) -> Builder { + Builder { + inner: self.inner.name(name), + ..self + } + } + + pub(super) fn stack_size(self, size: usize) -> Builder { + Builder { + inner: self.inner.stack_size(size), + ..self + } + } + + pub(super) fn spawn(self, f: F) -> std::io::Result> + where + F: FnOnce() -> T, + F: Send + 'static, + T: Send + 'static, + { + let inner_handle = self.inner.spawn(move || { + self.priority.apply_to_current_thread(); + f() + })?; + + Ok(JoinHandle { + inner: Some(inner_handle), + allow_leak: false, + }) + } +} + +pub(crate) struct JoinHandle { + // `inner` is an `Option` so that we can + // take ownership of the contained `JoinHandle`. + inner: Option>, + allow_leak: bool, +} + +impl JoinHandle { + pub(crate) fn join(mut self) -> T { + self.inner.take().unwrap().join() + } +} + +impl Drop for JoinHandle { + fn drop(&mut self) { + if !self.allow_leak { + return; + } + + if let Some(join_handle) = self.inner.take() { + join_handle.detach(); + } + } +} + +impl fmt::Debug for JoinHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad("JoinHandle { .. }") + } +} diff --git a/crates/server/src/server/schedule/thread/pool.rs b/crates/server/src/server/schedule/thread/pool.rs new file mode 100644 index 00000000..81443c5d --- /dev/null +++ b/crates/server/src/server/schedule/thread/pool.rs @@ -0,0 +1,113 @@ +// +------------------------------------------------------------+ +// | Code adopted from: | +// | Repository: https://github.com/rust-lang/rust-analyzer.git | +// | File: `crates/stdx/src/thread/pool.rs` | +// | Commit: 03b3cb6be9f21c082f4206b35c7fe7f291c94eaa | +// +------------------------------------------------------------+ +//! [`Pool`] implements a basic custom thread pool +//! inspired by the [`threadpool` crate](http://docs.rs/threadpool). +//! When you spawn a task you specify a thread priority +//! so the pool can schedule it to run on a thread with that priority. +//! rust-analyzer uses this to prioritize work based on latency requirements. +//! +//! The thread pool is implemented entirely using +//! the threading utilities in [`crate::server::schedule::thread`]. + +use std::{ + num::NonZeroUsize, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; + +use crossbeam::channel::{Receiver, Sender}; + +use super::{Builder, JoinHandle, ThreadPriority}; + +pub(crate) struct Pool { + // `_handles` is never read: the field is present + // only for its `Drop` impl. + + // The worker threads exit once the channel closes; + // make sure to keep `job_sender` above `handles` + // so that the channel is actually closed + // before we join the worker threads! + job_sender: Sender, + _handles: Vec, + extant_tasks: Arc, +} + +struct Job { + requested_priority: ThreadPriority, + f: Box, +} + +impl Pool { + pub(crate) fn new(threads: NonZeroUsize) -> Pool { + // Override OS defaults to avoid stack overflows on platforms with low stack size defaults. + const STACK_SIZE: usize = 2 * 1024 * 1024; + const INITIAL_PRIORITY: ThreadPriority = ThreadPriority::Worker; + + let threads = usize::from(threads); + + // Channel buffer capacity is between 2 and 4, depending on the pool size. + let (job_sender, job_receiver) = crossbeam::channel::bounded(std::cmp::min(threads * 2, 4)); + let extant_tasks = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::with_capacity(threads); + for i in 0..threads { + let handle = Builder::new(INITIAL_PRIORITY) + .stack_size(STACK_SIZE) + .name(format!("air:worker:{i}")) + .spawn({ + let extant_tasks = Arc::clone(&extant_tasks); + let job_receiver: Receiver = job_receiver.clone(); + move || { + let mut current_priority = INITIAL_PRIORITY; + for job in job_receiver { + if job.requested_priority != current_priority { + job.requested_priority.apply_to_current_thread(); + current_priority = job.requested_priority; + } + extant_tasks.fetch_add(1, Ordering::SeqCst); + (job.f)(); + extant_tasks.fetch_sub(1, Ordering::SeqCst); + } + } + }) + .expect("failed to spawn thread"); + + handles.push(handle); + } + + Pool { + _handles: handles, + extant_tasks, + job_sender, + } + } + + pub(crate) fn spawn(&self, priority: ThreadPriority, f: F) + where + F: FnOnce() + Send + 'static, + { + let f = Box::new(move || { + if cfg!(debug_assertions) { + priority.assert_is_used_on_current_thread(); + } + f(); + }); + + let job = Job { + requested_priority: priority, + f, + }; + self.job_sender.send(job).unwrap(); + } + + #[allow(dead_code)] + pub(super) fn len(&self) -> usize { + self.extant_tasks.load(Ordering::SeqCst) + } +} diff --git a/crates/server/src/server/schedule/thread/priority.rs b/crates/server/src/server/schedule/thread/priority.rs new file mode 100644 index 00000000..e6a55524 --- /dev/null +++ b/crates/server/src/server/schedule/thread/priority.rs @@ -0,0 +1,297 @@ +// +------------------------------------------------------------+ +// | Code adopted from: | +// | Repository: https://github.com/rust-lang/rust-analyzer.git | +// | File: `crates/stdx/src/thread/intent.rs` | +// | Commit: 03b3cb6be9f21c082f4206b35c7fe7f291c94eaa | +// +------------------------------------------------------------+ +//! An opaque façade around platform-specific QoS APIs. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +// Please maintain order from least to most priority for the derived `Ord` impl. +pub(crate) enum ThreadPriority { + /// Any thread which does work that isn't in a critical path. + Worker, + + /// Any thread which does work caused by the user typing, or + /// work that the editor may wait on. + LatencySensitive, +} + +impl ThreadPriority { + // These APIs must remain private; + // we only want consumers to set thread priority + // during thread creation. + + pub(crate) fn apply_to_current_thread(self) { + let class = thread_priority_to_qos_class(self); + set_current_thread_qos_class(class); + } + + pub(crate) fn assert_is_used_on_current_thread(self) { + if IS_QOS_AVAILABLE { + let class = thread_priority_to_qos_class(self); + assert_eq!(get_current_thread_qos_class(), Some(class)); + } + } +} + +use imp::QoSClass; + +const IS_QOS_AVAILABLE: bool = imp::IS_QOS_AVAILABLE; + +fn set_current_thread_qos_class(class: QoSClass) { + imp::set_current_thread_qos_class(class); +} + +fn get_current_thread_qos_class() -> Option { + imp::get_current_thread_qos_class() +} + +fn thread_priority_to_qos_class(priority: ThreadPriority) -> QoSClass { + imp::thread_priority_to_qos_class(priority) +} + +// All Apple platforms use XNU as their kernel +// and thus have the concept of QoS. +#[cfg(target_vendor = "apple")] +mod imp { + use super::ThreadPriority; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + // Please maintain order from least to most priority for the derived `Ord` impl. + pub(super) enum QoSClass { + // Documentation adapted from https://github.com/apple-oss-distributions/libpthread/blob/67e155c94093be9a204b69637d198eceff2c7c46/include/sys/qos.h#L55 + // + /// TLDR: invisible maintenance tasks + /// + /// Contract: + /// + /// * **You do not care about how long it takes for work to finish.** + /// * **You do not care about work being deferred temporarily.** + /// (e.g. if the device's battery is in a critical state) + /// + /// Examples: + /// + /// * in a video editor: + /// creating periodic backups of project files + /// * in a browser: + /// cleaning up cached sites which have not been accessed in a long time + /// * in a collaborative word processor: + /// creating a searchable index of all documents + /// + /// Use this QoS class for background tasks + /// which the user did not initiate themselves + /// and which are invisible to the user. + /// It is expected that this work will take significant time to complete: + /// minutes or even hours. + /// + /// This QoS class provides the most energy and thermally-efficient execution possible. + /// All other work is prioritized over background tasks. + Background, + + /// TLDR: tasks that don't block using your app + /// + /// Contract: + /// + /// * **Your app remains useful even as the task is executing.** + /// + /// Examples: + /// + /// * in a video editor: + /// exporting a video to disk - + /// the user can still work on the timeline + /// * in a browser: + /// automatically extracting a downloaded zip file - + /// the user can still switch tabs + /// * in a collaborative word processor: + /// downloading images embedded in a document - + /// the user can still make edits + /// + /// Use this QoS class for tasks which + /// may or may not be initiated by the user, + /// but whose result is visible. + /// It is expected that this work will take a few seconds to a few minutes. + /// Typically your app will include a progress bar + /// for tasks using this class. + /// + /// This QoS class provides a balance between + /// performance, responsiveness and efficiency. + Utility, + + /// TLDR: tasks that block using your app + /// + /// Contract: + /// + /// * **You need this work to complete + /// before the user can keep interacting with your app.** + /// * **Your work will not take more than a few seconds to complete.** + /// + /// Examples: + /// + /// * in a video editor: + /// opening a saved project + /// * in a browser: + /// loading a list of the user's bookmarks and top sites + /// when a new tab is created + /// * in a collaborative word processor: + /// running a search on the document's content + /// + /// Use this QoS class for tasks which were initiated by the user + /// and block the usage of your app while they are in progress. + /// It is expected that this work will take a few seconds or less to complete; + /// not long enough to cause the user to switch to something else. + /// Your app will likely indicate progress on these tasks + /// through the display of placeholder content or modals. + /// + /// This QoS class is not energy-efficient. + /// Rather, it provides responsiveness + /// by prioritizing work above other tasks on the system + /// except for critical user-interactive work. + UserInitiated, + + /// TLDR: render loops and nothing else + /// + /// Contract: + /// + /// * **You absolutely need this work to complete immediately + /// or your app will appear to freeze.** + /// * **Your work will always complete virtually instantaneously.** + /// + /// Examples: + /// + /// * the main thread in a GUI application + /// * the update & render loop in a game + /// * a secondary thread which progresses an animation + /// + /// Use this QoS class for any work which, if delayed, + /// will make your user interface unresponsive. + /// It is expected that this work will be virtually instantaneous. + /// + /// This QoS class is not energy-efficient. + /// Specifying this class is a request to run with + /// nearly all available system CPU and I/O bandwidth even under contention. + UserInteractive, + } + + pub(super) const IS_QOS_AVAILABLE: bool = true; + + pub(super) fn set_current_thread_qos_class(class: QoSClass) { + let c = match class { + QoSClass::UserInteractive => libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, + QoSClass::UserInitiated => libc::qos_class_t::QOS_CLASS_USER_INITIATED, + QoSClass::Utility => libc::qos_class_t::QOS_CLASS_UTILITY, + QoSClass::Background => libc::qos_class_t::QOS_CLASS_BACKGROUND, + }; + + #[allow(unsafe_code)] + let code = unsafe { libc::pthread_set_qos_class_self_np(c, 0) }; + + if code == 0 { + return; + } + + #[allow(unsafe_code)] + let errno = unsafe { *libc::__error() }; + + match errno { + libc::EPERM => { + // This thread has been excluded from the QoS system + // due to a previous call to a function such as `pthread_setschedparam` + // which is incompatible with QoS. + // + // Panic instead of returning an error + // to maintain the invariant that we only use QoS APIs. + panic!("tried to set QoS of thread which has opted out of QoS (os error {errno})") + } + + libc::EINVAL => { + // This is returned if we pass something other than a qos_class_t + // to `pthread_set_qos_class_self_np`. + // + // This is impossible, so again panic. + unreachable!( + "invalid qos_class_t value was passed to pthread_set_qos_class_self_np" + ) + } + + _ => { + // `pthread_set_qos_class_self_np`’s documentation + // does not mention any other errors. + unreachable!("`pthread_set_qos_class_self_np` returned unexpected error {errno}") + } + } + } + + pub(super) fn get_current_thread_qos_class() -> Option { + #[allow(unsafe_code)] + let current_thread = unsafe { libc::pthread_self() }; + let mut qos_class_raw = libc::qos_class_t::QOS_CLASS_UNSPECIFIED; + #[allow(unsafe_code)] + let code = unsafe { + libc::pthread_get_qos_class_np(current_thread, &mut qos_class_raw, std::ptr::null_mut()) + }; + + if code != 0 { + // `pthread_get_qos_class_np`’s documentation states that + // an error value is placed into errno if the return code is not zero. + // However, it never states what errors are possible. + // Inspecting the source[0] shows that, as of this writing, it always returns zero. + // + // Whatever errors the function could report in future are likely to be + // ones which we cannot handle anyway + // + // 0: https://github.com/apple-oss-distributions/libpthread/blob/67e155c94093be9a204b69637d198eceff2c7c46/src/qos.c#L171-L177 + #[allow(unsafe_code)] + let errno = unsafe { *libc::__error() }; + unreachable!("`pthread_get_qos_class_np` failed unexpectedly (os error {errno})"); + } + + match qos_class_raw { + libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE => Some(QoSClass::UserInteractive), + libc::qos_class_t::QOS_CLASS_USER_INITIATED => Some(QoSClass::UserInitiated), + libc::qos_class_t::QOS_CLASS_DEFAULT => None, // QoS has never been set + libc::qos_class_t::QOS_CLASS_UTILITY => Some(QoSClass::Utility), + libc::qos_class_t::QOS_CLASS_BACKGROUND => Some(QoSClass::Background), + + libc::qos_class_t::QOS_CLASS_UNSPECIFIED => { + // Using manual scheduling APIs causes threads to “opt out” of QoS. + // At this point they become incompatible with QoS, + // and as such have the “unspecified” QoS class. + // + // Panic instead of returning an error + // to maintain the invariant that we only use QoS APIs. + panic!("tried to get QoS of thread which has opted out of QoS") + } + } + } + + pub(super) fn thread_priority_to_qos_class(priority: ThreadPriority) -> QoSClass { + match priority { + ThreadPriority::Worker => QoSClass::Utility, + ThreadPriority::LatencySensitive => QoSClass::UserInitiated, + } + } +} + +// FIXME: Windows has QoS APIs, we should use them! +#[cfg(not(target_vendor = "apple"))] +mod imp { + use super::ThreadPriority; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + pub(super) enum QoSClass { + Default, + } + + pub(super) const IS_QOS_AVAILABLE: bool = false; + + pub(super) fn set_current_thread_qos_class(_: QoSClass) {} + + pub(super) fn get_current_thread_qos_class() -> Option { + None + } + + pub(super) fn thread_priority_to_qos_class(_: ThreadPriority) -> QoSClass { + QoSClass::Default + } +} diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs new file mode 100644 index 00000000..feca05c1 --- /dev/null +++ b/crates/server/src/session.rs @@ -0,0 +1,135 @@ +//! Data model, state management, and configuration resolution. + +use lsp_types::Url; +use lsp_types::WorkspaceFolder; + +use crate::document::{DocumentKey, DocumentVersion, PositionEncoding, TextDocument}; + +pub(crate) use self::capabilities::ResolvedClientCapabilities; +pub use self::index::DocumentQuery; + +mod capabilities; +mod index; +mod workspaces; + +/// The global state for the LSP +pub(crate) struct Session { + /// Used to retrieve information about open documents and settings. + index: index::Index, + /// The global position encoding, negotiated during LSP initialization. + position_encoding: PositionEncoding, + /// Tracks what LSP features the client supports and doesn't support. + resolved_client_capabilities: ResolvedClientCapabilities, +} + +/// An immutable snapshot of `Session` that references +/// a specific document. +pub(crate) struct DocumentSnapshot { + document_ref: index::DocumentQuery, + position_encoding: PositionEncoding, +} + +impl Session { + pub(crate) fn new( + resolved_client_capabilities: ResolvedClientCapabilities, + position_encoding: PositionEncoding, + workspace_folders: Vec, + ) -> anyhow::Result { + Ok(Self { + position_encoding, + index: index::Index::new(workspace_folders)?, + resolved_client_capabilities, + }) + } + + pub(crate) fn key_from_url(&self, url: Url) -> DocumentKey { + self.index.key_from_url(url) + } + + /// Creates a document snapshot with the URL referencing the document to snapshot. + pub(crate) fn take_snapshot(&self, url: Url) -> Option { + let key = self.key_from_url(url); + Some(DocumentSnapshot { + document_ref: self.index.make_document_ref(key)?, + position_encoding: self.position_encoding, + }) + } + + /// Iterates over the LSP URLs for all open text documents. These URLs are valid file paths. + #[allow(dead_code)] + pub(crate) fn text_document_urls(&self) -> impl Iterator + '_ { + self.index.text_document_urls() + } + + /// Updates a text document at the associated `key`. + /// + /// The document key must point to a text document, or this will throw an error. + pub(crate) fn update_text_document( + &mut self, + key: &DocumentKey, + content_changes: Vec, + new_version: DocumentVersion, + ) -> anyhow::Result<()> { + let encoding = self.encoding(); + + self.index + .update_text_document(key, content_changes, new_version, encoding) + } + + /// Registers a text document at the provided `url`. + /// If a document is already open here, it will be overwritten. + pub(crate) fn open_text_document(&mut self, url: Url, document: TextDocument) { + self.index.open_text_document(url, document); + } + + /// De-registers a document, specified by its key. + /// Calling this multiple times for the same document is a logic error. + pub(crate) fn close_document(&mut self, key: &DocumentKey) -> anyhow::Result<()> { + self.index.close_document(key)?; + Ok(()) + } + + /// Reloads the settings index + pub(crate) fn reload_settings(&mut self, changed_url: &Url) { + self.index.reload_settings(changed_url); + } + + /// Open a workspace folder at the given `url`. + pub(crate) fn open_workspace_folder(&mut self, url: &Url) { + self.index.open_workspace_folder(url) + } + + /// Close a workspace folder at the given `url`. + pub(crate) fn close_workspace_folder(&mut self, url: &Url) { + self.index.close_workspace_folder(url); + } + + #[allow(dead_code)] + pub(crate) fn num_documents(&self) -> usize { + self.index.num_documents() + } + + #[allow(dead_code)] + pub(crate) fn num_workspaces(&self) -> usize { + self.index.num_workspaces() + } + + #[allow(dead_code)] + pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities { + &self.resolved_client_capabilities + } + + pub(crate) fn encoding(&self) -> PositionEncoding { + self.position_encoding + } +} + +impl DocumentSnapshot { + pub fn query(&self) -> &index::DocumentQuery { + &self.document_ref + } + + pub(crate) fn encoding(&self) -> PositionEncoding { + self.position_encoding + } +} diff --git a/crates/server/src/session/capabilities.rs b/crates/server/src/session/capabilities.rs new file mode 100644 index 00000000..5b2309a2 --- /dev/null +++ b/crates/server/src/session/capabilities.rs @@ -0,0 +1,41 @@ +use lsp_types::ClientCapabilities; +use lsp_types::PositionEncodingKind; + +/// A resolved representation of the [ClientCapabilities] the Client sends over that we +/// actually do something with +#[derive(Debug, Default, Clone)] +pub(crate) struct ResolvedClientCapabilities { + pub(crate) position_encodings: Vec, + #[allow(dead_code)] + pub(crate) dynamic_registration_for_did_change_configuration: bool, + pub(crate) dynamic_registration_for_did_change_watched_files: bool, +} + +impl ResolvedClientCapabilities { + pub(crate) fn new(capabilities: ClientCapabilities) -> Self { + let position_encodings = capabilities + .general + .and_then(|general_client_capabilities| general_client_capabilities.position_encodings) + .unwrap_or(vec![PositionEncodingKind::UTF16]); + + let dynamic_registration_for_did_change_configuration = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_configuration) + .and_then(|did_change_configuration| did_change_configuration.dynamic_registration) + .unwrap_or(false); + + let dynamic_registration_for_did_change_watched_files = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files) + .and_then(|watched_files| watched_files.dynamic_registration) + .unwrap_or_default(); + + Self { + position_encodings, + dynamic_registration_for_did_change_configuration, + dynamic_registration_for_did_change_watched_files, + } + } +} diff --git a/crates/server/src/session/index.rs b/crates/server/src/session/index.rs new file mode 100644 index 00000000..d3e25ade --- /dev/null +++ b/crates/server/src/session/index.rs @@ -0,0 +1,256 @@ +use std::borrow::Cow; +use std::path::PathBuf; +use std::{path::Path, sync::Arc}; + +use lsp_types::Url; +use lsp_types::WorkspaceFolder; +use rustc_hash::FxHashMap; + +use workspace::settings::Settings; + +use crate::document::{DocumentKey, DocumentVersion, PositionEncoding, TextDocument}; +use crate::session::workspaces::WorkspaceSettingsResolver; + +/// Stores and tracks all open documents in a session, along with their associated settings. +#[derive(Default)] +pub(crate) struct Index { + /// Maps all document file URLs to the associated document controller + documents: FxHashMap, + + /// Maps a workspace folder root to its settings. + settings: WorkspaceSettingsResolver, +} + +/// A mutable handler to an underlying document. +#[derive(Debug)] +enum DocumentController { + Text(Arc), +} + +/// A read-only query to an open document. +/// This query can 'select' a text document, but eventually could gain support for +/// selecting notebooks or individual notebook cells. +/// It also includes document settings. +#[derive(Clone)] +pub enum DocumentQuery { + Text { + file_url: Url, + document: Arc, + settings: Arc, + }, +} + +impl Index { + pub(super) fn new(workspace_folders: Vec) -> anyhow::Result { + Ok(Self { + documents: FxHashMap::default(), + settings: WorkspaceSettingsResolver::from_workspace_folders(workspace_folders), + }) + } + + pub(super) fn text_document_urls(&self) -> impl Iterator + '_ { + self.documents + .iter() + .filter(|(_, doc)| doc.as_text().is_some()) + .map(|(url, _)| url) + } + + pub(super) fn update_text_document( + &mut self, + key: &DocumentKey, + content_changes: Vec, + new_version: DocumentVersion, + encoding: PositionEncoding, + ) -> anyhow::Result<()> { + let controller = self.document_controller_for_key(key)?; + let Some(document) = controller.as_text_mut() else { + anyhow::bail!("Text document URI does not point to a text document"); + }; + document.apply_changes(content_changes, new_version, encoding); + Ok(()) + } + + pub(super) fn key_from_url(&self, url: Url) -> DocumentKey { + DocumentKey::Text(url) + } + + pub(super) fn open_workspace_folder(&mut self, url: &Url) { + self.settings.open_workspace_folder(url) + } + + pub(super) fn close_workspace_folder(&mut self, url: &Url) { + self.settings.close_workspace_folder(url) + } + + pub(super) fn num_documents(&self) -> usize { + self.documents.len() + } + + pub(super) fn num_workspaces(&self) -> usize { + self.settings.len() + } + + pub(super) fn make_document_ref(&self, key: DocumentKey) -> Option { + let url = self.url_for_key(&key)?.clone(); + + let settings = self.settings_for_url(&url); + + let controller = self.documents.get(&url)?; + Some(controller.make_ref(url, settings)) + } + + /// Reloads relevant existing settings files based on a changed settings file path. + pub(super) fn reload_settings(&mut self, changed_url: &Url) { + self.settings.reload_workspaces_matched_by_url(changed_url); + } + + pub(super) fn open_text_document(&mut self, url: Url, document: TextDocument) { + self.documents + .insert(url, DocumentController::new_text(document)); + } + + pub(super) fn close_document(&mut self, key: &DocumentKey) -> anyhow::Result<()> { + let Some(url) = self.url_for_key(key).cloned() else { + anyhow::bail!("Tried to close unavailable document `{key}`"); + }; + + let Some(_) = self.documents.remove(&url) else { + anyhow::bail!("tried to close document that didn't exist at {}", url) + }; + Ok(()) + } + + // TODO: Index should manage per workspace client settings at some point once we have some + // pub(super) fn client_settings( + // &self, + // key: &DocumentKey, + // global_settings: &ClientSettings, + // ) -> ResolvedClientSettings { + // let Some(url) = self.url_for_key(key) else { + // return ResolvedClientSettings::global(global_settings); + // }; + // let Some(WorkspaceSettings { + // client_settings, .. + // }) = self.settings_for_url(url) + // else { + // return ResolvedClientSettings::global(global_settings); + // }; + // client_settings.clone() + // } + + fn document_controller_for_key( + &mut self, + key: &DocumentKey, + ) -> anyhow::Result<&mut DocumentController> { + let Some(url) = self.url_for_key(key).cloned() else { + anyhow::bail!("Tried to open unavailable document `{key}`"); + }; + let Some(controller) = self.documents.get_mut(&url) else { + anyhow::bail!("Document controller not available at `{}`", url); + }; + Ok(controller) + } + + fn url_for_key<'a>(&'a self, key: &'a DocumentKey) -> Option<&'a Url> { + match key { + DocumentKey::Text(path) => Some(path), + } + } + + fn settings_for_url(&self, url: &Url) -> Arc { + self.settings.settings_for_url(url) + } +} + +impl DocumentController { + fn new_text(document: TextDocument) -> Self { + Self::Text(Arc::new(document)) + } + + fn make_ref(&self, file_url: Url, settings: Arc) -> DocumentQuery { + match &self { + Self::Text(document) => DocumentQuery::Text { + file_url, + document: document.clone(), + settings, + }, + } + } + + pub(crate) fn as_text(&self) -> Option<&TextDocument> { + match self { + Self::Text(document) => Some(document), + } + } + + /// Request mutable access to the [`TextDocument`] managed by this controller + /// + /// [`Arc::make_mut`] is the key to this: + /// - If `document` is not being referenced by anyone else, this just returns the + /// `document` directly. + /// - If `document` is also referenced by someone else, this clones the `document` + /// that lives inside the `Arc` and updates the `Arc` in place, then provides access + /// to that. This allows any background tasks to continue working with their version + /// of the document, while we are free to modify the "source of truth" document. + /// + /// This effectively implements "copy on modify" semantics for `TextDocument`. + pub(crate) fn as_text_mut(&mut self) -> Option<&mut TextDocument> { + Some(match self { + Self::Text(document) => Arc::make_mut(document), + }) + } +} + +impl DocumentQuery { + /// Get the document settings associated with this query. + pub(crate) fn settings(&self) -> &Settings { + // Note that `&Arc` nicely derefs to `&Settings` here automatically + match self { + Self::Text { settings, .. } => settings, + } + } + + /// Get the version of document selected by this query. + #[allow(dead_code)] + pub(crate) fn version(&self) -> DocumentVersion { + match self { + Self::Text { document, .. } => document.version(), + } + } + + /// Get the URL for the document selected by this query. + pub(crate) fn file_url(&self) -> &Url { + match self { + Self::Text { file_url, .. } => file_url, + } + } + + /// Get the path for the document selected by this query. + /// + /// Returns `None` if this is an unsaved (untitled) document. + /// + /// The path isn't guaranteed to point to a real path on the filesystem. This is the case + /// for unsaved (untitled) documents. + #[allow(dead_code)] + pub(crate) fn file_path(&self) -> Option { + self.file_url().to_file_path().ok() + } + + /// Get the path for the document selected by this query, ignoring whether the file exists on disk. + /// + /// Returns the URL's path if this is an unsaved (untitled) document. + #[allow(dead_code)] + pub(crate) fn virtual_file_path(&self) -> Cow { + self.file_path().map_or_else( + || Cow::Borrowed(Path::new(self.file_url().path())), + Cow::Owned, + ) + } + + /// Attempt to access the single inner text document selected by the query. + pub(crate) fn as_single_document(&self) -> &TextDocument { + match self { + Self::Text { document, .. } => document, + } + } +} diff --git a/crates/server/src/session/workspaces.rs b/crates/server/src/session/workspaces.rs new file mode 100644 index 00000000..010573f3 --- /dev/null +++ b/crates/server/src/session/workspaces.rs @@ -0,0 +1,219 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use lsp_types::Url; +use lsp_types::WorkspaceFolder; +use workspace::discovery::discover_settings; +use workspace::discovery::DiscoveredSettings; +use workspace::resolve::PathResolver; +use workspace::settings::Settings; + +/// Convenience type for the inner resolver of path -> [`Settings`] +/// +/// We store [`Settings`] in an [`Arc`] so we can easily share them across threads in a +/// `DocumentQuery` without needing to clone. +type SettingsResolver = PathResolver>; + +/// Resolver for retrieving [`Settings`] associated with a workspace specific [`Path`] +#[derive(Debug, Default)] +pub(crate) struct WorkspaceSettingsResolver { + /// Resolves a `path` to the closest workspace specific `SettingsResolver`. + /// That `SettingsResolver` can then return `Settings` for the `path`. + path_to_settings_resolver: PathResolver, +} + +impl WorkspaceSettingsResolver { + /// Construct a new workspace settings resolver from an initial set of workspace folders + pub(crate) fn from_workspace_folders(workspace_folders: Vec) -> Self { + // How to do better here? + let fallback = Arc::new(Settings::default()); + + let settings_resolver_fallback = SettingsResolver::new(fallback); + let path_to_settings_resolver = PathResolver::new(settings_resolver_fallback); + + let mut resolver = Self { + path_to_settings_resolver, + }; + + // Add each workspace folder's settings into the resolver. + for workspace_folder in workspace_folders { + resolver.open_workspace_folder(&workspace_folder.uri) + } + + resolver + } + + /// Open a workspace folder + /// + /// If we fail for any reason (i.e. parse failure of an `air.toml`), we handle the + /// failure internally. This allows us to: + /// - Avoid preventing the server from starting up at all (which would happen if we + /// propagated an error up) + /// - Control the toast notification sent to the user + pub(crate) fn open_workspace_folder(&mut self, url: &Url) { + let failed_to_open_workspace_folder = |url, error| { + show_err_msg!( + "Failed to open workspace folder for '{url}'. Check the logs for more information." + ); + tracing::error!("Failed to open workspace folder for '{url}':\n{error}"); + }; + + let path = match Self::url_to_path(url) { + Ok(Some(path)) => path, + Ok(None) => { + tracing::warn!("Ignoring non-file workspace URL '{url}'"); + return; + } + Err(error) => { + failed_to_open_workspace_folder(url, error); + return; + } + }; + + let discovered_settings = match discover_settings(&[&path]) { + Ok(discovered_settings) => discovered_settings, + Err(error) => { + failed_to_open_workspace_folder(url, error.into()); + return; + } + }; + + // How to do better here? + let fallback = Arc::new(Settings::default()); + + let mut settings_resolver = SettingsResolver::new(fallback); + + for DiscoveredSettings { + directory, + settings, + } in discovered_settings + { + settings_resolver.add(&directory, Arc::new(settings)); + } + + tracing::trace!("Adding workspace settings: {}", path.display()); + self.path_to_settings_resolver.add(&path, settings_resolver); + } + + pub(crate) fn close_workspace_folder(&mut self, url: &Url) { + match Self::url_to_path(url) { + Ok(Some(path)) => { + tracing::trace!("Removing workspace settings: {}", path.display()); + self.path_to_settings_resolver.remove(&path); + } + Ok(None) => { + tracing::warn!("Ignoring non-file workspace URL: {url}"); + } + Err(error) => { + show_err_msg!( + "Failed to close workspace folder for '{url}'. Check the logs for more information." + ); + tracing::error!("Failed to close workspace folder for '{url}':\n{error}"); + } + } + } + + pub(crate) fn len(&self) -> usize { + self.path_to_settings_resolver.len() + } + + /// Return the appropriate [`Settings`] for a given document [`Url`]. + pub(crate) fn settings_for_url(&self, url: &Url) -> Arc { + if let Ok(Some(path)) = Self::url_to_path(url) { + return self.settings_for_path(&path); + } + + // For `untitled` schemes, we have special behavior. + // If there is exactly 1 workspace, we resolve using a path of + // `{workspace_path}/untitled` to provide relevant settings for this workspace. + if url.scheme() == "untitled" && self.path_to_settings_resolver.len() == 1 { + tracing::trace!("Using workspace settings for 'untitled' URL: {url}"); + let workspace_path = self.path_to_settings_resolver.keys().next().unwrap(); + let path = workspace_path.join("untitled"); + return self.settings_for_path(&path); + } + + tracing::trace!("Using default settings for non-file URL: {url}"); + self.path_to_settings_resolver.fallback().fallback().clone() + } + + /// Reloads all workspaces matched by the [`Url`] + /// + /// This is utilized by the watched files handler to reload the settings + /// resolver whenever an `air.toml` is modified. + pub(crate) fn reload_workspaces_matched_by_url(&mut self, url: &Url) { + let path = match Self::url_to_path(url) { + Ok(Some(path)) => path, + Ok(None) => { + tracing::trace!("Ignoring non-`file` changed URL: {url}"); + return; + } + Err(error) => { + show_err_msg!( + "Failed to reload workspaces associated with '{url}'. Check the logs for more information." + ); + tracing::error!("Failed to reload workspaces associated with '{url}':\n{error}"); + return; + } + }; + + if !path.ends_with("air.toml") { + // We could get called with a changed file that isn't an `air.toml` if we are + // watching more than `air.toml` files + tracing::trace!("Ignoring non-`air.toml` changed URL: {url}"); + return; + } + + for (workspace_path, settings_resolver) in self.path_to_settings_resolver.matches_mut(&path) + { + tracing::trace!("Reloading workspace settings: {}", workspace_path.display()); + + settings_resolver.clear(); + + let discovered_settings = match discover_settings(&[workspace_path]) { + Ok(discovered_settings) => discovered_settings, + Err(error) => { + let workspace_path = workspace_path.display(); + show_err_msg!( + "Failed to reload workspace for '{workspace_path}'. Check the logs for more information." + ); + tracing::error!("Failed to reload workspace for '{workspace_path}':\n{error}"); + continue; + } + }; + + for DiscoveredSettings { + directory, + settings, + } in discovered_settings + { + settings_resolver.add(&directory, Arc::new(settings)); + } + } + } + + /// Return the appropriate [`Settings`] for a given [`Path`]. + /// + /// This actually performs a double resolution. It first resolves to the + /// workspace specific `SettingsResolver` that matches this path, and then uses that + /// resolver to actually resolve the `Settings` for this path. We do it this way + /// to ensure we can easily add and remove workspaces (including all of their + /// hierarchical paths). + fn settings_for_path(&self, path: &Path) -> Arc { + let settings_resolver = self.path_to_settings_resolver.resolve_or_fallback(path); + settings_resolver.resolve_or_fallback(path).clone() + } + + fn url_to_path(url: &Url) -> anyhow::Result> { + if url.scheme() != "file" { + return Ok(None); + } + + let path = url + .to_file_path() + .map_err(|()| anyhow::anyhow!("Failed to convert workspace URL to file path: {url}"))?; + + Ok(Some(path)) + } +} diff --git a/crates/server/src/snapshots/server__crates__tests__crate_names.snap b/crates/server/src/snapshots/server__crates__tests__crate_names.snap new file mode 100644 index 00000000..42180ad5 --- /dev/null +++ b/crates/server/src/snapshots/server__crates__tests__crate_names.snap @@ -0,0 +1,21 @@ +--- +source: crates/server/src/crates.rs +expression: AIR_CRATE_NAMES +--- +[ + "air", + "air_formatter_test", + "air_r_factory", + "air_r_formatter", + "air_r_parser", + "air_r_syntax", + "biome_ungrammar", + "fs", + "server", + "server_test", + "source_file", + "tests_macros", + "workspace", + "xtask", + "xtask_codegen", +] diff --git a/crates/server/src/test.rs b/crates/server/src/test.rs new file mode 100644 index 00000000..f2a9279e --- /dev/null +++ b/crates/server/src/test.rs @@ -0,0 +1,7 @@ +mod client; +mod client_ext; +mod utils; + +pub(crate) use client::with_client; +pub(crate) use client_ext::TestClientExt; +pub(crate) use utils::extract_marked_range; diff --git a/crates/server/src/test/client.rs b/crates/server/src/test/client.rs new file mode 100644 index 00000000..a965634d --- /dev/null +++ b/crates/server/src/test/client.rs @@ -0,0 +1,48 @@ +use std::sync::LazyLock; +use std::sync::Mutex; + +use server_test::TestClient; + +use crate::Server; + +/// Global test client used by all unit tests +/// +/// The language [Server] has per-process global state, such as the global `tracing` +/// subscriber and a global `MESSENGER` to send `ShowMessage` notifications to the client. +/// Because of this, we cannot just repeatedly call [test_client()] to start up a new +/// client/server pair per unit test. Instead, unit tests use [with_client()] to access +/// the global test client, which they can then manipulate. Synchronization is managed +/// through a [Mutex], ensuring that multiple unit tests that need to mutate the client +/// can't run simultaneously (while still allowing other unit tests to run in parallel). +/// Unit tests should be careful not to put the client/server pair into a state that +/// prevents other unit tests from running successfully. +/// +/// If you need to modify a client/server pair in such a way that no other unit tests will +/// be able to use it, create an integration test instead, which runs in its own process. +static TEST_CLIENT: LazyLock> = LazyLock::new(|| Mutex::new(test_client())); + +pub(crate) fn with_client(f: F) +where + F: FnOnce(&mut server_test::TestClient), +{ + let mut client = TEST_CLIENT.lock().unwrap(); + f(&mut client) +} + +fn test_client() -> server_test::TestClient { + let mut client = + server_test::TestClient::new(|worker_threads, connection, connection_threads| { + let server = Server::new(worker_threads, connection, connection_threads).unwrap(); + server.run().unwrap(); + }); + + // Initialize and wait for the server response + let id = client.initialize(); + let response = client.recv_response(); + assert_eq!(id, response.id); + + // Notify the server we have received its initialize response + client.initialized(); + + client +} diff --git a/crates/server/src/test/client_ext.rs b/crates/server/src/test/client_ext.rs new file mode 100644 index 00000000..36a1f91a --- /dev/null +++ b/crates/server/src/test/client_ext.rs @@ -0,0 +1,151 @@ +use biome_text_size::TextRange; +use server_test::TestClient; + +use crate::document::PositionEncoding; +use crate::document::TextDocument; +use crate::proto::TextRangeExt; + +pub(crate) trait TestClientExt { + fn open_document(&mut self, doc: &TextDocument) -> lsp_types::TextDocumentItem; + + fn format_document(&mut self, doc: &TextDocument) -> String; + fn format_document_range(&mut self, doc: &TextDocument, range: TextRange) -> String; + fn format_document_edits(&mut self, doc: &TextDocument) -> Option>; + fn format_document_range_edits( + &mut self, + doc: &TextDocument, + range: TextRange, + ) -> Option>; + + fn position_encoding(&self) -> PositionEncoding; +} + +impl TestClientExt for TestClient { + fn open_document(&mut self, doc: &TextDocument) -> lsp_types::TextDocumentItem { + let path = format!("test://{}", uuid::Uuid::new_v4()); + let uri = url::Url::parse(&path).unwrap(); + + let text_document = lsp_types::TextDocumentItem { + uri, + language_id: String::from("r"), + version: 0, + text: doc.contents().to_string(), + }; + + let params = lsp_types::DidOpenTextDocumentParams { + text_document: text_document.clone(), + }; + self.did_open_text_document(params); + + text_document + } + + fn format_document(&mut self, doc: &TextDocument) -> String { + let edits = self.format_document_edits(doc).unwrap(); + apply_text_edits(edits, doc, self.position_encoding()).unwrap() + } + + fn format_document_range(&mut self, doc: &TextDocument, range: TextRange) -> String { + let Some(edits) = self.format_document_range_edits(doc, range) else { + return doc.contents().to_string(); + }; + apply_text_edits(edits, doc, self.position_encoding()).unwrap() + } + + fn format_document_edits(&mut self, doc: &TextDocument) -> Option> { + let lsp_doc = self.open_document(doc); + + let options = lsp_types::FormattingOptions { + tab_size: 4, + insert_spaces: false, + ..Default::default() + }; + + self.formatting(lsp_types::DocumentFormattingParams { + text_document: lsp_types::TextDocumentIdentifier { + uri: lsp_doc.uri.clone(), + }, + options, + work_done_progress_params: Default::default(), + }); + + let response = self.recv_response(); + + if let Some(err) = response.error { + panic!("Unexpected error: {}", err.message); + }; + + let value: Option> = + serde_json::from_value(response.result.unwrap().clone()).unwrap(); + + self.close_document(lsp_doc.uri); + + value + } + + fn format_document_range_edits( + &mut self, + doc: &TextDocument, + range: TextRange, + ) -> Option> { + let lsp_doc = self.open_document(doc); + + let options = lsp_types::FormattingOptions { + tab_size: 4, + insert_spaces: false, + ..Default::default() + }; + + let range = range.into_proto(doc.source_file(), self.position_encoding()); + + self.range_formatting(lsp_types::DocumentRangeFormattingParams { + text_document: lsp_types::TextDocumentIdentifier { + uri: lsp_doc.uri.clone(), + }, + range, + options, + work_done_progress_params: Default::default(), + }); + + let response = self.recv_response(); + + if let Some(err) = response.error { + panic!("Unexpected error: {}", err.message); + }; + + let value: Option> = + serde_json::from_value(response.result.unwrap().clone()).unwrap(); + + self.close_document(lsp_doc.uri); + + value + } + + fn position_encoding(&self) -> PositionEncoding { + self.encoding().try_into().unwrap() + } +} + +fn apply_text_edits( + mut edits: Vec, + doc: &crate::document::TextDocument, + encoding: crate::document::PositionEncoding, +) -> anyhow::Result { + use std::ops::Range; + + let mut new_text = doc.contents().to_string(); + + let source = doc.source_file(); + + // Apply edits from bottom to top to avoid inserted newlines to invalidate + // positions in earlier parts of the doc (they are sent in reading order + // accorder to the LSP protocol) + edits.reverse(); + + for edit in edits { + let range: Range = TextRange::from_proto(edit.range, source, encoding).into(); + new_text.replace_range(range, &edit.new_text); + } + + Ok(new_text) +} diff --git a/crates/lsp/src/test_utils.rs b/crates/server/src/test/utils.rs similarity index 100% rename from crates/lsp/src/test_utils.rs rename to crates/server/src/test/utils.rs diff --git a/crates/server/tests/initialization.rs b/crates/server/tests/initialization.rs new file mode 100644 index 00000000..a4a14b3f --- /dev/null +++ b/crates/server/tests/initialization.rs @@ -0,0 +1,63 @@ +use assert_matches::assert_matches; +use lsp_types::PositionEncodingKind; +use lsp_types::ServerCapabilities; +use lsp_types::ServerInfo; +use lsp_types::TextDocumentSyncCapability; +use lsp_types::TextDocumentSyncKind; +use lsp_types::TextDocumentSyncOptions; + +// Normal usage of `with_client()` handles client initialization, so to test it we have +// to run this particular test in its own process and manually start up and initialize +// the client. This also gives us a chance to test the shutdown/exit procedure. + +#[test] +fn test_initialization_and_shutdown() { + let mut client = + server_test::TestClient::new(|worker_threads, connection, connection_threads| { + let server = + server::Server::new(worker_threads, connection, connection_threads).unwrap(); + server.run().unwrap(); + }); + + let id = client.initialize(); + + let value = client.recv_response(); + assert_eq!(id, value.id); + let value: lsp_types::InitializeResult = + serde_json::from_value(value.result.unwrap().clone()).unwrap(); + + client.initialized(); + + assert_matches!( + value, + lsp_types::InitializeResult { + capabilities, + server_info + } => { + assert_matches!(capabilities, ServerCapabilities { + position_encoding, + text_document_sync, + .. + } => { + assert_eq!(position_encoding, Some(PositionEncodingKind::UTF8)); + assert_eq!(text_document_sync, Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + will_save: Some(false), + will_save_wait_until: Some(false), + ..Default::default() + }, + ))); + }); + + assert_matches!(server_info, Some(ServerInfo { name, version }) => { + assert!(name.contains("Air Language Server")); + assert!(version.is_some()); + }); + } + ); + + client.shutdown(); + client.exit(); +} diff --git a/crates/line_ending/Cargo.toml b/crates/server_test/Cargo.toml similarity index 67% rename from crates/line_ending/Cargo.toml rename to crates/server_test/Cargo.toml index b29eeb6e..ed524bd4 100644 --- a/crates/line_ending/Cargo.toml +++ b/crates/server_test/Cargo.toml @@ -1,6 +1,5 @@ [package] -name = "line_ending" -description = "Utilities for normalizing line endings" +name = "server_test" version = "0.0.0" publish = false authors.workspace = true @@ -10,11 +9,12 @@ homepage.workspace = true keywords.workspace = true license.workspace = true repository.workspace = true +rust-version.workspace = true [dependencies] -memchr.workspace = true - -[dev-dependencies] +lsp-types.workspace = true +lsp-server.workspace = true +url.workspace = true [lints] workspace = true diff --git a/crates/server_test/src/lib.rs b/crates/server_test/src/lib.rs new file mode 100644 index 00000000..988e382f --- /dev/null +++ b/crates/server_test/src/lib.rs @@ -0,0 +1,4 @@ +mod lsp_client; + +pub use lsp_client::TestClient; +pub use lsp_client::TEST_CLIENT_NAME; diff --git a/crates/server_test/src/lsp_client.rs b/crates/server_test/src/lsp_client.rs new file mode 100644 index 00000000..be8a3044 --- /dev/null +++ b/crates/server_test/src/lsp_client.rs @@ -0,0 +1,197 @@ +// +// lsp_client.rs +// +// Copyright (C) 2024 Posit Software, PBC. All rights reserved. +// +// + +use lsp_server::Connection; +use lsp_server::RequestId; +use lsp_types::ClientInfo; +use lsp_types::GeneralClientCapabilities; +use lsp_types::PositionEncodingKind; +use std::num::NonZeroUsize; +use std::thread::JoinHandle; + +pub const TEST_CLIENT_NAME: &str = "AirTestClient"; + +pub struct TestClient { + client: Connection, + server_handle: Option>, + request_id: i32, + encoding: PositionEncodingKind, + init_params: Option, +} + +impl TestClient { + pub fn new(start_server: F) -> Self + where + F: FnOnce(NonZeroUsize, lsp_server::Connection, Option) + + Send + + 'static, + { + let worker_threads = NonZeroUsize::new(4).unwrap(); + let (server, client) = lsp_server::Connection::memory(); + + let server_handle = std::thread::spawn(move || { + start_server(worker_threads, server, None); + }); + + Self { + client, + server_handle: Some(server_handle), + request_id: 0, + encoding: PositionEncodingKind::UTF8, + init_params: None, + } + } + + pub fn encoding(&self) -> &PositionEncodingKind { + &self.encoding + } + + fn id(&mut self) -> RequestId { + let id = self.request_id; + self.request_id = id + 1; + RequestId::from(id) + } + + pub fn recv_response(&mut self) -> lsp_server::Response { + // Unwrap: Result (Err if stream closed) + let message = self.client.receiver.recv().unwrap(); + + match message { + lsp_server::Message::Request(request) => panic!("Expected response, got {request:?}"), + lsp_server::Message::Response(response) => response, + lsp_server::Message::Notification(notification) => { + panic!("Expected response, got {notification:?}") + } + } + } + + pub fn notify(&mut self, params: N::Params) + where + N: lsp_types::notification::Notification, + { + let method = N::METHOD.to_string(); + let notification = lsp_server::Notification::new(method, params); + let message = lsp_server::Message::Notification(notification); + + // Unwrap: For this test client it's fine to panic if we can't send + self.client.sender.send(message).unwrap() + } + + pub fn request(&mut self, params: R::Params) -> RequestId + where + R: lsp_types::request::Request, + { + let id = self.id(); + let method = R::METHOD.to_string(); + let request = lsp_server::Request::new(id.clone(), method, params); + let message = lsp_server::Message::Request(request); + + // Unwrap: For this test client it's fine to panic if we can't send + self.client.sender.send(message).unwrap(); + + id + } + + pub fn initialize(&mut self) -> RequestId { + let params: Option = std::mem::take(&mut self.init_params); + let params = params.unwrap_or_default(); + let params = Self::with_client_info(params); + let params = Self::with_utf8(params); + self.request::(params) + } + + // Regardless of how we got the params, ensure the client name is set to + // `AirTestClient` so we can recognize it when we set up global logging. + fn with_client_info( + mut init_params: lsp_types::InitializeParams, + ) -> lsp_types::InitializeParams { + init_params.client_info = Some(ClientInfo { + name: String::from(TEST_CLIENT_NAME), + version: None, + }); + init_params + } + + // Regardless of how we got the params, ensure we use UTF-8 encodings + fn with_utf8(mut init_params: lsp_types::InitializeParams) -> lsp_types::InitializeParams { + init_params.capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![ + PositionEncodingKind::UTF8, + PositionEncodingKind::UTF16, + ]), + ..Default::default() + }); + init_params + } + + pub fn initialized(&mut self) { + let params = lsp_types::InitializedParams {}; + self.notify::(params) + } + + pub fn with_initialize_params(&mut self, init_params: lsp_types::InitializeParams) { + self.init_params = Some(init_params); + } + + pub fn close_document(&mut self, uri: url::Url) { + let params = lsp_types::DidCloseTextDocumentParams { + text_document: lsp_types::TextDocumentIdentifier { uri }, + }; + self.did_close_text_document(params) + } + + pub fn shutdown(&mut self) { + self.check_no_incoming(); + let id = self.request::(()); + assert_eq!(id, self.recv_response().id); + } + + fn check_no_incoming(&self) { + let mut messages = Vec::new(); + + while let Ok(message) = self.client.receiver.try_recv() { + messages.push(message); + } + + if !messages.is_empty() { + panic!("Must handle all messages before shutdown. Found the following unhandled incoming messages:\n{messages:?}"); + } + } + + pub fn exit(&mut self) { + // Unwrap: Can only exit once + let server_handle = + std::mem::take(&mut self.server_handle).expect("`exit()` can only be called once"); + + self.notify::(()); + + // Now wait for the server task to complete. + // Unwrap: Panics if task can't shut down as expected + server_handle + .join() + .expect("Couldn't join on the server thread."); + } + + pub fn did_open_text_document(&mut self, params: lsp_types::DidOpenTextDocumentParams) { + self.notify::(params) + } + + pub fn did_close_text_document(&mut self, params: lsp_types::DidCloseTextDocumentParams) { + self.notify::(params) + } + + pub fn formatting(&mut self, params: lsp_types::DocumentFormattingParams) -> RequestId { + self.request::(params) + } + + pub fn range_formatting( + &mut self, + params: lsp_types::DocumentRangeFormattingParams, + ) -> RequestId { + self.request::(params) + } +} diff --git a/crates/source_file/Cargo.toml b/crates/source_file/Cargo.toml new file mode 100644 index 00000000..13c1d320 --- /dev/null +++ b/crates/source_file/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "source_file" +version = "0.0.0" +publish = false +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[dependencies] +biome_text_size = { workspace = true } +memchr = { workspace = true } + +[lints] +workspace = true diff --git a/crates/source_file/src/lib.rs b/crates/source_file/src/lib.rs new file mode 100644 index 00000000..41bbf6de --- /dev/null +++ b/crates/source_file/src/lib.rs @@ -0,0 +1,19 @@ +//! Tools for managing a single source file +//! +//! In particular, [SourceFile] manages the conversions between UTF-8 byte offsets into a +//! [String], and line number + line offset (also known as row/column or row/character) +//! backed [SourceLocation]s, where the line offset is measured in UTF code units and is +//! dependent on the [LineOffsetEncoding] used. [SourceLocation]s are meant to easily map +//! to LSP `Position`s, and can handle the common `PositionEncodingKind`s of UTF-8, +//! UTF-16, and UTF-32. + +pub use crate::newlines::{find_newline, infer_line_ending, normalize_newlines, LineEnding}; +pub use crate::source_file::SourceFile; +pub use crate::source_location::LineNumber; +pub use crate::source_location::LineOffset; +pub use crate::source_location::LineOffsetEncoding; +pub use crate::source_location::SourceLocation; + +mod newlines; +mod source_file; +mod source_location; diff --git a/crates/source_file/src/newlines.rs b/crates/source_file/src/newlines.rs new file mode 100644 index 00000000..23de2e98 --- /dev/null +++ b/crates/source_file/src/newlines.rs @@ -0,0 +1,189 @@ +use std::sync::LazyLock; + +use biome_text_size::TextSize; +use memchr::memchr2; +use memchr::memmem; + +static CR_FINDER: LazyLock = LazyLock::new(|| memmem::Finder::new(b"\r")); +static CRLF_FINDER: LazyLock = LazyLock::new(|| memmem::Finder::new(b"\r\n")); + +/// Line ending styles +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum LineEnding { + Lf, + Cr, + Crlf, +} + +impl LineEnding { + pub const fn as_str(&self) -> &'static str { + match self { + LineEnding::Lf => "\n", + LineEnding::Crlf => "\r\n", + LineEnding::Cr => "\r", + } + } + + #[allow(clippy::len_without_is_empty)] + pub const fn len(&self) -> usize { + match self { + LineEnding::Lf | LineEnding::Cr => 1, + LineEnding::Crlf => 2, + } + } + + pub fn text_len(&self) -> TextSize { + match self { + LineEnding::Lf | LineEnding::Cr => TextSize::from(1), + LineEnding::Crlf => TextSize::from(2), + } + } +} + +/// Infers the line endings of a document. Defaults to [LineEnding::Lf] when no line +/// endings are detected. +/// +/// If you need the position of the first newline detected, use [find_newline]. +#[inline] +pub fn infer_line_ending(text: &str) -> LineEnding { + match find_newline(text) { + Some((_position, ending)) => ending, + None => LineEnding::Lf, + } +} + +/// Finds the next newline character. Returns its position and the [`LineEnding`]. +#[inline] +pub fn find_newline(text: &str) -> Option<(usize, LineEnding)> { + let bytes = text.as_bytes(); + if let Some(position) = memchr2(b'\n', b'\r', bytes) { + let line_ending = match bytes[position] { + // Explicit branch for `\n` as this is the most likely path + b'\n' => LineEnding::Lf, + // '\r\n' + b'\r' if bytes.get(position.saturating_add(1)) == Some(&b'\n') => LineEnding::Crlf, + // '\r' + _ => LineEnding::Cr, + }; + + Some((position, line_ending)) + } else { + None + } +} + +/// Normalize line endings within a string +/// +/// We replace `\r\n` with `\n` in-place, which doesn't break utf-8 encoding. +/// While we *can* call `as_mut_vec` and do surgery on the live string +/// directly, let's rather steal the contents of `x`. This makes the code +/// safe even if a panic occurs. +/// +/// # Source +/// +/// --- +/// authors = ["rust-analyzer team"] +/// license = "MIT OR Apache-2.0" +/// origin = "https://github.com/rust-lang/rust-analyzer/blob/master/crates/rust-analyzer/src/line_index.rs" +/// --- +#[inline] +pub fn normalize_newlines(text: String) -> (String, LineEnding) { + match infer_line_ending(&text) { + LineEnding::Lf => (text, LineEnding::Lf), + LineEnding::Cr => (normalize_cr_newlines(text), LineEnding::Cr), + LineEnding::Crlf => (normalize_crlf_newlines(text), LineEnding::Crlf), + } +} + +fn normalize_crlf_newlines(text: String) -> String { + let mut buf = text.into_bytes(); + let mut gap_len = 0; + let mut tail = buf.as_mut_slice(); + let mut crlf_seen = false; + + loop { + let idx = match CRLF_FINDER.find(&tail[gap_len..]) { + None if crlf_seen => tail.len(), + // SAFETY: buf is unchanged and therefore still contains utf8 data + None => return unsafe { String::from_utf8_unchecked(buf) }, + Some(idx) => { + crlf_seen = true; + idx + gap_len + } + }; + tail.copy_within(gap_len..idx, 0); + tail = &mut tail[idx - gap_len..]; + if tail.len() == gap_len { + break; + } + gap_len += 1; + } + + // Account for removed `\r`. + // After `set_len`, `buf` is guaranteed to contain utf-8 again. + unsafe { + let new_len = buf.len() - gap_len; + buf.set_len(new_len); + String::from_utf8_unchecked(buf) + } +} + +fn normalize_cr_newlines(text: String) -> String { + let mut buf = text.into_bytes(); + + if CR_FINDER.find(&buf).is_some() { + // 1:1 byte replacement, total length does not change + for byte in buf.iter_mut() { + if byte == &b'\r' { + *byte = b'\n'; + } + } + } + + unsafe { String::from_utf8_unchecked(buf) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix() { + let src = "a\nb\nc\n\n\n\n"; + assert_eq!(find_newline(src), Some((1, LineEnding::Lf))); + assert_eq!( + normalize_newlines(src.to_string()), + (src.to_string(), LineEnding::Lf) + ); + } + + #[test] + fn dos() { + let src = "\r\na\r\n\r\nb\r\nc\r\n\r\n\r\n\r\n"; + assert_eq!(find_newline(src), Some((0, LineEnding::Crlf))); + assert_eq!( + normalize_newlines(src.to_string()), + (String::from("\na\n\nb\nc\n\n\n\n"), LineEnding::Crlf) + ); + } + + #[test] + fn mixed() { + let src = "a\r\nb\r\nc\r\n\n\r\n\n"; + assert_eq!(find_newline(src), Some((1, LineEnding::Crlf))); + assert_eq!( + normalize_newlines(src.to_string()), + (String::from("a\nb\nc\n\n\n\n"), LineEnding::Crlf) + ); + } + + #[test] + fn none() { + let src = "abc"; + assert_eq!(find_newline(src), None); + assert_eq!( + normalize_newlines(src.to_string()), + (src.to_string(), LineEnding::Lf) + ); + } +} diff --git a/crates/source_file/src/source_file.rs b/crates/source_file/src/source_file.rs new file mode 100644 index 00000000..3c887e80 --- /dev/null +++ b/crates/source_file/src/source_file.rs @@ -0,0 +1,966 @@ +//! This top level documentation details the algorithms and terminology behind +//! [SourceFile::offset] and [SourceFile::source_location]. The primary goal of +//! these functions (and really this whole module) is to handle conversion between +//! a [byte offset](TextSize) and a [line number + line offset](SourceLocation), +//! including treatment of various UTF encodings. +//! +//! Both [TextSize] and [SourceLocation] are ways of pointing at a location within a +//! file: +//! +//! - A [TextSize] is a simple byte offset into a UTF-8 encoded [String]. +//! +//! - A [SourceLocation] contains a location encoded as `line_number` and `line_offset`, +//! where: +//! - `line_number` ([LineNumber]) represents the 0-indexed line number +//! - `line_offset` ([LineOffset]) represents the 0-indexed offset from the start of the +//! line represented by `line_number`. The offset itself is (critically) measured in a +//! UTF concept known as "code units", and is meaningless without the corresponding +//! [LineOffsetEncoding]. +//! +//! A [SourceLocation] is meant to map to an LSP `Position`, and a [LineOffsetEncoding] is +//! meant to map to an LSP `PositionEncodingKind`. We are typically handed an LSP +//! `Position`, must convert it to a [TextSize] (by going through [SourceLocation]), then +//! we use that [TextSize] to index into our [String] representing our document. On the +//! way out, we must convert from [TextSize] or [TextRange] back to LSP `Position` or LSP +//! `Range` by going back through [SourceLocation]. +//! +//! Now, for some definitions: +//! +//! - Code unit: The minimal bit combination that can represent a single character, +//! depending on the encoding used. +//! - UTF-8: +//! - 1 code unit = 1 byte = 8 bits +//! - UTF-16: +//! - 1 code unit = 2 bytes = 16 bits +//! - UTF-32: +//! - 1 code unit = 4 bytes = 32 bits +//! +//! - Character: A combination of code units that construct a single UTF element. +//! - UTF-8: +//! - 1 character = 1,2,3,4 code units = 1,2,3,4 bytes = 8,16,24,32 bits +//! - UTF-16: +//! - 1 character = 1,2 code units = 2,4 bytes = 16,32 bits +//! - UTF-32: +//! - 1 character = 1 code units = 4 bytes = 32 bits +//! +//! - Unicode Scalar Value: Any Unicode Code Point other than a Surrogate Code Point ( +//! which are only used by UTF-16). Technically, this means any value in the range of +//! [0 to 0x10FFFF] excluding the slice of [0xD800 to 0xDFFF]. The [char] type +//! represents these. +//! +//! - Unicode Code Point: Any value in the Unicode code space of [0 to 0x10FFFF]. This +//! means that something representing an arbitrary code point must be 4 bytes, implying +//! that something representing a Unicode Scalar Value must also be 4 bytes, and +//! practically a [char] has the same memory layout as a [u32] under the hood. +//! +//! - Rust [String] and [str] are in UTF-8, and all [byte offsets](TextSize) into them +//! assume the strings are encoded in UTF-8. +//! +//! One key thing to note is that `\n` (or `\r\n`) is the same in all encodings. This +//! means that finding the [LineNumber] you are on is easy, you are either given it +//! through [SourceLocation::line_number], or it can be easily computed from a UTF-8 [byte +//! offset](TextSize) by doing a binary search into an ordered vector of line start +//! locations. That isolates the "hard" details of encoding translation to the +//! [LineOffset], which is typically an extremely small slice of the overall file. +//! +//! # Implementing [SourceFile::offset] +//! +//! ## UTF-8 code units -> UTF-8 byte offset +//! +//! Easy! 1 UTF-8 code unit maps directly to 1 byte in a UTF-8 string, so counting the +//! code units is equivalent to finding the byte offset into the UTF-8 string. +//! +//! ## UTF-16 code units -> UTF-8 byte offset +//! +//! 1 UTF-16 code unit is always 2 bytes if the string is encoded in UTF-16. But if +//! the string is encoded in UTF-8 as ours is, we don't immediately know if the +//! UTF-16 code unit would be represented by 1 or 2 bytes in a UTF-8 string. +//! +//! To do this, we iterate over the [str::chars()] of the string, which are Unicode Scalar +//! Values, i.e. a UTF-32 character, the widest of them all. To figure out the correct +//! amount of UTF-16 code units to count up, we compute the [char::len_utf16()] of each +//! character, which returns the number of UTF-16 code units required if the [char] +//! were instead encoded in UTF-16. Once we've reached the [LineOffset] offset, we've +//! found all the [char]s we care about. To find the byte offset in UTF-8 encoded space, +//! we sum up the [char::len_utf8()] of each of those [char]s. +//! +//! ## UTF-32 code units -> UTF-8 byte offset +//! +//! Very similar to UTF-16, except 1 UTF-32 code unit is always 4 bytes if the string +//! itself is encoded in UTF-32. +//! +//! This is slightly easier than UTF-16. Because [str::chars()] already returns Unicode +//! Scalar Values, also known as UTF-32 characters, and because 1 UTF-32 character is +//! the same size as 1 UTF-32 code unit, we just iterate over the [str::chars()] up to +//! the [LineOffset] value, summing the [char::len_utf8()] of each [char] along the way. +//! +//! # Implementing [SourceFile::source_location] +//! +//! ## UTF-8 byte offset -> UTF-8 code units +//! +//! Easy! Like with the other direction, UTF-8 byte offsets can be directly translated +//! to UTF-8 code units, so there is nothing to do. +//! +//! ## UTF-8 byte offset -> UTF-16 code units +//! +//! This is actually pretty easy. All we do is slice the [String] from its start up to +//! the UTF-8 byte offset in question, then call [str::encode_utf16()] and count the +//! number of UTF-16 code units it returns. +//! +//! This would be expensive if we had to reencode as UTF-16 from the beginning of the +//! file, but we actually just need to reencode as UTF-16 from the beginning of the +//! line that the offset is on, up to the offset position itself, which is a very small +//! slice. This works because the line number itself is not dependent on the encoding, +//! only the line offset into that line is. +//! +//! ## UTF-8 byte offset -> UTF-32 code units +//! +//! Same as with UTF-16, but rather than [str::encode_utf16()], we can use [str::chars()] +//! because it already returns Unicode Scalar Values, which are UTF-32 characters, which +//! are equivalent to UTF-32 code units. + +use biome_text_size::TextLen; +use biome_text_size::TextRange; +use biome_text_size::TextSize; + +use crate::source_location::LineNumber; +use crate::source_location::LineOffsetEncoding; +use crate::LineOffset; +use crate::SourceLocation; + +/// Manager of a single source file +/// +/// Builds a vector of line start locations on creation, for use with +/// [TextSize] <-> [SourceLocation] conversions. In particular, see: +/// +/// - [Self::offset()] for [SourceLocation] -> [TextSize] +/// - [Self::source_location()] for [TextSize] -> [SourceLocation] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SourceFile { + contents: String, + line_starts: Vec, + kind: SourceKind, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum SourceKind { + /// Optimized for an ASCII only document + Ascii, + + /// Document containing UTF-8 + Utf8, +} + +impl SourceKind { + const fn is_ascii(self) -> bool { + matches!(self, SourceKind::Ascii) + } +} + +impl SourceFile { + /// Builds the [`SourceFile`] from the contents of a file. + pub fn new(contents: String) -> Self { + let (line_starts, kind) = Self::analyze(&contents); + Self { + contents, + line_starts, + kind, + } + } + + fn analyze(contents: &str) -> (Vec, SourceKind) { + let mut line_starts: Vec = Vec::with_capacity(contents.len() / 88); + + // Always push a start for an offset of `0`, needed for an invariant in `line_number()` + line_starts.push(TextSize::from(0)); + + let mut utf8 = false; + + let bytes = contents.as_bytes(); + assert!(u32::try_from(bytes.len()).is_ok()); + + for (i, byte) in bytes.iter().enumerate() { + utf8 |= !byte.is_ascii(); + + match byte { + // Only track one line break for `\r\n`. + b'\r' if bytes.get(i + 1) == Some(&b'\n') => continue, + b'\n' | b'\r' => { + // SAFETY: Assertion above guarantees `i <= u32::MAX` + #[allow(clippy::cast_possible_truncation)] + line_starts.push(TextSize::from(i as u32) + TextSize::from(1)); + } + _ => {} + } + } + + let kind = if utf8 { + SourceKind::Utf8 + } else { + SourceKind::Ascii + }; + + (line_starts, kind) + } + + /// Returns a reference to the contents in the source file. + pub fn contents(&self) -> &str { + &self.contents + } + + /// Consumes the source file, returning only the contents. + pub fn into_contents(self) -> String { + self.contents + } + + /// Replace text in the source file and reanalyze afterwards. + pub fn replace_range(&mut self, range: R, replace_with: &str) + where + R: std::ops::RangeBounds, + { + self.contents.replace_range(range, replace_with); + let (line_starts, kind) = Self::analyze(&self.contents); + self.line_starts = line_starts; + self.kind = kind; + } + + /// Returns `true` if the text only consists of ASCII characters + pub fn is_ascii(&self) -> bool { + self.kind.is_ascii() + } + + /// Return the number of lines in the source file. + pub fn line_count(&self) -> usize { + self.line_starts().len() + } + + /// Returns the row number for a given offset. + /// + /// ## Examples + /// + /// ``` + /// use biome_text_size::TextSize; + /// use source_file::{SourceFile, SourceLocation, LineNumber}; + /// + /// let source = "def a():\n pass".to_string(); + /// let source = SourceFile::new(source); + /// + /// assert_eq!(source.line_number(TextSize::from(0)), LineNumber::from(0)); + /// assert_eq!(source.line_number(TextSize::from(4)), LineNumber::from(0)); + /// assert_eq!(source.line_number(TextSize::from(13)), LineNumber::from(1)); + /// ``` + pub fn line_number(&self, offset: TextSize) -> LineNumber { + let line_number = match self.line_starts().binary_search(&offset) { + // `offset` is at the start of a line + Ok(line_number) => line_number, + Err(next_line_number) => { + // SAFETY: Safe because the line starts always contain an entry for the offset 0 + next_line_number - 1 + } + }; + + LineNumber::try_from(line_number) + .expect("Number of line starts should fit in a `LineNumber`") + } + + /// Returns the [byte offset](TextSize) for the line's start. + pub fn line_start(&self, line_number: LineNumber) -> TextSize { + let line_number = usize::from(line_number); + + if line_number >= self.line_count() { + // If asking for a line number past the last line, return last byte + self.contents().text_len() + } else { + self.line_starts()[line_number] + } + } + + /// Returns the [byte offset](TextSize) of the line's end. + /// + /// The offset is the end of the line, up to and including the newline character + /// ending the line (if any), making it equivalent to the next line's start. + pub(crate) fn line_end(&self, line_number: LineNumber) -> TextSize { + let line_number = usize::from(line_number); + + if line_number.saturating_add(1) >= self.line_count() { + // If asking for a line number past the last line, return last byte + self.contents().text_len() + } else { + self.line_starts()[line_number + 1] + } + } + + /// Returns the [`TextRange`] of the line. + /// + /// The start points to the first character's [byte offset](TextSize). The end points + /// up to, and including, the newline character ending the line (if any). This makes + /// the range a `[)` range. + pub fn line_range(&self, line_number: LineNumber) -> TextRange { + TextRange::new(self.line_start(line_number), self.line_end(line_number)) + } + + /// Returns the [byte offsets](TextSize) for every line + pub fn line_starts(&self) -> &[TextSize] { + &self.line_starts + } + + /// Returns the [SourceLocation] at the [byte offset](TextSize). + /// + /// ## Examples + /// + /// ``` + /// use biome_text_size::TextSize; + /// use source_file::{SourceFile, SourceLocation, LineNumber, LineOffset, LineOffsetEncoding}; + /// + /// let source = "x <- function()\n NULL".to_string(); + /// let source = SourceFile::new(source); + /// + /// assert_eq!( + /// source.source_location(TextSize::from(0), LineOffsetEncoding::UTF8), + /// SourceLocation::new( + /// LineNumber::from(0), + /// LineOffset::new(0, LineOffsetEncoding::UTF8) + /// ) + /// ); + /// assert_eq!( + /// source.source_location(TextSize::from(4), LineOffsetEncoding::UTF8), + /// SourceLocation::new( + /// LineNumber::from(0), + /// LineOffset::new(4, LineOffsetEncoding::UTF8) + /// ) + /// ); + /// assert_eq!( + /// source.source_location(TextSize::from(20), LineOffsetEncoding::UTF8), + /// SourceLocation::new( + /// LineNumber::from(1), + /// LineOffset::new(4, LineOffsetEncoding::UTF8) + /// ) + /// ); + /// ``` + pub fn source_location( + &self, + offset: TextSize, + encoding: LineOffsetEncoding, + ) -> SourceLocation { + let line_number = self.line_number(offset); + let line_range_up_to_offset = TextRange::new(self.line_start(line_number), offset); + + let line_offset = if self.is_ascii() { + LineOffset::new(line_range_up_to_offset.len().into(), encoding) + } else { + match encoding { + LineOffsetEncoding::UTF8 => { + LineOffset::new(line_range_up_to_offset.len().into(), encoding) + } + LineOffsetEncoding::UTF16 => { + let line_contents_up_to_offset = &self.contents()[line_range_up_to_offset]; + let offset = line_contents_up_to_offset + .encode_utf16() + .count() + .try_into() + .expect("A single line's `offset` should fit in a `u32`"); + LineOffset::new(offset, encoding) + } + LineOffsetEncoding::UTF32 => { + let line_contents_up_to_offset = &self.contents()[line_range_up_to_offset]; + let offset = line_contents_up_to_offset + .chars() + .count() + .try_into() + .expect("A single line's `offset` should fit in a `u32`"); + LineOffset::new(offset, encoding) + } + } + }; + + SourceLocation::new(line_number, line_offset) + } + + /// Returns the [byte offset](TextSize) at the [SourceLocation]. + /// + /// ## Examples + /// + /// ### ASCII + /// + /// ``` + /// use source_file::{SourceFile, SourceLocation, LineNumber, LineOffset, LineOffsetEncoding}; + /// use biome_text_size::TextSize; + /// + /// let source = r#"a = 4 + /// c = "some string" + /// x = b"#.to_string(); + /// + /// let source = SourceFile::new(source); + /// + /// // First line, first column + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(0), + /// LineOffset::new(0, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(0) + /// ); + /// + /// // Second line, 4th column + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(1), + /// LineOffset::new(4, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(10) + /// ); + /// + /// // Offset past the end of the first line + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(0), + /// LineOffset::new(10, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(6) + /// ); + /// + /// // Offset past the end of the file + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(3), + /// LineOffset::new(0, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(29) + /// ); + /// ``` + /// + /// ### UTF8 + /// + /// ``` + /// use source_file::{SourceFile, SourceLocation, LineNumber, LineOffset, LineOffsetEncoding}; + /// use biome_text_size::TextSize; + /// + /// let source = r#"a = 4 + /// c = "❤️" + /// x = b"#.to_string(); + /// + /// let source = SourceFile::new(source); + /// + /// // First line, first column + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(0), + /// LineOffset::new(0, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(0) + /// ); + /// + /// // Third line, 2nd column, after emoji, UTF8 + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(2), + /// LineOffset::new(1, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(20) + /// ); + /// + /// // Third line, 2nd column, after emoji, UTF16 + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(2), + /// LineOffset::new(1, LineOffsetEncoding::UTF16) + /// )), + /// TextSize::from(20) + /// ); + /// + /// // Offset past the end of the second line, UTF8 + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(1), + /// LineOffset::new(10, LineOffsetEncoding::UTF8) + /// )), + /// TextSize::from(16) + /// ); + /// + /// // Offset past the end of the second line, UTF32 + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(1), + /// LineOffset::new(10, LineOffsetEncoding::UTF32) + /// )), + /// TextSize::from(19) + /// ); + /// + /// // Offset past the end of the file + /// assert_eq!( + /// source.offset(SourceLocation::new( + /// LineNumber::from(3), + /// LineOffset::new(0, LineOffsetEncoding::UTF32) + /// )), + /// TextSize::from(24) + /// ); + /// ``` + /// + pub fn offset(&self, source_location: SourceLocation) -> TextSize { + let (line_number, line_offset) = source_location.into_fields(); + + let line_range = self.line_range(line_number); + + let offset = if self.is_ascii() { + TextSize::from(line_offset.raw()) + } else { + match line_offset.encoding() { + LineOffsetEncoding::UTF8 => TextSize::from(line_offset.raw()), + LineOffsetEncoding::UTF16 => { + let n_code_units = line_offset.raw(); + let line_contents = &self.contents()[line_range]; + + let mut i = 0; + let mut offset = 0; + + for c in line_contents.chars() { + if i >= n_code_units { + break; + } + i += c.len_utf16() as u32; + offset += c.len_utf8() as u32; + } + + TextSize::from(offset) + } + LineOffsetEncoding::UTF32 => { + let n_code_units = line_offset.raw(); + let line_contents = &self.contents()[line_range]; + + let mut offset: u32 = 0; + + for c in line_contents.chars().take(n_code_units as usize) { + offset += c.len_utf8() as u32; + } + + TextSize::from(offset) + } + } + }; + + line_range.start() + offset.clamp(TextSize::from(0), line_range.len()) + } +} + +#[cfg(test)] +mod tests { + use biome_text_size::TextSize; + + use crate::source_location::LineNumber; + use crate::source_location::LineOffset; + use crate::source_location::LineOffsetEncoding; + use crate::SourceFile; + use crate::SourceLocation; + + #[test] + fn ascii_source_file() { + let source = SourceFile::new(String::new()); + assert_eq!(source.line_starts(), &[TextSize::from(0)]); + + let source = SourceFile::new("x = 1".to_string()); + assert_eq!(source.line_starts(), &[TextSize::from(0)]); + + let source = SourceFile::new("x = 1\n".to_string()); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(6)] + ); + + let source = SourceFile::new("x = 1\ny = 2\nz = x + y\n".to_string()); + assert_eq!( + source.line_starts(), + &[ + TextSize::from(0), + TextSize::from(6), + TextSize::from(12), + TextSize::from(22) + ] + ); + } + + #[test] + fn ascii_source_location() { + let contents = "x = 1\ny = 2".to_string(); + let source = SourceFile::new(contents); + + // First row. + let loc = source.source_location(TextSize::from(2), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(2, LineOffsetEncoding::UTF8) + ) + ); + + // Second row. + let loc = source.source_location(TextSize::from(6), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + + let loc = source.source_location(TextSize::from(11), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(5, LineOffsetEncoding::UTF8) + ) + ); + } + + #[test] + fn ascii_carriage_return() { + let contents = "x = 4\ry = 3".to_string(); + let source = SourceFile::new(contents); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(6)] + ); + + assert_eq!( + source.source_location(TextSize::from(4), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(4, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(6), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(7), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF8) + ) + ); + } + + #[test] + fn ascii_carriage_return_newline() { + let contents = "x = 4\r\ny = 3".to_string(); + let source = SourceFile::new(contents); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(7)] + ); + + assert_eq!( + source.source_location(TextSize::from(4), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(4, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(7), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(8), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF8) + ) + ); + } + + #[test] + fn utf8_source_file() { + let source = SourceFile::new("x = '🫣'".to_string()); + assert_eq!(source.line_count(), 1); + assert_eq!(source.line_starts(), &[TextSize::from(0)]); + + let source = SourceFile::new("x = '🫣'\n".to_string()); + assert_eq!(source.line_count(), 2); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(11)] + ); + + let source = SourceFile::new("x = '🫣'\ny = 2\nz = x + y\n".to_string()); + assert_eq!(source.line_count(), 4); + assert_eq!( + source.line_starts(), + &[ + TextSize::from(0), + TextSize::from(11), + TextSize::from(17), + TextSize::from(27) + ] + ); + + let source = SourceFile::new("# 🫣\nclass Foo:\n \"\"\".\"\"\"".to_string()); + assert_eq!(source.line_count(), 3); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(7), TextSize::from(18)] + ); + } + + #[test] + fn utf8_carriage_return() { + let contents = "x = '🫣'\ry = 3".to_string(); + let source = SourceFile::new(contents); + assert_eq!(source.line_count(), 2); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(11)] + ); + + // Second ', UTF8 + assert_eq!( + source.source_location(TextSize::from(9), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(9, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(11), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + assert_eq!( + source.source_location(TextSize::from(12), LineOffsetEncoding::UTF8), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF8) + ) + ); + + // Second ', UTF16 + assert_eq!( + source.source_location(TextSize::from(9), LineOffsetEncoding::UTF16), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(7, LineOffsetEncoding::UTF16) + ) + ); + assert_eq!( + source.source_location(TextSize::from(11), LineOffsetEncoding::UTF16), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF16) + ) + ); + assert_eq!( + source.source_location(TextSize::from(12), LineOffsetEncoding::UTF16), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF16) + ) + ); + + // Second ', UTF32 + assert_eq!( + source.source_location(TextSize::from(9), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(6, LineOffsetEncoding::UTF32) + ) + ); + assert_eq!( + source.source_location(TextSize::from(11), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF32) + ) + ); + assert_eq!( + source.source_location(TextSize::from(12), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF32) + ) + ); + } + + #[test] + fn utf8_carriage_return_newline() { + let contents = "x = '🫣'\r\ny = 3".to_string(); + let source = SourceFile::new(contents); + assert_eq!(source.line_count(), 2); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(12)] + ); + + // Second ' + assert_eq!( + source.source_location(TextSize::from(9), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(6, LineOffsetEncoding::UTF32) + ) + ); + assert_eq!( + source.source_location(TextSize::from(12), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF32) + ) + ); + assert_eq!( + source.source_location(TextSize::from(13), LineOffsetEncoding::UTF32), + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(1, LineOffsetEncoding::UTF32) + ) + ); + } + + #[test] + fn utf8_byte_offset() { + let contents = "x = '☃'\ny = 2".to_string(); + let source = SourceFile::new(contents); + assert_eq!( + source.line_starts(), + &[TextSize::from(0), TextSize::from(10)] + ); + + // First row, start + let loc = source.source_location(TextSize::from(0), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + let loc = source.source_location(TextSize::from(0), LineOffsetEncoding::UTF16); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(0, LineOffsetEncoding::UTF16) + ) + ); + let loc = source.source_location(TextSize::from(0), LineOffsetEncoding::UTF32); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(0, LineOffsetEncoding::UTF32) + ) + ); + + // First row, right before + let loc = source.source_location(TextSize::from(5), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(5, LineOffsetEncoding::UTF8) + ) + ); + let loc = source.source_location(TextSize::from(5), LineOffsetEncoding::UTF16); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(5, LineOffsetEncoding::UTF16) + ) + ); + let loc = source.source_location(TextSize::from(5), LineOffsetEncoding::UTF32); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(5, LineOffsetEncoding::UTF32) + ) + ); + + // First row, right after + let loc = source.source_location(TextSize::from(8), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(8, LineOffsetEncoding::UTF8) + ) + ); + let loc = source.source_location(TextSize::from(8), LineOffsetEncoding::UTF16); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(6, LineOffsetEncoding::UTF16) + ) + ); + let loc = source.source_location(TextSize::from(8), LineOffsetEncoding::UTF32); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(0), + LineOffset::new(6, LineOffsetEncoding::UTF32) + ) + ); + + // Second row, start + let loc = source.source_location(TextSize::from(10), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF8) + ) + ); + let loc = source.source_location(TextSize::from(10), LineOffsetEncoding::UTF16); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF16) + ) + ); + let loc = source.source_location(TextSize::from(10), LineOffsetEncoding::UTF32); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(0, LineOffsetEncoding::UTF32) + ) + ); + + // One-past-the-end. + let loc = source.source_location(TextSize::from(15), LineOffsetEncoding::UTF8); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(5, LineOffsetEncoding::UTF8) + ) + ); + let loc = source.source_location(TextSize::from(15), LineOffsetEncoding::UTF16); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(5, LineOffsetEncoding::UTF16) + ) + ); + let loc = source.source_location(TextSize::from(15), LineOffsetEncoding::UTF32); + assert_eq!( + loc, + SourceLocation::new( + LineNumber::from(1), + LineOffset::new(5, LineOffsetEncoding::UTF32) + ) + ); + } +} diff --git a/crates/source_file/src/source_location.rs b/crates/source_file/src/source_location.rs new file mode 100644 index 00000000..e31fb167 --- /dev/null +++ b/crates/source_file/src/source_location.rs @@ -0,0 +1,92 @@ +use std::fmt::Debug; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SourceLocation { + line_number: LineNumber, + line_offset: LineOffset, +} + +impl SourceLocation { + pub fn new(line_number: LineNumber, line_offset: LineOffset) -> Self { + Self { + line_number, + line_offset, + } + } + + pub fn line_number(&self) -> LineNumber { + self.line_number + } + + pub fn line_offset(&self) -> LineOffset { + self.line_offset + } + + pub fn into_fields(self) -> (LineNumber, LineOffset) { + (self.line_number, self.line_offset) + } +} + +/// A 0-indexed line number +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct LineNumber(u32); + +impl From for LineNumber { + fn from(value: u32) -> Self { + LineNumber(value) + } +} + +impl TryFrom for LineNumber { + type Error = std::num::TryFromIntError; + + fn try_from(value: usize) -> Result { + Ok(LineNumber(u32::try_from(value)?)) + } +} + +impl From for u32 { + fn from(value: LineNumber) -> Self { + value.0 + } +} + +impl From for usize { + fn from(value: LineNumber) -> Self { + value.0 as usize + } +} + +/// A 0-indexed offset into a line, represented as a number of code units under one of the +/// three possible [LineOffsetEncoding]s +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct LineOffset { + raw: u32, + encoding: LineOffsetEncoding, +} + +impl LineOffset { + pub fn new(raw: u32, encoding: LineOffsetEncoding) -> Self { + Self { raw, encoding } + } + + pub fn raw(&self) -> u32 { + self.raw + } + + pub fn encoding(&self) -> LineOffsetEncoding { + self.encoding + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum LineOffsetEncoding { + /// Preferred encoding, as Rust [String]s are UTF-8 + UTF8, + + /// UTF-16 is the encoding supported by all LSP clients, but is most expensive to translate + UTF16, + + /// Second choice because UTF-32 uses a fixed 4 byte encoding for each character (makes conversion relatively easy) + UTF32, +} diff --git a/crates/tests_macros/src/lib.rs b/crates/tests_macros/src/lib.rs index 3ff3db27..8b83af54 100644 --- a/crates/tests_macros/src/lib.rs +++ b/crates/tests_macros/src/lib.rs @@ -252,24 +252,3 @@ pub fn gen_tests(input: TokenStream) -> TokenStream { Err(e) => abort!(e, "{}", e), } } - -// We can't effectively interact with the server task and shut it down from `Drop` -// so we do it on teardown in this procedural macro. Users must return their client. -#[proc_macro_attribute] -pub fn lsp_test(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(item as syn::ItemFn); - - let name = &input.sig.ident; - let block = &input.block; - - let expanded = quote! { - #[tokio::test] - async fn #name() { - let mut client = #block; - client.shutdown().await; - client.exit().await; - } - }; - - TokenStream::from(expanded) -} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 00000000..37854b82 --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "workspace" +version = "0.1.0" +publish = false +authors.workspace = true +categories.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +air_r_formatter = { workspace = true } +biome_formatter = { workspace = true, features = ["serde"] } +fs = { workspace = true } +ignore = { workspace = true } +rustc-hash = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true, features = ["derive"] } +source_file = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +insta = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/workspace/src/discovery.rs b/crates/workspace/src/discovery.rs new file mode 100644 index 00000000..fd005fb7 --- /dev/null +++ b/crates/workspace/src/discovery.rs @@ -0,0 +1,151 @@ +use ignore::DirEntry; +use rustc_hash::FxHashSet; +use std::path::Path; +use std::path::PathBuf; +use thiserror::Error; + +use crate::settings::Settings; +use crate::toml::find_air_toml_in_directory; +use crate::toml::parse_air_toml; +use crate::toml::ParseTomlError; + +#[derive(Debug, Error)] +pub enum DiscoverSettingsError { + #[error(transparent)] + ParseToml(#[from] ParseTomlError), +} + +#[derive(Debug)] +pub struct DiscoveredSettings { + pub directory: PathBuf, + pub settings: Settings, +} + +/// This is the core function for walking a set of `paths` looking for `air.toml`s. +/// +/// You typically follow this function up by loading the set of returned path into a +/// [crate::resolve::PathResolver]. +/// +/// For each `path`, we: +/// - Walk up its ancestors, looking for an `air.toml` +/// - TODO(hierarchical): Walk down its children, looking for nested `air.toml`s +pub fn discover_settings>( + paths: &[P], +) -> Result, DiscoverSettingsError> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let mut seen = FxHashSet::default(); + let mut discovered_settings = Vec::with_capacity(paths.len()); + + // Load the `resolver` with `Settings` associated with each `path` + for path in &paths { + for ancestor in path.ancestors() { + let is_new_ancestor = seen.insert(ancestor); + + if !is_new_ancestor { + // We already visited this ancestor, we can stop here. + break; + } + + if let Some(toml) = find_air_toml_in_directory(ancestor) { + let settings = parse_settings(&toml)?; + discovered_settings.push(DiscoveredSettings { + directory: ancestor.to_path_buf(), + settings, + }); + break; + } + } + } + + // TODO(hierarchical): Also iterate through the directories and collect `air.toml` + // found nested withing the directories for hierarchical support + + Ok(discovered_settings) +} + +/// Parse [Settings] from a given `air.toml` +// TODO(hierarchical): Allow for an `extends` option in `air.toml`, which will make things +// more complex, but will be very useful once we support hierarchical configuration as a +// way of "inheriting" most top level configuration while slightly tweaking it in a nested directory. +fn parse_settings(toml: &Path) -> Result { + let options = parse_air_toml(toml)?; + let settings = options.into_settings(); + Ok(settings) +} + +/// For each provided `path`, recursively search for any R files within that `path` +/// that match our inclusion criteria +/// +/// NOTE: Make sure that the inclusion criteria that guide `path` discovery are also +/// consistently applied to [discover_settings()]. +pub fn discover_r_file_paths>(paths: &[P]) -> Vec> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let Some((first_path, paths)) = paths.split_first() else { + // No paths provided + return Vec::new(); + }; + + // TODO: Parallel directory visitor + let mut builder = ignore::WalkBuilder::new(first_path); + + for path in paths { + builder.add(path); + } + + // TODO: Make these configurable options (possibly just one?) + // Right now we explicitly call them even though they are `true` by default + // to remind us to expose them. + // + // "This toggles, as a group, all the filters that are enabled by default" + // builder.standard_filters(true) + builder.hidden(true); + builder.parents(true); + builder.ignore(false); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + + let mut paths = Vec::new(); + + // Walk all `paths` recursively, collecting R files that we can format + for path in builder.build() { + match path { + Ok(entry) => { + if let Some(path) = is_match(entry) { + paths.push(Ok(path)); + } + } + Err(err) => { + paths.push(Err(err)); + } + } + } + + paths +} + +// Decide whether or not to accept an `entry` based on include/exclude rules. +fn is_match(entry: DirEntry) -> Option { + // Ignore directories + if entry.file_type().map_or(true, |ft| ft.is_dir()) { + return None; + } + + // Accept all files that are passed-in directly, even non-R files + if entry.depth() == 0 { + let path = entry.into_path(); + return Some(path); + } + + // Otherwise check if we should accept this entry + // TODO: Many other checks based on user exclude/includes + let path = entry.into_path(); + + if !fs::has_r_extension(&path) { + return None; + } + + Some(path) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs new file mode 100644 index 00000000..a185a3c1 --- /dev/null +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,5 @@ +pub mod discovery; +pub mod resolve; +pub mod settings; +pub mod toml; +pub mod toml_options; diff --git a/crates/workspace/src/resolve.rs b/crates/workspace/src/resolve.rs new file mode 100644 index 00000000..9c129431 --- /dev/null +++ b/crates/workspace/src/resolve.rs @@ -0,0 +1,102 @@ +use std::collections::btree_map::Keys; +use std::collections::btree_map::Range; +use std::collections::btree_map::RangeMut; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Resolves a [`Path`] to its associated `T` +/// +/// To use a [`PathResolver`]: +/// - Load directories into it using [`PathResolver::add()`] +/// - Resolve a [`Path`] to its associated `T` with [`PathResolver::resolve()`] +/// +/// See [`PathResolver::resolve()`] for more details on the implementation. +#[derive(Debug, Default)] +pub struct PathResolver { + /// Fallback value to be used when a `path` isn't associated with anything in the `map` + fallback: T, + + /// An ordered `BTreeMap` from a `path` (normally, a directory) to a `T` + map: BTreeMap, +} + +impl PathResolver { + /// Create a new empty [`PathResolver`] + pub fn new(fallback: T) -> Self { + Self { + fallback, + map: BTreeMap::new(), + } + } + + pub fn fallback(&self) -> &T { + &self.fallback + } + + pub fn add(&mut self, path: &Path, value: T) -> Option { + self.map.insert(path.to_path_buf(), value) + } + + pub fn remove(&mut self, path: &Path) -> Option { + self.map.remove(path) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn keys(&self) -> Keys<'_, PathBuf, T> { + self.map.keys() + } + + pub fn clear(&mut self) { + self.map.clear(); + } + + /// Resolve a [`Path`] to its associated `T` + /// + /// This resolver works by finding the closest directory to the `path` to search for. + /// + /// The [`BTreeMap`] is an ordered map, so if you do: + /// + /// ```text + /// resolver.add("a/b", value1) + /// resolver.add("a/b/c", value2) + /// resolver.add("a/b/d", value3) + /// resolver.resolve("a/b/c/test.R") + /// ``` + /// + /// Then it detects both `"a/b"` and `"a/b/c"` as being "less than" the path of + /// `"a/b/c/test.R"`, and then chooses `"a/b/c"` because it is at the back of + /// that returned sorted list (i.e. the "closest" match). + pub fn resolve(&self, path: &Path) -> Option<&T> { + self.resolve_entry(path).map(|(_, value)| value) + } + + /// Same as `resolve()`, but returns the internal `fallback` if no associated value + /// is found. + pub fn resolve_or_fallback(&self, path: &Path) -> &T { + self.resolve(path).unwrap_or(self.fallback()) + } + + /// Same as `resolve()`, but returns the `(key, value)` pair. + /// + /// Useful when you need the matched workspace path + pub fn resolve_entry(&self, path: &Path) -> Option<(&PathBuf, &T)> { + self.matches(path).next_back() + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches(&self, path: &Path) -> Range<'_, PathBuf, T> { + self.map.range(..path.to_path_buf()) + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches_mut(&mut self, path: &Path) -> RangeMut<'_, PathBuf, T> { + self.map.range_mut(..path.to_path_buf()) + } +} diff --git a/crates/workspace/src/settings.rs b/crates/workspace/src/settings.rs new file mode 100644 index 00000000..dd474ae1 --- /dev/null +++ b/crates/workspace/src/settings.rs @@ -0,0 +1,59 @@ +mod indent_style; +mod indent_width; +mod line_ending; +mod line_length; +mod magic_line_break; + +pub use indent_style::*; +pub use indent_width::*; +pub use line_ending::*; +pub use line_length::*; +pub use magic_line_break::*; + +use air_r_formatter::context::RFormatOptions; + +/// Resolved configuration settings used within air +/// +/// May still require a source document to finalize some options, such as +/// `LineEnding::Auto` in the formatter. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Settings { + /// Settings to configure code formatting. + pub format: FormatSettings, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct FormatSettings { + pub indent_style: IndentStyle, + pub indent_width: IndentWidth, + pub line_ending: LineEnding, + pub line_length: LineLength, + pub magic_line_break: MagicLineBreak, +} + +impl FormatSettings { + // Finalize `RFormatOptions` in preparation for a formatting operation on `source` + pub fn to_format_options(&self, source: &str) -> RFormatOptions { + let line_ending = match self.line_ending { + LineEnding::Lf => biome_formatter::LineEnding::Lf, + LineEnding::Crlf => biome_formatter::LineEnding::Crlf, + #[cfg(target_os = "windows")] + LineEnding::Native => biome_formatter::LineEnding::Crlf, + #[cfg(not(target_os = "windows"))] + LineEnding::Native => biome_formatter::LineEnding::Lf, + LineEnding::Auto => match source_file::find_newline(source) { + Some((_, source_file::LineEnding::Lf)) => biome_formatter::LineEnding::Lf, + Some((_, source_file::LineEnding::Crlf)) => biome_formatter::LineEnding::Crlf, + Some((_, source_file::LineEnding::Cr)) => biome_formatter::LineEnding::Cr, + None => biome_formatter::LineEnding::Lf, + }, + }; + + RFormatOptions::new() + .with_indent_style(self.indent_style.into()) + .with_indent_width(self.indent_width.into()) + .with_line_ending(line_ending) + .with_line_width(self.line_length.into()) + .with_magic_line_break(self.magic_line_break.into()) + } +} diff --git a/crates/workspace/src/settings/indent_style.rs b/crates/workspace/src/settings/indent_style.rs new file mode 100644 index 00000000..f84d94da --- /dev/null +++ b/crates/workspace/src/settings/indent_style.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IndentStyle { + /// Tab + #[default] + Tab, + /// Space + Space, +} + +impl IndentStyle { + /// Returns `true` if this is an [IndentStyle::Tab]. + pub const fn is_tab(&self) -> bool { + matches!(self, IndentStyle::Tab) + } + + /// Returns `true` if this is an [IndentStyle::Space]. + pub const fn is_space(&self) -> bool { + matches!(self, IndentStyle::Space) + } +} + +impl FromStr for IndentStyle { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "tab" => Ok(Self::Tab), + "space" => Ok(Self::Space), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for IndentStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IndentStyle::Tab => std::write!(f, "Tab"), + IndentStyle::Space => std::write!(f, "Space"), + } + } +} + +impl From for biome_formatter::IndentStyle { + fn from(value: IndentStyle) -> Self { + match value { + IndentStyle::Tab => biome_formatter::IndentStyle::Tab, + IndentStyle::Space => biome_formatter::IndentStyle::Space, + } + } +} diff --git a/crates/workspace/src/settings/indent_width.rs b/crates/workspace/src/settings/indent_width.rs new file mode 100644 index 00000000..f9717faa --- /dev/null +++ b/crates/workspace/src/settings/indent_width.rs @@ -0,0 +1,148 @@ +use std::fmt; +use std::num::NonZeroU8; + +/// Validated value for the `indent-width` formatter options +/// +/// The allowed range of values is 1..=24 +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct IndentWidth(NonZeroU8); + +impl IndentWidth { + /// Maximum allowed value for a valid [IndentWidth] + const MAX: u8 = 24; + + /// Return the numeric value for this [IndentWidth] + pub fn value(&self) -> u8 { + self.0.get() + } +} + +impl Default for IndentWidth { + fn default() -> Self { + Self(NonZeroU8::new(4).unwrap()) + } +} + +impl std::fmt::Debug for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl std::fmt::Display for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for IndentWidth { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u8 = serde::Deserialize::deserialize(deserializer)?; + let indent_width = IndentWidth::try_from(value).map_err(serde::de::Error::custom)?; + Ok(indent_width) + } +} + +/// Error type returned when converting a u8 or NonZeroU8 to a [`IndentWidth`] fails +#[derive(Clone, Copy, Debug)] +pub struct IndentWidthFromIntError(u8); + +impl std::error::Error for IndentWidthFromIntError {} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: u8) -> Result { + match NonZeroU8::try_from(value) { + Ok(value) => IndentWidth::try_from(value), + Err(_) => Err(IndentWidthFromIntError(value)), + } + } +} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: NonZeroU8) -> Result { + if value.get() <= Self::MAX { + Ok(IndentWidth(value)) + } else { + Err(IndentWidthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for IndentWidthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The indent width must be a value between 1 and {max}, not {value}.", + max = IndentWidth::MAX, + value = self.0 + ) + } +} + +impl From for u8 { + fn from(value: IndentWidth) -> Self { + value.0.get() + } +} + +impl From for NonZeroU8 { + fn from(value: IndentWidth) -> Self { + value.0 + } +} + +impl From for biome_formatter::IndentWidth { + fn from(value: IndentWidth) -> Self { + // Unwrap: We assert that we match biome's `IndentWidth` perfectly + biome_formatter::IndentWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::IndentWidth; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + indent_width: Option, + } + + #[test] + fn deserialize_indent_width() -> Result<()> { + let options: Options = toml::from_str( + r" +indent-width = 2 +", + )?; + + assert_eq!( + options.indent_width, + Some(IndentWidth::try_from(2).unwrap()) + ); + + Ok(()) + } + + #[test] + fn deserialize_oob_indent_width() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +indent-width = 25 +", + ); + let error = result.err().context("Expected OOB `IndentWidth` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/line_ending.rs b/crates/workspace/src/settings/line_ending.rs new file mode 100644 index 00000000..b2bcf870 --- /dev/null +++ b/crates/workspace/src/settings/line_ending.rs @@ -0,0 +1,31 @@ +use std::fmt; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LineEnding { + /// The newline style is detected automatically on a file per file basis. + /// Files with mixed line endings will be converted to the first detected line ending. + /// Defaults to [`LineEnding::Lf`] for a files that contain no line endings. + #[default] + Auto, + + /// Line endings will be converted to `\n` as is common on Unix. + Lf, + + /// Line endings will be converted to `\r\n` as is common on Windows. + Crlf, + + /// Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + Native, +} + +impl fmt::Display for LineEnding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Lf => write!(f, "lf"), + Self::Crlf => write!(f, "crlf"), + Self::Native => write!(f, "native"), + } + } +} diff --git a/crates/workspace/src/settings/line_length.rs b/crates/workspace/src/settings/line_length.rs new file mode 100644 index 00000000..934ef64d --- /dev/null +++ b/crates/workspace/src/settings/line_length.rs @@ -0,0 +1,145 @@ +use std::fmt; +use std::num::NonZeroU16; + +/// Validated value for the `line-length` formatter options +/// +/// The allowed range of values is 1..=320 +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct LineLength(NonZeroU16); + +impl LineLength { + /// Maximum allowed value for a valid [LineLength] + const MAX: u16 = 320; + + /// Return the numeric value for this [LineLength] + pub fn value(&self) -> u16 { + self.0.get() + } +} + +impl Default for LineLength { + fn default() -> Self { + Self(NonZeroU16::new(80).unwrap()) + } +} + +impl std::fmt::Debug for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for LineLength { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u16 = serde::Deserialize::deserialize(deserializer)?; + let line_length = LineLength::try_from(value).map_err(serde::de::Error::custom)?; + Ok(line_length) + } +} + +/// Error type returned when converting a u16 or NonZeroU16 to a [`LineLength`] fails +#[derive(Clone, Copy, Debug)] +pub struct LineLengthFromIntError(u16); + +impl std::error::Error for LineLengthFromIntError {} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: u16) -> Result { + match NonZeroU16::try_from(value) { + Ok(value) => LineLength::try_from(value), + Err(_) => Err(LineLengthFromIntError(value)), + } + } +} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: NonZeroU16) -> Result { + if value.get() <= Self::MAX { + Ok(LineLength(value)) + } else { + Err(LineLengthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for LineLengthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The line length must be a value between 1 and {max}, not {value}.", + max = LineLength::MAX, + value = self.0 + ) + } +} + +impl From for u16 { + fn from(value: LineLength) -> Self { + value.0.get() + } +} + +impl From for NonZeroU16 { + fn from(value: LineLength) -> Self { + value.0 + } +} + +impl From for biome_formatter::LineWidth { + fn from(value: LineLength) -> Self { + // Unwrap: We assert that we match biome's `LineWidth` perfectly + biome_formatter::LineWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::LineLength; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + line_length: Option, + } + + #[test] + fn deserialize_line_length() -> Result<()> { + let options: Options = toml::from_str( + r" +line-length = 50 +", + )?; + + assert_eq!(options.line_length, Some(LineLength::try_from(50).unwrap())); + + Ok(()) + } + + #[test] + fn deserialize_oob_line_length() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +line-length = 400 +", + ); + let error = result.err().context("Expected OOB `LineLength` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/magic_line_break.rs b/crates/workspace/src/settings/magic_line_break.rs new file mode 100644 index 00000000..e6d26d27 --- /dev/null +++ b/crates/workspace/src/settings/magic_line_break.rs @@ -0,0 +1,53 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} + +impl From for air_r_formatter::options::MagicLineBreak { + fn from(value: MagicLineBreak) -> Self { + match value { + MagicLineBreak::Respect => air_r_formatter::options::MagicLineBreak::Respect, + MagicLineBreak::Ignore => air_r_formatter::options::MagicLineBreak::Ignore, + } + } +} diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap new file mode 100644 index 00000000..dad86226 --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/indent_width.rs +expression: error +--- +TOML parse error at line 2, column 16 + | +2 | indent-width = 25 + | ^^ +The indent width must be a value between 1 and 24, not 25. diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap new file mode 100644 index 00000000..9570385c --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/line_length.rs +expression: error +--- +TOML parse error at line 2, column 15 + | +2 | line-length = 400 + | ^^^ +The line length must be a value between 1 and 320, not 400. diff --git a/crates/workspace/src/toml.rs b/crates/workspace/src/toml.rs new file mode 100644 index 00000000..2cfb5ca9 --- /dev/null +++ b/crates/workspace/src/toml.rs @@ -0,0 +1,111 @@ +//! Utilities for locating (and extracting configuration from) an air.toml. + +use crate::toml_options::TomlOptions; +use std::fmt::Display; +use std::fmt::Formatter; +use std::io; +use std::path::{Path, PathBuf}; + +/// Parse an `air.toml` file. +pub fn parse_air_toml>(path: P) -> Result { + let contents = std::fs::read_to_string(path.as_ref()) + .map_err(|err| ParseTomlError::Read(path.as_ref().to_path_buf(), err))?; + + toml::from_str(&contents) + .map_err(|err| ParseTomlError::Deserialize(path.as_ref().to_path_buf(), err)) +} + +#[derive(Debug)] +pub enum ParseTomlError { + Read(PathBuf, io::Error), + Deserialize(PathBuf, toml::de::Error), +} + +impl std::error::Error for ParseTomlError {} + +impl Display for ParseTomlError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + // It's nicer if we don't make these paths relative, so we can quickly + // jump to the TOML file to see what is wrong + Self::Read(path, err) => { + write!(f, "Failed to read {path}:\n{err}", path = path.display()) + } + Self::Deserialize(path, err) => { + write!(f, "Failed to parse {path}:\n{err}", path = path.display()) + } + } + } +} + +/// Return the path to the `air.toml` file in a given directory. +pub fn find_air_toml_in_directory>(path: P) -> Option { + // Check for `air.toml`. + let toml = path.as_ref().join("air.toml"); + + if toml.is_file() { + Some(toml) + } else { + None + } +} + +/// Find the path to the closest `air.toml` if one exists, walking up the filesystem +pub fn find_air_toml>(path: P) -> Option { + for directory in path.as_ref().ancestors() { + if let Some(toml) = find_air_toml_in_directory(directory) { + return Some(toml); + } + } + None +} + +#[cfg(test)] +mod tests { + use anyhow::{Context, Result}; + use std::fs; + use tempfile::TempDir; + + use crate::settings::LineEnding; + use crate::toml::find_air_toml; + use crate::toml::parse_air_toml; + use crate::toml_options::TomlOptions; + + #[test] + + fn deserialize_empty() -> Result<()> { + let options: TomlOptions = toml::from_str(r"")?; + assert_eq!(options.global.indent_width, None); + assert_eq!(options.global.line_length, None); + assert_eq!(options.format, None); + Ok(()) + } + + #[test] + fn find_and_parse_air_toml() -> Result<()> { + let tempdir = TempDir::new()?; + let toml = tempdir.path().join("air.toml"); + fs::write( + toml, + r#" +line-length = 88 + +[format] +line-ending = "auto" +"#, + )?; + + let toml = find_air_toml(tempdir.path()).context("Failed to find air.toml")?; + let options = parse_air_toml(toml)?; + + let line_ending = options + .format + .context("Expected to find [format] table")? + .line_ending + .context("Expected to find `line-ending` field")?; + + assert_eq!(line_ending, LineEnding::Auto); + + Ok(()) + } +} diff --git a/crates/workspace/src/toml_options.rs b/crates/workspace/src/toml_options.rs new file mode 100644 index 00000000..fb397d6a --- /dev/null +++ b/crates/workspace/src/toml_options.rs @@ -0,0 +1,121 @@ +use crate::settings::FormatSettings; +use crate::settings::IndentStyle; +use crate::settings::IndentWidth; +use crate::settings::LineEnding; +use crate::settings::LineLength; +use crate::settings::MagicLineBreak; +use crate::settings::Settings; + +/// The Rust representation of `air.toml` +/// +/// The names and types of the fields in this struct determine the names and types +/// that can be specified in the `air.toml`. +/// +/// Every field is optional at this point, nothing is "finalized". +/// Finalization is done in [TomlOptions::into_settings]. +/// +/// Global options are specified at top level in the TOML file. +/// All other options are nested within their own `[table]`. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct TomlOptions { + /// Global options affecting multiple commands. + #[serde(flatten)] + pub global: GlobalTomlOptions, + + /// Options to configure code formatting. + pub format: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct GlobalTomlOptions { + /// The line length at which the formatter prefers to wrap lines. + /// + /// The value must be greater than or equal to `1` and less than or equal to `320`. + /// + /// Note: While the formatter will attempt to format lines such that they remain + /// within the `line-length`, it isn't a hard upper bound, and formatted lines may + /// exceed the `line-length`. + pub line_length: Option, + + /// The number of spaces per indentation level (tab). + /// + /// The value must be greater than or equal to `1` and less than or equal to `24`. + /// + /// Used by the formatter to determine the visual width of a tab. + /// + /// This option changes the number of spaces the formatter inserts when + /// using `indent-style = "space"`. It also represents the width of a tab when + /// `indent-style = "tab"` for the purposes of computing the `line-length`. + pub indent_width: Option, +} + +/// Configures the way air formats your code. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct FormatTomlOptions { + /// Whether to use spaces or tabs for indentation. + /// + /// `indent-style = "tab"` (default): + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # A tab `\t` indents the `cat()` call. + /// } + /// ``` + /// + /// `indent-style = "space"`: + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # Spaces indent the `cat()` call. + /// } + /// ``` + /// + /// We recommend you use tabs for accessibility. + /// + /// See `indent-width` to configure the number of spaces per indentation and the tab width. + pub indent_style: Option, + + /// The character air uses at the end of a line. + /// + /// * `auto`: The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to `\n` for files that contain no line endings. + /// * `lf`: Line endings will be converted to `\n`. The default line ending on Unix. + /// * `cr-lf`: Line endings will be converted to `\r\n`. The default line ending on Windows. + /// * `native`: Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + pub line_ending: Option, + + /// Air respects a small set of magic line breaks as an indication that certain + /// function calls or function signatures should be left expanded. If this option + /// is set to `true`, magic line breaks are ignored. + /// + /// It may be preferable to ignore magic line breaks if you prefer that `line-length` + /// should be the only value that influences line breaks. + pub ignore_magic_line_break: Option, +} + +impl TomlOptions { + pub fn into_settings(self) -> Settings { + let format = self.format.unwrap_or_default(); + + let format = FormatSettings { + indent_style: format.indent_style.unwrap_or_default(), + indent_width: self.global.indent_width.unwrap_or_default(), + line_ending: format.line_ending.unwrap_or_default(), + line_length: self.global.line_length.unwrap_or_default(), + magic_line_break: match format.ignore_magic_line_break { + Some(ignore_magic_line_break) => { + if ignore_magic_line_break { + MagicLineBreak::Ignore + } else { + MagicLineBreak::Respect + } + } + None => MagicLineBreak::Respect, + }, + }; + + Settings { format } + } +} diff --git a/editors/code/src/lsp.ts b/editors/code/src/lsp.ts index f37fc43c..3c93faef 100644 --- a/editors/code/src/lsp.ts +++ b/editors/code/src/lsp.ts @@ -65,11 +65,6 @@ export class Lsp { { language: "r", pattern: "**/*.{r,R}" }, { language: "r", pattern: "**/*.{rprofile,Rprofile}" }, ], - synchronize: { - // Notify the server about file changes to R files contained in the workspace - fileEvents: - vscode.workspace.createFileSystemWatcher("**/*.[Rr]"), - }, outputChannel: this.channel, };