From 896511f8a4458acf27f9a91f7bc3d241a6e672ad Mon Sep 17 00:00:00 2001 From: psteinroe Date: Mon, 26 Oct 2026 14:39:46 +0000 Subject: [PATCH] refactor(workspace): move the code actions into their own module features/code_actions.rs only held the request and result types, while the logic of each action lived in workspace/server.rs, server/expand_star.rs and server/statement_execution.rs. Each action now has a submodule: - lint: the fixes and suppressions of lint diagnostics, and fix_file - expand_star: moved from workspace/server - execute_statement: its types, the "Execute Statement" actions, and the execution against the database (moved from server/statement_execution) - schema_cache: the "Invalidate Schema Cache" action The server keeps gathering the inputs (documents, settings, snapshot, connection) and calls into the module. The types are re-exported from features::code_actions, so the public API is unchanged. No behaviour change. --- .../src/features/code_actions.rs | 104 ++------ .../code_actions/execute_statement.rs | 93 +++++++ .../execute_statement/execution.rs} | 6 +- .../code_actions}/expand_star.rs | 4 +- .../src/features/code_actions/lint.rs | 209 +++++++++++++++ .../src/features/code_actions/schema_cache.rs | 15 ++ crates/pgls_workspace/src/workspace/server.rs | 243 +++--------------- 7 files changed, 372 insertions(+), 302 deletions(-) create mode 100644 crates/pgls_workspace/src/features/code_actions/execute_statement.rs rename crates/pgls_workspace/src/{workspace/server/statement_execution.rs => features/code_actions/execute_statement/execution.rs} (97%) rename crates/pgls_workspace/src/{workspace/server => features/code_actions}/expand_star.rs (98%) create mode 100644 crates/pgls_workspace/src/features/code_actions/lint.rs create mode 100644 crates/pgls_workspace/src/features/code_actions/schema_cache.rs diff --git a/crates/pgls_workspace/src/features/code_actions.rs b/crates/pgls_workspace/src/features/code_actions.rs index 8c7d6e32f..b5162cb1a 100644 --- a/crates/pgls_workspace/src/features/code_actions.rs +++ b/crates/pgls_workspace/src/features/code_actions.rs @@ -1,9 +1,24 @@ +//! The code actions offered at the cursor, and the commands and fixes they run: lint fixes and +//! suppressions (`lint`), expanding a `*` into its columns (`expand_star`), executing the +//! statement (`execute_statement`), and invalidating the schema cache (`schema_cache`). + use crate::workspace::StatementId; use pgls_analyse::{ActionCategory, Applicability}; use pgls_configuration::RuleSelector; use pgls_fs::PgLSPath; use pgls_text_size::{TextRange, TextSize}; +pub(crate) mod execute_statement; +pub(crate) mod expand_star; +pub(crate) mod lint; +pub(crate) mod schema_cache; + +pub use execute_statement::{ + EXECUTE_STATEMENT_MAX_ROWS, ExecuteStatementError, ExecuteStatementParams, + ExecuteStatementResult, +}; +pub use lint::{FixFileMode, FixFileParams, FixFileResult}; + #[derive(Debug, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct CodeActionsParams { @@ -67,92 +82,3 @@ pub enum CommandActionCategory { ExecuteStatement(StatementId), InvalidateSchemaCache, } - -#[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, -} - -/// The most rows an executed statement returns; the rest are dropped and the result is marked -/// as truncated. -pub const EXECUTE_STATEMENT_MAX_ROWS: usize = 100; - -/// The outcome of executing a statement. -/// -/// A statement that returns rows (`select`, `values`, `returning`, `show`, `explain`, ...) fills -/// `columns` and `rows`. Any other statement sets `rows_affected` instead. -#[derive(Debug, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ExecuteStatementResult { - /// A short summary for the user. - pub message: String, - /// The names of the returned columns. - #[serde(default)] - pub columns: Vec, - /// The returned rows, at most [`EXECUTE_STATEMENT_MAX_ROWS`]. Each value is in its Postgres - /// text representation, as `psql` shows it, and `null` for SQL `NULL`. - #[serde(default)] - pub rows: Vec>>, - /// Whether the statement returned more rows than `rows` holds. - #[serde(default)] - pub truncated: bool, - /// The number of rows affected, for a statement that returns no rows. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rows_affected: Option, - /// Why Postgres rejected the statement, if it did. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// An error that Postgres raised while executing a statement. -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ExecuteStatementError { - /// The primary error message, e.g. `division by zero`. - pub message: String, - /// The SQLSTATE error code, e.g. `22012`. - pub code: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hint: Option, - /// The offset of the error in the document, if Postgres reported a position. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub position: Option, -} - -#[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, - pub skip: Vec, -} - -/// Which fixes `fix_file` applies. -#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub enum FixFileMode { - /// Only the fixes that are safe to apply. - SafeFixes, - /// The safe and the unsafe fixes. - SafeAndUnsafeFixes, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct FixFileResult { - /// The content of the file with the fixes applied. - pub code: String, - /// The number of fixes that were applied. - pub fixes_applied: u32, - /// The number of unsafe fixes that were not applied with [FixFileMode::SafeFixes]. - pub skipped_suggested_fixes: u32, -} diff --git a/crates/pgls_workspace/src/features/code_actions/execute_statement.rs b/crates/pgls_workspace/src/features/code_actions/execute_statement.rs new file mode 100644 index 000000000..6a6974a57 --- /dev/null +++ b/crates/pgls_workspace/src/features/code_actions/execute_statement.rs @@ -0,0 +1,93 @@ +//! The command that executes the statement at the cursor against the database. + +use pgls_fs::PgLSPath; +use pgls_text_size::TextSize; + +use super::{CodeAction, CodeActionKind, CommandAction, CommandActionCategory}; +use crate::workspace::{DefaultMapper, Document, StatementId}; + +#[cfg(feature = "db")] +pub(crate) mod execution; + +#[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, +} + +/// The most rows an executed statement returns; the rest are dropped and the result is marked +/// as truncated. +pub const EXECUTE_STATEMENT_MAX_ROWS: usize = 100; + +/// The outcome of executing a statement. +/// +/// A statement that returns rows (`select`, `values`, `returning`, `show`, `explain`, ...) fills +/// `columns` and `rows`. Any other statement sets `rows_affected` instead. +#[derive(Debug, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ExecuteStatementResult { + /// A short summary for the user. + pub message: String, + /// The names of the returned columns. + #[serde(default)] + pub columns: Vec, + /// The returned rows, at most [`EXECUTE_STATEMENT_MAX_ROWS`]. Each value is in its Postgres + /// text representation, as `psql` shows it, and `null` for SQL `NULL`. + #[serde(default)] + pub rows: Vec>>, + /// Whether the statement returned more rows than `rows` holds. + #[serde(default)] + pub truncated: bool, + /// The number of rows affected, for a statement that returns no rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rows_affected: Option, + /// Why Postgres rejected the statement, if it did. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// An error that Postgres raised while executing a statement. +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ExecuteStatementError { + /// The primary error message, e.g. `division by zero`. + pub message: String, + /// The SQLSTATE error code, e.g. `22012`. + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, + /// The offset of the error in the document, if Postgres reported a position. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +/// The actions that execute the statements at `cursor`. `disabled_reason` says why statement +/// execution isn't allowed, if it isn't. +pub(crate) fn execute_statement_actions( + doc: &Document, + cursor: TextSize, + disabled_reason: Option, +) -> Vec { + doc.iter_at(DefaultMapper, cursor) + .map(|(stmt, _, txt)| { + let title = format!( + "Execute Statement: {}...", + txt.chars().take(50).collect::() + ); + + CodeAction { + title, + kind: CodeActionKind::Command(CommandAction { + category: CommandActionCategory::ExecuteStatement(stmt), + }), + disabled_reason: disabled_reason.clone(), + } + }) + .collect() +} diff --git a/crates/pgls_workspace/src/workspace/server/statement_execution.rs b/crates/pgls_workspace/src/features/code_actions/execute_statement/execution.rs similarity index 97% rename from crates/pgls_workspace/src/workspace/server/statement_execution.rs rename to crates/pgls_workspace/src/features/code_actions/execute_statement/execution.rs index 18c81288d..2e5460385 100644 --- a/crates/pgls_workspace/src/workspace/server/statement_execution.rs +++ b/crates/pgls_workspace/src/features/code_actions/execute_statement/execution.rs @@ -5,10 +5,8 @@ use sqlx::{ postgres::{PgColumn, PgDatabaseError, PgErrorPosition, PgRow}, }; +use super::{EXECUTE_STATEMENT_MAX_ROWS, ExecuteStatementError, ExecuteStatementResult}; use crate::diagnostics::is_connection_failure; -use crate::features::code_actions::{ - EXECUTE_STATEMENT_MAX_ROWS, ExecuteStatementError, ExecuteStatementResult, -}; /// Executes `sql` and collects the rows it returns, if any. /// @@ -18,7 +16,7 @@ use crate::features::code_actions::{ /// /// Each statement runs on its own: afterwards, the session is reset, so a `begin` or `set` does /// not carry over to the next statement on the connection. -pub(super) async fn execute_statement( +pub(crate) async fn execute_statement( pool: &PgPool, sql: &str, ) -> Result { diff --git a/crates/pgls_workspace/src/workspace/server/expand_star.rs b/crates/pgls_workspace/src/features/code_actions/expand_star.rs similarity index 98% rename from crates/pgls_workspace/src/workspace/server/expand_star.rs rename to crates/pgls_workspace/src/features/code_actions/expand_star.rs index 2b7807087..bcca1c520 100644 --- a/crates/pgls_workspace/src/workspace/server/expand_star.rs +++ b/crates/pgls_workspace/src/features/code_actions/expand_star.rs @@ -9,7 +9,7 @@ use crate::features::code_actions::{CodeAction, CodeActionKind, EditAction, Text /// The action that replaces the `*` or `t.*` of `reference` with its columns. `content` is the /// text of the file, and the statement of the reference starts at `stmt_start`. -pub(super) fn expand_star_action( +pub(crate) fn expand_star_action( reference: &Reference, content: &str, stmt_start: TextSize, @@ -44,7 +44,7 @@ pub(super) fn expand_star_action( /// `select *` or `select u.*`. The line of the cursor is scanned on its own: `*` in comments /// and strings, `count(*)`, and multiplications don't count. If the line doesn't scan, like /// a line inside a multi-line string, any `*` on it counts. -pub(super) fn is_at_star(content: &str, position: TextSize) -> bool { +pub(crate) fn is_at_star(content: &str, position: TextSize) -> bool { let offset = usize::from(position).min(content.len()); let line_start = content[..offset].rfind('\n').map_or(0, |i| i + 1); let line_end = content[offset..] diff --git a/crates/pgls_workspace/src/features/code_actions/lint.rs b/crates/pgls_workspace/src/features/code_actions/lint.rs new file mode 100644 index 000000000..6549d6df3 --- /dev/null +++ b/crates/pgls_workspace/src/features/code_actions/lint.rs @@ -0,0 +1,209 @@ +//! The lint code actions: the fixes of the lint diagnostics, the actions that suppress them, and +//! applying all fixes of a file. + +use std::borrow::Cow; + +use pgls_analyse::{ActionCategory, Applicability, SUPPRESSION_ACTION_CATEGORY}; +use pgls_analyser::{LinterDiagnostic, RuleFix}; +use pgls_configuration::RuleSelector; +use pgls_diagnostics::Diagnostic; +use pgls_fs::PgLSPath; +use pgls_text_size::{TextRange, TextSize}; +use tracing::warn; + +use super::{CodeAction, CodeActionKind, EditAction, TextEdit}; +use crate::workspace::Document; + +#[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, + pub skip: Vec, +} + +/// Which fixes `fix_file` applies. +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum FixFileMode { + /// Only the fixes that are safe to apply. + SafeFixes, + /// The safe and the unsafe fixes. + SafeAndUnsafeFixes, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct FixFileResult { + /// The content of the file with the fixes applied. + pub code: String, + /// The number of fixes that were applied. + pub fixes_applied: u32, + /// The number of unsafe fixes that were not applied with [FixFileMode::SafeFixes]. + pub skipped_suggested_fixes: u32, +} + +/// The fixes of the lint diagnostics at `cursor`, and an action to suppress each of them. +/// `diagnostics` are the lint diagnostics of `doc` that are not suppressed. +pub(crate) fn lint_actions( + diagnostics: &[LinterDiagnostic], + doc: &Document, + cursor: TextSize, +) -> Vec { + let mut actions = Vec::new(); + for diagnostic in diagnostics { + let Some(span) = diagnostic.location().span else { + continue; + }; + if !span.contains_inclusive(cursor) { + continue; + } + let category = diagnostic.get_category_name(); + let rule = category.strip_prefix("lint/").unwrap_or(category); + + if let Some(fix) = diagnostic.get_fix() { + actions.push(CodeAction { + title: fix.message.clone(), + kind: CodeActionKind::Edit(EditAction { + rule: Some(category.to_string()), + category: ActionCategory::QuickFix(Cow::Owned(rule.to_string())), + applicability: fix.applicability, + edits: fix + .edits + .iter() + .map(|edit| TextEdit { + range: edit.range, + new_text: edit.replacement.clone(), + }) + .collect(), + }), + disabled_reason: None, + }); + } + + actions.push(CodeAction { + title: format!("Suppress rule {rule} for this statement"), + kind: CodeActionKind::Edit(EditAction { + rule: Some(category.to_string()), + category: ActionCategory::Other(Cow::Borrowed(SUPPRESSION_ACTION_CATEGORY)), + applicability: Applicability::MaybeIncorrect, + edits: vec![suppression_edit(doc, span.start(), rule)], + }), + disabled_reason: None, + }); + } + actions +} + +/// Applies the fixes of the lint diagnostics to `code`, the content of the file at `path`, until +/// no fix is left. `lint` returns the lint diagnostics of a document that are not suppressed. +pub(crate) fn fix_file( + path: &PgLSPath, + mut code: String, + mode: FixFileMode, + mut lint: impl FnMut(&Document) -> Vec, +) -> FixFileResult { + /// Fixes can enable other fixes, e.g. `CONCURRENTLY` enables `IF NOT EXISTS`, and fixes + /// that overlap are applied one after the other. + const MAX_PASSES: usize = 10; + + let mut fixes_applied = 0; + let mut skipped_suggested_fixes = 0; + let mut converged = false; + for _ in 0..MAX_PASSES { + let doc = Document::new(code.clone(), 0); + let diagnostics = lint(&doc); + + skipped_suggested_fixes = 0; + let mut fixes: Vec<&RuleFix> = Vec::new(); + for fix in diagnostics.iter().filter_map(LinterDiagnostic::get_fix) { + if fix.applicability == Applicability::Always || mode == FixFileMode::SafeAndUnsafeFixes + { + fixes.push(fix); + } else { + skipped_suggested_fixes += 1; + } + } + fixes.sort_by_key(|fix| fix.range().map(TextRange::start)); + + // Applies the fixes that don't touch the text of an earlier fix in this pass. + let mut combined = RuleFix::new(""); + let mut applied = 0; + for fix in fixes { + let Some(range) = fix.range() else { + continue; + }; + if combined + .range() + .is_some_and(|previous| range.start() <= previous.end()) + { + continue; + } + combined.edits.extend(fix.edits.iter().cloned()); + applied += 1; + } + if applied == 0 { + converged = true; + break; + } + code = combined.apply(&code); + fixes_applied += applied; + } + if !converged { + warn!( + ?path, + "Fixes still changed the file after {MAX_PASSES} passes, some may be left" + ); + } + + FixFileResult { + code, + fixes_applied, + skipped_suggested_fixes, + } +} + +/// The edit that adds `-- pgls-ignore ` on its own line above the statement at `offset`, +/// with the statement's indentation and the file's line ending. Above the statement, the comment +/// suppresses the rule for all of the statement, including the bodies of its functions. +fn suppression_edit(doc: &Document, offset: TextSize, rule: &str) -> TextEdit { + let content = doc.get_document_content(); + let start = doc + .statement_ranges() + .iter() + .find(|statement| statement.contains_inclusive(offset)) + .map_or(offset, |statement| statement.start()); + let line_ending = match content.find('\n') { + Some(newline) if content[..newline].ends_with('\r') => "\r\n", + _ => "\n", + }; + + let start = usize::from(start); + let line_start = content[..start] + .rfind('\n') + .map_or(0, |newline| newline + 1); + let before = &content[line_start..start]; + let indentation: String = before + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let offset = |offset: usize| TextSize::try_from(offset).unwrap(); + if before.trim().is_empty() { + TextEdit { + range: TextRange::empty(offset(line_start)), + new_text: format!("{indentation}-- pgls-ignore {rule}{line_ending}"), + } + } else { + // The statement starts after another one on the same line, so it moves to a line of its + // own. + let previous_end = line_start + before.trim_end().len(); + TextEdit { + range: TextRange::new(offset(previous_end), offset(start)), + new_text: format!( + "{line_ending}{indentation}-- pgls-ignore {rule}{line_ending}{indentation}" + ), + } + } +} diff --git a/crates/pgls_workspace/src/features/code_actions/schema_cache.rs b/crates/pgls_workspace/src/features/code_actions/schema_cache.rs new file mode 100644 index 000000000..0c4b6c892 --- /dev/null +++ b/crates/pgls_workspace/src/features/code_actions/schema_cache.rs @@ -0,0 +1,15 @@ +//! The command that drops the cached database schemas, so that they are loaded again. + +use super::{CodeAction, CodeActionKind, CommandAction, CommandActionCategory}; + +/// The action that invalidates the schema cache. `disabled_reason` says why it isn't available, +/// if it isn't. +pub(crate) fn invalidate_schema_cache_action(disabled_reason: Option) -> CodeAction { + CodeAction { + title: "Invalidate Schema Cache".into(), + kind: CodeActionKind::Command(CommandAction { + category: CommandActionCategory::InvalidateSchemaCache, + }), + disabled_reason, + } +} diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 2222a477a..d4c4df773 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -7,6 +7,8 @@ use std::{ sync::{Arc, Mutex, RwLock}, }; +#[cfg(feature = "db")] +use crate::features::code_actions::execute_statement::execution; use analyser::AnalyserVisitorBuilder; #[cfg(feature = "db")] use async_helper::run_async; @@ -14,24 +16,20 @@ use async_helper::run_async; use connection_manager::ConnectionManager; #[cfg(feature = "db")] use document::TypecheckDiagnosticsMapper; -use document::{DefaultMapper, Document, ExecuteStatementMapper, FormatStatementMapper}; +use document::{Document, ExecuteStatementMapper, FormatStatementMapper}; #[cfg(feature = "db")] use futures::{StreamExt, TryStreamExt, stream}; #[cfg(feature = "db")] use pg_query::convert_to_positional_params_with_metadata; -use pgls_analyse::{ - ActionCategory, AnalysisFilter, Applicability, RuleCategories, RuleCategoriesBuilder, - RuleFilter, SUPPRESSION_ACTION_CATEGORY, -}; +use pgls_analyse::{AnalysisFilter, RuleCategories, RuleCategoriesBuilder, RuleFilter}; #[cfg(feature = "db")] use pgls_analyser::StatementAnalysis; use pgls_analyser::{ Analyser, AnalyserConfig, AnalyserParams, AnalysisResult, Direction, FileKind, - LinterDiagnostic, LinterOptions, MigrationSection, RuleFix, + LinterDiagnostic, LinterOptions, MigrationSection, }; use pgls_catalog::resolve::{ColumnSource, Reference, ResolveParams, resolve}; use pgls_catalog::{Catalog, CatalogBase, Origin, Session, TransactionMode}; -use std::borrow::Cow; use pgls_catalog::Snapshot; use pgls_configuration::RuleSelector; @@ -45,16 +43,15 @@ use pgls_text_size::{TextRange, TextSize}; use pgls_typecheck::TypecheckParams; use pgls_workspace_macros::ignored_path; use snapshot_manager::SnapshotManager; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; use crate::{ WorkspaceError, configuration::to_analyser_rules, features::{ code_actions::{ - CodeAction, CodeActionKind, CodeActionsParams, CodeActionsResult, CommandAction, - CommandActionCategory, EditAction, ExecuteStatementParams, ExecuteStatementResult, - FixFileMode, FixFileParams, FixFileResult, TextEdit, + self, CodeAction, CodeActionsParams, CodeActionsResult, ExecuteStatementParams, + ExecuteStatementResult, FixFileParams, FixFileResult, }, completions::{CompletionsResult, GetCompletionsParams, get_statement_for_completions}, diagnostics::{ @@ -87,15 +84,12 @@ mod connection_key; #[cfg(feature = "db")] mod connection_manager; pub(crate) mod document; -mod expand_star; mod migration; mod pg_query; #[cfg(feature = "db")] mod pgpass; mod snapshot_manager; mod sql_function; -#[cfg(feature = "db")] -mod statement_execution; mod statement_identifier; mod tree_sitter; @@ -344,60 +338,6 @@ impl WorkspaceServer { ) } - /// The fixes of the lint diagnostics at the cursor, and an action to suppress each of them. - fn lint_code_actions( - &self, - settings: &Settings, - params: &CodeActionsParams, - doc: &Document, - ) -> Vec { - let mut actions = Vec::new(); - for diagnostic in - self.lint_diagnostics(settings, ¶ms.path, doc, ¶ms.only, ¶ms.skip) - { - let Some(span) = diagnostic.location().span else { - continue; - }; - if !span.contains_inclusive(params.cursor_position) { - continue; - } - let category = diagnostic.get_category_name(); - let rule = category.strip_prefix("lint/").unwrap_or(category); - - if let Some(fix) = diagnostic.get_fix() { - actions.push(CodeAction { - title: fix.message.clone(), - kind: CodeActionKind::Edit(EditAction { - rule: Some(category.to_string()), - category: ActionCategory::QuickFix(Cow::Owned(rule.to_string())), - applicability: fix.applicability, - edits: fix - .edits - .iter() - .map(|edit| TextEdit { - range: edit.range, - new_text: edit.replacement.clone(), - }) - .collect(), - }), - disabled_reason: None, - }); - } - - actions.push(CodeAction { - title: format!("Suppress rule {rule} for this statement"), - kind: CodeActionKind::Edit(EditAction { - rule: Some(category.to_string()), - category: ActionCategory::Other(Cow::Borrowed(SUPPRESSION_ACTION_CATEGORY)), - applicability: Applicability::MaybeIncorrect, - edits: vec![suppression_edit(doc, span.start(), rule)], - }), - disabled_reason: None, - }); - } - actions - } - /// The refactors offered at `position`: expanding a `*` into its columns. `settings` are the /// ones of the file. fn refactor_code_actions( @@ -409,7 +349,7 @@ impl WorkspaceServer { // Code actions are requested whenever the cursor moves, so don't resolve the file unless // the cursor is on a `*` of a select list. let content = doc.get_document_content(); - if !expand_star::is_at_star(content, position) { + if !code_actions::expand_star::is_at_star(content, position) { return Vec::new(); } @@ -429,7 +369,9 @@ impl WorkspaceServer { let (stmt_start, references) = self.references_at(settings, doc, &snapshot, position); references .iter() - .filter_map(|reference| expand_star::expand_star_action(reference, content, stmt_start)) + .filter_map(|reference| { + code_actions::expand_star::expand_star_action(reference, content, stmt_start) + }) .collect() } @@ -1100,34 +1042,22 @@ impl Workspace for WorkspaceServer { None => Some("Statement execution not allowed against database.".into()), }; - let mut actions: Vec = parser - .iter_at(DefaultMapper, params.cursor_position) - .map(|(stmt, _, txt)| { - let title = format!( - "Execute Statement: {}...", - txt.chars().take(50).collect::() - ); - - CodeAction { - title, - kind: CodeActionKind::Command(CommandAction { - category: CommandActionCategory::ExecuteStatement(stmt), - }), - disabled_reason: disabled_reason.clone(), - } - }) - .collect(); - - actions.splice( - 0..0, - self.refactor_code_actions(settings, parser, params.cursor_position), - ); - + let mut actions = Vec::new(); if let Some(settings) = settings { - let mut lint_actions = self.lint_code_actions(settings, ¶ms, parser); - lint_actions.append(&mut actions); - actions = lint_actions; + let diagnostics = + self.lint_diagnostics(settings, ¶ms.path, parser, ¶ms.only, ¶ms.skip); + actions.extend(code_actions::lint::lint_actions( + &diagnostics, + parser, + params.cursor_position, + )); } + actions.extend(self.refactor_code_actions(settings, parser, params.cursor_position)); + actions.extend(code_actions::execute_statement::execute_statement_actions( + parser, + params.cursor_position, + disabled_reason, + )); #[cfg(feature = "db")] let invalidate_disabled_reason = match settings { @@ -1146,28 +1076,20 @@ impl Workspace for WorkspaceServer { let invalidate_disabled_reason: Option = Some("Database support not enabled.".into()); - actions.push(CodeAction { - title: "Invalidate Schema Cache".into(), - kind: CodeActionKind::Command(CommandAction { - category: CommandActionCategory::InvalidateSchemaCache, - }), - disabled_reason: invalidate_disabled_reason, - }); + actions.push(code_actions::schema_cache::invalidate_schema_cache_action( + invalidate_disabled_reason, + )); Ok(CodeActionsResult { actions }) } #[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. - const MAX_PASSES: usize = 10; - let documents = self.documents.read().unwrap(); let doc = documents .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; - let mut code = doc.get_document_content().to_string(); + let code = doc.get_document_content().to_string(); let settings = self.workspaces(); let Some(settings) = settings.settings_for(params.project_key, params.path.as_path()) @@ -1179,62 +1101,12 @@ impl Workspace for WorkspaceServer { }); }; - let mut fixes_applied = 0; - let mut skipped_suggested_fixes = 0; - let mut converged = false; - for _ in 0..MAX_PASSES { - let doc = Document::new(code.clone(), 0); - let diagnostics = - self.lint_diagnostics(settings, ¶ms.path, &doc, ¶ms.only, ¶ms.skip); - - skipped_suggested_fixes = 0; - let mut fixes: Vec<&RuleFix> = Vec::new(); - for fix in diagnostics.iter().filter_map(LinterDiagnostic::get_fix) { - if fix.applicability == Applicability::Always - || params.fix_file_mode == FixFileMode::SafeAndUnsafeFixes - { - fixes.push(fix); - } else { - skipped_suggested_fixes += 1; - } - } - fixes.sort_by_key(|fix| fix.range().map(TextRange::start)); - - // Applies the fixes that don't touch the text of an earlier fix in this pass. - let mut combined = RuleFix::new(""); - let mut applied = 0; - for fix in fixes { - let Some(range) = fix.range() else { - continue; - }; - if combined - .range() - .is_some_and(|previous| range.start() <= previous.end()) - { - continue; - } - combined.edits.extend(fix.edits.iter().cloned()); - applied += 1; - } - if applied == 0 { - converged = true; - break; - } - code = combined.apply(&code); - fixes_applied += applied; - } - if !converged { - warn!( - path = ?params.path, - "Fixes still changed the file after {MAX_PASSES} passes, some may be left" - ); - } - - Ok(FixFileResult { + Ok(code_actions::lint::fix_file( + ¶ms.path, code, - fixes_applied, - skipped_suggested_fixes, - }) + params.fix_file_mode, + |doc| self.lint_diagnostics(settings, ¶ms.path, doc, ¶ms.only, ¶ms.skip), + )) } #[cfg(feature = "db")] @@ -1285,7 +1157,7 @@ impl Workspace for WorkspaceServer { let Some(result) = self.connection.with_pools(&settings.db, |pools| { let pool = pools.statement_pool.clone(); - run_async(async move { statement_execution::execute_statement(&pool, &content).await })? + run_async(async move { execution::execute_statement(&pool, &content).await })? .map_err(WorkspaceError::from) }) else { return Ok(no_connection); @@ -2159,49 +2031,6 @@ fn document_sections(settings: &Settings, doc: &Document) -> Vec` on its own line above the statement at `offset`, -/// with the statement's indentation and the file's line ending. Above the statement, the comment -/// suppresses the rule for all of the statement, including the bodies of its functions. -fn suppression_edit(doc: &Document, offset: TextSize, rule: &str) -> TextEdit { - let content = doc.get_document_content(); - let start = doc - .statement_ranges() - .iter() - .find(|statement| statement.contains_inclusive(offset)) - .map_or(offset, |statement| statement.start()); - let line_ending = match content.find('\n') { - Some(newline) if content[..newline].ends_with('\r') => "\r\n", - _ => "\n", - }; - - let start = usize::from(start); - let line_start = content[..start] - .rfind('\n') - .map_or(0, |newline| newline + 1); - let before = &content[line_start..start]; - let indentation: String = before - .chars() - .take_while(|c| *c == ' ' || *c == '\t') - .collect(); - let offset = |offset: usize| TextSize::try_from(offset).unwrap(); - if before.trim().is_empty() { - TextEdit { - range: TextRange::empty(offset(line_start)), - new_text: format!("{indentation}-- pgls-ignore {rule}{line_ending}"), - } - } else { - // The statement starts after another one on the same line, so it moves to a line of its - // own. - let previous_end = line_start + before.trim_end().len(); - TextEdit { - range: TextRange::new(offset(previous_end), offset(start)), - new_text: format!( - "{line_ending}{indentation}-- pgls-ignore {rule}{line_ending}{indentation}" - ), - } - } -} - /// Returns `true` if `path` is a directory or /// if it is a symlink that resolves to a directory. /// Keeps at most one blank line in `text`, which only holds whitespace and comments.