From 3b8a6e4f58eab7f860a734d7b041e0f28b89a827 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 16:23:52 +0800 Subject: [PATCH 01/13] fix(lsp): complete requests while workspace loads --- src/global_state.rs | 3 - src/global_state/dispatch/request.rs | 79 +++++++++++----- src/global_state/event_loop.rs | 2 - src/global_state/main_loop.rs | 133 ++++++++++++++++++--------- 4 files changed, 147 insertions(+), 70 deletions(-) diff --git a/src/global_state.rs b/src/global_state.rs index f90674a2..0de8d41b 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -117,8 +117,6 @@ pub(crate) struct WorkspaceState { pub(crate) vfs_loader: Handle, Receiver>, pub(crate) vfs: Arc)>>, pub(crate) workspace_vfs: WorkspaceVfsReadiness, - pub(crate) pending_workspace_readiness_requests: Vec, - pub(crate) workspaces: Arc>, pub(crate) fetch_workspaces_task: ExclTask<(Arc>, Vec), WorkspaceFetchCause>, @@ -188,7 +186,6 @@ impl GlobalState { vfs_loader, vfs: Arc::new(RwLock::new((Vfs::default(), IntMap::default()))), workspace_vfs: WorkspaceVfsReadiness::default(), - pending_workspace_readiness_requests: Vec::new(), workspaces: Arc::from(vec![]), fetch_workspaces_task: ExclTask::default(), registered_client_file_watcher_globs: None, diff --git a/src/global_state/dispatch/request.rs b/src/global_state/dispatch/request.rs index 341c5191..90207e21 100644 --- a/src/global_state/dispatch/request.rs +++ b/src/global_state/dispatch/request.rs @@ -1,6 +1,8 @@ use lsp_server::Request; -use lsp_types::request::{ - DocumentDiagnosticRequest, Request as _, WorkspaceDiagnosticRequest, WorkspaceSymbolRequest, +use lsp_types::{ + DocumentDiagnosticReport, DocumentDiagnosticReportResult, FullDocumentDiagnosticReport, + RelatedFullDocumentDiagnosticReport, WorkspaceDiagnosticReport, + WorkspaceDiagnosticReportResult, WorkspaceSymbolResponse, }; use crate::{ @@ -9,14 +11,31 @@ use crate::{ }; impl GlobalState { - pub(in crate::global_state) fn handle_request(&mut self, req: Request) { - if !self.is_workspace_ready() && Self::is_workspace_readiness_request(&req) { - if Self::is_pull_diagnostic_request(&req) { - self.workspace.workspace_vfs.defer_diagnostics_until_ready(); - } + pub(in crate::global_state) fn handle_request(&mut self, mut req: Request) { + if !self.is_workspace_ready() { + tracing::debug!( + method = %req.method, + id = ?req.id, + readiness = ?self.workspace.workspace_vfs, + "workspace is not ready; checking for a terminal fallback response" + ); + + let mut readiness_dispatcher = ReqDispatcher { req: Some(req), global_state: self }; + readiness_dispatcher + .on_sync_mut::( + handle_document_diagnostic_before_workspace_ready, + ) + .on_sync_mut::( + handle_workspace_diagnostic_before_workspace_ready, + ) + .on_sync_mut::( + handle_workspace_symbol_before_workspace_ready, + ); - self.workspace.pending_workspace_readiness_requests.push(req); - return; + let Some(pending_req) = readiness_dispatcher.req.take() else { + return; + }; + req = pending_req; } let mut dispatcher = ReqDispatcher { req: Some(req), global_state: self }; @@ -84,20 +103,34 @@ impl GlobalState { .on::(handle_selection_range) .finish(); } +} - fn is_pull_diagnostic_request(req: &Request) -> bool { - matches!( - req.method.as_str(), - DocumentDiagnosticRequest::METHOD | WorkspaceDiagnosticRequest::METHOD - ) - } +fn handle_document_diagnostic_before_workspace_ready( + state: &mut GlobalState, + _: lsp_types::DocumentDiagnosticParams, +) -> anyhow::Result { + state.workspace.workspace_vfs.defer_diagnostics_until_ready(); + Ok(DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, + items: Vec::new(), + }, + }) + .into()) +} - fn is_workspace_readiness_request(req: &Request) -> bool { - matches!( - req.method.as_str(), - DocumentDiagnosticRequest::METHOD - | WorkspaceDiagnosticRequest::METHOD - | WorkspaceSymbolRequest::METHOD - ) - } +fn handle_workspace_diagnostic_before_workspace_ready( + state: &mut GlobalState, + _: lsp_types::WorkspaceDiagnosticParams, +) -> anyhow::Result { + state.workspace.workspace_vfs.defer_diagnostics_until_ready(); + Ok(WorkspaceDiagnosticReportResult::Report(WorkspaceDiagnosticReport { items: Vec::new() })) +} + +fn handle_workspace_symbol_before_workspace_ready( + _: &mut GlobalState, + _: lsp_types::WorkspaceSymbolParams, +) -> anyhow::Result> { + Ok(Some(WorkspaceSymbolResponse::Flat(Vec::new()))) } diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index 0c05fa9f..1d2cd2b7 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -176,8 +176,6 @@ impl GlobalState { } if self.is_workspace_ready() { - self.drain_pending_workspace_readiness_requests(); - let client_refresh = !was_workspace_ready || state_changed; if client_refresh && self.config_state.config.cli_code_lens_refresh_support() { diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index 03c20286..7c3271ed 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -51,16 +51,6 @@ impl GlobalState { }; handler(self, res) } - - pub(in crate::global_state) fn drain_pending_workspace_readiness_requests(&mut self) { - let pending_requests = - std::mem::take(&mut self.workspace.pending_workspace_readiness_requests); - for req in pending_requests { - if !self.is_completed(&req) { - self.handle_request(req); - } - } - } } #[cfg(test)] @@ -380,10 +370,25 @@ mod tests { } #[test] - fn diagnostic_requests_are_parked_until_workspace_ready() { - let root = TestDir::new("diagnostic-request-readiness-queue"); + fn diagnostic_request_gets_terminal_fallback_before_workspace_ready() { + let root = TestDir::new("diagnostic-request-readiness-fallback"); let root_path = root.path().to_path_buf(); - let mut state = test_state(root_path); + let (mut state, client) = test_state_with_caps( + root_path, + ClientCapabilities { + text_document: Some(lsp_types::TextDocumentClientCapabilities { + diagnostic: Some(Default::default()), + ..Default::default() + }), + workspace: Some(lsp_types::WorkspaceClientCapabilities { + diagnostic: Some(lsp_types::DiagnosticWorkspaceClientCapabilities { + refresh_support: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }, + ); let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; let request_id = lsp_server::RequestId::from(7); let req = Request::new( @@ -400,8 +405,19 @@ mod tests { state.register_request(Instant::now(), &req); state.handle_request(req); - assert_eq!(state.workspace.pending_workspace_readiness_requests.len(), 1); - assert!(state.tasks.task_pool.receiver.recv_timeout(Duration::from_millis(50)).is_err()); + let response = client.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let Message::Response(response) = response else { + panic!("expected immediate workspace diagnostic fallback response"); + }; + assert_eq!(response.id, request_id); + assert!(response.error.is_none(), "{:?}", response.error); + let report: lsp_types::WorkspaceDiagnosticReportResult = + serde_json::from_value(response.result.unwrap()).unwrap(); + let lsp_types::WorkspaceDiagnosticReportResult::Report(report) = report else { + panic!("expected a full workspace diagnostic fallback report"); + }; + assert!(report.items.is_empty()); + assert!(state.tasks.task_pool.receiver.try_recv().is_err()); state .handle_event(Event::Vfs(vfs_loader::Message::Progress { @@ -411,20 +427,62 @@ mod tests { })) .unwrap(); - assert!(state.workspace.pending_workspace_readiness_requests.is_empty()); - let task = state.tasks.task_pool.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); - let Task::Response(response) = task else { - panic!("expected parked diagnostic request to resume as response task, got {task:?}"); + let refresh = client.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let Message::Request(refresh) = refresh else { + panic!("expected diagnostic refresh after workspace readiness"); }; - assert_eq!(response.response.id, request_id); + assert_eq!(refresh.method, lsp_types::request::WorkspaceDiagnosticRefresh::METHOD); } #[test] - fn workspace_symbol_requests_are_parked_until_workspace_ready() { - let root = TestDir::new("workspace-symbol-readiness-queue"); + fn document_diagnostic_request_gets_empty_report_before_workspace_ready() { + let root = TestDir::new("document-diagnostic-readiness-fallback"); let root_path = root.path().to_path_buf(); - let mut state = test_state(root_path); - let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + let (mut state, client) = test_state_with_caps(root_path, ClientCapabilities::default()); + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); + let request_id = lsp_server::RequestId::from(9); + let req = Request::new( + request_id.clone(), + lsp_types::request::DocumentDiagnosticRequest::METHOD.to_owned(), + lsp_types::DocumentDiagnosticParams { + text_document: lsp_types::TextDocumentIdentifier { + uri: lsp_types::Url::parse("file:///not-loaded-yet.sv").unwrap(), + }, + identifier: None, + previous_result_id: None, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }, + ); + + state.register_request(Instant::now(), &req); + state.handle_request(req); + + let response = client.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let Message::Response(response) = response else { + panic!("expected immediate document diagnostic fallback response"); + }; + assert_eq!(response.id, request_id); + assert!(response.error.is_none(), "{:?}", response.error); + let report: lsp_types::DocumentDiagnosticReportResult = + serde_json::from_value(response.result.unwrap()).unwrap(); + let lsp_types::DocumentDiagnosticReportResult::Report( + lsp_types::DocumentDiagnosticReport::Full(report), + ) = report + else { + panic!("expected a full document diagnostic fallback report"); + }; + assert!(report.full_document_diagnostic_report.result_id.is_none()); + assert!(report.full_document_diagnostic_report.items.is_empty()); + assert!(state.tasks.task_pool.receiver.try_recv().is_err()); + } + + #[test] + fn workspace_symbol_request_gets_empty_result_before_workspace_ready() { + let root = TestDir::new("workspace-symbol-readiness-fallback"); + let root_path = root.path().to_path_buf(); + let (mut state, client) = test_state_with_caps(root_path, ClientCapabilities::default()); + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); let request_id = lsp_server::RequestId::from(8); let req = Request::new( request_id.clone(), @@ -439,25 +497,16 @@ mod tests { state.register_request(Instant::now(), &req); state.handle_request(req); - assert_eq!(state.workspace.pending_workspace_readiness_requests.len(), 1); - assert!(state.tasks.task_pool.receiver.try_recv().is_err()); - - state - .handle_event(Event::Vfs(vfs_loader::Message::Progress { - n_total: 1, - n_done: 1, - config_version, - })) - .unwrap(); - - assert!(state.workspace.pending_workspace_readiness_requests.is_empty()); - let task = state.tasks.task_pool.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); - let Task::Response(response) = task else { - panic!( - "expected parked workspace symbol request to resume as response task, got {task:?}" - ); + let response = client.receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let Message::Response(response) = response else { + panic!("expected immediate workspace symbol fallback response"); }; - assert_eq!(response.response.id, request_id); + assert_eq!(response.id, request_id); + assert!(response.error.is_none(), "{:?}", response.error); + let symbols: Option = + serde_json::from_value(response.result.unwrap()).unwrap(); + assert_eq!(symbols, Some(lsp_types::WorkspaceSymbolResponse::Flat(Vec::new()))); + assert!(state.tasks.task_pool.receiver.try_recv().is_err()); } #[test] From 683e3bc0c681d3f55c981933a40663c90716237b Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 16:29:39 +0800 Subject: [PATCH 02/13] fix(vfs): distinguish scans from external changes --- crates/vfs/src/loader.rs | 6 ++ crates/vfs/src/notify.rs | 7 +- crates/vfs/src/vfs.rs | 12 ++-- src/global_state/event_loop.rs | 87 ++++++++++++++++++------- src/global_state/process_changes.rs | 99 ++++++++++++++++++++++------- 5 files changed, 157 insertions(+), 54 deletions(-) diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index 00628d8c..f5edde02 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -45,6 +45,7 @@ pub struct Config { pub enum Message { Progress { n_total: usize, n_done: usize, config_version: u32 }, Loaded { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, + Changed { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, } pub type Sender = crossbeam_channel::Sender; @@ -131,6 +132,11 @@ impl fmt::Debug for Message { .field("n_files", &files.len()) .field("config_version", config_version) .finish(), + Message::Changed { files, config_version } => f + .debug_struct("Changed") + .field("n_files", &files.len()) + .field("config_version", config_version) + .finish(), Message::Progress { n_total, n_done, config_version } => f .debug_struct("Progress") .field("n_total", n_total) diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 99fc38b4..46c63f65 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -141,7 +141,7 @@ impl BrowserLoader { let contents = read(path.as_path()); let files = vec![(path, contents)]; self.record_loaded_files(&files); - self.send(loader::Message::Loaded { files, config_version: self.config_version }); + self.send(loader::Message::Changed { files, config_version: self.config_version }); } fn record_loaded_files(&mut self, files: &[(AbsPathBuf, LoadResult)]) { @@ -321,7 +321,7 @@ impl NotifyActor { let contents = read(path.as_path()); let files = vec![(path, contents)]; self.record_loaded_files(&files); - self.send(loader::Message::Loaded { + self.send(loader::Message::Changed { files, config_version: self.config_version, }); @@ -334,7 +334,7 @@ impl NotifyActor { let files = self.process_notify_event(event); self.record_loaded_files(&files); - self.send(loader::Message::Loaded { + self.send(loader::Message::Changed { files, config_version: self.config_version, }); @@ -585,6 +585,7 @@ mod tests { let message = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); match &message { loader::Message::Loaded { config_version, .. } + | loader::Message::Changed { config_version, .. } | loader::Message::Progress { config_version, .. } if *config_version == version => { diff --git a/crates/vfs/src/vfs.rs b/crates/vfs/src/vfs.rs index 7afc288f..b871532b 100644 --- a/crates/vfs/src/vfs.rs +++ b/crates/vfs/src/vfs.rs @@ -272,16 +272,17 @@ impl Vfs { .filter_map(move |file_id| self.file_path(file_id).map(|path| (file_id, path))) } - pub fn set_file_contents(&mut self, path: &VfsPath, contents: LoadResult) { + /// Updates a file and returns whether the observable VFS contents changed. + pub fn set_file_contents(&mut self, path: &VfsPath, contents: LoadResult) -> bool { let file_id = self.file_id_or_alloc(path); use FileState::*; use LoadResult::*; let Some(state) = self.file_states.get_mut(file_id.0 as usize) else { - return; + return false; }; let change_kind = match contents { Loaded(new, new_ending) => match state { - Exists(old, _) if *old == new => return, + Exists(old, _) if *old == new => return false, Exists(_, _) => { let change_kind = ChangeKind::Modify(Arc::from(new.as_str()), new_ending); *state = Exists(new, new_ending); @@ -298,13 +299,14 @@ impl Vfs { *state = Deleted; ChangeKind::Delete } - Deleted => return, + Deleted => return false, }, - DecodeError => return, + DecodeError => return false, }; let changed_file = ChangedFile { file_id, change_kind }; self.changes.push(changed_file); + true } pub fn has_changes(&self) -> bool { diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index 1d2cd2b7..ecbd9fab 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -10,7 +10,7 @@ use vfs::{VfsPath, loader as vfs_loader}; use super::{ GlobalState, WorkspaceFetchCompletion, process_changes::DiagnosticInvalidation, - reload::FetchWorkspaceProgress, + reload::{self, FetchWorkspaceProgress}, respond::Progress, task::{ResponseTask, Task}, }; @@ -32,6 +32,7 @@ impl Event { Event::Task(task) => task.kind(), Event::Vfs(vfs_loader::Message::Progress { .. }) => "vfs.progress", Event::Vfs(vfs_loader::Message::Loaded { .. }) => "vfs.loaded", + Event::Vfs(vfs_loader::Message::Changed { .. }) => "vfs.changed", } } @@ -53,6 +54,9 @@ impl Event { Event::Vfs(vfs_loader::Message::Loaded { files, .. }) => { format!("vfs loaded files={}", files.len()) } + Event::Vfs(vfs_loader::Message::Changed { files, .. }) => { + format!("vfs changed files={}", files.len()) + } } } } @@ -385,32 +389,69 @@ impl GlobalState { } } vfs_loader::Message::Loaded { files, config_version } => { - always!( - config_version <= self.workspace.workspace_vfs.current_vfs_config_version() - ); - if !self.workspace.workspace_vfs.accepts_vfs_loaded(config_version) { - tracing::debug!( - config_version, - current_config_version = - self.workspace.workspace_vfs.current_vfs_config_version(), - files = files.len(), - "stale VFS loaded batch ignored" - ); - return; - } + self.process_vfs_files(files, config_version, false); + } + vfs_loader::Message::Changed { files, config_version } => { + self.process_vfs_files(files, config_version, true); + } + } + } - let vfs = &mut self.workspace.vfs.write().0; + fn process_vfs_files( + &mut self, + files: Vec<(utils::paths::AbsPathBuf, vfs_loader::LoadResult)>, + config_version: u32, + external_change: bool, + ) { + always!(config_version <= self.workspace.workspace_vfs.current_vfs_config_version()); + if !self.workspace.workspace_vfs.accepts_vfs_loaded(config_version) { + tracing::debug!( + config_version, + current_config_version = self.workspace.workspace_vfs.current_vfs_config_version(), + files = files.len(), + external_change, + "stale VFS file batch ignored" + ); + return; + } - for (path, content) in files { - let path = VfsPath::from(path); - let open_file_id = vfs - .file_id(&path) - .is_some_and(|file_id| self.analysis.mem_docs.contains_file_id(file_id)); - if !self.analysis.mem_docs.contains_path(&path) && !open_file_id { - vfs.set_file_contents(&path, content); - } + let workspace_manifest_change = { + let vfs = &mut self.workspace.vfs.write().0; + let mut manifest_change = None; + + for (abs_path, content) in files { + let path = VfsPath::from(abs_path.clone()); + let existing_file_id = vfs.file_id(&path); + let existed = existing_file_id.is_some_and(|file_id| vfs.exists(file_id)); + let exists_after = matches!(&content, vfs_loader::LoadResult::Loaded(..)); + let has_structure_change = existed != exists_after; + let open_file_id = existing_file_id + .is_some_and(|file_id| self.analysis.mem_docs.contains_file_id(file_id)); + if self.analysis.mem_docs.contains_path(&path) || open_file_id { + continue; + } + + let changed = vfs.set_file_contents(&path, content); + if changed + && external_change + && reload::should_refresh_for_change(&abs_path, has_structure_change) + { + manifest_change.get_or_insert(abs_path); } } + + manifest_change + }; + + if let Some(path) = workspace_manifest_change { + tracing::info!( + config_version, + %path, + "server file watcher reported a workspace manifest change" + ); + let config = Arc::make_mut(&mut self.config_state.config); + config.refresh_project_manifests(); + self.request_workspace_auto_reload(format!("server file watcher change: {path}")); } } } diff --git a/src/global_state/process_changes.rs b/src/global_state/process_changes.rs index 5e97e91c..5d135a05 100644 --- a/src/global_state/process_changes.rs +++ b/src/global_state/process_changes.rs @@ -12,7 +12,6 @@ use vfs::{ChangedFile, FileId, Vfs, VfsPath}; use super::{ DEFAULT_REQ_HANDLER, GlobalState, diagnostics::publisher::{PublishDiagnosticsBatch, PublishDiagnosticsTask}, - reload::should_refresh_for_change, task::Task, }; use crate::{config::user_config::DiagnosticsUpdateUserConfig, lsp_ext::to_proto}; @@ -56,7 +55,6 @@ impl GlobalState { return false; }; - let mut workspace_structure_change = None; let mut has_structure_changes = false; // Any file was added or deleted let mut bytes = vec![]; let mut changed_file_ids = FxHashSet::default(); @@ -71,14 +69,8 @@ impl GlobalState { } else { vfs.file_path(changed_file.file_id) }; - if let Some(path) = - path.and_then(|path| path.as_abs_path()).map(|apath| apath.to_path_buf()) - { - let created_or_deleted = changed_file.is_created_or_deleted(); - has_structure_changes |= created_or_deleted; - if !is_identity_redirect && should_refresh_for_change(&path, created_or_deleted) { - workspace_structure_change = Some(path.clone()); - } + if path.and_then(VfsPath::as_abs_path).is_some() { + has_structure_changes |= changed_file.is_created_or_deleted(); } if matches!(&changed_file.change_kind, vfs::ChangeKind::Delete) { @@ -146,12 +138,6 @@ impl GlobalState { self.request_diagnostics(pending_diagnostic_targets.into_iter().collect()); } - if let Some(path) = workspace_structure_change { - let config = triomphe::Arc::make_mut(&mut self.config_state.config); - config.refresh_project_manifests(); - self.request_workspace_auto_reload(format!("workspace vfs change: {:?}", path)); - } - true } @@ -421,8 +407,11 @@ impl GlobalState { mod tests { use lsp_server::Connection; use lsp_types::{ClientCapabilities, TraceValue}; - use utils::{lines::LineEnding, test_support::TestDir}; - use vfs::{VfsPath, loader::LoadResult}; + use utils::{lines::LineEnding, paths::AbsPathBuf, test_support::TestDir}; + use vfs::{ + VfsPath, + loader::{LoadResult, Message as VfsMessage}, + }; use crate::{ Opt, @@ -431,10 +420,7 @@ mod tests { i18n::I18n, }; - #[test] - fn ordinary_file_creation_does_not_request_workspace_reload() { - let root = TestDir::new("ordinary-file-no-workspace-reload"); - let root_path = root.path().to_path_buf(); + fn test_state(root_path: AbsPathBuf) -> GlobalState { let config = config::Config::new( Opt { process_name: "vide-test".to_string(), @@ -450,7 +436,28 @@ mod tests { Vec::new(), ); let (server, _client) = Connection::memory(); - let mut state = GlobalState::new(server.sender, config, TraceValue::Off); + GlobalState::new(server.sender, config, TraceValue::Off) + } + + fn vfs_manifest_message( + manifest_path: AbsPathBuf, + config_version: u32, + changed: bool, + ) -> VfsMessage { + let files = + vec![(manifest_path, LoadResult::Loaded("[project]\n".to_owned(), LineEnding::Unix))]; + if changed { + VfsMessage::Changed { files, config_version } + } else { + VfsMessage::Loaded { files, config_version } + } + } + + #[test] + fn ordinary_file_creation_does_not_request_workspace_reload() { + let root = TestDir::new("ordinary-file-no-workspace-reload"); + let root_path = root.path().to_path_buf(); + let mut state = test_state(root_path); let file_path = root.join("top.sv"); state.workspace.vfs.write().0.set_file_contents( @@ -464,4 +471,50 @@ mod tests { "loading an ordinary source file should not queue a project configuration reload" ); } + + #[test] + fn config_scan_manifest_does_not_request_workspace_reload() { + let root = TestDir::new("config-scan-manifest-no-reload"); + let mut state = test_state(root.path().to_path_buf()); + let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + + state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), config_version, false)); + assert!(state.process_changes()); + + assert!( + !state.workspace.fetch_workspaces_task.has_op_requested(), + "initial manifest discovery must not recursively reload the workspace" + ); + } + + #[test] + fn external_manifest_change_requests_workspace_reload() { + let root = TestDir::new("external-manifest-reload"); + let mut state = test_state(root.path().to_path_buf()); + let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + + state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), config_version, true)); + + assert!( + state.workspace.fetch_workspaces_task.has_op_requested(), + "an external manifest change must reload the workspace" + ); + } + + #[test] + fn unchanged_external_manifest_does_not_request_workspace_reload() { + let root = TestDir::new("unchanged-external-manifest-no-reload"); + let mut state = test_state(root.path().to_path_buf()); + let manifest_path = root.join("vide.toml"); + let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + + state.process_vfs_msg(vfs_manifest_message(manifest_path.clone(), config_version, false)); + state.process_vfs_msg(vfs_manifest_message(manifest_path, config_version, true)); + assert!(state.process_changes()); + + assert!( + !state.workspace.fetch_workspaces_task.has_op_requested(), + "an unchanged watcher event must not reclassify an initial scan change" + ); + } } From 91e09586b6765492e70a6f7beec87753f9760e26 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 16:30:26 +0800 Subject: [PATCH 03/13] test(lsp): repoll cold diagnostic fallbacks --- src/tests.rs | 64 ++++++++++++++++++++++++++++++++++++++-- src/tests/diagnostics.rs | 28 ++++-------------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 744f0b33..a82db76a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -370,7 +370,38 @@ fn request_document_diagnostics_with_previous_result_id( request_id: i32, previous_result_id: Option, ) -> (Option, Vec) { - let request_id = lsp_server::RequestId::from(request_id); + for attempt in 0..50 { + let current_request_id = if attempt == 0 { + lsp_server::RequestId::from(request_id) + } else { + lsp_server::RequestId::from(format!( + "document-diagnostic-{request_id}-readiness-{attempt}" + )) + }; + let result = request_document_diagnostics_once( + client, + uri.clone(), + current_request_id, + previous_result_id.clone(), + ); + if result.0.is_some() || !result.1.is_empty() { + return result; + } + wait_for_workspace_diagnostic_refresh_or_tick( + client, + "document diagnostics workspace readiness", + ); + } + + panic!("document diagnostics remained in the cold-start fallback state"); +} + +fn request_document_diagnostics_once( + client: &Connection, + uri: Url, + request_id: lsp_server::RequestId, + previous_result_id: Option, +) -> (Option, Vec) { client .sender .send(Message::Request(Request::new( @@ -732,7 +763,36 @@ fn request_workspace_diagnostic_report( request_id: i32, previous_result_ids: Vec, ) -> lsp_types::WorkspaceDiagnosticReport { - let request_id = lsp_server::RequestId::from(request_id); + for attempt in 0..50 { + let current_request_id = if attempt == 0 { + lsp_server::RequestId::from(request_id) + } else { + lsp_server::RequestId::from(format!( + "workspace-diagnostic-{request_id}-readiness-{attempt}" + )) + }; + let report = request_workspace_diagnostic_report_once( + client, + current_request_id, + previous_result_ids.clone(), + ); + if !report.items.is_empty() { + return report; + } + wait_for_workspace_diagnostic_refresh_or_tick( + client, + "workspace diagnostics workspace readiness", + ); + } + + panic!("workspace diagnostics remained in the cold-start fallback state"); +} + +fn request_workspace_diagnostic_report_once( + client: &Connection, + request_id: lsp_server::RequestId, + previous_result_ids: Vec, +) -> lsp_types::WorkspaceDiagnosticReport { client .sender .send(Message::Request(Request::new( diff --git a/src/tests/diagnostics.rs b/src/tests/diagnostics.rs index 6e8045fe..bd65df04 100644 --- a/src/tests/diagnostics.rs +++ b/src/tests/diagnostics.rs @@ -1113,29 +1113,11 @@ fn restored_project_manifest_clears_diagnostics_for_excluded_files() { ))) .unwrap(); - let second_id = lsp_server::RequestId::from(2); - client - .sender - .send(Message::Request(Request::new( - second_id.clone(), - WorkspaceDiagnosticRequest::METHOD.to_string(), - WorkspaceDiagnosticParams { - identifier: None, - previous_result_ids: vec![lsp_types::PreviousResultId { - uri: ignored_uri.clone(), - value: ignored_result_id, - }], - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - let second: WorkspaceDiagnosticReportResult = - recv_response(&client, second_id, "workspaceDiagnostic"); - let second_report = match second { - WorkspaceDiagnosticReportResult::Report(report) => report, - other => panic!("unexpected workspace diagnostic response: {other:?}"), - }; + let second_report = request_workspace_diagnostic_report( + &client, + 2, + vec![lsp_types::PreviousResultId { uri: ignored_uri.clone(), value: ignored_result_id }], + ); for item in second_report.items { if let lsp_types::WorkspaceDocumentDiagnosticReport::Full(full) = item && full.uri == ignored_uri From 31617a44a6640d33788c935ee707c0d0b7b2dc32 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 17:11:59 +0800 Subject: [PATCH 04/13] test(lsp): make readiness synchronization explicit --- src/tests.rs | 34 +++----- src/tests/code_actions.rs | 17 +--- src/tests/diagnostics.rs | 149 ++++++++++----------------------- src/tests/workspace_symbols.rs | 5 ++ 4 files changed, 60 insertions(+), 145 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index a82db76a..7fa779ed 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -384,7 +384,11 @@ fn request_document_diagnostics_with_previous_result_id( current_request_id, previous_result_id.clone(), ); - if result.0.is_some() || !result.1.is_empty() { + // A diagnostically enabled document gets a result id once the workspace + // is ready, even when its diagnostic list is legitimately empty. The + // cold-start fallback deliberately has no result id. Do not use the + // item count as a readiness signal: an empty list is valid data. + if result.0.is_some() { return result; } wait_for_workspace_diagnostic_refresh_or_tick( @@ -763,29 +767,11 @@ fn request_workspace_diagnostic_report( request_id: i32, previous_result_ids: Vec, ) -> lsp_types::WorkspaceDiagnosticReport { - for attempt in 0..50 { - let current_request_id = if attempt == 0 { - lsp_server::RequestId::from(request_id) - } else { - lsp_server::RequestId::from(format!( - "workspace-diagnostic-{request_id}-readiness-{attempt}" - )) - }; - let report = request_workspace_diagnostic_report_once( - client, - current_request_id, - previous_result_ids.clone(), - ); - if !report.items.is_empty() { - return report; - } - wait_for_workspace_diagnostic_refresh_or_tick( - client, - "workspace diagnostics workspace readiness", - ); - } - - panic!("workspace diagnostics remained in the cold-start fallback state"); + request_workspace_diagnostic_report_once( + client, + lsp_server::RequestId::from(request_id), + previous_result_ids, + ) } fn request_workspace_diagnostic_report_once( diff --git a/src/tests/code_actions.rs b/src/tests/code_actions.rs index 3322df41..2486f96f 100644 --- a/src/tests/code_actions.rs +++ b/src/tests/code_actions.rs @@ -180,22 +180,7 @@ endmodule let (_temp_dir, client, server_thread, uri) = setup_diagnostics_test(code_action_client_caps(), UserConfig::default(), text); - let diagnostics_id = lsp_server::RequestId::from(212); - client - .sender - .send(Message::Request(Request::new( - diagnostics_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri: uri.clone() }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - let (_result_id, diagnostics) = recv_document_diagnostics(&client, diagnostics_id); + let (_result_id, diagnostics) = request_document_diagnostics(&client, uri.clone(), 212); assert!( diagnostics.iter().any(|diag| diag.message == "expected ';'"), "expected parse diagnostic for missing semicolon, got {diagnostics:?}" diff --git a/src/tests/diagnostics.rs b/src/tests/diagnostics.rs index bd65df04..064cc902 100644 --- a/src/tests/diagnostics.rs +++ b/src/tests/diagnostics.rs @@ -76,8 +76,22 @@ fn pull_capable_client_does_not_receive_duplicate_publish_diagnostics() { }), ..Default::default() }; - let (_temp_dir, client, server_thread, uri) = - setup_diagnostics_test(pull_caps, UserConfig::default(), "module broken(;\nendmodule\n"); + let file_text = "module broken(;\nendmodule\n"; + let temp_dir = TempDir::new("pull-diagnostics-no-publish"); + let file_path = temp_dir.path().join("broken.sv"); + let readiness_path = temp_dir.path().join("readiness.sv"); + fs::write(&readiness_path, "module readiness;\nendmodule\n").unwrap(); + let (client, server_thread) = + spawn_test_workspace(temp_dir.path().to_path_buf(), pull_caps, UserConfig::default()); + let uri = to_proto::url_from_abs_path(file_path.as_path()).unwrap(); + let readiness_uri = to_proto::url_from_abs_path(readiness_path.as_path()).unwrap(); + + // Establish workspace readiness before opening the document. This keeps + // the publishDiagnostics observation below intact: no notification for the + // target document can be consumed as part of readiness synchronization. + let _ = request_document_diagnostics(&client, readiness_uri, 100); + open_test_document(&client, uri.clone(), file_text); + let request_id = lsp_server::RequestId::from(1); let request = Request::new( request_id.clone(), @@ -309,23 +323,7 @@ endmodule let (_temp_dir, client, server_thread, uri) = setup_diagnostics_test(pull_caps, UserConfig::default(), file_text); - let request_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - request_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - - let (_result_id, diagnostics) = recv_document_diagnostics(&client, request_id); + let (_result_id, diagnostics) = request_document_diagnostics(&client, uri, 1); assert!( diagnostics.iter().all(|diag| !diag.message.contains("port 'b' has no connection")), "unconfigured workspaces should suppress semantic diagnostics: {diagnostics:?}" @@ -393,23 +391,7 @@ endmodule let (_temp_dir, client, server_thread, uri) = setup_syntax_only_config_diagnostics_test(pull_caps, UserConfig::default(), file_text); - let request_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - request_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - - let (_result_id, diagnostics) = recv_document_diagnostics(&client, request_id); + let (_result_id, diagnostics) = request_document_diagnostics(&client, uri, 1); assert!( diagnostics.iter().all(|diag| !diag.message.contains("port 'b' has no connection")), "syntax-only configs should suppress semantic diagnostics: {diagnostics:?}" @@ -439,23 +421,7 @@ endmodule let (_temp_dir, client, server_thread, uri) = setup_empty_config_diagnostics_test(pull_caps, UserConfig::default(), file_text); - let request_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - request_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - - let (_result_id, diagnostics) = recv_document_diagnostics(&client, request_id); + let (_result_id, diagnostics) = request_document_diagnostics(&client, uri, 1); assert!( diagnostics.iter().all(|diag| !diag.message.contains("port 'b' has no connection")), "empty configs should suppress semantic diagnostics: {diagnostics:?}" @@ -480,23 +446,7 @@ endmodule let (_temp_dir, client, server_thread, uri) = setup_syntax_only_config_diagnostics_test(pull_caps, UserConfig::default(), file_text); - let request_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - request_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - - let (_result_id, diagnostics) = recv_document_diagnostics(&client, request_id); + let (_result_id, diagnostics) = request_document_diagnostics(&client, uri, 1); assert!( diagnostics.iter().any(|diag| diag.message.contains("expected")), "syntax-only configs should still report parse diagnostics: {diagnostics:?}" @@ -529,14 +479,25 @@ fn document_diagnostics_respect_disabled_source_root_policy() { let ignored_path = ignored_dir.join("ignored.sv"); let ignored_text = "module ignored(;\nendmodule\n"; fs::write(&ignored_path, ignored_text).unwrap(); - fs::write(rtl_dir.join("top.sv"), "module top;\nendmodule\n").unwrap(); + let top_path = rtl_dir.join("top.sv"); + fs::write(&top_path, "module top;\nendmodule\n").unwrap(); let (client, server_thread) = spawn_test_workspace(temp_dir.path().to_path_buf(), pull_caps, user_config); let ignored_uri = to_proto::url_from_abs_path(ignored_path.as_path()).unwrap(); + let top_uri = to_proto::url_from_abs_path(top_path.as_path()).unwrap(); open_test_document(&client, ignored_uri.clone(), ignored_text); - let (result_id, diagnostics) = request_document_diagnostics(&client, ignored_uri, 1); + // The ignored document legitimately has no result id, just like the cold + // fallback. Synchronize through an enabled source first, then make one raw + // request so the two empty responses are never conflated. + let _ = request_document_diagnostics(&client, top_uri, 1); + let (result_id, diagnostics) = request_document_diagnostics_once( + &client, + ignored_uri, + lsp_server::RequestId::from(3), + None, + ); assert!(result_id.is_none(), "disabled source roots should not receive result ids"); assert!( diagnostics.is_empty(), @@ -861,22 +822,7 @@ fn unsaved_library_include_header_changes_are_used_for_dependent_diagnostics() { let top_uri = to_proto::url_from_abs_path(top_path.as_path()).unwrap(); let header_uri = to_proto::url_from_abs_path(header_path.as_path()).unwrap(); - let first_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - first_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri: top_uri.clone() }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - let (_, initial_diagnostics) = recv_document_diagnostics(&client, first_id); + let (_, initial_diagnostics) = request_document_diagnostics(&client, top_uri.clone(), 1); assert!( initial_diagnostics.is_empty(), "saved library include header should define ENABLE_COUNTER: {initial_diagnostics:?}" @@ -975,22 +921,7 @@ fn unsaved_include_header_changes_are_used_for_dependent_diagnostics() { let top_uri = to_proto::url_from_abs_path(top_path.as_path()).unwrap(); let header_uri = to_proto::url_from_abs_path(header_path.as_path()).unwrap(); - let first_id = lsp_server::RequestId::from(1); - client - .sender - .send(Message::Request(Request::new( - first_id.clone(), - DocumentDiagnosticRequest::METHOD.to_string(), - DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri: top_uri.clone() }, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: Default::default(), - }, - ))) - .unwrap(); - let (_, initial_diagnostics) = recv_document_diagnostics(&client, first_id); + let (_, initial_diagnostics) = request_document_diagnostics(&client, top_uri.clone(), 1); assert!( initial_diagnostics.is_empty(), "saved include header should define ENABLE_COUNTER: {initial_diagnostics:?}" @@ -1053,7 +984,8 @@ fn restored_project_manifest_clears_diagnostics_for_excluded_files() { fs::create_dir_all(&rtl_dir).unwrap(); fs::write(&manifest_path, DEFAULT_TEST_CONFIG).unwrap(); fs::write(ignored_dir.join("ignored.sv"), "module ignored(;\nendmodule\n").unwrap(); - fs::write(rtl_dir.join("top.sv"), "module top;\nendmodule\n").unwrap(); + let top_path = rtl_dir.join("top.sv"); + fs::write(&top_path, "module top;\nendmodule\n").unwrap(); let root_path = temp_dir.path().to_path_buf(); let opt = Opt { @@ -1076,8 +1008,10 @@ fn restored_project_manifest_clears_diagnostics_for_excluded_files() { let server_thread = spawn_default_test_server(config, server); let ignored_uri = to_proto::url_from_abs_path(ignored_dir.join("ignored.sv").as_path()).unwrap(); + let top_uri = to_proto::url_from_abs_path(top_path.as_path()).unwrap(); let manifest_uri = to_proto::url_from_abs_path(manifest_path.as_path()).unwrap(); + let _ = request_document_diagnostics(&client, ignored_uri.clone(), 100); let first_report = request_workspace_diagnostic_report(&client, 1, Vec::new()); let mut saw_ignored_diagnostic = false; let mut ignored_result_id = None; @@ -1113,6 +1047,10 @@ fn restored_project_manifest_clears_diagnostics_for_excluded_files() { ))) .unwrap(); + // The second workspace report is expected to contain a legitimate empty + // cleanup entry. Synchronize through the still-enabled top-level source so + // an empty cold fallback cannot satisfy that assertion by accident. + let _ = request_document_diagnostics(&client, top_uri, 101); let second_report = request_workspace_diagnostic_report( &client, 2, @@ -1278,6 +1216,7 @@ fn deleted_workspace_file_requests_diagnostic_refresh() { let server_thread = spawn_default_test_server(config, server); let broken_uri = to_proto::url_from_abs_path(broken_path.as_path()).unwrap(); + let _ = request_document_diagnostics(&client, broken_uri.clone(), 100); let first_report = request_workspace_diagnostic_report(&client, 1, Vec::new()); let mut saw_broken_diagnostic = false; let mut broken_result_id = None; diff --git a/src/tests/workspace_symbols.rs b/src/tests/workspace_symbols.rs index 3fc351c1..26750f54 100644 --- a/src/tests/workspace_symbols.rs +++ b/src/tests/workspace_symbols.rs @@ -26,6 +26,11 @@ endmodule &files, ); + // Workspace symbols use the same workspace/VFS readiness boundary. A + // document diagnostic result id gives the test an explicit synchronization + // point before asserting on symbol contents. + let _ = request_document_diagnostics(&client, uris[0].clone(), 180); + let symbols_id = lsp_server::RequestId::from(181); client .sender From 4c4bebe0e999febc3556cbbd1ee9d6f2b39aabcd Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 17:55:22 +0800 Subject: [PATCH 05/13] fix(vfs): isolate blocking watcher operations --- crates/vfs/src/loader.rs | 39 + crates/vfs/src/notify.rs | 2282 ++++++++++++++++++++++++--- src/global_state/event_loop.rs | 80 +- src/global_state/main_loop.rs | 31 + src/global_state/workspace_state.rs | 15 +- 5 files changed, 2235 insertions(+), 212 deletions(-) diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index f5edde02..39ad0611 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -46,6 +46,8 @@ pub enum Message { Progress { n_total: usize, n_done: usize, config_version: u32 }, Loaded { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, Changed { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, + ScanFailed { config_version: u32, failure: ScanFailure }, + WatcherStatus(WatcherStatus), } pub type Sender = crossbeam_channel::Sender; @@ -58,6 +60,37 @@ pub enum LoadResult { DecodeError, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WatcherStatus { + Ready { config_version: u32 }, + Failed { config_version: u32, failure: WatcherFailure }, +} + +impl WatcherStatus { + pub fn config_version(&self) -> u32 { + match self { + Self::Ready { config_version } | Self::Failed { config_version, .. } => *config_version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WatcherFailure { + Create { error: String }, + Watch { path: AbsPathBuf, error: String }, + Unwatch { path: AbsPathBuf, error: String }, + Notify { error: String }, + Protocol { error: String }, + Stopped { error: String }, +} + +/// A content scan that could not produce a complete VFS generation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanFailure { + pub root: AbsPathBuf, + pub error: String, +} + pub trait Handle: fmt::Debug { fn spawn(sender: Sender) -> Self where @@ -137,6 +170,12 @@ impl fmt::Debug for Message { .field("n_files", &files.len()) .field("config_version", config_version) .finish(), + Message::ScanFailed { config_version, failure } => f + .debug_struct("ScanFailed") + .field("config_version", config_version) + .field("failure", failure) + .finish(), + Message::WatcherStatus(status) => f.debug_tuple("WatcherStatus").field(status).finish(), Message::Progress { n_total, n_done, config_version } => f .debug_struct("Progress") .field("n_total", n_total) diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 46c63f65..8551069a 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -1,10 +1,13 @@ use std::{fs, mem, sync::atomic::AtomicUsize}; -use ::notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use ::notify::{ + Config, ErrorKind, RecommendedWatcher, RecursiveMode, Watcher, + event::{ModifyKind, RenameMode}, +}; use crossbeam_channel::{Receiver, Sender, select, unbounded}; use itertools::Itertools; use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use utils::{ lines::LineEnding, paths::{AbsPath, AbsPathBuf}, @@ -106,10 +109,17 @@ impl BrowserLoader { let previous_loaded_paths = mem::take(&mut self.loaded_paths); let mut reported_paths = FxHashSet::default(); let mut loaded_paths = FxHashSet::default(); + let mut scan_failures = Vec::new(); for (index, entry) in config.to_load.into_iter().enumerate() { let (watch_tx, _) = unbounded(); - let files = NotifyActor::load_entry(&watch_tx, entry, false); + let files = match NotifyActor::load_entry(&watch_tx, entry, false) { + Ok(files) => files, + Err(failure) => { + scan_failures.push(failure); + Vec::new() + } + }; reported_paths.extend(files.iter().map(|(path, _)| path.clone())); loaded_paths.extend( files @@ -118,7 +128,20 @@ impl BrowserLoader { .map(|(path, _)| path.clone()), ); self.send(loader::Message::Loaded { files, config_version }); - self.send(loader::Message::Progress { n_total, n_done: index + 1, config_version }); + let n_done = index + 1; + if n_done < n_total { + self.send(loader::Message::Progress { n_total, n_done, config_version }); + } + } + + if !scan_failures.is_empty() { + loaded_paths.extend(previous_loaded_paths); + self.loaded_paths = loaded_paths; + for failure in scan_failures { + self.send(loader::Message::ScanFailed { config_version, failure }); + } + self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); + return; } let unloaded = previous_loaded_paths @@ -130,11 +153,7 @@ impl BrowserLoader { if !unloaded.is_empty() { self.send(loader::Message::Loaded { files: unloaded, config_version }); } - if has_reconcile_step { - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); - } else if n_total == 0 { - self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); - } + self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); } fn invalidate(&mut self, path: AbsPathBuf) { @@ -161,44 +180,526 @@ impl BrowserLoader { } } -type NotifyEvent = ::notify::Result<::notify::Event>; +#[derive(Debug)] +struct WatchPlan { + config_version: u32, + coverage_revision: u64, + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WatchTarget { + path: AbsPathBuf, + // A path receives a new registration revision after it leaves coverage and + // later reappears. The manager then re-registers that spelling even if the + // OS backend silently discarded the old watch when the path was removed. + registration_revision: u64, +} + +#[derive(Debug)] +struct WatchSync { + config_version: u32, + coverage_revision: u64, + targets: Vec, +} + +#[derive(Debug)] +enum WatcherCommand { + Replace(WatchPlan), + Sync(WatchSync), + Abort { through_config_version: u32 }, +} + +#[derive(Debug)] +enum WatcherOutput { + Installed { config_version: u32, coverage_revision: u64 }, + Synced { config_version: u32, coverage_revision: u64 }, + Notify { config_version: u32, event: ::notify::Event }, + Failed { config_version: u32, failure: loader::WatcherFailure }, +} + +impl WatcherOutput { + fn config_version(&self) -> u32 { + match self { + Self::Installed { config_version, .. } + | Self::Synced { config_version, .. } + | Self::Notify { config_version, .. } + | Self::Failed { config_version, .. } => *config_version, + } + } +} + +#[derive(Debug)] +struct WatcherManagerHandle { + command_sender: Sender, + output_receiver: Receiver, +} + +#[derive(Debug, Default)] +struct WatchCoverage { + // Desired coverage owned by the loader actor. The watcher manager owns the + // separate installed set and only changes it from full snapshots of this map. + targets: FxHashMap, + // Changes whenever the desired snapshot changes. Installation and sync + // acknowledgements carry this value so a post-install rescan can detect gaps. + revision: u64, + // Monotonic identity for one incarnation of a path in desired coverage. + next_registration_revision: u64, +} + +impl WatchCoverage { + fn replace(&mut self, paths: impl IntoIterator) { + self.targets.clear(); + self.revision = self.revision.wrapping_add(1); + for path in paths { + self.insert_new_target(path); + } + } + + fn add(&mut self, paths: impl IntoIterator) -> bool { + let mut changed = false; + for path in paths { + if !self.targets.contains_key(&path) { + self.insert_new_target(path); + changed = true; + } + } + if changed { + self.revision = self.revision.wrapping_add(1); + } + changed + } + + fn reconcile(&mut self, paths: FxHashSet) -> bool { + let before = self.targets.len(); + self.targets.retain(|path, _| paths.contains(path)); + let mut changed = self.targets.len() != before; + for path in paths { + if !self.targets.contains_key(&path) { + self.insert_new_target(path); + changed = true; + } + } + if changed { + self.revision = self.revision.wrapping_add(1); + } + changed + } + + fn invalidate_prefix(&mut self, removed_path: &AbsPath) -> bool { + let before = self.targets.len(); + self.targets.retain(|path, _| !path.starts_with(removed_path)); + let changed = self.targets.len() != before; + if changed { + self.revision = self.revision.wrapping_add(1); + } + changed + } + + fn snapshot(&self) -> Vec { + self.targets + .iter() + .map(|(path, registration_revision)| WatchTarget { + path: path.clone(), + registration_revision: *registration_revision, + }) + .sorted_by(|left, right| left.path.cmp(&right.path)) + .collect() + } + + fn insert_new_target(&mut self, path: AbsPathBuf) { + self.next_registration_revision = self.next_registration_revision.wrapping_add(1); + self.targets.insert(path, self.next_registration_revision); + } +} + +#[derive(Debug, Default)] +enum WatcherPhase { + // Installing and Syncing each represent the only watcher mutation in + // flight. Every matching acknowledgement is followed by a full rescan; + // Ready is reached only when that rescan leaves the desired coverage at + // the acknowledged revision. Failed is terminal for the generation. + #[default] + Unconfigured, + Installing { + coverage_revision: u64, + }, + Syncing { + coverage_revision: u64, + }, + Ready, + Failed, +} struct NotifyActor { sender: loader::Sender, config_version: u32, watched_files: FxHashSet, watched_dirs: Vec, + watch_coverage: WatchCoverage, + watcher_phase: WatcherPhase, loaded_paths: FxHashSet, - // Drop order is significant. - watcher: Option<(RecommendedWatcher, Receiver)>, + watcher_manager: Option, } #[derive(Debug)] enum Event { ServerMsg(ServerMsg), - NotifyEvent(NotifyEvent), + WatcherOutput(WatcherOutput), + WatcherManagerStopped, +} + +#[derive(Debug)] +enum ActorFailure { + Watcher(loader::WatcherFailure), + Scan(loader::ScanFailure), +} + +impl From for ActorFailure { + fn from(failure: loader::WatcherFailure) -> Self { + Self::Watcher(failure) + } +} + +impl From for ActorFailure { + fn from(failure: loader::ScanFailure) -> Self { + Self::Scan(failure) + } +} + +fn spawn_watcher_manager() -> std::io::Result { + spawn_watcher_manager_with::(|config_version, event_sender| { + RecommendedWatcher::new( + move |event: ::notify::Result<::notify::Event>| { + let output = match event { + Ok(event) => WatcherOutput::Notify { config_version, event }, + Err(error) => WatcherOutput::Failed { + config_version, + failure: loader::WatcherFailure::Notify { error: error.to_string() }, + }, + }; + if event_sender.send(output).is_err() { + tracing::debug!( + config_version, + "watcher event dropped because the manager receiver is closed" + ); + } + }, + Config::default(), + ) + }) +} + +fn spawn_watcher_manager_with(create: F) -> std::io::Result +where + W: Watcher + Send + 'static, + F: FnMut(u32, Sender) -> ::notify::Result + Send + 'static, +{ + let (command_sender, command_receiver) = unbounded(); + let (output_sender, output_receiver) = unbounded(); + // OS watcher construction and registration are not cancellable and may never + // return. This sole detached owner keeps those calls off the VFS loader and + // main-loop shutdown paths. Commands are intentionally serialized: a stuck + // backend call can delay later watcher generations, but it cannot delay + // content loading or an LSP response. + let thread = thread::Builder::new(thread::ThreadIntent::Worker) + .name("VfsWatcherManager".to_owned()) + .allow_leak(true) + .spawn(move || run_watcher_manager(command_receiver, output_sender, create))?; + drop(thread); + Ok(WatcherManagerHandle { command_sender, output_receiver }) +} + +// The manager is the sole owner of the OS watcher and the authoritative record +// of successfully installed targets. The actor never mutates this state +// directly. +struct ActiveWatcher { + config_version: u32, + watcher: Option, + installed_targets: FxHashMap, +} + +fn run_watcher_manager( + command_receiver: Receiver, + output_sender: Sender, + mut create: F, +) where + W: Watcher, + F: FnMut(u32, Sender) -> ::notify::Result, +{ + let mut active: Option> = None; + + while let Ok(command) = command_receiver.recv() { + match command { + WatcherCommand::Replace(plan) => { + active = None; + let version = plan.config_version; + let mut state = ActiveWatcher { + config_version: version, + watcher: None, + installed_targets: FxHashMap::default(), + }; + if let Err(failure) = reconcile_watch_coverage( + version, + &mut state, + plan.targets, + &output_sender, + &mut create, + ) { + if output_sender + .send(WatcherOutput::Failed { config_version: version, failure }) + .is_err() + { + return; + } + continue; + } + + active = Some(state); + if output_sender + .send(WatcherOutput::Installed { + config_version: version, + coverage_revision: plan.coverage_revision, + }) + .is_err() + { + return; + } + } + WatcherCommand::Sync(sync) => { + let Some(mut state) = active.take() else { + if output_sender + .send(WatcherOutput::Failed { + config_version: sync.config_version, + failure: loader::WatcherFailure::Protocol { + error: "watcher sync has no active generation".to_owned(), + }, + }) + .is_err() + { + return; + } + continue; + }; + if state.config_version != sync.config_version { + let active_version = state.config_version; + active = Some(state); + if output_sender + .send(WatcherOutput::Failed { + config_version: sync.config_version, + failure: loader::WatcherFailure::Protocol { + error: format!( + "watcher sync generation {0} does not match active generation {active_version}", + sync.config_version + ), + }, + }) + .is_err() + { + return; + } + continue; + } + + if let Err(failure) = reconcile_watch_coverage( + sync.config_version, + &mut state, + sync.targets, + &output_sender, + &mut create, + ) { + if output_sender + .send(WatcherOutput::Failed { + config_version: sync.config_version, + failure, + }) + .is_err() + { + return; + } + continue; + } + + active = Some(state); + if output_sender + .send(WatcherOutput::Synced { + config_version: sync.config_version, + coverage_revision: sync.coverage_revision, + }) + .is_err() + { + return; + } + } + WatcherCommand::Abort { through_config_version } => { + let active_version = active.as_ref().map(|state| state.config_version); + if active_version.is_some_and(|version| version <= through_config_version) { + active = None; + tracing::debug!( + through_config_version, + ?active_version, + "server file watcher generation aborted" + ); + } else { + tracing::debug!( + through_config_version, + ?active_version, + "watcher abort did not match the active generation" + ); + } + } + } + } +} + +fn reconcile_watch_coverage( + config_version: u32, + state: &mut ActiveWatcher, + targets: Vec, + output_sender: &Sender, + create: &mut F, +) -> Result<(), loader::WatcherFailure> +where + W: Watcher, + F: FnMut(u32, Sender) -> ::notify::Result, +{ + let desired_targets = targets + .into_iter() + .map(|target| (target.path, target.registration_revision)) + .collect::>(); + + let paths_to_remove = state + .installed_targets + .iter() + .filter(|(path, revision)| desired_targets.get(*path) != Some(*revision)) + .map(|(path, _)| path.clone()) + .sorted() + .collect_vec(); + if let Some(watcher) = &mut state.watcher { + for path in &paths_to_remove { + tracing::debug!(config_version, %path, "server file watch removal started"); + match watcher.unwatch(path.as_ref()) { + Ok(()) => { + tracing::debug!(config_version, %path, "server file watch removal finished"); + } + Err(error) + if matches!(error.kind, ErrorKind::PathNotFound | ErrorKind::WatchNotFound) => + { + tracing::debug!( + config_version, + %path, + %error, + "server file watch was already removed by the backend" + ); + } + Err(error) => { + return Err(loader::WatcherFailure::Unwatch { + path: path.clone(), + error: error.to_string(), + }); + } + } + } + } + for path in paths_to_remove { + state.installed_targets.remove(&path); + } + + let targets_to_add = desired_targets + .iter() + .filter(|(path, revision)| state.installed_targets.get(*path) != Some(*revision)) + .map(|(path, revision)| WatchTarget { + path: path.clone(), + registration_revision: *revision, + }) + .sorted_by(|left, right| left.path.cmp(&right.path)) + .collect_vec(); + if !targets_to_add.is_empty() && state.watcher.is_none() { + state.watcher = Some(create_watcher(config_version, output_sender, create)?); + } + if let Some(watcher) = &mut state.watcher { + let paths = targets_to_add.iter().map(|target| target.path.clone()).collect_vec(); + watch_paths(config_version, watcher, &paths)?; + } + for target in targets_to_add { + state.installed_targets.insert(target.path, target.registration_revision); + } + + Ok(()) +} + +fn create_watcher( + config_version: u32, + output_sender: &Sender, + create: &mut F, +) -> Result +where + F: FnMut(u32, Sender) -> ::notify::Result, +{ + tracing::debug!(config_version, "server file watcher creation started"); + let watcher = create(config_version, output_sender.clone()) + .map_err(|error| loader::WatcherFailure::Create { error: error.to_string() })?; + tracing::debug!(config_version, "server file watcher creation finished"); + Ok(watcher) +} + +fn watch_paths( + config_version: u32, + watcher: &mut W, + paths: &[AbsPathBuf], +) -> Result<(), loader::WatcherFailure> { + for path in paths { + tracing::debug!(config_version, %path, "server file watch registration started"); + if let Err(error) = watcher.watch(path.as_ref(), RecursiveMode::NonRecursive) { + return Err(loader::WatcherFailure::Watch { + path: path.clone(), + error: error.to_string(), + }); + } + tracing::debug!(config_version, %path, "server file watch registration finished"); + } + Ok(()) } impl NotifyActor { fn new(sender: loader::Sender) -> NotifyActor { + let watcher_manager = match spawn_watcher_manager() { + Ok(manager) => Some(manager), + Err(error) => { + tracing::error!(%error, "failed to spawn server file watcher manager"); + None + } + }; + Self::new_with_manager(sender, watcher_manager) + } + + fn new_with_manager( + sender: loader::Sender, + watcher_manager: Option, + ) -> NotifyActor { NotifyActor { sender, config_version: 0, watched_files: FxHashSet::default(), watched_dirs: Vec::new(), + watch_coverage: WatchCoverage::default(), + watcher_phase: WatcherPhase::Unconfigured, loaded_paths: FxHashSet::default(), - watcher: None, + watcher_manager, } } fn next_event(&self, receiver: &Receiver) -> Option { - let Some((_, watcher_receiver)) = &self.watcher else { + let Some(watcher_manager) = &self.watcher_manager else { return receiver.recv().ok().map(Event::ServerMsg); }; select! { recv(receiver) -> it => it.ok().map(Event::ServerMsg), - recv(watcher_receiver) -> it => it.ok().map(Event::NotifyEvent), + recv(watcher_manager.output_receiver) -> it => Some(match it { + Ok(output) => Event::WatcherOutput(output), + Err(_) => Event::WatcherManagerStopped, + }), } } @@ -208,113 +709,14 @@ impl NotifyActor { match event { Event::ServerMsg(msg) => match msg { ServerMsg::Config(config) => { - self.watcher = None; - if !config.to_watch.is_empty() { - let (watcher_sender, watcher_receiver) = unbounded(); - let watcher = log_notify_error(RecommendedWatcher::new( - move |event| { - if watcher_sender.send(event).is_err() { - tracing::debug!( - "notify event dropped because watcher receiver is closed" - ); - } - }, - Config::default(), - )); - self.watcher = watcher.map(|it| (it, watcher_receiver)); - } - - let config_version = config.version; - self.config_version = config_version; - let has_reconcile_step = !self.loaded_paths.is_empty(); - let n_entries = config.to_load.len(); - let n_total = n_entries + usize::from(has_reconcile_step); - if n_total > 0 { - self.send(loader::Message::Progress { - n_total, - n_done: 0, - config_version, - }); - } - - self.watched_files.clear(); - self.watched_dirs.clear(); - let previous_loaded_paths = mem::take(&mut self.loaded_paths); - - let (entry_tx, entry_rx) = unbounded(); - let (watch_tx, watch_rx) = unbounded(); - let (loaded_tx, loaded_rx) = unbounded(); - let processed = AtomicUsize::new(0); - - config.to_load.into_par_iter().enumerate().for_each(|(i, entry)| { - let do_watch = config.to_watch.contains(&i); - if do_watch && entry_tx.send(entry.clone()).is_err() { - tracing::debug!("watched entry dropped because receiver is closed"); - } - let files = Self::load_entry(&watch_tx, entry, do_watch); - let reported_paths = - files.iter().map(|(path, _)| path.clone()).collect_vec(); - let loaded_paths = files - .iter() - .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) - .map(|(path, _)| path.clone()) - .collect_vec(); - if loaded_tx.send((reported_paths, loaded_paths)).is_err() { - tracing::debug!( - "loaded path batch dropped because receiver is closed" - ); - } - self.send(loader::Message::Loaded { files, config_version }); - self.send(loader::Message::Progress { - n_total, - n_done: 1 + processed - .fetch_add(1, std::sync::atomic::Ordering::AcqRel), - config_version, - }); - }); - - drop(loaded_tx); - let mut reported_paths = FxHashSet::default(); - let mut loaded_paths = FxHashSet::default(); - for (reported, loaded) in loaded_rx { - reported_paths.extend(reported); - loaded_paths.extend(loaded); - } - - drop(watch_tx); - for path in watch_rx { - self.watch(&path); - } - - drop(entry_tx); - for entry in entry_rx { - match entry { - loader::Entry::Files(files) => self.watched_files.extend(files), - loader::Entry::Directories(dir) => self.watched_dirs.push(dir), - } - } - - let unloaded = previous_loaded_paths - .difference(&reported_paths) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec(); - self.loaded_paths = loaded_paths; - if !unloaded.is_empty() { - self.send(loader::Message::Loaded { files: unloaded, config_version }); - } - if has_reconcile_step { - self.send(loader::Message::Progress { - n_total, - n_done: n_total, - config_version, - }); - } else if n_total == 0 { - self.send(loader::Message::Progress { - n_total, - n_done: 0, - config_version, - }); + self.watcher_phase = WatcherPhase::Unconfigured; + if let Some(watch_plan) = self.load_and_reconcile(config) { + self.watcher_phase = WatcherPhase::Installing { + coverage_revision: watch_plan.coverage_revision, + }; + self.send_watcher_command(WatcherCommand::Replace(watch_plan)); + } else { + self.watcher_phase = WatcherPhase::Failed; } } ServerMsg::Invalidate(path) => { @@ -327,44 +729,391 @@ impl NotifyActor { }); } }, - Event::NotifyEvent(event) => { - let Some(event) = log_notify_error(event) else { - continue; - }; - - let files = self.process_notify_event(event); - self.record_loaded_files(&files); - self.send(loader::Message::Changed { - files, - config_version: self.config_version, + Event::WatcherOutput(output) => self.handle_watcher_output(output), + Event::WatcherManagerStopped => { + self.watcher_manager = None; + self.fail_watcher_generation(loader::WatcherFailure::Stopped { + error: "server file watcher manager stopped".to_owned(), }); } } } } - fn process_notify_event(&mut self, event: ::notify::Event) -> Vec<(AbsPathBuf, LoadResult)> { + fn load_and_reconcile(&mut self, config: loader::Config) -> Option { + let config_version = config.version; + self.config_version = config_version; + let has_reconcile_step = !self.loaded_paths.is_empty(); + let n_entries = config.to_load.len(); + let n_total = n_entries + usize::from(has_reconcile_step); + if n_total > 0 { + self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); + } + + self.watched_files.clear(); + self.watched_dirs.clear(); + let previous_loaded_paths = mem::take(&mut self.loaded_paths); + let (entry_tx, entry_rx) = unbounded(); + let (watch_tx, watch_rx) = unbounded(); + let (loaded_tx, loaded_rx) = unbounded(); + let (scan_failure_tx, scan_failure_rx) = unbounded(); + let processed = AtomicUsize::new(0); + + config.to_load.into_par_iter().enumerate().for_each(|(i, entry)| { + let do_watch = config.to_watch.contains(&i); + if do_watch && entry_tx.send(entry.clone()).is_err() { + tracing::debug!("watched entry dropped because receiver is closed"); + } + let files = match Self::load_entry(&watch_tx, entry, do_watch) { + Ok(files) => files, + Err(failure) => { + if scan_failure_tx.send(failure).is_err() { + tracing::debug!("scan failure dropped because receiver is closed"); + } + Vec::new() + } + }; + let reported_paths = files.iter().map(|(path, _)| path.clone()).collect_vec(); + let loaded_paths = files + .iter() + .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) + .map(|(path, _)| path.clone()) + .collect_vec(); + if loaded_tx.send((reported_paths, loaded_paths)).is_err() { + tracing::debug!("loaded path batch dropped because receiver is closed"); + } + self.send(loader::Message::Loaded { files, config_version }); + let n_done = 1 + processed.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + if n_done < n_total { + self.send(loader::Message::Progress { n_total, n_done, config_version }); + } + }); + drop(scan_failure_tx); + + drop(loaded_tx); + let mut reported_paths = FxHashSet::default(); + let mut loaded_paths = FxHashSet::default(); + for (reported, loaded) in loaded_rx { + reported_paths.extend(reported); + loaded_paths.extend(loaded); + } + + drop(entry_tx); + for entry in entry_rx { + match entry { + loader::Entry::Files(files) => self.watched_files.extend(files), + loader::Entry::Directories(dir) => self.watched_dirs.push(dir), + } + } + + drop(watch_tx); + let scan_failures = scan_failure_rx.into_iter().collect_vec(); + if !scan_failures.is_empty() { + let mut retained_paths = previous_loaded_paths; + retained_paths.extend(loaded_paths); + self.loaded_paths = retained_paths; + self.fail_scan_generation(scan_failures); + self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); + return None; + } + + let unloaded = previous_loaded_paths + .difference(&reported_paths) + .cloned() + .map(|path| (path, LoadResult::LoadError)) + .collect_vec(); + self.loaded_paths = loaded_paths; + if !unloaded.is_empty() { + self.send(loader::Message::Loaded { files: unloaded, config_version }); + } + self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); + + let paths: Vec = + watch_rx.into_iter().collect::>().into_iter().sorted().collect(); + self.watch_coverage.replace(paths); + Some(WatchPlan { + config_version, + coverage_revision: self.watch_coverage.revision, + targets: self.watch_coverage.snapshot(), + }) + } + + fn handle_watcher_output(&mut self, output: WatcherOutput) { + if output.config_version() != self.config_version { + return; + } + + match output { + WatcherOutput::Installed { config_version, coverage_revision } => { + let expected = matches!( + self.watcher_phase, + WatcherPhase::Installing { coverage_revision: expected } + if expected == coverage_revision + ); + if expected { + self.rescan_and_converge_watcher(config_version, coverage_revision); + } else { + self.fail_watcher_generation(loader::WatcherFailure::Protocol { + error: format!( + "unexpected watcher installation acknowledgement for coverage revision {coverage_revision} while in phase {:?}", + self.watcher_phase + ), + }); + } + } + WatcherOutput::Synced { config_version, coverage_revision } => { + let expected = matches!( + self.watcher_phase, + WatcherPhase::Syncing { coverage_revision: expected_revision } + if expected_revision == coverage_revision + ); + if expected { + self.rescan_and_converge_watcher(config_version, coverage_revision); + } else { + self.fail_watcher_generation(loader::WatcherFailure::Protocol { + error: format!( + "unexpected watcher sync acknowledgement for coverage revision {coverage_revision} while in phase {:?}", + self.watcher_phase + ), + }); + } + } + WatcherOutput::Notify { config_version, event } => { + if matches!(self.watcher_phase, WatcherPhase::Failed) { + return; + } + let revision_before_event = self.watch_coverage.revision; + let files = match self.process_notify_event(event) { + Ok(files) => files, + Err(failure) => { + self.fail_actor_generation(failure); + return; + } + }; + self.record_loaded_files(&files); + self.send(loader::Message::Changed { files, config_version }); + if self.watch_coverage.revision != revision_before_event + && matches!(self.watcher_phase, WatcherPhase::Ready) + { + self.begin_watcher_sync(); + } + } + WatcherOutput::Failed { failure, .. } => { + self.fail_watcher_generation(failure); + } + } + } + + fn rescan_and_converge_watcher(&mut self, config_version: u32, acknowledged_revision: u64) { + let files = match self.rescan_watched_scope() { + Ok(files) => files, + Err(failure) => { + self.fail_scan_generation(vec![failure]); + return; + } + }; + self.send(loader::Message::Changed { files, config_version }); + if self.watch_coverage.revision == acknowledged_revision { + self.watcher_phase = WatcherPhase::Ready; + self.send_watcher_status(loader::WatcherStatus::Ready { config_version }); + } else { + self.begin_watcher_sync(); + } + } + + fn rescan_watched_scope( + &mut self, + ) -> Result, loader::ScanFailure> { + let mut files = self + .watched_files + .iter() + .cloned() + .map(|path| { + let result = read(&path); + (path, result) + }) + .collect_vec(); + let (watch_tx, watch_rx) = unbounded(); + for file in &self.watched_files { + let anchor = nearest_existing_parent_anchor(file)?; + if watch_tx.send(anchor).is_err() { + tracing::debug!("watched file anchor dropped because receiver is closed"); + } + } + for dirs in self.watched_dirs.clone() { + for root in dirs.include_roots() { + files.extend(Self::load_directory_subtree(&watch_tx, &dirs, root, true)?); + } + } + drop(watch_tx); + + let reported_paths = files.iter().map(|(path, _)| path.clone()).collect::>(); + files.extend( + self.loaded_paths + .iter() + .filter(|path| self.is_watched_file(path) && !reported_paths.contains(*path)) + .cloned() + .map(|path| (path, LoadResult::LoadError)), + ); + self.record_loaded_files(&files); + self.watch_coverage.reconcile(watch_rx.into_iter().collect()); + Ok(files) + } + + fn begin_watcher_sync(&mut self) { + let sync = WatchSync { + config_version: self.config_version, + coverage_revision: self.watch_coverage.revision, + targets: self.watch_coverage.snapshot(), + }; + self.watcher_phase = WatcherPhase::Syncing { coverage_revision: sync.coverage_revision }; + self.send_watcher_command(WatcherCommand::Sync(sync)); + } + + fn send_watcher_command(&mut self, command: WatcherCommand) { + let config_version = match &command { + WatcherCommand::Replace(plan) => plan.config_version, + WatcherCommand::Sync(sync) => sync.config_version, + WatcherCommand::Abort { through_config_version } => *through_config_version, + }; + let sent = self + .watcher_manager + .as_ref() + .is_some_and(|manager| manager.command_sender.send(command).is_ok()); + if sent { + return; + } + + self.watcher_manager = None; + debug_assert_eq!(config_version, self.config_version); + self.fail_watcher_generation(loader::WatcherFailure::Stopped { + error: "server file watcher manager is unavailable".to_owned(), + }); + } + + fn fail_watcher_generation(&mut self, failure: loader::WatcherFailure) { + if !self.enter_failed_watcher_phase() { + return; + } + self.send_watcher_status(loader::WatcherStatus::Failed { + config_version: self.config_version, + failure, + }); + } + + fn fail_scan_generation(&mut self, failures: Vec) { + assert!(!failures.is_empty(), "a failed scan must report at least one cause"); + if !self.enter_failed_watcher_phase() { + return; + } + for failure in failures { + self.send(loader::Message::ScanFailed { config_version: self.config_version, failure }); + } + } + + fn fail_actor_generation(&mut self, failure: ActorFailure) { + match failure { + ActorFailure::Watcher(failure) => self.fail_watcher_generation(failure), + ActorFailure::Scan(failure) => self.fail_scan_generation(vec![failure]), + } + } + + fn enter_failed_watcher_phase(&mut self) -> bool { + if matches!(self.watcher_phase, WatcherPhase::Failed) { + return false; + } + + self.watcher_phase = WatcherPhase::Failed; + self.abort_watcher_generation(); + true + } + + fn abort_watcher_generation(&mut self) { + let command = WatcherCommand::Abort { through_config_version: self.config_version }; + let sent = self + .watcher_manager + .as_ref() + .is_some_and(|manager| manager.command_sender.send(command).is_ok()); + if sent || self.watcher_manager.is_none() { + return; + } + + tracing::error!( + config_version = self.config_version, + "failed to abort server file watcher generation because the manager stopped" + ); + self.watcher_manager = None; + } + + fn send_watcher_status(&self, status: loader::WatcherStatus) { + self.send(loader::Message::WatcherStatus(status)); + } + + fn process_notify_event( + &mut self, + event: ::notify::Event, + ) -> Result, ActorFailure> { + if event.need_rescan() { + return self.rescan_watched_scope().map_err(ActorFailure::from); + } + if !(event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()) { - return Vec::new(); + return Ok(Vec::new()); } + let removal_path_count = match event.kind { + kind if kind.is_remove() => event.paths.len(), + ::notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) => event.paths.len(), + ::notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)) + if event.paths.len() == 2 => + { + 1 + } + ::notify::EventKind::Modify(ModifyKind::Name( + RenameMode::Any | RenameMode::Other | RenameMode::Both, + )) => return self.rescan_watched_scope().map_err(ActorFailure::from), + _ => 0, + }; + let mut files = Vec::new(); - for path in event.paths.into_iter().filter_map(|path| AbsPathBuf::try_from(path).ok()) { - if event.kind.is_remove() { - let unloaded = self.unload_removed_path(&path); - if !unloaded.is_empty() { - files.extend(unloaded); - continue; + for (index, raw_path) in event.paths.into_iter().enumerate() { + let path = AbsPathBuf::try_from(raw_path.clone()).map_err(|_| { + loader::WatcherFailure::Notify { + error: format!( + "notify returned a non-absolute or non-UTF-8 path: {raw_path:?}" + ), } + })?; + let is_removal_path = index < removal_path_count; + if is_removal_path { + self.watch_coverage.invalidate_prefix(&path); + files.extend(self.unload_removed_path(&path)); } - let metadata = fs::metadata(&path).ok(); + let metadata = match fs::metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(loader::WatcherFailure::Notify { + error: format!("failed to inspect notify path {path}: {error}"), + } + .into()); + } + }; + if is_removal_path && metadata.is_none() { + continue; + } let file_type = metadata.as_ref().map(|meta| meta.file_type()); let is_file = file_type.as_ref().is_some_and(|it| it.is_file()); let is_dir = file_type.as_ref().is_some_and(|it| it.is_dir()); if is_dir && self.is_watched_dir(&path) { - files.extend(self.load_created_directory(&path)); + files.extend(self.load_created_directory(&path)?); + continue; + } + + if is_dir && self.is_ancestor_of_watched_target(&path) { + files.extend(self.rescan_watched_scope()?); continue; } @@ -379,7 +1128,7 @@ impl NotifyActor { files.push((path.clone(), read(&path))); } - files + Ok(files) } fn is_watched_dir(&self, path: &AbsPathBuf) -> bool { @@ -391,21 +1140,34 @@ impl NotifyActor { || self.watched_dirs.iter().any(|dir| dir.contains_file(path)) } - fn load_created_directory(&mut self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { + fn is_ancestor_of_watched_target(&self, path: &AbsPath) -> bool { + self.watched_files.iter().any(|file| file.starts_with(path)) + || self + .watched_dirs + .iter() + .flat_map(|directories| directories.include_roots()) + .any(|root| root.starts_with(path)) + } + + fn load_created_directory( + &mut self, + path: &AbsPathBuf, + ) -> Result, loader::ScanFailure> { let dirs = self.watched_dirs.iter().filter(|dir| dir.contains_dir(path)).cloned().collect_vec(); let mut files = Vec::new(); + let mut watch_paths = FxHashSet::default(); for dir in dirs { let (watch_tx, watch_rx) = unbounded(); - files.extend(Self::load_directory_subtree(&watch_tx, &dir, path, true)); + files.extend(Self::load_directory_subtree(&watch_tx, &dir, path, true)?); drop(watch_tx); - for path in watch_rx { - self.watch(&path); - } + watch_paths.extend(watch_rx); } - files + self.watch_coverage.add(watch_paths); + + Ok(files) } fn unload_removed_path(&self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { @@ -421,25 +1183,31 @@ impl NotifyActor { watch_tx: &Sender, entry: loader::Entry, watch: bool, - ) -> Vec<(AbsPathBuf, LoadResult)> { + ) -> Result, loader::ScanFailure> { match entry { - loader::Entry::Files(files) => files - .into_iter() - .map(|file| { - if watch && watch_tx.send(file.to_owned()).is_err() { - tracing::debug!("watched file path dropped because receiver is closed"); + loader::Entry::Files(files) => { + let mut loaded = Vec::with_capacity(files.len()); + for file in files { + if watch { + let anchor = nearest_existing_parent_anchor(&file)?; + if watch_tx.send(anchor).is_err() { + tracing::debug!( + "watched file anchor dropped because receiver is closed" + ); + } } let contents = read(file.as_path()); - (file, contents) - }) - .collect_vec(), + loaded.push((file, contents)); + } + Ok(loaded) + } loader::Entry::Directories(dirs) => { let mut res = Vec::new(); for root in dirs.include_roots() { - res.extend(Self::load_directory_subtree(watch_tx, &dirs, root, watch)); + res.extend(Self::load_directory_subtree(watch_tx, &dirs, root, watch)?); } - res + Ok(res) } } } @@ -449,49 +1217,52 @@ impl NotifyActor { dirs: &loader::Directories, root: &AbsPathBuf, watch: bool, - ) -> Vec<(AbsPathBuf, LoadResult)> { - let walkdir = WalkDir::new(root).follow_links(true).into_iter().filter_entry(|entry| { - if !entry.file_type().is_dir() { - return true; + ) -> Result, loader::ScanFailure> { + if watch { + let anchor = nearest_existing_parent_anchor(root)?; + if watch_tx.send(anchor).is_err() { + tracing::debug!("watched directory anchor dropped because receiver is closed"); } - let Ok(path) = AbsPathBuf::try_from(entry.path().to_path_buf()) else { - return false; - }; - root == &path || dirs.contains_dir(&path) - }); + } - let files = walkdir.filter_map(|it| it.ok()).filter_map(|entry| { + let mut files = Vec::new(); + let mut walkdir = WalkDir::new(root).follow_links(true).into_iter(); + while let Some(entry) = walkdir.next() { + let entry = match entry { + Ok(entry) => entry, + Err(error) if walkdir_error_is_not_found(&error) => { + tracing::debug!(%error, %root, "directory disappeared while it was scanned"); + continue; + } + Err(error) => return Err(scan_failure(root, error.to_string())), + }; let is_dir = entry.file_type().is_dir(); let is_file = entry.file_type().is_file(); - let abs_path = AbsPathBuf::try_from(entry.into_path()).ok()?; + let raw_path = entry.into_path(); + let abs_path = absolute_scan_path(root, raw_path)?; + + if is_dir && root != &abs_path && !dirs.contains_dir(&abs_path) { + walkdir.skip_current_dir(); + continue; + } if is_dir && watch && watch_tx.send(abs_path.to_owned()).is_err() { tracing::debug!("watched directory path dropped because receiver is closed"); } if !is_file { - return None; + continue; } if !dirs.contains_file(&abs_path) { - return None; + continue; } - Some(abs_path) - }); - - files - .map(|file| { - let contents = read(file.as_path()); - (file, contents) - }) - .collect_vec() - } - - fn watch(&mut self, path: &AbsPathBuf) { - if let Some((watcher, _)) = &mut self.watcher { - log_notify_error(watcher.watch(path.as_ref(), RecursiveMode::NonRecursive)); + let contents = read(abs_path.as_path()); + files.push((abs_path, contents)); } + + Ok(files) } fn record_loaded_files(&mut self, files: &[(AbsPathBuf, LoadResult)]) { @@ -512,6 +1283,42 @@ impl NotifyActor { } } +fn nearest_existing_parent_anchor(target: &AbsPath) -> Result { + let mut candidate = target.parent().unwrap_or(target).to_owned(); + loop { + match fs::metadata(&candidate) { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let Some(parent) = candidate.parent() else { + return Err(scan_failure(target, error.to_string())); + }; + candidate = parent.to_owned(); + } + Err(error) => return Err(scan_failure(target, error.to_string())), + } + } +} + +fn walkdir_error_is_not_found(error: &walkdir::Error) -> bool { + error.io_error().is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) +} + +fn absolute_scan_path( + root: &AbsPath, + raw_path: std::path::PathBuf, +) -> Result { + AbsPathBuf::try_from(raw_path.clone()).map_err(|_| { + scan_failure( + root, + format!("directory scan returned a non-absolute or non-UTF-8 path: {raw_path:?}"), + ) + }) +} + +fn scan_failure(root: &AbsPath, error: String) -> loader::ScanFailure { + loader::ScanFailure { root: root.to_owned(), error } +} + fn read(path: &AbsPath) -> LoadResult { let Ok(bytes) = std::fs::read(path) else { return LoadResult::LoadError; @@ -523,17 +1330,16 @@ fn read(path: &AbsPath) -> LoadResult { LoadResult::Loaded(text, ending) } -fn log_notify_error(res: ::notify::Result) -> Option { - res.map_err(|err| tracing::warn!("notify error: {}", err)).ok() -} - #[cfg(test)] mod tests { - use std::time::Duration; + use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }; use ::notify::{ - Event as NotifyEvent, EventKind, - event::{CreateKind, RemoveKind}, + Event as NotifyEvent, EventHandler, EventKind, WatcherKind, + event::{CreateKind, Flag, RemoveKind}, }; use utils::paths::AbsPathBuf; @@ -543,9 +1349,86 @@ mod tests { loader::{self, Handle as _}, }; - struct TestDir { - _dir: tempfile::TempDir, - path: AbsPathBuf, + struct TestDir { + _dir: tempfile::TempDir, + path: AbsPathBuf, + } + + struct RejectingWatcher; + + #[derive(Default)] + struct RecordedWatchCalls { + watched: Vec, + unwatched: Vec, + drops: usize, + } + + struct RecordingWatcher { + calls: Arc>, + } + + impl Drop for RecordingWatcher { + fn drop(&mut self) { + self.calls.lock().unwrap().drops += 1; + } + } + + impl Watcher for RejectingWatcher { + fn new(_event_handler: F, _config: Config) -> ::notify::Result + where + Self: Sized, + { + unreachable!("constructed directly by watcher-manager tests") + } + + fn watch( + &mut self, + _path: &std::path::Path, + _recursive_mode: RecursiveMode, + ) -> ::notify::Result<()> { + Err(::notify::Error::generic("injected watch failure")) + } + + fn unwatch(&mut self, _path: &std::path::Path) -> ::notify::Result<()> { + Ok(()) + } + + fn kind() -> WatcherKind + where + Self: Sized, + { + WatcherKind::NullWatcher + } + } + + impl Watcher for RecordingWatcher { + fn new(_event_handler: F, _config: Config) -> ::notify::Result + where + Self: Sized, + { + unreachable!("constructed directly by watcher-manager tests") + } + + fn watch( + &mut self, + path: &std::path::Path, + _recursive_mode: RecursiveMode, + ) -> ::notify::Result<()> { + self.calls.lock().unwrap().watched.push(path.to_path_buf()); + Ok(()) + } + + fn unwatch(&mut self, path: &std::path::Path) -> ::notify::Result<()> { + self.calls.lock().unwrap().unwatched.push(path.to_path_buf()); + Ok(()) + } + + fn kind() -> WatcherKind + where + Self: Sized, + { + WatcherKind::NullWatcher + } } impl TestDir { @@ -586,11 +1469,18 @@ mod tests { match &message { loader::Message::Loaded { config_version, .. } | loader::Message::Changed { config_version, .. } + | loader::Message::ScanFailed { config_version, .. } | loader::Message::Progress { config_version, .. } if *config_version == version => { return message; } + loader::Message::WatcherStatus( + loader::WatcherStatus::Ready { config_version } + | loader::WatcherStatus::Failed { config_version, .. }, + ) if *config_version == version => { + return message; + } _ => {} } } @@ -629,6 +1519,982 @@ mod tests { NotifyActor::new(sender) } + struct FakeWatcherHarness { + server: Sender, + loader: Receiver, + commands: Receiver, + output: Sender, + actor_thread: std::thread::JoinHandle<()>, + } + + fn spawn_actor_with_fake_manager() -> FakeWatcherHarness { + let (loader_sender, loader_receiver) = unbounded(); + let (command_sender, command_receiver) = unbounded(); + let (output_sender, output_receiver) = unbounded(); + let manager = WatcherManagerHandle { command_sender, output_receiver }; + let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); + let (server_sender, server_receiver) = unbounded(); + let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); + FakeWatcherHarness { + server: server_sender, + loader: loader_receiver, + commands: command_receiver, + output: output_sender, + actor_thread, + } + } + + fn acknowledge_command(command: &WatcherCommand, output: &Sender) { + let acknowledgement = match command { + WatcherCommand::Replace(plan) => WatcherOutput::Installed { + config_version: plan.config_version, + coverage_revision: plan.coverage_revision, + }, + WatcherCommand::Sync(sync) => WatcherOutput::Synced { + config_version: sync.config_version, + coverage_revision: sync.coverage_revision, + }, + WatcherCommand::Abort { .. } => { + panic!("abort commands do not have acknowledgements") + } + }; + output.send(acknowledgement).unwrap(); + } + + fn target_paths(targets: &[WatchTarget]) -> Vec { + targets.iter().map(|target| target.path.clone()).collect() + } + + #[test] + fn stalled_watcher_manager_does_not_delay_content_terminal() { + let dir = TestDir::new("vfs-loader-stalled-watcher-manager"); + let first = dir.join("first.sv"); + let second = dir.join("second.sv"); + std::fs::write(&first, "module first; endmodule\n").unwrap(); + std::fs::write(&second, "module second; endmodule\n").unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![first.clone()])], + to_watch: vec![0], + })) + .unwrap(); + assert_loaded(&collect_until_progress_done(&loader, 1), &first); + assert!(matches!( + commands.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherCommand::Replace(WatchPlan { config_version: 1, .. }) + )); + + server + .send(ServerMsg::Config(loader::Config { + version: 2, + to_load: vec![loader::Entry::Files(vec![second.clone()])], + to_watch: vec![0], + })) + .unwrap(); + assert_loaded(&collect_until_progress_done(&loader, 2), &second); + assert!(matches!( + commands.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherCommand::Replace(WatchPlan { config_version: 2, .. }) + )); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn watcher_install_ack_rescans_scan_install_gap() { + let dir = TestDir::new("vfs-loader-watch-install-gap"); + let root = dir.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let WatcherCommand::Replace(plan) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected watcher replacement"); + }; + let initial_targets = target_paths(&plan.targets); + assert!(initial_targets.contains(&root)); + assert!(initial_targets.contains(&root.parent().unwrap().to_owned())); + + let created_dir = root.join("generated"); + let created_file = created_dir.join("new.sv"); + std::fs::create_dir_all(&created_dir).unwrap(); + std::fs::write(&created_file, "module new; endmodule\n").unwrap(); + output + .send(WatcherOutput::Installed { + config_version: 1, + coverage_revision: plan.coverage_revision, + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected gap-closing rescan"); + }; + assert!(files.iter().any(|(path, result)| { + path == &created_file + && matches!(result, LoadResult::Loaded(text, _) if text.contains("module new")) + })); + let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected watches for directories created during installation"); + }; + assert_eq!(sync.config_version, 1); + assert!(target_paths(&sync.targets).contains(&created_dir)); + + acknowledge_command(&WatcherCommand::Sync(sync), &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn unexpected_watcher_ack_fails_the_generation() { + let dir = TestDir::new("vfs-loader-unexpected-watcher-ack"); + let root = dir.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let WatcherCommand::Replace(plan) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected watcher replacement"); + }; + + output + .send(WatcherOutput::Installed { + config_version: 1, + coverage_revision: plan.coverage_revision + 1, + }) + .unwrap(); + + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Failed { + config_version: 1, + failure: loader::WatcherFailure::Protocol { error }, + }) if error.contains("unexpected watcher installation acknowledgement") + )); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn watcher_failure_is_terminal_for_the_current_generation() { + let dir = TestDir::new("vfs-loader-terminal-watcher-failure"); + let root = dir.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + + let created_dir = root.join("generated"); + std::fs::create_dir_all(&created_dir).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + + output + .send(WatcherOutput::Failed { + config_version: 1, + failure: loader::WatcherFailure::Notify { error: "backend failed".to_owned() }, + }) + .unwrap(); + acknowledge_command(&sync, &output); + + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Failed { + config_version: 1, + failure: loader::WatcherFailure::Notify { error }, + }) if error == "backend failed" + )); + while let Ok(message) = loader.recv_timeout(Duration::from_millis(100)) { + assert!( + !matches!( + message, + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { + config_version: 1 + }) + ), + "failed watcher generation must never become ready" + ); + } + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn rescan_flag_reconciles_missed_files_and_watch_coverage() { + let dir = TestDir::new("vfs-loader-rescan-flag"); + let root = dir.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + let missed_dir = root.join("generated"); + let missed_file = missed_dir.join("new.sv"); + std::fs::create_dir_all(&missed_dir).unwrap(); + std::fs::write(&missed_file, "module new; endmodule\n").unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Other).set_flag(Flag::Rescan), + }) + .unwrap(); + + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected a full rescan after the backend rescan flag"); + }; + assert!(files.iter().any(|(path, result)| { + path == &missed_file + && matches!(result, LoadResult::Loaded(text, _) if text.contains("module new")) + })); + let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected missed directory coverage to be synchronized"); + }; + assert!(target_paths(&sync.targets).contains(&missed_dir)); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn rename_from_unloads_directory_and_removes_watch_coverage() { + let dir = TestDir::new("vfs-loader-rename-from"); + let root = dir.join("workspace"); + let moved_from = root.join("generated"); + let moved_file = moved_from.join("old.sv"); + let moved_to = dir.join("outside"); + std::fs::create_dir_all(&moved_from).unwrap(); + std::fs::write(&moved_file, "module old; endmodule\n").unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root))], + to_watch: vec![0], + })) + .unwrap(); + assert_loaded(&collect_until_progress_done(&loader, 1), &moved_file); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::rename(&moved_from, &moved_to).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Modify(ModifyKind::Name(RenameMode::From))) + .add_path(path_buf(&moved_from)), + }) + .unwrap(); + + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected moved directory contents to be unloaded"); + }; + assert_eq!(files, vec![(moved_file, LoadResult::LoadError)]); + let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected moved directory coverage to be removed"); + }; + assert!(!target_paths(&sync.targets).contains(&moved_from)); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn watcher_sync_has_only_one_mutation_in_flight() { + let dir = TestDir::new("vfs-loader-single-watch-batch"); + let root = dir.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + let first = root.join("first"); + std::fs::create_dir_all(&first).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&first)), + }) + .unwrap(); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let first_command = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + let WatcherCommand::Sync(first_sync) = &first_command else { + panic!("expected first full coverage sync"); + }; + assert_eq!(first_sync.config_version, 1); + assert!(target_paths(&first_sync.targets).contains(&first)); + + let second = root.join("second"); + std::fs::create_dir_all(&second).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&second)), + }) + .unwrap(); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!( + commands.recv_timeout(Duration::from_millis(100)).is_err(), + "a second coverage sync was sent before the first was acknowledged" + ); + + acknowledge_command(&first_command, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let WatcherCommand::Sync(second_sync) = + commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected the pending coverage sync after the first acknowledgement"); + }; + assert!(target_paths(&second_sync.targets).contains(&second)); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn recreated_directory_is_registered_again() { + let dir = TestDir::new("vfs-loader-recreated-watch-directory"); + let root = dir.join("workspace"); + let recreated = root.join("generated"); + std::fs::create_dir_all(&root).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::create_dir_all(&recreated).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&recreated)), + }) + .unwrap(); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let initial_add = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&initial_add, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::remove_dir(&recreated).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) + .add_path(path_buf(&recreated)), + }) + .unwrap(); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let removal_sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + + std::fs::create_dir_all(&recreated).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&recreated)), + }) + .unwrap(); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); + acknowledge_command(&removal_sync, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + let WatcherCommand::Sync(reinstall_sync) = + commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected recreated directory coverage sync"); + }; + let recreated_target = reinstall_sync + .targets + .iter() + .find(|target| target.path == recreated) + .expect("recreated directory should be in coverage"); + let original_revision = match initial_add { + WatcherCommand::Sync(sync) => { + sync.targets + .into_iter() + .find(|target| target.path == recreated) + .unwrap() + .registration_revision + } + WatcherCommand::Replace(_) | WatcherCommand::Abort { .. } => unreachable!(), + }; + assert_ne!(recreated_target.registration_revision, original_revision); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn directory_walk_errors_are_all_reported_before_terminal_progress() { + let dir = TestDir::new("vfs-loader-directory-walk-errors"); + let first_root = dir.join("first-workspace"); + let second_root = dir.join("second-workspace"); + for root in [&first_root, &second_root] { + std::fs::create_dir_all(root).unwrap(); + std::os::unix::fs::symlink(root, root.join("loop")).unwrap(); + } + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![ + loader::Entry::Directories(watched_sv_dir(first_root.clone())), + loader::Entry::Directories(watched_sv_dir(second_root.clone())), + ], + to_watch: vec![0, 1], + })) + .unwrap(); + let mut failed_roots = FxHashSet::default(); + loop { + match recv_version_message(&loader, 1) { + loader::Message::ScanFailed { + config_version: 1, + failure: loader::ScanFailure { root: failed_root, error }, + } => { + assert!(!error.is_empty()); + failed_roots.insert(failed_root); + } + loader::Message::Progress { n_done, n_total, .. } if n_done == n_total => { + assert_eq!( + failed_roots, + FxHashSet::from_iter([first_root.clone(), second_root.clone()]), + "every scan failure must be reported before terminal progress" + ); + break; + } + _ => {} + } + } + assert!(matches!( + commands.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherCommand::Abort { through_config_version: 1 } + )); + assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn new_generation_scan_failure_aborts_previous_watcher() { + let dir = TestDir::new("vfs-loader-new-scan-failure-abort"); + let initial_file = dir.join("initial.sv"); + std::fs::write(&initial_file, "module initial; endmodule\n").unwrap(); + let failed_root = dir.join("failed-workspace"); + std::fs::create_dir_all(&failed_root).unwrap(); + std::os::unix::fs::symlink(&failed_root, failed_root.join("loop")).unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![initial_file])], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + server + .send(ServerMsg::Config(loader::Config { + version: 2, + to_load: vec![loader::Entry::Directories(watched_sv_dir(failed_root))], + to_watch: vec![0], + })) + .unwrap(); + let mut failure_seen = false; + loop { + match recv_version_message(&loader, 2) { + loader::Message::ScanFailed { .. } => failure_seen = true, + loader::Message::Progress { n_done, n_total, .. } if n_done == n_total => { + assert!(failure_seen); + break; + } + _ => {} + } + } + assert!(matches!( + commands.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherCommand::Abort { through_config_version: 2 } + )); + assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn invalid_walk_path_is_a_structured_scan_failure() { + let dir = TestDir::new("vfs-loader-invalid-walk-path"); + let root = dir.join("workspace"); + let failure = absolute_scan_path(&root, std::path::PathBuf::from("relative")).unwrap_err(); + assert!(matches!( + failure, + loader::ScanFailure { root: failed_root, error } + if failed_root == root && error.contains("relative") + )); + } + + #[test] + fn manager_reregisters_a_recreated_path_with_a_new_revision() { + let dir = TestDir::new("vfs-loader-manager-reregister"); + let watched_path = dir.join("generated"); + std::fs::create_dir_all(&watched_path).unwrap(); + let calls = Arc::new(Mutex::new(RecordedWatchCalls::default())); + let manager_calls = Arc::clone(&calls); + let manager = spawn_watcher_manager_with::(move |_, _| { + Ok(RecordingWatcher { calls: Arc::clone(&manager_calls) }) + }) + .unwrap(); + + manager + .command_sender + .send(WatcherCommand::Replace(WatchPlan { + config_version: 1, + coverage_revision: 1, + targets: vec![WatchTarget { path: watched_path.clone(), registration_revision: 1 }], + })) + .unwrap(); + assert!(matches!( + manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherOutput::Installed { config_version: 1, coverage_revision: 1 } + )); + + manager + .command_sender + .send(WatcherCommand::Sync(WatchSync { + config_version: 1, + coverage_revision: 2, + targets: vec![WatchTarget { path: watched_path.clone(), registration_revision: 2 }], + })) + .unwrap(); + assert!(matches!( + manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherOutput::Synced { config_version: 1, coverage_revision: 2 } + )); + + let calls = calls.lock().unwrap(); + assert_eq!(calls.watched, vec![path_buf(&watched_path), path_buf(&watched_path)]); + assert_eq!(calls.unwatched, vec![path_buf(&watched_path)]); + } + + #[test] + fn manager_abort_drops_an_older_active_generation() { + let dir = TestDir::new("vfs-loader-manager-abort"); + let watched_path = dir.join("workspace"); + std::fs::create_dir_all(&watched_path).unwrap(); + let calls = Arc::new(Mutex::new(RecordedWatchCalls::default())); + let manager_calls = Arc::clone(&calls); + let manager = spawn_watcher_manager_with::(move |_, _| { + Ok(RecordingWatcher { calls: Arc::clone(&manager_calls) }) + }) + .unwrap(); + + manager + .command_sender + .send(WatcherCommand::Replace(WatchPlan { + config_version: 1, + coverage_revision: 1, + targets: vec![WatchTarget { path: watched_path, registration_revision: 1 }], + })) + .unwrap(); + assert!(matches!( + manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + WatcherOutput::Installed { config_version: 1, coverage_revision: 1 } + )); + + manager.command_sender.send(WatcherCommand::Abort { through_config_version: 2 }).unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while calls.lock().unwrap().drops == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!(calls.lock().unwrap().drops, 1); + } + + #[test] + fn exact_file_uses_parent_anchor_across_delete_and_recreate() { + let dir = TestDir::new("vfs-loader-exact-file-anchor"); + let file = dir.join("top.sv"); + std::fs::write(&file, "module first; endmodule\n").unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![file.clone()])], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + let WatcherCommand::Replace(plan) = &install else { + panic!("expected initial watcher plan"); + }; + assert_eq!(target_paths(&plan.targets), vec![file.parent().unwrap().to_owned()]); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::remove_file(&file).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Remove(RemoveKind::File)) + .add_path(path_buf(&file)), + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected exact file unload"); + }; + assert_eq!(files, vec![(file.clone(), LoadResult::LoadError)]); + + std::fs::write(&file, "module second; endmodule\n").unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::File)) + .add_path(path_buf(&file)), + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected recreated exact file load"); + }; + assert!(matches!( + files.as_slice(), + [(path, LoadResult::Loaded(text, _))] + if path == &file && text.contains("module second") + )); + assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); + + std::fs::remove_file(&file).unwrap(); + std::fs::write(&file, "module third; endmodule\n").unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Remove(RemoveKind::File)) + .add_path(path_buf(&file)), + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected delayed remove to observe the recreated exact file"); + }; + assert!( + files + .iter() + .any(|(path, result)| { path == &file && matches!(result, LoadResult::LoadError) }) + ); + assert!(matches!( + files.iter().rev().find(|(path, _)| path == &file), + Some((_, LoadResult::Loaded(text, _))) if text.contains("module third") + )); + assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn directory_root_uses_parent_anchor_across_delete_and_recreate() { + let dir = TestDir::new("vfs-loader-directory-root-anchor"); + let root = dir.join("workspace"); + let file = root.join("top.sv"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&file, "module first; endmodule\n").unwrap(); + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + let WatcherCommand::Replace(plan) = &install else { + panic!("expected initial watcher plan"); + }; + assert!(target_paths(&plan.targets).contains(&root.parent().unwrap().to_owned())); + acknowledge_command(&install, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::remove_dir_all(&root).unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) + .add_path(path_buf(&root)), + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected directory root unload"); + }; + assert_eq!(files, vec![(file.clone(), LoadResult::LoadError)]); + let removal_sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + let WatcherCommand::Sync(sync) = &removal_sync else { + panic!("expected root removal coverage sync"); + }; + assert!(!target_paths(&sync.targets).contains(&root)); + assert!(target_paths(&sync.targets).contains(&root.parent().unwrap().to_owned())); + acknowledge_command(&removal_sync, &output); + assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) + )); + + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&file, "module second; endmodule\n").unwrap(); + output + .send(WatcherOutput::Notify { + config_version: 1, + event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&root)), + }) + .unwrap(); + let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { + panic!("expected recreated directory root load"); + }; + assert!(files.iter().any(|(path, result)| { + path == &file + && matches!(result, LoadResult::Loaded(text, _) if text.contains("module second")) + })); + let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected recreated directory root coverage sync"); + }; + assert!(target_paths(&sync.targets).contains(&root)); + + drop(server); + drop(output); + actor_thread.join().unwrap(); + } + + #[test] + fn watcher_failure_is_reported_structurally() { + let dir = TestDir::new("vfs-loader-watcher-failure"); + let file = dir.join("top.sv"); + std::fs::write(&file, "module top; endmodule\n").unwrap(); + let manager = spawn_watcher_manager_with::(|_, _| { + Err(::notify::Error::generic("injected creation failure")) + }) + .unwrap(); + let (loader_sender, loader) = unbounded(); + let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); + let (server, server_receiver) = unbounded(); + let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![file])], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Failed { + config_version: 1, + failure: loader::WatcherFailure::Create { error }, + }) if error == "injected creation failure" + )); + + drop(server); + actor_thread.join().unwrap(); + } + + #[test] + fn watcher_path_failure_drops_generation_and_is_reported_structurally() { + let dir = TestDir::new("vfs-loader-watch-path-failure"); + let file = dir.join("top.sv"); + std::fs::write(&file, "module top; endmodule\n").unwrap(); + let manager = + spawn_watcher_manager_with::(|_, _| Ok(RejectingWatcher)).unwrap(); + let (loader_sender, loader) = unbounded(); + let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); + let (server, server_receiver) = unbounded(); + let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); + + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![file.clone()])], + to_watch: vec![0], + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Failed { + config_version: 1, + failure: loader::WatcherFailure::Watch { path, error }, + }) if path.as_path() == file.parent().unwrap() + && error == "injected watch failure" + )); + + drop(server); + actor_thread.join().unwrap(); + } + + #[test] + fn stopped_watcher_manager_is_reported_structurally() { + let FakeWatcherHarness { server, loader, commands, output, actor_thread } = + spawn_actor_with_fake_manager(); + server + .send(ServerMsg::Config(loader::Config { + version: 1, + to_load: Vec::new(), + to_watch: Vec::new(), + })) + .unwrap(); + collect_until_progress_done(&loader, 1); + commands.recv_timeout(Duration::from_secs(1)).unwrap(); + + drop(output); + assert!(matches!( + recv_version_message(&loader, 1), + loader::Message::WatcherStatus(loader::WatcherStatus::Failed { + config_version: 1, + failure: loader::WatcherFailure::Stopped { error }, + }) if error == "server file watcher manager stopped" + )); + + drop(server); + actor_thread.join().unwrap(); + } + #[test] fn empty_config_emits_ready_ack_progress() { let (mut handle, receiver) = spawn_loader(); @@ -769,10 +2635,12 @@ mod tests { let mut actor = actor(); actor.watched_dirs.push(watched_sv_dir(root)); - let files = actor.process_notify_event( - NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&created_dir)), - ); + let files = actor + .process_notify_event( + NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&created_dir)), + ) + .unwrap(); actor.record_loaded_files(&files); assert!( @@ -800,10 +2668,12 @@ mod tests { actor.watched_dirs.push(watched_sv_dir(root)); actor.loaded_paths.extend([top.clone(), child.clone(), sibling.clone()]); - let mut files = actor.process_notify_event( - NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) - .add_path(path_buf(&removed_dir)), - ); + let mut files = actor + .process_notify_event( + NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) + .add_path(path_buf(&removed_dir)), + ) + .unwrap(); files.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); actor.record_loaded_files(&files); diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index ecbd9fab..f64a6c15 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -23,6 +23,12 @@ pub(in crate::global_state) enum Event { Vfs(vfs_loader::Message), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VfsFileSource { + ConfigScan, + ExternalChange, +} + impl Event { fn kind(&self) -> &'static str { match self { @@ -33,6 +39,8 @@ impl Event { Event::Vfs(vfs_loader::Message::Progress { .. }) => "vfs.progress", Event::Vfs(vfs_loader::Message::Loaded { .. }) => "vfs.loaded", Event::Vfs(vfs_loader::Message::Changed { .. }) => "vfs.changed", + Event::Vfs(vfs_loader::Message::ScanFailed { .. }) => "vfs.scan_failed", + Event::Vfs(vfs_loader::Message::WatcherStatus(_)) => "vfs.watcher_status", } } @@ -57,6 +65,22 @@ impl Event { Event::Vfs(vfs_loader::Message::Changed { files, .. }) => { format!("vfs changed files={}", files.len()) } + Event::Vfs(vfs_loader::Message::ScanFailed { config_version, failure }) => { + format!( + "vfs scan failed config_version={config_version} root={} error={}", + failure.root, failure.error + ) + } + Event::Vfs(vfs_loader::Message::WatcherStatus(status)) => match status { + vfs_loader::WatcherStatus::Ready { config_version } => { + format!("vfs watcher ready config_version={config_version}") + } + vfs_loader::WatcherStatus::Failed { config_version, failure } => { + format!( + "vfs watcher failed config_version={config_version} failure={failure:?}" + ) + } + }, } } } @@ -389,10 +413,56 @@ impl GlobalState { } } vfs_loader::Message::Loaded { files, config_version } => { - self.process_vfs_files(files, config_version, false); + self.process_vfs_files(files, config_version, VfsFileSource::ConfigScan); } vfs_loader::Message::Changed { files, config_version } => { - self.process_vfs_files(files, config_version, true); + self.process_vfs_files(files, config_version, VfsFileSource::ExternalChange); + } + vfs_loader::Message::ScanFailed { config_version, failure } => { + let current_config_version = + self.workspace.workspace_vfs.current_vfs_config_version(); + always!(config_version <= current_config_version); + if !self.workspace.workspace_vfs.fail_vfs_scan(config_version) { + tracing::debug!( + config_version, + current_config_version, + %failure.root, + error = %failure.error, + "stale VFS scan failure ignored" + ); + return; + } + + tracing::error!( + config_version, + %failure.root, + error = %failure.error, + "VFS content scan failed; workspace remains unavailable" + ); + } + vfs_loader::Message::WatcherStatus(status) => { + let config_version = status.config_version(); + let current_config_version = + self.workspace.workspace_vfs.current_vfs_config_version(); + always!(config_version <= current_config_version); + if config_version != current_config_version { + tracing::debug!( + config_version, + current_config_version, + ?status, + "stale server file watcher status ignored" + ); + return; + } + + match status { + vfs_loader::WatcherStatus::Ready { .. } => { + tracing::info!(config_version, "server file watcher is ready"); + } + vfs_loader::WatcherStatus::Failed { failure, .. } => { + tracing::error!(config_version, ?failure, "server file watcher failed"); + } + } } } } @@ -401,7 +471,7 @@ impl GlobalState { &mut self, files: Vec<(utils::paths::AbsPathBuf, vfs_loader::LoadResult)>, config_version: u32, - external_change: bool, + source: VfsFileSource, ) { always!(config_version <= self.workspace.workspace_vfs.current_vfs_config_version()); if !self.workspace.workspace_vfs.accepts_vfs_loaded(config_version) { @@ -409,7 +479,7 @@ impl GlobalState { config_version, current_config_version = self.workspace.workspace_vfs.current_vfs_config_version(), files = files.len(), - external_change, + ?source, "stale VFS file batch ignored" ); return; @@ -433,7 +503,7 @@ impl GlobalState { let changed = vfs.set_file_contents(&path, content); if changed - && external_change + && source == VfsFileSource::ExternalChange && reload::should_refresh_for_change(&abs_path, has_structure_change) { manifest_change.get_or_insert(abs_path); diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index 7c3271ed..d35f0e85 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -369,6 +369,37 @@ mod tests { assert!(client.receiver.recv_timeout(Duration::from_millis(50)).is_err()); } + #[test] + fn scan_failure_keeps_terminal_vfs_generation_unready() { + let root = TestDir::new("failed-vfs-scan-stays-unready"); + let root_path = root.path().to_path_buf(); + let mut state = test_state(root_path.clone()); + let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + + state.process_vfs_msg(vfs_loader::Message::ScanFailed { + config_version, + failure: vfs_loader::ScanFailure { + root: root_path, + error: "injected scan failure".to_owned(), + }, + }); + state.process_vfs_msg(vfs_loader::Message::Progress { + n_total: 1, + n_done: 1, + config_version, + }); + + assert!(!state.workspace.workspace_vfs.is_ready()); + + let next_version = state.workspace.workspace_vfs.begin_vfs_load(0).config_version; + state.process_vfs_msg(vfs_loader::Message::Progress { + n_total: 0, + n_done: 0, + config_version: next_version, + }); + assert!(state.workspace.workspace_vfs.is_ready()); + } + #[test] fn diagnostic_request_gets_terminal_fallback_before_workspace_ready() { let root = TestDir::new("diagnostic-request-readiness-fallback"); diff --git a/src/global_state/workspace_state.rs b/src/global_state/workspace_state.rs index da04b785..3f515ebd 100644 --- a/src/global_state/workspace_state.rs +++ b/src/global_state/workspace_state.rs @@ -49,6 +49,7 @@ pub(crate) struct WorkspaceVfsReadiness { vfs_config_version: u32, vfs_progress: VfsProgress, vfs_ready: bool, + vfs_scan_failed: bool, vfs_client_progress_active: bool, diagnostic_readiness_revision: u64, diagnostics_deferred_until_ready: bool, @@ -64,6 +65,7 @@ impl Default for WorkspaceVfsReadiness { vfs_config_version: 0, vfs_progress: VfsProgress::default(), vfs_ready: true, + vfs_scan_failed: false, vfs_client_progress_active: false, diagnostic_readiness_revision: 0, diagnostics_deferred_until_ready: false, @@ -133,6 +135,7 @@ impl WorkspaceVfsReadiness { self.vfs_progress = VfsProgress { config_version: self.vfs_config_version, n_done: 0, n_total }; self.vfs_ready = false; + self.vfs_scan_failed = false; self.vfs_client_progress_active = false; BeginVfsLoad { config_version: self.vfs_config_version, superseded_client_progress_active } } @@ -151,10 +154,20 @@ impl WorkspaceVfsReadiness { } self.vfs_progress = VfsProgress { config_version, n_done, n_total }; - self.vfs_ready = !self.vfs_progress.in_progress(); + self.vfs_ready = !self.vfs_progress.in_progress() && !self.vfs_scan_failed; Some(self.vfs_progress) } + pub(crate) fn fail_vfs_scan(&mut self, config_version: u32) -> bool { + if config_version != self.vfs_config_version { + return false; + } + + self.vfs_scan_failed = true; + self.vfs_ready = false; + true + } + pub(crate) fn note_vfs_client_progress_started(&mut self) { self.vfs_client_progress_active = true; } From 13f12867be88e928d7181a8a476d9f2fd49420a4 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 18:37:09 +0800 Subject: [PATCH 06/13] refactor(vfs): keep Loaded/Changed, drop watcher manager Server-side notify stays best-effort at rust-analyzer's abstraction level: configuration scans report Loaded, later disk/invalidate events report Changed, and OS watcher failures are logged rather than terminal for a generation. Remove the WatcherManager state machine, coverage revisions, ScanFailed, and WatcherStatus protocol. Workspace readiness follows content-scan Progress only, matching other LSPs that do not treat notify completeness as a readiness invariant. --- crates/vfs/src/loader.rs | 48 +- crates/vfs/src/notify.rs | 2397 ++++----------------------- src/global_state/event_loop.rs | 64 - src/global_state/main_loop.rs | 31 - src/global_state/workspace_state.rs | 15 +- 5 files changed, 289 insertions(+), 2266 deletions(-) diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index 39ad0611..255ff44f 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -42,12 +42,19 @@ pub struct Config { } /// Messages sent by a loader generation back to the main loop. +/// +/// Mirrors rust-analyzer's `vfs::loader::Message` shape: configuration scans +/// report [`Loaded`], while later disk or explicit invalidations report +/// [`Changed`]. Server-side watching is best-effort; failures are logged rather +/// than modeled as protocol-level readiness failures. pub enum Message { Progress { n_total: usize, n_done: usize, config_version: u32 }, + /// Initial contents discovered by a configuration scan (or its unload + /// reconciliation). Must not be treated as an external workspace edit. Loaded { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, + /// Contents that changed after the current configuration generation was + /// established (file watcher events or explicit invalidation). Changed { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, - ScanFailed { config_version: u32, failure: ScanFailure }, - WatcherStatus(WatcherStatus), } pub type Sender = crossbeam_channel::Sender; @@ -60,37 +67,6 @@ pub enum LoadResult { DecodeError, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WatcherStatus { - Ready { config_version: u32 }, - Failed { config_version: u32, failure: WatcherFailure }, -} - -impl WatcherStatus { - pub fn config_version(&self) -> u32 { - match self { - Self::Ready { config_version } | Self::Failed { config_version, .. } => *config_version, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WatcherFailure { - Create { error: String }, - Watch { path: AbsPathBuf, error: String }, - Unwatch { path: AbsPathBuf, error: String }, - Notify { error: String }, - Protocol { error: String }, - Stopped { error: String }, -} - -/// A content scan that could not produce a complete VFS generation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ScanFailure { - pub root: AbsPathBuf, - pub error: String, -} - pub trait Handle: fmt::Debug { fn spawn(sender: Sender) -> Self where @@ -170,12 +146,6 @@ impl fmt::Debug for Message { .field("n_files", &files.len()) .field("config_version", config_version) .finish(), - Message::ScanFailed { config_version, failure } => f - .debug_struct("ScanFailed") - .field("config_version", config_version) - .field("failure", failure) - .finish(), - Message::WatcherStatus(status) => f.debug_tuple("WatcherStatus").field(status).finish(), Message::Progress { n_total, n_done, config_version } => f .debug_struct("Progress") .field("n_total", n_total) diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 8551069a..27238993 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -1,13 +1,10 @@ use std::{fs, mem, sync::atomic::AtomicUsize}; -use ::notify::{ - Config, ErrorKind, RecommendedWatcher, RecursiveMode, Watcher, - event::{ModifyKind, RenameMode}, -}; +use ::notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use crossbeam_channel::{Receiver, Sender, select, unbounded}; use itertools::Itertools; use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator}; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashSet; use utils::{ lines::LineEnding, paths::{AbsPath, AbsPathBuf}, @@ -109,17 +106,10 @@ impl BrowserLoader { let previous_loaded_paths = mem::take(&mut self.loaded_paths); let mut reported_paths = FxHashSet::default(); let mut loaded_paths = FxHashSet::default(); - let mut scan_failures = Vec::new(); for (index, entry) in config.to_load.into_iter().enumerate() { let (watch_tx, _) = unbounded(); - let files = match NotifyActor::load_entry(&watch_tx, entry, false) { - Ok(files) => files, - Err(failure) => { - scan_failures.push(failure); - Vec::new() - } - }; + let files = NotifyActor::load_entry(&watch_tx, entry, false); reported_paths.extend(files.iter().map(|(path, _)| path.clone())); loaded_paths.extend( files @@ -128,20 +118,7 @@ impl BrowserLoader { .map(|(path, _)| path.clone()), ); self.send(loader::Message::Loaded { files, config_version }); - let n_done = index + 1; - if n_done < n_total { - self.send(loader::Message::Progress { n_total, n_done, config_version }); - } - } - - if !scan_failures.is_empty() { - loaded_paths.extend(previous_loaded_paths); - self.loaded_paths = loaded_paths; - for failure in scan_failures { - self.send(loader::Message::ScanFailed { config_version, failure }); - } - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); - return; + self.send(loader::Message::Progress { n_total, n_done: index + 1, config_version }); } let unloaded = previous_loaded_paths @@ -153,7 +130,11 @@ impl BrowserLoader { if !unloaded.is_empty() { self.send(loader::Message::Loaded { files: unloaded, config_version }); } - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); + if has_reconcile_step { + self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); + } else if n_total == 0 { + self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); + } } fn invalidate(&mut self, path: AbsPathBuf) { @@ -180,526 +161,44 @@ impl BrowserLoader { } } -#[derive(Debug)] -struct WatchPlan { - config_version: u32, - coverage_revision: u64, - targets: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WatchTarget { - path: AbsPathBuf, - // A path receives a new registration revision after it leaves coverage and - // later reappears. The manager then re-registers that spelling even if the - // OS backend silently discarded the old watch when the path was removed. - registration_revision: u64, -} - -#[derive(Debug)] -struct WatchSync { - config_version: u32, - coverage_revision: u64, - targets: Vec, -} - -#[derive(Debug)] -enum WatcherCommand { - Replace(WatchPlan), - Sync(WatchSync), - Abort { through_config_version: u32 }, -} - -#[derive(Debug)] -enum WatcherOutput { - Installed { config_version: u32, coverage_revision: u64 }, - Synced { config_version: u32, coverage_revision: u64 }, - Notify { config_version: u32, event: ::notify::Event }, - Failed { config_version: u32, failure: loader::WatcherFailure }, -} - -impl WatcherOutput { - fn config_version(&self) -> u32 { - match self { - Self::Installed { config_version, .. } - | Self::Synced { config_version, .. } - | Self::Notify { config_version, .. } - | Self::Failed { config_version, .. } => *config_version, - } - } -} - -#[derive(Debug)] -struct WatcherManagerHandle { - command_sender: Sender, - output_receiver: Receiver, -} - -#[derive(Debug, Default)] -struct WatchCoverage { - // Desired coverage owned by the loader actor. The watcher manager owns the - // separate installed set and only changes it from full snapshots of this map. - targets: FxHashMap, - // Changes whenever the desired snapshot changes. Installation and sync - // acknowledgements carry this value so a post-install rescan can detect gaps. - revision: u64, - // Monotonic identity for one incarnation of a path in desired coverage. - next_registration_revision: u64, -} - -impl WatchCoverage { - fn replace(&mut self, paths: impl IntoIterator) { - self.targets.clear(); - self.revision = self.revision.wrapping_add(1); - for path in paths { - self.insert_new_target(path); - } - } - - fn add(&mut self, paths: impl IntoIterator) -> bool { - let mut changed = false; - for path in paths { - if !self.targets.contains_key(&path) { - self.insert_new_target(path); - changed = true; - } - } - if changed { - self.revision = self.revision.wrapping_add(1); - } - changed - } - - fn reconcile(&mut self, paths: FxHashSet) -> bool { - let before = self.targets.len(); - self.targets.retain(|path, _| paths.contains(path)); - let mut changed = self.targets.len() != before; - for path in paths { - if !self.targets.contains_key(&path) { - self.insert_new_target(path); - changed = true; - } - } - if changed { - self.revision = self.revision.wrapping_add(1); - } - changed - } - - fn invalidate_prefix(&mut self, removed_path: &AbsPath) -> bool { - let before = self.targets.len(); - self.targets.retain(|path, _| !path.starts_with(removed_path)); - let changed = self.targets.len() != before; - if changed { - self.revision = self.revision.wrapping_add(1); - } - changed - } - - fn snapshot(&self) -> Vec { - self.targets - .iter() - .map(|(path, registration_revision)| WatchTarget { - path: path.clone(), - registration_revision: *registration_revision, - }) - .sorted_by(|left, right| left.path.cmp(&right.path)) - .collect() - } - - fn insert_new_target(&mut self, path: AbsPathBuf) { - self.next_registration_revision = self.next_registration_revision.wrapping_add(1); - self.targets.insert(path, self.next_registration_revision); - } -} - -#[derive(Debug, Default)] -enum WatcherPhase { - // Installing and Syncing each represent the only watcher mutation in - // flight. Every matching acknowledgement is followed by a full rescan; - // Ready is reached only when that rescan leaves the desired coverage at - // the acknowledged revision. Failed is terminal for the generation. - #[default] - Unconfigured, - Installing { - coverage_revision: u64, - }, - Syncing { - coverage_revision: u64, - }, - Ready, - Failed, -} +type NotifyEvent = ::notify::Result<::notify::Event>; struct NotifyActor { sender: loader::Sender, config_version: u32, watched_files: FxHashSet, watched_dirs: Vec, - watch_coverage: WatchCoverage, - watcher_phase: WatcherPhase, loaded_paths: FxHashSet, - watcher_manager: Option, + // Drop order is significant. + watcher: Option<(RecommendedWatcher, Receiver)>, } #[derive(Debug)] enum Event { ServerMsg(ServerMsg), - WatcherOutput(WatcherOutput), - WatcherManagerStopped, -} - -#[derive(Debug)] -enum ActorFailure { - Watcher(loader::WatcherFailure), - Scan(loader::ScanFailure), -} - -impl From for ActorFailure { - fn from(failure: loader::WatcherFailure) -> Self { - Self::Watcher(failure) - } -} - -impl From for ActorFailure { - fn from(failure: loader::ScanFailure) -> Self { - Self::Scan(failure) - } -} - -fn spawn_watcher_manager() -> std::io::Result { - spawn_watcher_manager_with::(|config_version, event_sender| { - RecommendedWatcher::new( - move |event: ::notify::Result<::notify::Event>| { - let output = match event { - Ok(event) => WatcherOutput::Notify { config_version, event }, - Err(error) => WatcherOutput::Failed { - config_version, - failure: loader::WatcherFailure::Notify { error: error.to_string() }, - }, - }; - if event_sender.send(output).is_err() { - tracing::debug!( - config_version, - "watcher event dropped because the manager receiver is closed" - ); - } - }, - Config::default(), - ) - }) -} - -fn spawn_watcher_manager_with(create: F) -> std::io::Result -where - W: Watcher + Send + 'static, - F: FnMut(u32, Sender) -> ::notify::Result + Send + 'static, -{ - let (command_sender, command_receiver) = unbounded(); - let (output_sender, output_receiver) = unbounded(); - // OS watcher construction and registration are not cancellable and may never - // return. This sole detached owner keeps those calls off the VFS loader and - // main-loop shutdown paths. Commands are intentionally serialized: a stuck - // backend call can delay later watcher generations, but it cannot delay - // content loading or an LSP response. - let thread = thread::Builder::new(thread::ThreadIntent::Worker) - .name("VfsWatcherManager".to_owned()) - .allow_leak(true) - .spawn(move || run_watcher_manager(command_receiver, output_sender, create))?; - drop(thread); - Ok(WatcherManagerHandle { command_sender, output_receiver }) -} - -// The manager is the sole owner of the OS watcher and the authoritative record -// of successfully installed targets. The actor never mutates this state -// directly. -struct ActiveWatcher { - config_version: u32, - watcher: Option, - installed_targets: FxHashMap, -} - -fn run_watcher_manager( - command_receiver: Receiver, - output_sender: Sender, - mut create: F, -) where - W: Watcher, - F: FnMut(u32, Sender) -> ::notify::Result, -{ - let mut active: Option> = None; - - while let Ok(command) = command_receiver.recv() { - match command { - WatcherCommand::Replace(plan) => { - active = None; - let version = plan.config_version; - let mut state = ActiveWatcher { - config_version: version, - watcher: None, - installed_targets: FxHashMap::default(), - }; - if let Err(failure) = reconcile_watch_coverage( - version, - &mut state, - plan.targets, - &output_sender, - &mut create, - ) { - if output_sender - .send(WatcherOutput::Failed { config_version: version, failure }) - .is_err() - { - return; - } - continue; - } - - active = Some(state); - if output_sender - .send(WatcherOutput::Installed { - config_version: version, - coverage_revision: plan.coverage_revision, - }) - .is_err() - { - return; - } - } - WatcherCommand::Sync(sync) => { - let Some(mut state) = active.take() else { - if output_sender - .send(WatcherOutput::Failed { - config_version: sync.config_version, - failure: loader::WatcherFailure::Protocol { - error: "watcher sync has no active generation".to_owned(), - }, - }) - .is_err() - { - return; - } - continue; - }; - if state.config_version != sync.config_version { - let active_version = state.config_version; - active = Some(state); - if output_sender - .send(WatcherOutput::Failed { - config_version: sync.config_version, - failure: loader::WatcherFailure::Protocol { - error: format!( - "watcher sync generation {0} does not match active generation {active_version}", - sync.config_version - ), - }, - }) - .is_err() - { - return; - } - continue; - } - - if let Err(failure) = reconcile_watch_coverage( - sync.config_version, - &mut state, - sync.targets, - &output_sender, - &mut create, - ) { - if output_sender - .send(WatcherOutput::Failed { - config_version: sync.config_version, - failure, - }) - .is_err() - { - return; - } - continue; - } - - active = Some(state); - if output_sender - .send(WatcherOutput::Synced { - config_version: sync.config_version, - coverage_revision: sync.coverage_revision, - }) - .is_err() - { - return; - } - } - WatcherCommand::Abort { through_config_version } => { - let active_version = active.as_ref().map(|state| state.config_version); - if active_version.is_some_and(|version| version <= through_config_version) { - active = None; - tracing::debug!( - through_config_version, - ?active_version, - "server file watcher generation aborted" - ); - } else { - tracing::debug!( - through_config_version, - ?active_version, - "watcher abort did not match the active generation" - ); - } - } - } - } -} - -fn reconcile_watch_coverage( - config_version: u32, - state: &mut ActiveWatcher, - targets: Vec, - output_sender: &Sender, - create: &mut F, -) -> Result<(), loader::WatcherFailure> -where - W: Watcher, - F: FnMut(u32, Sender) -> ::notify::Result, -{ - let desired_targets = targets - .into_iter() - .map(|target| (target.path, target.registration_revision)) - .collect::>(); - - let paths_to_remove = state - .installed_targets - .iter() - .filter(|(path, revision)| desired_targets.get(*path) != Some(*revision)) - .map(|(path, _)| path.clone()) - .sorted() - .collect_vec(); - if let Some(watcher) = &mut state.watcher { - for path in &paths_to_remove { - tracing::debug!(config_version, %path, "server file watch removal started"); - match watcher.unwatch(path.as_ref()) { - Ok(()) => { - tracing::debug!(config_version, %path, "server file watch removal finished"); - } - Err(error) - if matches!(error.kind, ErrorKind::PathNotFound | ErrorKind::WatchNotFound) => - { - tracing::debug!( - config_version, - %path, - %error, - "server file watch was already removed by the backend" - ); - } - Err(error) => { - return Err(loader::WatcherFailure::Unwatch { - path: path.clone(), - error: error.to_string(), - }); - } - } - } - } - for path in paths_to_remove { - state.installed_targets.remove(&path); - } - - let targets_to_add = desired_targets - .iter() - .filter(|(path, revision)| state.installed_targets.get(*path) != Some(*revision)) - .map(|(path, revision)| WatchTarget { - path: path.clone(), - registration_revision: *revision, - }) - .sorted_by(|left, right| left.path.cmp(&right.path)) - .collect_vec(); - if !targets_to_add.is_empty() && state.watcher.is_none() { - state.watcher = Some(create_watcher(config_version, output_sender, create)?); - } - if let Some(watcher) = &mut state.watcher { - let paths = targets_to_add.iter().map(|target| target.path.clone()).collect_vec(); - watch_paths(config_version, watcher, &paths)?; - } - for target in targets_to_add { - state.installed_targets.insert(target.path, target.registration_revision); - } - - Ok(()) -} - -fn create_watcher( - config_version: u32, - output_sender: &Sender, - create: &mut F, -) -> Result -where - F: FnMut(u32, Sender) -> ::notify::Result, -{ - tracing::debug!(config_version, "server file watcher creation started"); - let watcher = create(config_version, output_sender.clone()) - .map_err(|error| loader::WatcherFailure::Create { error: error.to_string() })?; - tracing::debug!(config_version, "server file watcher creation finished"); - Ok(watcher) -} - -fn watch_paths( - config_version: u32, - watcher: &mut W, - paths: &[AbsPathBuf], -) -> Result<(), loader::WatcherFailure> { - for path in paths { - tracing::debug!(config_version, %path, "server file watch registration started"); - if let Err(error) = watcher.watch(path.as_ref(), RecursiveMode::NonRecursive) { - return Err(loader::WatcherFailure::Watch { - path: path.clone(), - error: error.to_string(), - }); - } - tracing::debug!(config_version, %path, "server file watch registration finished"); - } - Ok(()) + NotifyEvent(NotifyEvent), } impl NotifyActor { fn new(sender: loader::Sender) -> NotifyActor { - let watcher_manager = match spawn_watcher_manager() { - Ok(manager) => Some(manager), - Err(error) => { - tracing::error!(%error, "failed to spawn server file watcher manager"); - None - } - }; - Self::new_with_manager(sender, watcher_manager) - } - - fn new_with_manager( - sender: loader::Sender, - watcher_manager: Option, - ) -> NotifyActor { NotifyActor { sender, config_version: 0, watched_files: FxHashSet::default(), watched_dirs: Vec::new(), - watch_coverage: WatchCoverage::default(), - watcher_phase: WatcherPhase::Unconfigured, loaded_paths: FxHashSet::default(), - watcher_manager, + watcher: None, } } fn next_event(&self, receiver: &Receiver) -> Option { - let Some(watcher_manager) = &self.watcher_manager else { + let Some((_, watcher_receiver)) = &self.watcher else { return receiver.recv().ok().map(Event::ServerMsg); }; select! { recv(receiver) -> it => it.ok().map(Event::ServerMsg), - recv(watcher_manager.output_receiver) -> it => Some(match it { - Ok(output) => Event::WatcherOutput(output), - Err(_) => Event::WatcherManagerStopped, - }), + recv(watcher_receiver) -> it => it.ok().map(Event::NotifyEvent), } } @@ -709,14 +208,113 @@ impl NotifyActor { match event { Event::ServerMsg(msg) => match msg { ServerMsg::Config(config) => { - self.watcher_phase = WatcherPhase::Unconfigured; - if let Some(watch_plan) = self.load_and_reconcile(config) { - self.watcher_phase = WatcherPhase::Installing { - coverage_revision: watch_plan.coverage_revision, - }; - self.send_watcher_command(WatcherCommand::Replace(watch_plan)); - } else { - self.watcher_phase = WatcherPhase::Failed; + self.watcher = None; + if !config.to_watch.is_empty() { + let (watcher_sender, watcher_receiver) = unbounded(); + let watcher = log_notify_error(RecommendedWatcher::new( + move |event| { + if watcher_sender.send(event).is_err() { + tracing::debug!( + "notify event dropped because watcher receiver is closed" + ); + } + }, + Config::default(), + )); + self.watcher = watcher.map(|it| (it, watcher_receiver)); + } + + let config_version = config.version; + self.config_version = config_version; + let has_reconcile_step = !self.loaded_paths.is_empty(); + let n_entries = config.to_load.len(); + let n_total = n_entries + usize::from(has_reconcile_step); + if n_total > 0 { + self.send(loader::Message::Progress { + n_total, + n_done: 0, + config_version, + }); + } + + self.watched_files.clear(); + self.watched_dirs.clear(); + let previous_loaded_paths = mem::take(&mut self.loaded_paths); + + let (entry_tx, entry_rx) = unbounded(); + let (watch_tx, watch_rx) = unbounded(); + let (loaded_tx, loaded_rx) = unbounded(); + let processed = AtomicUsize::new(0); + + config.to_load.into_par_iter().enumerate().for_each(|(i, entry)| { + let do_watch = config.to_watch.contains(&i); + if do_watch && entry_tx.send(entry.clone()).is_err() { + tracing::debug!("watched entry dropped because receiver is closed"); + } + let files = Self::load_entry(&watch_tx, entry, do_watch); + let reported_paths = + files.iter().map(|(path, _)| path.clone()).collect_vec(); + let loaded_paths = files + .iter() + .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) + .map(|(path, _)| path.clone()) + .collect_vec(); + if loaded_tx.send((reported_paths, loaded_paths)).is_err() { + tracing::debug!( + "loaded path batch dropped because receiver is closed" + ); + } + self.send(loader::Message::Loaded { files, config_version }); + self.send(loader::Message::Progress { + n_total, + n_done: 1 + processed + .fetch_add(1, std::sync::atomic::Ordering::AcqRel), + config_version, + }); + }); + + drop(loaded_tx); + let mut reported_paths = FxHashSet::default(); + let mut loaded_paths = FxHashSet::default(); + for (reported, loaded) in loaded_rx { + reported_paths.extend(reported); + loaded_paths.extend(loaded); + } + + drop(watch_tx); + for path in watch_rx { + self.watch(&path); + } + + drop(entry_tx); + for entry in entry_rx { + match entry { + loader::Entry::Files(files) => self.watched_files.extend(files), + loader::Entry::Directories(dir) => self.watched_dirs.push(dir), + } + } + + let unloaded = previous_loaded_paths + .difference(&reported_paths) + .cloned() + .map(|path| (path, LoadResult::LoadError)) + .collect_vec(); + self.loaded_paths = loaded_paths; + if !unloaded.is_empty() { + self.send(loader::Message::Loaded { files: unloaded, config_version }); + } + if has_reconcile_step { + self.send(loader::Message::Progress { + n_total, + n_done: n_total, + config_version, + }); + } else if n_total == 0 { + self.send(loader::Message::Progress { + n_total, + n_done: 0, + config_version, + }); } } ServerMsg::Invalidate(path) => { @@ -729,486 +327,123 @@ impl NotifyActor { }); } }, - Event::WatcherOutput(output) => self.handle_watcher_output(output), - Event::WatcherManagerStopped => { - self.watcher_manager = None; - self.fail_watcher_generation(loader::WatcherFailure::Stopped { - error: "server file watcher manager stopped".to_owned(), + Event::NotifyEvent(event) => { + let Some(event) = log_notify_error(event) else { + continue; + }; + + let files = self.process_notify_event(event); + if files.is_empty() { + continue; + } + self.record_loaded_files(&files); + self.send(loader::Message::Changed { + files, + config_version: self.config_version, }); } } } } - fn load_and_reconcile(&mut self, config: loader::Config) -> Option { - let config_version = config.version; - self.config_version = config_version; - let has_reconcile_step = !self.loaded_paths.is_empty(); - let n_entries = config.to_load.len(); - let n_total = n_entries + usize::from(has_reconcile_step); - if n_total > 0 { - self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); + fn process_notify_event(&mut self, event: ::notify::Event) -> Vec<(AbsPathBuf, LoadResult)> { + if !(event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()) { + return Vec::new(); } - self.watched_files.clear(); - self.watched_dirs.clear(); - let previous_loaded_paths = mem::take(&mut self.loaded_paths); - let (entry_tx, entry_rx) = unbounded(); - let (watch_tx, watch_rx) = unbounded(); - let (loaded_tx, loaded_rx) = unbounded(); - let (scan_failure_tx, scan_failure_rx) = unbounded(); - let processed = AtomicUsize::new(0); - - config.to_load.into_par_iter().enumerate().for_each(|(i, entry)| { - let do_watch = config.to_watch.contains(&i); - if do_watch && entry_tx.send(entry.clone()).is_err() { - tracing::debug!("watched entry dropped because receiver is closed"); - } - let files = match Self::load_entry(&watch_tx, entry, do_watch) { - Ok(files) => files, - Err(failure) => { - if scan_failure_tx.send(failure).is_err() { - tracing::debug!("scan failure dropped because receiver is closed"); - } - Vec::new() + let mut files = Vec::new(); + for path in event.paths.into_iter().filter_map(|path| AbsPathBuf::try_from(path).ok()) { + if event.kind.is_remove() { + let unloaded = self.unload_removed_path(&path); + if !unloaded.is_empty() { + files.extend(unloaded); + continue; } - }; - let reported_paths = files.iter().map(|(path, _)| path.clone()).collect_vec(); - let loaded_paths = files - .iter() - .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) - .map(|(path, _)| path.clone()) - .collect_vec(); - if loaded_tx.send((reported_paths, loaded_paths)).is_err() { - tracing::debug!("loaded path batch dropped because receiver is closed"); } - self.send(loader::Message::Loaded { files, config_version }); - let n_done = 1 + processed.fetch_add(1, std::sync::atomic::Ordering::AcqRel); - if n_done < n_total { - self.send(loader::Message::Progress { n_total, n_done, config_version }); + + let metadata = fs::metadata(&path).ok(); + let file_type = metadata.as_ref().map(|meta| meta.file_type()); + let is_file = file_type.as_ref().is_some_and(|it| it.is_file()); + let is_dir = file_type.as_ref().is_some_and(|it| it.is_dir()); + + if is_dir && self.is_watched_dir(&path) { + files.extend(self.load_created_directory(&path)); + continue; } - }); - drop(scan_failure_tx); - drop(loaded_tx); - let mut reported_paths = FxHashSet::default(); - let mut loaded_paths = FxHashSet::default(); - for (reported, loaded) in loaded_rx { - reported_paths.extend(reported); - loaded_paths.extend(loaded); - } + if metadata.is_some() && !is_file { + continue; + } - drop(entry_tx); - for entry in entry_rx { - match entry { - loader::Entry::Files(files) => self.watched_files.extend(files), - loader::Entry::Directories(dir) => self.watched_dirs.push(dir), + if !self.is_watched_file(&path) { + continue; } - } - drop(watch_tx); - let scan_failures = scan_failure_rx.into_iter().collect_vec(); - if !scan_failures.is_empty() { - let mut retained_paths = previous_loaded_paths; - retained_paths.extend(loaded_paths); - self.loaded_paths = retained_paths; - self.fail_scan_generation(scan_failures); - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); - return None; + files.push((path.clone(), read(&path))); } - let unloaded = previous_loaded_paths - .difference(&reported_paths) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec(); - self.loaded_paths = loaded_paths; - if !unloaded.is_empty() { - self.send(loader::Message::Loaded { files: unloaded, config_version }); - } - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); - - let paths: Vec = - watch_rx.into_iter().collect::>().into_iter().sorted().collect(); - self.watch_coverage.replace(paths); - Some(WatchPlan { - config_version, - coverage_revision: self.watch_coverage.revision, - targets: self.watch_coverage.snapshot(), - }) + files } - fn handle_watcher_output(&mut self, output: WatcherOutput) { - if output.config_version() != self.config_version { - return; - } + fn is_watched_dir(&self, path: &AbsPathBuf) -> bool { + self.watched_dirs.iter().any(|dir| dir.contains_dir(path)) + } - match output { - WatcherOutput::Installed { config_version, coverage_revision } => { - let expected = matches!( - self.watcher_phase, - WatcherPhase::Installing { coverage_revision: expected } - if expected == coverage_revision - ); - if expected { - self.rescan_and_converge_watcher(config_version, coverage_revision); - } else { - self.fail_watcher_generation(loader::WatcherFailure::Protocol { - error: format!( - "unexpected watcher installation acknowledgement for coverage revision {coverage_revision} while in phase {:?}", - self.watcher_phase - ), - }); - } - } - WatcherOutput::Synced { config_version, coverage_revision } => { - let expected = matches!( - self.watcher_phase, - WatcherPhase::Syncing { coverage_revision: expected_revision } - if expected_revision == coverage_revision - ); - if expected { - self.rescan_and_converge_watcher(config_version, coverage_revision); - } else { - self.fail_watcher_generation(loader::WatcherFailure::Protocol { - error: format!( - "unexpected watcher sync acknowledgement for coverage revision {coverage_revision} while in phase {:?}", - self.watcher_phase - ), - }); - } - } - WatcherOutput::Notify { config_version, event } => { - if matches!(self.watcher_phase, WatcherPhase::Failed) { - return; - } - let revision_before_event = self.watch_coverage.revision; - let files = match self.process_notify_event(event) { - Ok(files) => files, - Err(failure) => { - self.fail_actor_generation(failure); - return; - } - }; - self.record_loaded_files(&files); - self.send(loader::Message::Changed { files, config_version }); - if self.watch_coverage.revision != revision_before_event - && matches!(self.watcher_phase, WatcherPhase::Ready) - { - self.begin_watcher_sync(); - } - } - WatcherOutput::Failed { failure, .. } => { - self.fail_watcher_generation(failure); - } - } + fn is_watched_file(&self, path: &AbsPathBuf) -> bool { + self.watched_files.contains(path) + || self.watched_dirs.iter().any(|dir| dir.contains_file(path)) } - fn rescan_and_converge_watcher(&mut self, config_version: u32, acknowledged_revision: u64) { - let files = match self.rescan_watched_scope() { - Ok(files) => files, - Err(failure) => { - self.fail_scan_generation(vec![failure]); - return; + fn load_created_directory(&mut self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { + let dirs = + self.watched_dirs.iter().filter(|dir| dir.contains_dir(path)).cloned().collect_vec(); + let mut files = Vec::new(); + + for dir in dirs { + let (watch_tx, watch_rx) = unbounded(); + files.extend(Self::load_directory_subtree(&watch_tx, &dir, path, true)); + drop(watch_tx); + for path in watch_rx { + self.watch(&path); } - }; - self.send(loader::Message::Changed { files, config_version }); - if self.watch_coverage.revision == acknowledged_revision { - self.watcher_phase = WatcherPhase::Ready; - self.send_watcher_status(loader::WatcherStatus::Ready { config_version }); - } else { - self.begin_watcher_sync(); } + + files } - fn rescan_watched_scope( - &mut self, - ) -> Result, loader::ScanFailure> { - let mut files = self - .watched_files + fn unload_removed_path(&self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { + self.loaded_paths .iter() + .filter(|loaded_path| loaded_path.starts_with(path)) .cloned() - .map(|path| { - let result = read(&path); - (path, result) - }) - .collect_vec(); - let (watch_tx, watch_rx) = unbounded(); - for file in &self.watched_files { - let anchor = nearest_existing_parent_anchor(file)?; - if watch_tx.send(anchor).is_err() { - tracing::debug!("watched file anchor dropped because receiver is closed"); - } - } - for dirs in self.watched_dirs.clone() { - for root in dirs.include_roots() { - files.extend(Self::load_directory_subtree(&watch_tx, &dirs, root, true)?); - } - } - drop(watch_tx); - - let reported_paths = files.iter().map(|(path, _)| path.clone()).collect::>(); - files.extend( - self.loaded_paths - .iter() - .filter(|path| self.is_watched_file(path) && !reported_paths.contains(*path)) - .cloned() - .map(|path| (path, LoadResult::LoadError)), - ); - self.record_loaded_files(&files); - self.watch_coverage.reconcile(watch_rx.into_iter().collect()); - Ok(files) + .map(|path| (path, LoadResult::LoadError)) + .collect_vec() } - fn begin_watcher_sync(&mut self) { - let sync = WatchSync { - config_version: self.config_version, - coverage_revision: self.watch_coverage.revision, - targets: self.watch_coverage.snapshot(), - }; - self.watcher_phase = WatcherPhase::Syncing { coverage_revision: sync.coverage_revision }; - self.send_watcher_command(WatcherCommand::Sync(sync)); - } + fn load_entry( + watch_tx: &Sender, + entry: loader::Entry, + watch: bool, + ) -> Vec<(AbsPathBuf, LoadResult)> { + match entry { + loader::Entry::Files(files) => files + .into_iter() + .map(|file| { + if watch && watch_tx.send(file.to_owned()).is_err() { + tracing::debug!("watched file path dropped because receiver is closed"); + } + let contents = read(file.as_path()); + (file, contents) + }) + .collect_vec(), + loader::Entry::Directories(dirs) => { + let mut res = Vec::new(); - fn send_watcher_command(&mut self, command: WatcherCommand) { - let config_version = match &command { - WatcherCommand::Replace(plan) => plan.config_version, - WatcherCommand::Sync(sync) => sync.config_version, - WatcherCommand::Abort { through_config_version } => *through_config_version, - }; - let sent = self - .watcher_manager - .as_ref() - .is_some_and(|manager| manager.command_sender.send(command).is_ok()); - if sent { - return; - } - - self.watcher_manager = None; - debug_assert_eq!(config_version, self.config_version); - self.fail_watcher_generation(loader::WatcherFailure::Stopped { - error: "server file watcher manager is unavailable".to_owned(), - }); - } - - fn fail_watcher_generation(&mut self, failure: loader::WatcherFailure) { - if !self.enter_failed_watcher_phase() { - return; - } - self.send_watcher_status(loader::WatcherStatus::Failed { - config_version: self.config_version, - failure, - }); - } - - fn fail_scan_generation(&mut self, failures: Vec) { - assert!(!failures.is_empty(), "a failed scan must report at least one cause"); - if !self.enter_failed_watcher_phase() { - return; - } - for failure in failures { - self.send(loader::Message::ScanFailed { config_version: self.config_version, failure }); - } - } - - fn fail_actor_generation(&mut self, failure: ActorFailure) { - match failure { - ActorFailure::Watcher(failure) => self.fail_watcher_generation(failure), - ActorFailure::Scan(failure) => self.fail_scan_generation(vec![failure]), - } - } - - fn enter_failed_watcher_phase(&mut self) -> bool { - if matches!(self.watcher_phase, WatcherPhase::Failed) { - return false; - } - - self.watcher_phase = WatcherPhase::Failed; - self.abort_watcher_generation(); - true - } - - fn abort_watcher_generation(&mut self) { - let command = WatcherCommand::Abort { through_config_version: self.config_version }; - let sent = self - .watcher_manager - .as_ref() - .is_some_and(|manager| manager.command_sender.send(command).is_ok()); - if sent || self.watcher_manager.is_none() { - return; - } - - tracing::error!( - config_version = self.config_version, - "failed to abort server file watcher generation because the manager stopped" - ); - self.watcher_manager = None; - } - - fn send_watcher_status(&self, status: loader::WatcherStatus) { - self.send(loader::Message::WatcherStatus(status)); - } - - fn process_notify_event( - &mut self, - event: ::notify::Event, - ) -> Result, ActorFailure> { - if event.need_rescan() { - return self.rescan_watched_scope().map_err(ActorFailure::from); - } - - if !(event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()) { - return Ok(Vec::new()); - } - - let removal_path_count = match event.kind { - kind if kind.is_remove() => event.paths.len(), - ::notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) => event.paths.len(), - ::notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)) - if event.paths.len() == 2 => - { - 1 - } - ::notify::EventKind::Modify(ModifyKind::Name( - RenameMode::Any | RenameMode::Other | RenameMode::Both, - )) => return self.rescan_watched_scope().map_err(ActorFailure::from), - _ => 0, - }; - - let mut files = Vec::new(); - for (index, raw_path) in event.paths.into_iter().enumerate() { - let path = AbsPathBuf::try_from(raw_path.clone()).map_err(|_| { - loader::WatcherFailure::Notify { - error: format!( - "notify returned a non-absolute or non-UTF-8 path: {raw_path:?}" - ), - } - })?; - let is_removal_path = index < removal_path_count; - if is_removal_path { - self.watch_coverage.invalidate_prefix(&path); - files.extend(self.unload_removed_path(&path)); - } - - let metadata = match fs::metadata(&path) { - Ok(metadata) => Some(metadata), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => { - return Err(loader::WatcherFailure::Notify { - error: format!("failed to inspect notify path {path}: {error}"), - } - .into()); - } - }; - if is_removal_path && metadata.is_none() { - continue; - } - let file_type = metadata.as_ref().map(|meta| meta.file_type()); - let is_file = file_type.as_ref().is_some_and(|it| it.is_file()); - let is_dir = file_type.as_ref().is_some_and(|it| it.is_dir()); - - if is_dir && self.is_watched_dir(&path) { - files.extend(self.load_created_directory(&path)?); - continue; - } - - if is_dir && self.is_ancestor_of_watched_target(&path) { - files.extend(self.rescan_watched_scope()?); - continue; - } - - if metadata.is_some() && !is_file { - continue; - } - - if !self.is_watched_file(&path) { - continue; - } - - files.push((path.clone(), read(&path))); - } - - Ok(files) - } - - fn is_watched_dir(&self, path: &AbsPathBuf) -> bool { - self.watched_dirs.iter().any(|dir| dir.contains_dir(path)) - } - - fn is_watched_file(&self, path: &AbsPathBuf) -> bool { - self.watched_files.contains(path) - || self.watched_dirs.iter().any(|dir| dir.contains_file(path)) - } - - fn is_ancestor_of_watched_target(&self, path: &AbsPath) -> bool { - self.watched_files.iter().any(|file| file.starts_with(path)) - || self - .watched_dirs - .iter() - .flat_map(|directories| directories.include_roots()) - .any(|root| root.starts_with(path)) - } - - fn load_created_directory( - &mut self, - path: &AbsPathBuf, - ) -> Result, loader::ScanFailure> { - let dirs = - self.watched_dirs.iter().filter(|dir| dir.contains_dir(path)).cloned().collect_vec(); - let mut files = Vec::new(); - let mut watch_paths = FxHashSet::default(); - - for dir in dirs { - let (watch_tx, watch_rx) = unbounded(); - files.extend(Self::load_directory_subtree(&watch_tx, &dir, path, true)?); - drop(watch_tx); - watch_paths.extend(watch_rx); - } - - self.watch_coverage.add(watch_paths); - - Ok(files) - } - - fn unload_removed_path(&self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { - self.loaded_paths - .iter() - .filter(|loaded_path| loaded_path.starts_with(path)) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec() - } - - fn load_entry( - watch_tx: &Sender, - entry: loader::Entry, - watch: bool, - ) -> Result, loader::ScanFailure> { - match entry { - loader::Entry::Files(files) => { - let mut loaded = Vec::with_capacity(files.len()); - for file in files { - if watch { - let anchor = nearest_existing_parent_anchor(&file)?; - if watch_tx.send(anchor).is_err() { - tracing::debug!( - "watched file anchor dropped because receiver is closed" - ); - } - } - let contents = read(file.as_path()); - loaded.push((file, contents)); - } - Ok(loaded) - } - loader::Entry::Directories(dirs) => { - let mut res = Vec::new(); - - for root in dirs.include_roots() { - res.extend(Self::load_directory_subtree(watch_tx, &dirs, root, watch)?); - } - Ok(res) - } + for root in dirs.include_roots() { + res.extend(Self::load_directory_subtree(watch_tx, &dirs, root, watch)); + } + res + } } } @@ -1217,52 +452,49 @@ impl NotifyActor { dirs: &loader::Directories, root: &AbsPathBuf, watch: bool, - ) -> Result, loader::ScanFailure> { - if watch { - let anchor = nearest_existing_parent_anchor(root)?; - if watch_tx.send(anchor).is_err() { - tracing::debug!("watched directory anchor dropped because receiver is closed"); + ) -> Vec<(AbsPathBuf, LoadResult)> { + let walkdir = WalkDir::new(root).follow_links(true).into_iter().filter_entry(|entry| { + if !entry.file_type().is_dir() { + return true; } - } - - let mut files = Vec::new(); - let mut walkdir = WalkDir::new(root).follow_links(true).into_iter(); - while let Some(entry) = walkdir.next() { - let entry = match entry { - Ok(entry) => entry, - Err(error) if walkdir_error_is_not_found(&error) => { - tracing::debug!(%error, %root, "directory disappeared while it was scanned"); - continue; - } - Err(error) => return Err(scan_failure(root, error.to_string())), + let Ok(path) = AbsPathBuf::try_from(entry.path().to_path_buf()) else { + return false; }; + root == &path || dirs.contains_dir(&path) + }); + + let files = walkdir.filter_map(|it| it.ok()).filter_map(|entry| { let is_dir = entry.file_type().is_dir(); let is_file = entry.file_type().is_file(); - let raw_path = entry.into_path(); - let abs_path = absolute_scan_path(root, raw_path)?; - - if is_dir && root != &abs_path && !dirs.contains_dir(&abs_path) { - walkdir.skip_current_dir(); - continue; - } + let abs_path = AbsPathBuf::try_from(entry.into_path()).ok()?; if is_dir && watch && watch_tx.send(abs_path.to_owned()).is_err() { tracing::debug!("watched directory path dropped because receiver is closed"); } if !is_file { - continue; + return None; } if !dirs.contains_file(&abs_path) { - continue; + return None; } - let contents = read(abs_path.as_path()); - files.push((abs_path, contents)); - } + Some(abs_path) + }); + + files + .map(|file| { + let contents = read(file.as_path()); + (file, contents) + }) + .collect_vec() + } - Ok(files) + fn watch(&mut self, path: &AbsPathBuf) { + if let Some((watcher, _)) = &mut self.watcher { + log_notify_error(watcher.watch(path.as_ref(), RecursiveMode::NonRecursive)); + } } fn record_loaded_files(&mut self, files: &[(AbsPathBuf, LoadResult)]) { @@ -1283,42 +515,6 @@ impl NotifyActor { } } -fn nearest_existing_parent_anchor(target: &AbsPath) -> Result { - let mut candidate = target.parent().unwrap_or(target).to_owned(); - loop { - match fs::metadata(&candidate) { - Ok(_) => return Ok(candidate), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let Some(parent) = candidate.parent() else { - return Err(scan_failure(target, error.to_string())); - }; - candidate = parent.to_owned(); - } - Err(error) => return Err(scan_failure(target, error.to_string())), - } - } -} - -fn walkdir_error_is_not_found(error: &walkdir::Error) -> bool { - error.io_error().is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) -} - -fn absolute_scan_path( - root: &AbsPath, - raw_path: std::path::PathBuf, -) -> Result { - AbsPathBuf::try_from(raw_path.clone()).map_err(|_| { - scan_failure( - root, - format!("directory scan returned a non-absolute or non-UTF-8 path: {raw_path:?}"), - ) - }) -} - -fn scan_failure(root: &AbsPath, error: String) -> loader::ScanFailure { - loader::ScanFailure { root: root.to_owned(), error } -} - fn read(path: &AbsPath) -> LoadResult { let Ok(bytes) = std::fs::read(path) else { return LoadResult::LoadError; @@ -1330,16 +526,17 @@ fn read(path: &AbsPath) -> LoadResult { LoadResult::Loaded(text, ending) } +fn log_notify_error(res: ::notify::Result) -> Option { + res.map_err(|err| tracing::warn!("notify error: {}", err)).ok() +} + #[cfg(test)] mod tests { - use std::{ - sync::{Arc, Mutex}, - time::{Duration, Instant}, - }; + use std::time::Duration; use ::notify::{ - Event as NotifyEvent, EventHandler, EventKind, WatcherKind, - event::{CreateKind, Flag, RemoveKind}, + Event as NotifyEvent, EventKind, + event::{CreateKind, RemoveKind}, }; use utils::paths::AbsPathBuf; @@ -1354,83 +551,6 @@ mod tests { path: AbsPathBuf, } - struct RejectingWatcher; - - #[derive(Default)] - struct RecordedWatchCalls { - watched: Vec, - unwatched: Vec, - drops: usize, - } - - struct RecordingWatcher { - calls: Arc>, - } - - impl Drop for RecordingWatcher { - fn drop(&mut self) { - self.calls.lock().unwrap().drops += 1; - } - } - - impl Watcher for RejectingWatcher { - fn new(_event_handler: F, _config: Config) -> ::notify::Result - where - Self: Sized, - { - unreachable!("constructed directly by watcher-manager tests") - } - - fn watch( - &mut self, - _path: &std::path::Path, - _recursive_mode: RecursiveMode, - ) -> ::notify::Result<()> { - Err(::notify::Error::generic("injected watch failure")) - } - - fn unwatch(&mut self, _path: &std::path::Path) -> ::notify::Result<()> { - Ok(()) - } - - fn kind() -> WatcherKind - where - Self: Sized, - { - WatcherKind::NullWatcher - } - } - - impl Watcher for RecordingWatcher { - fn new(_event_handler: F, _config: Config) -> ::notify::Result - where - Self: Sized, - { - unreachable!("constructed directly by watcher-manager tests") - } - - fn watch( - &mut self, - path: &std::path::Path, - _recursive_mode: RecursiveMode, - ) -> ::notify::Result<()> { - self.calls.lock().unwrap().watched.push(path.to_path_buf()); - Ok(()) - } - - fn unwatch(&mut self, path: &std::path::Path) -> ::notify::Result<()> { - self.calls.lock().unwrap().unwatched.push(path.to_path_buf()); - Ok(()) - } - - fn kind() -> WatcherKind - where - Self: Sized, - { - WatcherKind::NullWatcher - } - } - impl TestDir { fn new(name: &str) -> Self { let dir = tempfile::Builder::new().prefix(&format!("vide-{name}-")).tempdir().unwrap(); @@ -1453,6 +573,7 @@ mod tests { loader::Message::Loaded { files, config_version } if config_version == version => { loaded_batches.push(files); } + loader::Message::Changed { .. } => {} loader::Message::Progress { n_total, n_done, config_version } if config_version == version && n_done == n_total => { @@ -1469,18 +590,11 @@ mod tests { match &message { loader::Message::Loaded { config_version, .. } | loader::Message::Changed { config_version, .. } - | loader::Message::ScanFailed { config_version, .. } | loader::Message::Progress { config_version, .. } if *config_version == version => { return message; } - loader::Message::WatcherStatus( - loader::WatcherStatus::Ready { config_version } - | loader::WatcherStatus::Failed { config_version, .. }, - ) if *config_version == version => { - return message; - } _ => {} } } @@ -1519,982 +633,6 @@ mod tests { NotifyActor::new(sender) } - struct FakeWatcherHarness { - server: Sender, - loader: Receiver, - commands: Receiver, - output: Sender, - actor_thread: std::thread::JoinHandle<()>, - } - - fn spawn_actor_with_fake_manager() -> FakeWatcherHarness { - let (loader_sender, loader_receiver) = unbounded(); - let (command_sender, command_receiver) = unbounded(); - let (output_sender, output_receiver) = unbounded(); - let manager = WatcherManagerHandle { command_sender, output_receiver }; - let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); - let (server_sender, server_receiver) = unbounded(); - let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); - FakeWatcherHarness { - server: server_sender, - loader: loader_receiver, - commands: command_receiver, - output: output_sender, - actor_thread, - } - } - - fn acknowledge_command(command: &WatcherCommand, output: &Sender) { - let acknowledgement = match command { - WatcherCommand::Replace(plan) => WatcherOutput::Installed { - config_version: plan.config_version, - coverage_revision: plan.coverage_revision, - }, - WatcherCommand::Sync(sync) => WatcherOutput::Synced { - config_version: sync.config_version, - coverage_revision: sync.coverage_revision, - }, - WatcherCommand::Abort { .. } => { - panic!("abort commands do not have acknowledgements") - } - }; - output.send(acknowledgement).unwrap(); - } - - fn target_paths(targets: &[WatchTarget]) -> Vec { - targets.iter().map(|target| target.path.clone()).collect() - } - - #[test] - fn stalled_watcher_manager_does_not_delay_content_terminal() { - let dir = TestDir::new("vfs-loader-stalled-watcher-manager"); - let first = dir.join("first.sv"); - let second = dir.join("second.sv"); - std::fs::write(&first, "module first; endmodule\n").unwrap(); - std::fs::write(&second, "module second; endmodule\n").unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![first.clone()])], - to_watch: vec![0], - })) - .unwrap(); - assert_loaded(&collect_until_progress_done(&loader, 1), &first); - assert!(matches!( - commands.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherCommand::Replace(WatchPlan { config_version: 1, .. }) - )); - - server - .send(ServerMsg::Config(loader::Config { - version: 2, - to_load: vec![loader::Entry::Files(vec![second.clone()])], - to_watch: vec![0], - })) - .unwrap(); - assert_loaded(&collect_until_progress_done(&loader, 2), &second); - assert!(matches!( - commands.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherCommand::Replace(WatchPlan { config_version: 2, .. }) - )); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn watcher_install_ack_rescans_scan_install_gap() { - let dir = TestDir::new("vfs-loader-watch-install-gap"); - let root = dir.join("workspace"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let WatcherCommand::Replace(plan) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected watcher replacement"); - }; - let initial_targets = target_paths(&plan.targets); - assert!(initial_targets.contains(&root)); - assert!(initial_targets.contains(&root.parent().unwrap().to_owned())); - - let created_dir = root.join("generated"); - let created_file = created_dir.join("new.sv"); - std::fs::create_dir_all(&created_dir).unwrap(); - std::fs::write(&created_file, "module new; endmodule\n").unwrap(); - output - .send(WatcherOutput::Installed { - config_version: 1, - coverage_revision: plan.coverage_revision, - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected gap-closing rescan"); - }; - assert!(files.iter().any(|(path, result)| { - path == &created_file - && matches!(result, LoadResult::Loaded(text, _) if text.contains("module new")) - })); - let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected watches for directories created during installation"); - }; - assert_eq!(sync.config_version, 1); - assert!(target_paths(&sync.targets).contains(&created_dir)); - - acknowledge_command(&WatcherCommand::Sync(sync), &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn unexpected_watcher_ack_fails_the_generation() { - let dir = TestDir::new("vfs-loader-unexpected-watcher-ack"); - let root = dir.join("workspace"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let WatcherCommand::Replace(plan) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected watcher replacement"); - }; - - output - .send(WatcherOutput::Installed { - config_version: 1, - coverage_revision: plan.coverage_revision + 1, - }) - .unwrap(); - - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Failed { - config_version: 1, - failure: loader::WatcherFailure::Protocol { error }, - }) if error.contains("unexpected watcher installation acknowledgement") - )); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn watcher_failure_is_terminal_for_the_current_generation() { - let dir = TestDir::new("vfs-loader-terminal-watcher-failure"); - let root = dir.join("workspace"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - - let created_dir = root.join("generated"); - std::fs::create_dir_all(&created_dir).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - - output - .send(WatcherOutput::Failed { - config_version: 1, - failure: loader::WatcherFailure::Notify { error: "backend failed".to_owned() }, - }) - .unwrap(); - acknowledge_command(&sync, &output); - - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Failed { - config_version: 1, - failure: loader::WatcherFailure::Notify { error }, - }) if error == "backend failed" - )); - while let Ok(message) = loader.recv_timeout(Duration::from_millis(100)) { - assert!( - !matches!( - message, - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { - config_version: 1 - }) - ), - "failed watcher generation must never become ready" - ); - } - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn rescan_flag_reconciles_missed_files_and_watch_coverage() { - let dir = TestDir::new("vfs-loader-rescan-flag"); - let root = dir.join("workspace"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - let missed_dir = root.join("generated"); - let missed_file = missed_dir.join("new.sv"); - std::fs::create_dir_all(&missed_dir).unwrap(); - std::fs::write(&missed_file, "module new; endmodule\n").unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Other).set_flag(Flag::Rescan), - }) - .unwrap(); - - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected a full rescan after the backend rescan flag"); - }; - assert!(files.iter().any(|(path, result)| { - path == &missed_file - && matches!(result, LoadResult::Loaded(text, _) if text.contains("module new")) - })); - let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected missed directory coverage to be synchronized"); - }; - assert!(target_paths(&sync.targets).contains(&missed_dir)); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn rename_from_unloads_directory_and_removes_watch_coverage() { - let dir = TestDir::new("vfs-loader-rename-from"); - let root = dir.join("workspace"); - let moved_from = root.join("generated"); - let moved_file = moved_from.join("old.sv"); - let moved_to = dir.join("outside"); - std::fs::create_dir_all(&moved_from).unwrap(); - std::fs::write(&moved_file, "module old; endmodule\n").unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root))], - to_watch: vec![0], - })) - .unwrap(); - assert_loaded(&collect_until_progress_done(&loader, 1), &moved_file); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::rename(&moved_from, &moved_to).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Modify(ModifyKind::Name(RenameMode::From))) - .add_path(path_buf(&moved_from)), - }) - .unwrap(); - - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected moved directory contents to be unloaded"); - }; - assert_eq!(files, vec![(moved_file, LoadResult::LoadError)]); - let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected moved directory coverage to be removed"); - }; - assert!(!target_paths(&sync.targets).contains(&moved_from)); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn watcher_sync_has_only_one_mutation_in_flight() { - let dir = TestDir::new("vfs-loader-single-watch-batch"); - let root = dir.join("workspace"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - let first = root.join("first"); - std::fs::create_dir_all(&first).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&first)), - }) - .unwrap(); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let first_command = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - let WatcherCommand::Sync(first_sync) = &first_command else { - panic!("expected first full coverage sync"); - }; - assert_eq!(first_sync.config_version, 1); - assert!(target_paths(&first_sync.targets).contains(&first)); - - let second = root.join("second"); - std::fs::create_dir_all(&second).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&second)), - }) - .unwrap(); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!( - commands.recv_timeout(Duration::from_millis(100)).is_err(), - "a second coverage sync was sent before the first was acknowledged" - ); - - acknowledge_command(&first_command, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let WatcherCommand::Sync(second_sync) = - commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected the pending coverage sync after the first acknowledgement"); - }; - assert!(target_paths(&second_sync.targets).contains(&second)); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn recreated_directory_is_registered_again() { - let dir = TestDir::new("vfs-loader-recreated-watch-directory"); - let root = dir.join("workspace"); - let recreated = root.join("generated"); - std::fs::create_dir_all(&root).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::create_dir_all(&recreated).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&recreated)), - }) - .unwrap(); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let initial_add = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&initial_add, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::remove_dir(&recreated).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) - .add_path(path_buf(&recreated)), - }) - .unwrap(); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let removal_sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - - std::fs::create_dir_all(&recreated).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&recreated)), - }) - .unwrap(); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); - acknowledge_command(&removal_sync, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - let WatcherCommand::Sync(reinstall_sync) = - commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected recreated directory coverage sync"); - }; - let recreated_target = reinstall_sync - .targets - .iter() - .find(|target| target.path == recreated) - .expect("recreated directory should be in coverage"); - let original_revision = match initial_add { - WatcherCommand::Sync(sync) => { - sync.targets - .into_iter() - .find(|target| target.path == recreated) - .unwrap() - .registration_revision - } - WatcherCommand::Replace(_) | WatcherCommand::Abort { .. } => unreachable!(), - }; - assert_ne!(recreated_target.registration_revision, original_revision); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[cfg(unix)] - #[test] - fn directory_walk_errors_are_all_reported_before_terminal_progress() { - let dir = TestDir::new("vfs-loader-directory-walk-errors"); - let first_root = dir.join("first-workspace"); - let second_root = dir.join("second-workspace"); - for root in [&first_root, &second_root] { - std::fs::create_dir_all(root).unwrap(); - std::os::unix::fs::symlink(root, root.join("loop")).unwrap(); - } - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![ - loader::Entry::Directories(watched_sv_dir(first_root.clone())), - loader::Entry::Directories(watched_sv_dir(second_root.clone())), - ], - to_watch: vec![0, 1], - })) - .unwrap(); - let mut failed_roots = FxHashSet::default(); - loop { - match recv_version_message(&loader, 1) { - loader::Message::ScanFailed { - config_version: 1, - failure: loader::ScanFailure { root: failed_root, error }, - } => { - assert!(!error.is_empty()); - failed_roots.insert(failed_root); - } - loader::Message::Progress { n_done, n_total, .. } if n_done == n_total => { - assert_eq!( - failed_roots, - FxHashSet::from_iter([first_root.clone(), second_root.clone()]), - "every scan failure must be reported before terminal progress" - ); - break; - } - _ => {} - } - } - assert!(matches!( - commands.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherCommand::Abort { through_config_version: 1 } - )); - assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[cfg(unix)] - #[test] - fn new_generation_scan_failure_aborts_previous_watcher() { - let dir = TestDir::new("vfs-loader-new-scan-failure-abort"); - let initial_file = dir.join("initial.sv"); - std::fs::write(&initial_file, "module initial; endmodule\n").unwrap(); - let failed_root = dir.join("failed-workspace"); - std::fs::create_dir_all(&failed_root).unwrap(); - std::os::unix::fs::symlink(&failed_root, failed_root.join("loop")).unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![initial_file])], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - server - .send(ServerMsg::Config(loader::Config { - version: 2, - to_load: vec![loader::Entry::Directories(watched_sv_dir(failed_root))], - to_watch: vec![0], - })) - .unwrap(); - let mut failure_seen = false; - loop { - match recv_version_message(&loader, 2) { - loader::Message::ScanFailed { .. } => failure_seen = true, - loader::Message::Progress { n_done, n_total, .. } if n_done == n_total => { - assert!(failure_seen); - break; - } - _ => {} - } - } - assert!(matches!( - commands.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherCommand::Abort { through_config_version: 2 } - )); - assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn invalid_walk_path_is_a_structured_scan_failure() { - let dir = TestDir::new("vfs-loader-invalid-walk-path"); - let root = dir.join("workspace"); - let failure = absolute_scan_path(&root, std::path::PathBuf::from("relative")).unwrap_err(); - assert!(matches!( - failure, - loader::ScanFailure { root: failed_root, error } - if failed_root == root && error.contains("relative") - )); - } - - #[test] - fn manager_reregisters_a_recreated_path_with_a_new_revision() { - let dir = TestDir::new("vfs-loader-manager-reregister"); - let watched_path = dir.join("generated"); - std::fs::create_dir_all(&watched_path).unwrap(); - let calls = Arc::new(Mutex::new(RecordedWatchCalls::default())); - let manager_calls = Arc::clone(&calls); - let manager = spawn_watcher_manager_with::(move |_, _| { - Ok(RecordingWatcher { calls: Arc::clone(&manager_calls) }) - }) - .unwrap(); - - manager - .command_sender - .send(WatcherCommand::Replace(WatchPlan { - config_version: 1, - coverage_revision: 1, - targets: vec![WatchTarget { path: watched_path.clone(), registration_revision: 1 }], - })) - .unwrap(); - assert!(matches!( - manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherOutput::Installed { config_version: 1, coverage_revision: 1 } - )); - - manager - .command_sender - .send(WatcherCommand::Sync(WatchSync { - config_version: 1, - coverage_revision: 2, - targets: vec![WatchTarget { path: watched_path.clone(), registration_revision: 2 }], - })) - .unwrap(); - assert!(matches!( - manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherOutput::Synced { config_version: 1, coverage_revision: 2 } - )); - - let calls = calls.lock().unwrap(); - assert_eq!(calls.watched, vec![path_buf(&watched_path), path_buf(&watched_path)]); - assert_eq!(calls.unwatched, vec![path_buf(&watched_path)]); - } - - #[test] - fn manager_abort_drops_an_older_active_generation() { - let dir = TestDir::new("vfs-loader-manager-abort"); - let watched_path = dir.join("workspace"); - std::fs::create_dir_all(&watched_path).unwrap(); - let calls = Arc::new(Mutex::new(RecordedWatchCalls::default())); - let manager_calls = Arc::clone(&calls); - let manager = spawn_watcher_manager_with::(move |_, _| { - Ok(RecordingWatcher { calls: Arc::clone(&manager_calls) }) - }) - .unwrap(); - - manager - .command_sender - .send(WatcherCommand::Replace(WatchPlan { - config_version: 1, - coverage_revision: 1, - targets: vec![WatchTarget { path: watched_path, registration_revision: 1 }], - })) - .unwrap(); - assert!(matches!( - manager.output_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), - WatcherOutput::Installed { config_version: 1, coverage_revision: 1 } - )); - - manager.command_sender.send(WatcherCommand::Abort { through_config_version: 2 }).unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - while calls.lock().unwrap().drops == 0 && Instant::now() < deadline { - std::thread::yield_now(); - } - assert_eq!(calls.lock().unwrap().drops, 1); - } - - #[test] - fn exact_file_uses_parent_anchor_across_delete_and_recreate() { - let dir = TestDir::new("vfs-loader-exact-file-anchor"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module first; endmodule\n").unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - let WatcherCommand::Replace(plan) = &install else { - panic!("expected initial watcher plan"); - }; - assert_eq!(target_paths(&plan.targets), vec![file.parent().unwrap().to_owned()]); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::remove_file(&file).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Remove(RemoveKind::File)) - .add_path(path_buf(&file)), - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected exact file unload"); - }; - assert_eq!(files, vec![(file.clone(), LoadResult::LoadError)]); - - std::fs::write(&file, "module second; endmodule\n").unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::File)) - .add_path(path_buf(&file)), - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected recreated exact file load"); - }; - assert!(matches!( - files.as_slice(), - [(path, LoadResult::Loaded(text, _))] - if path == &file && text.contains("module second") - )); - assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); - - std::fs::remove_file(&file).unwrap(); - std::fs::write(&file, "module third; endmodule\n").unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Remove(RemoveKind::File)) - .add_path(path_buf(&file)), - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected delayed remove to observe the recreated exact file"); - }; - assert!( - files - .iter() - .any(|(path, result)| { path == &file && matches!(result, LoadResult::LoadError) }) - ); - assert!(matches!( - files.iter().rev().find(|(path, _)| path == &file), - Some((_, LoadResult::Loaded(text, _))) if text.contains("module third") - )); - assert!(commands.recv_timeout(Duration::from_millis(100)).is_err()); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn directory_root_uses_parent_anchor_across_delete_and_recreate() { - let dir = TestDir::new("vfs-loader-directory-root-anchor"); - let root = dir.join("workspace"); - let file = root.join("top.sv"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(&file, "module first; endmodule\n").unwrap(); - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(watched_sv_dir(root.clone()))], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - let install = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - let WatcherCommand::Replace(plan) = &install else { - panic!("expected initial watcher plan"); - }; - assert!(target_paths(&plan.targets).contains(&root.parent().unwrap().to_owned())); - acknowledge_command(&install, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::remove_dir_all(&root).unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) - .add_path(path_buf(&root)), - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected directory root unload"); - }; - assert_eq!(files, vec![(file.clone(), LoadResult::LoadError)]); - let removal_sync = commands.recv_timeout(Duration::from_secs(1)).unwrap(); - let WatcherCommand::Sync(sync) = &removal_sync else { - panic!("expected root removal coverage sync"); - }; - assert!(!target_paths(&sync.targets).contains(&root)); - assert!(target_paths(&sync.targets).contains(&root.parent().unwrap().to_owned())); - acknowledge_command(&removal_sync, &output); - assert!(matches!(recv_version_message(&loader, 1), loader::Message::Changed { .. })); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Ready { config_version: 1 }) - )); - - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(&file, "module second; endmodule\n").unwrap(); - output - .send(WatcherOutput::Notify { - config_version: 1, - event: NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&root)), - }) - .unwrap(); - let loader::Message::Changed { files, .. } = recv_version_message(&loader, 1) else { - panic!("expected recreated directory root load"); - }; - assert!(files.iter().any(|(path, result)| { - path == &file - && matches!(result, LoadResult::Loaded(text, _) if text.contains("module second")) - })); - let WatcherCommand::Sync(sync) = commands.recv_timeout(Duration::from_secs(1)).unwrap() - else { - panic!("expected recreated directory root coverage sync"); - }; - assert!(target_paths(&sync.targets).contains(&root)); - - drop(server); - drop(output); - actor_thread.join().unwrap(); - } - - #[test] - fn watcher_failure_is_reported_structurally() { - let dir = TestDir::new("vfs-loader-watcher-failure"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let manager = spawn_watcher_manager_with::(|_, _| { - Err(::notify::Error::generic("injected creation failure")) - }) - .unwrap(); - let (loader_sender, loader) = unbounded(); - let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); - let (server, server_receiver) = unbounded(); - let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file])], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Failed { - config_version: 1, - failure: loader::WatcherFailure::Create { error }, - }) if error == "injected creation failure" - )); - - drop(server); - actor_thread.join().unwrap(); - } - - #[test] - fn watcher_path_failure_drops_generation_and_is_reported_structurally() { - let dir = TestDir::new("vfs-loader-watch-path-failure"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let manager = - spawn_watcher_manager_with::(|_, _| Ok(RejectingWatcher)).unwrap(); - let (loader_sender, loader) = unbounded(); - let actor = NotifyActor::new_with_manager(loader_sender, Some(manager)); - let (server, server_receiver) = unbounded(); - let actor_thread = std::thread::spawn(move || actor.run(server_receiver)); - - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: vec![0], - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Failed { - config_version: 1, - failure: loader::WatcherFailure::Watch { path, error }, - }) if path.as_path() == file.parent().unwrap() - && error == "injected watch failure" - )); - - drop(server); - actor_thread.join().unwrap(); - } - - #[test] - fn stopped_watcher_manager_is_reported_structurally() { - let FakeWatcherHarness { server, loader, commands, output, actor_thread } = - spawn_actor_with_fake_manager(); - server - .send(ServerMsg::Config(loader::Config { - version: 1, - to_load: Vec::new(), - to_watch: Vec::new(), - })) - .unwrap(); - collect_until_progress_done(&loader, 1); - commands.recv_timeout(Duration::from_secs(1)).unwrap(); - - drop(output); - assert!(matches!( - recv_version_message(&loader, 1), - loader::Message::WatcherStatus(loader::WatcherStatus::Failed { - config_version: 1, - failure: loader::WatcherFailure::Stopped { error }, - }) if error == "server file watcher manager stopped" - )); - - drop(server); - actor_thread.join().unwrap(); - } - #[test] fn empty_config_emits_ready_ack_progress() { let (mut handle, receiver) = spawn_loader(); @@ -2635,12 +773,10 @@ mod tests { let mut actor = actor(); actor.watched_dirs.push(watched_sv_dir(root)); - let files = actor - .process_notify_event( - NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&created_dir)), - ) - .unwrap(); + let files = actor.process_notify_event( + NotifyEvent::new(EventKind::Create(CreateKind::Folder)) + .add_path(path_buf(&created_dir)), + ); actor.record_loaded_files(&files); assert!( @@ -2656,6 +792,33 @@ mod tests { assert!(actor.loaded_paths.contains(&child)); } + #[test] + fn invalidate_reports_changed_not_loaded() { + let dir = TestDir::new("vfs-loader-invalidate-changed"); + let file = dir.join("top.sv"); + std::fs::write(&file, "module top; endmodule\n").unwrap(); + let (mut handle, receiver) = spawn_loader(); + + handle.set_config(loader::Config { + version: 1, + to_load: vec![loader::Entry::Files(vec![file.clone()])], + to_watch: Vec::new(), + }); + collect_until_progress_done(&receiver, 1); + + std::fs::write(&file, "module top2; endmodule\n").unwrap(); + handle.invalidate(file.clone()); + + let loader::Message::Changed { files, config_version } = recv_version_message(&receiver, 1) + else { + panic!("invalidate must report Changed, not Loaded"); + }; + assert_eq!(config_version, 1); + assert!(files.iter().any(|(path, result)| { + path == &file && matches!(result, LoadResult::Loaded(text, _) if text.contains("top2")) + })); + } + #[test] fn removed_watched_directory_unloads_loaded_descendants() { let dir = TestDir::new("vfs-loader-removed-directory-unload"); @@ -2668,12 +831,10 @@ mod tests { actor.watched_dirs.push(watched_sv_dir(root)); actor.loaded_paths.extend([top.clone(), child.clone(), sibling.clone()]); - let mut files = actor - .process_notify_event( - NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) - .add_path(path_buf(&removed_dir)), - ) - .unwrap(); + let mut files = actor.process_notify_event( + NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) + .add_path(path_buf(&removed_dir)), + ); files.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); actor.record_loaded_files(&files); diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index f64a6c15..037091d5 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -39,8 +39,6 @@ impl Event { Event::Vfs(vfs_loader::Message::Progress { .. }) => "vfs.progress", Event::Vfs(vfs_loader::Message::Loaded { .. }) => "vfs.loaded", Event::Vfs(vfs_loader::Message::Changed { .. }) => "vfs.changed", - Event::Vfs(vfs_loader::Message::ScanFailed { .. }) => "vfs.scan_failed", - Event::Vfs(vfs_loader::Message::WatcherStatus(_)) => "vfs.watcher_status", } } @@ -65,22 +63,6 @@ impl Event { Event::Vfs(vfs_loader::Message::Changed { files, .. }) => { format!("vfs changed files={}", files.len()) } - Event::Vfs(vfs_loader::Message::ScanFailed { config_version, failure }) => { - format!( - "vfs scan failed config_version={config_version} root={} error={}", - failure.root, failure.error - ) - } - Event::Vfs(vfs_loader::Message::WatcherStatus(status)) => match status { - vfs_loader::WatcherStatus::Ready { config_version } => { - format!("vfs watcher ready config_version={config_version}") - } - vfs_loader::WatcherStatus::Failed { config_version, failure } => { - format!( - "vfs watcher failed config_version={config_version} failure={failure:?}" - ) - } - }, } } } @@ -418,52 +400,6 @@ impl GlobalState { vfs_loader::Message::Changed { files, config_version } => { self.process_vfs_files(files, config_version, VfsFileSource::ExternalChange); } - vfs_loader::Message::ScanFailed { config_version, failure } => { - let current_config_version = - self.workspace.workspace_vfs.current_vfs_config_version(); - always!(config_version <= current_config_version); - if !self.workspace.workspace_vfs.fail_vfs_scan(config_version) { - tracing::debug!( - config_version, - current_config_version, - %failure.root, - error = %failure.error, - "stale VFS scan failure ignored" - ); - return; - } - - tracing::error!( - config_version, - %failure.root, - error = %failure.error, - "VFS content scan failed; workspace remains unavailable" - ); - } - vfs_loader::Message::WatcherStatus(status) => { - let config_version = status.config_version(); - let current_config_version = - self.workspace.workspace_vfs.current_vfs_config_version(); - always!(config_version <= current_config_version); - if config_version != current_config_version { - tracing::debug!( - config_version, - current_config_version, - ?status, - "stale server file watcher status ignored" - ); - return; - } - - match status { - vfs_loader::WatcherStatus::Ready { .. } => { - tracing::info!(config_version, "server file watcher is ready"); - } - vfs_loader::WatcherStatus::Failed { failure, .. } => { - tracing::error!(config_version, ?failure, "server file watcher failed"); - } - } - } } } diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index d35f0e85..7c3271ed 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -369,37 +369,6 @@ mod tests { assert!(client.receiver.recv_timeout(Duration::from_millis(50)).is_err()); } - #[test] - fn scan_failure_keeps_terminal_vfs_generation_unready() { - let root = TestDir::new("failed-vfs-scan-stays-unready"); - let root_path = root.path().to_path_buf(); - let mut state = test_state(root_path.clone()); - let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; - - state.process_vfs_msg(vfs_loader::Message::ScanFailed { - config_version, - failure: vfs_loader::ScanFailure { - root: root_path, - error: "injected scan failure".to_owned(), - }, - }); - state.process_vfs_msg(vfs_loader::Message::Progress { - n_total: 1, - n_done: 1, - config_version, - }); - - assert!(!state.workspace.workspace_vfs.is_ready()); - - let next_version = state.workspace.workspace_vfs.begin_vfs_load(0).config_version; - state.process_vfs_msg(vfs_loader::Message::Progress { - n_total: 0, - n_done: 0, - config_version: next_version, - }); - assert!(state.workspace.workspace_vfs.is_ready()); - } - #[test] fn diagnostic_request_gets_terminal_fallback_before_workspace_ready() { let root = TestDir::new("diagnostic-request-readiness-fallback"); diff --git a/src/global_state/workspace_state.rs b/src/global_state/workspace_state.rs index 3f515ebd..da04b785 100644 --- a/src/global_state/workspace_state.rs +++ b/src/global_state/workspace_state.rs @@ -49,7 +49,6 @@ pub(crate) struct WorkspaceVfsReadiness { vfs_config_version: u32, vfs_progress: VfsProgress, vfs_ready: bool, - vfs_scan_failed: bool, vfs_client_progress_active: bool, diagnostic_readiness_revision: u64, diagnostics_deferred_until_ready: bool, @@ -65,7 +64,6 @@ impl Default for WorkspaceVfsReadiness { vfs_config_version: 0, vfs_progress: VfsProgress::default(), vfs_ready: true, - vfs_scan_failed: false, vfs_client_progress_active: false, diagnostic_readiness_revision: 0, diagnostics_deferred_until_ready: false, @@ -135,7 +133,6 @@ impl WorkspaceVfsReadiness { self.vfs_progress = VfsProgress { config_version: self.vfs_config_version, n_done: 0, n_total }; self.vfs_ready = false; - self.vfs_scan_failed = false; self.vfs_client_progress_active = false; BeginVfsLoad { config_version: self.vfs_config_version, superseded_client_progress_active } } @@ -154,20 +151,10 @@ impl WorkspaceVfsReadiness { } self.vfs_progress = VfsProgress { config_version, n_done, n_total }; - self.vfs_ready = !self.vfs_progress.in_progress() && !self.vfs_scan_failed; + self.vfs_ready = !self.vfs_progress.in_progress(); Some(self.vfs_progress) } - pub(crate) fn fail_vfs_scan(&mut self, config_version: u32) -> bool { - if config_version != self.vfs_config_version { - return false; - } - - self.vfs_scan_failed = true; - self.vfs_ready = false; - true - } - pub(crate) fn note_vfs_client_progress_started(&mut self) { self.vfs_client_progress_active = true; } From e5ba7f86c900aa60a0e6f4bed2c26731a69c78e5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 18:54:04 +0800 Subject: [PATCH 07/13] refactor(vfs): vendor rust-analyzer VFS (phase 0) Replace the self-grown vfs crate with an owned tinymist-style fork of rust-analyzer's `vfs` + `vfs-notify`, pinned to commit 5300ee2665. Adaptations for vide so far: - map `paths`/`stdx` onto `utils` - feature-gate the notify backend - document upstream and fork policy in UPSTREAM.md Hardlink identity is gone with the old Vfs implementation. Call sites across hir/ide/host still need a Phase 1 cutover to the new API (`Option>`, r-a Change shape, r-a loader Config). --- crates/vfs/Cargo.toml | 12 +- crates/vfs/UPSTREAM.md | 29 + crates/vfs/src/anchored_path.rs | 44 +- crates/vfs/src/file_set.rs | 393 ++++--------- crates/vfs/src/file_set/tests.rs | 65 +++ crates/vfs/src/lib.rs | 351 ++++++++++- crates/vfs/src/loader.rs | 228 +++++--- crates/vfs/src/notify.rs | 958 ++++++++----------------------- crates/vfs/src/path_glob.rs | 70 --- crates/vfs/src/path_interner.rs | 43 ++ crates/vfs/src/test_support.rs | 53 -- crates/vfs/src/vfs.rs | 597 ------------------- crates/vfs/src/vfs_path.rs | 198 +++++-- crates/vfs/src/vfs_path/tests.rs | 30 + 14 files changed, 1216 insertions(+), 1855 deletions(-) create mode 100644 crates/vfs/UPSTREAM.md create mode 100644 crates/vfs/src/file_set/tests.rs delete mode 100644 crates/vfs/src/path_glob.rs create mode 100644 crates/vfs/src/path_interner.rs delete mode 100644 crates/vfs/src/test_support.rs delete mode 100644 crates/vfs/src/vfs.rs create mode 100644 crates/vfs/src/vfs_path/tests.rs diff --git a/crates/vfs/Cargo.toml b/crates/vfs/Cargo.toml index 14640a29..f05a7665 100644 --- a/crates/vfs/Cargo.toml +++ b/crates/vfs/Cargo.toml @@ -1,26 +1,22 @@ [package] name = "vfs" version = "0.0.0" -description = "Isolating high level crates from the file-system, preventing them from directly interacting with the underlying system." - +description = "Owned fork of rust-analyzer VFS: path-interned files and async loader/watcher." edition.workspace = true [features] default = ["notify-backend"] -notify-backend = ["dep:itertools", "dep:notify", "dep:rayon", "dep:tracing", "dep:walkdir"] +notify-backend = ["dep:notify", "dep:rayon", "dep:walkdir"] [dependencies] crossbeam-channel.workspace = true fst = "0.4.7" -globset.workspace = true -indexmap = "2.0.0" -itertools = { workspace = true, optional = true } +indexmap = { version = "2.0.0", features = [] } nohash-hasher.workspace = true notify = { version = "6.1.1", optional = true } rayon = { version = "1.10.0", optional = true } rustc-hash.workspace = true -tracing = { workspace = true, optional = true } -triomphe.workspace = true +tracing.workspace = true utils.workspace = true walkdir = { version = "2.4.0", optional = true } diff --git a/crates/vfs/UPSTREAM.md b/crates/vfs/UPSTREAM.md new file mode 100644 index 00000000..2f7269df --- /dev/null +++ b/crates/vfs/UPSTREAM.md @@ -0,0 +1,29 @@ +# Upstream: rust-analyzer VFS + +This crate is an owned fork of rust-analyzer's virtual file system, following the +same approach as tinymist-vfs: copy upstream code, adapt to vide, do not track +`ra_ap_*` crates.io packages. + +## Sources + +| Component | Upstream path | +|-----------|---------------| +| Core VFS / loader / FileSet | https://github.com/rust-lang/rust-analyzer/tree/master/crates/vfs | +| Notify loader backend | https://github.com/rust-lang/rust-analyzer/tree/master/crates/vfs-notify | + +- **Pinned commit:** `5300ee266534f8c68065285de005759c58ac7883` +- **Upstream licenses:** Apache-2.0 OR MIT (rust-analyzer) +- **Vide modifications:** owned by PASCAL Research Group / vide contributors (MIT) + +## Intentional fork deltas (vide) + +- Map `paths` / `stdx` onto `utils` instead of rust-analyzer workspace crates +- Feature-gate notify backend for wasm (`notify-backend`) +- No VFS hardlink / path-identity redirects (removed relative to prior vide Vfs) +- SV-oriented loader configuration is supplied by callers (extensions, roots) + +## Sync policy + +Manual only. Prefer pulling structural bugfixes from rust-analyzer; do **not** +reintroduce watcher completeness state machines or generation-scoped readiness +coupled to OS notify. diff --git a/crates/vfs/src/anchored_path.rs b/crates/vfs/src/anchored_path.rs index 55e439c5..1b9fb355 100644 --- a/crates/vfs/src/anchored_path.rs +++ b/crates/vfs/src/anchored_path.rs @@ -1,13 +1,49 @@ -use crate::vfs::FileId; +//! Analysis-level representation of file-system paths. +//! +//! The primary goal of this is to losslessly represent paths like +//! +//! ```ignore +//! #[path = "./bar.rs"] +//! mod foo; +//! ``` +//! +//! The first approach one might reach for is to use `PathBuf`. The problem here +//! is that `PathBuf` depends on host target (windows or linux), but +//! rust-analyzer should be capable to process `#[path = r"C:\bar.rs"]` on Unix. +//! +//! The second try is to use a `String`. This also fails, however. Consider a +//! hypothetical scenario, where rust-analyzer operates in a +//! networked/distributed mode. There's one global instance of rust-analyzer, +//! which processes requests from different machines. Now, the semantics of +//! `#[path = "/abs/path.rs"]` actually depends on which file-system we are at! +//! That is, even absolute paths exist relative to a file system! +//! +//! A more realistic scenario here is virtual VFS paths we use for testing. More +//! generally, there can be separate "universes" of VFS paths. +//! +//! That's why we use anchored representation -- each path carries an info about +//! a file this path originates from. We can fetch fs/"universe" information +//! from the anchor than. +use crate::FileId; +/// Path relative to a file. +/// +/// Owned version of [`AnchoredPath`]. #[derive(Clone, PartialEq, Eq, Debug)] pub struct AnchoredPathBuf { - pub anchor_id: FileId, + /// File that this path is relative to. + pub anchor: FileId, + /// Path relative to `anchor`'s containing directory. pub path: String, } -#[derive(Clone, PartialEq, Eq, Debug)] +/// Path relative to a file. +/// +/// Borrowed version of [`AnchoredPathBuf`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct AnchoredPath<'a> { - pub anchor_id: FileId, + /// File that this path is relative to. + pub anchor: FileId, + /// Path relative to `anchor`'s containing directory. pub path: &'a str, } diff --git a/crates/vfs/src/file_set.rs b/crates/vfs/src/file_set.rs index 0da2cfbc..0c41ede5 100644 --- a/crates/vfs/src/file_set.rs +++ b/crates/vfs/src/file_set.rs @@ -1,171 +1,96 @@ +//! Partitions a list of files into disjoint subsets. +//! +//! Files which do not belong to any explicitly configured `FileSet` belong to +//! the default `FileSet`. +use std::fmt; + use fst::{IntoStreamer, Streamer}; -use nohash_hasher::IntMap; -use rustc_hash::{FxHashMap, FxHashSet}; -use utils::paths::{AbsPath, AbsPathBuf}; +use indexmap::IndexMap; +use rustc_hash::{FxBuildHasher, FxHashMap}; -use crate::{ - anchored_path::AnchoredPath, - path_glob::PathGlobMatcher, - vfs::{FileId, Vfs}, - vfs_path::VfsPath, -}; +use crate::{AnchoredPath, FileId, Vfs, VfsPath}; -/// Bidirectional path map for a single source root. -/// -/// A `FileSet` stores the path spelling selected during source-root -/// partitioning. That spelling may be a VFS alias rather than the primary path -/// if the alias is the one that belongs to this root. -#[derive(Debug, Default, Clone, Eq, PartialEq)] +/// A set of [`VfsPath`]s identified by [`FileId`]s. +#[derive(Default, Clone, Eq, PartialEq)] pub struct FileSet { files: FxHashMap, - paths: IntMap, + paths: IndexMap, } impl FileSet { + /// Returns the number of stored paths. pub fn len(&self) -> usize { self.files.len() } - pub fn is_empty(&self) -> bool { - self.files.is_empty() + /// Get the id of the file corresponding to `path`. + /// + /// If either `path`'s [`anchor`](AnchoredPath::anchor) or the resolved path is not in + /// the set, returns [`None`]. + pub fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { + let mut base = self.paths[&path.anchor].clone(); + base.pop(); + let path = base.join(path.path)?; + self.files.get(&path).copied() } - pub fn get_file(&self, path: &VfsPath) -> Option<&FileId> { + /// Get the id corresponding to `path` if it exists in the set. + pub fn file_for_path(&self, path: &VfsPath) -> Option<&FileId> { self.files.get(path) } - pub fn get_path(&self, file: &FileId) -> Option<&VfsPath> { + /// Get the path corresponding to `file` if it exists in the set. + pub fn path_for_file(&self, file: &FileId) -> Option<&VfsPath> { self.paths.get(file) } - pub fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let mut base = self.paths.get(&path.anchor_id)?.clone(); - base.pop(); - let path = base.join(path.path)?; - self.files.get(&path).copied() - } - + /// Insert the `file_id, path` pair into the set. + /// + /// # Note + /// Multiple [`FileId`] can be mapped to the same [`VfsPath`], and vice-versa. pub fn insert(&mut self, file_id: FileId, path: VfsPath) { self.files.insert(path.clone(), file_id); self.paths.insert(file_id, path); } + /// Iterate over this set's ids. pub fn iter(&self) -> impl Iterator + '_ { self.paths.keys().copied() } } -/// Rules for partitioning VFS files into ordered source roots. +impl fmt::Debug for FileSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FileSet").field("n_files", &self.files.len()).finish() + } +} + +/// This contains path prefixes to partition a [`Vfs`] into [`FileSet`]s. /// -/// Root order is significant. For one VFS file with several aliases, -/// classification evaluates every alias and chooses the earliest non-ignored -/// root. Within a single alias, normal prefix matching still picks the most -/// specific configured root. +/// # Example +/// ```rust +/// # use vfs::{file_set::FileSetConfigBuilder, VfsPath, Vfs}; +/// let mut builder = FileSetConfigBuilder::default(); +/// builder.add_file_set(vec![VfsPath::new_virtual_path("/src".to_string())]); +/// let config = builder.build(); +/// let mut file_system = Vfs::default(); +/// file_system.set_file_contents(VfsPath::new_virtual_path("/src/main.rs".to_string()), Some(vec![])); +/// file_system.set_file_contents(VfsPath::new_virtual_path("/src/lib.rs".to_string()), Some(vec![])); +/// file_system.set_file_contents(VfsPath::new_virtual_path("/build.rs".to_string()), Some(vec![])); +/// // contains the sets : +/// // { "/src/main.rs", "/src/lib.rs" } +/// // { "build.rs" } +/// let sets = config.partition(&file_system); +/// ``` #[derive(Debug)] pub struct FileSetConfig { - // Number of sets that can partition into. - // This should be `self.map.len() + 1` for files that don't fit in any defined set. - len: usize, - // Encoded paths -> sets they belong to. + /// Number of sets that `self` can partition a [`Vfs`] into. + /// + /// This should be the number of sets in `self.map` + 1 for files that don't fit in any + /// defined set. + n_file_sets: usize, + /// Map from encoded paths to the set they belong to. map: fst::Map>, - filters: Vec, -} - -/// A source-root partition plus the subset that matched semantic source rules. -#[derive(Debug, Default, Clone, Eq, PartialEq)] -pub struct PartitionedFileSet { - pub file_set: FileSet, - pub source_files: Option>, -} - -/// Matcher used separately for source classification, directory scans, and -/// include/search roots. -/// -/// Keeping those roles separate lets exact source files participate in source -/// ownership without forcing the loader or watcher to scan their parent -/// directories recursively. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct PathMatcher { - scan_roots: Vec, - kind: PathMatcherKind, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -enum PathMatcherKind { - AllUnderRoots, - Glob(PathGlobMatcher), -} - -impl Default for PathMatcher { - fn default() -> Self { - Self::all_under_roots(Vec::new()) - } -} - -impl PathMatcher { - pub fn all_under_roots(scan_roots: Vec) -> Self { - Self { scan_roots, kind: PathMatcherKind::AllUnderRoots } - } - - pub fn glob(scan_roots: Vec, matcher: PathGlobMatcher) -> Self { - Self { scan_roots, kind: PathMatcherKind::Glob(matcher) } - } - - pub fn is_empty(&self) -> bool { - self.scan_roots.is_empty() - } - - pub fn contains_file(&self, path: &AbsPath) -> bool { - self.contains_scan_root(path) - && match &self.kind { - PathMatcherKind::AllUnderRoots => true, - PathMatcherKind::Glob(matcher) => matcher.is_match(path), - } - } - - pub fn contains_dir(&self, path: &AbsPath) -> bool { - self.contains_scan_root(path) - } - - pub fn scan_roots(&self) -> impl Iterator { - self.scan_roots.iter() - } - - fn contains_scan_root(&self, path: &AbsPath) -> bool { - self.scan_roots.iter().any(|root| path.starts_with(root)) - } -} - -/// Include/source/exclude policy for one file-set root. -#[derive(Debug, Default, Clone, Eq, PartialEq)] -pub struct FileSetFilter { - pub include: Vec, - pub source: Option>, - pub exclude_paths: Vec, - pub exclude_globs: Option, -} - -impl FileSetFilter { - fn matches(&self, path: &VfsPath) -> bool { - let Some(path) = path.as_abs_path() else { - return self.include.is_empty() && self.exclude_paths.is_empty(); - }; - self.include.iter().any(|include| include.contains_file(path)) - && !self.exclude_paths.iter().any(|exclude| path.starts_with(exclude)) - && !self.exclude_globs.as_ref().is_some_and(|exclude| exclude.is_match(path)) - } - - fn is_source(&self, path: &VfsPath) -> bool { - let Some(source) = &self.source else { - return false; - }; - let Some(path) = path.as_abs_path() else { - return false; - }; - source.iter().any(|source| source.contains_file(path)) - && !self.exclude_paths.iter().any(|exclude| path.starts_with(exclude)) - && !self.exclude_globs.as_ref().is_some_and(|exclude| exclude.is_match(path)) - } } impl Default for FileSetConfig { @@ -175,68 +100,49 @@ impl Default for FileSetConfig { } impl FileSetConfig { + /// Returns a builder for `FileSetConfig`. pub fn builder() -> FileSetConfigBuilder { FileSetConfigBuilder::default() } + /// Partition `vfs` into `FileSet`s. + /// + /// Creates a new [`FileSet`] for every set of prefixes in `self`. pub fn partition(&self, vfs: &Vfs) -> Vec { - self.partition_with_source(vfs).into_iter().map(|partition| partition.file_set).collect() - } - - pub fn partition_with_source(&self, vfs: &Vfs) -> Vec { let mut scratch_space = Vec::new(); - let mut set = (0..self.len) - .map(|idx| PartitionedFileSet { - file_set: FileSet::default(), - source_files: self - .filters - .get(idx) - .and_then(|filter| filter.source.as_ref().map(|_| FxHashSet::default())), - }) - .collect::>(); + let mut res = vec![FileSet::default(); self.len()]; for (file_id, path) in vfs.iter() { - let (root, path) = self.classify_vfs_file(vfs, file_id, path, &mut scratch_space); - if let Some(partition) = set.get_mut(root) { - partition.file_set.insert(file_id, path.clone()); - if self.filters.get(root).is_some_and(|filter| filter.is_source(path)) - && let Some(source_files) = &mut partition.source_files - { - source_files.insert(file_id); - } - } + let root = self.classify(path, &mut scratch_space); + res[root].insert(file_id, path.clone()); } - set + res } - fn classify_vfs_file<'a>( - &self, - vfs: &'a Vfs, - file_id: FileId, - primary_path: &'a VfsPath, - scratch_space: &mut Vec, - ) -> (usize, &'a VfsPath) { - let ignored = self.len - 1; - let mut best = None; - for path in vfs.file_paths(file_id) { - let root = self.classify(path, scratch_space); - if root != ignored && best.is_none_or(|(best_root, _)| root < best_root) { - best = Some((root, path)); - } - } - best.unwrap_or((ignored, primary_path)) + /// Number of sets that `self` can partition a [`Vfs`] into. + fn len(&self) -> usize { + self.n_file_sets + } + + /// Get the lexicographically ordered vector of the underlying map. + pub fn roots(&self) -> Vec<(Vec, u64)> { + self.map.stream().into_byte_vec() } + /// Returns the set index for the given `path`. + /// + /// `scratch_space` is used as a buffer and will be entirely replaced. fn classify(&self, path: &VfsPath, scratch_space: &mut Vec) -> usize { + // `path` is a file, but r-a only cares about the containing directory. We don't + // want `/foo/bar_baz.rs` to be attributed to source root directory `/foo/bar`. + let path = path.parent().unwrap_or_else(|| path.clone()); + scratch_space.clear(); path.encode(scratch_space); let automaton = PrefixOf::new(scratch_space.as_slice()); - let mut longest_prefix = self.len - 1; + let mut longest_prefix = self.len() - 1; let mut stream = self.map.search(automaton).into_stream(); while let Some((_, v)) = stream.next() { - let idx = v as usize; - if self.filters.get(idx).is_some_and(|filter| filter.matches(path)) { - longest_prefix = idx; - } + longest_prefix = v as usize; } longest_prefix } @@ -245,61 +151,43 @@ impl FileSetConfig { /// Builder for [`FileSetConfig`]. #[derive(Default)] pub struct FileSetConfigBuilder { - roots: Vec, -} - -struct FileSetSpec { - roots: Vec, - filter: FileSetFilter, + roots: Vec>, } impl FileSetConfigBuilder { + /// Returns the number of sets currently held. pub fn len(&self) -> usize { self.roots.len() } + /// Add a new set of paths prefixes. pub fn add_file_set(&mut self, roots: Vec) { - let include_paths: Vec = roots - .iter() - .filter_map(|root| root.as_abs_path().map(|path| path.to_path_buf())) - .collect(); - let include = if include_paths.is_empty() { - Vec::new() - } else { - vec![PathMatcher::all_under_roots(include_paths)] - }; - self.add_filtered_file_set(roots, FileSetFilter { include, ..FileSetFilter::default() }); - } - - pub fn add_filtered_file_set(&mut self, roots: Vec, filter: FileSetFilter) { - self.roots.push(FileSetSpec { roots, filter }); + self.roots.push(roots); } + /// Build the `FileSetConfig`. pub fn build(self) -> FileSetConfig { - let len = self.roots.len() + 1; - let filters = self.roots.iter().map(|spec| spec.filter.clone()).collect(); - let mut entries = self - .roots - .into_iter() - .enumerate() - .flat_map(|(i, spec)| { - spec.roots.into_iter().map(move |p| { + let n_file_sets = self.roots.len() + 1; + let map = { + let mut entries = Vec::new(); + for (i, paths) in self.roots.into_iter().enumerate() { + for p in paths { let mut buf = Vec::new(); p.encode(&mut buf); - (buf, i as u64) - }) - }) - .collect::>(); - - // make sure that the longer one comes later - entries.sort(); - entries.dedup_by(|(a, _), (b, _)| a == b); - - FileSetConfig { len, map: fst::Map::from_iter(entries).unwrap_or_default(), filters } + entries.push((buf, i as u64)); + } + } + entries.sort(); + entries.dedup_by(|(a, _), (b, _)| a == b); + fst::Map::from_iter(entries).unwrap() + }; + FileSetConfig { n_file_sets, map } } } -// It will match if `prefix_of` is a prefix of the given data. +/// Implements [`fst::Automaton`] +/// +/// It will match if `prefix_of` is a prefix of the given data. struct PrefixOf<'a> { prefix_of: &'a [u8], } @@ -313,88 +201,19 @@ impl<'a> PrefixOf<'a> { impl fst::Automaton for PrefixOf<'_> { type State = usize; - fn start(&self) -> usize { 0 } - fn is_match(&self, &state: &usize) -> bool { state != !0 } - fn can_match(&self, &state: &usize) -> bool { state != !0 } - fn accept(&self, &state: &usize, byte: u8) -> usize { if self.prefix_of.get(state) == Some(&byte) { state + 1 } else { !0 } } } #[cfg(test)] -mod tests { - use utils::lines::LineEnding; - - use super::*; - use crate::{loader::LoadResult, test_support::TestDir}; - - #[test] - fn partition_uses_project_alias_when_first_ingress_was_another_path() { - let dir = TestDir::new("alias-partition"); - let workspace = dir.join("workspace"); - let source = dir.write("workspace/top.sv", "module top; endmodule\n"); - let alias = dir.join("workspace/../workspace/top.sv"); - - let alias_vfs_path = VfsPath::from(alias); - let source_vfs_path = VfsPath::from(source); - let mut vfs = Vfs::default(); - vfs.set_file_contents( - &alias_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let file_id = vfs.file_id(&alias_vfs_path).unwrap(); - vfs.set_file_contents( - &source_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - assert_eq!(vfs.file_id(&source_vfs_path), Some(file_id)); - - let mut builder = FileSetConfig::builder(); - builder.add_file_set(vec![VfsPath::from(workspace)]); - let partitions = builder.build().partition(&vfs); - - assert_eq!(partitions[0].get_path(&file_id), Some(&source_vfs_path)); - assert_eq!(partitions[1].get_path(&file_id), None); - } - - #[test] - fn partition_prefers_earlier_root_when_aliases_match_multiple_roots() { - let dir = TestDir::new("alias-root-priority"); - let local_root = dir.join("local"); - let library_root = dir.join("library"); - let local = dir.write("local/top.sv", "module top; endmodule\n"); - let library = dir.join("library/../local/top.sv"); - - let local_vfs_path = VfsPath::from(local); - let library_vfs_path = VfsPath::from(library); - let mut vfs = Vfs::default(); - vfs.set_file_contents( - &library_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let file_id = vfs.file_id(&library_vfs_path).unwrap(); - vfs.set_file_contents( - &local_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - - let mut builder = FileSetConfig::builder(); - builder.add_file_set(vec![VfsPath::from(local_root)]); - builder.add_file_set(vec![VfsPath::from(library_root)]); - let partitions = builder.build().partition(&vfs); - - assert_eq!(partitions[0].get_path(&file_id), Some(&local_vfs_path)); - assert_eq!(partitions[1].get_path(&file_id), None); - assert_eq!(partitions[2].get_path(&file_id), None); - } -} +mod tests; diff --git a/crates/vfs/src/file_set/tests.rs b/crates/vfs/src/file_set/tests.rs new file mode 100644 index 00000000..3cdb60dc --- /dev/null +++ b/crates/vfs/src/file_set/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn path_prefix() { + let mut file_set = FileSetConfig::builder(); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo".into())]); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo/bar/baz".into())]); + let file_set = file_set.build(); + + let mut vfs = Vfs::default(); + vfs.set_file_contents(VfsPath::new_virtual_path("/foo/src/lib.rs".into()), Some(Vec::new())); + vfs.set_file_contents( + VfsPath::new_virtual_path("/foo/src/bar/baz/lib.rs".into()), + Some(Vec::new()), + ); + vfs.set_file_contents( + VfsPath::new_virtual_path("/foo/bar/baz/lib.rs".into()), + Some(Vec::new()), + ); + vfs.set_file_contents(VfsPath::new_virtual_path("/quux/lib.rs".into()), Some(Vec::new())); + + let partition = file_set.partition(&vfs).into_iter().map(|it| it.len()).collect::>(); + assert_eq!(partition, vec![2, 1, 1]); +} + +#[test] +fn name_prefix() { + let mut file_set = FileSetConfig::builder(); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo".into())]); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo-things".into())]); + let file_set = file_set.build(); + + let mut vfs = Vfs::default(); + vfs.set_file_contents(VfsPath::new_virtual_path("/foo/src/lib.rs".into()), Some(Vec::new())); + vfs.set_file_contents( + VfsPath::new_virtual_path("/foo-things/src/lib.rs".into()), + Some(Vec::new()), + ); + + let partition = file_set.partition(&vfs).into_iter().map(|it| it.len()).collect::>(); + assert_eq!(partition, vec![1, 1, 0]); +} + +/// Ensure that we don't consider `/foo/bar_baz.rs` to be in the +/// `/foo/bar/` root. +#[test] +fn name_prefix_partially_matches() { + let mut file_set = FileSetConfig::builder(); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo".into())]); + file_set.add_file_set(vec![VfsPath::new_virtual_path("/foo/bar".into())]); + let file_set = file_set.build(); + + let mut vfs = Vfs::default(); + + // These two are both in /foo. + vfs.set_file_contents(VfsPath::new_virtual_path("/foo/lib.rs".into()), Some(Vec::new())); + vfs.set_file_contents(VfsPath::new_virtual_path("/foo/bar_baz.rs".into()), Some(Vec::new())); + + // Only this file is in /foo/bar. + vfs.set_file_contents(VfsPath::new_virtual_path("/foo/bar/biz.rs".into()), Some(Vec::new())); + + let partition = file_set.partition(&vfs).into_iter().map(|it| it.len()).collect::>(); + + assert_eq!(partition, vec![2, 1, 0]); +} diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 1e62c68b..8d723bb4 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -1,15 +1,346 @@ -pub mod anchored_path; -mod file_set; +//! Owned fork of rust-analyzer's virtual file system (tinymist-style). +//! +//! Upstream: +//! See `UPSTREAM.md` for the pinned commit and license notes. +//! +//! # Virtual File System +//! +//! VFS records all file changes pushed to it via [`set_file_contents`]. +//! As such it only ever stores changes, not the actual content of a file at any given moment. +//! All file changes are logged, and can be retrieved via +//! [`take_changes`] method. The pack of changes is then pushed to `salsa` and +//! triggers incremental recomputation. +//! +//! Files in VFS are identified with [`FileId`]s -- interned paths. The notion of +//! the path, [`VfsPath`] is somewhat abstract: at the moment, it is represented +//! as an [`std::path::PathBuf`] internally, but this is an implementation detail. +//! +//! VFS doesn't do IO or file watching itself. For that, see the [`loader`] +//! module. [`loader::Handle`] is an object-safe trait which abstracts both file +//! loading and file watching. [`Handle`] is dynamically configured with a set of +//! directory entries which should be scanned and watched. [`Handle`] then +//! asynchronously pushes file changes. Directory entries are configured in +//! free-form via list of globs, it's up to the [`Handle`] to interpret the globs +//! in any specific way. +//! +//! VFS stores a flat list of files. [`file_set::FileSet`] can partition this list +//! of files into disjoint sets of files. Traversal-like operations (including +//! getting the neighbor file by the relative path) are handled by the [`FileSet`]. +//! [`FileSet`]s are also pushed to salsa and cause it to re-check `mod foo;` +//! declarations when files are created or deleted. +//! +//! [`FileSet`] and [`loader::Entry`] play similar, but different roles. +//! Both specify the "set of paths/files", one is geared towards file watching, +//! the other towards salsa changes. In particular, single [`FileSet`] +//! may correspond to several [`loader::Entry`]. For example, a crate from +//! crates.io which uses code generation would have two [`Entries`] -- for sources +//! in `~/.cargo`, and for generated code in `./target/debug/build`. It will +//! have a single [`FileSet`] which unions the two sources. +//! +//! [`set_file_contents`]: Vfs::set_file_contents +//! [`take_changes`]: Vfs::take_changes +//! [`FileSet`]: file_set::FileSet +//! [`Handle`]: loader::Handle +//! [`Entries`]: loader::Entry + +mod anchored_path; +pub mod file_set; pub mod loader; #[cfg(feature = "notify-backend")] pub mod notify; -mod path_glob; -#[cfg(test)] -mod test_support; -mod vfs; +mod path_interner; mod vfs_path; -pub use file_set::{FileSet, FileSetConfig, FileSetFilter, PartitionedFileSet, PathMatcher}; -pub use path_glob::PathGlobMatcher; -pub use vfs::{ChangeKind, ChangedFile, FileId, FileState, Vfs}; -pub use vfs_path::VfsPath; +use std::{ + fmt, + hash::{BuildHasherDefault, Hash}, + mem, +}; + +use crate::path_interner::PathInterner; + +pub use crate::{ + anchored_path::{AnchoredPath, AnchoredPathBuf}, + vfs_path::VfsPath, +}; +use indexmap::{IndexMap, map::Entry}; +pub use utils::paths::{AbsPath, AbsPathBuf}; + +use rustc_hash::FxHasher; +use tracing::{Level, span}; + +fn hash_once(thing: impl std::hash::Hash) -> u64 { + let mut h = Hasher::default(); + thing.hash(&mut h); + h.finish() +} + + +/// Handle to a file in [`Vfs`] +/// +/// Most functions in rust-analyzer use this when they need to refer to a file. +#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] +pub struct FileId(u32); +// pub struct FileId(NonMaxU32); + +impl FileId { + const MAX: u32 = 0x7fff_ffff; + + #[inline] + pub const fn from_raw(raw: u32) -> FileId { + assert!(raw <= Self::MAX); + FileId(raw) + } + + #[inline] + pub const fn index(self) -> u32 { + self.0 + } +} + +/// safe because `FileId` is a newtype of `u32` +impl nohash_hasher::IsEnabled for FileId {} + +/// Storage for all file changes and the file id to path mapping. +/// +/// For more information see the [crate-level](crate) documentation. +#[derive(Default)] +pub struct Vfs { + interner: PathInterner, + data: Vec, + changes: IndexMap>, +} + +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] +pub enum FileState { + /// The file exists with the given content hash. + Exists(u64), + /// The file is deleted. + Deleted, + /// The file was specifically excluded by the user. We still include excluded files + /// when they're opened (without their contents). + Excluded, +} + +/// Changed file in the [`Vfs`]. +#[derive(Debug)] +pub struct ChangedFile { + /// Id of the changed file + pub file_id: FileId, + /// Kind of change + pub change: Change, +} + +impl ChangedFile { + /// Returns `true` if the change is not [`Delete`](ChangeKind::Delete). + pub fn exists(&self) -> bool { + !matches!(self.change, Change::Delete) + } + + /// Returns `true` if the change is [`Create`](ChangeKind::Create) or + /// [`Delete`](Change::Delete). + pub fn is_created_or_deleted(&self) -> bool { + matches!(self.change, Change::Create(_, _) | Change::Delete) + } + + /// Returns `true` if the change is [`Create`](ChangeKind::Create). + pub fn is_created(&self) -> bool { + matches!(self.change, Change::Create(_, _)) + } + + /// Returns `true` if the change is [`Modify`](ChangeKind::Modify). + pub fn is_modified(&self) -> bool { + matches!(self.change, Change::Modify(_, _)) + } + + pub fn kind(&self) -> ChangeKind { + match self.change { + Change::Create(_, _) => ChangeKind::Create, + Change::Modify(_, _) => ChangeKind::Modify, + Change::Delete => ChangeKind::Delete, + } + } +} + +/// Kind of [file change](ChangedFile). +#[derive(Eq, PartialEq, Debug)] +pub enum Change { + /// The file was (re-)created + Create(Vec, u64), + /// The file was modified + Modify(Vec, u64), + /// The file was deleted + Delete, +} + +/// Kind of [file change](ChangedFile). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChangeKind { + /// The file was (re-)created + Create, + /// The file was modified + Modify, + /// The file was deleted + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileExcluded { + Yes, + No, +} + +impl Vfs { + /// Id of the given path if it exists in the `Vfs` and is not deleted. + pub fn file_id(&self, path: &VfsPath) -> Option<(FileId, FileExcluded)> { + let file_id = self.interner.get(path)?; + let file_state = self.get(file_id); + match file_state { + FileState::Exists(_) => Some((file_id, FileExcluded::No)), + FileState::Deleted => None, + FileState::Excluded => Some((file_id, FileExcluded::Yes)), + } + } + + /// File path corresponding to the given `file_id`. + /// + /// # Panics + /// + /// Panics if the id is not present in the `Vfs`. + pub fn file_path(&self, file_id: FileId) -> &VfsPath { + self.interner.lookup(file_id) + } + + /// Returns an iterator over the stored ids and their corresponding paths. + /// + /// This will skip deleted files. + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.data.len()) + .map(|it| FileId(it as u32)) + .filter(move |&file_id| matches!(self.get(file_id), FileState::Exists(_))) + .map(move |file_id| { + let path = self.interner.lookup(file_id); + (file_id, path) + }) + } + + /// Update the `path` with the given `contents`. `None` means the file was deleted. + /// + /// Returns `true` if the file was modified, and saves the [change](ChangedFile). + /// + /// If the path does not currently exists in the `Vfs`, allocates a new + /// [`FileId`] for it. + pub fn set_file_contents(&mut self, path: VfsPath, contents: Option>) -> bool { + let _p = span!(Level::INFO, "Vfs::set_file_contents").entered(); + let file_id = self.alloc_file_id(path); + let state: FileState = self.get(file_id); + let change = match (state, contents) { + (FileState::Deleted, None) => return false, + (FileState::Deleted, Some(v)) => { + let hash = hash_once::(&*v); + Change::Create(v, hash) + } + (FileState::Exists(_), None) => Change::Delete, + (FileState::Exists(hash), Some(v)) => { + let new_hash = hash_once::(&*v); + if new_hash == hash { + return false; + } + Change::Modify(v, new_hash) + } + (FileState::Excluded, _) => return false, + }; + + let mut set_data = |change_kind| { + self.data[file_id.0 as usize] = match change_kind { + &Change::Create(_, hash) | &Change::Modify(_, hash) => FileState::Exists(hash), + Change::Delete => FileState::Deleted, + }; + }; + + let changed_file = ChangedFile { file_id, change }; + match self.changes.entry(file_id) { + // two changes to the same file in one cycle, merge them appropriately + Entry::Occupied(mut o) => { + use Change::*; + + match (&mut o.get_mut().change, changed_file.change) { + // newer `Delete` wins + (change, Delete) => *change = Delete, + // merge `Create` with `Create` or `Modify` + (Create(prev, old_hash), Create(new, new_hash) | Modify(new, new_hash)) => { + *prev = new; + *old_hash = new_hash; + } + // collapse identical `Modify`es + (Modify(prev, old_hash), Modify(new, new_hash)) => { + *prev = new; + *old_hash = new_hash; + } + // equivalent to `Modify` + (change @ Delete, Create(new, new_hash)) => { + *change = Modify(new, new_hash); + } + // shouldn't occur, but collapse into `Create` + (change @ Delete, Modify(new, new_hash)) => { + // Shouldn't occur; collapse into Create like upstream. + *change = Create(new, new_hash); + } + // shouldn't occur, but keep the Create + (prev @ Modify(_, _), new @ Create(_, _)) => *prev = new, + } + set_data(&o.get().change); + } + Entry::Vacant(v) => set_data(&v.insert(changed_file).change), + }; + + true + } + + /// Drain and returns all the changes in the `Vfs`. + pub fn take_changes(&mut self) -> IndexMap> { + mem::take(&mut self.changes) + } + + /// Provides a panic-less way to verify file_id validity. + pub fn exists(&self, file_id: FileId) -> bool { + matches!(self.get(file_id), FileState::Exists(_)) + } + + /// Returns the id associated with `path` + /// + /// - If `path` does not exists in the `Vfs`, allocate a new id for it, associated with a + /// deleted file; + /// - Else, returns `path`'s id. + /// + /// Does not record a change. + fn alloc_file_id(&mut self, path: VfsPath) -> FileId { + let file_id = self.interner.intern(path); + let idx = file_id.0 as usize; + let len = self.data.len().max(idx + 1); + self.data.resize(len, FileState::Deleted); + file_id + } + + /// Returns the status of the file associated with the given `file_id`. + /// + /// # Panics + /// + /// Panics if no file is associated to that id. + fn get(&self, file_id: FileId) -> FileState { + self.data[file_id.0 as usize] + } + + /// We cannot ignore excluded files, because this will lead to errors when the client + /// requests semantic information for them, so we instead mark them specially. + pub fn insert_excluded_file(&mut self, path: VfsPath) { + let file_id = self.alloc_file_id(path); + self.data[file_id.0 as usize] = FileState::Excluded; + } +} + +impl fmt::Debug for Vfs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Vfs").field("n_files", &self.data.len()).finish() + } +} diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index 255ff44f..53e5edcd 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -1,85 +1,137 @@ -// Object safe interface for file watching and reading. +//! Dynamically compatible interface for file watching and reading. use std::fmt; -use utils::{ - lines::LineEnding, - paths::{AbsPath, AbsPathBuf}, -}; +use utils::paths::{AbsPath, AbsPathBuf}; -use crate::{PathGlobMatcher, PathMatcher}; - -/// File extensions loaded from recursive directory entries. -/// -/// Exact file entries are represented by [`Entry::Files`] and do not rely on -/// extension expansion. -pub const SOURCE_FILE_EXTENSIONS: &[&str] = &["v", "sv", "vh", "svh", "svi", "map"]; - -/// A loader input boundary. -/// -/// Exact files are loaded as listed. Directory entries are expanded using -/// extension and matcher rules and are also eligible for recursive watching. +/// A set of files on the file system. #[derive(Debug, Clone)] pub enum Entry { + /// The `Entry` is represented by a raw set of files. Files(Vec), + /// The `Entry` is represented by `Directories`. Directories(Directories), } -/// Recursive directory load policy. +/// Specifies a set of files on the file system. +/// +/// A file is included if: +/// * it has included extension +/// * it is under an `include` path +/// * it is not under `exclude` path +/// +/// If many include/exclude paths match, the longest one wins. +/// +/// If a path is in both `include` and `exclude`, the `exclude` one wins. #[derive(Debug, Clone, Default)] pub struct Directories { pub extensions: Vec, - pub include: Vec, + pub include: Vec, pub exclude: Vec, - pub exclude_globs: Option, } -/// Complete loader configuration for one generation. +/// [`Handle`]'s configuration. #[derive(Debug)] pub struct Config { + /// Version number to associate progress updates to the right config + /// version. pub version: u32, - pub to_load: Vec, - pub to_watch: Vec, + /// Set of initially loaded files. + pub load: Vec, + /// Index of watched entries in `load`. + /// + /// If a path in a watched entry is modified,the [`Handle`] should notify it. + pub watch: Vec, } -/// Messages sent by a loader generation back to the main loop. -/// -/// Mirrors rust-analyzer's `vfs::loader::Message` shape: configuration scans -/// report [`Loaded`], while later disk or explicit invalidations report -/// [`Changed`]. Server-side watching is best-effort; failures are logged rather -/// than modeled as protocol-level readiness failures. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum LoadingProgress { + Started, + Progress(usize), + Finished, +} + +/// Message about an action taken by a [`Handle`]. pub enum Message { - Progress { n_total: usize, n_done: usize, config_version: u32 }, - /// Initial contents discovered by a configuration scan (or its unload - /// reconciliation). Must not be treated as an external workspace edit. - Loaded { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, - /// Contents that changed after the current configuration generation was - /// established (file watcher events or explicit invalidation). - Changed { files: Vec<(AbsPathBuf, LoadResult)>, config_version: u32 }, + /// Indicate a gradual progress. + /// + /// This is supposed to be the number of loaded files. + Progress { + /// The total files to be loaded. + n_total: usize, + /// The files that have been loaded successfully. + n_done: LoadingProgress, + /// The dir being loaded, `None` if its for a file. + dir: Option, + /// The [`Config`] version. + config_version: u32, + }, + /// The handle loaded the following files' content for the first time. + Loaded { files: Vec<(AbsPathBuf, Option>)> }, + /// The handle loaded the following files' content. + Changed { files: Vec<(AbsPathBuf, Option>)> }, } +/// Type that will receive [`Messages`](Message) from a [`Handle`]. pub type Sender = crossbeam_channel::Sender; -/// Result of reading one file from the loader. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum LoadResult { - Loaded(String, LineEnding), - LoadError, - DecodeError, -} - +/// Interface for reading and watching files. pub trait Handle: fmt::Debug { + /// Spawn a new handle with the given `sender`. fn spawn(sender: Sender) -> Self where Self: Sized; + /// Set this handle's configuration. fn set_config(&mut self, config: Config); + /// The file's content at `path` has been modified, and should be reloaded. fn invalidate(&mut self, path: AbsPathBuf); - fn load_sync(&mut self, path: &AbsPath) -> LoadResult; + /// Load the content of the given file, returning [`None`] if it does not + /// exists. + fn load_sync(&mut self, path: &AbsPath) -> Option>; } impl Entry { + /// Returns: + /// ```text + /// Entry::Directories(Directories { + /// extensions: ["rs"], + /// include: [base], + /// exclude: [base/.git], + /// }) + /// ``` + pub fn rs_files_recursively(base: AbsPathBuf) -> Entry { + Entry::Directories(dirs(base, &[".git"])) + } + + /// Returns: + /// ```text + /// Entry::Directories(Directories { + /// extensions: ["rs"], + /// include: [base], + /// exclude: [base/.git, base/target], + /// }) + /// ``` + pub fn local_cargo_package(base: AbsPathBuf) -> Entry { + Entry::Directories(dirs(base, &[".git", "target"])) + } + + /// Returns: + /// ```text + /// Entry::Directories(Directories { + /// extensions: ["rs"], + /// include: [base], + /// exclude: [base/.git, /tests, /examples, /benches], + /// }) + /// ``` + pub fn cargo_package_dependency(base: AbsPathBuf) -> Entry { + Entry::Directories(dirs(base, &[".git", "/tests", "/examples", "/benches"])) + } + + /// Returns `true` if `path` is included in `self`. + /// + /// See [`Directories::contains_file`]. pub fn contains_file(&self, path: &AbsPath) -> bool { match self { Entry::Files(files) => files.iter().any(|it| it == path), @@ -87,6 +139,10 @@ impl Entry { } } + /// Returns `true` if `path` is included in `self`. + /// + /// - If `self` is `Entry::Files`, returns `false` + /// - Else, see [`Directories::contains_dir`]. pub fn contains_dir(&self, path: &AbsPath) -> bool { match self { Entry::Files(_) => false, @@ -96,62 +152,86 @@ impl Entry { } impl Directories { + /// Returns `true` if `path` is included in `self`. pub fn contains_file(&self, path: &AbsPath) -> bool { + // First, check the file extension... let ext = path.extension().unwrap_or_default(); if self.extensions.iter().all(|it| it.as_str() != ext) { return false; } - self.includes_file(path) + // Then, check for path inclusion... + self.includes_path(path) } + /// Returns `true` if `path` is included in `self`. + /// + /// Since `path` is supposed to be a directory, this will not take extension + /// into account. pub fn contains_dir(&self, path: &AbsPath) -> bool { - self.includes_dir(path) - } - - pub fn include_roots(&self) -> impl Iterator { - self.include.iter().flat_map(PathMatcher::scan_roots) + self.includes_path(path) } /// Returns `true` if `path` is included in `self`. /// /// It is included if - /// - An include root is a prefix of `path`. - /// - No literal exclude prefix matches `path`. - fn includes_file(&self, path: &AbsPath) -> bool { - self.include.iter().any(|include| include.contains_file(path)) && !self.is_excluded(path) - } + /// - An element in `self.include` is a prefix of `path`. + /// - This path is longer than any element in `self.exclude` that is a prefix + /// of `path`. In case of equality, exclusion wins. + fn includes_path(&self, path: &AbsPath) -> bool { + let mut include: Option<&AbsPathBuf> = None; + for incl in &self.include { + if path.starts_with(incl) { + include = Some(match include { + Some(prev) if prev.starts_with(incl) => prev, + _ => incl, + }); + } + } - fn includes_dir(&self, path: &AbsPath) -> bool { - self.include.iter().any(|include| include.contains_dir(path)) - && !self.exclude.iter().any(|excl| path.starts_with(excl)) - } + let include = match include { + Some(it) => it, + None => return false, + }; - fn is_excluded(&self, path: &AbsPath) -> bool { - self.exclude.iter().any(|excl| path.starts_with(excl)) - || self.exclude_globs.as_ref().is_some_and(|exclude| exclude.is_match(path)) + !self.exclude.iter().any(|excl| path.starts_with(excl) && excl.starts_with(include)) } } +/// Returns : +/// ```text +/// Directories { +/// extensions: ["rs"], +/// include: [base], +/// exclude: [base/], +/// } +/// ``` +fn dirs(base: AbsPathBuf, exclude: &[&str]) -> Directories { + let exclude = exclude.iter().map(|it| base.join(it)).collect::>(); + Directories { extensions: vec!["rs".to_owned()], include: vec![base], exclude } +} + impl fmt::Debug for Message { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Message::Loaded { files, config_version } => f - .debug_struct("Loaded") - .field("n_files", &files.len()) - .field("config_version", config_version) - .finish(), - Message::Changed { files, config_version } => f - .debug_struct("Changed") - .field("n_files", &files.len()) - .field("config_version", config_version) - .finish(), - Message::Progress { n_total, n_done, config_version } => f + Message::Loaded { files } => { + f.debug_struct("Loaded").field("n_files", &files.len()).finish() + } + Message::Changed { files } => { + f.debug_struct("Changed").field("n_files", &files.len()).finish() + } + Message::Progress { n_total, n_done, dir, config_version } => f .debug_struct("Progress") .field("n_total", n_total) .field("n_done", n_done) + .field("dir", dir) .field("config_version", config_version) .finish(), } } } + +#[test] +fn handle_is_dyn_compatible() { + fn _assert(_: &dyn Handle) {} +} diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 27238993..f5315ebf 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -1,181 +1,81 @@ -use std::{fs, mem, sync::atomic::AtomicUsize}; +//! Owned fork of rust-analyzer `vfs-notify`. +//! +//! Upstream: +//! +//! An implementation of `loader::Handle`, based on `walkdir` and `notify`. +//! +//! The file watching bits here are untested and quite probably buggy. For this +//! reason, by default we don't watch files and rely on editor's file watching +//! capabilities. +//! +//! Hopefully, one day a reliable file watching/walking crate appears on +//! crates.io, and we can reduce this to trivial glue code. + +use std::{ + fs, + path::{Component, Path}, + sync::atomic::AtomicUsize, +}; -use ::notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use crossbeam_channel::{Receiver, Sender, select, unbounded}; -use itertools::Itertools; -use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator}; +use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::AccessKind}; +use utils::paths::{AbsPath, AbsPathBuf, Utf8PathBuf}; +use rayon::iter::{IndexedParallelIterator as _, IntoParallelIterator as _, ParallelIterator}; use rustc_hash::FxHashSet; -use utils::{ - lines::LineEnding, - paths::{AbsPath, AbsPathBuf}, - thread, -}; +use crate::loader::{self, LoadingProgress}; use walkdir::WalkDir; -use crate::loader::{self, LoadResult}; - #[derive(Debug)] pub struct NotifyHandle { // Relative order of fields below is significant. - sender: Sender, - _handler: Option, - browser_loader: Option, + sender: Sender, + _thread: utils::thread::JoinHandle, } #[derive(Debug)] -enum ServerMsg { +enum Message { Config(loader::Config), Invalidate(AbsPathBuf), } impl loader::Handle for NotifyHandle { fn spawn(sender: loader::Sender) -> NotifyHandle { - if cfg!(target_os = "emscripten") { - let (server_sender, _) = unbounded::(); - return NotifyHandle { - sender: server_sender, - _handler: None, - browser_loader: Some(BrowserLoader::new(sender)), - }; - } - let actor = NotifyActor::new(sender); - let (sender, receiver) = unbounded::(); - let thread = match thread::Builder::new(thread::ThreadIntent::Worker) + let (sender, receiver) = unbounded::(); + let thread = utils::thread::Builder::new(utils::thread::ThreadIntent::Worker) .name("VfsLoader".to_owned()) .spawn(move || actor.run(receiver)) - { - Ok(thread) => Some(thread), - Err(err) => { - tracing::error!(%err, "failed to spawn VFS loader thread"); - None - } - }; - NotifyHandle { sender, _handler: thread, browser_loader: None } + .expect("failed to spawn thread"); + NotifyHandle { sender, _thread: thread } } fn set_config(&mut self, config: loader::Config) { - if let Some(loader) = &mut self.browser_loader { - loader.set_config(config); - return; - } - - if self.sender.send(ServerMsg::Config(config)).is_err() { - tracing::error!("failed to send VFS config to loader thread"); - } + self.sender.send(Message::Config(config)).unwrap(); } fn invalidate(&mut self, path: AbsPathBuf) { - if let Some(loader) = &mut self.browser_loader { - loader.invalidate(path); - return; - } - - if self.sender.send(ServerMsg::Invalidate(path)).is_err() { - tracing::error!("failed to send VFS invalidation to loader thread"); - } + self.sender.send(Message::Invalidate(path)).unwrap(); } - fn load_sync(&mut self, path: &AbsPath) -> LoadResult { + fn load_sync(&mut self, path: &AbsPath) -> Option> { read(path) } } -#[derive(Debug)] -struct BrowserLoader { - sender: loader::Sender, - config_version: u32, - loaded_paths: FxHashSet, -} - -impl BrowserLoader { - fn new(sender: loader::Sender) -> Self { - Self { sender, config_version: 0, loaded_paths: FxHashSet::default() } - } - - fn set_config(&mut self, config: loader::Config) { - let config_version = config.version; - self.config_version = config_version; - let has_reconcile_step = !self.loaded_paths.is_empty(); - let n_entries = config.to_load.len(); - let n_total = n_entries + usize::from(has_reconcile_step); - if n_total > 0 { - self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); - } - - let previous_loaded_paths = mem::take(&mut self.loaded_paths); - let mut reported_paths = FxHashSet::default(); - let mut loaded_paths = FxHashSet::default(); - - for (index, entry) in config.to_load.into_iter().enumerate() { - let (watch_tx, _) = unbounded(); - let files = NotifyActor::load_entry(&watch_tx, entry, false); - reported_paths.extend(files.iter().map(|(path, _)| path.clone())); - loaded_paths.extend( - files - .iter() - .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) - .map(|(path, _)| path.clone()), - ); - self.send(loader::Message::Loaded { files, config_version }); - self.send(loader::Message::Progress { n_total, n_done: index + 1, config_version }); - } - - let unloaded = previous_loaded_paths - .difference(&reported_paths) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec(); - self.loaded_paths = loaded_paths; - if !unloaded.is_empty() { - self.send(loader::Message::Loaded { files: unloaded, config_version }); - } - if has_reconcile_step { - self.send(loader::Message::Progress { n_total, n_done: n_total, config_version }); - } else if n_total == 0 { - self.send(loader::Message::Progress { n_total, n_done: 0, config_version }); - } - } - - fn invalidate(&mut self, path: AbsPathBuf) { - let contents = read(path.as_path()); - let files = vec![(path, contents)]; - self.record_loaded_files(&files); - self.send(loader::Message::Changed { files, config_version: self.config_version }); - } - - fn record_loaded_files(&mut self, files: &[(AbsPathBuf, LoadResult)]) { - for (path, result) in files { - if matches!(result, LoadResult::LoadError) { - self.loaded_paths.remove(path); - } else { - self.loaded_paths.insert(path.clone()); - } - } - } - - fn send(&self, msg: loader::Message) { - if self.sender.send(msg).is_err() { - tracing::error!("failed to send browser VFS loader message to main loop"); - } - } -} - -type NotifyEvent = ::notify::Result<::notify::Event>; +type NotifyEvent = notify::Result; struct NotifyActor { sender: loader::Sender, - config_version: u32, - watched_files: FxHashSet, - watched_dirs: Vec, - loaded_paths: FxHashSet, + watched_file_entries: FxHashSet, + watched_dir_entries: Vec, + seen_paths: FxHashSet, // Drop order is significant. watcher: Option<(RecommendedWatcher, Receiver)>, } #[derive(Debug)] enum Event { - ServerMsg(ServerMsg), + Message(Message), NotifyEvent(NotifyEvent), } @@ -183,41 +83,39 @@ impl NotifyActor { fn new(sender: loader::Sender) -> NotifyActor { NotifyActor { sender, - config_version: 0, - watched_files: FxHashSet::default(), - watched_dirs: Vec::new(), - loaded_paths: FxHashSet::default(), + watched_dir_entries: Vec::new(), + watched_file_entries: FxHashSet::default(), + seen_paths: FxHashSet::default(), watcher: None, } } - fn next_event(&self, receiver: &Receiver) -> Option { + fn next_event(&self, receiver: &Receiver) -> Option { let Some((_, watcher_receiver)) = &self.watcher else { - return receiver.recv().ok().map(Event::ServerMsg); + return receiver.recv().ok().map(Event::Message); }; select! { - recv(receiver) -> it => it.ok().map(Event::ServerMsg), - recv(watcher_receiver) -> it => it.ok().map(Event::NotifyEvent), + recv(receiver) -> it => it.ok().map(Event::Message), + recv(watcher_receiver) -> it => Some(Event::NotifyEvent(it.unwrap())), } } - fn run(mut self, server_inbox: Receiver) { - while let Some(event) = self.next_event(&server_inbox) { - tracing::debug!(?event, "vfs-loader event"); + fn run(mut self, inbox: Receiver) { + while let Some(event) = self.next_event(&inbox) { + tracing::debug!(?event, "vfs-notify event"); match event { - Event::ServerMsg(msg) => match msg { - ServerMsg::Config(config) => { + Event::Message(msg) => match msg { + Message::Config(config) => { self.watcher = None; - if !config.to_watch.is_empty() { + if !config.watch.is_empty() { let (watcher_sender, watcher_receiver) = unbounded(); let watcher = log_notify_error(RecommendedWatcher::new( move |event| { - if watcher_sender.send(event).is_err() { - tracing::debug!( - "notify event dropped because watcher receiver is closed" - ); - } + // we don't care about the error. If sending fails that usually + // means we were dropped, so unwrapping will just add to the + // panic noise. + _ = watcher_sender.send(event); }, Config::default(), )); @@ -225,62 +123,54 @@ impl NotifyActor { } let config_version = config.version; - self.config_version = config_version; - let has_reconcile_step = !self.loaded_paths.is_empty(); - let n_entries = config.to_load.len(); - let n_total = n_entries + usize::from(has_reconcile_step); - if n_total > 0 { - self.send(loader::Message::Progress { - n_total, - n_done: 0, - config_version, - }); - } - self.watched_files.clear(); - self.watched_dirs.clear(); - let previous_loaded_paths = mem::take(&mut self.loaded_paths); + let n_total = config.load.len(); + self.watched_dir_entries.clear(); + self.watched_file_entries.clear(); + self.seen_paths.clear(); + + self.send(loader::Message::Progress { + n_total, + n_done: LoadingProgress::Started, + config_version, + dir: None, + }); let (entry_tx, entry_rx) = unbounded(); let (watch_tx, watch_rx) = unbounded(); - let (loaded_tx, loaded_rx) = unbounded(); let processed = AtomicUsize::new(0); - config.to_load.into_par_iter().enumerate().for_each(|(i, entry)| { - let do_watch = config.to_watch.contains(&i); - if do_watch && entry_tx.send(entry.clone()).is_err() { - tracing::debug!("watched entry dropped because receiver is closed"); - } - let files = Self::load_entry(&watch_tx, entry, do_watch); - let reported_paths = - files.iter().map(|(path, _)| path.clone()).collect_vec(); - let loaded_paths = files - .iter() - .filter(|(_, result)| !matches!(result, LoadResult::LoadError)) - .map(|(path, _)| path.clone()) - .collect_vec(); - if loaded_tx.send((reported_paths, loaded_paths)).is_err() { - tracing::debug!( - "loaded path batch dropped because receiver is closed" - ); + config.load.into_par_iter().enumerate().for_each(|(i, entry)| { + let do_watch = config.watch.contains(&i); + if do_watch { + _ = entry_tx.send(entry.clone()); } - self.send(loader::Message::Loaded { files, config_version }); + let files = Self::load_entry( + |f| _ = watch_tx.send(f.to_owned()), + entry, + do_watch, + |file| { + self.send(loader::Message::Progress { + n_total, + n_done: LoadingProgress::Progress( + processed.load(std::sync::atomic::Ordering::Relaxed), + ), + dir: Some(file), + config_version, + }); + }, + ); + self.send(loader::Message::Loaded { files }); self.send(loader::Message::Progress { n_total, - n_done: 1 + processed - .fetch_add(1, std::sync::atomic::Ordering::AcqRel), + n_done: LoadingProgress::Progress( + processed.fetch_add(1, std::sync::atomic::Ordering::AcqRel) + 1, + ), config_version, + dir: None, }); }); - drop(loaded_tx); - let mut reported_paths = FxHashSet::default(); - let mut loaded_paths = FxHashSet::default(); - for (reported, loaded) in loaded_rx { - reported_paths.extend(reported); - loaded_paths.extend(loaded); - } - drop(watch_tx); for path in watch_rx { self.watch(&path); @@ -289,561 +179,219 @@ impl NotifyActor { drop(entry_tx); for entry in entry_rx { match entry { - loader::Entry::Files(files) => self.watched_files.extend(files), - loader::Entry::Directories(dir) => self.watched_dirs.push(dir), + loader::Entry::Files(files) => { + self.watched_file_entries.extend(files) + } + loader::Entry::Directories(dir) => { + self.watched_dir_entries.push(dir) + } } } - let unloaded = previous_loaded_paths - .difference(&reported_paths) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec(); - self.loaded_paths = loaded_paths; - if !unloaded.is_empty() { - self.send(loader::Message::Loaded { files: unloaded, config_version }); - } - if has_reconcile_step { - self.send(loader::Message::Progress { - n_total, - n_done: n_total, - config_version, - }); - } else if n_total == 0 { - self.send(loader::Message::Progress { - n_total, - n_done: 0, - config_version, - }); - } + self.send(loader::Message::Progress { + n_total, + n_done: LoadingProgress::Finished, + config_version, + dir: None, + }); } - ServerMsg::Invalidate(path) => { + Message::Invalidate(path) => { let contents = read(path.as_path()); let files = vec![(path, contents)]; - self.record_loaded_files(&files); - self.send(loader::Message::Changed { - files, - config_version: self.config_version, - }); + self.send(loader::Message::Changed { files }); } }, Event::NotifyEvent(event) => { - let Some(event) = log_notify_error(event) else { - continue; - }; - - let files = self.process_notify_event(event); - if files.is_empty() { - continue; - } - self.record_loaded_files(&files); - self.send(loader::Message::Changed { - files, - config_version: self.config_version, - }); - } - } - } - } + if let Some(event) = log_notify_error(event) + && let EventKind::Create(_) + | EventKind::Modify(_) + | EventKind::Remove(_) + | EventKind::Access(AccessKind::Open(_)) = event.kind + { + let abs_paths: Vec = event + .paths + .into_iter() + .filter_map(|path| { + Some( + AbsPathBuf::try_from(Utf8PathBuf::from_path_buf(path).ok()?) + .expect("path is absolute"), + ) + }) + .collect(); + + let mut saw_new_file = false; + for abs_path in &abs_paths { + if self.seen_paths.insert(abs_path.clone()) { + saw_new_file = true; + } + } - fn process_notify_event(&mut self, event: ::notify::Event) -> Vec<(AbsPathBuf, LoadResult)> { - if !(event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()) { - return Vec::new(); - } + // Only consider access events for files that we haven't seen + // before. + // + // This is important on FUSE filesystems, where we may not get a + // Create event. In other cases we're about to access the file, so + // we don't want an infinite loop where processing an Access event + // creates another Access event. + if matches!(event.kind, EventKind::Access(_)) && !saw_new_file { + continue; + } - let mut files = Vec::new(); - for path in event.paths.into_iter().filter_map(|path| AbsPathBuf::try_from(path).ok()) { - if event.kind.is_remove() { - let unloaded = self.unload_removed_path(&path); - if !unloaded.is_empty() { - files.extend(unloaded); - continue; + let files = abs_paths + .into_iter() + .filter_map(|path| -> Option<(AbsPathBuf, Option>)> { + // Ignore events for files/directories that we're not watching. + if !(self.watched_file_entries.contains(&path) + || self + .watched_dir_entries + .iter() + .any(|dir| dir.contains_file(&path))) + { + return None; + } + + // For removed files, fs::metadata() will return Err, but + // we still want to update the VFS. + if matches!(event.kind, EventKind::Remove(_)) { + return Some((path, None)); + } + + let meta = fs::metadata(&path).ok()?; + if meta.file_type().is_dir() + && self + .watched_dir_entries + .iter() + .any(|dir| dir.contains_dir(&path)) + { + self.watch(path.as_ref()); + return None; + } + + if !meta.file_type().is_file() { + return None; + } + + let contents = read(&path); + Some((path, contents)) + }) + .collect(); + self.send(loader::Message::Changed { files }); + } } } - - let metadata = fs::metadata(&path).ok(); - let file_type = metadata.as_ref().map(|meta| meta.file_type()); - let is_file = file_type.as_ref().is_some_and(|it| it.is_file()); - let is_dir = file_type.as_ref().is_some_and(|it| it.is_dir()); - - if is_dir && self.is_watched_dir(&path) { - files.extend(self.load_created_directory(&path)); - continue; - } - - if metadata.is_some() && !is_file { - continue; - } - - if !self.is_watched_file(&path) { - continue; - } - - files.push((path.clone(), read(&path))); } - - files - } - - fn is_watched_dir(&self, path: &AbsPathBuf) -> bool { - self.watched_dirs.iter().any(|dir| dir.contains_dir(path)) - } - - fn is_watched_file(&self, path: &AbsPathBuf) -> bool { - self.watched_files.contains(path) - || self.watched_dirs.iter().any(|dir| dir.contains_file(path)) - } - - fn load_created_directory(&mut self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { - let dirs = - self.watched_dirs.iter().filter(|dir| dir.contains_dir(path)).cloned().collect_vec(); - let mut files = Vec::new(); - - for dir in dirs { - let (watch_tx, watch_rx) = unbounded(); - files.extend(Self::load_directory_subtree(&watch_tx, &dir, path, true)); - drop(watch_tx); - for path in watch_rx { - self.watch(&path); - } - } - - files - } - - fn unload_removed_path(&self, path: &AbsPathBuf) -> Vec<(AbsPathBuf, LoadResult)> { - self.loaded_paths - .iter() - .filter(|loaded_path| loaded_path.starts_with(path)) - .cloned() - .map(|path| (path, LoadResult::LoadError)) - .collect_vec() } fn load_entry( - watch_tx: &Sender, + mut watch: impl FnMut(&Path), entry: loader::Entry, - watch: bool, - ) -> Vec<(AbsPathBuf, LoadResult)> { + do_watch: bool, + send_message: impl Fn(AbsPathBuf), + ) -> Vec<(AbsPathBuf, Option>)> { match entry { loader::Entry::Files(files) => files .into_iter() .map(|file| { - if watch && watch_tx.send(file.to_owned()).is_err() { - tracing::debug!("watched file path dropped because receiver is closed"); + if do_watch { + watch(file.as_ref()); } let contents = read(file.as_path()); (file, contents) }) - .collect_vec(), + .collect::>(), loader::Entry::Directories(dirs) => { let mut res = Vec::new(); - for root in dirs.include_roots() { - res.extend(Self::load_directory_subtree(watch_tx, &dirs, root, watch)); - } - res - } - } - } - - fn load_directory_subtree( - watch_tx: &Sender, - dirs: &loader::Directories, - root: &AbsPathBuf, - watch: bool, - ) -> Vec<(AbsPathBuf, LoadResult)> { - let walkdir = WalkDir::new(root).follow_links(true).into_iter().filter_entry(|entry| { - if !entry.file_type().is_dir() { - return true; - } - let Ok(path) = AbsPathBuf::try_from(entry.path().to_path_buf()) else { - return false; - }; - root == &path || dirs.contains_dir(&path) - }); - - let files = walkdir.filter_map(|it| it.ok()).filter_map(|entry| { - let is_dir = entry.file_type().is_dir(); - let is_file = entry.file_type().is_file(); - let abs_path = AbsPathBuf::try_from(entry.into_path()).ok()?; - - if is_dir && watch && watch_tx.send(abs_path.to_owned()).is_err() { - tracing::debug!("watched directory path dropped because receiver is closed"); - } - - if !is_file { - return None; - } + for root in &dirs.include { + send_message(root.clone()); + let walkdir = + WalkDir::new(root).follow_links(true).into_iter().filter_entry(|entry| { + if !entry.file_type().is_dir() { + return true; + } + let path = entry.path(); - if !dirs.contains_file(&abs_path) { - return None; - } + if path_might_be_cyclic(path) { + return false; + } - Some(abs_path) - }); + // We want to filter out subdirectories that are roots themselves, because they will be visited separately. + let path: &Path = path; + dirs.exclude.iter().all(|it| >::as_ref(it) != path) + && (>::as_ref(root) == path + || dirs.include.iter().all(|it| { + >::as_ref(it) != path + })) + }); - files - .map(|file| { - let contents = read(file.as_path()); - (file, contents) - }) - .collect_vec() - } + let files = walkdir.filter_map(|it| it.ok()).filter_map(|entry| { + let depth = entry.depth(); + let is_dir = entry.file_type().is_dir(); + let is_file = entry.file_type().is_file(); + let abs_path = AbsPathBuf::try_from( + Utf8PathBuf::from_path_buf(entry.into_path()).ok()?, + ) + .ok()?; + if depth < 2 && is_dir { + send_message(abs_path.clone()); + } + if is_dir && do_watch { + watch(abs_path.as_ref()); + } + if !is_file { + return None; + } + let ext = abs_path.extension().unwrap_or_default(); + if dirs.extensions.iter().all(|it| it.as_str() != ext) { + return None; + } + Some(abs_path) + }); - fn watch(&mut self, path: &AbsPathBuf) { - if let Some((watcher, _)) = &mut self.watcher { - log_notify_error(watcher.watch(path.as_ref(), RecursiveMode::NonRecursive)); + res.extend(files.map(|file| { + let contents = read(file.as_path()); + (file, contents) + })); + } + res + } } } - fn record_loaded_files(&mut self, files: &[(AbsPathBuf, LoadResult)]) { - for (path, result) in files { - if matches!(result, LoadResult::LoadError) { - self.loaded_paths.remove(path); - } else { - self.loaded_paths.insert(path.clone()); - } + fn watch(&mut self, path: &Path) { + if let Some((watcher, _)) = &mut self.watcher { + log_notify_error(watcher.watch(path, RecursiveMode::Recursive)); } } + #[track_caller] fn send(&self, msg: loader::Message) { - // Call self.sender with msg - if self.sender.send(msg).is_err() { - tracing::error!("failed to send VFS loader message to main loop"); - } + self.sender.send(msg).unwrap(); } } -fn read(path: &AbsPath) -> LoadResult { - let Ok(bytes) = std::fs::read(path) else { - return LoadResult::LoadError; - }; - let Ok(text) = String::from_utf8(bytes) else { - return LoadResult::DecodeError; - }; - let (text, ending) = LineEnding::normalize(text); - LoadResult::Loaded(text, ending) +fn read(path: &AbsPath) -> Option> { + std::fs::read(path).ok() } -fn log_notify_error(res: ::notify::Result) -> Option { +fn log_notify_error(res: notify::Result) -> Option { res.map_err(|err| tracing::warn!("notify error: {}", err)).ok() } -#[cfg(test)] -mod tests { - use std::time::Duration; - - use ::notify::{ - Event as NotifyEvent, EventKind, - event::{CreateKind, RemoveKind}, +/// Is `path` a symlink to a parent directory? +/// +/// Including this path is guaranteed to cause an infinite loop. This +/// heuristic is not sufficient to catch all symlink cycles (it's +/// possible to construct cycle using two or more symlinks), but it +/// catches common cases. +fn path_might_be_cyclic(path: &Path) -> bool { + let Ok(destination) = std::fs::read_link(path) else { + return false; }; - use utils::paths::AbsPathBuf; - - use super::*; - use crate::{ - PathMatcher, - loader::{self, Handle as _}, - }; - - struct TestDir { - _dir: tempfile::TempDir, - path: AbsPathBuf, - } - - impl TestDir { - fn new(name: &str) -> Self { - let dir = tempfile::Builder::new().prefix(&format!("vide-{name}-")).tempdir().unwrap(); - let path = AbsPathBuf::assert_utf8(dir.path().to_path_buf()); - Self { _dir: dir, path } - } - - fn join(&self, path: &str) -> AbsPathBuf { - self.path.join(path) - } - } - - fn collect_until_progress_done( - receiver: &Receiver, - version: u32, - ) -> Vec> { - let mut loaded_batches = Vec::new(); - loop { - match receiver.recv_timeout(Duration::from_secs(1)).unwrap() { - loader::Message::Loaded { files, config_version } if config_version == version => { - loaded_batches.push(files); - } - loader::Message::Changed { .. } => {} - loader::Message::Progress { n_total, n_done, config_version } - if config_version == version && n_done == n_total => - { - return loaded_batches; - } - _ => {} - } - } - } - - fn recv_version_message(receiver: &Receiver, version: u32) -> loader::Message { - loop { - let message = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); - match &message { - loader::Message::Loaded { config_version, .. } - | loader::Message::Changed { config_version, .. } - | loader::Message::Progress { config_version, .. } - if *config_version == version => - { - return message; - } - _ => {} - } - } - } - - fn spawn_loader() -> (NotifyHandle, Receiver) { - let (sender, receiver) = unbounded(); - (::spawn(sender), receiver) - } - - fn assert_loaded(batches: &[Vec<(AbsPathBuf, LoadResult)>], expected_path: &AbsPathBuf) { - assert!( - batches.iter().flatten().any(|(path, result)| { - path == expected_path && matches!(result, LoadResult::Loaded(_, _)) - }), - "expected loaded path {expected_path}, got {batches:?}" - ); - } - - fn path_buf(path: &AbsPathBuf) -> std::path::PathBuf { - let path: &std::path::Path = path.as_ref(); - path.to_path_buf() - } - - fn watched_sv_dir(root: AbsPathBuf) -> loader::Directories { - loader::Directories { - extensions: vec!["sv".to_owned()], - include: vec![PathMatcher::all_under_roots(vec![root])], - exclude: Vec::new(), - exclude_globs: None, - } - } - - fn actor() -> NotifyActor { - let (sender, _receiver) = unbounded(); - NotifyActor::new(sender) - } - - #[test] - fn empty_config_emits_ready_ack_progress() { - let (mut handle, receiver) = spawn_loader(); - - handle.set_config(loader::Config { version: 1, to_load: Vec::new(), to_watch: Vec::new() }); - - assert!(matches!( - recv_version_message(&receiver, 1), - loader::Message::Progress { n_done: 0, n_total: 0, .. } - )); - } - - #[test] - fn removed_config_file_is_unloaded() { - let dir = TestDir::new("vfs-loader-unload-file"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let (mut handle, receiver) = spawn_loader(); - - handle.set_config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: Vec::new(), - }); - let loaded = collect_until_progress_done(&receiver, 1); - assert_loaded(&loaded, &file); - - handle.set_config(loader::Config { version: 2, to_load: Vec::new(), to_watch: Vec::new() }); - - assert!(matches!( - recv_version_message(&receiver, 2), - loader::Message::Progress { n_done: 0, n_total: 1, .. } - )); - let loader::Message::Loaded { files: unloaded, .. } = recv_version_message(&receiver, 2) - else { - panic!("expected unload batch before final progress"); - }; - assert_eq!(unloaded, vec![(file, LoadResult::LoadError)]); - assert!(matches!( - recv_version_message(&receiver, 2), - loader::Message::Progress { n_done: 1, n_total: 1, .. } - )); - } - - #[test] - fn configured_missing_file_is_not_reconciled_twice() { - let dir = TestDir::new("vfs-loader-missing-config-file"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let (mut handle, receiver) = spawn_loader(); - - handle.set_config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: Vec::new(), - }); - let loaded = collect_until_progress_done(&receiver, 1); - assert_loaded(&loaded, &file); - - std::fs::remove_file(&file).unwrap(); - handle.set_config(loader::Config { - version: 2, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: Vec::new(), - }); - - let mut unload_count = 0; - loop { - match recv_version_message(&receiver, 2) { - loader::Message::Loaded { files, .. } => { - unload_count += files - .iter() - .filter(|(path, result)| { - path == &file && matches!(result, LoadResult::LoadError) - }) - .count(); - } - loader::Message::Progress { n_done, n_total, .. } if n_done == n_total => break, - _ => {} - } - } - - assert_eq!(unload_count, 1); - } - #[test] - fn removed_config_directory_is_unloaded() { - let dir = TestDir::new("vfs-loader-unload-directory"); - let source_dir = dir.join("rtl"); - std::fs::create_dir_all(&source_dir).unwrap(); - let file = source_dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let (mut handle, receiver) = spawn_loader(); - - handle.set_config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Directories(loader::Directories { - extensions: vec!["sv".to_owned()], - include: vec![PathMatcher::all_under_roots(vec![source_dir])], - exclude: Vec::new(), - exclude_globs: None, - })], - to_watch: Vec::new(), - }); - let loaded = collect_until_progress_done(&receiver, 1); - assert_loaded(&loaded, &file); - - handle.set_config(loader::Config { version: 2, to_load: Vec::new(), to_watch: Vec::new() }); - - assert!(matches!( - recv_version_message(&receiver, 2), - loader::Message::Progress { n_done: 0, n_total: 1, .. } - )); - let loader::Message::Loaded { files: unloaded, .. } = recv_version_message(&receiver, 2) - else { - panic!("expected unload batch before final progress"); - }; - assert_eq!(unloaded, vec![(file, LoadResult::LoadError)]); - assert!(matches!( - recv_version_message(&receiver, 2), - loader::Message::Progress { n_done: 1, n_total: 1, .. } - )); - } + // If the symlink is of the form "../..", it's a parent symlink. + let is_relative_parent = + destination.components().all(|c| matches!(c, Component::CurDir | Component::ParentDir)); - #[test] - fn created_watched_directory_is_loaded_immediately() { - let dir = TestDir::new("vfs-loader-created-directory-load"); - let root = dir.join("workspace"); - let created_dir = root.join("generated"); - let nested_dir = created_dir.join("nested"); - std::fs::create_dir_all(&nested_dir).unwrap(); - let top = created_dir.join("top.sv"); - let child = nested_dir.join("child.sv"); - let ignored = created_dir.join("notes.txt"); - std::fs::write(&top, "module top; endmodule\n").unwrap(); - std::fs::write(&child, "module child; endmodule\n").unwrap(); - std::fs::write(&ignored, "not systemverilog").unwrap(); - let mut actor = actor(); - actor.watched_dirs.push(watched_sv_dir(root)); - - let files = actor.process_notify_event( - NotifyEvent::new(EventKind::Create(CreateKind::Folder)) - .add_path(path_buf(&created_dir)), - ); - actor.record_loaded_files(&files); - - assert!( - files.iter().any(|(path, result)| { - path == &top && matches!(result, LoadResult::Loaded(_, _)) - }) - ); - assert!(files.iter().any(|(path, result)| { - path == &child && matches!(result, LoadResult::Loaded(_, _)) - })); - assert!(!files.iter().any(|(path, _)| path == &ignored)); - assert!(actor.loaded_paths.contains(&top)); - assert!(actor.loaded_paths.contains(&child)); - } - - #[test] - fn invalidate_reports_changed_not_loaded() { - let dir = TestDir::new("vfs-loader-invalidate-changed"); - let file = dir.join("top.sv"); - std::fs::write(&file, "module top; endmodule\n").unwrap(); - let (mut handle, receiver) = spawn_loader(); - - handle.set_config(loader::Config { - version: 1, - to_load: vec![loader::Entry::Files(vec![file.clone()])], - to_watch: Vec::new(), - }); - collect_until_progress_done(&receiver, 1); - - std::fs::write(&file, "module top2; endmodule\n").unwrap(); - handle.invalidate(file.clone()); - - let loader::Message::Changed { files, config_version } = recv_version_message(&receiver, 1) - else { - panic!("invalidate must report Changed, not Loaded"); - }; - assert_eq!(config_version, 1); - assert!(files.iter().any(|(path, result)| { - path == &file && matches!(result, LoadResult::Loaded(text, _) if text.contains("top2")) - })); - } - - #[test] - fn removed_watched_directory_unloads_loaded_descendants() { - let dir = TestDir::new("vfs-loader-removed-directory-unload"); - let root = dir.join("workspace"); - let removed_dir = root.join("removed"); - let top = removed_dir.join("top.sv"); - let child = removed_dir.join("nested/child.sv"); - let sibling = root.join("sibling.sv"); - let mut actor = actor(); - actor.watched_dirs.push(watched_sv_dir(root)); - actor.loaded_paths.extend([top.clone(), child.clone(), sibling.clone()]); - - let mut files = actor.process_notify_event( - NotifyEvent::new(EventKind::Remove(RemoveKind::Folder)) - .add_path(path_buf(&removed_dir)), - ); - files.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - actor.record_loaded_files(&files); - - let mut expected = - vec![(child.clone(), LoadResult::LoadError), (top.clone(), LoadResult::LoadError)]; - expected.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - assert_eq!(files, expected); - assert!(!actor.loaded_paths.contains(&top)); - assert!(!actor.loaded_paths.contains(&child)); - assert!(actor.loaded_paths.contains(&sibling)); - } + is_relative_parent || path.starts_with(destination) } diff --git a/crates/vfs/src/path_glob.rs b/crates/vfs/src/path_glob.rs deleted file mode 100644 index da63e365..00000000 --- a/crates/vfs/src/path_glob.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{ - fmt, - hash::{Hash, Hasher}, - sync::Arc, -}; - -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; -use utils::paths::{AbsPath, AbsPathBuf}; - -#[derive(Clone)] -pub struct PathGlobMatcher { - root: AbsPathBuf, - patterns: Vec, - matcher: Arc, -} - -impl PathGlobMatcher { - pub fn new(root: AbsPathBuf, patterns: Vec) -> Result { - let mut builder = GlobSetBuilder::new(); - for pattern in &patterns { - let glob = - GlobBuilder::new(pattern).literal_separator(true).backslash_escape(true).build()?; - builder.add(glob); - } - let matcher = Arc::new(builder.build()?); - - Ok(Self { root, patterns, matcher }) - } - - pub fn is_match(&self, path: &AbsPath) -> bool { - let Some(relative) = path.strip_prefix(&self.root) else { - return false; - }; - - let relative = relative.as_ref().to_string_lossy().replace('\\', "/"); - self.matcher.is_match(relative.as_str()) - } - - pub fn root(&self) -> &AbsPathBuf { - &self.root - } - - pub fn patterns(&self) -> &[String] { - &self.patterns - } -} - -impl fmt::Debug for PathGlobMatcher { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PathGlobMatcher") - .field("root", &self.root) - .field("patterns", &self.patterns) - .finish() - } -} - -impl PartialEq for PathGlobMatcher { - fn eq(&self, other: &Self) -> bool { - self.root == other.root && self.patterns == other.patterns - } -} - -impl Eq for PathGlobMatcher {} - -impl Hash for PathGlobMatcher { - fn hash(&self, state: &mut H) { - self.root.hash(state); - self.patterns.hash(state); - } -} diff --git a/crates/vfs/src/path_interner.rs b/crates/vfs/src/path_interner.rs new file mode 100644 index 00000000..225bfc72 --- /dev/null +++ b/crates/vfs/src/path_interner.rs @@ -0,0 +1,43 @@ +//! Maps paths to compact integer ids. We don't care about clearings paths which +//! no longer exist -- the assumption is total size of paths we ever look at is +//! not too big. +use std::hash::BuildHasherDefault; + +use indexmap::IndexSet; +use rustc_hash::FxHasher; + +use crate::{FileId, VfsPath}; + +/// Structure to map between [`VfsPath`] and [`FileId`]. +#[derive(Default)] +pub(crate) struct PathInterner { + map: IndexSet>, +} + +impl PathInterner { + /// Get the id corresponding to `path`. + /// + /// If `path` does not exists in `self`, returns [`None`]. + pub(crate) fn get(&self, path: &VfsPath) -> Option { + self.map.get_index_of(path).map(|i| FileId(i as u32)) + } + + /// Insert `path` in `self`. + /// + /// - If `path` already exists in `self`, returns its associated id; + /// - Else, returns a newly allocated id. + pub(crate) fn intern(&mut self, path: VfsPath) -> FileId { + let (id, _added) = self.map.insert_full(path); + assert!(id < FileId::MAX as usize); + FileId(id as u32) + } + + /// Returns the path corresponding to `id`. + /// + /// # Panics + /// + /// Panics if `id` does not exists in `self`. + pub(crate) fn lookup(&self, id: FileId) -> &VfsPath { + self.map.get_index(id.0 as usize).unwrap() + } +} diff --git a/crates/vfs/src/test_support.rs b/crates/vfs/src/test_support.rs deleted file mode 100644 index c3810688..00000000 --- a/crates/vfs/src/test_support.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::{ - fs, - time::{SystemTime, UNIX_EPOCH}, -}; - -use utils::paths::{AbsPathBuf, Utf8Path}; - -pub(crate) struct TestDir { - path: AbsPathBuf, -} - -impl TestDir { - pub(crate) fn new(name: &str) -> Self { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time before unix epoch") - .as_nanos(); - let path = - std::env::temp_dir().join(format!("vide-vfs-{name}-{}-{suffix}", std::process::id())); - fs::create_dir_all(&path).unwrap_or_else(|err| { - panic!("failed to create test directory {}: {err}", path.display()); - }); - let path = AbsPathBuf::assert_utf8(path); - Self { path } - } - - pub(crate) fn join(&self, path: impl AsRef) -> AbsPathBuf { - self.path.absolutize(path) - } - - pub(crate) fn write( - &self, - path: impl AsRef, - contents: impl AsRef<[u8]>, - ) -> AbsPathBuf { - let path = self.join(path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap_or_else(|err| { - panic!("failed to create test directory {parent}: {err}"); - }); - } - fs::write(&path, contents).unwrap_or_else(|err| { - panic!("failed to write test file {path}: {err}"); - }); - path - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} diff --git a/crates/vfs/src/vfs.rs b/crates/vfs/src/vfs.rs deleted file mode 100644 index b871532b..00000000 --- a/crates/vfs/src/vfs.rs +++ /dev/null @@ -1,597 +0,0 @@ -use std::{fmt, mem}; - -use rustc_hash::FxHashMap; -use triomphe::Arc; -use utils::{ - lines::LineEnding, - path_identity::{PathKey, path_alias_paths}, -}; - -use crate::{loader::LoadResult, vfs_path::VfsPath}; - -#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] -pub struct FileId(pub u32); - -impl nohash_hasher::IsEnabled for FileId {} - -#[derive(Default)] -pub struct Vfs { - identities: VfsIdentityIndex, - file_states: Vec, - changes: Vec, -} - -#[derive(PartialEq, PartialOrd, Clone)] -pub enum FileState { - Exists(String, LineEnding), - Deleted, -} - -#[derive(Debug)] -pub struct ChangedFile { - pub file_id: FileId, - pub change_kind: ChangeKind, -} - -impl ChangedFile { - pub fn is_created_or_deleted(&self) -> bool { - matches!(self.change_kind, ChangeKind::Create(_, _) | ChangeKind::Delete) - } - - pub fn text(&self) -> Option> { - match &self.change_kind { - ChangeKind::Create(text, _) | ChangeKind::Modify(text, _) => Some(text.clone()), - ChangeKind::Delete => None, - } - } - - pub fn ending(&self) -> Option { - match &self.change_kind { - ChangeKind::Create(_, ending) | ChangeKind::Modify(_, ending) => Some(*ending), - ChangeKind::Delete => None, - } - } -} - -#[derive(Clone, Debug)] -pub enum ChangeKind { - Create(Arc, LineEnding), - Modify(Arc, LineEnding), - Delete, -} - -/// Identity service behind [`Vfs`]. -/// -/// A single [`FileId`] can be reached through several path spellings. The -/// primary path preserves the first spelling for legacy callers, while aliases -/// record every proven spelling for identity-aware consumers such as file-set -/// partitioning. File identity is path-based: distinct live paths such as -/// hard links remain distinct source files. -#[derive(Default)] -struct VfsIdentityIndex { - primary_paths: Vec, - aliases: Vec>, - exact_paths: FxHashMap, - real_path_keys: FxHashMap, - redirects: Vec, -} - -/// Result of registering a path ingress with the VFS identity service. -/// -/// `file_id` is the canonical owner after all alias evidence has been applied. -/// `merged` contains duplicate ids that were redirected to that owner and whose -/// file state still needs to be reconciled by [`Vfs`]. -struct IdentityRegistration { - file_id: FileId, - merged: Vec, -} - -impl VfsIdentityIndex { - fn file_id(&self, path: &VfsPath) -> Option { - if let Some(file_id) = self.exact_paths.get(path).copied() { - return Some(self.canonical_file_id(file_id)); - } - - let path = path.as_abs_path()?; - if let Some(file_id) = self.real_path_keys.get(&PathKey::from_abs_path(path)).copied() { - return Some(self.canonical_file_id(file_id)); - } - for alias in path_alias_paths(path).into_iter().map(VfsPath::from) { - if let Some(file_id) = self.exact_paths.get(&alias).copied() { - return Some(self.canonical_file_id(file_id)); - } - } - - None - } - - fn file_path(&self, file_id: FileId) -> Option<&VfsPath> { - let file_id = self.canonical_file_id(file_id); - self.primary_paths.get(file_id.0 as usize) - } - - fn original_file_path(&self, file_id: FileId) -> Option<&VfsPath> { - self.primary_paths.get(file_id.0 as usize) - } - - fn file_paths(&self, file_id: FileId) -> &[VfsPath] { - let file_id = self.canonical_file_id(file_id); - self.aliases.get(file_id.0 as usize).map_or(&[], Vec::as_slice) - } - - /// Resolves or allocates a file id and records the current path as alias - /// evidence for that id. - fn file_id_or_alloc(&mut self, path: &VfsPath) -> IdentityRegistration { - let file_id = self.file_id(path).unwrap_or_else(|| self.alloc_file_id(path)); - self.register_path(path, file_id) - } - - fn len(&self) -> usize { - self.primary_paths.len() - } - - fn canonical_file_id(&self, file_id: FileId) -> FileId { - let mut current = file_id; - while let Some(next) = self.redirects.get(current.0 as usize).copied() - && next != current - { - current = next; - } - current - } - - fn alloc_file_id(&mut self, path: &VfsPath) -> FileId { - let id = self.primary_paths.len(); - assert!(id < u32::MAX as usize); - let file_id = FileId(id as u32); - self.primary_paths.push(path.clone()); - self.aliases.push(Vec::new()); - self.redirects.push(file_id); - file_id - } - - fn register_path(&mut self, path: &VfsPath, file_id: FileId) -> IdentityRegistration { - let mut owner = self.canonical_file_id(file_id); - let mut merged = Vec::new(); - - if let Some(path) = path.as_abs_path() { - let aliases = path_alias_paths(path).into_iter().map(VfsPath::from).collect::>(); - for alias in &aliases { - if let Some(existing) = self.exact_paths.get(alias).copied() { - owner = self.merge_file_ids(owner, existing, &mut merged); - } - if let Some(path) = alias.as_abs_path() - && let Some(existing) = - self.real_path_keys.get(&PathKey::from_abs_path(path)).copied() - { - owner = self.merge_file_ids(owner, existing, &mut merged); - } - } - for alias in aliases { - self.register_exact_path(alias, owner); - } - } else { - if let Some(existing) = self.exact_paths.get(path).copied() { - owner = self.merge_file_ids(owner, existing, &mut merged); - } - self.register_exact_path(path.clone(), owner); - } - - IdentityRegistration { file_id: owner, merged } - } - - fn register_exact_path(&mut self, path: VfsPath, file_id: FileId) { - let file_id = self.canonical_file_id(file_id); - if let Some(path) = path.as_abs_path() { - self.real_path_keys.insert(PathKey::from_abs_path(path), file_id); - } - self.exact_paths.insert(path.clone(), file_id); - - let Some(aliases) = self.aliases.get_mut(file_id.0 as usize) else { - return; - }; - if !aliases.contains(&path) { - aliases.push(path); - } - } - - fn merge_file_ids(&mut self, lhs: FileId, rhs: FileId, merged: &mut Vec) -> FileId { - let lhs = self.canonical_file_id(lhs); - let rhs = self.canonical_file_id(rhs); - if lhs == rhs { - return lhs; - } - - let (owner, duplicate) = if lhs <= rhs { (lhs, rhs) } else { (rhs, lhs) }; - self.redirects[duplicate.0 as usize] = owner; - merged.push(duplicate); - - let duplicate_aliases = std::mem::take(&mut self.aliases[duplicate.0 as usize]); - for alias in duplicate_aliases { - self.register_exact_path(alias, owner); - } - for file_id in self.real_path_keys.values_mut() { - if *file_id == duplicate { - *file_id = owner; - } - } - - owner - } -} - -impl Vfs { - pub(crate) fn file_paths(&self, file_id: FileId) -> &[VfsPath] { - self.identities.file_paths(file_id) - } -} - -impl Vfs { - pub fn file_id(&self, path: &VfsPath) -> Option { - self.identities.file_id(path).filter(|id| self.exists(*id)) - } - - pub fn file_path(&self, file_id: FileId) -> Option<&VfsPath> { - self.identities.file_path(file_id) - } - - /// Registers a path at the VFS identity boundary without changing file - /// contents. - /// - /// LSP open notifications use this to learn whether a URI aliases an - /// already-open analysis buffer before deciding whether the incoming text - /// should become the canonical VFS text. - pub fn register_file_ingress(&mut self, path: &VfsPath) -> FileId { - self.file_id_or_alloc(path) - } - - /// Returns the first path spelling recorded for this exact [`FileId`]. - /// - /// Unlike [`Self::file_path`], this does not follow identity redirects. - /// It is intended for cleanup of historical changes, for example clearing - /// diagnostics that were published before a duplicate id was merged. - pub fn original_file_path(&self, file_id: FileId) -> Option<&VfsPath> { - self.identities.original_file_path(file_id) - } - - pub fn canonical_file_id(&self, file_id: FileId) -> FileId { - self.identities.canonical_file_id(file_id) - } - - pub fn line_ending(&self, file_id: FileId) -> Option { - match self.file_state(file_id)? { - FileState::Exists(_, ending) => Some(*ending), - FileState::Deleted => None, - } - } - - pub fn iter(&self) -> impl Iterator + '_ { - (0..self.file_states.len()) - .map(|it| FileId(it as u32)) - .filter(move |&file_id| self.exists(file_id)) - .filter_map(move |file_id| self.file_path(file_id).map(|path| (file_id, path))) - } - - /// Updates a file and returns whether the observable VFS contents changed. - pub fn set_file_contents(&mut self, path: &VfsPath, contents: LoadResult) -> bool { - let file_id = self.file_id_or_alloc(path); - use FileState::*; - use LoadResult::*; - let Some(state) = self.file_states.get_mut(file_id.0 as usize) else { - return false; - }; - let change_kind = match contents { - Loaded(new, new_ending) => match state { - Exists(old, _) if *old == new => return false, - Exists(_, _) => { - let change_kind = ChangeKind::Modify(Arc::from(new.as_str()), new_ending); - *state = Exists(new, new_ending); - change_kind - } - Deleted => { - let change_kind = ChangeKind::Create(Arc::from(new.as_str()), new_ending); - *state = Exists(new, new_ending); - change_kind - } - }, - LoadError => match state { - Exists(_, _) => { - *state = Deleted; - ChangeKind::Delete - } - Deleted => return false, - }, - DecodeError => return false, - }; - - let changed_file = ChangedFile { file_id, change_kind }; - self.changes.push(changed_file); - true - } - - pub fn has_changes(&self) -> bool { - !self.changes.is_empty() - } - - pub fn take_changes(&mut self) -> Vec { - mem::take(&mut self.changes) - } - - pub fn exists(&self, file_id: FileId) -> bool { - matches!(self.file_state(file_id), Some(FileState::Exists(_, _))) - } - - fn file_id_or_alloc(&mut self, path: &VfsPath) -> FileId { - let registration = self.identities.file_id_or_alloc(path); - let file_id = registration.file_id; - let id = file_id.0 as usize; - let len = self.file_states.len().max(id + 1); - self.file_states.resize(len, FileState::Deleted); - for duplicate in registration.merged { - self.merge_file_state(file_id, duplicate); - } - file_id - } - - fn merge_file_state(&mut self, owner: FileId, duplicate: FileId) { - if owner == duplicate { - return; - } - - let owner_idx = owner.0 as usize; - let duplicate_idx = duplicate.0 as usize; - let Some(duplicate_state) = self.file_states.get(duplicate_idx).cloned() else { - return; - }; - - if matches!(self.file_states.get(owner_idx), Some(FileState::Deleted)) - && let FileState::Exists(text, ending) = duplicate_state.clone() - { - self.file_states[owner_idx] = FileState::Exists(text.clone(), ending); - self.changes.push(ChangedFile { - file_id: owner, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), ending), - }); - } - - if matches!(duplicate_state, FileState::Exists(_, _)) { - self.file_states[duplicate_idx] = FileState::Deleted; - self.changes.push(ChangedFile { file_id: duplicate, change_kind: ChangeKind::Delete }); - } - } - - fn file_state(&self, file_id: FileId) -> Option<&FileState> { - self.file_states.get(file_id.0 as usize) - } -} - -impl fmt::Debug for Vfs { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Vfs").field("n_files", &self.identities.len()).finish() - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use utils::lines::LineEnding; - #[cfg(windows)] - use utils::paths::AbsPathBuf; - - use super::*; - use crate::test_support::TestDir; - - #[test] - fn load_error_marks_existing_file_deleted() { - let mut vfs = Vfs::default(); - let path = VfsPath::new_virtual_path("/workspace/vide.toml".to_owned()); - - vfs.set_file_contents( - &path, - LoadResult::Loaded("sources = []\n".to_owned(), LineEnding::Unix), - ); - let file_id = vfs.file_id(&path).unwrap(); - vfs.take_changes(); - - vfs.set_file_contents(&path, LoadResult::LoadError); - - assert!(!vfs.exists(file_id)); - assert_eq!(vfs.file_id(&path), None); - let changes = vfs.take_changes(); - assert_eq!(changes.len(), 1); - assert!(matches!(changes[0].change_kind, ChangeKind::Delete)); - } - - #[test] - fn renamed_file_uses_new_path_identity_after_delete_then_create() { - let dir = TestDir::new("rename-delete-create"); - let old_path = dir.write("old_child.sv", "module child; endmodule\n"); - let new_path = dir.join("child.sv"); - let old_vfs_path = VfsPath::from(old_path.clone()); - let new_vfs_path = VfsPath::from(new_path.clone()); - let mut vfs = Vfs::default(); - - vfs.set_file_contents( - &old_vfs_path, - LoadResult::Loaded("module child; endmodule\n".to_owned(), LineEnding::Unix), - ); - let old_file_id = vfs.file_id(&old_vfs_path).unwrap(); - vfs.take_changes(); - - fs::rename(&old_path, &new_path).unwrap(); - vfs.set_file_contents(&old_vfs_path, LoadResult::LoadError); - vfs.set_file_contents( - &new_vfs_path, - LoadResult::Loaded("module child; endmodule\n".to_owned(), LineEnding::Unix), - ); - - let new_file_id = vfs.file_id(&new_vfs_path).unwrap(); - assert_ne!(old_file_id, new_file_id); - assert_eq!(vfs.file_id(&old_vfs_path), None); - assert_eq!(vfs.file_path(new_file_id), Some(&new_vfs_path)); - } - - #[test] - fn renamed_file_uses_new_path_identity_after_create_then_delete() { - let dir = TestDir::new("rename-create-delete"); - let old_path = dir.write("old_child.sv", "module child; endmodule\n"); - let new_path = dir.join("child.sv"); - let old_vfs_path = VfsPath::from(old_path.clone()); - let new_vfs_path = VfsPath::from(new_path.clone()); - let mut vfs = Vfs::default(); - - vfs.set_file_contents( - &old_vfs_path, - LoadResult::Loaded("module child; endmodule\n".to_owned(), LineEnding::Unix), - ); - let old_file_id = vfs.file_id(&old_vfs_path).unwrap(); - vfs.take_changes(); - - fs::rename(&old_path, &new_path).unwrap(); - vfs.set_file_contents( - &new_vfs_path, - LoadResult::Loaded("module child; endmodule\n".to_owned(), LineEnding::Unix), - ); - vfs.set_file_contents(&old_vfs_path, LoadResult::LoadError); - - let new_file_id = vfs.file_id(&new_vfs_path).unwrap(); - assert_ne!(old_file_id, new_file_id); - assert_eq!(vfs.file_id(&old_vfs_path), None); - assert_eq!(vfs.file_path(new_file_id), Some(&new_vfs_path)); - } - - #[cfg(windows)] - #[test] - fn path_spellings_with_different_drive_letter_case_share_file_id() { - let path = AbsPathBuf::assert_utf8(std::env::current_dir().unwrap()).join("top.sv"); - let mut lower_drive_path = path.to_string(); - assert_eq!(lower_drive_path.as_bytes().get(1), Some(&b':')); - lower_drive_path.replace_range(0..1, &lower_drive_path[0..1].to_ascii_lowercase()); - let lower_drive_path = AbsPathBuf::try_from(lower_drive_path.as_str()).unwrap(); - - let mut vfs = Vfs::default(); - let upper_vfs_path = VfsPath::from(path); - let lower_vfs_path = VfsPath::from(lower_drive_path); - - vfs.set_file_contents( - &upper_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let first_file_id = vfs.file_id(&upper_vfs_path).unwrap(); - - vfs.set_file_contents( - &lower_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let second_file_id = vfs.file_id(&lower_vfs_path).unwrap(); - - assert_eq!(first_file_id, second_file_id); - assert_eq!(vfs.iter().count(), 1); - } - - #[cfg(windows)] - #[test] - fn path_spellings_with_verbatim_and_normal_spelling_share_file_id() { - let path = AbsPathBuf::assert_utf8(std::env::current_dir().unwrap()).join("top.sv"); - let verbatim_path = AbsPathBuf::try_from(format!(r"\\?\{path}").as_str()).unwrap(); - - let mut vfs = Vfs::default(); - let normal_vfs_path = VfsPath::from(path); - let verbatim_vfs_path = VfsPath::from(verbatim_path); - - vfs.set_file_contents( - &normal_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let first_file_id = vfs.file_id(&normal_vfs_path).unwrap(); - - vfs.set_file_contents( - &verbatim_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let second_file_id = vfs.file_id(&verbatim_vfs_path).unwrap(); - - assert_eq!(first_file_id, second_file_id); - assert_eq!(vfs.iter().count(), 1); - } - - #[test] - fn hard_links_remain_distinct_source_files() { - let dir = TestDir::new("hard-link-source-identity"); - let source = dir.join("workspace/top.sv"); - let alias = dir.join("alias/top.sv"); - let source_vfs_path = VfsPath::from(source.clone()); - let alias_vfs_path = VfsPath::from(alias.clone()); - let mut vfs = Vfs::default(); - - vfs.set_file_contents( - &source_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let source_file_id = vfs.file_id(&source_vfs_path).unwrap(); - - if let Some(parent) = source.parent() { - fs::create_dir_all(parent).unwrap(); - } - if let Some(parent) = alias.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&source, "module top; endmodule\n").unwrap(); - fs::hard_link(&source, &alias).unwrap(); - - vfs.set_file_contents( - &alias_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let alias_file_id = vfs.file_id(&alias_vfs_path).unwrap(); - - assert_ne!(source_file_id, alias_file_id); - assert_eq!(vfs.iter().count(), 2); - } - - #[test] - fn hard_links_stay_distinct_after_existing_identity_evidence() { - let dir = TestDir::new("hard-link-existing-identity"); - let source = dir.join("workspace/top.sv"); - let alias = dir.join("alias/top.sv"); - let source_vfs_path = VfsPath::from(source.clone()); - let alias_vfs_path = VfsPath::from(alias.clone()); - let mut vfs = Vfs::default(); - - vfs.set_file_contents( - &source_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let source_file_id = vfs.file_id(&source_vfs_path).unwrap(); - vfs.set_file_contents( - &alias_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - let alias_file_id = vfs.file_id(&alias_vfs_path).unwrap(); - assert_ne!(source_file_id, alias_file_id); - vfs.take_changes(); - - if let Some(parent) = source.parent() { - fs::create_dir_all(parent).unwrap(); - } - if let Some(parent) = alias.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&source, "module top; endmodule\n").unwrap(); - fs::hard_link(&source, &alias).unwrap(); - - vfs.set_file_contents( - &source_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - vfs.set_file_contents( - &alias_vfs_path, - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), - ); - - assert_eq!(vfs.file_id(&source_vfs_path), Some(source_file_id)); - assert_eq!(vfs.file_id(&alias_vfs_path), Some(alias_file_id)); - assert_eq!(vfs.iter().count(), 2); - } -} diff --git a/crates/vfs/src/vfs_path.rs b/crates/vfs/src/vfs_path.rs index 0caac3f0..aaa4659c 100644 --- a/crates/vfs/src/vfs_path.rs +++ b/crates/vfs/src/vfs_path.rs @@ -1,38 +1,61 @@ -// Abstract-ish representation of paths for VFS. +//! Abstract-ish representation of paths for VFS. use std::fmt; -use utils::paths::{AbsPath, AbsPathBuf, RelPath, Utf8Path}; +use utils::paths::{AbsPath, AbsPathBuf, RelPath}; +/// Path in [`Vfs`]. +/// +/// Long-term, we want to support files which do not reside in the file-system, +/// so we treat `VfsPath`s as opaque identifiers. +/// +/// [`Vfs`]: crate::Vfs #[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] -pub struct VfsPath(VfsPathKinds); +pub struct VfsPath(VfsPathRepr); impl VfsPath { + /// Creates an "in-memory" path from `/`-separated string. + /// + /// This is most useful for testing, to avoid windows/linux differences + /// + /// # Panics + /// + /// Panics if `path` does not start with `'/'`. pub fn new_virtual_path(path: String) -> VfsPath { assert!(path.starts_with('/')); - VfsPath(VfsPathKinds::VirtualPath(VirtualPath(path))) + VfsPath(VfsPathRepr::VirtualPath(VirtualPath(path))) } + /// Create a path from string. Input should be a string representation of + /// an absolute path inside filesystem pub fn new_real_path(path: String) -> VfsPath { VfsPath::from(AbsPathBuf::assert(path.into())) } - pub fn as_abs_path(&self) -> Option<&AbsPath> { + /// Returns the `AbsPath` representation of `self` if `self` is on the file system. + pub fn as_path(&self) -> Option<&AbsPath> { match &self.0 { - VfsPathKinds::RealPath(it) => Some(it.as_path()), - VfsPathKinds::VirtualPath(_) => None, + VfsPathRepr::PathBuf(it) => Some(it.as_path()), + VfsPathRepr::VirtualPath(_) => None, } } - /// Creates a new [`VfsPath`] with `path` adjoined to `self`. + pub fn into_abs_path(self) -> Option { + match self.0 { + VfsPathRepr::PathBuf(it) => Some(it), + VfsPathRepr::VirtualPath(_) => None, + } + } + + /// Creates a new `VfsPath` with `path` adjoined to `self`. pub fn join(&self, path: &str) -> Option { match &self.0 { - VfsPathKinds::RealPath(it) => { + VfsPathRepr::PathBuf(it) => { let res = it.join(path).normalize(); - Some(VfsPath(VfsPathKinds::RealPath(res))) + Some(VfsPath(VfsPathRepr::PathBuf(res))) } - VfsPathKinds::VirtualPath(it) => { + VfsPathRepr::VirtualPath(it) => { let res = it.join(path)?; - Some(VfsPath(VfsPathKinds::VirtualPath(res))) + Some(VfsPath(VfsPathRepr::VirtualPath(res))) } } } @@ -40,31 +63,39 @@ impl VfsPath { /// Remove the last component of `self` if there is one. /// /// If `self` has no component, returns `false`; else returns `true`. + /// + /// # Example + /// + /// ```ignore + /// # use vfs::{AbsPathBuf, VfsPath}; + /// let mut path = VfsPath::from(AbsPathBuf::assert("/foo/bar".into())); + /// assert!(path.pop()); + /// assert_eq!(path, VfsPath::from(AbsPathBuf::assert("/foo".into()))); + /// assert!(path.pop()); + /// assert_eq!(path, VfsPath::from(AbsPathBuf::assert("/".into()))); + /// assert!(!path.pop()); + /// ``` pub fn pop(&mut self) -> bool { match &mut self.0 { - VfsPathKinds::RealPath(it) => it.pop(), - VfsPathKinds::VirtualPath(it) => it.pop(), + VfsPathRepr::PathBuf(it) => it.pop(), + VfsPathRepr::VirtualPath(it) => it.pop(), } } /// Returns `true` if `other` is a prefix of `self`. pub fn starts_with(&self, other: &VfsPath) -> bool { match (&self.0, &other.0) { - (VfsPathKinds::RealPath(lhs), VfsPathKinds::RealPath(rhs)) => lhs.starts_with(rhs), - (VfsPathKinds::VirtualPath(lhs), VfsPathKinds::VirtualPath(rhs)) => { - lhs.starts_with(rhs) - } - (VfsPathKinds::RealPath(_) | VfsPathKinds::VirtualPath(_), _) => false, + (VfsPathRepr::PathBuf(lhs), VfsPathRepr::PathBuf(rhs)) => lhs.starts_with(rhs), + (VfsPathRepr::VirtualPath(lhs), VfsPathRepr::VirtualPath(rhs)) => lhs.starts_with(rhs), + (VfsPathRepr::PathBuf(_) | VfsPathRepr::VirtualPath(_), _) => false, } } pub fn strip_prefix(&self, other: &VfsPath) -> Option<&RelPath> { match (&self.0, &other.0) { - (VfsPathKinds::RealPath(lhs), VfsPathKinds::RealPath(rhs)) => lhs.strip_prefix(rhs), - (VfsPathKinds::VirtualPath(lhs), VfsPathKinds::VirtualPath(rhs)) => { - lhs.strip_prefix(rhs) - } - (VfsPathKinds::RealPath(_) | VfsPathKinds::VirtualPath(_), _) => None, + (VfsPathRepr::PathBuf(lhs), VfsPathRepr::PathBuf(rhs)) => lhs.strip_prefix(rhs), + (VfsPathRepr::VirtualPath(lhs), VfsPathRepr::VirtualPath(rhs)) => lhs.strip_prefix(rhs), + (VfsPathRepr::PathBuf(_) | VfsPathRepr::VirtualPath(_), _) => None, } } @@ -73,31 +104,33 @@ impl VfsPath { /// Returns [`None`] if the path is a root or prefix. pub fn parent(&self) -> Option { let mut parent = self.clone(); - parent.pop().then_some(parent) + if parent.pop() { Some(parent) } else { None } } /// Returns `self`'s base name and file extension. pub fn name_and_extension(&self) -> Option<(&str, Option<&str>)> { match &self.0 { - VfsPathKinds::RealPath(p) => p.name_and_extension(), - VfsPathKinds::VirtualPath(p) => p.name_and_extension(), + VfsPathRepr::PathBuf(p) => p.name_and_extension(), + VfsPathRepr::VirtualPath(p) => p.name_and_extension(), } } + /// **Don't make this `pub`** + /// /// Encode the path in the given buffer. /// - /// The encoding will be `0` if [`AbsPathBuf`], `1` if [`VirtualPath`], - /// followed by `self`'s representation. + /// The encoding will be `0` if [`AbsPathBuf`], `1` if [`VirtualPath`], followed + /// by `self`'s representation. /// /// Note that this encoding is dependent on the operating system. pub(crate) fn encode(&self, buf: &mut Vec) { let tag = match &self.0 { - VfsPathKinds::RealPath(_) => 0, - VfsPathKinds::VirtualPath(_) => 1, + VfsPathRepr::PathBuf(_) => 0, + VfsPathRepr::VirtualPath(_) => 1, }; buf.push(tag); match &self.0 { - VfsPathKinds::RealPath(path) => { + VfsPathRepr::PathBuf(path) => { #[cfg(windows)] { use windows_paths::Encode; @@ -136,7 +169,7 @@ impl VfsPath { buf.extend(path.as_os_str().to_string_lossy().as_bytes()); } } - VfsPathKinds::VirtualPath(VirtualPath(s)) => buf.extend(s.as_bytes()), + VfsPathRepr::VirtualPath(VirtualPath(s)) => buf.extend(s.as_bytes()), } } } @@ -216,26 +249,54 @@ mod windows_paths { } } } + #[test] + fn paths_encoding() { + // drive letter casing agnostic + test_eq("C:/x.rs", "c:/x.rs"); + // separator agnostic + test_eq("C:/x/y.rs", "C:\\x\\y.rs"); + + fn test_eq(a: &str, b: &str) { + let mut b1 = Vec::new(); + let mut b2 = Vec::new(); + vfs(a).encode(&mut b1); + vfs(b).encode(&mut b2); + assert_eq!(b1, b2); + } + } + + #[test] + fn test_sep_root_dir_encoding() { + let mut buf = Vec::new(); + vfs("C:/x/y").encode(&mut buf); + assert_eq!(&buf, &[0, 67, 0, 58, 0, 92, 0, 120, 0, 92, 0, 121, 0]) + } + + #[cfg(test)] + fn vfs(str: &str) -> super::VfsPath { + use super::{AbsPathBuf, VfsPath}; + VfsPath::from(AbsPathBuf::try_from(str).unwrap()) + } } /// Internal, private representation of [`VfsPath`]. #[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] -enum VfsPathKinds { - RealPath(AbsPathBuf), +enum VfsPathRepr { + PathBuf(AbsPathBuf), VirtualPath(VirtualPath), } impl From for VfsPath { fn from(v: AbsPathBuf) -> Self { - VfsPath(VfsPathKinds::RealPath(v.normalize())) + VfsPath(VfsPathRepr::PathBuf(v.normalize())) } } impl fmt::Display for VfsPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.0 { - VfsPathKinds::RealPath(it) => it.fmt(f), - VfsPathKinds::VirtualPath(VirtualPath(it)) => it.fmt(f), + VfsPathRepr::PathBuf(it) => it.fmt(f), + VfsPathRepr::VirtualPath(VirtualPath(it)) => it.fmt(f), } } } @@ -246,15 +307,29 @@ impl fmt::Debug for VfsPath { } } -impl fmt::Debug for VfsPathKinds { +impl fmt::Debug for VfsPathRepr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { - VfsPathKinds::RealPath(it) => it.fmt(f), - VfsPathKinds::VirtualPath(VirtualPath(it)) => it.fmt(f), + VfsPathRepr::PathBuf(it) => it.fmt(f), + VfsPathRepr::VirtualPath(VirtualPath(it)) => it.fmt(f), } } } +impl PartialEq for VfsPath { + fn eq(&self, other: &AbsPath) -> bool { + match &self.0 { + VfsPathRepr::PathBuf(lhs) => lhs == other, + VfsPathRepr::VirtualPath(_) => false, + } + } +} +impl PartialEq for AbsPath { + fn eq(&self, other: &VfsPath) -> bool { + other == self + } +} + /// `/`-separated virtual path. /// /// This is used to describe files that do not reside on the file system. @@ -262,26 +337,53 @@ impl fmt::Debug for VfsPathKinds { struct VirtualPath(String); impl VirtualPath { + /// Returns `true` if `other` is a prefix of `self` (as strings). fn starts_with(&self, other: &VirtualPath) -> bool { self.0.starts_with(&other.0) } fn strip_prefix(&self, base: &VirtualPath) -> Option<&RelPath> { - <_ as AsRef>::as_ref(&self.0) + <_ as AsRef>::as_ref(&self.0) .strip_prefix(&base.0) .ok() .map(RelPath::new_unchecked) } + /// Remove the last component of `self`. + /// + /// This will find the last `'/'` in `self`, and remove everything after it, + /// including the `'/'`. + /// + /// If `self` contains no `'/'`, returns `false`; else returns `true`. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut path = VirtualPath("/foo/bar".to_string()); + /// path.pop(); + /// assert_eq!(path.0, "/foo"); + /// path.pop(); + /// assert_eq!(path.0, ""); + /// ``` fn pop(&mut self) -> bool { - let Some(pos) = self.0.rfind('/') else { - return false; + let pos = match self.0.rfind('/') { + Some(pos) => pos, + None => return false, }; - self.0 = self.0[..pos].to_string(); true } + /// Append the given *relative* path `path` to `self`. + /// + /// This will resolve any leading `"../"` in `path` before appending it. + /// + /// Returns [`None`] if `path` has more leading `"../"` than the number of + /// components in `self`. + /// + /// # Notes + /// + /// In practice, appending here means `self/path` as strings. fn join(&self, mut path: &str) -> Option { let mut res = self.clone(); while path.starts_with("../") { @@ -299,8 +401,7 @@ impl VirtualPath { /// /// # Returns /// - `None` if `self` ends with `"//"`. - /// - `Some((name, None))` if `self`'s base contains no `.`, or only one `.` - /// at the start. + /// - `Some((name, None))` if `self`'s base contains no `.`, or only one `.` at the start. /// - `Some((name, Some(extension))` else. /// /// # Note @@ -328,3 +429,6 @@ impl VirtualPath { } } } + +#[cfg(test)] +mod tests; diff --git a/crates/vfs/src/vfs_path/tests.rs b/crates/vfs/src/vfs_path/tests.rs new file mode 100644 index 00000000..2d89362e --- /dev/null +++ b/crates/vfs/src/vfs_path/tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn virtual_path_extensions() { + assert_eq!(VirtualPath("/".to_owned()).name_and_extension(), None); + assert_eq!( + VirtualPath("/directory".to_owned()).name_and_extension(), + Some(("directory", None)) + ); + assert_eq!( + VirtualPath("/directory/".to_owned()).name_and_extension(), + Some(("directory", None)) + ); + assert_eq!( + VirtualPath("/directory/file".to_owned()).name_and_extension(), + Some(("file", None)) + ); + assert_eq!( + VirtualPath("/directory/.file".to_owned()).name_and_extension(), + Some((".file", None)) + ); + assert_eq!( + VirtualPath("/directory/.file.rs".to_owned()).name_and_extension(), + Some((".file", Some("rs"))) + ); + assert_eq!( + VirtualPath("/directory/file.rs".to_owned()).name_and_extension(), + Some(("file", Some("rs"))) + ); +} From 0be675fdb1d4cad0ccd74d2687bec5bb8e8edfb5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:07:13 +0800 Subject: [PATCH 08/13] refactor(vfs): cut host over to rust-analyzer VFS API Wire hir/ide/project-model and the LSP host to the vendored r-a VFS: - content is Option>; line endings live in the host map - loader Message matches r-a: Progress carries config_version and LoadingProgress; Loaded/Changed carry only files - generation readiness is gated solely by Progress::Finished - drop VFS hardlink identity and remap paths through FileId redirects - keep SV PathMatcher/FileSet partitioning and SOURCE_FILE_EXTENSIONS as intentional fork deltas - retain #301 cold-request terminal fallbacks and Loaded vs Changed provenance for workspace reload --- crates/hir/src/base_db/change.rs | 10 +- crates/hir/src/base_db/source_db.rs | 8 +- crates/hir/src/file.rs | 2 +- crates/hir/src/hir_def/macro_file/tests.rs | 6 +- crates/hir/src/preproc/tests.rs | 12 +- crates/hir/src/scope.rs | 6 +- crates/hir/src/semantics/pathres.rs | 6 +- crates/hir/src/type_infer.rs | 6 +- crates/ide/src/analysis.rs | 2 +- crates/ide/src/code_action/tests.rs | 2 +- crates/ide/src/completion/engine/tests.rs | 2 +- crates/ide/src/db/root_db.rs | 4 +- crates/ide/src/definitions.rs | 2 +- crates/ide/src/diagnostics.rs | 90 ++--- crates/ide/src/document_highlight.rs | 2 +- crates/ide/src/formatting.rs | 2 +- crates/ide/src/inlay_hint.rs | 2 +- crates/ide/src/macro_hover_tests.rs | 4 +- crates/ide/src/module_resolution.rs | 6 +- crates/ide/src/selection_ranges.rs | 2 +- crates/ide/src/semantic_index.rs | 6 +- crates/ide/src/semantic_target.rs | 8 +- crates/ide/src/semantic_tokens.rs | 2 +- crates/ide/src/source_targets/tests.rs | 12 +- crates/ide/src/source_targets/tests/cache.rs | 2 +- crates/ide/src/verilog_2005.rs | 44 +-- crates/ide/src/workspace_symbols.rs | 8 +- crates/project-model/src/lib.rs | 41 +- crates/vfs/Cargo.toml | 2 + crates/vfs/src/file_set.rs | 358 ++++++++++++------ crates/vfs/src/lib.rs | 33 ++ crates/vfs/src/loader.rs | 25 +- crates/vfs/src/notify.rs | 3 +- crates/vfs/src/path_glob.rs | 70 ++++ crates/vfs/src/vfs_path.rs | 5 + crates/workspace-model/src/source_root.rs | 8 +- src/global_state/diagnostics.rs | 6 +- src/global_state/event_loop.rs | 67 ++-- .../handlers/notification/text_changes.rs | 39 +- .../handlers/request/code_action.rs | 6 +- src/global_state/handlers/request/symbols.rs | 2 +- src/global_state/main_loop.rs | 55 +-- src/global_state/mem_docs.rs | 5 +- src/global_state/process_changes.rs | 114 +++--- src/global_state/qihe/tests.rs | 10 +- src/global_state/reload.rs | 14 +- src/global_state/snapshot.rs | 31 +- src/global_state/workspace_state.rs | 15 +- src/lsp_ext/to_proto.rs | 2 +- 49 files changed, 705 insertions(+), 464 deletions(-) create mode 100644 crates/vfs/src/path_glob.rs diff --git a/crates/hir/src/base_db/change.rs b/crates/hir/src/base_db/change.rs index 57250cb7..9401a68b 100644 --- a/crates/hir/src/base_db/change.rs +++ b/crates/hir/src/base_db/change.rs @@ -1,6 +1,6 @@ use salsa::Durability; use triomphe::Arc; -use vfs::ChangedFile; +use vfs::{Change as VfsChange, ChangedFile}; use crate::base_db::{ project::{PreprocessConfig, SharedProjectConfig}, @@ -66,15 +66,15 @@ impl Change { .path_for_file(&file_id) .and_then(|path| path.as_abs_path().map(ToOwned::to_owned)); - match changed_file.change_kind { - vfs::ChangeKind::Create(_, _) => { + match &changed_file.change { + VfsChange::Create(_, _) => { files_changed |= files.insert(file_id); } - vfs::ChangeKind::Delete => { + VfsChange::Delete => { files.remove(&file_id); files_changed = true; } - vfs::ChangeKind::Modify(_, _) => {} + VfsChange::Modify(_, _) => {} } let text = changed_file.text().unwrap_or_else(|| Arc::from("")); diff --git a/crates/hir/src/base_db/source_db.rs b/crates/hir/src/base_db/source_db.rs index c813020c..5b44bd46 100644 --- a/crates/hir/src/base_db/source_db.rs +++ b/crates/hir/src/base_db/source_db.rs @@ -5,7 +5,7 @@ use syntax::{ }; use triomphe::Arc; use utils::{line_index::TextSize, path_identity::PathIdentityIndex}; -use vfs::{FileId, anchored_path::AnchoredPath}; +use vfs::{FileId, AnchoredPath}; use crate::base_db::{ compilation_plan::{self, CompilationPlan}, @@ -643,8 +643,8 @@ mod tests { salsa::{self, Durability}, }; - const TOP: FileId = FileId(0); - const MANIFEST: FileId = FileId(1); + const TOP: FileId = FileId::from_raw(0); + const MANIFEST: FileId = FileId::from_raw(1); const ROOT: SourceRootId = SourceRootId(0); #[salsa::database(SourceDbStorage, SourceRootDbStorage)] @@ -663,7 +663,7 @@ mod tests { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/hir/src/file.rs b/crates/hir/src/file.rs index b397c755..06bb5502 100644 --- a/crates/hir/src/file.rs +++ b/crates/hir/src/file.rs @@ -37,7 +37,7 @@ mod tests { #[test] fn as_file_returns_source_file_when_hir_file_is_real_file() { - let file_id = FileId(7); + let file_id = FileId::from_raw(7); let hir_file_id = HirFileId::from(file_id); diff --git a/crates/hir/src/hir_def/macro_file/tests.rs b/crates/hir/src/hir_def/macro_file/tests.rs index 88c59fd2..730a99f3 100644 --- a/crates/hir/src/hir_def/macro_file/tests.rs +++ b/crates/hir/src/hir_def/macro_file/tests.rs @@ -12,7 +12,7 @@ use utils::{ line_index::{TextRange, TextSize}, paths::{AbsPathBuf, Utf8PathBuf}, }; -use vfs::{FileId, FileSet, VfsPath, anchored_path::AnchoredPath}; +use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; use super::*; use crate::{ @@ -30,7 +30,7 @@ use crate::{ file::HirFileId, }; -const TOP: FileId = FileId(0); +const TOP: FileId = FileId::from_raw(0); const ROOT: SourceRootId = SourceRootId(0); const PROFILE: CompilationProfileId = CompilationProfileId(0); @@ -50,7 +50,7 @@ impl fmt::Debug for TestDb { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/hir/src/preproc/tests.rs b/crates/hir/src/preproc/tests.rs index ecd16b1e..b39dc830 100644 --- a/crates/hir/src/preproc/tests.rs +++ b/crates/hir/src/preproc/tests.rs @@ -7,7 +7,7 @@ use utils::{ line_index::{TextRange, TextSize}, paths::{AbsPathBuf, Utf8PathBuf}, }; -use vfs::{FileId, FileSet, VfsPath, anchored_path::AnchoredPath}; +use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; use super::*; use crate::{ @@ -33,10 +33,10 @@ use crate::{ source_map::IsSrc, }; -const TOP: FileId = FileId(0); -const HEADER: FileId = FileId(1); -const LEAF: FileId = FileId(2); -const MANIFEST: FileId = FileId(3); +const TOP: FileId = FileId::from_raw(0); +const HEADER: FileId = FileId::from_raw(1); +const LEAF: FileId = FileId::from_raw(2); +const MANIFEST: FileId = FileId::from_raw(3); const ROOT: SourceRootId = SourceRootId(0); const PROFILE: CompilationProfileId = CompilationProfileId(0); @@ -56,7 +56,7 @@ impl fmt::Debug for TestDb { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/hir/src/scope.rs b/crates/hir/src/scope.rs index 7e039e07..96e5bf1b 100644 --- a/crates/hir/src/scope.rs +++ b/crates/hir/src/scope.rs @@ -690,7 +690,7 @@ mod tests { get::GetRef, paths::{AbsPathBuf, Utf8PathBuf}, }; - use vfs::{FileId, FileSet, VfsPath, anchored_path::AnchoredPath}; + use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; use crate::{ base_db::{ @@ -710,7 +710,7 @@ mod tests { symbol::{DefKind, DefLoc, NameContext}, }; - const TOP: FileId = FileId(0); + const TOP: FileId = FileId::from_raw(0); const ROOT: SourceRootId = SourceRootId(0); const PROFILE: CompilationProfileId = CompilationProfileId(0); @@ -730,7 +730,7 @@ mod tests { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/hir/src/semantics/pathres.rs b/crates/hir/src/semantics/pathres.rs index 8ea81897..68c619f7 100644 --- a/crates/hir/src/semantics/pathres.rs +++ b/crates/hir/src/semantics/pathres.rs @@ -302,7 +302,7 @@ mod tests { use smol_str::SmolStr; use triomphe::Arc; use utils::paths::{AbsPathBuf, Utf8PathBuf}; - use vfs::{FileId, FileSet, VfsPath, anchored_path::AnchoredPath}; + use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; use super::*; use crate::{ @@ -323,7 +323,7 @@ mod tests { symbol::{DefKind, NameContext}, }; - const TOP: FileId = FileId(0); + const TOP: FileId = FileId::from_raw(0); const ROOT: SourceRootId = SourceRootId(0); const PROFILE: CompilationProfileId = CompilationProfileId(0); @@ -343,7 +343,7 @@ mod tests { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/hir/src/type_infer.rs b/crates/hir/src/type_infer.rs index 956ac1d2..cbca8206 100644 --- a/crates/hir/src/type_infer.rs +++ b/crates/hir/src/type_infer.rs @@ -858,7 +858,7 @@ mod tests { use smol_str::SmolStr; use triomphe::Arc; use utils::paths::{AbsPathBuf, Utf8PathBuf}; - use vfs::{FileId, FileSet, VfsPath, anchored_path::AnchoredPath}; + use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; use super::*; use crate::{ @@ -878,7 +878,7 @@ mod tests { symbol::{DefLoc, NameContext}, }; - const TOP: FileId = FileId(0); + const TOP: FileId = FileId::from_raw(0); const ROOT: SourceRootId = SourceRootId(0); const PROFILE: CompilationProfileId = CompilationProfileId(0); @@ -898,7 +898,7 @@ mod tests { impl FileLoader for TestDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); SourceRootDb::source_root(self, source_root_id).resolve_path(path) } } diff --git a/crates/ide/src/analysis.rs b/crates/ide/src/analysis.rs index b3789e41..cdc5912c 100644 --- a/crates/ide/src/analysis.rs +++ b/crates/ide/src/analysis.rs @@ -142,7 +142,7 @@ impl Analysis { let plan = db.compilation_plan_for_profile(Some(profile_id)); let mut file_ids = plan.roots.clone(); file_ids.extend(plan.include_only.iter().copied()); - file_ids.sort_unstable_by_key(|file_id| file_id.0); + file_ids.sort_unstable_by_key(|file_id| file_id.index()); file_ids.dedup(); file_ids }) diff --git a/crates/ide/src/code_action/tests.rs b/crates/ide/src/code_action/tests.rs index fa7b9bd8..8b3a8ba7 100644 --- a/crates/ide/src/code_action/tests.rs +++ b/crates/ide/src/code_action/tests.rs @@ -113,7 +113,7 @@ fn db_with_file(text: &str) -> (RootDb, FileId, TextSize) { } fn db_with_text(text: &str) -> (RootDb, FileId) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let mut file_set = FileSet::default(); file_set.insert(file_id, VfsPath::new_virtual_path("/test.sv".to_owned())); diff --git a/crates/ide/src/completion/engine/tests.rs b/crates/ide/src/completion/engine/tests.rs index 8931edcd..572cd0ed 100644 --- a/crates/ide/src/completion/engine/tests.rs +++ b/crates/ide/src/completion/engine/tests.rs @@ -18,7 +18,7 @@ fn setup_with_path(text: &str, path: &str) -> (AnalysisHost, FilePosition) { let mut owned = text; owned = owned.replace(marker, ""); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path(path.to_string()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/db/root_db.rs b/crates/ide/src/db/root_db.rs index 49d9586a..385684c7 100644 --- a/crates/ide/src/db/root_db.rs +++ b/crates/ide/src/db/root_db.rs @@ -17,7 +17,7 @@ use hir::{ }, }; use triomphe::Arc; -use vfs::{FileId, anchored_path::AnchoredPath}; +use vfs::{FileId, AnchoredPath}; use crate::db::{ line_index_db::LineIndexDbStorage, workspace_symbol_index_db::WorkspaceSymbolIndexDbStorage, @@ -53,7 +53,7 @@ impl fmt::Debug for RootDb { impl FileLoader for RootDb { fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let source_root_id = SourceRootDb::source_root_id(self, path.anchor_id); + let source_root_id = SourceRootDb::source_root_id(self, path.anchor); let source_root = SourceRootDb::source_root(self, source_root_id); source_root.resolve_path(path) } diff --git a/crates/ide/src/definitions.rs b/crates/ide/src/definitions.rs index 82bbe533..63096d06 100644 --- a/crates/ide/src/definitions.rs +++ b/crates/ide/src/definitions.rs @@ -374,7 +374,7 @@ mod tests { use crate::{analysis_host::AnalysisHost, db::root_db::RootDb}; fn host_with_file(text: &str) -> (AnalysisHost, FileId) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.v".to_string()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 6a403e1f..71674cca 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -185,7 +185,7 @@ fn compilation_profile_file_ids(db: &RootDb, profile_id: CompilationProfileId) - let plan = db.compilation_plan_for_profile(Some(profile_id)); let mut file_ids = plan.roots.clone(); file_ids.extend(plan.include_only.iter().copied()); - file_ids.sort_unstable_by_key(|file_id| file_id.0); + file_ids.sort_unstable_by_key(|file_id| file_id.index()); file_ids.dedup(); file_ids } @@ -527,7 +527,7 @@ mod tests { false, ); - let diagnostics = diagnostics(&db, FileId(2)); + let diagnostics = diagnostics(&db, FileId::from_raw(2)); assert!( diagnostics.iter().any(|diag| { @@ -552,7 +552,7 @@ mod tests { false, ); - let diagnostics = diagnostics(&db, FileId(1)); + let diagnostics = diagnostics(&db, FileId::from_raw(1)); assert!( diagnostics.iter().all(|diag| diag.source != DiagnosticSource::Vide), @@ -571,7 +571,7 @@ mod tests { false, ); - let diagnostics = diagnostics(&db, FileId(2)); + let diagnostics = diagnostics(&db, FileId::from_raw(2)); assert!( diagnostics.iter().any(|diag| { @@ -596,7 +596,7 @@ mod tests { false, ); - let diagnostics = diagnostics(&db, FileId(2)); + let diagnostics = diagnostics(&db, FileId::from_raw(2)); let diagnostic = diagnostics .iter() .find(|diag| { @@ -607,7 +607,7 @@ mod tests { panic!("expected generated instantiation diagnostic: {diagnostics:?}") }); - assert_eq!(diagnostic.file_id, FileId(2)); + assert_eq!(diagnostic.file_id, FileId::from_raw(2)); assert_eq!(diagnostic.range, range_of(top, "child")); assert_ne!(diagnostic.range, range_of(top, "`MAKE")); } @@ -625,7 +625,7 @@ mod tests { ); disable_semantic_diagnostics(&mut db); - let diagnostics = diagnostics(&db, FileId(2)); + let diagnostics = diagnostics(&db, FileId::from_raw(2)); assert!( diagnostics.iter().all(|diag| { @@ -635,7 +635,7 @@ mod tests { "display-only virtual expansion must not publish ambiguous module diagnostics: {diagnostics:?}" ); assert!( - diagnostics.iter().all(|diag| diag.file_id.0 < 3), + diagnostics.iter().all(|diag| diag.file_id.index() < 3), "diagnostics must not target synthetic virtual FileIds: {diagnostics:?}" ); } @@ -651,7 +651,7 @@ mod tests { true, ); - let diagnostics = diagnostics(&db, FileId(1)); + let diagnostics = diagnostics(&db, FileId::from_raw(1)); assert!( diagnostics.iter().all(|diag| diag.source != DiagnosticSource::Vide), @@ -664,7 +664,7 @@ mod tests { let text = "`ifdef USE_IMPL\nlogic if_body;\n`else\nlogic else_body;\n`endif\n"; let db = db_with_files(&[("/top.sv", text)], false); - let diagnostics = diagnostics(&db, FileId(0)); + let diagnostics = diagnostics(&db, FileId::from_raw(0)); let inactive = diagnostics .iter() .find(|diag| diag.name == INACTIVE_PREPROCESSOR_BRANCH.name) @@ -681,7 +681,7 @@ mod tests { let text = "`ifdef USE_IMPL\nlogic if_body;\n`else\nlogic else_body;\n`endif\n"; let db = db_with_predefines(&[("/top.sv", text)], vec!["USE_IMPL".to_owned()]); - let diagnostics = diagnostics(&db, FileId(0)); + let diagnostics = diagnostics(&db, FileId::from_raw(0)); let inactive = diagnostics .iter() .find(|diag| diag.name == INACTIVE_PREPROCESSOR_BRANCH.name) @@ -698,7 +698,7 @@ mod tests { ); disable_diagnostics(&mut db); - let diagnostics = diagnostics(&db, FileId(0)); + let diagnostics = diagnostics(&db, FileId::from_raw(0)); assert!( diagnostics.is_empty(), @@ -723,11 +723,11 @@ mod tests { "expected semantic diagnostic from module declared in another file: {diagnostics:?}" ); assert!( - diagnostics.iter().all(|diag| diag.file_id == FileId(1)), + diagnostics.iter().all(|diag| diag.file_id == FileId::from_raw(1)), "document diagnostics should only include diagnostics attributed to the requested file: {diagnostics:?}" ); assert!( - db.semantic_diagnostics(FileId(0)).is_empty(), + db.semantic_diagnostics(FileId::from_raw(0)).is_empty(), "child file should not receive diagnostics that belong to top.sv" ); } @@ -742,7 +742,7 @@ mod tests { false, ); - let diagnostics = diagnostics(&db, FileId(1)); + let diagnostics = diagnostics(&db, FileId::from_raw(1)); assert!(!diagnostics.is_empty(), "expected syntax diagnostics: {diagnostics:?}"); assert!( @@ -759,7 +759,7 @@ mod tests { true, ); - let diagnostics = diagnostics(&db, FileId(0)); + let diagnostics = diagnostics(&db, FileId::from_raw(0)); assert!( diagnostics.is_empty(), @@ -769,8 +769,8 @@ mod tests { #[test] fn syntax_only_manifest_does_not_disable_open_file_syntax_diagnostics() { - let manifest_id = FileId(0); - let open_file_id = FileId(1); + let manifest_id = FileId::from_raw(0); + let open_file_id = FileId::from_raw(1); let mut manifest_files = FileSet::default(); manifest_files.insert(manifest_id, VfsPath::new_virtual_path("/project/vide.toml".into())); let mut open_files = FileSet::default(); @@ -818,7 +818,7 @@ mod tests { #[test] fn best_effort_index_root_does_not_produce_fallback_compilation_plan() { let mut db = RootDb::new(None); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let mut file_set = FileSet::default(); file_set.insert(file_id, VfsPath::new_virtual_path("/top.sv".to_owned())); @@ -846,19 +846,19 @@ mod tests { let mut db = RootDb::new(None); let mut file_set = FileSet::default(); - file_set.insert(FileId(0), VfsPath::from(top_path.clone())); - file_set.insert(FileId(1), VfsPath::from(header_path)); + file_set.insert(FileId::from_raw(0), VfsPath::from(top_path.clone())); + file_set.insert(FileId::from_raw(1), VfsPath::from(header_path)); let mut change = Change::new(); change.add_changed_file(ChangedFile { - file_id: FileId(0), + file_id: FileId::from_raw(0), change_kind: ChangeKind::Create( Arc::from("module top;\n`include \"defs.vh\"\nendmodule\n"), LineEnding::Unix, ), }); change.add_changed_file(ChangedFile { - file_id: FileId(1), + file_id: FileId::from_raw(1), change_kind: ChangeKind::Create( Arc::from("logic value = missing_name;\n"), LineEnding::Unix, @@ -885,7 +885,7 @@ mod tests { "expected semantic diagnostic in included header: {diagnostics:?}" ); assert!( - diagnostics.iter().all(|diag| diag.file_id == FileId(1)), + diagnostics.iter().all(|diag| diag.file_id == FileId::from_raw(1)), "header diagnostics should be attributed to the header file: {diagnostics:?}" ); } @@ -904,16 +904,16 @@ mod tests { let mut db = RootDb::new(None); let mut file_set = FileSet::default(); - file_set.insert(FileId(0), VfsPath::from(pkg_path.clone())); - file_set.insert(FileId(1), VfsPath::from(frag_path)); + file_set.insert(FileId::from_raw(0), VfsPath::from(pkg_path.clone())); + file_set.insert(FileId::from_raw(1), VfsPath::from(frag_path)); let mut change = Change::new(); change.add_changed_file(ChangedFile { - file_id: FileId(0), + file_id: FileId::from_raw(0), change_kind: ChangeKind::Create(Arc::from(pkg_text), LineEnding::Unix), }); change.add_changed_file(ChangedFile { - file_id: FileId(1), + file_id: FileId::from_raw(1), change_kind: ChangeKind::Create(Arc::from(vfs_frag_text), LineEnding::Unix), }); change.set_roots(vec![SourceRoot::new_local(file_set)]); @@ -928,15 +928,15 @@ mod tests { db.apply_change(change); let plan = db.compilation_plan_for_root(SourceRootId(0)); - assert!(plan.include_only.contains(&FileId(1))); - assert_eq!(plan.roots, vec![FileId(0)]); + assert!(plan.include_only.contains(&FileId::from_raw(1))); + assert_eq!(plan.roots, vec![FileId::from_raw(0)]); let diagnostics = compilation_profile_diagnostics(&db, CompilationProfileId(0)); assert!( diagnostics .iter() - .any(|diag| diag.file_id == FileId(1) && diag.message.contains("missing_name")), + .any(|diag| diag.file_id == FileId::from_raw(1) && diag.message.contains("missing_name")), "included .sv should use VFS text and receive mapped diagnostics: {diagnostics:?}" ); } @@ -962,22 +962,22 @@ mod tests { let mut db = RootDb::new(None); let mut src_files = FileSet::default(); - src_files.insert(FileId(0), VfsPath::from(top_path)); + src_files.insert(FileId::from_raw(0), VfsPath::from(top_path)); let mut include_files = FileSet::default(); - include_files.insert(FileId(1), VfsPath::from(mid_path)); - include_files.insert(FileId(2), VfsPath::from(leaf_path)); + include_files.insert(FileId::from_raw(1), VfsPath::from(mid_path)); + include_files.insert(FileId::from_raw(2), VfsPath::from(leaf_path)); let mut change = Change::new(); change.add_changed_file(ChangedFile { - file_id: FileId(0), + file_id: FileId::from_raw(0), change_kind: ChangeKind::Create(Arc::from(top_text), LineEnding::Unix), }); change.add_changed_file(ChangedFile { - file_id: FileId(1), + file_id: FileId::from_raw(1), change_kind: ChangeKind::Create(Arc::from(mid_text), LineEnding::Unix), }); change.add_changed_file(ChangedFile { - file_id: FileId(2), + file_id: FileId::from_raw(2), change_kind: ChangeKind::Create(Arc::from(vfs_leaf_text), LineEnding::Unix), }); change.set_roots(vec![ @@ -999,15 +999,15 @@ mod tests { let plan = db.compilation_plan_for_root(SourceRootId(0)); assert_eq!(plan.include_only.len(), 2); - assert!(plan.include_only.contains(&FileId(1))); - assert!(plan.include_only.contains(&FileId(2))); + assert!(plan.include_only.contains(&FileId::from_raw(1))); + assert!(plan.include_only.contains(&FileId::from_raw(2))); let diagnostics = compilation_profile_diagnostics(&db, CompilationProfileId(0)); assert!( diagnostics .iter() - .any(|diag| diag.file_id == FileId(2) && diag.message.contains("missing_name")), + .any(|diag| diag.file_id == FileId::from_raw(2) && diag.message.contains("missing_name")), "transitively included .sv should use VFS text: {diagnostics:?}" ); } @@ -1025,23 +1025,23 @@ mod tests { let mut db = RootDb::new(None); let mut file_set = FileSet::default(); - file_set.insert(FileId(0), VfsPath::from(a_path.clone())); - file_set.insert(FileId(1), VfsPath::from(b_path.clone())); + file_set.insert(FileId::from_raw(0), VfsPath::from(a_path.clone())); + file_set.insert(FileId::from_raw(1), VfsPath::from(b_path.clone())); let mut change = Change::new(); change.add_changed_file(ChangedFile { - file_id: FileId(0), + file_id: FileId::from_raw(0), change_kind: ChangeKind::Create(Arc::from(a_text), LineEnding::Unix), }); change.add_changed_file(ChangedFile { - file_id: FileId(1), + file_id: FileId::from_raw(1), change_kind: ChangeKind::Create(Arc::from(b_text), LineEnding::Unix), }); change.set_roots(vec![SourceRoot::new_local(file_set)]); db.apply_change(change); let plan = db.compilation_plan_for_root(SourceRootId(0)); - assert_eq!(plan.roots, vec![FileId(0), FileId(1)]); + assert_eq!(plan.roots, vec![FileId::from_raw(0), FileId::from_raw(1)]); let buffers = compilation_source_buffers_for_plan(&db, &plan); let buffer_paths = buffers.iter().map(|buffer| buffer.path.as_str()).collect::>(); let a_path = a_path.to_string(); diff --git a/crates/ide/src/document_highlight.rs b/crates/ide/src/document_highlight.rs index 6c74b182..4e29f320 100644 --- a/crates/ide/src/document_highlight.rs +++ b/crates/ide/src/document_highlight.rs @@ -133,7 +133,7 @@ mod tests { let mut owned = text; owned = owned.replace(marker, ""); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.v".to_string()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/formatting.rs b/crates/ide/src/formatting.rs index 8d384dcf..68d82550 100644 --- a/crates/ide/src/formatting.rs +++ b/crates/ide/src/formatting.rs @@ -390,7 +390,7 @@ mod tests { }; fn db_with_file(text: &str) -> (RootDb, FileId) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.sv".to_owned()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/inlay_hint.rs b/crates/ide/src/inlay_hint.rs index 79ad0d4e..cc633a7b 100644 --- a/crates/ide/src/inlay_hint.rs +++ b/crates/ide/src/inlay_hint.rs @@ -590,7 +590,7 @@ mod tests { use crate::db::root_db::RootDb; fn db_with_file(text: &str) -> (RootDb, FileId) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.sv".to_owned()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/macro_hover_tests.rs b/crates/ide/src/macro_hover_tests.rs index dd50567f..007ced97 100644 --- a/crates/ide/src/macro_hover_tests.rs +++ b/crates/ide/src/macro_hover_tests.rs @@ -76,8 +76,8 @@ endmodule std::fs::write(&top_path, &top_text).unwrap(); std::fs::write(&header_path, &header_text).unwrap(); - let top_file_id = FileId(0); - let header_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let header_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); file_set.insert(header_file_id, VfsPath::from(header_path)); diff --git a/crates/ide/src/module_resolution.rs b/crates/ide/src/module_resolution.rs index 86dc8b27..caf74170 100644 --- a/crates/ide/src/module_resolution.rs +++ b/crates/ide/src/module_resolution.rs @@ -191,7 +191,7 @@ fn module_candidates(db: &RootDb, name: &Ident) -> Vec { ); } - candidates.sort_by_key(|(file_id, name_start, _)| (file_id.0, *name_start)); + candidates.sort_by_key(|(file_id, name_start, _)| (file_id.index(), *name_start)); candidates.dedup_by_key(|(_, _, module_id)| *module_id); candidates.into_iter().map(|(_, _, module_id)| module_id).collect() } @@ -254,7 +254,7 @@ fn resolve_by_proximity( } } - candidates.sort_by_key(|module_id| module_id.file_id.file_id().0); + candidates.sort_by_key(|module_id| module_id.file_id.file_id().index()); match best_modules.as_slice() { [] => ModuleResolution::Unresolved, @@ -572,7 +572,7 @@ mod tests { fn file_path(files: &[(String, String)], file_id: FileId) -> String { files - .get(file_id.0 as usize) + .get(file_id.index() as usize) .map(|(path, _)| path.clone()) .unwrap_or_else(|| format!("", file_id)) } diff --git a/crates/ide/src/selection_ranges.rs b/crates/ide/src/selection_ranges.rs index 3c9e475f..e1ad28fc 100644 --- a/crates/ide/src/selection_ranges.rs +++ b/crates/ide/src/selection_ranges.rs @@ -110,7 +110,7 @@ mod tests { use crate::{FilePosition, db::root_db::RootDb}; fn db_with_file(text: &str) -> (RootDb, FileId) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.sv".to_owned()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/semantic_index.rs b/crates/ide/src/semantic_index.rs index bd83501d..450217a5 100644 --- a/crates/ide/src/semantic_index.rs +++ b/crates/ide/src/semantic_index.rs @@ -135,7 +135,7 @@ impl ModuleIndex { modules_by_name: modules_by_name .into_iter() .map(|(name, mut modules)| { - modules.sort_by_key(|module| (module.file_id.0, module.name_range.start())); + modules.sort_by_key(|module| (module.file_id.index(), module.name_range.start())); modules.dedup_by(|lhs, rhs| { lhs.module_id == rhs.module_id || (lhs.file_id == rhs.file_id && lhs.name_range == rhs.name_range) @@ -486,9 +486,9 @@ fn finish_edge_map( fn sort_and_dedup_edges(edges: &mut Vec) { edges.sort_by_key(|edge| { ( - edge.caller.file_id.0, + edge.caller.file_id.index(), edge.caller.name_range.start(), - edge.callee.file_id.0, + edge.callee.file_id.index(), edge.callee.name_range.start(), edge.call_range.start(), ) diff --git a/crates/ide/src/semantic_target.rs b/crates/ide/src/semantic_target.rs index 0d549461..4a121dea 100644 --- a/crates/ide/src/semantic_target.rs +++ b/crates/ide/src/semantic_target.rs @@ -354,7 +354,7 @@ mod tests { } fn setup(text: &str, needle: &str) -> (AnalysisHost, FileId, TextSize, TextRange) { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.sv".to_string()); let mut file_set = FileSet::default(); file_set.insert(file_id, path); @@ -417,7 +417,7 @@ mod tests { }; let resolution = TargetResolution::from_source_resolution( - FileId(0), + FileId::from_raw(0), crate::source_targets::SourceTargetResolution::Blocked(block.clone()), ); @@ -439,7 +439,7 @@ mod tests { }; let resolution = TargetResolution::from_source_resolution( - FileId(0), + FileId::from_raw(0), crate::source_targets::SourceTargetResolution::Blocked(block), ); @@ -468,7 +468,7 @@ mod tests { }; let resolution = TargetResolution::from_source_resolution( - FileId(0), + FileId::from_raw(0), crate::source_targets::SourceTargetResolution::Ambiguous(alternatives), ); diff --git a/crates/ide/src/semantic_tokens.rs b/crates/ide/src/semantic_tokens.rs index 8048efb4..fe247cd9 100644 --- a/crates/ide/src/semantic_tokens.rs +++ b/crates/ide/src/semantic_tokens.rs @@ -718,7 +718,7 @@ mod tests { fn setup(text: &str) -> (AnalysisHost, FileId) { let text = normalize_fixture_text(text); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path("/test.v".to_string()); let mut file_set = FileSet::default(); diff --git a/crates/ide/src/source_targets/tests.rs b/crates/ide/src/source_targets/tests.rs index d5f6974f..937613cb 100644 --- a/crates/ide/src/source_targets/tests.rs +++ b/crates/ide/src/source_targets/tests.rs @@ -12,7 +12,7 @@ mod macro_gate; #[test] fn source_targets_origin_source_range_hit_test_is_half_open() { - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let range = TextRange::new(5.into(), 10.into()); let origin = Origin::File { file: file_id, range }; @@ -34,7 +34,7 @@ fn source_targets_origin_source_range_hit_test_is_half_open() { fn source_targets_source_token_range_mismatch_uses_original_syntax_hit() { let (root, offset, parser_range) = root_and_offset("module m; wire payload_i; endmodule\n", "payload_i", 2); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let origin_range = TextRange::new( parser_range.start() + TextSize::from(1), parser_range.end() - TextSize::from(1), @@ -60,7 +60,7 @@ fn source_targets_source_token_range_mismatch_uses_original_syntax_hit() { #[test] fn source_targets_macro_argument_selects_syntax_token_by_trace_identity() { let db = RootDb::new(None); - let model_file = FileId(0); + let model_file = FileId::from_raw(0); let source = r#"`define ID(x) x module m; assign y = `ID(payload_i); @@ -125,7 +125,7 @@ endmodule #[test] fn source_targets_macro_argument_selects_only_the_hit_emitted_token() { let db = RootDb::new(None); - let model_file = FileId(0); + let model_file = FileId::from_raw(0); let source = r#"`define DUP(x) x x module m; assign y = `DUP(payload_i); @@ -228,7 +228,7 @@ fn source_targets_normal_syntax_path_still_selects_non_preproc_offsets() { fn source_targets_same_origin_hits_are_available_without_dropping_emitted_tokens() { let (root, offset, parser_range) = root_and_offset("module m; wire payload_i; endmodule\n", "payload_i", 0); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let hits = vec![test_source_hit(file_id, parser_range, 0), test_source_hit(file_id, parser_range, 1)]; @@ -248,7 +248,7 @@ fn source_targets_same_origin_hits_are_available_without_dropping_emitted_tokens fn source_targets_reports_ambiguous_preproc_hits_for_conflicting_targets() { let (root, offset, parser_range) = root_and_offset("module m; wire payload_i; endmodule\n", "payload_i", 2); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let first = TextRange::new(parser_range.start(), parser_range.start() + TextSize::from(4)); let second = TextRange::new(parser_range.start() + TextSize::from(1), parser_range.end()); let hits = vec![test_source_hit(file_id, first, 0), test_source_hit(file_id, second, 1)]; diff --git a/crates/ide/src/source_targets/tests/cache.rs b/crates/ide/src/source_targets/tests/cache.rs index 3db5354b..4688c98f 100644 --- a/crates/ide/src/source_targets/tests/cache.rs +++ b/crates/ide/src/source_targets/tests/cache.rs @@ -4,7 +4,7 @@ use super::*; fn source_target_request_cache_reuses_origin_lookup_for_repeated_reference_hits() { let mut cache = SourceTargetRequestCache::default(); let mut lookups = 0usize; - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let offset = TextSize::from(12); for _ in 0..3 { diff --git a/crates/ide/src/verilog_2005.rs b/crates/ide/src/verilog_2005.rs index 7d3173b5..953e7134 100644 --- a/crates/ide/src/verilog_2005.rs +++ b/crates/ide/src/verilog_2005.rs @@ -99,7 +99,7 @@ fn setup(text: &str) -> (AnalysisHost, FileId) { fn setup_with_path(text: &str, path: &str) -> (AnalysisHost, FileId) { let text = normalize_fixture_text(text); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path(path.to_string()); let mut file_set = FileSet::default(); @@ -120,7 +120,7 @@ fn setup_with_path(text: &str, path: &str) -> (AnalysisHost, FileId) { fn setup_best_effort_with_path(text: &str, path: &str) -> (AnalysisHost, FileId, String) { let text = normalize_fixture_text(text); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let path = VfsPath::new_virtual_path(path.to_string()); let mut file_set = FileSet::default(); @@ -143,9 +143,9 @@ fn setup_best_effort_with_path(text: &str, path: &str) -> (AnalysisHost, FileId, fn parsed_file_nodes_survive_parse_lru_eviction() { let mut file_set = FileSet::default(); let files = [ - (FileId(0), "/a.sv", "module a;\n wire x;\nendmodule\n"), - (FileId(1), "/b.sv", "module b;\nendmodule\n"), - (FileId(2), "/c.sv", "module c;\nendmodule\n"), + (FileId::from_raw(0), "/a.sv", "module a;\n wire x;\nendmodule\n"), + (FileId::from_raw(1), "/b.sv", "module b;\nendmodule\n"), + (FileId::from_raw(2), "/c.sv", "module c;\nendmodule\n"), ]; let mut change = Change::new(); @@ -162,13 +162,13 @@ fn parsed_file_nodes_survive_parse_lru_eviction() { db.apply_change(change); let sema = Semantics::new(&db); - let parsed_file = sema.parse_file(FileId(0)); + let parsed_file = sema.parse_file(FileId::from_raw(0)); let root = parsed_file.root().expect("a.sv should parse"); let child_count = root.child_count(); assert!(child_count > 0); - let _ = db.parse_src(FileId(1)); - let _ = db.parse_src(FileId(2)); + let _ = db.parse_src(FileId::from_raw(1)); + let _ = db.parse_src(FileId::from_raw(2)); assert_eq!(root.child_count(), child_count); assert!(root.first_token().is_some()); @@ -220,7 +220,7 @@ fn setup_marked_with_predefines( ) -> (AnalysisHost, FileId, String, HashMap) { let (text, markers) = strip_markers(normalize_fixture_text(text)); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let mut file_set = FileSet::default(); file_set.insert(file_id, VfsPath::new_virtual_path("/feature.v".to_owned())); @@ -273,8 +273,8 @@ fn setup_include_macro_project( std::fs::write(&top_path, &top_text).unwrap(); std::fs::write(&header_path, &header_text).unwrap(); - let top_file_id = FileId(0); - let header_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let header_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); @@ -610,8 +610,8 @@ endmodule fn best_effort_single_file_rename_rejects_cross_file_symbol() { let child_text = "module child;\nendmodule\n"; let top_text = "module top;\n child u();\nendmodule\n"; - let child_file_id = FileId(0); - let top_file_id = FileId(1); + let child_file_id = FileId::from_raw(0); + let top_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(child_file_id, VfsPath::new_virtual_path("/child.sv".to_owned())); @@ -1078,8 +1078,8 @@ endmodule let manifest_range = marked_range(&manifest_markers, "def", TextSize::of("\"FROM_MANIFEST=1\"")); - let top_file_id = FileId(0); - let manifest_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let manifest_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); file_set.insert(manifest_file_id, VfsPath::from(manifest_path.clone())); @@ -1234,8 +1234,8 @@ endmodule std::fs::write(&top_path, &top_text).unwrap(); std::fs::write(&define_path, define_text).unwrap(); - let top_file_id = FileId(0); - let define_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let define_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); file_set.insert(define_file_id, VfsPath::from(define_path)); @@ -1390,8 +1390,8 @@ endmodule std::fs::write(&top_path, &top_text).unwrap(); std::fs::write(&defs_path, defs_text).unwrap(); - let top_file_id = FileId(0); - let defs_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let defs_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); @@ -1779,8 +1779,8 @@ endmodule let (manifest_text, manifest_markers) = strip_markers(marked_manifest_text); let manifest_range = marked_range(&manifest_markers, "def", TextSize::of("\"LANE_WIDTH=12\"")); - let top_file_id = FileId(0); - let manifest_file_id = FileId(1); + let top_file_id = FileId::from_raw(0); + let manifest_file_id = FileId::from_raw(1); let mut file_set = FileSet::default(); file_set.insert(top_file_id, VfsPath::from(top_path)); file_set.insert(manifest_file_id, VfsPath::from(manifest_path.clone())); @@ -2984,7 +2984,7 @@ endmodule )); std::fs::write(&file_path, &text).unwrap(); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let mut file_set = FileSet::default(); file_set.insert(file_id, VfsPath::from(file_path)); diff --git a/crates/ide/src/workspace_symbols.rs b/crates/ide/src/workspace_symbols.rs index abf162e7..c5202551 100644 --- a/crates/ide/src/workspace_symbols.rs +++ b/crates/ide/src/workspace_symbols.rs @@ -267,8 +267,8 @@ impl Ord for RankedSymbol<'_> { fn compare_search_entries(lhs: &SymbolEntry, rhs: &SymbolEntry) -> Ordering { lhs.symbol .file_id - .0 - .cmp(&rhs.symbol.file_id.0) + .index() + .cmp(&rhs.symbol.file_id.index()) .then_with(|| lhs.symbol.focus_range.start().cmp(&rhs.symbol.focus_range.start())) .then_with(|| lhs.normalized_name.cmp(&rhs.normalized_name)) .then_with(|| lhs.symbol.name.cmp(&rhs.symbol.name)) @@ -277,7 +277,7 @@ fn compare_search_entries(lhs: &SymbolEntry, rhs: &SymbolEntry) -> Ordering { fn compare_symbol_entries(lhs: &SymbolEntry, rhs: &SymbolEntry) -> Ordering { lhs.normalized_name .cmp(&rhs.normalized_name) - .then_with(|| lhs.symbol.file_id.0.cmp(&rhs.symbol.file_id.0)) + .then_with(|| lhs.symbol.file_id.index().cmp(&rhs.symbol.file_id.index())) .then_with(|| lhs.symbol.focus_range.start().cmp(&rhs.symbol.focus_range.start())) .then_with(|| lhs.symbol.name.cmp(&rhs.symbol.name)) } @@ -333,7 +333,7 @@ mod tests { fn symbol(name: &str, container_name: Option<&str>) -> WorkspaceSymbol { WorkspaceSymbol { - file_id: FileId(0), + file_id: FileId::from_raw(0), name: name.to_owned(), focus_range: TextRange::empty(0.into()), full_range: TextRange::empty(0.into()), diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index 0c8c67a2..aca62715 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -656,12 +656,13 @@ pub fn get_workspace_folder( } let mut directory_include = Vec::new(); - if !root.include_dirs.is_empty() { - directory_include.push(PathMatcher::all_under_roots(root.include_dirs.clone())); - } + directory_include.extend(root.include_dirs.iter().cloned()); if !root.source_directories.is_empty() { - directory_include.push(root.source_directories.clone()); + directory_include + .extend(root.source_directories.scan_roots().cloned()); } + directory_include.sort(); + directory_include.dedup(); if !directory_include.is_empty() { let dirs = vfs::loader::Directories { extensions: source_file_extensions(), @@ -828,7 +829,7 @@ mod tests { use std::fs; use utils::{lines::LineEnding, test_support::TestDir}; - use vfs::{Vfs, loader::LoadResult}; + use vfs::Vfs; use workspace_model::{source_db::SourceFileKind, source_root::SourceRootRole}; use super::*; @@ -1056,10 +1057,7 @@ include_dirs = ["include"] let mut vfs = Vfs::default(); for file in [&header, &top] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); @@ -1120,10 +1118,7 @@ include_dirs = ["include"] let mut vfs = Vfs::default(); for file in [&top, &manifest] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); @@ -1202,10 +1197,7 @@ exclude = ["rtl/excluded/**"] let mut vfs = Vfs::default(); for file in [&top, &excluded_top] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); @@ -1286,10 +1278,7 @@ include_dirs = [] let mut vfs = Vfs::default(); for file in [&top, &sibling] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); @@ -1327,10 +1316,7 @@ include_dirs = [] let mut vfs = Vfs::default(); for file in [&top, &sibling] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); @@ -1370,10 +1356,7 @@ exclude = ["**/*_bb.v"] let mut vfs = Vfs::default(); for file in [&top, &blackbox] { - vfs.set_file_contents( - &VfsPath::from(file.clone()), - LoadResult::Loaded(String::new(), LineEnding::Unix), - ); + vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); } let roots = source_root_config.partition(&vfs); diff --git a/crates/vfs/Cargo.toml b/crates/vfs/Cargo.toml index f05a7665..d0f0e14d 100644 --- a/crates/vfs/Cargo.toml +++ b/crates/vfs/Cargo.toml @@ -11,12 +11,14 @@ notify-backend = ["dep:notify", "dep:rayon", "dep:walkdir"] [dependencies] crossbeam-channel.workspace = true fst = "0.4.7" +globset.workspace = true indexmap = { version = "2.0.0", features = [] } nohash-hasher.workspace = true notify = { version = "6.1.1", optional = true } rayon = { version = "1.10.0", optional = true } rustc-hash.workspace = true tracing.workspace = true +triomphe.workspace = true utils.workspace = true walkdir = { version = "2.4.0", optional = true } diff --git a/crates/vfs/src/file_set.rs b/crates/vfs/src/file_set.rs index 0c41ede5..edd13247 100644 --- a/crates/vfs/src/file_set.rs +++ b/crates/vfs/src/file_set.rs @@ -1,96 +1,169 @@ -//! Partitions a list of files into disjoint subsets. -//! -//! Files which do not belong to any explicitly configured `FileSet` belong to -//! the default `FileSet`. -use std::fmt; - use fst::{IntoStreamer, Streamer}; -use indexmap::IndexMap; -use rustc_hash::{FxBuildHasher, FxHashMap}; +use nohash_hasher::IntMap; +use rustc_hash::{FxHashMap, FxHashSet}; +use utils::paths::{AbsPath, AbsPathBuf}; -use crate::{AnchoredPath, FileId, Vfs, VfsPath}; +use crate::{ + AnchoredPath, FileId, Vfs, VfsPath, + path_glob::PathGlobMatcher, +}; -/// A set of [`VfsPath`]s identified by [`FileId`]s. -#[derive(Default, Clone, Eq, PartialEq)] +/// Bidirectional path map for a single source root. +/// +/// A `FileSet` stores the path spelling selected during source-root +/// partitioning. That spelling may be a VFS alias rather than the primary path +/// if the alias is the one that belongs to this root. +#[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct FileSet { files: FxHashMap, - paths: IndexMap, + paths: IntMap, } impl FileSet { - /// Returns the number of stored paths. pub fn len(&self) -> usize { self.files.len() } - /// Get the id of the file corresponding to `path`. - /// - /// If either `path`'s [`anchor`](AnchoredPath::anchor) or the resolved path is not in - /// the set, returns [`None`]. - pub fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { - let mut base = self.paths[&path.anchor].clone(); - base.pop(); - let path = base.join(path.path)?; - self.files.get(&path).copied() + pub fn is_empty(&self) -> bool { + self.files.is_empty() } - /// Get the id corresponding to `path` if it exists in the set. - pub fn file_for_path(&self, path: &VfsPath) -> Option<&FileId> { + pub fn get_file(&self, path: &VfsPath) -> Option<&FileId> { self.files.get(path) } - /// Get the path corresponding to `file` if it exists in the set. - pub fn path_for_file(&self, file: &FileId) -> Option<&VfsPath> { + pub fn get_path(&self, file: &FileId) -> Option<&VfsPath> { self.paths.get(file) } - /// Insert the `file_id, path` pair into the set. - /// - /// # Note - /// Multiple [`FileId`] can be mapped to the same [`VfsPath`], and vice-versa. + pub fn resolve_path(&self, path: AnchoredPath<'_>) -> Option { + let mut base = self.paths.get(&path.anchor)?.clone(); + base.pop(); + let path = base.join(path.path)?; + self.files.get(&path).copied() + } + pub fn insert(&mut self, file_id: FileId, path: VfsPath) { self.files.insert(path.clone(), file_id); self.paths.insert(file_id, path); } - /// Iterate over this set's ids. pub fn iter(&self) -> impl Iterator + '_ { self.paths.keys().copied() } } -impl fmt::Debug for FileSet { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FileSet").field("n_files", &self.files.len()).finish() - } -} - -/// This contains path prefixes to partition a [`Vfs`] into [`FileSet`]s. +/// Rules for partitioning VFS files into ordered source roots. /// -/// # Example -/// ```rust -/// # use vfs::{file_set::FileSetConfigBuilder, VfsPath, Vfs}; -/// let mut builder = FileSetConfigBuilder::default(); -/// builder.add_file_set(vec![VfsPath::new_virtual_path("/src".to_string())]); -/// let config = builder.build(); -/// let mut file_system = Vfs::default(); -/// file_system.set_file_contents(VfsPath::new_virtual_path("/src/main.rs".to_string()), Some(vec![])); -/// file_system.set_file_contents(VfsPath::new_virtual_path("/src/lib.rs".to_string()), Some(vec![])); -/// file_system.set_file_contents(VfsPath::new_virtual_path("/build.rs".to_string()), Some(vec![])); -/// // contains the sets : -/// // { "/src/main.rs", "/src/lib.rs" } -/// // { "build.rs" } -/// let sets = config.partition(&file_system); -/// ``` +/// Root order is significant. For one VFS file with several aliases, +/// classification evaluates every alias and chooses the earliest non-ignored +/// root. Within a single alias, normal prefix matching still picks the most +/// specific configured root. #[derive(Debug)] pub struct FileSetConfig { - /// Number of sets that `self` can partition a [`Vfs`] into. - /// - /// This should be the number of sets in `self.map` + 1 for files that don't fit in any - /// defined set. - n_file_sets: usize, - /// Map from encoded paths to the set they belong to. + // Number of sets that can partition into. + // This should be `self.map.len() + 1` for files that don't fit in any defined set. + len: usize, + // Encoded paths -> sets they belong to. map: fst::Map>, + filters: Vec, +} + +/// A source-root partition plus the subset that matched semantic source rules. +#[derive(Debug, Default, Clone, Eq, PartialEq)] +pub struct PartitionedFileSet { + pub file_set: FileSet, + pub source_files: Option>, +} + +/// Matcher used separately for source classification, directory scans, and +/// include/search roots. +/// +/// Keeping those roles separate lets exact source files participate in source +/// ownership without forcing the loader or watcher to scan their parent +/// directories recursively. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct PathMatcher { + scan_roots: Vec, + kind: PathMatcherKind, +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +enum PathMatcherKind { + AllUnderRoots, + Glob(PathGlobMatcher), +} + +impl Default for PathMatcher { + fn default() -> Self { + Self::all_under_roots(Vec::new()) + } +} + +impl PathMatcher { + pub fn all_under_roots(scan_roots: Vec) -> Self { + Self { scan_roots, kind: PathMatcherKind::AllUnderRoots } + } + + pub fn glob(scan_roots: Vec, matcher: PathGlobMatcher) -> Self { + Self { scan_roots, kind: PathMatcherKind::Glob(matcher) } + } + + pub fn is_empty(&self) -> bool { + self.scan_roots.is_empty() + } + + pub fn contains_file(&self, path: &AbsPath) -> bool { + self.contains_scan_root(path) + && match &self.kind { + PathMatcherKind::AllUnderRoots => true, + PathMatcherKind::Glob(matcher) => matcher.is_match(path), + } + } + + pub fn contains_dir(&self, path: &AbsPath) -> bool { + self.contains_scan_root(path) + } + + pub fn scan_roots(&self) -> impl Iterator { + self.scan_roots.iter() + } + + fn contains_scan_root(&self, path: &AbsPath) -> bool { + self.scan_roots.iter().any(|root| path.starts_with(root)) + } +} + +/// Include/source/exclude policy for one file-set root. +#[derive(Debug, Default, Clone, Eq, PartialEq)] +pub struct FileSetFilter { + pub include: Vec, + pub source: Option>, + pub exclude_paths: Vec, + pub exclude_globs: Option, +} + +impl FileSetFilter { + fn matches(&self, path: &VfsPath) -> bool { + let Some(path) = path.as_path() else { + return self.include.is_empty() && self.exclude_paths.is_empty(); + }; + self.include.iter().any(|include| include.contains_file(path)) + && !self.exclude_paths.iter().any(|exclude| path.starts_with(exclude)) + && !self.exclude_globs.as_ref().is_some_and(|exclude| exclude.is_match(path)) + } + + fn is_source(&self, path: &VfsPath) -> bool { + let Some(source) = &self.source else { + return false; + }; + let Some(path) = path.as_path() else { + return false; + }; + source.iter().any(|source| source.contains_file(path)) + && !self.exclude_paths.iter().any(|exclude| path.starts_with(exclude)) + && !self.exclude_globs.as_ref().is_some_and(|exclude| exclude.is_match(path)) + } } impl Default for FileSetConfig { @@ -100,49 +173,62 @@ impl Default for FileSetConfig { } impl FileSetConfig { - /// Returns a builder for `FileSetConfig`. pub fn builder() -> FileSetConfigBuilder { FileSetConfigBuilder::default() } - /// Partition `vfs` into `FileSet`s. - /// - /// Creates a new [`FileSet`] for every set of prefixes in `self`. pub fn partition(&self, vfs: &Vfs) -> Vec { + self.partition_with_source(vfs).into_iter().map(|partition| partition.file_set).collect() + } + + pub fn partition_with_source(&self, vfs: &Vfs) -> Vec { let mut scratch_space = Vec::new(); - let mut res = vec![FileSet::default(); self.len()]; + let mut set = (0..self.len) + .map(|idx| PartitionedFileSet { + file_set: FileSet::default(), + source_files: self + .filters + .get(idx) + .and_then(|filter| filter.source.as_ref().map(|_| FxHashSet::default())), + }) + .collect::>(); for (file_id, path) in vfs.iter() { - let root = self.classify(path, &mut scratch_space); - res[root].insert(file_id, path.clone()); + let (root, path) = self.classify_vfs_file(vfs, file_id, path, &mut scratch_space); + if let Some(partition) = set.get_mut(root) { + partition.file_set.insert(file_id, path.clone()); + if self.filters.get(root).is_some_and(|filter| filter.is_source(path)) + && let Some(source_files) = &mut partition.source_files + { + source_files.insert(file_id); + } + } } - res + set } - /// Number of sets that `self` can partition a [`Vfs`] into. - fn len(&self) -> usize { - self.n_file_sets + fn classify_vfs_file<'a>( + &self, + _vfs: &'a Vfs, + _file_id: FileId, + primary_path: &'a VfsPath, + scratch_space: &mut Vec, + ) -> (usize, &'a VfsPath) { + // Path identity aliases were removed with the r-a VFS fork; classify the + // primary interned spelling only. + (self.classify(primary_path, scratch_space), primary_path) } - /// Get the lexicographically ordered vector of the underlying map. - pub fn roots(&self) -> Vec<(Vec, u64)> { - self.map.stream().into_byte_vec() - } - - /// Returns the set index for the given `path`. - /// - /// `scratch_space` is used as a buffer and will be entirely replaced. fn classify(&self, path: &VfsPath, scratch_space: &mut Vec) -> usize { - // `path` is a file, but r-a only cares about the containing directory. We don't - // want `/foo/bar_baz.rs` to be attributed to source root directory `/foo/bar`. - let path = path.parent().unwrap_or_else(|| path.clone()); - scratch_space.clear(); path.encode(scratch_space); let automaton = PrefixOf::new(scratch_space.as_slice()); - let mut longest_prefix = self.len() - 1; + let mut longest_prefix = self.len - 1; let mut stream = self.map.search(automaton).into_stream(); while let Some((_, v)) = stream.next() { - longest_prefix = v as usize; + let idx = v as usize; + if self.filters.get(idx).is_some_and(|filter| filter.matches(path)) { + longest_prefix = idx; + } } longest_prefix } @@ -151,43 +237,61 @@ impl FileSetConfig { /// Builder for [`FileSetConfig`]. #[derive(Default)] pub struct FileSetConfigBuilder { - roots: Vec>, + roots: Vec, +} + +struct FileSetSpec { + roots: Vec, + filter: FileSetFilter, } impl FileSetConfigBuilder { - /// Returns the number of sets currently held. pub fn len(&self) -> usize { self.roots.len() } - /// Add a new set of paths prefixes. pub fn add_file_set(&mut self, roots: Vec) { - self.roots.push(roots); + let include_paths: Vec = roots + .iter() + .filter_map(|root| root.as_path().map(|path| path.to_path_buf())) + .collect(); + let include = if include_paths.is_empty() { + Vec::new() + } else { + vec![PathMatcher::all_under_roots(include_paths)] + }; + self.add_filtered_file_set(roots, FileSetFilter { include, ..FileSetFilter::default() }); + } + + pub fn add_filtered_file_set(&mut self, roots: Vec, filter: FileSetFilter) { + self.roots.push(FileSetSpec { roots, filter }); } - /// Build the `FileSetConfig`. pub fn build(self) -> FileSetConfig { - let n_file_sets = self.roots.len() + 1; - let map = { - let mut entries = Vec::new(); - for (i, paths) in self.roots.into_iter().enumerate() { - for p in paths { + let len = self.roots.len() + 1; + let filters = self.roots.iter().map(|spec| spec.filter.clone()).collect(); + let mut entries = self + .roots + .into_iter() + .enumerate() + .flat_map(|(i, spec)| { + spec.roots.into_iter().map(move |p| { let mut buf = Vec::new(); p.encode(&mut buf); - entries.push((buf, i as u64)); - } - } - entries.sort(); - entries.dedup_by(|(a, _), (b, _)| a == b); - fst::Map::from_iter(entries).unwrap() - }; - FileSetConfig { n_file_sets, map } + (buf, i as u64) + }) + }) + .collect::>(); + + // make sure that the longer one comes later + entries.sort(); + entries.dedup_by(|(a, _), (b, _)| a == b); + + FileSetConfig { len, map: fst::Map::from_iter(entries).unwrap_or_default(), filters } } } -/// Implements [`fst::Automaton`] -/// -/// It will match if `prefix_of` is a prefix of the given data. +// It will match if `prefix_of` is a prefix of the given data. struct PrefixOf<'a> { prefix_of: &'a [u8], } @@ -201,19 +305,61 @@ impl<'a> PrefixOf<'a> { impl fst::Automaton for PrefixOf<'_> { type State = usize; + fn start(&self) -> usize { 0 } + fn is_match(&self, &state: &usize) -> bool { state != !0 } + fn can_match(&self, &state: &usize) -> bool { state != !0 } + fn accept(&self, &state: &usize, byte: u8) -> usize { if self.prefix_of.get(state) == Some(&byte) { state + 1 } else { !0 } } } + #[cfg(test)] -mod tests; +mod tests { + use super::*; + + #[test] + fn partition_places_file_under_matching_root() { + let root = VfsPath::new_virtual_path("/workspace".into()); + let file = VfsPath::new_virtual_path("/workspace/top.sv".into()); + let mut vfs = Vfs::default(); + assert!(vfs.set_file_contents(file.clone(), Some(b"module top; endmodule\n".to_vec()))); + let file_id = vfs.file_id(&file).unwrap().0; + + let mut builder = FileSetConfig::builder(); + builder.add_file_set(vec![root]); + let partitions = builder.build().partition(&vfs); + + assert_eq!(partitions[0].get_path(&file_id), Some(&file)); + assert_eq!(partitions[1].get_path(&file_id), None); + } + + #[test] + fn partition_prefers_earlier_root_when_prefixes_overlap() { + let local_root = VfsPath::new_virtual_path("/local".into()); + let library_root = VfsPath::new_virtual_path("/".into()); + let file = VfsPath::new_virtual_path("/local/top.sv".into()); + let mut vfs = Vfs::default(); + assert!(vfs.set_file_contents(file.clone(), Some(b"module top; endmodule\n".to_vec()))); + let file_id = vfs.file_id(&file).unwrap().0; + + let mut builder = FileSetConfig::builder(); + builder.add_file_set(vec![local_root]); + builder.add_file_set(vec![library_root]); + let partitions = builder.build().partition(&vfs); + + assert_eq!(partitions[0].get_path(&file_id), Some(&file)); + assert_eq!(partitions[1].get_path(&file_id), None); + assert_eq!(partitions[2].get_path(&file_id), None); + } +} diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 8d723bb4..c9bbfd39 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -48,6 +48,7 @@ pub mod file_set; pub mod loader; #[cfg(feature = "notify-backend")] pub mod notify; +mod path_glob; mod path_interner; mod vfs_path; @@ -61,9 +62,11 @@ use crate::path_interner::PathInterner; pub use crate::{ anchored_path::{AnchoredPath, AnchoredPathBuf}, + file_set::{FileSet, FileSetConfig, FileSetConfigBuilder, FileSetFilter, PartitionedFileSet, PathMatcher}, vfs_path::VfsPath, }; use indexmap::{IndexMap, map::Entry}; +pub use crate::path_glob::PathGlobMatcher; pub use utils::paths::{AbsPath, AbsPathBuf}; use rustc_hash::FxHasher; @@ -160,6 +163,36 @@ impl ChangedFile { Change::Delete => ChangeKind::Delete, } } + + /// UTF-8 file text for analysis; invalid UTF-8 becomes empty. + pub fn text(&self) -> Option> { + let bytes = match &self.change { + Change::Create(bytes, _) | Change::Modify(bytes, _) => bytes.as_slice(), + Change::Delete => return None, + }; + let text = std::str::from_utf8(bytes).unwrap_or(""); + Some(triomphe::Arc::::from(text)) + } + + /// Bytes for Create/Modify; `None` for Delete. + pub fn contents(&self) -> Option<&[u8]> { + match &self.change { + Change::Create(bytes, _) | Change::Modify(bytes, _) => Some(bytes.as_slice()), + Change::Delete => None, + } + } +} + +impl Change { + pub fn from_bytes(bytes: Option>) -> Self { + match bytes { + None => Change::Delete, + Some(v) => { + let hash = hash_once::(&*v); + Change::Create(v, hash) + } + } + } } /// Kind of [file change](ChangedFile). diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index 53e5edcd..c643d077 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -1,8 +1,16 @@ //! Dynamically compatible interface for file watching and reading. +//! +//! Owned fork of rust-analyzer `vfs::loader` with vide deltas for SystemVerilog +//! extensions and optional exclude globs. use std::fmt; use utils::paths::{AbsPath, AbsPathBuf}; +use crate::PathGlobMatcher; + +/// File extensions loaded from recursive directory entries for SystemVerilog. +pub const SOURCE_FILE_EXTENSIONS: &[&str] = &["v", "sv", "vh", "svh", "svi", "map"]; + /// A set of files on the file system. #[derive(Debug, Clone)] pub enum Entry { @@ -22,11 +30,14 @@ pub enum Entry { /// If many include/exclude paths match, the longest one wins. /// /// If a path is in both `include` and `exclude`, the `exclude` one wins. +/// +/// `exclude_globs` is a vide-specific extension for project-config globs. #[derive(Debug, Clone, Default)] pub struct Directories { pub extensions: Vec, pub include: Vec, pub exclude: Vec, + pub exclude_globs: Option, } /// [`Handle`]'s configuration. @@ -178,6 +189,7 @@ impl Directories { /// - An element in `self.include` is a prefix of `path`. /// - This path is longer than any element in `self.exclude` that is a prefix /// of `path`. In case of equality, exclusion wins. + /// - Optional `exclude_globs` does not match (vide extension). fn includes_path(&self, path: &AbsPath) -> bool { let mut include: Option<&AbsPathBuf> = None; for incl in &self.include { @@ -194,7 +206,11 @@ impl Directories { None => return false, }; - !self.exclude.iter().any(|excl| path.starts_with(excl) && excl.starts_with(include)) + if self.exclude.iter().any(|excl| path.starts_with(excl) && excl.starts_with(include)) { + return false; + } + + !self.exclude_globs.as_ref().is_some_and(|globs| globs.is_match(path)) } } @@ -208,7 +224,12 @@ impl Directories { /// ``` fn dirs(base: AbsPathBuf, exclude: &[&str]) -> Directories { let exclude = exclude.iter().map(|it| base.join(it)).collect::>(); - Directories { extensions: vec!["rs".to_owned()], include: vec![base], exclude } + Directories { + extensions: vec!["rs".to_owned()], + include: vec![base], + exclude, + exclude_globs: None, + } } impl fmt::Debug for Message { diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index f5315ebf..729ecdb6 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -341,8 +341,7 @@ impl NotifyActor { if !is_file { return None; } - let ext = abs_path.extension().unwrap_or_default(); - if dirs.extensions.iter().all(|it| it.as_str() != ext) { + if !dirs.contains_file(abs_path.as_path()) { return None; } Some(abs_path) diff --git a/crates/vfs/src/path_glob.rs b/crates/vfs/src/path_glob.rs new file mode 100644 index 00000000..da63e365 --- /dev/null +++ b/crates/vfs/src/path_glob.rs @@ -0,0 +1,70 @@ +use std::{ + fmt, + hash::{Hash, Hasher}, + sync::Arc, +}; + +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use utils::paths::{AbsPath, AbsPathBuf}; + +#[derive(Clone)] +pub struct PathGlobMatcher { + root: AbsPathBuf, + patterns: Vec, + matcher: Arc, +} + +impl PathGlobMatcher { + pub fn new(root: AbsPathBuf, patterns: Vec) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in &patterns { + let glob = + GlobBuilder::new(pattern).literal_separator(true).backslash_escape(true).build()?; + builder.add(glob); + } + let matcher = Arc::new(builder.build()?); + + Ok(Self { root, patterns, matcher }) + } + + pub fn is_match(&self, path: &AbsPath) -> bool { + let Some(relative) = path.strip_prefix(&self.root) else { + return false; + }; + + let relative = relative.as_ref().to_string_lossy().replace('\\', "/"); + self.matcher.is_match(relative.as_str()) + } + + pub fn root(&self) -> &AbsPathBuf { + &self.root + } + + pub fn patterns(&self) -> &[String] { + &self.patterns + } +} + +impl fmt::Debug for PathGlobMatcher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PathGlobMatcher") + .field("root", &self.root) + .field("patterns", &self.patterns) + .finish() + } +} + +impl PartialEq for PathGlobMatcher { + fn eq(&self, other: &Self) -> bool { + self.root == other.root && self.patterns == other.patterns + } +} + +impl Eq for PathGlobMatcher {} + +impl Hash for PathGlobMatcher { + fn hash(&self, state: &mut H) { + self.root.hash(state); + self.patterns.hash(state); + } +} diff --git a/crates/vfs/src/vfs_path.rs b/crates/vfs/src/vfs_path.rs index aaa4659c..0c7c67db 100644 --- a/crates/vfs/src/vfs_path.rs +++ b/crates/vfs/src/vfs_path.rs @@ -39,6 +39,11 @@ impl VfsPath { } } + /// Alias kept for vide call sites (rust-analyzer name is `as_path`). + pub fn as_abs_path(&self) -> Option<&AbsPath> { + self.as_path() + } + pub fn into_abs_path(self) -> Option { match self.0 { VfsPathRepr::PathBuf(it) => Some(it), diff --git a/crates/workspace-model/src/source_root.rs b/crates/workspace-model/src/source_root.rs index f0abff31..95f41774 100644 --- a/crates/workspace-model/src/source_root.rs +++ b/crates/workspace-model/src/source_root.rs @@ -1,4 +1,4 @@ -use vfs::{FileId, FileSet, FileSetConfig, Vfs, VfsPath, anchored_path::AnchoredPath}; +use vfs::{FileId, FileSet, FileSetConfig, Vfs, VfsPath, AnchoredPath}; use crate::source_db::SourceFileKind; @@ -238,7 +238,7 @@ mod tests { #[test] fn ignored_root_preserves_file_kind() { let mut file_set = FileSet::default(); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); file_set.insert(file_id, VfsPath::new_virtual_path("/ignored/file.sv".into())); let root = SourceRoot::new_ignored(file_set); @@ -249,7 +249,7 @@ mod tests { #[test] fn best_effort_index_root_preserves_file_kind() { let mut file_set = FileSet::default(); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); file_set.insert(file_id, VfsPath::new_virtual_path("/indexed/file.sv".into())); let root = SourceRoot::new_best_effort_index(file_set); @@ -282,7 +282,7 @@ mod tests { #[test] fn source_filtered_root_preserves_project_manifest_kind() { let mut file_set = FileSet::default(); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); file_set.insert(file_id, VfsPath::new_virtual_path("/root/vide.toml".into())); let root = SourceRoot::new_local_with_source_files(file_set, Vec::new()); diff --git a/src/global_state/diagnostics.rs b/src/global_state/diagnostics.rs index 92de4ef1..7a635cf9 100644 --- a/src/global_state/diagnostics.rs +++ b/src/global_state/diagnostics.rs @@ -94,14 +94,14 @@ pub(crate) enum DiagnosticOwner { impl DiagnosticOwner { fn result_id_fragment(self) -> String { match self { - DiagnosticOwner::File(file_id) => format!("file:{}", file_id.0), + DiagnosticOwner::File(file_id) => format!("file:{}", file_id.index()), DiagnosticOwner::SourceRoot(source_root_id) => { format!("source-root:{}", source_root_id.0) } DiagnosticOwner::CompilationProfile(profile_id) => { format!("compilation-profile:{}", profile_id.0) } - DiagnosticOwner::External { source, file } => format!("external-{source}:{}", file.0), + DiagnosticOwner::External { source, file } => format!("external-{source}:{}", file.index()), } } } @@ -197,7 +197,7 @@ impl DiagnosticSnapshotKey { let dependency_revisions = self .dependency_revisions .iter() - .map(|(file_id, revision)| format!("{}:{}", file_id.0, revision.result_id_fragment())) + .map(|(file_id, revision)| format!("{}:{}", file_id.index(), revision.result_id_fragment())) .collect::>() .join(","); let external_revisions = self diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index 037091d5..0f382c69 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -55,12 +55,12 @@ impl Event { } Event::Task(task) => task.summary(), Event::Vfs(vfs_loader::Message::Progress { n_done, n_total, .. }) => { - format!("vfs progress {n_done}/{n_total}") + format!("vfs progress {n_done:?}/{n_total}") } - Event::Vfs(vfs_loader::Message::Loaded { files, .. }) => { + Event::Vfs(vfs_loader::Message::Loaded { files }) => { format!("vfs loaded files={}", files.len()) } - Event::Vfs(vfs_loader::Message::Changed { files, .. }) => { + Event::Vfs(vfs_loader::Message::Changed { files }) => { format!("vfs changed files={}", files.len()) } } @@ -344,15 +344,23 @@ impl GlobalState { pub(in crate::global_state) fn process_vfs_msg(&mut self, msg: vfs_loader::Message) { match msg { - vfs_loader::Message::Progress { n_total, n_done, config_version } => { + vfs_loader::Message::Progress { n_total, n_done, dir: _, config_version } => { always!( config_version <= self.workspace.workspace_vfs.current_vfs_config_version() ); + use vfs_loader::LoadingProgress; + let (n_done_count, state) = match n_done { + LoadingProgress::Started => (0, Progress::Begin), + LoadingProgress::Progress(n) => (n.min(n_total), Progress::Report), + LoadingProgress::Finished => (n_total, Progress::End), + }; + let Some(progress) = self.workspace.workspace_vfs.accept_vfs_progress( config_version, - n_done, + n_done_count, n_total, + matches!(n_done, LoadingProgress::Finished), ) else { tracing::debug!( config_version, @@ -363,18 +371,12 @@ impl GlobalState { return; }; + // Empty loads still flip readiness via Finished Progress, but do not + // spam the client with a 0/0 work-done sequence. if progress.n_total == 0 { return; } - let state = if progress.n_done == 0 { - Progress::Begin - } else if progress.n_done < progress.n_total { - Progress::Report - } else { - assert_eq!(progress.n_done, progress.n_total); - Progress::End - }; let starts_client_progress = state == Progress::Begin; let finishes_client_progress = state == Progress::End; @@ -394,50 +396,40 @@ impl GlobalState { } } } - vfs_loader::Message::Loaded { files, config_version } => { - self.process_vfs_files(files, config_version, VfsFileSource::ConfigScan); + // rust-analyzer style: content batches have no config_version; only + // Progress gates generation readiness. + vfs_loader::Message::Loaded { files } => { + self.process_vfs_files(files, VfsFileSource::ConfigScan); } - vfs_loader::Message::Changed { files, config_version } => { - self.process_vfs_files(files, config_version, VfsFileSource::ExternalChange); + vfs_loader::Message::Changed { files } => { + self.process_vfs_files(files, VfsFileSource::ExternalChange); } } } fn process_vfs_files( &mut self, - files: Vec<(utils::paths::AbsPathBuf, vfs_loader::LoadResult)>, - config_version: u32, + files: Vec<(utils::paths::AbsPathBuf, Option>)>, source: VfsFileSource, ) { - always!(config_version <= self.workspace.workspace_vfs.current_vfs_config_version()); - if !self.workspace.workspace_vfs.accepts_vfs_loaded(config_version) { - tracing::debug!( - config_version, - current_config_version = self.workspace.workspace_vfs.current_vfs_config_version(), - files = files.len(), - ?source, - "stale VFS file batch ignored" - ); - return; - } - let workspace_manifest_change = { let vfs = &mut self.workspace.vfs.write().0; let mut manifest_change = None; for (abs_path, content) in files { let path = VfsPath::from(abs_path.clone()); - let existing_file_id = vfs.file_id(&path); - let existed = existing_file_id.is_some_and(|file_id| vfs.exists(file_id)); - let exists_after = matches!(&content, vfs_loader::LoadResult::Loaded(..)); + let existing = vfs.file_id(&path); + let existed = existing.is_some_and(|(file_id, _)| vfs.exists(file_id)); + let exists_after = content.is_some(); let has_structure_change = existed != exists_after; - let open_file_id = existing_file_id - .is_some_and(|file_id| self.analysis.mem_docs.contains_file_id(file_id)); + let open_file_id = existing.is_some_and(|(file_id, _)| { + self.analysis.mem_docs.contains_file_id(file_id) + }); if self.analysis.mem_docs.contains_path(&path) || open_file_id { continue; } - let changed = vfs.set_file_contents(&path, content); + let changed = vfs.set_file_contents(path, content); if changed && source == VfsFileSource::ExternalChange && reload::should_refresh_for_change(&abs_path, has_structure_change) @@ -451,7 +443,6 @@ impl GlobalState { if let Some(path) = workspace_manifest_change { tracing::info!( - config_version, %path, "server file watcher reported a workspace manifest change" ); diff --git a/src/global_state/handlers/notification/text_changes.rs b/src/global_state/handlers/notification/text_changes.rs index 2e59d7bc..ce92c9cd 100644 --- a/src/global_state/handlers/notification/text_changes.rs +++ b/src/global_state/handlers/notification/text_changes.rs @@ -5,7 +5,7 @@ use utils::{ line_index::LineIndex, lines::{LineEnding, LineInfo, PositionEncoding}, }; -use vfs::{FileId, VfsPath, loader::LoadResult}; +use vfs::{FileId, VfsPath}; use crate::{global_state::GlobalState, lsp_ext::from_proto}; @@ -16,8 +16,15 @@ pub(super) fn set_vfs_file_contents( ) -> anyhow::Result { let (text, endings) = LineEnding::normalize(text); let mut vfs = state.workspace.vfs.write(); - vfs.0.set_file_contents(path, LoadResult::Loaded(text, endings)); - vfs.0.file_id(path).ok_or_else(|| anyhow::format_err!("loaded file has no FileId: {path}")) + let path = path.clone(); + vfs.0.set_file_contents(path.clone(), Some(text.into_bytes())); + let file_id = vfs + .0 + .file_id(&path) + .map(|(id, _)| id) + .ok_or_else(|| anyhow::format_err!("loaded file has no FileId: {path}"))?; + vfs.1.insert(file_id, endings); + Ok(file_id) } pub(super) fn open_vfs_file_contents( @@ -26,25 +33,29 @@ pub(super) fn open_vfs_file_contents( text: &str, ) -> anyhow::Result { let mut vfs = state.workspace.vfs.write(); - let file_id = vfs.0.register_file_ingress(path); - if state.analysis.mem_docs.contains_file_id(file_id) { + if let Some((file_id, _)) = vfs.0.file_id(path) + && state.analysis.mem_docs.contains_file_id(file_id) + { return Ok(file_id); } let (text, endings) = LineEnding::normalize(text.to_owned()); - vfs.0.set_file_contents(path, LoadResult::Loaded(text, endings)); - vfs.0.file_id(path).ok_or_else(|| anyhow::format_err!("loaded file has no FileId: {path}")) + let path = path.clone(); + vfs.0.set_file_contents(path.clone(), Some(text.into_bytes())); + let file_id = vfs + .0 + .file_id(&path) + .map(|(id, _)| id) + .ok_or_else(|| anyhow::format_err!("loaded file has no FileId: {path}"))?; + vfs.1.insert(file_id, endings); + Ok(file_id) } pub(super) fn open_mem_doc_file_id(state: &GlobalState, path: &VfsPath) -> Option { state.analysis.mem_docs.file_id(path).or_else(|| { - state - .workspace - .vfs - .read() - .0 - .file_id(path) - .filter(|file_id| state.analysis.mem_docs.contains_file_id(*file_id)) + state.workspace.vfs.read().0.file_id(path).and_then(|(file_id, _)| { + state.analysis.mem_docs.contains_file_id(file_id).then_some(file_id) + }) }) } diff --git a/src/global_state/handlers/request/code_action.rs b/src/global_state/handlers/request/code_action.rs index 40ebe450..081eae0d 100644 --- a/src/global_state/handlers/request/code_action.rs +++ b/src/global_state/handlers/request/code_action.rs @@ -286,7 +286,7 @@ mod tests { option_name: Option<&str>, ) -> IdeDiagnostic { IdeDiagnostic { - file_id: FileId(0), + file_id: FileId::from_raw(0), code, subsystem, name: name.to_owned(), @@ -368,7 +368,7 @@ mod tests { data: None, }; let ide_diag = IdeDiagnostic { - file_id: FileId(0), + file_id: FileId::from_raw(0), code: 129, subsystem: 6, name: "MixingOrderedAndNamedPorts".to_owned(), @@ -393,7 +393,7 @@ mod tests { #[test] fn code_action_diagnostics_are_built_from_server_diagnostics() { let diag = IdeDiagnostic { - file_id: FileId(0), + file_id: FileId::from_raw(0), code: 129, subsystem: 6, name: "MixingOrderedAndNamedPorts".to_owned(), diff --git a/src/global_state/handlers/request/symbols.rs b/src/global_state/handlers/request/symbols.rs index 990389d6..7b9cb8e8 100644 --- a/src/global_state/handlers/request/symbols.rs +++ b/src/global_state/handlers/request/symbols.rs @@ -38,7 +38,7 @@ pub(crate) fn handle_workspace_symbol( params: lsp_types::WorkspaceSymbolParams, ) -> anyhow::Result> { let mut file_ids = snap.file_ids(); - file_ids.sort_unstable_by_key(|file_id| file_id.0); + file_ids.sort_unstable_by_key(|file_id| file_id.index()); file_ids.dedup(); let symbols = snap.analysis.workspace_symbol(¶ms.query, file_ids)?; diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index 7c3271ed..1014c700 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -68,8 +68,8 @@ mod tests { use project_model::{ProjectModel, project_manifest::ProjectManifest}; use rustc_hash::FxHashSet; use triomphe::Arc; - use utils::{lines::LineEnding, paths::AbsPathBuf, test_support::TestDir}; - use vfs::{FileId, VfsPath, loader as vfs_loader, loader::LoadResult}; + use utils::{paths::AbsPathBuf, test_support::TestDir}; + use vfs::{FileId, VfsPath, loader as vfs_loader}; use super::*; use crate::{ @@ -172,7 +172,7 @@ mod tests { ); let (server, client) = Connection::memory(); let mut state = GlobalState::new(server.sender, config, lsp_types::TraceValue::Off); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let primary_uri = to_proto::url_from_abs_path(root.write("workspace/top.sv", "").as_path()).unwrap(); let alias_uri = @@ -237,7 +237,7 @@ mod tests { ); let (server, client) = Connection::memory(); let mut state = GlobalState::new(server.sender, config, lsp_types::TraceValue::Off); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let alias_uri = to_proto::url_from_abs_path(root.write("alias/top.sv", "").as_path()).unwrap(); let diagnostic = Diagnostic { @@ -296,7 +296,7 @@ mod tests { let (server, client) = Connection::memory(); let mut state = GlobalState::new(server.sender, config, lsp_types::TraceValue::Off); state.diagnostics.diagnostics_revision = 2; - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let uri = to_proto::url_from_abs_path(root.write("workspace/top.sv", "").as_path()).unwrap(); let diagnostic = Diagnostic { @@ -317,28 +317,24 @@ mod tests { } #[test] - fn stale_loaded_batches_do_not_update_vfs() { - let root = TestDir::new("stale-loaded-batches"); + fn content_batches_apply_without_generation_token() { + // rust-analyzer style: Loaded/Changed carry no config_version. Generation + // readiness is gated only by Progress; content batches always apply. + let root = TestDir::new("content-batches-apply"); let root_path = root.path().to_path_buf(); - let file_path = root_path.join("stale.sv"); + let file_path = root_path.join("file.sv"); let mut state = test_state(root_path); - let stale_load = state.workspace.workspace_vfs.begin_vfs_load(1); - let current_load = state.workspace.workspace_vfs.begin_vfs_load(1); - assert!(!stale_load.superseded_client_progress_active); - assert!(!current_load.superseded_client_progress_active); + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); state.process_vfs_msg(vfs_loader::Message::Loaded { - files: vec![( - file_path.clone(), - LoadResult::Loaded("module stale; endmodule\n".to_string(), LineEnding::Unix), - )], - config_version: 1, + files: vec![(file_path.clone(), Some(b"module top; endmodule\n".to_vec()))], }); let vfs_path = VfsPath::from(file_path); let mut vfs = state.workspace.vfs.write(); - assert!(vfs.0.file_id(&vfs_path).is_none()); - assert!(vfs.0.take_changes().is_empty()); + assert!(vfs.0.file_id(&vfs_path).is_some()); + assert!(!vfs.0.take_changes().is_empty()); } #[test] @@ -361,7 +357,8 @@ mod tests { state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 0, - n_done: 0, + n_done: vfs_loader::LoadingProgress::Finished, + dir: None, config_version, }); @@ -422,7 +419,8 @@ mod tests { state .handle_event(Event::Vfs(vfs_loader::Message::Progress { n_total: 1, - n_done: 1, + n_done: vfs_loader::LoadingProgress::Finished, + dir: None, config_version, })) .unwrap(); @@ -584,7 +582,8 @@ mod tests { state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 4, - n_done: 4, + n_done: vfs_loader::LoadingProgress::Finished, + dir: None, config_version: stale_config, }); @@ -601,7 +600,8 @@ mod tests { state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 4, - n_done: 2, + n_done: vfs_loader::LoadingProgress::Progress(2), + dir: None, config_version: current_config, }); @@ -644,7 +644,8 @@ mod tests { let stale_config = state.workspace.workspace_vfs.begin_vfs_load(4).config_version; state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 4, - n_done: 0, + n_done: vfs_loader::LoadingProgress::Started, + dir: None, config_version: stale_config, }); assert!(matches!(recv_work_done_progress(&client), WorkDoneProgress::Begin(_))); @@ -679,7 +680,8 @@ mod tests { state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 2, - n_done: 2, + n_done: vfs_loader::LoadingProgress::Finished, + dir: None, config_version, }); @@ -692,7 +694,8 @@ mod tests { state.process_vfs_msg(vfs_loader::Message::Progress { n_total: 2, - n_done: 1, + n_done: vfs_loader::LoadingProgress::Progress(1), + dir: None, config_version, }); diff --git a/src/global_state/mem_docs.rs b/src/global_state/mem_docs.rs index 9b054416..d79925e4 100644 --- a/src/global_state/mem_docs.rs +++ b/src/global_state/mem_docs.rs @@ -94,6 +94,7 @@ impl MemDocs { true } + #[allow(dead_code)] pub(crate) fn remap_file_id(&mut self, from: FileId, to: FileId) { if from == to { return; @@ -219,8 +220,8 @@ mod tests { fn mem_docs_alias_matrix() { let canonical_path = VfsPath::new_virtual_path("/workspace/top.sv".to_owned()); let alias_path = VfsPath::new_virtual_path("/alias/top.sv".to_owned()); - let duplicate = FileId(1); - let canonical = FileId(0); + let duplicate = FileId::from_raw(1); + let canonical = FileId::from_raw(0); let mut report = String::new(); let path = canonical_path.clone(); diff --git a/src/global_state/process_changes.rs b/src/global_state/process_changes.rs index 5d135a05..876fc6af 100644 --- a/src/global_state/process_changes.rs +++ b/src/global_state/process_changes.rs @@ -27,23 +27,12 @@ impl GlobalState { pub(crate) fn process_changes(&mut self) -> bool { let pending_diagnostic_targets = std::mem::take(&mut self.diagnostics.pending_document_diagnostic_targets); - let mut diagnostic_targets_changed = !pending_diagnostic_targets.is_empty(); + let diagnostic_targets_changed = !pending_diagnostic_targets.is_empty(); let mut write_guard = self.workspace.vfs.write(); - let changed_files = write_guard.0.take_changes(); + let changed_files: Vec = write_guard.0.take_changes().into_values().collect(); // downgrade earlier to allow more reader let read_guard = RwLockWriteGuard::downgrade_to_upgradable(write_guard); let vfs = &read_guard.0; - let file_id_redirects = changed_files - .iter() - .filter_map(|changed_file| { - let canonical = vfs.canonical_file_id(changed_file.file_id); - (canonical != changed_file.file_id).then_some((changed_file.file_id, canonical)) - }) - .collect_vec(); - diagnostic_targets_changed |= !file_id_redirects.is_empty(); - for (from, to) in file_id_redirects { - self.analysis.mem_docs.remap_file_id(from, to); - } // collect changes let Some(changed_files) = Self::colease_modifications(changed_files) else { @@ -62,18 +51,12 @@ impl GlobalState { let mut deleted_file_ids = FxHashSet::default(); let mut deleted_push_diagnostics = Vec::new(); for changed_file in changed_files { - let is_identity_redirect = - vfs.canonical_file_id(changed_file.file_id) != changed_file.file_id; - let path = if is_identity_redirect { - vfs.original_file_path(changed_file.file_id) - } else { - vfs.file_path(changed_file.file_id) - }; - if path.and_then(VfsPath::as_abs_path).is_some() { + let path = Some(vfs.file_path(changed_file.file_id)); + if path.and_then(|path| path.as_abs_path()).is_some() { has_structure_changes |= changed_file.is_created_or_deleted(); } - if matches!(&changed_file.change_kind, vfs::ChangeKind::Delete) { + if matches!(changed_file.kind(), vfs::ChangeKind::Delete) { deleted_file_ids.insert(changed_file.file_id); if let Some(path) = path.cloned() { deleted_push_diagnostics.push((changed_file.file_id, path)); @@ -262,8 +245,13 @@ impl GlobalState { let mut change = Change::new(); for changed_file in bytes { let file_id = changed_file.file_id; - if let Some(line_ending) = changed_file.ending() { - line_ending_map.insert(file_id, line_ending); + if let Some(raw) = changed_file.contents() { + if let Ok(text) = std::str::from_utf8(raw) { + let (_, ending) = LineEnding::normalize(text.to_owned()); + line_ending_map.insert(file_id, ending); + } + } else { + line_ending_map.remove(&file_id); } change.add_changed_file(changed_file) } @@ -280,8 +268,8 @@ impl GlobalState { return None; } - // collapse modifications - use vfs::ChangeKind::*; + // collapse modifications (r-a Change carries raw bytes + content hash) + use vfs::Change::*; let mut file_changes = FxHashMap::default(); for changed_file in vfs_changes { @@ -289,54 +277,50 @@ impl GlobalState { Occupied(mut entry) => { let (change, just_created) = entry.get_mut(); - match (change, *just_created, changed_file.change_kind) { + match (change, *just_created, changed_file.change) { (change, _, Delete) => *change = Delete, - ( - Create(prev, prev_ending), - _, - Create(new, new_ending) | Modify(new, new_ending), - ) => { + (Create(prev, prev_hash), _, Create(new, new_hash) | Modify(new, new_hash)) => { *prev = new; - *prev_ending = new_ending; + *prev_hash = new_hash; } - (Modify(prev, prev_ending), _, Modify(new, new_ending)) => { + (Modify(prev, prev_hash), _, Modify(new, new_hash)) => { *prev = new; - *prev_ending = new_ending; + *prev_hash = new_hash; } - (change @ Delete, _, Create(new, new_ending)) => { - *change = Modify(new, new_ending); + (change @ Delete, _, Create(new, new_hash)) => { + *change = Modify(new, new_hash); *just_created = true; } - (change @ Delete, _, Modify(new, new_ending)) => { + (change @ Delete, _, Modify(new, new_hash)) => { tracing::debug!( ?changed_file.file_id, "received modify after delete while coalescing VFS changes" ); - *change = Modify(new, new_ending); + *change = Modify(new, new_hash); } - (Modify(prev, prev_ending), _, Create(new, new_ending)) => { + (Modify(prev, prev_hash), _, Create(new, new_hash)) => { tracing::debug!( ?changed_file.file_id, "received create after modify while coalescing VFS changes" ); *prev = new; - *prev_ending = new_ending; + *prev_hash = new_hash; } } } Vacant(v) => { - let just_created = matches!(&changed_file.change_kind, Create(_, _)); - v.insert((changed_file.change_kind, just_created)); + let just_created = matches!(&changed_file.change, Create(_, _)); + v.insert((changed_file.change, just_created)); } } } let changed_file = file_changes .into_iter() - .filter(|(_, (change_kind, just_created))| { - !(*just_created && matches!(change_kind, Delete)) + .filter(|(_, (change, just_created))| { + !(*just_created && matches!(change, Delete)) }) - .map(|(file_id, (change_kind, _))| ChangedFile { file_id, change_kind }) + .map(|(file_id, (change, _))| ChangedFile { file_id, change }) .collect_vec(); Some(changed_file) @@ -407,11 +391,8 @@ impl GlobalState { mod tests { use lsp_server::Connection; use lsp_types::{ClientCapabilities, TraceValue}; - use utils::{lines::LineEnding, paths::AbsPathBuf, test_support::TestDir}; - use vfs::{ - VfsPath, - loader::{LoadResult, Message as VfsMessage}, - }; + use utils::{paths::AbsPathBuf, test_support::TestDir}; + use vfs::{VfsPath, loader::Message as VfsMessage}; use crate::{ Opt, @@ -439,17 +420,12 @@ mod tests { GlobalState::new(server.sender, config, TraceValue::Off) } - fn vfs_manifest_message( - manifest_path: AbsPathBuf, - config_version: u32, - changed: bool, - ) -> VfsMessage { - let files = - vec![(manifest_path, LoadResult::Loaded("[project]\n".to_owned(), LineEnding::Unix))]; + fn vfs_manifest_message(manifest_path: AbsPathBuf, changed: bool) -> VfsMessage { + let files = vec![(manifest_path, Some(b"[project]\n".to_vec()))]; if changed { - VfsMessage::Changed { files, config_version } + VfsMessage::Changed { files } } else { - VfsMessage::Loaded { files, config_version } + VfsMessage::Loaded { files } } } @@ -461,8 +437,8 @@ mod tests { let file_path = root.join("top.sv"); state.workspace.vfs.write().0.set_file_contents( - &VfsPath::from(file_path), - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), + VfsPath::from(file_path), + Some(b"module top; endmodule\n".to_vec()), ); assert!(state.process_changes()); @@ -476,9 +452,9 @@ mod tests { fn config_scan_manifest_does_not_request_workspace_reload() { let root = TestDir::new("config-scan-manifest-no-reload"); let mut state = test_state(root.path().to_path_buf()); - let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); - state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), config_version, false)); + state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), false)); assert!(state.process_changes()); assert!( @@ -491,9 +467,9 @@ mod tests { fn external_manifest_change_requests_workspace_reload() { let root = TestDir::new("external-manifest-reload"); let mut state = test_state(root.path().to_path_buf()); - let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); - state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), config_version, true)); + state.process_vfs_msg(vfs_manifest_message(root.join("vide.toml"), true)); assert!( state.workspace.fetch_workspaces_task.has_op_requested(), @@ -506,10 +482,10 @@ mod tests { let root = TestDir::new("unchanged-external-manifest-no-reload"); let mut state = test_state(root.path().to_path_buf()); let manifest_path = root.join("vide.toml"); - let config_version = state.workspace.workspace_vfs.begin_vfs_load(1).config_version; + let _ = state.workspace.workspace_vfs.begin_vfs_load(1); - state.process_vfs_msg(vfs_manifest_message(manifest_path.clone(), config_version, false)); - state.process_vfs_msg(vfs_manifest_message(manifest_path, config_version, true)); + state.process_vfs_msg(vfs_manifest_message(manifest_path.clone(), false)); + state.process_vfs_msg(vfs_manifest_message(manifest_path, true)); assert!(state.process_changes()); assert!( diff --git a/src/global_state/qihe/tests.rs b/src/global_state/qihe/tests.rs index cbab0585..f72beb75 100644 --- a/src/global_state/qihe/tests.rs +++ b/src/global_state/qihe/tests.rs @@ -147,7 +147,7 @@ fn stale_qihe_result_does_not_replace_current_diagnostics() { let (server, _client) = lsp_server::Connection::memory(); let mut state = GlobalState::new(server.sender, config, TraceValue::Off); state.qihe.run_generation = QiheRunId::new(2); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let current = Diagnostic { range: Range::new(Position::new(0, 0), Position::new(0, 1)), severity: Some(DiagnosticSeverity::WARNING), @@ -214,7 +214,7 @@ fn current_qihe_result_closes_active_progress() { #[test] fn cancelled_qihe_finished_result_does_not_commit_diagnostics() { let (_root, mut state) = new_test_state("cancelled-qihe-finished-result"); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let diagnostic = Diagnostic { range: Range::new(Position::new(0, 0), Position::new(0, 1)), severity: Some(DiagnosticSeverity::WARNING), @@ -295,7 +295,7 @@ fn qihe_diagnostics_are_scoped_to_diagnostic_commit_freshness() { ); let (server, _client) = lsp_server::Connection::memory(); let mut state = GlobalState::new(server.sender, config, TraceValue::Off); - let file_id = FileId(0); + let file_id = FileId::from_raw(0); let diagnostic = Diagnostic { range: Range::new(Position::new(0, 0), Position::new(0, 1)), severity: Some(DiagnosticSeverity::WARNING), @@ -359,7 +359,7 @@ fn qihe_result_with_stale_diagnostic_freshness_does_not_commit() { run_id: QiheRunId::new(1), update: QiheUpdate { by_file: rustc_hash::FxHashMap::from_iter([( - FileId(0), + FileId::from_raw(0), vec![Diagnostic { range: Range::new(Position::new(0, 0), Position::new(0, 1)), severity: Some(DiagnosticSeverity::WARNING), @@ -409,7 +409,7 @@ fn qihe_diagnostics_use_pull_refresh_for_pull_capable_clients() { let (server, client) = lsp_server::Connection::memory(); let mut state = GlobalState::new(server.sender, config, TraceValue::Off); - state.publish_qihe_diagnostics(rustc_hash::FxHashSet::from_iter([FileId(0)])); + state.publish_qihe_diagnostics(rustc_hash::FxHashSet::from_iter([FileId::from_raw(0)])); let message = client .receiver diff --git a/src/global_state/reload.rs b/src/global_state/reload.rs index de069f19..bf15abba 100644 --- a/src/global_state/reload.rs +++ b/src/global_state/reload.rs @@ -160,7 +160,7 @@ impl GlobalState { } let files_config = self.config_state.config.files(); - let (to_load, to_watch, source_root_config, project_config) = + let (load, watch, source_root_config, project_config) = get_workspace_folder(&self.workspace.workspaces, &files_config.exclude); let mut change = Change::new(); { @@ -171,13 +171,13 @@ impl GlobalState { change.set_project_config(project_config); self.analysis.analysis_host.apply_change(change); - let to_watch = match files_config.watcher { + let watch = match files_config.watcher { FilesWatcher::Client => vec![], - FilesWatcher::Server => to_watch, + FilesWatcher::Server => watch, }; - let to_load_len = to_load.len(); - let vfs_load = self.workspace.workspace_vfs.begin_vfs_load(to_load_len); + let load_len = load.len(); + let vfs_load = self.workspace.workspace_vfs.begin_vfs_load(load_len); if vfs_load.superseded_client_progress_active { self.report_progress( self.config_state.config.i18n.text(keys::PROGRESS_ROOTS_SCANNING), @@ -189,8 +189,8 @@ impl GlobalState { } self.workspace.vfs_loader.handle.set_config(vfs::loader::Config { - to_load, - to_watch, + load, + watch, version: vfs_load.config_version, }); diff --git a/src/global_state/snapshot.rs b/src/global_state/snapshot.rs index 020d4492..28f0a377 100644 --- a/src/global_state/snapshot.rs +++ b/src/global_state/snapshot.rs @@ -1,6 +1,5 @@ use std::{path::Path, sync::Arc as StdArc}; -use anyhow::Context; use hir::base_db::source_root::{SourceRootDiagnosticScope, SourceRootRole}; use ide::{Cancellable, analysis::Analysis}; use lsp_types::Url; @@ -89,7 +88,7 @@ impl GlobalStateSnapshot { pub(crate) fn file_id(&self, url: &lsp_types::Url) -> anyhow::Result { let path = from_proto::vfs_path(url)?; let vfs = self.vfs_read(); - let file_id = + let (file_id, _) = vfs.file_id(&path).ok_or_else(|| anyhow::format_err!("file not found: {path}"))?; Ok(file_id) } @@ -104,11 +103,11 @@ impl GlobalStateSnapshot { pub(crate) fn file_id_for_path(&self, path: &Path) -> Option { let path = VfsPath::from(AbsPathBuf::try_from(path.to_path_buf()).ok()?); - self.vfs_read().file_id(&path) + self.vfs_read().file_id(&path).map(|(file_id, _)| file_id) } pub(crate) fn file_path(&self, file_id: FileId) -> Option { - self.vfs_read().file_path(file_id)?.as_abs_path().map(ToOwned::to_owned) + self.vfs_read().file_path(file_id).as_abs_path().map(ToOwned::to_owned) } pub(crate) fn line_info(&self, file_id: FileId) -> anyhow::Result { @@ -117,8 +116,7 @@ impl GlobalStateSnapshot { vfs.1 .get(&file_id) .copied() - .or_else(|| vfs.0.line_ending(file_id)) - .with_context(|| format!("missing line ending metadata for {file_id:?}"))? + .unwrap_or(utils::lines::LineEnding::Unix) }; let index = self.analysis.line_index(file_id)?; let encoding = self.config.position_encoding(); @@ -458,8 +456,7 @@ impl GlobalStateSnapshot { /// URI and document version come from the same open document spelling. pub(crate) fn url(&self, id: FileId) -> anyhow::Result { let vfs = &self.vfs_read(); - let path = - vfs.file_path(id).ok_or_else(|| anyhow::format_err!("unknown file id: {id:?}"))?; + let path = vfs.file_path(id); let path = path .as_abs_path() .ok_or_else(|| anyhow::format_err!("file {id:?} has no file URI: {path}"))?; @@ -517,8 +514,8 @@ impl GlobalStateSnapshot { mod tests { use lsp_server::Connection; use lsp_types::{ClientCapabilities, TraceValue}; - use utils::{lines::LineEnding, test_support::TestDir}; - use vfs::{FileId, VfsPath, loader::LoadResult}; + use utils::{test_support::TestDir}; + use vfs::{FileId, VfsPath, /*removed*/}; use crate::{ Opt, @@ -549,22 +546,22 @@ mod tests { let mut state = GlobalState::new(server.sender, config, TraceValue::Off); state.workspace.vfs.write().0.set_file_contents( - &VfsPath::from(root.join("top.sv")), - LoadResult::Loaded("module top; endmodule\n".to_owned(), LineEnding::Unix), + VfsPath::from(root.join("top.sv")), + Some(b"module top; endmodule\n".to_vec()), ); assert!(state.process_changes()); let snapshot = state.make_snapshot(); state.workspace.vfs.write().0.set_file_contents( - &VfsPath::from(root.join("child.sv")), - LoadResult::Loaded("module child; endmodule\n".to_owned(), LineEnding::Unix), + VfsPath::from(root.join("child.sv")), + Some(b"module child; endmodule\n".to_vec()), ); let mut live_file_ids = state.workspace.vfs.read().0.iter().map(|(file_id, _)| file_id).collect::>(); - live_file_ids.sort_unstable_by_key(|file_id| file_id.0); - assert_eq!(live_file_ids, vec![FileId(0), FileId(1)]); + live_file_ids.sort_unstable_by_key(|file_id| file_id.index()); + assert_eq!(live_file_ids, vec![FileId::from_raw(0), FileId::from_raw(1)]); - assert_eq!(snapshot.file_ids(), vec![FileId(0)]); + assert_eq!(snapshot.file_ids(), vec![FileId::from_raw(0)]); } } diff --git a/src/global_state/workspace_state.rs b/src/global_state/workspace_state.rs index da04b785..c272d6d1 100644 --- a/src/global_state/workspace_state.rs +++ b/src/global_state/workspace_state.rs @@ -28,6 +28,7 @@ pub(crate) struct VfsProgress { } impl VfsProgress { + #[allow(dead_code)] pub(crate) fn in_progress(&self) -> bool { self.n_done < self.n_total } @@ -142,16 +143,22 @@ impl WorkspaceVfsReadiness { config_version: u32, n_done: usize, n_total: usize, + finished: bool, ) -> Option { if config_version != self.vfs_config_version { return None; } - if n_done < self.vfs_progress.n_done { + if !finished && n_done < self.vfs_progress.n_done { return None; } self.vfs_progress = VfsProgress { config_version, n_done, n_total }; - self.vfs_ready = !self.vfs_progress.in_progress(); + // rust-analyzer style: generation is ready when Progress reports Finished + // (or empty total with Finished), not when content batches carry a version. + self.vfs_ready = finished || (n_total == 0 && n_done == 0); + if finished { + self.vfs_progress.n_done = n_total; + } Some(self.vfs_progress) } @@ -163,10 +170,6 @@ impl WorkspaceVfsReadiness { self.vfs_client_progress_active = false; } - pub(crate) fn accepts_vfs_loaded(&self, config_version: u32) -> bool { - config_version == self.vfs_config_version - } - pub(crate) fn current_vfs_config_version(&self) -> u32 { self.vfs_config_version } diff --git a/src/lsp_ext/to_proto.rs b/src/lsp_ext/to_proto.rs index d13b6fb6..56af331d 100644 --- a/src/lsp_ext/to_proto.rs +++ b/src/lsp_ext/to_proto.rs @@ -1032,7 +1032,7 @@ mod tests { encoding: PositionEncoding::Utf8, }; let diag = IdeDiagnostic { - file_id: FileId(0), + file_id: FileId::from_raw(0), code: 2, subsystem: 0, name: "inactive-preprocessor-branch".to_owned(), From 1183eae5a49d3710a8df25eb39a8cfaef93c2e2c Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:13:11 +0800 Subject: [PATCH 09/13] chore: fmt and fix tests for r-a VFS cutover Apply cargo fmt, satisfy clippy -D warnings, and update ide test fixtures to ChangedFile::create/modify helpers after the API change. --- crates/hir/src/base_db/source_db.rs | 2 +- crates/hir/src/hir_def/macro_file/tests.rs | 2 +- crates/hir/src/preproc/tests.rs | 2 +- crates/hir/src/scope.rs | 2 +- crates/hir/src/semantics/pathres.rs | 2 +- crates/hir/src/type_infer.rs | 2 +- crates/ide/src/code_action/tests.rs | 13 +-- crates/ide/src/completion/engine/tests.rs | 10 +- crates/ide/src/db/root_db.rs | 2 +- crates/ide/src/definitions.rs | 10 +- crates/ide/src/diagnostics.rs | 91 +++++------------ crates/ide/src/document_highlight.rs | 10 +- crates/ide/src/formatting.rs | 8 +- crates/ide/src/inlay_hint.rs | 13 +-- crates/ide/src/macro_hover_tests.rs | 14 +-- crates/ide/src/module_resolution.rs | 14 +-- crates/ide/src/selection_ranges.rs | 9 +- crates/ide/src/semantic_index.rs | 3 +- crates/ide/src/semantic_target.rs | 13 +-- crates/ide/src/semantic_tokens.rs | 10 +- crates/ide/src/verilog_2005.rs | 109 +++++---------------- crates/project-model/src/lib.rs | 35 +++++-- crates/vfs/src/file_set.rs | 16 ++- crates/vfs/src/lib.rs | 92 ++++++++++------- crates/vfs/src/loader.rs | 7 +- crates/vfs/src/notify.rs | 19 ++-- crates/vfs/src/vfs_path.rs | 10 +- crates/workspace-model/src/source_root.rs | 2 +- src/global_state/diagnostics.rs | 8 +- src/global_state/event_loop.rs | 5 +- src/global_state/process_changes.rs | 26 ++--- src/global_state/snapshot.rs | 9 +- 32 files changed, 230 insertions(+), 340 deletions(-) diff --git a/crates/hir/src/base_db/source_db.rs b/crates/hir/src/base_db/source_db.rs index 5b44bd46..a0e27c63 100644 --- a/crates/hir/src/base_db/source_db.rs +++ b/crates/hir/src/base_db/source_db.rs @@ -5,7 +5,7 @@ use syntax::{ }; use triomphe::Arc; use utils::{line_index::TextSize, path_identity::PathIdentityIndex}; -use vfs::{FileId, AnchoredPath}; +use vfs::{AnchoredPath, FileId}; use crate::base_db::{ compilation_plan::{self, CompilationPlan}, diff --git a/crates/hir/src/hir_def/macro_file/tests.rs b/crates/hir/src/hir_def/macro_file/tests.rs index 730a99f3..70334578 100644 --- a/crates/hir/src/hir_def/macro_file/tests.rs +++ b/crates/hir/src/hir_def/macro_file/tests.rs @@ -12,7 +12,7 @@ use utils::{ line_index::{TextRange, TextSize}, paths::{AbsPathBuf, Utf8PathBuf}, }; -use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; +use vfs::{AnchoredPath, FileId, FileSet, VfsPath}; use super::*; use crate::{ diff --git a/crates/hir/src/preproc/tests.rs b/crates/hir/src/preproc/tests.rs index b39dc830..c95ca70d 100644 --- a/crates/hir/src/preproc/tests.rs +++ b/crates/hir/src/preproc/tests.rs @@ -7,7 +7,7 @@ use utils::{ line_index::{TextRange, TextSize}, paths::{AbsPathBuf, Utf8PathBuf}, }; -use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; +use vfs::{AnchoredPath, FileId, FileSet, VfsPath}; use super::*; use crate::{ diff --git a/crates/hir/src/scope.rs b/crates/hir/src/scope.rs index 96e5bf1b..868252f3 100644 --- a/crates/hir/src/scope.rs +++ b/crates/hir/src/scope.rs @@ -690,7 +690,7 @@ mod tests { get::GetRef, paths::{AbsPathBuf, Utf8PathBuf}, }; - use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; + use vfs::{AnchoredPath, FileId, FileSet, VfsPath}; use crate::{ base_db::{ diff --git a/crates/hir/src/semantics/pathres.rs b/crates/hir/src/semantics/pathres.rs index 68c619f7..5b0294d2 100644 --- a/crates/hir/src/semantics/pathres.rs +++ b/crates/hir/src/semantics/pathres.rs @@ -302,7 +302,7 @@ mod tests { use smol_str::SmolStr; use triomphe::Arc; use utils::paths::{AbsPathBuf, Utf8PathBuf}; - use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; + use vfs::{AnchoredPath, FileId, FileSet, VfsPath}; use super::*; use crate::{ diff --git a/crates/hir/src/type_infer.rs b/crates/hir/src/type_infer.rs index cbca8206..584c002e 100644 --- a/crates/hir/src/type_infer.rs +++ b/crates/hir/src/type_infer.rs @@ -858,7 +858,7 @@ mod tests { use smol_str::SmolStr; use triomphe::Arc; use utils::paths::{AbsPathBuf, Utf8PathBuf}; - use vfs::{FileId, FileSet, VfsPath, AnchoredPath}; + use vfs::{AnchoredPath, FileId, FileSet, VfsPath}; use super::*; use crate::{ diff --git a/crates/ide/src/code_action/tests.rs b/crates/ide/src/code_action/tests.rs index 8b3a8ba7..2a1dcd3c 100644 --- a/crates/ide/src/code_action/tests.rs +++ b/crates/ide/src/code_action/tests.rs @@ -1,12 +1,8 @@ use std::{fmt::Write, fs, path::Path}; use hir::base_db::{change::Change, source_root::SourceRoot}; -use triomphe::Arc; -use utils::{ - lines::LineEnding, - text_edit::{TextRange, TextSize}, -}; -use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; +use utils::text_edit::{TextRange, TextSize}; +use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::db::root_db::RootDb; @@ -119,10 +115,7 @@ fn db_with_text(text: &str) -> (RootDb, FileId) { let mut change = Change::new(); change.set_roots(vec![SourceRoot::new_local(file_set)]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut db = RootDb::new(None); db.apply_change(change); diff --git a/crates/ide/src/completion/engine/tests.rs b/crates/ide/src/completion/engine/tests.rs index 572cd0ed..5d6bf55c 100644 --- a/crates/ide/src/completion/engine/tests.rs +++ b/crates/ide/src/completion/engine/tests.rs @@ -1,9 +1,8 @@ use std::path::Path; use hir::base_db::{change::Change, source_root::SourceRoot}; -use triomphe::Arc; -use utils::{lines::LineEnding, text_edit::TextSize}; -use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; +use utils::text_edit::TextSize; +use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::{ @@ -27,10 +26,7 @@ fn setup_with_path(text: &str, path: &str) -> (AnalysisHost, FilePosition) { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(owned.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, owned.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/db/root_db.rs b/crates/ide/src/db/root_db.rs index 385684c7..2b9af162 100644 --- a/crates/ide/src/db/root_db.rs +++ b/crates/ide/src/db/root_db.rs @@ -17,7 +17,7 @@ use hir::{ }, }; use triomphe::Arc; -use vfs::{FileId, AnchoredPath}; +use vfs::{AnchoredPath, FileId}; use crate::db::{ line_index_db::LineIndexDbStorage, workspace_symbol_index_db::WorkspaceSymbolIndexDbStorage, diff --git a/crates/ide/src/definitions.rs b/crates/ide/src/definitions.rs index 63096d06..10139205 100644 --- a/crates/ide/src/definitions.rs +++ b/crates/ide/src/definitions.rs @@ -366,9 +366,8 @@ mod tests { symbol::DefKind, }; use syntax::SyntaxNodeExt; - use triomphe::Arc; - use utils::{lines::LineEnding, text_edit::TextSize}; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use utils::text_edit::TextSize; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::{analysis_host::AnalysisHost, db::root_db::RootDb}; @@ -383,10 +382,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 71674cca..06b4d31e 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -429,11 +429,10 @@ mod tests { use triomphe::Arc; use utils::{ line_index::{TextRange, TextSize}, - lines::LineEnding, paths::AbsPathBuf, test_support::TestDir, }; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::{ AMBIGUOUS_MODULE_INSTANTIATION, DIAGNOSTIC_INACTIVE_PREPROCESSOR_BRANCH, DiagnosticSource, @@ -486,13 +485,10 @@ mod tests { let mut change = Change::new(); for (idx, (path, text)) in files.iter().enumerate() { - let file_id = FileId(idx as u32); + let file_id = FileId::from_raw(idx as u32); let path = VfsPath::new_virtual_path((*path).to_owned()); file_set.insert(file_id, path); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(*text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, *text)); } change.set_roots(vec![SourceRoot::new(role, file_set)]); @@ -782,17 +778,8 @@ mod tests { SourceRoot::new_ignored(open_files), ]); change.set_project_config(Arc::new(ProjectConfig::new(vec![None, None], Vec::new()))); - change.add_changed_file(ChangedFile { - file_id: manifest_id, - change_kind: ChangeKind::Create(Arc::from(""), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: open_file_id, - change_kind: ChangeKind::Create( - Arc::from("module open(;\nendmodule\n"), - LineEnding::Unix, - ), - }); + change.add_changed_file(ChangedFile::create(manifest_id, "")); + change.add_changed_file(ChangedFile::create(open_file_id, "module open(;\nendmodule\n")); let mut db = RootDb::new(None); db.apply_change(change); @@ -824,10 +811,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![SourceRoot::new_best_effort_index(file_set)]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from("module top; endmodule\n"), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, "module top; endmodule\n")); db.apply_change(change); let plan = db.compilation_plan_for_root(SourceRootId(0)); @@ -850,20 +834,14 @@ mod tests { file_set.insert(FileId::from_raw(1), VfsPath::from(header_path)); let mut change = Change::new(); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(0), - change_kind: ChangeKind::Create( - Arc::from("module top;\n`include \"defs.vh\"\nendmodule\n"), - LineEnding::Unix, - ), - }); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(1), - change_kind: ChangeKind::Create( - Arc::from("logic value = missing_name;\n"), - LineEnding::Unix, - ), - }); + change.add_changed_file(ChangedFile::create( + FileId::from_raw(0), + "module top;\n`include \"defs.vh\"\nendmodule\n", + )); + change.add_changed_file(ChangedFile::create( + FileId::from_raw(1), + "logic value = missing_name;\n", + )); change.set_roots(vec![SourceRoot::new_local(file_set)]); change.set_project_config(Arc::new(ProjectConfig::new( vec![Some(CompilationProfileId(0))], @@ -908,14 +886,8 @@ mod tests { file_set.insert(FileId::from_raw(1), VfsPath::from(frag_path)); let mut change = Change::new(); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(0), - change_kind: ChangeKind::Create(Arc::from(pkg_text), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(1), - change_kind: ChangeKind::Create(Arc::from(vfs_frag_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(FileId::from_raw(0), pkg_text)); + change.add_changed_file(ChangedFile::create(FileId::from_raw(1), vfs_frag_text)); change.set_roots(vec![SourceRoot::new_local(file_set)]); change.set_project_config(Arc::new(ProjectConfig::new( vec![Some(CompilationProfileId(0))], @@ -936,7 +908,8 @@ mod tests { assert!( diagnostics .iter() - .any(|diag| diag.file_id == FileId::from_raw(1) && diag.message.contains("missing_name")), + .any(|diag| diag.file_id == FileId::from_raw(1) + && diag.message.contains("missing_name")), "included .sv should use VFS text and receive mapped diagnostics: {diagnostics:?}" ); } @@ -968,18 +941,9 @@ mod tests { include_files.insert(FileId::from_raw(2), VfsPath::from(leaf_path)); let mut change = Change::new(); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(0), - change_kind: ChangeKind::Create(Arc::from(top_text), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(1), - change_kind: ChangeKind::Create(Arc::from(mid_text), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(2), - change_kind: ChangeKind::Create(Arc::from(vfs_leaf_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(FileId::from_raw(0), top_text)); + change.add_changed_file(ChangedFile::create(FileId::from_raw(1), mid_text)); + change.add_changed_file(ChangedFile::create(FileId::from_raw(2), vfs_leaf_text)); change.set_roots(vec![ SourceRoot::new_local(src_files), SourceRoot::new_local(include_files), @@ -1007,7 +971,8 @@ mod tests { assert!( diagnostics .iter() - .any(|diag| diag.file_id == FileId::from_raw(2) && diag.message.contains("missing_name")), + .any(|diag| diag.file_id == FileId::from_raw(2) + && diag.message.contains("missing_name")), "transitively included .sv should use VFS text: {diagnostics:?}" ); } @@ -1029,14 +994,8 @@ mod tests { file_set.insert(FileId::from_raw(1), VfsPath::from(b_path.clone())); let mut change = Change::new(); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(0), - change_kind: ChangeKind::Create(Arc::from(a_text), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: FileId::from_raw(1), - change_kind: ChangeKind::Create(Arc::from(b_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(FileId::from_raw(0), a_text)); + change.add_changed_file(ChangedFile::create(FileId::from_raw(1), b_text)); change.set_roots(vec![SourceRoot::new_local(file_set)]); db.apply_change(change); diff --git a/crates/ide/src/document_highlight.rs b/crates/ide/src/document_highlight.rs index 4e29f320..d3b02bd5 100644 --- a/crates/ide/src/document_highlight.rs +++ b/crates/ide/src/document_highlight.rs @@ -119,9 +119,8 @@ mod tests { use hir::base_db::{change::Change, source_root::SourceRoot}; use insta::assert_debug_snapshot; - use triomphe::Arc; - use utils::{lines::LineEnding, text_edit::TextSize}; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use utils::text_edit::TextSize; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::{ScopeVisibility, analysis_host::AnalysisHost, test_utils::normalize_fixture_text}; @@ -142,10 +141,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(owned.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, owned.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/formatting.rs b/crates/ide/src/formatting.rs index 68d82550..d7d47f30 100644 --- a/crates/ide/src/formatting.rs +++ b/crates/ide/src/formatting.rs @@ -375,13 +375,12 @@ mod tests { use std::fmt::Write; use hir::base_db::{change::Change, source_root::SourceRoot}; - use triomphe::Arc; use utils::{ cancellation::CancellationToken, lines::{LineEnding, LineInfo, PositionEncoding}, text_edit::TextSize, }; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::{FmtConfig, FormatterProvider, format_on_type}; use crate::{ @@ -399,10 +398,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut db = RootDb::new(None); change.apply(&mut db); diff --git a/crates/ide/src/inlay_hint.rs b/crates/ide/src/inlay_hint.rs index cc633a7b..d47dc7c3 100644 --- a/crates/ide/src/inlay_hint.rs +++ b/crates/ide/src/inlay_hint.rs @@ -579,12 +579,8 @@ fn should_skip(expr: &Expr, name: &str) -> bool { #[cfg(test)] mod tests { use hir::base_db::{change::Change, source_root::SourceRoot}; - use triomphe::Arc; - use utils::{ - lines::LineEnding, - text_edit::{TextRange, TextSize}, - }; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use utils::text_edit::{TextRange, TextSize}; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::{InlayHintConfig, inlay_hint}; use crate::db::root_db::RootDb; @@ -599,10 +595,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut db = RootDb::new(None); change.apply(&mut db); diff --git a/crates/ide/src/macro_hover_tests.rs b/crates/ide/src/macro_hover_tests.rs index 007ced97..b4bc3203 100644 --- a/crates/ide/src/macro_hover_tests.rs +++ b/crates/ide/src/macro_hover_tests.rs @@ -6,8 +6,8 @@ use hir::base_db::{ source_root::{SourceRoot, SourceRootId}, }; use triomphe::Arc; -use utils::{lines::LineEnding, test_support::TestDir, text_edit::TextSize}; -use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; +use utils::{test_support::TestDir, text_edit::TextSize}; +use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use crate::{ FilePosition, @@ -95,14 +95,8 @@ endmodule preprocess: PreprocessConfig::with_predefine_strings(predefines, vec![include_dir]), }], ))); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: header_file_id, - change_kind: ChangeKind::Create(Arc::from(header_text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(header_file_id, header_text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/module_resolution.rs b/crates/ide/src/module_resolution.rs index caf74170..267f414a 100644 --- a/crates/ide/src/module_resolution.rs +++ b/crates/ide/src/module_resolution.rs @@ -335,9 +335,8 @@ mod tests { }; use smol_str::SmolStr; use syntax::{SyntaxNodeExt, ast}; - use triomphe::Arc; - use utils::{lines::LineEnding, text_edit::TextSize}; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet}; + use utils::text_edit::TextSize; + use vfs::{ChangedFile, FileId, FileSet}; use super::*; @@ -350,12 +349,9 @@ mod tests { let mut change = Change::new(); for (idx, (path, text)) in files.iter().enumerate() { - let file_id = FileId(idx as u32); + let file_id = FileId::from_raw(idx as u32); file_set.insert(file_id, VfsPath::new_virtual_path(path.clone())); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); } change.set_roots(vec![root(file_set)]); @@ -453,7 +449,7 @@ mod tests { ResolutionFixture { root: root.unwrap_or_else(|| panic!("missing root in {}", path.display())), query: query.unwrap_or_else(|| panic!("missing query in {}", path.display())), - focus: FileId( + focus: FileId::from_raw( focus_index .unwrap_or_else(|| panic!("missing focus file in {}", path.display())) as u32, diff --git a/crates/ide/src/selection_ranges.rs b/crates/ide/src/selection_ranges.rs index e1ad28fc..2ccfb8a3 100644 --- a/crates/ide/src/selection_ranges.rs +++ b/crates/ide/src/selection_ranges.rs @@ -102,9 +102,7 @@ mod tests { use std::fmt::Write; use hir::base_db::{change::Change, source_root::SourceRoot}; - use triomphe::Arc; - use utils::lines::LineEnding; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::selection_ranges; use crate::{FilePosition, db::root_db::RootDb}; @@ -119,10 +117,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut db = RootDb::new(None); change.apply(&mut db); diff --git a/crates/ide/src/semantic_index.rs b/crates/ide/src/semantic_index.rs index 450217a5..2189e285 100644 --- a/crates/ide/src/semantic_index.rs +++ b/crates/ide/src/semantic_index.rs @@ -135,7 +135,8 @@ impl ModuleIndex { modules_by_name: modules_by_name .into_iter() .map(|(name, mut modules)| { - modules.sort_by_key(|module| (module.file_id.index(), module.name_range.start())); + modules + .sort_by_key(|module| (module.file_id.index(), module.name_range.start())); modules.dedup_by(|lhs, rhs| { lhs.module_id == rhs.module_id || (lhs.file_id == rhs.file_id && lhs.name_range == rhs.name_range) diff --git a/crates/ide/src/semantic_target.rs b/crates/ide/src/semantic_target.rs index 4a121dea..16ae233b 100644 --- a/crates/ide/src/semantic_target.rs +++ b/crates/ide/src/semantic_target.rs @@ -339,12 +339,8 @@ mod tests { semantics::Semantics, }; use syntax::token::TokenKindExt; - use triomphe::Arc; - use utils::{ - line_index::{TextRange, TextSize}, - lines::LineEnding, - }; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use utils::line_index::{TextRange, TextSize}; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::analysis_host::AnalysisHost; @@ -362,10 +358,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/semantic_tokens.rs b/crates/ide/src/semantic_tokens.rs index fe247cd9..2fd668bb 100644 --- a/crates/ide/src/semantic_tokens.rs +++ b/crates/ide/src/semantic_tokens.rs @@ -709,9 +709,8 @@ mod tests { use hir::base_db::{change::Change, source_root::SourceRoot}; use insta::assert_debug_snapshot; - use triomphe::Arc; - use utils::{lines::LineEnding, text_edit::TextRange}; - use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; + use utils::text_edit::TextRange; + use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use super::*; use crate::{analysis_host::AnalysisHost, test_utils::normalize_fixture_text}; @@ -727,10 +726,7 @@ mod tests { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/ide/src/verilog_2005.rs b/crates/ide/src/verilog_2005.rs index 953e7134..b1095460 100644 --- a/crates/ide/src/verilog_2005.rs +++ b/crates/ide/src/verilog_2005.rs @@ -21,11 +21,10 @@ use hir::{ use insta::assert_snapshot; use triomphe::Arc; use utils::{ - lines::LineEnding, test_support::TestDir, text_edit::{TextRange, TextSize}, }; -use vfs::{ChangeKind, ChangedFile, FileId, FileSet, VfsPath}; +use vfs::{ChangedFile, FileId, FileSet, VfsPath}; use crate::{ FilePosition, ScopeVisibility, @@ -108,10 +107,7 @@ fn setup_with_path(text: &str, path: &str) -> (AnalysisHost, FileId) { let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -129,10 +125,7 @@ fn setup_best_effort_with_path(text: &str, path: &str) -> (AnalysisHost, FileId, let mut change = Change::new(); change.set_roots(vec![root]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -151,10 +144,7 @@ fn parsed_file_nodes_survive_parse_lru_eviction() { let mut change = Change::new(); for (file_id, path, text) in files { file_set.insert(file_id, VfsPath::new_virtual_path(path.to_owned())); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text)); } change.set_roots(vec![SourceRoot::new_local(file_set)]); @@ -196,14 +186,11 @@ fn setup_marked_files(files: &[(&str, &str)]) -> (AnalysisHost, Vec) let mut marked_files = Vec::new(); for (idx, (path, text)) in files.iter().enumerate() { - let file_id = FileId(idx as u32); + let file_id = FileId::from_raw(idx as u32); let text = normalize_fixture_text(text); let (text, markers) = strip_markers(text); file_set.insert(file_id, VfsPath::new_virtual_path((*path).to_owned())); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); marked_files.push((file_id, text, markers)); } @@ -234,10 +221,7 @@ fn setup_marked_with_predefines( preprocess: PreprocessConfig::with_predefine_strings(predefines, Vec::new()), }], ))); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -293,14 +277,8 @@ fn setup_include_macro_project( }, }], ))); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: header_file_id, - change_kind: ChangeKind::Create(Arc::from(header_text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(header_file_id, header_text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -619,14 +597,8 @@ fn best_effort_single_file_rename_rejects_cross_file_symbol() { let mut change = Change::new(); change.set_roots(vec![SourceRoot::new_best_effort_index(file_set)]); - change.add_changed_file(ChangedFile { - file_id: child_file_id, - change_kind: ChangeKind::Create(Arc::from(child_text), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(child_file_id, child_text)); + change.add_changed_file(ChangedFile::create(top_file_id, top_text)); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -1100,14 +1072,8 @@ endmodule }, }], ))); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: manifest_file_id, - change_kind: ChangeKind::Create(Arc::from(manifest_text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(manifest_file_id, manifest_text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -1253,14 +1219,8 @@ endmodule }, }], ))); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: define_file_id, - change_kind: ChangeKind::Create(Arc::from(define_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(define_file_id, define_text)); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -1399,14 +1359,8 @@ endmodule let mut change = Change::new(); change.set_roots(vec![SourceRoot::new_local(file_set)]); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: defs_file_id, - change_kind: ChangeKind::Create(Arc::from(defs_text), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(defs_file_id, defs_text)); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -1801,14 +1755,8 @@ endmodule }, }], ))); - change.add_changed_file(ChangedFile { - file_id: top_file_id, - change_kind: ChangeKind::Create(Arc::from(top_text.as_str()), LineEnding::Unix), - }); - change.add_changed_file(ChangedFile { - file_id: manifest_file_id, - change_kind: ChangeKind::Create(Arc::from(manifest_text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(top_file_id, top_text.as_str())); + change.add_changed_file(ChangedFile::create(manifest_file_id, manifest_text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); @@ -2195,10 +2143,7 @@ endmodule let updated_header = "`define HEADER_WIDTH 16\n"; let mut change = Change::new(); - change.add_changed_file(ChangedFile { - file_id: fixture.header_file_id, - change_kind: ChangeKind::Modify(Arc::from(updated_header), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::modify(fixture.header_file_id, updated_header)); fixture.host.apply_change(change); let analysis = fixture.host.make_analysis(); @@ -2569,13 +2514,10 @@ fn verilog_2005_hover_after_truncation_uses_current_syntax_context() { let (mut host, file_id, _clean_text, markers) = setup_marked(full); let mut change = Change::new(); - change.add_changed_file(ChangedFile { + change.add_changed_file(ChangedFile::modify( file_id, - change_kind: ChangeKind::Modify( - Arc::from("module\taxi_addr_miter(i_last_addr, i_size, i_burst, i_len);"), - LineEnding::Unix, - ), - }); + "module\taxi_addr_miter(i_last_addr, i_size, i_burst, i_len);", + )); host.apply_change(change); let hover = host @@ -2990,10 +2932,7 @@ endmodule let mut change = Change::new(); change.set_roots(vec![SourceRoot::new_local(file_set)]); - change.add_changed_file(ChangedFile { - file_id, - change_kind: ChangeKind::Create(Arc::from(text.as_str()), LineEnding::Unix), - }); + change.add_changed_file(ChangedFile::create(file_id, text.as_str())); let mut host = AnalysisHost::default(); host.apply_change(change); diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index aca62715..313c1c7d 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -658,8 +658,7 @@ pub fn get_workspace_folder( let mut directory_include = Vec::new(); directory_include.extend(root.include_dirs.iter().cloned()); if !root.source_directories.is_empty() { - directory_include - .extend(root.source_directories.scan_roots().cloned()); + directory_include.extend(root.source_directories.scan_roots().cloned()); } directory_include.sort(); directory_include.dedup(); @@ -828,7 +827,7 @@ fn collect_dependency_roots( mod tests { use std::fs; - use utils::{lines::LineEnding, test_support::TestDir}; + use utils::test_support::TestDir; use vfs::Vfs; use workspace_model::{source_db::SourceFileKind, source_root::SourceRootRole}; @@ -1057,7 +1056,10 @@ include_dirs = ["include"] let mut vfs = Vfs::default(); for file in [&header, &top] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); @@ -1118,7 +1120,10 @@ include_dirs = ["include"] let mut vfs = Vfs::default(); for file in [&top, &manifest] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); @@ -1197,7 +1202,10 @@ exclude = ["rtl/excluded/**"] let mut vfs = Vfs::default(); for file in [&top, &excluded_top] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); @@ -1278,7 +1286,10 @@ include_dirs = [] let mut vfs = Vfs::default(); for file in [&top, &sibling] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); @@ -1316,7 +1327,10 @@ include_dirs = [] let mut vfs = Vfs::default(); for file in [&top, &sibling] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); @@ -1356,7 +1370,10 @@ exclude = ["**/*_bb.v"] let mut vfs = Vfs::default(); for file in [&top, &blackbox] { - vfs.set_file_contents(VfsPath::from(file.clone()).clone(), Some(String::new().into_bytes())); + vfs.set_file_contents( + VfsPath::from(file.clone()).clone(), + Some(String::new().into_bytes()), + ); } let roots = source_root_config.partition(&vfs); diff --git a/crates/vfs/src/file_set.rs b/crates/vfs/src/file_set.rs index edd13247..3aa8065b 100644 --- a/crates/vfs/src/file_set.rs +++ b/crates/vfs/src/file_set.rs @@ -3,10 +3,7 @@ use nohash_hasher::IntMap; use rustc_hash::{FxHashMap, FxHashSet}; use utils::paths::{AbsPath, AbsPathBuf}; -use crate::{ - AnchoredPath, FileId, Vfs, VfsPath, - path_glob::PathGlobMatcher, -}; +use crate::{AnchoredPath, FileId, Vfs, VfsPath, path_glob::PathGlobMatcher}; /// Bidirectional path map for a single source root. /// @@ -250,11 +247,13 @@ impl FileSetConfigBuilder { self.roots.len() } + pub fn is_empty(&self) -> bool { + self.roots.is_empty() + } + pub fn add_file_set(&mut self, roots: Vec) { - let include_paths: Vec = roots - .iter() - .filter_map(|root| root.as_path().map(|path| path.to_path_buf())) - .collect(); + let include_paths: Vec = + roots.iter().filter_map(|root| root.as_path().map(|path| path.to_path_buf())).collect(); let include = if include_paths.is_empty() { Vec::new() } else { @@ -323,7 +322,6 @@ impl fst::Automaton for PrefixOf<'_> { } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index c9bbfd39..0afa9a35 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -6,36 +6,37 @@ //! # Virtual File System //! //! VFS records all file changes pushed to it via [`set_file_contents`]. -//! As such it only ever stores changes, not the actual content of a file at any given moment. -//! All file changes are logged, and can be retrieved via +//! As such it only ever stores changes, not the actual content of a file at any +//! given moment. All file changes are logged, and can be retrieved via //! [`take_changes`] method. The pack of changes is then pushed to `salsa` and //! triggers incremental recomputation. //! -//! Files in VFS are identified with [`FileId`]s -- interned paths. The notion of -//! the path, [`VfsPath`] is somewhat abstract: at the moment, it is represented -//! as an [`std::path::PathBuf`] internally, but this is an implementation detail. +//! Files in VFS are identified with [`FileId`]s -- interned paths. The notion +//! of the path, [`VfsPath`] is somewhat abstract: at the moment, it is +//! represented as an [`std::path::PathBuf`] internally, but this is an +//! implementation detail. //! //! VFS doesn't do IO or file watching itself. For that, see the [`loader`] //! module. [`loader::Handle`] is an object-safe trait which abstracts both file -//! loading and file watching. [`Handle`] is dynamically configured with a set of -//! directory entries which should be scanned and watched. [`Handle`] then +//! loading and file watching. [`Handle`] is dynamically configured with a set +//! of directory entries which should be scanned and watched. [`Handle`] then //! asynchronously pushes file changes. Directory entries are configured in -//! free-form via list of globs, it's up to the [`Handle`] to interpret the globs -//! in any specific way. +//! free-form via list of globs, it's up to the [`Handle`] to interpret the +//! globs in any specific way. //! -//! VFS stores a flat list of files. [`file_set::FileSet`] can partition this list -//! of files into disjoint sets of files. Traversal-like operations (including -//! getting the neighbor file by the relative path) are handled by the [`FileSet`]. -//! [`FileSet`]s are also pushed to salsa and cause it to re-check `mod foo;` -//! declarations when files are created or deleted. +//! VFS stores a flat list of files. [`file_set::FileSet`] can partition this +//! list of files into disjoint sets of files. Traversal-like operations +//! (including getting the neighbor file by the relative path) are handled by +//! the [`FileSet`]. [`FileSet`]s are also pushed to salsa and cause it to +//! re-check `mod foo;` declarations when files are created or deleted. //! //! [`FileSet`] and [`loader::Entry`] play similar, but different roles. //! Both specify the "set of paths/files", one is geared towards file watching, //! the other towards salsa changes. In particular, single [`FileSet`] //! may correspond to several [`loader::Entry`]. For example, a crate from -//! crates.io which uses code generation would have two [`Entries`] -- for sources -//! in `~/.cargo`, and for generated code in `./target/debug/build`. It will -//! have a single [`FileSet`] which unions the two sources. +//! crates.io which uses code generation would have two [`Entries`] -- for +//! sources in `~/.cargo`, and for generated code in `./target/debug/build`. It +//! will have a single [`FileSet`] which unions the two sources. //! //! [`set_file_contents`]: Vfs::set_file_contents //! [`take_changes`]: Vfs::take_changes @@ -58,19 +59,21 @@ use std::{ mem, }; -use crate::path_interner::PathInterner; +use indexmap::{IndexMap, map::Entry}; +use rustc_hash::FxHasher; +use tracing::{Level, span}; +pub use utils::paths::{AbsPath, AbsPathBuf}; +use crate::path_interner::PathInterner; pub use crate::{ anchored_path::{AnchoredPath, AnchoredPathBuf}, - file_set::{FileSet, FileSetConfig, FileSetConfigBuilder, FileSetFilter, PartitionedFileSet, PathMatcher}, + file_set::{ + FileSet, FileSetConfig, FileSetConfigBuilder, FileSetFilter, PartitionedFileSet, + PathMatcher, + }, + path_glob::PathGlobMatcher, vfs_path::VfsPath, }; -use indexmap::{IndexMap, map::Entry}; -pub use crate::path_glob::PathGlobMatcher; -pub use utils::paths::{AbsPath, AbsPathBuf}; - -use rustc_hash::FxHasher; -use tracing::{Level, span}; fn hash_once(thing: impl std::hash::Hash) -> u64 { let mut h = Hasher::default(); @@ -78,7 +81,6 @@ fn hash_once(thing: impl std::hash::Hash) - h.finish() } - /// Handle to a file in [`Vfs`] /// /// Most functions in rust-analyzer use this when they need to refer to a file. @@ -120,8 +122,8 @@ pub enum FileState { Exists(u64), /// The file is deleted. Deleted, - /// The file was specifically excluded by the user. We still include excluded files - /// when they're opened (without their contents). + /// The file was specifically excluded by the user. We still include + /// excluded files when they're opened (without their contents). Excluded, } @@ -181,6 +183,25 @@ impl ChangedFile { Change::Delete => None, } } + + /// Test/host helper: create a file with UTF-8 text contents. + pub fn create(file_id: FileId, text: impl AsRef) -> Self { + let bytes = text.as_ref().as_bytes().to_vec(); + let hash = hash_once::(&*bytes); + Self { file_id, change: Change::Create(bytes, hash) } + } + + /// Test/host helper: modify a file with UTF-8 text contents. + pub fn modify(file_id: FileId, text: impl AsRef) -> Self { + let bytes = text.as_ref().as_bytes().to_vec(); + let hash = hash_once::(&*bytes); + Self { file_id, change: Change::Modify(bytes, hash) } + } + + /// Test/host helper: delete a file. + pub fn delete(file_id: FileId) -> Self { + Self { file_id, change: Change::Delete } + } } impl Change { @@ -257,9 +278,11 @@ impl Vfs { }) } - /// Update the `path` with the given `contents`. `None` means the file was deleted. + /// Update the `path` with the given `contents`. `None` means the file was + /// deleted. /// - /// Returns `true` if the file was modified, and saves the [change](ChangedFile). + /// Returns `true` if the file was modified, and saves the + /// [change](ChangedFile). /// /// If the path does not currently exists in the `Vfs`, allocates a new /// [`FileId`] for it. @@ -342,8 +365,8 @@ impl Vfs { /// Returns the id associated with `path` /// - /// - If `path` does not exists in the `Vfs`, allocate a new id for it, associated with a - /// deleted file; + /// - If `path` does not exists in the `Vfs`, allocate a new id for it, + /// associated with a deleted file; /// - Else, returns `path`'s id. /// /// Does not record a change. @@ -364,8 +387,9 @@ impl Vfs { self.data[file_id.0 as usize] } - /// We cannot ignore excluded files, because this will lead to errors when the client - /// requests semantic information for them, so we instead mark them specially. + /// We cannot ignore excluded files, because this will lead to errors when + /// the client requests semantic information for them, so we instead + /// mark them specially. pub fn insert_excluded_file(&mut self, path: VfsPath) { let file_id = self.alloc_file_id(path); self.data[file_id.0 as usize] = FileState::Excluded; diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index c643d077..eb32c017 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -50,7 +50,8 @@ pub struct Config { pub load: Vec, /// Index of watched entries in `load`. /// - /// If a path in a watched entry is modified,the [`Handle`] should notify it. + /// If a path in a watched entry is modified,the [`Handle`] should notify + /// it. pub watch: Vec, } @@ -187,8 +188,8 @@ impl Directories { /// /// It is included if /// - An element in `self.include` is a prefix of `path`. - /// - This path is longer than any element in `self.exclude` that is a prefix - /// of `path`. In case of equality, exclusion wins. + /// - This path is longer than any element in `self.exclude` that is a + /// prefix of `path`. In case of equality, exclusion wins. /// - Optional `exclude_globs` does not match (vide extension). fn includes_path(&self, path: &AbsPath) -> bool { let mut include: Option<&AbsPathBuf> = None; diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 729ecdb6..236f569e 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -19,12 +19,13 @@ use std::{ use crossbeam_channel::{Receiver, Sender, select, unbounded}; use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::AccessKind}; -use utils::paths::{AbsPath, AbsPathBuf, Utf8PathBuf}; use rayon::iter::{IndexedParallelIterator as _, IntoParallelIterator as _, ParallelIterator}; use rustc_hash::FxHashSet; -use crate::loader::{self, LoadingProgress}; +use utils::paths::{AbsPath, AbsPathBuf, Utf8PathBuf}; use walkdir::WalkDir; +use crate::loader::{self, LoadingProgress}; + #[derive(Debug)] pub struct NotifyHandle { // Relative order of fields below is significant. @@ -315,13 +316,17 @@ impl NotifyActor { return false; } - // We want to filter out subdirectories that are roots themselves, because they will be visited separately. + // We want to filter out subdirectories that are roots themselves, + // because they will be visited separately. let path: &Path = path; - dirs.exclude.iter().all(|it| >::as_ref(it) != path) + dirs.exclude + .iter() + .all(|it| >::as_ref(it) != path) && (>::as_ref(root) == path - || dirs.include.iter().all(|it| { - >::as_ref(it) != path - })) + || dirs + .include + .iter() + .all(|it| >::as_ref(it) != path)) }); let files = walkdir.filter_map(|it| it.ok()).filter_map(|entry| { diff --git a/crates/vfs/src/vfs_path.rs b/crates/vfs/src/vfs_path.rs index 0c7c67db..0f325ee3 100644 --- a/crates/vfs/src/vfs_path.rs +++ b/crates/vfs/src/vfs_path.rs @@ -31,7 +31,8 @@ impl VfsPath { VfsPath::from(AbsPathBuf::assert(path.into())) } - /// Returns the `AbsPath` representation of `self` if `self` is on the file system. + /// Returns the `AbsPath` representation of `self` if `self` is on the file + /// system. pub fn as_path(&self) -> Option<&AbsPath> { match &self.0 { VfsPathRepr::PathBuf(it) => Some(it.as_path()), @@ -124,8 +125,8 @@ impl VfsPath { /// /// Encode the path in the given buffer. /// - /// The encoding will be `0` if [`AbsPathBuf`], `1` if [`VirtualPath`], followed - /// by `self`'s representation. + /// The encoding will be `0` if [`AbsPathBuf`], `1` if [`VirtualPath`], + /// followed by `self`'s representation. /// /// Note that this encoding is dependent on the operating system. pub(crate) fn encode(&self, buf: &mut Vec) { @@ -406,7 +407,8 @@ impl VirtualPath { /// /// # Returns /// - `None` if `self` ends with `"//"`. - /// - `Some((name, None))` if `self`'s base contains no `.`, or only one `.` at the start. + /// - `Some((name, None))` if `self`'s base contains no `.`, or only one `.` + /// at the start. /// - `Some((name, Some(extension))` else. /// /// # Note diff --git a/crates/workspace-model/src/source_root.rs b/crates/workspace-model/src/source_root.rs index 95f41774..6b313109 100644 --- a/crates/workspace-model/src/source_root.rs +++ b/crates/workspace-model/src/source_root.rs @@ -1,4 +1,4 @@ -use vfs::{FileId, FileSet, FileSetConfig, Vfs, VfsPath, AnchoredPath}; +use vfs::{AnchoredPath, FileId, FileSet, FileSetConfig, Vfs, VfsPath}; use crate::source_db::SourceFileKind; diff --git a/src/global_state/diagnostics.rs b/src/global_state/diagnostics.rs index 7a635cf9..957cee84 100644 --- a/src/global_state/diagnostics.rs +++ b/src/global_state/diagnostics.rs @@ -101,7 +101,9 @@ impl DiagnosticOwner { DiagnosticOwner::CompilationProfile(profile_id) => { format!("compilation-profile:{}", profile_id.0) } - DiagnosticOwner::External { source, file } => format!("external-{source}:{}", file.index()), + DiagnosticOwner::External { source, file } => { + format!("external-{source}:{}", file.index()) + } } } } @@ -197,7 +199,9 @@ impl DiagnosticSnapshotKey { let dependency_revisions = self .dependency_revisions .iter() - .map(|(file_id, revision)| format!("{}:{}", file_id.index(), revision.result_id_fragment())) + .map(|(file_id, revision)| { + format!("{}:{}", file_id.index(), revision.result_id_fragment()) + }) .collect::>() .join(","); let external_revisions = self diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index 0f382c69..aabf04b1 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -422,9 +422,8 @@ impl GlobalState { let existed = existing.is_some_and(|(file_id, _)| vfs.exists(file_id)); let exists_after = content.is_some(); let has_structure_change = existed != exists_after; - let open_file_id = existing.is_some_and(|(file_id, _)| { - self.analysis.mem_docs.contains_file_id(file_id) - }); + let open_file_id = existing + .is_some_and(|(file_id, _)| self.analysis.mem_docs.contains_file_id(file_id)); if self.analysis.mem_docs.contains_path(&path) || open_file_id { continue; } diff --git a/src/global_state/process_changes.rs b/src/global_state/process_changes.rs index 876fc6af..4d12ad0f 100644 --- a/src/global_state/process_changes.rs +++ b/src/global_state/process_changes.rs @@ -279,7 +279,11 @@ impl GlobalState { match (change, *just_created, changed_file.change) { (change, _, Delete) => *change = Delete, - (Create(prev, prev_hash), _, Create(new, new_hash) | Modify(new, new_hash)) => { + ( + Create(prev, prev_hash), + _, + Create(new, new_hash) | Modify(new, new_hash), + ) => { *prev = new; *prev_hash = new_hash; } @@ -317,9 +321,7 @@ impl GlobalState { let changed_file = file_changes .into_iter() - .filter(|(_, (change, just_created))| { - !(*just_created && matches!(change, Delete)) - }) + .filter(|(_, (change, just_created))| !(*just_created && matches!(change, Delete))) .map(|(file_id, (change, _))| ChangedFile { file_id, change }) .collect_vec(); @@ -422,11 +424,7 @@ mod tests { fn vfs_manifest_message(manifest_path: AbsPathBuf, changed: bool) -> VfsMessage { let files = vec![(manifest_path, Some(b"[project]\n".to_vec()))]; - if changed { - VfsMessage::Changed { files } - } else { - VfsMessage::Loaded { files } - } + if changed { VfsMessage::Changed { files } } else { VfsMessage::Loaded { files } } } #[test] @@ -436,10 +434,12 @@ mod tests { let mut state = test_state(root_path); let file_path = root.join("top.sv"); - state.workspace.vfs.write().0.set_file_contents( - VfsPath::from(file_path), - Some(b"module top; endmodule\n".to_vec()), - ); + state + .workspace + .vfs + .write() + .0 + .set_file_contents(VfsPath::from(file_path), Some(b"module top; endmodule\n".to_vec())); assert!(state.process_changes()); assert!( diff --git a/src/global_state/snapshot.rs b/src/global_state/snapshot.rs index 28f0a377..27de92a6 100644 --- a/src/global_state/snapshot.rs +++ b/src/global_state/snapshot.rs @@ -113,10 +113,7 @@ impl GlobalStateSnapshot { pub(crate) fn line_info(&self, file_id: FileId) -> anyhow::Result { let ending = { let vfs = self.vfs.read(); - vfs.1 - .get(&file_id) - .copied() - .unwrap_or(utils::lines::LineEnding::Unix) + vfs.1.get(&file_id).copied().unwrap_or(utils::lines::LineEnding::Unix) }; let index = self.analysis.line_index(file_id)?; let encoding = self.config.position_encoding(); @@ -514,8 +511,8 @@ impl GlobalStateSnapshot { mod tests { use lsp_server::Connection; use lsp_types::{ClientCapabilities, TraceValue}; - use utils::{test_support::TestDir}; - use vfs::{FileId, VfsPath, /*removed*/}; + use utils::test_support::TestDir; + use vfs::{FileId, VfsPath /* removed */}; use crate::{ Opt, From 7971c449aed69f11d43284c26f5281a907bd1b56 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:26:13 +0800 Subject: [PATCH 10/13] refactor(vfs): align notify 8, dummy loader, exclude prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue r-a convergence on the request-readiness branch: - Bump `notify` 6 → 8.2 (rust-analyzer pin) - Add `dummy::DummyHandle` for builds without OS watching - Feature `server-file-watcher` (default on); wasm uses `vide` with default-features off so notify is not linked - Expand manifest exclude globs to directory prefixes in project-model; remove `exclude_globs` from loader::Directories - Keep FileSet PathMatcher for source-root classification --- Cargo.toml | 3 ++ crates/project-model/src/lib.rs | 53 ++++++++++++++++++++++++++-- crates/vfs/Cargo.toml | 2 +- crates/vfs/UPSTREAM.md | 5 +-- crates/vfs/src/dummy.rs | 61 +++++++++++++++++++++++++++++++++ crates/vfs/src/lib.rs | 1 + crates/vfs/src/loader.rs | 24 +++---------- crates/vide-lsp-wasm/Cargo.toml | 3 +- src/global_state.rs | 20 +++++++++-- 9 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 crates/vfs/src/dummy.rs diff --git a/Cargo.toml b/Cargo.toml index afbe6d01..3814a361 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,5 +137,8 @@ insta.workspace = true utils = { workspace = true, features = ["test-support"] } [features] +default = ["server-file-watcher"] +# OS notify-based server file watching (disable for wasm / headless client-only). +server-file-watcher = ["vfs/notify-backend"] profile-trace = ["dep:tracing-chrome"] user-config-schema = ["dep:schemars"] diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index 313c1c7d..d87e2469 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -62,6 +62,10 @@ pub struct WorkspaceRoot { pub extra_files: Vec, /// Include/search roots loaded as headers and passed to preprocessing. pub include_dirs: Vec, + /// Absolute directory prefixes excluded from directory loads (expanded from + /// manifest exclude globs before the loader sees them). + pub exclude_prefixes: Vec, + /// Glob matcher retained for FileSet classification of opened files. pub exclude_globs: Option, } @@ -192,6 +196,7 @@ impl Workspace { let source_policy = ManifestSourcePolicy::from_manifest(source_patterns); let source_patterns = validate_manifest_patterns(source_policy.patterns(), "sources")?; let exclude_patterns = validate_manifest_patterns(exclude_patterns, "exclude")?; + let exclude_prefixes = exclude_prefixes_from_patterns(&workspace_root, &exclude_patterns); let exclude_globs = compile_manifest_globs(&workspace_root, exclude_patterns, "exclude")?; let source_locations = source_locations_for_patterns(&workspace_root, &source_patterns); @@ -224,6 +229,7 @@ impl Workspace { source_files: source_locations.source_files, extra_files: vec![manifest_path.clone()], include_dirs: include_dirs.clone(), + exclude_prefixes, exclude_globs, }; let roots = workspace_roots(kind, &source_policy, has_source_paths, root_parts); @@ -246,6 +252,7 @@ impl Workspace { source_files: Vec::new(), extra_files: Vec::new(), include_dirs: include_dirs.clone(), + exclude_prefixes: Vec::new(), exclude_globs: None, }; let roots = workspace_roots(kind, &ManifestSourcePolicy::DefaultIndex, true, root_parts); @@ -308,6 +315,7 @@ struct WorkspaceRootParts { source_files: Vec, extra_files: Vec, include_dirs: Vec, + exclude_prefixes: Vec, exclude_globs: Option, } @@ -319,6 +327,7 @@ impl WorkspaceRootParts { source_files: Vec::new(), extra_files: self.extra_files.clone(), include_dirs: self.include_dirs.clone(), + exclude_prefixes: self.exclude_prefixes.clone(), exclude_globs: self.exclude_globs.clone(), } } @@ -330,6 +339,7 @@ impl WorkspaceRootParts { source_files: self.source_files.clone(), extra_files: Vec::new(), include_dirs: Vec::new(), + exclude_prefixes: self.exclude_prefixes.clone(), exclude_globs: self.exclude_globs.clone(), } } @@ -379,6 +389,7 @@ fn push_workspace_root( source_files: parts.source_files, extra_files: parts.extra_files, include_dirs: parts.include_dirs, + exclude_prefixes: parts.exclude_prefixes, exclude_globs: parts.exclude_globs, }; if root.has_load_paths() { @@ -448,6 +459,36 @@ fn compile_manifest_globs( .with_context(|| format!("failed to compile manifest {field} glob patterns")) } +/// Expand exclude globs into absolute directory prefixes for the loader. +/// +/// Patterns that are pure wildcards (e.g. `**/*.sv`) are skipped here; those +/// still participate in FileSet classification via +/// [`WorkspaceRoot::exclude_globs`]. +fn exclude_prefixes_from_patterns( + workspace_root: &AbsPathBuf, + patterns: &[String], +) -> Vec { + let mut out = Vec::new(); + for pattern in patterns { + let mut p = pattern.as_str(); + while let Some(stripped) = p.strip_suffix("/**") { + p = stripped; + } + while let Some(stripped) = p.strip_suffix("/*") { + p = stripped; + } + while let Some(stripped) = p.strip_suffix('/') { + p = stripped; + } + if p.is_empty() || p.contains(['*', '?', '[']) { + continue; + } + out.push(workspace_root.join(p)); + } + sort_and_remove_subfolders(&mut out); + out +} + /// Normalized manifest source-pattern facts, separated by how each consumer /// uses them. #[derive(Debug, Default)] @@ -623,6 +664,10 @@ pub fn get_workspace_folder( exclude_paths.push(excl.clone()); } } + // Manifest exclude globs are expanded to directory prefixes before + // the loader (r-a-shaped Directories has no glob field). + exclude_paths.extend(root.exclude_prefixes.iter().cloned()); + sort_and_remove_subfolders(&mut exclude_paths); let mut include = Vec::new(); if !root.include_dirs.is_empty() { include.push(PathMatcher::all_under_roots(root.include_dirs.clone())); @@ -667,7 +712,6 @@ pub fn get_workspace_folder( extensions: source_file_extensions(), include: directory_include, exclude: exclude_paths.clone(), - exclude_globs: root.exclude_globs.clone(), }; load_entries.push(vfs::loader::Entry::Directories(dirs)); } @@ -1361,12 +1405,17 @@ exclude = ["**/*_bb.v"] let (load, _, source_root_config, _) = get_workspace_folder(&model.workspaces, &[]); assert!(errors.is_empty(), "{errors:#?}"); + // Filename globs like `**/*_bb.v` cannot be expressed as loader directory + // prefixes (r-a-shaped Directories). They still classify via FileSet. let dirs = match &load[0] { vfs::loader::Entry::Directories(dirs) => dirs, other => panic!("expected directory loader entry, got {other:?}"), }; assert!(dirs.contains_file(top.as_path())); - assert!(!dirs.contains_file(blackbox.as_path())); + assert!( + dirs.contains_file(blackbox.as_path()), + "loader may still discover filename-glob matches; FileSet drops them" + ); let mut vfs = Vfs::default(); for file in [&top, &blackbox] { diff --git a/crates/vfs/Cargo.toml b/crates/vfs/Cargo.toml index d0f0e14d..0695e0bc 100644 --- a/crates/vfs/Cargo.toml +++ b/crates/vfs/Cargo.toml @@ -14,7 +14,7 @@ fst = "0.4.7" globset.workspace = true indexmap = { version = "2.0.0", features = [] } nohash-hasher.workspace = true -notify = { version = "6.1.1", optional = true } +notify = { version = "8.2.0", optional = true } rayon = { version = "1.10.0", optional = true } rustc-hash.workspace = true tracing.workspace = true diff --git a/crates/vfs/UPSTREAM.md b/crates/vfs/UPSTREAM.md index 2f7269df..001759de 100644 --- a/crates/vfs/UPSTREAM.md +++ b/crates/vfs/UPSTREAM.md @@ -18,9 +18,10 @@ same approach as tinymist-vfs: copy upstream code, adapt to vide, do not track ## Intentional fork deltas (vide) - Map `paths` / `stdx` onto `utils` instead of rust-analyzer workspace crates -- Feature-gate notify backend for wasm (`notify-backend`) +- Feature-gate OS notify backend (`notify-backend`); `dummy::DummyHandle` when disabled (wasm) - No VFS hardlink / path-identity redirects (removed relative to prior vide Vfs) -- SV-oriented loader configuration is supplied by callers (extensions, roots) +- SV-oriented loader configuration is supplied by callers (extensions, exclude prefixes) +- FileSet keeps PathMatcher / source filters for workspace source roots ## Sync policy diff --git a/crates/vfs/src/dummy.rs b/crates/vfs/src/dummy.rs new file mode 100644 index 00000000..fb2aa5fb --- /dev/null +++ b/crates/vfs/src/dummy.rs @@ -0,0 +1,61 @@ +//! In-memory / no-OS loader used when the notify backend is disabled (e.g. +//! wasm). +//! +//! Does not scan the real filesystem or install watchers. Configuration scans +//! complete immediately with empty file batches so the host can still drive +//! readiness from Progress. File contents must come from the client (mem docs) +//! or explicit `invalidate` / `load_sync` against whatever FS the host +//! provides. + +use std::fmt; + +use crossbeam_channel::Sender; +use utils::paths::{AbsPath, AbsPathBuf}; + +use crate::loader::{self, LoadingProgress}; + +pub struct DummyHandle { + sender: Sender, +} + +impl loader::Handle for DummyHandle { + fn spawn(sender: loader::Sender) -> Self { + Self { sender } + } + + fn set_config(&mut self, config: loader::Config) { + let config_version = config.version; + let n_total = config.load.len(); + let _ = self.sender.send(loader::Message::Progress { + n_total, + n_done: LoadingProgress::Started, + dir: None, + config_version, + }); + // No disk scan: report empty loads so Progress can finish. + for _ in &config.load { + let _ = self.sender.send(loader::Message::Loaded { files: Vec::new() }); + } + let _ = self.sender.send(loader::Message::Progress { + n_total, + n_done: LoadingProgress::Finished, + dir: None, + config_version, + }); + } + + fn invalidate(&mut self, path: AbsPathBuf) { + let contents = std::fs::read(path.as_path()).ok(); + let _ = self.sender.send(loader::Message::Changed { files: vec![(path, contents)] }); + } + + fn load_sync(&mut self, path: &AbsPath) -> Option> { + std::fs::read(path).ok() + } +} + +impl fmt::Debug for DummyHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DummyHandle").finish_non_exhaustive() + } +} diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 0afa9a35..5c0d0249 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -45,6 +45,7 @@ //! [`Entries`]: loader::Entry mod anchored_path; +pub mod dummy; pub mod file_set; pub mod loader; #[cfg(feature = "notify-backend")] diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index eb32c017..bac44220 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -1,13 +1,12 @@ //! Dynamically compatible interface for file watching and reading. //! -//! Owned fork of rust-analyzer `vfs::loader` with vide deltas for SystemVerilog -//! extensions and optional exclude globs. +//! Owned fork of rust-analyzer `vfs::loader`. SystemVerilog extensions and +//! exclude prefixes are supplied by callers (`project-model`); glob expansion +//! happens before `set_config`, not inside the loader. use std::fmt; use utils::paths::{AbsPath, AbsPathBuf}; -use crate::PathGlobMatcher; - /// File extensions loaded from recursive directory entries for SystemVerilog. pub const SOURCE_FILE_EXTENSIONS: &[&str] = &["v", "sv", "vh", "svh", "svi", "map"]; @@ -30,14 +29,11 @@ pub enum Entry { /// If many include/exclude paths match, the longest one wins. /// /// If a path is in both `include` and `exclude`, the `exclude` one wins. -/// -/// `exclude_globs` is a vide-specific extension for project-config globs. #[derive(Debug, Clone, Default)] pub struct Directories { pub extensions: Vec, pub include: Vec, pub exclude: Vec, - pub exclude_globs: Option, } /// [`Handle`]'s configuration. @@ -190,7 +186,6 @@ impl Directories { /// - An element in `self.include` is a prefix of `path`. /// - This path is longer than any element in `self.exclude` that is a /// prefix of `path`. In case of equality, exclusion wins. - /// - Optional `exclude_globs` does not match (vide extension). fn includes_path(&self, path: &AbsPath) -> bool { let mut include: Option<&AbsPathBuf> = None; for incl in &self.include { @@ -207,11 +202,7 @@ impl Directories { None => return false, }; - if self.exclude.iter().any(|excl| path.starts_with(excl) && excl.starts_with(include)) { - return false; - } - - !self.exclude_globs.as_ref().is_some_and(|globs| globs.is_match(path)) + !self.exclude.iter().any(|excl| path.starts_with(excl) && excl.starts_with(include)) } } @@ -225,12 +216,7 @@ impl Directories { /// ``` fn dirs(base: AbsPathBuf, exclude: &[&str]) -> Directories { let exclude = exclude.iter().map(|it| base.join(it)).collect::>(); - Directories { - extensions: vec!["rs".to_owned()], - include: vec![base], - exclude, - exclude_globs: None, - } + Directories { extensions: vec!["rs".to_owned()], include: vec![base], exclude } } impl fmt::Debug for Message { diff --git a/crates/vide-lsp-wasm/Cargo.toml b/crates/vide-lsp-wasm/Cargo.toml index 1e8cac19..3b4fcdae 100644 --- a/crates/vide-lsp-wasm/Cargo.toml +++ b/crates/vide-lsp-wasm/Cargo.toml @@ -9,5 +9,6 @@ name = "vide-lsp-wasm" path = "src/main.rs" [dependencies] -vide = { path = "../.." } +# No server-side notify watcher under wasm/emscripten; client supplies files. +vide = { path = "../..", default-features = false } serde_json.workspace = true diff --git a/src/global_state.rs b/src/global_state.rs index 0de8d41b..208f3d9a 100644 --- a/src/global_state.rs +++ b/src/global_state.rs @@ -35,7 +35,7 @@ use project_model::Workspace; use rustc_hash::{FxHashMap, FxHashSet}; use triomphe::Arc; use utils::{cancellation::CancellationToken, excl_task::ExclTask, lines::LineEnding}; -use vfs::{self, FileId, Vfs, notify::NotifyHandle}; +use vfs::{self, FileId, Vfs}; #[cfg(test)] pub(crate) use self::workspace_state::VfsProgress; @@ -123,6 +123,21 @@ pub(crate) struct WorkspaceState { pub(crate) registered_client_file_watcher_globs: Option>, } +fn spawn_vfs_loader( + sender: crossbeam_channel::Sender, +) -> Box { + #[cfg(feature = "server-file-watcher")] + { + let handle: vfs::notify::NotifyHandle = vfs::loader::Handle::spawn(sender); + Box::new(handle) + } + #[cfg(not(feature = "server-file-watcher"))] + { + let handle: vfs::dummy::DummyHandle = vfs::loader::Handle::spawn(sender); + Box::new(handle) + } +} + impl GlobalState { pub(crate) fn new( sender: Sender, @@ -131,8 +146,7 @@ impl GlobalState { ) -> GlobalState { let vfs_loader = { let (sender, receiver) = unbounded::(); - let handle: NotifyHandle = vfs::loader::Handle::spawn(sender); - let handle = Box::new(handle) as Box; + let handle = spawn_vfs_loader(sender); Handle { handle, receiver } }; From f331165d48ec45fb5590c0855bd1b4eff4d07956 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:35:39 +0800 Subject: [PATCH 11/13] fix(project-model): expand shallow source globs before loader CI failed on `sources = ["rtl/*.sv"]`: after removing loader globs, recursive Directories under `rtl` incorrectly accepted nested files. - Expand non-`**` source globs to `Entry::Files` at config time - Keep `**` patterns as recursive directory loads with prefix excludes - Write files in the shell-separator semantics test so expansion works --- crates/project-model/src/lib.rs | 43 +++++++++++++++++++++----- crates/vfs/src/file_set.rs | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index d87e2469..c52a3a3d 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -700,10 +700,31 @@ pub fn get_workspace_folder( load_entries.push(vfs::loader::Entry::Files(source_files)); } - let mut directory_include = Vec::new(); - directory_include.extend(root.include_dirs.iter().cloned()); + // Recursive loads: include_dirs + pure root matchers. + // Glob source patterns (e.g. `rtl/*.sv`) are expanded to Entry::Files + // so the r-a-style loader does not need glob semantics. + let mut directory_include = root.include_dirs.clone(); if !root.source_directories.is_empty() { - directory_include.extend(root.source_directories.scan_roots().cloned()); + if root.source_directories.prefers_recursive_directory_load() { + directory_include.extend(root.source_directories.scan_roots().cloned()); + } else { + let matched = root + .source_directories + .collect_matching_files(vfs::loader::SOURCE_FILE_EXTENSIONS); + let matched = matched + .into_iter() + .filter(|path| { + !is_excluded_load_file( + path.as_path(), + &exclude_paths, + &root.exclude_globs, + ) + }) + .collect_vec(); + if !matched.is_empty() { + load_entries.push(vfs::loader::Entry::Files(matched)); + } + } } directory_include.sort(); directory_include.dedup(); @@ -1277,17 +1298,23 @@ include_dirs = [] let top = rtl.join("top.sv"); let nested_top = rtl.join("nested/top.sv"); + fs::write(&top, "module top; endmodule\n").unwrap(); + fs::write(&nested_top, "module nested; endmodule\n").unwrap(); let manifest = ProjectManifest::from_path(&root).unwrap(); let (model, errors) = ProjectModel::load(vec![manifest]); let (load, _, _, _) = get_workspace_folder(&model.workspaces, &[]); assert!(errors.is_empty(), "{errors:#?}"); - let dirs = match &load[0] { - vfs::loader::Entry::Directories(dirs) => dirs, - other => panic!("expected directory loader entry, got {other:?}"), + // Shallow globs expand to Entry::Files (loader has no glob depth rules). + let files = match load.iter().find_map(|entry| match entry { + vfs::loader::Entry::Files(files) => Some(files), + _ => None, + }) { + Some(files) => files, + None => panic!("expected file loader entry for rtl/*.sv, got {load:?}"), }; - assert!(dirs.contains_file(top.as_path())); - assert!(!dirs.contains_file(nested_top.as_path())); + assert!(files.iter().any(|path| path == &top)); + assert!(!files.iter().any(|path| path == &nested_top)); } #[test] diff --git a/crates/vfs/src/file_set.rs b/crates/vfs/src/file_set.rs index 3aa8065b..c62c70f1 100644 --- a/crates/vfs/src/file_set.rs +++ b/crates/vfs/src/file_set.rs @@ -126,11 +126,66 @@ impl PathMatcher { self.scan_roots.iter() } + /// Whether the loader may use recursive `Directories` for this matcher. + /// + /// - `AllUnderRoots` → yes + /// - Glob patterns that contain `**` (e.g. `rtl/**`) → yes (prefix roots) + /// - Shallow globs like `rtl/*.sv` → no; expand to `Entry::Files` instead + pub fn prefers_recursive_directory_load(&self) -> bool { + match &self.kind { + PathMatcherKind::AllUnderRoots => true, + PathMatcherKind::Glob(matcher) => { + matcher.patterns().iter().all(|pattern| pattern.contains("**")) + } + } + } + + /// Walk `scan_roots` and collect absolute files that match this matcher and + /// one of `extensions` (no leading dot). Used to expand shallow globs like + /// `rtl/*.sv` without putting glob logic inside the loader. + pub fn collect_matching_files(&self, extensions: &[&str]) -> Vec { + let mut files = Vec::new(); + for root in &self.scan_roots { + collect_files_under(root, &mut files); + } + files.retain(|path| { + let ext = path.extension().unwrap_or_default(); + extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)) + && self.contains_file(path.as_path()) + }); + files.sort(); + files.dedup(); + files + } + fn contains_scan_root(&self, path: &AbsPath) -> bool { self.scan_roots.iter().any(|root| path.starts_with(root)) } } +fn collect_files_under(root: &AbsPathBuf, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root.as_path()) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if let Ok(dir) = AbsPathBuf::try_from(path) { + collect_files_under(&dir, out); + } + continue; + } + if file_type.is_file() + && let Ok(file) = AbsPathBuf::try_from(path) + { + out.push(file); + } + } +} + /// Include/source/exclude policy for one file-set root. #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct FileSetFilter { From f833f340bb3b1c440e528bba7335f8b7a2e0ca41 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:49:35 +0800 Subject: [PATCH 12/13] chore(vfs): drop UPSTREAM.md Source attribution lives in module docs; a separate upstream pin file is not needed for this owned fork. --- crates/vfs/UPSTREAM.md | 30 ------------------------------ crates/vfs/src/lib.rs | 1 - 2 files changed, 31 deletions(-) delete mode 100644 crates/vfs/UPSTREAM.md diff --git a/crates/vfs/UPSTREAM.md b/crates/vfs/UPSTREAM.md deleted file mode 100644 index 001759de..00000000 --- a/crates/vfs/UPSTREAM.md +++ /dev/null @@ -1,30 +0,0 @@ -# Upstream: rust-analyzer VFS - -This crate is an owned fork of rust-analyzer's virtual file system, following the -same approach as tinymist-vfs: copy upstream code, adapt to vide, do not track -`ra_ap_*` crates.io packages. - -## Sources - -| Component | Upstream path | -|-----------|---------------| -| Core VFS / loader / FileSet | https://github.com/rust-lang/rust-analyzer/tree/master/crates/vfs | -| Notify loader backend | https://github.com/rust-lang/rust-analyzer/tree/master/crates/vfs-notify | - -- **Pinned commit:** `5300ee266534f8c68065285de005759c58ac7883` -- **Upstream licenses:** Apache-2.0 OR MIT (rust-analyzer) -- **Vide modifications:** owned by PASCAL Research Group / vide contributors (MIT) - -## Intentional fork deltas (vide) - -- Map `paths` / `stdx` onto `utils` instead of rust-analyzer workspace crates -- Feature-gate OS notify backend (`notify-backend`); `dummy::DummyHandle` when disabled (wasm) -- No VFS hardlink / path-identity redirects (removed relative to prior vide Vfs) -- SV-oriented loader configuration is supplied by callers (extensions, exclude prefixes) -- FileSet keeps PathMatcher / source filters for workspace source roots - -## Sync policy - -Manual only. Prefer pulling structural bugfixes from rust-analyzer; do **not** -reintroduce watcher completeness state machines or generation-scoped readiness -coupled to OS notify. diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 5c0d0249..ad504175 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -1,7 +1,6 @@ //! Owned fork of rust-analyzer's virtual file system (tinymist-style). //! //! Upstream: -//! See `UPSTREAM.md` for the pinned commit and license notes. //! //! # Virtual File System //! From 28d7da4a7032cad731e975b56be657a829fd2574 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 15 Jul 2026 19:54:57 +0800 Subject: [PATCH 13/13] chore: trim verbose VFS and host comments Keep short module docs and non-obvious behavior notes; drop fork narratives, outdated identity comments, and redundant restatements. --- Cargo.toml | 2 +- crates/project-model/src/lib.rs | 26 +++---------- crates/vfs/src/dummy.rs | 10 +---- crates/vfs/src/file_set.rs | 39 ++++---------------- crates/vfs/src/lib.rs | 57 ++++------------------------- crates/vfs/src/loader.rs | 8 +--- crates/vfs/src/notify.rs | 13 +------ crates/vfs/src/vfs_path.rs | 1 - src/global_state/event_loop.rs | 5 +-- src/global_state/main_loop.rs | 2 - src/global_state/workspace_state.rs | 2 - 11 files changed, 27 insertions(+), 138 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3814a361..00971354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,7 +138,7 @@ utils = { workspace = true, features = ["test-support"] } [features] default = ["server-file-watcher"] -# OS notify-based server file watching (disable for wasm / headless client-only). +# Server OS file watching (off for wasm). server-file-watcher = ["vfs/notify-backend"] profile-trace = ["dep:tracing-chrome"] user-config-schema = ["dep:schemars"] diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index c52a3a3d..31129a7f 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -62,10 +62,10 @@ pub struct WorkspaceRoot { pub extra_files: Vec, /// Include/search roots loaded as headers and passed to preprocessing. pub include_dirs: Vec, - /// Absolute directory prefixes excluded from directory loads (expanded from - /// manifest exclude globs before the loader sees them). + /// Exclude directory prefixes for loader `Directories` (from manifest + /// globs). pub exclude_prefixes: Vec, - /// Glob matcher retained for FileSet classification of opened files. + /// Exclude globs for FileSet classification. pub exclude_globs: Option, } @@ -459,11 +459,8 @@ fn compile_manifest_globs( .with_context(|| format!("failed to compile manifest {field} glob patterns")) } -/// Expand exclude globs into absolute directory prefixes for the loader. -/// -/// Patterns that are pure wildcards (e.g. `**/*.sv`) are skipped here; those -/// still participate in FileSet classification via -/// [`WorkspaceRoot::exclude_globs`]. +/// Expand exclude globs to directory prefixes; skip pure wildcards (e.g. +/// `**/*.sv`). fn exclude_prefixes_from_patterns( workspace_root: &AbsPathBuf, patterns: &[String], @@ -664,8 +661,6 @@ pub fn get_workspace_folder( exclude_paths.push(excl.clone()); } } - // Manifest exclude globs are expanded to directory prefixes before - // the loader (r-a-shaped Directories has no glob field). exclude_paths.extend(root.exclude_prefixes.iter().cloned()); sort_and_remove_subfolders(&mut exclude_paths); let mut include = Vec::new(); @@ -700,9 +695,6 @@ pub fn get_workspace_folder( load_entries.push(vfs::loader::Entry::Files(source_files)); } - // Recursive loads: include_dirs + pure root matchers. - // Glob source patterns (e.g. `rtl/*.sv`) are expanded to Entry::Files - // so the r-a-style loader does not need glob semantics. let mut directory_include = root.include_dirs.clone(); if !root.source_directories.is_empty() { if root.source_directories.prefers_recursive_directory_load() { @@ -1305,7 +1297,6 @@ include_dirs = [] let (load, _, _, _) = get_workspace_folder(&model.workspaces, &[]); assert!(errors.is_empty(), "{errors:#?}"); - // Shallow globs expand to Entry::Files (loader has no glob depth rules). let files = match load.iter().find_map(|entry| match entry { vfs::loader::Entry::Files(files) => Some(files), _ => None, @@ -1432,17 +1423,12 @@ exclude = ["**/*_bb.v"] let (load, _, source_root_config, _) = get_workspace_folder(&model.workspaces, &[]); assert!(errors.is_empty(), "{errors:#?}"); - // Filename globs like `**/*_bb.v` cannot be expressed as loader directory - // prefixes (r-a-shaped Directories). They still classify via FileSet. let dirs = match &load[0] { vfs::loader::Entry::Directories(dirs) => dirs, other => panic!("expected directory loader entry, got {other:?}"), }; assert!(dirs.contains_file(top.as_path())); - assert!( - dirs.contains_file(blackbox.as_path()), - "loader may still discover filename-glob matches; FileSet drops them" - ); + assert!(dirs.contains_file(blackbox.as_path())); let mut vfs = Vfs::default(); for file in [&top, &blackbox] { diff --git a/crates/vfs/src/dummy.rs b/crates/vfs/src/dummy.rs index fb2aa5fb..622d3e02 100644 --- a/crates/vfs/src/dummy.rs +++ b/crates/vfs/src/dummy.rs @@ -1,11 +1,4 @@ -//! In-memory / no-OS loader used when the notify backend is disabled (e.g. -//! wasm). -//! -//! Does not scan the real filesystem or install watchers. Configuration scans -//! complete immediately with empty file batches so the host can still drive -//! readiness from Progress. File contents must come from the client (mem docs) -//! or explicit `invalidate` / `load_sync` against whatever FS the host -//! provides. +//! Loader with no OS file watching (e.g. wasm). use std::fmt; @@ -32,7 +25,6 @@ impl loader::Handle for DummyHandle { dir: None, config_version, }); - // No disk scan: report empty loads so Progress can finish. for _ in &config.load { let _ = self.sender.send(loader::Message::Loaded { files: Vec::new() }); } diff --git a/crates/vfs/src/file_set.rs b/crates/vfs/src/file_set.rs index c62c70f1..e1153f29 100644 --- a/crates/vfs/src/file_set.rs +++ b/crates/vfs/src/file_set.rs @@ -5,11 +5,7 @@ use utils::paths::{AbsPath, AbsPathBuf}; use crate::{AnchoredPath, FileId, Vfs, VfsPath, path_glob::PathGlobMatcher}; -/// Bidirectional path map for a single source root. -/// -/// A `FileSet` stores the path spelling selected during source-root -/// partitioning. That spelling may be a VFS alias rather than the primary path -/// if the alias is the one that belongs to this root. +/// Files belonging to one source root. #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct FileSet { files: FxHashMap, @@ -50,35 +46,23 @@ impl FileSet { } } -/// Rules for partitioning VFS files into ordered source roots. -/// -/// Root order is significant. For one VFS file with several aliases, -/// classification evaluates every alias and chooses the earliest non-ignored -/// root. Within a single alias, normal prefix matching still picks the most -/// specific configured root. +/// Partitions VFS files into ordered source roots (earlier roots win). #[derive(Debug)] pub struct FileSetConfig { - // Number of sets that can partition into. - // This should be `self.map.len() + 1` for files that don't fit in any defined set. + /// `map` size + 1 for files that match no root. len: usize, - // Encoded paths -> sets they belong to. map: fst::Map>, filters: Vec, } -/// A source-root partition plus the subset that matched semantic source rules. +/// One partition: all files in the root, plus optional semantic source subset. #[derive(Debug, Default, Clone, Eq, PartialEq)] pub struct PartitionedFileSet { pub file_set: FileSet, pub source_files: Option>, } -/// Matcher used separately for source classification, directory scans, and -/// include/search roots. -/// -/// Keeping those roles separate lets exact source files participate in source -/// ownership without forcing the loader or watcher to scan their parent -/// directories recursively. +/// Path include rule: recursive roots and/or a glob. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct PathMatcher { scan_roots: Vec, @@ -126,11 +110,8 @@ impl PathMatcher { self.scan_roots.iter() } - /// Whether the loader may use recursive `Directories` for this matcher. - /// - /// - `AllUnderRoots` → yes - /// - Glob patterns that contain `**` (e.g. `rtl/**`) → yes (prefix roots) - /// - Shallow globs like `rtl/*.sv` → no; expand to `Entry::Files` instead + /// True if this can be loaded as recursive directory roots (`**` globs or + /// plain roots). pub fn prefers_recursive_directory_load(&self) -> bool { match &self.kind { PathMatcherKind::AllUnderRoots => true, @@ -140,9 +121,7 @@ impl PathMatcher { } } - /// Walk `scan_roots` and collect absolute files that match this matcher and - /// one of `extensions` (no leading dot). Used to expand shallow globs like - /// `rtl/*.sv` without putting glob logic inside the loader. + /// Files under `scan_roots` matching this rule and `extensions`. pub fn collect_matching_files(&self, extensions: &[&str]) -> Vec { let mut files = Vec::new(); for root in &self.scan_roots { @@ -265,8 +244,6 @@ impl FileSetConfig { primary_path: &'a VfsPath, scratch_space: &mut Vec, ) -> (usize, &'a VfsPath) { - // Path identity aliases were removed with the r-a VFS fork; classify the - // primary interned spelling only. (self.classify(primary_path, scratch_space), primary_path) } diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index ad504175..9a9748ea 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -1,47 +1,12 @@ -//! Owned fork of rust-analyzer's virtual file system (tinymist-style). +//! Virtual file system: interned paths, content change log, and loader +//! interface. //! -//! Upstream: -//! -//! # Virtual File System -//! -//! VFS records all file changes pushed to it via [`set_file_contents`]. -//! As such it only ever stores changes, not the actual content of a file at any -//! given moment. All file changes are logged, and can be retrieved via -//! [`take_changes`] method. The pack of changes is then pushed to `salsa` and -//! triggers incremental recomputation. -//! -//! Files in VFS are identified with [`FileId`]s -- interned paths. The notion -//! of the path, [`VfsPath`] is somewhat abstract: at the moment, it is -//! represented as an [`std::path::PathBuf`] internally, but this is an -//! implementation detail. -//! -//! VFS doesn't do IO or file watching itself. For that, see the [`loader`] -//! module. [`loader::Handle`] is an object-safe trait which abstracts both file -//! loading and file watching. [`Handle`] is dynamically configured with a set -//! of directory entries which should be scanned and watched. [`Handle`] then -//! asynchronously pushes file changes. Directory entries are configured in -//! free-form via list of globs, it's up to the [`Handle`] to interpret the -//! globs in any specific way. -//! -//! VFS stores a flat list of files. [`file_set::FileSet`] can partition this -//! list of files into disjoint sets of files. Traversal-like operations -//! (including getting the neighbor file by the relative path) are handled by -//! the [`FileSet`]. [`FileSet`]s are also pushed to salsa and cause it to -//! re-check `mod foo;` declarations when files are created or deleted. -//! -//! [`FileSet`] and [`loader::Entry`] play similar, but different roles. -//! Both specify the "set of paths/files", one is geared towards file watching, -//! the other towards salsa changes. In particular, single [`FileSet`] -//! may correspond to several [`loader::Entry`]. For example, a crate from -//! crates.io which uses code generation would have two [`Entries`] -- for -//! sources in `~/.cargo`, and for generated code in `./target/debug/build`. It -//! will have a single [`FileSet`] which unions the two sources. +//! Based on rust-analyzer's `vfs` crate +//! (). +//! IO and watching live in [`loader`] backends ([`notify`], [`dummy`]). //! //! [`set_file_contents`]: Vfs::set_file_contents //! [`take_changes`]: Vfs::take_changes -//! [`FileSet`]: file_set::FileSet -//! [`Handle`]: loader::Handle -//! [`Entries`]: loader::Entry mod anchored_path; pub mod dummy; @@ -81,12 +46,9 @@ fn hash_once(thing: impl std::hash::Hash) - h.finish() } -/// Handle to a file in [`Vfs`] -/// -/// Most functions in rust-analyzer use this when they need to refer to a file. +/// Interned file identity in [`Vfs`]. #[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] pub struct FileId(u32); -// pub struct FileId(NonMaxU32); impl FileId { const MAX: u32 = 0x7fff_ffff; @@ -166,7 +128,7 @@ impl ChangedFile { } } - /// UTF-8 file text for analysis; invalid UTF-8 becomes empty. + /// UTF-8 text; invalid UTF-8 becomes empty. pub fn text(&self) -> Option> { let bytes = match &self.change { Change::Create(bytes, _) | Change::Modify(bytes, _) => bytes.as_slice(), @@ -176,7 +138,6 @@ impl ChangedFile { Some(triomphe::Arc::::from(text)) } - /// Bytes for Create/Modify; `None` for Delete. pub fn contents(&self) -> Option<&[u8]> { match &self.change { Change::Create(bytes, _) | Change::Modify(bytes, _) => Some(bytes.as_slice()), @@ -184,21 +145,18 @@ impl ChangedFile { } } - /// Test/host helper: create a file with UTF-8 text contents. pub fn create(file_id: FileId, text: impl AsRef) -> Self { let bytes = text.as_ref().as_bytes().to_vec(); let hash = hash_once::(&*bytes); Self { file_id, change: Change::Create(bytes, hash) } } - /// Test/host helper: modify a file with UTF-8 text contents. pub fn modify(file_id: FileId, text: impl AsRef) -> Self { let bytes = text.as_ref().as_bytes().to_vec(); let hash = hash_once::(&*bytes); Self { file_id, change: Change::Modify(bytes, hash) } } - /// Test/host helper: delete a file. pub fn delete(file_id: FileId) -> Self { Self { file_id, change: Change::Delete } } @@ -339,7 +297,6 @@ impl Vfs { } // shouldn't occur, but collapse into `Create` (change @ Delete, Modify(new, new_hash)) => { - // Shouldn't occur; collapse into Create like upstream. *change = Create(new, new_hash); } // shouldn't occur, but keep the Create diff --git a/crates/vfs/src/loader.rs b/crates/vfs/src/loader.rs index bac44220..e93519cf 100644 --- a/crates/vfs/src/loader.rs +++ b/crates/vfs/src/loader.rs @@ -1,13 +1,9 @@ -//! Dynamically compatible interface for file watching and reading. -//! -//! Owned fork of rust-analyzer `vfs::loader`. SystemVerilog extensions and -//! exclude prefixes are supplied by callers (`project-model`); glob expansion -//! happens before `set_config`, not inside the loader. +//! File loading and watching interface used by the VFS. use std::fmt; use utils::paths::{AbsPath, AbsPathBuf}; -/// File extensions loaded from recursive directory entries for SystemVerilog. +/// Extensions for recursive directory loads (SystemVerilog sources). pub const SOURCE_FILE_EXTENSIONS: &[&str] = &["v", "sv", "vh", "svh", "svi", "map"]; /// A set of files on the file system. diff --git a/crates/vfs/src/notify.rs b/crates/vfs/src/notify.rs index 236f569e..300075a5 100644 --- a/crates/vfs/src/notify.rs +++ b/crates/vfs/src/notify.rs @@ -1,15 +1,4 @@ -//! Owned fork of rust-analyzer `vfs-notify`. -//! -//! Upstream: -//! -//! An implementation of `loader::Handle`, based on `walkdir` and `notify`. -//! -//! The file watching bits here are untested and quite probably buggy. For this -//! reason, by default we don't watch files and rely on editor's file watching -//! capabilities. -//! -//! Hopefully, one day a reliable file watching/walking crate appears on -//! crates.io, and we can reduce this to trivial glue code. +//! `loader::Handle` backed by `walkdir` and OS `notify` (best-effort). use std::{ fs, diff --git a/crates/vfs/src/vfs_path.rs b/crates/vfs/src/vfs_path.rs index 0f325ee3..e122d0c4 100644 --- a/crates/vfs/src/vfs_path.rs +++ b/crates/vfs/src/vfs_path.rs @@ -40,7 +40,6 @@ impl VfsPath { } } - /// Alias kept for vide call sites (rust-analyzer name is `as_path`). pub fn as_abs_path(&self) -> Option<&AbsPath> { self.as_path() } diff --git a/src/global_state/event_loop.rs b/src/global_state/event_loop.rs index aabf04b1..50395263 100644 --- a/src/global_state/event_loop.rs +++ b/src/global_state/event_loop.rs @@ -371,8 +371,7 @@ impl GlobalState { return; }; - // Empty loads still flip readiness via Finished Progress, but do not - // spam the client with a 0/0 work-done sequence. + // Ready already updated; skip empty 0/0 client progress. if progress.n_total == 0 { return; } @@ -396,8 +395,6 @@ impl GlobalState { } } } - // rust-analyzer style: content batches have no config_version; only - // Progress gates generation readiness. vfs_loader::Message::Loaded { files } => { self.process_vfs_files(files, VfsFileSource::ConfigScan); } diff --git a/src/global_state/main_loop.rs b/src/global_state/main_loop.rs index 1014c700..f6a64cbd 100644 --- a/src/global_state/main_loop.rs +++ b/src/global_state/main_loop.rs @@ -318,8 +318,6 @@ mod tests { #[test] fn content_batches_apply_without_generation_token() { - // rust-analyzer style: Loaded/Changed carry no config_version. Generation - // readiness is gated only by Progress; content batches always apply. let root = TestDir::new("content-batches-apply"); let root_path = root.path().to_path_buf(); let file_path = root_path.join("file.sv"); diff --git a/src/global_state/workspace_state.rs b/src/global_state/workspace_state.rs index c272d6d1..2534d64a 100644 --- a/src/global_state/workspace_state.rs +++ b/src/global_state/workspace_state.rs @@ -153,8 +153,6 @@ impl WorkspaceVfsReadiness { } self.vfs_progress = VfsProgress { config_version, n_done, n_total }; - // rust-analyzer style: generation is ready when Progress reports Finished - // (or empty total with Finished), not when content batches carry a version. self.vfs_ready = finished || (n_total == 0 && n_done == 0); if finished { self.vfs_progress.n_done = n_total;