mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-09-04 17:30:37 +00:00
wip
This commit is contained in:
parent
269d4bceae
commit
4e3446f6ee
14 changed files with 577 additions and 1668 deletions
103
Cargo.lock
generated
103
Cargo.lock
generated
|
@ -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",
|
||||
]
|
||||
|
@ -170,21 +170,21 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
|||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.1.11"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0"
|
||||
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",
|
||||
|
@ -192,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",
|
||||
|
@ -442,7 +442,7 @@ dependencies = [
|
|||
"directories",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.15",
|
||||
"thiserror 2.0.16",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
@ -498,7 +498,7 @@ dependencies = [
|
|||
"insta",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.15",
|
||||
"thiserror 2.0.16",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
@ -883,9 +883,9 @@ dependencies = [
|
|||
|
||||
[[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",
|
||||
|
@ -903,7 +903,7 @@ version = "0.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.2",
|
||||
"bitflags 2.9.3",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
@ -940,11 +940,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",
|
||||
]
|
||||
|
@ -1010,7 +1010,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",
|
||||
]
|
||||
|
||||
|
@ -1106,7 +1106,7 @@ version = "8.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.2",
|
||||
"bitflags 2.9.3",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
|
@ -1224,9 +1224,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"
|
||||
|
@ -1235,7 +1235,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",
|
||||
]
|
||||
|
||||
|
@ -1417,7 +1417,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]]
|
||||
|
@ -1428,19 +1428,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]]
|
||||
|
@ -1454,13 +1454,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]]
|
||||
|
@ -1471,9 +1471,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"
|
||||
|
@ -1482,7 +1482,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",
|
||||
]
|
||||
|
@ -1516,7 +1516,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",
|
||||
|
@ -1630,9 +1630,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",
|
||||
|
@ -1765,15 +1765,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]]
|
||||
|
@ -1793,11 +1793,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]]
|
||||
|
@ -1813,9 +1813,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",
|
||||
|
@ -2129,13 +2129,14 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.4"
|
||||
version = "2.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
|
||||
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2393,9 +2394,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",
|
||||
]
|
||||
|
@ -2412,7 +2413,7 @@ 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]]
|
||||
|
|
|
@ -202,25 +202,13 @@ impl LanguageServer for DjangoLanguageServer {
|
|||
async fn did_open(&self, params: lsp_types::DidOpenTextDocumentParams) {
|
||||
tracing::info!("Opened document: {:?}", params.text_document.uri);
|
||||
|
||||
self.with_session_mut(|session| {
|
||||
let uri = params.text_document.uri.clone();
|
||||
let version = params.text_document.version;
|
||||
let language_id =
|
||||
self.with_session_mut(|_session| {
|
||||
// TODO: Handle document open after refactoring
|
||||
let _uri = params.text_document.uri.clone();
|
||||
let _version = params.text_document.version;
|
||||
let _language_id =
|
||||
djls_workspace::LanguageId::from(params.text_document.language_id.as_str());
|
||||
let text = params.text_document.text.clone();
|
||||
|
||||
// Convert LSP Uri to url::Url
|
||||
if let Ok(url) = url::Url::parse(&uri.to_string()) {
|
||||
if let Err(e) =
|
||||
session
|
||||
.documents_mut()
|
||||
.open_document(url, version, language_id, text)
|
||||
{
|
||||
tracing::error!("Failed to handle did_open: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Invalid URI: {:?}", uri);
|
||||
}
|
||||
let _text = params.text_document.text.clone();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
@ -228,22 +216,11 @@ impl LanguageServer for DjangoLanguageServer {
|
|||
async fn did_change(&self, params: lsp_types::DidChangeTextDocumentParams) {
|
||||
tracing::info!("Changed document: {:?}", params.text_document.uri);
|
||||
|
||||
self.with_session_mut(|session| {
|
||||
let uri = ¶ms.text_document.uri;
|
||||
let version = params.text_document.version;
|
||||
let changes = params.content_changes.clone();
|
||||
|
||||
// Convert LSP Uri to url::Url
|
||||
if let Ok(url) = url::Url::parse(&uri.to_string()) {
|
||||
if let Err(e) = session
|
||||
.documents_mut()
|
||||
.update_document(&url, version, changes)
|
||||
{
|
||||
tracing::error!("Failed to handle did_change: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Invalid URI: {:?}", uri);
|
||||
}
|
||||
self.with_session_mut(|_session| {
|
||||
// TODO: Handle document change after refactoring
|
||||
let _uri = ¶ms.text_document.uri;
|
||||
let _version = params.text_document.version;
|
||||
let _changes = params.content_changes.clone();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
@ -251,99 +228,19 @@ impl LanguageServer for DjangoLanguageServer {
|
|||
async fn did_close(&self, params: lsp_types::DidCloseTextDocumentParams) {
|
||||
tracing::info!("Closed document: {:?}", params.text_document.uri);
|
||||
|
||||
self.with_session_mut(|session| {
|
||||
let uri = ¶ms.text_document.uri;
|
||||
|
||||
// Convert LSP Uri to url::Url
|
||||
if let Ok(url) = url::Url::parse(&uri.to_string()) {
|
||||
session.documents_mut().close_document(&url);
|
||||
} else {
|
||||
tracing::error!("Invalid URI: {:?}", uri);
|
||||
}
|
||||
self.with_session_mut(|_session| {
|
||||
// TODO: Handle document close after refactoring
|
||||
let _uri = ¶ms.text_document.uri;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn completion(
|
||||
&self,
|
||||
params: lsp_types::CompletionParams,
|
||||
_params: lsp_types::CompletionParams,
|
||||
) -> LspResult<Option<lsp_types::CompletionResponse>> {
|
||||
Ok(self
|
||||
.with_session(|session| {
|
||||
if let Some(project) = session.project() {
|
||||
if let Some(tags) = project.template_tags() {
|
||||
let uri = ¶ms.text_document_position.text_document.uri;
|
||||
let position = params.text_document_position.position;
|
||||
|
||||
// Convert LSP Uri to url::Url
|
||||
if let Ok(url) = url::Url::parse(&uri.to_string()) {
|
||||
if let Some(context) =
|
||||
session.documents().get_template_context(&url, position)
|
||||
{
|
||||
// Use the context to generate completions
|
||||
let mut completions: Vec<lsp_types::CompletionItem> = 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 { "" };
|
||||
lsp_types::CompletionItem {
|
||||
label: tag.name().to_string(),
|
||||
kind: Some(lsp_types::CompletionItemKind::KEYWORD),
|
||||
detail: Some(format!(
|
||||
"Template tag from {}",
|
||||
tag.library()
|
||||
)),
|
||||
documentation: tag.doc().as_ref().map(|doc| {
|
||||
lsp_types::Documentation::MarkupContent(
|
||||
lsp_types::MarkupContent {
|
||||
kind: lsp_types::MarkupKind::Markdown,
|
||||
value: (*doc).to_string(),
|
||||
},
|
||||
)
|
||||
}),
|
||||
insert_text: Some(match context.closing_brace {
|
||||
djls_workspace::ClosingBrace::None => {
|
||||
format!("{}{} %}}", leading_space, tag.name())
|
||||
}
|
||||
djls_workspace::ClosingBrace::PartialClose => {
|
||||
format!("{}{} %", leading_space, tag.name())
|
||||
}
|
||||
djls_workspace::ClosingBrace::FullClose => {
|
||||
format!("{}{} ", leading_space, tag.name())
|
||||
}
|
||||
}),
|
||||
insert_text_format: Some(
|
||||
lsp_types::InsertTextFormat::PLAIN_TEXT,
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if completions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
completions.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
Some(lsp_types::CompletionResponse::Array(completions))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await)
|
||||
// TODO: Handle completion after refactoring
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) {
|
||||
|
|
|
@ -1,17 +1,59 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use djls_conf::Settings;
|
||||
use djls_project::DjangoProject;
|
||||
use djls_workspace::DocumentStore;
|
||||
use djls_workspace::{FileSystem, StdFileSystem, db::Database};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use salsa::StorageHandle;
|
||||
use tower_lsp_server::lsp_types;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
/// The Django project configuration
|
||||
project: Option<DjangoProject>,
|
||||
documents: DocumentStore,
|
||||
|
||||
/// LSP server settings
|
||||
settings: Settings,
|
||||
|
||||
/// 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
|
||||
/// // Clone the StorageHandle for use in an async context
|
||||
/// let db_handle = session.db_handle.clone();
|
||||
///
|
||||
/// // Use it in an async context
|
||||
/// async_fn(move || {
|
||||
/// // Get a database from the handle
|
||||
/// let storage = db_handle.into_storage();
|
||||
/// let db = Database::from_storage(storage);
|
||||
///
|
||||
/// // Use the database
|
||||
/// db.some_query(args)
|
||||
/// });
|
||||
/// ```
|
||||
db_handle: StorageHandle<Database>,
|
||||
|
||||
/// File system abstraction for reading files
|
||||
file_system: Arc<dyn FileSystem>,
|
||||
|
||||
/// Index of open documents with overlays (in-memory changes)
|
||||
/// Maps document URL to its current content
|
||||
overlays: HashMap<Url, String>,
|
||||
|
||||
/// Tracks the session revision for change detection
|
||||
revision: u64,
|
||||
|
||||
#[allow(dead_code)]
|
||||
client_capabilities: lsp_types::ClientCapabilities,
|
||||
}
|
||||
|
@ -72,8 +114,11 @@ impl Session {
|
|||
Self {
|
||||
client_capabilities: params.capabilities.clone(),
|
||||
project,
|
||||
documents: DocumentStore::new(),
|
||||
settings,
|
||||
db_handle: StorageHandle::new(None),
|
||||
file_system: Arc::new(StdFileSystem),
|
||||
overlays: HashMap::new(),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -85,13 +130,7 @@ impl Session {
|
|||
&mut self.project
|
||||
}
|
||||
|
||||
pub fn documents(&self) -> &DocumentStore {
|
||||
&self.documents
|
||||
}
|
||||
|
||||
pub fn documents_mut(&mut self) -> &mut DocumentStore {
|
||||
&mut self.documents
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> &Settings {
|
||||
&self.settings
|
||||
|
@ -100,4 +139,28 @@ impl Session {
|
|||
pub fn set_settings(&mut self, settings: Settings) {
|
||||
self.settings = settings;
|
||||
}
|
||||
|
||||
/// Get a database instance from the session.
|
||||
///
|
||||
/// This creates a usable database from the handle, which can be used
|
||||
/// to query and update data. The database itself is not Send/Sync,
|
||||
/// but the StorageHandle is, allowing us to work with tower-lsp.
|
||||
pub fn db(&self) -> Database {
|
||||
let storage = self.db_handle.clone().into_storage();
|
||||
Database::from_storage(storage)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project: None,
|
||||
settings: Settings::default(),
|
||||
db_handle: StorageHandle::new(None),
|
||||
file_system: Arc::new(StdFileSystem),
|
||||
overlays: HashMap::new(),
|
||||
revision: 0,
|
||||
client_capabilities: lsp_types::ClientCapabilities::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,9 +15,8 @@ use super::db::Database;
|
|||
use super::db::SourceFile;
|
||||
use super::db::TemplateAst;
|
||||
use super::db::TemplateLoaderOrder;
|
||||
use super::vfs::FileKind;
|
||||
use super::vfs::VfsSnapshot;
|
||||
use super::FileId;
|
||||
use super::FileKind;
|
||||
|
||||
/// Owner of the Salsa [`Database`] plus the handles for updating inputs.
|
||||
///
|
||||
|
@ -39,7 +38,7 @@ impl FileStore {
|
|||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
db: Database::default(),
|
||||
db: Database::new(),
|
||||
files: HashMap::new(),
|
||||
template_loader: None,
|
||||
}
|
||||
|
@ -59,34 +58,26 @@ impl FileStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// Mirror a VFS snapshot into Salsa inputs.
|
||||
///
|
||||
/// This method is the core synchronization point between the VFS and Salsa.
|
||||
/// It iterates through all files in the snapshot and:
|
||||
/// - Creates [`SourceFile`] inputs for new files
|
||||
/// - Updates `.text` and `.kind` only when changed to preserve incremental reuse
|
||||
///
|
||||
/// The method is idempotent and minimizes Salsa invalidations by checking for
|
||||
/// actual changes before updating inputs.
|
||||
pub(crate) fn apply_vfs_snapshot(&mut self, snap: &VfsSnapshot) {
|
||||
for (id, rec) in &snap.files {
|
||||
let new_text = snap.get_text(*id).unwrap_or_else(|| Arc::<str>::from(""));
|
||||
let new_kind = rec.meta.kind;
|
||||
// TODO: This will be replaced with direct file management
|
||||
// pub(crate) fn apply_vfs_snapshot(&mut self, snap: &VfsSnapshot) {
|
||||
// for (id, rec) in &snap.files {
|
||||
// let new_text = snap.get_text(*id).unwrap_or_else(|| Arc::<str>::from(""));
|
||||
// let new_kind = rec.meta.kind;
|
||||
|
||||
if let Some(sf) = self.files.get(id) {
|
||||
// Update if changed — avoid touching Salsa when not needed
|
||||
if sf.kind(&self.db) != new_kind {
|
||||
sf.set_kind(&mut self.db).to(new_kind);
|
||||
}
|
||||
if sf.text(&self.db).as_ref() != &*new_text {
|
||||
sf.set_text(&mut self.db).to(new_text.clone());
|
||||
}
|
||||
} else {
|
||||
let sf = SourceFile::new(&self.db, new_kind, new_text);
|
||||
self.files.insert(*id, sf);
|
||||
}
|
||||
}
|
||||
}
|
||||
// if let Some(sf) = self.files.get(id) {
|
||||
// // Update if changed — avoid touching Salsa when not needed
|
||||
// if sf.kind(&self.db) != new_kind {
|
||||
// sf.set_kind(&mut self.db).to(new_kind);
|
||||
// }
|
||||
// if sf.text(&self.db).as_ref() != &*new_text {
|
||||
// sf.set_text(&mut self.db).to(new_text.clone());
|
||||
// }
|
||||
// } else {
|
||||
// let sf = SourceFile::new(&self.db, new_kind, new_text);
|
||||
// self.files.insert(*id, sf);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/// Get the text content of a file by its [`FileId`].
|
||||
///
|
||||
|
@ -130,109 +121,6 @@ impl Default for FileStore {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use camino::Utf8PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::vfs::TextSource;
|
||||
use crate::vfs::Vfs;
|
||||
|
||||
#[test]
|
||||
fn test_filestore_template_ast_caching() {
|
||||
let mut store = FileStore::new();
|
||||
let vfs = Vfs::default();
|
||||
|
||||
// Create a template file in VFS
|
||||
let url = url::Url::parse("file:///test.html").unwrap();
|
||||
let path = Utf8PathBuf::from("/test.html");
|
||||
let content: Arc<str> = Arc::from("{% if user %}Hello {{ user.name }}{% endif %}");
|
||||
let file_id = vfs.intern_file(
|
||||
url.clone(),
|
||||
path.clone(),
|
||||
FileKind::Template,
|
||||
TextSource::Overlay(content.clone()),
|
||||
);
|
||||
vfs.set_overlay(file_id, content.clone()).unwrap();
|
||||
|
||||
// Apply VFS snapshot to FileStore
|
||||
let snapshot = vfs.snapshot();
|
||||
store.apply_vfs_snapshot(&snapshot);
|
||||
|
||||
// Get template AST - should parse and cache
|
||||
let ast1 = store.get_template_ast(file_id);
|
||||
assert!(ast1.is_some());
|
||||
|
||||
// Get again - should return cached
|
||||
let ast2 = store.get_template_ast(file_id);
|
||||
assert!(ast2.is_some());
|
||||
assert!(Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filestore_template_errors() {
|
||||
let mut store = FileStore::new();
|
||||
let vfs = Vfs::default();
|
||||
|
||||
// Create a template with an unclosed tag
|
||||
let url = url::Url::parse("file:///error.html").unwrap();
|
||||
let path = Utf8PathBuf::from("/error.html");
|
||||
let content: Arc<str> = Arc::from("{% if user %}Hello {{ user.name }"); // Missing closing
|
||||
let file_id = vfs.intern_file(
|
||||
url.clone(),
|
||||
path.clone(),
|
||||
FileKind::Template,
|
||||
TextSource::Overlay(content.clone()),
|
||||
);
|
||||
vfs.set_overlay(file_id, content).unwrap();
|
||||
|
||||
// Apply VFS snapshot
|
||||
let snapshot = vfs.snapshot();
|
||||
store.apply_vfs_snapshot(&snapshot);
|
||||
|
||||
// Get errors - should contain parsing errors
|
||||
let errors = store.get_template_errors(file_id);
|
||||
// The template has unclosed tags, so there should be errors
|
||||
// We don't assert on specific error count as the parser may evolve
|
||||
|
||||
// Verify errors are cached
|
||||
let errors2 = store.get_template_errors(file_id);
|
||||
assert!(Arc::ptr_eq(&errors, &errors2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filestore_invalidation_on_content_change() {
|
||||
let mut store = FileStore::new();
|
||||
let vfs = Vfs::default();
|
||||
|
||||
// Create initial template
|
||||
let url = url::Url::parse("file:///change.html").unwrap();
|
||||
let path = Utf8PathBuf::from("/change.html");
|
||||
let content1: Arc<str> = Arc::from("{% if user %}Hello{% endif %}");
|
||||
let file_id = vfs.intern_file(
|
||||
url.clone(),
|
||||
path.clone(),
|
||||
FileKind::Template,
|
||||
TextSource::Overlay(content1.clone()),
|
||||
);
|
||||
vfs.set_overlay(file_id, content1).unwrap();
|
||||
|
||||
// Apply snapshot and get AST
|
||||
let snapshot1 = vfs.snapshot();
|
||||
store.apply_vfs_snapshot(&snapshot1);
|
||||
let ast1 = store.get_template_ast(file_id);
|
||||
|
||||
// Change content
|
||||
let content2: Arc<str> = Arc::from("{% for item in items %}{{ item }}{% endfor %}");
|
||||
vfs.set_overlay(file_id, content2).unwrap();
|
||||
|
||||
// Apply new snapshot
|
||||
let snapshot2 = vfs.snapshot();
|
||||
store.apply_vfs_snapshot(&snapshot2);
|
||||
|
||||
// Get AST again - should be different due to content change
|
||||
let ast2 = store.get_template_ast(file_id);
|
||||
assert!(ast1.is_some() && ast2.is_some());
|
||||
assert!(!Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap()));
|
||||
}
|
||||
}
|
||||
// TODO: Re-enable tests after VFS removal is complete
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
|
|
|
@ -4,23 +4,36 @@
|
|||
//! Inputs are kept minimal to avoid unnecessary recomputation.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
use djls_templates::Ast;
|
||||
use dashmap::DashMap;
|
||||
use url::Url;
|
||||
|
||||
use crate::vfs::FileKind;
|
||||
use crate::{FileId, FileKind};
|
||||
|
||||
/// Salsa database root for workspace
|
||||
///
|
||||
/// The [`Database`] provides default storage and, in tests, captures Salsa events for
|
||||
/// reuse/diagnostics. It serves as the core incremental computation engine, tracking
|
||||
/// dependencies and invalidations across all inputs and derived queries.
|
||||
///
|
||||
/// This database also manages the file system overlay for the workspace,
|
||||
/// mapping URLs to FileIds and storing file content.
|
||||
#[salsa::db]
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(test), derive(Default))]
|
||||
pub struct Database {
|
||||
storage: salsa::Storage<Self>,
|
||||
|
||||
/// Map from file URL to FileId (thread-safe)
|
||||
files: DashMap<Url, FileId>,
|
||||
|
||||
/// Map from FileId to file content (thread-safe)
|
||||
content: DashMap<FileId, Arc<str>>,
|
||||
|
||||
/// Next FileId to allocate (thread-safe counter)
|
||||
next_file_id: Arc<AtomicU32>,
|
||||
|
||||
// The logs are only used for testing and demonstrating reuse:
|
||||
#[cfg(test)]
|
||||
|
@ -45,11 +58,86 @@ impl Default for Database {
|
|||
}
|
||||
}
|
||||
}))),
|
||||
files: DashMap::new(),
|
||||
content: DashMap::new(),
|
||||
next_file_id: Arc::new(AtomicU32::new(0)),
|
||||
logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Create a new database instance
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
storage: salsa::Storage::new(None),
|
||||
files: DashMap::new(),
|
||||
content: DashMap::new(),
|
||||
next_file_id: Arc::new(AtomicU32::new(0)),
|
||||
#[cfg(test)]
|
||||
logs: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new database instance from a storage handle.
|
||||
/// This is used by Session::db() to create databases from the StorageHandle.
|
||||
pub fn from_storage(storage: salsa::Storage<Self>) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
files: DashMap::new(),
|
||||
content: DashMap::new(),
|
||||
next_file_id: Arc::new(AtomicU32::new(0)),
|
||||
#[cfg(test)]
|
||||
logs: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a file in the workspace
|
||||
pub fn set_file(&mut self, url: Url, content: String, _kind: FileKind) {
|
||||
let file_id = if let Some(existing_id) = self.files.get(&url) {
|
||||
*existing_id
|
||||
} else {
|
||||
let new_id = FileId::from_raw(self.next_file_id.fetch_add(1, Ordering::SeqCst));
|
||||
self.files.insert(url.clone(), new_id);
|
||||
new_id
|
||||
};
|
||||
|
||||
let content = Arc::<str>::from(content);
|
||||
self.content.insert(file_id, content.clone());
|
||||
|
||||
// TODO: Update Salsa inputs here when we connect them
|
||||
}
|
||||
|
||||
/// Remove a file from the workspace
|
||||
pub fn remove_file(&mut self, url: &Url) {
|
||||
if let Some((_, file_id)) = self.files.remove(url) {
|
||||
self.content.remove(&file_id);
|
||||
// TODO: Remove from Salsa when we connect inputs
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the content of a file by URL
|
||||
pub fn get_file_content(&self, url: &Url) -> Option<Arc<str>> {
|
||||
let file_id = self.files.get(url)?;
|
||||
self.content.get(&*file_id).map(|content| content.clone())
|
||||
}
|
||||
|
||||
/// Get the content of a file by FileId
|
||||
pub(crate) fn get_content_by_id(&self, file_id: FileId) -> Option<Arc<str>> {
|
||||
self.content.get(&file_id).map(|content| content.clone())
|
||||
}
|
||||
|
||||
/// Check if a file exists in the workspace
|
||||
pub fn has_file(&self, url: &Url) -> bool {
|
||||
self.files.contains_key(url)
|
||||
}
|
||||
|
||||
/// Get all file URLs in the workspace
|
||||
pub fn files(&self) -> impl Iterator<Item = Url> + use<'_> {
|
||||
self.files.iter().map(|entry| entry.key().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[salsa::db]
|
||||
impl salsa::Database for Database {}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::vfs::FileKind;
|
||||
use crate::FileKind;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LanguageId {
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
mod language;
|
||||
mod line_index;
|
||||
mod store;
|
||||
mod template;
|
||||
|
||||
pub use language::LanguageId;
|
||||
pub use line_index::LineIndex;
|
||||
pub use store::DocumentStore;
|
||||
pub use template::ClosingBrace;
|
||||
pub use template::TemplateTagContext;
|
||||
use tower_lsp_server::lsp_types::Position;
|
||||
|
|
|
@ -1,643 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use camino::Utf8PathBuf;
|
||||
use djls_project::TemplateTags;
|
||||
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::Diagnostic;
|
||||
use tower_lsp_server::lsp_types::DiagnosticSeverity;
|
||||
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 tower_lsp_server::lsp_types::Range;
|
||||
use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent;
|
||||
|
||||
use crate::bridge::FileStore;
|
||||
use crate::db::TemplateAst;
|
||||
use crate::vfs::FileKind;
|
||||
use crate::vfs::TextSource;
|
||||
use crate::vfs::Vfs;
|
||||
use crate::ClosingBrace;
|
||||
use crate::LanguageId;
|
||||
use crate::LineIndex;
|
||||
use crate::TextDocument;
|
||||
|
||||
pub struct DocumentStore {
|
||||
vfs: Arc<Vfs>,
|
||||
file_store: Arc<Mutex<FileStore>>,
|
||||
documents: HashMap<String, TextDocument>,
|
||||
}
|
||||
|
||||
impl Default for DocumentStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vfs: Arc::new(Vfs::default()),
|
||||
file_store: Arc::new(Mutex::new(FileStore::new())),
|
||||
documents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentStore {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Open a document with the given URI, version, language, and text content.
|
||||
/// This creates a new TextDocument and stores it internally, hiding VFS details.
|
||||
pub fn open_document(
|
||||
&mut self,
|
||||
uri: url::Url,
|
||||
version: i32,
|
||||
language_id: LanguageId,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let uri_str = uri.to_string();
|
||||
let kind = FileKind::from(language_id.clone());
|
||||
|
||||
// Convert URI to path - simplified for now, just use URI string
|
||||
let path = Utf8PathBuf::from(uri.as_str());
|
||||
|
||||
// Store content in VFS
|
||||
let text_source = TextSource::Overlay(Arc::from(text.as_str()));
|
||||
let file_id = self.vfs.intern_file(uri, path, kind, text_source);
|
||||
|
||||
// Set overlay content in VFS
|
||||
self.vfs.set_overlay(file_id, Arc::from(text.as_str()))?;
|
||||
|
||||
// Sync VFS snapshot to FileStore for Salsa tracking
|
||||
let snapshot = self.vfs.snapshot();
|
||||
let mut file_store = self.file_store.lock().unwrap();
|
||||
file_store.apply_vfs_snapshot(&snapshot);
|
||||
|
||||
// Create TextDocument with LineIndex
|
||||
let document = TextDocument::new(uri_str.clone(), version, language_id, file_id, &text);
|
||||
self.documents.insert(uri_str, document);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a document with the given URI, version, and text changes.
|
||||
/// This applies changes to the document and updates the VFS accordingly.
|
||||
pub fn update_document(
|
||||
&mut self,
|
||||
uri: &url::Url,
|
||||
version: i32,
|
||||
changes: Vec<TextDocumentContentChangeEvent>,
|
||||
) -> Result<()> {
|
||||
let uri_str = uri.to_string();
|
||||
|
||||
// Get document and file_id from the documents HashMap
|
||||
let document = self
|
||||
.documents
|
||||
.get(&uri_str)
|
||||
.ok_or_else(|| anyhow!("Document not found: {}", uri_str))?;
|
||||
let file_id = document.file_id();
|
||||
|
||||
// Get current content from VFS
|
||||
let snapshot = self.vfs.snapshot();
|
||||
let current_content = snapshot
|
||||
.get_text(file_id)
|
||||
.ok_or_else(|| anyhow!("File content not found: {}", uri_str))?;
|
||||
|
||||
// Get line index from the document
|
||||
let line_index = document.line_index();
|
||||
|
||||
// Apply text changes using the existing function
|
||||
let new_content = apply_text_changes(¤t_content, &changes, line_index)?;
|
||||
|
||||
// Update TextDocument version and content
|
||||
if let Some(document) = self.documents.get_mut(&uri_str) {
|
||||
document.version = version;
|
||||
document.update_content(&new_content);
|
||||
}
|
||||
|
||||
// Update VFS with new content
|
||||
self.vfs
|
||||
.set_overlay(file_id, Arc::from(new_content.as_str()))?;
|
||||
|
||||
// Sync VFS snapshot to FileStore for Salsa tracking
|
||||
let snapshot = self.vfs.snapshot();
|
||||
let mut file_store = self.file_store.lock().unwrap();
|
||||
file_store.apply_vfs_snapshot(&snapshot);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a document with the given URI.
|
||||
/// This removes the document from internal storage and cleans up resources.
|
||||
pub fn close_document(&mut self, uri: &url::Url) {
|
||||
let uri_str = uri.as_str();
|
||||
|
||||
// Remove TextDocument metadata
|
||||
self.documents.remove(uri_str);
|
||||
|
||||
// Note: We don't remove from VFS as it might be useful for caching
|
||||
// The VFS will handle cleanup internally
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_line_index(&self, uri: &str) -> Option<&LineIndex> {
|
||||
self.documents.get(uri).map(super::TextDocument::line_index)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn get_version(&self, uri: &str) -> Option<i32> {
|
||||
self.documents.get(uri).map(super::TextDocument::version)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn is_version_valid(&self, uri: &str, version: i32) -> bool {
|
||||
self.get_version(uri) == Some(version)
|
||||
}
|
||||
|
||||
// TextDocument helper methods
|
||||
#[must_use]
|
||||
pub fn get_document(&self, uri: &str) -> Option<&TextDocument> {
|
||||
self.documents.get(uri)
|
||||
}
|
||||
|
||||
pub fn get_document_mut(&mut self, uri: &str) -> Option<&mut TextDocument> {
|
||||
self.documents.get_mut(uri)
|
||||
}
|
||||
|
||||
// URI-based query methods (new API)
|
||||
#[must_use]
|
||||
pub fn get_document_by_url(&self, uri: &url::Url) -> Option<&TextDocument> {
|
||||
self.get_document(uri.as_str())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_document_text(&self, uri: &url::Url) -> Option<Arc<str>> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
let file_id = document.file_id();
|
||||
let snapshot = self.vfs.snapshot();
|
||||
snapshot.get_text(file_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_line_text(&self, uri: &url::Url, line: u32) -> Option<String> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
let snapshot = self.vfs.snapshot();
|
||||
let content = snapshot.get_text(document.file_id())?;
|
||||
document.get_line(content.as_ref(), line)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_word_at_position(&self, uri: &url::Url, position: Position) -> Option<String> {
|
||||
// This is a simplified implementation - get the line and extract word at position
|
||||
let line_text = self.get_line_text(uri, position.line)?;
|
||||
let char_pos: usize = position.character.try_into().ok()?;
|
||||
|
||||
if char_pos >= line_text.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find word boundaries (simplified - considers alphanumeric and underscore as word chars)
|
||||
let line_bytes = line_text.as_bytes();
|
||||
let mut start = char_pos;
|
||||
let mut end = char_pos;
|
||||
|
||||
// Find start of word
|
||||
while start > 0 && is_word_char(line_bytes[start - 1]) {
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
// Find end of word
|
||||
while end < line_text.len() && is_word_char(line_bytes[end]) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if start < end {
|
||||
Some(line_text[start..end].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Position mapping methods
|
||||
#[must_use]
|
||||
pub fn offset_to_position(&self, uri: &url::Url, offset: usize) -> Option<Position> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
Some(document.offset_to_position(offset as u32))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn position_to_offset(&self, uri: &url::Url, position: Position) -> Option<usize> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
document
|
||||
.position_to_offset(position)
|
||||
.map(|offset| offset as usize)
|
||||
}
|
||||
|
||||
// Template-specific methods
|
||||
#[must_use]
|
||||
pub fn get_template_ast(&self, uri: &url::Url) -> Option<Arc<TemplateAst>> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
let file_id = document.file_id();
|
||||
let file_store = self.file_store.lock().unwrap();
|
||||
file_store.get_template_ast(file_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_template_errors(&self, uri: &url::Url) -> Vec<String> {
|
||||
let Some(document) = self.get_document_by_url(uri) else {
|
||||
return vec![];
|
||||
};
|
||||
let file_id = document.file_id();
|
||||
let file_store = self.file_store.lock().unwrap();
|
||||
let errors = file_store.get_template_errors(file_id);
|
||||
errors.to_vec()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_template_context(
|
||||
&self,
|
||||
uri: &url::Url,
|
||||
position: Position,
|
||||
) -> Option<crate::TemplateTagContext> {
|
||||
let document = self.get_document_by_url(uri)?;
|
||||
let snapshot = self.vfs.snapshot();
|
||||
let content = snapshot.get_text(document.file_id())?;
|
||||
document.get_template_tag_context(content.as_ref(), position)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_completions(
|
||||
&self,
|
||||
uri: &str,
|
||||
position: Position,
|
||||
tags: &TemplateTags,
|
||||
) -> Option<CompletionResponse> {
|
||||
// Check if this is a Django template using TextDocument metadata
|
||||
let document = self.get_document(uri)?;
|
||||
if document.language_id() != LanguageId::HtmlDjango {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try to get cached AST from FileStore for better context analysis
|
||||
// This demonstrates using the cached AST, though we still fall back to string parsing
|
||||
let file_id = document.file_id();
|
||||
let file_store = self.file_store.lock().unwrap();
|
||||
if let Some(_ast) = file_store.get_template_ast(file_id) {
|
||||
// TODO: In a future enhancement, we could use the AST to provide
|
||||
// more intelligent completions based on the current node context
|
||||
// For now, we continue with the existing string-based approach
|
||||
}
|
||||
|
||||
// Get template tag context from document
|
||||
let vfs_snapshot = self.vfs.snapshot();
|
||||
let text_content = vfs_snapshot.get_text(file_id)?;
|
||||
let context = document.get_template_tag_context(text_content.as_ref(), position)?;
|
||||
|
||||
let mut completions: Vec<CompletionItem> = 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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get template parsing diagnostics for a file.
|
||||
///
|
||||
/// This method uses the cached template errors from Salsa to generate LSP diagnostics.
|
||||
/// The errors are only re-computed when the file content changes, providing efficient
|
||||
/// incremental error reporting.
|
||||
pub fn get_template_diagnostics(&self, uri: &str) -> Vec<Diagnostic> {
|
||||
let Some(document) = self.get_document(uri) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Only process template files
|
||||
if document.language_id() != LanguageId::HtmlDjango {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let file_id = document.file_id();
|
||||
let Some(_line_index) = self.get_line_index(uri) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// Get cached template errors from FileStore
|
||||
let file_store = self.file_store.lock().unwrap();
|
||||
let errors = file_store.get_template_errors(file_id);
|
||||
|
||||
// Convert template errors to LSP diagnostics
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| {
|
||||
// For now, we'll place all errors at the start of the file
|
||||
// In a future enhancement, we could use error spans for precise locations
|
||||
let range = Range {
|
||||
start: Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
};
|
||||
|
||||
Diagnostic {
|
||||
range,
|
||||
severity: Some(DiagnosticSeverity::ERROR),
|
||||
source: Some("djls-templates".to_string()),
|
||||
message: error.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a byte represents a word character (alphanumeric or underscore)
|
||||
fn is_word_char(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
/// Apply text changes to content, handling multiple changes correctly
|
||||
fn apply_text_changes(
|
||||
content: &str,
|
||||
changes: &[TextDocumentContentChangeEvent],
|
||||
line_index: &LineIndex,
|
||||
) -> Result<String> {
|
||||
if changes.is_empty() {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
|
||||
// Check for full document replacement first
|
||||
for change in changes {
|
||||
if change.range.is_none() {
|
||||
return Ok(change.text.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Sort changes by start position in reverse order (end to start)
|
||||
let mut sorted_changes = changes.to_vec();
|
||||
sorted_changes.sort_by(|a, b| {
|
||||
match (a.range, b.range) {
|
||||
(Some(range_a), Some(range_b)) => {
|
||||
// Primary sort: by line (reverse)
|
||||
let line_cmp = range_b.start.line.cmp(&range_a.start.line);
|
||||
if line_cmp == std::cmp::Ordering::Equal {
|
||||
// Secondary sort: by character (reverse)
|
||||
range_b.start.character.cmp(&range_a.start.character)
|
||||
} else {
|
||||
line_cmp
|
||||
}
|
||||
}
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
|
||||
let mut result = content.to_string();
|
||||
|
||||
for change in &sorted_changes {
|
||||
if let Some(range) = change.range {
|
||||
// Convert UTF-16 positions to UTF-8 offsets
|
||||
let start_offset = line_index
|
||||
.offset_utf16(range.start, &result)
|
||||
.ok_or_else(|| anyhow!("Invalid start position: {:?}", range.start))?;
|
||||
let end_offset = line_index
|
||||
.offset_utf16(range.end, &result)
|
||||
.ok_or_else(|| anyhow!("Invalid end position: {:?}", range.end))?;
|
||||
|
||||
if start_offset as usize > result.len() || end_offset as usize > result.len() {
|
||||
return Err(anyhow!(
|
||||
"Offset out of bounds: start={}, end={}, len={}",
|
||||
start_offset,
|
||||
end_offset,
|
||||
result.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Apply the change
|
||||
result.replace_range(start_offset as usize..end_offset as usize, &change.text);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tower_lsp_server::lsp_types::Range;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_apply_single_character_insertion() {
|
||||
let content = "Hello world";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 6), Position::new(0, 6))),
|
||||
range_length: None,
|
||||
text: "beautiful ".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "Hello beautiful world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_single_character_deletion() {
|
||||
let content = "Hello world";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 5), Position::new(0, 6))),
|
||||
range_length: None,
|
||||
text: String::new(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "Helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_multiple_changes_in_reverse_order() {
|
||||
let content = "line 1\nline 2\nline 3";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// Insert "new " at position (1, 0) and "another " at position (0, 0)
|
||||
let changes = vec![
|
||||
TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))),
|
||||
range_length: None,
|
||||
text: "another ".to_string(),
|
||||
},
|
||||
TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(1, 0), Position::new(1, 0))),
|
||||
range_length: None,
|
||||
text: "new ".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "another line 1\nnew line 2\nline 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_multiline_replacement() {
|
||||
let content = "line 1\nline 2\nline 3";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 0), Position::new(2, 6))),
|
||||
range_length: None,
|
||||
text: "completely new content".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "completely new content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_full_document_replacement() {
|
||||
let content = "old content";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: None,
|
||||
range_length: None,
|
||||
text: "brand new content".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "brand new content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utf16_line_index_basic() {
|
||||
let content = "hello world";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// ASCII characters should have 1:1 UTF-8:UTF-16 mapping
|
||||
let pos = Position::new(0, 6);
|
||||
let offset = line_index.offset_utf16(pos, content).unwrap();
|
||||
assert_eq!(offset, 6);
|
||||
assert_eq!(&content[6..7], "w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utf16_line_index_with_emoji() {
|
||||
let content = "hello 👋 world";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// 👋 is 2 UTF-16 code units but 4 UTF-8 bytes
|
||||
let pos_after_emoji = Position::new(0, 8); // UTF-16 position after "hello 👋"
|
||||
let offset = line_index.offset_utf16(pos_after_emoji, content).unwrap();
|
||||
|
||||
// Should point to the space before "world"
|
||||
assert_eq!(offset, 10); // UTF-8 byte offset
|
||||
assert_eq!(&content[10..11], " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utf16_line_index_multiline() {
|
||||
let content = "first line\nsecond line";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
let pos = Position::new(1, 7); // Position at 'l' in "line" on second line
|
||||
let offset = line_index.offset_utf16(pos, content).unwrap();
|
||||
assert_eq!(offset, 18); // 11 (first line + \n) + 7
|
||||
assert_eq!(&content[18..19], "l");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_changes_with_emoji() {
|
||||
let content = "hello 👋 world";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// Insert text after the space following the emoji (UTF-16 position 9)
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 9), Position::new(0, 9))),
|
||||
range_length: None,
|
||||
text: "beautiful ".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "hello 👋 beautiful world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_index_utf16_tracking() {
|
||||
let content = "a👋b";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// Check UTF-16 line starts are tracked correctly
|
||||
assert_eq!(line_index.line_starts_utf16, vec![0]);
|
||||
assert_eq!(line_index.length_utf16, 4); // 'a' (1) + 👋 (2) + 'b' (1) = 4 UTF-16 units
|
||||
assert_eq!(line_index.length, 6); // 'a' (1) + 👋 (4) + 'b' (1) = 6 UTF-8 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_changes_at_boundaries() {
|
||||
let content = "abc";
|
||||
let line_index = LineIndex::new(content);
|
||||
|
||||
// Insert at beginning
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))),
|
||||
range_length: None,
|
||||
text: "start".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "startabc");
|
||||
|
||||
// Insert at end
|
||||
let line_index = LineIndex::new(content);
|
||||
let changes = vec![TextDocumentContentChangeEvent {
|
||||
range: Some(Range::new(Position::new(0, 3), Position::new(0, 3))),
|
||||
range_length: None,
|
||||
text: "end".to_string(),
|
||||
}];
|
||||
|
||||
let result = apply_text_changes(content, &changes, &line_index).unwrap();
|
||||
assert_eq!(result, "abcend");
|
||||
}
|
||||
}
|
|
@ -1,22 +1,33 @@
|
|||
mod bridge;
|
||||
mod db;
|
||||
pub mod db;
|
||||
mod document;
|
||||
mod vfs;
|
||||
mod lsp_system;
|
||||
mod system;
|
||||
|
||||
pub use document::ClosingBrace;
|
||||
pub use document::DocumentStore;
|
||||
pub use document::LanguageId;
|
||||
pub use document::LineIndex;
|
||||
pub use document::TemplateTagContext;
|
||||
pub use document::TextDocument;
|
||||
pub use db::Database;
|
||||
pub use document::{TextDocument, LanguageId};
|
||||
pub use system::{FileSystem, StdFileSystem};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Stable, compact identifier for files across the subsystem.
|
||||
///
|
||||
/// [`FileId`] decouples file identity from paths/URIs, providing efficient keys for maps and
|
||||
/// Salsa inputs. Once assigned to a file (via its URI), a [`FileId`] remains stable for the
|
||||
/// lifetime of the VFS, even if the file's content or metadata changes.
|
||||
/// lifetime of the system.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
|
||||
pub(crate) struct FileId(u32);
|
||||
pub struct FileId(u32);
|
||||
|
||||
impl FileId {
|
||||
/// Create a [`FileId`] from a raw u32 value.
|
||||
|
@ -27,6 +38,7 @@ impl FileId {
|
|||
|
||||
/// Get the underlying u32 index value.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn index(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
|
154
crates/djls-workspace/src/lsp_system.rs
Normal file
154
crates/djls-workspace/src/lsp_system.rs
Normal file
|
@ -0,0 +1,154 @@
|
|||
//! LSP-aware file system wrapper that handles overlays
|
||||
//!
|
||||
//! This is the KEY pattern from Ruff - the LspSystem wraps a FileSystem
|
||||
//! and intercepts reads to check for overlays first. This allows unsaved
|
||||
//! changes to be used without going through Salsa.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::system::FileSystem;
|
||||
|
||||
/// LSP-aware file system that checks overlays before disk
|
||||
///
|
||||
/// This is the critical piece that makes overlays work efficiently in Ruff's
|
||||
/// architecture. Instead of updating Salsa for every keystroke, we intercept
|
||||
/// file reads here and return overlay content when available.
|
||||
pub struct LspSystem {
|
||||
/// The underlying file system (usually StdFileSystem)
|
||||
inner: Arc<dyn FileSystem>,
|
||||
|
||||
/// Map of open document URLs to their overlay content
|
||||
overlays: HashMap<Url, String>,
|
||||
}
|
||||
|
||||
impl LspSystem {
|
||||
/// Create a new LspSystem wrapping the given file system
|
||||
pub fn new(file_system: Arc<dyn FileSystem>) -> Self {
|
||||
Self {
|
||||
inner: file_system,
|
||||
overlays: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set overlay content for a document
|
||||
pub fn set_overlay(&mut self, url: Url, content: String) {
|
||||
self.overlays.insert(url, content);
|
||||
}
|
||||
|
||||
/// Remove overlay content for a document
|
||||
pub fn remove_overlay(&mut self, url: &Url) {
|
||||
self.overlays.remove(url);
|
||||
}
|
||||
|
||||
/// Check if a document has an overlay
|
||||
pub fn has_overlay(&self, url: &Url) -> bool {
|
||||
self.overlays.contains_key(url)
|
||||
}
|
||||
|
||||
/// Get overlay content if it exists
|
||||
pub fn get_overlay(&self, url: &Url) -> Option<&String> {
|
||||
self.overlays.get(url)
|
||||
}
|
||||
|
||||
/// Convert a URL to a file path
|
||||
fn url_to_path(url: &Url) -> Option<PathBuf> {
|
||||
if url.scheme() == "file" {
|
||||
url.to_file_path().ok().or_else(|| {
|
||||
// Fallback for simple conversion
|
||||
Some(PathBuf::from(url.path()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSystem for LspSystem {
|
||||
fn read_to_string(&self, path: &Path) -> io::Result<String> {
|
||||
// First check if we have an overlay for this path
|
||||
// Convert path to URL for lookup
|
||||
let url = Url::from_file_path(path)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid path"))?;
|
||||
|
||||
if let Some(content) = self.overlays.get(&url) {
|
||||
// Return overlay content instead of reading from disk
|
||||
return Ok(content.clone());
|
||||
}
|
||||
|
||||
// No overlay, read from underlying file system
|
||||
self.inner.read_to_string(path)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
// Check overlays first
|
||||
if let Ok(url) = Url::from_file_path(path) {
|
||||
if self.overlays.contains_key(&url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.exists(path)
|
||||
}
|
||||
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
// Overlays are always files
|
||||
if let Ok(url) = Url::from_file_path(path) {
|
||||
if self.overlays.contains_key(&url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.is_file(path)
|
||||
}
|
||||
|
||||
fn is_directory(&self, path: &Path) -> bool {
|
||||
// Overlays are never directories
|
||||
if let Ok(url) = Url::from_file_path(path) {
|
||||
if self.overlays.contains_key(&url) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.is_directory(path)
|
||||
}
|
||||
|
||||
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
|
||||
// Overlays don't affect directory listings
|
||||
self.inner.read_directory(path)
|
||||
}
|
||||
|
||||
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
|
||||
// Can't provide metadata for overlays
|
||||
self.inner.metadata(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for working with URL-based overlays
|
||||
pub trait LspSystemExt {
|
||||
/// Read file content by URL, checking overlays first
|
||||
fn read_url(&self, url: &Url) -> io::Result<String>;
|
||||
}
|
||||
|
||||
impl LspSystemExt for LspSystem {
|
||||
fn read_url(&self, url: &Url) -> io::Result<String> {
|
||||
// Check overlays first
|
||||
if let Some(content) = self.overlays.get(url) {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
|
||||
// Convert URL to path and read from file system
|
||||
if let Some(path_buf) = Self::url_to_path(url) {
|
||||
self.inner.read_to_string(&path_buf)
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("Cannot convert URL to path: {}", url),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
118
crates/djls-workspace/src/system.rs
Normal file
118
crates/djls-workspace/src/system.rs
Normal file
|
@ -0,0 +1,118 @@
|
|||
//! File system abstraction following Ruff's pattern
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Trait for file system operations
|
||||
///
|
||||
/// This follows Ruff's pattern of abstracting file system operations behind a trait,
|
||||
/// allowing different implementations for testing, in-memory operation, and real file access.
|
||||
pub trait FileSystem: Send + Sync {
|
||||
/// Read the entire contents of a file
|
||||
fn read_to_string(&self, path: &Path) -> io::Result<String>;
|
||||
|
||||
/// Check if a path exists
|
||||
fn exists(&self, path: &Path) -> bool;
|
||||
|
||||
/// Check if a path is a file
|
||||
fn is_file(&self, path: &Path) -> bool;
|
||||
|
||||
/// Check if a path is a directory
|
||||
fn is_directory(&self, path: &Path) -> bool;
|
||||
|
||||
/// List directory contents
|
||||
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>>;
|
||||
|
||||
/// Get file metadata (size, modified time, etc.)
|
||||
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
|
||||
}
|
||||
|
||||
/// Standard file system implementation that uses std::fs
|
||||
pub struct StdFileSystem;
|
||||
|
||||
impl FileSystem for StdFileSystem {
|
||||
fn read_to_string(&self, path: &Path) -> io::Result<String> {
|
||||
std::fs::read_to_string(path)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
fn is_directory(&self, path: &Path) -> bool {
|
||||
path.is_dir()
|
||||
}
|
||||
|
||||
fn read_directory(&self, path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
|
||||
let mut entries = Vec::new();
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
entries.push(entry?.path());
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
|
||||
std::fs::metadata(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory file system for testing
|
||||
#[cfg(test)]
|
||||
pub struct MemoryFileSystem {
|
||||
files: std::collections::HashMap<std::path::PathBuf, String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl MemoryFileSystem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, path: std::path::PathBuf, content: String) {
|
||||
self.files.insert(path, content);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FileSystem for MemoryFileSystem {
|
||||
fn read_to_string(&self, path: &Path) -> io::Result<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
self.files.contains_key(path)
|
||||
}
|
||||
|
||||
fn is_directory(&self, _path: &Path) -> bool {
|
||||
// Simplified for testing - no directories in memory filesystem
|
||||
false
|
||||
}
|
||||
|
||||
fn read_directory(&self, _path: &Path) -> io::Result<Vec<std::path::PathBuf>> {
|
||||
// Simplified for testing
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn metadata(&self, _path: &Path) -> io::Result<std::fs::Metadata> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Metadata not supported in memory filesystem",
|
||||
))
|
||||
}
|
||||
}
|
25
crates/djls-workspace/src/test_db.rs
Normal file
25
crates/djls-workspace/src/test_db.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
//! Test module to explore Salsa thread safety
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::Database;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn test_database_clone() {
|
||||
let db = Database::new();
|
||||
let _db2 = db.clone();
|
||||
println!("✅ Database can be cloned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // This will fail
|
||||
fn test_database_send() {
|
||||
let db = Database::new();
|
||||
let db2 = db.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let _ = db2;
|
||||
}).join().unwrap();
|
||||
}
|
||||
}
|
|
@ -1,367 +0,0 @@
|
|||
//! Change-tracked, concurrent virtual file system keyed by [`FileId`].
|
||||
//!
|
||||
//! The VFS provides thread-safe, identity-stable storage with cheap change detection
|
||||
//! and snapshotting. Downstream systems consume snapshots to avoid locking and to
|
||||
//! batch updates.
|
||||
|
||||
mod watcher;
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::Hash;
|
||||
use std::hash::Hasher;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use camino::Utf8PathBuf;
|
||||
use dashmap::DashMap;
|
||||
use url::Url;
|
||||
use watcher::VfsWatcher;
|
||||
use watcher::WatchConfig;
|
||||
use watcher::WatchEvent;
|
||||
|
||||
use super::FileId;
|
||||
|
||||
/// Monotonic counter representing global VFS state.
|
||||
///
|
||||
/// [`Revision`] increments whenever file content changes occur in the VFS.
|
||||
/// This provides a cheap way to detect if any changes have occurred since
|
||||
/// a previous snapshot was taken.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default, PartialOrd, Ord)]
|
||||
pub(crate) struct Revision(u64);
|
||||
|
||||
impl Revision {
|
||||
/// Create a [`Revision`] from a raw u64 value.
|
||||
#[must_use]
|
||||
fn from_raw(raw: u64) -> Self {
|
||||
Revision(raw)
|
||||
}
|
||||
|
||||
/// Get the underlying u64 value.
|
||||
#[must_use]
|
||||
fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// File classification at the VFS layer.
|
||||
///
|
||||
/// [`FileKind`] determines how a file should be processed by downstream analyzers.
|
||||
/// This classification is performed when files are first ingested into the VFS.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub enum FileKind {
|
||||
/// Python source file
|
||||
Python,
|
||||
/// Django template file
|
||||
Template,
|
||||
/// Other file type
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Metadata associated with a file in the VFS.
|
||||
///
|
||||
/// [`FileMeta`] contains all non-content information about a file, including its
|
||||
/// identity (URI), filesystem path, and classification.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FileMeta {
|
||||
/// The file's URI (typically file:// scheme)
|
||||
uri: Url,
|
||||
/// The file's path in the filesystem
|
||||
path: Utf8PathBuf,
|
||||
/// Classification for routing to analyzers
|
||||
pub kind: FileKind,
|
||||
}
|
||||
|
||||
/// Source of text content in the VFS.
|
||||
///
|
||||
/// [`TextSource`] tracks where file content originated from, which is useful for
|
||||
/// debugging and understanding the current state of the VFS. All variants hold
|
||||
/// `Arc<str>` for efficient sharing.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum TextSource {
|
||||
/// Content loaded from disk
|
||||
Disk(Arc<str>),
|
||||
/// Content from LSP client overlay (in-memory edits)
|
||||
Overlay(Arc<str>),
|
||||
/// Content generated programmatically
|
||||
Generated(Arc<str>),
|
||||
}
|
||||
|
||||
/// Content hash for efficient change detection.
|
||||
///
|
||||
/// [`FileHash`] encapsulates the hashing logic used to detect when file content
|
||||
/// has changed, avoiding unnecessary recomputation in downstream systems like Salsa.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct FileHash(u64);
|
||||
|
||||
impl FileHash {
|
||||
/// Compute hash from text source content.
|
||||
fn from_text_source(src: &TextSource) -> Self {
|
||||
let s: &str = match src {
|
||||
TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s,
|
||||
};
|
||||
let mut h = DefaultHasher::new();
|
||||
s.hash(&mut h);
|
||||
Self(h.finish())
|
||||
}
|
||||
|
||||
/// Check if this hash differs from another, indicating content changed.
|
||||
fn differs_from(self, other: Self) -> bool {
|
||||
self.0 != other.0
|
||||
}
|
||||
|
||||
/// Get raw hash value (for debugging/logging).
|
||||
fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete record of a file in the VFS.
|
||||
///
|
||||
/// [`FileRecord`] combines metadata, current text content, and a content hash
|
||||
/// for efficient change detection.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileRecord {
|
||||
/// File metadata (URI, path, kind, version)
|
||||
pub meta: FileMeta,
|
||||
/// Current text content and its source
|
||||
text: TextSource,
|
||||
/// Hash of current content for change detection
|
||||
hash: FileHash,
|
||||
}
|
||||
|
||||
/// Thread-safe virtual file system with change tracking.
|
||||
///
|
||||
/// [`Vfs`] provides concurrent access to file content with stable [`FileId`] assignment,
|
||||
/// content hashing for change detection, and atomic snapshot generation. It uses
|
||||
/// `DashMap` for lock-free concurrent access and atomic counters for revision tracking.
|
||||
pub struct Vfs {
|
||||
/// Atomic counter for generating unique [`FileId`]s
|
||||
next_file_id: AtomicU32,
|
||||
/// Map from URI to [`FileId`] for deduplication
|
||||
by_uri: DashMap<Url, FileId>,
|
||||
/// Map from [`FileId`] to [`FileRecord`] for content storage
|
||||
files: DashMap<FileId, FileRecord>,
|
||||
/// Global revision counter, incremented on content changes
|
||||
head: AtomicU64,
|
||||
/// Optional file system watcher for external change detection
|
||||
watcher: std::sync::Mutex<Option<VfsWatcher>>,
|
||||
/// Map from filesystem path to [`FileId`] for watcher events
|
||||
by_path: DashMap<Utf8PathBuf, FileId>,
|
||||
}
|
||||
|
||||
impl Vfs {
|
||||
/// Get or create a [`FileId`] for the given URI.
|
||||
///
|
||||
/// Returns the existing [`FileId`] if the URI is already known, or creates a new
|
||||
/// [`FileRecord`] with the provided metadata and text. This method computes and
|
||||
/// stores a content hash for change detection.
|
||||
pub(crate) fn intern_file(
|
||||
&self,
|
||||
uri: Url,
|
||||
path: Utf8PathBuf,
|
||||
kind: FileKind,
|
||||
text: TextSource,
|
||||
) -> FileId {
|
||||
if let Some(id) = self.by_uri.get(&uri).map(|entry| *entry) {
|
||||
return id;
|
||||
}
|
||||
let id = FileId(self.next_file_id.fetch_add(1, Ordering::SeqCst));
|
||||
let meta = FileMeta {
|
||||
uri: uri.clone(),
|
||||
path: path.clone(),
|
||||
kind,
|
||||
};
|
||||
let hash = FileHash::from_text_source(&text);
|
||||
self.by_uri.insert(uri, id);
|
||||
self.by_path.insert(path, id);
|
||||
self.files.insert(id, FileRecord { meta, text, hash });
|
||||
id
|
||||
}
|
||||
|
||||
/// Set overlay text for a file, typically from LSP didChange events.
|
||||
///
|
||||
/// Updates the file's text to an Overlay variant with the new content.
|
||||
/// Only increments the global revision if the content actually changed
|
||||
/// (detected via hash comparison).
|
||||
///
|
||||
/// Returns a tuple of (new global revision, whether content changed).
|
||||
pub(crate) fn set_overlay(&self, id: FileId, new_text: Arc<str>) -> Result<(Revision, bool)> {
|
||||
let mut rec = self
|
||||
.files
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| anyhow!("unknown file: {:?}", id))?;
|
||||
let next = TextSource::Overlay(new_text);
|
||||
let new_hash = FileHash::from_text_source(&next);
|
||||
let changed = new_hash.differs_from(rec.hash);
|
||||
if changed {
|
||||
rec.text = next;
|
||||
rec.hash = new_hash;
|
||||
self.head.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok((
|
||||
Revision::from_raw(self.head.load(Ordering::SeqCst)),
|
||||
changed,
|
||||
))
|
||||
}
|
||||
|
||||
/// Create an immutable snapshot of the current VFS state.
|
||||
///
|
||||
/// Materializes a consistent view of all files for downstream consumers.
|
||||
/// The snapshot includes the current revision and a clone of all file records.
|
||||
/// This operation is relatively cheap due to `Arc` sharing of text content.
|
||||
pub(crate) fn snapshot(&self) -> VfsSnapshot {
|
||||
VfsSnapshot {
|
||||
revision: Revision::from_raw(self.head.load(Ordering::SeqCst)),
|
||||
files: self
|
||||
.files
|
||||
.iter()
|
||||
.map(|entry| (*entry.key(), entry.value().clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable file system watching with the given configuration.
|
||||
///
|
||||
/// This starts monitoring the specified root directories for external changes.
|
||||
/// Returns an error if file watching is disabled in the config or fails to start.
|
||||
pub fn enable_file_watching(&self, config: WatchConfig) -> Result<()> {
|
||||
let watcher = VfsWatcher::new(config)?;
|
||||
*self
|
||||
.watcher
|
||||
.lock()
|
||||
.map_err(|e| anyhow!("Failed to lock watcher mutex: {}", e))? = Some(watcher);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process pending file system events from the watcher.
|
||||
///
|
||||
/// This should be called periodically to sync external file changes into the VFS.
|
||||
/// Returns the number of files that were updated.
|
||||
pub fn process_file_events(&self) -> usize {
|
||||
// Get events from the watcher
|
||||
let events = {
|
||||
let Ok(guard) = self.watcher.lock() else {
|
||||
return 0; // Return 0 if mutex is poisoned
|
||||
};
|
||||
if let Some(watcher) = guard.as_ref() {
|
||||
watcher.try_recv_events()
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let mut updated_count = 0;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
WatchEvent::Modified(path) | WatchEvent::Created(path) => {
|
||||
if let Err(e) = self.load_from_disk(&path) {
|
||||
eprintln!("Failed to load file from disk: {path}: {e}");
|
||||
} else {
|
||||
updated_count += 1;
|
||||
}
|
||||
}
|
||||
WatchEvent::Deleted(path) => {
|
||||
// For now, we don't remove deleted files from VFS
|
||||
// This maintains stable `FileId`s for consumers
|
||||
eprintln!("File deleted (keeping in VFS): {path}");
|
||||
}
|
||||
WatchEvent::Renamed { from, to } => {
|
||||
// Handle rename by updating the path mapping
|
||||
if let Some(file_id) = self.by_path.remove(&from).map(|(_, id)| id) {
|
||||
self.by_path.insert(to.clone(), file_id);
|
||||
if let Err(e) = self.load_from_disk(&to) {
|
||||
eprintln!("Failed to load renamed file: {to}: {e}");
|
||||
} else {
|
||||
updated_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated_count
|
||||
}
|
||||
|
||||
/// Load a file's content from disk and update the VFS.
|
||||
///
|
||||
/// This method reads the file from the filesystem and updates the VFS entry
|
||||
/// if the content has changed. It's used by the file watcher to sync external changes.
|
||||
fn load_from_disk(&self, path: &Utf8PathBuf) -> Result<()> {
|
||||
// Check if we have this file tracked
|
||||
if let Some(file_id) = self.by_path.get(path).map(|entry| *entry.value()) {
|
||||
// Read content from disk
|
||||
let content = fs::read_to_string(path.as_std_path())
|
||||
.map_err(|e| anyhow!("Failed to read file {}: {}", path, e))?;
|
||||
|
||||
let new_text = TextSource::Disk(Arc::from(content.as_str()));
|
||||
let new_hash = FileHash::from_text_source(&new_text);
|
||||
|
||||
// Update the file if content changed
|
||||
if let Some(mut record) = self.files.get_mut(&file_id) {
|
||||
if new_hash.differs_from(record.hash) {
|
||||
record.text = new_text;
|
||||
record.hash = new_hash;
|
||||
self.head.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if file watching is currently enabled.
|
||||
pub fn is_file_watching_enabled(&self) -> bool {
|
||||
self.watcher.lock().map(|g| g.is_some()).unwrap_or(false) // Return false if mutex is poisoned
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Vfs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next_file_id: AtomicU32::new(0),
|
||||
by_uri: DashMap::new(),
|
||||
files: DashMap::new(),
|
||||
head: AtomicU64::new(0),
|
||||
watcher: std::sync::Mutex::new(None),
|
||||
by_path: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable snapshot view of the VFS at a specific revision.
|
||||
///
|
||||
/// [`VfsSnapshot`] provides a consistent view of all files for downstream consumers,
|
||||
/// avoiding the need for locking during processing. Snapshots are created atomically
|
||||
/// and can be safely shared across threads.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct VfsSnapshot {
|
||||
/// The global revision at the time of snapshot
|
||||
revision: Revision,
|
||||
/// All files in the VFS at snapshot time
|
||||
pub files: HashMap<FileId, FileRecord>,
|
||||
}
|
||||
|
||||
impl VfsSnapshot {
|
||||
/// Get the text content of a file in this snapshot.
|
||||
///
|
||||
/// Returns `None` if the [`FileId`] is not present in the snapshot.
|
||||
#[must_use]
|
||||
pub fn get_text(&self, id: FileId) -> Option<Arc<str>> {
|
||||
self.files.get(&id).map(|r| match &r.text {
|
||||
TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the metadata for a file in this snapshot.
|
||||
///
|
||||
/// Returns `None` if the [`FileId`] is not present in the snapshot.
|
||||
#[must_use]
|
||||
pub fn meta(&self, id: FileId) -> Option<&FileMeta> {
|
||||
self.files.get(&id).map(|r| &r.meta)
|
||||
}
|
||||
}
|
|
@ -1,325 +0,0 @@
|
|||
//! File system watching for VFS synchronization.
|
||||
//!
|
||||
//! This module provides file system watching capabilities to detect external changes
|
||||
//! and synchronize them with the VFS. It uses cross-platform file watching with
|
||||
//! debouncing to handle rapid changes efficiently.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use camino::Utf8PathBuf;
|
||||
use notify::Config;
|
||||
use notify::Event;
|
||||
use notify::EventKind;
|
||||
use notify::RecommendedWatcher;
|
||||
use notify::RecursiveMode;
|
||||
use notify::Watcher;
|
||||
|
||||
/// Event types that can occur in the file system.
|
||||
///
|
||||
/// [`WatchEvent`] represents the different types of file system changes that
|
||||
/// the watcher can detect and process.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum WatchEvent {
|
||||
/// A file was modified (content changed)
|
||||
Modified(Utf8PathBuf),
|
||||
/// A new file was created
|
||||
Created(Utf8PathBuf),
|
||||
/// A file was deleted
|
||||
Deleted(Utf8PathBuf),
|
||||
/// A file was renamed from one path to another
|
||||
Renamed { from: Utf8PathBuf, to: Utf8PathBuf },
|
||||
}
|
||||
|
||||
/// Configuration for the file watcher.
|
||||
///
|
||||
/// [`WatchConfig`] controls how the file watcher operates, including what
|
||||
/// directories to watch and how to filter events.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WatchConfig {
|
||||
/// Whether file watching is enabled
|
||||
pub enabled: bool,
|
||||
/// Root directories to watch recursively
|
||||
pub roots: Vec<Utf8PathBuf>,
|
||||
/// Debounce time in milliseconds (collect events for this duration before processing)
|
||||
pub debounce_ms: u64,
|
||||
/// File patterns to include (e.g., ["*.py", "*.html"])
|
||||
pub include_patterns: Vec<String>,
|
||||
/// File patterns to exclude (e.g., ["__pycache__", ".git", "*.pyc"])
|
||||
pub exclude_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
// TODO: Allow for user config instead of hardcoding defaults
|
||||
impl Default for WatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
roots: Vec::new(),
|
||||
debounce_ms: 250,
|
||||
include_patterns: vec!["*.py".to_string(), "*.html".to_string()],
|
||||
exclude_patterns: vec![
|
||||
"__pycache__".to_string(),
|
||||
".git".to_string(),
|
||||
".pyc".to_string(),
|
||||
"node_modules".to_string(),
|
||||
".venv".to_string(),
|
||||
"venv".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File system watcher for VFS synchronization.
|
||||
///
|
||||
/// [`VfsWatcher`] monitors the file system for changes and provides a channel
|
||||
/// for consuming batched events. It handles debouncing and filtering internally.
|
||||
pub struct VfsWatcher {
|
||||
/// The underlying file system watcher
|
||||
_watcher: RecommendedWatcher,
|
||||
/// Receiver for processed watch events
|
||||
rx: mpsc::Receiver<Vec<WatchEvent>>,
|
||||
/// Configuration for the watcher
|
||||
config: WatchConfig,
|
||||
/// Handle to the background processing thread
|
||||
_handle: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl VfsWatcher {
|
||||
/// Create a new file watcher with the given configuration.
|
||||
///
|
||||
/// This starts watching the specified root directories and begins processing
|
||||
/// events in a background thread.
|
||||
pub fn new(config: WatchConfig) -> Result<Self> {
|
||||
if !config.enabled {
|
||||
return Err(anyhow!("File watching is disabled"));
|
||||
}
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
// Create the file system watcher
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: notify::Result<Event>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = event_tx.send(event);
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
)?;
|
||||
|
||||
// Watch all root directories
|
||||
for root in &config.roots {
|
||||
let std_path = root.as_std_path();
|
||||
if std_path.exists() {
|
||||
watcher.watch(std_path, RecursiveMode::Recursive)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn background thread for event processing
|
||||
let config_clone = config.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
Self::process_events(&event_rx, &watch_tx, &config_clone);
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_watcher: watcher,
|
||||
rx: watch_rx,
|
||||
config,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the next batch of processed watch events.
|
||||
///
|
||||
/// This is a non-blocking operation that returns immediately. If no events
|
||||
/// are available, it returns an empty vector.
|
||||
#[must_use]
|
||||
pub fn try_recv_events(&self) -> Vec<WatchEvent> {
|
||||
self.rx.try_recv().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Background thread function for processing raw file system events.
|
||||
///
|
||||
/// This function handles debouncing, filtering, and batching of events before
|
||||
/// sending them to the main thread for VFS synchronization.
|
||||
fn process_events(
|
||||
event_rx: &mpsc::Receiver<Event>,
|
||||
watch_tx: &mpsc::Sender<Vec<WatchEvent>>,
|
||||
config: &WatchConfig,
|
||||
) {
|
||||
let mut pending_events: HashMap<Utf8PathBuf, WatchEvent> = HashMap::new();
|
||||
let mut last_batch_time = Instant::now();
|
||||
let debounce_duration = Duration::from_millis(config.debounce_ms);
|
||||
|
||||
loop {
|
||||
// Try to receive events with a timeout for batching
|
||||
match event_rx.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(event) => {
|
||||
// Process the raw notify event into our WatchEvent format
|
||||
if let Some(watch_events) = Self::convert_notify_event(event, config) {
|
||||
for watch_event in watch_events {
|
||||
let path = Self::get_event_path(&watch_event);
|
||||
// Only keep the latest event for each path
|
||||
pending_events.insert(path.clone(), watch_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Timeout - check if we should flush pending events
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Channel disconnected, exit the thread
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should flush pending events
|
||||
if !pending_events.is_empty() && last_batch_time.elapsed() >= debounce_duration {
|
||||
let events: Vec<WatchEvent> = pending_events.values().cloned().collect();
|
||||
if watch_tx.send(events).is_err() {
|
||||
// Main thread disconnected, exit
|
||||
break;
|
||||
}
|
||||
pending_events.clear();
|
||||
last_batch_time = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`notify::Event`] into our [`WatchEvent`] format.
|
||||
fn convert_notify_event(event: Event, config: &WatchConfig) -> Option<Vec<WatchEvent>> {
|
||||
let mut watch_events = Vec::new();
|
||||
|
||||
for path in event.paths {
|
||||
if let Ok(utf8_path) = Utf8PathBuf::try_from(path) {
|
||||
if Self::should_include_path_static(&utf8_path, config) {
|
||||
match event.kind {
|
||||
EventKind::Create(_) => watch_events.push(WatchEvent::Created(utf8_path)),
|
||||
EventKind::Modify(_) => watch_events.push(WatchEvent::Modified(utf8_path)),
|
||||
EventKind::Remove(_) => watch_events.push(WatchEvent::Deleted(utf8_path)),
|
||||
_ => {} // Ignore other event types for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if watch_events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(watch_events)
|
||||
}
|
||||
}
|
||||
|
||||
/// Static version of should_include_path for use in convert_notify_event.
|
||||
fn should_include_path_static(path: &Utf8PathBuf, config: &WatchConfig) -> bool {
|
||||
let path_str = path.as_str();
|
||||
|
||||
// Check exclude patterns first
|
||||
for pattern in &config.exclude_patterns {
|
||||
if path_str.contains(pattern) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no include patterns, include everything (that's not excluded)
|
||||
if config.include_patterns.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check include patterns
|
||||
for pattern in &config.include_patterns {
|
||||
if let Some(extension) = pattern.strip_prefix("*.") {
|
||||
if path_str.ends_with(extension) {
|
||||
return true;
|
||||
}
|
||||
} else if path_str.contains(pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the path from a [`WatchEvent`].
|
||||
fn get_event_path(event: &WatchEvent) -> &Utf8PathBuf {
|
||||
match event {
|
||||
WatchEvent::Modified(path) | WatchEvent::Created(path) | WatchEvent::Deleted(path) => {
|
||||
path
|
||||
}
|
||||
WatchEvent::Renamed { to, .. } => to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VfsWatcher {
|
||||
fn drop(&mut self) {
|
||||
// The background thread will exit when the event channel is dropped
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_watch_config_default() {
|
||||
let config = WatchConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.debounce_ms, 250);
|
||||
assert!(config.include_patterns.contains(&"*.py".to_string()));
|
||||
assert!(config.exclude_patterns.contains(&".git".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_include_path() {
|
||||
let config = WatchConfig::default();
|
||||
|
||||
// Should include Python files
|
||||
assert!(VfsWatcher::should_include_path_static(
|
||||
&Utf8PathBuf::from("test.py"),
|
||||
&config
|
||||
));
|
||||
|
||||
// Should include HTML files
|
||||
assert!(VfsWatcher::should_include_path_static(
|
||||
&Utf8PathBuf::from("template.html"),
|
||||
&config
|
||||
));
|
||||
|
||||
// Should exclude .git files
|
||||
assert!(!VfsWatcher::should_include_path_static(
|
||||
&Utf8PathBuf::from(".git/config"),
|
||||
&config
|
||||
));
|
||||
|
||||
// Should exclude __pycache__ files
|
||||
assert!(!VfsWatcher::should_include_path_static(
|
||||
&Utf8PathBuf::from("__pycache__/test.pyc"),
|
||||
&config
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watch_event_types() {
|
||||
let path1 = Utf8PathBuf::from("test.py");
|
||||
let path2 = Utf8PathBuf::from("new.py");
|
||||
|
||||
let modified = WatchEvent::Modified(path1.clone());
|
||||
let created = WatchEvent::Created(path1.clone());
|
||||
let deleted = WatchEvent::Deleted(path1.clone());
|
||||
let renamed = WatchEvent::Renamed {
|
||||
from: path1,
|
||||
to: path2,
|
||||
};
|
||||
|
||||
// Test that events can be created and compared
|
||||
assert_ne!(modified, created);
|
||||
assert_ne!(created, deleted);
|
||||
assert_ne!(deleted, renamed);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue