diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0cf90195f..83a963f7d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -239,6 +239,8 @@ jobs: run: bun run test - name: Run cli test working-directory: packages/@postgres-language-server/cli + env: + PGLS_BINARY: ${{ github.workspace }}/target/release/postgres-language-server run: bun run test test-wasm: diff --git a/Cargo.lock b/Cargo.lock index 3b4c694ca..63c4ab0ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3028,6 +3028,7 @@ dependencies = [ name = "pgls_splinter" version = "0.0.0" dependencies = [ + "biome_deserialize 0.6.0", "insta", "pgls_analyse", "pgls_configuration", diff --git a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_detects_issues_snapshot.snap b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_detects_issues_snapshot.snap index db0348a93..dd1a2e091 100644 --- a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_detects_issues_snapshot.snap +++ b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_detects_issues_snapshot.snap @@ -5,8 +5,6 @@ snapshot_kind: text --- status: success stdout: -Warning: Deprecated config filename detected. Use 'postgres-language-server.jsonc'. - Command completed in . stderr: splinter/performance/noPrimaryKey ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_empty_database_snapshot.snap b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_empty_database_snapshot.snap index 449797f39..9d9e6c858 100644 --- a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_empty_database_snapshot.snap +++ b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_empty_database_snapshot.snap @@ -5,7 +5,5 @@ snapshot_kind: text --- status: success stdout: -Warning: Deprecated config filename detected. Use 'postgres-language-server.jsonc'. - Command completed in . stderr: diff --git a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_no_database_snapshot.snap b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_no_database_snapshot.snap index 0cbb89765..1cc720301 100644 --- a/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_no_database_snapshot.snap +++ b/crates/pgls_cli/tests/snapshots/assert_dblint__dblint_no_database_snapshot.snap @@ -5,7 +5,5 @@ snapshot_kind: text --- status: success stdout: -Warning: Deprecated config filename detected. Use 'postgres-language-server.jsonc'. - Command completed in . stderr: diff --git a/crates/pgls_configuration/src/lib.rs b/crates/pgls_configuration/src/lib.rs index 8d14a331b..33968f356 100644 --- a/crates/pgls_configuration/src/lib.rs +++ b/crates/pgls_configuration/src/lib.rs @@ -139,10 +139,11 @@ impl PartialConfiguration { ..Default::default() }), typecheck: Some(PartialTypecheckConfiguration { + enabled: Some(true), ..Default::default() }), plpgsql_check: Some(PartialPlPgSqlCheckConfiguration { - ..Default::default() + enabled: Some(true), }), db: Some(PartialDatabaseConfiguration { connection_string: None, @@ -197,3 +198,39 @@ impl ConfigurationPathHint { matches!(self, Self::FromLsp(_)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_config_has_no_empty_objects() { + let config = PartialConfiguration::init(); + let json_value: serde_json::Value = + serde_json::to_value(&config).expect("failed to serialize config"); + + fn find_empty_objects(value: &serde_json::Value, path: &str) -> Vec { + let mut empty_paths = Vec::new(); + if let serde_json::Value::Object(map) = value { + if map.is_empty() && !path.is_empty() { + empty_paths.push(path.to_string()); + } + for (key, val) in map { + let new_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + empty_paths.extend(find_empty_objects(val, &new_path)); + } + } + empty_paths + } + + let empty_objects = find_empty_objects(&json_value, ""); + assert!( + empty_objects.is_empty(), + "init() configuration should not contain empty objects. Found at: {empty_objects:?}" + ); + } +} diff --git a/crates/pgls_configuration/src/splinter/mod.rs b/crates/pgls_configuration/src/splinter/mod.rs index d0d795204..9ef7abfe8 100644 --- a/crates/pgls_configuration/src/splinter/mod.rs +++ b/crates/pgls_configuration/src/splinter/mod.rs @@ -4,6 +4,7 @@ mod options; pub use options::SplinterRuleOptions; mod rules; +use biome_deserialize::StringSet; use biome_deserialize_macros::{Merge, Partial}; use bpaf::Bpaf; pub use rules::*; @@ -16,6 +17,11 @@ pub struct SplinterConfiguration { #[doc = r" if `false`, it disables the feature and the linter won't be executed. `true` by default"] #[partial(bpaf(hide))] pub enabled: bool, + #[doc = r" A list of glob patterns for database objects to ignore across all rules."] + #[doc = r" Patterns use Unix-style globs where `*` matches any sequence of characters."] + #[doc = r#" Format: `schema.object_name`, e.g., "public.my_table", "audit.*""#] + #[partial(bpaf(hide))] + pub ignore: StringSet, #[doc = r" List of rules"] #[partial(bpaf(pure(Default::default()), optional, hide))] pub rules: Rules, @@ -24,11 +30,24 @@ impl SplinterConfiguration { pub const fn is_disabled(&self) -> bool { !self.enabled } + #[doc = r" Build a matcher from the global ignore patterns."] + #[doc = r" Returns None if no patterns are configured."] + pub fn get_global_ignore_matcher(&self) -> Option { + if self.ignore.is_empty() { + return None; + } + let mut m = pgls_matcher::Matcher::new(pgls_matcher::MatchOptions::default()); + for p in self.ignore.iter() { + let _ = m.add_pattern(p); + } + Some(m) + } } impl Default for SplinterConfiguration { fn default() -> Self { Self { enabled: true, + ignore: Default::default(), rules: Default::default(), } } diff --git a/crates/pgls_splinter/Cargo.toml b/crates/pgls_splinter/Cargo.toml index 15fa254a5..89822d053 100644 --- a/crates/pgls_splinter/Cargo.toml +++ b/crates/pgls_splinter/Cargo.toml @@ -21,9 +21,10 @@ serde_json.workspace = true sqlx.workspace = true [dev-dependencies] -insta.workspace = true -pgls_console.workspace = true -pgls_test_utils.workspace = true +biome_deserialize.workspace = true +insta.workspace = true +pgls_console.workspace = true +pgls_test_utils.workspace = true [lib] doctest = false diff --git a/crates/pgls_splinter/src/lib.rs b/crates/pgls_splinter/src/lib.rs index 73450fc59..a8403a7e1 100644 --- a/crates/pgls_splinter/src/lib.rs +++ b/crates/pgls_splinter/src/lib.rs @@ -6,7 +6,7 @@ pub mod rule; pub mod rules; use pgls_analyse::{AnalysisFilter, RegistryVisitor, RuleMeta}; -use pgls_configuration::splinter::Rules; +use pgls_configuration::splinter::SplinterConfiguration; use pgls_schema_cache::SchemaCache; use sqlx::PgPool; @@ -18,8 +18,8 @@ pub use rule::SplinterRule; pub struct SplinterParams<'a> { pub conn: &'a PgPool, pub schema_cache: Option<&'a SchemaCache>, - /// Optional rules configuration for per-rule database object filtering - pub rules_config: Option<&'a Rules>, + /// Optional splinter configuration for global and per-rule database object filtering + pub config: Option<&'a SplinterConfiguration>, } /// Visitor that collects enabled splinter rules based on filter @@ -138,24 +138,36 @@ pub async fn run_splinter( let mut diagnostics: Vec = results.into_iter().map(Into::into).collect(); - if let Some(rules_config) = params.rules_config { - let rule_matchers = rules_config.get_ignore_matchers(); + if let Some(config) = params.config { + // Build global ignore matcher if patterns exist + let global_ignore_matcher = config.get_global_ignore_matcher(); - if !rule_matchers.is_empty() { + // Get per-rule ignore matchers + let rule_matchers = config.rules.get_ignore_matchers(); + + // Filter diagnostics based on global and per-rule ignore patterns + if global_ignore_matcher.is_some() || !rule_matchers.is_empty() { diagnostics.retain(|diag| { - let rule_name = diag.category.name().split('/').next_back().unwrap_or(""); + let object_identifier = match (&diag.advices.schema, &diag.advices.object_name) { + (Some(schema), Some(name)) => format!("{schema}.{name}"), + _ => return true, // Keep diagnostics without schema.name + }; + + // Check global ignore first + if let Some(ref matcher) = global_ignore_matcher { + if matcher.matches(&object_identifier) { + return false; + } + } + // Then check per-rule ignore + let rule_name = diag.category.name().split('/').next_back().unwrap_or(""); if let Some(matcher) = rule_matchers.get(rule_name) { - if let (Some(schema), Some(name)) = - (&diag.advices.schema, &diag.advices.object_name) - { - let object_identifier = format!("{schema}.{name}"); - - if matcher.matches(&object_identifier) { - return false; - } + if matcher.matches(&object_identifier) { + return false; } } + true }); } diff --git a/crates/pgls_splinter/tests/diagnostics.rs b/crates/pgls_splinter/tests/diagnostics.rs index 316fce687..00e978078 100644 --- a/crates/pgls_splinter/tests/diagnostics.rs +++ b/crates/pgls_splinter/tests/diagnostics.rs @@ -1,6 +1,9 @@ +use biome_deserialize::StringSet; use pgls_analyse::AnalysisFilter; use pgls_configuration::rules::RuleConfiguration; -use pgls_configuration::splinter::{Performance, Rules, SplinterRuleOptions}; +use pgls_configuration::splinter::{ + Performance, Rules, SplinterConfiguration, SplinterRuleOptions, +}; use pgls_console::fmt::{Formatter, HTML}; use pgls_diagnostics::{Diagnostic, LogCategory, Visit}; use pgls_splinter::{SplinterParams, run_splinter}; @@ -66,7 +69,7 @@ impl TestSetup<'_> { SplinterParams { conn: self.test_db, schema_cache: None, - rules_config: None, + config: None, }, &filter, ) @@ -251,7 +254,7 @@ async fn missing_roles_runs_generic_checks_only(test_db: PgPool) { SplinterParams { conn: &test_db, schema_cache: None, - rules_config: None, + config: None, }, &filter, ) @@ -275,7 +278,7 @@ async fn missing_roles_runs_generic_checks_only(test_db: PgPool) { SplinterParams { conn: &test_db, schema_cache: None, - rules_config: None, + config: None, }, &filter, ) @@ -308,7 +311,7 @@ async fn ignore_filtering_filters_matching_objects(test_db: PgPool) { SplinterParams { conn: &test_db, schema_cache: None, - rules_config: None, + config: None, }, &filter, ) @@ -329,38 +332,41 @@ async fn ignore_filtering_filters_matching_objects(test_db: PgPool) { ); // Now run with ignore config that filters out some tables - let rules_config = Rules { - recommended: None, - all: None, - performance: Some(Performance { + let splinter_config = SplinterConfiguration { + rules: Rules { recommended: None, all: None, - auth_rls_initplan: None, - duplicate_index: None, - multiple_permissive_policies: None, - no_primary_key: Some(RuleConfiguration::WithOptions( - pgls_configuration::rules::RuleWithOptions { - level: pgls_configuration::rules::RulePlainConfiguration::Warn, - options: SplinterRuleOptions { - ignore: vec![ - "public.ignored_table".to_string(), - "public.another_*".to_string(), // glob pattern - ], + performance: Some(Performance { + recommended: None, + all: None, + auth_rls_initplan: None, + duplicate_index: None, + multiple_permissive_policies: None, + no_primary_key: Some(RuleConfiguration::WithOptions( + pgls_configuration::rules::RuleWithOptions { + level: pgls_configuration::rules::RulePlainConfiguration::Warn, + options: SplinterRuleOptions { + ignore: vec![ + "public.ignored_table".to_string(), + "public.another_*".to_string(), // glob pattern + ], + }, }, - }, - )), - table_bloat: None, - unindexed_foreign_keys: None, - unused_index: None, - }), - security: None, + )), + table_bloat: None, + unindexed_foreign_keys: None, + unused_index: None, + }), + security: None, + }, + ..Default::default() }; let diagnostics_with_ignore = run_splinter( SplinterParams { conn: &test_db, schema_cache: None, - rules_config: Some(&rules_config), + config: Some(&splinter_config), }, &filter, ) @@ -410,28 +416,31 @@ async fn ignore_filtering_with_schema_wildcard(test_db: PgPool) { .expect("Failed to create test tables"); // Run with ignore config that filters out all audit schema tables - let rules_config = Rules { - recommended: None, - all: None, - performance: Some(Performance { + let splinter_config = SplinterConfiguration { + rules: Rules { recommended: None, all: None, - auth_rls_initplan: None, - duplicate_index: None, - multiple_permissive_policies: None, - no_primary_key: Some(RuleConfiguration::WithOptions( - pgls_configuration::rules::RuleWithOptions { - level: pgls_configuration::rules::RulePlainConfiguration::Warn, - options: SplinterRuleOptions { - ignore: vec!["audit.*".to_string()], + performance: Some(Performance { + recommended: None, + all: None, + auth_rls_initplan: None, + duplicate_index: None, + multiple_permissive_policies: None, + no_primary_key: Some(RuleConfiguration::WithOptions( + pgls_configuration::rules::RuleWithOptions { + level: pgls_configuration::rules::RulePlainConfiguration::Warn, + options: SplinterRuleOptions { + ignore: vec!["audit.*".to_string()], + }, }, - }, - )), - table_bloat: None, - unindexed_foreign_keys: None, - unused_index: None, - }), - security: None, + )), + table_bloat: None, + unindexed_foreign_keys: None, + unused_index: None, + }), + security: None, + }, + ..Default::default() }; let filter = AnalysisFilter::default(); @@ -439,7 +448,7 @@ async fn ignore_filtering_with_schema_wildcard(test_db: PgPool) { SplinterParams { conn: &test_db, schema_cache: None, - rules_config: Some(&rules_config), + config: Some(&splinter_config), }, &filter, ) @@ -471,3 +480,138 @@ async fn ignore_filtering_with_schema_wildcard(test_db: PgPool) { "Expected remaining diagnostic to be for regular_table" ); } + +#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] +async fn global_ignore_filters_all_rules(test_db: PgPool) { + // Create tables in different schemas + sqlx::raw_sql( + r#" + CREATE SCHEMA audit; + CREATE TABLE audit.log_table (data text); + CREATE TABLE public.ignored_table (data text); + CREATE TABLE public.kept_table (data text); + "#, + ) + .execute(&test_db) + .await + .expect("Failed to create test tables"); + + // Run with global ignore config that filters out audit schema and a specific table + let splinter_config = SplinterConfiguration { + ignore: StringSet::from_iter(["audit.*".to_string(), "public.ignored_table".to_string()]), + ..Default::default() + }; + + let filter = AnalysisFilter::default(); + let diagnostics = run_splinter( + SplinterParams { + conn: &test_db, + schema_cache: None, + config: Some(&splinter_config), + }, + &filter, + ) + .await + .expect("Failed to run splinter checks"); + + // Filter to only noPrimaryKey diagnostics + let no_pk_diagnostics: Vec<_> = diagnostics + .iter() + .filter(|d| d.category.name().contains("noPrimaryKey")) + .collect(); + + // Should only have the public.kept_table diagnostic + assert_eq!( + no_pk_diagnostics.len(), + 1, + "Expected 1 noPrimaryKey diagnostic (audit.* and public.ignored_table should be globally ignored), got {}", + no_pk_diagnostics.len() + ); + + assert_eq!( + no_pk_diagnostics[0].advices.schema.as_deref(), + Some("public"), + "Expected remaining diagnostic to be in public schema" + ); + assert_eq!( + no_pk_diagnostics[0].advices.object_name.as_deref(), + Some("kept_table"), + "Expected remaining diagnostic to be for kept_table" + ); +} + +#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] +async fn global_ignore_combined_with_per_rule_ignore(test_db: PgPool) { + // Create tables to test combined ignore behavior + sqlx::raw_sql( + r#" + CREATE TABLE public.globally_ignored (data text); + CREATE TABLE public.rule_ignored (data text); + CREATE TABLE public.kept (data text); + "#, + ) + .execute(&test_db) + .await + .expect("Failed to create test tables"); + + // Run with both global and per-rule ignore + let splinter_config = SplinterConfiguration { + ignore: StringSet::from_iter(["public.globally_ignored".to_string()]), + rules: Rules { + recommended: None, + all: None, + performance: Some(Performance { + recommended: None, + all: None, + auth_rls_initplan: None, + duplicate_index: None, + multiple_permissive_policies: None, + no_primary_key: Some(RuleConfiguration::WithOptions( + pgls_configuration::rules::RuleWithOptions { + level: pgls_configuration::rules::RulePlainConfiguration::Warn, + options: SplinterRuleOptions { + ignore: vec!["public.rule_ignored".to_string()], + }, + }, + )), + table_bloat: None, + unindexed_foreign_keys: None, + unused_index: None, + }), + security: None, + }, + ..Default::default() + }; + + let filter = AnalysisFilter::default(); + let diagnostics = run_splinter( + SplinterParams { + conn: &test_db, + schema_cache: None, + config: Some(&splinter_config), + }, + &filter, + ) + .await + .expect("Failed to run splinter checks"); + + // Filter to only noPrimaryKey diagnostics + let no_pk_diagnostics: Vec<_> = diagnostics + .iter() + .filter(|d| d.category.name().contains("noPrimaryKey")) + .collect(); + + // Should only have the public.kept diagnostic + assert_eq!( + no_pk_diagnostics.len(), + 1, + "Expected 1 noPrimaryKey diagnostic (globally_ignored and rule_ignored should be filtered), got {}", + no_pk_diagnostics.len() + ); + + assert_eq!( + no_pk_diagnostics[0].advices.object_name.as_deref(), + Some("kept"), + "Expected remaining diagnostic to be for kept" + ); +} diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index c218a357d..cae45719d 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -333,6 +333,7 @@ fn to_linter_settings( fn to_splinter_settings(conf: SplinterConfiguration) -> SplinterSettings { SplinterSettings { enabled: conf.enabled, + ignore: conf.ignore, rules: Some(conf.rules), } } @@ -463,6 +464,9 @@ pub struct SplinterSettings { /// Enabled by default pub enabled: bool, + /// Global ignore patterns for database objects (applies to all rules) + pub ignore: StringSet, + /// List of rules pub rules: Option, } @@ -471,11 +475,23 @@ impl Default for SplinterSettings { fn default() -> Self { Self { enabled: true, + ignore: StringSet::default(), rules: Some(pgls_configuration::splinter::Rules::default()), } } } +impl SplinterSettings { + /// Convert settings back to SplinterConfiguration for use with splinter + pub fn to_configuration(&self) -> pgls_configuration::splinter::SplinterConfiguration { + pgls_configuration::splinter::SplinterConfiguration { + enabled: self.enabled, + ignore: self.ignore.clone(), + rules: self.rules.clone().unwrap_or_default(), + } + } +} + /// Type checking settings for the entire workspace #[derive(Debug)] pub struct PlPgSqlCheckSettings { diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 7c2ec8a31..8d54fd046 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -812,7 +812,7 @@ impl Workspace for WorkspaceServer { let pool_clone = pool.clone(); let schema_cache_clone = schema_cache.clone(); let categories = params.categories; - let rules_config = settings.splinter.rules.clone(); + let splinter_config = settings.splinter.to_configuration(); let splinter_result = run_async(async move { let filter = AnalysisFilter { categories, @@ -822,7 +822,7 @@ impl Workspace for WorkspaceServer { let splinter_params = pgls_splinter::SplinterParams { conn: &pool_clone, schema_cache: schema_cache_clone.as_deref(), - rules_config: rules_config.as_ref(), + config: Some(&splinter_config), }; pgls_splinter::run_splinter(splinter_params, &filter).await }); diff --git a/crates/pgls_workspace/src/workspace/server/analyser.rs b/crates/pgls_workspace/src/workspace/server/analyser.rs index cd0ef2fa3..f73289c2a 100644 --- a/crates/pgls_workspace/src/workspace/server/analyser.rs +++ b/crates/pgls_workspace/src/workspace/server/analyser.rs @@ -326,6 +326,7 @@ mod tests { let settings = Settings { splinter: SplinterSettings { enabled: true, + ignore: Default::default(), rules: Some(SplinterRules { performance: Some(Performance { auth_rls_initplan: Some(RuleConfiguration::Plain( @@ -371,6 +372,7 @@ mod tests { }, splinter: SplinterSettings { enabled: true, + ignore: Default::default(), rules: Some(SplinterRules { performance: Some(Performance { auth_rls_initplan: Some(RuleConfiguration::Plain( diff --git a/docs/codegen/src/main.rs b/docs/codegen/src/main.rs index 45dfc8397..786579718 100644 --- a/docs/codegen/src/main.rs +++ b/docs/codegen/src/main.rs @@ -5,8 +5,8 @@ use docs_codegen::cli_doc::generate_cli_doc; use docs_codegen::default_configuration::generate_default_configuration; use docs_codegen::env_variables::generate_env_variables; use docs_codegen::rules_docs::generate_rules_docs; -use docs_codegen::rules_index::generate_rules_index; -use docs_codegen::rules_sources::generate_rule_sources; +use docs_codegen::rules_index::{generate_rules_index, generate_splinter_rules_index}; +use docs_codegen::rules_sources::{generate_database_rule_sources, generate_rule_sources}; use docs_codegen::schema::generate_schema; use docs_codegen::splinter_docs::generate_splinter_docs; use docs_codegen::version::replace_version; @@ -26,7 +26,9 @@ fn main() -> anyhow::Result<()> { generate_rules_docs(&docs_root)?; generate_splinter_docs(&docs_root)?; generate_rules_index(&docs_root)?; + generate_splinter_rules_index(&docs_root)?; generate_rule_sources(&docs_root)?; + generate_database_rule_sources(&docs_root)?; generate_schema(&docs_root)?; replace_version(&docs_root)?; diff --git a/docs/codegen/src/rules_index.rs b/docs/codegen/src/rules_index.rs index b0a92f2f4..1b3e68f52 100644 --- a/docs/codegen/src/rules_index.rs +++ b/docs/codegen/src/rules_index.rs @@ -11,7 +11,7 @@ use std::{ str::{self}, }; -use crate::utils; +use crate::utils::{self, SplinterRuleMetadata}; /// Generates the lint rules index. /// @@ -123,3 +123,90 @@ fn generate_rule_summary(docs: &'static str) -> io::Result { panic!("No summary found in rule documentation"); } + +/// Generates the splinter (database linter) rules index. +/// +/// * `docs_dir`: Path to the docs directory. +pub fn generate_splinter_rules_index(docs_dir: &Path) -> anyhow::Result<()> { + let index_file = docs_dir.join("reference/database_rules.md"); + + let mut visitor = crate::utils::SplinterRulesVisitor::default(); + pgls_splinter::registry::visit_registry(&mut visitor); + + let crate::utils::SplinterRulesVisitor { groups } = visitor; + + let mut content = Vec::new(); + + for (group, rules) in groups { + generate_splinter_group(group, rules, &mut content)?; + } + + let new_content = String::from_utf8(content)?; + + let file_content = fs::read_to_string(&index_file)?; + + let new_content = utils::replace_section(&file_content, "SPLINTER_RULES_INDEX", &new_content); + + fs::write(index_file, new_content)?; + + Ok(()) +} + +fn generate_splinter_group( + group: &'static str, + rules: BTreeMap<&'static str, SplinterRuleMetadata>, + content: &mut dyn io::Write, +) -> io::Result<()> { + let (group_name, description) = extract_splinter_group_metadata(group); + + writeln!(content, "\n## {group_name}")?; + writeln!(content)?; + write_markup_to_string(content, description)?; + writeln!(content)?; + writeln!(content)?; + writeln!(content, "| Rule name | Description | Properties |")?; + writeln!(content, "| --- | --- | --- |")?; + + for (rule_name, rule_metadata) in rules { + let is_recommended = rule_metadata.metadata.recommended; + let requires_supabase = rule_metadata.requires_supabase; + let dashed_rule = Case::Kebab.convert(rule_name); + + let mut properties = String::new(); + if is_recommended { + properties.push_str("✅ "); + } + if requires_supabase { + properties.push('⚡'); + } + + let summary = rule_metadata.description; + + write!( + content, + "| [{rule_name}](./rules/{dashed_rule}.md) | {summary} | {properties} |" + )?; + + writeln!(content)?; + } + + Ok(()) +} + +fn extract_splinter_group_metadata(group: &str) -> (&str, Markup) { + match group { + "performance" => ( + "Performance", + markup! { + "Rules that detect potential performance issues in your database schema." + }, + ), + "security" => ( + "Security", + markup! { + "Rules that detect potential security vulnerabilities in your database schema." + }, + ), + _ => panic!("Unknown splinter group ID {group:?}"), + } +} diff --git a/docs/codegen/src/rules_sources.rs b/docs/codegen/src/rules_sources.rs index fe239e395..949c86a3c 100644 --- a/docs/codegen/src/rules_sources.rs +++ b/docs/codegen/src/rules_sources.rs @@ -7,6 +7,8 @@ use std::fs; use std::io::Write; use std::path::Path; +use crate::utils; + #[derive(Debug, Eq, PartialEq)] struct SourceSet { source_rule_name: String, @@ -78,7 +80,9 @@ pub fn generate_rule_sources(docs_dir: &Path) -> anyhow::Result<()> { "Many rules are inspired by or directly ported from other tools. This page lists the sources of each rule.", )?; - writeln!(buffer, "## Exclusive rules",)?; + writeln!(buffer)?; + writeln!(buffer, "## Exclusive rules")?; + writeln!(buffer)?; if exclusive_rules.is_empty() { writeln!(buffer, "_No exclusive rules available._")?; } @@ -86,10 +90,13 @@ pub fn generate_rule_sources(docs_dir: &Path) -> anyhow::Result<()> { writeln!(buffer, "- [{rule}]({link}) ")?; } - writeln!(buffer, "## Rules from other sources",)?; + writeln!(buffer)?; + writeln!(buffer, "## Rules from other sources")?; for (source, rules) in rules_by_source { + writeln!(buffer)?; writeln!(buffer, "### {source}")?; + writeln!(buffer)?; writeln!(buffer, r#"| {source} Rule Name | Rule Name |"#)?; writeln!(buffer, r#"| ---- | ---- |"#)?; @@ -103,6 +110,69 @@ pub fn generate_rule_sources(docs_dir: &Path) -> anyhow::Result<()> { Ok(()) } +pub fn generate_database_rule_sources(docs_dir: &Path) -> anyhow::Result<()> { + let rule_sources_file = docs_dir.join("reference/database_rule_sources.md"); + + let mut visitor = crate::utils::SplinterRulesVisitor::default(); + pgls_splinter::registry::visit_registry(&mut visitor); + + let crate::utils::SplinterRulesVisitor { groups } = visitor; + + let rules: Vec<_> = groups + .into_iter() + .flat_map(|(_, rules)| rules.into_iter()) + .collect(); + + // Group rules by source (currently all from Splinter) + let mut rules_by_source = BTreeMap::<&str, Vec<(&str, &str)>>::new(); + + for (rule_name, _metadata) in &rules { + let kebab_rule_name = Case::Kebab.convert(rule_name); + rules_by_source + .entry("Splinter") + .or_default() + .push((rule_name, Box::leak(kebab_rule_name.into_boxed_str()))); + } + + let new_content = generate_database_sources_content(&rules_by_source)?; + + let file_content = fs::read_to_string(&rule_sources_file)?; + + let new_content = utils::replace_section(&file_content, "DATABASE_RULE_SOURCES", &new_content); + + fs::write(rule_sources_file, new_content)?; + + Ok(()) +} + +fn generate_database_sources_content( + rules_by_source: &BTreeMap<&str, Vec<(&str, &str)>>, +) -> Result { + let mut buffer = Vec::new(); + + for (source, rules) in rules_by_source { + let source_url = match *source { + "Splinter" => "https://github.com/supabase/splinter", + _ => "", + }; + + writeln!(buffer)?; + writeln!(buffer, "### {source}")?; + writeln!(buffer)?; + writeln!(buffer, r#"| {source} Rule Name | Rule Name |"#)?; + writeln!(buffer, r#"| ---- | ---- |"#)?; + + for (rule_name, kebab_rule_name) in rules { + writeln!( + buffer, + "| [{rule_name}]({source_url}) | [{rule_name}](./rules/{kebab_rule_name}.md) |" + )?; + } + } + + Ok(String::from_utf8(buffer)?) +} + fn push_to_table(source_set: BTreeSet, buffer: &mut Vec) -> Result<()> { for source_set in source_set { write!( diff --git a/docs/features/database_linting.md b/docs/features/database_linting.md new file mode 100644 index 000000000..d29d2db24 --- /dev/null +++ b/docs/features/database_linting.md @@ -0,0 +1,135 @@ +# Database Linting + +The database linter analyzes your live Postgres database schema to detect performance issues, security vulnerabilities, and configuration problems. Unlike the [file-based linter](./linting.md) which checks SQL migration files, the database linter connects directly to your database and inspects the actual schema state. + +All database linting rules are powered by existing tools such as [Splinter](https://github.com/supabase/splinter). + +## Rules + +See the [Database Linter Rules Reference](../reference/database_rules.md) for the complete list of available rules and their descriptions. + +## Configuration + +Configure database linting behavior in your `postgres-language-server.jsonc`: + +```json +{ + "splinter": { + // Enable/disable the database linter entirely + "enabled": true, + "rules": { + // Configure rule groups + "performance": { + // Individual rule configuration + "noPrimaryKey": "warn", + "unusedIndex": "info" + }, + "security": { + "rlsDisabledInPublic": "error", + "authUsersExposed": "error" + } + } + } +} +``` + +## Ignoring Database Objects + +You can ignore specific database objects using glob patterns. Patterns use Unix-style globs where `*` matches any sequence of characters. Patterns should be in the format `schema.object_name`. + +### Global Ignore + +To ignore objects across all rules, use the top-level `ignore` field: + +```json +{ + "splinter": { + "ignore": [ + "audit.*", + "temp_*" + ], + "rules": { + // ... + } + } +} +``` + +This is useful for excluding entire schemas (like audit logs or temporary tables) from all database linting. + +### Per-Rule Ignore + +To ignore objects for a specific rule only, use the rule-level `ignore` option: + +```json +{ + "splinter": { + "rules": { + "performance": { + "noPrimaryKey": { + "level": "warn", + "options": { + "ignore": [ + "public.temp_*", + "staging.*" + ] + } + } + } + } + } +} +``` + +### Pattern Examples + +| Pattern | Matches | +|---------|---------| +| `public.my_table` | Specific table in public schema | +| `audit.*` | All objects in the audit schema | +| `*.temp_*` | Objects with temp_ prefix in any schema | +| `public.log_*` | Tables starting with log_ in public schema | + +## Supabase-Specific Rules + +Some rules are specifically designed for Supabase projects and will be automatically skipped if Supabase-specific database roles are not detected. These rules check for issues related to: + +- Auth schema exposure +- RLS policy configuration +- API schema security +- Supabase-specific extensions + +## CLI Usage + +The database linter can be run via the CLI: + +```bash +# Run database linting +postgres-language-server dblint + +# With specific rules +postgres-language-server dblint --only security/rlsDisabledInPublic + +# Skip certain rules +postgres-language-server dblint --skip performance/tableBloat +``` + +See the [CLI Reference](../reference/cli.md) for more options. + +## Database Connection + +The database linter requires a database connection to analyze the schema. Configure your connection in `postgres-language-server.jsonc`: + +```json +{ + "db": { + "host": "127.0.0.1", + "port": 5432, + "database": "postgres", + "username": "postgres", + "password": "postgres" + } +} +``` + +See the [database connection guide](../guides/configure_database.md) for more details. diff --git a/docs/reference/database_rule_sources.md b/docs/reference/database_rule_sources.md new file mode 100644 index 000000000..22fd9a783 --- /dev/null +++ b/docs/reference/database_rule_sources.md @@ -0,0 +1,39 @@ +# Database Linter Rule Sources + +Many database linter rules are inspired by or directly ported from other tools. This page lists the sources of each rule. + +## Exclusive rules + +_No exclusive rules available._ + +## Rules from other sources + +[//]: # (BEGIN DATABASE_RULE_SOURCES) + +### Splinter + +| Splinter Rule Name | Rule Name | +| ---- | ---- | +| [authRlsInitplan](https://github.com/supabase/splinter) | [authRlsInitplan](./rules/auth-rls-initplan.md) | +| [duplicateIndex](https://github.com/supabase/splinter) | [duplicateIndex](./rules/duplicate-index.md) | +| [multiplePermissivePolicies](https://github.com/supabase/splinter) | [multiplePermissivePolicies](./rules/multiple-permissive-policies.md) | +| [noPrimaryKey](https://github.com/supabase/splinter) | [noPrimaryKey](./rules/no-primary-key.md) | +| [tableBloat](https://github.com/supabase/splinter) | [tableBloat](./rules/table-bloat.md) | +| [unindexedForeignKeys](https://github.com/supabase/splinter) | [unindexedForeignKeys](./rules/unindexed-foreign-keys.md) | +| [unusedIndex](https://github.com/supabase/splinter) | [unusedIndex](./rules/unused-index.md) | +| [authUsersExposed](https://github.com/supabase/splinter) | [authUsersExposed](./rules/auth-users-exposed.md) | +| [extensionInPublic](https://github.com/supabase/splinter) | [extensionInPublic](./rules/extension-in-public.md) | +| [extensionVersionsOutdated](https://github.com/supabase/splinter) | [extensionVersionsOutdated](./rules/extension-versions-outdated.md) | +| [fkeyToAuthUnique](https://github.com/supabase/splinter) | [fkeyToAuthUnique](./rules/fkey-to-auth-unique.md) | +| [foreignTableInApi](https://github.com/supabase/splinter) | [foreignTableInApi](./rules/foreign-table-in-api.md) | +| [functionSearchPathMutable](https://github.com/supabase/splinter) | [functionSearchPathMutable](./rules/function-search-path-mutable.md) | +| [insecureQueueExposedInApi](https://github.com/supabase/splinter) | [insecureQueueExposedInApi](./rules/insecure-queue-exposed-in-api.md) | +| [materializedViewInApi](https://github.com/supabase/splinter) | [materializedViewInApi](./rules/materialized-view-in-api.md) | +| [policyExistsRlsDisabled](https://github.com/supabase/splinter) | [policyExistsRlsDisabled](./rules/policy-exists-rls-disabled.md) | +| [rlsDisabledInPublic](https://github.com/supabase/splinter) | [rlsDisabledInPublic](./rules/rls-disabled-in-public.md) | +| [rlsEnabledNoPolicy](https://github.com/supabase/splinter) | [rlsEnabledNoPolicy](./rules/rls-enabled-no-policy.md) | +| [rlsReferencesUserMetadata](https://github.com/supabase/splinter) | [rlsReferencesUserMetadata](./rules/rls-references-user-metadata.md) | +| [securityDefinerView](https://github.com/supabase/splinter) | [securityDefinerView](./rules/security-definer-view.md) | +| [unsupportedRegTypes](https://github.com/supabase/splinter) | [unsupportedRegTypes](./rules/unsupported-reg-types.md) | + +[//]: # (END DATABASE_RULE_SOURCES) diff --git a/docs/reference/database_rules.md b/docs/reference/database_rules.md new file mode 100644 index 000000000..80f2873c8 --- /dev/null +++ b/docs/reference/database_rules.md @@ -0,0 +1,49 @@ +# Database Linter Rules + +Below is the list of database linting rules supported by the Postgres Language Server, divided by group. These rules analyze your live database schema to detect issues. + +All rules are powered by [Splinter](https://github.com/supabase/splinter). + +Here's a legend of the emojis: + +- The icon ✅ indicates that the rule is part of the recommended rules. +- The icon ⚡ indicates that the rule requires a Supabase database. + +[//]: # (BEGIN SPLINTER_RULES_INDEX) + +## Performance + +Rules that detect potential performance issues in your database schema. + +| Rule name | Description | Properties | +| --- | --- | --- | +| [authRlsInitplan](./rules/auth-rls-initplan.md) | Detects if calls to \`current_setting()\` and \`auth.()\` in RLS policies are being unnecessarily re-evaluated for each row | ✅ ⚡ | +| [duplicateIndex](./rules/duplicate-index.md) | Detects cases where two ore more identical indexes exist. | ✅ | +| [multiplePermissivePolicies](./rules/multiple-permissive-policies.md) | Detects if multiple permissive row level security policies are present on a table for the same \`role\` and \`action\` (e.g. insert). Multiple permissive policies are suboptimal for performance as each policy must be executed for every relevant query. | ✅ | +| [noPrimaryKey](./rules/no-primary-key.md) | Detects if a table does not have a primary key. Tables without a primary key can be inefficient to interact with at scale. | ✅ | +| [tableBloat](./rules/table-bloat.md) | Detects if a table has excess bloat and may benefit from maintenance operations like vacuum full or cluster. | ✅ | +| [unindexedForeignKeys](./rules/unindexed-foreign-keys.md) | Identifies foreign key constraints without a covering index, which can impact database performance. | ✅ | +| [unusedIndex](./rules/unused-index.md) | Detects if an index has never been used and may be a candidate for removal. | ✅ | + +## Security + +Rules that detect potential security vulnerabilities in your database schema. + +| Rule name | Description | Properties | +| --- | --- | --- | +| [authUsersExposed](./rules/auth-users-exposed.md) | Detects if auth.users is exposed to anon or authenticated roles via a view or materialized view in schemas exposed to PostgREST, potentially compromising user data security. | ✅ ⚡ | +| [extensionInPublic](./rules/extension-in-public.md) | Detects extensions installed in the \`public\` schema. | ✅ | +| [extensionVersionsOutdated](./rules/extension-versions-outdated.md) | Detects extensions that are not using the default (recommended) version. | ✅ | +| [fkeyToAuthUnique](./rules/fkey-to-auth-unique.md) | Detects user defined foreign keys to unique constraints in the auth schema. | ✅ ⚡ | +| [foreignTableInApi](./rules/foreign-table-in-api.md) | Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies. | ✅ ⚡ | +| [functionSearchPathMutable](./rules/function-search-path-mutable.md) | Detects functions where the search_path parameter is not set. | ✅ | +| [insecureQueueExposedInApi](./rules/insecure-queue-exposed-in-api.md) | Detects cases where an insecure Queue is exposed over Data APIs | ✅ ⚡ | +| [materializedViewInApi](./rules/materialized-view-in-api.md) | Detects materialized views that are accessible over the Data APIs. | ✅ ⚡ | +| [policyExistsRlsDisabled](./rules/policy-exists-rls-disabled.md) | Detects cases where row level security (RLS) policies have been created, but RLS has not been enabled for the underlying table. | ✅ | +| [rlsDisabledInPublic](./rules/rls-disabled-in-public.md) | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | ✅ ⚡ | +| [rlsEnabledNoPolicy](./rules/rls-enabled-no-policy.md) | Detects cases where row level security (RLS) has been enabled on a table but no RLS policies have been created. | ✅ | +| [rlsReferencesUserMetadata](./rules/rls-references-user-metadata.md) | Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy. | ✅ ⚡ | +| [securityDefinerView](./rules/security-definer-view.md) | Detects views defined with the SECURITY DEFINER property. These views enforce Postgres permissions and row level security policies (RLS) of the view creator, rather than that of the querying user | ✅ ⚡ | +| [unsupportedRegTypes](./rules/unsupported-reg-types.md) | Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade. | ✅ | + +[//]: # (END SPLINTER_RULES_INDEX) diff --git a/docs/reference/rule_sources.md b/docs/reference/rule_sources.md index f61c78d02..5828da7f6 100644 --- a/docs/reference/rule_sources.md +++ b/docs/reference/rule_sources.md @@ -1,9 +1,14 @@ # Rule Sources Many rules are inspired by or directly ported from other tools. This page lists the sources of each rule. + ## Exclusive rules + _No exclusive rules available._ + ## Rules from other sources + ### Eugene + | Eugene Rule Name | Rule Name | | ---- | ---- | | [E11](https://kaveland.no/eugene/hints/E11/index.html) |[addSerialColumn](./rules/add-serial-column.md) | @@ -12,7 +17,9 @@ _No exclusive rules available._ | [E9](https://kaveland.no/eugene/hints/E9/index.html) |[lockTimeoutWarning](./rules/lock-timeout-warning.md) | | [W12](https://kaveland.no/eugene/hints/W12/index.html) |[multipleAlterTable](./rules/multiple-alter-table.md) | | [W13](https://kaveland.no/eugene/hints/W13/index.html) |[creatingEnum](./rules/creating-enum.md) | + ### Squawk + | Squawk Rule Name | Rule Name | | ---- | ---- | | [adding-field-with-default](https://squawkhq.com/docs/adding-field-with-default) |[addingFieldWithDefault](./rules/adding-field-with-default.md) | diff --git a/docs/schema.json b/docs/schema.json index 71db27702..8aa6756ef 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -1063,6 +1063,17 @@ "null" ] }, + "ignore": { + "description": "A list of glob patterns for database objects to ignore across all rules. Patterns use Unix-style globs where `*` matches any sequence of characters. Format: `schema.object_name`, e.g., \"public.my_table\", \"audit.*\"", + "anyOf": [ + { + "$ref": "#/definitions/StringSet" + }, + { + "type": "null" + } + ] + }, "rules": { "description": "List of rules", "anyOf": [ diff --git a/flake.nix b/flake.nix index 200bf73db..0424b85d2 100644 --- a/flake.nix +++ b/flake.nix @@ -28,9 +28,22 @@ # Read rust-toolchain.toml to get the exact Rust version rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + # Nightly toolchain for rustfmt (used by codegen) + rustNightly = pkgs.rust-bin.nightly.latest.minimal.override { + extensions = [ "rustfmt" ]; + }; + + # Extract just nightly rustfmt (to avoid nightly rustc taking precedence) + nightlyRustfmtOnly = pkgs.runCommand "nightly-rustfmt" { } '' + mkdir -p $out/bin + ln -s ${rustNightly}/bin/rustfmt $out/bin/rustfmt + ''; + # Development dependencies buildInputs = with pkgs; [ - # Rust toolchain + # Nightly rustfmt (for codegen) - must come before stable toolchain + nightlyRustfmtOnly + # Rust toolchain (stable from rust-toolchain.toml) rustToolchain # Node.js ecosystem @@ -48,6 +61,7 @@ # Build tools just git + taplo # Docker docker-compose @@ -64,6 +78,9 @@ # WebAssembly toolchain emscripten + + # Database tools + sqlx-cli ]; # Environment variables @@ -81,21 +98,7 @@ inherit buildInputs; hardeningDisable = [ "fortify" ]; shellHook = '' - echo "PostgreSQL Language Server Development Environment" - echo "Available tools:" - echo " • Rust $(rustc --version)" - echo " • Node.js $(node --version)" - echo " • Bun $(bun --version)" - echo " • Just $(just --version)" - echo "" - echo "Development Commands:" - echo " • just --list # Show tasks" - echo " • cargo check # Check Rust" - echo " • bun install # Install deps" - echo "" - echo "Use Docker for database:" - echo " • docker-compose up -d" - echo "" + echo "Postgres Language Server Development Environment" # Set environment variables ${pkgs.lib.concatStringsSep "\n" ( diff --git a/mkdocs.yml b/mkdocs.yml index 99966100d..2f9bcd5b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Features: - Syntax Diagnostics: features/syntax_diagnostics.md - Linting: features/linting.md + - Database Linting: features/database_linting.md - Type Checking: features/type_checking.md - PL/pgSQL Support: features/plpgsql.md - Autocompletion & Hover: features/editor_features.md @@ -32,7 +33,9 @@ nav: - Reference: - CLI Commands: reference/cli.md - Linter Rules: reference/rules.md - - Rule Sources: reference/rule_sources.md + - Linter Rule Sources: reference/rule_sources.md + - Database Linter Rules: reference/database_rules.md + - Database Linter Rule Sources: reference/database_rule_sources.md - Environment Variables: reference/env_variables.md plugins: diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index 69d727961..087d30601 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -425,6 +425,10 @@ export interface PartialSplinterConfiguration { * if `false`, it disables the feature and the linter won't be executed. `true` by default */ enabled?: boolean; + /** + * A list of glob patterns for database objects to ignore across all rules. Patterns use Unix-style globs where `*` matches any sequence of characters. Format: `schema.object_name`, e.g., "public.my_table", "audit.*" + */ + ignore?: StringSet; /** * List of rules */ diff --git a/packages/@postgres-language-server/cli/bin/postgres-language-server b/packages/@postgres-language-server/cli/bin/postgres-language-server index ed898fb64..0875a6ed0 100755 --- a/packages/@postgres-language-server/cli/bin/postgres-language-server +++ b/packages/@postgres-language-server/cli/bin/postgres-language-server @@ -1,5 +1,6 @@ #!/usr/bin/env node const { platform, arch, env } = process; +const { execSync } = require("child_process"); const PLATFORMS = { win32: { diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index 69d727961..087d30601 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -425,6 +425,10 @@ export interface PartialSplinterConfiguration { * if `false`, it disables the feature and the linter won't be executed. `true` by default */ enabled?: boolean; + /** + * A list of glob patterns for database objects to ignore across all rules. Patterns use Unix-style globs where `*` matches any sequence of characters. Format: `schema.object_name`, e.g., "public.my_table", "audit.*" + */ + ignore?: StringSet; /** * List of rules */ diff --git a/postgrestools.jsonc b/postgres-language-server.jsonc similarity index 57% rename from postgrestools.jsonc rename to postgres-language-server.jsonc index 47d08c729..f7d3040d6 100644 --- a/postgrestools.jsonc +++ b/postgres-language-server.jsonc @@ -1,5 +1,6 @@ { - "$schema": "./docs/schema.json", + "$schema": "https://pg-language-server.com/latest/schema.json", + "extends": [], "vcs": { "enabled": false, "clientKind": "git", @@ -14,7 +15,16 @@ "recommended": true } }, - // YOU CAN COMMENT ME OUT :) + "splinter": { + "enabled": true + }, + "typecheck": { + "enabled": true + }, + "plpgsqlCheck": { + "enabled": true + }, + // you can write comments here :)) "db": { "host": "127.0.0.1", "port": 5432, @@ -22,6 +32,7 @@ "password": "postgres", "database": "postgres", "connTimeoutSecs": 10, - "allowStatementExecutionsAgainst": ["127.0.0.1/*", "localhost/*"] + "disableConnection": false } } + diff --git a/xtask/codegen/src/generate_configuration.rs b/xtask/codegen/src/generate_configuration.rs index 0f243909e..7a5cd91f7 100644 --- a/xtask/codegen/src/generate_configuration.rs +++ b/xtask/codegen/src/generate_configuration.rs @@ -214,12 +214,54 @@ fn generate_lint_mod_file(tool: &ToolConfig) -> String { quote! {} }; - let string_set_import = if handles_files { + let string_set_import = if handles_files || tool.name == "splinter" { quote! { use biome_deserialize::StringSet; } } else { quote! {} }; + // For splinter, add global ignore patterns for database objects + let is_splinter = tool.name == "splinter"; + + let splinter_ignore_field = if is_splinter { + quote! { + /// A list of glob patterns for database objects to ignore across all rules. + /// Patterns use Unix-style globs where `*` matches any sequence of characters. + /// Format: `schema.object_name`, e.g., "public.my_table", "audit.*" + #[partial(bpaf(hide))] + pub ignore: StringSet, + } + } else { + quote! {} + }; + + let splinter_ignore_default = if is_splinter { + quote! { + ignore: Default::default(), + } + } else { + quote! {} + }; + + let splinter_ignore_method = if is_splinter { + quote! { + /// Build a matcher from the global ignore patterns. + /// Returns None if no patterns are configured. + pub fn get_global_ignore_matcher(&self) -> Option { + if self.ignore.is_empty() { + return None; + } + let mut m = pgls_matcher::Matcher::new(pgls_matcher::MatchOptions::default()); + for p in self.ignore.iter() { + let _ = m.add_pattern(p); + } + Some(m) + } + } + } else { + quote! {} + }; + let content = quote! { //! Generated file, do not edit by hand, see `xtask/codegen` @@ -242,6 +284,8 @@ fn generate_lint_mod_file(tool: &ToolConfig) -> String { #[partial(bpaf(hide))] pub enabled: bool, + #splinter_ignore_field + /// List of rules #[partial(bpaf(pure(Default::default()), optional, hide))] pub rules: Rules, @@ -253,12 +297,15 @@ fn generate_lint_mod_file(tool: &ToolConfig) -> String { pub const fn is_disabled(&self) -> bool { !self.enabled } + + #splinter_ignore_method } impl Default for #config_struct { fn default() -> Self { Self { enabled: true, + #splinter_ignore_default rules: Default::default(), #file_defaults } diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs index 89f4cce39..4412dfc49 100644 --- a/xtask/src/lib.rs +++ b/xtask/src/lib.rs @@ -30,7 +30,12 @@ pub fn project_root() -> PathBuf { pub fn run_rustfmt(mode: Mode) -> Result<()> { let _dir = pushd(project_root()); - let _e = pushenv("RUSTUP_TOOLCHAIN", "nightly"); + // Only set RUSTUP_TOOLCHAIN if nightly isn't already on PATH (e.g., in Nix) + let _e = if !is_nightly_rustfmt_available() { + Some(pushenv("RUSTUP_TOOLCHAIN", "nightly")) + } else { + None + }; ensure_rustfmt()?; match mode { Mode::Overwrite => run!("cargo fmt"), @@ -55,7 +60,12 @@ pub fn prepend_generated_preamble(content: impl Display) -> String { } pub fn reformat_without_preamble(text: impl Display) -> Result { - let _e = pushenv("RUSTUP_TOOLCHAIN", "nightly"); + // Only set RUSTUP_TOOLCHAIN if nightly isn't already on PATH (e.g., in Nix) + let _e = if !is_nightly_rustfmt_available() { + Some(pushenv("RUSTUP_TOOLCHAIN", "nightly")) + } else { + None + }; ensure_rustfmt()?; let output = run!( "rustfmt --config newline_style=Unix"; @@ -65,6 +75,13 @@ pub fn reformat_without_preamble(text: impl Display) -> Result { Ok(format!("{output}\n")) } +/// Check if nightly rustfmt is already available on PATH (e.g., provided by Nix) +fn is_nightly_rustfmt_available() -> bool { + run!("rustfmt --version") + .map(|out| out.contains("nightly")) + .unwrap_or(false) +} + pub fn ensure_rustfmt() -> Result<()> { let out = run!("rustfmt --version")?; if !out.contains("nightly") {