diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 33e6451f..4ca28ea9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -50,7 +50,9 @@ jobs: - host: ubuntu-latest target: x86_64-unknown-linux-gnu docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian - build: |- + build: | + rustup install 1.86.0 && + rustup default 1.86.0 && set -e && yarn build --target x86_64-unknown-linux-gnu && strip *.node @@ -165,10 +167,8 @@ jobs: crates/uroborosql-fmt-wasm/pkg/uroborosql_fmt_wasm_bg.wasm if-no-files-found: error - deploy-to-gh-pages: - if: github.repository == 'future-architect/uroborosql-fmt' && - contains('refs/heads/main', github.ref) && github.event_name == 'push' - needs: [build-wasm, build-napi] + package-napi: + needs: build-napi runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -184,7 +184,6 @@ jobs: run: yarn install # *.node のダウンロード - # TODO: まとめてダウンロードできないか? - name: Download .node file for aarch64-apple-darwin uses: actions/download-artifact@v4 with: @@ -222,9 +221,28 @@ jobs: cd repo npm pack cd .. - cd .. - cd .. - mv ${{ env.WORKING_DIR }}/repo/*.tgz ./wasm + mv repo/*.tgz ./ + + - name: Upload npm pack artifact + uses: actions/upload-artifact@v4 + with: + name: napi-package + path: ${{ env.WORKING_DIR }}/*.tgz + if-no-files-found: error + + deploy-to-gh-pages: + if: github.repository == 'future-architect/uroborosql-fmt' && + contains('refs/heads/main', github.ref) && github.event_name == 'push' + needs: [build-wasm, package-napi] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Download npm pack artifact + uses: actions/download-artifact@v4 + with: + name: napi-package + path: ./wasm - name: Download wasm uses: actions/download-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index 2fbf1ede..db405ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "assert_cmd" version = "2.0.17" @@ -101,12 +107,34 @@ dependencies = [ "tempfile", ] +[[package]] +name = "async-codec-lite" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2527c30e3972d8ff366b353125dae828c4252a154dbe6063684f6c5e014760a3" +dependencies = [ + "anyhow", + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "log", + "pin-project-lite", + "thiserror", +] + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.4.0" @@ -136,6 +164,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "cfg-if" version = "1.0.0" @@ -173,7 +207,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.33", + "syn 2.0.108", ] [[package]] @@ -220,6 +254,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "cstree" version = "0.12.2" @@ -243,7 +283,7 @@ checksum = "84d8f6eaf2917e8bf0173045fe7824c0809e21ef09dc721108da4ee67ce7494b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.33", + "syn 2.0.108", ] [[package]] @@ -253,7 +293,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f34ba9a9bcb8645379e9de8cb3ecfcf4d1c85ba66d90deb3259206fa5aa193b" dependencies = [ "quote", - "syn 2.0.33", + "syn 2.0.108", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -311,12 +365,98 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[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 = "fxhash" version = "0.2.1" @@ -345,7 +485,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.4.0", "ignore", "walkdir", ] @@ -356,6 +496,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.2" @@ -368,6 +514,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "ignore" version = "0.4.20" @@ -470,6 +622,19 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "memchr" version = "2.6.3" @@ -482,7 +647,7 @@ version = "2.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd063c93b900149304e3ba96ce5bf210cd4f81ef5eb80ded0d100df3e85a3ac0" dependencies = [ - "bitflags", + "bitflags 2.4.0", "ctor", "napi-derive", "napi-sys", @@ -583,6 +748,24 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "postgresql-cst-parser" version = "0.2.0" @@ -623,18 +806,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.67" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -645,7 +828,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.4.0", ] [[package]] @@ -677,13 +860,23 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.4.0", "errno", "libc", "linux-raw-sys", @@ -740,7 +933,7 @@ checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.33", + "syn 2.0.108", ] [[package]] @@ -754,6 +947,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "similar" version = "2.7.0" @@ -764,6 +968,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.13.2" @@ -782,6 +992,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strsim" version = "0.10.0" @@ -801,15 +1017,21 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.33" +version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9caece70c63bfba29ec2fed841a09851b14a235c60010fa4de58089b6c025668" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "tempfile" version = "3.10.1" @@ -851,7 +1073,7 @@ checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" dependencies = [ "proc-macro2", "quote", - "syn 2.0.33", + "syn 2.0.108", ] [[package]] @@ -863,6 +1085,120 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "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-server" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f3f8ec0dcfdda4d908bad2882fe0f89cf2b606e78d16491323e918dfa95765" +dependencies = [ + "async-codec-lite", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + [[package]] name = "triomphe" version = "0.1.14" @@ -931,7 +1267,9 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "tokio", "uroborosql-fmt", + "uroborosql-language-server", ] [[package]] @@ -943,6 +1281,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uroborosql-language-server" +version = "0.1.0" +dependencies = [ + "ropey", + "serde_json", + "tokio", + "tower-lsp-server", + "uroborosql-fmt", + "uroborosql-lint", +] + [[package]] name = "uroborosql-lint" version = "0.1.0" @@ -984,35 +1334,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.33", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1020,22 +1357,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.33", - "wasm-bindgen-backend", + "syn 2.0.108", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index e7389afd..05b09d16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ members = [ "crates/uroborosql-fmt-napi", "crates/uroborosql-fmt-wasm", "crates/uroborosql-lint", - "crates/uroborosql-lint-cli" + "crates/uroborosql-lint-cli", + "crates/uroborosql-language-server" ] resolver = "2" @@ -18,6 +19,7 @@ repository = "https://github.com/future-architect/uroborosql-fmt" [workspace.dependencies] # Internal crates uroborosql-fmt = { path = "./crates/uroborosql-fmt" } +uroborosql-language-server = { path = "./crates/uroborosql-language-server", default-features = false } postgresql-cst-parser = { git = "https://github.com/future-architect/postgresql-cst-parser", branch = "feat/new-apis-for-linter" } [profile.release] diff --git a/crates/uroborosql-fmt-napi/Cargo.toml b/crates/uroborosql-fmt-napi/Cargo.toml index 7a7ba96e..e33de52b 100644 --- a/crates/uroborosql-fmt-napi/Cargo.toml +++ b/crates/uroborosql-fmt-napi/Cargo.toml @@ -11,6 +11,8 @@ crate-type = ["cdylib"] napi = { version = "2.12.2", default-features = false, features = ["napi4"] } napi-derive = "2.12.2" uroborosql-fmt = { workspace = true } +uroborosql-language-server = { workspace = true, features = ["runtime-tokio"] } +tokio = { version = "1", features = ["rt-multi-thread"] } [build-dependencies] napi-build = "2.0.1" diff --git a/crates/uroborosql-fmt-napi/index.d.ts b/crates/uroborosql-fmt-napi/index.d.ts index 05dda234..b1c04952 100644 --- a/crates/uroborosql-fmt-napi/index.d.ts +++ b/crates/uroborosql-fmt-napi/index.d.ts @@ -5,3 +5,4 @@ export function runfmt(input: string, configPath?: string | undefined | null): string export function runfmtWithSettings(input: string, settingsJson: string, configPath?: string | undefined | null): string +export function runLanguageServer(): void diff --git a/crates/uroborosql-fmt-napi/index.js b/crates/uroborosql-fmt-napi/index.js index 7e1a85ac..26051c7a 100644 --- a/crates/uroborosql-fmt-napi/index.js +++ b/crates/uroborosql-fmt-napi/index.js @@ -252,7 +252,8 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { runfmt, runfmtWithSettings } = nativeBinding +const { runfmt, runfmtWithSettings, runLanguageServer } = nativeBinding module.exports.runfmt = runfmt module.exports.runfmtWithSettings = runfmtWithSettings +module.exports.runLanguageServer = runLanguageServer diff --git a/crates/uroborosql-fmt-napi/src/lib.rs b/crates/uroborosql-fmt-napi/src/lib.rs index c553d7a0..607e521d 100644 --- a/crates/uroborosql-fmt-napi/src/lib.rs +++ b/crates/uroborosql-fmt-napi/src/lib.rs @@ -1,18 +1,23 @@ #![deny(clippy::all)] use napi::{Error, Result, Status}; +use tokio::runtime::Runtime; use uroborosql_fmt::format_sql; #[macro_use] extern crate napi_derive; +fn generic_error(err: E) -> Error { + Error::new(Status::GenericFailure, format!("{err}")) +} + #[napi] pub fn runfmt(input: String, config_path: Option<&str>) -> Result { let result = format_sql(&input, None, config_path); match result { Ok(res) => Ok(res), - Err(e) => Err(Error::new(Status::GenericFailure, format!("{e}"))), + Err(e) => Err(generic_error(e)), } } @@ -22,6 +27,12 @@ pub fn runfmt_with_settings( settings_json: String, config_path: Option<&str>, ) -> Result { - format_sql(&input, Some(&settings_json), config_path) - .map_err(|e| Error::new(Status::GenericFailure, format!("{e}"))) + format_sql(&input, Some(&settings_json), config_path).map_err(generic_error) +} + +#[napi] +pub fn run_language_server() -> Result<()> { + let runtime = Runtime::new().map_err(generic_error)?; + runtime.block_on(async { uroborosql_language_server::run_stdio().await }); + Ok(()) } diff --git a/crates/uroborosql-language-server/Cargo.toml b/crates/uroborosql-language-server/Cargo.toml new file mode 100644 index 00000000..c5036475 --- /dev/null +++ b/crates/uroborosql-language-server/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "uroborosql-language-server" +version = "0.1.0" +edition = "2021" + +[features] +default = ["runtime-tokio"] +# for native desktop (use Tokio runtime) +runtime-tokio = ["tokio", "tower-lsp-server/runtime-tokio"] +# for WASM/browser (runtime-agnostic, no Tokio) +runtime-agnostic = ["tower-lsp-server/runtime-agnostic"] + +[dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "time"], optional = true } +tower-lsp-server = { version = "0.22.1", default-features = false } +ropey = "1.6.1" +uroborosql-fmt = { path = "../uroborosql-fmt" } +uroborosql-lint = { path = "../uroborosql-lint" } + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/uroborosql-language-server/src/lib.rs b/crates/uroborosql-language-server/src/lib.rs new file mode 100644 index 00000000..bdb8bf94 --- /dev/null +++ b/crates/uroborosql-language-server/src/lib.rs @@ -0,0 +1,151 @@ +mod server; +mod text; + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use ropey::Rope; +use tower_lsp_server::lsp_types::Uri; +use tower_lsp_server::lsp_types::*; +pub use tower_lsp_server::ClientSocket; +#[cfg(feature = "runtime-tokio")] +use tower_lsp_server::Server; +use tower_lsp_server::{Client, LspService}; +use uroborosql_lint::{Diagnostic as SqlDiagnostic, LintError, Linter, Severity as SqlSeverity}; + +use crate::text::rope_range_to_char_range; + +#[derive(Clone)] +pub struct Backend { + client: Client, + linter: Arc, + documents: Arc>>, +} + +#[derive(Clone)] +struct DocumentState { + rope: Rope, + version: i32, +} + +impl Backend { + pub fn new(client: Client) -> Self { + Self { + client, + linter: Arc::new(Linter::new()), + documents: Arc::new(RwLock::new(HashMap::new())), + } + } + + async fn lint_and_publish(&self, uri: &Uri, text: &str, version: Option) { + let diagnostics = match self.linter.run(text) { + Ok(diags) => diags.into_iter().map(to_lsp_diagnostic).collect(), + Err(err) => vec![to_parse_error(err)], + }; + + self.client + .publish_diagnostics(uri.clone(), diagnostics, version) + .await; + } + + fn upsert_document(&self, uri: &Uri, text: &str, version: Option) { + let resolved_version = version.or_else(|| { + self.documents + .read() + .ok() + .and_then(|docs| docs.get(uri).map(|doc| doc.version)) + }); + let version = resolved_version.unwrap_or_default(); + + if let Ok(mut docs) = self.documents.write() { + docs.insert( + uri.clone(), + DocumentState { + rope: Rope::from_str(text), + version, + }, + ); + } + } + + fn apply_change(&self, uri: &Uri, change: TextDocumentContentChangeEvent, version: i32) { + if let Ok(mut docs) = self.documents.write() { + if let Some(doc) = docs.get_mut(uri) { + if version < doc.version { + return; + } + doc.version = version; + if let Some(range) = change.range { + if let Some((start, end)) = rope_range_to_char_range(&doc.rope, &range) { + doc.rope.remove(start..end); + doc.rope.insert(start, &change.text); + } + } else { + doc.rope = Rope::from_str(&change.text); + } + } + } + } + + fn remove_document(&self, uri: &Uri) { + if let Ok(mut docs) = self.documents.write() { + docs.remove(uri); + } + } + + fn document_rope(&self, uri: &Uri) -> Option { + self.documents + .read() + .ok() + .and_then(|docs| docs.get(uri).map(|doc| doc.rope.clone())) + } + + fn document_text(&self, uri: &Uri) -> Option { + self.document_rope(uri).map(|rope| rope.to_string()) + } +} + +#[cfg(feature = "runtime-tokio")] +pub async fn run_stdio() { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(Backend::new); + Server::new(stdin, stdout, socket).serve(service).await; +} + +fn to_lsp_diagnostic(diag: SqlDiagnostic) -> Diagnostic { + let severity = match diag.severity { + SqlSeverity::Error => Some(DiagnosticSeverity::ERROR), + SqlSeverity::Warning => Some(DiagnosticSeverity::WARNING), + SqlSeverity::Info => Some(DiagnosticSeverity::INFORMATION), + }; + + let range = Range { + start: Position::new(diag.span.start.line as u32, diag.span.start.column as u32), + end: Position::new(diag.span.end.line as u32, diag.span.end.column as u32), + }; + + Diagnostic { + range, + severity, + code: Some(NumberOrString::String(diag.rule_id.to_string())), + source: Some("uroborosql-lint".into()), + message: diag.message, + ..Diagnostic::default() + } +} + +fn to_parse_error(err: LintError) -> Diagnostic { + let message = match err { + LintError::ParseError(reason) => format!("Failed to parse SQL: {reason}"), + }; + + Diagnostic { + range: Range::default(), + severity: Some(DiagnosticSeverity::ERROR), + source: Some("uroborosql-lint".into()), + message, + ..Diagnostic::default() + } +} diff --git a/crates/uroborosql-language-server/src/main.rs b/crates/uroborosql-language-server/src/main.rs new file mode 100644 index 00000000..f8642245 --- /dev/null +++ b/crates/uroborosql-language-server/src/main.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "runtime-tokio")] +#[tokio::main] +async fn main() { + uroborosql_language_server::run_stdio().await; +} + +#[cfg(not(feature = "runtime-tokio"))] +fn main() { + panic!("binary entrypoint requires the `runtime-tokio` feature"); +} diff --git a/crates/uroborosql-language-server/src/server.rs b/crates/uroborosql-language-server/src/server.rs new file mode 100644 index 00000000..defd8d45 --- /dev/null +++ b/crates/uroborosql-language-server/src/server.rs @@ -0,0 +1,184 @@ +use crate::text::rope_char_to_position; +use crate::text::rope_position_to_char; +use crate::Backend; +use tower_lsp_server::jsonrpc::Result; +use tower_lsp_server::lsp_types::*; +use tower_lsp_server::LanguageServer; +use uroborosql_fmt::format_sql; + +impl LanguageServer for Backend { + async fn initialize(&self, _: InitializeParams) -> Result { + let sync_options = TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { + include_text: Some(true), + })), + ..TextDocumentSyncOptions::default() + }; + + let capabilities = ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Options(sync_options)), + document_formatting_provider: Some(OneOf::Left(true)), + document_range_formatting_provider: Some(OneOf::Left(true)), + ..ServerCapabilities::default() + }; + + Ok(InitializeResult { + capabilities, + server_info: Some(ServerInfo { + name: "uroborosql-language-server".into(), + version: None, + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "uroborosql-language-server initialized") + .await; + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let text_document = params.text_document; + let uri = text_document.uri; + let version = text_document.version; + let text = text_document.text; + + self.upsert_document(&uri, &text, Some(version)); + + self.lint_and_publish(&uri, &text, Some(version)).await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + if params.content_changes.is_empty() { + return; + } + + let uri = params.text_document.uri; + let version = params.text_document.version; + for change in params.content_changes { + if change.range.is_some() { + self.apply_change(&uri, change, version); + } else { + self.upsert_document(&uri, &change.text, Some(version)); + } + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + self.remove_document(&uri); + self.client.publish_diagnostics(uri, vec![], None).await; + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + let uri = params.text_document.uri; + if let Some(text) = params.text { + self.upsert_document(&uri, &text, None); + self.lint_and_publish(&uri, &text, None).await; + } else if let Some(text) = self.document_text(&uri) { + self.lint_and_publish(&uri, &text, None).await; + } else { + self.client + .log_message( + MessageType::WARNING, + "didSave received without text; skipping lint", + ) + .await; + } + } + + async fn code_action(&self, _: CodeActionParams) -> Result> { + Ok(None) + } + + async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { + let uri = params.text_document.uri; + let rope = match self.document_rope(&uri) { + Some(rope) => rope, + None => return Ok(None), + }; + let text = rope.to_string(); + + match format_sql(&text, None, None) { + Ok(formatted) => { + if formatted == text { + return Ok(Some(vec![])); + } + + let end = rope_char_to_position(&rope, rope.len_chars()); + // replace the entire document + let edit = TextEdit { + range: Range { + start: Position::new(0, 0), + end, + }, + new_text: formatted, + }; + + Ok(Some(vec![edit])) + } + Err(err) => { + self.client + .log_message( + MessageType::ERROR, + format!("formatting failed for {}: {err}", uri.as_str()), + ) + .await; + Ok(None) + } + } + } + + async fn range_formatting( + &self, + params: DocumentRangeFormattingParams, + ) -> Result>> { + let uri = params.text_document.uri; + let rope = match self.document_rope(&uri) { + Some(rope) => rope, + None => return Ok(None), + }; + + let start_char = match rope_position_to_char(&rope, params.range.start) { + Some(pos) => pos, + None => return Ok(None), + }; + let end_char = match rope_position_to_char(&rope, params.range.end) { + Some(pos) => pos, + None => return Ok(None), + }; + if start_char > end_char || end_char > rope.len_chars() { + return Ok(None); + } + + let slice = rope.slice(start_char..end_char).to_string(); + // ignore settings for now + match format_sql(&slice, None, None) { + Ok(formatted) => { + let edit = TextEdit { + range: Range { + start: rope_char_to_position(&rope, start_char), + end: rope_char_to_position(&rope, end_char), + }, + new_text: formatted, + }; + Ok(Some(vec![edit])) + } + Err(err) => { + self.client + .log_message( + MessageType::ERROR, + format!("range formatting failed for {}: {err}", uri.as_str()), + ) + .await; + Ok(None) + } + } + } +} diff --git a/crates/uroborosql-language-server/src/text.rs b/crates/uroborosql-language-server/src/text.rs new file mode 100644 index 00000000..b740e3aa --- /dev/null +++ b/crates/uroborosql-language-server/src/text.rs @@ -0,0 +1,48 @@ +use ropey::Rope; +use tower_lsp_server::lsp_types::{Position, Range}; + +/// Converts an LSP position (line/character) into a char index within the Rope. +/// Returns `None` if the requested line/character falls outside the current document. +pub fn rope_position_to_char(rope: &Rope, position: Position) -> Option { + let line = position.line as usize; + let column = position.character as usize; + let line_count = rope.len_lines(); + + if line > line_count { + return None; + } + + if line == line_count { + return if column == 0 { + Some(rope.len_chars()) + } else { + None + }; + } + + let line_start = rope.line_to_char(line); + let line_len = rope.line(line).len_chars(); + if column > line_len { + None + } else { + Some(line_start + column) + } +} + +/// Converts a Rope char index into an LSP position (line/character). +/// Clamps the index to the end of the document if it exceeds `len_chars`. +pub fn rope_char_to_position(rope: &Rope, idx: usize) -> Position { + let total_chars = rope.len_chars(); + let clamped = idx.min(total_chars); + let line = rope.char_to_line(clamped); + let line_start = rope.line_to_char(line); + Position::new(line as u32, (clamped - line_start) as u32) +} + +/// Converts an LSP range into a pair of Rope char indices. +/// Returns `None` if either endpoint of the range is invalid within the document. +pub fn rope_range_to_char_range(rope: &Rope, range: &Range) -> Option<(usize, usize)> { + let start = rope_position_to_char(rope, range.start)?; + let end = rope_position_to_char(rope, range.end)?; + Some((start, end)) +} diff --git a/crates/uroborosql-language-server/tests/lsp_flow.rs b/crates/uroborosql-language-server/tests/lsp_flow.rs new file mode 100644 index 00000000..c2b96196 --- /dev/null +++ b/crates/uroborosql-language-server/tests/lsp_flow.rs @@ -0,0 +1,326 @@ +use std::collections::VecDeque; +use std::str::FromStr; +use std::time::Duration; + +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; +use tokio::time; +use tower_lsp_server::jsonrpc::{Request, Response}; +use tower_lsp_server::lsp_types::Uri; +use tower_lsp_server::lsp_types::*; +use tower_lsp_server::{Client, LanguageServer, LspService, Server}; +use uroborosql_language_server::Backend; + +struct TestServer { + req_stream: DuplexStream, + res_stream: DuplexStream, + responses: VecDeque, + notifications: VecDeque, +} + +impl TestServer { + fn new(init: F) -> Self + where + F: FnOnce(Client) -> S, + S: LanguageServer, + { + let (req_client, req_server) = tokio::io::duplex(2048); + let (res_server, res_client) = tokio::io::duplex(2048); + + let (service, socket) = LspService::new(init); + tokio::spawn(async move { + Server::new(req_server, res_server, socket) + .serve(service) + .await + }); + + Self { + req_stream: req_client, + res_stream: res_client, + responses: VecDeque::new(), + notifications: VecDeque::new(), + } + } + + fn encode(payload: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload).into_bytes() + } + + fn decode(buffer: &[u8]) -> Vec { + let mut remainder = buffer; + let mut frames = Vec::new(); + while !remainder.is_empty() { + let sep = match remainder.windows(4).position(|w| w == b"\r\n\r\n") { + Some(idx) => idx + 4, + None => break, + }; + let (header, body) = remainder.split_at(sep); + let len = std::str::from_utf8(header) + .unwrap() + .strip_prefix("Content-Length: ") + .unwrap() + .strip_suffix("\r\n\r\n") + .unwrap() + .parse::() + .unwrap(); + let (payload, rest) = body.split_at(len); + frames.push(String::from_utf8(payload.to_vec()).unwrap()); + remainder = rest; + } + frames + } + + async fn send_request(&mut self, req: Request) { + let payload = serde_json::to_string(&req).unwrap(); + self.req_stream + .write_all(&Self::encode(&payload)) + .await + .unwrap(); + } + + async fn receive_response(&mut self) -> Response { + loop { + if let Some(buffer) = self.responses.pop_back() { + return serde_json::from_str(&buffer).unwrap(); + } + + self.read_into_queues().await; + } + } + + async fn receive_notification(&mut self) -> Request { + loop { + if let Some(buffer) = self.notifications.pop_back() { + return serde_json::from_str(&buffer).unwrap(); + } + + self.read_into_queues().await; + } + } + + async fn read_into_queues(&mut self) { + let mut buf = vec![0u8; 4096]; + let n = self.res_stream.read(&mut buf).await.unwrap(); + for frame in Self::decode(&buf[..n]) { + let value: serde_json::Value = serde_json::from_str(&frame).unwrap(); + if value.get("id").is_some() { + self.responses.push_front(frame); + } else { + self.notifications.push_front(frame); + } + } + } + + async fn receive_notification_timeout(&mut self, dur: Duration) -> Option { + match time::timeout(dur, self.receive_notification()).await { + Ok(req) => Some(req), + Err(_) => None, + } + } +} + +fn build_initialize(id: i64) -> Request { + Request::build("initialize") + .params(json!(InitializeParams::default())) + .id(id) + .finish() +} + +fn build_initialized() -> Request { + Request::build("initialized") + .params(json!(InitializedParams {})) + .finish() +} + +fn build_did_open(uri: &Uri, text: &str, version: i32) -> Request { + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "sql".into(), + version, + text: text.into(), + }, + }; + Request::build("textDocument/didOpen") + .params(json!(params)) + .finish() +} + +fn build_did_change(uri: &Uri, version: i32, text: &str) -> Request { + let params = DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + uri: uri.clone(), + version, + }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: text.into(), + }], + }; + Request::build("textDocument/didChange") + .params(json!(params)) + .finish() +} + +fn build_did_save(uri: &Uri, text: &str) -> Request { + let params = DidSaveTextDocumentParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + text: Some(text.into()), + }; + Request::build("textDocument/didSave") + .params(json!(params)) + .finish() +} + +fn build_formatting(uri: &Uri, id: i64) -> Request { + let params = DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + options: FormattingOptions { + tab_size: 2, + insert_spaces: true, + ..FormattingOptions::default() + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + Request::build("textDocument/formatting") + .params(json!(params)) + .id(id) + .finish() +} + +fn build_range_formatting(uri: &Uri, range: Range, id: i64) -> Request { + let params = DocumentRangeFormattingParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + range, + options: FormattingOptions { + tab_size: 2, + insert_spaces: true, + ..FormattingOptions::default() + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + Request::build("textDocument/rangeFormatting") + .params(json!(params)) + .id(id) + .finish() +} + +#[tokio::test] +async fn diagnostics_publish_on_open_and_save() { + let mut server = TestServer::new(Backend::new); + let uri = Uri::from_str("file:///test.sql").unwrap(); + + // initialize handshake + server.send_request(build_initialize(1)).await; + let init_res = server.receive_response().await; + assert!(init_res.is_ok()); + + server.send_request(build_initialized()).await; + let init_notification = server.receive_notification().await; + assert_eq!(init_notification.method(), "window/logMessage"); + + // didOpen triggers lint (initial diagnostics) + server + .send_request(build_did_open(&uri, "SELECT DISTINCT id FROM users;", 1)) + .await; + let diag_notification = server.receive_notification().await; + assert_eq!( + diag_notification.method(), + "textDocument/publishDiagnostics" + ); + let diagnostics = diag_notification.params().unwrap()["diagnostics"] + .as_array() + .unwrap() + .clone(); + assert!( + !diagnostics.is_empty(), + "expected diagnostics on didOpen, got none" + ); + + // didChange should not emit diagnostics + server + .send_request(build_did_change(&uri, 2, "SELECT DISTINCT id FROM users;")) + .await; + let change_notification = server + .receive_notification_timeout(Duration::from_millis(100)) + .await; + assert!( + change_notification.is_none(), + "didChange should not publish diagnostics" + ); + + // didSave should emit diagnostics again + server + .send_request(build_did_save(&uri, "SELECT DISTINCT id FROM users;")) + .await; + let save_notification = server.receive_notification().await; + assert_eq!( + save_notification.method(), + "textDocument/publishDiagnostics" + ); +} + +#[tokio::test] +async fn document_formatting_returns_edit() { + let mut server = TestServer::new(Backend::new); + let uri = Uri::from_str("file:///fmt.sql").unwrap(); + + server.send_request(build_initialize(1)).await; + assert!(server.receive_response().await.is_ok()); + server.send_request(build_initialized()).await; + let _ = server.receive_notification().await; + + let original = "select A from B"; + server.send_request(build_did_open(&uri, original, 1)).await; + let _ = server.receive_notification().await; + + server.send_request(build_formatting(&uri, 2)).await; + let response = server.receive_response().await; + assert!(response.is_ok()); + let value = serde_json::to_value(&response).unwrap(); + let edits = value["result"] + .as_array() + .expect("formatting result should be array"); + assert_eq!(edits.len(), 1); + let new_text = edits[0]["newText"].as_str().unwrap(); + assert_ne!( + new_text, original, + "formatted text should differ from original" + ); +} + +#[tokio::test] +async fn range_formatting_returns_edit() { + let mut server = TestServer::new(Backend::new); + let uri = Uri::from_str("file:///range.sql").unwrap(); + + server.send_request(build_initialize(1)).await; + assert!(server.receive_response().await.is_ok()); + server.send_request(build_initialized()).await; + let _ = server.receive_notification().await; + + let original = "select a from b;"; + server.send_request(build_did_open(&uri, original, 1)).await; + let _ = server.receive_notification().await; + + let range = Range { + start: Position::new(0, 0), + end: Position::new(0, original.len() as u32), + }; + server + .send_request(build_range_formatting(&uri, range, 2)) + .await; + let response = server.receive_response().await; + assert!(response.is_ok()); + let value = serde_json::to_value(&response).unwrap(); + let edits = value["result"] + .as_array() + .expect("range formatting should return edits"); + assert_eq!(edits.len(), 1); + assert_ne!( + edits[0]["newText"].as_str().unwrap(), + original, + "range formatting should rewrite selection" + ); +}