From 60a7bb8143feff57100cb8e1fca9aaaa1b2cb788 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Thu, 22 Oct 2026 17:51:09 +0000 Subject: [PATCH 01/17] feat: apply the nearest configuration file to each file (nested configs) Each file uses the nearest postgres-language-server.jsonc at or above it. A project is a workspace folder (LSP) or the working directory (CLI) with root settings and the settings of the configuration files below it; every workspace request names its project. --- Cargo.lock | 1 + crates/pgls_cli/src/commands/check.rs | 5 +- crates/pgls_cli/src/commands/dblint.rs | 1 + crates/pgls_cli/src/commands/format.rs | 1 + .../src/execute/process_file/check.rs | 1 + .../src/execute/process_file/format.rs | 12 +- .../execute/process_file/workspace_file.rs | 1 + crates/pgls_cli/src/execute/stdin.rs | 3 + crates/pgls_cli/src/execute/walk.rs | 7 +- crates/pgls_cli/src/lib.rs | 77 +- crates/pgls_cli/src/workspace.rs | 13 +- crates/pgls_cli/tests/assert_check.rs | 68 ++ crates/pgls_fs/Cargo.toml | 1 + crates/pgls_fs/src/fs.rs | 13 + crates/pgls_fs/src/fs/memory.rs | 25 +- crates/pgls_fs/src/fs/os.rs | 32 +- crates/pgls_lsp/src/handlers/code_actions.rs | 9 +- crates/pgls_lsp/src/handlers/completions.rs | 10 +- crates/pgls_lsp/src/handlers/formatting.rs | 9 +- crates/pgls_lsp/src/handlers/hover.rs | 2 + crates/pgls_lsp/src/handlers/inlay_hints.rs | 10 +- crates/pgls_lsp/src/handlers/text_document.rs | 12 +- crates/pgls_lsp/src/server.rs | 98 ++- crates/pgls_lsp/src/session.rs | 314 ++++++--- crates/pgls_lsp/tests/server.rs | 285 +++++++- crates/pgls_wasm/src/lib.rs | 37 +- crates/pgls_workspace/src/configuration.rs | 113 ++- crates/pgls_workspace/src/diagnostics.rs | 14 + .../src/features/code_actions.rs | 3 + .../src/features/completions.rs | 1 + .../src/features/diagnostics.rs | 10 +- crates/pgls_workspace/src/features/format.rs | 1 + .../src/features/inlay_hints.rs | 1 + .../pgls_workspace/src/features/on_hover.rs | 1 + crates/pgls_workspace/src/settings.rs | 254 ++++--- crates/pgls_workspace/src/workspace.rs | 95 ++- crates/pgls_workspace/src/workspace/client.rs | 7 + crates/pgls_workspace/src/workspace/server.rs | 373 +++++----- .../src/workspace/server.tests.rs | 655 ++++++++++++++++-- .../src/workspace/server/snapshot_manager.rs | 10 +- crates/pgls_workspace_macros/src/lib.rs | 45 +- docs/configuration.md | 39 ++ .../backend-jsonrpc/src/workspace.ts | 23 +- .../backend-jsonrpc/tests/workspace.test.mjs | 7 +- .../backend-jsonrpc/src/workspace.ts | 23 +- .../backend-jsonrpc/tests/workspace.test.mjs | 7 +- 46 files changed, 2160 insertions(+), 569 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9860413af..d95cdae0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2592,6 +2592,7 @@ dependencies = [ "crossbeam", "directories", "enumflags2", + "ignore", "oxc_resolver", "parking_lot", "pgls_diagnostics", diff --git a/crates/pgls_cli/src/commands/check.rs b/crates/pgls_cli/src/commands/check.rs index d19f7bd5c..aea0af7cc 100644 --- a/crates/pgls_cli/src/commands/check.rs +++ b/crates/pgls_cli/src/commands/check.rs @@ -33,6 +33,7 @@ pub fn check( let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; + session.apply_nested_configurations(cli_options, args.configuration.clone())?; let paths = resolve_paths(session.fs(), &configuration, &args)?; @@ -76,7 +77,9 @@ pub fn check( fn check_database_connection(session: &CliSession) -> Result, CliDiagnostic> { let result = session .workspace() - .check_database_connection(CheckDatabaseConnectionParams::default())?; + .check_database_connection(CheckDatabaseConnectionParams { + project_key: session.project_key(), + })?; Ok(result .error .map(|message| Error::from(DatabaseUnavailable::new(message)))) diff --git a/crates/pgls_cli/src/commands/dblint.rs b/crates/pgls_cli/src/commands/dblint.rs index 36dc048c3..760e909e9 100644 --- a/crates/pgls_cli/src/commands/dblint.rs +++ b/crates/pgls_cli/src/commands/dblint.rs @@ -27,6 +27,7 @@ pub fn dblint( let start = Instant::now(); let params = PullDatabaseDiagnosticsParams { + project_key: session.project_key(), categories: RuleCategoriesBuilder::default().all().build(), max_diagnostics, only: Vec::new(), // Uses configuration settings diff --git a/crates/pgls_cli/src/commands/format.rs b/crates/pgls_cli/src/commands/format.rs index df6095f83..402bedd23 100644 --- a/crates/pgls_cli/src/commands/format.rs +++ b/crates/pgls_cli/src/commands/format.rs @@ -29,6 +29,7 @@ pub fn format( let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; + session.apply_nested_configurations(cli_options, args.configuration.clone())?; let vcs = VcsTargeting { staged: args.staged, diff --git a/crates/pgls_cli/src/execute/process_file/check.rs b/crates/pgls_cli/src/execute/process_file/check.rs index 612a6da4c..be959c78f 100644 --- a/crates/pgls_cli/src/execute/process_file/check.rs +++ b/crates/pgls_cli/src/execute/process_file/check.rs @@ -37,6 +37,7 @@ pub(crate) fn check_with_guard<'ctx>( let fixed = ctx .workspace .fix_file(FixFileParams { + project_key: ctx.project_key, path: PgLSPath::new(&workspace_file.path), fix_file_mode, only: Vec::new(), diff --git a/crates/pgls_cli/src/execute/process_file/format.rs b/crates/pgls_cli/src/execute/process_file/format.rs index 84dd610b1..0d98a09bf 100644 --- a/crates/pgls_cli/src/execute/process_file/format.rs +++ b/crates/pgls_cli/src/execute/process_file/format.rs @@ -17,12 +17,12 @@ fn format_with_guard<'ctx>( let path = workspace_file.path.clone(); let pgls_path = pgls_fs::PgLSPath::new(&path); - let format_result = workspace_file - .guard() - .pull_file_formatting(PullFileFormattingParams { - path: pgls_path.clone(), - range: None, - }); + let guard = workspace_file.guard(); + let format_result = guard.pull_file_formatting(PullFileFormattingParams { + project_key: guard.project_key(), + path: pgls_path.clone(), + range: None, + }); let format_result = format_result.map_err(Message::from)?; diff --git a/crates/pgls_cli/src/execute/process_file/workspace_file.rs b/crates/pgls_cli/src/execute/process_file/workspace_file.rs index 35b711aba..d5953657d 100644 --- a/crates/pgls_cli/src/execute/process_file/workspace_file.rs +++ b/crates/pgls_cli/src/execute/process_file/workspace_file.rs @@ -40,6 +40,7 @@ impl<'ctx, 'app> WorkspaceFile<'ctx, 'app> { let guard = FileGuard::open( ctx.workspace, OpenFileParams { + project_key: ctx.project_key, path: pgls_path, version: 0, content: input.clone(), diff --git a/crates/pgls_cli/src/execute/stdin.rs b/crates/pgls_cli/src/execute/stdin.rs index 41fdabe81..40430f44d 100644 --- a/crates/pgls_cli/src/execute/stdin.rs +++ b/crates/pgls_cli/src/execute/stdin.rs @@ -27,18 +27,21 @@ pub(crate) fn process( /// they are, so stdout always contains the best-effort output. fn format(session: &mut CliSession, payload: StdinPayload) -> Result<(), CliDiagnostic> { let pgls_path = PgLSPath::new(&payload.path); + let project_key = session.project_key(); let workspace = session.workspace(); let result = { let guard = FileGuard::open( workspace, OpenFileParams { + project_key, path: pgls_path.clone(), version: 0, content: payload.content.clone(), }, )?; guard.pull_file_formatting(PullFileFormattingParams { + project_key, path: pgls_path, range: None, })? diff --git a/crates/pgls_cli/src/execute/walk.rs b/crates/pgls_cli/src/execute/walk.rs index 342794b3c..59e7e73d1 100644 --- a/crates/pgls_cli/src/execute/walk.rs +++ b/crates/pgls_cli/src/execute/walk.rs @@ -8,7 +8,7 @@ use pgls_diagnostics::{DiagnosticExt, Error, Resource}; use pgls_fs::{FileSystem, PathInterner, PgLSPath}; use pgls_fs::{TraversalContext, TraversalScope}; use pgls_workspace::dome::Dome; -use pgls_workspace::workspace::IsPathIgnoredParams; +use pgls_workspace::workspace::{IsPathIgnoredParams, ProjectKey}; use pgls_workspace::{Workspace, WorkspaceError}; use rustc_hash::FxHashSet; use std::collections::BTreeSet; @@ -51,6 +51,7 @@ pub(crate) fn traverse( let fs = &**session.fs(); let workspace = session.workspace(); + let project_key = session.project_key(); let max_diagnostics = config.max_diagnostics(); let remaining_diagnostics = AtomicU32::new(max_diagnostics); @@ -71,6 +72,7 @@ pub(crate) fn traverse( &TraversalOptions { fs, workspace, + project_key, config, interner, matches: &matches, @@ -280,6 +282,8 @@ pub(crate) struct TraversalOptions<'ctx, 'app> { pub(crate) fs: &'app dyn FileSystem, /// Instance of [Workspace] used by this instance of the CLI pub(crate) workspace: &'ctx dyn Workspace, + /// The project that every file of the traversal belongs to + pub(crate) project_key: ProjectKey, /// Determines how the files should be processed pub(crate) config: &'ctx ExecutionConfig, /// File paths interner cache used by the filesystem traversal @@ -355,6 +359,7 @@ impl TraversalContext for TraversalOptions<'_, '_> { let can_handle = !self .workspace .is_path_ignored(IsPathIgnoredParams { + project_key: self.project_key, pgls_path: pgls_path.clone(), }) .unwrap_or_else(|err| { diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index e72c4da27..db2510043 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -13,6 +13,8 @@ use pgls_console::{ColorMode, Console, ConsoleExt, markup}; use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; use std::path::PathBuf; +use pgls_workspace::configuration::{NestedConfiguration, apply_nested_configurations}; +use pgls_workspace::workspace::ProjectKey; use pgls_workspace::{App, DynRef, Workspace, WorkspaceRef}; mod changed; @@ -43,6 +45,8 @@ pub(crate) use pgls_env::VERSION; pub struct CliSession<'app> { /// Instance of [App] used by this run of the CLI pub app: App<'app>, + /// The project of the working directory, once [CliSession::setup_workspace] registered it + project_key: ProjectKey, } impl<'app> CliSession<'app> { @@ -56,6 +60,7 @@ impl<'app> CliSession<'app> { console, WorkspaceRef::Borrowed(workspace), ), + project_key: ProjectKey::default(), }) } @@ -222,23 +227,43 @@ impl<'app> CliSession<'app> { }); } - let mut configuration = loaded_configuration.configuration; + Ok(apply_overrides( + loaded_configuration.configuration, + cli_configuration, + )) + } - // Env vars override config file but are overridden by explicit CLI args. - if let Some(env_db) = pgls_configuration::database::PartialDatabaseConfiguration::from_env() - { - let env_config = PartialConfiguration { - db: Some(env_db), - ..Default::default() - }; - configuration.merge_with(env_config); - } + /// The project of the working directory, which every file of this run belongs to + pub fn project_key(&self) -> ProjectKey { + self.project_key + } - if let Some(cli_config) = cli_configuration { - configuration.merge_with(cli_config); + /// Applies every configuration file below the working directory to the project, so that each + /// file uses its nearest configuration file. The environment and the arguments apply to them + /// like to the root configuration file. Skipped when the user chose a configuration file. + pub fn apply_nested_configurations( + &self, + cli_options: &CliOptions, + cli_configuration: Option, + ) -> Result<(), CliDiagnostic> { + if cli_options.config_path.is_some() { + return Ok(()); } + let Some(root) = self.fs().working_directory() else { + return Ok(()); + }; - Ok(configuration) + let nested = apply_nested_configurations( + self.workspace(), + self.fs(), + self.project_key, + &root, + |configuration| apply_overrides(configuration, cli_configuration.clone()), + )?; + for NestedConfiguration { result, .. } in nested { + result?; + } + Ok(()) } pub fn setup_workspace( @@ -246,7 +271,9 @@ impl<'app> CliSession<'app> { configuration: PartialConfiguration, vcs: VcsIntegration, ) -> Result<(), CliDiagnostic> { - workspace::setup_workspace(self.workspace(), self.fs(), configuration, vcs) + self.project_key = + workspace::setup_workspace(self.workspace(), self.fs(), configuration, vcs)?; + Ok(()) } pub fn report( @@ -260,6 +287,28 @@ impl<'app> CliSession<'app> { } } +/// Lays the database configuration from the environment and then the arguments over a +/// configuration file. +fn apply_overrides( + mut configuration: PartialConfiguration, + cli_configuration: Option, +) -> PartialConfiguration { + // Env vars override config file but are overridden by explicit CLI args. + if let Some(env_db) = pgls_configuration::database::PartialDatabaseConfiguration::from_env() { + let env_config = PartialConfiguration { + db: Some(env_db), + ..Default::default() + }; + configuration.merge_with(env_config); + } + + if let Some(cli_config) = cli_configuration { + configuration.merge_with(cli_config); + } + + configuration +} + /// Controls whether workspace setup should include VCS integration details. pub enum VcsIntegration { Enabled, diff --git a/crates/pgls_cli/src/workspace.rs b/crates/pgls_cli/src/workspace.rs index d08df1601..fc2e8f033 100644 --- a/crates/pgls_cli/src/workspace.rs +++ b/crates/pgls_cli/src/workspace.rs @@ -3,7 +3,7 @@ use pgls_configuration::{ConfigurationPathHint, PartialConfiguration}; use pgls_fs::FileSystem; use pgls_workspace::PartialConfigurationExt; use pgls_workspace::configuration::{LoadedConfiguration, load_configuration}; -use pgls_workspace::workspace::{RegisterProjectFolderParams, UpdateSettingsParams}; +use pgls_workspace::workspace::{ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams}; use pgls_workspace::{DynRef, Workspace}; /// Load configuration from disk and emit warnings for deprecated filenames. @@ -14,13 +14,14 @@ pub fn load_config( load_configuration(fs, config_hint).map_err(CliDiagnostic::from) } -/// Configure the workspace and VCS integration according to the provided configuration. +/// Registers the project of the working directory and configures it and the VCS integration +/// according to the provided configuration. Returns the key of the project. pub fn setup_workspace( workspace: &dyn Workspace, fs: &DynRef<'_, dyn FileSystem>, configuration: PartialConfiguration, vcs: VcsIntegration, -) -> Result<(), CliDiagnostic> { +) -> Result { let (vcs_base_path, gitignore_matches) = match vcs { VcsIntegration::Enabled => configuration .retrieve_gitignore_matches(fs, fs.working_directory().as_deref()) @@ -28,15 +29,15 @@ pub fn setup_workspace( VcsIntegration::Disabled => (None, vec![]), }; - workspace + let project_key = workspace .register_project_folder(RegisterProjectFolderParams { path: fs.working_directory(), - set_as_current_workspace: true, }) .map_err(CliDiagnostic::from)?; workspace .update_settings(UpdateSettingsParams { + project_key, workspace_directory: fs.working_directory(), configuration, vcs_base_path, @@ -44,5 +45,5 @@ pub fn setup_workspace( }) .map_err(CliDiagnostic::from)?; - Ok(()) + Ok(project_key) } diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 6c735221d..4aaa76a4c 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -373,6 +373,74 @@ async fn check_reads_the_password_from_pgpassfile(test_db: PgPool) { assert!(output.contains("lint/unknownColumn"), "{output}"); } +/// Each file uses the nearest configuration file at or above it, below the working directory. +#[cfg(target_os = "linux")] +#[test] +fn check_uses_the_nearest_configuration_file() { + let root = tempfile::tempdir().unwrap(); + let write = |path: &str, content: &str| { + let path = root.path().join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + }; + write( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ); + write( + "db1/postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true }, "linter": { "enabled": false } }"#, + ); + write( + "db2/postgres-language-server.jsonc", + r#"{ + "extends": ["../postgres-language-server.jsonc"], + "linter": { "rules": { "banDropColumn": "error" } } + }"#, + ); + for file in ["a.sql", "db1/a.sql", "db2/schema/a.sql"] { + write(file, "alter table users drop column name;\n"); + } + + let check = |paths: &[&str]| { + let mut cmd = cargo_bin_cmd!("postgres-language-server"); + for var in ["DATABASE_URL", "PGHOST", "PGPORT", "PGUSER", "PGDATABASE"] { + cmd.env_remove(var); + } + let output = cmd + .current_dir(root.path()) + .args(["check", "--reporter", "github", "--log-level", "none"]) + .args(paths) + .output() + .expect("failed to run CLI"); + String::from_utf8_lossy(&output.stdout).into_owned() + }; + let annotation = + |level: &str, file: &str| format!("::{level} title=lint/banDropColumn,file={file},"); + + // The traversal of the working directory passes absolute paths. + let stdout = check(&[]); + let absolute = |file: &str| root.path().join(file).display().to_string(); + assert!( + stdout.contains(&annotation("warning", &absolute("a.sql"))), + "{stdout}" + ); + assert!(!stdout.contains("db1/a.sql"), "{stdout}"); + assert!( + stdout.contains(&annotation("error", &absolute("db2/schema/a.sql"))), + "{stdout}" + ); + + // Paths given as arguments are relative to the working directory. + let stdout = check(&["a.sql", "db1/a.sql", "db2/schema/a.sql"]); + assert!(stdout.contains(&annotation("warning", "a.sql")), "{stdout}"); + assert!(!stdout.contains("db1/a.sql"), "{stdout}"); + assert!( + stdout.contains(&annotation("error", "db2/schema/a.sql")), + "{stdout}" + ); +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] diff --git a/crates/pgls_fs/Cargo.toml b/crates/pgls_fs/Cargo.toml index 4eada5c80..565c04c14 100644 --- a/crates/pgls_fs/Cargo.toml +++ b/crates/pgls_fs/Cargo.toml @@ -15,6 +15,7 @@ version = "0.0.0" crossbeam = { workspace = true } directories = "5.0.1" enumflags2 = { workspace = true } +ignore = { workspace = true } oxc_resolver = { workspace = true } parking_lot = { version = "0.12.3", features = ["arc_lock"] } pgls_diagnostics = { workspace = true } diff --git a/crates/pgls_fs/src/fs.rs b/crates/pgls_fs/src/fs.rs index 4b5762b79..534eda291 100644 --- a/crates/pgls_fs/src/fs.rs +++ b/crates/pgls_fs/src/fs.rs @@ -182,8 +182,17 @@ pub trait FileSystem: Send + Sync + RefUnwindSafe { specifier: &str, path: &Path, ) -> Result; + + /// Finds the files at or below `root` named like one of `file_names`. + /// + /// It skips hidden directories, the directories in [SKIPPED_DIRECTORIES], and, on the OS file + /// system, everything that `.gitignore` and `.ignore` files exclude. + fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec; } +/// Directories that [FileSystem::find_files] never descends into. +pub const SKIPPED_DIRECTORIES: &[&str] = &["node_modules", "target"]; + /// Result of the auto search #[derive(Debug)] pub struct AutoSearchResult { @@ -389,6 +398,10 @@ where ) -> Result { T::resolve_configuration(self, specifier, path) } + + fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { + T::find_files(self, root, file_names) + } } #[derive(Debug, Diagnostic, Deserialize, Serialize)] diff --git a/crates/pgls_fs/src/fs/memory.rs b/crates/pgls_fs/src/fs/memory.rs index 9879ae157..95cfa0497 100644 --- a/crates/pgls_fs/src/fs/memory.rs +++ b/crates/pgls_fs/src/fs/memory.rs @@ -13,7 +13,7 @@ use pgls_diagnostics::{Error, Severity}; use crate::fs::OpenOptions; use crate::{FileSystem, PgLSPath, TraversalContext, TraversalScope}; -use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic}; +use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, SKIPPED_DIRECTORIES}; type OnGetChangedFiles = Option< Arc< @@ -259,6 +259,29 @@ impl FileSystem for MemoryFileSystem { // not needed for the memory file system todo!() } + + fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { + let files = self.files.0.read(); + let mut found: Vec<_> = files + .keys() + .filter(|path| { + let Ok(relative) = path.strip_prefix(root) else { + return false; + }; + let mut components: Vec<_> = relative.iter().map(|c| c.to_string_lossy()).collect(); + let Some(file_name) = components.pop() else { + return false; + }; + file_names.contains(&&*file_name) + && !components.iter().any(|directory| { + directory.starts_with('.') || SKIPPED_DIRECTORIES.contains(&&**directory) + }) + }) + .cloned() + .collect(); + found.sort(); + found + } } struct MemoryFile { diff --git a/crates/pgls_fs/src/fs/os.rs b/crates/pgls_fs/src/fs/os.rs index b895b03ae..6fad289e6 100644 --- a/crates/pgls_fs/src/fs/os.rs +++ b/crates/pgls_fs/src/fs/os.rs @@ -1,5 +1,5 @@ //! Implementation of the [FileSystem] and related traits for the underlying OS filesystem -use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic}; +use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, SKIPPED_DIRECTORIES}; use crate::fs::OpenOptions; use crate::{ FileSystem, PgLSPath, dir, @@ -145,6 +145,36 @@ impl FileSystem for OsFileSystem { ) -> Result { self.configuration_resolver.resolve(path, specifier) } + + fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { + ignore::WalkBuilder::new(root) + .hidden(true) + .require_git(false) + .filter_entry(|entry| { + !(entry + .file_type() + .is_some_and(|file_type| file_type.is_dir()) + && entry + .file_name() + .to_str() + .is_some_and(|name| SKIPPED_DIRECTORIES.contains(&name))) + }) + .build() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + }) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| file_names.contains(&name)) + }) + .map(ignore::DirEntry::into_path) + .collect() + } } struct OsFile { diff --git a/crates/pgls_lsp/src/handlers/code_actions.rs b/crates/pgls_lsp/src/handlers/code_actions.rs index b3c2f6750..83faf1569 100644 --- a/crates/pgls_lsp/src/handlers/code_actions.rs +++ b/crates/pgls_lsp/src/handlers/code_actions.rs @@ -22,10 +22,12 @@ pub fn get_actions( ) -> Result { let url = params.text_document.uri; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let cursor_position = get_cursor_position(session, &url, params.range.start)?; let workspace_actions = session.workspace.pull_code_actions(CodeActionsParams { + project_key, path, cursor_position, only: vec![], @@ -156,10 +158,15 @@ pub async fn execute_command( let doc_url: ls_types::Uri = serde_json::from_value(params.arguments[1].clone())?; let path = session.file_path(&doc_url)?; + let project_key = session.project_key(&path); let result = session .workspace - .execute_statement(ExecuteStatementParams { statement_id, path })?; + .execute_statement(ExecuteStatementParams { + project_key, + statement_id, + path, + })?; /* * Updating all diagnostics: the changes caused by the statement execution diff --git a/crates/pgls_lsp/src/handlers/completions.rs b/crates/pgls_lsp/src/handlers/completions.rs index 392b2ace9..bd88b6a1d 100644 --- a/crates/pgls_lsp/src/handlers/completions.rs +++ b/crates/pgls_lsp/src/handlers/completions.rs @@ -16,16 +16,18 @@ pub fn get_completions( ) -> Result { let url = params.text_document_position.text_document.uri; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let doc = session.document(&url)?; let encoding = adapters::negotiated_encoding(session.client_capabilities().unwrap()); let position = get_cursor_position(session, &url, params.text_document_position.position)?; - let completion_result = match session - .workspace - .get_completions(GetCompletionsParams { path, position }) - { + let completion_result = match session.workspace.get_completions(GetCompletionsParams { + project_key, + path, + position, + }) { Ok(result) => result, Err(e) => match e { WorkspaceError::DatabaseConnectionError(_) => { diff --git a/crates/pgls_lsp/src/handlers/formatting.rs b/crates/pgls_lsp/src/handlers/formatting.rs index 2f69d124c..9a14c6032 100644 --- a/crates/pgls_lsp/src/handlers/formatting.rs +++ b/crates/pgls_lsp/src/handlers/formatting.rs @@ -16,10 +16,15 @@ pub fn formatting( ) -> Result>, LspError> { let url = params.text_document.uri; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let result = session .workspace - .pull_file_formatting(PullFileFormattingParams { path, range: None })?; + .pull_file_formatting(PullFileFormattingParams { + project_key, + path, + range: None, + })?; result_to_edits(result, session.position_encoding()) } @@ -30,12 +35,14 @@ pub fn range_formatting( ) -> Result>, LspError> { let url = params.text_document.uri; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let doc = session.document(&url)?; let range = from_lsp::text_range(&doc.line_index, params.range, session.position_encoding())?; let result = session .workspace .pull_file_formatting(PullFileFormattingParams { + project_key, path, range: Some(range), })?; diff --git a/crates/pgls_lsp/src/handlers/hover.rs b/crates/pgls_lsp/src/handlers/hover.rs index 964b420c2..0261b42e6 100644 --- a/crates/pgls_lsp/src/handlers/hover.rs +++ b/crates/pgls_lsp/src/handlers/hover.rs @@ -11,8 +11,10 @@ pub(crate) fn on_hover( let url = params.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); match session.workspace.on_hover(OnHoverParams { + project_key, path, position: get_cursor_position(session, &url, position)?, }) { diff --git a/crates/pgls_lsp/src/handlers/inlay_hints.rs b/crates/pgls_lsp/src/handlers/inlay_hints.rs index bb41b108a..f92f759a9 100644 --- a/crates/pgls_lsp/src/handlers/inlay_hints.rs +++ b/crates/pgls_lsp/src/handlers/inlay_hints.rs @@ -17,14 +17,16 @@ pub(crate) fn inlay_hints( ) -> Result>, LspError> { let url = params.text_document.uri; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let doc = session.document(&url)?; let encoding = session.position_encoding(); let range = from_lsp::text_range(&doc.line_index, params.range, encoding)?; - let result = match session - .workspace - .pull_inlay_hints(PullInlayHintsParams { path, range }) - { + let result = match session.workspace.pull_inlay_hints(PullInlayHintsParams { + project_key, + path, + range, + }) { Ok(result) => result, Err(e) => match e { WorkspaceError::DatabaseConnectionError(_) => return Ok(None), diff --git a/crates/pgls_lsp/src/handlers/text_document.rs b/crates/pgls_lsp/src/handlers/text_document.rs index 3f748d38e..f65aa413f 100644 --- a/crates/pgls_lsp/src/handlers/text_document.rs +++ b/crates/pgls_lsp/src/handlers/text_document.rs @@ -17,9 +17,11 @@ pub(crate) async fn did_open( let content = params.text_document.text; let path = session.file_path(&url)?; + let project_key = session.project_key(&path); let doc = Document::new(version, &content); session.workspace.open_file(OpenFileParams { + project_key, path, version, content, @@ -50,8 +52,10 @@ pub(crate) async fn did_change( let version = params.text_document.version; let pgls_path = session.file_path(&url)?; + let project_key = session.project_key(&pgls_path); let old_text = session.workspace.get_file_content(GetFileContentParams { + project_key, path: pgls_path.clone(), })?; tracing::trace!("old document: {:?}", old_text); @@ -68,6 +72,7 @@ pub(crate) async fn did_change( session.insert_document(url.clone(), Document::new(version, &text)); session.workspace.change_file(ChangeFileParams { + project_key, path: pgls_path, version, content: text, @@ -93,9 +98,10 @@ pub(crate) async fn did_close( // so that no stale diagnostics are published after the file is closed. session.cancel_pending_diagnostics(&url); - session - .workspace - .close_file(CloseFileParams { path: pgls_path })?; + session.workspace.close_file(CloseFileParams { + project_key: session.project_key(&pgls_path), + path: pgls_path, + })?; session.remove_document(&url); diff --git a/crates/pgls_lsp/src/server.rs b/crates/pgls_lsp/src/server.rs index 94f473687..4187be566 100644 --- a/crates/pgls_lsp/src/server.rs +++ b/crates/pgls_lsp/src/server.rs @@ -8,7 +8,6 @@ use futures::FutureExt; use futures::future::ready; use pgls_configuration::database::PartialDatabaseConfiguration; use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; -use pgls_workspace::workspace::{RegisterProjectFolderParams, UnregisterProjectFolderParams}; use pgls_workspace::{DynRef, Workspace, workspace}; use rustc_hash::FxHashMap; use serde_json::json; @@ -71,11 +70,12 @@ impl LSPServer { "workspace/didChangeWatchedFiles", match self.session.base_path() { Some(base_path) => { + // Configuration files below the root apply to their directories. let watchers = ConfigName::file_names() .iter() .map(|config_name| FileSystemWatcher { glob_pattern: GlobPattern::String(format!( - "{}/{}", + "{}/**/{}", base_path.display(), config_name )), @@ -176,20 +176,18 @@ impl LanguageServer for LSPServer { for file_path in file_paths { match file_path { Some(file_path) => { - let base_path = self.session.base_path(); - if let Some(base_path) = base_path { - let possible_config_json = file_path.strip_prefix(&base_path); - if let Ok(watched_file) = possible_config_json - && ConfigName::file_names() - .contains(&&*watched_file.display().to_string()) - { - self.session.reload_workspace_settings().await; - self.setup_capabilities().await; - // self.session.update_all_diagnostics().await; - // for now we are only interested to the configuration file, - // so it's OK to exist the loop - break; - } + // The root configuration file or one below it, which applies to its directory. + let is_config_file = file_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| ConfigName::file_names().contains(&file_name)); + if is_config_file { + self.session.reload_workspace_settings().await; + self.setup_capabilities().await; + self.session.update_all_diagnostics().await; + // for now we are only interested to the configuration file, + // so it's OK to exist the loop + break; } } None => { @@ -224,44 +222,29 @@ impl LanguageServer for LSPServer { } async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { - for removed in ¶ms.event.removed { - if let Ok(project_path) = self.session.file_path(&removed.uri) { - let result = self - .session - .workspace - .unregister_project_folder(UnregisterProjectFolderParams { path: project_path }) - .map_err(into_lsp_error); - - if let Err(err) = result { - error!("Failed to remove project from the workspace: {}", err); - self.session - .client - .log_message(MessageType::ERROR, err) - .await; - } - } - } + let paths = |folders: &[WorkspaceFolder]| -> Vec { + folders + .iter() + .filter_map(|folder| { + let path = folder.uri.to_file_path().map(|path| path.into_owned()); + if path.is_none() { + error!( + "The Workspace root URI {:?} could not be parsed as a filesystem path", + folder.uri + ); + } + path + }) + .collect() + }; + self.session + .change_workspace_folders(paths(¶ms.event.added), &paths(¶ms.event.removed)); - for added in ¶ms.event.added { - if let Ok(project_path) = self.session.file_path(&added.uri) { - let result = self - .session - .workspace - .register_project_folder(RegisterProjectFolderParams { - path: Some(project_path.to_path_buf()), - set_as_current_workspace: true, - }) - .map_err(into_lsp_error); - - if let Err(err) = result { - error!("Failed to add project to the workspace: {}", err); - self.session - .client - .log_message(MessageType::ERROR, err) - .await; - } - } - } + // Loading the configuration registers the projects of the new folders and unregisters the + // ones of the removed folders. + self.session.reload_workspace_settings().await; + self.setup_capabilities().await; + self.session.update_all_diagnostics().await; } #[tracing::instrument(level = "trace", skip_all)] @@ -510,6 +493,7 @@ impl ServerFactory { workspace_method!(builder, is_path_ignored); workspace_method!(builder, update_settings); + workspace_method!(builder, update_nested_settings); workspace_method!(builder, get_file_content); workspace_method!(builder, open_file); workspace_method!(builder, change_file); @@ -571,10 +555,8 @@ mod tests { use std::path::Path; use tower_lsp_server::ls_types::Uri; - /// A session backed by a real working directory, so that reloading the configuration keeps - /// updating the settings of the *same* project. [`pgls_fs::MemoryFileSystem`] has no working - /// directory, which makes every reload register a fresh project with default settings and would - /// therefore hide whether a cleared override is really reset. + /// A session backed by a real working directory, whose project every configuration reload + /// updates. /// /// The caller owns the client socket so it can decide how to drain it; nothing drains it by /// default, and logging to a client that is never read would block a transition. @@ -614,6 +596,7 @@ mod tests { session .workspace .is_path_ignored(IsPathIgnoredParams { + project_key: session.project_key(path), pgls_path: PgLSPath::new(path), }) .expect("is_path_ignored") @@ -668,6 +651,7 @@ mod tests { session .workspace .open_file(OpenFileParams { + project_key: session.project_key(&document), path: PgLSPath::new(&document), version: 0, content: "select 1;".to_string(), diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 7807a87bb..b70b88580 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -12,13 +12,17 @@ use pgls_diagnostics::{DiagnosticExt, Error}; use pgls_fs::{ConfigName, FileSystem, PgLSPath}; use pgls_workspace::PartialConfigurationExt; use pgls_workspace::Workspace; -use pgls_workspace::configuration::{LoadedConfiguration, load_configuration}; +use pgls_workspace::configuration::{ + LoadedConfiguration, NestedConfiguration, apply_nested_configurations, load_configuration, +}; use pgls_workspace::features; -use pgls_workspace::workspace::{RegisterProjectFolderParams, UpdateSettingsParams}; +use pgls_workspace::workspace::{ + ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, UpdateSettingsParams, +}; use pgls_workspace::{DynRef, WorkspaceError}; use rustc_hash::FxHashMap; use serde_json::Value; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; @@ -95,15 +99,20 @@ pub(crate) struct Session { /// Layer set through workspace/didChangeConfiguration and the custom override request. client_overrides: RwLock, + /// The workspace folders, which the client can change after the initialization. `None` if the + /// client has none and uses the root URI. + workspace_folders: RwLock>>, + + /// The projects of the workspace by their folder, deepest first: one per workspace folder, or + /// one for the root. A document belongs to the project of the deepest folder that contains it. + projects: RwLock>, + /// Serialises a whole configuration transition: storing the layer, reloading the settings and /// refreshing the diagnostics of all open documents. /// /// Two transitions really can run at the same time — the server runs on a multi-threaded - /// runtime and tower-lsp handles several messages concurrently — and that matters for three - /// things. `register_project_folder` and `update_settings` are separate workspace calls with - /// the current-project selection in between, so a parallel transition can make settings land - /// on the wrong project. An older transition's diagnostics refresh could publish after a newer - /// one, leaving the client with diagnostics computed under superseded settings; the staleness + /// runtime and tower-lsp handles several messages concurrently — and that matters for two + /// things. An older transition's diagnostics refresh could publish after a newer one, leaving the client with diagnostics computed under superseded settings; the staleness /// check does not catch that, because the document version is unchanged. And a burst of client /// requests would otherwise run one full reload and one type check pass per message. /// @@ -223,6 +232,8 @@ impl Session { notified_deprecated_settings: AtomicBool::new(false), env_config, client_overrides: RwLock::new(ClientOverrides::default()), + workspace_folders: RwLock::default(), + projects: RwLock::default(), configuration_transition: tokio::sync::Mutex::new(()), #[cfg(test)] running_transitions: std::sync::atomic::AtomicUsize::new(0), @@ -244,6 +255,21 @@ impl Session { root_uri: Option, workspace_folders: Option>, ) { + *self.workspace_folders.write().unwrap() = workspace_folders.as_ref().map(|folders| { + folders + .iter() + .filter_map(|folder| { + let path = folder.uri.to_file_path().map(|path| path.into_owned()); + if path.is_none() { + error!( + "The Workspace root URI {:?} could not be parsed as a filesystem path", + folder.uri + ); + } + path + }) + .collect() + }); let result = self.initialize_params.set(InitializeParams { client_capabilities, client_information, @@ -333,6 +359,7 @@ impl Session { let diagnostics: Vec = { let result = self.workspace.pull_file_diagnostics( features::diagnostics::PullFileDiagnosticsParams { + project_key: self.project_key(&pgls_path), path: pgls_path.clone(), max_diagnostics: u32::MAX, categories: categories.build(), @@ -490,10 +517,34 @@ impl Session { } /// Get the current workspace folders - pub(crate) fn get_workspace_folders(&self) -> Option<&Vec> { - self.initialize_params - .get() - .and_then(|c| c.workspace_folders.as_ref()) + pub(crate) fn get_workspace_folders(&self) -> Option> { + self.workspace_folders.read().unwrap().clone() + } + + /// Applies a change of the workspace folders. The caller reloads the configuration. + pub(crate) fn change_workspace_folders(&self, added: Vec, removed: &[PathBuf]) { + let mut folders = self.workspace_folders.write().unwrap(); + // A client without workspace folders at the start used the root URI. + let folders = folders.get_or_insert_with(|| self.base_path().into_iter().collect()); + folders.retain(|folder| !removed.contains(folder)); + for folder in added { + if !folders.contains(&folder) { + folders.push(folder); + } + } + } + + /// The project that the file at `path` belongs to: the one of the deepest workspace folder + /// that contains it, or else the one of the shallowest folder. Before the configuration is loaded, the key of no + /// project, for which the workspace only reports what needs no settings. + pub(crate) fn project_key(&self, path: &Path) -> ProjectKey { + let projects = self.projects.read().unwrap(); + projects + .iter() + .find(|(folder, _)| path.starts_with(folder)) + .or_else(|| projects.last()) + .map(|(_, key)| *key) + .unwrap_or_default() } /// Returns the base path of the workspace on the filesystem if it has one @@ -519,53 +570,185 @@ impl Session { .map(|params| ¶ms.client_capabilities) } - /// This function attempts to read the `postgres-language-server.jsonc` configuration file from - /// the root URI and update the workspace settings accordingly + /// This function attempts to read the `postgres-language-server.jsonc` configuration file of + /// every workspace folder, or of the root URI, and update the settings of its project + /// accordingly. The configuration files below a folder apply to the files below them. #[tracing::instrument(level = "trace", skip(self))] async fn load_workspace_settings(&self) { - // Providing a custom configuration path will not allow to support workspaces - if let Some(config_path) = &self.config_path { - let base_path = ConfigurationPathHint::FromUser(config_path.clone()); - let status = self.load_pgls_configuration_file(base_path).await; - self.set_configuration_status(status); + let roots = if let Some(config_path) = &self.config_path { + // Providing a custom configuration path will not allow to support workspaces + vec![ConfigurationPathHint::FromUser(config_path.clone())] } else if let Some(folders) = self.get_workspace_folders() { info!("Detected workspace folder."); - self.set_configuration_status(ConfigurationStatus::Loading); - for folder in folders { - info!( - "Attempt to load the configuration file in {:?}", - folder.uri.to_string() - ); - let base_path = folder.uri.to_file_path(); - match base_path { - Some(base_path) => { - let status = self - .load_pgls_configuration_file(ConfigurationPathHint::FromWorkspace( - base_path.into_owned(), - )) - .await; - self.set_configuration_status(status); - } - None => { - error!( - "The Workspace root URI {:?} could not be parsed as a filesystem path", - folder.uri - ); - } - } - } + folders + .into_iter() + .map(ConfigurationPathHint::FromWorkspace) + .collect() } else { - let base_path = match self.base_path() { + vec![match self.base_path() { None => ConfigurationPathHint::default(), Some(path) => ConfigurationPathHint::FromLsp(path), + }] + }; + + self.set_configuration_status(ConfigurationStatus::Loading); + let mut status = ConfigurationStatus::Loaded; + let mut projects = Vec::new(); + for root in roots { + let (project, project_status) = self.load_project(root).await; + projects.extend(project); + if !matches!(project_status, ConfigurationStatus::Loaded) { + status = project_status; + } + } + + // Unregister the projects of the workspace folders that were removed. + let previous = std::mem::take(&mut *self.projects.write().unwrap()); + for (folder, _) in previous { + if !projects.iter().any(|(project, _)| project == &folder) { + let result = + self.workspace + .unregister_project_folder(UnregisterProjectFolderParams { + path: PgLSPath::new(&folder), + }); + if let Err(error) = result { + error!("Failed to unregister the project folder: {}", error); + } + } + } + + projects.sort_by_key(|(folder, _)| std::cmp::Reverse(folder.components().count())); + *self.projects.write().unwrap() = projects; + self.set_configuration_status(status); + } + + /// Loads the configuration of a project and its nested configuration files. Returns the folder + /// and the key of the project, unless it could not be registered. + async fn load_project( + &self, + base_path: ConfigurationPathHint, + ) -> (Option<(PathBuf, ProjectKey)>, ConfigurationStatus) { + // The configuration files below a workspace root apply to their directories. With a + // configuration file chosen by the user, that file applies to everything. + let root = match &base_path { + ConfigurationPathHint::FromWorkspace(path) | ConfigurationPathHint::FromLsp(path) => { + Some(path.clone()) + } + ConfigurationPathHint::FromUser(_) | ConfigurationPathHint::None => None, + }; + let folder = root.clone().or_else(|| self.fs.working_directory()); + + let project_key = + match self + .workspace + .register_project_folder(RegisterProjectFolderParams { + path: folder.clone(), + }) { + Ok(project_key) => project_key, + Err(error) => { + error!("Failed to register the project folder: {}", error); + self.client.log_message(MessageType::ERROR, &error).await; + return (None, ConfigurationStatus::Error); + } }; - let status = self.load_pgls_configuration_file(base_path).await; - self.set_configuration_status(status); + let folder = folder.unwrap_or_default(); + let is_new = !self + .projects + .read() + .unwrap() + .iter() + .any(|(project, _)| project == &folder); + + let status = self + .load_pgls_configuration_file(project_key, base_path) + .await; + if !matches!(status, ConfigurationStatus::Loaded) { + // A project keeps its settings while its configuration is broken, and a new one is + // only registered once its configuration loads. + if is_new { + let _ = self + .workspace + .unregister_project_folder(UnregisterProjectFolderParams { + path: PgLSPath::new(&folder), + }); + return (None, status); + } + return (Some((folder, project_key)), status); + } + let project = Some((folder, project_key)); + + let status = match root { + Some(root) => { + self.load_nested_configuration_files(project_key, &root) + .await + } + None => ConfigurationStatus::Loaded, + }; + (project, status) + } + + /// Lays the layers of the session over a configuration file: the environment and the client + /// overrides. + fn configure(&self) -> impl Fn(PartialConfiguration) -> PartialConfiguration + '_ { + let (client_overrides, overridden_sections) = { + let overrides = self.client_overrides.read().unwrap(); + (overrides.configuration().cloned(), overrides.sections()) + }; + move |configuration| { + let mut configuration = merge_layers( + configuration, + self.env_config.as_ref(), + client_overrides.as_ref(), + ); + overridden_sections.materialize_defaults(&mut configuration); + configuration + } + } + + /// Applies every configuration file below `root` to the project, with the same layers as the + /// root configuration. + async fn load_nested_configuration_files( + &self, + project_key: ProjectKey, + root: &Path, + ) -> ConfigurationStatus { + let nested = match apply_nested_configurations( + &*self.workspace, + &self.fs, + project_key, + root, + self.configure(), + ) { + Ok(nested) => nested, + Err(error) => { + error!("Failed to apply the nested configuration files: {}", error); + self.client.log_message(MessageType::ERROR, &error).await; + return ConfigurationStatus::Error; + } + }; + + let mut status = ConfigurationStatus::Loaded; + for NestedConfiguration { directory, result } in nested { + match result { + Ok(()) => info!("Loaded the nested configuration in {}", directory.display()), + Err(error) => { + error!( + "Couldn't load the configuration file in {}, reason:\n {}", + directory.display(), + error + ); + self.client.log_message(MessageType::ERROR, &error).await; + status = ConfigurationStatus::Error; + } + } } + status } + /// Loads the configuration file at or above the root of a project into its root settings. async fn load_pgls_configuration_file( &self, + project_key: ProjectKey, base_path: ConfigurationPathHint, ) -> ConfigurationStatus { match load_configuration(&self.fs, base_path.clone()) { @@ -621,49 +804,14 @@ impl Session { info!("Update workspace settings."); - let fs = &self.fs; - let (client_overrides, overridden_sections) = { - let overrides = self.client_overrides.read().unwrap(); - (overrides.configuration().cloned(), overrides.sections()) - }; - let mut configuration = merge_layers( - fs_configuration, - self.env_config.as_ref(), - client_overrides.as_ref(), - ); - overridden_sections.materialize_defaults(&mut configuration); - + let configuration = self.configure()(fs_configuration); let result = configuration .retrieve_gitignore_matches(&self.fs, configuration_path.as_deref()); match result { Ok((vcs_base_path, gitignore_matches)) => { - let register_result = - if let ConfigurationPathHint::FromWorkspace(path) = &base_path { - // We don't need the key - self.workspace - .register_project_folder(RegisterProjectFolderParams { - path: Some(path.clone()), - // This is naive, but we don't know if the user has a file already open or not, so we register every project as the current one. - // The correct one is actually set when the LSP calls `textDocument/didOpen` - set_as_current_workspace: true, - }) - .err() - } else { - self.workspace - .register_project_folder(RegisterProjectFolderParams { - path: fs.working_directory(), - set_as_current_workspace: true, - }) - .err() - }; - if let Some(error) = register_result { - error!("Failed to register the project folder: {}", error); - self.client.log_message(MessageType::ERROR, &error).await; - return ConfigurationStatus::Error; - } - let result = self.workspace.update_settings(UpdateSettingsParams { + project_key, workspace_directory: self.fs.working_directory(), configuration, vcs_base_path, diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index c58197149..c29fec46b 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -11,7 +11,7 @@ use pgls_configuration::Merge; use pgls_configuration::PartialConfiguration; use pgls_configuration::StringSet; use pgls_configuration::database::PartialDatabaseConfiguration; -use pgls_fs::MemoryFileSystem; +use pgls_fs::{ConfigName, MemoryFileSystem, OsFileSystem}; use pgls_lsp::LSPServer; use pgls_lsp::ServerFactory; use pgls_workspace::DynRef; @@ -46,10 +46,10 @@ use tower_lsp_server::ls_types::WorkDoneProgressParams; use tower_lsp_server::ls_types::WorkspaceFolder; use tower_lsp_server::ls_types::{ ClientCapabilities, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, - FileChangeType, FileEvent, InitializeResult, InitializedParams, PublishDiagnosticsParams, - TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem, Uri, - VersionedTextDocumentIdentifier, + DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, FileChangeType, FileEvent, InitializeResult, InitializedParams, + PublishDiagnosticsParams, TextDocumentContentChangeEvent, TextDocumentIdentifier, + TextDocumentItem, Uri, VersionedTextDocumentIdentifier, WorkspaceFoldersChangeEvent, }; use tower_lsp_server::{jsonrpc::Request, ls_types::InitializeParams}; @@ -144,9 +144,24 @@ impl Server { } /// Basic implementation of the `initialize` request for tests + async fn initialize(&mut self) -> Result<()> { + self.initialize_with_root(url!("")).await + } + + /// Like [Server::initialize], with the workspace root at `root_uri` + async fn initialize_with_root(&mut self, root_uri: Uri) -> Result<()> { + self.initialize_with_folders(root_uri, None).await + } + + /// Like [Server::initialize], with the workspace root at `root_uri` and the given workspace + /// folders // The `root_path` field is deprecated, but we still need to specify it #[allow(deprecated)] - async fn initialize(&mut self) -> Result<()> { + async fn initialize_with_folders( + &mut self, + root_uri: Uri, + workspace_folders: Option>, + ) -> Result<()> { let _res: InitializeResult = self .request( "initialize", @@ -154,11 +169,11 @@ impl Server { InitializeParams { process_id: None, root_path: None, - root_uri: Some(url!("")), + root_uri: Some(root_uri), initialization_options: None, capabilities: ClientCapabilities::default(), trace: None, - workspace_folders: None, + workspace_folders, client_info: None, locale: None, work_done_progress_params: Default::default(), @@ -634,9 +649,6 @@ async fn client_configuration_overrides_are_sticky_and_clearable(test_db: PgPool server.initialize().await?; server.initialized().await?; - // The document is opened before the first transition: `textDocument/didOpen` picks the project - // a path belongs to, and with a file system that has no working directory every configuration - // reload registers another project under the same empty path. server.open_document(STATEMENT).await?; let options = test_db.connect_options(); @@ -3083,3 +3095,254 @@ async fn test_crash_on_delete_character() -> Result<()> { Ok(()) } + +/// Waits for the next diagnostics of the document at `uri`. +async fn wait_for_diagnostics_of( + receiver: &mut Receiver, + uri: &Uri, +) -> Result> { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let Some(ServerNotification::PublishDiagnostics(params)) = receiver.next().await else { + bail!("the server closed the connection"); + }; + if ¶ms.uri == uri { + return Ok(params.diagnostics); + } + } + }) + .await + .with_context(|| format!("timed out waiting for diagnostics of {}", uri.as_str()))? +} + +/// The severities of the `banDropColumn` diagnostics. +fn ban_drop_column_severities(diagnostics: &[lsp::Diagnostic]) -> Vec { + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(lsp::NumberOrString::String(code)) if code == "lint/banDropColumn" + ) + }) + .filter_map(|diagnostic| diagnostic.severity) + .collect() +} + +/// Opens a document that drops a column, and returns the severities of its `banDropColumn` +/// diagnostics. +async fn open_and_check( + server: &mut Server, + receiver: &mut Receiver, + uri: Uri, +) -> Result> { + server + .open_named_document("alter table users drop column name;", uri.clone(), "sql") + .await?; + Ok(ban_drop_column_severities( + &wait_for_diagnostics_of(receiver, &uri).await?, + )) +} + +/// A configuration file below the workspace root applies to the files in its directory, and the +/// server picks up configuration files that are created or deleted while it runs. +#[tokio::test] +async fn nested_configuration_files_apply_to_their_directories() -> Result<()> { + let root = tempfile::tempdir()?; + let write_config = |directory: &str, content: &str| -> Result<()> { + let directory = root.path().join(directory); + std::fs::create_dir_all(&directory)?; + std::fs::write(directory.join(ConfigName::pgls_jsonc()), content)?; + Ok(()) + }; + write_config("", "{}")?; + write_config("db1", r#"{ "linter": { "enabled": false } }"#)?; + write_config( + "db2", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + )?; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_root(Uri::from_file_path(root.path()).unwrap()) + .await?; + server.initialized().await?; + + let document = |path: &str| Uri::from_file_path(root.path().join(path)).unwrap(); + + assert_eq!( + open_and_check(&mut server, &mut receiver, document("a.sql")).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("db1/a.sql")).await?, + [] + ); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("db2/schema/a.sql")).await?, + [lsp::DiagnosticSeverity::ERROR] + ); + + // A configuration file created while the server runs. + write_config("db3", r#"{ "linter": { "enabled": false } }"#)?; + server + .notify( + "workspace/didChangeWatchedFiles", + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: document("db3/postgres-language-server.jsonc"), + typ: FileChangeType::CREATED, + }], + }, + ) + .await?; + drain_notifications(&mut receiver); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("db3/a.sql")).await?, + [] + ); + + // A deleted one no longer applies, and the open documents are checked again. + std::fs::remove_file(root.path().join("db1").join(ConfigName::pgls_jsonc()))?; + drain_notifications(&mut receiver); + server + .notify( + "workspace/didChangeWatchedFiles", + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: document("db1/postgres-language-server.jsonc"), + typ: FileChangeType::DELETED, + }], + }, + ) + .await?; + // Diagnostics published before the deletion can still be on their way. + let db1 = document("db1/a.sql"); + let refreshed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let diagnostics = wait_for_diagnostics_of(&mut receiver, &db1).await?; + if !ban_drop_column_severities(&diagnostics).is_empty() { + return Ok::<_, Error>(ban_drop_column_severities(&diagnostics)); + } + } + }) + .await + .context("the diagnostics were not refreshed after deleting the configuration file")??; + assert_eq!(refreshed, [lsp::DiagnosticSeverity::WARNING]); + + server.shutdown().await?; + reader.abort(); + + Ok(()) +} + +/// Waits until the diagnostics of the document at `uri` have the given `banDropColumn` severities. +async fn wait_for_severities( + receiver: &mut Receiver, + uri: &Uri, + expected: &[lsp::DiagnosticSeverity], +) -> Result<()> { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let diagnostics = wait_for_diagnostics_of(receiver, uri).await?; + if ban_drop_column_severities(&diagnostics) == expected { + return Ok::<_, Error>(()); + } + } + }) + .await + .with_context(|| format!("the diagnostics of {} were not refreshed", uri.as_str()))? +} + +/// A workspace folder added after the initialization gets its own project with its configuration, +/// and a removed one no longer applies. +#[tokio::test] +async fn workspace_folders_can_be_added_and_removed() -> Result<()> { + let root = tempfile::tempdir()?; + for (folder, configuration) in [ + ("one", "{}"), + ( + "two", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ] { + std::fs::create_dir_all(root.path().join(folder))?; + std::fs::write( + root.path().join(folder).join(ConfigName::pgls_jsonc()), + configuration, + )?; + } + let folder = |name: &str| WorkspaceFolder { + uri: Uri::from_file_path(root.path().join(name)).unwrap(), + name: name.to_string(), + }; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_folders(folder("one").uri, Some(vec![folder("one")])) + .await?; + server.initialized().await?; + + // Outside of every workspace folder, the project of the shallowest one applies. + let document = Uri::from_file_path(root.path().join("two").join("a.sql")).unwrap(); + assert_eq!( + open_and_check(&mut server, &mut receiver, document.clone()).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + + let change = |added: Vec, removed: Vec| { + DidChangeWorkspaceFoldersParams { + event: WorkspaceFoldersChangeEvent { added, removed }, + } + }; + + server + .notify( + "workspace/didChangeWorkspaceFolders", + change(vec![folder("two")], vec![]), + ) + .await?; + wait_for_severities(&mut receiver, &document, &[lsp::DiagnosticSeverity::ERROR]).await?; + + server + .notify( + "workspace/didChangeWorkspaceFolders", + change(vec![], vec![folder("two")]), + ) + .await?; + wait_for_severities( + &mut receiver, + &document, + &[lsp::DiagnosticSeverity::WARNING], + ) + .await?; + + server.shutdown().await?; + reader.abort(); + + Ok(()) +} diff --git a/crates/pgls_wasm/src/lib.rs b/crates/pgls_wasm/src/lib.rs index 20a9cd741..73ebcc8bd 100644 --- a/crates/pgls_wasm/src/lib.rs +++ b/crates/pgls_wasm/src/lib.rs @@ -31,7 +31,7 @@ use pgls_workspace::features::diagnostics::PullFileDiagnosticsParams; use pgls_workspace::features::format::PullFileFormattingParams; use pgls_workspace::features::on_hover::OnHoverParams; use pgls_workspace::workspace::{ - ChangeFileParams, CloseFileParams, OpenFileParams, RegisterProjectFolderParams, + ChangeFileParams, CloseFileParams, OpenFileParams, ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams, }; use serde::{Deserialize, Serialize}; @@ -194,6 +194,8 @@ impl MemoryFs { /// Backed by `pgls_workspace::WorkspaceServer` for full linting capabilities. pub struct Workspace { inner: pgls_workspace::workspace::server::WorkspaceServer, + /// The project that every file belongs to + project_key: ProjectKey, fs: MemoryFs, } @@ -208,14 +210,14 @@ impl Workspace { pub fn new() -> Self { let inner = pgls_workspace::workspace::server::WorkspaceServer::new(); - // Register a project folder to enable workspace features - let _ = inner.register_project_folder(RegisterProjectFolderParams { - path: None, - set_as_current_workspace: true, - }); + // Register a project without a folder, which every file belongs to + let project_key = inner + .register_project_folder(RegisterProjectFolderParams { path: None }) + .unwrap_or_default(); Self { inner, + project_key, fs: MemoryFs::new(), } } @@ -252,6 +254,7 @@ impl Workspace { // Also open in the inner workspace let pgls_path = PgLSPath::new(PathBuf::from(path)); let _ = self.inner.open_file(OpenFileParams { + project_key: self.project_key, path: pgls_path.clone(), content: content.into(), version: 0, @@ -259,6 +262,7 @@ impl Workspace { // Update the content let _ = self.inner.change_file(ChangeFileParams { + project_key: self.project_key, path: pgls_path, content: content.into(), version: 1, @@ -273,7 +277,10 @@ impl Workspace { self.fs.remove(path); let pgls_path = PgLSPath::new(PathBuf::from(path)); - let _ = self.inner.close_file(CloseFileParams { path: pgls_path }); + let _ = self.inner.close_file(CloseFileParams { + project_key: self.project_key, + path: pgls_path, + }); } /// Read a file's content. @@ -302,6 +309,7 @@ impl Workspace { let result = self .inner .pull_file_diagnostics(PullFileDiagnosticsParams { + project_key: self.project_key, path: pgls_path, categories: RuleCategories::all(), max_diagnostics: 100, @@ -347,6 +355,7 @@ impl Workspace { let pgls_path = PgLSPath::new(PathBuf::from(FORMAT_SQL_PATH)); self.inner .open_file(OpenFileParams { + project_key: self.project_key, path: pgls_path.clone(), content: sql.into(), version: 0, @@ -355,7 +364,10 @@ impl Workspace { let result = self.format_open_document(pgls_path.clone(), options_json); - let _ = self.inner.close_file(CloseFileParams { path: pgls_path }); + let _ = self.inner.close_file(CloseFileParams { + project_key: self.project_key, + path: pgls_path, + }); result } @@ -373,6 +385,7 @@ impl Workspace { self.inner .update_settings(UpdateSettingsParams { + project_key: self.project_key, configuration: PartialConfiguration { format: Some(format), ..Default::default() @@ -387,7 +400,11 @@ impl Workspace { let result = self .inner - .pull_file_formatting(PullFileFormattingParams { path, range: None }) + .pull_file_formatting(PullFileFormattingParams { + project_key: self.project_key, + path, + range: None, + }) .map_err(|e| WasmError::Internal(format!("Format failed: {e}")))?; Ok(FormatResult { @@ -418,6 +435,7 @@ impl Workspace { let result = self .inner .get_completions(GetCompletionsParams { + project_key: self.project_key, path: pgls_path, position: TextSize::from(offset), }) @@ -465,6 +483,7 @@ impl Workspace { let result = self .inner .on_hover(OnHoverParams { + project_key: self.project_key, path: pgls_path, position: TextSize::from(offset), }) diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index f43910393..52603f09e 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -15,7 +15,13 @@ use pgls_console::markup; use pgls_env::PGLS_WEBSITE; use pgls_fs::{AutoSearchResult, ConfigName, FileSystem, OpenOptions}; -use crate::{DynRef, WorkspaceError, settings::Settings}; +use crate::{ + DynRef, Workspace, WorkspaceError, + settings::Settings, + workspace::{ + NestedSettingsParams, ProjectKey, UpdateNestedSettingsParams, UpdateNestedSettingsResult, + }, +}; /// Information regarding the configuration that was found. /// @@ -100,6 +106,111 @@ pub fn load_configuration( Ok(loaded) } +/// A configuration file in a folder below the root of a project. +#[derive(Debug)] +pub struct NestedConfiguration { + /// The folder of the configuration file. Its settings apply to the files below it. + pub directory: PathBuf, + /// Whether the configuration file was loaded and applied. The files below a configuration file + /// that could not be applied use the default settings. + pub result: Result<(), WorkspaceError>, +} + +/// Applies every configuration file below `root` to the project as nested settings, so that each +/// file uses the settings of the nearest configuration file. They replace the nested settings the +/// project had before. +/// +/// A nested configuration file inherits nothing from the configuration files above it unless it +/// `extends` them, and the user configuration is never merged into it: that one only applies, as +/// the root settings, to the files that have no project configuration at or above them. The user +/// configuration directories are skipped even when they are below `root`, for example when the +/// home directory is opened as a workspace. +/// +/// `configure` lays the layers of the caller, such as the environment or the arguments of the +/// CLI, over each configuration file. Folders that the settings above them ignore are skipped. +/// +/// Returns the configuration files that apply. Fails if the project is not registered. +pub fn apply_nested_configurations( + workspace: &dyn Workspace, + fs: &DynRef<'_, dyn FileSystem>, + project_key: ProjectKey, + root: &Path, + configure: impl Fn(PartialConfiguration) -> PartialConfiguration, +) -> Result, WorkspaceError> { + let user_config_directories = fs.user_config_directories(); + let mut directories: Vec = fs + .find_files(root, ConfigName::file_names().as_slice()) + .into_iter() + .filter_map(|file_path| file_path.parent().map(PathBuf::from)) + .filter(|directory| directory != root) + .filter(|directory| !user_config_directories.contains(directory)) + .collect(); + directories.sort(); + directories.dedup(); + + let mut load_errors = Vec::new(); + let nested = directories + .into_iter() + .map(|directory| { + load_nested_configuration(fs, &directory, &configure).unwrap_or_else(|error| { + load_errors.push((directory.clone(), error)); + // Neither the configuration above it nor a part of the broken one applies. + NestedSettingsParams { + directory, + configuration: PartialConfiguration::default(), + vcs_base_path: None, + gitignore_matches: Vec::new(), + } + }) + }) + .collect(); + + let UpdateNestedSettingsResult { applied, errors } = + workspace.update_nested_settings(UpdateNestedSettingsParams { + project_key, + nested, + })?; + + Ok(applied + .into_iter() + .map(|directory| { + let load_error = load_errors + .iter() + .position(|(failed, _)| failed == &directory) + .map(|index| load_errors.swap_remove(index).1); + let apply_error = errors + .iter() + .find(|error| error.directory == directory) + .map(|error| ConfigurationDiagnostic::invalid_configuration(&error.message).into()); + let result = match load_error.or(apply_error) { + Some(error) => Err(error), + None => Ok(()), + }; + NestedConfiguration { directory, result } + }) + .collect()) +} + +fn load_nested_configuration( + fs: &DynRef<'_, dyn FileSystem>, + directory: &Path, + configure: impl Fn(PartialConfiguration) -> PartialConfiguration, +) -> Result { + let loaded = load_configuration( + fs, + ConfigurationPathHint::FromWorkspace(directory.to_path_buf()), + )?; + let configuration = configure(loaded.configuration); + let (vcs_base_path, gitignore_matches) = + configuration.retrieve_gitignore_matches(fs, Some(directory))?; + Ok(NestedSettingsParams { + directory: directory.to_path_buf(), + configuration, + vcs_base_path, + gitignore_matches, + }) +} + /// - [Result]: if an error occurred while loading the configuration file. /// - [Option]: sometimes not having a configuration file should not be an error, so we need this type. /// - [ConfigurationPayload]: The result of the operation diff --git a/crates/pgls_workspace/src/diagnostics.rs b/crates/pgls_workspace/src/diagnostics.rs index 7db9e96c3..5d8465f29 100644 --- a/crates/pgls_workspace/src/diagnostics.rs +++ b/crates/pgls_workspace/src/diagnostics.rs @@ -29,6 +29,8 @@ pub enum WorkspaceError { CantReadFile(CantReadFile), /// The file does not exist in the [crate::Workspace] NotFound(NotFound), + /// The project is not registered in the [crate::Workspace] + NoProject(NoProject), /// Error emitted by the underlying transport layer for a remote Workspace TransportError(TransportError), /// Emitted when the file is ignored and should not be processed @@ -54,6 +56,10 @@ impl WorkspaceError { Self::NotFound(NotFound) } + pub fn no_project() -> Self { + Self::NoProject(NoProject) + } + pub fn protected_file(file_path: impl Into) -> Self { Self::ProtectedFile(ProtectedFile { file_path: file_path.into(), @@ -281,6 +287,14 @@ impl From for WorkspaceError { )] pub struct NotFound; +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "project", + message = "The project is not registered in the workspace.", + tags(INTERNAL) +)] +pub struct NoProject; + #[derive(Debug, Serialize, Deserialize, Diagnostic)] #[diagnostic( category = "project", diff --git a/crates/pgls_workspace/src/features/code_actions.rs b/crates/pgls_workspace/src/features/code_actions.rs index d47ecbaa3..b76974e7c 100644 --- a/crates/pgls_workspace/src/features/code_actions.rs +++ b/crates/pgls_workspace/src/features/code_actions.rs @@ -7,6 +7,7 @@ use pgls_text_size::{TextRange, TextSize}; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct CodeActionsParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, pub cursor_position: TextSize, pub only: Vec, @@ -69,6 +70,7 @@ pub enum CommandActionCategory { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ExecuteStatementParams { + pub project_key: crate::workspace::ProjectKey, pub statement_id: StatementId, pub path: PgLSPath, } @@ -82,6 +84,7 @@ pub struct ExecuteStatementResult { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FixFileParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, pub fix_file_mode: FixFileMode, pub only: Vec, diff --git a/crates/pgls_workspace/src/features/completions.rs b/crates/pgls_workspace/src/features/completions.rs index 85a242091..22bc6306e 100644 --- a/crates/pgls_workspace/src/features/completions.rs +++ b/crates/pgls_workspace/src/features/completions.rs @@ -9,6 +9,7 @@ use crate::workspace::{Document, GetCompletionsFilter, StatementId, WithCSTMappe #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct GetCompletionsParams { + pub project_key: crate::workspace::ProjectKey, /// The File for which a completion is requested. pub path: PgLSPath, /// The Cursor position in the file for which a completion is requested. diff --git a/crates/pgls_workspace/src/features/diagnostics.rs b/crates/pgls_workspace/src/features/diagnostics.rs index 02c296221..3b7cdc299 100644 --- a/crates/pgls_workspace/src/features/diagnostics.rs +++ b/crates/pgls_workspace/src/features/diagnostics.rs @@ -5,6 +5,7 @@ use pgls_fs::PgLSPath; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PullFileDiagnosticsParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, pub categories: RuleCategories, pub max_diagnostics: u32, @@ -19,9 +20,13 @@ pub struct PullDiagnosticsResult { pub skipped_diagnostics: u32, } -#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct CheckDatabaseConnectionParams {} +pub struct CheckDatabaseConnectionParams { + /// The project whose databases are checked: the one of its root settings and of every + /// nested configuration file. + pub project_key: crate::workspace::ProjectKey, +} #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -33,6 +38,7 @@ pub struct CheckDatabaseConnectionResult { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PullDatabaseDiagnosticsParams { + pub project_key: crate::workspace::ProjectKey, pub categories: RuleCategories, pub max_diagnostics: u32, pub only: Vec, diff --git a/crates/pgls_workspace/src/features/format.rs b/crates/pgls_workspace/src/features/format.rs index e27e3d614..0a50b760f 100644 --- a/crates/pgls_workspace/src/features/format.rs +++ b/crates/pgls_workspace/src/features/format.rs @@ -4,6 +4,7 @@ use pgls_text_size::TextRange; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PullFileFormattingParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, /// Optional range to format. If None, format the entire file. pub range: Option, diff --git a/crates/pgls_workspace/src/features/inlay_hints.rs b/crates/pgls_workspace/src/features/inlay_hints.rs index cea0a03e0..cb753aabe 100644 --- a/crates/pgls_workspace/src/features/inlay_hints.rs +++ b/crates/pgls_workspace/src/features/inlay_hints.rs @@ -4,6 +4,7 @@ use pgls_text_size::{TextRange, TextSize}; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct PullInlayHintsParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, /// The hints of the statements that overlap this range are returned. pub range: TextRange, diff --git a/crates/pgls_workspace/src/features/on_hover.rs b/crates/pgls_workspace/src/features/on_hover.rs index 6779a15b9..7a0f0a1ef 100644 --- a/crates/pgls_workspace/src/features/on_hover.rs +++ b/crates/pgls_workspace/src/features/on_hover.rs @@ -4,6 +4,7 @@ use pgls_text_size::TextSize; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct OnHoverParams { + pub project_key: crate::workspace::ProjectKey, pub path: PgLSPath, pub position: TextSize, } diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index e289a03b4..4d16977f7 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -42,8 +42,27 @@ use crate::{ pub struct ProjectData { /// The root path of the project. This path should be **absolute**. path: PgLSPath, - /// The settings of the project, usually inferred from the configuration file e.g. `biome.json`. + /// The settings of the project, usually inferred from the configuration file at or above its + /// root. settings: Settings, + /// The settings of the configuration files below the root, by their folder, deepest first. + nested: Vec<(PathBuf, Settings)>, +} + +impl ProjectData { + /// The settings that apply to `path`: those of the deepest nested configuration file that + /// contains it, or else the root settings. A relative path is relative to the root. + fn settings_for(&self, path: &Path) -> &Settings { + let path = if path.is_relative() { + Cow::Owned(self.path.join(path)) + } else { + Cow::Borrowed(path) + }; + self.nested + .iter() + .find(|(directory, _)| path.starts_with(directory)) + .map_or(&self.settings, |(_, settings)| settings) + } } #[derive(Debug, Default)] @@ -51,116 +70,76 @@ pub struct ProjectData { pub struct WorkspaceSettings { /// The data of the projects data: WorkspaceData, - /// The ID of the current project. - current_project: ProjectKey, } impl WorkspaceSettings { - pub fn get_current_project_key(&self) -> ProjectKey { - self.current_project + /// Insert a new project using its folder. Use [WorkspaceSettings::get_settings_mut] to + /// retrieve a mutable reference to its [Settings] and manipulate them. + pub fn insert_project(&mut self, workspace_path: impl Into) -> ProjectKey { + let path = PgLSPath::new(workspace_path.into()); + trace!("Insert workspace folder: {:?}", path); + self.data.insert(ProjectData { + path, + settings: Settings::default(), + nested: Vec::new(), + }) } - pub fn get_current_project_path(&self) -> Option<&PgLSPath> { - trace!("Current key {:?}", self.current_project); - self.data - .get(self.current_project) - .as_ref() - .map(|d| &d.path) + /// Remove a project, including its nested settings, using its folder. + pub fn remove_project(&mut self, workspace_path: &Path) { + if let Some(key) = self.get_project_key(workspace_path) { + self.data.remove(key); + } } - pub fn get_current_project_data_mut(&mut self) -> &mut ProjectData { + /// The project registered at exactly `path`. + pub fn get_project_key(&self, path: &Path) -> Option { self.data - .get_mut(self.current_project) - .expect("Current project not configured") + .iter() + .find(|(_, project)| project.path.as_path() == path) + .map(|(key, _)| key) } - /// Retrieves the settings of the current workspace folder - pub fn get_current_settings(&self) -> Option<&Settings> { - trace!("Current key {:?}", self.current_project); - let data = self.data.get(self.current_project); - if let Some(data) = data { - Some(&data.settings) - } else { - None - } + /// The folder of a project. + pub fn get_project_path(&self, key: ProjectKey) -> Option<&PgLSPath> { + self.data.get(key).map(|project| &project.path) } - /// Retrieves a mutable reference of the settings of the current project - pub fn get_current_settings_mut(&mut self) -> &mut Settings { - &mut self - .data - .get_mut(self.current_project) - .expect("You must have at least one workspace.") - .settings + /// The root settings of a project. + pub fn get_settings(&self, key: ProjectKey) -> Option<&Settings> { + self.data.get(key).map(|project| &project.settings) } - /// Register the current project using its unique key - pub fn register_current_project(&mut self, key: ProjectKey) { - self.current_project = key; + /// The root settings of a project. + pub fn get_settings_mut(&mut self, key: ProjectKey) -> Option<&mut Settings> { + self.data.get_mut(key).map(|project| &mut project.settings) } - /// Insert a new project using its folder. Use [WorkspaceSettings::get_current_settings_mut] to retrieve - /// a mutable reference to its [Settings] and manipulate them. - pub fn insert_project(&mut self, workspace_path: impl Into) -> ProjectKey { - let path = PgLSPath::new(workspace_path.into()); - trace!("Insert workspace folder: {:?}", path); - self.data.insert(ProjectData { - path, - settings: Settings::default(), - }) + /// The settings of a project that apply to `path`, so that a file uses the nearest + /// configuration file. + pub fn get_settings_for_path(&self, key: ProjectKey, path: &Path) -> Option<&Settings> { + self.data.get(key).map(|project| project.settings_for(path)) } - /// Remove a project using its folder. - pub fn remove_project(&mut self, workspace_path: &Path) { - let keys_to_remove = { - let mut data = vec![]; - let iter = self.data.iter(); - - for (key, path_to_settings) in iter { - if path_to_settings.path.as_path() == workspace_path { - data.push(key) - } - } - - data - }; - - for key in keys_to_remove { - self.data.remove(key) - } + /// The root settings and the nested settings of a project. + pub fn get_all_settings(&self, key: ProjectKey) -> impl Iterator { + self.data.get(key).into_iter().flat_map(|project| { + std::iter::once(&project.settings) + .chain(project.nested.iter().map(|(_, settings)| settings)) + }) } - /// Checks if the current path belongs to a registered project. - /// - /// If there's a match, and the match **isn't** the current project, it returns the new key. - pub fn path_belongs_to_current_workspace(&self, path: &PgLSPath) -> Option { - if self.data.is_empty() { - return None; - } - trace!("Current key: {:?}", self.current_project); - let iter = self.data.iter(); - for (key, path_to_settings) in iter { - trace!( - "Workspace path {:?}, file path {:?}", - path_to_settings.path, path - ); - trace!("Iter key: {:?}", key); - if key == self.current_project { - continue; - } - if path.strip_prefix(path_to_settings.path.as_path()).is_ok() { - trace!("Update workspace to {:?}", key); - return Some(key); - } + /// Replaces the settings of the configuration files below the root of a project. + pub fn set_nested_settings(&mut self, key: ProjectKey, mut nested: Vec<(PathBuf, Settings)>) { + nested.sort_by(|(a, _), (b, _)| { + b.components() + .count() + .cmp(&a.components().count()) + .then_with(|| a.cmp(b)) + }); + if let Some(project) = self.data.get_mut(key) { + project.nested = nested; } - None - } - - /// Checks if the current path belongs to a registered project. - /// - /// If there's a match, and the match **isn't** the current project, the function will mark the match as the current project. - pub fn set_current_project(&mut self, new_key: ProjectKey) { - self.current_project = new_key; } } @@ -176,12 +155,14 @@ impl<'a> WorkspaceSettingsHandle<'a> { } } - pub(crate) fn settings(&self) -> Option<&Settings> { - self.inner.get_current_settings() + /// The root settings of a project. + pub(crate) fn settings(&self, key: ProjectKey) -> Option<&Settings> { + self.inner.get_settings(key) } - pub(crate) fn path(&self) -> Option<&PgLSPath> { - self.inner.get_current_project_path() + /// The settings of a project that apply to the file at `path`. + pub(crate) fn settings_for(&self, key: ProjectKey, path: &Path) -> Option<&Settings> { + self.inner.get_settings_for_path(key, path) } } @@ -940,10 +921,93 @@ impl Default for FilesSettings { #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; + use pgls_configuration::StringSet; use pgls_configuration::database::PartialDatabaseConfiguration; - use super::DatabaseSettings; + use super::{DatabaseSettings, Settings, WorkspaceSettings}; + use crate::workspace::ProjectKey; + + fn with_linter(enabled: bool) -> Settings { + let mut settings = Settings::default(); + settings.linter.enabled = enabled; + settings + } + + /// The nested settings of the folder that contains the path, or else the root settings. + fn linter_enabled(settings: &WorkspaceSettings, key: ProjectKey, path: &str) -> bool { + settings + .get_settings_for_path(key, Path::new(path)) + .unwrap() + .linter + .enabled + } + + /// A file uses the deepest nested settings that contain it. + #[test] + fn a_path_uses_the_nearest_settings() { + let mut settings = WorkspaceSettings::default(); + let key = settings.insert_project("/repo"); + settings.get_settings_mut(key).unwrap().linter.enabled = false; + settings.set_nested_settings( + key, + vec![ + (PathBuf::from("/repo/db1"), with_linter(true)), + (PathBuf::from("/repo/db1/legacy"), with_linter(false)), + (PathBuf::from("/repo/db10"), with_linter(true)), + ], + ); + + assert!(!linter_enabled(&settings, key, "/repo/a.sql")); + assert!(linter_enabled(&settings, key, "/repo/db1/a.sql")); + assert!(!linter_enabled(&settings, key, "/repo/db1/legacy/x/a.sql")); + assert!(linter_enabled(&settings, key, "/repo/db10/a.sql")); + assert!(!linter_enabled(&settings, key, "/repo/db2/a.sql")); + // A path outside of the project uses its root settings. + assert!(!linter_enabled(&settings, key, "/elsewhere/a.sql")); + } + + #[test] + fn a_relative_path_is_relative_to_the_root_of_the_project() { + let mut settings = WorkspaceSettings::default(); + let key = settings.insert_project("/repo"); + settings.set_nested_settings(key, vec![(PathBuf::from("/repo/db"), with_linter(false))]); + + assert!(!linter_enabled(&settings, key, "db/a.sql")); + assert!(linter_enabled(&settings, key, "a.sql")); + } + + /// Projects are independent: each one has its own nested settings. + #[test] + fn projects_have_their_own_nested_settings() { + let mut settings = WorkspaceSettings::default(); + let one = settings.insert_project("/one"); + let two = settings.insert_project("/two"); + settings.set_nested_settings(one, vec![(PathBuf::from("/one/db"), with_linter(false))]); + + assert!(!linter_enabled(&settings, one, "/one/db/a.sql")); + assert!(linter_enabled(&settings, two, "/one/db/a.sql")); + assert_eq!(settings.get_all_settings(one).count(), 2); + assert_eq!(settings.get_all_settings(two).count(), 1); + } + + #[test] + fn removing_a_project_removes_its_nested_settings() { + let mut settings = WorkspaceSettings::default(); + let key = settings.insert_project("/repo"); + settings.set_nested_settings(key, vec![(PathBuf::from("/repo/db"), with_linter(false))]); + + settings.remove_project(Path::new("/repo")); + + assert_eq!(settings.get_project_key(Path::new("/repo")), None); + assert_eq!(settings.get_all_settings(key).count(), 0); + assert!( + settings + .get_settings_for_path(key, Path::new("/repo/db/a.sql")) + .is_none() + ); + } #[test] fn should_identify_allowed_statement_executions() { diff --git a/crates/pgls_workspace/src/workspace.rs b/crates/pgls_workspace/src/workspace.rs index 0966b1e0f..78a38d70d 100644 --- a/crates/pgls_workspace/src/workspace.rs +++ b/crates/pgls_workspace/src/workspace.rs @@ -36,6 +36,7 @@ pub(crate) use server::document::*; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct OpenFileParams { + pub project_key: ProjectKey, pub path: PgLSPath, pub content: String, pub version: i32, @@ -44,12 +45,14 @@ pub struct OpenFileParams { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct CloseFileParams { + pub project_key: ProjectKey, pub path: PgLSPath, } #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct ChangeFileParams { + pub project_key: ProjectKey, pub path: PgLSPath, pub version: i32, pub content: String, @@ -58,12 +61,15 @@ pub struct ChangeFileParams { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct IsPathIgnoredParams { + pub project_key: ProjectKey, pub pgls_path: PgLSPath, } #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct UpdateSettingsParams { + /// The project whose root settings are updated. + pub project_key: ProjectKey, pub configuration: PartialConfiguration, pub vcs_base_path: Option, pub gitignore_matches: Vec, @@ -73,6 +79,7 @@ pub struct UpdateSettingsParams { #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct GetFileContentParams { + pub project_key: ProjectKey, pub path: PgLSPath, } @@ -91,8 +98,49 @@ pub struct ServerInfo { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct RegisterProjectFolderParams { + /// The folder of the project. A project without a folder contains every path. pub path: Option, - pub set_as_current_workspace: bool, +} + +/// The configuration file of a folder below the root of a project. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct NestedSettingsParams { + /// The folder of the configuration file. Its settings apply to the files below it. + pub directory: PathBuf, + pub configuration: PartialConfiguration, + pub vcs_base_path: Option, + pub gitignore_matches: Vec, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdateNestedSettingsParams { + pub project_key: ProjectKey, + /// Every configuration file below the root of the project. They replace the previous ones. + pub nested: Vec, +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct UpdateNestedSettingsResult { + /// The folders whose settings now apply. The others are ignored by the settings above them, + /// or are not below the root of the project. + pub applied: Vec, + /// The folders whose configuration could not be applied, with the reason. Their files use + /// the default settings, never the ones of the configuration above them. + pub errors: Vec, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct NestedSettingsError { + pub directory: PathBuf, + pub message: String, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -154,21 +202,29 @@ pub trait Workspace: Send + Sync + RefUnwindSafe { params: PullInlayHintsParams, ) -> Result; - /// Register a possible workspace project folder. Returns the key of said project. Use this key when you want to switch to different projects. + /// Register a project folder, or return the key of the project already registered for it. + /// Every request about a file names the project that the file belongs to. fn register_project_folder( &self, params: RegisterProjectFolderParams, ) -> Result; - /// Unregister a workspace project folder. The settings that belong to that project are deleted. + /// Unregister a project folder. Its settings, including the nested ones, are deleted. fn unregister_project_folder( &self, params: UnregisterProjectFolderParams, ) -> Result<(), WorkspaceError>; - /// Update the global settings for this workspace + /// Update the root settings of a project fn update_settings(&self, params: UpdateSettingsParams) -> Result<(), WorkspaceError>; + /// Replace the settings of the configuration files below the root of a project. A file uses + /// the settings of the deepest of them that contains it, or else the root settings. + fn update_nested_settings( + &self, + params: UpdateNestedSettingsParams, + ) -> Result; + /// Add a new file to the workspace fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError>; @@ -200,7 +256,8 @@ pub trait Workspace: Send + Sync + RefUnwindSafe { /// Invalidate the schema cache. /// /// # Arguments - /// * `all` - If true, clears all cached schemas. If false, clears only the current connection's cache. + /// * `all` - If true, clears all cached schemas. If false, clears the schemas loaded from a + /// database connection, but keeps one set from JSON. /// /// The schema will be reloaded lazily on the next operation that requires it. fn invalidate_schema_cache(&self, all: bool) -> Result<(), WorkspaceError>; @@ -229,18 +286,30 @@ where /// automatically on drop pub struct FileGuard<'app, W: Workspace + ?Sized> { workspace: &'app W, + project_key: ProjectKey, path: PgLSPath, } impl<'app, W: Workspace + ?Sized> FileGuard<'app, W> { pub fn open(workspace: &'app W, params: OpenFileParams) -> Result { + let project_key = params.project_key; let path = params.path.clone(); workspace.open_file(params)?; - Ok(Self { workspace, path }) + Ok(Self { + workspace, + project_key, + path, + }) + } + + /// The project that the file belongs to + pub fn project_key(&self) -> ProjectKey { + self.project_key } pub fn change_file(&self, version: i32, content: String) -> Result<(), WorkspaceError> { self.workspace.change_file(ChangeFileParams { + project_key: self.project_key, path: self.path.clone(), version, content, @@ -249,6 +318,7 @@ impl<'app, W: Workspace + ?Sized> FileGuard<'app, W> { pub fn get_file_content(&self) -> Result { self.workspace.get_file_content(GetFileContentParams { + project_key: self.project_key, path: self.path.clone(), }) } @@ -262,6 +332,7 @@ impl<'app, W: Workspace + ?Sized> FileGuard<'app, W> { ) -> Result { self.workspace .pull_file_diagnostics(PullFileDiagnosticsParams { + project_key: self.project_key, path: self.path.clone(), categories, max_diagnostics, @@ -282,6 +353,7 @@ impl Drop for FileGuard<'_, W> { fn drop(&mut self) { self.workspace .close_file(CloseFileParams { + project_key: self.project_key, path: self.path.clone(), }) // `close_file` can only error if the file was already closed, in @@ -295,6 +367,15 @@ new_key_type! { pub struct ProjectKey; } +/// Identifies a project of the workspace. [ProjectKey] is serialized like this. +#[cfg(feature = "schema")] +#[derive(JsonSchema)] +#[allow(dead_code)] +struct SerializedProjectKey { + idx: u32, + version: u32, +} + #[cfg(feature = "schema")] impl JsonSchema for ProjectKey { fn schema_name() -> String { @@ -302,7 +383,7 @@ impl JsonSchema for ProjectKey { } fn json_schema(generator: &mut SchemaGenerator) -> Schema { - ::json_schema(generator) + SerializedProjectKey::json_schema(generator) } } diff --git a/crates/pgls_workspace/src/workspace/client.rs b/crates/pgls_workspace/src/workspace/client.rs index ae5789879..11f4892f4 100644 --- a/crates/pgls_workspace/src/workspace/client.rs +++ b/crates/pgls_workspace/src/workspace/client.rs @@ -143,6 +143,13 @@ where self.request("pgls/update_settings", params) } + fn update_nested_settings( + &self, + params: super::UpdateNestedSettingsParams, + ) -> Result { + self.request("pgls/update_nested_settings", params) + } + fn is_path_ignored(&self, params: IsPathIgnoredParams) -> Result { self.request("pgls/is_path_ignored", params) } diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index b186db9f2..a9334a1b5 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -74,9 +74,9 @@ use crate::{ }; use super::{ - GetFileContentParams, IsPathIgnoredParams, OpenFileParams, ProjectKey, - RegisterProjectFolderParams, ServerInfo, UnregisterProjectFolderParams, UpdateSettingsParams, - Workspace, + GetFileContentParams, IsPathIgnoredParams, NestedSettingsError, OpenFileParams, ProjectKey, + RegisterProjectFolderParams, ServerInfo, UnregisterProjectFolderParams, + UpdateNestedSettingsParams, UpdateNestedSettingsResult, UpdateSettingsParams, Workspace, }; pub use statement_identifier::StatementId; @@ -196,54 +196,21 @@ impl WorkspaceServer { workspace_mut.insert_project(path.clone()) } - /// Retrieves the current project path - fn get_current_project_path(&self) -> Option { - self.workspaces().path().cloned() - } - - /// Sets the current project of the current workspace - fn set_current_project(&self, project_key: ProjectKey) { - let mut workspace = self.workspaces_mut(); - let workspace_mut = workspace.as_mut(); - workspace_mut.set_current_project(project_key); - } - - /// Checks whether the current path belongs to the current project. - /// - /// If there's a match, and the match **isn't** the current project, it returns the new key. - fn path_belongs_to_current_workspace(&self, path: &PgLSPath) -> Option { - let workspaces = self.workspaces(); - workspaces.as_ref().path_belongs_to_current_workspace(path) - } - - fn is_ignored_by_migration_config(&self, path: &Path) -> bool { - let settings = self.workspaces(); - let settings = settings.settings(); - let Some(settings) = settings else { - return false; - }; - settings - .migrations - .as_ref() - .and_then(|migration_settings| { - let ignore_before = migration_settings.after.as_ref()?; - let migrations_dir = migration_settings.path.as_ref()?; - let migration = migration::get_migration(path, migrations_dir)?; - - Some(&migration.sequence_number <= ignore_before) - }) - .unwrap_or(false) - } - - /// Check whether a file is ignored in the top-level config `files.ignore`/`files.include` - fn is_ignored(&self, path: &Path) -> bool { + /// Check whether a file is ignored by the settings of its project that apply to it + fn is_ignored(&self, project_key: ProjectKey, path: &Path) -> bool { // Never ignore config files regardless `include`/`ignore` if self.is_config_file(path) { return false; } + let settings = self.workspaces(); + let Some(settings) = settings.settings_for(project_key, path) else { + return false; + }; + // Apply top-level `include`/`ignore` - self.is_ignored_by_top_level_config(path) || self.is_ignored_by_migration_config(path) + is_ignored_by_files_settings(settings, path) + || is_ignored_by_migration_settings(settings, path) } /// Whether the file is a migration. Migration-only rules don't run on other files. @@ -465,17 +432,16 @@ impl WorkspaceServer { /// and the session follow the statements, so that each statement sees the objects created /// earlier in the file. Each migration section gets a session of its own, and the changes of /// a down section don't carry into the next section. `visit` gets every parsed statement with - /// the catalog and the session as they are before it. + /// the catalog and the session as they are before it. `settings` are the ones of the file. fn walk_statements( &self, + settings: Option<&Settings>, doc: &Document, snapshot: &Arc, end: TextSize, mut visit: impl FnMut(&pgls_analyser::AnalysableStatement, &Catalog, &Session), ) { - let (search_path, sections) = self - .workspaces() - .settings() + let (search_path, sections) = settings .map(|settings| { ( settings.typecheck.search_path.clone(), @@ -549,12 +515,14 @@ impl WorkspaceServer { /// (the body of a SQL function rather than its `CREATE FUNCTION`). fn references_at( &self, + settings: Option<&Settings>, doc: &Document, snapshot: &Arc, position: TextSize, ) -> Vec { let mut innermost: Option<(TextRange, Vec)> = None; self.walk_statements( + settings, doc, snapshot, position + TextSize::from(1), @@ -613,6 +581,7 @@ impl WorkspaceServer { doc: &Document, statement_start: TextSize, snapshot: Arc, + search_path: &[String], ) -> Arc { let statements: Vec<_> = doc .iter(ExecuteStatementMapper) @@ -624,12 +593,6 @@ impl WorkspaceServer { return snapshot; } - let search_path = self - .workspaces() - .settings() - .map(|settings| settings.typecheck.search_path.clone()) - .unwrap_or_default(); - let mut hasher = DefaultHasher::new(); Arc::as_ptr(&snapshot).hash(&mut hasher); search_path.hash(&mut hasher); @@ -646,7 +609,7 @@ impl WorkspaceServer { } let mut catalog = Catalog::new(Some(self.catalog_base(&snapshot))); - let mut session = Session::new(snapshot.expand_search_path(&search_path)); + let mut session = Session::new(snapshot.expand_search_path(search_path)); for (_, ast) in &statements { catalog.apply(ast, session.search_path()); session.apply(ast); @@ -824,34 +787,43 @@ impl WorkspaceServer { .and_then(|s| s.to_str()) .is_some_and(|file_name| ConfigName::file_names().contains(&file_name)) } +} - /// Check whether a file is ignored in the top-level config `files.ignore`/`files.include` - fn is_ignored_by_top_level_config(&self, path: &Path) -> bool { - let settings = self.workspaces(); - let settings = settings.settings(); - let Some(settings) = settings else { - return false; - }; +/// Check whether a file is ignored by `files.ignore`/`files.include` or the VCS ignore file +fn is_ignored_by_files_settings(settings: &Settings, path: &Path) -> bool { + let is_included = settings.files.included_files.is_empty() + || is_dir(path) + || settings.files.included_files.matches_path(path); + !is_included + || settings.files.ignored_files.matches_path(path) + || settings.files.git_ignore.as_ref().is_some_and(|ignore| { + // `matched_path_or_any_parents` panics if `source` is not under the gitignore root. + // This checks excludes absolute paths that are not a prefix of the base root. + if !path.has_root() || path.starts_with(ignore.path()) { + // Because Postgres Language Server passes a list of paths, + // we use `matched_path_or_any_parents` instead of `matched`. + ignore + .matched_path_or_any_parents(path, path.is_dir()) + .is_ignore() + } else { + false + } + }) +} - let is_included = settings.files.included_files.is_empty() - || is_dir(path) - || settings.files.included_files.matches_path(path); - !is_included - || settings.files.ignored_files.matches_path(path) - || settings.files.git_ignore.as_ref().is_some_and(|ignore| { - // `matched_path_or_any_parents` panics if `source` is not under the gitignore root. - // This checks excludes absolute paths that are not a prefix of the base root. - if !path.has_root() || path.starts_with(ignore.path()) { - // Because Postgres Language Server passes a list of paths, - // we use `matched_path_or_any_parents` instead of `matched`. - ignore - .matched_path_or_any_parents(path, path.is_dir()) - .is_ignore() - } else { - false - } - }) - } +/// Check whether a file is a migration before `migrations.after` +fn is_ignored_by_migration_settings(settings: &Settings, path: &Path) -> bool { + settings + .migrations + .as_ref() + .and_then(|migration_settings| { + let ignore_before = migration_settings.after.as_ref()?; + let migrations_dir = migration_settings.path.as_ref()?; + let migration = migration::get_migration(path, migrations_dir)?; + + Some(&migration.sequence_number <= ignore_before) + }) + .unwrap_or(false) } impl Workspace for WorkspaceServer { @@ -859,33 +831,11 @@ impl Workspace for WorkspaceServer { &self, params: RegisterProjectFolderParams, ) -> Result { - let current_project_path = self.get_current_project_path(); - debug!( - "Compare the current project with the new one {:?} {:?} {:?}", - current_project_path, - params.path.as_ref(), - current_project_path.as_deref() != params.path.as_ref() - ); - - let is_new_path = match (current_project_path.as_deref(), params.path.as_ref()) { - (Some(current_project_path), Some(params_path)) => current_project_path != params_path, - (Some(_), None) => { - // If the current project is set, but no path is provided, we assume it's a new project - true - } - _ => true, - }; + let path = params.path.unwrap_or_default(); + let existing = self.workspaces().as_ref().get_project_key(&path); + debug!("Register the project {:?}, known as {:?}", path, existing); - if is_new_path { - let path = params.path.unwrap_or_default(); - let key = self.register_project(path.clone()); - if params.set_as_current_workspace { - self.set_current_project(key); - } - Ok(key) - } else { - Ok(self.workspaces().as_ref().get_current_project_key()) - } + Ok(existing.unwrap_or_else(|| self.register_project(path))) } fn unregister_project_folder( @@ -897,7 +847,7 @@ impl Workspace for WorkspaceServer { Ok(()) } - /// Update the global settings for this workspace + /// Update the root settings of a project /// /// ## Panics /// This function may panic if the internal settings mutex has been poisoned @@ -908,7 +858,8 @@ impl Workspace for WorkspaceServer { workspace .as_mut() - .get_current_settings_mut() + .get_settings_mut(params.project_key) + .ok_or_else(WorkspaceError::no_project)? .merge_with_configuration( params.configuration, params.workspace_directory, @@ -919,8 +870,75 @@ impl Workspace for WorkspaceServer { Ok(()) } + #[tracing::instrument(level = "trace", skip(self), err)] + fn update_nested_settings( + &self, + params: UpdateNestedSettingsParams, + ) -> Result { + let mut entries = params.nested; + // The settings above a folder decide whether it is ignored, so start with the shallowest. + entries.sort_by(|a, b| { + (a.directory.components().count(), &a.directory) + .cmp(&(b.directory.components().count(), &b.directory)) + }); + + let mut result = UpdateNestedSettingsResult::default(); + let mut nested: Vec<(PathBuf, Settings)> = Vec::new(); + { + let workspace = self.workspaces(); + let workspace = workspace.as_ref(); + let project_path = workspace + .get_project_path(params.project_key) + .ok_or_else(WorkspaceError::no_project)?; + let root = workspace + .get_settings(params.project_key) + .ok_or_else(WorkspaceError::no_project)?; + + for entry in entries { + let directory = entry.directory; + if directory == project_path.as_path() + || !directory.starts_with(project_path.as_path()) + || nested.iter().any(|(applied, _)| applied == &directory) + { + continue; + } + let above = nested + .iter() + .rev() + .find(|(applied, _)| directory.starts_with(applied)) + .map_or(root, |(_, settings)| settings); + if is_ignored_by_files_settings(above, &directory) { + continue; + } + + // A configuration that cannot be applied falls back to the defaults rather than to + // the settings above it, which could point at another database. + let mut settings = Settings::default(); + if let Err(error) = settings.merge_with_configuration( + entry.configuration, + Some(directory.clone()), + entry.vcs_base_path, + entry.gitignore_matches.as_slice(), + ) { + result.errors.push(NestedSettingsError { + directory: directory.clone(), + message: error.to_string(), + }); + settings = Settings::default(); + } + result.applied.push(directory.clone()); + nested.push((directory, settings)); + } + } + + self.workspaces_mut() + .as_mut() + .set_nested_settings(params.project_key, nested); + Ok(result) + } + /// Add a new file to the workspace - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] #[tracing::instrument(level = "info", skip_all, fields(path = params.path.as_path().as_os_str().to_str()), err)] fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError> { let mut documents = self.documents.write().unwrap(); @@ -928,15 +946,11 @@ impl Workspace for WorkspaceServer { .entry(params.path.clone()) .or_insert_with(|| Document::new(params.content, params.version)); - if let Some(project_key) = self.path_belongs_to_current_workspace(¶ms.path) { - self.set_current_project(project_key); - } - Ok(()) } /// Remove a file from the workspace - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn close_file(&self, params: super::CloseFileParams) -> Result<(), WorkspaceError> { let mut documents = self.documents.write().unwrap(); documents @@ -951,7 +965,7 @@ impl Workspace for WorkspaceServer { path = params.path.as_os_str().to_str(), version = params.version ), err)] - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn change_file(&self, params: super::ChangeFileParams) -> Result<(), WorkspaceError> { let mut documents = self.documents.write().unwrap(); @@ -973,7 +987,7 @@ impl Workspace for WorkspaceServer { None } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn get_file_content(&self, params: GetFileContentParams) -> Result { let documents = self.documents.read().unwrap(); let document = documents @@ -983,10 +997,10 @@ impl Workspace for WorkspaceServer { } fn is_path_ignored(&self, params: IsPathIgnoredParams) -> Result { - Ok(self.is_ignored(params.pgls_path.as_path())) + Ok(self.is_ignored(params.project_key, params.pgls_path.as_path())) } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn pull_code_actions( &self, params: CodeActionsParams, @@ -997,7 +1011,7 @@ impl Workspace for WorkspaceServer { .ok_or(WorkspaceError::not_found())?; let settings = self.workspaces(); - let settings = settings.settings(); + let settings = settings.settings_for(params.project_key, params.path.as_path()); let disabled_reason = match settings { Some(settings) if settings.db.allow_statement_executions => None, @@ -1060,7 +1074,7 @@ impl Workspace for WorkspaceServer { Ok(CodeActionsResult { actions }) } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn fix_file(&self, params: FixFileParams) -> Result { /// Fixes can enable other fixes, e.g. `CONCURRENTLY` enables `IF NOT EXISTS`, and fixes /// that overlap are applied one after the other. @@ -1073,7 +1087,8 @@ impl Workspace for WorkspaceServer { let mut code = doc.get_document_content().to_string(); let settings = self.workspaces(); - let Some(settings) = settings.settings() else { + let Some(settings) = settings.settings_for(params.project_key, params.path.as_path()) + else { return Ok(FixFileResult { code, fixes_applied: 0, @@ -1132,7 +1147,7 @@ impl Workspace for WorkspaceServer { } #[cfg(feature = "db")] - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn execute_statement( &self, params: ExecuteStatementParams, @@ -1159,13 +1174,16 @@ impl Workspace for WorkspaceServer { }; let settings = self.workspaces(); - let Some(result) = settings.settings().and_then(|settings| { - self.connection.with_pool(&settings.db, |pool| { - let pool = pool.clone(); - run_async(async move { pool.execute(sqlx::query(&content)).await })? - .map_err(WorkspaceError::from) + let Some(result) = settings + .settings_for(params.project_key, params.path.as_path()) + .and_then(|settings| { + self.connection.with_pool(&settings.db, |pool| { + let pool = pool.clone(); + run_async(async move { pool.execute(sqlx::query(&content)).await })? + .map_err(WorkspaceError::from) + }) }) - }) else { + else { return Ok(ExecuteStatementResult { message: "No database connection available.".into(), }); @@ -1181,7 +1199,7 @@ impl Workspace for WorkspaceServer { } #[cfg(not(feature = "db"))] - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] #[allow(unused_variables)] fn execute_statement( &self, @@ -1200,15 +1218,8 @@ impl Workspace for WorkspaceServer { // so clearing the json schema keeps behavior consistent self.snapshot.clear_all(); } else { - // Only clear current connection if one exists - let settings = self.workspaces(); - if let Some(settings) = settings.settings() { - let _ = self.connection.with_pool(&settings.db, |pool| { - self.snapshot.clear_connection(pool); - Ok::<(), WorkspaceError>(()) - }); - } - // If no connection, nothing to clear - just return Ok + // Every project can have its own database, so clear the schemas of all connections + self.snapshot.clear_all_connections(); } Ok(()) } @@ -1219,14 +1230,14 @@ impl Workspace for WorkspaceServer { Ok(()) } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn pull_file_diagnostics( &self, params: PullFileDiagnosticsParams, ) -> Result { let settings = self.workspaces(); - let settings = match settings.settings() { + let settings = match settings.settings_for(params.project_key, params.path.as_path()) { Some(settings) => settings, None => { // return an empty result if no settings are available @@ -1360,7 +1371,7 @@ impl Workspace for WorkspaceServer { params: crate::features::diagnostics::PullDatabaseDiagnosticsParams, ) -> Result { let settings = self.workspaces(); - let Some(settings) = settings.settings() else { + let Some(settings) = settings.settings(params.project_key) else { debug!("No settings available. Returning empty diagnostics."); return Ok(PullDiagnosticsResult::default()); }; @@ -1423,14 +1434,14 @@ impl Workspace for WorkspaceServer { }) } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] fn pull_file_formatting( &self, params: PullFileFormattingParams, ) -> Result { let settings = self.workspaces(); - let settings = match settings.settings() { + let settings = match settings.settings_for(params.project_key, params.path.as_path()) { Some(settings) => settings, None => { return Ok(PullFormattingResult::default()); @@ -1633,25 +1644,25 @@ impl Workspace for WorkspaceServer { #[cfg(feature = "db")] fn check_database_connection( &self, - _params: CheckDatabaseConnectionParams, + params: CheckDatabaseConnectionParams, ) -> Result { + // Every configuration file of the project can have its own database, so check all of them. let settings = self.workspaces(); - let Some(settings) = settings.settings() else { - return Ok(CheckDatabaseConnectionResult::default()); - }; - - let result = self - .connection - .try_with_pool(&settings.db, |pool| self.snapshot.load(pool).map(|_| ())); - match result { - None | Some(Ok(())) => Ok(CheckDatabaseConnectionResult::default()), - Some(Err(err @ WorkspaceError::DatabaseConnectionError(_))) => { - Ok(CheckDatabaseConnectionResult { - error: Some(err.to_string()), - }) + for settings in settings.as_ref().get_all_settings(params.project_key) { + let result = self + .connection + .try_with_pool(&settings.db, |pool| self.snapshot.load(pool).map(|_| ())); + match result { + None | Some(Ok(())) => {} + Some(Err(err @ WorkspaceError::DatabaseConnectionError(_))) => { + return Ok(CheckDatabaseConnectionResult { + error: Some(err.to_string()), + }); + } + Some(Err(err)) => return Err(err), } - Some(Err(err)) => Err(err), } + Ok(CheckDatabaseConnectionResult::default()) } #[cfg(not(feature = "db"))] @@ -1671,7 +1682,7 @@ impl Workspace for WorkspaceServer { Ok(PullDiagnosticsResult::default()) } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] #[tracing::instrument(level = "debug", skip_all, fields( path = params.path.as_os_str().to_str(), position = params.position.to_string() @@ -1685,10 +1696,15 @@ impl Workspace for WorkspaceServer { .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; + let settings = self.workspaces(); + let search_path = settings + .settings_for(params.project_key, params.path.as_path()) + .map(|settings| settings.typecheck.search_path.clone()) + .unwrap_or_default(); + #[cfg(feature = "db")] - let Some(snapshot) = self - .workspaces() - .settings() + let Some(snapshot) = settings + .settings_for(params.project_key, params.path.as_path()) .and_then(|settings| { self.connection .with_pool(&settings.db, |pool| self.snapshot.load(pool)) @@ -1712,7 +1728,8 @@ impl Workspace for WorkspaceServer { } Some((id, range, cst)) => { let position = params.position - range.start(); - let snapshot = self.snapshot_before(parsed_doc, range.start(), snapshot); + let snapshot = + self.snapshot_before(parsed_doc, range.start(), snapshot, &search_path); let items = pgls_completions::complete(pgls_completions::CompletionParams { position, @@ -1726,7 +1743,7 @@ impl Workspace for WorkspaceServer { } } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] #[tracing::instrument(level = "debug", skip_all, fields( path = params.path.as_os_str().to_str(), position = params.position.to_string() @@ -1737,10 +1754,14 @@ impl Workspace for WorkspaceServer { .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; + let settings = self.workspaces(); + let settings = settings.settings_for(params.project_key, params.path.as_path()); + let search_path = settings + .map(|settings| settings.typecheck.search_path.clone()) + .unwrap_or_default(); + #[cfg(feature = "db")] - let Some(snapshot) = self - .workspaces() - .settings() + let Some(snapshot) = settings .and_then(|settings| { self.connection .with_pool(&settings.db, |pool| self.snapshot.load(pool)) @@ -1767,8 +1788,8 @@ impl Workspace for WorkspaceServer { { Some((stmt_id, range, ts_tree, maybe_ast)) => { let position_in_stmt = params.position - range.start(); - let references = self.references_at(doc, &snapshot, params.position); - let snapshot = self.snapshot_before(doc, range.start(), snapshot); + let references = self.references_at(settings, doc, &snapshot, params.position); + let snapshot = self.snapshot_before(doc, range.start(), snapshot, &search_path); let markdown_blocks = pgls_hover::on_hover(pgls_hover::OnHoverParams { ts_tree: &ts_tree, @@ -1786,7 +1807,7 @@ impl Workspace for WorkspaceServer { } } - #[ignored_path(path=¶ms.path)] + #[ignored_path(project=params.project_key, path=¶ms.path)] #[tracing::instrument(level = "debug", skip_all, fields( path = params.path.as_os_str().to_str(), range = ?params.range @@ -1800,10 +1821,11 @@ impl Workspace for WorkspaceServer { .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; + let settings = self.workspaces(); + let settings = settings.settings_for(params.project_key, params.path.as_path()); + #[cfg(feature = "db")] - let Some(snapshot) = self - .workspaces() - .settings() + let Some(snapshot) = settings .and_then(|settings| { self.connection .with_pool(&settings.db, |pool| self.snapshot.load(pool)) @@ -1822,6 +1844,7 @@ impl Workspace for WorkspaceServer { let mut hints = Vec::new(); self.walk_statements( + settings, doc, &snapshot, params.range.end(), diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index 8f0e857a0..f9883069a 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -1,4 +1,6 @@ use std::{ + path::{Path, PathBuf}, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -12,15 +14,17 @@ use pgls_configuration::{ migrations::{PartialMigrationsConfiguration, SectionMarkers}, }; +use pgls_configuration::ConfigurationPathHint; #[cfg(not(target_os = "windows"))] use pgls_configuration::plpgsql_check::PartialPlPgSqlCheckConfiguration; -use pgls_diagnostics::Diagnostic; -use pgls_fs::PgLSPath; +use pgls_diagnostics::{Diagnostic, Severity}; +use pgls_fs::{ConfigName, FileSystem, MemoryFileSystem, PgLSPath}; use pgls_text_size::TextRange; use sqlx::{Executor, PgPool}; use crate::{ - Workspace, WorkspaceError, + DynRef, Workspace, WorkspaceError, + configuration::{apply_nested_configurations, load_configuration}, features::code_actions::{ CodeActionKind, ExecuteStatementResult, FixFileMode, FixFileParams, TextEdit, }, @@ -28,29 +32,30 @@ use crate::{ features::inlay_hints::{InlayHint, InlayHintKind, PullInlayHintsParams}, features::on_hover::OnHoverParams, workspace::{ - OpenFileParams, RegisterProjectFolderParams, StatementId, UpdateSettingsParams, + IsPathIgnoredParams, OpenFileParams, ProjectKey, RegisterProjectFolderParams, StatementId, + UnregisterProjectFolderParams, UpdateNestedSettingsParams, UpdateSettingsParams, server::WorkspaceServer, }, }; +/// A workspace with a project without a folder, which every file belongs to. fn get_test_workspace( partial_config: Option, -) -> Result { +) -> Result<(WorkspaceServer, ProjectKey), WorkspaceError> { let workspace = WorkspaceServer::new(); - workspace.register_project_folder(RegisterProjectFolderParams { - path: None, - set_as_current_workspace: true, - })?; + let project_key = + workspace.register_project_folder(RegisterProjectFolderParams { path: None })?; workspace.update_settings(UpdateSettingsParams { + project_key, configuration: partial_config.unwrap_or(PartialConfiguration::init()), gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, })?; - Ok(workspace) + Ok((workspace, project_key)) } #[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] @@ -70,7 +75,8 @@ async fn test_diagnostics(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = r#" @@ -86,6 +92,7 @@ async fn test_diagnostics(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -94,6 +101,7 @@ async fn test_diagnostics(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -120,7 +128,8 @@ async fn test_diagnostics(test_db: PgPool) { #[test] fn test_suppresses_multiline_alter_table_drop_column() { - let workspace = get_test_workspace(None).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(None).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = r#"ALTER TABLE users @@ -129,6 +138,7 @@ fn test_suppresses_multiline_alter_table_drop_column() { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -137,6 +147,7 @@ fn test_suppresses_multiline_alter_table_drop_column() { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -168,11 +179,13 @@ fn migration_diagnostics( }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("20250301120000_migration.sql"); workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -181,6 +194,7 @@ fn migration_diagnostics( let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -272,7 +286,8 @@ DROP INDEX CONCURRENTLY IF EXISTS users_name_idx; #[test] fn test_legacy_suppression_still_suppresses_and_warns() { - let workspace = get_test_workspace(None).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(None).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = r#"-- pgls-ignore lint/safety/banDropColumn @@ -280,6 +295,7 @@ ALTER TABLE users DROP COLUMN deprecated_field;"#; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -288,6 +304,7 @@ ALTER TABLE users DROP COLUMN deprecated_field;"#; let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -325,13 +342,15 @@ fn test_unreachable_database_diagnostics_keep_static_results_and_back_off() { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = "drop table non_existing_table;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -341,6 +360,7 @@ fn test_unreachable_database_diagnostics_keep_static_results_and_back_off() { let pull_diagnostics = || { workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -392,7 +412,8 @@ async fn test_syntax_error(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = r#" @@ -401,6 +422,7 @@ async fn test_syntax_error(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -409,6 +431,7 @@ async fn test_syntax_error(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -432,12 +455,14 @@ async fn test_syntax_error(test_db: PgPool) { #[tokio::test] async fn named_parameter_normalization_preserves_syntax_diagnostic_offsets() { - let workspace = get_test_workspace(None).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(None).expect("Unable to create test workspace"); let path = PgLSPath::new("named-parameter.sql"); let content = "SELECT * FROM :very_long_schema.existing_table AS t seect 1;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -446,6 +471,7 @@ async fn named_parameter_normalization_preserves_syntax_diagnostic_offsets() { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path, categories: RuleCategories::all(), max_diagnostics: 100, @@ -492,12 +518,14 @@ async fn format_preserves_named_parameters() { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("named-parameter-format.sql"); let content = "SELECT x FROM :raw_data.t WHERE t.a = :'agen_code' AND t.b = :var_date;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -505,7 +533,11 @@ async fn format_preserves_named_parameters() { .expect("Unable to open test file"); let result = workspace - .pull_file_formatting(PullFileFormattingParams { path, range: None }) + .pull_file_formatting(PullFileFormattingParams { + project_key, + path, + range: None, + }) .expect("Unable to format file"); assert!(result.formatted.contains(":raw_data.t")); @@ -525,7 +557,8 @@ async fn correctly_ignores_files() { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = r#" @@ -534,6 +567,7 @@ async fn correctly_ignores_files() { let diagnostics_result = workspace.pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -546,13 +580,16 @@ async fn correctly_ignores_files() { .is_ok_and(|res| res.diagnostics.is_empty() && res.skipped_diagnostics == 0) ); - let close_file_result = - workspace.close_file(crate::workspace::CloseFileParams { path: path.clone() }); + let close_file_result = workspace.close_file(crate::workspace::CloseFileParams { + project_key, + path: path.clone(), + }); assert!(close_file_result.is_ok()); let execute_statement_result = workspace.execute_statement(crate::workspace::ExecuteStatementParams { + project_key, path: path.clone(), statement_id: StatementId::Root { content: Arc::from(content), @@ -580,7 +617,8 @@ async fn test_dedupe_diagnostics(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -601,6 +639,7 @@ async fn test_dedupe_diagnostics(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -609,6 +648,7 @@ async fn test_dedupe_diagnostics(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -652,7 +692,8 @@ async fn test_plpgsql_syntax_error_without_plpgsql_check(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -672,6 +713,7 @@ async fn test_plpgsql_syntax_error_without_plpgsql_check(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -680,6 +722,7 @@ async fn test_plpgsql_syntax_error_without_plpgsql_check(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -697,7 +740,8 @@ async fn test_plpgsql_syntax_error_without_plpgsql_check(test_db: PgPool) { async fn test_plpgsql_assign_composite_types(test_db: PgPool) { let conf = PartialConfiguration::init(); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -726,6 +770,7 @@ async fn test_plpgsql_assign_composite_types(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -734,6 +779,7 @@ async fn test_plpgsql_assign_composite_types(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -763,7 +809,8 @@ async fn test_positional_params(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -780,6 +827,7 @@ async fn test_positional_params(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -788,6 +836,7 @@ async fn test_positional_params(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -818,7 +867,8 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -841,6 +891,7 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -849,6 +900,7 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -868,6 +920,7 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { ); let _ = workspace.update_settings(UpdateSettingsParams { + project_key, configuration: PartialConfiguration { plpgsql_check: Some(PartialPlPgSqlCheckConfiguration { enabled: Some(false), @@ -882,6 +935,7 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -918,7 +972,8 @@ async fn test_disable_typecheck(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -934,6 +989,7 @@ async fn test_disable_typecheck(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -942,6 +998,7 @@ async fn test_disable_typecheck(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -958,6 +1015,7 @@ async fn test_disable_typecheck(test_db: PgPool) { ); let _ = workspace.update_settings(UpdateSettingsParams { + project_key, configuration: PartialConfiguration { typecheck: Some(PartialTypecheckConfiguration { enabled: Some(false), @@ -972,6 +1030,7 @@ async fn test_disable_typecheck(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -1007,12 +1066,14 @@ async fn test_create_as_typecheck_diagnostic_offsets(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = "SELECT 1;\nCREATE VIEW v1 AS SELECT t.nope FROM t;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1021,6 +1082,7 @@ async fn test_create_as_typecheck_diagnostic_offsets(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path, categories: RuleCategories::all(), max_diagnostics: 100, @@ -1085,7 +1147,8 @@ async fn named_identifier_params_skip_only_affected_typecheck(test_db: PgPool) { .await .expect("test table setup must succeed"); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("named-identifier-parameter.sql"); let content = r#" SELECT * FROM :raw_data.documents; @@ -1094,6 +1157,7 @@ SELECT missing_column FROM named_parameter_typecheck_users; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1102,6 +1166,7 @@ SELECT missing_column FROM named_parameter_typecheck_users; let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path, categories: RuleCategories::all(), max_diagnostics: 100, @@ -1154,10 +1219,12 @@ fn typecheck_diagnostics( }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1165,6 +1232,7 @@ fn typecheck_diagnostics( .expect("Unable to open test file"); workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path, categories: RuleCategories::all(), max_diagnostics: 100, @@ -1276,7 +1344,8 @@ $$; async fn test_named_params(_test_db: PgPool) { let conf = PartialConfiguration::init(); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -1292,6 +1361,7 @@ FOR NO KEY UPDATE; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1300,6 +1370,7 @@ FOR NO KEY UPDATE; let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -1330,7 +1401,8 @@ async fn test_format_keeps_sql_function_body_intact() { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = @@ -1338,6 +1410,7 @@ async fn test_format_keeps_sql_function_body_intact() { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1346,6 +1419,7 @@ async fn test_format_keeps_sql_function_body_intact() { let result = workspace .pull_file_formatting(PullFileFormattingParams { + project_key, path: path.clone(), range: None, }) @@ -1375,11 +1449,13 @@ fn format_content(content: &str) -> String { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1387,7 +1463,11 @@ fn format_content(content: &str) -> String { .expect("Unable to open test file"); workspace - .pull_file_formatting(PullFileFormattingParams { path, range: None }) + .pull_file_formatting(PullFileFormattingParams { + project_key, + path, + range: None, + }) .expect("Unable to pull formatting") .formatted } @@ -1516,7 +1596,8 @@ async fn test_cstyle_comments(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -1533,6 +1614,7 @@ async fn test_cstyle_comments(test_db: PgPool) { workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1541,6 +1623,7 @@ async fn test_cstyle_comments(test_db: PgPool) { let diagnostics = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -1588,11 +1671,12 @@ async fn test_search_path_configuration(test_db: PgPool) { // without glob { - let workspace = + let (workspace, project_key) = get_test_workspace(Some(glob_conf.clone())).expect("Unable to create test workspace"); workspace .open_file(OpenFileParams { + project_key, path: path_glob.clone(), content: file_content.into(), version: 1, @@ -1601,6 +1685,7 @@ async fn test_search_path_configuration(test_db: PgPool) { let diagnostics_glob = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path_glob.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -1634,11 +1719,12 @@ async fn test_search_path_configuration(test_db: PgPool) { }); // checking with the pattern should yield no diagnostics { - let workspace = + let (workspace, project_key) = get_test_workspace(Some(glob_conf.clone())).expect("Unable to create test workspace"); workspace .open_file(OpenFileParams { + project_key, path: path_glob.clone(), content: file_content.into(), version: 1, @@ -1647,6 +1733,7 @@ async fn test_search_path_configuration(test_db: PgPool) { let diagnostics_glob = workspace .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key, path: path_glob.clone(), categories: RuleCategories::all(), max_diagnostics: 100, @@ -1692,7 +1779,8 @@ async fn test_multi_line_completions(test_db: PgPool) { ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); @@ -1714,6 +1802,7 @@ select * from auth.users; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: sanitized_content, version: 1, @@ -1722,6 +1811,7 @@ select * from auth.users; let completions = workspace .get_completions(crate::workspace::GetCompletionsParams { + project_key, path: path.clone(), position, }) @@ -1761,7 +1851,8 @@ async fn completions_see_the_ddl_before_the_statement(test_db: PgPool) { }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let labels = |content: &str| { @@ -1771,6 +1862,7 @@ async fn completions_see_the_ddl_before_the_statement(test_db: PgPool) { .expect("Unable to find cursor position in test content"); workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.replace('|', ""), version: 1, @@ -1778,12 +1870,16 @@ async fn completions_see_the_ddl_before_the_statement(test_db: PgPool) { .expect("Unable to open test file"); let completions = workspace .get_completions(crate::workspace::GetCompletionsParams { + project_key, path: path.clone(), position, }) .expect("Unable to request completions"); workspace - .close_file(crate::workspace::CloseFileParams { path: path.clone() }) + .close_file(crate::workspace::CloseFileParams { + project_key, + path: path.clone(), + }) .expect("Unable to close test file"); completions .items @@ -1821,7 +1917,7 @@ fn is_typecheck(diagnostic: &pgls_diagnostics::serde::Diagnostic) -> bool { /// A workspace that reads dbmate section markers, so a `transaction:false` section runs its /// statements outside a transaction, where `CONCURRENTLY` is allowed. -fn dbmate_workspace() -> WorkspaceServer { +fn dbmate_workspace() -> (WorkspaceServer, ProjectKey) { let mut conf = PartialConfiguration::init(); conf.merge_with(PartialConfiguration { migrations: Some(PartialMigrationsConfiguration { @@ -1835,12 +1931,13 @@ fn dbmate_workspace() -> WorkspaceServer { #[test] fn test_code_actions_fix_and_suppress_lint_diagnostics() { - let workspace = dbmate_workspace(); + let (workspace, project_key) = dbmate_workspace(); let path = PgLSPath::new("test.sql"); let content = "-- migrate:up transaction:false\nselect 1;\n drop index users_email_idx;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1849,6 +1946,7 @@ fn test_code_actions_fix_and_suppress_lint_diagnostics() { let actions = workspace .pull_code_actions(crate::features::code_actions::CodeActionsParams { + project_key, path: path.clone(), cursor_position: 47.into(), only: vec!["safety/requireConcurrentIndexDeletion".parse().unwrap()], @@ -1895,12 +1993,14 @@ fn test_code_actions_fix_and_suppress_lint_diagnostics() { fn test_no_concurrently_fix_in_a_transaction() { // Without section markers, a file with several statements is assumed to run in a // transaction, where `DROP INDEX CONCURRENTLY` fails. Only the suppression is offered. - let workspace = get_test_workspace(None).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(None).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = "select 1;\n drop index users_email_idx;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1909,6 +2009,7 @@ fn test_no_concurrently_fix_in_a_transaction() { let titles: Vec<_> = workspace .pull_code_actions(crate::features::code_actions::CodeActionsParams { + project_key, path: path.clone(), cursor_position: 15.into(), only: vec!["safety/requireConcurrentIndexDeletion".parse().unwrap()], @@ -1928,12 +2029,13 @@ fn test_no_concurrently_fix_in_a_transaction() { #[test] fn test_fix_file() { - let workspace = dbmate_workspace(); + let (workspace, project_key) = dbmate_workspace(); let path = PgLSPath::new("test.sql"); let content = "-- migrate:up transaction:false\ndrop index users_email_idx;\n-- pgls-ignore requireConcurrentIndexDeletion\ndrop index users_name_idx;"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -1943,6 +2045,7 @@ fn test_fix_file() { let fix = |fix_file_mode| { workspace .fix_file(FixFileParams { + project_key, path: path.clone(), fix_file_mode, only: vec![ @@ -1995,12 +2098,14 @@ async fn test_inlay_hints(test_db: PgPool) { }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = "create function file_point(a int, b int) returns int language sql as 'select a + b';\nselect db_point(1, y => 2), file_point(3, 4);\nselect db_point(5, 6);\n"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -2011,6 +2116,7 @@ async fn test_inlay_hints(test_db: PgPool) { let third_line = content.rfind("select").unwrap(); let hints = workspace .pull_inlay_hints(PullInlayHintsParams { + project_key, path: path.clone(), range: TextRange::new( u32::try_from(second_line).unwrap().into(), @@ -2069,7 +2175,8 @@ async fn test_hover_shows_resolved_references(test_db: PgPool) { }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("test.sql"); let content = "create function file_pick(i int) returns int language sql as 'select i';\n\ @@ -2081,6 +2188,7 @@ async fn test_hover_shows_resolved_references(test_db: PgPool) { select label as ambiguous from hover_items, hover_tags;\n"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -2091,6 +2199,7 @@ async fn test_hover_shows_resolved_references(test_db: PgPool) { let offset = content.find(needle).expect("needle in content") + 2; workspace .on_hover(OnHoverParams { + project_key, path: path.clone(), position: u32::try_from(offset).unwrap().into(), }) @@ -2142,7 +2251,8 @@ async fn test_inlay_hints_follow_migration_sections(test_db: PgPool) { }), ..Default::default() }); - let workspace = get_test_workspace(Some(conf)).expect("Unable to create test workspace"); + let (workspace, project_key) = + get_test_workspace(Some(conf)).expect("Unable to create test workspace"); let path = PgLSPath::new("20250301120000_migration.sql"); let content = "-- migrate:up\n\ @@ -2154,6 +2264,7 @@ async fn test_inlay_hints_follow_migration_sections(test_db: PgPool) { select up_point(5, 6), down_point(7, 8);\n"; workspace .open_file(OpenFileParams { + project_key, path: path.clone(), content: content.into(), version: 1, @@ -2162,6 +2273,7 @@ async fn test_inlay_hints_follow_migration_sections(test_db: PgPool) { let hints = workspace .pull_inlay_hints(PullInlayHintsParams { + project_key, path: path.clone(), range: TextRange::new(0.into(), u32::try_from(content.len()).unwrap().into()), }) @@ -2179,3 +2291,450 @@ async fn test_inlay_hints_follow_migration_sections(test_db: PgPool) { .collect(); assert_eq!(labels, ["x:1", "y:2", "a:3", "b:4", "x:5", "y:6"]); } + +/// A project of a workspace, set up like the language server does it. +struct TestProject { + workspace: Rc, + project_key: ProjectKey, +} + +impl TestProject { + /// A project at `root`, with the configuration file at or above it as its root settings and + /// every configuration file below it as nested settings. Returns the folders of the nested + /// settings. + fn new(fs: &DynRef<'_, dyn FileSystem>, root: &Path) -> (Self, Vec) { + let project = Self::without_nested_configurations(fs, root); + let directories = project.apply_nested_configurations(fs, root); + (project, directories) + } + + /// A project at `root`, with the configuration file at or above it as its root settings. + fn without_nested_configurations(fs: &DynRef<'_, dyn FileSystem>, root: &Path) -> Self { + let workspace = WorkspaceServer::new(); + let project_key = workspace + .register_project_folder(RegisterProjectFolderParams { + path: Some(root.to_path_buf()), + }) + .unwrap(); + workspace + .update_settings(UpdateSettingsParams { + project_key, + configuration: load_configuration( + fs, + ConfigurationPathHint::FromWorkspace(root.to_path_buf()), + ) + .unwrap() + .configuration, + gitignore_matches: vec![], + vcs_base_path: None, + workspace_directory: Some(root.to_path_buf()), + }) + .unwrap(); + + Self { + workspace: Rc::new(workspace), + project_key, + } + } + + /// Another project of the same workspace. + fn other(&self, project_key: ProjectKey) -> Self { + Self { + workspace: Rc::clone(&self.workspace), + project_key, + } + } + + /// Applies the configuration files below `root`, which have to load. Returns their folders. + fn apply_nested_configurations( + &self, + fs: &DynRef<'_, dyn FileSystem>, + root: &Path, + ) -> Vec { + apply_nested_configurations( + &*self.workspace, + fs, + self.project_key, + root, + |configuration| configuration, + ) + .unwrap() + .into_iter() + .map(|nested| { + assert!(nested.result.is_ok(), "{:?}", nested.result); + nested.directory + }) + .collect() + } + + /// The severities of the `banDropColumn` diagnostics of a file with a `DROP COLUMN`. + fn severities(&self, path: impl AsRef) -> Vec { + self.severities_of(path, "alter table users drop column name;") + } + + /// The severities of the `banDropColumn` diagnostics of a file with the given content. + fn severities_of(&self, path: impl AsRef, content: &str) -> Vec { + let path = PgLSPath::new(path.as_ref()); + self.workspace + .open_file(OpenFileParams { + project_key: self.project_key, + path: path.clone(), + content: content.into(), + version: 1, + }) + .expect("Unable to open test file"); + + self.workspace + .pull_file_diagnostics(crate::workspace::PullFileDiagnosticsParams { + project_key: self.project_key, + path, + categories: RuleCategories::all(), + max_diagnostics: 100, + only: vec![], + skip: vec![], + }) + .expect("Unable to pull diagnostics") + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic + .category() + .is_some_and(|category| category.name() == "lint/banDropColumn") + }) + .map(|diagnostic| diagnostic.severity()) + .collect() + } + + fn is_ignored(&self, path: impl AsRef) -> bool { + self.workspace + .is_path_ignored(IsPathIgnoredParams { + project_key: self.project_key, + pgls_path: PgLSPath::new(path.as_ref()), + }) + .unwrap() + } +} + +/// A memory file system with a configuration file in each of the given folders. +fn file_system_with_configurations(configurations: &[(&str, &str)]) -> MemoryFileSystem { + let mut fs = MemoryFileSystem::default(); + for (directory, configuration) in configurations { + fs.insert( + Path::new(directory).join(ConfigName::pgls_jsonc()), + *configuration, + ); + } + fs +} + +#[test] +fn files_use_the_nearest_configuration_file() { + let fs = file_system_with_configurations(&[ + ("/repo", r#"{ "files": { "ignore": ["legacy"] } }"#), + ("/repo/db1", r#"{ "linter": { "enabled": false } }"#), + ( + "/repo/db2", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ("/repo/db2/nested", r#"{ "linter": { "enabled": false } }"#), + // Inside a directory the root project ignores. + ("/repo/legacy", r#"{ "linter": { "enabled": false } }"#), + // Never looked at. + ( + "/repo/node_modules/pkg", + r#"{ "linter": { "enabled": false } }"#, + ), + ]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + + let (project, directories) = TestProject::new(&fs, Path::new("/repo")); + assert_eq!( + directories, + [ + PathBuf::from("/repo/db1"), + PathBuf::from("/repo/db2"), + PathBuf::from("/repo/db2/nested"), + ] + ); + + assert_eq!(project.severities("/repo/a.sql"), [Severity::Warning]); + assert_eq!(project.severities("/repo/db1/a.sql"), []); + assert_eq!(project.severities("/repo/db2/sub/a.sql"), [Severity::Error]); + assert_eq!(project.severities("/repo/db2/nested/a.sql"), []); + assert_eq!( + project.severities("/repo/node_modules/pkg/a.sql"), + [Severity::Warning] + ); + // A relative path is relative to the root of the project, like the paths of the CLI. + assert_eq!(project.severities("db2/a.sql"), [Severity::Error]); + assert!(project.is_ignored("/repo/legacy/a.sql")); +} + +/// Applying the nested configuration files again replaces the ones from before, so a deleted +/// configuration file no longer applies. +#[test] +fn nested_settings_replace_the_previous_ones() { + let mut fs = file_system_with_configurations(&[ + ("/repo", "{}"), + ("/repo/db1", r#"{ "linter": { "enabled": false } }"#), + ]); + fs.remove(&Path::new("/repo/db1").join(ConfigName::pgls_jsonc())); + let without_db1: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let with_db1: DynRef<'_, dyn FileSystem> = + DynRef::Owned(Box::new(file_system_with_configurations(&[ + ("/repo", "{}"), + ("/repo/db1", r#"{ "linter": { "enabled": false } }"#), + ]))); + + let (project, _) = TestProject::new(&with_db1, Path::new("/repo")); + assert_eq!(project.severities("/repo/db1/a.sql"), []); + + assert_eq!( + project.apply_nested_configurations(&without_db1, Path::new("/repo")), + Vec::::new() + ); + assert_eq!(project.severities("/repo/db1/a.sql"), [Severity::Warning]); +} + +/// The files below a configuration file that cannot be applied use the defaults, not the settings +/// above it, which could point at another database. +#[test] +fn a_nested_configuration_that_cannot_be_applied_uses_the_defaults() { + let fs = file_system_with_configurations(&[ + ( + "/repo", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ("/repo/broken", r#"{ "files": { "ignore": ["[invalid"] } }"#), + ("/repo/unreadable", r#"{ "linter": "#), + ]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let project = TestProject::without_nested_configurations(&fs, Path::new("/repo")); + + let nested = apply_nested_configurations( + &*project.workspace, + &fs, + project.project_key, + Path::new("/repo"), + |configuration| configuration, + ) + .unwrap(); + let failed: Vec<_> = nested + .iter() + .filter(|nested| nested.result.is_err()) + .map(|nested| nested.directory.clone()) + .collect(); + assert_eq!( + failed, + [ + PathBuf::from("/repo/broken"), + PathBuf::from("/repo/unreadable") + ] + ); + + assert_eq!(project.severities("/repo/a.sql"), [Severity::Error]); + assert_eq!( + project.severities("/repo/broken/a.sql"), + [Severity::Warning] + ); + assert_eq!( + project.severities("/repo/unreadable/a.sql"), + [Severity::Warning] + ); +} + +/// Each project has its own nested settings, even when the folder of one is below the other, as +/// with nested workspace folders. Unregistering a project removes its nested settings with it. +#[test] +fn projects_have_their_own_nested_settings() { + let fs = file_system_with_configurations(&[ + ("/outer", "{}"), + ("/outer/inner", r#"{ "linter": { "enabled": false } }"#), + ]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let (outer, directories) = TestProject::new(&fs, Path::new("/outer")); + assert_eq!(directories, [PathBuf::from("/outer/inner")]); + + let inner = outer.other( + outer + .workspace + .register_project_folder(RegisterProjectFolderParams { + path: Some(PathBuf::from("/outer/inner")), + }) + .unwrap(), + ); + inner + .workspace + .update_settings(UpdateSettingsParams { + project_key: inner.project_key, + configuration: serde_json::from_str( + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ) + .unwrap(), + gitignore_matches: vec![], + vcs_base_path: None, + workspace_directory: None, + }) + .unwrap(); + + // The same file, by the project that the client says it belongs to. + assert_eq!(outer.severities("/outer/inner/a.sql"), []); + assert_eq!(inner.severities("/outer/inner/a.sql"), [Severity::Error]); + + outer + .workspace + .unregister_project_folder(UnregisterProjectFolderParams { + path: PgLSPath::new("/outer"), + }) + .unwrap(); + assert_eq!(inner.severities("/outer/inner/a.sql"), [Severity::Error]); + assert!(matches!( + outer + .workspace + .update_nested_settings(UpdateNestedSettingsParams { + project_key: outer.project_key, + nested: vec![], + }), + Err(WorkspaceError::NoProject(_)) + )); +} + +/// Registering a folder again reuses its project instead of adding one that shadows it. +#[test] +fn registering_a_project_folder_again_reuses_its_project() { + let workspace = WorkspaceServer::new(); + let register = |path: &str| { + workspace + .register_project_folder(RegisterProjectFolderParams { + path: Some(PathBuf::from(path)), + }) + .unwrap() + }; + + let first = register("/one"); + let second = register("/two"); + assert_ne!(first, second); + assert_eq!(register("/one"), first); +} + +#[test] +fn updating_the_settings_of_an_unknown_project_fails() { + let workspace = WorkspaceServer::new(); + let result = workspace.update_settings(UpdateSettingsParams { + project_key: ProjectKey::default(), + configuration: PartialConfiguration::init(), + gitignore_matches: vec![], + vcs_base_path: None, + workspace_directory: None, + }); + assert!(matches!(result, Err(WorkspaceError::NoProject(_)))); +} + +/// The user configuration only applies to the files without a project configuration at or above +/// them, as the root settings. Nested configurations never merge it implicitly, but can extend it, +/// and its directory is not a nested configuration even when it is below the root. +#[test] +fn nested_configurations_do_not_merge_the_user_configuration() { + let home = PathBuf::from("/home/alice"); + let user_config_directory = home + .join("Library") + .join("Application Support") + .join("postgres-language-server"); + + let mut fs = MemoryFileSystem::default(); + fs.set_home_directory(home.clone()); + fs.set_user_config_directories(vec![user_config_directory.clone()]); + fs.insert( + user_config_directory.join(ConfigName::pgls_jsonc()), + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ); + fs.insert(home.join("repo/db").join(ConfigName::pgls_jsonc()), "{}"); + fs.insert( + home.join("repo/shared").join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["~/Library/Application Support/postgres-language-server/postgres-language-server.jsonc"] }"#, + ); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + + // The home directory is the workspace, and has no project configuration. + let root = home.clone(); + let loaded = + load_configuration(&fs, ConfigurationPathHint::FromWorkspace(root.clone())).unwrap(); + assert_eq!( + loaded.file_path(), + Some( + user_config_directory + .join(ConfigName::pgls_jsonc()) + .as_path() + ) + ); + + let (project, directories) = TestProject::new(&fs, &root); + assert_eq!( + directories, + [home.join("repo/db"), home.join("repo/shared")] + ); + + // No project configuration: the user configuration applies. + assert_eq!( + project.severities(home.join("repo/a.sql")), + [Severity::Error] + ); + assert_eq!( + project.severities(user_config_directory.join("a.sql")), + [Severity::Error] + ); + // A nested configuration does not merge it, unless it extends it. + assert_eq!( + project.severities(home.join("repo/db/a.sql")), + [Severity::Warning] + ); + assert_eq!( + project.severities(home.join("repo/shared/a.sql")), + [Severity::Error] + ); +} + +/// The migrations settings of a file come from its nearest configuration file: the section markers, +/// `lintDown`, and the migrations directory, which is relative to the nested configuration file. +#[test] +fn nested_configurations_have_their_own_migrations_settings() { + const DBMATE_DROPS: &str = r#"-- migrate:up +ALTER TABLE users DROP COLUMN name; + +-- migrate:down +ALTER TABLE users DROP COLUMN email; +"#; + + let mut fs = MemoryFileSystem::default(); + fs.insert(Path::new("/repo").join(ConfigName::pgls_jsonc()), "{}"); + fs.insert( + Path::new("/repo/db").join(ConfigName::pgls_jsonc()), + r#"{ + "migrations": { + "migrationsDir": "migrations", + "sectionMarkers": "dbmate", + "lintDown": false, + "after": 20240101000000 + } + }"#, + ); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let (project, directories) = TestProject::new(&fs, Path::new("/repo")); + assert_eq!(directories, [PathBuf::from("/repo/db")]); + + // The nested configuration skips the down section. + assert_eq!( + project.severities_of("/repo/db/migrations/20250301120000_drop.sql", DBMATE_DROPS), + [Severity::Warning] + ); + // The root configuration has no section markers, so both drops are checked. + assert_eq!( + project.severities_of("/repo/migrations/20250301120000_drop.sql", DBMATE_DROPS), + [Severity::Warning, Severity::Warning] + ); + + // `after` applies to the migrations directory of the nested configuration only. + assert!(project.is_ignored("/repo/db/migrations/20230101000000_old.sql")); + assert!(!project.is_ignored("/repo/db/20230101000000_old.sql")); + assert!(!project.is_ignored("/repo/migrations/20230101000000_old.sql")); +} diff --git a/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs b/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs index 5c080bd52..37727cfff 100644 --- a/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs +++ b/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs @@ -24,7 +24,7 @@ use super::{async_helper::run_async, connection_key::ConnectionKey}; /// /// DB-only API: /// - `load()` - Load schema from database connection -/// - `clear_connection()` - Clear schema for specific connection +/// - `clear_all_connections()` - Clear the schemas of all connections pub struct SnapshotManager { /// Connection-based snapshots (db mode only) #[cfg(feature = "db")] @@ -102,14 +102,6 @@ impl SnapshotManager { Ok(snapshot) } - /// Clear the snapshot for a specific connection. - #[cfg(feature = "db")] - pub fn clear_connection(&self, pool: &PgPool) { - let key: ConnectionKey = pool.into(); - let mut schemas = self.db_schemas.write().unwrap(); - schemas.remove(&key); - } - /// Clear all connection-based snapshots. #[cfg(feature = "db")] pub fn clear_all_connections(&self) { diff --git a/crates/pgls_workspace_macros/src/lib.rs b/crates/pgls_workspace_macros/src/lib.rs index 6f36c96fe..c0f8ff3d4 100644 --- a/crates/pgls_workspace_macros/src/lib.rs +++ b/crates/pgls_workspace_macros/src/lib.rs @@ -5,39 +5,45 @@ use quote::quote; use syn::{TypePath, TypeTuple, parse_macro_input}; struct IgnoredPath { + project: syn::Expr, path: syn::Expr, } +/// Parses `name = `. +fn parse_argument(input: syn::parse::ParseStream, name: &str) -> syn::Result { + let arg_name: syn::Ident = input.parse()?; + if arg_name != name { + return Err(syn::Error::new_spanned( + arg_name, + format!("Expected '{name}' argument."), + )); + } + let _: syn::Token!(=) = input.parse()?; + input.parse() +} + impl syn::parse::Parse for IgnoredPath { fn parse(input: syn::parse::ParseStream) -> syn::Result { - let arg_name: syn::Ident = input.parse()?; - - if arg_name != "path" { - return Err(syn::Error::new_spanned( - arg_name, - "Expected 'path' argument.", - )); - } - - let _: syn::Token!(=) = input.parse()?; - let path: syn::Expr = input.parse()?; + let project = parse_argument(input, "project")?; + let _: syn::Token!(,) = input.parse()?; + let path = parse_argument(input, "path")?; - Ok(Self { path }) + Ok(Self { project, path }) } } #[proc_macro_attribute] /// You can use this on a workspace server function to return a default if the specified path -/// is ignored by the user's settings. +/// is ignored by the settings of the project it belongs to. /// /// This will work for any function where &self is in scope and that returns `Result`, `Result<(), E>`, or `T`, where `T: Default`. -/// `path` needs to point at a `&PgLSPath`. +/// `project` needs to be a `ProjectKey`, and `path` needs to point at a `&PgLSPath`. /// /// ### Usage /// /// ```ignore /// impl WorkspaceServer { -/// #[ignore_path(path=¶ms.path)] +/// #[ignored_path(project=params.project_key, path=¶ms.path)] /// fn foo(&self, params: FooParams) -> Result { /// ... codeblock /// } @@ -47,7 +53,7 @@ impl syn::parse::Parse for IgnoredPath { /// /// impl WorkspaceServer { /// fn foo(&self, params: FooParams) -> Result { -/// if self.is_ignored(¶ms.path) { +/// if self.is_ignored(params.project_key, ¶ms.path) { /// return Ok(FooResult::default()); /// } /// ... codeblock @@ -58,6 +64,7 @@ pub fn ignored_path(args: TokenStream, input: TokenStream) -> TokenStream { let ignored_path = parse_macro_input!(args as IgnoredPath); let input_fn = parse_macro_input!(input as syn::ItemFn); + let macro_specified_project = ignored_path.project; let macro_specified_path = ignored_path.path; let vis = &input_fn.vis; @@ -80,7 +87,7 @@ pub fn ignored_path(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(quote! { #(#attrs)* #vis #sig { - if self.is_ignored(#macro_specified_path) { + if self.is_ignored(#macro_specified_project, #macro_specified_path) { return Ok(()); }; #block @@ -95,7 +102,7 @@ pub fn ignored_path(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(quote! { #(#attrs)* #vis #sig { - if self.is_ignored(#macro_specified_path) { + if self.is_ignored(#macro_specified_project, #macro_specified_path) { return Ok(#ident::default()); }; #block @@ -110,7 +117,7 @@ pub fn ignored_path(args: TokenStream, input: TokenStream) -> TokenStream { TokenStream::from(quote! { #(#attrs)* #vis #sig { - if self.is_ignored(#macro_specified_path) { + if self.is_ignored(#macro_specified_project, #macro_specified_path) { return Default::default(); } #block diff --git a/docs/configuration.md b/docs/configuration.md index 6ad504e87..500f123c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,6 +180,45 @@ individual fields. The URI takes precedence over any other connection fields. } ``` +## Nested configurations / monorepos + +A repository can hold more than one configuration file. Each file uses the nearest `postgres-language-server.jsonc` in its own folder or above it, so different folders can point at different databases or use different linter and formatter settings: + +``` +my-repo/ +├── postgres-language-server.jsonc # applies to everything else +└── src/ + ├── db1/ + │ ├── postgres-language-server.jsonc # applies to src/db1/** + │ └── migrations/0001_init.sql + └── db2/ + ├── postgres-language-server.jsonc # applies to src/db2/** + └── schema.sql +``` + +The language server finds the configuration files below each workspace folder when it starts, and reloads them when one is created, changed, or deleted, or when a workspace folder is added or removed. Each workspace folder is a project of its own, so a file belongs to the deepest workspace folder that contains it. The `check` and `format` commands find the configuration files below the working directory. + +A nested configuration file inherits nothing from the configuration files above it: settings it does not set have their defaults. To share settings, extend the parent configuration explicitly; the nested file's own settings take precedence: + +```json +{ + "extends": ["../../postgres-language-server.jsonc"], + "db": { + "database": "db1" + } +} +``` + +Paths in a nested configuration file, such as `migrations.migrationsDir`, are relative to its folder. + +The [user configuration](#where-the-configuration-is-loaded-from) is a fallback for the workspace root only. When there is no configuration file at or above the root, it applies to the files that no nested configuration file covers. It is never merged into a nested configuration file. To use it there, extend it explicitly, for example with `"extends": ["~/.config/postgres-language-server/postgres-language-server.jsonc"]`. + +Things to keep in mind: + +- Configuration files in hidden folders, `node_modules`, `target`, folders ignored by `.gitignore`, folders that the configuration above them ignores through `files.ignore` or `files.include`, and the user configuration directory are not loaded as nested configurations. +- The database connection of a project is only opened when a file of that project needs it. Projects that point at the same database share its connection and schema cache. +- When a configuration file is passed explicitly with `--config-path`, it applies to all files and nested configuration files are not looked up. +- `dblint` uses the configuration of the working directory. ## Sharing settings with `extends` diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index 5a3b0d711..265424f26 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -2,6 +2,7 @@ import type { Transport } from "./transport"; export interface IsPathIgnoredParams { pgls_path: PgLSPath; + project_key: ProjectKey; } export interface PgLSPath { /** @@ -14,24 +15,34 @@ export interface PgLSPath { */ was_written: boolean; } +/** + * Identifies a project of the workspace. [ProjectKey] is serialized like this. + */ +export interface ProjectKey { + idx: number; + version: number; +} export type FileKind = FileKind2[]; /** * The priority of the file */ export type FileKind2 = "Config" | "Ignore" | "Inspectable" | "Handleable"; export interface RegisterProjectFolderParams { + /** + * The folder of the project. A project without a folder contains every path. + */ path?: string; - setAsCurrentWorkspace: boolean; } -export type ProjectKey = string; export interface GetFileContentParams { path: PgLSPath; + project_key: ProjectKey; } export interface PullFileDiagnosticsParams { categories: RuleCategories; max_diagnostics: number; only: RuleCode[]; path: PgLSPath; + project_key: ProjectKey; skip: RuleCode[]; } export type RuleCategories = RuleCategory[]; @@ -311,6 +322,7 @@ export interface GetCompletionsParams { * The Cursor position in the file for which a completion is requested. */ position: TextSize; + project_key: ProjectKey; } export interface CompletionsResult { items: CompletionItem[]; @@ -351,6 +363,10 @@ export type CompletionItemKind = export interface UpdateSettingsParams { configuration: PartialConfiguration; gitignore_matches: string[]; + /** + * The project whose root settings are updated. + */ + project_key: ProjectKey; vcs_base_path?: string; workspace_directory?: string; } @@ -1481,15 +1497,18 @@ Each pattern should be in the format `schema.object_name`, for example: - `"publ export interface OpenFileParams { content: string; path: PgLSPath; + project_key: ProjectKey; version: number; } export interface ChangeFileParams { content: string; path: PgLSPath; + project_key: ProjectKey; version: number; } export interface CloseFileParams { path: PgLSPath; + project_key: ProjectKey; } export type Configuration = PartialConfiguration; export interface Workspace { diff --git a/packages/@postgres-language-server/backend-jsonrpc/tests/workspace.test.mjs b/packages/@postgres-language-server/backend-jsonrpc/tests/workspace.test.mjs index 1f816e41c..3fbe8b374 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/tests/workspace.test.mjs +++ b/packages/@postgres-language-server/backend-jsonrpc/tests/workspace.test.mjs @@ -15,10 +15,9 @@ describe("Workspace API", () => { ); const workspace = await createWorkspaceWithBinary(command); - workspace.registerProjectFolder({ - setAsCurrentWorkspace: true, - }); + const projectKey = await workspace.registerProjectFolder({}); await workspace.openFile({ + project_key: projectKey, path: { path: "test.sql", was_written: false, @@ -33,6 +32,7 @@ describe("Workspace API", () => { skip: [], max_diagnostics: 100, categories: [], + project_key: projectKey, path: { path: "test.sql", was_written: false, @@ -44,6 +44,7 @@ describe("Workspace API", () => { expect(diagnostics[0].description).toBe("Invalid statement: syntax error at end of input"); await workspace.closeFile({ + project_key: projectKey, path: { path: "test.sql", was_written: false, diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 5a3b0d711..265424f26 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -2,6 +2,7 @@ import type { Transport } from "./transport"; export interface IsPathIgnoredParams { pgls_path: PgLSPath; + project_key: ProjectKey; } export interface PgLSPath { /** @@ -14,24 +15,34 @@ export interface PgLSPath { */ was_written: boolean; } +/** + * Identifies a project of the workspace. [ProjectKey] is serialized like this. + */ +export interface ProjectKey { + idx: number; + version: number; +} export type FileKind = FileKind2[]; /** * The priority of the file */ export type FileKind2 = "Config" | "Ignore" | "Inspectable" | "Handleable"; export interface RegisterProjectFolderParams { + /** + * The folder of the project. A project without a folder contains every path. + */ path?: string; - setAsCurrentWorkspace: boolean; } -export type ProjectKey = string; export interface GetFileContentParams { path: PgLSPath; + project_key: ProjectKey; } export interface PullFileDiagnosticsParams { categories: RuleCategories; max_diagnostics: number; only: RuleCode[]; path: PgLSPath; + project_key: ProjectKey; skip: RuleCode[]; } export type RuleCategories = RuleCategory[]; @@ -311,6 +322,7 @@ export interface GetCompletionsParams { * The Cursor position in the file for which a completion is requested. */ position: TextSize; + project_key: ProjectKey; } export interface CompletionsResult { items: CompletionItem[]; @@ -351,6 +363,10 @@ export type CompletionItemKind = export interface UpdateSettingsParams { configuration: PartialConfiguration; gitignore_matches: string[]; + /** + * The project whose root settings are updated. + */ + project_key: ProjectKey; vcs_base_path?: string; workspace_directory?: string; } @@ -1481,15 +1497,18 @@ Each pattern should be in the format `schema.object_name`, for example: - `"publ export interface OpenFileParams { content: string; path: PgLSPath; + project_key: ProjectKey; version: number; } export interface ChangeFileParams { content: string; path: PgLSPath; + project_key: ProjectKey; version: number; } export interface CloseFileParams { path: PgLSPath; + project_key: ProjectKey; } export type Configuration = PartialConfiguration; export interface Workspace { diff --git a/packages/@postgrestools/backend-jsonrpc/tests/workspace.test.mjs b/packages/@postgrestools/backend-jsonrpc/tests/workspace.test.mjs index 1f816e41c..3fbe8b374 100644 --- a/packages/@postgrestools/backend-jsonrpc/tests/workspace.test.mjs +++ b/packages/@postgrestools/backend-jsonrpc/tests/workspace.test.mjs @@ -15,10 +15,9 @@ describe("Workspace API", () => { ); const workspace = await createWorkspaceWithBinary(command); - workspace.registerProjectFolder({ - setAsCurrentWorkspace: true, - }); + const projectKey = await workspace.registerProjectFolder({}); await workspace.openFile({ + project_key: projectKey, path: { path: "test.sql", was_written: false, @@ -33,6 +32,7 @@ describe("Workspace API", () => { skip: [], max_diagnostics: 100, categories: [], + project_key: projectKey, path: { path: "test.sql", was_written: false, @@ -44,6 +44,7 @@ describe("Workspace API", () => { expect(diagnostics[0].description).toBe("Invalid statement: syntax error at end of input"); await workspace.closeFile({ + project_key: projectKey, path: { path: "test.sql", was_written: false, From 12f0bdf21d3037d261b2b5157e6acabe7235d7bb Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 23 Oct 2026 14:03:26 +0000 Subject: [PATCH 02/17] perf(workspace): bound nested configuration discovery Run the search off the async runtime, prune the folders the root configuration ignores while walking, skip the home directory and filesystem roots, cap the walk at 100k entries / 12 levels with a warning, and only look at the ancestors of the checked paths in the CLI. Coalesce reloads from watched-file events and ignore events in folders the search skips. --- Cargo.lock | 1 + crates/pgls_cli/src/commands/check.rs | 10 +- crates/pgls_cli/src/commands/format.rs | 16 +- crates/pgls_cli/src/commands/mod.rs | 12 + crates/pgls_cli/src/lib.rs | 39 ++- crates/pgls_cli/src/workspace.rs | 14 +- crates/pgls_cli/tests/assert_check.rs | 77 ++++++ crates/pgls_fs/Cargo.toml | 3 + crates/pgls_fs/src/fs.rs | 71 ++++- crates/pgls_fs/src/fs/memory.rs | 41 ++- crates/pgls_fs/src/fs/os.rs | 88 +++++-- crates/pgls_fs/src/lib.rs | 3 +- crates/pgls_fs/tests/find_files.rs | 160 ++++++++++++ crates/pgls_lsp/src/handlers/configuration.rs | 6 +- crates/pgls_lsp/src/server.rs | 100 ++----- crates/pgls_lsp/src/session.rs | 197 +++++++++++--- crates/pgls_lsp/tests/server.rs | 95 ++++++- crates/pgls_workspace/src/configuration.rs | 237 +++++++++++++++-- crates/pgls_workspace/src/settings.rs | 44 ++++ crates/pgls_workspace/src/workspace/server.rs | 32 +-- .../src/workspace/server.tests.rs | 244 +++++++++++++++++- docs/configuration.md | 6 +- 22 files changed, 1270 insertions(+), 226 deletions(-) create mode 100644 crates/pgls_fs/tests/find_files.rs diff --git a/Cargo.lock b/Cargo.lock index d95cdae0e..ec77ef466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2601,6 +2601,7 @@ dependencies = [ "schemars", "serde", "smallvec", + "tempfile", "tracing", ] diff --git a/crates/pgls_cli/src/commands/check.rs b/crates/pgls_cli/src/commands/check.rs index aea0af7cc..3c210f15e 100644 --- a/crates/pgls_cli/src/commands/check.rs +++ b/crates/pgls_cli/src/commands/check.rs @@ -1,5 +1,7 @@ use crate::cli_options::CliOptions; -use crate::commands::{get_files_to_process_with_cli_options, read_stdin_payload}; +use crate::commands::{ + get_files_to_process_with_cli_options, nested_configuration_paths, read_stdin_payload, +}; use crate::diagnostics::DatabaseUnavailable; use crate::execute::{run_files, run_stdin}; use crate::reporter::Report; @@ -33,9 +35,13 @@ pub fn check( let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; - session.apply_nested_configurations(cli_options, args.configuration.clone())?; let paths = resolve_paths(session.fs(), &configuration, &args)?; + session.apply_nested_configurations( + cli_options, + args.configuration.clone(), + &nested_configuration_paths(&args.stdin_file_path, &paths), + )?; let vcs = VcsTargeting { staged: args.staged, diff --git a/crates/pgls_cli/src/commands/format.rs b/crates/pgls_cli/src/commands/format.rs index 402bedd23..84a6f5d87 100644 --- a/crates/pgls_cli/src/commands/format.rs +++ b/crates/pgls_cli/src/commands/format.rs @@ -1,5 +1,7 @@ use crate::cli_options::CliOptions; -use crate::commands::{get_files_to_process_with_cli_options, read_stdin_payload}; +use crate::commands::{ + get_files_to_process_with_cli_options, nested_configuration_paths, read_stdin_payload, +}; use crate::execute::{run_files, run_stdin}; use crate::reporter::Report; use crate::{CliDiagnostic, CliSession, VcsIntegration}; @@ -29,7 +31,6 @@ pub fn format( let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; - session.apply_nested_configurations(cli_options, args.configuration.clone())?; let vcs = VcsTargeting { staged: args.staged, @@ -42,12 +43,21 @@ pub fn format( }; let execution = ExecutionConfig::new(mode, u32::MAX); + let paths = match args.stdin_file_path { + Some(_) => Vec::new(), + None => resolve_paths(session.fs(), &configuration, &args)?, + }; + session.apply_nested_configurations( + cli_options, + args.configuration.clone(), + &nested_configuration_paths(&args.stdin_file_path, &paths), + )?; + if let Some(stdin_path) = args.stdin_file_path.as_deref() { let payload = read_stdin_payload(stdin_path, session.console(), "format")?; return run_stdin(&mut session, &execution, payload); } - let paths = resolve_paths(session.fs(), &configuration, &args)?; let report: Report = run_files(&mut session, &execution, paths)?; let exit_result = enforce_exit_codes(cli_options, &report); diff --git a/crates/pgls_cli/src/commands/mod.rs b/crates/pgls_cli/src/commands/mod.rs index 5d49271e0..694f7359b 100644 --- a/crates/pgls_cli/src/commands/mod.rs +++ b/crates/pgls_cli/src/commands/mod.rs @@ -392,6 +392,18 @@ pub(crate) fn get_files_to_process_with_cli_options( } } +/// The paths whose nested configuration files apply: the virtual path of the code piped to +/// `stdin`, or else the paths to process. +pub(crate) fn nested_configuration_paths( + stdin_file_path: &Option, + paths: &[OsString], +) -> Vec { + match stdin_file_path { + Some(path) => vec![OsString::from(path)], + None => paths.to_vec(), + } +} + /// Reads the code piped to `stdin` for the given virtual file path. pub(crate) fn read_stdin_payload( path: &str, diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index db2510043..bc674db36 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -11,9 +11,14 @@ use pgls_configuration::Merge; use pgls_configuration::PartialConfiguration; use pgls_console::{ColorMode, Console, ConsoleExt, markup}; use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; +use std::ffi::OsString; use std::path::PathBuf; +use std::sync::Arc; -use pgls_workspace::configuration::{NestedConfiguration, apply_nested_configurations}; +use pgls_workspace::configuration::{ + NestedConfiguration, NestedConfigurationSearch, apply_nested_configurations, +}; +use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::ProjectKey; use pgls_workspace::{App, DynRef, Workspace, WorkspaceRef}; @@ -47,6 +52,9 @@ pub struct CliSession<'app> { pub app: App<'app>, /// The project of the working directory, once [CliSession::setup_workspace] registered it project_key: ProjectKey, + /// The files settings of the root configuration, which decide the folders to search for + /// nested configuration files + root_files: Option, } impl<'app> CliSession<'app> { @@ -61,6 +69,7 @@ impl<'app> CliSession<'app> { WorkspaceRef::Borrowed(workspace), ), project_key: ProjectKey::default(), + root_files: None, }) } @@ -238,13 +247,15 @@ impl<'app> CliSession<'app> { self.project_key } - /// Applies every configuration file below the working directory to the project, so that each - /// file uses its nearest configuration file. The environment and the arguments apply to them - /// like to the root configuration file. Skipped when the user chose a configuration file. + /// Applies the configuration files that apply to `paths` to the project, so that each file + /// uses its nearest configuration file below the working directory. The environment and the + /// arguments apply to them like to the root configuration file. Skipped when the user chose a + /// configuration file. pub fn apply_nested_configurations( - &self, + &mut self, cli_options: &CliOptions, cli_configuration: Option, + paths: &[OsString], ) -> Result<(), CliDiagnostic> { if cli_options.config_path.is_some() { return Ok(()); @@ -253,14 +264,24 @@ impl<'app> CliSession<'app> { return Ok(()); }; + let root_files = self.root_files.take().map(Arc::new); let nested = apply_nested_configurations( self.workspace(), self.fs(), self.project_key, - &root, + NestedConfigurationSearch { + paths: Some(paths.iter().map(PathBuf::from).collect()), + root_files, + ..NestedConfigurationSearch::new(root) + }, |configuration| apply_overrides(configuration, cli_configuration.clone()), )?; - for NestedConfiguration { result, .. } in nested { + if let Some(incomplete) = nested.incomplete { + self.console().log(markup! { + "Warning: "{incomplete.to_string()}"\n" + }); + } + for NestedConfiguration { result, .. } in nested.configurations { result?; } Ok(()) @@ -271,8 +292,10 @@ impl<'app> CliSession<'app> { configuration: PartialConfiguration, vcs: VcsIntegration, ) -> Result<(), CliDiagnostic> { - self.project_key = + let (project_key, root_files) = workspace::setup_workspace(self.workspace(), self.fs(), configuration, vcs)?; + self.project_key = project_key; + self.root_files = Some(root_files); Ok(()) } diff --git a/crates/pgls_cli/src/workspace.rs b/crates/pgls_cli/src/workspace.rs index fc2e8f033..5b5e3e752 100644 --- a/crates/pgls_cli/src/workspace.rs +++ b/crates/pgls_cli/src/workspace.rs @@ -3,6 +3,7 @@ use pgls_configuration::{ConfigurationPathHint, PartialConfiguration}; use pgls_fs::FileSystem; use pgls_workspace::PartialConfigurationExt; use pgls_workspace::configuration::{LoadedConfiguration, load_configuration}; +use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::{ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams}; use pgls_workspace::{DynRef, Workspace}; @@ -15,13 +16,14 @@ pub fn load_config( } /// Registers the project of the working directory and configures it and the VCS integration -/// according to the provided configuration. Returns the key of the project. +/// according to the provided configuration. Returns the key of the project and the files settings +/// of the configuration. pub fn setup_workspace( workspace: &dyn Workspace, fs: &DynRef<'_, dyn FileSystem>, configuration: PartialConfiguration, vcs: VcsIntegration, -) -> Result { +) -> Result<(ProjectKey, FilesSettings), CliDiagnostic> { let (vcs_base_path, gitignore_matches) = match vcs { VcsIntegration::Enabled => configuration .retrieve_gitignore_matches(fs, fs.working_directory().as_deref()) @@ -35,6 +37,12 @@ pub fn setup_workspace( }) .map_err(CliDiagnostic::from)?; + let files = FilesSettings::from_configuration( + &configuration, + fs.working_directory(), + vcs_base_path.clone(), + &gitignore_matches, + )?; workspace .update_settings(UpdateSettingsParams { project_key, @@ -45,5 +53,5 @@ pub fn setup_workspace( }) .map_err(CliDiagnostic::from)?; - Ok(project_key) + Ok((project_key, files)) } diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 4aaa76a4c..16a775ecc 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -441,6 +441,83 @@ fn check_uses_the_nearest_configuration_file() { ); } +/// Writes `files` below a new temporary directory. +fn write_tree(files: &[(&str, &str)]) -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + for (path, content) in files { + let path = root.path().join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + root +} + +/// Runs `check` in `cwd` with the GitHub reporter, without the connection settings of the +/// environment but with `envs`. Returns whether it succeeded and its output. +fn check_in(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (bool, String) { + let mut cmd = cargo_bin_cmd!("postgres-language-server"); + for var in [ + "DATABASE_URL", + "PGHOST", + "PGPORT", + "PGUSER", + "PGPASSWORD", + "PGDATABASE", + ] { + cmd.env_remove(var); + } + cmd.env("PGPASSFILE", "/nonexistent/pgpass"); + cmd.envs(envs.iter().copied()); + let output = cmd + .current_dir(cwd) + .args(["check", "--reporter", "github", "--log-level", "none"]) + .args(args) + .output() + .expect("failed to run CLI"); + ( + output.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + ) +} + +/// `check` with paths only loads the configuration files on the way to them and below the folders +/// among them, so a broken configuration file elsewhere doesn't matter. +#[test] +fn check_only_loads_the_configuration_files_of_its_paths() { + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ), + ( + "tests/fixtures/broken/postgres-language-server.jsonc", + "{ this is not json", + ), + ( + "db1/postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true }, "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ("a.sql", "select 1;\n"), + ("db1/schema/a.sql", "alter table users drop column name;\n"), + ]); + + let (success, output) = check_in(root.path(), &["a.sql"], &[]); + assert!(success, "{output}"); + + for path in ["db1/schema/a.sql", "db1"] { + let (success, output) = check_in(root.path(), &[path], &[]); + assert!(!success, "{output}"); + assert!( + output.contains("::error title=lint/banDropColumn"), + "the configuration of db1 doesn't apply to {path}:\n{output}" + ); + } +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] diff --git a/crates/pgls_fs/Cargo.toml b/crates/pgls_fs/Cargo.toml index 565c04c14..8fac059f3 100644 --- a/crates/pgls_fs/Cargo.toml +++ b/crates/pgls_fs/Cargo.toml @@ -26,6 +26,9 @@ serde = { workspace = true, optional = true } smallvec = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +tempfile = "3.15.0" + [features] schema = ["dep:schemars", "pgls_diagnostics/schema"] serde = ["dep:serde"] diff --git a/crates/pgls_fs/src/fs.rs b/crates/pgls_fs/src/fs.rs index 534eda291..6fb2bc9e4 100644 --- a/crates/pgls_fs/src/fs.rs +++ b/crates/pgls_fs/src/fs.rs @@ -183,16 +183,75 @@ pub trait FileSystem: Send + Sync + RefUnwindSafe { path: &Path, ) -> Result; - /// Finds the files at or below `root` named like one of `file_names`. + /// Finds the files at or below `root` named like one of `search.file_names`. /// - /// It skips hidden directories, the directories in [SKIPPED_DIRECTORIES], and, on the OS file - /// system, everything that `.gitignore` and `.ignore` files exclude. - fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec; + /// It skips hidden directories, the directories in [SKIPPED_DIRECTORIES], the directories that + /// [FindFiles::skip_directory] skips, and, on the OS file system, everything that `.gitignore` + /// and `.ignore` files exclude. It stops at [FindFiles::max_depth] and + /// [FindFiles::max_entries]. + fn find_files(&self, root: &Path, search: &FindFiles) -> FoundFiles; } /// Directories that [FileSystem::find_files] never descends into. pub const SKIPPED_DIRECTORIES: &[&str] = &["node_modules", "target"]; +/// Decides whether [FileSystem::find_files] skips a directory, given its path. +pub type SkipDirectory = Arc bool + Send + Sync>; + +/// What [FileSystem::find_files] looks for, and where it stops. +pub struct FindFiles<'a> { + pub file_names: &'a [&'a str], + /// Skips the directories for which it returns `true`, except below a directory that contains + /// one of the files: the file there decides what applies below it. + pub skip_directory: Option, + /// The search doesn't descend into directories deeper than this below the root. + pub max_depth: usize, + /// The search stops after this many files and directories. + pub max_entries: usize, +} + +impl<'a> FindFiles<'a> { + /// The depth limit of [FindFiles::new]. Configuration files deeper below a workspace folder + /// are rare, and a deeper tree is more likely a vendored or generated one. + pub const DEFAULT_MAX_DEPTH: usize = 12; + /// The entry limit of [FindFiles::new]. Walking it takes about 0.2s on a warm cache. + pub const DEFAULT_MAX_ENTRIES: usize = 100_000; + + pub fn new(file_names: &'a [&'a str]) -> Self { + Self { + file_names, + skip_directory: None, + max_depth: Self::DEFAULT_MAX_DEPTH, + max_entries: Self::DEFAULT_MAX_ENTRIES, + } + } +} + +/// Whether [FindFiles::skip_directory] skips the directory at `path` below `root`. `has_file` +/// tells whether a directory contains one of the files. +fn skips_directory( + skip_directory: Option<&SkipDirectory>, + root: &Path, + path: &Path, + has_file: impl Fn(&Path) -> bool, +) -> bool { + skip_directory.is_some_and(|skip_directory| skip_directory(path)) + && !path + .ancestors() + .skip(1) + .take_while(|ancestor| *ancestor != root && ancestor.starts_with(root)) + .any(has_file) +} + +/// The result of [FileSystem::find_files]. +#[derive(Debug, Default)] +pub struct FoundFiles { + pub files: Vec, + /// Whether the search stopped at [FindFiles::max_depth] or [FindFiles::max_entries], so that + /// it may have missed files. + pub truncated: bool, +} + /// Result of the auto search #[derive(Debug)] pub struct AutoSearchResult { @@ -399,8 +458,8 @@ where T::resolve_configuration(self, specifier, path) } - fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { - T::find_files(self, root, file_names) + fn find_files(&self, root: &Path, search: &FindFiles) -> FoundFiles { + T::find_files(self, root, search) } } diff --git a/crates/pgls_fs/src/fs/memory.rs b/crates/pgls_fs/src/fs/memory.rs index 95cfa0497..6ec851b10 100644 --- a/crates/pgls_fs/src/fs/memory.rs +++ b/crates/pgls_fs/src/fs/memory.rs @@ -13,7 +13,10 @@ use pgls_diagnostics::{Error, Severity}; use crate::fs::OpenOptions; use crate::{FileSystem, PgLSPath, TraversalContext, TraversalScope}; -use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, SKIPPED_DIRECTORIES}; +use super::{ + BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, FindFiles, FoundFiles, + SKIPPED_DIRECTORIES, skips_directory, +}; type OnGetChangedFiles = Option< Arc< @@ -260,8 +263,15 @@ impl FileSystem for MemoryFileSystem { todo!() } - fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { + fn find_files(&self, root: &Path, search: &FindFiles) -> FoundFiles { let files = self.files.0.read(); + let has_file = |directory: &Path| { + search + .file_names + .iter() + .any(|name| files.contains_key(&directory.join(name))) + }; + let mut truncated = false; let mut found: Vec<_> = files .keys() .filter(|path| { @@ -272,15 +282,36 @@ impl FileSystem for MemoryFileSystem { let Some(file_name) = components.pop() else { return false; }; - file_names.contains(&&*file_name) - && !components.iter().any(|directory| { + if !search.file_names.contains(&&*file_name) + || components.iter().any(|directory| { directory.starts_with('.') || SKIPPED_DIRECTORIES.contains(&&**directory) }) + { + return false; + } + if components.len() > search.max_depth { + truncated = true; + return false; + } + !path + .ancestors() + .skip(1) + .take_while(|directory| *directory != root) + .any(|directory| { + skips_directory(search.skip_directory.as_ref(), root, directory, has_file) + }) }) .cloned() .collect(); found.sort(); - found + if found.len() > search.max_entries { + found.truncate(search.max_entries); + truncated = true; + } + FoundFiles { + files: found, + truncated, + } } } diff --git a/crates/pgls_fs/src/fs/os.rs b/crates/pgls_fs/src/fs/os.rs index 6fad289e6..44b27b39c 100644 --- a/crates/pgls_fs/src/fs/os.rs +++ b/crates/pgls_fs/src/fs/os.rs @@ -1,5 +1,8 @@ //! Implementation of the [FileSystem] and related traits for the underlying OS filesystem -use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, SKIPPED_DIRECTORIES}; +use super::{ + BoxedTraversal, ErrorKind, File, FileSystemDiagnostic, FindFiles, FoundFiles, + SKIPPED_DIRECTORIES, skips_directory, +}; use crate::fs::OpenOptions; use crate::{ FileSystem, PgLSPath, dir, @@ -11,6 +14,8 @@ use rayon::{Scope, scope}; use std::fs::{DirEntry, FileType}; use std::panic::AssertUnwindSafe; use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::{ env, fs, io::{self, ErrorKind as IoErrorKind, Read, Seek, Write}, @@ -146,34 +151,67 @@ impl FileSystem for OsFileSystem { self.configuration_resolver.resolve(path, specifier) } - fn find_files(&self, root: &Path, file_names: &[&str]) -> Vec { - ignore::WalkBuilder::new(root) - .hidden(true) - .require_git(false) - .filter_entry(|entry| { - !(entry + fn find_files(&self, root: &Path, search: &FindFiles) -> FoundFiles { + let file_names: Vec = search.file_names.iter().map(|&name| name.into()).collect(); + let truncated = Arc::new(AtomicBool::new(false)); + let filter = { + let root = root.to_path_buf(); + let skip_directory = search.skip_directory.clone(); + let max_depth = search.max_depth; + let file_names = file_names.clone(); + let truncated = Arc::clone(&truncated); + move |entry: &ignore::DirEntry| { + if !entry .file_type() .is_some_and(|file_type| file_type.is_dir()) - && entry - .file_name() - .to_str() - .is_some_and(|name| SKIPPED_DIRECTORIES.contains(&name))) - }) - .build() - .filter_map(Result::ok) - .filter(|entry| { - entry - .file_type() - .is_some_and(|file_type| file_type.is_file()) - }) - .filter(|entry| { - entry + || entry.depth() == 0 + { + return true; + } + if entry .file_name() .to_str() - .is_some_and(|name| file_names.contains(&name)) - }) - .map(ignore::DirEntry::into_path) - .collect() + .is_some_and(|name| SKIPPED_DIRECTORIES.contains(&name)) + { + return false; + } + if entry.depth() > max_depth { + truncated.store(true, Ordering::Relaxed); + return false; + } + !skips_directory(skip_directory.as_ref(), &root, entry.path(), |directory| { + file_names.iter().any(|name| directory.join(name).is_file()) + }) + } + }; + + let mut files = Vec::new(); + let walk = ignore::WalkBuilder::new(root) + .hidden(true) + .require_git(false) + .filter_entry(filter) + .build(); + for (index, entry) in walk.filter_map(Result::ok).enumerate() { + if index >= search.max_entries { + truncated.store(true, Ordering::Relaxed); + break; + } + let is_match = entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + && entry + .file_name() + .to_str() + .is_some_and(|name| file_names.iter().any(|file_name| file_name == name)); + if is_match { + files.push(entry.into_path()); + } + } + + FoundFiles { + files, + truncated: truncated.load(Ordering::Relaxed), + } } } diff --git a/crates/pgls_fs/src/lib.rs b/crates/pgls_fs/src/lib.rs index 352f40a1a..ee374cc2b 100644 --- a/crates/pgls_fs/src/lib.rs +++ b/crates/pgls_fs/src/lib.rs @@ -11,5 +11,6 @@ pub use path::PgLSPath; pub use fs::{ AutoSearchResult, ConfigName, ErrorEntry, File, FileSystem, FileSystemDiagnostic, - FileSystemExt, MemoryFileSystem, OpenOptions, OsFileSystem, TraversalContext, TraversalScope, + FileSystemExt, FindFiles, FoundFiles, MemoryFileSystem, OpenOptions, OsFileSystem, + SKIPPED_DIRECTORIES, SkipDirectory, TraversalContext, TraversalScope, }; diff --git a/crates/pgls_fs/tests/find_files.rs b/crates/pgls_fs/tests/find_files.rs new file mode 100644 index 000000000..98d8c42fc --- /dev/null +++ b/crates/pgls_fs/tests/find_files.rs @@ -0,0 +1,160 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; + +use pgls_fs::{ConfigName, FileSystem, FindFiles, OsFileSystem, SkipDirectory}; + +/// Creates `files` below a new temporary directory. +fn tree(files: &[&str]) -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + for file in files { + let path = root.path().join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "{}").unwrap(); + } + root +} + +/// The files the search finds, relative to `root`, and whether it stopped at a limit. +fn find(root: &Path, search: FindFiles) -> (Vec, bool) { + let found = OsFileSystem::new(root.to_path_buf()).find_files(root, &search); + let mut files: Vec<_> = found + .files + .iter() + .map(|file| { + file.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + files.sort(); + (files, found.truncated) +} + +const CONFIG: &[&str] = &["postgres-language-server.jsonc"]; + +#[test] +fn skips_hidden_vendored_and_ignored_folders() { + let root = tree(&[ + "postgres-language-server.jsonc", + "db1/postgres-language-server.jsonc", + ".hidden/postgres-language-server.jsonc", + "node_modules/pkg/postgres-language-server.jsonc", + "target/postgres-language-server.jsonc", + "build/postgres-language-server.jsonc", + "db1/other.jsonc", + ]); + std::fs::write(root.path().join(".gitignore"), "build/\n").unwrap(); + + let (files, truncated) = find(root.path(), FindFiles::new(CONFIG)); + assert_eq!( + files, + [ + "db1/postgres-language-server.jsonc", + "postgres-language-server.jsonc" + ] + ); + assert!(!truncated); +} + +/// A skipped directory is not searched, unless a directory between the root and it has one of the +/// files: the file there decides what applies below it. +#[test] +fn skips_directories_up_to_the_first_file() { + let root = tree(&[ + "legacy/app/postgres-language-server.jsonc", + "db1/postgres-language-server.jsonc", + "db1/legacy/postgres-language-server.jsonc", + ]); + let skip_directory: SkipDirectory = Arc::new(|directory: &Path| directory.ends_with("legacy")); + + let (files, _) = find( + root.path(), + FindFiles { + skip_directory: Some(skip_directory), + ..FindFiles::new(CONFIG) + }, + ); + assert_eq!( + files, + [ + "db1/legacy/postgres-language-server.jsonc", + "db1/postgres-language-server.jsonc" + ] + ); +} + +#[test] +fn stops_at_the_limits() { + let root = tree(&[ + "a/postgres-language-server.jsonc", + "a/b/postgres-language-server.jsonc", + "a/b/c/postgres-language-server.jsonc", + ]); + + let (files, truncated) = find( + root.path(), + FindFiles { + max_depth: 2, + ..FindFiles::new(CONFIG) + }, + ); + assert_eq!( + files, + [ + "a/b/postgres-language-server.jsonc", + "a/postgres-language-server.jsonc" + ] + ); + assert!(truncated); + + let (files, truncated) = find( + root.path(), + FindFiles { + max_entries: 2, + ..FindFiles::new(CONFIG) + }, + ); + assert!(files.len() < 3, "{files:?}"); + assert!(truncated); +} + +/// Measures the search that nested configuration discovery runs on every configuration load. Run +/// it with +/// `PGLS_FIND_FILES_ROOT=/path/to/repo cargo test -p pgls_fs --release --test find_files -- --ignored --nocapture`. +/// With `PGLS_FIND_FILES_SKIP=`, it skips the directories with that name, like a root +/// configuration that ignores them. +#[test] +#[ignore] +fn measure_find_files() { + let root = PathBuf::from(std::env::var("PGLS_FIND_FILES_ROOT").expect("PGLS_FIND_FILES_ROOT")); + let fs = OsFileSystem::new(root.clone()); + let names = ConfigName::file_names(); + let runs: u32 = std::env::var("PGLS_FIND_FILES_RUNS") + .ok() + .and_then(|runs| runs.parse().ok()) + .unwrap_or(5); + let skip_directory = std::env::var("PGLS_FIND_FILES_SKIP") + .ok() + .map(|name| Arc::new(move |directory: &Path| directory.ends_with(&name)) as SkipDirectory); + let search = FindFiles { + skip_directory, + ..FindFiles::new(&names) + }; + // Warm the page cache once, then measure. + let _ = fs.find_files(&root, &search); + let mut found = Default::default(); + let start = Instant::now(); + for _ in 0..runs { + found = fs.find_files(&root, &search); + } + let found: pgls_fs::FoundFiles = found; + println!( + "find_files({}) = {:?} per run, found {} files, truncated: {}", + root.display(), + start.elapsed() / runs, + found.files.len(), + found.truncated, + ); +} diff --git a/crates/pgls_lsp/src/handlers/configuration.rs b/crates/pgls_lsp/src/handlers/configuration.rs index c7a3bda4e..72658c355 100644 --- a/crates/pgls_lsp/src/handlers/configuration.rs +++ b/crates/pgls_lsp/src/handlers/configuration.rs @@ -1,4 +1,4 @@ -use crate::session::Session; +use crate::session::SessionHandle; use pgls_configuration::PartialConfiguration; use serde::Deserialize; use tower_lsp_server::jsonrpc::{Error, Result as LspResult}; @@ -19,7 +19,7 @@ pub(crate) struct SetConfigurationOverridesParams { /// This is a request rather than a notification so the client receives an /// acknowledgement and can distinguish applied from failed configuration. pub(crate) async fn set_configuration_overrides( - session: &Session, + session: &SessionHandle, params: SetConfigurationOverridesParams, ) -> LspResult<()> { let status = session.set_client_overrides(params.overrides).await; @@ -36,7 +36,7 @@ pub(crate) async fn set_configuration_overrides( /// The configuration notification writes a sticky layer so the snapshot /// survives later reloads, unlike the former one-shot merge. pub(crate) async fn did_change_configuration( - session: &Session, + session: &SessionHandle, params: DidChangeConfigurationParams, ) { match serde_json::from_value::(params.settings) { diff --git a/crates/pgls_lsp/src/server.rs b/crates/pgls_lsp/src/server.rs index 4187be566..afdc44f93 100644 --- a/crates/pgls_lsp/src/server.rs +++ b/crates/pgls_lsp/src/server.rs @@ -1,16 +1,13 @@ use crate::capabilities::server_capabilities; use crate::handlers; -use crate::session::{ - CapabilitySet, CapabilityStatus, ClientInformation, Session, SessionHandle, SessionKey, -}; +use crate::session::{ClientInformation, Session, SessionHandle, SessionKey}; use crate::utils::{into_lsp_error, panic_to_lsp_error}; use futures::FutureExt; use futures::future::ready; use pgls_configuration::database::PartialDatabaseConfiguration; -use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; +use pgls_fs::{FileSystem, OsFileSystem}; use pgls_workspace::{DynRef, Workspace, workspace}; use rustc_hash::FxHashMap; -use serde_json::json; use std::panic::RefUnwindSafe; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -51,48 +48,6 @@ impl LSPServer { is_initialized, } } - - async fn setup_capabilities(&self) { - let mut capabilities = CapabilitySet::default(); - - capabilities.add_capability( - "pgls_did_change_extension_settings", - "workspace/didChangeConfiguration", - if self.session.can_register_did_change_configuration() { - CapabilityStatus::Enable(None) - } else { - CapabilityStatus::Disable - }, - ); - - capabilities.add_capability( - "pgls_did_change_workspace_settings", - "workspace/didChangeWatchedFiles", - match self.session.base_path() { - Some(base_path) => { - // Configuration files below the root apply to their directories. - let watchers = ConfigName::file_names() - .iter() - .map(|config_name| FileSystemWatcher { - glob_pattern: GlobPattern::String(format!( - "{}/**/{}", - base_path.display(), - config_name - )), - kind: Some(WatchKind::all()), - }) - .collect(); - - CapabilityStatus::Enable(Some(json!( - DidChangeWatchedFilesRegistrationOptions { watchers } - ))) - } - _ => CapabilityStatus::Disable, - }, - ); - - self.session.register_capabilities(capabilities).await; - } } impl LanguageServer for LSPServer { @@ -150,7 +105,7 @@ impl LanguageServer for LSPServer { .log_message(MessageType::INFO, msg) .await; - self.setup_capabilities().await; + self.session.setup_capabilities().await; // Diagnostics are disabled by default, so update them after fetching workspace config self.session.update_all_diagnostics().await; @@ -164,39 +119,27 @@ impl LanguageServer for LSPServer { #[tracing::instrument(level = "info", skip_all)] async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { handlers::configuration::did_change_configuration(&self.session, params).await; - self.setup_capabilities().await; + self.session.setup_capabilities().await; } #[tracing::instrument(level = "trace", skip_all)] async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { - let file_paths = params - .changes - .iter() - .map(|change| change.uri.to_file_path()); - for file_path in file_paths { - match file_path { - Some(file_path) => { - // The root configuration file or one below it, which applies to its directory. - let is_config_file = file_path - .file_name() - .and_then(|file_name| file_name.to_str()) - .is_some_and(|file_name| ConfigName::file_names().contains(&file_name)); - if is_config_file { - self.session.reload_workspace_settings().await; - self.setup_capabilities().await; - self.session.update_all_diagnostics().await; - // for now we are only interested to the configuration file, - // so it's OK to exist the loop - break; + let is_configuration_change = + params + .changes + .iter() + .any(|change| match change.uri.to_file_path() { + Some(file_path) => self.session.is_configuration_change(&file_path), + None => { + error!( + "The URI {:?} could not be parsed as a filesystem path", + change.uri + ); + false } - } - None => { - error!( - "The Workspace root URI {file_path:?} could not be parsed as a filesystem path" - ); - continue; - } - } + }); + if is_configuration_change { + self.session.schedule_configuration_reload(); } } @@ -243,7 +186,7 @@ impl LanguageServer for LSPServer { // Loading the configuration registers the projects of the new folders and unregisters the // ones of the removed folders. self.session.reload_workspace_settings().await; - self.setup_capabilities().await; + self.session.setup_capabilities().await; self.session.update_all_diagnostics().await; } @@ -549,8 +492,9 @@ mod tests { use crate::session::SessionHandle; use futures::StreamExt; use pgls_configuration::PartialConfiguration; - use pgls_fs::{OsFileSystem, PgLSPath}; + use pgls_fs::{ConfigName, OsFileSystem, PgLSPath}; use pgls_workspace::workspace::{IsPathIgnoredParams, OpenFileParams}; + use serde_json::json; use std::future::ready; use std::path::Path; use tower_lsp_server::ls_types::Uri; diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index b70b88580..6b48240c5 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -13,15 +13,17 @@ use pgls_fs::{ConfigName, FileSystem, PgLSPath}; use pgls_workspace::PartialConfigurationExt; use pgls_workspace::Workspace; use pgls_workspace::configuration::{ - LoadedConfiguration, NestedConfiguration, apply_nested_configurations, load_configuration, + LoadedConfiguration, NestedConfiguration, NestedConfigurationSearch, + apply_nested_configurations, is_searched_for_configuration, load_configuration, }; use pgls_workspace::features; +use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::{ ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, UpdateSettingsParams, }; use pgls_workspace::{DynRef, WorkspaceError}; use rustc_hash::FxHashMap; -use serde_json::Value; +use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; @@ -31,12 +33,15 @@ use std::sync::atomic::{AtomicBool, AtomicU8}; use std::time::Duration; use tokio::sync::Notify; use tokio::sync::OnceCell; -use tokio::task::AbortHandle; +use tokio::task::{AbortHandle, spawn_blocking}; use tower_lsp_server::ls_types::Uri; use tower_lsp_server::ls_types::{self, ClientCapabilities}; +use tower_lsp_server::ls_types::{ + DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, WatchKind, +}; use tower_lsp_server::ls_types::{MessageType, Registration}; use tower_lsp_server::ls_types::{Unregistration, WorkspaceFolder}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; /// Debounce delay for `textDocument/didChange` diagnostics. /// @@ -45,6 +50,10 @@ use tracing::{debug, error, info}; /// and the timer resets, so only one analysis run fires per burst. const DIAGNOSTIC_DEBOUNCE: Duration = Duration::from_millis(300); +/// How long the server waits after a configuration file changed before it reloads the +/// configuration, so that a burst of changes causes one reload. +const CONFIGURATION_RELOAD_DEBOUNCE: Duration = Duration::from_millis(200); + pub(crate) struct ClientInformation { #[allow(dead_code)] /// The name of the client @@ -107,6 +116,13 @@ pub(crate) struct Session { /// one for the root. A document belongs to the project of the deepest folder that contains it. projects: RwLock>, + /// The files settings of the root configuration of each project, by its folder. They decide + /// which folders are searched for nested configuration files. + root_files: RwLock>>>, + + /// Whether a reload for changed configuration files is scheduled and hasn't started yet. + reload_scheduled: AtomicBool, + /// Serialises a whole configuration transition: storing the layer, reloading the settings and /// refreshing the diagnostics of all open documents. /// @@ -234,6 +250,8 @@ impl Session { client_overrides: RwLock::new(ClientOverrides::default()), workspace_folders: RwLock::default(), projects: RwLock::default(), + root_files: RwLock::default(), + reload_scheduled: AtomicBool::new(false), configuration_transition: tokio::sync::Mutex::new(()), #[cfg(test)] running_transitions: std::sync::atomic::AtomicUsize::new(0), @@ -282,6 +300,90 @@ impl Session { } } + /// Registers the capabilities that depend on the client and the workspace folders. + pub(crate) async fn setup_capabilities(&self) { + let mut capabilities = CapabilitySet::default(); + + capabilities.add_capability( + "pgls_did_change_extension_settings", + "workspace/didChangeConfiguration", + if self.can_register_did_change_configuration() { + CapabilityStatus::Enable(None) + } else { + CapabilityStatus::Disable + }, + ); + + capabilities.add_capability( + "pgls_did_change_workspace_settings", + "workspace/didChangeWatchedFiles", + match self.base_path() { + Some(base_path) => { + // Configuration files below the root apply to their directories. + let watchers = ConfigName::file_names() + .iter() + .map(|config_name| FileSystemWatcher { + glob_pattern: GlobPattern::String(format!( + "{}/**/{}", + base_path.display(), + config_name + )), + kind: Some(WatchKind::all()), + }) + .collect(); + + CapabilityStatus::Enable(Some(json!( + DidChangeWatchedFilesRegistrationOptions { watchers } + ))) + } + _ => CapabilityStatus::Disable, + }, + ); + + self.register_capabilities(capabilities).await; + } + + /// Whether a change of the file at `path` changes the configuration: a configuration file at + /// or above a workspace folder, or one below it that the nested configuration search looks + /// at. Others, e.g. in `node_modules` or in ignored folders, are skipped. + pub(crate) fn is_configuration_change(&self, path: &Path) -> bool { + let is_configuration_file = path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| ConfigName::file_names().contains(&file_name)); + let Some(directory) = path.parent().filter(|_| is_configuration_file) else { + return false; + }; + let projects = self.projects.read().unwrap(); + let Some((folder, _)) = projects + .iter() + .find(|(folder, _)| directory.starts_with(folder)) + else { + return true; + }; + let root_files = self.root_files.read().unwrap(); + let root_files = root_files.get(folder).cloned().flatten(); + is_searched_for_configuration(&self.fs, folder, root_files.as_deref(), directory) + } + + /// Reloads the configuration and refreshes the diagnostics shortly, once for every + /// configuration file that changes in the meantime, such as during a `git checkout`. + pub(crate) fn schedule_configuration_reload(self: &Arc) { + if self.reload_scheduled.swap(true, Ordering::AcqRel) { + return; + } + let session = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(CONFIGURATION_RELOAD_DEBOUNCE).await; + // A change from here on needs another reload, as this one may have read the file + // before it. + session.reload_scheduled.store(false, Ordering::Release); + session.reload_workspace_settings().await; + session.setup_capabilities().await; + session.update_all_diagnostics().await; + }); + } + /// Register a set of capabilities with the client pub(crate) async fn register_capabilities(&self, capabilities: CapabilitySet) { let mut registrations = Vec::new(); @@ -574,7 +676,7 @@ impl Session { /// every workspace folder, or of the root URI, and update the settings of its project /// accordingly. The configuration files below a folder apply to the files below them. #[tracing::instrument(level = "trace", skip(self))] - async fn load_workspace_settings(&self) { + async fn load_workspace_settings(self: &Arc) { let roots = if let Some(config_path) = &self.config_path { // Providing a custom configuration path will not allow to support workspaces vec![ConfigurationPathHint::FromUser(config_path.clone())] @@ -606,6 +708,7 @@ impl Session { let previous = std::mem::take(&mut *self.projects.write().unwrap()); for (folder, _) in previous { if !projects.iter().any(|(project, _)| project == &folder) { + self.root_files.write().unwrap().remove(&folder); let result = self.workspace .unregister_project_folder(UnregisterProjectFolderParams { @@ -625,7 +728,7 @@ impl Session { /// Loads the configuration of a project and its nested configuration files. Returns the folder /// and the key of the project, unless it could not be registered. async fn load_project( - &self, + self: &Arc, base_path: ConfigurationPathHint, ) -> (Option<(PathBuf, ProjectKey)>, ConfigurationStatus) { // The configuration files below a workspace root apply to their directories. With a @@ -659,7 +762,7 @@ impl Session { .iter() .any(|(project, _)| project == &folder); - let status = self + let (status, root_files) = self .load_pgls_configuration_file(project_key, base_path) .await; if !matches!(status, ConfigurationStatus::Loaded) { @@ -675,11 +778,16 @@ impl Session { } return (Some((folder, project_key)), status); } + let root_files = root_files.map(Arc::new); + self.root_files + .write() + .unwrap() + .insert(folder.clone(), root_files.clone()); let project = Some((folder, project_key)); let status = match root { Some(root) => { - self.load_nested_configuration_files(project_key, &root) + self.load_nested_configuration_files(project_key, root, root_files) .await } None => ConfigurationStatus::Loaded, @@ -706,29 +814,49 @@ impl Session { } /// Applies every configuration file below `root` to the project, with the same layers as the - /// root configuration. + /// root configuration. The folders that `root_files` ignores are not searched. async fn load_nested_configuration_files( - &self, + self: &Arc, project_key: ProjectKey, - root: &Path, + root: PathBuf, + root_files: Option>, ) -> ConfigurationStatus { - let nested = match apply_nested_configurations( - &*self.workspace, - &self.fs, - project_key, - root, - self.configure(), - ) { - Ok(nested) => nested, - Err(error) => { + // Walking a large folder takes a while, so keep it off the async runtime. + let session = Arc::clone(self); + let result = spawn_blocking(move || { + apply_nested_configurations( + &*session.workspace, + &session.fs, + project_key, + NestedConfigurationSearch { + root_files, + ..NestedConfigurationSearch::new(root) + }, + session.configure(), + ) + }) + .await; + let nested = match result { + Ok(Ok(nested)) => nested, + Ok(Err(error)) => { error!("Failed to apply the nested configuration files: {}", error); self.client.log_message(MessageType::ERROR, &error).await; return ConfigurationStatus::Error; } + Err(error) => { + error!("Failed to look for nested configuration files: {}", error); + return ConfigurationStatus::Error; + } }; + if let Some(incomplete) = nested.incomplete { + warn!("{incomplete}"); + self.client + .log_message(MessageType::WARNING, incomplete.to_string()) + .await; + } let mut status = ConfigurationStatus::Loaded; - for NestedConfiguration { directory, result } in nested { + for NestedConfiguration { directory, result } in nested.configurations { match result { Ok(()) => info!("Loaded the nested configuration in {}", directory.display()), Err(error) => { @@ -746,11 +874,14 @@ impl Session { } /// Loads the configuration file at or above the root of a project into its root settings. + /// + /// Also returns the files settings of the configuration, which decide the folders to search + /// for nested configuration files. async fn load_pgls_configuration_file( &self, project_key: ProjectKey, base_path: ConfigurationPathHint, - ) -> ConfigurationStatus { + ) -> (ConfigurationStatus, Option) { match load_configuration(&self.fs, base_path.clone()) { Ok(loaded_configuration) => { let LoadedConfiguration { @@ -810,9 +941,17 @@ impl Session { match result { Ok((vcs_base_path, gitignore_matches)) => { + let workspace_directory = self.fs.working_directory(); + let root_files = FilesSettings::from_configuration( + &configuration, + workspace_directory.clone(), + vcs_base_path.clone(), + &gitignore_matches, + ) + .ok(); let result = self.workspace.update_settings(UpdateSettingsParams { project_key, - workspace_directory: self.fs.working_directory(), + workspace_directory, configuration, vcs_base_path, gitignore_matches, @@ -821,22 +960,22 @@ impl Session { if let Err(error) = result { error!("Failed to set workspace settings: {}", error); self.client.log_message(MessageType::ERROR, &error).await; - ConfigurationStatus::Error + (ConfigurationStatus::Error, None) } else { - ConfigurationStatus::Loaded + (ConfigurationStatus::Loaded, root_files) } } Err(err) => { error!("Couldn't load the configuration file, reason:\n {}", err); self.client.log_message(MessageType::ERROR, &err).await; - ConfigurationStatus::Error + (ConfigurationStatus::Error, None) } } } Err(err) => { error!("Couldn't load the configuration file, reason:\n {}", err); self.client.log_message(MessageType::ERROR, &err).await; - ConfigurationStatus::Error + (ConfigurationStatus::Error, None) } } } @@ -847,7 +986,7 @@ impl Session { /// open documents happen as one transition under `configuration_transition`, so that two /// concurrent calls cannot interleave. pub(crate) async fn set_client_overrides( - &self, + self: &Arc, overrides: Option, ) -> ConfigurationStatus { let _transition = self.configuration_transition.lock().await; @@ -878,7 +1017,7 @@ impl Session { /// Reloads the configuration from disk, re-applying the sticky layers. /// /// Diagnostics are left alone: the callers decide whether the reload should refresh them. - pub(crate) async fn reload_workspace_settings(&self) -> ConfigurationStatus { + pub(crate) async fn reload_workspace_settings(self: &Arc) -> ConfigurationStatus { let _transition = self.configuration_transition.lock().await; #[cfg(test)] let _running = self.enter_transition(); diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index c29fec46b..bee034cef 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -23,6 +23,7 @@ use sqlx::Executor; use sqlx::PgPool; use std::any::type_name; use std::fmt::Display; +use std::path::Path; use std::str::FromStr; use std::time::Duration; use tokio::io::AsyncWriteExt; @@ -688,7 +689,10 @@ async fn client_configuration_overrides_are_sticky_and_clearable(test_db: PgPool ); // A configuration reload carries no client configuration, but the sticky layer survives it. + drain_notifications(&mut receiver); server.change_watched_configuration_file().await?; + // The reload refreshes the diagnostics of the open document. + wait_for_diagnostics(&mut receiver, 0).await?; drain_notifications(&mut receiver); server.replace_document(1, STATEMENT).await?; assert!( @@ -3209,10 +3213,15 @@ async fn nested_configuration_files_apply_to_their_directories() -> Result<()> { ) .await?; drain_notifications(&mut receiver); - assert_eq!( - open_and_check(&mut server, &mut receiver, document("db3/a.sql")).await?, - [] - ); + // The reload can come after the document is first checked. + server + .open_named_document( + "alter table users drop column name;", + document("db3/a.sql"), + "sql", + ) + .await?; + wait_for_severities(&mut receiver, &document("db3/a.sql"), &[]).await?; // A deleted one no longer applies, and the open documents are checked again. std::fs::remove_file(root.path().join("db1").join(ConfigName::pgls_jsonc()))?; @@ -3346,3 +3355,81 @@ async fn workspace_folders_can_be_added_and_removed() -> Result<()> { Ok(()) } + +/// A burst of configuration file changes, e.g. from a `git checkout`, reloads the configuration +/// once, and a change in a folder the nested configuration search skips doesn't reload it. +#[tokio::test] +async fn configuration_changes_are_coalesced() -> Result<()> { + let root = tempfile::tempdir()?; + let config = root.path().join(ConfigName::pgls_jsonc()); + std::fs::write(&config, "{}")?; + let skipped = root + .path() + .join("node_modules/pkg") + .join(ConfigName::pgls_jsonc()); + std::fs::create_dir_all(skipped.parent().unwrap())?; + std::fs::write(&skipped, r#"{ "linter": { "enabled": false } }"#)?; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_root(Uri::from_file_path(root.path()).unwrap()) + .await?; + server.initialized().await?; + + let document = Uri::from_file_path(root.path().join("a.sql")).unwrap(); + assert_eq!( + open_and_check(&mut server, &mut receiver, document.clone()).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + + let changed = |path: &Path| DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(path).unwrap(), + typ: FileChangeType::CHANGED, + }], + }; + // Counts the times the diagnostics of the document are refreshed within a second. + async fn refreshes(receiver: &mut Receiver, uri: &Uri) -> usize { + let mut refreshes = 0; + let _ = tokio::time::timeout(Duration::from_secs(1), async { + while let Some(ServerNotification::PublishDiagnostics(params)) = receiver.next().await { + if ¶ms.uri == uri { + refreshes += 1; + } + } + }) + .await; + refreshes + } + + server + .notify("workspace/didChangeWatchedFiles", changed(&skipped)) + .await?; + assert_eq!( + refreshes(&mut receiver, &document).await, + 0, + "a configuration file in node_modules reloaded the configuration" + ); + + for _ in 0..5 { + server + .notify("workspace/didChangeWatchedFiles", changed(&config)) + .await?; + } + assert_eq!(refreshes(&mut receiver, &document).await, 1); + + server.shutdown().await?; + reader.abort(); + Ok(()) +} diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 52603f09e..8a13e7261 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -3,6 +3,7 @@ use std::{ io::ErrorKind, ops::Deref, path::{Path, PathBuf}, + sync::Arc, }; use pgls_analyser::LinterRules; @@ -13,11 +14,15 @@ use pgls_configuration::{ }; use pgls_console::markup; use pgls_env::PGLS_WEBSITE; -use pgls_fs::{AutoSearchResult, ConfigName, FileSystem, OpenOptions}; +use pgls_fs::{ + AutoSearchResult, ConfigName, FileSystem, FindFiles, OpenOptions, SKIPPED_DIRECTORIES, + SkipDirectory, +}; +use rustc_hash::FxHashSet; use crate::{ DynRef, Workspace, WorkspaceError, - settings::Settings, + settings::{FilesSettings, Settings}, workspace::{ NestedSettingsParams, ProjectKey, UpdateNestedSettingsParams, UpdateNestedSettingsResult, }, @@ -116,33 +121,104 @@ pub struct NestedConfiguration { pub result: Result<(), WorkspaceError>, } -/// Applies every configuration file below `root` to the project as nested settings, so that each -/// file uses the settings of the nearest configuration file. They replace the nested settings the -/// project had before. +/// Where [apply_nested_configurations] looks for configuration files. +pub struct NestedConfigurationSearch { + /// The folder of the project. + pub root: PathBuf, + /// Only the configuration files that apply to these paths are looked up: those in the folders + /// between the root and each path, and those below the paths that are folders. `None` looks + /// below the whole root. Relative paths are relative to the root. + pub paths: Option>, + /// The files settings of the root configuration. The folders they ignore are not searched. + pub root_files: Option>, +} + +impl NestedConfigurationSearch { + /// Looks for every configuration file below `root`. + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + paths: None, + root_files: None, + } + } +} + +/// The result of [apply_nested_configurations]. +#[derive(Debug, Default)] +pub struct NestedConfigurations { + /// The configuration files that apply. + pub configurations: Vec, + /// Set if the search did not look at every folder it should have, so that some configuration + /// files may be missing. + pub incomplete: Option, +} + +/// Why [apply_nested_configurations] did not look at every folder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IncompleteSearch { + /// The folder is the home directory or a filesystem root, which holds unrelated projects. + NotAProjectFolder(PathBuf), + /// The search stopped at the depth or entry limit of [FindFiles]. + LimitReached(PathBuf), +} + +impl std::fmt::Display for IncompleteSearch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAProjectFolder(folder) => write!( + f, + "{} is the home directory or a filesystem root, so the configuration files below \ + it are not used. Open a project folder to use them.", + folder.display() + ), + Self::LimitReached(folder) => write!( + f, + "Stopped looking for configuration files below {} after {} files or {} levels of \ + folders, so some may not be used. Ignore the folders that hold no SQL files in \ + `files.ignore` or `.gitignore`.", + folder.display(), + FindFiles::DEFAULT_MAX_ENTRIES, + FindFiles::DEFAULT_MAX_DEPTH + ), + } + } +} + +/// Applies the configuration files below the root of a project to it as nested settings, so that +/// each file uses the settings of the nearest configuration file. They replace the nested settings +/// the project had before. /// /// A nested configuration file inherits nothing from the configuration files above it unless it /// `extends` them, and the user configuration is never merged into it: that one only applies, as /// the root settings, to the files that have no project configuration at or above them. The user -/// configuration directories are skipped even when they are below `root`, for example when the -/// home directory is opened as a workspace. +/// configuration directories are skipped even when they are below the root, and so is everything +/// below the home directory or a filesystem root, which hold unrelated projects. /// /// `configure` lays the layers of the caller, such as the environment or the arguments of the /// CLI, over each configuration file. Folders that the settings above them ignore are skipped. /// -/// Returns the configuration files that apply. Fails if the project is not registered. +/// This walks the file system, so call it off the async runtime. Fails if the project is not +/// registered. pub fn apply_nested_configurations( workspace: &dyn Workspace, fs: &DynRef<'_, dyn FileSystem>, project_key: ProjectKey, - root: &Path, + search: NestedConfigurationSearch, configure: impl Fn(PartialConfiguration) -> PartialConfiguration, -) -> Result, WorkspaceError> { +) -> Result { + let NestedConfigurationSearch { + root, + paths, + root_files, + } = search; + let root = root.as_path(); + let (directories, incomplete) = + find_nested_configurations(fs, root, paths.as_deref(), root_files); let user_config_directories = fs.user_config_directories(); - let mut directories: Vec = fs - .find_files(root, ConfigName::file_names().as_slice()) + let mut directories: Vec = directories .into_iter() - .filter_map(|file_path| file_path.parent().map(PathBuf::from)) - .filter(|directory| directory != root) + .filter(|directory| directory != root && directory.starts_with(root)) .filter(|directory| !user_config_directories.contains(directory)) .collect(); directories.sort(); @@ -171,7 +247,7 @@ pub fn apply_nested_configurations( nested, })?; - Ok(applied + let configurations = applied .into_iter() .map(|directory| { let load_error = load_errors @@ -188,7 +264,136 @@ pub fn apply_nested_configurations( }; NestedConfiguration { directory, result } }) - .collect()) + .collect(); + Ok(NestedConfigurations { + configurations, + incomplete, + }) +} + +/// The folders with a configuration file that [NestedConfigurationSearch] asks for. +fn find_nested_configurations( + fs: &DynRef<'_, dyn FileSystem>, + root: &Path, + paths: Option<&[PathBuf]>, + root_files: Option>, +) -> (Vec, Option) { + let file_names = ConfigName::file_names(); + let skip_directory = root_files.map(|root_files| { + Arc::new(move |directory: &Path| root_files.is_ignored(directory)) as SkipDirectory + }); + let mut incomplete = None; + let mut find = |folder: &Path| -> Vec { + if is_not_a_project_folder(fs, folder) { + incomplete.get_or_insert(IncompleteSearch::NotAProjectFolder(folder.to_path_buf())); + return Vec::new(); + } + let found = fs.find_files( + folder, + &FindFiles { + skip_directory: skip_directory.clone(), + ..FindFiles::new(&file_names) + }, + ); + if found.truncated { + incomplete.get_or_insert(IncompleteSearch::LimitReached(folder.to_path_buf())); + } + found + .files + .into_iter() + .filter_map(|file_path| file_path.parent().map(PathBuf::from)) + .collect() + }; + + let Some(paths) = paths else { + let directories = find(root); + return (directories, incomplete); + }; + + let mut directories = Vec::new(); + // The folders between the root and a path that were looked at, with their ancestors. + let mut looked_at = FxHashSet::default(); + for path in paths { + let path = normalize_path(&root.join(path)); + if !path.starts_with(root) { + continue; + } + if fs.path_is_dir(&path) { + directories.extend(find(&path)); + } + for directory in path + .ancestors() + .skip(1) + .take_while(|directory| *directory != root && directory.starts_with(root)) + { + if !looked_at.insert(directory.to_path_buf()) { + break; + } + if file_names + .iter() + .any(|file_name| fs.path_is_file(&directory.join(file_name))) + { + directories.push(directory.to_path_buf()); + } + } + } + (directories, incomplete) +} + +/// Whether [apply_nested_configurations] looks for a configuration file in `directory` when it +/// searches the whole `root`, so that a change of the file there has to be reloaded. +/// `root_files` are the files settings of the root configuration. +pub fn is_searched_for_configuration( + fs: &DynRef<'_, dyn FileSystem>, + root: &Path, + root_files: Option<&FilesSettings>, + directory: &Path, +) -> bool { + let Ok(relative) = directory.strip_prefix(root) else { + return false; + }; + if relative.as_os_str().is_empty() { + return true; + } + let is_skipped = |name: &OsStr| { + name.to_str() + .is_none_or(|name| name.starts_with('.') || SKIPPED_DIRECTORIES.contains(&name)) + }; + if is_not_a_project_folder(fs, root) + || relative.iter().any(is_skipped) + || relative.iter().count() > FindFiles::DEFAULT_MAX_DEPTH + || fs + .user_config_directories() + .iter() + .any(|user| user == directory) + { + return false; + } + let Some(root_files) = root_files else { + return true; + }; + // Like the search: the root settings decide down to the first configuration file below it. + let mut folder = root.to_path_buf(); + for component in relative { + folder.push(component); + if root_files.is_ignored(&folder) { + return false; + } + if folder != directory + && ConfigName::file_names() + .iter() + .any(|file_name| fs.path_is_file(&folder.join(file_name))) + { + return true; + } + } + true +} + +/// The home directory and the filesystem roots hold unrelated projects, so looking for +/// configuration files below them would apply the ones of other projects and take long. +fn is_not_a_project_folder(fs: &DynRef<'_, dyn FileSystem>, folder: &Path) -> bool { + folder.parent().is_none() || fs.home_directory().is_some_and(|home| home == folder) } fn load_nested_configuration( diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 4d16977f7..03e530ead 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -908,6 +908,50 @@ pub(crate) const DEFAULT_FILE_SIZE_LIMIT: NonZeroU64 = // SAFETY: This constant is initialized with a non-zero value NonZeroU64::new(1024 * 1024).unwrap(); +impl FilesSettings { + /// The files settings of a configuration, without applying the rest of it. + pub fn from_configuration( + configuration: &PartialConfiguration, + working_directory: Option, + vcs_path: Option, + gitignore_matches: &[String], + ) -> Result { + Ok(to_file_settings( + working_directory, + configuration.files.clone().map(FilesConfiguration::from), + vcs_path, + gitignore_matches, + )? + .unwrap_or_default()) + } + + /// Whether a file is ignored by `files.ignore`/`files.include` or the VCS ignore file. + pub fn is_ignored(&self, path: &Path) -> bool { + let is_included = self.included_files.is_empty() + || is_dir(path) + || self.included_files.matches_path(path); + !is_included + || self.ignored_files.matches_path(path) + || self.git_ignore.as_ref().is_some_and(|ignore| { + // `matched_path_or_any_parents` panics if `source` is not under the gitignore root. + // This checks excludes absolute paths that are not a prefix of the base root. + if !path.has_root() || path.starts_with(ignore.path()) { + // Because Postgres Language Server passes a list of paths, + // we use `matched_path_or_any_parents` instead of `matched`. + ignore + .matched_path_or_any_parents(path, path.is_dir()) + .is_ignore() + } else { + false + } + }) + } +} + +fn is_dir(path: &Path) -> bool { + path.is_dir() || (path.is_symlink() && std::fs::read_link(path).is_ok_and(|path| path.is_dir())) +} + impl Default for FilesSettings { fn default() -> Self { Self { diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index a9334a1b5..de9ff6b01 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -1,6 +1,5 @@ use std::{ collections::HashMap, - fs, hash::{DefaultHasher, Hash, Hasher}, panic::RefUnwindSafe, path::{Path, PathBuf}, @@ -209,8 +208,7 @@ impl WorkspaceServer { }; // Apply top-level `include`/`ignore` - is_ignored_by_files_settings(settings, path) - || is_ignored_by_migration_settings(settings, path) + settings.files.is_ignored(path) || is_ignored_by_migration_settings(settings, path) } /// Whether the file is a migration. Migration-only rules don't run on other files. @@ -789,28 +787,6 @@ impl WorkspaceServer { } } -/// Check whether a file is ignored by `files.ignore`/`files.include` or the VCS ignore file -fn is_ignored_by_files_settings(settings: &Settings, path: &Path) -> bool { - let is_included = settings.files.included_files.is_empty() - || is_dir(path) - || settings.files.included_files.matches_path(path); - !is_included - || settings.files.ignored_files.matches_path(path) - || settings.files.git_ignore.as_ref().is_some_and(|ignore| { - // `matched_path_or_any_parents` panics if `source` is not under the gitignore root. - // This checks excludes absolute paths that are not a prefix of the base root. - if !path.has_root() || path.starts_with(ignore.path()) { - // Because Postgres Language Server passes a list of paths, - // we use `matched_path_or_any_parents` instead of `matched`. - ignore - .matched_path_or_any_parents(path, path.is_dir()) - .is_ignore() - } else { - false - } - }) -} - /// Check whether a file is a migration before `migrations.after` fn is_ignored_by_migration_settings(settings: &Settings, path: &Path) -> bool { settings @@ -907,7 +883,7 @@ impl Workspace for WorkspaceServer { .rev() .find(|(applied, _)| directory.starts_with(applied)) .map_or(root, |(_, settings)| settings); - if is_ignored_by_files_settings(above, &directory) { + if above.files.is_ignored(&directory) { continue; } @@ -1890,10 +1866,6 @@ fn document_sections(settings: &Settings, doc: &Document) -> Vec bool { - path.is_dir() || (path.is_symlink() && fs::read_link(path).is_ok_and(|path| path.is_dir())) -} - /// Keeps at most one blank line in `text`, which only holds whitespace and comments. fn collapse_blank_lines(text: &str) -> String { let mut result = String::with_capacity(text.len()); diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index f9883069a..790184e22 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -18,19 +18,23 @@ use pgls_configuration::ConfigurationPathHint; #[cfg(not(target_os = "windows"))] use pgls_configuration::plpgsql_check::PartialPlPgSqlCheckConfiguration; use pgls_diagnostics::{Diagnostic, Severity}; -use pgls_fs::{ConfigName, FileSystem, MemoryFileSystem, PgLSPath}; +use pgls_fs::{ConfigName, FileSystem, FindFiles, MemoryFileSystem, PgLSPath}; use pgls_text_size::TextRange; use sqlx::{Executor, PgPool}; use crate::{ DynRef, Workspace, WorkspaceError, - configuration::{apply_nested_configurations, load_configuration}, + configuration::{ + IncompleteSearch, NestedConfigurationSearch, apply_nested_configurations, + is_searched_for_configuration, load_configuration, + }, features::code_actions::{ CodeActionKind, ExecuteStatementResult, FixFileMode, FixFileParams, TextEdit, }, features::format::PullFileFormattingParams, features::inlay_hints::{InlayHint, InlayHintKind, PullInlayHintsParams}, features::on_hover::OnHoverParams, + settings::FilesSettings, workspace::{ IsPathIgnoredParams, OpenFileParams, ProjectKey, RegisterProjectFolderParams, StatementId, UnregisterProjectFolderParams, UpdateNestedSettingsParams, UpdateSettingsParams, @@ -2337,6 +2341,24 @@ impl TestProject { } } + /// Applies the configuration files the search finds. Returns their folders and whether the + /// search was incomplete. + fn search( + &self, + fs: &DynRef<'_, dyn FileSystem>, + search: NestedConfigurationSearch, + ) -> (Vec, Option) { + let nested = + apply_nested_configurations(&*self.workspace, fs, self.project_key, search, |c| c) + .unwrap(); + let directories = nested + .configurations + .into_iter() + .map(|nested| nested.directory) + .collect(); + (directories, nested.incomplete) + } + /// Another project of the same workspace. fn other(&self, project_key: ProjectKey) -> Self { Self { @@ -2355,10 +2377,11 @@ impl TestProject { &*self.workspace, fs, self.project_key, - root, + NestedConfigurationSearch::new(root), |configuration| configuration, ) .unwrap() + .configurations .into_iter() .map(|nested| { assert!(nested.result.is_ok(), "{:?}", nested.result); @@ -2415,6 +2438,20 @@ impl TestProject { } } +/// The files settings of the configuration file at or above `root`. +fn root_files(fs: &DynRef<'_, dyn FileSystem>, root: &str) -> Arc { + let configuration = load_configuration( + fs, + ConfigurationPathHint::FromWorkspace(PathBuf::from(root)), + ) + .unwrap() + .configuration; + Arc::new( + FilesSettings::from_configuration(&configuration, Some(PathBuf::from(root)), None, &[]) + .unwrap(), + ) +} + /// A memory file system with a configuration file in each of the given folders. fn file_system_with_configurations(configurations: &[(&str, &str)]) -> MemoryFileSystem { let mut fs = MemoryFileSystem::default(); @@ -2470,6 +2507,172 @@ fn files_use_the_nearest_configuration_file() { assert!(project.is_ignored("/repo/legacy/a.sql")); } +/// The search doesn't descend into the folders that the root configuration ignores, except below +/// a nested configuration file: its settings decide there, so the configuration files below it +/// are still found. +#[test] +fn the_search_skips_the_folders_the_root_configuration_ignores() { + let fs = file_system_with_configurations(&[ + ( + "/repo", + r#"{ "files": { "ignore": ["legacy", "db1/generated"] } }"#, + ), + ("/repo/legacy/app", "{}"), + ("/repo/db1", "{}"), + ( + "/repo/db1/generated", + r#"{ "linter": { "enabled": false } }"#, + ), + ]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let project = TestProject::without_nested_configurations(&fs, Path::new("/repo")); + + let (directories, incomplete) = project.search( + &fs, + NestedConfigurationSearch { + root_files: Some(root_files(&fs, "/repo")), + ..NestedConfigurationSearch::new("/repo") + }, + ); + assert_eq!( + directories, + [ + PathBuf::from("/repo/db1"), + PathBuf::from("/repo/db1/generated") + ] + ); + assert_eq!(incomplete, None); + assert_eq!(project.severities("/repo/db1/generated/a.sql"), []); +} + +/// The home directory and the filesystem roots hold unrelated projects, so their configuration +/// files are not applied, and the search says so. +#[test] +fn the_search_skips_the_home_directory() { + let mut fs = file_system_with_configurations(&[( + "/home/alice/project", + r#"{ "linter": { "enabled": false } }"#, + )]); + fs.set_home_directory("/home/alice"); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + + let project = TestProject::without_nested_configurations(&fs, Path::new("/home/alice")); + let (directories, incomplete) = + project.search(&fs, NestedConfigurationSearch::new("/home/alice")); + assert_eq!(directories, Vec::::new()); + assert_eq!( + incomplete, + Some(IncompleteSearch::NotAProjectFolder(PathBuf::from( + "/home/alice" + ))) + ); + assert_eq!( + project.severities("/home/alice/project/a.sql"), + [Severity::Warning] + ); + + let project = TestProject::without_nested_configurations(&fs, Path::new("/")); + let (directories, incomplete) = project.search(&fs, NestedConfigurationSearch::new("/")); + assert_eq!(directories, Vec::::new()); + assert_eq!( + incomplete, + Some(IncompleteSearch::NotAProjectFolder(PathBuf::from("/"))) + ); +} + +/// A search for some paths, like the CLI's, only finds the configuration files on the way to +/// each path and below the paths that are folders. +#[test] +fn the_search_for_paths_only_looks_at_their_folders() { + let mut fs = file_system_with_configurations(&[ + ("/repo", "{}"), + ("/repo/db1", "{}"), + ("/repo/db1/sub", "{}"), + ("/repo/db2", "{}"), + ("/repo/db2/nested", "{}"), + ("/repo/db3", "{ this is not json"), + ]); + fs.insert(PathBuf::from("/repo/db1/sub/a.sql"), "select 1;"); + fs.insert(PathBuf::from("/repo/db2/b.sql"), "select 1;"); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let project = TestProject::without_nested_configurations(&fs, Path::new("/repo")); + + let (directories, incomplete) = project.search( + &fs, + NestedConfigurationSearch { + paths: Some(vec![ + PathBuf::from("db1/sub/a.sql"), + PathBuf::from("/repo/db1/sub/b.sql"), + PathBuf::from("/repo/db2"), + PathBuf::from("/elsewhere/c.sql"), + ]), + ..NestedConfigurationSearch::new("/repo") + }, + ); + assert_eq!( + directories, + [ + PathBuf::from("/repo/db1"), + PathBuf::from("/repo/db2"), + PathBuf::from("/repo/db1/sub"), + PathBuf::from("/repo/db2/nested"), + ] + ); + assert_eq!(incomplete, None); +} + +/// The search stops at its depth limit and says so. +#[test] +fn the_search_reports_its_depth_limit() { + let deep = format!( + "/repo/{}", + ["d"; FindFiles::DEFAULT_MAX_DEPTH + 1].join("/") + ); + let fs = + file_system_with_configurations(&[("/repo", "{}"), ("/repo/db1", "{}"), (&deep, "{}")]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let project = TestProject::without_nested_configurations(&fs, Path::new("/repo")); + + let (directories, incomplete) = project.search(&fs, NestedConfigurationSearch::new("/repo")); + assert_eq!(directories, [PathBuf::from("/repo/db1")]); + assert_eq!( + incomplete, + Some(IncompleteSearch::LimitReached(PathBuf::from("/repo"))) + ); +} + +/// A change of a configuration file only reloads the configuration if the search looks at its +/// folder. +#[test] +fn configuration_changes_in_skipped_folders_are_ignored() { + let fs = file_system_with_configurations(&[ + ( + "/repo", + r#"{ "files": { "ignore": ["legacy", "db1/generated"] } }"#, + ), + ("/repo/db1", "{}"), + ]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let root_files = root_files(&fs, "/repo"); + let is_searched = |directory: &str| { + is_searched_for_configuration( + &fs, + Path::new("/repo"), + Some(&root_files), + Path::new(directory), + ) + }; + + assert!(is_searched("/repo")); + assert!(is_searched("/repo/db2")); + assert!(is_searched("/repo/db1/generated")); + assert!(!is_searched("/repo/legacy")); + assert!(!is_searched("/repo/legacy/app")); + assert!(!is_searched("/repo/node_modules/pkg")); + assert!(!is_searched("/repo/.git")); + assert!(!is_searched("/elsewhere")); +} + /// Applying the nested configuration files again replaces the ones from before, so a deleted /// configuration file no longer applies. #[test] @@ -2515,10 +2718,11 @@ fn a_nested_configuration_that_cannot_be_applied_uses_the_defaults() { &*project.workspace, &fs, project.project_key, - Path::new("/repo"), + NestedConfigurationSearch::new("/repo"), |configuration| configuration, ) - .unwrap(); + .unwrap() + .configurations; let failed: Vec<_> = nested .iter() .filter(|nested| nested.result.is_err()) @@ -2655,8 +2859,8 @@ fn nested_configurations_do_not_merge_the_user_configuration() { ); let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); - // The home directory is the workspace, and has no project configuration. - let root = home.clone(); + // The workspace has no project configuration. + let root = home.join("repo"); let loaded = load_configuration(&fs, ConfigurationPathHint::FromWorkspace(root.clone())).unwrap(); assert_eq!( @@ -2679,10 +2883,6 @@ fn nested_configurations_do_not_merge_the_user_configuration() { project.severities(home.join("repo/a.sql")), [Severity::Error] ); - assert_eq!( - project.severities(user_config_directory.join("a.sql")), - [Severity::Error] - ); // A nested configuration does not merge it, unless it extends it. assert_eq!( project.severities(home.join("repo/db/a.sql")), @@ -2694,6 +2894,28 @@ fn nested_configurations_do_not_merge_the_user_configuration() { ); } +/// A user configuration directory below the workspace, e.g. with `XDG_CONFIG_HOME` pointing into +/// it, holds the user configuration, not a nested one. +#[test] +fn the_user_configuration_directories_are_not_nested_configurations() { + let mut fs = file_system_with_configurations(&[ + ("/repo", "{}"), + ( + "/repo/config/postgres-language-server", + r#"{ "linter": { "enabled": false } }"#, + ), + ]); + fs.set_user_config_directories(vec![PathBuf::from("/repo/config/postgres-language-server")]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + + let (project, directories) = TestProject::new(&fs, Path::new("/repo")); + assert_eq!(directories, Vec::::new()); + assert_eq!( + project.severities("/repo/config/postgres-language-server/a.sql"), + [Severity::Warning] + ); +} + /// The migrations settings of a file come from its nearest configuration file: the section markers, /// `lintDown`, and the migrations directory, which is relative to the nested configuration file. #[test] diff --git a/docs/configuration.md b/docs/configuration.md index 500f123c4..98f08f09e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -196,7 +196,7 @@ my-repo/ └── schema.sql ``` -The language server finds the configuration files below each workspace folder when it starts, and reloads them when one is created, changed, or deleted, or when a workspace folder is added or removed. Each workspace folder is a project of its own, so a file belongs to the deepest workspace folder that contains it. The `check` and `format` commands find the configuration files below the working directory. +The language server finds the configuration files below each workspace folder when it starts, and reloads them when one is created, changed, or deleted, or when a workspace folder is added or removed. Each workspace folder is a project of its own, so a file belongs to the deepest workspace folder that contains it. The `check` and `format` commands use the configuration files below the working directory: the ones in the folders between the working directory and each path they check, and the ones below the folders they check. A nested configuration file inherits nothing from the configuration files above it: settings it does not set have their defaults. To share settings, extend the parent configuration explicitly; the nested file's own settings take precedence: @@ -215,7 +215,9 @@ The [user configuration](#where-the-configuration-is-loaded-from) is a fallback Things to keep in mind: -- Configuration files in hidden folders, `node_modules`, `target`, folders ignored by `.gitignore`, folders that the configuration above them ignores through `files.ignore` or `files.include`, and the user configuration directory are not loaded as nested configurations. +- Configuration files in hidden folders, `node_modules`, `target`, folders ignored by `.gitignore`, folders that the configuration above them ignores through `files.ignore` or `files.include`, and the user configuration directory are not loaded as nested configurations. Ignoring the large folders that hold no SQL files also makes the search faster. +- When the workspace folder (or the working directory of the CLI) is your home directory or a filesystem root, nested configuration files are not looked up, because those folders hold unrelated projects. +- The search stops after 100,000 files and folders, or 12 levels of folders below the workspace folder, and logs a warning. Configuration files beyond that are not used. - The database connection of a project is only opened when a file of that project needs it. Projects that point at the same database share its connection and schema cache. - When a configuration file is passed explicitly with `--config-path`, it applies to all files and nested configuration files are not looked up. - `dblint` uses the configuration of the working directory. From 77260d0ede211bd83a54d577b51eb044a0d78fae Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 23 Oct 2026 14:13:47 +0000 Subject: [PATCH 03/17] fix: keep each folder on its own settings The database environment variables only fill in the connection options a nested configuration file doesn't set itself. A workspace folder whose configuration fails to load keeps its own project instead of borrowing another folder's, files outside every folder use a folder-less project with the defaults, and the CLI reports a broken nested configuration with its path and keeps checking with the defaults for that folder. --- crates/pgls_cli/src/commands/check.rs | 9 +- crates/pgls_cli/src/commands/format.rs | 11 ++- crates/pgls_cli/src/commands/mod.rs | 10 ++- crates/pgls_cli/src/lib.rs | 48 ++++++++--- crates/pgls_cli/tests/assert_check.rs | 83 +++++++++++++++++- crates/pgls_configuration/src/database.rs | 77 +++++++++++++++++ crates/pgls_lsp/src/session.rs | 86 ++++++++++++------- crates/pgls_lsp/tests/server.rs | 60 +++++++++++++ crates/pgls_workspace/src/configuration.rs | 48 ++++++++--- .../src/workspace/server.tests.rs | 14 ++- docs/configuration.md | 8 ++ 11 files changed, 382 insertions(+), 72 deletions(-) diff --git a/crates/pgls_cli/src/commands/check.rs b/crates/pgls_cli/src/commands/check.rs index 3c210f15e..8c4bdb121 100644 --- a/crates/pgls_cli/src/commands/check.rs +++ b/crates/pgls_cli/src/commands/check.rs @@ -1,6 +1,7 @@ use crate::cli_options::CliOptions; use crate::commands::{ - get_files_to_process_with_cli_options, nested_configuration_paths, read_stdin_payload, + get_files_to_process_with_cli_options, nested_configuration_paths, print_diagnostics, + read_stdin_payload, }; use crate::diagnostics::DatabaseUnavailable; use crate::execute::{run_files, run_stdin}; @@ -37,7 +38,7 @@ pub fn check( session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; let paths = resolve_paths(session.fs(), &configuration, &args)?; - session.apply_nested_configurations( + let configuration_errors = session.apply_nested_configurations( cli_options, args.configuration.clone(), &nested_configuration_paths(&args.stdin_file_path, &paths), @@ -63,6 +64,7 @@ pub fn check( let execution = ExecutionConfig::new(mode, max_diagnostics); if let Some(stdin_path) = args.stdin_file_path.as_deref() { + print_diagnostics(session.console(), configuration_errors); let payload = read_stdin_payload(stdin_path, session.console(), "check")?; run_stdin(&mut session, &execution, payload) } else { @@ -71,6 +73,9 @@ pub fn check( if let Some(error) = database_error { report.prepend_diagnostic(error); } + for error in configuration_errors.into_iter().rev() { + report.prepend_diagnostic(error); + } let exit_result = enforce_exit_codes(cli_options, &report); session.report("check", cli_options, &report)?; diff --git a/crates/pgls_cli/src/commands/format.rs b/crates/pgls_cli/src/commands/format.rs index 84a6f5d87..ac731e75e 100644 --- a/crates/pgls_cli/src/commands/format.rs +++ b/crates/pgls_cli/src/commands/format.rs @@ -1,6 +1,7 @@ use crate::cli_options::CliOptions; use crate::commands::{ - get_files_to_process_with_cli_options, nested_configuration_paths, read_stdin_payload, + get_files_to_process_with_cli_options, nested_configuration_paths, print_diagnostics, + read_stdin_payload, }; use crate::execute::{run_files, run_stdin}; use crate::reporter::Report; @@ -47,18 +48,22 @@ pub fn format( Some(_) => Vec::new(), None => resolve_paths(session.fs(), &configuration, &args)?, }; - session.apply_nested_configurations( + let configuration_errors = session.apply_nested_configurations( cli_options, args.configuration.clone(), &nested_configuration_paths(&args.stdin_file_path, &paths), )?; if let Some(stdin_path) = args.stdin_file_path.as_deref() { + print_diagnostics(session.console(), configuration_errors); let payload = read_stdin_payload(stdin_path, session.console(), "format")?; return run_stdin(&mut session, &execution, payload); } - let report: Report = run_files(&mut session, &execution, paths)?; + let mut report: Report = run_files(&mut session, &execution, paths)?; + for error in configuration_errors.into_iter().rev() { + report.prepend_diagnostic(error); + } let exit_result = enforce_exit_codes(cli_options, &report); session.report("format", cli_options, &report)?; diff --git a/crates/pgls_cli/src/commands/mod.rs b/crates/pgls_cli/src/commands/mod.rs index 694f7359b..f4dd21e19 100644 --- a/crates/pgls_cli/src/commands/mod.rs +++ b/crates/pgls_cli/src/commands/mod.rs @@ -5,7 +5,8 @@ use crate::logging::LoggingKind; use crate::{CliDiagnostic, LoggingLevel, VERSION}; use bpaf::Bpaf; use pgls_configuration::{PartialConfiguration, partial_configuration}; -use pgls_console::Console; +use pgls_console::{Console, ConsoleExt, markup}; +use pgls_diagnostics::{Error, PrintDiagnostic}; use pgls_fs::FileSystem; use pgls_workspace::DynRef; use std::ffi::OsString; @@ -404,6 +405,13 @@ pub(crate) fn nested_configuration_paths( } } +/// Prints diagnostics to stderr, for the commands that write their result to stdout. +pub(crate) fn print_diagnostics(console: &mut dyn Console, diagnostics: Vec) { + for diagnostic in diagnostics { + console.error(markup! {{PrintDiagnostic::simple(&diagnostic)}}); + } +} + /// Reads the code piped to `stdin` for the given virtual file path. pub(crate) fn read_stdin_payload( path: &str, diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index bc674db36..120c82a18 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -9,7 +9,9 @@ use commands::check::{self, CheckArgs}; use commands::format::{self, FormatArgs}; use pgls_configuration::Merge; use pgls_configuration::PartialConfiguration; +use pgls_configuration::database::PartialDatabaseConfiguration; use pgls_console::{ColorMode, Console, ConsoleExt, markup}; +use pgls_diagnostics::{DiagnosticExt, Error}; use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; use std::ffi::OsString; use std::path::PathBuf; @@ -248,20 +250,23 @@ impl<'app> CliSession<'app> { } /// Applies the configuration files that apply to `paths` to the project, so that each file - /// uses its nearest configuration file below the working directory. The environment and the - /// arguments apply to them like to the root configuration file. Skipped when the user chose a - /// configuration file. + /// uses its nearest configuration file below the working directory. The arguments apply to + /// them like to the root configuration file, and the environment fills in the connection + /// options they don't set. Skipped when the user chose a configuration file. + /// + /// The folder of a configuration file that can't be loaded uses the default settings. Returns + /// an error diagnostic for each such file. pub fn apply_nested_configurations( &mut self, cli_options: &CliOptions, cli_configuration: Option, paths: &[OsString], - ) -> Result<(), CliDiagnostic> { + ) -> Result, CliDiagnostic> { if cli_options.config_path.is_some() { - return Ok(()); + return Ok(Vec::new()); } let Some(root) = self.fs().working_directory() else { - return Ok(()); + return Ok(Vec::new()); }; let root_files = self.root_files.take().map(Arc::new); @@ -274,17 +279,28 @@ impl<'app> CliSession<'app> { root_files, ..NestedConfigurationSearch::new(root) }, - |configuration| apply_overrides(configuration, cli_configuration.clone()), + PartialDatabaseConfiguration::from_env().as_ref(), + |configuration| apply_arguments(configuration, cli_configuration.clone()), )?; if let Some(incomplete) = nested.incomplete { self.console().log(markup! { "Warning: "{incomplete.to_string()}"\n" }); } - for NestedConfiguration { result, .. } in nested.configurations { - result?; - } - Ok(()) + let errors = nested + .configurations + .into_iter() + .filter_map(|NestedConfiguration { directory, result }| { + let error = result.err()?; + let file_path = ConfigName::file_names() + .iter() + .map(|file_name| directory.join(file_name)) + .find(|file_path| self.fs().path_is_file(file_path)) + .unwrap_or(directory); + Some(Error::from(error).with_file_path(file_path.display().to_string())) + }) + .collect(); + Ok(errors) } pub fn setup_workspace( @@ -317,7 +333,7 @@ fn apply_overrides( cli_configuration: Option, ) -> PartialConfiguration { // Env vars override config file but are overridden by explicit CLI args. - if let Some(env_db) = pgls_configuration::database::PartialDatabaseConfiguration::from_env() { + if let Some(env_db) = PartialDatabaseConfiguration::from_env() { let env_config = PartialConfiguration { db: Some(env_db), ..Default::default() @@ -325,6 +341,14 @@ fn apply_overrides( configuration.merge_with(env_config); } + apply_arguments(configuration, cli_configuration) +} + +/// Lays the configuration from the arguments over a configuration file. +fn apply_arguments( + mut configuration: PartialConfiguration, + cli_configuration: Option, +) -> PartialConfiguration { if let Some(cli_config) = cli_configuration { configuration.merge_with(cli_config); } diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 16a775ecc..3a49e94ae 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -452,8 +452,8 @@ fn write_tree(files: &[(&str, &str)]) -> tempfile::TempDir { root } -/// Runs `check` in `cwd` with the GitHub reporter, without the connection settings of the -/// environment but with `envs`. Returns whether it succeeded and its output. +/// Runs `check` in `cwd` without the connection settings of the environment but with `envs`. +/// Returns whether it succeeded and its output. fn check_in(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (bool, String) { let mut cmd = cargo_bin_cmd!("postgres-language-server"); for var in [ @@ -470,7 +470,7 @@ fn check_in(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (bool, String) cmd.envs(envs.iter().copied()); let output = cmd .current_dir(cwd) - .args(["check", "--reporter", "github", "--log-level", "none"]) + .args(["check", "--log-level", "none"]) .args(args) .output() .expect("failed to run CLI"); @@ -509,7 +509,7 @@ fn check_only_loads_the_configuration_files_of_its_paths() { assert!(success, "{output}"); for path in ["db1/schema/a.sql", "db1"] { - let (success, output) = check_in(root.path(), &[path], &[]); + let (success, output) = check_in(root.path(), &["--reporter", "github", path], &[]); assert!(!success, "{output}"); assert!( output.contains("::error title=lint/banDropColumn"), @@ -518,6 +518,81 @@ fn check_only_loads_the_configuration_files_of_its_paths() { } } +/// A nested configuration file that can't be loaded is reported with its path, its folder uses +/// the default settings, and the other files are still checked. +#[test] +fn check_reports_a_broken_nested_configuration_and_continues() { + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true }, "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ( + "tests/fixtures/broken/postgres-language-server.jsonc", + "{ this is not json", + ), + ("a.sql", "alter table users drop column name;\n"), + ( + "tests/fixtures/broken/b.sql", + "alter table users drop column name;\n", + ), + ]); + + let (success, output) = check_in(root.path(), &[], &[]); + assert!(!success, "{output}"); + assert!( + output.contains("tests/fixtures/broken/postgres-language-server.jsonc"), + "the broken configuration file is not named:\n{output}" + ); + + let (_, output) = check_in(root.path(), &["--reporter", "github"], &[]); + let annotation = |level: &str, file: &str| { + format!( + "::{level} title=lint/banDropColumn,file={},", + root.path().join(file).display() + ) + }; + assert!(output.contains(&annotation("error", "a.sql")), "{output}"); + // The defaults, not the root configuration's `error`. + assert!( + output.contains(&annotation("warning", "tests/fixtures/broken/b.sql")), + "{output}" + ); +} + +/// The database environment variables don't replace the database of a nested configuration file +/// that sets one, so that `db1/` and `db2/` keep their own databases. +#[cfg(target_os = "linux")] +#[test] +fn the_environment_does_not_replace_the_database_of_a_nested_configuration() { + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ), + ( + "db1/postgres-language-server.jsonc", + r#"{ "db": { "host": "127.0.0.1", "port": 5432, "username": "postgres", "password": "postgres", "database": "nested_db_that_does_not_exist" } }"#, + ), + ("db1/a.sql", "select 1;\n"), + ]); + + for envs in [ + &[][..], + &[("PGDATABASE", "postgres")][..], + &[( + "DATABASE_URL", + "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + )][..], + ] { + let (_, output) = check_in(root.path(), &["db1/a.sql"], envs); + assert!( + output.contains("nested_db_that_does_not_exist"), + "with {envs:?}, the nested configuration did not use its own database:\n{output}" + ); + } +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] diff --git a/crates/pgls_configuration/src/database.rs b/crates/pgls_configuration/src/database.rs index 2e650031d..d32ff8222 100644 --- a/crates/pgls_configuration/src/database.rs +++ b/crates/pgls_configuration/src/database.rs @@ -150,12 +150,89 @@ impl PartialDatabaseConfiguration { ..Default::default() }) } + + /// Whether it sets an option of the connection: the connection string, the host, port, + /// username, password or database. + pub fn sets_connection(&self) -> bool { + self.connection_string.is_some() + || self.host.is_some() + || self.port.is_some() + || self.username.is_some() + || self.password.is_some() + || self.database.is_some() + } + + /// The part of this environment layer (see [Self::from_env]) that applies to a nested + /// configuration file whose own `db` section is `own`, without what it extends: the options + /// that the file doesn't set itself, and `DATABASE_URL` only if the file sets no connection + /// option, because the connection string takes precedence over the others. + pub fn for_nested_configuration(&self, own: Option<&Self>) -> Option { + let Some(own) = own.filter(|own| own.sets_connection()) else { + return Some(self.clone()); + }; + let layer = Self { + connection_string: None, + host: self.host.clone().filter(|_| own.host.is_none()), + port: self.port.filter(|_| own.port.is_none()), + username: self.username.clone().filter(|_| own.username.is_none()), + password: self.password.clone().filter(|_| own.password.is_none()), + database: self.database.clone().filter(|_| own.database.is_none()), + ..Default::default() + }; + layer.sets_connection().then_some(layer) + } } #[cfg(test)] mod tests { use super::PartialDatabaseConfiguration; + #[test] + fn the_environment_only_fills_in_what_a_nested_configuration_does_not_set() { + let environment = PartialDatabaseConfiguration { + connection_string: Some("postgres://ci:5432/postgres".to_string()), + host: Some("ci".to_string()), + database: Some("postgres".to_string()), + ..Default::default() + }; + + // Without own connection options, the whole environment applies. + assert_eq!( + environment.for_nested_configuration(None), + Some(environment.clone()) + ); + let timeout_only = PartialDatabaseConfiguration { + conn_timeout_secs: Some(2), + ..Default::default() + }; + assert_eq!( + environment.for_nested_configuration(Some(&timeout_only)), + Some(environment.clone()) + ); + + // With some, the connection string doesn't, nor do the options the file sets. + let own_database = PartialDatabaseConfiguration { + database: Some("db1".to_string()), + ..Default::default() + }; + assert_eq!( + environment.for_nested_configuration(Some(&own_database)), + Some(PartialDatabaseConfiguration { + host: Some("ci".to_string()), + ..Default::default() + }) + ); + let own_connection = PartialDatabaseConfiguration { + host: Some("localhost".to_string()), + database: Some("db1".to_string()), + ..Default::default() + }; + assert_eq!( + environment.for_nested_configuration(Some(&own_connection)), + None + ); + } + #[test] fn partial_configuration_debug_redacts_database_secrets() { let cfg = pgls_configuration::PartialConfiguration { diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 6b48240c5..11e0d35ae 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -27,6 +27,7 @@ use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; +use std::sync::OnceLock; use std::sync::RwLock; use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicBool, AtomicU8}; @@ -116,6 +117,9 @@ pub(crate) struct Session { /// one for the root. A document belongs to the project of the deepest folder that contains it. projects: RwLock>, + /// The project of the files outside every workspace folder, once one was opened. + folderless_project: OnceLock, + /// The files settings of the root configuration of each project, by its folder. They decide /// which folders are searched for nested configuration files. root_files: RwLock>>>, @@ -250,6 +254,7 @@ impl Session { client_overrides: RwLock::new(ClientOverrides::default()), workspace_folders: RwLock::default(), projects: RwLock::default(), + folderless_project: OnceLock::new(), root_files: RwLock::default(), reload_scheduled: AtomicBool::new(false), configuration_transition: tokio::sync::Mutex::new(()), @@ -637,16 +642,33 @@ impl Session { } /// The project that the file at `path` belongs to: the one of the deepest workspace folder - /// that contains it, or else the one of the shallowest folder. Before the configuration is loaded, the key of no - /// project, for which the workspace only reports what needs no settings. + /// that contains it. A file outside every workspace folder belongs to a project without a + /// folder, which has the default settings, so that it never gets the settings (and the + /// database) of an unrelated folder. Without workspace folders or a root, or with a + /// configuration file chosen by the user, the only project applies to every file. Before the + /// configuration is loaded, the key of no project, for which the workspace only reports what + /// needs no settings. pub(crate) fn project_key(&self, path: &Path) -> ProjectKey { let projects = self.projects.read().unwrap(); - projects - .iter() - .find(|(folder, _)| path.starts_with(folder)) - .or_else(|| projects.last()) - .map(|(_, key)| *key) - .unwrap_or_default() + if let Some((_, key)) = projects.iter().find(|(folder, _)| path.starts_with(folder)) { + return *key; + } + let Some((_, only_project)) = projects.last() else { + return ProjectKey::default(); + }; + if self.config_path.is_some() + || (self.get_workspace_folders().is_none() && self.base_path().is_none()) + { + return *only_project; + } + *self.folderless_project.get_or_init(|| { + self.workspace + .register_project_folder(RegisterProjectFolderParams { path: None }) + .unwrap_or_else(|error| { + error!("Failed to register the project without a folder: {}", error); + ProjectKey::default() + }) + }) } /// Returns the base path of the workspace on the filesystem if it has one @@ -755,27 +777,14 @@ impl Session { } }; let folder = folder.unwrap_or_default(); - let is_new = !self - .projects - .read() - .unwrap() - .iter() - .any(|(project, _)| project == &folder); let (status, root_files) = self .load_pgls_configuration_file(project_key, base_path) .await; if !matches!(status, ConfigurationStatus::Loaded) { - // A project keeps its settings while its configuration is broken, and a new one is - // only registered once its configuration loads. - if is_new { - let _ = self - .workspace - .unregister_project_folder(UnregisterProjectFolderParams { - path: PgLSPath::new(&folder), - }); - return (None, status); - } + // A project keeps its settings while its configuration is broken, and a new one has + // the defaults. Either way its files never fall back to the settings of another + // folder, which could point at another database. return (Some((folder, project_key)), status); } let root_files = root_files.map(Arc::new); @@ -795,26 +804,33 @@ impl Session { (project, status) } - /// Lays the layers of the session over a configuration file: the environment and the client - /// overrides. + /// Lays the layers of the session over the root configuration file: the environment and the + /// client overrides. fn configure(&self) -> impl Fn(PartialConfiguration) -> PartialConfiguration + '_ { + self.configure_with(self.env_config.as_ref()) + } + + /// Lays the layers of the session over a configuration file: `environment` and the client + /// overrides. + fn configure_with<'a>( + &'a self, + environment: Option<&'a PartialConfiguration>, + ) -> impl Fn(PartialConfiguration) -> PartialConfiguration + 'a { let (client_overrides, overridden_sections) = { let overrides = self.client_overrides.read().unwrap(); (overrides.configuration().cloned(), overrides.sections()) }; move |configuration| { - let mut configuration = merge_layers( - configuration, - self.env_config.as_ref(), - client_overrides.as_ref(), - ); + let mut configuration = + merge_layers(configuration, environment, client_overrides.as_ref()); overridden_sections.materialize_defaults(&mut configuration); configuration } } /// Applies every configuration file below `root` to the project, with the same layers as the - /// root configuration. The folders that `root_files` ignores are not searched. + /// root configuration, except that the environment only fills in the connection options a + /// file doesn't set. The folders that `root_files` ignores are not searched. async fn load_nested_configuration_files( self: &Arc, project_key: ProjectKey, @@ -832,7 +848,11 @@ impl Session { root_files, ..NestedConfigurationSearch::new(root) }, - session.configure(), + session + .env_config + .as_ref() + .and_then(|environment| environment.db.as_ref()), + session.configure_with(None), ) }) .await; diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index bee034cef..e7628b8cc 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -3433,3 +3433,63 @@ async fn configuration_changes_are_coalesced() -> Result<()> { reader.abort(); Ok(()) } + +/// A workspace folder whose configuration file is broken keeps its own project with the default +/// settings, and a file outside every workspace folder gets the defaults too. Neither gets the +/// settings of another folder, which could point at another database. +#[tokio::test] +async fn files_never_get_the_settings_of_another_workspace_folder() -> Result<()> { + let root = tempfile::tempdir()?; + for (folder, configuration) in [ + ( + "one", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + ), + ("two", "{ this is not json"), + ] { + std::fs::create_dir_all(root.path().join(folder))?; + std::fs::write( + root.path().join(folder).join(ConfigName::pgls_jsonc()), + configuration, + )?; + } + let folder = |name: &str| WorkspaceFolder { + uri: Uri::from_file_path(root.path().join(name)).unwrap(), + name: name.to_string(), + }; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_folders(folder("one").uri, Some(vec![folder("one"), folder("two")])) + .await?; + server.initialized().await?; + + let document = |path: &str| Uri::from_file_path(root.path().join(path)).unwrap(); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("one/a.sql")).await?, + [lsp::DiagnosticSeverity::ERROR] + ); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("two/a.sql")).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + assert_eq!( + open_and_check(&mut server, &mut receiver, document("elsewhere/a.sql")).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + + server.shutdown().await?; + reader.abort(); + Ok(()) +} diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 8a13e7261..f93dce79f 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -10,7 +10,7 @@ use pgls_analyser::LinterRules; use pgls_configuration::Merge; use pgls_configuration::{ ConfigurationDiagnostic, ConfigurationPathHint, ConfigurationPayload, PartialConfiguration, - VERSION, diagnostics::CantLoadExtendFile, + VERSION, database::PartialDatabaseConfiguration, diagnostics::CantLoadExtendFile, }; use pgls_console::markup; use pgls_env::PGLS_WEBSITE; @@ -41,6 +41,8 @@ pub struct LoadedConfiguration { pub file_path: Option, /// The Deserialized configuration pub configuration: PartialConfiguration, + /// The `db` section of the configuration file itself, without what it extends + pub file_database: Option, } impl LoadedConfiguration { @@ -54,6 +56,7 @@ impl LoadedConfiguration { deserialized: mut partial_configuration, } = value; + let file_database = partial_configuration.db.clone(); partial_configuration.apply_extends( fs, &configuration_file_path, @@ -62,6 +65,7 @@ impl LoadedConfiguration { Ok(Self { configuration: partial_configuration, + file_database, directory_path: configuration_file_path.parent().map(PathBuf::from), file_path: Some(configuration_file_path), }) @@ -195,8 +199,11 @@ impl std::fmt::Display for IncompleteSearch { /// configuration directories are skipped even when they are below the root, and so is everything /// below the home directory or a filesystem root, which hold unrelated projects. /// -/// `configure` lays the layers of the caller, such as the environment or the arguments of the -/// CLI, over each configuration file. Folders that the settings above them ignore are skipped. +/// The database settings of the `environment` (see [PartialDatabaseConfiguration::from_env]) +/// only fill in the connection options that a nested configuration file doesn't set itself, so +/// that each file keeps its own database. `configure` then lays the layers of the caller, such as +/// the arguments of the CLI, over each configuration file. Folders that the settings above them +/// ignore are skipped. /// /// This walks the file system, so call it off the async runtime. Fails if the project is not /// registered. @@ -205,6 +212,7 @@ pub fn apply_nested_configurations( fs: &DynRef<'_, dyn FileSystem>, project_key: ProjectKey, search: NestedConfigurationSearch, + environment: Option<&PartialDatabaseConfiguration>, configure: impl Fn(PartialConfiguration) -> PartialConfiguration, ) -> Result { let NestedConfigurationSearch { @@ -228,16 +236,18 @@ pub fn apply_nested_configurations( let nested = directories .into_iter() .map(|directory| { - load_nested_configuration(fs, &directory, &configure).unwrap_or_else(|error| { - load_errors.push((directory.clone(), error)); - // Neither the configuration above it nor a part of the broken one applies. - NestedSettingsParams { - directory, - configuration: PartialConfiguration::default(), - vcs_base_path: None, - gitignore_matches: Vec::new(), - } - }) + load_nested_configuration(fs, &directory, environment, &configure).unwrap_or_else( + |error| { + load_errors.push((directory.clone(), error)); + // Neither the configuration above it nor a part of the broken one applies. + NestedSettingsParams { + directory, + configuration: PartialConfiguration::default(), + vcs_base_path: None, + gitignore_matches: Vec::new(), + } + }, + ) }) .collect(); @@ -399,13 +409,23 @@ fn is_not_a_project_folder(fs: &DynRef<'_, dyn FileSystem>, folder: &Path) -> bo fn load_nested_configuration( fs: &DynRef<'_, dyn FileSystem>, directory: &Path, + environment: Option<&PartialDatabaseConfiguration>, configure: impl Fn(PartialConfiguration) -> PartialConfiguration, ) -> Result { let loaded = load_configuration( fs, ConfigurationPathHint::FromWorkspace(directory.to_path_buf()), )?; - let configuration = configure(loaded.configuration); + let mut configuration = loaded.configuration; + if let Some(environment) = environment + .and_then(|environment| environment.for_nested_configuration(loaded.file_database.as_ref())) + { + configuration.merge_with(PartialConfiguration { + db: Some(environment), + ..Default::default() + }); + } + let configuration = configure(configuration); let (vcs_base_path, gitignore_matches) = configuration.retrieve_gitignore_matches(fs, Some(directory))?; Ok(NestedSettingsParams { diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index 790184e22..b28b79582 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -2348,9 +2348,15 @@ impl TestProject { fs: &DynRef<'_, dyn FileSystem>, search: NestedConfigurationSearch, ) -> (Vec, Option) { - let nested = - apply_nested_configurations(&*self.workspace, fs, self.project_key, search, |c| c) - .unwrap(); + let nested = apply_nested_configurations( + &*self.workspace, + fs, + self.project_key, + search, + None, + |c| c, + ) + .unwrap(); let directories = nested .configurations .into_iter() @@ -2378,6 +2384,7 @@ impl TestProject { fs, self.project_key, NestedConfigurationSearch::new(root), + None, |configuration| configuration, ) .unwrap() @@ -2719,6 +2726,7 @@ fn a_nested_configuration_that_cannot_be_applied_uses_the_defaults() { &fs, project.project_key, NestedConfigurationSearch::new("/repo"), + None, |configuration| configuration, ) .unwrap() diff --git a/docs/configuration.md b/docs/configuration.md index 98f08f09e..12e6d42cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,6 +213,14 @@ Paths in a nested configuration file, such as `migrations.migrationsDir`, are re The [user configuration](#where-the-configuration-is-loaded-from) is a fallback for the workspace root only. When there is no configuration file at or above the root, it applies to the files that no nested configuration file covers. It is never merged into a nested configuration file. To use it there, extend it explicitly, for example with `"extends": ["~/.config/postgres-language-server/postgres-language-server.jsonc"]`. +The database environment variables (`DATABASE_URL`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`) override the connection of the root configuration, as without nested configurations. In a nested configuration file, they only fill in the connection options that the file doesn't set itself, so that each folder keeps its own database even when, for example, `DATABASE_URL` is set in your shell or CI: + +- An environment variable is used for an option (`host`, `port`, `username`, `password`, `database`) that the nested file doesn't set itself. An option that the file only gets through `extends` is overridden like in the root configuration. +- `DATABASE_URL` is only used when the nested file sets none of `connectionString`, `host`, `port`, `username`, `password`, and `database` itself, because a connection string takes precedence over the other options. +- The database options passed to the CLI, such as `--connection-string`, and the editor's client overrides apply to every configuration file. + +Each workspace folder uses its own settings. When the configuration file of a workspace folder can't be loaded, the error is reported and the folder keeps the settings it had, or the defaults if it is new, until the file is fixed; its nested configuration files are not loaded meanwhile. Files outside every workspace folder use the default settings. A nested configuration file that can't be loaded gives its folder the default settings, and `check` reports it as an error but still checks the other files. + Things to keep in mind: - Configuration files in hidden folders, `node_modules`, `target`, folders ignored by `.gitignore`, folders that the configuration above them ignores through `files.ignore` or `files.include`, and the user configuration directory are not loaded as nested configurations. Ignoring the large folders that hold no SQL files also makes the search faster. From f1846ed3798b11c3aeda2b505a9e5a05a8452ce6 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 23 Oct 2026 14:14:56 +0000 Subject: [PATCH 04/17] test(cli): build the paths of the broken configuration test per platform --- crates/pgls_cli/tests/assert_check.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 3a49e94ae..04d2b99da 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -538,24 +538,33 @@ fn check_reports_a_broken_nested_configuration_and_continues() { ), ]); + let broken = Path::new("tests").join("fixtures").join("broken"); let (success, output) = check_in(root.path(), &[], &[]); assert!(!success, "{output}"); assert!( - output.contains("tests/fixtures/broken/postgres-language-server.jsonc"), + output.contains( + &broken + .join("postgres-language-server.jsonc") + .display() + .to_string() + ), "the broken configuration file is not named:\n{output}" ); let (_, output) = check_in(root.path(), &["--reporter", "github"], &[]); - let annotation = |level: &str, file: &str| { + let annotation = |level: &str, file: &Path| { format!( "::{level} title=lint/banDropColumn,file={},", root.path().join(file).display() ) }; - assert!(output.contains(&annotation("error", "a.sql")), "{output}"); + assert!( + output.contains(&annotation("error", Path::new("a.sql"))), + "{output}" + ); // The defaults, not the root configuration's `error`. assert!( - output.contains(&annotation("warning", "tests/fixtures/broken/b.sql")), + output.contains(&annotation("warning", &broken.join("b.sql"))), "{output}" ); } From fcfe3279325c82af4eeeed1c8b32ccbc48dcfc8d Mon Sep 17 00:00:00 2001 From: psteinroe Date: Fri, 23 Oct 2026 14:23:33 +0000 Subject: [PATCH 05/17] fix(lsp): watch every workspace folder, the user config and extended files Register one watcher per workspace folder (a relative pattern if the client supports it, else an escaped glob) and register them again when the folders change. Also watch the configuration file above a folder, an explicit configuration path, the user configuration and every file a loaded configuration extends. Announce in the CLI and the LSP log when the user configuration applies. --- crates/pgls_cli/src/lib.rs | 31 ++- crates/pgls_cli/tests/assert_check.rs | 44 +++ crates/pgls_lsp/src/session.rs | 174 ++++++++++-- crates/pgls_lsp/tests/server.rs | 297 ++++++++++++++++++++- crates/pgls_workspace/src/configuration.rs | 70 +++-- docs/configuration.md | 4 + 6 files changed, 562 insertions(+), 58 deletions(-) diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index 120c82a18..1630c78e0 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -232,6 +232,15 @@ impl<'app> CliSession<'app> { }); } + if loaded_configuration.is_user_configuration + && let Some(file_path) = &loaded_configuration.file_path + { + // On stderr, so that it doesn't end up in the output of a reporter or of `format`. + self.console().error(markup! { + "Using the user configuration at "{file_path.display().to_string()}"\n" + }); + } + for deprecation in loaded_configuration.configuration.deprecations() { self.console().log(markup! { "Warning: "{deprecation}"\n" @@ -290,15 +299,19 @@ impl<'app> CliSession<'app> { let errors = nested .configurations .into_iter() - .filter_map(|NestedConfiguration { directory, result }| { - let error = result.err()?; - let file_path = ConfigName::file_names() - .iter() - .map(|file_name| directory.join(file_name)) - .find(|file_path| self.fs().path_is_file(file_path)) - .unwrap_or(directory); - Some(Error::from(error).with_file_path(file_path.display().to_string())) - }) + .filter_map( + |NestedConfiguration { + directory, result, .. + }| { + let error = result.err()?; + let file_path = ConfigName::file_names() + .iter() + .map(|file_name| directory.join(file_name)) + .find(|file_path| self.fs().path_is_file(file_path)) + .unwrap_or(directory); + Some(Error::from(error).with_file_path(file_path.display().to_string())) + }, + ) .collect(); Ok(errors) } diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 04d2b99da..74d61e060 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -602,6 +602,50 @@ fn the_environment_does_not_replace_the_database_of_a_nested_configuration() { } } +/// Without a project configuration, `check` says that it uses the user configuration, on stderr +/// so that it stays out of the reporters' output. +#[cfg(unix)] +#[test] +fn check_announces_the_user_configuration() { + let root = write_tree(&[ + ( + "config/postgres-language-server/postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ), + ("project/a.sql", "select 1;\n"), + ]); + let config_home = root.path().join("config"); + let output = cargo_bin_cmd!("postgres-language-server") + .current_dir(root.path().join("project")) + .env("XDG_CONFIG_HOME", &config_home) + .args([ + "check", + "--log-level", + "none", + "--reporter", + "json", + "a.sql", + ]) + .output() + .expect("failed to run CLI"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let user_configuration = config_home + .join("postgres-language-server") + .join("postgres-language-server.jsonc"); + assert!( + stderr.contains(&format!( + "Using the user configuration at {}", + user_configuration.display() + )), + "{stderr}" + ); + assert!( + !String::from_utf8_lossy(&output.stdout).contains("user configuration"), + "the announcement ended up in the report" + ); +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 11e0d35ae..84621e820 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -22,7 +22,7 @@ use pgls_workspace::workspace::{ ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, UpdateSettingsParams, }; use pgls_workspace::{DynRef, WorkspaceError}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -38,7 +38,8 @@ use tokio::task::{AbortHandle, spawn_blocking}; use tower_lsp_server::ls_types::Uri; use tower_lsp_server::ls_types::{self, ClientCapabilities}; use tower_lsp_server::ls_types::{ - DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, WatchKind, + DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, OneOf, + RelativePattern, WatchKind, }; use tower_lsp_server::ls_types::{MessageType, Registration}; use tower_lsp_server::ls_types::{Unregistration, WorkspaceFolder}; @@ -124,6 +125,12 @@ pub(crate) struct Session { /// which folders are searched for nested configuration files. root_files: RwLock>>>, + /// The files the configuration was loaded from besides the nested configuration files: the + /// root configuration files, which can be above a workspace folder or the user + /// configuration, and the files they and the nested ones extend. Changing one of them + /// reloads the configuration. + configuration_files: RwLock>, + /// Whether a reload for changed configuration files is scheduled and hasn't started yet. reload_scheduled: AtomicBool, @@ -256,6 +263,7 @@ impl Session { projects: RwLock::default(), folderless_project: OnceLock::new(), root_files: RwLock::default(), + configuration_files: RwLock::default(), reload_scheduled: AtomicBool::new(false), configuration_transition: tokio::sync::Mutex::new(()), #[cfg(test)] @@ -319,39 +327,100 @@ impl Session { }, ); + let watchers = self.configuration_watchers(); capabilities.add_capability( "pgls_did_change_workspace_settings", "workspace/didChangeWatchedFiles", - match self.base_path() { - Some(base_path) => { - // Configuration files below the root apply to their directories. - let watchers = ConfigName::file_names() - .iter() - .map(|config_name| FileSystemWatcher { - glob_pattern: GlobPattern::String(format!( - "{}/**/{}", - base_path.display(), - config_name - )), - kind: Some(WatchKind::all()), - }) - .collect(); - - CapabilityStatus::Enable(Some(json!( - DidChangeWatchedFilesRegistrationOptions { watchers } - ))) - } - _ => CapabilityStatus::Disable, + if watchers.is_empty() { + CapabilityStatus::Disable + } else { + CapabilityStatus::Enable(Some(json!(DidChangeWatchedFilesRegistrationOptions { + watchers + }))) }, ); self.register_capabilities(capabilities).await; } + /// The watchers for the files the configuration comes from: the configuration files below + /// each workspace folder (or the root), the explicit configuration file, the user + /// configuration, and the files the loaded configuration files extend. + fn configuration_watchers(&self) -> Vec { + let relative_patterns = self + .client_capabilities() + .and_then(|capabilities| capabilities.workspace.as_ref()) + .and_then(|workspace| workspace.did_change_watched_files) + .and_then(|watched_files| watched_files.relative_pattern_support) + == Some(true); + let watcher = |folder: &Path, pattern: &str| { + let glob_pattern = match Uri::from_file_path(folder).filter(|_| relative_patterns) { + Some(base_uri) => GlobPattern::Relative(RelativePattern { + base_uri: OneOf::Right(base_uri), + pattern: pattern.to_string(), + }), + None => GlobPattern::String(format!("{}/{pattern}", escape_glob(folder))), + }; + FileSystemWatcher { + glob_pattern, + kind: Some(WatchKind::all()), + } + }; + + let folders = match &self.config_path { + // A configuration file chosen by the user applies to everything. + Some(_) => Vec::new(), + None => self + .get_workspace_folders() + .or_else(|| self.base_path().map(|base_path| vec![base_path])) + .unwrap_or_default(), + }; + let mut watchers: Vec<_> = folders + .iter() + .flat_map(|folder| { + ConfigName::file_names() + .into_iter() + .map(move |file_name| watcher(folder, &format!("**/{file_name}"))) + }) + .collect(); + + let mut files: Vec = + self.configuration_files + .read() + .unwrap() + .iter() + .cloned() + .chain(self.config_path.iter().map( + |config_path| match self.fs.working_directory() { + Some(working_directory) if config_path.is_relative() => { + working_directory.join(config_path) + } + _ => config_path.clone(), + }, + )) + .chain( + self.fs + .user_config_directories() + .into_iter() + .map(|directory| directory.join(ConfigName::pgls_jsonc())), + ) + .collect(); + files.sort(); + files.dedup(); + watchers.extend(files.iter().filter_map(|file| { + let file_name = file.file_name()?.to_str()?; + Some(watcher(file.parent()?, &escape_glob(Path::new(file_name)))) + })); + watchers + } + /// Whether a change of the file at `path` changes the configuration: a configuration file at /// or above a workspace folder, or one below it that the nested configuration search looks /// at. Others, e.g. in `node_modules` or in ignored folders, are skipped. pub(crate) fn is_configuration_change(&self, path: &Path) -> bool { + if self.configuration_files.read().unwrap().contains(path) { + return true; + } let is_configuration_file = path .file_name() .and_then(|file_name| file_name.to_str()) @@ -716,6 +785,7 @@ impl Session { }; self.set_configuration_status(ConfigurationStatus::Loading); + let previous_files = std::mem::take(&mut *self.configuration_files.write().unwrap()); let mut status = ConfigurationStatus::Loaded; let mut projects = Vec::new(); for root in roots { @@ -744,6 +814,13 @@ impl Session { projects.sort_by_key(|(folder, _)| std::cmp::Reverse(folder.components().count())); *self.projects.write().unwrap() = projects; + if !matches!(status, ConfigurationStatus::Loaded) { + // A broken file can't tell what it extends, so keep watching what it did before. + self.configuration_files + .write() + .unwrap() + .extend(previous_files); + } self.set_configuration_status(status); } @@ -876,7 +953,16 @@ impl Session { .await; } let mut status = ConfigurationStatus::Loaded; - for NestedConfiguration { directory, result } in nested.configurations { + for NestedConfiguration { + directory, + result, + extended_files, + } in nested.configurations + { + self.configuration_files + .write() + .unwrap() + .extend(extended_files); match result { Ok(()) => info!("Loaded the nested configuration in {}", directory.display()), Err(error) => { @@ -908,6 +994,8 @@ impl Session { configuration: fs_configuration, directory_path: configuration_path, file_path: configuration_file_path, + extended_files, + is_user_configuration, .. } = loaded_configuration; @@ -917,6 +1005,28 @@ impl Session { .as_ref() .map_or("".to_string(), |p| p.display().to_string()) ); + if is_user_configuration + && let (Some(file_path), Some(directory)) = + (&configuration_file_path, &configuration_path) + { + self.client + .log_message( + MessageType::INFO, + format!( + "Using the user configuration at {} for {}, which has no \ + configuration file.", + file_path.display(), + directory.display() + ), + ) + .await; + } + self.configuration_files.write().unwrap().extend( + configuration_file_path + .iter() + .cloned() + .chain(extended_files), + ); // Check if using deprecated config filename and notify user (once) if !self.notified_deprecated_config.load(Ordering::Relaxed) @@ -1101,6 +1211,24 @@ impl Session { } } +/// A path as a glob pattern that matches only it: with `/` as the separator, and the characters +/// that globs treat specially in brackets. +fn escape_glob(path: &Path) -> String { + let mut pattern = String::new(); + for character in path.to_string_lossy().chars() { + match character { + '\\' if cfg!(windows) => pattern.push('/'), + '*' | '?' | '[' | '{' | '}' => { + pattern.push('['); + pattern.push(character); + pattern.push(']'); + } + _ => pattern.push(character), + } + } + pattern +} + /// Tracks one running configuration transition for [`Session::max_concurrent_transitions`]. #[cfg(test)] pub(crate) struct RunningTransition<'a> { diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index e7628b8cc..4e988d0d3 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -23,7 +23,7 @@ use sqlx::Executor; use sqlx::PgPool; use std::any::type_name; use std::fmt::Display; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; use tokio::io::AsyncWriteExt; @@ -162,6 +162,22 @@ impl Server { &mut self, root_uri: Uri, workspace_folders: Option>, + ) -> Result<()> { + self.initialize_with_capabilities( + Some(root_uri), + workspace_folders, + ClientCapabilities::default(), + ) + .await + } + + /// Like [Server::initialize_with_folders], with the given client capabilities + #[allow(deprecated)] + async fn initialize_with_capabilities( + &mut self, + root_uri: Option, + workspace_folders: Option>, + capabilities: ClientCapabilities, ) -> Result<()> { let _res: InitializeResult = self .request( @@ -170,9 +186,9 @@ impl Server { InitializeParams { process_id: None, root_path: None, - root_uri: Some(root_uri), + root_uri, initialization_options: None, - capabilities: ClientCapabilities::default(), + capabilities, trace: None, workspace_folders, client_info: None, @@ -3493,3 +3509,278 @@ async fn files_never_get_the_settings_of_another_workspace_folder() -> Result<() reader.abort(); Ok(()) } + +/// Answers every request of the server and forwards the params of `client/registerCapability`. +async fn registration_capturing_client_handler( + mut stream: I, + mut sink: O, + mut registrations: Sender, +) where + I: Stream + Unpin, + O: Sink + Unpin, +{ + while let Some(req) = stream.next().await { + if req.method() == "client/registerCapability" + && let Some(params) = req.params() + { + let _ = registrations.send(params.clone()).await; + } + if let Some(id) = req.id() { + sink.send(Response::from_ok(id.clone(), Value::Null)) + .await + .ok(); + } + } +} + +/// The glob patterns of the next `workspace/didChangeWatchedFiles` registration, as strings or as +/// ` ` for relative patterns. +async fn next_watched_files_registration( + registrations: &mut Receiver, +) -> Result> { + loop { + let registration = tokio::time::timeout(Duration::from_secs(5), registrations.next()) + .await + .context("no client/registerCapability request")? + .context("the server closed the connection")?; + let watchers: Vec = registration["registrations"] + .as_array() + .into_iter() + .flatten() + .filter(|registration| registration["method"] == "workspace/didChangeWatchedFiles") + .flat_map(|registration| { + registration["registerOptions"]["watchers"] + .as_array() + .cloned() + .unwrap_or_default() + }) + .collect(); + if watchers.is_empty() { + continue; + } + return Ok(watchers + .iter() + .map(|watcher| match &watcher["globPattern"] { + Value::String(glob) => glob.clone(), + relative => format!( + "{} {}", + relative["baseUri"].as_str().unwrap_or_default(), + relative["pattern"].as_str().unwrap_or_default() + ), + }) + .collect()); + } +} + +/// The server watches the configuration files below every workspace folder, also the ones added +/// later, with relative patterns if the client supports them. +#[tokio::test] +async fn the_configuration_files_of_every_workspace_folder_are_watched() -> Result<()> { + let root = tempfile::tempdir()?; + for folder in ["one", "two", "three"] { + std::fs::create_dir_all(root.path().join(folder))?; + std::fs::write( + root.path().join(folder).join(ConfigName::pgls_jsonc()), + "{}", + )?; + } + let folder = |name: &str| WorkspaceFolder { + uri: Uri::from_file_path(root.path().join(name)).unwrap(), + name: name.to_string(), + }; + let glob = |name: &str| { + format!( + "{}/**/{}", + root.path().join(name).display(), + ConfigName::pgls_jsonc() + ) + }; + + for relative_patterns in [false, true] { + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut registrations) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(registration_capturing_client_handler(stream, sink, sender)); + + let capabilities = ClientCapabilities { + workspace: Some(lsp::WorkspaceClientCapabilities { + did_change_watched_files: Some(lsp::DidChangeWatchedFilesClientCapabilities { + dynamic_registration: Some(true), + relative_pattern_support: Some(relative_patterns), + }), + ..Default::default() + }), + ..Default::default() + }; + // No root URI, only workspace folders. + server + .initialize_with_capabilities( + None, + Some(vec![folder("one"), folder("two")]), + capabilities, + ) + .await?; + server.initialized().await?; + + let expected = |name: &str| { + if relative_patterns { + format!( + "{} **/{}", + folder(name).uri.as_str(), + ConfigName::pgls_jsonc() + ) + } else { + glob(name) + } + }; + let patterns = next_watched_files_registration(&mut registrations).await?; + for name in ["one", "two"] { + assert!( + patterns.contains(&expected(name)), + "folder `{name}` is not watched: {patterns:?}" + ); + } + + server + .notify( + "workspace/didChangeWorkspaceFolders", + lsp::DidChangeWorkspaceFoldersParams { + event: lsp::WorkspaceFoldersChangeEvent { + added: vec![folder("three")], + removed: vec![folder("one")], + }, + }, + ) + .await?; + let patterns = next_watched_files_registration(&mut registrations).await?; + assert!( + patterns.contains(&expected("three")), + "the added folder is not watched: {patterns:?}" + ); + assert!( + !patterns.contains(&expected("one")), + "the removed folder is still watched: {patterns:?}" + ); + + server.shutdown().await?; + reader.abort(); + } + Ok(()) +} + +/// A change of a file that a configuration extends, here outside the workspace folder, reloads +/// the configuration. +#[tokio::test] +async fn a_change_of_an_extended_file_reloads_the_configuration() -> Result<()> { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + let shared = root.path().join("shared").join("base.jsonc"); + std::fs::create_dir_all(&workspace)?; + std::fs::create_dir_all(shared.parent().unwrap())?; + std::fs::write( + workspace.join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["../shared/base.jsonc"] }"#, + )?; + std::fs::write(&shared, "{}")?; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(workspace.clone()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_root(Uri::from_file_path(&workspace).unwrap()) + .await?; + server.initialized().await?; + + let document = Uri::from_file_path(workspace.join("a.sql")).unwrap(); + assert_eq!( + open_and_check(&mut server, &mut receiver, document.clone()).await?, + [lsp::DiagnosticSeverity::WARNING] + ); + + std::fs::write( + &shared, + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + )?; + drain_notifications(&mut receiver); + server + .notify( + "workspace/didChangeWatchedFiles", + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(&shared).unwrap(), + typ: FileChangeType::CHANGED, + }], + }, + ) + .await?; + wait_for_severities(&mut receiver, &document, &[lsp::DiagnosticSeverity::ERROR]).await?; + + server.shutdown().await?; + reader.abort(); + Ok(()) +} + +/// The user configuration and the files a configuration extends are watched too. +#[tokio::test] +async fn the_user_configuration_and_extended_files_are_watched() -> Result<()> { + let user_config_directory = if cfg!(windows) { + PathBuf::from("z:\\config\\postgres-language-server") + } else { + PathBuf::from("/config/postgres-language-server") + }; + let mut fs = MemoryFileSystem::default(); + fs.set_user_config_directories(vec![user_config_directory.clone()]); + let workspace = url!("").to_file_path().unwrap().into_owned(); + fs.insert( + workspace.join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["../shared/base.jsonc"] }"#.to_string(), + ); + let shared = workspace + .parent() + .unwrap() + .join("shared") + .join("base.jsonc"); + fs.insert(shared.clone(), "{}".to_string()); + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs(None, DynRef::Owned(Box::new(fs))) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut registrations) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(registration_capturing_client_handler(stream, sink, sender)); + + server.initialize().await?; + server.initialized().await?; + + let patterns = next_watched_files_registration(&mut registrations).await?; + let glob = |path: &Path| path.display().to_string().replace('\\', "/"); + for file in [shared, user_config_directory.join(ConfigName::pgls_jsonc())] { + assert!( + patterns.contains(&glob(&file)), + "{} is not watched: {patterns:?}", + file.display() + ); + } + + server.shutdown().await?; + reader.abort(); + Ok(()) +} diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index f93dce79f..8e4613a77 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -18,7 +18,7 @@ use pgls_fs::{ AutoSearchResult, ConfigName, FileSystem, FindFiles, OpenOptions, SKIPPED_DIRECTORIES, SkipDirectory, }; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ DynRef, Workspace, WorkspaceError, @@ -43,6 +43,10 @@ pub struct LoadedConfiguration { pub configuration: PartialConfiguration, /// The `db` section of the configuration file itself, without what it extends pub file_database: Option, + /// The files it extends + pub extended_files: Vec, + /// Whether it is the user configuration, which applies because the project has none + pub is_user_configuration: bool, } impl LoadedConfiguration { @@ -57,7 +61,7 @@ impl LoadedConfiguration { } = value; let file_database = partial_configuration.db.clone(); - partial_configuration.apply_extends( + let extended_files = partial_configuration.apply_extends( fs, &configuration_file_path, &external_resolution_base_path, @@ -66,6 +70,8 @@ impl LoadedConfiguration { Ok(Self { configuration: partial_configuration, file_database, + extended_files, + is_user_configuration: false, directory_path: configuration_file_path.parent().map(PathBuf::from), file_path: Some(configuration_file_path), }) @@ -112,6 +118,7 @@ pub fn load_configuration( let mut loaded = LoadedConfiguration::try_from_payload(config, fs)?; loaded.directory_path = Some(project_directory); + loaded.is_user_configuration = true; Ok(loaded) } @@ -123,6 +130,8 @@ pub struct NestedConfiguration { /// Whether the configuration file was loaded and applied. The files below a configuration file /// that could not be applied use the default settings. pub result: Result<(), WorkspaceError>, + /// The files it extends. + pub extended_files: Vec, } /// Where [apply_nested_configurations] looks for configuration files. @@ -233,11 +242,16 @@ pub fn apply_nested_configurations( directories.dedup(); let mut load_errors = Vec::new(); + let mut extended_files = FxHashMap::default(); let nested = directories .into_iter() - .map(|directory| { - load_nested_configuration(fs, &directory, environment, &configure).unwrap_or_else( - |error| { + .map( + |directory| match load_nested_configuration(fs, &directory, environment, &configure) { + Ok((nested, extended)) => { + extended_files.insert(directory, extended); + nested + } + Err(error) => { load_errors.push((directory.clone(), error)); // Neither the configuration above it nor a part of the broken one applies. NestedSettingsParams { @@ -246,9 +260,9 @@ pub fn apply_nested_configurations( vcs_base_path: None, gitignore_matches: Vec::new(), } - }, - ) - }) + } + }, + ) .collect(); let UpdateNestedSettingsResult { applied, errors } = @@ -272,7 +286,11 @@ pub fn apply_nested_configurations( Some(error) => Err(error), None => Ok(()), }; - NestedConfiguration { directory, result } + NestedConfiguration { + extended_files: extended_files.remove(&directory).unwrap_or_default(), + directory, + result, + } }) .collect(); Ok(NestedConfigurations { @@ -411,7 +429,7 @@ fn load_nested_configuration( directory: &Path, environment: Option<&PartialDatabaseConfiguration>, configure: impl Fn(PartialConfiguration) -> PartialConfiguration, -) -> Result { +) -> Result<(NestedSettingsParams, Vec), WorkspaceError> { let loaded = load_configuration( fs, ConfigurationPathHint::FromWorkspace(directory.to_path_buf()), @@ -428,12 +446,13 @@ fn load_nested_configuration( let configuration = configure(configuration); let (vcs_base_path, gitignore_matches) = configuration.retrieve_gitignore_matches(fs, Some(directory))?; - Ok(NestedSettingsParams { + let nested = NestedSettingsParams { directory: directory.to_path_buf(), configuration, vcs_base_path, gitignore_matches, - }) + }; + Ok((nested, loaded.extended_files)) } /// - [Result]: if an error occurred while loading the configuration file. @@ -619,19 +638,21 @@ fn strip_jsonc(content: &str) -> Result { } pub trait PartialConfigurationExt { + /// Returns the files it extended. fn apply_extends( &mut self, fs: &DynRef<'_, dyn FileSystem>, file_path: &Path, external_resolution_base_path: &Path, - ) -> Result<(), WorkspaceError>; + ) -> Result, WorkspaceError>; + /// Returns each configuration file in `extends` with its path. fn deserialize_extends( &mut self, fs: &DynRef<'_, dyn FileSystem>, relative_resolution_base_path: &Path, external_resolution_base_path: &Path, - ) -> Result, WorkspaceError>; + ) -> Result, WorkspaceError>; fn retrieve_gitignore_matches( &self, @@ -652,12 +673,15 @@ impl PartialConfigurationExt for PartialConfiguration { fs: &DynRef<'_, dyn FileSystem>, file_path: &Path, external_resolution_base_path: &Path, - ) -> Result<(), WorkspaceError> { - let configurations = self.deserialize_extends( - fs, - file_path.parent().expect("file path should have a parent"), - external_resolution_base_path, - )?; + ) -> Result, WorkspaceError> { + let (extended_files, configurations): (Vec<_>, Vec<_>) = self + .deserialize_extends( + fs, + file_path.parent().expect("file path should have a parent"), + external_resolution_base_path, + )? + .into_iter() + .unzip(); let extended_configuration = configurations.into_iter().reduce( |mut previous_configuration, current_configuration| { @@ -671,7 +695,7 @@ impl PartialConfigurationExt for PartialConfiguration { self.merge_with(extended_configuration) } - Ok(()) + Ok(extended_files) } /// It attempts to deserialize all the configuration files that were specified in the `extends` property @@ -680,7 +704,7 @@ impl PartialConfigurationExt for PartialConfiguration { fs: &DynRef<'_, dyn FileSystem>, relative_resolution_base_path: &Path, external_resolution_base_path: &Path, - ) -> Result, WorkspaceError> { + ) -> Result, WorkspaceError> { let Some(extends) = &self.extends else { return Ok(Vec::new()); }; @@ -746,7 +770,7 @@ impl PartialConfigurationExt for PartialConfiguration { let deserialized = serde_json::from_str::(&strip_jsonc(&content)?) .map_err(ConfigurationDiagnostic::new_deserialization_error)?; - deserialized_configurations.push(deserialized) + deserialized_configurations.push((extend_configuration_file_path, deserialized)) } Ok(deserialized_configurations) } diff --git a/docs/configuration.md b/docs/configuration.md index 12e6d42cb..201d81c40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,6 +127,10 @@ The user configuration is only a fallback for projects without a configuration f The user configuration applies to the project as if it were placed in the project directory: globs in `files` and the VCS settings are resolved against the project. Relative paths in its `extends` are resolved against its own directory. +When the user configuration applies, the CLI prints `Using the user configuration at ` to stderr, and the language server writes it to its log. + +The language server reloads the configuration when any of these files is created, changed, or deleted: the configuration files below each workspace folder, the configuration file above a workspace folder that applies to it, the configuration file set in your editor, the user configuration, and the files that a loaded configuration file `extends`. + ## Sharing configuration with `extends` `extends` lists configuration files to inherit from. They are applied from left to right, and the settings of the file that declares `extends` take precedence over all of them. From a61af74da1b0ac9382b0b7b035b669c0bef678ed Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 06:26:26 +0000 Subject: [PATCH 06/17] fix(configuration): resolve root paths against the config folder, apply extends transitively, replace on reload Relative paths and globs of the root configuration are resolved against the folder of the configuration file instead of the working directory of the process. An extended file's own extends are applied too, with cycle detection and a depth limit. The LSP and the CLI replace the root settings on reload, so removed sections go back to their defaults; the wasm format call keeps merging. --- crates/pgls_cli/src/lib.rs | 13 +- crates/pgls_cli/src/workspace.rs | 15 +- crates/pgls_cli/tests/assert_check.rs | 29 +++ crates/pgls_lsp/src/session.rs | 13 +- crates/pgls_lsp/tests/server.rs | 122 +++++++++++ crates/pgls_wasm/src/lib.rs | 2 + crates/pgls_workspace/src/configuration.rs | 199 ++++++++++++++++-- crates/pgls_workspace/src/workspace.rs | 6 + crates/pgls_workspace/src/workspace/server.rs | 20 +- .../src/workspace/server.tests.rs | 37 ++++ docs/configuration.md | 1 + .../backend-jsonrpc/src/workspace.ts | 7 + .../backend-jsonrpc/src/workspace.ts | 7 + 13 files changed, 436 insertions(+), 35 deletions(-) diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index 1630c78e0..de22066c5 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -57,6 +57,8 @@ pub struct CliSession<'app> { /// The files settings of the root configuration, which decide the folders to search for /// nested configuration files root_files: Option, + /// The folder of the root configuration file, which its relative paths are relative to + configuration_directory: Option, } impl<'app> CliSession<'app> { @@ -72,6 +74,7 @@ impl<'app> CliSession<'app> { ), project_key: ProjectKey::default(), root_files: None, + configuration_directory: None, }) } @@ -221,6 +224,7 @@ impl<'app> CliSession<'app> { let fs = self.fs(); let loaded_configuration = workspace::load_config(fs, cli_options.as_configuration_path_hint())?; + self.configuration_directory = loaded_configuration.directory_path.clone(); if let Some(config_path) = &loaded_configuration.file_path && let Some(file_name) = config_path.file_name().and_then(|name| name.to_str()) @@ -321,8 +325,13 @@ impl<'app> CliSession<'app> { configuration: PartialConfiguration, vcs: VcsIntegration, ) -> Result<(), CliDiagnostic> { - let (project_key, root_files) = - workspace::setup_workspace(self.workspace(), self.fs(), configuration, vcs)?; + let (project_key, root_files) = workspace::setup_workspace( + self.workspace(), + self.fs(), + configuration, + self.configuration_directory.clone(), + vcs, + )?; self.project_key = project_key; self.root_files = Some(root_files); Ok(()) diff --git a/crates/pgls_cli/src/workspace.rs b/crates/pgls_cli/src/workspace.rs index 5b5e3e752..768c15dfe 100644 --- a/crates/pgls_cli/src/workspace.rs +++ b/crates/pgls_cli/src/workspace.rs @@ -6,6 +6,7 @@ use pgls_workspace::configuration::{LoadedConfiguration, load_configuration}; use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::{ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams}; use pgls_workspace::{DynRef, Workspace}; +use std::path::PathBuf; /// Load configuration from disk and emit warnings for deprecated filenames. pub fn load_config( @@ -16,17 +17,20 @@ pub fn load_config( } /// Registers the project of the working directory and configures it and the VCS integration -/// according to the provided configuration. Returns the key of the project and the files settings -/// of the configuration. +/// according to the provided configuration, whose relative paths are relative to +/// `configuration_directory` (by default the working directory). Returns the key of the project +/// and the files settings of the configuration. pub fn setup_workspace( workspace: &dyn Workspace, fs: &DynRef<'_, dyn FileSystem>, configuration: PartialConfiguration, + configuration_directory: Option, vcs: VcsIntegration, ) -> Result<(ProjectKey, FilesSettings), CliDiagnostic> { + let configuration_directory = configuration_directory.or_else(|| fs.working_directory()); let (vcs_base_path, gitignore_matches) = match vcs { VcsIntegration::Enabled => configuration - .retrieve_gitignore_matches(fs, fs.working_directory().as_deref()) + .retrieve_gitignore_matches(fs, configuration_directory.as_deref()) .map_err(CliDiagnostic::from)?, VcsIntegration::Disabled => (None, vec![]), }; @@ -39,17 +43,18 @@ pub fn setup_workspace( let files = FilesSettings::from_configuration( &configuration, - fs.working_directory(), + configuration_directory.clone(), vcs_base_path.clone(), &gitignore_matches, )?; workspace .update_settings(UpdateSettingsParams { project_key, - workspace_directory: fs.working_directory(), + workspace_directory: configuration_directory, configuration, vcs_base_path, gitignore_matches, + replace: true, }) .map_err(CliDiagnostic::from)?; diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 74d61e060..8c9000962 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -646,6 +646,35 @@ fn check_announces_the_user_configuration() { ); } +/// Relative paths of the configuration file are relative to its folder, also when `check` runs +/// in a folder below it. +#[test] +fn relative_paths_are_relative_to_the_configuration_file() { + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true }, "migrations": { "migrationsDir": "migrations", "after": 5 } }"#, + ), + ( + "migrations/0009_later.sql", + "alter table users drop column name;\n", + ), + ("sub/a.sql", "select 1;\n"), + ]); + + let migration = Path::new("..").join("migrations").join("0009_later.sql"); + let (_, output) = check_in( + &root.path().join("sub"), + &[migration.to_str().unwrap()], + &[], + ); + // `banDropColumn` only runs on migrations once `migrations` is configured. + assert!( + output.contains("lint/banDropColumn"), + "the file is not recognised as a migration:\n{output}" + ); +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 84621e820..749eb0335 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -1071,7 +1071,16 @@ impl Session { match result { Ok((vcs_base_path, gitignore_matches)) => { - let workspace_directory = self.fs.working_directory(); + // Relative paths are relative to the folder of the configuration file, + // or the project's for the user configuration and the defaults. + let workspace_directory = configuration_path + .clone() + .or_else(|| match &base_path { + ConfigurationPathHint::FromWorkspace(path) + | ConfigurationPathHint::FromLsp(path) => Some(path.clone()), + _ => None, + }) + .or_else(|| self.fs.working_directory()); let root_files = FilesSettings::from_configuration( &configuration, workspace_directory.clone(), @@ -1085,6 +1094,8 @@ impl Session { configuration, vcs_base_path, gitignore_matches, + // A section removed from the file goes back to its defaults. + replace: true, }); if let Err(error) = result { diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index 4e988d0d3..2bf51a10a 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -3784,3 +3784,125 @@ async fn the_user_configuration_and_extended_files_are_watched() -> Result<()> { reader.abort(); Ok(()) } + +/// Relative paths of the root configuration, here `migrations.migrationsDir`, are relative to the +/// folder of the configuration file, like in a nested one, not to the working directory of the +/// server process (Neovim started elsewhere, the daemon, another workspace folder). +#[tokio::test] +async fn relative_paths_of_the_root_configuration_are_relative_to_its_folder() -> Result<()> { + let root = tempfile::tempdir()?; + let elsewhere = tempfile::tempdir()?; + let migrations = r#"{ "migrations": { "migrationsDir": "migrations", "after": 5 } }"#; + for directory in [root.path().to_path_buf(), root.path().join("sub")] { + std::fs::create_dir_all(&directory)?; + std::fs::write(directory.join(ConfigName::pgls_jsonc()), migrations)?; + } + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + // The server process runs somewhere else than the workspace. + DynRef::Owned(Box::new(OsFileSystem::new(elsewhere.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_root(Uri::from_file_path(root.path()).unwrap()) + .await?; + server.initialized().await?; + + let document = |path: &str| Uri::from_file_path(root.path().join(path)).unwrap(); + // `banDropColumn` only runs on migrations once `migrations` is configured, and migrations up + // to `after` are ignored. + for directory in ["sub/", ""] { + assert_eq!( + open_and_check( + &mut server, + &mut receiver, + document(&format!("{directory}migrations/0001_init.sql")) + ) + .await?, + [], + "{directory}migrations/0001_init.sql" + ); + assert_eq!( + open_and_check( + &mut server, + &mut receiver, + document(&format!("{directory}migrations/0009_later.sql")) + ) + .await?, + [lsp::DiagnosticSeverity::WARNING], + "{directory}migrations/0009_later.sql is not recognised as a migration" + ); + } + + server.shutdown().await?; + reader.abort(); + Ok(()) +} + +/// Reloading the root configuration replaces its settings, so a section removed from the file +/// goes back to its defaults, like in a nested configuration. +#[tokio::test] +async fn a_section_removed_from_the_configuration_goes_back_to_its_defaults() -> Result<()> { + let root = tempfile::tempdir()?; + let config = root.path().join(ConfigName::pgls_jsonc()); + std::fs::write( + &config, + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + )?; + + let factory = ServerFactory::default(); + let (service, client) = factory + .create_with_fs( + None, + DynRef::Owned(Box::new(OsFileSystem::new(root.path().to_path_buf()))), + ) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_root(Uri::from_file_path(root.path()).unwrap()) + .await?; + server.initialized().await?; + + let document = Uri::from_file_path(root.path().join("a.sql")).unwrap(); + assert_eq!( + open_and_check(&mut server, &mut receiver, document.clone()).await?, + [lsp::DiagnosticSeverity::ERROR] + ); + + std::fs::write(&config, "{}")?; + drain_notifications(&mut receiver); + server + .notify( + "workspace/didChangeWatchedFiles", + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(&config).unwrap(), + typ: FileChangeType::CHANGED, + }], + }, + ) + .await?; + wait_for_severities( + &mut receiver, + &document, + &[lsp::DiagnosticSeverity::WARNING], + ) + .await + .context("the removed `linter` section still applies after the reload")?; + + server.shutdown().await?; + reader.abort(); + Ok(()) +} diff --git a/crates/pgls_wasm/src/lib.rs b/crates/pgls_wasm/src/lib.rs index 73ebcc8bd..5d726f770 100644 --- a/crates/pgls_wasm/src/lib.rs +++ b/crates/pgls_wasm/src/lib.rs @@ -393,6 +393,8 @@ impl Workspace { vcs_base_path: None, gitignore_matches: vec![], workspace_directory: None, + // Only the formatter settings change, the others keep their values. + replace: false, }) .map_err(|e| { WasmError::InvalidOptions(format!("Failed to apply format options: {e}")) diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 8e4613a77..91c422381 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -665,36 +665,25 @@ impl PartialConfigurationExt for PartialConfiguration { /// Mutates the configuration so that any fields that have not been configured explicitly are /// filled in with their values from configs listed in the `extends` field. /// - /// The `extends` configs are applied from left to right. + /// The `extends` configs are applied from left to right, each with its own `extends` applied + /// first. Relative paths in an extended file are relative to that file. /// - /// If a configuration can't be resolved from the file system, the operation will fail. + /// If a configuration can't be resolved from the file system, extends itself, or is extended + /// more than [MAX_EXTENDS_DEPTH] levels deep, the operation will fail. fn apply_extends( &mut self, fs: &DynRef<'_, dyn FileSystem>, file_path: &Path, external_resolution_base_path: &Path, ) -> Result, WorkspaceError> { - let (extended_files, configurations): (Vec<_>, Vec<_>) = self - .deserialize_extends( - fs, - file_path.parent().expect("file path should have a parent"), - external_resolution_base_path, - )? - .into_iter() - .unzip(); - - let extended_configuration = configurations.into_iter().reduce( - |mut previous_configuration, current_configuration| { - previous_configuration.merge_with(current_configuration); - previous_configuration - }, - ); - if let Some(mut extended_configuration) = extended_configuration { - // We swap them to avoid having to clone `self.configuration` to merge it. - std::mem::swap(self, &mut extended_configuration); - self.merge_with(extended_configuration) - } - + let mut extended_files = Vec::new(); + apply_extends_recursively( + self, + fs, + &mut vec![normalize_path(file_path)], + external_resolution_base_path, + &mut extended_files, + )?; Ok(extended_files) } @@ -819,6 +808,77 @@ impl PartialConfigurationExt for PartialConfiguration { } } +/// How many levels deep configuration files can extend each other. +const MAX_EXTENDS_DEPTH: usize = 10; + +/// Applies the `extends` of `configuration`, the last file of `chain`, and theirs, collecting the +/// extended files. +fn apply_extends_recursively( + configuration: &mut PartialConfiguration, + fs: &DynRef<'_, dyn FileSystem>, + chain: &mut Vec, + external_resolution_base_path: &Path, + extended_files: &mut Vec, +) -> Result<(), WorkspaceError> { + let file_path = chain + .last() + .expect("the chain starts with the configuration file"); + let mut configurations = Vec::new(); + for (path, mut extended) in configuration.deserialize_extends( + fs, + file_path.parent().expect("file path should have a parent"), + external_resolution_base_path, + )? { + let path = normalize_path(&path); + if chain.contains(&path) { + let cycle: Vec<_> = chain + .iter() + .chain(std::iter::once(&path)) + .map(|file| file.display().to_string()) + .collect(); + return Err(ConfigurationDiagnostic::invalid_configuration(format!( + "The configuration files extend each other: {}", + cycle.join(" -> ") + )) + .into()); + } + if chain.len() > MAX_EXTENDS_DEPTH { + return Err(ConfigurationDiagnostic::invalid_configuration(format!( + "The configuration files extend each other more than {MAX_EXTENDS_DEPTH} levels \ + deep, at {}", + path.display() + )) + .into()); + } + extended_files.push(path.clone()); + chain.push(path); + apply_extends_recursively( + &mut extended, + fs, + chain, + external_resolution_base_path, + extended_files, + )?; + chain.pop(); + configurations.push(extended); + } + + let extended_configuration = + configurations + .into_iter() + .reduce(|mut previous_configuration, current_configuration| { + previous_configuration.merge_with(current_configuration); + previous_configuration + }); + if let Some(mut extended_configuration) = extended_configuration { + // We swap them to avoid having to clone `configuration` to merge it. + std::mem::swap(configuration, &mut extended_configuration); + configuration.merge_with(extended_configuration) + } + + Ok(()) +} + /// Expands a leading `~` in a path to the home directory. /// /// Returns `None` if the path doesn't start with `~` or the home directory is unknown. @@ -942,6 +1002,99 @@ mod tests { format!(r#"{{ "files": {{ "maxSize": {max_size} }} }}"#) } + /// A configuration that extends a file which itself extends another, e.g. a nested + /// configuration extending the root configuration that extends a shared one, gets all three. + /// Relative paths in an extended file are relative to it. + #[test] + fn extends_is_applied_transitively() { + let mut fs = file_system(); + fs.insert( + project_directory() + .join("db1") + .join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["../postgres-language-server.jsonc"], "db": { "database": "db1" } }"#, + ); + fs.insert( + project_directory().join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["./shared/base.jsonc"], "db": { "database": "root", "host": "db" } }"#, + ); + fs.insert( + project_directory().join("shared").join("base.jsonc"), + r#"{ "extends": ["./files.jsonc"] }"#, + ); + fs.insert( + project_directory().join("shared").join("files.jsonc"), + files_config(7), + ); + + let loaded = load( + fs, + ConfigurationPathHint::FromWorkspace(project_directory().join("db1")), + ); + assert_eq!(max_file_size(&loaded), Some(7)); + let db = loaded.configuration.db.as_ref().unwrap(); + assert_eq!(db.database.as_deref(), Some("db1")); + assert_eq!(db.host.as_deref(), Some("db")); + assert_eq!( + loaded.extended_files, + [ + project_directory().join(ConfigName::pgls_jsonc()), + project_directory().join("shared").join("base.jsonc"), + project_directory().join("shared").join("files.jsonc"), + ] + ); + } + + #[test] + fn extends_fails_on_a_cycle() { + let mut fs = file_system(); + fs.insert( + project_directory().join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["./a.jsonc"] }"#, + ); + fs.insert( + project_directory().join("a.jsonc"), + r#"{ "extends": ["./b.jsonc"] }"#, + ); + fs.insert( + project_directory().join("b.jsonc"), + r#"{ "extends": ["./a.jsonc"] }"#, + ); + + let error = load_configuration( + &DynRef::Owned(Box::new(fs)), + ConfigurationPathHint::FromWorkspace(project_directory()), + ) + .expect_err("a cycle can't be applied"); + assert!(error.to_string().contains("extend each other"), "{error}"); + } + + #[test] + fn extends_fails_when_too_deep() { + let mut fs = file_system(); + fs.insert( + project_directory().join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["./0.jsonc"] }"#, + ); + for level in 0..=MAX_EXTENDS_DEPTH { + fs.insert( + project_directory().join(format!("{level}.jsonc")), + format!(r#"{{ "extends": ["./{}.jsonc"] }}"#, level + 1), + ); + } + fs.insert( + project_directory().join(format!("{}.jsonc", MAX_EXTENDS_DEPTH + 1)), + "{}", + ); + + let error = load_configuration( + &DynRef::Owned(Box::new(fs)), + ConfigurationPathHint::FromWorkspace(project_directory()), + ) + .expect_err("too deep"); + assert!(error.to_string().contains("levels"), "{error}"); + } + #[test] fn extends_expands_the_home_directory() { let mut fs = file_system(); diff --git a/crates/pgls_workspace/src/workspace.rs b/crates/pgls_workspace/src/workspace.rs index 78a38d70d..9f670387c 100644 --- a/crates/pgls_workspace/src/workspace.rs +++ b/crates/pgls_workspace/src/workspace.rs @@ -73,7 +73,13 @@ pub struct UpdateSettingsParams { pub configuration: PartialConfiguration, pub vcs_base_path: Option, pub gitignore_matches: Vec, + /// The folder that relative paths and globs of the configuration are resolved against: the + /// folder of the configuration file. pub workspace_directory: Option, + /// Whether the configuration replaces the root settings. Otherwise it is merged into them, and + /// the sections it doesn't set keep their values. + #[serde(default)] + pub replace: bool, } #[derive(Debug, serde::Serialize, serde::Deserialize)] diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index de9ff6b01..52ad718d7 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -831,17 +831,29 @@ impl Workspace for WorkspaceServer { #[tracing::instrument(level = "trace", skip(self), err)] fn update_settings(&self, params: UpdateSettingsParams) -> Result<(), WorkspaceError> { let mut workspace = self.workspaces_mut(); - - workspace + let settings = workspace .as_mut() .get_settings_mut(params.project_key) - .ok_or_else(WorkspaceError::no_project)? - .merge_with_configuration( + .ok_or_else(WorkspaceError::no_project)?; + + if params.replace { + // A configuration that can't be applied leaves the settings as they were. + let mut replacement = Settings::default(); + replacement.merge_with_configuration( params.configuration, params.workspace_directory, params.vcs_base_path, params.gitignore_matches.as_slice(), )?; + *settings = replacement; + } else { + settings.merge_with_configuration( + params.configuration, + params.workspace_directory, + params.vcs_base_path, + params.gitignore_matches.as_slice(), + )?; + } Ok(()) } diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index b28b79582..1580c0c20 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -57,6 +57,7 @@ fn get_test_workspace( gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, + replace: false, })?; Ok((workspace, project_key)) @@ -935,6 +936,7 @@ async fn test_disable_plpgsql_check(test_db: PgPool) { gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, + replace: false, }); let diagnostics = workspace @@ -1030,6 +1032,7 @@ async fn test_disable_typecheck(test_db: PgPool) { gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, + replace: false, }); let diagnostics = workspace @@ -2332,6 +2335,7 @@ impl TestProject { gitignore_matches: vec![], vcs_base_path: None, workspace_directory: Some(root.to_path_buf()), + replace: false, }) .unwrap(); @@ -2514,6 +2518,37 @@ fn files_use_the_nearest_configuration_file() { assert!(project.is_ignored("/repo/legacy/a.sql")); } +/// Updating the root settings with `replace` resets what the configuration doesn't set, while a +/// merge keeps it, which the wasm `format` call relies on. +#[test] +fn updating_the_settings_replaces_or_merges_them() { + let fs = file_system_with_configurations(&[( + "/repo", + r#"{ "linter": { "rules": { "banDropColumn": "error" } } }"#, + )]); + let fs: DynRef<'_, dyn FileSystem> = DynRef::Owned(Box::new(fs)); + let project = TestProject::without_nested_configurations(&fs, Path::new("/repo")); + assert_eq!(project.severities("/repo/a.sql"), [Severity::Error]); + + let update = |replace: bool| { + project + .workspace + .update_settings(UpdateSettingsParams { + project_key: project.project_key, + configuration: PartialConfiguration::default(), + vcs_base_path: None, + gitignore_matches: vec![], + workspace_directory: Some(PathBuf::from("/repo")), + replace, + }) + .unwrap(); + }; + update(false); + assert_eq!(project.severities("/repo/b.sql"), [Severity::Error]); + update(true); + assert_eq!(project.severities("/repo/c.sql"), [Severity::Warning]); +} + /// The search doesn't descend into the folders that the root configuration ignores, except below /// a nested configuration file: its settings decide there, so the configuration files below it /// are still found. @@ -2786,6 +2821,7 @@ fn projects_have_their_own_nested_settings() { gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, + replace: false, }) .unwrap(); @@ -2838,6 +2874,7 @@ fn updating_the_settings_of_an_unknown_project_fails() { gitignore_matches: vec![], vcs_base_path: None, workspace_directory: None, + replace: false, }); assert!(matches!(result, Err(WorkspaceError::NoProject(_)))); } diff --git a/docs/configuration.md b/docs/configuration.md index 201d81c40..fbb92ee44 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,6 +151,7 @@ The language server reloads the configuration when any of these files is created - Entries starting with `~/` (or `~\` on Windows) are resolved against your home directory. This works on every platform, so a project can extend your user configuration with the same path on macOS and Linux. - Entries starting with `.` or ending in `.jsonc` are resolved relative to the directory of the configuration file that declares them. Absolute paths are used as they are. - Any other entry is resolved as a package, like `require` would in Node.js. +- An extended file can itself use `extends`; its entries are resolved relative to that file and applied before it. Files that extend each other in a cycle, or more than 10 levels deep, are an error. ## Configuring a database connection diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index 265424f26..93fcdf3b9 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -367,7 +367,14 @@ export interface UpdateSettingsParams { * The project whose root settings are updated. */ project_key: ProjectKey; + /** + * Whether the configuration replaces the root settings. Otherwise it is merged into them, and the sections it doesn't set keep their values. + */ + replace?: boolean; vcs_base_path?: string; + /** + * The folder that relative paths and globs of the configuration are resolved against: the folder of the configuration file. + */ workspace_directory?: string; } /** diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 265424f26..93fcdf3b9 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -367,7 +367,14 @@ export interface UpdateSettingsParams { * The project whose root settings are updated. */ project_key: ProjectKey; + /** + * Whether the configuration replaces the root settings. Otherwise it is merged into them, and the sections it doesn't set keep their values. + */ + replace?: boolean; vcs_base_path?: string; + /** + * The folder that relative paths and globs of the configuration are resolved against: the folder of the configuration file. + */ workspace_directory?: string; } /** From 6592242a5e457f7437f5dd2ff20d7d387dc85542 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 06:30:19 +0000 Subject: [PATCH 07/17] test(cli): don't depend on the path format in the broken configuration test --- crates/pgls_cli/tests/assert_check.rs | 31 ++++++++++----------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 04d2b99da..b468cca1b 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -538,35 +538,26 @@ fn check_reports_a_broken_nested_configuration_and_continues() { ), ]); - let broken = Path::new("tests").join("fixtures").join("broken"); let (success, output) = check_in(root.path(), &[], &[]); assert!(!success, "{output}"); assert!( - output.contains( - &broken - .join("postgres-language-server.jsonc") - .display() - .to_string() - ), + output.lines().any(|line| line.contains("broken") + && line.contains("postgres-language-server.jsonc") + && line.contains("configuration")), "the broken configuration file is not named:\n{output}" ); let (_, output) = check_in(root.path(), &["--reporter", "github"], &[]); - let annotation = |level: &str, file: &Path| { - format!( - "::{level} title=lint/banDropColumn,file={},", - root.path().join(file).display() - ) + let level_of = |file: &str| { + output + .lines() + .find(|line| line.contains("title=lint/banDropColumn") && line.contains(file)) + .and_then(|line| line.strip_prefix("::")) + .and_then(|line| line.split(' ').next()) }; - assert!( - output.contains(&annotation("error", Path::new("a.sql"))), - "{output}" - ); + assert_eq!(level_of("a.sql"), Some("error"), "{output}"); // The defaults, not the root configuration's `error`. - assert!( - output.contains(&annotation("warning", &broken.join("b.sql"))), - "{output}" - ); + assert_eq!(level_of("b.sql"), Some("warning"), "{output}"); } /// The database environment variables don't replace the database of a nested configuration file From b3818b5b1cb3c325bd0354622d911998a098a4c2 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 06:46:27 +0000 Subject: [PATCH 08/17] perf(workspace): don't let one database block the others Load each connection's snapshot under its own lock instead of holding the write lock of the whole snapshot map during network I/O. check verifies the distinct databases of all configuration files concurrently and reports every one it can't reach, with its address and the folders that use it. --- crates/pgls_cli/src/commands/check.rs | 12 ++-- crates/pgls_cli/tests/assert_check.rs | 67 ++++++++++++++++- ...check_fails_when_database_login_fails.snap | 2 +- .../src/features/diagnostics.rs | 4 +- crates/pgls_workspace/src/settings.rs | 13 ++-- crates/pgls_workspace/src/workspace/server.rs | 72 ++++++++++++++++--- .../src/workspace/server/snapshot_manager.rs | 41 ++++++----- 7 files changed, 169 insertions(+), 42 deletions(-) diff --git a/crates/pgls_cli/src/commands/check.rs b/crates/pgls_cli/src/commands/check.rs index 8c4bdb121..26938f55d 100644 --- a/crates/pgls_cli/src/commands/check.rs +++ b/crates/pgls_cli/src/commands/check.rs @@ -68,9 +68,9 @@ pub fn check( let payload = read_stdin_payload(stdin_path, session.console(), "check")?; run_stdin(&mut session, &execution, payload) } else { - let database_error = check_database_connection(&session)?; + let database_errors = check_database_connection(&session)?; let mut report: Report = run_files(&mut session, &execution, paths)?; - if let Some(error) = database_error { + for error in database_errors.into_iter().rev() { report.prepend_diagnostic(error); } for error in configuration_errors.into_iter().rev() { @@ -85,15 +85,17 @@ pub fn check( /// A configured database that cannot be reached is an error: the checks that need it would /// otherwise be skipped silently. -fn check_database_connection(session: &CliSession) -> Result, CliDiagnostic> { +fn check_database_connection(session: &CliSession) -> Result, CliDiagnostic> { let result = session .workspace() .check_database_connection(CheckDatabaseConnectionParams { project_key: session.project_key(), })?; Ok(result - .error - .map(|message| Error::from(DatabaseUnavailable::new(message)))) + .errors + .into_iter() + .map(|message| Error::from(DatabaseUnavailable::new(message))) + .collect()) } fn resolve_paths( diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 9133fbf7c..ab89ac894 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -666,6 +666,49 @@ fn relative_paths_are_relative_to_the_configuration_file() { ); } +/// `check` verifies the databases of all configuration files at the same time, and reports each +/// one it can't reach with its address and the folder that uses it. +#[cfg(target_os = "linux")] +#[test] +fn check_reports_every_unreachable_database_at_once() { + let unreachable = |port: u16| { + format!( + r#"{{ "db": {{ "host": "10.255.255.1", "port": {port}, "username": "postgres", "password": "postgres", "connTimeoutSecs": 2 }} }}"# + ) + }; + let (one, two, three) = (unreachable(5001), unreachable(5002), unreachable(5003)); + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ), + ("db1/postgres-language-server.jsonc", &one), + ("db2/postgres-language-server.jsonc", &two), + ("db3/postgres-language-server.jsonc", &three), + ("db1/a.sql", "select 1;\n"), + ("db2/a.sql", "select 1;\n"), + ("db3/a.sql", "select 1;\n"), + ]); + + let start = std::time::Instant::now(); + let (success, output) = check_in(root.path(), &[], &[]); + let elapsed = start.elapsed(); + assert!(!success, "{output}"); + for (port, folder) in [(5001, "db1"), (5002, "db2"), (5003, "db3")] { + let address = format!("postgres@10.255.255.1:{port}/postgres, the database of "); + assert!( + output.lines().any(|line| line.contains(&address) + && line.contains(&root.path().join(folder).display().to_string())), + "the database of {folder} is not reported:\n{output}" + ); + } + // One connection timeout for the check, not one per database. + assert!( + elapsed < std::time::Duration::from_secs(5), + "the connection checks ran one after the other: {elapsed:?}" + ); +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] @@ -821,7 +864,7 @@ fn normalize_durations(input: &str) -> String { } fn normalize_output(status: ExitStatus, stdout: &str, stderr: &str) -> String { - let normalized_stdout = normalize_durations(stdout); + let normalized_stdout = normalize_test_environment(&normalize_durations(stdout)); let normalized_stderr = normalize_diagnostics(stderr); let status_label = if status.success() { "success" @@ -835,8 +878,28 @@ fn normalize_output(status: ExitStatus, stdout: &str, stderr: &str) -> String { ) } +/// Replaces the address of the database in connection errors, which names the database that +/// `sqlx::test` creates, and the working directory. +fn normalize_test_environment(input: &str) -> String { + const PREFIX: &str = "Could not connect to "; + let mut output = String::new(); + let mut rest = input; + while let Some(start) = rest.find(PREFIX) { + output.push_str(&rest[..start + PREFIX.len()]); + output.push_str(""); + rest = &rest[start + PREFIX.len()..]; + let end = rest.find([',', ' ']).unwrap_or(rest.len()); + rest = &rest[end..]; + } + output.push_str(rest); + match std::env::current_dir() { + Ok(cwd) => output.replace(&cwd.display().to_string(), ""), + Err(_) => output, + } +} + fn normalize_diagnostics(input: &str) -> String { - let normalized = normalize_durations(input); + let normalized = normalize_test_environment(&normalize_durations(input)); let mut lines = normalized.lines().peekable(); let mut diagnostic_sections: Vec = Vec::new(); let mut other_lines: Vec = Vec::new(); diff --git a/crates/pgls_cli/tests/snapshots/assert_check__check_fails_when_database_login_fails.snap b/crates/pgls_cli/tests/snapshots/assert_check__check_fails_when_database_login_fails.snap index 733587a1f..38ac58996 100644 --- a/crates/pgls_cli/tests/snapshots/assert_check__check_fails_when_database_login_fails.snap +++ b/crates/pgls_cli/tests/snapshots/assert_check__check_fails_when_database_login_fails.snap @@ -9,7 +9,7 @@ Found 1 error. stderr: database/connection ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - × Database error: password authentication failed for user "postgres" + × Could not connect to , the database of : Database error: password authentication failed for user "postgres" i The checks that need the database were skipped. Fix the connection settings, or pass --disable-db or set db.disableConnection to check without a database. diff --git a/crates/pgls_workspace/src/features/diagnostics.rs b/crates/pgls_workspace/src/features/diagnostics.rs index 3b7cdc299..e8aaf1fc1 100644 --- a/crates/pgls_workspace/src/features/diagnostics.rs +++ b/crates/pgls_workspace/src/features/diagnostics.rs @@ -31,8 +31,8 @@ pub struct CheckDatabaseConnectionParams { #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct CheckDatabaseConnectionResult { - /// Why the configured database cannot be used, if it cannot. - pub error: Option, + /// Why the configured databases cannot be used, one message per database that can't. + pub errors: Vec, } #[derive(Debug, serde::Serialize, serde::Deserialize)] diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 03e530ead..83d55afe1 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -121,11 +121,15 @@ impl WorkspaceSettings { self.data.get(key).map(|project| project.settings_for(path)) } - /// The root settings and the nested settings of a project. - pub fn get_all_settings(&self, key: ProjectKey) -> impl Iterator { + /// The root settings and the nested settings of a project, with the folders they apply to. + pub fn get_all_settings(&self, key: ProjectKey) -> impl Iterator { self.data.get(key).into_iter().flat_map(|project| { - std::iter::once(&project.settings) - .chain(project.nested.iter().map(|(_, settings)| settings)) + std::iter::once((project.path.as_path(), &project.settings)).chain( + project + .nested + .iter() + .map(|(folder, settings)| (folder.as_path(), settings)), + ) }) } @@ -713,6 +717,7 @@ impl Default for TypecheckSettings { pub const DEFAULT_DATABASE_PASSWORD: &str = "postgres"; /// Database settings for the entire workspace +#[derive(Clone)] pub struct DatabaseSettings { pub enable_connection: bool, pub connection_string: Option, diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 52ad718d7..df5bf5c79 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -87,6 +87,10 @@ mod async_helper; #[cfg(feature = "db")] mod connection_key; #[cfg(feature = "db")] +use crate::settings::DatabaseSettings; +#[cfg(feature = "db")] +use connection_key::ConnectionKey; +#[cfg(feature = "db")] mod connection_manager; pub(crate) mod document; mod migration; @@ -1634,23 +1638,71 @@ impl Workspace for WorkspaceServer { &self, params: CheckDatabaseConnectionParams, ) -> Result { - // Every configuration file of the project can have its own database, so check all of them. - let settings = self.workspaces(); - for settings in settings.as_ref().get_all_settings(params.project_key) { - let result = self - .connection - .try_with_pool(&settings.db, |pool| self.snapshot.load(pool).map(|_| ())); + // Every configuration file of the project can have its own database, so check each of + // them once, with the folders that use it. + let mut connections: Vec<(DatabaseSettings, Vec)> = Vec::new(); + for (folder, settings) in self + .workspaces() + .as_ref() + .get_all_settings(params.project_key) + { + if !settings.db.enable_connection { + continue; + } + let existing = connections.iter_mut().find(|(db, _)| { + ConnectionKey::from(db) == ConnectionKey::from(&settings.db) + && db.connection_string == settings.db.connection_string + && db.password == settings.db.password + && db.conn_timeout_secs == settings.db.conn_timeout_secs + }); + match existing { + Some((_, folders)) => folders.push(folder.to_path_buf()), + None => connections.push((settings.db.clone(), vec![folder.to_path_buf()])), + } + } + + // Concurrently, so that unreachable databases time out together rather than one after + // the other. + let results: Vec<_> = std::thread::scope(|scope| { + let checks: Vec<_> = connections + .iter() + .map(|(db, _)| { + scope.spawn(|| { + self.connection + .try_with_pool(db, |pool| self.snapshot.load(pool).map(|_| ())) + }) + }) + .collect(); + checks + .into_iter() + .map(|check| check.join().expect("a connection check panicked")) + .collect() + }); + + let mut errors = Vec::new(); + for ((db, folders), result) in connections.iter().zip(results) { match result { None | Some(Ok(())) => {} Some(Err(err @ WorkspaceError::DatabaseConnectionError(_))) => { - return Ok(CheckDatabaseConnectionResult { - error: Some(err.to_string()), - }); + let folders: Vec<_> = folders + .iter() + .filter(|folder| !folder.as_os_str().is_empty()) + .map(|folder| folder.display().to_string()) + .collect(); + let used_by = if folders.is_empty() { + String::new() + } else { + format!(", the database of {}", folders.join(", ")) + }; + errors.push(format!( + "Could not connect to {}@{}:{}/{}{used_by}: {err}", + db.username, db.host, db.port, db.database + )); } Some(Err(err)) => return Err(err), } } - Ok(CheckDatabaseConnectionResult::default()) + Ok(CheckDatabaseConnectionResult { errors }) } #[cfg(not(feature = "db"))] diff --git a/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs b/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs index 37727cfff..5345534f3 100644 --- a/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs +++ b/crates/pgls_workspace/src/workspace/server/snapshot_manager.rs @@ -1,5 +1,8 @@ use std::sync::{Arc, RwLock}; +#[cfg(feature = "db")] +use std::sync::Mutex; + use pgls_catalog::Snapshot; use crate::WorkspaceError; @@ -12,6 +15,10 @@ use std::collections::HashMap; #[cfg(feature = "db")] use super::{async_helper::run_async, connection_key::ConnectionKey}; +/// The snapshot of a connection, once it loaded. +#[cfg(feature = "db")] +type SnapshotSlot = Arc>>>; + /// Manages database snapshot storage and retrieval. /// /// In db mode: supports loading from database connections and/or JSON. @@ -26,9 +33,10 @@ use super::{async_helper::run_async, connection_key::ConnectionKey}; /// - `load()` - Load schema from database connection /// - `clear_all_connections()` - Clear the schemas of all connections pub struct SnapshotManager { - /// Connection-based snapshots (db mode only) + /// Connection-based snapshots (db mode only). Each connection has its own lock, held while + /// its snapshot loads, so that a slow or unreachable database doesn't block the others. #[cfg(feature = "db")] - db_schemas: RwLock>>, + db_schemas: RwLock>, /// JSON-loaded schema (available in both modes) schema: RwLock>>, @@ -77,29 +85,26 @@ impl SnapshotManager { pub fn load(&self, pool: &PgPool) -> Result, WorkspaceError> { let key: ConnectionKey = pool.into(); - // Try read lock first for cache hit - if let Ok(schemas) = self.db_schemas.read() - && let Some(cache) = schemas.get(&key) - { - return Ok(Arc::clone(cache)); - } - - // Cache miss - need write lock to insert - let mut schemas = self.db_schemas.write().unwrap(); + // The map is only locked to find the connection's slot, never during network I/O. + let existing = self.db_schemas.read().unwrap().get(&key).cloned(); + let slot = match existing { + Some(slot) => slot, + None => Arc::clone(self.db_schemas.write().unwrap().entry(key).or_default()), + }; - // Double-check after acquiring write lock - if let Some(cache) = schemas.get(&key) { + // Callers for the same connection wait for one load instead of starting their own. + let mut snapshot = slot.lock().unwrap(); + if let Some(cache) = snapshot.as_ref() { return Ok(Arc::clone(cache)); } - // Load the snapshot from the database + // Load the snapshot from the database. A failure leaves the slot empty for a retry. let pool_clone = pool.clone(); - let snapshot = Arc::new(run_async( + let loaded = Arc::new(run_async( async move { Snapshot::load(&pool_clone).await }, )??); - - schemas.insert(key, snapshot.clone()); - Ok(snapshot) + *snapshot = Some(Arc::clone(&loaded)); + Ok(loaded) } /// Clear all connection-based snapshots. From bf95d887c9cc16cf56825c7c61cf0ed3be01a5ca Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 06:57:24 +0000 Subject: [PATCH 09/17] test(lsp): expect forward slashes in watcher globs on Windows --- crates/pgls_lsp/tests/server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index 4e988d0d3..be310a061 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -3591,7 +3591,11 @@ async fn the_configuration_files_of_every_workspace_folder_are_watched() -> Resu let glob = |name: &str| { format!( "{}/**/{}", - root.path().join(name).display(), + root.path() + .join(name) + .display() + .to_string() + .replace('\\', "/"), ConfigName::pgls_jsonc() ) }; From d82b4d33ca9c16339e211aa01f1707e738a780d6 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 11:35:24 +0000 Subject: [PATCH 10/17] refactor(lsp): use a parking_lot lock for the root files settings --- Cargo.lock | 1 + crates/pgls_lsp/Cargo.toml | 1 + crates/pgls_lsp/src/session.rs | 10 ++++------ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec77ef466..f2547f64e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2666,6 +2666,7 @@ version = "0.0.0" dependencies = [ "anyhow", "futures", + "parking_lot", "pgls_analyse", "pgls_completions", "pgls_configuration", diff --git a/crates/pgls_lsp/Cargo.toml b/crates/pgls_lsp/Cargo.toml index 776a10291..71e0f599e 100644 --- a/crates/pgls_lsp/Cargo.toml +++ b/crates/pgls_lsp/Cargo.toml @@ -14,6 +14,7 @@ version = "0.0.0" [dependencies] anyhow = { workspace = true } futures = "0.3.31" +parking_lot = "0.12.3" pgls_analyse = { workspace = true } pgls_completions = { workspace = true } pgls_configuration = { workspace = true } diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 6b48240c5..26d286624 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -118,7 +118,7 @@ pub(crate) struct Session { /// The files settings of the root configuration of each project, by its folder. They decide /// which folders are searched for nested configuration files. - root_files: RwLock>>>, + root_files: parking_lot::RwLock>>>, /// Whether a reload for changed configuration files is scheduled and hasn't started yet. reload_scheduled: AtomicBool, @@ -250,7 +250,7 @@ impl Session { client_overrides: RwLock::new(ClientOverrides::default()), workspace_folders: RwLock::default(), projects: RwLock::default(), - root_files: RwLock::default(), + root_files: parking_lot::RwLock::default(), reload_scheduled: AtomicBool::new(false), configuration_transition: tokio::sync::Mutex::new(()), #[cfg(test)] @@ -361,8 +361,7 @@ impl Session { else { return true; }; - let root_files = self.root_files.read().unwrap(); - let root_files = root_files.get(folder).cloned().flatten(); + let root_files = self.root_files.read().get(folder).cloned().flatten(); is_searched_for_configuration(&self.fs, folder, root_files.as_deref(), directory) } @@ -708,7 +707,7 @@ impl Session { let previous = std::mem::take(&mut *self.projects.write().unwrap()); for (folder, _) in previous { if !projects.iter().any(|(project, _)| project == &folder) { - self.root_files.write().unwrap().remove(&folder); + self.root_files.write().remove(&folder); let result = self.workspace .unregister_project_folder(UnregisterProjectFolderParams { @@ -781,7 +780,6 @@ impl Session { let root_files = root_files.map(Arc::new); self.root_files .write() - .unwrap() .insert(folder.clone(), root_files.clone()); let project = Some((folder, project_key)); From 7d9c16d6af0014d6c82756ad85dbaf615853ec74 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 26 Oct 2026 14:27:12 +0000 Subject: [PATCH 11/17] refactor(workspace): configure projects in the workspace crate The LSP and the CLI each loaded the root configuration, laid the environment and their overrides over it, read the VCS ignore file and applied the result to the workspace, and each had its own layering of the environment and the overrides. That pipeline now lives in pgls_workspace::configuration: configure_project registers a project and applies its configuration file, and ConfigurationLayers lays the environment and the overrides over the root and the nested configuration files. The LSP and the CLI only render the notices it returns. No behaviour change: the CLI keeps merging its arguments and reading the VCS ignore file from the working directory, the LSP keeps replacing with its client overrides and reading it from the configuration folder. --- crates/pgls_cli/src/commands/check.rs | 8 +- crates/pgls_cli/src/commands/dblint.rs | 3 +- crates/pgls_cli/src/commands/format.rs | 8 +- crates/pgls_cli/src/lib.rs | 133 +++---- crates/pgls_cli/src/workspace.rs | 57 --- crates/pgls_lsp/src/configuration.rs | 222 +----------- crates/pgls_lsp/src/server.rs | 16 +- crates/pgls_lsp/src/session.rs | 335 +++++++----------- crates/pgls_workspace/src/configuration.rs | 37 +- .../src/configuration/layers.rs | 325 +++++++++++++++++ .../src/configuration/project.rs | 211 +++++++++++ .../src/workspace/server.tests.rs | 13 +- 12 files changed, 764 insertions(+), 604 deletions(-) delete mode 100644 crates/pgls_cli/src/workspace.rs create mode 100644 crates/pgls_workspace/src/configuration/layers.rs create mode 100644 crates/pgls_workspace/src/configuration/project.rs diff --git a/crates/pgls_cli/src/commands/check.rs b/crates/pgls_cli/src/commands/check.rs index dc3aea514..6f2c2a1a2 100644 --- a/crates/pgls_cli/src/commands/check.rs +++ b/crates/pgls_cli/src/commands/check.rs @@ -36,13 +36,15 @@ pub fn check( ) -> Result<(), CliDiagnostic> { validate_args(&args)?; - let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; - session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; + let configuration = session.configure_workspace( + cli_options, + args.configuration.clone(), + VcsIntegration::Enabled, + )?; let paths = resolve_paths(session.fs(), &configuration, &args)?; let configuration_errors = session.apply_nested_configurations( cli_options, - args.configuration.clone(), &nested_configuration_paths(&args.stdin_file_path, &paths), )?; diff --git a/crates/pgls_cli/src/commands/dblint.rs b/crates/pgls_cli/src/commands/dblint.rs index 760e909e9..91a59f77d 100644 --- a/crates/pgls_cli/src/commands/dblint.rs +++ b/crates/pgls_cli/src/commands/dblint.rs @@ -14,8 +14,7 @@ pub fn dblint( cli_options: &CliOptions, cli_configuration: Option, ) -> Result<(), CliDiagnostic> { - let configuration = session.prepare_with_config(cli_options, cli_configuration)?; - session.setup_workspace(configuration, VcsIntegration::Disabled)?; + session.configure_workspace(cli_options, cli_configuration, VcsIntegration::Disabled)?; let workspace = session.workspace(); let max_diagnostics = if cli_options.reporter.is_default() { diff --git a/crates/pgls_cli/src/commands/format.rs b/crates/pgls_cli/src/commands/format.rs index ac731e75e..3799a471d 100644 --- a/crates/pgls_cli/src/commands/format.rs +++ b/crates/pgls_cli/src/commands/format.rs @@ -30,8 +30,11 @@ pub fn format( ) -> Result<(), CliDiagnostic> { validate_args(&args)?; - let configuration = session.prepare_with_config(cli_options, args.configuration.clone())?; - session.setup_workspace(configuration.clone(), VcsIntegration::Enabled)?; + let configuration = session.configure_workspace( + cli_options, + args.configuration.clone(), + VcsIntegration::Enabled, + )?; let vcs = VcsTargeting { staged: args.staged, @@ -50,7 +53,6 @@ pub fn format( }; let configuration_errors = session.apply_nested_configurations( cli_options, - args.configuration.clone(), &nested_configuration_paths(&args.stdin_file_path, &paths), )?; diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index 6675e11f4..f33ff26a8 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -7,7 +7,6 @@ use cli_options::CliOptions; use commands::check::{self, CheckArgs}; use commands::format::{self, FormatArgs}; -use pgls_configuration::Merge; use pgls_configuration::PartialConfiguration; use pgls_configuration::database::PartialDatabaseConfiguration; use pgls_console::{ColorMode, Console, ConsoleExt, markup}; @@ -18,7 +17,8 @@ use std::path::PathBuf; use std::sync::Arc; use pgls_workspace::configuration::{ - NestedConfiguration, NestedConfigurationSearch, apply_nested_configurations, + self, ConfigurationLayers, ConfigurationNotice, ConfigureProjectParams, NestedConfiguration, + NestedConfigurationSearch, apply_nested_configurations, configure_project, }; use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::ProjectKey; @@ -34,7 +34,6 @@ mod metrics; mod panic; mod reporter; mod service; -mod workspace; /// The stack size of the threads that run the workspace: the tokio workers and blocking threads /// of the language server, and the rayon workers of `check`. @@ -70,11 +69,13 @@ pub(crate) use pgls_env::VERSION; pub struct CliSession<'app> { /// Instance of [App] used by this run of the CLI pub app: App<'app>, - /// The project of the working directory, once [CliSession::setup_workspace] registered it + /// The project of the working directory, once [CliSession::configure_workspace] registered it project_key: ProjectKey, /// The files settings of the root configuration, which decide the folders to search for /// nested configuration files root_files: Option, + /// The layers laid over each configuration file: the environment and the arguments + layers: ConfigurationLayers, } impl<'app> CliSession<'app> { @@ -90,6 +91,7 @@ impl<'app> CliSession<'app> { ), project_key: ProjectKey::default(), root_files: None, + layers: ConfigurationLayers::default(), }) } @@ -233,46 +235,65 @@ impl<'app> CliSession<'app> { &*self.app.workspace } - pub fn prepare_with_config( + /// Registers the project of the working directory and applies the configuration file, with + /// the database configuration of the environment and then the arguments laid over it, to it + /// and the VCS integration. Returns the configuration as applied. + pub fn configure_workspace( &mut self, cli_options: &CliOptions, cli_configuration: Option, + vcs: VcsIntegration, ) -> Result { setup_cli_subscriber(cli_options.log_level, cli_options.log_kind); - let fs = self.fs(); - let loaded_configuration = - workspace::load_config(fs, cli_options.as_configuration_path_hint())?; + self.layers = ConfigurationLayers { + environment: PartialDatabaseConfiguration::from_env(), + overrides: cli_configuration, + ..Default::default() + }; + let project = configure_project( + self.workspace(), + self.fs(), + ConfigureProjectParams { + hint: cli_options.as_configuration_path_hint(), + layers: &self.layers, + vcs: match vcs { + VcsIntegration::Enabled => configuration::VcsIntegration::FromWorkingDirectory, + VcsIntegration::Disabled => configuration::VcsIntegration::Disabled, + }, + }, + )?; + for notice in &project.notices { + self.print_configuration_notice(notice); + } + let applied = project.result?; + self.project_key = project.project_key; + self.root_files = Some(applied.files); + Ok(applied.configuration) + } - if let Some(config_path) = &loaded_configuration.file_path - && let Some(file_name) = config_path.file_name().and_then(|name| name.to_str()) - && ConfigName::is_deprecated(file_name) - { - self.console().log(markup! { + fn print_configuration_notice(&mut self, notice: &ConfigurationNotice) { + match notice { + ConfigurationNotice::DeprecatedFileName(_) => self.console().log(markup! { "Warning: " "Deprecated config filename detected. Use 'postgres-language-server.jsonc'.\n" - }); - } - - if loaded_configuration.is_user_configuration - && let Some(file_path) = &loaded_configuration.file_path - { + }), // On stderr, so that it doesn't end up in the output of a reporter or of `format`. - self.console().error(markup! { - "Using the user configuration at "{file_path.display().to_string()}"\n" - }); - } - - for deprecation in loaded_configuration.configuration.deprecations() { - self.console().log(markup! { - "Warning: "{deprecation}"\n" - }); + ConfigurationNotice::UserConfiguration { file_path, .. } => { + self.console().error(markup! { + "Using the user configuration at "{file_path.display().to_string()}"\n" + }) + } + ConfigurationNotice::DeprecatedSettings(deprecations) => { + for deprecation in deprecations { + self.console().log(markup! { + "Warning: "{deprecation}"\n" + }); + } + } + // `check` reports it when it checks the database connection. + ConfigurationNotice::InvalidConnectionString(_) => {} } - - Ok(apply_overrides( - loaded_configuration.configuration, - cli_configuration, - )) } /// The project of the working directory, which every file of this run belongs to @@ -290,7 +311,6 @@ impl<'app> CliSession<'app> { pub fn apply_nested_configurations( &mut self, cli_options: &CliOptions, - cli_configuration: Option, paths: &[OsString], ) -> Result, CliDiagnostic> { if cli_options.config_path.is_some() { @@ -310,8 +330,7 @@ impl<'app> CliSession<'app> { root_files, ..NestedConfigurationSearch::new(root) }, - PartialDatabaseConfiguration::from_env().as_ref(), - |configuration| apply_arguments(configuration, cli_configuration.clone()), + &self.layers, )?; if let Some(incomplete) = nested.incomplete { self.console().log(markup! { @@ -338,18 +357,6 @@ impl<'app> CliSession<'app> { Ok(errors) } - pub fn setup_workspace( - &mut self, - configuration: PartialConfiguration, - vcs: VcsIntegration, - ) -> Result<(), CliDiagnostic> { - let (project_key, root_files) = - workspace::setup_workspace(self.workspace(), self.fs(), configuration, vcs)?; - self.project_key = project_key; - self.root_files = Some(root_files); - Ok(()) - } - pub fn report( &mut self, command_name: &str, @@ -361,36 +368,6 @@ impl<'app> CliSession<'app> { } } -/// Lays the database configuration from the environment and then the arguments over a -/// configuration file. -fn apply_overrides( - mut configuration: PartialConfiguration, - cli_configuration: Option, -) -> PartialConfiguration { - // Env vars override config file but are overridden by explicit CLI args. - if let Some(env_db) = PartialDatabaseConfiguration::from_env() { - let env_config = PartialConfiguration { - db: Some(env_db), - ..Default::default() - }; - configuration.merge_with(env_config); - } - - apply_arguments(configuration, cli_configuration) -} - -/// Lays the configuration from the arguments over a configuration file. -fn apply_arguments( - mut configuration: PartialConfiguration, - cli_configuration: Option, -) -> PartialConfiguration { - if let Some(cli_config) = cli_configuration { - configuration.merge_with(cli_config); - } - - configuration -} - /// Controls whether workspace setup should include VCS integration details. pub enum VcsIntegration { Enabled, diff --git a/crates/pgls_cli/src/workspace.rs b/crates/pgls_cli/src/workspace.rs deleted file mode 100644 index 5b5e3e752..000000000 --- a/crates/pgls_cli/src/workspace.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{CliDiagnostic, VcsIntegration}; -use pgls_configuration::{ConfigurationPathHint, PartialConfiguration}; -use pgls_fs::FileSystem; -use pgls_workspace::PartialConfigurationExt; -use pgls_workspace::configuration::{LoadedConfiguration, load_configuration}; -use pgls_workspace::settings::FilesSettings; -use pgls_workspace::workspace::{ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams}; -use pgls_workspace::{DynRef, Workspace}; - -/// Load configuration from disk and emit warnings for deprecated filenames. -pub fn load_config( - fs: &DynRef<'_, dyn FileSystem>, - config_hint: ConfigurationPathHint, -) -> Result { - load_configuration(fs, config_hint).map_err(CliDiagnostic::from) -} - -/// Registers the project of the working directory and configures it and the VCS integration -/// according to the provided configuration. Returns the key of the project and the files settings -/// of the configuration. -pub fn setup_workspace( - workspace: &dyn Workspace, - fs: &DynRef<'_, dyn FileSystem>, - configuration: PartialConfiguration, - vcs: VcsIntegration, -) -> Result<(ProjectKey, FilesSettings), CliDiagnostic> { - let (vcs_base_path, gitignore_matches) = match vcs { - VcsIntegration::Enabled => configuration - .retrieve_gitignore_matches(fs, fs.working_directory().as_deref()) - .map_err(CliDiagnostic::from)?, - VcsIntegration::Disabled => (None, vec![]), - }; - - let project_key = workspace - .register_project_folder(RegisterProjectFolderParams { - path: fs.working_directory(), - }) - .map_err(CliDiagnostic::from)?; - - let files = FilesSettings::from_configuration( - &configuration, - fs.working_directory(), - vcs_base_path.clone(), - &gitignore_matches, - )?; - workspace - .update_settings(UpdateSettingsParams { - project_key, - workspace_directory: fs.working_directory(), - configuration, - vcs_base_path, - gitignore_matches, - }) - .map_err(CliDiagnostic::from)?; - - Ok((project_key, files)) -} diff --git a/crates/pgls_lsp/src/configuration.rs b/crates/pgls_lsp/src/configuration.rs index 8bdb6cab3..e4ee46bf1 100644 --- a/crates/pgls_lsp/src/configuration.rs +++ b/crates/pgls_lsp/src/configuration.rs @@ -1,132 +1,5 @@ -use pgls_configuration::{Merge, PartialConfiguration, StringSet}; - -/// Merges the configuration layers of a session, lowest precedence first: -/// -/// 1. the built-in defaults, -/// 2. the configuration file on disk, -/// 3. the environment configuration (`DATABASE_URL`, `PGHOST`, ...) captured once at server start, -/// 4. the sticky client override layer. -/// -/// The client layer deliberately outranks the environment. The comment this replaces read "Env -/// vars take highest priority — merge last so they override everything", and that stays true of -/// the configuration file: the environment still overrides it. But a client override is an -/// explicit, live instruction from the editor for this session, whereas the environment only -/// describes the shell the server happened to be started from, so the client wins over both. -/// -/// Only the client layer gets the override semantics of [`apply_override`]; the environment keeps -/// the plain [`Merge`] behaviour it always had. -pub(crate) fn merge_layers( - file: PartialConfiguration, - env: Option<&PartialConfiguration>, - client: Option<&PartialConfiguration>, -) -> PartialConfiguration { - let mut configuration = file; - if let Some(env) = env { - configuration.merge_with(env.clone()); - } - if let Some(client) = client { - apply_override(&mut configuration, client); - } - configuration -} - -/// Merges one layer that is meant to *override* the layers below it. -/// -/// Plain [`Merge`] is not enough for that, in two ways: -/// -/// * `Merge for Option` ignores `None`, so an override can only set values, never unset them. -/// The one case where that matters in practice is the connection string handled below. -/// * `Merge for StringSet` *unions* the two sets, so entries of a lower layer survive — and for an -/// ordered list such as `typecheck.searchPath` they even come first. Every set the override -/// specifies therefore replaces the one below it, by clearing the base set before merging. -fn apply_override(base: &mut PartialConfiguration, override_layer: &PartialConfiguration) { - // Clears the base sets that `override_layer` specifies, so the merge below replaces them - // instead of unioning them. Every `StringSet` of `PartialConfiguration` is listed, so that a - // newly added one cannot silently keep union semantics. - macro_rules! replace_string_sets { - ($section:ident: $($field:ident),+) => { - if let Some(section) = override_layer.$section.as_ref() { - $( - if section.$field.is_some() { - base.$section.get_or_insert_default().$field = Some(StringSet::default()); - } - )+ - } - }; - } - - if override_layer.extends.is_some() { - base.extends = Some(StringSet::default()); - } - replace_string_sets!(files: include, ignore); - replace_string_sets!(linter: include, ignore); - replace_string_sets!(format: include, ignore); - replace_string_sets!(splinter: ignore); - replace_string_sets!(typecheck: search_path); - replace_string_sets!(db: allow_statement_executions_against); - - // `db.connectionString` takes precedence over the individual connection fields and even - // derives host, port, username and database from them (see `DatabaseSettings::from`), so a - // connection string inherited from the configuration file or the environment would silently - // win over an override that names a concrete target. An override that names one therefore - // unsets the inherited connection string — unless it brings its own, which simply wins. - if let Some(db) = override_layer.db.as_ref() - && db.connection_string.is_none() - && (db.host.is_some() - || db.port.is_some() - || db.username.is_some() - || db.password.is_some() - || db.database.is_some()) - && let Some(base_db) = base.db.as_mut() - { - base_db.connection_string = None; - } - - base.merge_with(override_layer.clone()); -} - -macro_rules! define_section_mask { - ($($section:ident),+ $(,)?) => { - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub(crate) struct SectionMask { $( $section: bool, )+ } - impl SectionMask { - fn of(configuration: &PartialConfiguration) -> Self { - Self { $( $section: configuration.$section.is_some(), )+ } - } - fn union(self, other: Self) -> Self { - Self { $( $section: self.$section || other.$section, )+ } - } - /// Materialises every section in this mask that `configuration` omits, using that - /// section's defaults. - /// - /// `Settings::merge_with_configuration` leaves an existing section untouched when the - /// incoming configuration omits it, and re-registering a project preserves its - /// settings. Without this step, clearing an override would not restore the baseline: - /// with an empty configuration file and no environment configuration, `db` would keep - /// pointing at the overridden database. An all-`None` `Partial*` section maps to that - /// section's defaults in `pgls_workspace`, so materialising it resets the section. - pub(crate) fn materialize_defaults(self, configuration: &mut PartialConfiguration) { - $( if self.$section && configuration.$section.is_none() { - configuration.$section = Some(Default::default()); - } )+ - } - } - }; -} -define_section_mask!( - vcs, - files, - migrations, - linter, - splinter, - format, - pglinter, - typecheck, - plpgsql_check, - db, - inlay_hints, - semantic_tokens -); +use pgls_configuration::PartialConfiguration; +use pgls_workspace::configuration::SectionMask; /// The sticky layer of configuration a client supplies at runtime. #[derive(Debug, Default)] @@ -156,73 +29,8 @@ impl ClientOverrides { #[cfg(test)] mod tests { use super::*; - use pgls_configuration::{ - PartialTypecheckConfiguration, database::PartialDatabaseConfiguration, - }; - use serde_json::json; - - fn parse(value: serde_json::Value) -> PartialConfiguration { - serde_json::from_value(value).unwrap() - } - - #[test] - fn client_layer_wins_over_environment_and_file() { - let merged = merge_layers( - parse(json!({"db":{"host":"file"}})), - Some(&parse(json!({"db":{"host":"env"}}))), - Some(&parse(json!({"db":{"host":"client"}}))), - ); - assert_eq!(merged.db.unwrap().host.as_deref(), Some("client")); - } - - #[test] - fn override_search_path_replaces_and_preserves_order() { - let merged = merge_layers( - parse(json!({"typecheck":{"searchPath":["file", "common"]}})), - None, - Some(&parse( - json!({"typecheck":{"searchPath":["client_b", "client_a"]}}), - )), - ); - let actual: Vec<_> = merged - .typecheck - .unwrap() - .search_path - .unwrap() - .into_iter() - .collect(); - assert_eq!(actual, ["client_b", "client_a"]); - } - - #[test] - fn individual_database_fields_clear_inherited_connection_string() { - let inherited = parse(json!({"db":{"connectionString":"postgres://inherited"}})); - let merged = merge_layers( - inherited, - None, - Some(&parse(json!({"db":{"host":"localhost", "database":"db"}}))), - ); - assert_eq!(merged.db.unwrap().connection_string, None); - let merged = merge_layers( - parse(json!({"db":{"connectionString":"postgres://inherited"}})), - None, - Some(&parse(json!({"db":{"connectionString":"postgres://own"}}))), - ); - assert_eq!( - merged.db.unwrap().connection_string.as_deref(), - Some("postgres://own") - ); - } - - #[test] - fn empty_database_password_is_preserved() { - let merged = merge_layers( - PartialConfiguration::default(), - None, - Some(&parse(json!({"db":{"password":""}}))), - ); - assert_eq!(merged.db.unwrap().password.as_deref(), Some("")); - } + use pgls_configuration::database::PartialDatabaseConfiguration; + use pgls_workspace::configuration::{ConfigurationLayers, OverrideMode}; #[test] fn clearing_override_materializes_sticky_sections() { @@ -232,21 +40,13 @@ mod tests { ..Default::default() })); overrides.set(None); - let mut applied = merge_layers( - PartialConfiguration::default(), - None, - overrides.configuration(), - ); - overrides.sections().materialize_defaults(&mut applied); + let layers = ConfigurationLayers { + environment: None, + overrides: overrides.configuration().cloned(), + override_mode: OverrideMode::Replace, + default_sections: overrides.sections(), + }; + let applied = layers.apply(PartialConfiguration::default()); assert_eq!(applied.db, Some(PartialDatabaseConfiguration::default())); } - - #[test] - fn mask_tracks_sections() { - let mask = SectionMask::of(&PartialConfiguration { - typecheck: Some(PartialTypecheckConfiguration::default()), - ..Default::default() - }); - assert!(mask.typecheck); - } } diff --git a/crates/pgls_lsp/src/server.rs b/crates/pgls_lsp/src/server.rs index 0ed616362..6fd922894 100644 --- a/crates/pgls_lsp/src/server.rs +++ b/crates/pgls_lsp/src/server.rs @@ -366,19 +366,13 @@ pub struct ServerFactory { /// This shared flag is set to true once at least one sessions has been /// initialized on this server instance is_initialized: Arc, - /// Configuration from environment variables (DATABASE_URL, PGHOST, etc.). + /// The database settings of the environment variables (DATABASE_URL, PGHOST, etc.). /// Computed once at factory creation and passed to each session. - env_config: Option, + environment: Option, } impl ServerFactory { pub fn new(stop_on_disconnect: bool) -> Self { - let env_config = PartialDatabaseConfiguration::from_env().map(|db| { - pgls_configuration::PartialConfiguration { - db: Some(db), - ..Default::default() - } - }); Self { cancellation: Arc::default(), workspace: None, @@ -386,7 +380,7 @@ impl ServerFactory { next_session_key: AtomicU64::new(0), stop_on_disconnect, is_initialized: Arc::default(), - env_config, + environment: PartialDatabaseConfiguration::from_env(), } } @@ -407,7 +401,7 @@ impl ServerFactory { let session_key = SessionKey(self.next_session_key.fetch_add(1, Ordering::Relaxed)); - let env_config = self.env_config.clone(); + let environment = self.environment.clone(); let mut builder = LspService::build(move |client| { let mut session = Session::new( session_key, @@ -415,7 +409,7 @@ impl ServerFactory { workspace, self.cancellation.clone(), fs, - env_config, + environment, ); if let Some(path) = config_path { session.set_config_path(path); diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index e79d0bcb4..9c51c0b2b 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -1,5 +1,5 @@ use crate::adapters::{PositionEncoding, WideEncoding, negotiated_encoding}; -use crate::configuration::{ClientOverrides, merge_layers}; +use crate::configuration::ClientOverrides; use crate::diagnostics::LspError; use crate::documents::Document; use crate::utils; @@ -8,19 +8,20 @@ use futures::StreamExt; use futures::stream::FuturesUnordered; use parking_lot::{Mutex, RwLock}; use pgls_analyse::RuleCategoriesBuilder; +use pgls_configuration::database::PartialDatabaseConfiguration; use pgls_configuration::{ConfigurationPathHint, PartialConfiguration}; use pgls_diagnostics::{DiagnosticExt, Error}; use pgls_fs::{ConfigName, FileSystem, PgLSPath}; -use pgls_workspace::PartialConfigurationExt; use pgls_workspace::Workspace; use pgls_workspace::configuration::{ - LoadedConfiguration, NestedConfiguration, NestedConfigurationSearch, - apply_nested_configurations, is_searched_for_configuration, load_configuration, + ConfigurationLayers, ConfigurationNotice, ConfigureProjectParams, ConfiguredProject, + NestedConfiguration, NestedConfigurationSearch, OverrideMode, VcsIntegration, + apply_nested_configurations, configure_project, is_searched_for_configuration, }; use pgls_workspace::features; use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::{ - ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, UpdateSettingsParams, + ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, }; use pgls_workspace::{DynRef, WorkspaceError}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -103,8 +104,8 @@ pub(crate) struct Session { pub(crate) config_path: Option, - /// Extra configuration from environment variables, applied on every config load. - env_config: Option, + /// The database settings of the environment variables, applied on every config load. + environment: Option, /// Layer set through workspace/didChangeConfiguration and the custom override request. client_overrides: RwLock, @@ -122,7 +123,7 @@ pub(crate) struct Session { /// The files settings of the root configuration of each project, by its folder. They decide /// which folders are searched for nested configuration files. - root_files: parking_lot::RwLock>>>, + root_files: parking_lot::RwLock>>, /// The files the configuration was loaded from besides the nested configuration files: the /// root configuration files, which can be above a workspace folder or the user @@ -245,7 +246,7 @@ impl Session { workspace: Arc, cancellation: Arc, fs: DynRef<'static, dyn FileSystem>, - env_config: Option, + environment: Option, ) -> Self { let documents = Default::default(); Self { @@ -261,7 +262,7 @@ impl Session { notified_broken_configuration: AtomicBool::new(false), notified_deprecated_config: AtomicBool::new(false), notified_deprecated_settings: AtomicBool::new(false), - env_config, + environment, client_overrides: RwLock::new(ClientOverrides::default()), workspace_folders: RwLock::default(), projects: RwLock::default(), @@ -439,7 +440,7 @@ impl Session { else { return true; }; - let root_files = self.root_files.read().get(folder).cloned().flatten(); + let root_files = self.root_files.read().get(folder).cloned(); is_searched_for_configuration(&self.fs, folder, root_files.as_deref(), directory) } @@ -882,41 +883,54 @@ impl Session { } ConfigurationPathHint::FromUser(_) | ConfigurationPathHint::None => None, }; - let folder = root.clone().or_else(|| self.fs.working_directory()); - - let project_key = - match self - .workspace - .register_project_folder(RegisterProjectFolderParams { - path: folder.clone(), - }) { - Ok(project_key) => project_key, - Err(error) => { - error!("Failed to register the project folder: {}", error); - self.client.log_message(MessageType::ERROR, &error).await; - return (None, ConfigurationStatus::Error); - } - }; - let folder = folder.unwrap_or_default(); - let (status, root_files) = self - .load_pgls_configuration_file(project_key, base_path) - .await; - if !matches!(status, ConfigurationStatus::Loaded) { - // A project keeps its settings while its configuration is broken, and a new one has - // the defaults. Either way its files never fall back to the settings of another - // folder, which could point at another database. - return (Some((folder, project_key)), status); + let layers = self.layers(); + let project = match configure_project( + &*self.workspace, + &self.fs, + ConfigureProjectParams { + hint: base_path, + layers: &layers, + vcs: VcsIntegration::Enabled, + }, + ) { + Ok(project) => project, + Err(error) => { + error!("Failed to register the project folder: {}", error); + self.client.log_message(MessageType::ERROR, &error).await; + return (None, ConfigurationStatus::Error); + } + }; + let ConfiguredProject { + project_key, + folder, + configuration_files, + notices, + result, + } = project; + self.configuration_files.write().extend(configuration_files); + for notice in notices { + self.show_configuration_notice(notice).await; } - let root_files = root_files.map(Arc::new); + let root_files = match result { + Ok(applied) => Arc::new(applied.files), + Err(error) => { + error!("Couldn't load the configuration file, reason:\n {}", error); + self.client.log_message(MessageType::ERROR, &error).await; + // A project keeps its settings while its configuration is broken, and a new one + // has the defaults. Either way its files never fall back to the settings of + // another folder, which could point at another database. + return (Some((folder, project_key)), ConfigurationStatus::Error); + } + }; self.root_files .write() - .insert(folder.clone(), root_files.clone()); + .insert(folder.clone(), Arc::clone(&root_files)); let project = Some((folder, project_key)); let status = match root { Some(root) => { - self.load_nested_configuration_files(project_key, root, root_files) + self.load_nested_configuration_files(project_key, root, root_files, layers) .await } None => ConfigurationStatus::Loaded, @@ -924,27 +938,76 @@ impl Session { (project, status) } - /// Lays the layers of the session over the root configuration file: the environment and the - /// client overrides. - fn configure(&self) -> impl Fn(PartialConfiguration) -> PartialConfiguration + '_ { - self.configure_with(self.env_config.as_ref()) + /// The layers of the session that are laid over each configuration file: the environment and + /// the client overrides. + fn layers(&self) -> ConfigurationLayers { + let overrides = self.client_overrides.read(); + ConfigurationLayers { + environment: self.environment.clone(), + overrides: overrides.configuration().cloned(), + override_mode: OverrideMode::Replace, + default_sections: overrides.sections(), + } } - /// Lays the layers of the session over a configuration file: `environment` and the client - /// overrides. - fn configure_with<'a>( - &'a self, - environment: Option<&'a PartialConfiguration>, - ) -> impl Fn(PartialConfiguration) -> PartialConfiguration + 'a { - let (client_overrides, overridden_sections) = { - let overrides = self.client_overrides.read(); - (overrides.configuration().cloned(), overrides.sections()) - }; - move |configuration| { - let mut configuration = - merge_layers(configuration, environment, client_overrides.as_ref()); - overridden_sections.materialize_defaults(&mut configuration); - configuration + /// Tells the user about a configuration, each deprecation only once per session. + async fn show_configuration_notice(&self, notice: ConfigurationNotice) { + match notice { + ConfigurationNotice::UserConfiguration { + file_path, + directory, + } => { + self.client + .log_message( + MessageType::INFO, + format!( + "Using the user configuration at {} for {}, which has no \ + configuration file.", + file_path.display(), + directory.display() + ), + ) + .await; + } + ConfigurationNotice::DeprecatedFileName(_) => { + if !self + .notified_deprecated_config + .swap(true, Ordering::Relaxed) + { + self.client + .show_message( + MessageType::WARNING, + "You are using the deprecated config filename 'postgrestools.jsonc'. \ + Please rename it to 'postgres-language-server.jsonc'. \ + Support for the old filename will be removed in a future version.", + ) + .await; + } + } + ConfigurationNotice::DeprecatedSettings(deprecations) => { + if !self + .notified_deprecated_settings + .swap(true, Ordering::Relaxed) + { + self.client + .show_message( + MessageType::WARNING, + format!( + "Your configuration uses deprecated settings:\n{}", + deprecations.join("\n") + ), + ) + .await; + } + } + ConfigurationNotice::InvalidConnectionString(error) => { + self.client + .show_message( + MessageType::WARNING, + format!("{error} Database features are disabled."), + ) + .await; + } } } @@ -955,7 +1018,8 @@ impl Session { self: &Arc, project_key: ProjectKey, root: PathBuf, - root_files: Option>, + root_files: Arc, + layers: ConfigurationLayers, ) -> ConfigurationStatus { // Walking a large folder takes a while, so keep it off the async runtime. let session = Arc::clone(self); @@ -965,14 +1029,10 @@ impl Session { &session.fs, project_key, NestedConfigurationSearch { - root_files, + root_files: Some(root_files), ..NestedConfigurationSearch::new(root) }, - session - .env_config - .as_ref() - .and_then(|environment| environment.db.as_ref()), - session.configure_with(None), + &layers, ) }) .await; @@ -1019,153 +1079,6 @@ impl Session { status } - /// Loads the configuration file at or above the root of a project into its root settings. - /// - /// Also returns the files settings of the configuration, which decide the folders to search - /// for nested configuration files. - async fn load_pgls_configuration_file( - &self, - project_key: ProjectKey, - base_path: ConfigurationPathHint, - ) -> (ConfigurationStatus, Option) { - match load_configuration(&self.fs, base_path.clone()) { - Ok(loaded_configuration) => { - let LoadedConfiguration { - configuration: fs_configuration, - directory_path: configuration_path, - file_path: configuration_file_path, - extended_files, - is_user_configuration, - .. - } = loaded_configuration; - - info!( - "Configuration loaded successfully from disk at {}", - configuration_file_path - .as_ref() - .map_or("".to_string(), |p| p.display().to_string()) - ); - if is_user_configuration - && let (Some(file_path), Some(directory)) = - (&configuration_file_path, &configuration_path) - { - self.client - .log_message( - MessageType::INFO, - format!( - "Using the user configuration at {} for {}, which has no \ - configuration file.", - file_path.display(), - directory.display() - ), - ) - .await; - } - self.configuration_files.write().extend( - configuration_file_path - .iter() - .cloned() - .chain(extended_files), - ); - - // Check if using deprecated config filename and notify user (once) - if !self.notified_deprecated_config.load(Ordering::Relaxed) - && let Some(config_path) = &configuration_file_path - && let Some(file_name) = config_path.file_name().and_then(|n| n.to_str()) - && ConfigName::is_deprecated(file_name) - { - self.client - .show_message( - MessageType::WARNING, - "You are using the deprecated config filename 'postgrestools.jsonc'. \ - Please rename it to 'postgres-language-server.jsonc'. \ - Support for the old filename will be removed in a future version." - ) - .await; - self.notified_deprecated_config - .store(true, Ordering::Relaxed); - } - - let deprecations = fs_configuration.deprecations(); - if !deprecations.is_empty() - && !self - .notified_deprecated_settings - .swap(true, Ordering::Relaxed) - { - self.client - .show_message( - MessageType::WARNING, - format!( - "Your configuration uses deprecated settings:\n{}", - deprecations.join("\n") - ), - ) - .await; - } - - info!("Update workspace settings."); - - let configuration = self.configure()(fs_configuration); - let result = configuration - .retrieve_gitignore_matches(&self.fs, configuration_path.as_deref()); - - match result { - Ok((vcs_base_path, gitignore_matches)) => { - let workspace_directory = self.fs.working_directory(); - let root_files = FilesSettings::from_configuration( - &configuration, - workspace_directory.clone(), - vcs_base_path.clone(), - &gitignore_matches, - ) - .ok(); - - // An invalid connection string only disables the database features, - // so the user would not notice it otherwise. - if let Some(error) = configuration - .db - .as_ref() - .and_then(pgls_workspace::settings::connection_string_error) - { - self.client - .show_message( - MessageType::WARNING, - format!("{error} Database features are disabled."), - ) - .await; - } - - let result = self.workspace.update_settings(UpdateSettingsParams { - project_key, - workspace_directory, - configuration, - vcs_base_path, - gitignore_matches, - }); - - if let Err(error) = result { - error!("Failed to set workspace settings: {}", error); - self.client.log_message(MessageType::ERROR, &error).await; - (ConfigurationStatus::Error, None) - } else { - (ConfigurationStatus::Loaded, root_files) - } - } - Err(err) => { - error!("Couldn't load the configuration file, reason:\n {}", err); - self.client.log_message(MessageType::ERROR, &err).await; - (ConfigurationStatus::Error, None) - } - } - } - Err(err) => { - error!("Couldn't load the configuration file, reason:\n {}", err); - self.client.log_message(MessageType::ERROR, &err).await; - (ConfigurationStatus::Error, None) - } - } - } - /// Replaces the sticky client configuration override layer and applies it. `None` clears it. /// /// Storing the layer, reloading the workspace settings and refreshing the diagnostics of all diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 940c0989b..0184a1495 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -1,3 +1,12 @@ +mod layers; +mod project; + +pub use layers::{ConfigurationLayers, OverrideMode, SectionMask}; +pub use project::{ + AppliedConfiguration, ConfigurationNotice, ConfigureProjectParams, ConfiguredProject, + VcsIntegration, configure_project, +}; + use std::{ ffi::OsStr, io::ErrorKind, @@ -208,11 +217,10 @@ impl std::fmt::Display for IncompleteSearch { /// configuration directories are skipped even when they are below the root, and so is everything /// below the home directory or a filesystem root, which hold unrelated projects. /// -/// The database settings of the `environment` (see [PartialDatabaseConfiguration::from_env]) -/// only fill in the connection options that a nested configuration file doesn't set itself, so -/// that each file keeps its own database. `configure` then lays the layers of the caller, such as -/// the arguments of the CLI, over each configuration file. Folders that the settings above them -/// ignore are skipped. +/// `layers` are laid over each configuration file like over the root configuration file, except +/// that the environment only fills in the connection options that a nested configuration file +/// doesn't set itself, so that each file keeps its own database (see +/// [ConfigurationLayers::apply_nested]). Folders that the settings above them ignore are skipped. /// /// This walks the file system, so call it off the async runtime. Fails if the project is not /// registered. @@ -221,8 +229,7 @@ pub fn apply_nested_configurations( fs: &DynRef<'_, dyn FileSystem>, project_key: ProjectKey, search: NestedConfigurationSearch, - environment: Option<&PartialDatabaseConfiguration>, - configure: impl Fn(PartialConfiguration) -> PartialConfiguration, + layers: &ConfigurationLayers, ) -> Result { let NestedConfigurationSearch { root, @@ -246,7 +253,7 @@ pub fn apply_nested_configurations( let nested = directories .into_iter() .map( - |directory| match load_nested_configuration(fs, &directory, environment, &configure) { + |directory| match load_nested_configuration(fs, &directory, layers) { Ok((nested, extended)) => { extended_files.insert(directory, extended); nested @@ -427,23 +434,13 @@ fn is_not_a_project_folder(fs: &DynRef<'_, dyn FileSystem>, folder: &Path) -> bo fn load_nested_configuration( fs: &DynRef<'_, dyn FileSystem>, directory: &Path, - environment: Option<&PartialDatabaseConfiguration>, - configure: impl Fn(PartialConfiguration) -> PartialConfiguration, + layers: &ConfigurationLayers, ) -> Result<(NestedSettingsParams, Vec), WorkspaceError> { let loaded = load_configuration( fs, ConfigurationPathHint::FromWorkspace(directory.to_path_buf()), )?; - let mut configuration = loaded.configuration; - if let Some(environment) = environment - .and_then(|environment| environment.for_nested_configuration(loaded.file_database.as_ref())) - { - configuration.merge_with(PartialConfiguration { - db: Some(environment), - ..Default::default() - }); - } - let configuration = configure(configuration); + let configuration = layers.apply_nested(loaded.configuration, loaded.file_database.as_ref()); let (vcs_base_path, gitignore_matches) = configuration.retrieve_gitignore_matches(fs, Some(directory))?; let nested = NestedSettingsParams { diff --git a/crates/pgls_workspace/src/configuration/layers.rs b/crates/pgls_workspace/src/configuration/layers.rs new file mode 100644 index 000000000..9dc4789d6 --- /dev/null +++ b/crates/pgls_workspace/src/configuration/layers.rs @@ -0,0 +1,325 @@ +use pgls_configuration::{ + Merge, PartialConfiguration, StringSet, database::PartialDatabaseConfiguration, +}; + +/// The layers laid over each configuration file, lowest precedence first: +/// +/// 1. the configuration file, with the files it extends, +/// 2. the database settings of the environment ([PartialDatabaseConfiguration::from_env]), +/// 3. the overrides of the caller: the arguments of the CLI, or the configuration a client of +/// the language server sets. +/// +/// The overrides deliberately outrank the environment. They are an explicit instruction for this +/// run or session, whereas the environment only describes the shell that the process happened to +/// be started from. +#[derive(Clone, Debug, Default)] +pub struct ConfigurationLayers { + /// The database settings of the environment. They override the root configuration file, but + /// only fill in the connection options that a nested configuration file doesn't set, so that + /// each nested configuration keeps its own database. + pub environment: Option, + /// The layer over everything else. + pub overrides: Option, + /// How [ConfigurationLayers::overrides] applies. + pub override_mode: OverrideMode, + /// The sections that are reset to their defaults if no layer sets them, see + /// [SectionMask::materialize_defaults]. + pub default_sections: SectionMask, +} + +/// How the overrides of [ConfigurationLayers] apply to the layers below them. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OverrideMode { + /// Like the environment, with plain [Merge]: the sets are unioned, and a connection string + /// below keeps winning over the individual connection options. The arguments of the CLI. + #[default] + Merge, + /// With the semantics of [apply_override], so that the overrides replace what they set. The + /// configuration a client of the language server sets. + Replace, +} + +impl ConfigurationLayers { + /// Lays the layers over the root configuration file of a project. + pub fn apply(&self, file: PartialConfiguration) -> PartialConfiguration { + let mut configuration = file; + if let Some(environment) = &self.environment { + configuration.merge_with(PartialConfiguration { + db: Some(environment.clone()), + ..Default::default() + }); + } + self.apply_overrides(configuration) + } + + /// Lays the layers over a nested configuration file. `file_database` is the `db` section of + /// the file itself, without what it extends: the environment only fills in the connection + /// options that it doesn't set (see [PartialDatabaseConfiguration::for_nested_configuration]). + pub fn apply_nested( + &self, + file: PartialConfiguration, + file_database: Option<&PartialDatabaseConfiguration>, + ) -> PartialConfiguration { + let mut configuration = file; + if let Some(environment) = self + .environment + .as_ref() + .and_then(|environment| environment.for_nested_configuration(file_database)) + { + configuration.merge_with(PartialConfiguration { + db: Some(environment), + ..Default::default() + }); + } + self.apply_overrides(configuration) + } + + fn apply_overrides(&self, mut configuration: PartialConfiguration) -> PartialConfiguration { + if let Some(overrides) = &self.overrides { + match self.override_mode { + OverrideMode::Merge => configuration.merge_with(overrides.clone()), + OverrideMode::Replace => apply_override(&mut configuration, overrides), + } + } + self.default_sections + .materialize_defaults(&mut configuration); + configuration + } +} + +/// Merges one layer that is meant to *override* the layers below it. +/// +/// Plain [`Merge`] is not enough for that, in two ways: +/// +/// * `Merge for Option` ignores `None`, so an override can only set values, never unset them. +/// The one case where that matters in practice is the connection string handled below. +/// * `Merge for StringSet` *unions* the two sets, so entries of a lower layer survive — and for an +/// ordered list such as `typecheck.searchPath` they even come first. Every set the override +/// specifies therefore replaces the one below it, by clearing the base set before merging. +fn apply_override(base: &mut PartialConfiguration, override_layer: &PartialConfiguration) { + // Clears the base sets that `override_layer` specifies, so the merge below replaces them + // instead of unioning them. Every `StringSet` of `PartialConfiguration` is listed, so that a + // newly added one cannot silently keep union semantics. + macro_rules! replace_string_sets { + ($section:ident: $($field:ident),+) => { + if let Some(section) = override_layer.$section.as_ref() { + $( + if section.$field.is_some() { + base.$section.get_or_insert_default().$field = Some(StringSet::default()); + } + )+ + } + }; + } + + if override_layer.extends.is_some() { + base.extends = Some(StringSet::default()); + } + replace_string_sets!(files: include, ignore); + replace_string_sets!(linter: include, ignore); + replace_string_sets!(format: include, ignore); + replace_string_sets!(splinter: ignore); + replace_string_sets!(typecheck: search_path); + replace_string_sets!(db: allow_statement_executions_against); + + // `db.connectionString` takes precedence over the individual connection fields and even + // derives host, port, username and database from them (see `DatabaseSettings::from`), so a + // connection string inherited from the configuration file or the environment would silently + // win over an override that names a concrete target. An override that names one therefore + // unsets the inherited connection string — unless it brings its own, which simply wins. + if let Some(db) = override_layer.db.as_ref() + && db.connection_string.is_none() + && (db.host.is_some() + || db.port.is_some() + || db.username.is_some() + || db.password.is_some() + || db.database.is_some()) + && let Some(base_db) = base.db.as_mut() + { + base_db.connection_string = None; + } + + base.merge_with(override_layer.clone()); +} + +macro_rules! define_section_mask { + ($($section:ident),+ $(,)?) => { + /// A set of the sections of a [PartialConfiguration]. + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] + pub struct SectionMask { $( $section: bool, )+ } + impl SectionMask { + /// The sections that `configuration` sets. + pub fn of(configuration: &PartialConfiguration) -> Self { + Self { $( $section: configuration.$section.is_some(), )+ } + } + pub fn union(self, other: Self) -> Self { + Self { $( $section: self.$section || other.$section, )+ } + } + /// Materialises every section in this mask that `configuration` omits, using that + /// section's defaults. + /// + /// `Settings::merge_with_configuration` leaves an existing section untouched when the + /// incoming configuration omits it, and re-registering a project preserves its + /// settings. Without this step, clearing an override would not restore the baseline: + /// with an empty configuration file and no environment configuration, `db` would keep + /// pointing at the overridden database. An all-`None` `Partial*` section maps to that + /// section's defaults, so materialising it resets the section. + pub fn materialize_defaults(self, configuration: &mut PartialConfiguration) { + $( if self.$section && configuration.$section.is_none() { + configuration.$section = Some(Default::default()); + } )+ + } + } + }; +} +define_section_mask!( + vcs, + files, + migrations, + linter, + splinter, + format, + pglinter, + typecheck, + plpgsql_check, + db, + inlay_hints, + semantic_tokens +); + +#[cfg(test)] +mod tests { + use super::*; + use pgls_configuration::{ + PartialTypecheckConfiguration, database::PartialDatabaseConfiguration, + }; + use serde_json::json; + + fn parse(value: serde_json::Value) -> PartialConfiguration { + serde_json::from_value(value).unwrap() + } + + /// The layers of the language server: the client overrides replace what they set. + fn client_layers( + environment: Option, + overrides: Option, + ) -> ConfigurationLayers { + ConfigurationLayers { + environment: environment.map(|environment| parse(environment).db.unwrap()), + overrides: overrides.map(parse), + override_mode: OverrideMode::Replace, + default_sections: SectionMask::default(), + } + } + + #[test] + fn client_layer_wins_over_environment_and_file() { + let merged = client_layers( + Some(json!({"db":{"host":"env"}})), + Some(json!({"db":{"host":"client"}})), + ) + .apply(parse(json!({"db":{"host":"file"}}))); + assert_eq!(merged.db.unwrap().host.as_deref(), Some("client")); + } + + #[test] + fn override_search_path_replaces_and_preserves_order() { + let merged = client_layers( + None, + Some(json!({"typecheck":{"searchPath":["client_b", "client_a"]}})), + ) + .apply(parse( + json!({"typecheck":{"searchPath":["file", "common"]}}), + )); + let actual: Vec<_> = merged + .typecheck + .unwrap() + .search_path + .unwrap() + .into_iter() + .collect(); + assert_eq!(actual, ["client_b", "client_a"]); + } + + #[test] + fn individual_database_fields_clear_inherited_connection_string() { + let inherited = || parse(json!({"db":{"connectionString":"postgres://inherited"}})); + let merged = client_layers( + None, + Some(json!({"db":{"host":"localhost", "database":"db"}})), + ) + .apply(inherited()); + assert_eq!(merged.db.unwrap().connection_string, None); + let merged = client_layers( + None, + Some(json!({"db":{"connectionString":"postgres://own"}})), + ) + .apply(inherited()); + assert_eq!( + merged.db.unwrap().connection_string.as_deref(), + Some("postgres://own") + ); + } + + #[test] + fn empty_database_password_is_preserved() { + let merged = client_layers(None, Some(json!({"db":{"password":""}}))) + .apply(PartialConfiguration::default()); + assert_eq!(merged.db.unwrap().password.as_deref(), Some("")); + } + + /// The arguments of the CLI are merged like the environment. + #[test] + fn merged_overrides_union_sets() { + let layers = ConfigurationLayers { + overrides: Some(parse(json!({"typecheck":{"searchPath":["argument"]}}))), + ..Default::default() + }; + let merged = layers.apply(parse(json!({"typecheck":{"searchPath":["file"]}}))); + let actual: Vec<_> = merged + .typecheck + .unwrap() + .search_path + .unwrap() + .into_iter() + .collect(); + assert_eq!(actual, ["file", "argument"]); + } + + #[test] + fn default_sections_are_materialized() { + let layers = ConfigurationLayers { + default_sections: SectionMask::of(&PartialConfiguration { + db: Some(PartialDatabaseConfiguration::default()), + ..Default::default() + }), + ..Default::default() + }; + let applied = layers.apply(PartialConfiguration::default()); + assert_eq!(applied.db, Some(PartialDatabaseConfiguration::default())); + } + + /// The environment overrides the root configuration file, but only fills in a nested one. + #[test] + fn environment_fills_in_nested_configurations() { + let layers = client_layers(Some(json!({"db":{"host":"env", "database":"env"}})), None); + let file = || parse(json!({"db":{"host":"file"}})); + + let root = layers.apply(file()).db.unwrap(); + assert_eq!(root.host.as_deref(), Some("env")); + assert_eq!(root.database.as_deref(), Some("env")); + + let nested = layers.apply_nested(file(), file().db.as_ref()).db.unwrap(); + assert_eq!(nested.host.as_deref(), Some("file")); + assert_eq!(nested.database.as_deref(), Some("env")); + } + + #[test] + fn mask_tracks_sections() { + let mask = SectionMask::of(&PartialConfiguration { + typecheck: Some(PartialTypecheckConfiguration::default()), + ..Default::default() + }); + assert!(mask.typecheck); + } +} diff --git a/crates/pgls_workspace/src/configuration/project.rs b/crates/pgls_workspace/src/configuration/project.rs new file mode 100644 index 000000000..f9462cf59 --- /dev/null +++ b/crates/pgls_workspace/src/configuration/project.rs @@ -0,0 +1,211 @@ +use std::path::PathBuf; + +use pgls_configuration::{ConfigurationPathHint, PartialConfiguration}; +use pgls_fs::{ConfigName, FileSystem}; +use tracing::info; + +use crate::{ + DynRef, Workspace, WorkspaceError, + configuration::{ConfigurationLayers, LoadedConfiguration, load_configuration}, + settings::{FilesSettings, connection_string_error}, + workspace::{ProjectKey, RegisterProjectFolderParams, UpdateSettingsParams}, +}; + +use super::PartialConfigurationExt; + +/// What [configure_project] configures a project from. +pub struct ConfigureProjectParams<'a> { + /// Where to look for the configuration file. The folder of a workspace or LSP hint is the + /// folder of the project; otherwise it is the working directory. + pub hint: ConfigurationPathHint, + /// The layers laid over the configuration file. + pub layers: &'a ConfigurationLayers, + /// Whether, and from where, the VCS ignore file applies. + pub vcs: VcsIntegration, +} + +/// Whether the VCS ignore file of a project applies, and where it is looked up from, if the +/// configuration enables the VCS integration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VcsIntegration { + /// From the folder of the configuration file. + Enabled, + /// From the working directory of the process. + FromWorkingDirectory, + /// Never, whatever the configuration says. + Disabled, +} + +/// A project that [configure_project] registered. +#[derive(Debug)] +pub struct ConfiguredProject { + pub project_key: ProjectKey, + /// The folder the project was registered with. Empty for a project without a folder. + pub folder: PathBuf, + /// The files the settings come from: the configuration file and the files it extends. Set + /// even if the configuration could not be applied. + pub configuration_files: Vec, + /// What the user should know about the configuration. + pub notices: Vec, + /// The configuration that was applied, or why none was. The project keeps its previous + /// settings then, or the defaults if it is new. + pub result: Result, +} + +/// The root configuration of a project, as [configure_project] applied it. +#[derive(Debug)] +pub struct AppliedConfiguration { + /// The configuration file with the layers laid over it. + pub configuration: PartialConfiguration, + /// Its files settings, which decide the folders that are searched for nested configuration + /// files. + pub files: FilesSettings, +} + +/// Something about a configuration that the user should know, which callers report the way +/// their interface does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigurationNotice { + /// The configuration file has a deprecated name. + DeprecatedFileName(PathBuf), + /// The user configuration applies to the folder, which has no configuration file. + UserConfiguration { + file_path: PathBuf, + directory: PathBuf, + }, + /// The configuration uses deprecated settings, with a description of each. + DeprecatedSettings(Vec), + /// The connection string is invalid, so the database features are disabled. + InvalidConnectionString(String), +} + +/// Registers a project and applies its configuration file, with `layers` laid over it, to its +/// root settings. The configuration files below its folder are applied separately by +/// [super::apply_nested_configurations]. +/// +/// Fails only if the project can't be registered. A configuration that can't be loaded or +/// applied is reported in [ConfiguredProject::result]. +pub fn configure_project( + workspace: &dyn Workspace, + fs: &DynRef<'_, dyn FileSystem>, + params: ConfigureProjectParams, +) -> Result { + let ConfigureProjectParams { hint, layers, vcs } = params; + let folder = match &hint { + ConfigurationPathHint::FromWorkspace(path) | ConfigurationPathHint::FromLsp(path) => { + Some(path.clone()) + } + ConfigurationPathHint::FromUser(_) | ConfigurationPathHint::None => fs.working_directory(), + }; + let project_key = workspace.register_project_folder(RegisterProjectFolderParams { + path: folder.clone(), + })?; + + let mut configuration_files = Vec::new(); + let mut notices = Vec::new(); + let result = load_configuration(fs, hint).and_then(|loaded| { + info!( + "Configuration loaded successfully from disk at {}", + loaded + .file_path + .as_ref() + .map_or("".to_string(), |path| path.display().to_string()) + ); + configuration_files.extend( + loaded + .file_path + .iter() + .chain(&loaded.extended_files) + .cloned(), + ); + notices.extend(file_notices(&loaded)); + apply_root_configuration( + workspace, + fs, + project_key, + loaded, + layers, + vcs, + &mut notices, + ) + }); + Ok(ConfiguredProject { + project_key, + folder: folder.unwrap_or_default(), + configuration_files, + notices, + result, + }) +} + +/// What the user should know about a configuration file. +fn file_notices(loaded: &LoadedConfiguration) -> Vec { + let mut notices = Vec::new(); + if let Some(file_path) = &loaded.file_path + && let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) + && ConfigName::is_deprecated(file_name) + { + notices.push(ConfigurationNotice::DeprecatedFileName(file_path.clone())); + } + if loaded.is_user_configuration + && let (Some(file_path), Some(directory)) = (&loaded.file_path, &loaded.directory_path) + { + notices.push(ConfigurationNotice::UserConfiguration { + file_path: file_path.clone(), + directory: directory.clone(), + }); + } + let deprecations = loaded.configuration.deprecations(); + if !deprecations.is_empty() { + notices.push(ConfigurationNotice::DeprecatedSettings(deprecations)); + } + notices +} + +/// Lays `layers` over a loaded configuration file and applies it to the root settings of a +/// project. +fn apply_root_configuration( + workspace: &dyn Workspace, + fs: &DynRef<'_, dyn FileSystem>, + project_key: ProjectKey, + loaded: LoadedConfiguration, + layers: &ConfigurationLayers, + vcs: VcsIntegration, + notices: &mut Vec, +) -> Result { + let configuration = layers.apply(loaded.configuration); + let (vcs_base_path, gitignore_matches) = match vcs { + VcsIntegration::Enabled => { + configuration.retrieve_gitignore_matches(fs, loaded.directory_path.as_deref())? + } + VcsIntegration::FromWorkingDirectory => { + configuration.retrieve_gitignore_matches(fs, fs.working_directory().as_deref())? + } + VcsIntegration::Disabled => (None, Vec::new()), + }; + + // An invalid connection string only disables the database features, so the user would not + // notice it otherwise. + if let Some(error) = configuration.db.as_ref().and_then(connection_string_error) { + notices.push(ConfigurationNotice::InvalidConnectionString(error)); + } + + let workspace_directory = fs.working_directory(); + workspace.update_settings(UpdateSettingsParams { + project_key, + workspace_directory: workspace_directory.clone(), + configuration: configuration.clone(), + vcs_base_path: vcs_base_path.clone(), + gitignore_matches: gitignore_matches.clone(), + })?; + let files = FilesSettings::from_configuration( + &configuration, + workspace_directory, + vcs_base_path, + &gitignore_matches, + )?; + Ok(AppliedConfiguration { + configuration, + files, + }) +} diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index d635618a7..21b2fd677 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -25,8 +25,8 @@ use sqlx::{Executor, PgPool, Row}; use crate::{ DynRef, Workspace, WorkspaceError, configuration::{ - IncompleteSearch, NestedConfigurationSearch, apply_nested_configurations, - is_searched_for_configuration, load_configuration, + ConfigurationLayers, IncompleteSearch, NestedConfigurationSearch, + apply_nested_configurations, is_searched_for_configuration, load_configuration, }, features::code_actions::{ CodeActionKind, CodeActionsParams, CommandAction, CommandActionCategory, @@ -3674,8 +3674,7 @@ impl TestProject { fs, self.project_key, search, - None, - |c| c, + &ConfigurationLayers::default(), ) .unwrap(); let directories = nested @@ -3705,8 +3704,7 @@ impl TestProject { fs, self.project_key, NestedConfigurationSearch::new(root), - None, - |configuration| configuration, + &ConfigurationLayers::default(), ) .unwrap() .configurations @@ -4047,8 +4045,7 @@ fn a_nested_configuration_that_cannot_be_applied_uses_the_defaults() { &fs, project.project_key, NestedConfigurationSearch::new("/repo"), - None, - |configuration| configuration, + &ConfigurationLayers::default(), ) .unwrap() .configurations; From 37a5e46ef293a085ebbc1aa206951783f40e3c13 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 26 Oct 2026 14:32:39 +0000 Subject: [PATCH 12/17] refactor(workspace): find the project of a file in the workspace The LSP session matched a file against its own list of project folders to pick the project. The workspace already knows the folder of every project, so Workspace::find_project now returns the one of the deepest folder that contains a path, and the session only decides what a file outside every folder belongs to. It keeps its folder list to unregister removed folders. No behaviour change. --- crates/pgls_lsp/src/server.rs | 1 + crates/pgls_lsp/src/session.rs | 31 ++++++++----- crates/pgls_workspace/src/settings.rs | 10 +++++ crates/pgls_workspace/src/workspace.rs | 12 +++++ crates/pgls_workspace/src/workspace/client.rs | 11 ++++- crates/pgls_workspace/src/workspace/server.rs | 17 +++++-- .../src/workspace/server.tests.rs | 45 +++++++++++++++++-- 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/crates/pgls_lsp/src/server.rs b/crates/pgls_lsp/src/server.rs index 6fd922894..23e9220a2 100644 --- a/crates/pgls_lsp/src/server.rs +++ b/crates/pgls_lsp/src/server.rs @@ -467,6 +467,7 @@ impl ServerFactory { workspace_method!(builder, pull_inlay_hints); workspace_method!(builder, pull_semantic_tokens); workspace_method!(builder, register_project_folder); + workspace_method!(builder, find_project); workspace_method!(builder, unregister_project_folder); workspace_method!(builder, invalidate_schema_cache); diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 9c51c0b2b..4fb9324f2 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -21,7 +21,7 @@ use pgls_workspace::configuration::{ use pgls_workspace::features; use pgls_workspace::settings::FilesSettings; use pgls_workspace::workspace::{ - ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, + FindProjectParams, ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, }; use pgls_workspace::{DynRef, WorkspaceError}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -115,7 +115,8 @@ pub(crate) struct Session { workspace_folders: RwLock>>, /// The projects of the workspace by their folder, deepest first: one per workspace folder, or - /// one for the root. A document belongs to the project of the deepest folder that contains it. + /// one for the root. The workspace finds the project of a document; these are the folders to + /// unregister when they are removed. projects: RwLock>, /// The project of the files outside every workspace folder, once one was opened. @@ -759,17 +760,25 @@ impl Session { } /// The project that the file at `path` belongs to: the one of the deepest workspace folder - /// that contains it. A file outside every workspace folder belongs to a project without a - /// folder, which has the default settings, so that it never gets the settings (and the - /// database) of an unrelated folder. Without workspace folders or a root, or with a - /// configuration file chosen by the user, the only project applies to every file. Before the - /// configuration is loaded, the key of no project, for which the workspace only reports what - /// needs no settings. + /// that contains it, which the workspace looks up. A file outside every workspace folder + /// belongs to a project without a folder, which has the default settings, so that it never + /// gets the settings (and the database) of an unrelated folder. Without workspace folders or a + /// root, or with a configuration file chosen by the user, the only project applies to every + /// file. Before the configuration is loaded, the key of no project, for which the workspace + /// only reports what needs no settings. pub(crate) fn project_key(&self, path: &Path) -> ProjectKey { - let projects = self.projects.read(); - if let Some((_, key)) = projects.iter().find(|(folder, _)| path.starts_with(folder)) { - return *key; + match self.workspace.find_project(FindProjectParams { + path: PgLSPath::new(path), + }) { + Ok(Some(key)) => return key, + Ok(None) => {} + Err(error) => error!( + "Failed to find the project of {}: {}", + path.display(), + error + ), } + let projects = self.projects.read(); let Some((_, only_project)) = projects.last() else { return ProjectKey::default(); }; diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 36fa0fd1b..734295247 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -102,6 +102,16 @@ impl WorkspaceSettings { .map(|(key, _)| key) } + /// The project of the deepest folder that contains `path`. A project without a folder contains + /// every path. + pub fn find_project(&self, path: &Path) -> Option { + self.data + .iter() + .filter(|(_, project)| path.starts_with(project.path.as_path())) + .max_by_key(|(_, project)| project.path.components().count()) + .map(|(key, _)| key) + } + /// The folder of a project. pub fn get_project_path(&self, key: ProjectKey) -> Option<&PgLSPath> { self.data.get(key).map(|project| &project.path) diff --git a/crates/pgls_workspace/src/workspace.rs b/crates/pgls_workspace/src/workspace.rs index ad204d74a..43f9b72ac 100644 --- a/crates/pgls_workspace/src/workspace.rs +++ b/crates/pgls_workspace/src/workspace.rs @@ -144,6 +144,13 @@ pub struct NestedSettingsError { pub message: String, } +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct FindProjectParams { + pub path: PgLSPath, +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] @@ -217,6 +224,11 @@ pub trait Workspace: Send + Sync + RefUnwindSafe { params: RegisterProjectFolderParams, ) -> Result; + /// The project that the file at `path` belongs to: the one of the deepest folder that contains + /// it. A project without a folder contains every path. `None` if no project contains it. + fn find_project(&self, params: FindProjectParams) + -> Result, WorkspaceError>; + /// Unregister a project folder. Its settings, including the nested ones, are deleted. fn unregister_project_folder( &self, diff --git a/crates/pgls_workspace/src/workspace/client.rs b/crates/pgls_workspace/src/workspace/client.rs index 45f4dd825..46d5040c6 100644 --- a/crates/pgls_workspace/src/workspace/client.rs +++ b/crates/pgls_workspace/src/workspace/client.rs @@ -8,8 +8,8 @@ use std::{ }; use super::{ - CloseFileParams, GetFileContentParams, IsPathIgnoredParams, OpenFileParams, ProjectKey, - RegisterProjectFolderParams, UnregisterProjectFolderParams, + CloseFileParams, FindProjectParams, GetFileContentParams, IsPathIgnoredParams, OpenFileParams, + ProjectKey, RegisterProjectFolderParams, UnregisterProjectFolderParams, }; pub struct WorkspaceClient { @@ -120,6 +120,13 @@ where self.request("pgls/register_project_folder", params) } + fn find_project( + &self, + params: FindProjectParams, + ) -> Result, WorkspaceError> { + self.request("pgls/find_project", params) + } + fn unregister_project_folder( &self, params: UnregisterProjectFolderParams, diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 2222a477a..941b78cfc 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -71,9 +71,10 @@ use crate::{ }; use super::{ - GetFileContentParams, IsPathIgnoredParams, NestedSettingsError, OpenFileParams, ProjectKey, - RegisterProjectFolderParams, ServerInfo, UnregisterProjectFolderParams, - UpdateNestedSettingsParams, UpdateNestedSettingsResult, UpdateSettingsParams, Workspace, + FindProjectParams, GetFileContentParams, IsPathIgnoredParams, NestedSettingsError, + OpenFileParams, ProjectKey, RegisterProjectFolderParams, ServerInfo, + UnregisterProjectFolderParams, UpdateNestedSettingsParams, UpdateNestedSettingsResult, + UpdateSettingsParams, Workspace, }; pub use statement_identifier::StatementId; @@ -916,6 +917,16 @@ impl Workspace for WorkspaceServer { Ok(existing.unwrap_or_else(|| self.register_project(path))) } + fn find_project( + &self, + params: FindProjectParams, + ) -> Result, WorkspaceError> { + Ok(self + .workspaces() + .as_ref() + .find_project(params.path.as_path())) + } + fn unregister_project_folder( &self, params: UnregisterProjectFolderParams, diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index 21b2fd677..70b4bbbc0 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -39,9 +39,9 @@ use crate::{ features::semantic_tokens::{PullSemanticTokensParams, SemanticTokenKind}, settings::FilesSettings, workspace::{ - IsPathIgnoredParams, OpenFileParams, ProjectKey, RegisterProjectFolderParams, StatementId, - UnregisterProjectFolderParams, UpdateNestedSettingsParams, UpdateSettingsParams, - server::WorkspaceServer, + FindProjectParams, IsPathIgnoredParams, OpenFileParams, ProjectKey, + RegisterProjectFolderParams, StatementId, UnregisterProjectFolderParams, + UpdateNestedSettingsParams, UpdateSettingsParams, server::WorkspaceServer, }, }; @@ -4073,6 +4073,45 @@ fn a_nested_configuration_that_cannot_be_applied_uses_the_defaults() { ); } +/// A file belongs to the project of the deepest folder that contains it, and a project without a +/// folder contains every path. +#[test] +fn files_belong_to_the_project_of_the_deepest_folder() { + let workspace = WorkspaceServer::new(); + let register = |path: Option<&str>| { + workspace + .register_project_folder(RegisterProjectFolderParams { + path: path.map(PathBuf::from), + }) + .unwrap() + }; + let find = |path: &str| { + workspace + .find_project(FindProjectParams { + path: PgLSPath::new(path), + }) + .unwrap() + }; + let outer = register(Some("/outer")); + let inner = register(Some("/outer/inner")); + + assert_eq!(find("/outer/a.sql"), Some(outer)); + assert_eq!(find("/outer/inner/a.sql"), Some(inner)); + assert_eq!(find("/outer/innermost/a.sql"), Some(outer)); + assert_eq!(find("/elsewhere/a.sql"), None); + + let folderless = register(None); + assert_eq!(find("/elsewhere/a.sql"), Some(folderless)); + assert_eq!(find("/outer/inner/a.sql"), Some(inner)); + + workspace + .unregister_project_folder(UnregisterProjectFolderParams { + path: PgLSPath::new("/outer/inner"), + }) + .unwrap(); + assert_eq!(find("/outer/inner/a.sql"), Some(outer)); +} + /// Each project has its own nested settings, even when the folder of one is below the other, as /// with nested workspace folders. Unregistering a project removes its nested settings with it. #[test] From 8df9beab10948816da551781b0434c9b01b8ddd7 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 26 Oct 2026 14:57:33 +0000 Subject: [PATCH 13/17] refactor(configuration): apply extends with a loop instead of recursion The engineering rules don't allow recursion per configuration file. The files that extend each other are kept on a stack, and the depth limit is named EXTENDS_DEPTH_MAX. --- crates/pgls_workspace/src/configuration.rs | 221 ++++++++++++++------- 1 file changed, 149 insertions(+), 72 deletions(-) diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 18e9bfcff..8f0ce18e4 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -671,22 +671,14 @@ impl PartialConfigurationExt for PartialConfiguration { /// first. Relative paths in an extended file are relative to that file. /// /// If a configuration can't be resolved from the file system, extends itself, or is extended - /// more than [MAX_EXTENDS_DEPTH] levels deep, the operation will fail. + /// more than [EXTENDS_DEPTH_MAX] levels deep, the operation will fail. fn apply_extends( &mut self, fs: &DynRef<'_, dyn FileSystem>, file_path: &Path, external_resolution_base_path: &Path, ) -> Result, WorkspaceError> { - let mut extended_files = Vec::new(); - apply_extends_recursively( - self, - fs, - &mut vec![normalize_path(file_path)], - external_resolution_base_path, - &mut extended_files, - )?; - Ok(extended_files) + apply_extends_transitively(self, fs, file_path, external_resolution_base_path) } /// It attempts to deserialize all the configuration files that were specified in the `extends` property @@ -810,75 +802,124 @@ impl PartialConfigurationExt for PartialConfiguration { } } -/// How many levels deep configuration files can extend each other. -const MAX_EXTENDS_DEPTH: usize = 10; +/// How many levels deep configuration files can extend each other. A chain of a nested +/// configuration, the root configuration and a shared or user configuration is three levels; +/// ten leaves room for that and catches runaway chains that aren't cycles. +const EXTENDS_DEPTH_MAX: usize = 10; + +/// A configuration file whose `extends` [apply_extends_transitively] is applying. +struct ExtendingFile { + path: PathBuf, + configuration: PartialConfiguration, + /// The files in its `extends` that are still to be applied, in order. + pending: std::vec::IntoIter<(PathBuf, PartialConfiguration)>, + /// The files in its `extends` that were applied, merged from left to right. + extended: Option, +} -/// Applies the `extends` of `configuration`, the last file of `chain`, and theirs, collecting the -/// extended files. -fn apply_extends_recursively( +impl ExtendingFile { + fn new( + path: PathBuf, + mut configuration: PartialConfiguration, + fs: &DynRef<'_, dyn FileSystem>, + external_resolution_base_path: &Path, + ) -> Result { + let pending = configuration + .deserialize_extends( + fs, + path.parent().unwrap_or(Path::new("")), + external_resolution_base_path, + )? + .into_iter(); + Ok(Self { + path, + configuration, + pending, + extended: None, + }) + } + + /// The configuration over the files it extends. + fn finish(self) -> PartialConfiguration { + match self.extended { + Some(mut extended) => { + extended.merge_with(self.configuration); + extended + } + None => self.configuration, + } + } +} + +/// Applies the `extends` of `configuration`, the file at `file_path`, and of the files it +/// extends, depth first. Returns the extended files in the order they were found. Walks with a +/// stack instead of recursing, so that the depth is [EXTENDS_DEPTH_MAX] and not the stack's. +fn apply_extends_transitively( configuration: &mut PartialConfiguration, fs: &DynRef<'_, dyn FileSystem>, - chain: &mut Vec, + file_path: &Path, external_resolution_base_path: &Path, - extended_files: &mut Vec, -) -> Result<(), WorkspaceError> { - let file_path = chain - .last() - .expect("the chain starts with the configuration file"); - let mut configurations = Vec::new(); - for (path, mut extended) in configuration.deserialize_extends( +) -> Result, WorkspaceError> { + let mut extended_files = Vec::new(); + // The file and the files it extends that are being applied, each extending the one before. + let mut chain = vec![ExtendingFile::new( + normalize_path(file_path), + std::mem::take(configuration), fs, - file_path.parent().expect("file path should have a parent"), external_resolution_base_path, - )? { - let path = normalize_path(&path); - if chain.contains(&path) { - let cycle: Vec<_> = chain - .iter() - .chain(std::iter::once(&path)) - .map(|file| file.display().to_string()) - .collect(); - return Err(ConfigurationDiagnostic::invalid_configuration(format!( - "The configuration files extend each other: {}", - cycle.join(" -> ") - )) - .into()); - } - if chain.len() > MAX_EXTENDS_DEPTH { - return Err(ConfigurationDiagnostic::invalid_configuration(format!( - "The configuration files extend each other more than {MAX_EXTENDS_DEPTH} levels \ - deep, at {}", - path.display() - )) - .into()); + )?]; + loop { + let next = chain.last_mut().and_then(|file| file.pending.next()); + if let Some((path, extended)) = next { + let path = normalize_path(&path); + if chain.iter().any(|file| file.path == path) { + let cycle: Vec<_> = chain + .iter() + .map(|file| &file.path) + .chain(std::iter::once(&path)) + .map(|file| file.display().to_string()) + .collect(); + return Err(ConfigurationDiagnostic::invalid_configuration(format!( + "The configuration files extend each other: {}", + cycle.join(" -> ") + )) + .into()); + } + if chain.len() > EXTENDS_DEPTH_MAX { + return Err(ConfigurationDiagnostic::invalid_configuration(format!( + "The configuration files extend each other more than {EXTENDS_DEPTH_MAX} \ + levels deep, at {}", + path.display() + )) + .into()); + } + extended_files.push(path.clone()); + chain.push(ExtendingFile::new( + path, + extended, + fs, + external_resolution_base_path, + )?); + continue; } - extended_files.push(path.clone()); - chain.push(path); - apply_extends_recursively( - &mut extended, - fs, - chain, - external_resolution_base_path, - extended_files, - )?; - chain.pop(); - configurations.push(extended); - } - let extended_configuration = - configurations - .into_iter() - .reduce(|mut previous_configuration, current_configuration| { - previous_configuration.merge_with(current_configuration); - previous_configuration - }); - if let Some(mut extended_configuration) = extended_configuration { - // We swap them to avoid having to clone `configuration` to merge it. - std::mem::swap(configuration, &mut extended_configuration); - configuration.merge_with(extended_configuration) + let Some(file) = chain.pop() else { + break; + }; + let finished = file.finish(); + match chain.last_mut() { + // Later files in `extends` override earlier ones. + Some(extending) => match &mut extending.extended { + Some(extended) => extended.merge_with(finished), + None => extending.extended = Some(finished), + }, + None => { + *configuration = finished; + break; + } + } } - - Ok(()) + Ok(extended_files) } /// Expands a leading `~` in a path to the home directory. @@ -1047,6 +1088,42 @@ mod tests { ); } + /// The files in `extends` apply from left to right, each over the files it extends itself. + #[test] + fn extends_applies_later_files_over_earlier_ones() { + let mut fs = file_system(); + fs.insert( + project_directory().join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["./a.jsonc", "./b.jsonc"] }"#, + ); + fs.insert( + project_directory().join("a.jsonc"), + r#"{ "extends": ["./a_base.jsonc"], "db": { "database": "a" } }"#, + ); + fs.insert( + project_directory().join("a_base.jsonc"), + r#"{ "db": { "host": "a_base", "database": "a_base", "username": "a_base" } }"#, + ); + fs.insert( + project_directory().join("b.jsonc"), + r#"{ "db": { "host": "b" } }"#, + ); + + let loaded = load(fs, ConfigurationPathHint::FromWorkspace(project_directory())); + let db = loaded.configuration.db.as_ref().unwrap(); + assert_eq!(db.host.as_deref(), Some("b")); + assert_eq!(db.database.as_deref(), Some("a")); + assert_eq!(db.username.as_deref(), Some("a_base")); + assert_eq!( + loaded.extended_files, + [ + project_directory().join("a.jsonc"), + project_directory().join("a_base.jsonc"), + project_directory().join("b.jsonc"), + ] + ); + } + #[test] fn extends_fails_on_a_cycle() { let mut fs = file_system(); @@ -1078,14 +1155,14 @@ mod tests { project_directory().join(ConfigName::pgls_jsonc()), r#"{ "extends": ["./0.jsonc"] }"#, ); - for level in 0..=MAX_EXTENDS_DEPTH { + for level in 0..=EXTENDS_DEPTH_MAX { fs.insert( project_directory().join(format!("{level}.jsonc")), format!(r#"{{ "extends": ["./{}.jsonc"] }}"#, level + 1), ); } fs.insert( - project_directory().join(format!("{}.jsonc", MAX_EXTENDS_DEPTH + 1)), + project_directory().join(format!("{}.jsonc", EXTENDS_DEPTH_MAX + 1)), "{}", ); From cbac7c86e9395041eee8942cb47672a3186440fd Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 26 Oct 2026 15:01:58 +0000 Subject: [PATCH 14/17] refactor(lsp): drop the sections the client overrides used to reset The client overrides remembered every section they ever set, so that clearing one reset it to its defaults instead of keeping the overridden value. Now that the root settings are replaced on every load, a section nothing sets is at its defaults anyway, and the overrides are a plain optional layer. --- crates/pgls_lsp/src/configuration.rs | 52 ------------- crates/pgls_lsp/src/lib.rs | 1 - crates/pgls_lsp/src/server.rs | 5 +- crates/pgls_lsp/src/session.rs | 13 ++-- crates/pgls_workspace/src/configuration.rs | 7 +- .../src/configuration/layers.rs | 76 ------------------- 6 files changed, 12 insertions(+), 142 deletions(-) delete mode 100644 crates/pgls_lsp/src/configuration.rs diff --git a/crates/pgls_lsp/src/configuration.rs b/crates/pgls_lsp/src/configuration.rs deleted file mode 100644 index e4ee46bf1..000000000 --- a/crates/pgls_lsp/src/configuration.rs +++ /dev/null @@ -1,52 +0,0 @@ -use pgls_configuration::PartialConfiguration; -use pgls_workspace::configuration::SectionMask; - -/// The sticky layer of configuration a client supplies at runtime. -#[derive(Debug, Default)] -pub(crate) struct ClientOverrides { - configuration: Option, - /// This mask only ever grows: a cleared section remains in the applied stack so it can be - /// materialised as defaults instead of leaving stale workspace settings active. - sections: SectionMask, -} - -impl ClientOverrides { - /// Replaces the layer wholesale; it does not accumulate. `None` clears it. - pub(crate) fn set(&mut self, configuration: Option) { - if let Some(configuration) = &configuration { - self.sections = self.sections.union(SectionMask::of(configuration)); - } - self.configuration = configuration; - } - pub(crate) fn configuration(&self) -> Option<&PartialConfiguration> { - self.configuration.as_ref() - } - pub(crate) fn sections(&self) -> SectionMask { - self.sections - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pgls_configuration::database::PartialDatabaseConfiguration; - use pgls_workspace::configuration::{ConfigurationLayers, OverrideMode}; - - #[test] - fn clearing_override_materializes_sticky_sections() { - let mut overrides = ClientOverrides::default(); - overrides.set(Some(PartialConfiguration { - db: Some(PartialDatabaseConfiguration::default()), - ..Default::default() - })); - overrides.set(None); - let layers = ConfigurationLayers { - environment: None, - overrides: overrides.configuration().cloned(), - override_mode: OverrideMode::Replace, - default_sections: overrides.sections(), - }; - let applied = layers.apply(PartialConfiguration::default()); - assert_eq!(applied.db, Some(PartialDatabaseConfiguration::default())); - } -} diff --git a/crates/pgls_lsp/src/lib.rs b/crates/pgls_lsp/src/lib.rs index c67253168..eb5c45949 100644 --- a/crates/pgls_lsp/src/lib.rs +++ b/crates/pgls_lsp/src/lib.rs @@ -4,7 +4,6 @@ mod adapters; mod capabilities; -mod configuration; mod diagnostics; mod documents; mod handlers; diff --git a/crates/pgls_lsp/src/server.rs b/crates/pgls_lsp/src/server.rs index 23e9220a2..8561d816b 100644 --- a/crates/pgls_lsp/src/server.rs +++ b/crates/pgls_lsp/src/server.rs @@ -567,9 +567,8 @@ mod tests { } /// Clearing an override has to restore the baseline. The configuration file is `{}` and there is - /// no environment configuration, so nothing below the override layer configures `files` — and - /// `Settings::merge_with_configuration` keeps a section that the incoming configuration omits, - /// which is why the cleared section has to be materialized with its defaults. + /// no environment configuration, so nothing below the override layer configures `files`: the + /// root settings are replaced on every load, so the section goes back to its defaults. #[tokio::test] async fn clearing_client_overrides_restores_the_baseline() { let working_directory = tempfile::tempdir().unwrap(); diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index 4fb9324f2..d3b23f92f 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -1,5 +1,4 @@ use crate::adapters::{PositionEncoding, WideEncoding, negotiated_encoding}; -use crate::configuration::ClientOverrides; use crate::diagnostics::LspError; use crate::documents::Document; use crate::utils; @@ -108,7 +107,7 @@ pub(crate) struct Session { environment: Option, /// Layer set through workspace/didChangeConfiguration and the custom override request. - client_overrides: RwLock, + client_overrides: RwLock>, /// The workspace folders, which the client can change after the initialization. `None` if the /// client has none and uses the root URI. @@ -264,7 +263,7 @@ impl Session { notified_deprecated_config: AtomicBool::new(false), notified_deprecated_settings: AtomicBool::new(false), environment, - client_overrides: RwLock::new(ClientOverrides::default()), + client_overrides: RwLock::default(), workspace_folders: RwLock::default(), projects: RwLock::default(), folderless_project: OnceLock::new(), @@ -950,12 +949,10 @@ impl Session { /// The layers of the session that are laid over each configuration file: the environment and /// the client overrides. fn layers(&self) -> ConfigurationLayers { - let overrides = self.client_overrides.read(); ConfigurationLayers { environment: self.environment.clone(), - overrides: overrides.configuration().cloned(), + overrides: self.client_overrides.read().clone(), override_mode: OverrideMode::Replace, - default_sections: overrides.sections(), } } @@ -1100,7 +1097,7 @@ impl Session { let _transition = self.configuration_transition.lock().await; #[cfg(test)] let _running = self.enter_transition(); - self.client_overrides.write().set(overrides); + *self.client_overrides.write() = overrides; // Everything from here on is synchronous work behind `.await`s that complete immediately, so // a transition only ever suspends when something makes the server wait. Yielding here in // tests turns that into a reliable suspension point, which is what lets the test suite @@ -1115,7 +1112,7 @@ impl Session { /// Whether a client override layer is currently set. #[cfg(test)] pub(crate) fn has_client_overrides(&self) -> bool { - self.client_overrides.read().configuration().is_some() + self.client_overrides.read().is_some() } /// Reloads the configuration from disk, re-applying the sticky layers. diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 8f0ce18e4..810f759fe 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -1,7 +1,7 @@ mod layers; mod project; -pub use layers::{ConfigurationLayers, OverrideMode, SectionMask}; +pub use layers::{ConfigurationLayers, OverrideMode}; pub use project::{ AppliedConfiguration, ConfigurationNotice, ConfigureProjectParams, ConfiguredProject, VcsIntegration, configure_project, @@ -1109,7 +1109,10 @@ mod tests { r#"{ "db": { "host": "b" } }"#, ); - let loaded = load(fs, ConfigurationPathHint::FromWorkspace(project_directory())); + let loaded = load( + fs, + ConfigurationPathHint::FromWorkspace(project_directory()), + ); let db = loaded.configuration.db.as_ref().unwrap(); assert_eq!(db.host.as_deref(), Some("b")); assert_eq!(db.database.as_deref(), Some("a")); diff --git a/crates/pgls_workspace/src/configuration/layers.rs b/crates/pgls_workspace/src/configuration/layers.rs index 9dc4789d6..9d361819b 100644 --- a/crates/pgls_workspace/src/configuration/layers.rs +++ b/crates/pgls_workspace/src/configuration/layers.rs @@ -22,9 +22,6 @@ pub struct ConfigurationLayers { pub overrides: Option, /// How [ConfigurationLayers::overrides] applies. pub override_mode: OverrideMode, - /// The sections that are reset to their defaults if no layer sets them, see - /// [SectionMask::materialize_defaults]. - pub default_sections: SectionMask, } /// How the overrides of [ConfigurationLayers] apply to the layers below them. @@ -81,8 +78,6 @@ impl ConfigurationLayers { OverrideMode::Replace => apply_override(&mut configuration, overrides), } } - self.default_sections - .materialize_defaults(&mut configuration); configuration } } @@ -142,57 +137,9 @@ fn apply_override(base: &mut PartialConfiguration, override_layer: &PartialConfi base.merge_with(override_layer.clone()); } -macro_rules! define_section_mask { - ($($section:ident),+ $(,)?) => { - /// A set of the sections of a [PartialConfiguration]. - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] - pub struct SectionMask { $( $section: bool, )+ } - impl SectionMask { - /// The sections that `configuration` sets. - pub fn of(configuration: &PartialConfiguration) -> Self { - Self { $( $section: configuration.$section.is_some(), )+ } - } - pub fn union(self, other: Self) -> Self { - Self { $( $section: self.$section || other.$section, )+ } - } - /// Materialises every section in this mask that `configuration` omits, using that - /// section's defaults. - /// - /// `Settings::merge_with_configuration` leaves an existing section untouched when the - /// incoming configuration omits it, and re-registering a project preserves its - /// settings. Without this step, clearing an override would not restore the baseline: - /// with an empty configuration file and no environment configuration, `db` would keep - /// pointing at the overridden database. An all-`None` `Partial*` section maps to that - /// section's defaults, so materialising it resets the section. - pub fn materialize_defaults(self, configuration: &mut PartialConfiguration) { - $( if self.$section && configuration.$section.is_none() { - configuration.$section = Some(Default::default()); - } )+ - } - } - }; -} -define_section_mask!( - vcs, - files, - migrations, - linter, - splinter, - format, - pglinter, - typecheck, - plpgsql_check, - db, - inlay_hints, - semantic_tokens -); - #[cfg(test)] mod tests { use super::*; - use pgls_configuration::{ - PartialTypecheckConfiguration, database::PartialDatabaseConfiguration, - }; use serde_json::json; fn parse(value: serde_json::Value) -> PartialConfiguration { @@ -208,7 +155,6 @@ mod tests { environment: environment.map(|environment| parse(environment).db.unwrap()), overrides: overrides.map(parse), override_mode: OverrideMode::Replace, - default_sections: SectionMask::default(), } } @@ -286,19 +232,6 @@ mod tests { assert_eq!(actual, ["file", "argument"]); } - #[test] - fn default_sections_are_materialized() { - let layers = ConfigurationLayers { - default_sections: SectionMask::of(&PartialConfiguration { - db: Some(PartialDatabaseConfiguration::default()), - ..Default::default() - }), - ..Default::default() - }; - let applied = layers.apply(PartialConfiguration::default()); - assert_eq!(applied.db, Some(PartialDatabaseConfiguration::default())); - } - /// The environment overrides the root configuration file, but only fills in a nested one. #[test] fn environment_fills_in_nested_configurations() { @@ -313,13 +246,4 @@ mod tests { assert_eq!(nested.host.as_deref(), Some("file")); assert_eq!(nested.database.as_deref(), Some("env")); } - - #[test] - fn mask_tracks_sections() { - let mask = SectionMask::of(&PartialConfiguration { - typecheck: Some(PartialTypecheckConfiguration::default()), - ..Default::default() - }); - assert!(mask.typecheck); - } } From 2cb5414af46f92fdcdd1259b1c664e0a4c2b87c8 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 31 Oct 2026 09:10:30 +0000 Subject: [PATCH 15/17] fix(cli): resolve relative path arguments against the working directory --- crates/pgls_cli/src/lib.rs | 30 ++++++++++++++++++++--- crates/pgls_cli/tests/assert_check.rs | 34 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/pgls_cli/src/lib.rs b/crates/pgls_cli/src/lib.rs index 01a85ccad..190f4c86e 100644 --- a/crates/pgls_cli/src/lib.rs +++ b/crates/pgls_cli/src/lib.rs @@ -13,7 +13,7 @@ use pgls_console::{ColorMode, Console, ConsoleExt, markup}; use pgls_diagnostics::{DiagnosticExt, Error}; use pgls_fs::{ConfigName, FileSystem, OsFileSystem}; use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use pgls_workspace::configuration::{ @@ -239,15 +239,21 @@ impl<'app> CliSession<'app> { /// Registers the project of the working directory and applies the configuration file, with /// the database configuration of the environment and then the arguments laid over it, to it /// and the VCS integration. Relative paths in the configuration file are relative to its - /// folder. Returns the configuration as applied. + /// folder, and relative paths in the arguments to the working directory. Returns the + /// configuration as applied. pub fn configure_workspace( &mut self, cli_options: &CliOptions, - cli_configuration: Option, + mut cli_configuration: Option, vcs: VcsIntegration, ) -> Result { setup_cli_subscriber(cli_options.log_level, cli_options.log_kind); + if let (Some(configuration), Some(working_directory)) = + (cli_configuration.as_mut(), self.fs().working_directory()) + { + resolve_path_arguments(configuration, &working_directory); + } self.layers = ConfigurationLayers { environment: PartialDatabaseConfiguration::from_env(), overrides: cli_configuration, @@ -367,6 +373,24 @@ impl<'app> CliSession<'app> { } } +/// Makes the relative paths in the arguments absolute against the working directory. Otherwise +/// they would be relative to the folder of the configuration file that they are laid over, like +/// the paths in it. +fn resolve_path_arguments(configuration: &mut PartialConfiguration, working_directory: &Path) { + let paths = [ + configuration + .migrations + .as_mut() + .and_then(|migrations| migrations.migrations_dir.as_mut()), + configuration.vcs.as_mut().and_then(|vcs| vcs.root.as_mut()), + ]; + for path in paths.into_iter().flatten() { + if !path.is_empty() && Path::new(path.as_str()).is_relative() { + *path = working_directory.join(path.as_str()).display().to_string(); + } + } +} + pub fn to_color_mode(color: Option<&ColorsArg>) -> ColorMode { match color { Some(ColorsArg::Off) => ColorMode::Disabled, diff --git a/crates/pgls_cli/tests/assert_check.rs b/crates/pgls_cli/tests/assert_check.rs index 8b707bdb1..1a44ce630 100644 --- a/crates/pgls_cli/tests/assert_check.rs +++ b/crates/pgls_cli/tests/assert_check.rs @@ -696,6 +696,40 @@ fn relative_paths_are_relative_to_the_configuration_file() { ); } +/// A relative path in the arguments is relative to the working directory, like any other path +/// typed on the command line, not to the folder of the configuration file. +#[test] +fn relative_path_arguments_are_relative_to_the_working_directory() { + let root = write_tree(&[ + ( + "postgres-language-server.jsonc", + r#"{ "db": { "disableConnection": true } }"#, + ), + ( + "sub/migrations/0009_later.sql", + "alter table users drop column name;\n", + ), + ]); + + let migration = Path::new("migrations").join("0009_later.sql"); + let (_, output) = check_in( + &root.path().join("sub"), + &[ + "--migrations-dir", + "migrations", + "--after", + "5", + migration.to_str().unwrap(), + ], + &[], + ); + // `banDropColumn` only runs on migrations once `migrations` is configured. + assert!( + output.contains("lint/banDropColumn"), + "the file is not recognised as a migration:\n{output}" + ); +} + /// Runs `check` with the database settings given in `args` only, without the connection /// settings of the environment running the tests. #[cfg(target_os = "linux")] From 02d360ebf361a648bed76d35c723251931bb3959 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 31 Oct 2026 09:10:44 +0000 Subject: [PATCH 16/17] fix(configuration): resolve the packages an extended file extends from its folder --- crates/pgls_workspace/src/configuration.rs | 52 +++++++++++++++++++++- docs/configuration.md | 2 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 810f759fe..4231098b7 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -668,7 +668,8 @@ impl PartialConfigurationExt for PartialConfiguration { /// filled in with their values from configs listed in the `extends` field. /// /// The `extends` configs are applied from left to right, each with its own `extends` applied - /// first. Relative paths in an extended file are relative to that file. + /// first. Relative paths in an extended file are relative to that file, and it resolves + /// packages from its folder. /// /// If a configuration can't be resolved from the file system, extends itself, or is extended /// more than [EXTENDS_DEPTH_MAX] levels deep, the operation will fail. @@ -894,11 +895,14 @@ fn apply_extends_transitively( .into()); } extended_files.push(path.clone()); + // Like `require` in Node.js, an extended file resolves the packages in its own + // `extends` from its folder, e.g. from the `node_modules` of the package it is in. + let package_resolution_base_path = path.parent().unwrap_or(Path::new("")).to_owned(); chain.push(ExtendingFile::new( path, extended, fs, - external_resolution_base_path, + &package_resolution_base_path, )?); continue; } @@ -1151,6 +1155,50 @@ mod tests { assert!(error.to_string().contains("extend each other"), "{error}"); } + /// A package that an extended file extends is resolved from the folder of that file, like + /// `require` in Node.js, so a shared configuration can depend on a package of its own. + #[test] + fn extended_files_resolve_packages_from_their_folder() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + let config_package = project.join("node_modules/@acme/pgls-config"); + let base_package = config_package.join("node_modules/@acme/pgls-base"); + for (path, content) in [ + ( + project.join(ConfigName::pgls_jsonc()), + r#"{ "extends": ["@acme/pgls-config"] }"#, + ), + ( + config_package.join("package.json"), + r#"{ "name": "@acme/pgls-config", "main": "config.jsonc" }"#, + ), + ( + config_package.join("config.jsonc"), + r#"{ "extends": ["@acme/pgls-base"], "db": { "database": "config" } }"#, + ), + ( + base_package.join("package.json"), + r#"{ "name": "@acme/pgls-base", "main": "base.jsonc" }"#, + ), + ( + base_package.join("base.jsonc"), + r#"{ "db": { "host": "base" } }"#, + ), + ] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + let loaded = load_configuration( + &DynRef::Owned(Box::new(pgls_fs::OsFileSystem::new(project.clone()))), + ConfigurationPathHint::FromWorkspace(project), + ) + .expect("configuration should load"); + let db = loaded.configuration.db.as_ref().unwrap(); + assert_eq!(db.database.as_deref(), Some("config")); + assert_eq!(db.host.as_deref(), Some("base")); + } + #[test] fn extends_fails_when_too_deep() { let mut fs = file_system(); diff --git a/docs/configuration.md b/docs/configuration.md index db86a11b8..11df05e9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,7 +163,7 @@ The entries can point at files in different places: - Entries starting with `~/` (or `~\` on Windows) are resolved against your home directory. This works on every platform, so a project can extend your user configuration with the same path on macOS and Linux. Only your own home directory is supported: `~user/` is not expanded. - Entries starting with `.` or ending in `.jsonc` are resolved relative to the directory of the configuration file that declares them. Absolute paths are used as they are. - Any other entry is resolved as a package, like `require` would in Node.js. -- An extended file can itself use `extends`; its entries are resolved relative to that file and applied before it. Files that extend each other in a cycle, or more than 10 levels deep, are an error. +- An extended file can itself use `extends`; its entries are resolved relative to that file, packages from its folder, and applied before it. Files that extend each other in a cycle, or more than 10 levels deep, are an error. ## Configuring a database connection From b7baaa7871aef94a3e08714420e8cdb3d0048a09 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 31 Oct 2026 09:10:49 +0000 Subject: [PATCH 17/17] fix(configuration): allow at most 10 configuration files in a chain of extends --- crates/pgls_workspace/src/configuration.rs | 45 ++++++++++++++-------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/pgls_workspace/src/configuration.rs b/crates/pgls_workspace/src/configuration.rs index 4231098b7..72c3a3e0c 100644 --- a/crates/pgls_workspace/src/configuration.rs +++ b/crates/pgls_workspace/src/configuration.rs @@ -886,7 +886,7 @@ fn apply_extends_transitively( )) .into()); } - if chain.len() > EXTENDS_DEPTH_MAX { + if chain.len() >= EXTENDS_DEPTH_MAX { return Err(ConfigurationDiagnostic::invalid_configuration(format!( "The configuration files extend each other more than {EXTENDS_DEPTH_MAX} \ levels deep, at {}", @@ -1155,6 +1155,24 @@ mod tests { assert!(error.to_string().contains("extend each other"), "{error}"); } + /// A file system with a chain of `files` configuration files, each extending the next. + fn extends_chain(files: usize) -> MemoryFileSystem { + let mut fs = file_system(); + let name = |level: usize| match level { + 0 => ConfigName::pgls_jsonc().to_string(), + level => format!("{level}.jsonc"), + }; + for level in 0..files { + let content = if level + 1 < files { + format!(r#"{{ "extends": ["./{}"] }}"#, name(level + 1)) + } else { + "{}".to_string() + }; + fs.insert(project_directory().join(name(level)), content); + } + fs + } + /// A package that an extended file extends is resolved from the folder of that file, like /// `require` in Node.js, so a shared configuration can depend on a package of its own. #[test] @@ -1200,25 +1218,18 @@ mod tests { } #[test] - fn extends_fails_when_too_deep() { - let mut fs = file_system(); - fs.insert( - project_directory().join(ConfigName::pgls_jsonc()), - r#"{ "extends": ["./0.jsonc"] }"#, - ); - for level in 0..=EXTENDS_DEPTH_MAX { - fs.insert( - project_directory().join(format!("{level}.jsonc")), - format!(r#"{{ "extends": ["./{}.jsonc"] }}"#, level + 1), - ); - } - fs.insert( - project_directory().join(format!("{}.jsonc", EXTENDS_DEPTH_MAX + 1)), - "{}", + fn extends_applies_a_chain_as_deep_as_the_limit() { + let loaded = load( + extends_chain(EXTENDS_DEPTH_MAX), + ConfigurationPathHint::FromWorkspace(project_directory()), ); + assert_eq!(loaded.extended_files.len(), EXTENDS_DEPTH_MAX - 1); + } + #[test] + fn extends_fails_when_too_deep() { let error = load_configuration( - &DynRef::Owned(Box::new(fs)), + &DynRef::Owned(Box::new(extends_chain(EXTENDS_DEPTH_MAX + 1))), ConfigurationPathHint::FromWorkspace(project_directory()), ) .expect_err("too deep");