diff --git a/crates/biome_cli/src/execute/migrate.rs b/crates/biome_cli/src/execute/migrate.rs index c6fb7ccb50e5..1c3c3dd8dd91 100644 --- a/crates/biome_cli/src/execute/migrate.rs +++ b/crates/biome_cli/src/execute/migrate.rs @@ -71,6 +71,7 @@ pub(crate) fn run(migrate_payload: MigratePayload) -> Result<(), CliDiagnostic> content: FileContent::FromClient(biome_config_content.to_string()), version: 0, document_file_source: Some(JsonFileSource::json().into()), + persist_node_cache: false, })?; let parsed = parse_json_with_cache( &biome_config_content, diff --git a/crates/biome_cli/src/execute/mod.rs b/crates/biome_cli/src/execute/mod.rs index e1fef9360e7d..c4f04ca0833b 100644 --- a/crates/biome_cli/src/execute/mod.rs +++ b/crates/biome_cli/src/execute/mod.rs @@ -544,6 +544,7 @@ pub fn execute_mode( path: report_file.clone(), version: 0, document_file_source: None, + persist_node_cache: false, })?; let code = session.app.workspace.format_file(FormatFileParams { path: report_file.clone(), diff --git a/crates/biome_cli/src/execute/process_file/workspace_file.rs b/crates/biome_cli/src/execute/process_file/workspace_file.rs index e7f1f4dac555..62335ae7ddab 100644 --- a/crates/biome_cli/src/execute/process_file/workspace_file.rs +++ b/crates/biome_cli/src/execute/process_file/workspace_file.rs @@ -41,6 +41,7 @@ impl<'ctx, 'app> WorkspaceFile<'ctx, 'app> { path: biome_path, version: 0, content: FileContent::FromClient(input.clone()), + persist_node_cache: false, }, ) .with_file_path_and_code(path.display().to_string(), category!("internalError/fs"))?; diff --git a/crates/biome_cli/src/execute/std_in.rs b/crates/biome_cli/src/execute/std_in.rs index 54086c30171d..923d2697f8ab 100644 --- a/crates/biome_cli/src/execute/std_in.rs +++ b/crates/biome_cli/src/execute/std_in.rs @@ -50,6 +50,7 @@ pub(crate) fn run<'a>( version: 0, content: FileContent::FromClient(content.into()), document_file_source: None, + persist_node_cache: false, })?; let printed = workspace.format_file(FormatFileParams { path: biome_path.clone(), @@ -82,6 +83,7 @@ pub(crate) fn run<'a>( version: 0, content: FileContent::FromClient(content.into()), document_file_source: None, + persist_node_cache: false, })?; // apply fix file of the linter let file_features = workspace.file_features(SupportsFeatureParams { diff --git a/crates/biome_fs/src/path.rs b/crates/biome_fs/src/path.rs index a6bc6bc49f56..c67d97af48ee 100644 --- a/crates/biome_fs/src/path.rs +++ b/crates/biome_fs/src/path.rs @@ -193,6 +193,10 @@ impl BiomePath { pub fn is_to_inspect(&self) -> bool { self.kind.contains(FileKind::Inspectable) } + + pub fn to_path_buf(&self) -> PathBuf { + self.path.clone() + } } #[cfg(feature = "serde")] @@ -214,6 +218,12 @@ impl Deref for BiomePath { } } +impl From for PathBuf { + fn from(path: BiomePath) -> Self { + path.path + } +} + impl PartialOrd for BiomePath { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) diff --git a/crates/biome_lsp/src/handlers/text_document.rs b/crates/biome_lsp/src/handlers/text_document.rs index 741e663777d7..b7e6f6fcb4f9 100644 --- a/crates/biome_lsp/src/handlers/text_document.rs +++ b/crates/biome_lsp/src/handlers/text_document.rs @@ -34,6 +34,7 @@ pub(crate) async fn did_open( version, content: FileContent::FromClient(content), document_file_source: Some(language_hint), + persist_node_cache: true, })?; session.insert_document(url.clone(), doc); diff --git a/crates/biome_lsp/src/server.rs b/crates/biome_lsp/src/server.rs index 0098c3eaeef4..cada75cc091c 100644 --- a/crates/biome_lsp/src/server.rs +++ b/crates/biome_lsp/src/server.rs @@ -372,7 +372,9 @@ impl LanguageServer for LSPServer { let result = self .session .workspace - .unregister_project_folder(UnregisterProjectFolderParams { path: project_path }) + .unregister_project_folder(UnregisterProjectFolderParams { + path: project_path.into(), + }) .map_err(into_lsp_error); if let Err(err) = result { diff --git a/crates/biome_service/src/lib.rs b/crates/biome_service/src/lib.rs index 1387e8e06e9d..c71991e55274 100644 --- a/crates/biome_service/src/lib.rs +++ b/crates/biome_service/src/lib.rs @@ -2,7 +2,6 @@ pub mod documentation; pub mod file_handlers; pub mod matcher; -mod scanner; pub mod settings; pub mod workspace; diff --git a/crates/biome_service/src/settings.rs b/crates/biome_service/src/settings.rs index 688ac7f459da..409af147b7d0 100644 --- a/crates/biome_service/src/settings.rs +++ b/crates/biome_service/src/settings.rs @@ -65,10 +65,16 @@ pub struct WorkspaceSettings { } impl WorkspaceSettings { + /// Returns the key of the current project. pub fn get_current_project_key(&self) -> ProjectKey { *self.current_project.read().unwrap() } + /// Sets which project is the current one by its key. + pub fn set_current_project(&self, key: ProjectKey) { + *self.current_project.write().unwrap() = key; + } + /// Retrieves the settings of the current workspace folder pub fn get_current_settings(&self) -> Option { trace!("Current key {:?}", self.current_project); @@ -78,6 +84,15 @@ impl WorkspaceSettings { .map(|data| data.settings.clone()) } + /// Retrieves the files settings of the current workspace folder + pub fn get_current_files_settings(&self) -> Option { + trace!("Current key {:?}", self.current_project); + self.data + .pin() + .get(&self.get_current_project_key()) + .map(|data| data.settings.files.clone()) + } + /// Sets the settings of the current workspace folder. pub fn set_current_settings(&self, settings: Settings) { let data = self.data.pin(); @@ -95,15 +110,6 @@ impl WorkspaceSettings { data.insert(project_key, project_data); } - /// Retrieves the files settings of the current workspace folder - pub fn get_current_files_settings(&self) -> Option { - trace!("Current key {:?}", self.current_project); - self.data - .pin() - .get(&self.get_current_project_key()) - .map(|data| data.settings.files.clone()) - } - pub fn get_current_manifest(&self) -> Option { self.data .pin() @@ -145,10 +151,10 @@ impl WorkspaceSettings { } /// Remove a project using its folder. - pub fn remove_project(&self, workspace_path: &Path) { + pub fn remove_project(&self, project_path: &Path) { let data = self.data.pin(); - for (key, path_to_settings) in data.iter() { - if path_to_settings.path.as_path() == workspace_path { + for (key, project_data) in data.iter() { + if project_data.path.as_path() == project_path { data.remove(key); } } @@ -185,25 +191,24 @@ impl WorkspaceSettings { /// Checks whether the given `path` belongs to the current project and no /// other project. - pub fn path_belongs_only_to_current_project(&self, path: &BiomePath) -> bool { - let mut belongs_to_current = false; + pub fn path_belongs_only_to_project_with_path( + &self, + path: &BiomePath, + project_path: &Path, + ) -> bool { + let mut belongs_to_project = false; let mut belongs_to_other = false; - for (key, path_to_settings) in self.data.pin().iter() { - if path.strip_prefix(path_to_settings.path.as_path()).is_ok() { - if *key == self.get_current_project_key() { - belongs_to_current = true; + for project_data in self.data.pin().values() { + if path.strip_prefix(project_data.path.as_path()).is_ok() { + if project_data.path.as_path() == project_path { + belongs_to_project = true; } else { belongs_to_other = true; } } } - belongs_to_current && !belongs_to_other - } - - /// Sets which project is the current one by its key. - pub fn set_current_project(&self, key: ProjectKey) { - *self.current_project.write().unwrap() = key; + belongs_to_project && !belongs_to_other } /// Returns the maximum file size setting. diff --git a/crates/biome_service/src/workspace.rs b/crates/biome_service/src/workspace.rs index 8685b2f4b97d..f1b2bd521929 100644 --- a/crates/biome_service/src/workspace.rs +++ b/crates/biome_service/src/workspace.rs @@ -52,6 +52,7 @@ //! format a file with a language that does not have a formatter mod client; +mod scanner; mod server; pub use self::client::{TransportRequest, WorkspaceClient, WorkspaceTransport}; @@ -541,6 +542,14 @@ pub struct OpenFileParams { pub content: FileContent, pub version: i32, pub document_file_source: Option, + + /// Set to `true` to persist the node cache used during parsing, in order to + /// speed up subsequent reparsing if the document has been edited. + /// + /// This should only be enabled if reparsing is to be expected, such as when + /// the file is opened through the LSP Proxy. + #[serde(default)] + pub persist_node_cache: bool, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -966,7 +975,7 @@ pub struct RegisterProjectFolderParams { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct UnregisterProjectFolderParams { - pub path: BiomePath, + pub path: PathBuf, } pub trait Workspace: Send + Sync + RefUnwindSafe { @@ -1032,7 +1041,8 @@ pub trait Workspace: Send + Sync + RefUnwindSafe { /// Unregisters a workspace project folder. /// - /// The settings that belong to that project are deleted. + /// The settings that belong to that project are deleted. Any open files that belong to the + /// project are also closed. /// /// If a file watcher was registered as a result of a call to `scan_project_folder()`, it will /// also be unregistered. diff --git a/crates/biome_service/src/scanner.rs b/crates/biome_service/src/workspace/scanner.rs similarity index 95% rename from crates/biome_service/src/scanner.rs rename to crates/biome_service/src/workspace/scanner.rs index 9819e8df2266..5170fa52f08d 100644 --- a/crates/biome_service/src/scanner.rs +++ b/crates/biome_service/src/workspace/scanner.rs @@ -16,6 +16,8 @@ use crate::diagnostics::Panic; use crate::workspace::{DocumentFileSource, FileContent, OpenFileParams}; use crate::{Workspace, WorkspaceError}; +use super::server::WorkspaceServer; + pub(crate) struct ScanResult { /// Diagnostics reported while scanning the project. pub diagnostics: Vec, @@ -24,7 +26,10 @@ pub(crate) struct ScanResult { pub duration: Duration, } -pub(crate) fn scan(workspace: &dyn Workspace, folder: &Path) -> Result { +pub(crate) fn scan( + workspace: &WorkspaceServer, + folder: &Path, +) -> Result { init_thread_pool(); let (interner, _path_receiver) = PathInterner::new(); @@ -133,7 +138,7 @@ impl DiagnosticsCollector { /// Context object shared between directory traversal tasks. pub(crate) struct ScanContext<'app> { /// [Workspace] instance. - pub(crate) workspace: &'app dyn Workspace, + pub(crate) workspace: &'app WorkspaceServer, /// File paths interner cache used by the filesystem traversal. interner: PathInterner, @@ -188,11 +193,12 @@ impl<'app> TraversalContext for ScanContext<'app> { /// so panics are caught, and diagnostics are submitted in case of panic too. fn open_file(ctx: &ScanContext, path: &BiomePath) { match catch_unwind(move || { - ctx.workspace.open_file(OpenFileParams { + ctx.workspace.open_file_by_scanner(OpenFileParams { path: path.clone(), content: FileContent::FromServer, document_file_source: None, version: 0, + persist_node_cache: false, }) }) { Ok(Ok(())) => {} diff --git a/crates/biome_service/src/workspace/server.rs b/crates/biome_service/src/workspace/server.rs index f1dacc662d47..469953d9fb34 100644 --- a/crates/biome_service/src/workspace/server.rs +++ b/crates/biome_service/src/workspace/server.rs @@ -1,3 +1,4 @@ +use super::scanner::scan; use super::{ ChangeFileParams, CheckFileSizeParams, CheckFileSizeResult, CloseFileParams, FeatureName, FileContent, FixFileResult, FormatFileParams, FormatOnTypeParams, FormatRangeParams, @@ -13,7 +14,6 @@ use crate::file_handlers::{ Capabilities, CodeActionsParams, DocumentFileSource, FixAllParams, LintParams, ParseResult, }; use crate::is_dir; -use crate::scanner::scan; use crate::settings::WorkspaceSettings; use crate::workspace::{ FileFeaturesResult, GetFileContentParams, IsPathIgnoredParams, OrganizeImportsParams, @@ -34,25 +34,53 @@ use biome_project::{NodeJsProject, PackageJson, PackageType, Project}; use biome_rowan::NodeCache; use indexmap::IndexSet; use papaya::HashMap; +use rustc_hash::{FxBuildHasher, FxHashMap}; use std::ffi::OsStr; +use std::panic::RefUnwindSafe; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::{panic::RefUnwindSafe, sync::RwLock}; +use std::sync::{Mutex, RwLock}; use tracing::{debug, info, info_span}; pub(super) struct WorkspaceServer { /// features available throughout the application features: Features, + /// global settings object for this workspace settings: WorkspaceSettings, + /// Stores the document (text content + version number) associated with a URL - documents: HashMap, + documents: HashMap, + /// The current focused project current_project_path: RwLock>, + /// Stores the document sources used across the workspace file_sources: RwLock>, + /// Stores patterns to search for. - patterns: HashMap, + patterns: HashMap, + + /// Node cache for faster parsing of modified documents. + /// + /// ## Concurrency + /// + /// Because `NodeCache` cannot be cloned, and `papaya` doesn't give us owned + /// instances of stored values, we use an `FxHashMap` here, wrapped in a + /// `Mutex`. The node cache is only used by writers, meaning this wouldn't + /// be a great use case for `papaya` anyway. But it does mean we need to be + /// careful for deadlocks, and release guards to the mutex as soon as we + /// can. + /// + /// Additionally, we only use the node cache for documents opened through + /// the LSP proxy, since the editor use case is the one where we benefit + /// most from low-latency parsing, and having a document open in an editor + /// gives us a clear signal that edits -- and thus reparsing -- is to be + /// anticipated. For other documents, the performance degradation due to + /// lock contention would not be worth the potential of faster reparsing + /// that may never actually happen. + node_cache: Mutex>, + /// File system implementation. fs: Box, } @@ -65,7 +93,7 @@ pub(super) struct WorkspaceServer { /// could lead too hard to debug issues) impl RefUnwindSafe for WorkspaceServer {} -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct Document { pub(crate) content: String, pub(crate) version: i32, @@ -76,6 +104,14 @@ pub(crate) struct Document { /// The result of the parser (syntax tree + diagnostics). pub(crate) syntax: AnyParse, + + /// If `true`, this indicates the document has been opened by the scanner, + /// and should be unloaded only when the project is unregistered. + /// + /// Note it doesn't matter if the file is *also* opened explicitly through + /// the LSP Proxy, for instance. In such a case, the scanner's "claim" on + /// the file should be considered leading. + opened_by_scanner: bool, } impl WorkspaceServer { @@ -92,6 +128,7 @@ impl WorkspaceServer { current_project_path: RwLock::default(), file_sources: RwLock::default(), patterns: Default::default(), + node_cache: Default::default(), fs, } } @@ -168,24 +205,142 @@ impl WorkspaceServer { let _ = current_project_path.insert(path); } - /// Register a new project in the current workspace + /// Registers a new project in the current workspace. fn register_project(&self, path: PathBuf) -> ProjectKey { self.settings.insert_project(path) } - /// Sets the current project of the current workspace + /// Sets the current project of the current workspace. fn set_current_project(&self, project_key: ProjectKey) { self.settings.set_current_project(project_key); } - /// Checks whether the current path belongs to another project. + /// Checks whether the given `path` belongs to another registered project. /// - /// If there's a match, and the match is for a project **other than** the current project, it - /// returns the new key. + /// If there's a match, and the match is for a project **other than** the + /// current project, it returns the key of that project. fn path_belongs_to_other_project(&self, path: &BiomePath) -> Option { self.settings.path_belongs_to_other_project(path) } + /// Opens the file and marks it as opened by the scanner. + pub(super) fn open_file_by_scanner( + &self, + params: OpenFileParams, + ) -> Result<(), WorkspaceError> { + self.open_file_internal(true, params) + } + + #[tracing::instrument(level = "trace", skip(self))] + fn open_file_internal( + &self, + opened_by_scanner: bool, + params: OpenFileParams, + ) -> Result<(), WorkspaceError> { + let mut source = params + .document_file_source + .unwrap_or(DocumentFileSource::from_path(¶ms.path)); + let manifest = self.get_current_manifest()?; + + if let DocumentFileSource::Js(js) = &mut source { + if let Some(manifest) = manifest { + if manifest.r#type == Some(PackageType::Commonjs) && js.file_extension() == "js" { + js.set_module_kind(ModuleKind::Script); + } + } + } + + if let Some(project_key) = self.path_belongs_to_other_project(¶ms.path) { + self.set_current_project(project_key); + } + + let content = match params.content { + FileContent::FromClient(content) => content, + FileContent::FromServer => self.fs.read_file_from_path(¶ms.path)?, + }; + + let mut index = self.set_source(source); + let mut node_cache = NodeCache::default(); + let parsed = self.parse(¶ms.path, &content, index, &mut node_cache)?; + + if let Some(language) = parsed.language { + index = self.set_source(language); + } + + if params.persist_node_cache { + self.node_cache + .lock() + .unwrap() + .insert(params.path.to_path_buf(), node_cache); + } + + { + let mut document = Document { + content, + version: params.version, + file_source_index: index, + syntax: parsed.any_parse, + opened_by_scanner, + }; + + let documents = self.documents.pin(); + + // This isn't handled atomically, so in theory two calls to + // `open_file()` could happen concurrently and one would overwrite + // the other's entry without considering the merging we do here. + // This would mostly be problematic if someone opens and closes a + // file in their IDE at just the right moment while scanning is + // still in progress. In such a case, the file could be gone from + // the workspace by the time we get to the service data extraction. + // This is why we check again on insertion below, and worst-case we + // may end up needing to do another update. That still leaves a tiny + // theoretical window during which another `close_file()` could have + // caused undesirable side-effects, but: + // - This window is already _very_ unlikely to occur, due to the + // first check we do. + // - This window is also _very_ small, so the `open_file()` and + // `close_file()` calls would need to arrive effectively + // simultaneously. + // + // To prevent this with a 100% guarantee would require us to use + // `update_or_insert()`, which is atomic, but that requires cloning + // the document, which seems hardly worth it. + // That said, I don't think this code is particularly pretty either + // :sweat_smile: + if let Some(existing) = documents.get(¶ms.path) { + if existing.opened_by_scanner { + document.opened_by_scanner = true; + } + + if existing.version > params.version { + document.version = existing.version; + } + } + + let opened_by_scanner = document.opened_by_scanner; + let version = document.version; + + if let Some(existing) = documents.insert(params.path.clone(), document) { + if (existing.opened_by_scanner && !opened_by_scanner) + || (existing.version > version) + { + documents.update(params.path, |document| { + let mut document = document.clone(); + if existing.opened_by_scanner && !opened_by_scanner { + document.opened_by_scanner = true; + } + if existing.version > version { + document.version = version; + } + document + }); + } + } + } + + Ok(()) + } + /// Retrieves the parser result for a given file, calculating it if the file was not yet parsed. /// /// Returns an error if no file exists in the workspace with this path. @@ -202,6 +357,7 @@ impl WorkspaceServer { biome_path: &BiomePath, content: &str, file_source_index: usize, + node_cache: &mut NodeCache, ) -> Result { let Some(file_source) = self.get_source(file_source_index) else { return Err(WorkspaceError::not_found()); @@ -218,7 +374,7 @@ impl WorkspaceServer { file_source, content, self.settings.get_current_settings().as_ref(), - &mut NodeCache::default(), + node_cache, ); Ok(parsed) } @@ -363,49 +519,8 @@ impl Workspace for WorkspaceServer { Ok(()) } - /// Adds a new file to the workspace. - #[tracing::instrument(level = "trace", skip(self))] fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError> { - let mut source = params - .document_file_source - .unwrap_or(DocumentFileSource::from_path(¶ms.path)); - let manifest = self.get_current_manifest()?; - - if let DocumentFileSource::Js(js) = &mut source { - if let Some(manifest) = manifest { - if manifest.r#type == Some(PackageType::Commonjs) && js.file_extension() == "js" { - js.set_module_kind(ModuleKind::Script); - } - } - } - - if let Some(project_key) = self.path_belongs_to_other_project(¶ms.path) { - self.set_current_project(project_key); - } - - let content = match params.content { - FileContent::FromClient(content) => content, - FileContent::FromServer => self.fs.read_file_from_path(¶ms.path)?, - }; - - let mut index = self.set_source(source); - let parsed = self.parse(¶ms.path, &content, index)?; - - if let Some(language) = parsed.language { - index = self.set_source(language); - } - - self.documents.pin().insert( - params.path, - Document { - content, - version: params.version, - file_source_index: index, - syntax: parsed.any_parse, - }, - ); - - Ok(()) + self.open_file_internal(false, params) } fn set_manifest_for_project( @@ -427,6 +542,7 @@ impl Workspace for WorkspaceServer { version: params.version, file_source_index: index, syntax: parsed.into(), + opened_by_scanner: false, }, ); Ok(()) @@ -490,7 +606,24 @@ impl Workspace for WorkspaceServer { &self, params: UnregisterProjectFolderParams, ) -> Result<(), WorkspaceError> { + // Limit the scope of the pin and the lock inside. + { + let documents = self.documents.pin(); + let mut node_cache = self.node_cache.lock().unwrap(); + for (path, document) in documents.iter() { + if document.opened_by_scanner + && self + .settings + .path_belongs_only_to_project_with_path(path, ¶ms.path) + { + documents.remove(path); + node_cache.remove(params.path.as_path()); + } + } + } + self.settings.remove_project(params.path.as_path()); + Ok(()) } @@ -557,35 +690,74 @@ impl Workspace for WorkspaceServer { /// Changes the content of an open file. fn change_file(&self, params: ChangeFileParams) -> Result<(), WorkspaceError> { let documents = self.documents.pin(); - let index = documents + let (index, opened_by_scanner) = documents .get(¶ms.path) .map(|document| { debug_assert!(params.version > document.version); - document.file_source_index + (document.file_source_index, document.opened_by_scanner) }) .ok_or_else(WorkspaceError::not_found)?; - let parsed = self.parse(¶ms.path, ¶ms.content, index)?; + // We remove the node cache for the document, if it exists. + // This is done so that we need to hold the lock as short as possible + // (it's released directly after the statement). The potential downside + // is that if two calls to `change_file()` happen concurrently, then the + // second would have a cache miss, and not update the cache either. + // This seems an unlikely scenario however, and the impact is small + // anyway, so this seems a worthwhile tradeoff. + let node_cache = self + .node_cache + .lock() + .unwrap() + .remove(params.path.as_path()); + + let persist_node_cache = node_cache.is_some(); + let mut node_cache = node_cache.unwrap_or_default(); + + let parsed = self.parse(¶ms.path, ¶ms.content, index, &mut node_cache)?; let document = Document { content: params.content, version: params.version, file_source_index: index, syntax: parsed.any_parse, + opened_by_scanner, }; + if persist_node_cache { + self.node_cache + .lock() + .unwrap() + .insert(params.path.to_path_buf(), node_cache); + } + documents .insert(params.path, document) .ok_or_else(WorkspaceError::not_found)?; Ok(()) } - /// Removes a file from the workspace. + /// Closes a file that is opened in the workspace. + /// + /// This only unloads the document from the workspace if the file is NOT + /// opened by the scanner as well. If the scanner has opened the file, it + /// may still be required for multi-file analysis. fn close_file(&self, params: CloseFileParams) -> Result<(), WorkspaceError> { - self.documents - .pin() - .remove(¶ms.path) - .ok_or_else(WorkspaceError::not_found)?; + { + let documents = self.documents.pin(); + let document = documents + .get(¶ms.path) + .ok_or_else(WorkspaceError::not_found)?; + if !document.opened_by_scanner { + documents.remove(¶ms.path); + } + } + + self.node_cache + .lock() + .unwrap() + .remove(params.path.as_path()); + Ok(()) } diff --git a/crates/biome_service/tests/workspace.rs b/crates/biome_service/tests/workspace.rs index c63670bb0e66..478fe9f5cc44 100644 --- a/crates/biome_service/tests/workspace.rs +++ b/crates/biome_service/tests/workspace.rs @@ -1,14 +1,17 @@ #[cfg(test)] mod test { + use std::path::PathBuf; + use biome_analyze::RuleCategories; use biome_configuration::analyzer::{RuleGroup, RuleSelector}; use biome_fs::{BiomePath, MemoryFileSystem}; use biome_js_syntax::{JsFileSource, TextSize}; use biome_service::file_handlers::DocumentFileSource; use biome_service::workspace::{ - server, FileContent, FileGuard, OpenFileParams, RegisterProjectFolderParams, + server, CloseFileParams, FileContent, FileGuard, GetFileContentParams, OpenFileParams, + RegisterProjectFolderParams, UnregisterProjectFolderParams, }; - use biome_service::Workspace; + use biome_service::{Workspace, WorkspaceError}; fn create_server() -> Box { let workspace = server(Box::new(MemoryFileSystem::default())); @@ -36,6 +39,7 @@ mod test { content: FileContent::FromClient(SOURCE.into()), version: 0, document_file_source: Some(DocumentFileSource::from(JsFileSource::default())), + persist_node_cache: false, }, ) .unwrap(); @@ -57,6 +61,7 @@ mod test { content: FileContent::FromClient("export const foo: number".into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -76,6 +81,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42}"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -89,6 +95,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42}//comment"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -102,6 +109,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42,}"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -115,6 +123,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42}//comment"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -128,6 +137,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42,}"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -141,6 +151,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42}//comment"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -154,6 +165,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42}//comment"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -167,6 +179,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42,}"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -182,6 +195,7 @@ mod test { content: FileContent::FromClient(r#"{"a": 42,}//comment"#.into()), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -211,6 +225,7 @@ type User { ), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -237,6 +252,7 @@ type User { ), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -270,6 +286,7 @@ type User { ), version: 0, document_file_source: None, + persist_node_cache: false, }, ) .unwrap(); @@ -279,4 +296,71 @@ type User { assert!(syntax.starts_with("GritRoot")) } + + #[test] + fn files_loaded_by_the_scanner_are_only_unloaded_when_the_project_is_unregistered() { + const FILE_A_CONTENT: &[u8] = b"import { bar } from './b.ts';\nfunction foo() {}"; + const FILE_B_CONTENT: &[u8] = b"import { foo } from './a.ts';\nfunction bar() {}"; + + let mut fs = MemoryFileSystem::default(); + fs.insert(PathBuf::from("/project/a.ts"), FILE_A_CONTENT); + fs.insert(PathBuf::from("/project/b.ts"), FILE_B_CONTENT); + + let workspace = server(Box::new(fs)); + workspace + .register_project_folder(RegisterProjectFolderParams { + set_as_current_workspace: true, + path: Some(PathBuf::from("/project")), + }) + .unwrap(); + + workspace.scan_current_project_folder(()).unwrap(); + + macro_rules! assert_file_a_content { + () => { + assert_eq!( + workspace + .get_file_content(GetFileContentParams { + path: BiomePath::new("/project/a.ts"), + }) + .unwrap(), + String::from_utf8(FILE_A_CONTENT.to_vec()).unwrap(), + ); + }; + } + + assert_file_a_content!(); + + workspace + .open_file(OpenFileParams { + path: BiomePath::new("/project/a.ts"), + content: FileContent::FromServer, + version: 0, + document_file_source: None, + persist_node_cache: false, + }) + .unwrap(); + + assert_file_a_content!(); + + workspace + .close_file(CloseFileParams { + path: BiomePath::new("/project/a.ts"), + }) + .unwrap(); + + assert_file_a_content!(); + + workspace + .unregister_project_folder(UnregisterProjectFolderParams { + path: PathBuf::from("/project"), + }) + .unwrap(); + + assert!(workspace + .get_file_content(GetFileContentParams { + path: BiomePath::new("/project/a.ts"), + }) + .is_err_and(|error| matches!(error, WorkspaceError::NotFound(_)))); + } } diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 62f07b6c128a..584e31769789 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -2748,6 +2748,12 @@ export interface OpenFileParams { content: FileContent; documentFileSource?: DocumentFileSource; path: BiomePath; + /** + * Set to `true` to persist the node cache used during parsing, in order to speed up subsequent reparsing if the document has been edited. + +This should only be enabled if reparsing is to be expected, such as when the file is opened through the LSP Proxy. + */ + persistNodeCache?: boolean; version: number; } export type FileContent =