diff --git a/Cargo.lock b/Cargo.lock index b48cdd92..79834e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,9 +140,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" dependencies = [ "serde", ] @@ -168,17 +168,23 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" + [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "clap" -version = "4.5.45" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -186,9 +192,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -403,6 +409,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "djls" version = "5.2.0-alpha" @@ -425,7 +442,7 @@ dependencies = [ "directories", "serde", "tempfile", - "thiserror 2.0.15", + "thiserror 2.0.16", "toml", ] @@ -453,20 +470,25 @@ name = "djls-server" version = "0.0.0" dependencies = [ "anyhow", + "camino", + "dashmap", "djls-conf", "djls-dev", "djls-project", "djls-templates", + "djls-workspace", "percent-encoding", "pyo3", "salsa", "serde", "serde_json", + "tempfile", "tokio", "tower-lsp-server", "tracing", "tracing-appender", "tracing-subscriber", + "url", ] [[package]] @@ -477,10 +499,29 @@ dependencies = [ "insta", "serde", "tempfile", - "thiserror 2.0.15", + "thiserror 2.0.16", "toml", ] +[[package]] +name = "djls-workspace" +version = "0.0.0" +dependencies = [ + "anyhow", + "camino", + "dashmap", + "djls-project", + "djls-templates", + "notify", + "percent-encoding", + "salsa", + "tempfile", + "tokio", + "tower-lsp-server", + "tracing", + "url", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -564,6 +605,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.31" @@ -718,11 +777,118 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown 0.15.5", @@ -734,6 +900,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.3", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.43.1" @@ -757,11 +943,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "cfg-if", "libc", ] @@ -789,6 +975,26 @@ dependencies = [ "serde", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -807,7 +1013,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "libc", ] @@ -817,6 +1023,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.13" @@ -886,10 +1098,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.3", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -990,9 +1227,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -1001,7 +1238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.15", + "thiserror 2.0.16", "ucd-trie", ] @@ -1056,6 +1293,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1174,7 +1420,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", ] [[package]] @@ -1185,19 +1431,19 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.15", + "thiserror 2.0.16", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -1211,13 +1457,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -1228,9 +1474,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ron" @@ -1239,7 +1485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64", - "bitflags 2.9.2", + "bitflags 2.9.3", "serde", "serde_derive", ] @@ -1273,7 +1519,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "errno", "libc", "linux-raw-sys", @@ -1329,6 +1575,15 @@ dependencies = [ "synstructure", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1378,9 +1633,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -1465,6 +1720,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strsim" version = "0.11.1" @@ -1507,15 +1768,15 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1535,11 +1796,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.15", + "thiserror-impl 2.0.16", ] [[package]] @@ -1555,9 +1816,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -1613,6 +1874,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.47.1" @@ -1859,6 +2130,24 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1877,6 +2166,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1919,6 +2218,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2089,9 +2397,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -2108,9 +2416,15 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", ] +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "yaml-rust2" version = "0.10.3" @@ -2121,3 +2435,81 @@ dependencies = [ "encoding_rs", "hashlink", ] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 2a674a38..6d18e3b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ djls-dev = { path = "crates/djls-dev" } djls-project = { path = "crates/djls-project" } djls-server = { path = "crates/djls-server" } djls-templates = { path = "crates/djls-templates" } +djls-workspace = { path = "crates/djls-workspace" } # core deps, pin exact versions pyo3 = "0.25.0" @@ -17,9 +18,12 @@ salsa = "0.23.0" tower-lsp-server = { version = "0.22.0", features = ["proposed"] } anyhow = "1.0" +camino = "1.1" clap = { version = "4.5", features = ["derive"] } config = { version ="0.15", features = ["toml"] } +dashmap = "6.1" directories = "6.0" +notify = "8.2" percent-encoding = "2.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -29,6 +33,7 @@ toml = "0.9" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } +url = "2.5" which = "8.0" # testing diff --git a/crates/djls-server/Cargo.toml b/crates/djls-server/Cargo.toml index 396f0f38..7829bf05 100644 --- a/crates/djls-server/Cargo.toml +++ b/crates/djls-server/Cargo.toml @@ -11,8 +11,11 @@ default = [] djls-conf = { workspace = true } djls-project = { workspace = true } djls-templates = { workspace = true } +djls-workspace = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true } +dashmap = { workspace = true } percent-encoding = { workspace = true } pyo3 = { workspace = true } salsa = { workspace = true } @@ -23,9 +26,13 @@ tower-lsp-server = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } [build-dependencies] djls-dev = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [lints] workspace = true diff --git a/crates/djls-server/src/client.rs b/crates/djls-server/src/client.rs index 35eb8410..35e616fb 100644 --- a/crates/djls-server/src/client.rs +++ b/crates/djls-server/src/client.rs @@ -123,45 +123,38 @@ macro_rules! request { #[allow(dead_code)] pub mod messages { - use tower_lsp_server::lsp_types::MessageActionItem; - use tower_lsp_server::lsp_types::MessageType; - use tower_lsp_server::lsp_types::ShowDocumentParams; + use tower_lsp_server::lsp_types; use super::get_client; use super::Display; use super::Error; - notify!(log_message, message_type: MessageType, message: impl Display + Send + 'static); - notify!(show_message, message_type: MessageType, message: impl Display + Send + 'static); - request!(show_message_request, message_type: MessageType, message: impl Display + Send + 'static, actions: Option> ; Option); - request!(show_document, params: ShowDocumentParams ; bool); + notify!(log_message, message_type: lsp_types::MessageType, message: impl Display + Send + 'static); + notify!(show_message, message_type: lsp_types::MessageType, message: impl Display + Send + 'static); + request!(show_message_request, message_type: lsp_types::MessageType, message: impl Display + Send + 'static, actions: Option> ; Option); + request!(show_document, params: lsp_types::ShowDocumentParams ; bool); } #[allow(dead_code)] pub mod diagnostics { - use tower_lsp_server::lsp_types::Diagnostic; - use tower_lsp_server::lsp_types::Uri; + use tower_lsp_server::lsp_types; use super::get_client; - notify!(publish_diagnostics, uri: Uri, diagnostics: Vec, version: Option); + notify!(publish_diagnostics, uri: lsp_types::Uri, diagnostics: Vec, version: Option); notify_discard!(workspace_diagnostic_refresh,); } #[allow(dead_code)] pub mod workspace { - use tower_lsp_server::lsp_types::ApplyWorkspaceEditResponse; - use tower_lsp_server::lsp_types::ConfigurationItem; - use tower_lsp_server::lsp_types::LSPAny; - use tower_lsp_server::lsp_types::WorkspaceEdit; - use tower_lsp_server::lsp_types::WorkspaceFolder; + use tower_lsp_server::lsp_types; use super::get_client; use super::Error; - request!(apply_edit, edit: WorkspaceEdit ; ApplyWorkspaceEditResponse); - request!(configuration, items: Vec ; Vec); - request!(workspace_folders, ; Option>); + request!(apply_edit, edit: lsp_types::WorkspaceEdit ; lsp_types::ApplyWorkspaceEditResponse); + request!(configuration, items: Vec ; Vec); + request!(workspace_folders, ; Option>); } #[allow(dead_code)] @@ -176,19 +169,18 @@ pub mod editor { #[allow(dead_code)] pub mod capabilities { - use tower_lsp_server::lsp_types::Registration; - use tower_lsp_server::lsp_types::Unregistration; + use tower_lsp_server::lsp_types; use super::get_client; - notify_discard!(register_capability, registrations: Vec); - notify_discard!(unregister_capability, unregisterations: Vec); + notify_discard!(register_capability, registrations: Vec); + notify_discard!(unregister_capability, unregisterations: Vec); } #[allow(dead_code)] pub mod monitoring { use serde::Serialize; - use tower_lsp_server::lsp_types::ProgressToken; + use tower_lsp_server::lsp_types; use tower_lsp_server::Progress; use super::get_client; @@ -201,22 +193,24 @@ pub mod monitoring { } } - pub fn progress + Send>(token: ProgressToken, title: T) -> Option { + pub fn progress + Send>( + token: lsp_types::ProgressToken, + title: T, + ) -> Option { get_client().map(|client| client.progress(token, title)) } } #[allow(dead_code)] pub mod protocol { - use tower_lsp_server::lsp_types::notification::Notification; - use tower_lsp_server::lsp_types::request::Request; + use tower_lsp_server::lsp_types; use super::get_client; use super::Error; pub fn send_notification(params: N::Params) where - N: Notification, + N: lsp_types::notification::Notification, N::Params: Send + 'static, { if let Some(client) = get_client() { @@ -228,7 +222,7 @@ pub mod protocol { pub async fn send_request(params: R::Params) -> Result where - R: Request, + R: lsp_types::request::Request, R::Params: Send + 'static, R::Result: Send + 'static, { diff --git a/crates/djls-server/src/completions.rs b/crates/djls-server/src/completions.rs new file mode 100644 index 00000000..b9054f69 --- /dev/null +++ b/crates/djls-server/src/completions.rs @@ -0,0 +1,293 @@ +//! Completion logic for Django Language Server +//! +//! This module handles all LSP completion requests, analyzing cursor context +//! and generating appropriate completion items for Django templates. + +use djls_project::TemplateTags; +use djls_workspace::FileKind; +use djls_workspace::PositionEncoding; +use djls_workspace::TextDocument; +use tower_lsp_server::lsp_types::CompletionItem; +use tower_lsp_server::lsp_types::CompletionItemKind; +use tower_lsp_server::lsp_types::Documentation; +use tower_lsp_server::lsp_types::InsertTextFormat; +use tower_lsp_server::lsp_types::Position; + +/// Tracks what closing characters are needed to complete a template tag. +/// +/// Used to determine whether the completion system needs to insert +/// closing braces when completing a Django template tag. +#[derive(Debug)] +pub enum ClosingBrace { + /// No closing brace present - need to add full `%}` or `}}` + None, + /// Partial close present (just `}`) - need to add `%` or second `}` + PartialClose, + /// Full close present (`%}` or `}}`) - no closing needed + FullClose, +} + +/// Cursor context within a Django template tag for completion support. +/// +/// Captures the state around the cursor position to provide intelligent +/// completions and determine what text needs to be inserted. +#[derive(Debug)] +pub struct TemplateTagContext { + /// The partial tag text before the cursor (e.g., "loa" for "{% loa|") + pub partial_tag: String, + /// What closing characters are already present after the cursor + pub closing_brace: ClosingBrace, + /// Whether a space is needed before the completion (true if cursor is right after `{%`) + pub needs_leading_space: bool, +} + +/// Information about a line of text and cursor position within it +#[derive(Debug)] +pub struct LineInfo { + /// The complete line text + pub text: String, + /// The cursor offset within the line (in characters) + pub cursor_offset: usize, +} + +/// Main entry point for handling completion requests +pub fn handle_completion( + document: &TextDocument, + position: Position, + encoding: PositionEncoding, + file_kind: FileKind, + template_tags: Option<&TemplateTags>, +) -> Vec { + // Only handle template files + if file_kind != FileKind::Template { + return Vec::new(); + } + + // Get line information from document + let Some(line_info) = get_line_info(document, position, encoding) else { + return Vec::new(); + }; + + // Analyze template context at cursor position + let Some(context) = analyze_template_context(&line_info.text, line_info.cursor_offset) else { + return Vec::new(); + }; + + // Generate completions based on available template tags + generate_template_completions(&context, template_tags) +} + +/// Extract line information from document at given position +fn get_line_info( + document: &TextDocument, + position: Position, + encoding: PositionEncoding, +) -> Option { + let content = document.content(); + let lines: Vec<&str> = content.lines().collect(); + + let line_index = position.line as usize; + if line_index >= lines.len() { + return None; + } + + let line_text = lines[line_index].to_string(); + + // Convert LSP position to character index for Vec operations. + // + // LSP default encoding is UTF-16 (emoji = 2 units), but we need + // character counts (emoji = 1 char) to index into chars[..offset]. + // + // Example: + // "h€llo" cursor after € → UTF-16: 2, chars: 2 ✓, bytes: 4 ✗ + let cursor_offset_in_line = match encoding { + PositionEncoding::Utf16 => { + let utf16_pos = position.character as usize; + let mut char_offset = 0; // Count chars, not bytes + let mut utf16_offset = 0; + + for ch in line_text.chars() { + if utf16_offset >= utf16_pos { + break; + } + utf16_offset += ch.len_utf16(); + char_offset += 1; + } + char_offset + } + _ => position.character as usize, + }; + + Some(LineInfo { + text: line_text, + cursor_offset: cursor_offset_in_line.min(lines[line_index].chars().count()), + }) +} + +/// Analyze a line of template text to determine completion context +fn analyze_template_context(line: &str, cursor_offset: usize) -> Option { + if cursor_offset > line.chars().count() { + return None; + } + + let chars: Vec = line.chars().collect(); + let prefix = chars[..cursor_offset].iter().collect::(); + let rest_of_line = chars[cursor_offset..].iter().collect::(); + let rest_trimmed = rest_of_line.trim_start(); + + prefix.rfind("{%").map(|tag_start| { + let closing_brace = if rest_trimmed.starts_with("%}") { + ClosingBrace::FullClose + } else if rest_trimmed.starts_with('}') { + ClosingBrace::PartialClose + } else { + ClosingBrace::None + }; + + let partial_tag_start = tag_start + 2; // Skip "{%" + let content_after_tag = if partial_tag_start < prefix.len() { + &prefix[partial_tag_start..] + } else { + "" + }; + + // Check if we need a leading space - true if there's no space after {% + let needs_leading_space = + !content_after_tag.starts_with(' ') && !content_after_tag.is_empty(); + + let partial_tag = content_after_tag.trim().to_string(); + + TemplateTagContext { + partial_tag, + closing_brace, + needs_leading_space, + } + }) +} + +/// Generate Django template tag completion items based on context +fn generate_template_completions( + context: &TemplateTagContext, + template_tags: Option<&TemplateTags>, +) -> Vec { + let Some(tags) = template_tags else { + return Vec::new(); + }; + + let mut completions = Vec::new(); + + for tag in tags.iter() { + // Filter tags based on partial match + if tag.name().starts_with(&context.partial_tag) { + // Determine insertion text based on context + let mut insert_text = String::new(); + + // Add leading space if needed (cursor right after {%) + if context.needs_leading_space { + insert_text.push(' '); + } + + // Add the tag name + insert_text.push_str(tag.name()); + + // Add closing based on what's already present + match context.closing_brace { + ClosingBrace::None => insert_text.push_str(" %}"), + ClosingBrace::PartialClose => insert_text.push('%'), + ClosingBrace::FullClose => {} // No closing needed + } + + // Create completion item + let completion_item = CompletionItem { + label: tag.name().clone(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(format!("from {}", tag.library())), + documentation: tag.doc().map(|doc| Documentation::String(doc.clone())), + insert_text: Some(insert_text), + insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), + filter_text: Some(tag.name().clone()), + ..Default::default() + }; + + completions.push(completion_item); + } + } + + completions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_analyze_template_context_basic() { + let line = "{% loa"; + let cursor_offset = 6; // After "loa" + + let context = analyze_template_context(line, cursor_offset).expect("Should get context"); + + assert_eq!(context.partial_tag, "loa"); + assert!(!context.needs_leading_space); + assert!(matches!(context.closing_brace, ClosingBrace::None)); + } + + #[test] + fn test_analyze_template_context_needs_leading_space() { + let line = "{%loa"; + let cursor_offset = 5; // After "loa" + + let context = analyze_template_context(line, cursor_offset).expect("Should get context"); + + assert_eq!(context.partial_tag, "loa"); + assert!(context.needs_leading_space); + assert!(matches!(context.closing_brace, ClosingBrace::None)); + } + + #[test] + fn test_analyze_template_context_with_closing() { + let line = "{% load %}"; + let cursor_offset = 7; // After "load" + + let context = analyze_template_context(line, cursor_offset).expect("Should get context"); + + assert_eq!(context.partial_tag, "load"); + assert!(!context.needs_leading_space); + assert!(matches!(context.closing_brace, ClosingBrace::FullClose)); + } + + #[test] + fn test_analyze_template_context_partial_closing() { + let line = "{% load }"; + let cursor_offset = 7; // After "load" + + let context = analyze_template_context(line, cursor_offset).expect("Should get context"); + + assert_eq!(context.partial_tag, "load"); + assert!(!context.needs_leading_space); + assert!(matches!(context.closing_brace, ClosingBrace::PartialClose)); + } + + #[test] + fn test_analyze_template_context_no_template() { + let line = "Just regular HTML"; + let cursor_offset = 5; + + let context = analyze_template_context(line, cursor_offset); + + assert!(context.is_none()); + } + + #[test] + fn test_generate_template_completions_empty_tags() { + let context = TemplateTagContext { + partial_tag: "loa".to_string(), + needs_leading_space: false, + closing_brace: ClosingBrace::None, + }; + + let completions = generate_template_completions(&context, None); + + assert!(completions.is_empty()); + } +} diff --git a/crates/djls-server/src/db.rs b/crates/djls-server/src/db.rs deleted file mode 100644 index f6e4c338..00000000 --- a/crates/djls-server/src/db.rs +++ /dev/null @@ -1,22 +0,0 @@ -use salsa::Database; - -#[salsa::db] -#[derive(Clone, Default)] -pub struct ServerDatabase { - storage: salsa::Storage, -} - -impl ServerDatabase { - /// Create a new database from storage - pub fn new(storage: salsa::Storage) -> Self { - Self { storage } - } -} - -impl std::fmt::Debug for ServerDatabase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ServerDatabase").finish_non_exhaustive() - } -} - -impl Database for ServerDatabase {} diff --git a/crates/djls-server/src/lib.rs b/crates/djls-server/src/lib.rs index 57c433a2..055f9d03 100644 --- a/crates/djls-server/src/lib.rs +++ b/crates/djls-server/src/lib.rs @@ -1,10 +1,9 @@ mod client; -mod db; +mod completions; mod logging; mod queue; -mod server; -mod session; -mod workspace; +pub mod server; +pub mod session; use std::io::IsTerminal; @@ -12,7 +11,8 @@ use anyhow::Result; use tower_lsp_server::LspService; use tower_lsp_server::Server; -use crate::server::DjangoLanguageServer; +pub use crate::server::DjangoLanguageServer; +pub use crate::session::Session; pub fn run() -> Result<()> { if std::io::stdin().is_terminal() { diff --git a/crates/djls-server/src/logging.rs b/crates/djls-server/src/logging.rs index a540401a..030af946 100644 --- a/crates/djls-server/src/logging.rs +++ b/crates/djls-server/src/logging.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use tower_lsp_server::lsp_types::MessageType; +use tower_lsp_server::lsp_types; use tracing::field::Visit; use tracing::Level; use tracing_appender::non_blocking::WorkerGuard; @@ -32,13 +32,13 @@ use tracing_subscriber::Registry; /// that are sent to the client. It filters events by level to avoid overwhelming /// the client with verbose trace logs. pub struct LspLayer { - send_message: Arc, + send_message: Arc, } impl LspLayer { pub fn new(send_message: F) -> Self where - F: Fn(MessageType, String) + Send + Sync + 'static, + F: Fn(lsp_types::MessageType, String) + Send + Sync + 'static, { Self { send_message: Arc::new(send_message), @@ -82,10 +82,10 @@ where let metadata = event.metadata(); let message_type = match *metadata.level() { - Level::ERROR => MessageType::ERROR, - Level::WARN => MessageType::WARNING, - Level::INFO => MessageType::INFO, - Level::DEBUG => MessageType::LOG, + Level::ERROR => lsp_types::MessageType::ERROR, + Level::WARN => lsp_types::MessageType::WARNING, + Level::INFO => lsp_types::MessageType::INFO, + Level::DEBUG => lsp_types::MessageType::LOG, Level::TRACE => { // Skip TRACE level - too verbose for LSP client // TODO: Add MessageType::Debug in LSP 3.18.0 @@ -112,7 +112,7 @@ where /// Returns a `WorkerGuard` that must be kept alive for the file logging to work. pub fn init_tracing(send_message: F) -> WorkerGuard where - F: Fn(MessageType, String) + Send + Sync + 'static, + F: Fn(lsp_types::MessageType, String) + Send + Sync + 'static, { let file_appender = tracing_appender::rolling::daily("/tmp", "djls.log"); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 3977ef60..f0ef4c1c 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -1,27 +1,11 @@ use std::future::Future; use std::sync::Arc; +use djls_workspace::paths; +use djls_workspace::FileKind; use tokio::sync::RwLock; use tower_lsp_server::jsonrpc::Result as LspResult; -use tower_lsp_server::lsp_types::CompletionOptions; -use tower_lsp_server::lsp_types::CompletionParams; -use tower_lsp_server::lsp_types::CompletionResponse; -use tower_lsp_server::lsp_types::DidChangeConfigurationParams; -use tower_lsp_server::lsp_types::DidChangeTextDocumentParams; -use tower_lsp_server::lsp_types::DidCloseTextDocumentParams; -use tower_lsp_server::lsp_types::DidOpenTextDocumentParams; -use tower_lsp_server::lsp_types::InitializeParams; -use tower_lsp_server::lsp_types::InitializeResult; -use tower_lsp_server::lsp_types::InitializedParams; -use tower_lsp_server::lsp_types::OneOf; -use tower_lsp_server::lsp_types::SaveOptions; -use tower_lsp_server::lsp_types::ServerCapabilities; -use tower_lsp_server::lsp_types::ServerInfo; -use tower_lsp_server::lsp_types::TextDocumentSyncCapability; -use tower_lsp_server::lsp_types::TextDocumentSyncKind; -use tower_lsp_server::lsp_types::TextDocumentSyncOptions; -use tower_lsp_server::lsp_types::WorkspaceFoldersServerCapabilities; -use tower_lsp_server::lsp_types::WorkspaceServerCapabilities; +use tower_lsp_server::lsp_types; use tower_lsp_server::LanguageServer; use tracing_appender::non_blocking::WorkerGuard; @@ -91,19 +75,23 @@ impl DjangoLanguageServer { } impl LanguageServer for DjangoLanguageServer { - async fn initialize(&self, params: InitializeParams) -> LspResult { + async fn initialize( + &self, + params: lsp_types::InitializeParams, + ) -> LspResult { tracing::info!("Initializing server..."); let session = Session::new(¶ms); + let encoding = session.position_encoding(); { let mut session_lock = self.session.write().await; *session_lock = Some(session); } - Ok(InitializeResult { - capabilities: ServerCapabilities { - completion_provider: Some(CompletionOptions { + Ok(lsp_types::InitializeResult { + capabilities: lsp_types::ServerCapabilities { + completion_provider: Some(lsp_types::CompletionOptions { resolve_provider: Some(false), trigger_characters: Some(vec![ "{".to_string(), @@ -112,34 +100,35 @@ impl LanguageServer for DjangoLanguageServer { ]), ..Default::default() }), - workspace: Some(WorkspaceServerCapabilities { - workspace_folders: Some(WorkspaceFoldersServerCapabilities { + workspace: Some(lsp_types::WorkspaceServerCapabilities { + workspace_folders: Some(lsp_types::WorkspaceFoldersServerCapabilities { supported: Some(true), - change_notifications: Some(OneOf::Left(true)), + change_notifications: Some(lsp_types::OneOf::Left(true)), }), file_operations: None, }), - text_document_sync: Some(TextDocumentSyncCapability::Options( - TextDocumentSyncOptions { + text_document_sync: Some(lsp_types::TextDocumentSyncCapability::Options( + lsp_types::TextDocumentSyncOptions { open_close: Some(true), - change: Some(TextDocumentSyncKind::INCREMENTAL), + change: Some(lsp_types::TextDocumentSyncKind::INCREMENTAL), will_save: Some(false), will_save_wait_until: Some(false), - save: Some(SaveOptions::default().into()), + save: Some(lsp_types::SaveOptions::default().into()), }, )), + position_encoding: Some(lsp_types::PositionEncodingKind::from(encoding)), ..Default::default() }, - server_info: Some(ServerInfo { + server_info: Some(lsp_types::ServerInfo { name: SERVER_NAME.to_string(), version: Some(SERVER_VERSION.to_string()), }), - offset_encoding: None, + offset_encoding: Some(encoding.to_string()), }) } #[allow(clippy::too_many_lines)] - async fn initialized(&self, _params: InitializedParams) { + async fn initialized(&self, _params: lsp_types::InitializedParams) { tracing::info!("Server received initialized notification."); self.with_session_task(|session_arc| async move { @@ -214,55 +203,110 @@ impl LanguageServer for DjangoLanguageServer { Ok(()) } - async fn did_open(&self, params: DidOpenTextDocumentParams) { + async fn did_open(&self, params: lsp_types::DidOpenTextDocumentParams) { tracing::info!("Opened document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - let db = session.db(); - session.documents_mut().handle_did_open(&db, ¶ms); + let Some(url) = + paths::parse_lsp_uri(¶ms.text_document.uri, paths::LspContext::DidOpen) + else { + return; // Error parsing uri (unlikely), skip processing this document + }; + + let language_id = + djls_workspace::LanguageId::from(params.text_document.language_id.as_str()); + let document = djls_workspace::TextDocument::new( + params.text_document.text, + params.text_document.version, + language_id, + ); + + session.open_document(&url, document); }) .await; } - async fn did_change(&self, params: DidChangeTextDocumentParams) { + async fn did_change(&self, params: lsp_types::DidChangeTextDocumentParams) { tracing::info!("Changed document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - let db = session.db(); - let _ = session.documents_mut().handle_did_change(&db, ¶ms); + let Some(url) = + paths::parse_lsp_uri(¶ms.text_document.uri, paths::LspContext::DidChange) + else { + return; // Error parsing uri (unlikely), skip processing this change + }; + + session.update_document(&url, params.content_changes, params.text_document.version); }) .await; } - async fn did_close(&self, params: DidCloseTextDocumentParams) { + async fn did_close(&self, params: lsp_types::DidCloseTextDocumentParams) { tracing::info!("Closed document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - session.documents_mut().handle_did_close(¶ms); + let Some(url) = + paths::parse_lsp_uri(¶ms.text_document.uri, paths::LspContext::DidClose) + else { + return; // Error parsing uri (unlikely), skip processing this close + }; + + if session.close_document(&url).is_none() { + tracing::warn!("Attempted to close document without overlay: {}", url); + } }) .await; } - async fn completion(&self, params: CompletionParams) -> LspResult> { - Ok(self - .with_session(|session| { - if let Some(project) = session.project() { - if let Some(tags) = project.template_tags() { - let db = session.db(); - return session.documents().get_completions( - &db, - params.text_document_position.text_document.uri.as_str(), - params.text_document_position.position, - tags, - ); + async fn completion( + &self, + params: lsp_types::CompletionParams, + ) -> LspResult> { + let response = self + .with_session_mut(|session| { + let Some(url) = paths::parse_lsp_uri( + ¶ms.text_document_position.text_document.uri, + paths::LspContext::Completion, + ) else { + return None; // Error parsing uri (unlikely), return no completions + }; + + tracing::debug!( + "Completion requested for {} at {:?}", + url, + params.text_document_position.position + ); + + if let Some(path) = paths::url_to_path(&url) { + let document = session.get_document(&url)?; + let position = params.text_document_position.position; + let encoding = session.position_encoding(); + let file_kind = FileKind::from_path(&path); + let template_tags = session.project().and_then(|p| p.template_tags()); + + let completions = crate::completions::handle_completion( + &document, + position, + encoding, + file_kind, + template_tags, + ); + + if completions.is_empty() { + None + } else { + Some(lsp_types::CompletionResponse::Array(completions)) } + } else { + None } - None }) - .await) + .await; + + Ok(response) } - async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) { + async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) { tracing::info!("Configuration change detected. Reloading settings..."); let project_path = self diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index d4e8aafa..c651bd9a 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -1,55 +1,59 @@ +//! # LSP Session Management +//! +//! This module implements the LSP session abstraction that manages project-specific +//! state and delegates workspace operations to the Workspace facade. + use djls_conf::Settings; use djls_project::DjangoProject; -use salsa::StorageHandle; -use tower_lsp_server::lsp_types::ClientCapabilities; -use tower_lsp_server::lsp_types::InitializeParams; - -use crate::db::ServerDatabase; -use crate::workspace::Store; - -#[derive(Default)] +use djls_workspace::paths; +use djls_workspace::PositionEncoding; +use djls_workspace::TextDocument; +use djls_workspace::Workspace; +use tower_lsp_server::lsp_types; +use url::Url; + +/// LSP Session managing project-specific state and workspace operations. +/// +/// The Session serves as the main entry point for LSP operations, managing: +/// - Project configuration and settings +/// - Client capabilities and position encoding +/// - Workspace operations (delegated to the Workspace facade) +/// +/// All document lifecycle and database operations are delegated to the +/// encapsulated Workspace, which provides thread-safe Salsa database +/// management with proper mutation safety through `StorageHandleGuard`. pub struct Session { + /// The Django project configuration project: Option, - documents: Store, + + /// LSP server settings settings: Settings, + /// Workspace facade that encapsulates all workspace-related functionality + /// + /// This includes document buffers, file system abstraction, and the Salsa database. + /// The workspace provides a clean interface for document lifecycle management + /// and database operations while maintaining proper isolation and thread safety. + workspace: Workspace, + #[allow(dead_code)] - client_capabilities: ClientCapabilities, + client_capabilities: lsp_types::ClientCapabilities, - /// A thread-safe Salsa database handle that can be shared between threads. - /// - /// This implements the insight from [this Salsa Zulip discussion](https://salsa.zulipchat.com/#narrow/channel/145099-Using-Salsa/topic/.E2.9C.94.20Advice.20on.20using.20salsa.20from.20Sync.20.2B.20Send.20context/with/495497515) - /// where we're using the `StorageHandle` to create a thread-safe handle that can be - /// shared between threads. When we need to use it, we clone the handle to get a new reference. - /// - /// This handle allows us to create database instances as needed. - /// Even though we're using a single-threaded runtime, we still need - /// this to be thread-safe because of LSP trait requirements. - /// - /// Usage: - /// ```rust,ignore - /// // Use the StorageHandle in Session - /// let db_handle = StorageHandle::new(None); - /// - /// // Clone it to pass to different threads - /// let db_handle_clone = db_handle.clone(); - /// - /// // Use it in an async context - /// async_fn(move || { - /// // Get a database from the handle - /// let storage = db_handle_clone.into_storage(); - /// let db = ServerDatabase::new(storage); - /// - /// // Use the database - /// db.some_query(args) - /// }); - /// ``` - db_handle: StorageHandle, + /// Position encoding negotiated with client + position_encoding: PositionEncoding, } impl Session { - pub fn new(params: &InitializeParams) -> Self { - let project_path = crate::workspace::get_project_path(params); + pub fn new(params: &lsp_types::InitializeParams) -> Self { + let project_path = params + .workspace_folders + .as_ref() + .and_then(|folders| folders.first()) + .and_then(|folder| paths::lsp_uri_to_path(&folder.uri)) + .or_else(|| { + // Fall back to current directory + std::env::current_dir().ok() + }); let (project, settings) = if let Some(path) = &project_path { let settings = @@ -63,14 +67,15 @@ impl Session { }; Self { - client_capabilities: params.capabilities.clone(), project, - documents: Store::default(), settings, - db_handle: StorageHandle::new(None), + workspace: Workspace::new(), + client_capabilities: params.capabilities.clone(), + position_encoding: PositionEncoding::negotiate(params), } } + #[must_use] pub fn project(&self) -> Option<&DjangoProject> { self.project.as_ref() } @@ -79,14 +84,7 @@ impl Session { &mut self.project } - pub fn documents(&self) -> &Store { - &self.documents - } - - pub fn documents_mut(&mut self) -> &mut Store { - &mut self.documents - } - + #[must_use] pub fn settings(&self) -> &Settings { &self.settings } @@ -95,12 +93,57 @@ impl Session { self.settings = settings; } - /// Get a database instance directly from the session + #[must_use] + pub fn position_encoding(&self) -> PositionEncoding { + self.position_encoding + } + + /// Handle opening a document - sets buffer and creates file. + /// + /// Delegates to the workspace's document management. + pub fn open_document(&mut self, url: &Url, document: TextDocument) { + tracing::debug!("Opening document: {}", url); + self.workspace.open_document(url, document); + } + + /// Update a document with the given changes. + /// + /// Delegates to the workspace's document management. + pub fn update_document( + &mut self, + url: &Url, + changes: Vec, + new_version: i32, + ) { + self.workspace + .update_document(url, changes, new_version, self.position_encoding); + } + + /// Handle closing a document - removes buffer and bumps revision. /// - /// This creates a usable database from the handle, which can be used - /// to query and update data in the database. - pub fn db(&self) -> ServerDatabase { - let storage = self.db_handle.clone().into_storage(); - ServerDatabase::new(storage) + /// Delegates to the workspace's document management. + pub fn close_document(&mut self, url: &Url) -> Option { + tracing::debug!("Closing document: {}", url); + self.workspace.close_document(url) + } + + /// Get an open document from the buffer layer, if it exists. + /// + /// Delegates to the workspace's document management. + #[must_use] + pub fn get_document(&self, url: &Url) -> Option { + self.workspace.get_document(url) + } +} + +impl Default for Session { + fn default() -> Self { + Self { + project: None, + settings: Settings::default(), + workspace: Workspace::new(), + client_capabilities: lsp_types::ClientCapabilities::default(), + position_encoding: PositionEncoding::default(), + } } } diff --git a/crates/djls-server/src/workspace/document.rs b/crates/djls-server/src/workspace/document.rs deleted file mode 100644 index 4c23f135..00000000 --- a/crates/djls-server/src/workspace/document.rs +++ /dev/null @@ -1,216 +0,0 @@ -use salsa::Database; -use tower_lsp_server::lsp_types::DidOpenTextDocumentParams; -use tower_lsp_server::lsp_types::Position; -use tower_lsp_server::lsp_types::Range; -use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; - -#[salsa::input(debug)] -pub struct TextDocument { - #[returns(ref)] - pub uri: String, - #[returns(ref)] - pub contents: String, - #[returns(ref)] - pub index: LineIndex, - pub version: i32, - pub language_id: LanguageId, -} - -impl TextDocument { - pub fn from_did_open_params(db: &dyn Database, params: &DidOpenTextDocumentParams) -> Self { - let uri = params.text_document.uri.to_string(); - let contents = params.text_document.text.clone(); - let version = params.text_document.version; - let language_id = LanguageId::from(params.text_document.language_id.as_str()); - - let index = LineIndex::new(&contents); - TextDocument::new(db, uri, contents, index, version, language_id) - } - - pub fn with_changes( - self, - db: &dyn Database, - changes: &[TextDocumentContentChangeEvent], - new_version: i32, - ) -> Self { - let mut new_contents = self.contents(db).to_string(); - - for change in changes { - if let Some(range) = change.range { - let index = LineIndex::new(&new_contents); - - if let (Some(start_offset), Some(end_offset)) = ( - index.offset(range.start).map(|o| o as usize), - index.offset(range.end).map(|o| o as usize), - ) { - let mut updated_content = String::with_capacity( - new_contents.len() - (end_offset - start_offset) + change.text.len(), - ); - - updated_content.push_str(&new_contents[..start_offset]); - updated_content.push_str(&change.text); - updated_content.push_str(&new_contents[end_offset..]); - - new_contents = updated_content; - } - } else { - // Full document update - new_contents.clone_from(&change.text); - } - } - - let index = LineIndex::new(&new_contents); - TextDocument::new( - db, - self.uri(db).to_string(), - new_contents, - index, - new_version, - self.language_id(db), - ) - } - - #[allow(dead_code)] - pub fn get_text(self, db: &dyn Database) -> String { - self.contents(db).to_string() - } - - #[allow(dead_code)] - pub fn get_text_range(self, db: &dyn Database, range: Range) -> Option { - let index = self.index(db); - let start = index.offset(range.start)? as usize; - let end = index.offset(range.end)? as usize; - let contents = self.contents(db); - Some(contents[start..end].to_string()) - } - - pub fn get_line(self, db: &dyn Database, line: u32) -> Option { - let index = self.index(db); - let start = index.line_starts.get(line as usize)?; - let end = index - .line_starts - .get(line as usize + 1) - .copied() - .unwrap_or(index.length); - - let contents = self.contents(db); - Some(contents[*start as usize..end as usize].to_string()) - } - - #[allow(dead_code)] - pub fn line_count(self, db: &dyn Database) -> usize { - self.index(db).line_starts.len() - } - - pub fn get_template_tag_context( - self, - db: &dyn Database, - position: Position, - ) -> Option { - let line = self.get_line(db, position.line)?; - let char_pos: usize = position.character.try_into().ok()?; - let prefix = &line[..char_pos]; - let rest_of_line = &line[char_pos..]; - let rest_trimmed = rest_of_line.trim_start(); - - prefix.rfind("{%").map(|tag_start| { - // Check if we're immediately after {% with no space - let needs_leading_space = prefix.ends_with("{%"); - - let closing_brace = if rest_trimmed.starts_with("%}") { - ClosingBrace::FullClose - } else if rest_trimmed.starts_with('}') { - ClosingBrace::PartialClose - } else { - ClosingBrace::None - }; - - TemplateTagContext { - partial_tag: prefix[tag_start + 2..].trim().to_string(), - closing_brace, - needs_leading_space, - } - }) - } -} - -#[derive(Clone, Debug)] -pub struct LineIndex { - line_starts: Vec, - length: u32, -} - -impl LineIndex { - pub fn new(text: &str) -> Self { - let mut line_starts = vec![0]; - let mut pos = 0; - - for c in text.chars() { - pos += u32::try_from(c.len_utf8()).unwrap_or(0); - if c == '\n' { - line_starts.push(pos); - } - } - - Self { - line_starts, - length: pos, - } - } - - pub fn offset(&self, position: Position) -> Option { - let line_start = self.line_starts.get(position.line as usize)?; - - Some(line_start + position.character) - } - - #[allow(dead_code)] - pub fn position(&self, offset: u32) -> Position { - let line = match self.line_starts.binary_search(&offset) { - Ok(line) => line, - Err(line) => line - 1, - }; - - let line_start = self.line_starts[line]; - let character = offset - line_start; - - Position::new(u32::try_from(line).unwrap_or(0), character) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LanguageId { - HtmlDjango, - Other, - Python, -} - -impl From<&str> for LanguageId { - fn from(language_id: &str) -> Self { - match language_id { - "django-html" | "htmldjango" => Self::HtmlDjango, - "python" => Self::Python, - _ => Self::Other, - } - } -} - -impl From for LanguageId { - fn from(language_id: String) -> Self { - Self::from(language_id.as_str()) - } -} - -#[derive(Debug)] -pub enum ClosingBrace { - None, - PartialClose, // just } - FullClose, // %} -} - -#[derive(Debug)] -pub struct TemplateTagContext { - pub partial_tag: String, - pub closing_brace: ClosingBrace, - pub needs_leading_space: bool, -} diff --git a/crates/djls-server/src/workspace/mod.rs b/crates/djls-server/src/workspace/mod.rs deleted file mode 100644 index fb15df9b..00000000 --- a/crates/djls-server/src/workspace/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod document; -mod store; -mod utils; - -pub use store::Store; -pub use utils::get_project_path; diff --git a/crates/djls-server/src/workspace/store.rs b/crates/djls-server/src/workspace/store.rs deleted file mode 100644 index 3ec21097..00000000 --- a/crates/djls-server/src/workspace/store.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::collections::HashMap; - -use anyhow::anyhow; -use anyhow::Result; -use djls_project::TemplateTags; -use salsa::Database; -use tower_lsp_server::lsp_types::CompletionItem; -use tower_lsp_server::lsp_types::CompletionItemKind; -use tower_lsp_server::lsp_types::CompletionResponse; -use tower_lsp_server::lsp_types::DidChangeTextDocumentParams; -use tower_lsp_server::lsp_types::DidCloseTextDocumentParams; -use tower_lsp_server::lsp_types::DidOpenTextDocumentParams; -use tower_lsp_server::lsp_types::Documentation; -use tower_lsp_server::lsp_types::InsertTextFormat; -use tower_lsp_server::lsp_types::MarkupContent; -use tower_lsp_server::lsp_types::MarkupKind; -use tower_lsp_server::lsp_types::Position; - -use super::document::ClosingBrace; -use super::document::LanguageId; -use super::document::TextDocument; - -#[derive(Debug, Default)] -pub struct Store { - documents: HashMap, - versions: HashMap, -} - -impl Store { - pub fn handle_did_open(&mut self, db: &dyn Database, params: &DidOpenTextDocumentParams) { - let uri = params.text_document.uri.to_string(); - let version = params.text_document.version; - - let document = TextDocument::from_did_open_params(db, params); - - self.add_document(document, uri.clone()); - self.versions.insert(uri, version); - } - - pub fn handle_did_change( - &mut self, - db: &dyn Database, - params: &DidChangeTextDocumentParams, - ) -> Result<()> { - let uri = params.text_document.uri.as_str().to_string(); - let version = params.text_document.version; - - let document = self - .get_document(&uri) - .ok_or_else(|| anyhow!("Document not found: {}", uri))?; - - let new_document = document.with_changes(db, ¶ms.content_changes, version); - - self.documents.insert(uri.clone(), new_document); - self.versions.insert(uri, version); - - Ok(()) - } - - pub fn handle_did_close(&mut self, params: &DidCloseTextDocumentParams) { - self.remove_document(params.text_document.uri.as_str()); - } - - fn add_document(&mut self, document: TextDocument, uri: String) { - self.documents.insert(uri, document); - } - - fn remove_document(&mut self, uri: &str) { - self.documents.remove(uri); - self.versions.remove(uri); - } - - fn get_document(&self, uri: &str) -> Option<&TextDocument> { - self.documents.get(uri) - } - - #[allow(dead_code)] - fn get_document_mut(&mut self, uri: &str) -> Option<&mut TextDocument> { - self.documents.get_mut(uri) - } - - #[allow(dead_code)] - pub fn get_all_documents(&self) -> impl Iterator { - self.documents.values() - } - - #[allow(dead_code)] - pub fn get_documents_by_language<'db>( - &'db self, - db: &'db dyn Database, - language_id: LanguageId, - ) -> impl Iterator + 'db { - self.documents - .values() - .filter(move |doc| doc.language_id(db) == language_id) - } - - #[allow(dead_code)] - pub fn get_version(&self, uri: &str) -> Option { - self.versions.get(uri).copied() - } - - #[allow(dead_code)] - pub fn is_version_valid(&self, uri: &str, version: i32) -> bool { - self.get_version(uri) == Some(version) - } - - pub fn get_completions( - &self, - db: &dyn Database, - uri: &str, - position: Position, - tags: &TemplateTags, - ) -> Option { - let document = self.get_document(uri)?; - - if document.language_id(db) != LanguageId::HtmlDjango { - return None; - } - - let context = document.get_template_tag_context(db, position)?; - - let mut completions: Vec = tags - .iter() - .filter(|tag| { - context.partial_tag.is_empty() || tag.name().starts_with(&context.partial_tag) - }) - .map(|tag| { - let leading_space = if context.needs_leading_space { " " } else { "" }; - CompletionItem { - label: tag.name().to_string(), - kind: Some(CompletionItemKind::KEYWORD), - detail: Some(format!("Template tag from {}", tag.library())), - documentation: tag.doc().as_ref().map(|doc| { - Documentation::MarkupContent(MarkupContent { - kind: MarkupKind::Markdown, - value: (*doc).to_string(), - }) - }), - insert_text: Some(match context.closing_brace { - ClosingBrace::None => format!("{}{} %}}", leading_space, tag.name()), - ClosingBrace::PartialClose => format!("{}{} %", leading_space, tag.name()), - ClosingBrace::FullClose => format!("{}{} ", leading_space, tag.name()), - }), - insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), - ..Default::default() - } - }) - .collect(); - - if completions.is_empty() { - None - } else { - completions.sort_by(|a, b| a.label.cmp(&b.label)); - Some(CompletionResponse::Array(completions)) - } - } -} diff --git a/crates/djls-server/src/workspace/utils.rs b/crates/djls-server/src/workspace/utils.rs deleted file mode 100644 index 08a40ba0..00000000 --- a/crates/djls-server/src/workspace/utils.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::path::PathBuf; - -use percent_encoding::percent_decode_str; -use tower_lsp_server::lsp_types::InitializeParams; -use tower_lsp_server::lsp_types::Uri; - -/// Determines the project root path from initialization parameters. -/// -/// Tries the current directory first, then falls back to the first workspace folder. -pub fn get_project_path(params: &InitializeParams) -> Option { - // Try current directory first - std::env::current_dir().ok().or_else(|| { - // Fall back to the first workspace folder URI - params - .workspace_folders - .as_ref() - .and_then(|folders| folders.first()) - .and_then(|folder| uri_to_pathbuf(&folder.uri)) - }) -} - -/// Converts a `file:` URI into an absolute `PathBuf`. -fn uri_to_pathbuf(uri: &Uri) -> Option { - // Check if the scheme is "file" - if uri.scheme().is_none_or(|s| s.as_str() != "file") { - return None; - } - - // Get the path part as a string - let encoded_path_str = uri.path().as_str(); - - // Decode the percent-encoded path string - let decoded_path_cow = percent_decode_str(encoded_path_str).decode_utf8_lossy(); - let path_str = decoded_path_cow.as_ref(); - - #[cfg(windows)] - let path_str = { - // Remove leading '/' for paths like /C:/... - path_str.strip_prefix('/').unwrap_or(path_str) - }; - - Some(PathBuf::from(path_str)) -} diff --git a/crates/djls-templates/src/ast.rs b/crates/djls-templates/src/ast.rs index f355e703..9348dec1 100644 --- a/crates/djls-templates/src/ast.rs +++ b/crates/djls-templates/src/ast.rs @@ -5,17 +5,19 @@ use crate::tokens::Token; use crate::tokens::TokenStream; use crate::tokens::TokenType; -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub struct Ast { nodelist: Vec, line_offsets: LineOffsets, } impl Ast { + #[must_use] pub fn nodelist(&self) -> &Vec { &self.nodelist } + #[must_use] pub fn line_offsets(&self) -> &LineOffsets { &self.line_offsets } @@ -36,7 +38,7 @@ impl Ast { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct LineOffsets(pub Vec); impl LineOffsets { @@ -44,6 +46,7 @@ impl LineOffsets { self.0.push(offset); } + #[must_use] pub fn position_to_line_col(&self, position: usize) -> (usize, usize) { let position = u32::try_from(position).unwrap_or_default(); let line = match self.0.binary_search(&position) { @@ -63,6 +66,7 @@ impl LineOffsets { (line + 1, col) } + #[must_use] pub fn line_col_to_position(&self, line: u32, col: u32) -> u32 { // line is 1-based, so subtract 1 to get the index self.0[(line - 1) as usize] + col @@ -75,7 +79,7 @@ impl Default for LineOffsets { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub enum Node { Tag { name: String, @@ -104,16 +108,19 @@ pub struct Span { } impl Span { + #[must_use] pub fn new(start: u32, length: u32) -> Self { Self { start, length } } #[allow(clippy::trivially_copy_pass_by_ref)] + #[must_use] pub fn start(&self) -> u32 { self.start } #[allow(clippy::trivially_copy_pass_by_ref)] + #[must_use] pub fn length(&self) -> u32 { self.length } diff --git a/crates/djls-templates/src/lib.rs b/crates/djls-templates/src/lib.rs index 48350562..7c2369c5 100644 --- a/crates/djls-templates/src/lib.rs +++ b/crates/djls-templates/src/lib.rs @@ -1,11 +1,11 @@ -mod ast; +pub mod ast; mod error; mod lexer; mod parser; mod tagspecs; mod tokens; -use ast::Ast; +pub use ast::Ast; pub use error::QuickFix; pub use error::TemplateError; use lexer::Lexer; diff --git a/crates/djls-workspace/Cargo.toml b/crates/djls-workspace/Cargo.toml new file mode 100644 index 00000000..e2fb358a --- /dev/null +++ b/crates/djls-workspace/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "djls-workspace" +version = "0.0.0" +edition = "2021" + +[dependencies] +djls-templates = { workspace = true } +djls-project = { workspace = true } + +anyhow = { workspace = true } +camino = { workspace = true } +dashmap = { workspace = true } +notify = { workspace = true } +percent-encoding = { workspace = true } +salsa = { workspace = true } +tokio = { workspace = true } +tower-lsp-server = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/djls-workspace/src/buffers.rs b/crates/djls-workspace/src/buffers.rs new file mode 100644 index 00000000..eb9c0828 --- /dev/null +++ b/crates/djls-workspace/src/buffers.rs @@ -0,0 +1,84 @@ +//! Shared buffer storage for open documents +//! +//! This module provides the [`Buffers`] type which represents the in-memory +//! content of open files. These buffers are shared between the `Session` +//! (which manages document lifecycle) and the [`WorkspaceFileSystem`] (which +//! reads from them). +/// +/// [`WorkspaceFileSystem`]: crate::fs::WorkspaceFileSystem +use std::sync::Arc; + +use dashmap::DashMap; +use url::Url; + +use crate::document::TextDocument; + +/// Shared buffer storage between `Session` and [`FileSystem`]. +/// +/// Buffers represent the in-memory content of open files that takes +/// precedence over disk content when reading through the [`FileSystem`]. +/// This is the key abstraction that makes the sharing between Session +/// and [`WorkspaceFileSystem`] explicit and type-safe. +/// +/// The [`WorkspaceFileSystem`] holds a clone of this structure and checks +/// it before falling back to disk reads. +/// +/// ## Memory Management +/// +/// This structure does not implement eviction or memory limits because the +/// LSP protocol explicitly manages document lifecycle through `didOpen` and +/// `didClose` notifications. Documents are only stored while the editor has +/// them open, and are properly removed when the editor closes them. This +/// follows the battle-tested pattern used by production LSP servers like Ruff. +/// +/// [`FileSystem`]: crate::fs::FileSystem +/// [`WorkspaceFileSystem`]: crate::fs::WorkspaceFileSystem +#[derive(Clone, Debug)] +pub struct Buffers { + inner: Arc>, +} + +impl Buffers { + #[must_use] + pub fn new() -> Self { + Self { + inner: Arc::new(DashMap::new()), + } + } + + pub fn open(&self, url: Url, document: TextDocument) { + self.inner.insert(url, document); + } + + pub fn update(&self, url: Url, document: TextDocument) { + self.inner.insert(url, document); + } + + #[must_use] + pub fn close(&self, url: &Url) -> Option { + self.inner.remove(url).map(|(_, doc)| doc) + } + + #[must_use] + pub fn get(&self, url: &Url) -> Option { + self.inner.get(url).map(|entry| entry.clone()) + } + + /// Check if a document is open + #[must_use] + pub fn contains(&self, url: &Url) -> bool { + self.inner.contains_key(url) + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.inner + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + } +} + +impl Default for Buffers { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs new file mode 100644 index 00000000..e98fdfc7 --- /dev/null +++ b/crates/djls-workspace/src/db.rs @@ -0,0 +1,447 @@ +//! Salsa database for incremental computation. +//! +//! This module provides the [`Database`] which integrates with Salsa for +//! incremental computation of Django template parsing and analysis. +//! +//! ## Architecture +//! +//! The system uses a two-layer approach: +//! 1. **Buffer layer** ([`Buffers`]) - Stores open document content in memory +//! 2. **Salsa layer** ([`Database`]) - Tracks files and computes derived queries +//! +//! When Salsa needs file content, it calls [`source_text`] which: +//! 1. Creates a dependency on the file's revision (critical!) +//! 2. Reads through [`WorkspaceFileSystem`] which checks buffers first +//! 3. Falls back to disk if no buffer exists +//! +//! ## The Revision Dependency +//! +//! The [`source_text`] function **must** call `file.revision(db)` to create +//! a Salsa dependency. Without this, revision changes won't invalidate queries: +//! +//! ```ignore +//! let _ = file.revision(db); // Creates the dependency chain! +//! ``` +//! +//! [`Buffers`]: crate::buffers::Buffers +//! [`WorkspaceFileSystem`]: crate::fs::WorkspaceFileSystem + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; + +use dashmap::DashMap; +use salsa::Setter; + +use crate::FileKind; +use crate::FileSystem; + +/// Database trait that provides file system access for Salsa queries +#[salsa::db] +pub trait Db: salsa::Database { + /// Get the file system for reading files. + fn fs(&self) -> Arc; + + /// Read file content through the file system. + /// + /// Checks buffers first via [`WorkspaceFileSystem`](crate::fs::WorkspaceFileSystem), + /// then falls back to disk. + fn read_file_content(&self, path: &Path) -> std::io::Result; +} + +/// Salsa database for incremental computation. +/// +/// Tracks files and computes derived queries incrementally. Integrates with +/// [`WorkspaceFileSystem`](crate::fs::WorkspaceFileSystem) to read file content, +/// which checks buffers before falling back to disk. +#[salsa::db] +#[derive(Clone)] +pub struct Database { + storage: salsa::Storage, + + /// File system for reading file content (checks buffers first, then disk). + fs: Arc, + + /// Maps paths to [`SourceFile`] entities for O(1) lookup. + files: Arc>, + + // The logs are only used for testing and demonstrating reuse: + #[cfg(test)] + #[allow(dead_code)] + logs: Arc>>>, +} + +#[cfg(test)] +impl Default for Database { + fn default() -> Self { + use crate::fs::InMemoryFileSystem; + + let logs = >>>>::default(); + Self { + storage: salsa::Storage::new(Some(Box::new({ + let logs = logs.clone(); + move |event| { + eprintln!("Event: {event:?}"); + // Log interesting events, if logging is enabled + if let Some(logs) = &mut *logs.lock().unwrap() { + // only log interesting events + if let salsa::EventKind::WillExecute { .. } = event.kind { + logs.push(format!("Event: {event:?}")); + } + } + } + }))), + fs: Arc::new(InMemoryFileSystem::new()), + files: Arc::new(DashMap::new()), + logs, + } + } +} + +impl Database { + pub fn new(file_system: Arc, files: Arc>) -> Self { + Self { + storage: salsa::Storage::new(None), + fs: file_system, + files, + #[cfg(test)] + logs: Arc::new(Mutex::new(None)), + } + } + + pub fn from_storage( + storage: salsa::Storage, + file_system: Arc, + files: Arc>, + ) -> Self { + Self { + storage, + fs: file_system, + files, + #[cfg(test)] + logs: Arc::new(Mutex::new(None)), + } + } + + /// Read file content through the file system. + pub fn read_file_content(&self, path: &Path) -> std::io::Result { + self.fs.read_to_string(path) + } + + /// Get an existing [`SourceFile`] for the given path without creating it. + /// + /// Returns `Some(SourceFile)` if the file is already tracked, `None` otherwise. + /// This method uses an immutable reference and doesn't modify the database. + pub fn get_file(&self, path: &Path) -> Option { + self.files.get(path).map(|file_ref| *file_ref) + } + + /// Get or create a [`SourceFile`] for the given path. + /// + /// Files are created with an initial revision of 0 and tracked in the [`Database`]'s + /// `DashMap`. The `Arc` ensures cheap cloning while maintaining thread safety. + /// + /// ## Thread Safety + /// + /// This method is inherently thread-safe despite the check-then-create pattern because + /// it requires `&mut self`, ensuring exclusive access to the Database. Only one thread + /// can call this method at a time due to Rust's ownership rules. + pub fn get_or_create_file(&mut self, path: &PathBuf) -> SourceFile { + if let Some(file_ref) = self.files.get(path) { + // Copy the value (SourceFile is Copy) + // The guard drops automatically, no need for explicit drop + return *file_ref; + } + + // File doesn't exist, so we need to create it + let kind = FileKind::from_path(path); + let file = SourceFile::new(self, kind, Arc::from(path.to_string_lossy().as_ref()), 0); + + self.files.insert(path.clone(), file); + file + } + + /// Check if a file is being tracked without creating it. + /// + /// This is primarily used for testing to verify that files have been + /// created without affecting the database state. + pub fn has_file(&self, path: &Path) -> bool { + self.files.contains_key(path) + } + + /// Touch a file to mark it as modified, triggering re-evaluation of dependent queries. + /// + /// Similar to Unix `touch`, this updates the file's revision number to signal + /// that cached query results depending on this file should be invalidated. + /// + /// This is typically called when: + /// - A file is opened in the editor (if it was previously cached from disk) + /// - A file's content is modified + /// - A file's buffer is closed (reverting to disk content) + pub fn touch_file(&mut self, path: &Path) { + // Get the file if it exists + let Some(file_ref) = self.files.get(path) else { + tracing::debug!("File {} not tracked, skipping touch", path.display()); + return; + }; + let file = *file_ref; + drop(file_ref); // Explicitly drop to release the lock + + let current_rev = file.revision(self); + let new_rev = current_rev + 1; + file.set_revision(self).to(new_rev); + + tracing::debug!( + "Touched {}: revision {} -> {}", + path.display(), + current_rev, + new_rev + ); + } + + /// Get a reference to the storage for handle extraction. + /// + /// This is used by `Session` to extract the [`StorageHandle`](salsa::StorageHandle) after mutations. + pub fn storage(&self) -> &salsa::Storage { + &self.storage + } +} + +#[salsa::db] +impl salsa::Database for Database {} + +#[salsa::db] +impl Db for Database { + fn fs(&self) -> Arc { + self.fs.clone() + } + + fn read_file_content(&self, path: &Path) -> std::io::Result { + self.fs.read_to_string(path) + } +} + +/// Represents a single file without storing its content. +/// +/// [`SourceFile`] is a Salsa input entity that tracks a file's path, revision, and +/// classification for analysis routing. Following Ruff's pattern, content is NOT +/// stored here but read on-demand through the `source_text` tracked function. +#[salsa::input] +pub struct SourceFile { + /// The file's classification for analysis routing + pub kind: FileKind, + /// The file path + #[returns(ref)] + pub path: Arc, + /// The revision number for invalidation tracking + pub revision: u64, +} + +/// Read file content, creating a Salsa dependency on the file's revision. +#[salsa::tracked] +pub fn source_text(db: &dyn Db, file: SourceFile) -> Arc { + // This line creates the Salsa dependency on revision! Without this call, + // revision changes won't trigger invalidation + let _ = file.revision(db); + + let path = Path::new(file.path(db).as_ref()); + match db.read_file_content(path) { + Ok(content) => Arc::from(content), + Err(_) => { + Arc::from("") // Return empty string for missing files + } + } +} + +/// Represents a file path for Salsa tracking. +/// +/// [`FilePath`] is a Salsa input entity that tracks a file path for use in +/// path-based queries. This allows Salsa to properly track dependencies +/// on files identified by path rather than by SourceFile input. +#[salsa::input] +pub struct FilePath { + /// The file path as a string + #[returns(ref)] + pub path: Arc, +} + +/// Container for a parsed Django template AST. +/// +/// [`TemplateAst`] wraps the parsed AST from djls-templates along with any parsing errors. +/// This struct is designed to be cached by Salsa and shared across multiple consumers +/// without re-parsing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TemplateAst { + /// The parsed AST from djls-templates + pub ast: djls_templates::Ast, + /// Any errors encountered during parsing (stored as strings for simplicity) + pub errors: Vec, +} + +/// Parse a Django template file into an AST. +/// +/// This Salsa tracked function parses template files on-demand and caches the results. +/// The parse is only re-executed when the file's content changes (detected via content changes). +/// +/// Returns `None` for non-template files. +#[salsa::tracked] +pub fn parse_template(db: &dyn Db, file: SourceFile) -> Option> { + // Only parse template files + if file.kind(db) != FileKind::Template { + return None; + } + + let text_arc = source_text(db, file); + let text = text_arc.as_ref(); + + // Call the pure parsing function from djls-templates + // TODO: Move this whole function into djls-templates + match djls_templates::parse_template(text) { + Ok((ast, errors)) => { + // Convert errors to strings + let error_strings = errors.into_iter().map(|e| e.to_string()).collect(); + Some(Arc::new(TemplateAst { + ast, + errors: error_strings, + })) + } + Err(err) => { + // Even on fatal errors, return an empty AST with the error + Some(Arc::new(TemplateAst { + ast: djls_templates::Ast::default(), + errors: vec![err.to_string()], + })) + } + } +} + +#[cfg(test)] +mod tests { + use dashmap::DashMap; + use salsa::Setter; + + use super::*; + use crate::buffers::Buffers; + use crate::document::TextDocument; + use crate::fs::InMemoryFileSystem; + use crate::fs::WorkspaceFileSystem; + use crate::language::LanguageId; + + #[test] + fn test_parse_template_with_overlay() { + // Create a memory filesystem with initial template content + let mut memory_fs = InMemoryFileSystem::new(); + let template_path = PathBuf::from("/test/template.html"); + memory_fs.add_file( + template_path.clone(), + "{% block content %}Original{% endblock %}".to_string(), + ); + + // Create overlay storage + let buffers = Buffers::new(); + + // Create WorkspaceFileSystem that checks overlays first + let file_system = Arc::new(WorkspaceFileSystem::new( + buffers.clone(), + Arc::new(memory_fs), + )); + + // Create database with the file system + let files = Arc::new(DashMap::new()); + let mut db = Database::new(file_system, files); + + // Create a SourceFile for the template + let file = db.get_or_create_file(&template_path); + + // Parse template - should get original content from disk + let ast1 = parse_template(&db, file).expect("Should parse template"); + assert!(ast1.errors.is_empty(), "Should have no errors"); + + // Add an overlay with updated content + let url = crate::paths::path_to_url(&template_path).unwrap(); + let updated_document = TextDocument::new( + "{% block content %}Updated from overlay{% endblock %}".to_string(), + 2, + LanguageId::Other, + ); + buffers.open(url, updated_document); + + // Bump the file revision to trigger re-parse + file.set_revision(&mut db).to(1); + + // Parse again - should now get overlay content + let ast2 = parse_template(&db, file).expect("Should parse template"); + assert!(ast2.errors.is_empty(), "Should have no errors"); + + // Verify the content changed (we can't directly check the text, + // but the AST should be different) + // The AST will have different content in the block + assert_ne!( + format!("{:?}", ast1.ast), + format!("{:?}", ast2.ast), + "AST should change when overlay is added" + ); + } + + #[test] + fn test_parse_template_invalidation_on_revision_change() { + // Create a memory filesystem + let mut memory_fs = InMemoryFileSystem::new(); + let template_path = PathBuf::from("/test/template.html"); + memory_fs.add_file( + template_path.clone(), + "{% if true %}Initial{% endif %}".to_string(), + ); + + // Create overlay storage + let buffers = Buffers::new(); + + // Create WorkspaceFileSystem + let file_system = Arc::new(WorkspaceFileSystem::new( + buffers.clone(), + Arc::new(memory_fs), + )); + + // Create database + let files = Arc::new(DashMap::new()); + let mut db = Database::new(file_system, files); + + // Create a SourceFile for the template + let file = db.get_or_create_file(&template_path); + + // Parse template first time + let ast1 = parse_template(&db, file).expect("Should parse"); + + // Parse again without changing revision - should return same Arc (cached) + let ast2 = parse_template(&db, file).expect("Should parse"); + assert!(Arc::ptr_eq(&ast1, &ast2), "Should return cached result"); + + // Update overlay content + let url = crate::paths::path_to_url(&template_path).unwrap(); + let updated_document = TextDocument::new( + "{% if false %}Changed{% endif %}".to_string(), + 2, + LanguageId::Other, + ); + buffers.open(url, updated_document); + + // Bump revision to trigger invalidation + file.set_revision(&mut db).to(1); + + // Parse again - should get different result due to invalidation + let ast3 = parse_template(&db, file).expect("Should parse"); + assert!( + !Arc::ptr_eq(&ast1, &ast3), + "Should re-execute after revision change" + ); + + // Content should be different + assert_ne!( + format!("{:?}", ast1.ast), + format!("{:?}", ast3.ast), + "AST should be different after content change" + ); + } +} diff --git a/crates/djls-workspace/src/document.rs b/crates/djls-workspace/src/document.rs new file mode 100644 index 00000000..5f22d332 --- /dev/null +++ b/crates/djls-workspace/src/document.rs @@ -0,0 +1,495 @@ +//! LSP text document representation with efficient line indexing +//! +//! [`TextDocument`] stores open file content with version tracking for the LSP protocol. +//! Pre-computed line indices enable O(1) position lookups, which is critical for +//! performance when handling frequent position-based operations like hover, completion, +//! and diagnostics. + +use tower_lsp_server::lsp_types::Position; +use tower_lsp_server::lsp_types::Range; + +use crate::encoding::PositionEncoding; +use crate::language::LanguageId; + +/// In-memory representation of an open document in the LSP. +/// +/// Combines document content with metadata needed for LSP operations, +/// including version tracking for synchronization and pre-computed line +/// indices for efficient position lookups. +#[derive(Clone, Debug)] +pub struct TextDocument { + /// The document's content + content: String, + /// The version number of this document (from LSP) + version: i32, + /// The language identifier (python, htmldjango, etc.) + language_id: LanguageId, + /// Line index for efficient position lookups + line_index: LineIndex, +} + +impl TextDocument { + #[must_use] + pub fn new(content: String, version: i32, language_id: LanguageId) -> Self { + let line_index = LineIndex::new(&content); + Self { + content, + version, + language_id, + line_index, + } + } + + #[must_use] + pub fn content(&self) -> &str { + &self.content + } + + #[must_use] + pub fn version(&self) -> i32 { + self.version + } + + #[must_use] + pub fn language_id(&self) -> LanguageId { + self.language_id.clone() + } + + #[must_use] + pub fn line_index(&self) -> &LineIndex { + &self.line_index + } + + #[must_use] + pub fn get_line(&self, line: u32) -> Option { + let line_start = *self.line_index.line_starts.get(line as usize)?; + let line_end = self + .line_index + .line_starts + .get(line as usize + 1) + .copied() + .unwrap_or(self.line_index.length); + + Some(self.content[line_start as usize..line_end as usize].to_string()) + } + + #[must_use] + pub fn get_text_range(&self, range: Range, encoding: PositionEncoding) -> Option { + let start_offset = self.line_index.offset(range.start, &self.content, encoding) as usize; + let end_offset = self.line_index.offset(range.end, &self.content, encoding) as usize; + + Some(self.content[start_offset..end_offset].to_string()) + } + + /// Update the document content with LSP text changes + /// + /// Supports both full document replacement and incremental updates. + /// Following ruff's approach: incremental sync is used for network efficiency, + /// but we rebuild the full document text internally. + pub fn update( + &mut self, + changes: Vec, + version: i32, + encoding: PositionEncoding, + ) { + // Fast path: single change without range = full document replacement + if changes.len() == 1 && changes[0].range.is_none() { + self.content.clone_from(&changes[0].text); + self.line_index = LineIndex::new(&self.content); + self.version = version; + return; + } + + // Incremental path: apply changes to rebuild the document + let mut new_content = self.content.clone(); + + for change in changes { + if let Some(range) = change.range { + // Convert LSP range to byte offsets using the negotiated encoding + let start_offset = + self.line_index.offset(range.start, &new_content, encoding) as usize; + let end_offset = self.line_index.offset(range.end, &new_content, encoding) as usize; + + // Apply change + new_content.replace_range(start_offset..end_offset, &change.text); + + // Rebuild line index after each change since positions shift + // This is necessary for subsequent changes to have correct offsets + self.line_index = LineIndex::new(&new_content); + } else { + // No range means full replacement + new_content = change.text; + self.line_index = LineIndex::new(&new_content); + } + } + + self.content = new_content; + self.version = version; + } + + #[must_use] + pub fn position_to_offset( + &self, + position: Position, + encoding: PositionEncoding, + ) -> Option { + Some(self.line_index.offset(position, &self.content, encoding)) + } + + #[must_use] + pub fn offset_to_position(&self, offset: u32) -> Position { + self.line_index.position(offset) + } +} + +/// Pre-computed line start positions for efficient position/offset conversion. +/// +/// Computing line positions on every lookup would be O(n) where n is the document size. +/// By pre-computing during document creation/updates, we get O(1) lookups for line starts +/// and O(log n) for position-to-offset conversions via binary search. +#[derive(Clone, Debug)] +pub struct LineIndex { + pub line_starts: Vec, + pub length: u32, + pub kind: IndexKind, +} + +impl LineIndex { + #[must_use] + pub fn new(text: &str) -> Self { + let kind = if text.is_ascii() { + IndexKind::Ascii + } else { + IndexKind::Utf8 + }; + + let mut line_starts = vec![0]; + let mut pos_utf8 = 0; + + for c in text.chars() { + pos_utf8 += u32::try_from(c.len_utf8()).unwrap_or(0); + if c == '\n' { + line_starts.push(pos_utf8); + } + } + + Self { + line_starts, + length: pos_utf8, + kind, + } + } + + /// Convert position to text offset using the specified encoding + /// + /// Returns a valid offset, clamping out-of-bounds positions to document/line boundaries + pub fn offset(&self, position: Position, text: &str, encoding: PositionEncoding) -> u32 { + // Handle line bounds - if line > line_count, return document length + let line_start_utf8 = match self.line_starts.get(position.line as usize) { + Some(start) => *start, + None => return self.length, // Past end of document + }; + + if position.character == 0 { + return line_start_utf8; + } + + let next_line_start = self + .line_starts + .get(position.line as usize + 1) + .copied() + .unwrap_or(self.length); + + let Some(line_text) = text.get(line_start_utf8 as usize..next_line_start as usize) else { + return line_start_utf8; + }; + + // Fast path optimization for ASCII text, all encodings are equivalent to byte offsets + if matches!(self.kind, IndexKind::Ascii) { + let char_offset = position + .character + .min(u32::try_from(line_text.len()).unwrap_or(u32::MAX)); + return line_start_utf8 + char_offset; + } + + match encoding { + PositionEncoding::Utf8 => { + // UTF-8: character positions are already byte offsets + let char_offset = position + .character + .min(u32::try_from(line_text.len()).unwrap_or(u32::MAX)); + line_start_utf8 + char_offset + } + PositionEncoding::Utf16 => { + // UTF-16: count UTF-16 code units + let mut utf16_pos = 0; + let mut utf8_pos = 0; + + for c in line_text.chars() { + if utf16_pos >= position.character { + break; + } + utf16_pos += u32::try_from(c.len_utf16()).unwrap_or(0); + utf8_pos += u32::try_from(c.len_utf8()).unwrap_or(0); + } + + // If character position exceeds line length, clamp to line end + line_start_utf8 + utf8_pos + } + PositionEncoding::Utf32 => { + // UTF-32: count Unicode code points (characters) + let mut utf8_pos = 0; + + for (char_count, c) in line_text.chars().enumerate() { + if char_count >= position.character as usize { + break; + } + utf8_pos += u32::try_from(c.len_utf8()).unwrap_or(0); + } + + // If character position exceeds line length, clamp to line end + line_start_utf8 + utf8_pos + } + } + } + + #[allow(dead_code)] + #[must_use] + pub fn position(&self, offset: u32) -> Position { + let line = match self.line_starts.binary_search(&offset) { + Ok(line) => line, + Err(line) => line - 1, + }; + + let line_start = self.line_starts[line]; + let character = offset - line_start; + + Position::new(u32::try_from(line).unwrap_or(0), character) + } +} + +/// Index kind for ASCII optimization +#[derive(Clone, Debug)] +pub enum IndexKind { + /// Document contains only ASCII characters - enables fast path optimization + Ascii, + /// Document contains multi-byte UTF-8 characters - requires full UTF-8 processing + Utf8, +} + +#[cfg(test)] +mod tests { + use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; + + use super::*; + use crate::language::LanguageId; + + #[test] + fn test_incremental_update_single_change() { + let mut doc = TextDocument::new("Hello world".to_string(), 1, LanguageId::Other); + + // Replace "world" with "Rust" + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 6), Position::new(0, 11))), + range_length: None, + text: "Rust".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Hello Rust"); + assert_eq!(doc.version(), 2); + } + + #[test] + fn test_incremental_update_multiple_changes() { + let mut doc = TextDocument::new( + "First line\nSecond line\nThird line".to_string(), + 1, + LanguageId::Other, + ); + + // Multiple changes: replace "First" with "1st" and "Third" with "3rd" + let changes = vec![ + TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 0), Position::new(0, 5))), + range_length: None, + text: "1st".to_string(), + }, + TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(2, 0), Position::new(2, 5))), + range_length: None, + text: "3rd".to_string(), + }, + ]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "1st line\nSecond line\n3rd line"); + } + + #[test] + fn test_incremental_update_insertion() { + let mut doc = TextDocument::new("Hello world".to_string(), 1, LanguageId::Other); + + // Insert text at position (empty range) + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 5), Position::new(0, 5))), + range_length: None, + text: " beautiful".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Hello beautiful world"); + } + + #[test] + fn test_incremental_update_deletion() { + let mut doc = TextDocument::new("Hello beautiful world".to_string(), 1, LanguageId::Other); + + // Delete "beautiful " (replace with empty string) + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 6), Position::new(0, 16))), + range_length: None, + text: String::new(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Hello world"); + } + + #[test] + fn test_full_document_replacement() { + let mut doc = TextDocument::new("Old content".to_string(), 1, LanguageId::Other); + + // Full document replacement (no range) + let changes = vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "Completely new content".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Completely new content"); + assert_eq!(doc.version(), 2); + } + + #[test] + fn test_incremental_update_multiline() { + let mut doc = TextDocument::new("Line 1\nLine 2\nLine 3".to_string(), 1, LanguageId::Other); + + // Replace across multiple lines + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 5), Position::new(2, 4))), + range_length: None, + text: "A\nB\nC".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Line A\nB\nC 3"); + } + + #[test] + fn test_incremental_update_with_emoji() { + let mut doc = TextDocument::new("Hello 🌍 world".to_string(), 1, LanguageId::Other); + + // Replace "world" after emoji - must handle UTF-16 positions correctly + // "Hello " = 6 UTF-16 units, "🌍" = 2 UTF-16 units, " " = 1 unit, "world" starts at 9 + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 9), Position::new(0, 14))), + range_length: None, + text: "Rust".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Hello 🌍 Rust"); + } + + #[test] + fn test_incremental_update_newline_at_end() { + let mut doc = TextDocument::new("Hello".to_string(), 1, LanguageId::Other); + + // Add newline and new line at end + let changes = vec![TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 5), Position::new(0, 5))), + range_length: None, + text: "\nWorld".to_string(), + }]; + + doc.update(changes, 2, PositionEncoding::Utf16); + assert_eq!(doc.content(), "Hello\nWorld"); + } + + #[test] + fn test_utf16_position_handling() { + // Test document with emoji and multi-byte characters + let content = "Hello 🌍!\nSecond 行 line"; + let doc = TextDocument::new(content.to_string(), 1, LanguageId::HtmlDjango); + + // Test position after emoji + // "Hello 🌍!" - the 🌍 emoji is 4 UTF-8 bytes but 2 UTF-16 code units + // Position after the emoji should be at UTF-16 position 7 (Hello + space + emoji) + let pos_after_emoji = Position::new(0, 7); + let offset = doc + .position_to_offset(pos_after_emoji, PositionEncoding::Utf16) + .expect("Should get offset"); + + // The UTF-8 byte offset should be at the "!" character + assert_eq!(doc.content().chars().nth(7).unwrap(), '!'); + assert_eq!(&doc.content()[(offset as usize)..=(offset as usize)], "!"); + + // Test range extraction with non-ASCII characters + let range = Range::new(Position::new(0, 0), Position::new(0, 7)); + let text = doc + .get_text_range(range, PositionEncoding::Utf16) + .expect("Should get text range"); + assert_eq!(text, "Hello 🌍"); + + // Test position on second line with CJK character + // "Second 行 line" - 行 is 3 UTF-8 bytes but 1 UTF-16 code unit + // Position after the CJK character should be at UTF-16 position 8 + let pos_after_cjk = Position::new(1, 8); + let offset_cjk = doc + .position_to_offset(pos_after_cjk, PositionEncoding::Utf16) + .expect("Should get offset"); + + // Find the start of line 2 in UTF-8 bytes + let line2_start = doc.content().find('\n').unwrap() + 1; + let line2_offset = offset_cjk as usize - line2_start; + let line2 = &doc.content()[line2_start..]; + assert_eq!(&line2[line2_offset..=line2_offset], " "); + } + + #[test] + fn test_get_text_range_with_emoji() { + let content = "Hello 🌍 world"; + let doc = TextDocument::new(content.to_string(), 1, LanguageId::HtmlDjango); + + // Range that spans across the emoji + // "Hello 🌍 world" + // H(1) e(1) l(1) l(1) o(1) space(1) 🌍(2) space(1) w(1)... + // From position 5 (space before emoji) to position 8 (space after emoji) + let range = Range::new(Position::new(0, 5), Position::new(0, 8)); + let text = doc + .get_text_range(range, PositionEncoding::Utf16) + .expect("Should get text range"); + assert_eq!(text, " 🌍"); + } + + #[test] + fn test_line_index_utf16_conversion() { + let text = "Hello 🌍!\nWorld 行 test"; + let line_index = LineIndex::new(text); + + // Test position conversion with emoji on first line + let pos_emoji = Position::new(0, 7); // After emoji + let offset = line_index.offset(pos_emoji, text, PositionEncoding::Utf16); + assert_eq!(&text[(offset as usize)..=(offset as usize)], "!"); + + // Test position conversion with CJK on second line + // "World 行 test" + // W(1) o(1) r(1) l(1) d(1) space(1) 行(1) space(1) t(1)... + // Position after CJK character should be at UTF-16 position 7 + let pos_cjk = Position::new(1, 7); + let offset_cjk = line_index.offset(pos_cjk, text, PositionEncoding::Utf16); + assert_eq!(&text[(offset_cjk as usize)..=(offset_cjk as usize)], " "); + } +} diff --git a/crates/djls-workspace/src/encoding.rs b/crates/djls-workspace/src/encoding.rs new file mode 100644 index 00000000..93cda1f1 --- /dev/null +++ b/crates/djls-workspace/src/encoding.rs @@ -0,0 +1,269 @@ +use std::fmt; +use std::str::FromStr; + +use tower_lsp_server::lsp_types::InitializeParams; +use tower_lsp_server::lsp_types::PositionEncodingKind; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum PositionEncoding { + Utf8, + #[default] + Utf16, + Utf32, +} + +impl PositionEncoding { + /// Negotiate the best encoding with the client based on their capabilities. + /// Prefers UTF-8 > UTF-32 > UTF-16 for performance reasons. + pub fn negotiate(params: &InitializeParams) -> Self { + let client_encodings: &[PositionEncodingKind] = params + .capabilities + .general + .as_ref() + .and_then(|general| general.position_encodings.as_ref()) + .map_or(&[], |encodings| encodings.as_slice()); + + // Try to find the best encoding in preference order + for preferred in [ + PositionEncoding::Utf8, + PositionEncoding::Utf32, + PositionEncoding::Utf16, + ] { + if client_encodings + .iter() + .any(|kind| PositionEncoding::try_from(kind.clone()).ok() == Some(preferred)) + { + return preferred; + } + } + + // Fallback to UTF-16 if client doesn't specify encodings + PositionEncoding::Utf16 + } +} + +impl FromStr for PositionEncoding { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "utf-8" => Ok(PositionEncoding::Utf8), + "utf-16" => Ok(PositionEncoding::Utf16), + "utf-32" => Ok(PositionEncoding::Utf32), + _ => Err(()), + } + } +} + +impl fmt::Display for PositionEncoding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + PositionEncoding::Utf8 => "utf-8", + PositionEncoding::Utf16 => "utf-16", + PositionEncoding::Utf32 => "utf-32", + }; + write!(f, "{s}") + } +} + +impl From for PositionEncodingKind { + fn from(encoding: PositionEncoding) -> Self { + match encoding { + PositionEncoding::Utf8 => PositionEncodingKind::new("utf-8"), + PositionEncoding::Utf16 => PositionEncodingKind::new("utf-16"), + PositionEncoding::Utf32 => PositionEncodingKind::new("utf-32"), + } + } +} + +impl TryFrom for PositionEncoding { + type Error = (); + + fn try_from(kind: PositionEncodingKind) -> Result { + kind.as_str().parse() + } +} + +#[cfg(test)] +mod tests { + use tower_lsp_server::lsp_types::ClientCapabilities; + use tower_lsp_server::lsp_types::GeneralClientCapabilities; + + use super::*; + + #[test] + fn test_string_parsing_and_display() { + // Valid encodings parse correctly + assert_eq!( + "utf-8".parse::(), + Ok(PositionEncoding::Utf8) + ); + assert_eq!( + "utf-16".parse::(), + Ok(PositionEncoding::Utf16) + ); + assert_eq!( + "utf-32".parse::(), + Ok(PositionEncoding::Utf32) + ); + + // Invalid encoding returns error + assert!("invalid".parse::().is_err()); + assert!("UTF-8".parse::().is_err()); // case sensitive + + // Display produces correct strings + assert_eq!(PositionEncoding::Utf8.to_string(), "utf-8"); + assert_eq!(PositionEncoding::Utf16.to_string(), "utf-16"); + assert_eq!(PositionEncoding::Utf32.to_string(), "utf-32"); + } + + #[test] + fn test_lsp_type_conversions() { + // TryFrom for valid encodings + assert_eq!( + PositionEncoding::try_from(PositionEncodingKind::new("utf-8")), + Ok(PositionEncoding::Utf8) + ); + assert_eq!( + PositionEncoding::try_from(PositionEncodingKind::new("utf-16")), + Ok(PositionEncoding::Utf16) + ); + assert_eq!( + PositionEncoding::try_from(PositionEncodingKind::new("utf-32")), + Ok(PositionEncoding::Utf32) + ); + + // Invalid encoding returns error + assert!(PositionEncoding::try_from(PositionEncodingKind::new("unknown")).is_err()); + + // From produces correct LSP types + assert_eq!( + PositionEncodingKind::from(PositionEncoding::Utf8).as_str(), + "utf-8" + ); + assert_eq!( + PositionEncodingKind::from(PositionEncoding::Utf16).as_str(), + "utf-16" + ); + assert_eq!( + PositionEncodingKind::from(PositionEncoding::Utf32).as_str(), + "utf-32" + ); + } + + #[test] + fn test_negotiate_prefers_utf8_when_all_available() { + let params = InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![ + PositionEncodingKind::new("utf-16"), + PositionEncodingKind::new("utf-8"), + PositionEncodingKind::new("utf-32"), + ]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(PositionEncoding::negotiate(¶ms), PositionEncoding::Utf8); + } + + #[test] + fn test_negotiate_prefers_utf32_over_utf16() { + let params = InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![ + PositionEncodingKind::new("utf-16"), + PositionEncodingKind::new("utf-32"), + ]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + PositionEncoding::negotiate(¶ms), + PositionEncoding::Utf32 + ); + } + + #[test] + fn test_negotiate_accepts_utf16_when_only_option() { + let params = InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::new("utf-16")]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + PositionEncoding::negotiate(¶ms), + PositionEncoding::Utf16 + ); + } + + #[test] + fn test_negotiate_fallback_with_empty_encodings() { + let params = InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + PositionEncoding::negotiate(¶ms), + PositionEncoding::Utf16 + ); + } + + #[test] + fn test_negotiate_fallback_with_no_capabilities() { + let params = InitializeParams::default(); + assert_eq!( + PositionEncoding::negotiate(¶ms), + PositionEncoding::Utf16 + ); + } + + #[test] + fn test_negotiate_fallback_with_unknown_encodings() { + let params = InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings: Some(vec![ + PositionEncodingKind::new("utf-7"), + PositionEncodingKind::new("ascii"), + ]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + PositionEncoding::negotiate(¶ms), + PositionEncoding::Utf16 + ); + } + + #[test] + fn test_default_is_utf16() { + assert_eq!(PositionEncoding::default(), PositionEncoding::Utf16); + } +} diff --git a/crates/djls-workspace/src/fs.rs b/crates/djls-workspace/src/fs.rs new file mode 100644 index 00000000..1c4bdda0 --- /dev/null +++ b/crates/djls-workspace/src/fs.rs @@ -0,0 +1,329 @@ +//! Virtual file system abstraction +//! +//! This module provides the [`FileSystem`] trait that abstracts file I/O operations. +//! This allows the LSP to work with both real files and in-memory overlays. + +#[cfg(test)] +use std::collections::HashMap; +use std::io; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; +use std::sync::Arc; + +use crate::buffers::Buffers; +use crate::paths; + +pub trait FileSystem: Send + Sync { + fn read_to_string(&self, path: &Path) -> io::Result; + fn exists(&self, path: &Path) -> bool; +} + +#[cfg(test)] +pub struct InMemoryFileSystem { + files: HashMap, +} + +#[cfg(test)] +impl InMemoryFileSystem { + pub fn new() -> Self { + Self { + files: HashMap::new(), + } + } + + pub fn add_file(&mut self, path: PathBuf, content: String) { + self.files.insert(path, content); + } +} + +#[cfg(test)] +impl FileSystem for InMemoryFileSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + self.files + .get(path) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found")) + } + + fn exists(&self, path: &Path) -> bool { + self.files.contains_key(path) + } +} + +/// Standard file system implementation that uses [`std::fs`]. +pub struct OsFileSystem; + +impl FileSystem for OsFileSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + std::fs::read_to_string(path) + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } +} + +/// LSP file system that intercepts reads for buffered files. +/// +/// This implements a two-layer architecture where Layer 1 (open [`Buffers`]) +/// takes precedence over Layer 2 (Salsa database). When a file is read, +/// this system first checks for a buffer (in-memory content from +/// [`TextDocument`](crate::document::TextDocument)) and returns that content. +/// If no buffer exists, it falls back to reading from disk. +/// +/// ## Overlay Semantics +/// +/// Files in the overlay (buffered files) are treated as first-class files: +/// - `exists()` returns true for overlay files even if they don't exist on disk +/// - `read_to_string()` returns the overlay content +/// +/// This ensures consistent behavior across all filesystem operations for +/// buffered files that may not yet be saved to disk. +/// +/// This type is used by the [`Database`](crate::db::Database) to ensure all file reads go +/// through the buffer system first. +pub struct WorkspaceFileSystem { + /// In-memory buffers that take precedence over disk files + buffers: Buffers, + /// Fallback file system for disk operations + disk: Arc, +} + +impl WorkspaceFileSystem { + #[must_use] + pub fn new(buffers: Buffers, disk: Arc) -> Self { + Self { buffers, disk } + } +} + +impl FileSystem for WorkspaceFileSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + if let Some(url) = paths::path_to_url(path) { + if let Some(document) = self.buffers.get(&url) { + return Ok(document.content().to_string()); + } + } + self.disk.read_to_string(path) + } + + fn exists(&self, path: &Path) -> bool { + paths::path_to_url(path).is_some_and(|url| self.buffers.contains(&url)) + || self.disk.exists(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod in_memory { + use super::*; + + #[test] + fn test_read_existing_file() { + let mut fs = InMemoryFileSystem::new(); + fs.add_file("/test.py".into(), "file content".to_string()); + + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "file content" + ); + } + + #[test] + fn test_read_nonexistent_file() { + let fs = InMemoryFileSystem::new(); + + let result = fs.read_to_string(Path::new("/missing.py")); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + } + + #[test] + fn test_exists_returns_true_for_existing() { + let mut fs = InMemoryFileSystem::new(); + fs.add_file("/exists.py".into(), "content".to_string()); + + assert!(fs.exists(Path::new("/exists.py"))); + } + + #[test] + fn test_exists_returns_false_for_nonexistent() { + let fs = InMemoryFileSystem::new(); + + assert!(!fs.exists(Path::new("/missing.py"))); + } + } + + mod workspace { + use url::Url; + + use super::*; + use crate::buffers::Buffers; + use crate::document::TextDocument; + use crate::language::LanguageId; + + #[test] + fn test_reads_from_buffer_when_present() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + // Add file to buffer + let url = Url::from_file_path("/test.py").unwrap(); + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "buffer content" + ); + } + + #[test] + fn test_reads_from_disk_when_no_buffer() { + let mut disk_fs = InMemoryFileSystem::new(); + disk_fs.add_file("/test.py".into(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); + + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "disk content" + ); + } + + #[test] + fn test_buffer_overrides_disk() { + let mut disk_fs = InMemoryFileSystem::new(); + disk_fs.add_file("/test.py".into(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + // Add buffer with different content + let url = Url::from_file_path("/test.py").unwrap(); + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "buffer content" + ); + } + + #[test] + fn test_exists_for_buffer_only_file() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + // Add file only to buffer + let url = Url::from_file_path("/buffer_only.py").unwrap(); + let doc = TextDocument::new("content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert!(fs.exists(Path::new("/buffer_only.py"))); + } + + #[test] + fn test_exists_for_disk_only_file() { + let mut disk_fs = InMemoryFileSystem::new(); + disk_fs.add_file("/disk_only.py".into(), "content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); + + assert!(fs.exists(Path::new("/disk_only.py"))); + } + + #[test] + fn test_exists_for_both_buffer_and_disk() { + let mut disk_fs = InMemoryFileSystem::new(); + disk_fs.add_file("/both.py".into(), "disk".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + // Also add to buffer + let url = Url::from_file_path("/both.py").unwrap(); + let doc = TextDocument::new("buffer".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert!(fs.exists(Path::new("/both.py"))); + } + + #[test] + fn test_exists_returns_false_when_nowhere() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, disk); + + assert!(!fs.exists(Path::new("/nowhere.py"))); + } + + #[test] + fn test_read_error_when_file_nowhere() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, disk); + + let result = fs.read_to_string(Path::new("/missing.py")); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + } + + #[test] + fn test_reflects_buffer_updates() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + let url = Url::from_file_path("/test.py").unwrap(); + + // Initial buffer content + let doc1 = TextDocument::new("version 1".to_string(), 1, LanguageId::Python); + buffers.open(url.clone(), doc1); + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "version 1" + ); + + // Update buffer content + let doc2 = TextDocument::new("version 2".to_string(), 2, LanguageId::Python); + buffers.update(url, doc2); + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "version 2" + ); + } + + #[test] + fn test_handles_buffer_removal() { + let mut disk_fs = InMemoryFileSystem::new(); + disk_fs.add_file("/test.py".into(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + let url = Url::from_file_path("/test.py").unwrap(); + + // Add buffer + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url.clone(), doc); + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "buffer content" + ); + + // Remove buffer + let _ = buffers.close(&url); + assert_eq!( + fs.read_to_string(Path::new("/test.py")).unwrap(), + "disk content" + ); + } + } +} diff --git a/crates/djls-workspace/src/language.rs b/crates/djls-workspace/src/language.rs new file mode 100644 index 00000000..ea9b3834 --- /dev/null +++ b/crates/djls-workspace/src/language.rs @@ -0,0 +1,48 @@ +//! Language identification for document routing +//! +//! Maps LSP language identifiers to internal [`FileKind`] for analyzer routing. +//! Language IDs come from the LSP client and determine how files are processed. + +use crate::FileKind; + +/// Language identifier as reported by the LSP client. +/// +/// These identifiers follow VS Code's language ID conventions and determine +/// which analyzers and features are available for a document. Converts to +/// [`FileKind`] to route files to appropriate analyzers (Python vs Template). +#[derive(Clone, Debug, PartialEq)] +pub enum LanguageId { + Html, + HtmlDjango, + Other, + PlainText, + Python, +} + +impl From<&str> for LanguageId { + fn from(language_id: &str) -> Self { + match language_id { + "django-html" | "htmldjango" => Self::HtmlDjango, + "html" => Self::Html, + "plaintext" => Self::PlainText, + "python" => Self::Python, + _ => Self::Other, + } + } +} + +impl From for LanguageId { + fn from(language_id: String) -> Self { + Self::from(language_id.as_str()) + } +} + +impl From for FileKind { + fn from(language_id: LanguageId) -> Self { + match language_id { + LanguageId::Python => Self::Python, + LanguageId::HtmlDjango => Self::Template, + LanguageId::Html | LanguageId::PlainText | LanguageId::Other => Self::Other, + } + } +} diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs new file mode 100644 index 00000000..532f88c9 --- /dev/null +++ b/crates/djls-workspace/src/lib.rs @@ -0,0 +1,60 @@ +//! Workspace management for the Django Language Server +//! +//! This crate provides the core workspace functionality including document management, +//! file system abstractions, and Salsa integration for incremental computation of +//! Django projects. +//! +//! # Key Components +//! +//! - [`Buffers`] - Thread-safe storage for open documents +//! - [`Database`] - Salsa database for incremental computation +//! - [`TextDocument`] - LSP document representation with efficient indexing +//! - [`FileSystem`] - Abstraction layer for file operations with overlay support +//! - [`paths`] - Consistent URL/path conversion utilities + +mod buffers; +pub mod db; +mod document; +pub mod encoding; +mod fs; +mod language; +pub mod paths; +mod storage; +mod workspace; + +use std::path::Path; + +pub use buffers::Buffers; +pub use db::Database; +pub use document::TextDocument; +pub use encoding::PositionEncoding; +pub use fs::FileSystem; +pub use fs::OsFileSystem; +pub use fs::WorkspaceFileSystem; +pub use language::LanguageId; +pub use workspace::Workspace; + +/// File classification for routing to analyzers. +/// +/// [`FileKind`] determines how a file should be processed by downstream analyzers. +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub enum FileKind { + /// Python source file + Python, + /// Django template file + Template, + /// Other file type + Other, +} + +impl FileKind { + /// Determine [`FileKind`] from a file path extension. + #[must_use] + pub fn from_path(path: &Path) -> Self { + match path.extension().and_then(|s| s.to_str()) { + Some("py") => FileKind::Python, + Some("html" | "htm") => FileKind::Template, + _ => FileKind::Other, + } + } +} diff --git a/crates/djls-workspace/src/paths.rs b/crates/djls-workspace/src/paths.rs new file mode 100644 index 00000000..06960747 --- /dev/null +++ b/crates/djls-workspace/src/paths.rs @@ -0,0 +1,205 @@ +//! Path and URL conversion utilities +//! +//! This module provides consistent conversion between file paths and URLs, +//! handling platform-specific differences and encoding issues. + +use std::path::Path; +use std::path::PathBuf; + +use tower_lsp_server::lsp_types; +use url::Url; + +/// Convert a `file://` URL to a [`PathBuf`]. +/// +/// Handles percent-encoding and platform-specific path formats (e.g., Windows drives). +#[must_use] +pub fn url_to_path(url: &Url) -> Option { + // Only handle file:// URLs + if url.scheme() != "file" { + return None; + } + + // Get the path component and decode percent-encoding + let path = percent_encoding::percent_decode_str(url.path()) + .decode_utf8() + .ok()?; + + #[cfg(windows)] + let path = { + // Remove leading '/' for paths like /C:/... + path.strip_prefix('/').unwrap_or(&path) + }; + + Some(PathBuf::from(path.as_ref())) +} + +/// Context for LSP operations, used for error reporting +#[derive(Debug, Clone, Copy)] +pub enum LspContext { + /// textDocument/didOpen notification + DidOpen, + /// textDocument/didChange notification + DidChange, + /// textDocument/didClose notification + DidClose, + /// textDocument/completion request + Completion, +} + +impl std::fmt::Display for LspContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DidOpen => write!(f, "didOpen"), + Self::DidChange => write!(f, "didChange"), + Self::DidClose => write!(f, "didClose"), + Self::Completion => write!(f, "completion"), + } + } +} + +/// Parse an LSP URI to a [`Url`], logging errors if parsing fails. +/// +/// This function is designed for use in LSP notification handlers where +/// invalid URIs should be logged but not crash the server. +pub fn parse_lsp_uri(lsp_uri: &lsp_types::Uri, context: LspContext) -> Option { + match Url::parse(lsp_uri.as_str()) { + Ok(url) => Some(url), + Err(e) => { + tracing::error!( + "Invalid URI from LSP client in {}: {} - Error: {}", + context, + lsp_uri.as_str(), + e + ); + None + } + } +} + +/// Convert an LSP URI to a [`PathBuf`]. +/// +/// This is a convenience wrapper that parses the LSP URI string and converts it. +#[must_use] +pub fn lsp_uri_to_path(lsp_uri: &lsp_types::Uri) -> Option { + // Parse the URI string as a URL + let url = Url::parse(lsp_uri.as_str()).ok()?; + url_to_path(&url) +} + +/// Convert a [`Path`] to a `file://` URL +/// +/// Handles both absolute and relative paths. Relative paths are resolved +/// to absolute paths before conversion. This function does not require +/// the path to exist on the filesystem, making it suitable for overlay +/// files and other virtual content. +#[must_use] +pub fn path_to_url(path: &Path) -> Option { + // For absolute paths, convert directly + if path.is_absolute() { + return Url::from_file_path(path).ok(); + } + + // For relative paths, make them absolute without requiring existence + // First try to get the current directory + let current_dir = std::env::current_dir().ok()?; + let absolute_path = current_dir.join(path); + + // Try to canonicalize if the file exists (to resolve symlinks, etc.) + // but if it doesn't exist, use the joined path as-is + let final_path = std::fs::canonicalize(&absolute_path).unwrap_or(absolute_path); + + Url::from_file_path(final_path).ok() +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + #[test] + fn test_url_to_path_valid_file_url() { + let url = Url::parse("file:///home/user/test.py").unwrap(); + assert_eq!(url_to_path(&url), Some(PathBuf::from("/home/user/test.py"))); + } + + #[test] + fn test_url_to_path_non_file_scheme() { + let url = Url::parse("http://example.com/test.py").unwrap(); + assert_eq!(url_to_path(&url), None); + } + + #[test] + fn test_url_to_path_percent_encoded() { + let url = Url::parse("file:///home/user/test%20file.py").unwrap(); + assert_eq!( + url_to_path(&url), + Some(PathBuf::from("/home/user/test file.py")) + ); + } + + #[test] + #[cfg(windows)] + fn test_url_to_path_windows_drive() { + let url = Url::parse("file:///C:/Users/test.py").unwrap(); + assert_eq!(url_to_path(&url), Some(PathBuf::from("C:/Users/test.py"))); + } + + #[test] + fn test_parse_lsp_uri_valid() { + let uri = lsp_types::Uri::from_str("file:///test.py").unwrap(); + let result = parse_lsp_uri(&uri, LspContext::DidOpen); + assert!(result.is_some()); + assert_eq!(result.unwrap().scheme(), "file"); + } + + // lsp_uri_to_path tests + #[test] + fn test_lsp_uri_to_path_valid_file() { + let uri = lsp_types::Uri::from_str("file:///home/user/test.py").unwrap(); + assert_eq!( + lsp_uri_to_path(&uri), + Some(PathBuf::from("/home/user/test.py")) + ); + } + + #[test] + fn test_lsp_uri_to_path_non_file() { + let uri = lsp_types::Uri::from_str("http://example.com/test.py").unwrap(); + assert_eq!(lsp_uri_to_path(&uri), None); + } + + #[test] + fn test_lsp_uri_to_path_invalid_uri() { + let uri = lsp_types::Uri::from_str("not://valid").unwrap(); + assert_eq!(lsp_uri_to_path(&uri), None); + } + + // path_to_url tests + #[test] + fn test_path_to_url_absolute() { + let path = Path::new("/home/user/test.py"); + let url = path_to_url(path); + assert!(url.is_some()); + assert_eq!(url.clone().unwrap().scheme(), "file"); + assert!(url.unwrap().path().contains("test.py")); + } + + #[test] + fn test_path_to_url_relative() { + let path = Path::new("test.py"); + let url = path_to_url(path); + assert!(url.is_some()); + assert_eq!(url.clone().unwrap().scheme(), "file"); + // Should be resolved to absolute path + assert!(url.unwrap().path().ends_with("/test.py")); + } + + #[test] + fn test_path_to_url_nonexistent_absolute() { + let path = Path::new("/definitely/does/not/exist/test.py"); + let url = path_to_url(path); + assert!(url.is_some()); + assert_eq!(url.unwrap().scheme(), "file"); + } +} diff --git a/crates/djls-workspace/src/storage.rs b/crates/djls-workspace/src/storage.rs new file mode 100644 index 00000000..28a25fff --- /dev/null +++ b/crates/djls-workspace/src/storage.rs @@ -0,0 +1,343 @@ +use salsa::StorageHandle; + +use crate::db::Database; + +/// Safe wrapper for [`StorageHandle`](salsa::StorageHandle) that prevents misuse through type safety. +/// +/// This enum ensures that database handles can only be in one of two valid states, +/// making invalid states unrepresentable and eliminating the need for placeholder +/// handles during mutations. +/// +/// ## Panic Behavior +/// +/// Methods in this type may panic when the state machine invariants are violated. +/// These panics represent **programming bugs**, not runtime errors that should be +/// handled. They indicate violations of the internal API contract, similar to how +/// `RefCell::borrow_mut()` panics on double borrows. The panics ensure that bugs +/// are caught during development rather than causing silent data corruption. +pub enum SafeStorageHandle { + /// Handle is available for use + Available(StorageHandle), + /// Handle has been taken for mutation - no handle available + TakenForMutation, +} + +impl SafeStorageHandle { + /// Create a new `SafeStorageHandle` in the `Available` state + pub fn new(handle: StorageHandle) -> Self { + Self::Available(handle) + } + + /// Take the handle for mutation, leaving the enum in `TakenForMutation` state. + /// + /// ## Panics + /// + /// Panics if the handle has already been taken for mutation. + pub fn take_for_mutation(&mut self) -> StorageHandle { + match std::mem::replace(self, Self::TakenForMutation) { + Self::Available(handle) => handle, + Self::TakenForMutation => panic!( + "Database handle already taken for mutation. This indicates a programming error - \ + ensure you're not calling multiple mutation operations concurrently or forgetting \ + to restore the handle after a previous mutation." + ), + } + } + + /// Restore the handle after mutation, returning it to `Available` state. + /// + /// ## Panics + /// + /// Panics if the handle is not currently taken for mutation. + pub fn restore_from_mutation(&mut self, handle: StorageHandle) { + match self { + Self::TakenForMutation => { + *self = Self::Available(handle); + } + Self::Available(_) => panic!( + "Cannot restore database handle - handle is not currently taken for mutation. \ + This indicates a programming error in the StorageHandleGuard implementation." + ), + } + } + + /// Get a clone of the handle for read-only operations. + /// + /// ## Panics + /// + /// Panics if the handle is currently taken for mutation. + pub fn clone_for_read(&self) -> StorageHandle { + match self { + Self::Available(handle) => handle.clone(), + Self::TakenForMutation => panic!( + "Cannot access database handle for read - handle is currently taken for mutation. \ + Wait for the current mutation operation to complete." + ), + } + } + + /// Take the handle for mutation with automatic restoration via guard. + /// This ensures the handle is always restored even if the operation panics. + pub fn take_guarded(&mut self) -> StorageHandleGuard { + StorageHandleGuard::new(self) + } +} + +/// State of the [`StorageHandleGuard`] during its lifetime. +/// +/// See [`StorageHandleGuard`] for usage and state machine details. +enum GuardState { + /// Guard holds the handle, ready to be consumed + Active { handle: StorageHandle }, + /// Handle consumed, awaiting restoration + Consumed, + /// Handle restored to [`SafeStorageHandle`] + Restored, +} + +/// RAII guard for safe [`StorageHandle`](salsa::StorageHandle) management during mutations. +/// +/// This guard ensures that database handles are automatically restored even if +/// panics occur during mutation operations. It prevents double-takes and +/// provides clear error messages for misuse. +/// +/// ## State Machine +/// +/// The guard follows these valid state transitions: +/// - `Active` → `Consumed` (via `handle()` method) +/// - `Consumed` → `Restored` (via `restore()` method) +/// +/// ## Invalid Transitions +/// +/// Invalid operations will panic with specific error messages: +/// - `handle()` on `Consumed` state: "[`StorageHandle`](salsa::StorageHandle) already consumed" +/// - `handle()` on `Restored` state: "Cannot consume handle - guard has already been restored" +/// - `restore()` on `Active` state: "Cannot restore handle - it hasn't been consumed yet" +/// - `restore()` on `Restored` state: "Handle has already been restored" +/// +/// ## Drop Behavior +/// +/// The guard will panic on drop unless it's in the `Restored` state: +/// - Drop in `Active` state: "`StorageHandleGuard` dropped without using the handle" +/// - Drop in `Consumed` state: "`StorageHandleGuard` dropped without restoring handle" +/// - Drop in `Restored` state: No panic - proper cleanup completed +/// +/// ## Usage Example +/// +/// ```rust,ignore +/// let mut guard = StorageHandleGuard::new(&mut safe_handle); +/// let handle = guard.handle(); // Active → Consumed +/// // ... perform mutations with handle ... +/// guard.restore(updated_handle); // Consumed → Restored +/// // Guard drops cleanly in Restored state +/// ``` +#[must_use = "StorageHandleGuard must be used - dropping it immediately defeats the purpose"] +pub struct StorageHandleGuard<'a> { + /// Reference to the workspace's `SafeStorageHandle` for restoration + safe_handle: &'a mut SafeStorageHandle, + /// Current state of the guard and handle + state: GuardState, +} + +impl<'a> StorageHandleGuard<'a> { + /// Create a new guard by taking the handle from the `SafeStorageHandle`. + pub fn new(safe_handle: &'a mut SafeStorageHandle) -> Self { + let handle = safe_handle.take_for_mutation(); + Self { + safe_handle, + state: GuardState::Active { handle }, + } + } + + /// Get the [`StorageHandle`](salsa::StorageHandle) for mutation operations. + /// + /// ## Panics + /// + /// Panics if the handle has already been consumed or restored. + pub fn handle(&mut self) -> StorageHandle { + match std::mem::replace(&mut self.state, GuardState::Consumed) { + GuardState::Active { handle } => handle, + GuardState::Consumed => panic!( + "StorageHandle already consumed from guard. Each guard can only provide \ + the handle once - this prevents accidental multiple uses." + ), + GuardState::Restored => panic!( + "Cannot consume handle - guard has already been restored. Once restored, \ + the guard cannot provide the handle again." + ), + } + } + + /// Restore the handle manually before the guard drops. + /// + /// This is useful when you want to restore the handle and continue using + /// the workspace in the same scope. + /// + /// ## Panics + /// + /// Panics if the handle hasn't been consumed yet, or if already restored. + pub fn restore(mut self, handle: StorageHandle) { + match self.state { + GuardState::Consumed => { + self.safe_handle.restore_from_mutation(handle); + self.state = GuardState::Restored; + } + GuardState::Active { .. } => panic!( + "Cannot restore handle - it hasn't been consumed yet. Call guard.handle() \ + first to get the handle, then restore the updated handle after mutations." + ), + GuardState::Restored => { + panic!("Handle has already been restored. Each guard can only restore once.") + } + } + } +} + +impl Drop for StorageHandleGuard<'_> { + fn drop(&mut self) { + // Provide specific error messages based on the exact state + // Avoid double-panic during unwinding + if !std::thread::panicking() { + match &self.state { + GuardState::Active { .. } => { + panic!( + "StorageHandleGuard dropped without using the handle. Either call \ + guard.handle() to consume the handle for mutations, or ensure the \ + guard is properly used in your mutation workflow." + ); + } + GuardState::Consumed => { + panic!( + "StorageHandleGuard dropped without restoring handle. You must call \ + guard.restore(updated_handle) to properly restore the database handle \ + after mutation operations complete." + ); + } + GuardState::Restored => { + // All good - proper cleanup completed + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dashmap::DashMap; + + use super::*; + use crate::buffers::Buffers; + use crate::fs::OsFileSystem; + use crate::fs::WorkspaceFileSystem; + + fn create_test_handle() -> StorageHandle { + Database::new( + Arc::new(WorkspaceFileSystem::new( + Buffers::new(), + Arc::new(OsFileSystem), + )), + Arc::new(DashMap::new()), + ) + .storage() + .clone() + .into_zalsa_handle() + } + + #[test] + fn test_handle_lifecycle() { + // Test the happy path: take handle, use it, restore it + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + + let handle = safe_handle.take_for_mutation(); + + // Simulate using the handle to create a database + let storage = handle.into_storage(); + let db = Database::from_storage( + storage, + Arc::new(WorkspaceFileSystem::new( + Buffers::new(), + Arc::new(OsFileSystem), + )), + Arc::new(DashMap::new()), + ); + + // Get new handle after simulated mutation + let new_handle = db.storage().clone().into_zalsa_handle(); + + safe_handle.restore_from_mutation(new_handle); + + // Should be able to take it again + let _handle2 = safe_handle.take_for_mutation(); + } + + #[test] + fn test_guard_auto_restore_on_drop() { + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + + { + let mut guard = safe_handle.take_guarded(); + let handle = guard.handle(); + + // Simulate mutation + let storage = handle.into_storage(); + let db = Database::from_storage( + storage, + Arc::new(WorkspaceFileSystem::new( + Buffers::new(), + Arc::new(OsFileSystem), + )), + Arc::new(DashMap::new()), + ); + let new_handle = db.storage().clone().into_zalsa_handle(); + + guard.restore(new_handle); + } // Guard drops here in Restored state - should be clean + + // Should be able to use handle again after guard drops + let _handle = safe_handle.clone_for_read(); + } + + #[test] + #[should_panic(expected = "Database handle already taken for mutation")] + fn test_panic_on_double_mutation() { + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + + let _handle1 = safe_handle.take_for_mutation(); + // Can't take handle twice, should panic + let _handle2 = safe_handle.take_for_mutation(); + } + + #[test] + #[should_panic(expected = "Cannot access database handle for read")] + fn test_panic_on_read_during_mutation() { + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + + let _handle = safe_handle.take_for_mutation(); + // Can't read while mutating, should panic + let _read = safe_handle.clone_for_read(); + } + + #[test] + #[should_panic(expected = "Cannot restore handle - it hasn't been consumed yet")] + fn test_guard_enforces_consume_before_restore() { + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + let guard = safe_handle.take_guarded(); + + let dummy_handle = create_test_handle(); + // Try to restore without consuming, should panic + guard.restore(dummy_handle); + } + + #[test] + #[should_panic(expected = "StorageHandleGuard dropped without restoring handle")] + fn test_guard_panics_if_dropped_without_restore() { + let mut safe_handle = SafeStorageHandle::new(create_test_handle()); + + { + let mut guard = safe_handle.take_guarded(); + let _handle = guard.handle(); + } // Explicitly drop guard without restore, should panic + } +} diff --git a/crates/djls-workspace/src/workspace.rs b/crates/djls-workspace/src/workspace.rs new file mode 100644 index 00000000..dfb9a2da --- /dev/null +++ b/crates/djls-workspace/src/workspace.rs @@ -0,0 +1,416 @@ +//! Workspace facade for managing all workspace components +//! +//! This module provides the [`Workspace`] struct that encapsulates all workspace +//! components including buffers, file system, file tracking, and database handle. +//! This provides a clean API boundary between server and workspace layers. + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use dashmap::DashMap; +use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; +use url::Url; + +use crate::buffers::Buffers; +use crate::db::Database; +use crate::db::SourceFile; +use crate::document::TextDocument; +use crate::fs::OsFileSystem; +use crate::fs::WorkspaceFileSystem; +use crate::paths::url_to_path; +use crate::storage::SafeStorageHandle; + +/// Workspace facade that encapsulates all workspace components. +/// +/// This struct provides a unified interface for managing workspace state, +/// including in-memory buffers, file system abstraction, file tracking, +/// and the Salsa database handle. +pub struct Workspace { + /// Thread-safe shared buffer storage for open documents + buffers: Buffers, + /// File system abstraction with buffer interception + file_system: Arc, + /// Shared file tracking across all Database instances + files: Arc>, + /// Thread-safe Salsa database handle for incremental computation with safe mutation management + db_handle: SafeStorageHandle, +} + +impl Workspace { + /// Create a new [`Workspace`] with all components initialized. + #[must_use] + pub fn new() -> Self { + let buffers = Buffers::new(); + let files = Arc::new(DashMap::new()); + let file_system = Arc::new(WorkspaceFileSystem::new( + buffers.clone(), + Arc::new(OsFileSystem), + )); + let handle = Database::new(file_system.clone(), files.clone()) + .storage() + .clone() + .into_zalsa_handle(); + + Self { + buffers, + file_system, + files, + db_handle: SafeStorageHandle::new(handle), + } + } + + /// Execute a read-only operation with access to the database. + /// + /// Creates a temporary Database instance from the handle for the closure. + /// This is safe for concurrent read operations. + pub fn with_db(&self, f: F) -> R + where + F: FnOnce(&Database) -> R, + { + let handle = self.db_handle.clone_for_read(); + let storage = handle.into_storage(); + let db = Database::from_storage(storage, self.file_system.clone(), self.files.clone()); + f(&db) + } + + /// Execute a mutable operation with exclusive access to the database. + /// + /// Uses the `StorageHandleGuard` pattern to ensure the handle is safely restored + /// even if the operation panics. This eliminates the need for placeholder handles. + pub fn with_db_mut(&mut self, f: F) -> R + where + F: FnOnce(&mut Database) -> R, + { + let mut guard = self.db_handle.take_guarded(); + let handle = guard.handle(); + let storage = handle.into_storage(); + let mut db = Database::from_storage(storage, self.file_system.clone(), self.files.clone()); + let result = f(&mut db); + let new_handle = db.storage().clone().into_zalsa_handle(); + guard.restore(new_handle); + result + } + + /// Open a document in the workspace. + /// + /// Updates both the buffer layer and database layer. Creates the file in + /// the database or invalidates it if it already exists. + pub fn open_document(&mut self, url: &Url, document: TextDocument) { + // Layer 1: Add to buffers + self.buffers.open(url.clone(), document); + + // Layer 2: Create file and touch if it already exists + if let Some(path) = url_to_path(url) { + self.with_db_mut(|db| { + // Check if file already exists (was previously read from disk) + let already_exists = db.has_file(&path); + let _file = db.get_or_create_file(&path); + + if already_exists { + // File was already read - touch to invalidate cache + db.touch_file(&path); + } + // Note: New files automatically start at revision 0, no additional action needed + }); + } + } + + /// Update a document with incremental changes. + /// + /// Applies changes to the existing document and triggers database invalidation. + /// Falls back to full replacement if the document isn't currently open. + pub fn update_document( + &mut self, + url: &Url, + changes: Vec, + version: i32, + encoding: crate::encoding::PositionEncoding, + ) { + if let Some(mut document) = self.buffers.get(url) { + // Apply incremental changes to existing document + document.update(changes, version, encoding); + self.buffers.update(url.clone(), document); + } else if let Some(first_change) = changes.into_iter().next() { + // Fallback: treat first change as full replacement + if first_change.range.is_none() { + let document = TextDocument::new( + first_change.text, + version, + crate::language::LanguageId::Other, + ); + self.buffers.open(url.clone(), document); + } + } + + // Touch file in database to trigger invalidation + if let Some(path) = url_to_path(url) { + self.invalidate_file_if_exists(&path); + } + } + + /// Close a document and return it. + /// + /// Removes from buffers and triggers database invalidation to fall back to disk. + pub fn close_document(&mut self, url: &Url) -> Option { + let document = self.buffers.close(url); + + // Touch file in database to trigger re-read from disk + if let Some(path) = url_to_path(url) { + self.invalidate_file_if_exists(&path); + } + + document + } + + /// Get a document from the buffer if it's open. + /// + /// Returns a cloned [`TextDocument`] for the given URL if it exists in buffers. + #[must_use] + pub fn get_document(&self, url: &Url) -> Option { + self.buffers.get(url) + } + + /// Invalidate a file if it exists in the database. + /// + /// Used by document lifecycle methods to trigger cache invalidation. + fn invalidate_file_if_exists(&mut self, path: &Path) { + self.with_db_mut(|db| { + if db.has_file(path) { + db.touch_file(path); + } + }); + } + + #[must_use] + pub fn db_handle(&self) -> &SafeStorageHandle { + &self.db_handle + } +} + +impl Default for Workspace { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use tempfile::tempdir; + + use super::*; + use crate::db::source_text; + use crate::encoding::PositionEncoding; + use crate::LanguageId; + + #[test] + fn test_with_db_read() { + // Read-only access works + let workspace = Workspace::new(); + + let result = workspace.with_db(|db| { + // Can perform read operations + db.has_file(&PathBuf::from("test.py")) + }); + + assert!(!result); // File doesn't exist yet + } + + #[test] + fn test_with_db_mut() { + // Mutation access works + let mut workspace = Workspace::new(); + + // Create a file through mutation + workspace.with_db_mut(|db| { + let path = PathBuf::from("test.py"); + let _file = db.get_or_create_file(&path); + }); + + // Verify it exists + let exists = workspace.with_db(|db| db.has_file(&PathBuf::from("test.py"))); + assert!(exists); + } + + #[test] + fn test_concurrent_reads() { + // Multiple with_db calls can run simultaneously + let workspace = Arc::new(Workspace::new()); + + let w1 = workspace.clone(); + let w2 = workspace.clone(); + + // Spawn concurrent reads + let handle1 = + std::thread::spawn(move || w1.with_db(|db| db.has_file(&PathBuf::from("file1.py")))); + + let handle2 = + std::thread::spawn(move || w2.with_db(|db| db.has_file(&PathBuf::from("file2.py")))); + + // Both should complete without issues + assert!(!handle1.join().unwrap()); + assert!(!handle2.join().unwrap()); + } + + #[test] + fn test_sequential_mutations() { + // Multiple with_db_mut calls work in sequence + let mut workspace = Workspace::new(); + + // First mutation + workspace.with_db_mut(|db| { + let _file = db.get_or_create_file(&PathBuf::from("first.py")); + }); + + // Second mutation + workspace.with_db_mut(|db| { + let _file = db.get_or_create_file(&PathBuf::from("second.py")); + }); + + // Both files should exist + let (has_first, has_second) = workspace.with_db(|db| { + ( + db.has_file(&PathBuf::from("first.py")), + db.has_file(&PathBuf::from("second.py")), + ) + }); + + assert!(has_first); + assert!(has_second); + } + + #[test] + fn test_open_document() { + // Open doc → appears in buffers → queryable via db + let mut workspace = Workspace::new(); + let url = Url::parse("file:///test.py").unwrap(); + + // Open document + let document = TextDocument::new("print('hello')".to_string(), 1, LanguageId::Python); + workspace.open_document(&url, document); + + // Should be in buffers + assert!(workspace.buffers.get(&url).is_some()); + + // Should be queryable through database + let content = workspace.with_db_mut(|db| { + let file = db.get_or_create_file(&PathBuf::from("/test.py")); + source_text(db, file).to_string() + }); + + assert_eq!(content, "print('hello')"); + } + + #[test] + fn test_update_document() { + // Update changes buffer content + let mut workspace = Workspace::new(); + let url = Url::parse("file:///test.py").unwrap(); + + // Open with initial content + let document = TextDocument::new("initial".to_string(), 1, LanguageId::Python); + workspace.open_document(&url, document); + + // Update content + let changes = vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "updated".to_string(), + }]; + workspace.update_document(&url, changes, 2, PositionEncoding::Utf16); + + // Verify buffer was updated + let buffer = workspace.buffers.get(&url).unwrap(); + assert_eq!(buffer.content(), "updated"); + assert_eq!(buffer.version(), 2); + } + + #[test] + fn test_close_document() { + // Close removes from buffers + let mut workspace = Workspace::new(); + let url = Url::parse("file:///test.py").unwrap(); + + // Open document + let document = TextDocument::new("content".to_string(), 1, LanguageId::Python); + workspace.open_document(&url, document.clone()); + + // Close it + let closed = workspace.close_document(&url); + assert!(closed.is_some()); + + // Should no longer be in buffers + assert!(workspace.buffers.get(&url).is_none()); + } + + #[test] + fn test_buffer_takes_precedence_over_disk() { + // Open doc content overrides file system + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test.py"); + std::fs::write(&file_path, "disk content").unwrap(); + + let mut workspace = Workspace::new(); + let url = Url::from_file_path(&file_path).unwrap(); + + // Open document with different content than disk + let document = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + workspace.open_document(&url, document); + + // Database should return buffer content, not disk content + let content = workspace.with_db_mut(|db| { + let file = db.get_or_create_file(&file_path); + source_text(db, file).to_string() + }); + + assert_eq!(content, "buffer content"); + } + + #[test] + fn test_missing_file_returns_empty() { + // Non-existent files return "" not error + let mut workspace = Workspace::new(); + + let content = workspace.with_db_mut(|db| { + let file = db.get_or_create_file(&PathBuf::from("/nonexistent.py")); + source_text(db, file).to_string() + }); + + assert_eq!(content, ""); + } + + #[test] + fn test_file_invalidation_on_touch() { + // touch_file triggers Salsa recomputation + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test.py"); + std::fs::write(&file_path, "version 1").unwrap(); + + let mut workspace = Workspace::new(); + + // First read + let content1 = workspace.with_db_mut(|db| { + let file = db.get_or_create_file(&file_path); + source_text(db, file).to_string() + }); + assert_eq!(content1, "version 1"); + + // Update file on disk + std::fs::write(&file_path, "version 2").unwrap(); + + // Touch to invalidate + workspace.with_db_mut(|db| { + db.touch_file(&file_path); + }); + + // Should read new content + let content2 = workspace.with_db_mut(|db| { + let file = db.get_or_create_file(&file_path); + source_text(db, file).to_string() + }); + assert_eq!(content2, "version 2"); + } +}