diff --git a/Cargo.lock b/Cargo.lock index 09684985..222e79b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,9 +500,9 @@ dependencies = [ [[package]] name = "biome_string_case" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28b4d0e08c2f13f1c9e0df4e7a8f9bfa03ef3803713d1bcd5110578cc5c67be" +checksum = "5868798da491b19a5b27a0bad5d8727e1e65060fa2dac360b382df00ff520774" [[package]] name = "biome_text_edit" @@ -1298,6 +1298,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -2216,6 +2225,33 @@ dependencies = [ "indexmap 2.7.0", ] +[[package]] +name = "pg_analyse" +version = "0.0.0" +dependencies = [ + "biome_deserialize", + "biome_deserialize_macros", + "enumflags2", + "pg_console", + "pg_diagnostics", + "pg_query_ext", + "pg_schema_cache", + "rustc-hash 2.1.0", + "schemars", + "serde", + "text-size", +] + +[[package]] +name = "pg_analyser" +version = "0.0.0" +dependencies = [ + "pg_analyse", + "pg_console", + "pg_query_ext", + "serde", +] + [[package]] name = "pg_base_db" version = "0.0.0" @@ -2238,6 +2274,7 @@ dependencies = [ "libc", "mimalloc", "path-absolutize", + "pg_analyse", "pg_configuration", "pg_console", "pg_diagnostics", @@ -2290,8 +2327,11 @@ dependencies = [ "biome_deserialize", "biome_deserialize_macros", "bpaf", + "pg_analyse", + "pg_analyser", "pg_console", "pg_diagnostics", + "rustc-hash 2.1.0", "schemars", "serde", "serde_json", @@ -2428,8 +2468,11 @@ dependencies = [ name = "pg_lint" version = "0.0.0" dependencies = [ + "enumflags2", "lazy_static", "pg_base_db", + "pg_console", + "pg_diagnostics", "pg_query_ext", "pg_syntax", "serde", @@ -2488,6 +2531,7 @@ dependencies = [ "anyhow", "biome_deserialize", "futures", + "pg_analyse", "pg_configuration", "pg_console", "pg_diagnostics", @@ -2534,9 +2578,11 @@ name = "pg_query_ext" version = "0.0.0" dependencies = [ "petgraph", + "pg_diagnostics", "pg_lexer", "pg_query", "pg_query_ext_codegen", + "text-size", ] [[package]] @@ -2669,6 +2715,8 @@ dependencies = [ "dashmap 5.5.3", "futures", "ignore", + "pg_analyse", + "pg_analyser", "pg_configuration", "pg_console", "pg_diagnostics", @@ -2676,6 +2724,7 @@ dependencies = [ "pg_query_ext", "pg_schema_cache", "pg_statement_splitter", + "rustc-hash 2.1.0", "serde", "serde_json", "sqlx", @@ -2945,6 +2994,25 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.6.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quick-junit" version = "0.5.1" @@ -3127,6 +3195,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rules_check" +version = "0.0.0" +dependencies = [ + "anyhow", + "pg_analyse", + "pg_analyser", + "pg_console", + "pg_diagnostics", + "pg_query_ext", + "pg_statement_splitter", + "pg_workspace_new", + "pulldown-cmark", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4222,6 +4305,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-bidi" version = "0.3.17" @@ -4751,7 +4840,14 @@ dependencies = [ name = "xtask_codegen" version = "0.0.0" dependencies = [ + "anyhow", + "biome_string_case", "bpaf", + "pg_analyse", + "pg_analyser", + "proc-macro2", + "pulldown-cmark", + "quote", "xtask", ] diff --git a/Cargo.toml b/Cargo.toml index d804b35f..54a18bd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/*", "lib/*", "xtask/codegen"] +members = ["crates/*", "lib/*", "xtask/codegen", "xtask/rules_check"] resolver = "2" [workspace.package] @@ -17,6 +17,7 @@ rust-version = "1.82.0" anyhow = "1.0.92" biome_deserialize = "0.6.0" biome_deserialize_macros = "0.6.0" +biome_string_case = "0.5.8" bpaf = { version = "0.9.15", features = ["derive"] } crossbeam = "0.8.4" enumflags2 = "0.7.10" @@ -46,6 +47,8 @@ tree_sitter_sql = { path = "./lib/tree_sitter_sql", version = "0.0.0" } unicode-width = "0.1.12" # postgres specific crates +pg_analyse = { path = "./crates/pg_analyse", version = "0.0.0" } +pg_analyser = { path = "./crates/pg_analyser", version = "0.0.0" } pg_base_db = { path = "./crates/pg_base_db", version = "0.0.0" } pg_cli = { path = "./crates/pg_cli", version = "0.0.0" } pg_commands = { path = "./crates/pg_commands", version = "0.0.0" } diff --git a/crates/pg_analyse/Cargo.toml b/crates/pg_analyse/Cargo.toml new file mode 100644 index 00000000..464d5c50 --- /dev/null +++ b/crates/pg_analyse/Cargo.toml @@ -0,0 +1,30 @@ + +[package] +authors.workspace = true +categories.workspace = true +description = "" +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +name = "pg_analyse" +repository.workspace = true +version = "0.0.0" + + +[dependencies] +pg_console.workspace = true +pg_diagnostics.workspace = true +pg_query_ext.workspace = true +pg_schema_cache.workspace = true +rustc-hash = { workspace = true } + +biome_deserialize = { workspace = true, optional = true } +biome_deserialize_macros = { workspace = true, optional = true } +enumflags2.workspace = true +schemars = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"], optional = true } +text-size.workspace = true + +[features] +serde = ["dep:serde", "dep:schemars", "dep:biome_deserialize", "dep:biome_deserialize_macros"] diff --git a/crates/pg_analyse/src/categories.rs b/crates/pg_analyse/src/categories.rs new file mode 100644 index 00000000..11117e25 --- /dev/null +++ b/crates/pg_analyse/src/categories.rs @@ -0,0 +1,339 @@ +use enumflags2::{bitflags, BitFlags}; +use std::borrow::Cow; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub enum RuleCategory { + /// This rule performs static analysis of the source code to detect + /// invalid or error-prone patterns, and emits diagnostics along with + /// proposed fixes + Lint, + /// This rule detects refactoring opportunities and emits code action + /// signals + Action, + /// This rule detects transformations that should be applied to the code + Transformation, +} + +/// Actions that suppress rules should start with this string +pub const SUPPRESSION_ACTION_CATEGORY: &str = "quickfix.suppressRule"; + +/// The category of a code action, this type maps directly to the +/// [CodeActionKind] type in the Language Server Protocol specification +/// +/// [CodeActionKind]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionKind +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub enum ActionCategory { + /// Base kind for quickfix actions: 'quickfix'. + /// + /// This action provides a fix to the diagnostic emitted by the same signal + QuickFix(Cow<'static, str>), + /// Base kind for refactoring actions: 'refactor'. + /// + /// This action provides an optional refactor opportunity + Refactor(RefactorKind), + /// Base kind for source actions: `source`. + /// + /// Source code actions apply to the entire file. + Source(SourceActionKind), + /// This action is using a base kind not covered by any of the previous + /// variants + Other(Cow<'static, str>), +} + +impl ActionCategory { + /// Returns true if this category matches the provided filter + /// + /// ## Examples + /// + /// ``` + /// use std::borrow::Cow; + /// use pg_analyse::{ActionCategory, RefactorKind}; + /// + /// assert!(ActionCategory::QuickFix(Cow::from("quickfix")).matches("quickfix")); + /// + /// assert!(ActionCategory::Refactor(RefactorKind::None).matches("refactor")); + /// assert!(!ActionCategory::Refactor(RefactorKind::None).matches("refactor.extract")); + /// + /// assert!(ActionCategory::Refactor(RefactorKind::Extract).matches("refactor")); + /// assert!(ActionCategory::Refactor(RefactorKind::Extract).matches("refactor.extract")); + /// ``` + pub fn matches(&self, filter: &str) -> bool { + self.to_str().starts_with(filter) + } + + /// Returns the representation of this [ActionCategory] as a `CodeActionKind` string + pub fn to_str(&self) -> Cow<'static, str> { + match self { + ActionCategory::QuickFix(tag) => { + if tag.is_empty() { + Cow::Borrowed("quickfix.pglsp") + } else { + Cow::Owned(format!("quickfix.pglsp.{tag}")) + } + } + + ActionCategory::Refactor(RefactorKind::None) => Cow::Borrowed("refactor.pglsp"), + ActionCategory::Refactor(RefactorKind::Extract) => { + Cow::Borrowed("refactor.extract.pglsp") + } + ActionCategory::Refactor(RefactorKind::Inline) => { + Cow::Borrowed("refactor.inline.pglsp") + } + ActionCategory::Refactor(RefactorKind::Rewrite) => { + Cow::Borrowed("refactor.rewrite.pglsp") + } + ActionCategory::Refactor(RefactorKind::Other(tag)) => { + Cow::Owned(format!("refactor.{tag}.pglsp")) + } + + ActionCategory::Source(SourceActionKind::None) => Cow::Borrowed("source.pglsp"), + ActionCategory::Source(SourceActionKind::FixAll) => { + Cow::Borrowed("source.fixAll.pglsp") + } + ActionCategory::Source(SourceActionKind::OrganizeImports) => { + Cow::Borrowed("source.organizeImports.pglsp") + } + ActionCategory::Source(SourceActionKind::Other(tag)) => { + Cow::Owned(format!("source.{tag}.pglsp")) + } + + ActionCategory::Other(tag) => Cow::Owned(format!("{tag}.pglsp")), + } + } +} + +/// The sub-category of a refactor code action. +/// +/// [Check the LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionKind) for more information: +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub enum RefactorKind { + /// This action describes a refactor with no particular sub-category + None, + /// Base kind for refactoring extraction actions: 'refactor.extract'. + /// + /// Example extract actions: + /// - Extract method + /// - Extract function + /// - Extract variable + /// - Extract interface from class + Extract, + /// Base kind for refactoring inline actions: 'refactor.inline'. + /// + /// Example inline actions: + /// - Inline function + /// - Inline variable + /// - Inline constant + /// - ... + Inline, + /// Base kind for refactoring rewrite actions: 'refactor.rewrite'. + /// + /// Example rewrite actions: + /// - Convert JavaScript function to class + /// - Add or remove parameter + /// - Encapsulate field + /// - Make method static + /// - Move method to base class + /// - ... + Rewrite, + /// This action is using a refactor kind not covered by any of the previous + /// variants + Other(Cow<'static, str>), +} + +/// The sub-category of a source code action +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub enum SourceActionKind { + /// This action describes a source action with no particular sub-category + None, + // Base kind for a 'fix all' source action: `source.fixAll`. + // + // 'Fix all' actions automatically fix errors that have a clear fix that + // do not require user input. They should not suppress errors or perform + // unsafe fixes such as generating new types or classes. + FixAll, + /// Base kind for an organize imports source action: `source.organizeImports`. + OrganizeImports, + /// This action is using a source action kind not covered by any of the + /// previous variants + Other(Cow<'static, str>), +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[bitflags] +#[repr(u8)] +pub(crate) enum Categories { + Lint = 1 << RuleCategory::Lint as u8, + Action = 1 << RuleCategory::Action as u8, + Transformation = 1 << RuleCategory::Transformation as u8, +} + +#[derive(Debug, Copy, Clone)] +/// The categories supported by the analyser. +/// +/// The default implementation of this type returns an instance with all the categories. +/// +/// Use [RuleCategoriesBuilder] to generate the categories you want to query. +pub struct RuleCategories(BitFlags); + +impl RuleCategories { + pub fn empty() -> Self { + let empty: BitFlags = BitFlags::empty(); + Self(empty) + } + + pub fn all() -> Self { + let empty: BitFlags = BitFlags::all(); + Self(empty) + } + + /// Checks whether the current categories contain a specific [RuleCategories] + pub fn contains(&self, other: impl Into) -> bool { + self.0.contains(other.into().0) + } +} + +impl Default for RuleCategories { + fn default() -> Self { + Self::all() + } +} + +impl From for RuleCategories { + fn from(input: RuleCategory) -> Self { + match input { + RuleCategory::Lint => RuleCategories(BitFlags::from_flag(Categories::Lint)), + RuleCategory::Action => RuleCategories(BitFlags::from_flag(Categories::Action)), + RuleCategory::Transformation => { + RuleCategories(BitFlags::from_flag(Categories::Transformation)) + } + } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RuleCategories { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut flags = Vec::new(); + + if self.0.contains(Categories::Lint) { + flags.push(RuleCategory::Lint); + } + + if self.0.contains(Categories::Action) { + flags.push(RuleCategory::Action); + } + + if self.0.contains(Categories::Transformation) { + flags.push(RuleCategory::Transformation); + } + + serializer.collect_seq(flags) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RuleCategories { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, SeqAccess}; + use std::fmt::{self, Formatter}; + + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = RuleCategories; + + fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { + write!(formatter, "RuleCategories") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut result = RuleCategories::empty(); + + while let Some(item) = seq.next_element::()? { + result.0 |= RuleCategories::from(item).0; + } + + Ok(result) + } + } + + deserializer.deserialize_seq(Visitor) + } +} + +#[cfg(feature = "serde")] +impl schemars::JsonSchema for RuleCategories { + fn schema_name() -> String { + String::from("RuleCategories") + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + >::json_schema(gen) + } +} + +#[derive(Debug, Default)] +/// A convenient type create a [RuleCategories] type +/// +/// ``` +/// use pg_analyse::{RuleCategoriesBuilder, RuleCategory}; +/// let mut categories = RuleCategoriesBuilder::default().with_lint().build(); +/// +/// assert!(categories.contains(RuleCategory::Lint)); +/// assert!(!categories.contains(RuleCategory::Action)); +/// assert!(!categories.contains(RuleCategory::Transformation)); +/// ``` +pub struct RuleCategoriesBuilder { + flags: BitFlags, +} + +impl RuleCategoriesBuilder { + pub fn with_lint(mut self) -> Self { + self.flags.insert(Categories::Lint); + self + } + + pub fn with_action(mut self) -> Self { + self.flags.insert(Categories::Action); + self + } + + pub fn with_transformation(mut self) -> Self { + self.flags.insert(Categories::Transformation); + self + } + + pub fn all(mut self) -> Self { + self.flags = BitFlags::all(); + self + } + + pub fn build(self) -> RuleCategories { + RuleCategories(self.flags) + } +} diff --git a/crates/pg_analyse/src/context.rs b/crates/pg_analyse/src/context.rs new file mode 100644 index 00000000..4e43b1df --- /dev/null +++ b/crates/pg_analyse/src/context.rs @@ -0,0 +1,73 @@ +use crate::{ + categories::RuleCategory, + rule::{GroupCategory, Rule, RuleGroup, RuleMetadata}, +}; + +pub struct RuleContext<'a, R: Rule> { + stmt: &'a pg_query_ext::NodeEnum, + options: &'a R::Options, +} + +impl<'a, R> RuleContext<'a, R> +where + R: Rule + Sized + 'static, +{ + #[allow(clippy::too_many_arguments)] + pub fn new(stmt: &'a pg_query_ext::NodeEnum, options: &'a R::Options) -> Self { + Self { stmt, options } + } + + /// Returns the group that belongs to the current rule + pub fn group(&self) -> &'static str { + ::NAME + } + + /// Returns the category that belongs to the current rule + pub fn category(&self) -> RuleCategory { + <::Category as GroupCategory>::CATEGORY + } + + /// Returns the AST root + pub fn stmt(&self) -> &pg_query_ext::NodeEnum { + self.stmt + } + + /// Returns the metadata of the rule + /// + /// The metadata contains information about the rule, such as the name, version, language, and whether it is recommended. + /// + /// ## Examples + /// ```rust,ignore + /// declare_lint_rule! { + /// /// Some doc + /// pub(crate) Foo { + /// version: "0.0.0", + /// name: "foo", + /// recommended: true, + /// } + /// } + /// + /// impl Rule for Foo { + /// const CATEGORY: RuleCategory = RuleCategory::Lint; + /// type State = (); + /// type Signals = (); + /// type Options = (); + /// + /// fn run(ctx: &RuleContext) -> Self::Signals { + /// assert_eq!(ctx.metadata().name, "foo"); + /// } + /// } + /// ``` + pub fn metadata(&self) -> &RuleMetadata { + &R::METADATA + } + + /// It retrieves the options that belong to a rule, if they exist. + /// + /// In order to retrieve a typed data structure, you have to create a deserializable + /// data structure and define it inside the generic type `type Options` of the [Rule] + /// + pub fn options(&self) -> &R::Options { + self.options + } +} diff --git a/crates/pg_analyse/src/filter.rs b/crates/pg_analyse/src/filter.rs new file mode 100644 index 00000000..391831f3 --- /dev/null +++ b/crates/pg_analyse/src/filter.rs @@ -0,0 +1,189 @@ +use std::fmt::{Debug, Display, Formatter}; + +use crate::{ + categories::RuleCategories, + rule::{GroupCategory, Rule, RuleGroup}, +}; + +/// Allow filtering a single rule or group of rules by their names +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum RuleFilter<'a> { + Group(&'a str), + Rule(&'a str, &'a str), +} + +/// Allows filtering the list of rules that will be executed in a run of the analyser, +/// and at what source code range signals (diagnostics or actions) may be raised +#[derive(Debug, Default, Clone, Copy)] +pub struct AnalysisFilter<'a> { + /// Only allow rules with these categories to emit signals + pub categories: RuleCategories, + /// Only allow rules matching these names to emit signals + /// If `enabled_rules` is set to `None`, then all rules are enabled. + pub enabled_rules: Option<&'a [RuleFilter<'a>]>, + /// Do not allow rules matching these names to emit signals + pub disabled_rules: &'a [RuleFilter<'a>], +} + +impl<'analysis> AnalysisFilter<'analysis> { + /// It creates a new filter with the set of [enabled rules](RuleFilter) passed as argument + pub fn from_enabled_rules(enabled_rules: &'analysis [RuleFilter<'analysis>]) -> Self { + Self { + enabled_rules: Some(enabled_rules), + ..AnalysisFilter::default() + } + } + + /// Return `true` if the category `C` matches this filter + pub fn match_category(&self) -> bool { + self.categories.contains(C::CATEGORY) + } + + /// Return `true` if the group `G` matches this filter + pub fn match_group(&self) -> bool { + self.match_category::() + && self.enabled_rules.map_or(true, |enabled_rules| { + enabled_rules.iter().any(|filter| filter.match_group::()) + }) + && !self + .disabled_rules + .iter() + .any(|filter| matches!(filter, RuleFilter::Group(_)) && filter.match_group::()) + } + + /// Return `true` if the rule `R` matches this filter + pub fn match_rule(&self) -> bool { + self.match_category::<::Category>() + && self.enabled_rules.map_or(true, |enabled_rules| { + enabled_rules.iter().any(|filter| filter.match_rule::()) + }) + && !self + .disabled_rules + .iter() + .any(|filter| filter.match_rule::()) + } +} + +impl<'a> RuleFilter<'a> { + // Returns the group name of this filter. + pub fn group(self) -> &'a str { + match self { + RuleFilter::Group(group) => group, + RuleFilter::Rule(group, _) => group, + } + } + /// Return `true` if the group `G` matches this filter + pub fn match_group(self) -> bool { + match self { + RuleFilter::Group(group) => group == G::NAME, + RuleFilter::Rule(group, _) => group == G::NAME, + } + } + + /// Return `true` if the rule `R` matches this filter + pub fn match_rule(self) -> bool + where + R: Rule, + { + match self { + RuleFilter::Group(group) => group == ::NAME, + RuleFilter::Rule(group, rule) => { + group == ::NAME && rule == R::METADATA.name + } + } + } +} + +impl<'a> Debug for RuleFilter<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(self, f) + } +} + +impl<'a> Display for RuleFilter<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RuleFilter::Group(group) => { + write!(f, "{group}") + } + RuleFilter::Rule(group, rule) => { + write!(f, "{group}/{rule}") + } + } + } +} + +impl<'a> pg_console::fmt::Display for RuleFilter<'a> { + fn fmt(&self, fmt: &mut pg_console::fmt::Formatter) -> std::io::Result<()> { + match self { + RuleFilter::Group(group) => { + write!(fmt, "{group}") + } + RuleFilter::Rule(group, rule) => { + write!(fmt, "{group}/{rule}") + } + } + } +} + +/// Opaque identifier for a group of rule +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct GroupKey { + group: &'static str, +} + +impl GroupKey { + pub(crate) fn new(group: &'static str) -> Self { + Self { group } + } + + pub fn group() -> Self { + Self::new(G::NAME) + } +} + +impl From for RuleFilter<'static> { + fn from(key: GroupKey) -> Self { + RuleFilter::Group(key.group) + } +} + +/// Opaque identifier for a single rule +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RuleKey { + group: &'static str, + rule: &'static str, +} + +impl RuleKey { + pub fn new(group: &'static str, rule: &'static str) -> Self { + Self { group, rule } + } + + pub fn rule() -> Self { + Self::new(::NAME, R::METADATA.name) + } + + pub fn group(&self) -> &'static str { + self.group + } + + pub fn rule_name(&self) -> &'static str { + self.rule + } +} + +impl From for RuleFilter<'static> { + fn from(key: RuleKey) -> Self { + RuleFilter::Rule(key.group, key.rule) + } +} + +impl PartialEq for RuleFilter<'static> { + fn eq(&self, other: &RuleKey) -> bool { + match *self { + RuleFilter::Group(group) => group == other.group, + RuleFilter::Rule(group, rule) => group == other.group && rule == other.rule, + } + } +} diff --git a/crates/pg_analyse/src/lib.rs b/crates/pg_analyse/src/lib.rs new file mode 100644 index 00000000..ccaf82b7 --- /dev/null +++ b/crates/pg_analyse/src/lib.rs @@ -0,0 +1,23 @@ +mod categories; +pub mod context; +mod filter; +pub mod macros; +pub mod options; +mod registry; +mod rule; + +// Re-exported for use in the `declare_group` macro +pub use pg_diagnostics::category_concat; + +pub use crate::categories::{ + ActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder, RuleCategory, + SourceActionKind, SUPPRESSION_ACTION_CATEGORY, +}; +pub use crate::filter::{AnalysisFilter, GroupKey, RuleFilter, RuleKey}; +pub use crate::options::{AnalyserOptions, AnalyserRules}; +pub use crate::registry::{ + MetadataRegistry, RegistryRuleParams, RegistryVisitor, RuleRegistry, RuleRegistryBuilder, +}; +pub use crate::rule::{ + GroupCategory, Rule, RuleDiagnostic, RuleGroup, RuleMeta, RuleMetadata, RuleSource, +}; diff --git a/crates/pg_analyse/src/macros.rs b/crates/pg_analyse/src/macros.rs new file mode 100644 index 00000000..d97639a9 --- /dev/null +++ b/crates/pg_analyse/src/macros.rs @@ -0,0 +1,120 @@ +/// This macro is used to declare an analyser rule type, and implement the +// [RuleMeta] trait for it +/// # Example +/// +/// The macro itself expect the following syntax: +/// +/// ```rust,ignore +///use pg_analyse::declare_rule; +/// +/// declare_lint_rule! { +/// /// Documentation +/// pub(crate) ExampleRule { +/// version: "1.0.0", +/// name: "rule-name", +/// recommended: false, +/// } +/// } +/// ``` +/// +/// Check [crate](module documentation) for a better +/// understanding of how the macro works +#[macro_export] +macro_rules! declare_lint_rule { + ( $( #[doc = $doc:literal] )+ $vis:vis $id:ident { + version: $version:literal, + name: $name:tt, + $( $key:ident: $value:expr, )* + } ) => { + + pg_analyse::declare_rule!( + $( #[doc = $doc] )* + $vis $id { + version: $version, + name: $name, + $( $key: $value, )* + } + ); + + // Declare a new `rule_category!` macro in the module context that + // expands to the category of this rule + // This is implemented by calling the `group_category!` macro from the + // parent module (that should be declared by a call to `declare_group!`) + // and providing it with the name of this rule as a string literal token + #[allow(unused_macros)] + macro_rules! rule_category { + () => { super::group_category!( $name ) }; + } + }; +} + +#[macro_export] +macro_rules! declare_rule { + ( $( #[doc = $doc:literal] )+ $vis:vis $id:ident { + version: $version:literal, + name: $name:tt, + $( $key:ident: $value:expr, )* + } ) => { + $( #[doc = $doc] )* + $vis enum $id {} + + impl $crate::RuleMeta for $id { + type Group = super::Group; + const METADATA: $crate::RuleMetadata = + $crate::RuleMetadata::new($version, $name, concat!( $( $doc, "\n", )* )) $( .$key($value) )*; + } + } +} + +/// This macro is used by the codegen script to declare an analyser rule group, +/// and implement the [RuleGroup] trait for it +#[macro_export] +macro_rules! declare_lint_group { + ( $vis:vis $id:ident { name: $name:tt, rules: [ $( $( $rule:ident )::* , )* ] } ) => { + $vis enum $id {} + + impl $crate::RuleGroup for $id { + type Category = super::Category; + + const NAME: &'static str = $name; + + fn record_rules(registry: &mut V) { + $( registry.record_rule::<$( $rule )::*>(); )* + } + } + + pub(self) use $id as Group; + + // Declare a `group_category!` macro in the context of this module (and + // all its children). This macro takes the name of a rule as a string + // literal token and expands to the category of the lint rule with this + // name within this group. + // This is implemented by calling the `category_concat!` macro with the + // "lint" prefix, the name of this group, and the rule name argument + #[allow(unused_macros)] + macro_rules! group_category { + ( $rule_name:tt ) => { $crate::category_concat!( "lint", $name, $rule_name ) }; + } + + // Re-export the macro for child modules, so `declare_rule!` can access + // the category of its parent group by using the `super` module + pub(self) use group_category; + }; +} + +#[macro_export] +macro_rules! declare_category { + ( $vis:vis $id:ident { kind: $kind:ident, groups: [ $( $( $group:ident )::* , )* ] } ) => { + $vis enum $id {} + + impl $crate::GroupCategory for $id { + const CATEGORY: $crate::RuleCategory = $crate::RuleCategory::$kind; + + fn record_groups(registry: &mut V) { + $( registry.record_group::<$( $group )::*>(); )* + } + } + + pub(self) use $id as Category; + }; +} diff --git a/crates/pg_analyse/src/options.rs b/crates/pg_analyse/src/options.rs new file mode 100644 index 00000000..eaba5d37 --- /dev/null +++ b/crates/pg_analyse/src/options.rs @@ -0,0 +1,61 @@ +use rustc_hash::FxHashMap; + +use crate::{Rule, RuleKey}; +use std::any::{Any, TypeId}; +use std::fmt::Debug; + +/// A convenient new type data structure to store the options that belong to a rule +#[derive(Debug)] +pub struct RuleOptions(TypeId, Box); + +impl RuleOptions { + /// Creates a new [RuleOptions] + pub fn new(options: O) -> Self { + Self(TypeId::of::(), Box::new(options)) + } + + /// It returns the deserialized rule option + pub fn value(&self) -> &O { + let RuleOptions(type_id, value) = &self; + let current_id = TypeId::of::(); + debug_assert_eq!(type_id, ¤t_id); + // SAFETY: the code should fail when asserting the types. + // If the code throws an error here, it means that the developer didn't test + // the rule with the options + value.downcast_ref::().unwrap() + } +} + +/// A convenient new type data structure to insert and get rules +#[derive(Debug, Default)] +pub struct AnalyserRules(FxHashMap); + +impl AnalyserRules { + /// It tracks the options of a specific rule + pub fn push_rule(&mut self, rule_key: RuleKey, options: RuleOptions) { + self.0.insert(rule_key, options); + } + + /// It retrieves the options of a stored rule, given its name + pub fn get_rule_options(&self, rule_key: &RuleKey) -> Option<&O> { + self.0.get(rule_key).map(|o| o.value::()) + } +} + +/// A set of information useful to the analyser infrastructure +#[derive(Debug, Default)] +pub struct AnalyserOptions { + /// A data structured derived from the [`pglsp.toml`] file + pub rules: AnalyserRules, +} + +impl AnalyserOptions { + pub fn rule_options(&self) -> Option + where + R: Rule + 'static, + { + self.rules + .get_rule_options::(&RuleKey::rule::()) + .cloned() + } +} diff --git a/crates/pg_analyse/src/registry.rs b/crates/pg_analyse/src/registry.rs new file mode 100644 index 00000000..b80de1cb --- /dev/null +++ b/crates/pg_analyse/src/registry.rs @@ -0,0 +1,189 @@ +use std::{borrow, collections::BTreeSet}; + +use crate::{ + context::RuleContext, + filter::{AnalysisFilter, GroupKey, RuleKey}, + rule::{GroupCategory, Rule, RuleDiagnostic, RuleGroup}, + AnalyserOptions, +}; + +pub trait RegistryVisitor { + /// Record the category `C` to this visitor + fn record_category(&mut self) { + C::record_groups(self); + } + + /// Record the group `G` to this visitor + fn record_group(&mut self) { + G::record_rules(self); + } + + /// Record the rule `R` to this visitor + fn record_rule(&mut self) + where + R: Rule + 'static; +} + +/// Key struct for a rule in the metadata map, sorted alphabetically +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct MetadataKey { + inner: (&'static str, &'static str), +} + +impl MetadataKey { + fn into_group_key(self) -> GroupKey { + let (group, _) = self.inner; + GroupKey::new(group) + } + + fn into_rule_key(self) -> RuleKey { + let (group, rule) = self.inner; + RuleKey::new(group, rule) + } +} + +impl<'a> borrow::Borrow<(&'a str, &'a str)> for MetadataKey { + fn borrow(&self) -> &(&'a str, &'a str) { + &self.inner + } +} + +impl borrow::Borrow for MetadataKey { + fn borrow(&self) -> &str { + self.inner.0 + } +} + +/// Stores metadata information for all the rules in the registry, sorted +/// alphabetically +#[derive(Debug, Default)] +pub struct MetadataRegistry { + inner: BTreeSet, +} + +impl MetadataRegistry { + /// Return a unique identifier for a rule group if it's known by this registry + pub fn find_group(&self, group: &str) -> Option { + let key = self.inner.get(group)?; + Some(key.into_group_key()) + } + + /// Return a unique identifier for a rule if it's known by this registry + pub fn find_rule(&self, group: &str, rule: &str) -> Option { + let key = self.inner.get(&(group, rule))?; + Some(key.into_rule_key()) + } + + pub(crate) fn insert_rule(&mut self, group: &'static str, rule: &'static str) { + self.inner.insert(MetadataKey { + inner: (group, rule), + }); + } +} + +impl RegistryVisitor for MetadataRegistry { + fn record_rule(&mut self) + where + R: Rule + 'static, + { + self.insert_rule(::NAME, R::METADATA.name); + } +} + +pub struct RuleRegistryBuilder<'a> { + filter: &'a AnalysisFilter<'a>, + // Rule Registry + registry: RuleRegistry, +} + +impl RegistryVisitor for RuleRegistryBuilder<'_> { + fn record_category(&mut self) { + if self.filter.match_category::() { + C::record_groups(self); + } + } + + fn record_group(&mut self) { + if self.filter.match_group::() { + G::record_rules(self); + } + } + + /// Add the rule `R` to the list of rules stored in this registry instance + fn record_rule(&mut self) + where + R: Rule + 'static, + { + if !self.filter.match_rule::() { + return; + } + + let rule = RegistryRule::new::(); + + self.registry.rules.push(rule); + } +} + +/// The rule registry holds type-erased instances of all active analysis rules +pub struct RuleRegistry { + pub rules: Vec, +} + +impl IntoIterator for RuleRegistry { + type Item = RegistryRule; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.rules.into_iter() + } +} + +/// Internal representation of a single rule in the registry +#[derive(Copy, Clone)] +pub struct RegistryRule { + pub run: RuleExecutor, +} + +impl RuleRegistry { + pub fn builder<'a>(filter: &'a AnalysisFilter<'a>) -> RuleRegistryBuilder<'a> { + RuleRegistryBuilder { + filter, + registry: RuleRegistry { + rules: Default::default(), + }, + } + } +} + +pub struct RegistryRuleParams<'a> { + pub root: &'a pg_query_ext::NodeEnum, + pub options: &'a AnalyserOptions, +} + +/// Executor for rule as a generic function pointer +type RuleExecutor = fn(&RegistryRuleParams) -> Vec; + +impl RegistryRule { + fn new() -> Self + where + R: Rule + 'static, + { + /// Generic implementation of RuleExecutor for any rule type R + fn run(params: &RegistryRuleParams) -> Vec + where + R: Rule + 'static, + { + let options = params.options.rule_options::().unwrap_or_default(); + let ctx = RuleContext::new(params.root, &options); + R::run(&ctx) + } + + Self { run: run:: } + } +} + +impl RuleRegistryBuilder<'_> { + pub fn build(self) -> RuleRegistry { + self.registry + } +} diff --git a/crates/pg_analyse/src/rule.rs b/crates/pg_analyse/src/rule.rs new file mode 100644 index 00000000..f159861d --- /dev/null +++ b/crates/pg_analyse/src/rule.rs @@ -0,0 +1,326 @@ +use pg_console::fmt::Display; +use pg_console::{markup, MarkupBuf}; +use pg_diagnostics::advice::CodeSuggestionAdvice; +use pg_diagnostics::{ + Advices, Category, Diagnostic, DiagnosticTags, Location, LogCategory, MessageAndDescription, + Visit, +}; +use std::cmp::Ordering; +use std::fmt::Debug; +use text_size::TextRange; + +use crate::{categories::RuleCategory, context::RuleContext, registry::RegistryVisitor}; + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +/// Static metadata containing information about a rule +pub struct RuleMetadata { + /// It marks if a rule is deprecated, and if so a reason has to be provided. + pub deprecated: Option<&'static str>, + /// The version when the rule was implemented + pub version: &'static str, + /// The name of this rule, displayed in the diagnostics it emits + pub name: &'static str, + /// The content of the documentation comments for this rule + pub docs: &'static str, + /// Whether a rule is recommended or not + pub recommended: bool, + /// The source URL of the rule + pub sources: &'static [RuleSource], +} + +impl RuleMetadata { + pub const fn new(version: &'static str, name: &'static str, docs: &'static str) -> Self { + Self { + deprecated: None, + version, + name, + docs, + sources: &[], + recommended: false, + } + } + + pub const fn recommended(mut self, recommended: bool) -> Self { + self.recommended = recommended; + self + } + + pub const fn deprecated(mut self, deprecated: &'static str) -> Self { + self.deprecated = Some(deprecated); + self + } + + pub const fn sources(mut self, sources: &'static [RuleSource]) -> Self { + self.sources = sources; + self + } +} + +pub trait RuleMeta { + type Group: RuleGroup; + const METADATA: RuleMetadata; +} + +/// A rule group is a collection of rules under a given name, serving as a +/// "namespace" for lint rules and allowing the entire set of rules to be +/// disabled at once +pub trait RuleGroup { + type Category: GroupCategory; + /// The name of this group, displayed in the diagnostics emitted by its rules + const NAME: &'static str; + /// Register all the rules belonging to this group into `registry` + fn record_rules(registry: &mut V); +} + +/// A group category is a collection of rule groups under a given category ID, +/// serving as a broad classification on the kind of diagnostic or code action +/// these rule emit, and allowing whole categories of rules to be disabled at +/// once depending on the kind of analysis being performed +pub trait GroupCategory { + /// The category ID used for all groups and rule belonging to this category + const CATEGORY: RuleCategory; + /// Register all the groups belonging to this category into `registry` + fn record_groups(registry: &mut V); +} + +/// Trait implemented by all analysis rules: declares interest to a certain AstNode type, +/// and a callback function to be executed on all nodes matching the query to possibly +/// raise an analysis event +pub trait Rule: RuleMeta + Sized { + type Options: Default + Clone + Debug; + + fn run(ctx: &RuleContext) -> Vec; +} + +/// Diagnostic object returned by a single analysis rule +#[derive(Debug, Diagnostic)] +pub struct RuleDiagnostic { + #[category] + pub(crate) category: &'static Category, + #[location(span)] + pub(crate) span: Option, + #[message] + #[description] + pub(crate) message: MessageAndDescription, + #[tags] + pub(crate) tags: DiagnosticTags, + #[advice] + pub(crate) rule_advice: RuleAdvice, +} + +#[derive(Debug, Default)] +/// It contains possible advices to show when printing a diagnostic that belong to the rule +pub struct RuleAdvice { + pub(crate) details: Vec, + pub(crate) notes: Vec<(LogCategory, MarkupBuf)>, + pub(crate) suggestion_list: Option, + pub(crate) code_suggestion_list: Vec>, +} + +#[derive(Debug, Default)] +pub struct SuggestionList { + pub(crate) message: MarkupBuf, + pub(crate) list: Vec, +} + +impl Advices for RuleAdvice { + fn record(&self, visitor: &mut dyn Visit) -> std::io::Result<()> { + for detail in &self.details { + visitor.record_log( + detail.log_category, + &markup! { {detail.message} }.to_owned(), + )?; + visitor.record_frame(Location::builder().span(&detail.range).build())?; + } + // we then print notes + for (log_category, note) in &self.notes { + visitor.record_log(*log_category, &markup! { {note} }.to_owned())?; + } + + if let Some(suggestion_list) = &self.suggestion_list { + visitor.record_log( + LogCategory::Info, + &markup! { {suggestion_list.message} }.to_owned(), + )?; + let list: Vec<_> = suggestion_list + .list + .iter() + .map(|suggestion| suggestion as &dyn Display) + .collect(); + visitor.record_list(&list)?; + } + + // finally, we print possible code suggestions on how to fix the issue + for suggestion in &self.code_suggestion_list { + suggestion.record(visitor)?; + } + + Ok(()) + } +} + +#[derive(Debug)] +pub struct Detail { + pub log_category: LogCategory, + pub message: MarkupBuf, + pub range: Option, +} + +impl RuleDiagnostic { + /// Creates a new [`RuleDiagnostic`] with a severity and title that will be + /// used in a builder-like way to modify labels. + pub fn new(category: &'static Category, span: Option, title: impl Display) -> Self { + let message = markup!({ title }).to_owned(); + Self { + category, + span, + message: MessageAndDescription::from(message), + tags: DiagnosticTags::empty(), + rule_advice: RuleAdvice::default(), + } + } + + /// Set an explicit plain-text summary for this diagnostic. + pub fn description(mut self, summary: impl Into) -> Self { + self.message.set_description(summary.into()); + self + } + + /// Marks this diagnostic as deprecated code, which will + /// be displayed in the language server. + /// + /// This does not have any influence on the diagnostic rendering. + pub fn deprecated(mut self) -> Self { + self.tags |= DiagnosticTags::DEPRECATED_CODE; + self + } + + /// Marks this diagnostic as unnecessary code, which will + /// be displayed in the language server. + /// + /// This does not have any influence on the diagnostic rendering. + pub fn unnecessary(mut self) -> Self { + self.tags |= DiagnosticTags::UNNECESSARY_CODE; + self + } + + /// Attaches a label to this [`RuleDiagnostic`]. + /// + /// The given span has to be in the file that was provided while creating this [`RuleDiagnostic`]. + pub fn label(mut self, span: Option, msg: impl Display) -> Self { + self.rule_advice.details.push(Detail { + log_category: LogCategory::Info, + message: markup!({ msg }).to_owned(), + range: span, + }); + self + } + + /// Attaches a detailed message to this [`RuleDiagnostic`]. + pub fn detail(self, span: Option, msg: impl Display) -> Self { + self.label(span, msg) + } + + /// Adds a footer to this [`RuleDiagnostic`], which will be displayed under the actual error. + fn footer(mut self, log_category: LogCategory, msg: impl Display) -> Self { + self.rule_advice + .notes + .push((log_category, markup!({ msg }).to_owned())); + self + } + + /// Adds a footer to this [`RuleDiagnostic`], with the `Info` log category. + pub fn note(self, msg: impl Display) -> Self { + self.footer(LogCategory::Info, msg) + } + + /// It creates a new footer note which contains a message and a list of possible suggestions. + /// Useful when there's need to suggest a list of things inside a diagnostic. + pub fn footer_list(mut self, message: impl Display, list: &[impl Display]) -> Self { + if !list.is_empty() { + self.rule_advice.suggestion_list = Some(SuggestionList { + message: markup! { {message} }.to_owned(), + list: list + .iter() + .map(|msg| markup! { {msg} }.to_owned()) + .collect(), + }); + } + + self + } + + /// Adds a footer to this [`RuleDiagnostic`], with the `Warn` severity. + pub fn warning(self, msg: impl Display) -> Self { + self.footer(LogCategory::Warn, msg) + } + + pub(crate) fn span(&self) -> Option { + self.span + } + + pub fn advices(&self) -> &RuleAdvice { + &self.rule_advice + } +} + +#[derive(Debug, Clone, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, schemars::JsonSchema))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub enum RuleSource { + /// Rules from [Squawk](https://squawkhq.com) + Squawk(&'static str), +} + +impl PartialEq for RuleSource { + fn eq(&self, other: &Self) -> bool { + std::mem::discriminant(self) == std::mem::discriminant(other) + } +} + +impl std::fmt::Display for RuleSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Squawk(_) => write!(f, "Squawk"), + } + } +} + +impl PartialOrd for RuleSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RuleSource { + fn cmp(&self, other: &Self) -> Ordering { + let self_rule = self.as_rule_name(); + let other_rule = other.as_rule_name(); + self_rule.cmp(other_rule) + } +} + +impl RuleSource { + pub fn as_rule_name(&self) -> &'static str { + match self { + Self::Squawk(rule_name) => rule_name, + } + } + + pub fn to_namespaced_rule_name(&self) -> String { + match self { + Self::Squawk(rule_name) => format!("squawk/{rule_name}"), + } + } + + pub fn to_rule_url(&self) -> String { + match self { + Self::Squawk(rule_name) => format!("https://squawkhq.com/docs/{rule_name}"), + } + } + + pub fn as_url_and_rule_name(&self) -> (String, &'static str) { + (self.to_rule_url(), self.as_rule_name()) + } +} diff --git a/crates/pg_analyser/CONTRIBUTING.md b/crates/pg_analyser/CONTRIBUTING.md new file mode 100644 index 00000000..200be87c --- /dev/null +++ b/crates/pg_analyser/CONTRIBUTING.md @@ -0,0 +1,332 @@ +# Analyser + +## Creating a rule + +When creating or updating a lint rule, you need to be aware that there's a lot of generated code inside our toolchain. +Our CI ensures that this code is not out of sync and fails otherwise. +See the [code generation section](#code-generation) for more details. + +To create a new rule, you have to create and update several files. +Because it is a bit tedious, we provide an easy way to create and test your rule using [Just](https://just.systems/man/en/). +_Just_ is not part of the rust toolchain, you have to install it with [a package manager](https://just.systems/man/en/chapter_4.html). + +### Choose a name + +We follow a naming convention according to what the rule does: + +1. Forbid a concept + + ```block + no + ``` + + When a rule's sole intention is to **forbid a single concept** the rule should be named using the `no` prefix. + +1. Mandate a concept + + ```block + use + ``` + + When a rule's sole intention is to **mandate a single concept** the rule should be named using the `use` prefix. + +### Explain a rule to the user + +A rule should be informative to the user, and give as much explanation as possible. + +When writing a rule, you must adhere to the following **pillars**: +1. Explain to the user the error. Generally, this is the message of the diagnostic. +1. Explain to the user **why** the error is triggered. Generally, this is implemented with an additional node. +1. Tell the user what they should do. Generally, this is implemented using a code action. If a code action is not applicable a note should tell the user what they should do to fix the error. + +### Create and implement the rule + +> [!TIP] +> As a developer, you aren't forced to make a rule perfect in one PR. Instead, you are encouraged to lay out a plan and to split the work into multiple PRs. +> +> If you aren't familiar with the APIs, this is an option that you have. If you decide to use this option, you should make sure to describe your plan in an issue. + +Let's say we want to create a new **lint** rule called `useMyRuleName`, follow these steps: + +1. Run the command + + ```shell + just new-lintrule safety useMyRuleName + ``` + The script will generate a bunch of files inside the `pg_analyser` crate. + Among the other files, you'll find a file called `use_my_new_rule_name.rs` inside the `pg_analyser/lib/src/lint/safety` folder. You'll implement your rule in this file. + +1. The `Option` type doesn't have to be used, so it can be considered optional. However, it has to be defined as `type Option = ()`. +1. Implement the `run` function: The function is called for every statement, and should return zero or more diagnostics. Follow the [pillars](#explain-a-rule-to-the-user) when writing the message of a diagnostic + +Don't forget to format your code with `just f` and lint with `just l`. + +That's it! Now, let's test the rule. + +### Rule configuration + +Some rules may allow customization using options. +We try to keep rule options to a minimum and only when needed. +Before adding an option, it's worth a discussion. + +Let's assume that the rule we implement support the following options: + +- `behavior`: a string among `"A"`, `"B"`, and `"C"`; +- `threshold`: an integer between 0 and 255; +- `behaviorExceptions`: an array of strings. + +We would like to set the options in the `pglsp.toml` configuration file: + +```toml +[linter.rules.safety.myRule] +level = "warn" +options = { + behavior = "A" + threshold = 20 + behaviorExceptions = ["one", "two"] +} +``` + +The first step is to create the Rust data representation of the rule's options. + +```rust +#[derive(Clone, Debug, Default)] +pub struct MyRuleOptions { + behavior: Behavior, + threshold: u8, + behavior_exceptions: Box<[Box]> +} + +#[derive(Clone, Debug, Defaul)] +pub enum Behavior { + #[default] + A, + B, + C, +} +``` + +Note that we use a boxed slice `Box<[Box]>` instead of `Vec`. +This allows saving memory: [boxed slices and boxed str use 2 words instead of three words](https://nnethercote.github.io/perf-book/type-sizes.html#boxed-slices). + +With these types in place, you can set the associated type `Options` of the rule: + +```rust +impl Rule for MyRule { + type Options = MyRuleOptions; +} +``` + +A rule can retrieve its options with: + +```rust +let options = ctx.options(); +``` + +The compiler should warn you that `MyRuleOptions` does not implement some required types. +We currently require implementing _serde_'s traits `Deserialize`/`Serialize`. + +Also, we use other `serde` macros to adjust the JSON configuration: +- `rename_all = "snake_case"`: it renames all fields in camel-case, so they are in line with the naming style of the `pglsp.toml`. +- `deny_unknown_fields`: it raises an error if the configuration contains extraneous fields. +- `default`: it uses the `Default` value when the field is missing from `pglsp.toml`. This macro makes the field optional. + +You can simply use a derive macros: + +```rust +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub struct MyRuleOptions { + #[serde(default, skip_serializing_if = "is_default")] + main_behavior: Behavior, + + #[serde(default, skip_serializing_if = "is_default")] + extra_behaviors: Vec, +} + +#[derive(Debug, Default, Clone)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +pub enum Behavior { + #[default] + A, + B, + C, +} +``` + +### Coding the rule + +Below, there are many tips and guidelines on how to create a lint rule using our infrastructure. + + +#### `declare_lint_rule` + +This macro is used to declare an analyzer rule type, and implement the [RuleMeta] trait for it. + +The macro itself expects the following syntax: + +```rust +use pg_analyse::declare_lint_rule; + +declare_lint_rule! { + /// Documentation + pub(crate) ExampleRule { + version: "next", + name: "myRuleName", + recommended: false, + } +} +``` + +##### Lint rules inspired by other lint rules + +If a **lint** rule is inspired by an existing rule from other ecosystems (Squawk etc.), you can add a new metadata to the macro called `source`. Its value is `&'static [RuleSource]`, which is a reference to a slice of `RuleSource` elements, each representing a different source. + +If you're implementing a lint rule that matches the behaviour of the Squawk rule `ban-drop-column`, you'll use the variant `::Squawk` and pass the name of the rule: + +```rust +use pg_analyse::{declare_lint_rule, RuleSource}; + +declare_lint_rule! { + /// Documentation + pub(crate) ExampleRule { + version: "next", + name: "myRuleName", + recommended: false, + sources: &[RuleSource::Squawk("ban-drop-column")], + } +} +``` + +#### Category Macro + +Declaring a rule using `declare_lint_rule!` will cause a new `rule_category!` +macro to be declared in the surrounding module. This macro can be used to +refer to the corresponding diagnostic category for this lint rule, if it +has one. Using this macro instead of getting the category for a diagnostic +by dynamically parsing its string name has the advantage of statically +injecting the category at compile time and checking that it is correctly +registered to the `pg_diagnostics` library. + +```rust +declare_lint_rule! { + /// Documentation + pub(crate) ExampleRule { + version: "next", + name: "myRuleName", + recommended: false, + } +} + +impl Rule for BanDropColumn { + type Options = Options; + + fn run(ctx: &RuleContext) -> Vec { + vec![RuleDiagnostic::new( + rule_category!(), + None, + "message", + )] + } +} +``` + +### Document the rule + +The documentation needs to adhere to the following rules: +- The **first** paragraph of the documentation is used as brief description of the rule, and it **must** be written in one single line. Breaking the paragraph in multiple lines will break the table content of the rules page. +- The next paragraphs can be used to further document the rule with as many details as you see fit. +- The documentation must have a `## Examples` header, followed by two headers: `### Invalid` and `### Valid`. `### Invalid` must go first because we need to show when the rule is triggered. +- Rule options if any, must be documented in the `## Options` section. +- Each code block must have `sql` set as language defined. +- When adding _invalid_ snippets in the `### Invalid` section, you must use the `expect_diagnostic` code block property. We use this property to generate a diagnostic and attach it to the snippet. A snippet **must emit only ONE diagnostic**. +- When adding _valid_ snippets in the `### Valid` section, you can use one single snippet. +- You can use the code block property `ignore` to tell the code generation script to **not generate a diagnostic for an invalid snippet**. + +Here's an example of how the documentation could look like: + +```rust +declare_lint_rule! { + /// Dropping a column may break existing clients. + /// + /// Update your application code to no longer read or write the column. + /// + /// You can leave the column as nullable or delete the column once queries no longer select or modify the column. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// alter table test drop column id; + /// ``` + /// + pub BanDropColumn { + version: "next", + name: "banDropColumn", + recommended: true, + sources: &[RuleSource::Squawk("ban-drop-column")], + } +} +``` + +This will cause the documentation generator to ensure the rule does emit +exactly one diagnostic for this code, and to include a snapshot for the +diagnostic in the resulting documentation page. + +### Code generation + +For simplicity, use `just` to run all the commands with: + +```shell +just gen-lint +``` + +### Commit your work + +Once the rule implemented, tested, and documented, you are ready to open a pull request! + +Stage and commit your changes: + +```shell +> git add -A +> git commit -m 'feat(pg_analyser): myRuleName' +``` + + +### Deprecate a rule + +There are occasions when a rule must be deprecated, to avoid breaking changes. The reason +of deprecation can be multiple. + +In order to do, the macro allows adding additional field to add the reason for deprecation + +```rust +use pg_analyse::declare_lint_rule; + +declare_lint_rule! { + /// Dropping a column may break existing clients. + /// + /// Update your application code to no longer read or write the column. + /// + /// You can leave the column as nullable or delete the column once queries no longer select or modify the column. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// alter table test drop column id; + /// ``` + /// + pub BanDropColumn { + version: "next", + name: "banDropColumn", + recommended: true, + deprecated: true, + sources: &[RuleSource::Squawk("ban-drop-column")], + } +} +``` + diff --git a/crates/pg_analyser/Cargo.toml b/crates/pg_analyser/Cargo.toml new file mode 100644 index 00000000..6e3ce4c5 --- /dev/null +++ b/crates/pg_analyser/Cargo.toml @@ -0,0 +1,18 @@ + +[package] +authors.workspace = true +categories.workspace = true +description = "" +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +name = "pg_analyser" +repository.workspace = true +version = "0.0.0" + +[dependencies] +pg_analyse = { workspace = true } +pg_console = { workspace = true } +pg_query_ext = { workspace = true } +serde = { workspace = true } diff --git a/crates/pg_analyser/src/lib.rs b/crates/pg_analyser/src/lib.rs new file mode 100644 index 00000000..d6c14d62 --- /dev/null +++ b/crates/pg_analyser/src/lib.rs @@ -0,0 +1,67 @@ +use std::{ops::Deref, sync::LazyLock}; + +use pg_analyse::{ + AnalyserOptions, AnalysisFilter, MetadataRegistry, RegistryRuleParams, RuleDiagnostic, + RuleRegistry, +}; +pub use registry::visit_registry; + +mod lint; +pub mod options; +mod registry; + +pub static METADATA: LazyLock = LazyLock::new(|| { + let mut metadata = MetadataRegistry::default(); + visit_registry(&mut metadata); + metadata +}); + +/// Main entry point to the analyser. +pub struct Analyser<'a> { + /// Holds the metadata for all the rules statically known to the analyser + /// we need this later when we add suppression support + #[allow(dead_code)] + metadata: &'a MetadataRegistry, + + /// Holds all rule options + options: &'a AnalyserOptions, + + /// Holds all rules + registry: RuleRegistry, +} + +pub struct AnalyserContext<'a> { + pub root: &'a pg_query_ext::NodeEnum, +} + +pub struct AnalyserConfig<'a> { + pub options: &'a AnalyserOptions, + pub filter: AnalysisFilter<'a>, +} + +impl<'a> Analyser<'a> { + pub fn new(conf: AnalyserConfig<'a>) -> Self { + let mut builder = RuleRegistry::builder(&conf.filter); + visit_registry(&mut builder); + let registry = builder.build(); + + Self { + metadata: METADATA.deref(), + registry, + options: conf.options, + } + } + + pub fn run(&self, ctx: AnalyserContext) -> Vec { + let params = RegistryRuleParams { + root: ctx.root, + options: self.options, + }; + + self.registry + .rules + .iter() + .flat_map(|rule| (rule.run)(¶ms)) + .collect::>() + } +} diff --git a/crates/pg_analyser/src/lint.rs b/crates/pg_analyser/src/lint.rs new file mode 100644 index 00000000..0a2344ca --- /dev/null +++ b/crates/pg_analyser/src/lint.rs @@ -0,0 +1,4 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +pub mod safety; +::pg_analyse::declare_category! { pub Lint { kind : Lint , groups : [self :: safety :: Safety ,] } } diff --git a/crates/pg_analyser/src/lint/safety.rs b/crates/pg_analyser/src/lint/safety.rs new file mode 100644 index 00000000..4d78797b --- /dev/null +++ b/crates/pg_analyser/src/lint/safety.rs @@ -0,0 +1,5 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use pg_analyse::declare_lint_group; +pub mod ban_drop_column; +declare_lint_group! { pub Safety { name : "safety" , rules : [self :: ban_drop_column :: BanDropColumn ,] } } diff --git a/crates/pg_analyser/src/lint/safety/ban_drop_column.rs b/crates/pg_analyser/src/lint/safety/ban_drop_column.rs new file mode 100644 index 00000000..9f20227d --- /dev/null +++ b/crates/pg_analyser/src/lint/safety/ban_drop_column.rs @@ -0,0 +1,58 @@ +use pg_analyse::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; +use pg_console::markup; + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +// #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct Options { + test: String, +} + +declare_lint_rule! { + /// Dropping a column may break existing clients. + /// + /// Update your application code to no longer read or write the column. + /// + /// You can leave the column as nullable or delete the column once queries no longer select or modify the column. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// alter table test drop column id; + /// ``` + /// + pub BanDropColumn { + version: "next", + name: "banDropColumn", + recommended: true, + sources: &[RuleSource::Squawk("ban-drop-column")], + } +} + +impl Rule for BanDropColumn { + type Options = Options; + + fn run(ctx: &RuleContext) -> Vec { + let mut diagnostics = Vec::new(); + + if let pg_query_ext::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { + for cmd in &stmt.cmds { + if let Some(pg_query_ext::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pg_query_ext::protobuf::AlterTableType::AtDropColumn { + diagnostics.push(RuleDiagnostic::new( + rule_category!(), + None, + markup! { + "Dropping a column may break existing clients." + }, + ).detail(None, format!("[{}] You can leave the column as nullable or delete the column once queries no longer select or modify the column.", ctx.options().test))); + } + } + } + } + + diagnostics + } +} diff --git a/crates/pg_analyser/src/options.rs b/crates/pg_analyser/src/options.rs new file mode 100644 index 00000000..13e54068 --- /dev/null +++ b/crates/pg_analyser/src/options.rs @@ -0,0 +1,5 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::lint; +pub type BanDropColumn = + ::Options; diff --git a/crates/pg_analyser/src/registry.rs b/crates/pg_analyser/src/registry.rs new file mode 100644 index 00000000..27a5a413 --- /dev/null +++ b/crates/pg_analyser/src/registry.rs @@ -0,0 +1,6 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use pg_analyse::RegistryVisitor; +pub fn visit_registry(registry: &mut V) { + registry.record_category::(); +} diff --git a/crates/pg_cli/Cargo.toml b/crates/pg_cli/Cargo.toml index bbeab8ec..6abad15a 100644 --- a/crates/pg_cli/Cargo.toml +++ b/crates/pg_cli/Cargo.toml @@ -18,6 +18,7 @@ crossbeam = { workspace = true } dashmap = "5.5.3" hdrhistogram = { version = "7.5.4", default-features = false } path-absolutize = { version = "3.1.1", optional = false, features = ["use_unix_paths_on_wasm"] } +pg_analyse = { workspace = true } pg_configuration = { workspace = true } pg_console = { workspace = true } pg_diagnostics = { workspace = true } diff --git a/crates/pg_cli/src/commands/check.rs b/crates/pg_cli/src/commands/check.rs index bf09d5fa..986060a6 100644 --- a/crates/pg_cli/src/commands/check.rs +++ b/crates/pg_cli/src/commands/check.rs @@ -9,7 +9,7 @@ use pg_fs::FileSystem; use pg_workspace_new::{configuration::LoadedConfiguration, DynRef, Workspace, WorkspaceError}; use std::ffi::OsString; -use super::{determine_fix_file_mode, get_files_to_process_with_cli_options, CommandRunner}; +use super::{get_files_to_process_with_cli_options, CommandRunner}; pub(crate) struct CheckCommandPayload { pub(crate) write: bool, diff --git a/crates/pg_cli/src/commands/mod.rs b/crates/pg_cli/src/commands/mod.rs index 05c433a3..a0934a90 100644 --- a/crates/pg_cli/src/commands/mod.rs +++ b/crates/pg_cli/src/commands/mod.rs @@ -1,6 +1,5 @@ use crate::changed::{get_changed_files, get_staged_files}; use crate::cli_options::{cli_options, CliOptions, CliReporter, ColorsArg}; -use crate::diagnostics::DeprecatedArgument; use crate::execute::Stdin; use crate::logging::LoggingKind; use crate::{ @@ -8,8 +7,7 @@ use crate::{ }; use bpaf::Bpaf; use pg_configuration::{partial_configuration, PartialConfiguration}; -use pg_console::{markup, Console, ConsoleExt}; -use pg_diagnostics::{Diagnostic, PrintDiagnostic}; +use pg_console::Console; use pg_fs::FileSystem; use pg_workspace_new::configuration::{load_configuration, LoadedConfiguration}; use pg_workspace_new::settings::PartialConfigurationExt; @@ -430,7 +428,7 @@ pub(crate) fn determine_fix_file_mode( check_fix_incompatible_arguments(options)?; let safe_fixes = write || fix; - let unsafe_fixes = ((write || safe_fixes) && unsafe_); + let unsafe_fixes = (write || safe_fixes) && unsafe_; if unsafe_fixes { Ok(Some(FixFileMode::SafeAndUnsafeFixes)) @@ -477,9 +475,9 @@ mod tests { #[test] fn incompatible_arguments() { - for (write, suppress, suppression_reason, fix, unsafe_) in [ - (true, false, None, true, false), // --write --fix - ] { + { + let (write, suppress, suppression_reason, fix, unsafe_) = + (true, false, None, true, false); assert!(check_fix_incompatible_arguments(FixFileModeOptions { write, suppress, diff --git a/crates/pg_cli/src/execute/mod.rs b/crates/pg_cli/src/execute/mod.rs index bc7f6b46..c18f8ec2 100644 --- a/crates/pg_cli/src/execute/mod.rs +++ b/crates/pg_cli/src/execute/mod.rs @@ -90,7 +90,7 @@ pub enum TraversalMode { /// It's [None] if the `check` command is called without `--apply` or `--apply-suggested` /// arguments. // fix_file_mode: Option, - + /// An optional tuple. /// 1. The virtual path to the file /// 2. The content of the file diff --git a/crates/pg_cli/src/execute/process_file/check.rs b/crates/pg_cli/src/execute/process_file/check.rs index ff691a89..fa5b522b 100644 --- a/crates/pg_cli/src/execute/process_file/check.rs +++ b/crates/pg_cli/src/execute/process_file/check.rs @@ -1,3 +1,4 @@ +use pg_analyse::RuleCategoriesBuilder; use pg_diagnostics::{category, Error}; use crate::execute::diagnostics::ResultExt; @@ -24,10 +25,17 @@ pub(crate) fn check_with_guard<'ctx>( let input = workspace_file.input()?; let changed = false; + let (only, skip) = (Vec::new(), Vec::new()); + let max_diagnostics = ctx.remaining_diagnostics.load(Ordering::Relaxed); let pull_diagnostics_result = workspace_file .guard() - .pull_diagnostics(max_diagnostics) + .pull_diagnostics( + RuleCategoriesBuilder::default().all().build(), + max_diagnostics, + only, + skip, + ) .with_file_path_and_code( workspace_file.path.display().to_string(), category!("check"), diff --git a/crates/pg_completions/Cargo.toml b/crates/pg_completions/Cargo.toml index 9329108e..89a37059 100644 --- a/crates/pg_completions/Cargo.toml +++ b/crates/pg_completions/Cargo.toml @@ -25,7 +25,7 @@ sqlx.workspace = true tokio = { version = "1.41.1", features = ["full"] } [dev-dependencies] -pg_test_utils.workspace = true +pg_test_utils.workspace = true [lib] doctest = false diff --git a/crates/pg_configuration/Cargo.toml b/crates/pg_configuration/Cargo.toml index 13139916..63e2f773 100644 --- a/crates/pg_configuration/Cargo.toml +++ b/crates/pg_configuration/Cargo.toml @@ -15,8 +15,11 @@ version = "0.0.0" biome_deserialize = { workspace = true } biome_deserialize_macros = { workspace = true } bpaf = { workspace = true } +pg_analyse = { workspace = true } +pg_analyser = { workspace = true } pg_console = { workspace = true } pg_diagnostics = { workspace = true } +rustc-hash = { workspace = true } schemars = { workspace = true, features = ["indexmap1"], optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } diff --git a/crates/pg_configuration/src/analyser/linter/mod.rs b/crates/pg_configuration/src/analyser/linter/mod.rs new file mode 100644 index 00000000..20535a2e --- /dev/null +++ b/crates/pg_configuration/src/analyser/linter/mod.rs @@ -0,0 +1,58 @@ +mod rules; + +use biome_deserialize::StringSet; +use biome_deserialize_macros::{Merge, Partial}; +use bpaf::Bpaf; +pub use rules::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, Merge, PartialEq))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(rename_all = "camelCase", default, deny_unknown_fields))] +pub struct LinterConfiguration { + /// if `false`, it disables the feature and the linter won't be executed. `true` by default + #[partial(bpaf(hide))] + pub enabled: bool, + + /// List of rules + #[partial(bpaf(pure(Default::default()), optional, hide))] + pub rules: Rules, + + /// A list of Unix shell style patterns. The formatter will ignore files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub ignore: StringSet, + + /// A list of Unix shell style patterns. The formatter will include files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub include: StringSet, +} + +impl LinterConfiguration { + pub const fn is_disabled(&self) -> bool { + !self.enabled + } +} + +impl Default for LinterConfiguration { + fn default() -> Self { + Self { + enabled: true, + rules: Default::default(), + ignore: Default::default(), + include: Default::default(), + } + } +} + +impl PartialLinterConfiguration { + pub const fn is_disabled(&self) -> bool { + matches!(self.enabled, Some(false)) + } + + pub fn get_rules(&self) -> Rules { + self.rules.clone().unwrap_or_default() + } +} diff --git a/crates/pg_configuration/src/analyser/linter/rules.rs b/crates/pg_configuration/src/analyser/linter/rules.rs new file mode 100644 index 00000000..cbd875ad --- /dev/null +++ b/crates/pg_configuration/src/analyser/linter/rules.rs @@ -0,0 +1,236 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::analyser::{RuleConfiguration, RulePlainConfiguration}; +use biome_deserialize_macros::Merge; +use pg_analyse::{options::RuleOptions, RuleFilter}; +use pg_diagnostics::{Category, Severity}; +use rustc_hash::FxHashSet; +#[cfg(feature = "schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +#[derive( + Clone, + Copy, + Debug, + Eq, + Hash, + Merge, + Ord, + PartialEq, + PartialOrd, + serde :: Deserialize, + serde :: Serialize, +)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RuleGroup { + Safety, +} +impl RuleGroup { + pub const fn as_str(self) -> &'static str { + match self { + Self::Safety => Safety::GROUP_NAME, + } + } +} +impl std::str::FromStr for RuleGroup { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + Safety::GROUP_NAME => Ok(Self::Safety), + _ => Err("This rule group doesn't exist."), + } + } +} +#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Rules { + #[doc = r" It enables the lint rules recommended by Biome. `true` by default."] + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + #[doc = r" It enables ALL rules. The rules that belong to `nursery` won't be enabled."] + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safety: Option, +} +impl Rules { + #[doc = r" Checks if the code coming from [pg_diagnostics::Diagnostic] corresponds to a rule."] + #[doc = r" Usually the code is built like {group}/{rule_name}"] + pub fn has_rule(group: RuleGroup, rule_name: &str) -> Option<&'static str> { + match group { + RuleGroup::Safety => Safety::has_rule(rule_name), + } + } + #[doc = r" Given a category coming from [Diagnostic](pg_diagnostics::Diagnostic), this function returns"] + #[doc = r" the [Severity](pg_diagnostics::Severity) associated to the rule, if the configuration changed it."] + #[doc = r" If the severity is off or not set, then the function returns the default severity of the rule:"] + #[doc = r" [Severity::Error] for recommended rules and [Severity::Warning] for other rules."] + #[doc = r""] + #[doc = r" If not, the function returns [None]."] + pub fn get_severity_from_code(&self, category: &Category) -> Option { + let mut split_code = category.name().split('/'); + let _lint = split_code.next(); + debug_assert_eq!(_lint, Some("lint")); + let group = ::from_str(split_code.next()?).ok()?; + let rule_name = split_code.next()?; + let rule_name = Self::has_rule(group, rule_name)?; + let severity = match group { + RuleGroup::Safety => self + .safety + .as_ref() + .and_then(|group| group.get_rule_configuration(rule_name)) + .filter(|(level, _)| !matches!(level, RulePlainConfiguration::Off)) + .map_or_else( + || { + if Safety::is_recommended_rule(rule_name) { + Severity::Error + } else { + Severity::Warning + } + }, + |(level, _)| level.into(), + ), + }; + Some(severity) + } + #[doc = r" Ensure that `recommended` is set to `true` or implied."] + pub fn set_recommended(&mut self) { + if self.all != Some(true) && self.recommended == Some(false) { + self.recommended = Some(true) + } + if let Some(group) = &mut self.safety { + group.recommended = None; + } + } + pub(crate) const fn is_recommended_false(&self) -> bool { + matches!(self.recommended, Some(false)) + } + pub(crate) const fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + #[doc = r" It returns the enabled rules by default."] + #[doc = r""] + #[doc = r" The enabled rules are calculated from the difference with the disabled rules."] + pub fn as_enabled_rules(&self) -> FxHashSet> { + let mut enabled_rules = FxHashSet::default(); + let mut disabled_rules = FxHashSet::default(); + if let Some(group) = self.safety.as_ref() { + group.collect_preset_rules( + self.is_all_true(), + !self.is_recommended_false(), + &mut enabled_rules, + ); + enabled_rules.extend(&group.get_enabled_rules()); + disabled_rules.extend(&group.get_disabled_rules()); + } else if self.is_all_true() { + enabled_rules.extend(Safety::all_rules_as_filters()); + } else if !self.is_recommended_false() { + enabled_rules.extend(Safety::recommended_rules_as_filters()); + } + enabled_rules.difference(&disabled_rules).copied().collect() + } +} +#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", default, deny_unknown_fields)] +#[doc = r" A list of rules that belong to this group"] +pub struct Safety { + #[doc = r" It enables the recommended rules for this group"] + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + #[doc = r" It enables ALL rules for this group."] + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + #[doc = "Dropping a column may break existing clients."] + #[serde(skip_serializing_if = "Option::is_none")] + pub ban_drop_column: Option>, +} +impl Safety { + const GROUP_NAME: &'static str = "safety"; + pub(crate) const GROUP_RULES: &'static [&'static str] = &["banDropColumn"]; + const RECOMMENDED_RULES: &'static [&'static str] = &["banDropColumn"]; + const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = + &[RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])]; + const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = + &[RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])]; + #[doc = r" Retrieves the recommended rules"] + pub(crate) fn is_recommended_true(&self) -> bool { + matches!(self.recommended, Some(true)) + } + pub(crate) fn is_recommended_unset(&self) -> bool { + self.recommended.is_none() + } + pub(crate) fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + pub(crate) fn is_all_unset(&self) -> bool { + self.all.is_none() + } + pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.ban_drop_column.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + index_set + } + pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + if let Some(rule) = self.ban_drop_column.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); + } + } + index_set + } + #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] + pub(crate) fn has_rule(rule_name: &str) -> Option<&'static str> { + Some(Self::GROUP_RULES[Self::GROUP_RULES.binary_search(&rule_name).ok()?]) + } + #[doc = r" Checks if, given a rule name, it is marked as recommended"] + pub(crate) fn is_recommended_rule(rule_name: &str) -> bool { + Self::RECOMMENDED_RULES.contains(&rule_name) + } + pub(crate) fn recommended_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::RECOMMENDED_RULES_AS_FILTERS + } + pub(crate) fn all_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::ALL_RULES_AS_FILTERS + } + #[doc = r" Select preset rules"] + pub(crate) fn collect_preset_rules( + &self, + parent_is_all: bool, + parent_is_recommended: bool, + enabled_rules: &mut FxHashSet>, + ) { + if self.is_all_true() || self.is_all_unset() && parent_is_all { + enabled_rules.extend(Self::all_rules_as_filters()); + } else if self.is_recommended_true() + || self.is_recommended_unset() && self.is_all_unset() && parent_is_recommended + { + enabled_rules.extend(Self::recommended_rules_as_filters()); + } + } + pub(crate) fn get_rule_configuration( + &self, + rule_name: &str, + ) -> Option<(RulePlainConfiguration, Option)> { + match rule_name { + "banDropColumn" => self + .ban_drop_column + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + _ => None, + } + } +} +#[test] +fn test_order() { + for items in Safety::GROUP_RULES.windows(2) { + assert!(items[0] < items[1], "{} < {}", items[0], items[1]); + } +} diff --git a/crates/pg_configuration/src/analyser/mod.rs b/crates/pg_configuration/src/analyser/mod.rs new file mode 100644 index 00000000..2273eff0 --- /dev/null +++ b/crates/pg_configuration/src/analyser/mod.rs @@ -0,0 +1,389 @@ +pub mod linter; + +pub use crate::analyser::linter::*; +use biome_deserialize::Merge; +use biome_deserialize_macros::Deserializable; +use pg_analyse::options::RuleOptions; +use pg_analyse::RuleFilter; +use pg_diagnostics::Severity; +#[cfg(feature = "schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] +pub enum RuleConfiguration { + Plain(RulePlainConfiguration), + WithOptions(RuleWithOptions), +} +impl RuleConfiguration { + pub fn is_disabled(&self) -> bool { + matches!(self.level(), RulePlainConfiguration::Off) + } + pub fn is_enabled(&self) -> bool { + !self.is_disabled() + } + pub fn level(&self) -> RulePlainConfiguration { + match self { + Self::Plain(plain) => *plain, + Self::WithOptions(options) => options.level, + } + } + pub fn set_level(&mut self, level: RulePlainConfiguration) { + match self { + Self::Plain(plain) => *plain = level, + Self::WithOptions(options) => options.level = level, + } + } +} +// Rule configuration has a custom [Merge] implementation so that overriding the +// severity doesn't override the options. +impl Merge for RuleConfiguration { + fn merge_with(&mut self, other: Self) { + match self { + Self::Plain(_) => *self = other, + Self::WithOptions(this) => match other { + Self::Plain(level) => { + this.level = level; + } + Self::WithOptions(other) => { + this.merge_with(other); + } + }, + } + } +} +impl RuleConfiguration { + pub fn get_options(&self) -> Option { + match self { + Self::Plain(_) => None, + Self::WithOptions(options) => Some(RuleOptions::new(options.options.clone())), + } + } +} +impl Default for RuleConfiguration { + fn default() -> Self { + Self::Plain(RulePlainConfiguration::Error) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] +pub enum RuleFixConfiguration { + Plain(RulePlainConfiguration), + WithOptions(RuleWithFixOptions), +} +impl Default for RuleFixConfiguration { + fn default() -> Self { + Self::Plain(RulePlainConfiguration::Error) + } +} +impl RuleFixConfiguration { + pub fn is_disabled(&self) -> bool { + matches!(self.level(), RulePlainConfiguration::Off) + } + pub fn is_enabled(&self) -> bool { + !self.is_disabled() + } + pub fn level(&self) -> RulePlainConfiguration { + match self { + Self::Plain(plain) => *plain, + Self::WithOptions(options) => options.level, + } + } + pub fn set_level(&mut self, level: RulePlainConfiguration) { + match self { + Self::Plain(plain) => *plain = level, + Self::WithOptions(options) => options.level = level, + } + } +} +// Rule configuration has a custom [Merge] implementation so that overriding the +// severity doesn't override the options. +impl Merge for RuleFixConfiguration { + fn merge_with(&mut self, other: Self) { + match self { + Self::Plain(_) => *self = other, + Self::WithOptions(this) => match other { + Self::Plain(level) => { + this.level = level; + } + Self::WithOptions(other) => { + this.merge_with(other); + } + }, + } + } +} +impl RuleFixConfiguration { + pub fn get_options(&self) -> Option { + match self { + Self::Plain(_) => None, + Self::WithOptions(options) => Some(RuleOptions::new(options.options.clone())), + } + } +} +impl From<&RuleConfiguration> for Severity { + fn from(conf: &RuleConfiguration) -> Self { + match conf { + RuleConfiguration::Plain(p) => (*p).into(), + RuleConfiguration::WithOptions(conf) => { + let level = &conf.level; + (*level).into() + } + } + } +} +impl From for Severity { + fn from(conf: RulePlainConfiguration) -> Self { + match conf { + RulePlainConfiguration::Warn => Severity::Warning, + RulePlainConfiguration::Error => Severity::Error, + RulePlainConfiguration::Info => Severity::Information, + RulePlainConfiguration::Off => { + unreachable!("the rule is turned off, it should not step in here") + } + } + } +} +impl From for Severity { + fn from(conf: RuleAssistPlainConfiguration) -> Self { + match conf { + RuleAssistPlainConfiguration::On => Severity::Hint, + RuleAssistPlainConfiguration::Off => { + unreachable!("the rule is turned off, it should not step in here") + } + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RulePlainConfiguration { + #[default] + Warn, + Error, + Info, + Off, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] +pub enum RuleAssistConfiguration { + Plain(RuleAssistPlainConfiguration), + WithOptions(RuleAssistWithOptions), +} +impl RuleAssistConfiguration { + pub fn is_disabled(&self) -> bool { + matches!(self.level(), RuleAssistPlainConfiguration::Off) + } + pub fn is_enabled(&self) -> bool { + !self.is_disabled() + } + pub fn level(&self) -> RuleAssistPlainConfiguration { + match self { + Self::Plain(plain) => *plain, + Self::WithOptions(options) => options.level, + } + } + pub fn set_level(&mut self, level: RuleAssistPlainConfiguration) { + match self { + Self::Plain(plain) => *plain = level, + Self::WithOptions(options) => options.level = level, + } + } +} +// Rule configuration has a custom [Merge] implementation so that overriding the +// severity doesn't override the options. +impl Merge for RuleAssistConfiguration { + fn merge_with(&mut self, other: Self) { + match self { + Self::Plain(_) => *self = other, + Self::WithOptions(this) => match other { + Self::Plain(level) => { + this.level = level; + } + Self::WithOptions(other) => { + this.merge_with(other); + } + }, + } + } +} +impl RuleAssistConfiguration { + pub fn get_options(&self) -> Option { + match self { + Self::Plain(_) => None, + Self::WithOptions(options) => Some(RuleOptions::new(options.options.clone())), + } + } +} +impl Default for RuleAssistConfiguration { + fn default() -> Self { + Self::Plain(RuleAssistPlainConfiguration::Off) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum RuleAssistPlainConfiguration { + #[default] + On, + Off, +} +impl RuleAssistPlainConfiguration { + pub const fn is_enabled(&self) -> bool { + matches!(self, Self::On) + } + + pub const fn is_disabled(&self) -> bool { + matches!(self, Self::Off) + } +} +impl Merge for RuleAssistPlainConfiguration { + fn merge_with(&mut self, other: Self) { + *self = other; + } +} + +#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuleAssistWithOptions { + /// The severity of the emitted diagnostics by the rule + pub level: RuleAssistPlainConfiguration, + /// Rule's options + pub options: T, +} +impl Merge for RuleAssistWithOptions { + fn merge_with(&mut self, other: Self) { + self.level = other.level; + self.options = other.options; + } +} + +#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuleWithOptions { + /// The severity of the emitted diagnostics by the rule + pub level: RulePlainConfiguration, + /// Rule's options + pub options: T, +} +impl Merge for RuleWithOptions { + fn merge_with(&mut self, other: Self) { + self.level = other.level; + self.options = other.options; + } +} + +#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuleWithFixOptions { + /// The severity of the emitted diagnostics by the rule + pub level: RulePlainConfiguration, + /// Rule's options + pub options: T, +} + +impl Merge for RuleWithFixOptions { + fn merge_with(&mut self, other: Self) { + self.level = other.level; + self.options = other.options; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum RuleSelector { + Group(linter::RuleGroup), + Rule(linter::RuleGroup, &'static str), +} + +impl From for RuleFilter<'static> { + fn from(value: RuleSelector) -> Self { + match value { + RuleSelector::Group(group) => RuleFilter::Group(group.as_str()), + RuleSelector::Rule(group, name) => RuleFilter::Rule(group.as_str(), name), + } + } +} + +impl<'a> From<&'a RuleSelector> for RuleFilter<'static> { + fn from(value: &'a RuleSelector) -> Self { + match value { + RuleSelector::Group(group) => RuleFilter::Group(group.as_str()), + RuleSelector::Rule(group, name) => RuleFilter::Rule(group.as_str(), name), + } + } +} + +impl FromStr for RuleSelector { + type Err = &'static str; + fn from_str(selector: &str) -> Result { + let selector = selector.strip_prefix("lint/").unwrap_or(selector); + if let Some((group_name, rule_name)) = selector.split_once('/') { + let group = linter::RuleGroup::from_str(group_name)?; + if let Some(rule_name) = Rules::has_rule(group, rule_name) { + Ok(RuleSelector::Rule(group, rule_name)) + } else { + Err("This rule doesn't exist.") + } + } else { + match linter::RuleGroup::from_str(selector) { + Ok(group) => Ok(RuleSelector::Group(group)), + Err(_) => Err( + "This group doesn't exist. Use the syntax `/` to specify a rule.", + ), + } + } + } +} + +impl serde::Serialize for RuleSelector { + fn serialize(&self, serializer: S) -> Result { + match self { + RuleSelector::Group(group) => serializer.serialize_str(group.as_str()), + RuleSelector::Rule(group, rule_name) => { + let group_name = group.as_str(); + serializer.serialize_str(&format!("{group_name}/{rule_name}")) + } + } + } +} + +impl<'de> serde::Deserialize<'de> for RuleSelector { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = RuleSelector; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("/") + } + fn visit_str(self, v: &str) -> Result { + match RuleSelector::from_str(v) { + Ok(result) => Ok(result), + Err(error) => Err(serde::de::Error::custom(error)), + } + } + } + deserializer.deserialize_str(Visitor) + } +} + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for RuleSelector { + fn schema_name() -> String { + "RuleCode".to_string() + } + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(gen) + } +} diff --git a/crates/pg_configuration/src/generated.rs b/crates/pg_configuration/src/generated.rs new file mode 100644 index 00000000..3bae7b80 --- /dev/null +++ b/crates/pg_configuration/src/generated.rs @@ -0,0 +1,3 @@ +mod linter; + +pub use linter::push_to_analyser_rules; diff --git a/crates/pg_configuration/src/generated/linter.rs b/crates/pg_configuration/src/generated/linter.rs new file mode 100644 index 00000000..ecce64ef --- /dev/null +++ b/crates/pg_configuration/src/generated/linter.rs @@ -0,0 +1,19 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::analyser::linter::*; +use pg_analyse::{AnalyserRules, MetadataRegistry}; +pub fn push_to_analyser_rules( + rules: &Rules, + metadata: &MetadataRegistry, + analyser_rules: &mut AnalyserRules, +) { + if let Some(rules) = rules.safety.as_ref() { + for rule_name in Safety::GROUP_RULES { + if let Some((_, Some(rule_options))) = rules.get_rule_configuration(rule_name) { + if let Some(rule_key) = metadata.find_rule("safety", rule_name) { + analyser_rules.push_rule(rule_key, rule_options); + } + } + } + } +} diff --git a/crates/pg_configuration/src/lib.rs b/crates/pg_configuration/src/lib.rs index 4a089e59..6d2e5f60 100644 --- a/crates/pg_configuration/src/lib.rs +++ b/crates/pg_configuration/src/lib.rs @@ -1,18 +1,26 @@ -//! This module contains the configuration of `pg.json` +//! This module contains the configuration of `pglsp.toml` //! //! The configuration is divided by "tool", and then it's possible to further customise it //! by language. The language might further options divided by tool. +pub mod analyser; pub mod database; pub mod diagnostics; pub mod files; +pub mod generated; pub mod vcs; pub use crate::diagnostics::ConfigurationDiagnostic; use std::path::PathBuf; +pub use crate::generated::push_to_analyser_rules; use crate::vcs::{partial_vcs_configuration, PartialVcsConfiguration, VcsConfiguration}; +pub use analyser::{ + partial_linter_configuration, LinterConfiguration, PartialLinterConfiguration, + RuleConfiguration, RuleFixConfiguration, RulePlainConfiguration, RuleSelector, + RuleWithFixOptions, RuleWithOptions, Rules, +}; use biome_deserialize_macros::Partial; use bpaf::Bpaf; use database::{ @@ -44,6 +52,10 @@ pub struct Configuration { )] pub files: FilesConfiguration, + /// The configuration for the linter + #[partial(type, bpaf(external(partial_linter_configuration), optional))] + pub linter: LinterConfiguration, + /// The configuration of the database connection #[partial( type, @@ -66,6 +78,14 @@ impl PartialConfiguration { use_ignore_file: Some(false), ..Default::default() }), + linter: Some(PartialLinterConfiguration { + enabled: Some(true), + rules: Some(Rules { + recommended: Some(true), + ..Default::default() + }), + ..Default::default() + }), db: Some(PartialDatabaseConfiguration { host: Some("127.0.0.1".to_string()), port: Some(5432), diff --git a/crates/pg_console/src/write/termcolor.rs b/crates/pg_console/src/write/termcolor.rs index fc94e7d3..2364fde2 100644 --- a/crates/pg_console/src/write/termcolor.rs +++ b/crates/pg_console/src/write/termcolor.rs @@ -217,13 +217,7 @@ fn unicode_to_ascii(c: char) -> char { mod tests { use std::{fmt::Write, str::from_utf8}; - use pg_markup::markup; - use termcolor::Ansi; - - use crate as pg_console; - use crate::fmt::Formatter; - - use super::{SanitizeAdapter, Termcolor}; + use super::SanitizeAdapter; #[test] fn test_printing_complex_emojis() { diff --git a/crates/pg_diagnostics/src/serde.rs b/crates/pg_diagnostics/src/serde.rs index 8d02c2b0..f99a1195 100644 --- a/crates/pg_diagnostics/src/serde.rs +++ b/crates/pg_diagnostics/src/serde.rs @@ -358,7 +358,7 @@ impl<'de> Deserialize<'de> for DiagnosticTags { mod tests { use std::io; - use serde_json::{from_value, json, to_value, Value}; + use serde_json::{json, Value}; use text_size::{TextRange, TextSize}; use crate::{ diff --git a/crates/pg_diagnostics_categories/build.rs b/crates/pg_diagnostics_categories/build.rs index d9fe0a9c..dc263664 100644 --- a/crates/pg_diagnostics_categories/build.rs +++ b/crates/pg_diagnostics_categories/build.rs @@ -104,7 +104,7 @@ pub fn main() -> io::Result<()> { /// The `category_concat!` macro is a variant of `category!` using a /// slightly different syntax, for use in the `declare_group` and - /// `declare_rule` macros in the analyzer + /// `declare_rule` macros in the analyser #[macro_export] macro_rules! category_concat { #( #concat_macro_arms )* diff --git a/crates/pg_diagnostics_categories/src/categories.rs b/crates/pg_diagnostics_categories/src/categories.rs index 983406fd..12b25b73 100644 --- a/crates/pg_diagnostics_categories/src/categories.rs +++ b/crates/pg_diagnostics_categories/src/categories.rs @@ -13,10 +13,11 @@ // must be between `define_categories! {\n` and `\n ;\n`. define_categories! { - "somerule": "https://example.com/some-rule", + "lint/safety/banDropColumn": "https://pglsp.dev/linter/rules/ban-drop-column", + // end lint rules ; + // General categories "stdin", - "lint", "check", "configuration", "database/connection", @@ -28,4 +29,10 @@ define_categories! { "internalError/panic", "syntax", "dummy", + + // Lint groups start + "lint", + "lint/performance", + "lint/safety", + // Lint groups end } diff --git a/crates/pg_inlay_hints/Cargo.toml b/crates/pg_inlay_hints/Cargo.toml index 98835234..3a73bf4b 100644 --- a/crates/pg_inlay_hints/Cargo.toml +++ b/crates/pg_inlay_hints/Cargo.toml @@ -22,8 +22,8 @@ tree-sitter.workspace = true tree_sitter_sql.workspace = true [dev-dependencies] -async-std = "1.12.0" -pg_test_utils.workspace = true +async-std = "1.12.0" +pg_test_utils.workspace = true [lib] diff --git a/crates/pg_lint/Cargo.toml b/crates/pg_lint/Cargo.toml index a4f59389..a349c57d 100644 --- a/crates/pg_lint/Cargo.toml +++ b/crates/pg_lint/Cargo.toml @@ -12,14 +12,17 @@ version = "0.0.0" [dependencies] -lazy_static = "1.4.0" -pg_base_db.workspace = true -pg_query_ext.workspace = true -pg_syntax.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_plain = "1.0" -text-size.workspace = true +enumflags2.workspace = true +lazy_static = "1.4.0" +pg_base_db.workspace = true +pg_console.workspace = true +pg_diagnostics.workspace = true +pg_query_ext.workspace = true +pg_syntax.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_plain = "1.0" +text-size.workspace = true [dev-dependencies] diff --git a/crates/pg_lint/src/rules/lint/safety.rs b/crates/pg_lint/src/rules/lint/safety.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/pg_lint/src/rules/lint/safety/ban_drop_table.rs b/crates/pg_lint/src/rules/lint/safety/ban_drop_table.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/pg_lsp/src/db_connection.rs b/crates/pg_lsp/src/db_connection.rs index 6948781b..38286ea5 100644 --- a/crates/pg_lsp/src/db_connection.rs +++ b/crates/pg_lsp/src/db_connection.rs @@ -62,7 +62,7 @@ impl DbConnection { Ok(Self { pool, - connection_string: connection_string, + connection_string, schema_update_handle, close_tx, }) diff --git a/crates/pg_lsp/src/debouncer.rs b/crates/pg_lsp/src/debouncer.rs index 61f17bf3..a970e278 100644 --- a/crates/pg_lsp/src/debouncer.rs +++ b/crates/pg_lsp/src/debouncer.rs @@ -81,6 +81,6 @@ impl SimpleTokioDebouncer { self.shutdown_flag .store(true, std::sync::atomic::Ordering::Relaxed); - let _ = self.handle.abort(); // we don't care about any errors during shutdown + self.handle.abort(); // we don't care about any errors during shutdown } } diff --git a/crates/pg_lsp/src/main.rs b/crates/pg_lsp/src/main.rs index ea366fb6..a5202626 100644 --- a/crates/pg_lsp/src/main.rs +++ b/crates/pg_lsp/src/main.rs @@ -23,7 +23,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting server."); - let (service, socket) = LspService::new(|client| LspServer::new(client)); + let (service, socket) = LspService::new(LspServer::new); Server::new(stdin, stdout, socket).serve(service).await; diff --git a/crates/pg_lsp/src/server.rs b/crates/pg_lsp/src/server.rs index bf21b0c2..3efb4f17 100644 --- a/crates/pg_lsp/src/server.rs +++ b/crates/pg_lsp/src/server.rs @@ -435,7 +435,7 @@ impl LanguageServer for LspServer { let completions = self.session.get_available_completions(path, position).await; - Ok(completions.map(|c| CompletionResponse::List(c))) + Ok(completions.map(CompletionResponse::List)) } #[tracing::instrument( diff --git a/crates/pg_lsp_new/Cargo.toml b/crates/pg_lsp_new/Cargo.toml index e9d1b30f..7b71c175 100644 --- a/crates/pg_lsp_new/Cargo.toml +++ b/crates/pg_lsp_new/Cargo.toml @@ -15,6 +15,7 @@ version = "0.0.0" anyhow = { workspace = true } biome_deserialize = { workspace = true } futures = "0.3.31" +pg_analyse = { workspace = true } pg_configuration = { workspace = true } pg_console = { workspace = true } pg_diagnostics = { workspace = true } diff --git a/crates/pg_lsp_new/src/session.rs b/crates/pg_lsp_new/src/session.rs index 9db4912c..72670e71 100644 --- a/crates/pg_lsp_new/src/session.rs +++ b/crates/pg_lsp_new/src/session.rs @@ -4,6 +4,7 @@ use crate::utils; use anyhow::Result; use futures::stream::FuturesUnordered; use futures::StreamExt; +use pg_analyse::RuleCategoriesBuilder; use pg_configuration::ConfigurationPathHint; use pg_diagnostics::{DiagnosticExt, Error}; use pg_fs::{FileSystem, PgLspPath}; @@ -255,10 +256,15 @@ impl Session { .await; } + let categories = RuleCategoriesBuilder::default().all(); + let diagnostics: Vec = { let result = self.workspace.pull_diagnostics(PullDiagnosticsParams { path: pglsp_path.clone(), max_diagnostics: u64::MAX, + categories: categories.build(), + only: Vec::new(), + skip: Vec::new(), })?; tracing::trace!("pglsp diagnostics: {:#?}", result.diagnostics); diff --git a/crates/pg_lsp_new/src/utils.rs b/crates/pg_lsp_new/src/utils.rs index fb0bd196..33eef1f7 100644 --- a/crates/pg_lsp_new/src/utils.rs +++ b/crates/pg_lsp_new/src/utils.rs @@ -304,12 +304,11 @@ pub(crate) fn apply_document_changes( #[cfg(test)] mod tests { - use super::apply_document_changes; + use pg_lsp_converters::line_index::LineIndex; - use pg_lsp_converters::{PositionEncoding, WideEncoding}; + use pg_lsp_converters::PositionEncoding; use pg_text_edit::TextEdit; use tower_lsp::lsp_types as lsp; - use tower_lsp::lsp_types::{Position, Range, TextDocumentContentChangeEvent}; #[test] fn test_diff_1() { diff --git a/crates/pg_query_ext/Cargo.toml b/crates/pg_query_ext/Cargo.toml index 8e9868e9..4ef2ef87 100644 --- a/crates/pg_query_ext/Cargo.toml +++ b/crates/pg_query_ext/Cargo.toml @@ -15,8 +15,10 @@ version = "0.0.0" petgraph = "0.6.4" pg_query = "0.8" +pg_diagnostics.workspace = true pg_lexer.workspace = true pg_query_ext_codegen.workspace = true +text-size.workspace = true [lib] doctest = false diff --git a/crates/pg_query_ext/src/diagnostics.rs b/crates/pg_query_ext/src/diagnostics.rs new file mode 100644 index 00000000..2096f9cf --- /dev/null +++ b/crates/pg_query_ext/src/diagnostics.rs @@ -0,0 +1,25 @@ +use pg_diagnostics::{Diagnostic, MessageAndDescription}; +use text_size::TextRange; + +/// A specialized diagnostic for the libpg_query parser. +/// +/// Parser diagnostics are always **errors**. +#[derive(Clone, Debug, Diagnostic)] +#[diagnostic(category = "syntax", severity = Error)] +pub struct SyntaxDiagnostic { + /// The location where the error is occurred + #[location(span)] + span: Option, + #[message] + #[description] + pub message: MessageAndDescription, +} + +impl From for SyntaxDiagnostic { + fn from(err: pg_query::Error) -> Self { + SyntaxDiagnostic { + span: None, + message: MessageAndDescription::from(err.to_string()), + } + } +} diff --git a/crates/pg_query_ext/src/lib.rs b/crates/pg_query_ext/src/lib.rs index 92a9bc8c..8cbbd2d7 100644 --- a/crates/pg_query_ext/src/lib.rs +++ b/crates/pg_query_ext/src/lib.rs @@ -10,6 +10,7 @@ //! - `get_nodes` to get all the nodes in the AST as a petgraph tree //! - `ChildrenIterator` to iterate over the children of a node mod codegen; +pub mod diagnostics; pub use pg_query::protobuf; pub use pg_query::{Error, NodeEnum, Result}; diff --git a/crates/pg_schema_cache/Cargo.toml b/crates/pg_schema_cache/Cargo.toml index f06520b8..d43690d6 100644 --- a/crates/pg_schema_cache/Cargo.toml +++ b/crates/pg_schema_cache/Cargo.toml @@ -12,15 +12,15 @@ version = "0.0.0" [dependencies] -anyhow.workspace = true -async-std = { version = "1.12.0" } -futures-util = "0.3.31" -serde.workspace = true -serde_json.workspace = true +anyhow.workspace = true +async-std = { version = "1.12.0" } +futures-util = "0.3.31" +pg_console.workspace = true pg_diagnostics.workspace = true -pg_console.workspace = true -sqlx.workspace = true -tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +tokio.workspace = true [dev-dependencies] pg_test_utils.workspace = true diff --git a/crates/pg_schema_cache/src/schema_cache.rs b/crates/pg_schema_cache/src/schema_cache.rs index 8d73e631..6deac6f1 100644 --- a/crates/pg_schema_cache/src/schema_cache.rs +++ b/crates/pg_schema_cache/src/schema_cache.rs @@ -66,7 +66,7 @@ impl SchemaCache { self.columns.iter().find(|c| { c.name.as_str() == name && c.table_name.as_str() == table - && schema.is_none_or(|s| s == c.schema_name.as_str()) + && schema.map_or(true, |s| s == c.schema_name.as_str()) }) } diff --git a/crates/pg_workspace_new/Cargo.toml b/crates/pg_workspace_new/Cargo.toml index 520271f3..bb2bd7c8 100644 --- a/crates/pg_workspace_new/Cargo.toml +++ b/crates/pg_workspace_new/Cargo.toml @@ -16,6 +16,8 @@ biome_deserialize = "0.6.0" dashmap = "5.5.3" futures = "0.3.31" ignore = { workspace = true } +pg_analyse = { workspace = true, features = ["serde"] } +pg_analyser = { workspace = true } pg_configuration = { workspace = true } pg_console = { workspace = true } pg_diagnostics = { workspace = true } @@ -23,11 +25,12 @@ pg_fs = { workspace = true, features = ["serde"] } pg_query_ext = { workspace = true } pg_schema_cache = { workspace = true } pg_statement_splitter = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } sqlx.workspace = true text-size.workspace = true -tokio = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } toml = { workspace = true } tracing = { workspace = true, features = ["attributes", "log"] } tree-sitter.workspace = true diff --git a/crates/pg_workspace_new/src/configuration.rs b/crates/pg_workspace_new/src/configuration.rs index 20c2e812..481e9981 100644 --- a/crates/pg_workspace_new/src/configuration.rs +++ b/crates/pg_workspace_new/src/configuration.rs @@ -1,14 +1,17 @@ use std::{ io::ErrorKind, + ops::Deref, path::{Path, PathBuf}, }; +use pg_analyse::AnalyserRules; use pg_configuration::{ - ConfigurationDiagnostic, ConfigurationPathHint, ConfigurationPayload, PartialConfiguration, + push_to_analyser_rules, ConfigurationDiagnostic, ConfigurationPathHint, ConfigurationPayload, + PartialConfiguration, }; use pg_fs::{AutoSearchResult, ConfigName, FileSystem, OpenOptions}; -use crate::{DynRef, WorkspaceError}; +use crate::{settings::Settings, DynRef, WorkspaceError}; /// Information regarding the configuration that was found. /// @@ -174,3 +177,12 @@ pub fn create_config( Ok(()) } + +/// Returns the rules applied to a specific [Path], given the [Settings] +pub fn to_analyser_rules(settings: &Settings) -> AnalyserRules { + let mut analyser_rules = AnalyserRules::default(); + if let Some(rules) = settings.linter.rules.as_ref() { + push_to_analyser_rules(rules, pg_analyser::METADATA.deref(), &mut analyser_rules); + } + analyser_rules +} diff --git a/crates/pg_workspace_new/src/settings.rs b/crates/pg_workspace_new/src/settings.rs index d3abacdd..1950ef8d 100644 --- a/crates/pg_workspace_new/src/settings.rs +++ b/crates/pg_workspace_new/src/settings.rs @@ -1,5 +1,7 @@ use biome_deserialize::StringSet; +use pg_diagnostics::Category; use std::{ + borrow::Cow, num::NonZeroU64, path::{Path, PathBuf}, sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}, @@ -8,7 +10,7 @@ use std::{ use ignore::gitignore::{Gitignore, GitignoreBuilder}; use pg_configuration::{ database::PartialDatabaseConfiguration, diagnostics::InvalidIgnorePattern, - files::FilesConfiguration, ConfigurationDiagnostic, PartialConfiguration, + files::FilesConfiguration, ConfigurationDiagnostic, LinterConfiguration, PartialConfiguration, }; use pg_fs::FileSystem; @@ -22,6 +24,9 @@ pub struct Settings { /// Database settings for the workspace pub db: DatabaseSettings, + + /// Linter settings applied to all files in the workspace + pub linter: LinterSettings, } #[derive(Debug)] @@ -88,8 +93,50 @@ impl Settings { self.db = db.into() } + // linter part + if let Some(linter) = configuration.linter { + self.linter = + to_linter_settings(working_directory.clone(), LinterConfiguration::from(linter))?; + } + Ok(()) } + + /// Retrieves the settings of the linter + pub fn linter(&self) -> &LinterSettings { + &self.linter + } + + /// Returns linter rules. + pub fn as_linter_rules(&self) -> Option> { + self.linter.rules.as_ref().map(Cow::Borrowed) + } + + /// It retrieves the severity based on the `code` of the rule and the current configuration. + /// + /// The code of the has the following pattern: `{group}/{rule_name}`. + /// + /// It returns [None] if the `code` doesn't match any rule. + pub fn get_severity_from_rule_code(&self, code: &Category) -> Option { + let rules = self.linter.rules.as_ref(); + if let Some(rules) = rules { + rules.get_severity_from_code(code) + } else { + None + } + } +} + +fn to_linter_settings( + working_directory: Option, + conf: LinterConfiguration, +) -> Result { + Ok(LinterSettings { + enabled: conf.enabled, + rules: Some(conf.rules), + ignored_files: to_matcher(working_directory.clone(), Some(&conf.ignore))?, + included_files: to_matcher(working_directory.clone(), Some(&conf.include))?, + }) } fn to_file_settings( @@ -170,6 +217,33 @@ pub fn to_matcher( Ok(matcher) } +/// Linter settings for the entire workspace +#[derive(Debug)] +pub struct LinterSettings { + /// Enabled by default + pub enabled: bool, + + /// List of rules + pub rules: Option, + + /// List of ignored paths/files to match + pub ignored_files: Matcher, + + /// List of included paths/files to match + pub included_files: Matcher, +} + +impl Default for LinterSettings { + fn default() -> Self { + Self { + enabled: true, + rules: Some(pg_configuration::analyser::linter::Rules::default()), + ignored_files: Matcher::empty(), + included_files: Matcher::empty(), + } + } +} + /// Database settings for the entire workspace #[derive(Debug)] pub struct DatabaseSettings { diff --git a/crates/pg_workspace_new/src/workspace.rs b/crates/pg_workspace_new/src/workspace.rs index 20293e7f..3688c064 100644 --- a/crates/pg_workspace_new/src/workspace.rs +++ b/crates/pg_workspace_new/src/workspace.rs @@ -1,7 +1,8 @@ use std::{panic::RefUnwindSafe, path::PathBuf, sync::Arc}; pub use self::client::{TransportRequest, WorkspaceClient, WorkspaceTransport}; -use pg_configuration::PartialConfiguration; +use pg_analyse::RuleCategories; +use pg_configuration::{PartialConfiguration, RuleSelector}; use pg_fs::PgLspPath; use serde::{Deserialize, Serialize}; use text_size::TextRange; @@ -33,10 +34,10 @@ pub struct ChangeFileParams { #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct PullDiagnosticsParams { pub path: PgLspPath, - // pub categories: RuleCategories, + pub categories: RuleCategories, pub max_diagnostics: u64, - // pub only: Vec, - // pub skip: Vec, + pub only: Vec, + pub skip: Vec, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -188,17 +189,17 @@ impl<'app, W: Workspace + ?Sized> FileGuard<'app, W> { pub fn pull_diagnostics( &self, - // categories: RuleCategories, + categories: RuleCategories, max_diagnostics: u32, - // only: Vec, - // skip: Vec, + only: Vec, + skip: Vec, ) -> Result { self.workspace.pull_diagnostics(PullDiagnosticsParams { path: self.path.clone(), - // categories, + categories, max_diagnostics: max_diagnostics.into(), - // only, - // skip, + only, + skip, }) } // diff --git a/crates/pg_workspace_new/src/workspace/server.rs b/crates/pg_workspace_new/src/workspace/server.rs index f7ddbf59..1e569c86 100644 --- a/crates/pg_workspace_new/src/workspace/server.rs +++ b/crates/pg_workspace_new/src/workspace/server.rs @@ -1,8 +1,11 @@ use std::{fs, future::Future, panic::RefUnwindSafe, path::Path, sync::RwLock}; +use analyser::AnalyserVisitorBuilder; use change::StatementChange; use dashmap::{DashMap, DashSet}; use document::{Document, StatementRef}; +use pg_analyse::{AnalyserOptions, AnalysisFilter}; +use pg_analyser::{Analyser, AnalyserConfig, AnalyserContext}; use pg_diagnostics::{serde::Diagnostic as SDiagnostic, Diagnostic, DiagnosticExt, Severity}; use pg_fs::{ConfigName, PgLspPath}; use pg_query::PgQueryStore; @@ -15,6 +18,7 @@ use tracing::info; use tree_sitter::TreeSitterStore; use crate::{ + configuration::to_analyser_rules, settings::{Settings, SettingsHandle, SettingsHandleMut}, workspace::PullDiagnosticsResult, WorkspaceError, @@ -25,6 +29,7 @@ use super::{ Workspace, }; +mod analyser; mod change; mod document; mod pg_query; @@ -310,20 +315,69 @@ impl Workspace for WorkspaceServer { .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; + let settings = self.settings(); + + // create analyser for this run + // first, collect enabled and disabled rules from the workspace settings + let (enabled_rules, disabled_rules) = AnalyserVisitorBuilder::new(settings.as_ref()) + .with_linter_rules(¶ms.only, ¶ms.skip) + .finish(); + // then, build a map that contains all options + let options = AnalyserOptions { + rules: to_analyser_rules(settings.as_ref()), + }; + // next, build the analysis filter which will be used to match rules + let filter = AnalysisFilter { + categories: params.categories, + enabled_rules: Some(enabled_rules.as_slice()), + disabled_rules: &disabled_rules, + }; + // finally, create the analyser that will be used during this run + let analyser = Analyser::new(AnalyserConfig { + options: &options, + filter, + }); + let diagnostics: Vec = doc .statement_refs_with_ranges() .iter() .flat_map(|(stmt, r)| { let mut stmt_diagnostics = vec![]; - stmt_diagnostics.extend(self.pg_query.pull_diagnostics(stmt)); + stmt_diagnostics.extend(self.pg_query.diagnostics(stmt)); + let ast = self.pg_query.load(stmt); + if let Some(ast) = ast { + stmt_diagnostics.extend( + analyser + .run(AnalyserContext { root: &ast }) + .into_iter() + .map(SDiagnostic::new) + .collect::>(), + ); + } stmt_diagnostics .into_iter() .map(|d| { + // We do now check if the severity of the diagnostics should be changed. + // The configuration allows to change the severity of the diagnostics emitted by rules. + let severity = d + .category() + .filter(|category| category.name().starts_with("lint/")) + .map_or_else( + || d.severity(), + |category| { + settings + .as_ref() + .get_severity_from_rule_code(category) + .unwrap_or(Severity::Warning) + }, + ); + SDiagnostic::new( d.with_file_path(params.path.as_path().display().to_string()) - .with_file_span(r), + .with_file_span(r) + .with_severity(severity), ) }) .collect::>() diff --git a/crates/pg_workspace_new/src/workspace/server/analyser.rs b/crates/pg_workspace_new/src/workspace/server/analyser.rs new file mode 100644 index 00000000..7f6aa443 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/analyser.rs @@ -0,0 +1,129 @@ +use pg_analyse::{GroupCategory, RegistryVisitor, Rule, RuleCategory, RuleFilter, RuleGroup}; +use pg_configuration::RuleSelector; +use rustc_hash::FxHashSet; + +use crate::settings::Settings; + +pub(crate) struct AnalyserVisitorBuilder<'a, 'b> { + lint: Option>, + settings: &'b Settings, +} + +impl<'a, 'b> AnalyserVisitorBuilder<'a, 'b> { + pub(crate) fn new(settings: &'b Settings) -> Self { + Self { + settings, + lint: None, + } + } + #[must_use] + pub(crate) fn with_linter_rules( + mut self, + only: &'b [RuleSelector], + skip: &'b [RuleSelector], + ) -> Self { + self.lint = Some(LintVisitor::new(only, skip, self.settings)); + self + } + + #[must_use] + pub(crate) fn finish(self) -> (Vec>, Vec>) { + let mut disabled_rules = vec![]; + let mut enabled_rules = vec![]; + if let Some(mut lint) = self.lint { + pg_analyser::visit_registry(&mut lint); + let (linter_enabled_rules, linter_disabled_rules) = lint.finish(); + enabled_rules.extend(linter_enabled_rules); + disabled_rules.extend(linter_disabled_rules); + } + + (enabled_rules, disabled_rules) + } +} + +/// Type meant to register all the lint rules +#[derive(Debug)] +struct LintVisitor<'a, 'b> { + pub(crate) enabled_rules: FxHashSet>, + pub(crate) disabled_rules: FxHashSet>, + only: &'b [RuleSelector], + skip: &'b [RuleSelector], + settings: &'b Settings, +} + +impl<'a, 'b> LintVisitor<'a, 'b> { + pub(crate) fn new( + only: &'b [RuleSelector], + skip: &'b [RuleSelector], + settings: &'b Settings, + ) -> Self { + Self { + enabled_rules: Default::default(), + disabled_rules: Default::default(), + only, + skip, + settings, + } + } + + fn finish(mut self) -> (FxHashSet>, FxHashSet>) { + let has_only_filter = !self.only.is_empty(); + if !has_only_filter { + let enabled_rules = self + .settings + .as_linter_rules() + .map(|rules| rules.as_enabled_rules()) + .unwrap_or_default(); + self.enabled_rules.extend(enabled_rules); + } + (self.enabled_rules, self.disabled_rules) + } + + fn push_rule(&mut self) + where + R: Rule + 'static, + { + // Do not report unused suppression comment diagnostics if a single rule is run. + for selector in self.only { + let filter = RuleFilter::from(selector); + if filter.match_rule::() { + self.enabled_rules.insert(filter); + } + } + for selector in self.skip { + let filter = RuleFilter::from(selector); + if filter.match_rule::() { + self.disabled_rules.insert(filter); + } + } + } +} + +impl<'a, 'b> RegistryVisitor for LintVisitor<'a, 'b> { + fn record_category(&mut self) { + if C::CATEGORY == RuleCategory::Lint { + C::record_groups(self) + } + } + + fn record_group(&mut self) { + for selector in self.only { + if RuleFilter::from(selector).match_group::() { + G::record_rules(self) + } + } + + for selector in self.skip { + if RuleFilter::from(selector).match_group::() { + G::record_rules(self) + } + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + { + self.push_rule::() + } +} diff --git a/crates/pg_workspace_new/src/workspace/server/pg_query.rs b/crates/pg_workspace_new/src/workspace/server/pg_query.rs index 8d433c4d..e14e0a27 100644 --- a/crates/pg_workspace_new/src/workspace/server/pg_query.rs +++ b/crates/pg_workspace_new/src/workspace/server/pg_query.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use dashmap::DashMap; use pg_diagnostics::{serde::Diagnostic as SDiagnostic, Diagnostic, MessageAndDescription}; +use pg_query_ext::diagnostics::*; use text_size::TextRange; use super::{ @@ -10,34 +11,11 @@ use super::{ store::Store, }; -/// A specialized diagnostic for the libpg_query parser. -/// -/// Parser diagnostics are always **errors**. -#[derive(Clone, Debug, Diagnostic)] -#[diagnostic(category = "syntax", severity = Error)] -pub struct SyntaxDiagnostic { - /// The location where the error is occurred - #[location(span)] - span: Option, - #[message] - #[description] - pub message: MessageAndDescription, -} - pub struct PgQueryStore { ast_db: DashMap>, diagnostics: DashMap, } -impl From for SyntaxDiagnostic { - fn from(err: pg_query_ext::Error) -> Self { - SyntaxDiagnostic { - span: None, - message: MessageAndDescription::from(err.to_string()), - } - } -} - impl PgQueryStore { pub fn new() -> PgQueryStore { PgQueryStore { @@ -45,16 +23,10 @@ impl PgQueryStore { diagnostics: DashMap::new(), } } - - pub fn pull_diagnostics(&self, ref_: &StatementRef) -> Vec { - self.diagnostics - .get(ref_) - .map_or_else(Vec::new, |err| vec![SDiagnostic::new(err.value().clone())]) - } } impl Store for PgQueryStore { - fn fetch(&self, statement: &StatementRef) -> Option> { + fn load(&self, statement: &StatementRef) -> Option> { self.ast_db.get(statement).map(|x| x.clone()) } @@ -80,4 +52,10 @@ impl Store for PgQueryStore { self.remove_statement(&change.old.ref_); self.add_statement(&change.new_statement()); } + + fn diagnostics(&self, stmt: &StatementRef) -> Vec { + self.diagnostics + .get(stmt) + .map_or_else(Vec::new, |err| vec![SDiagnostic::new(err.value().clone())]) + } } diff --git a/crates/pg_workspace_new/src/workspace/server/store.rs b/crates/pg_workspace_new/src/workspace/server/store.rs index 157c2773..0891a974 100644 --- a/crates/pg_workspace_new/src/workspace/server/store.rs +++ b/crates/pg_workspace_new/src/workspace/server/store.rs @@ -6,12 +6,18 @@ use super::{ }; pub(crate) trait Store { + fn diagnostics(&self, _stmt: &StatementRef) -> Vec { + Vec::new() + } + #[allow(dead_code)] - fn fetch(&self, statement: &StatementRef) -> Option>; + fn load(&self, _stmt: &StatementRef) -> Option> { + None + } - fn add_statement(&self, statement: &Statement); + fn add_statement(&self, _stmt: &Statement) {} - fn remove_statement(&self, statement: &StatementRef); + fn remove_statement(&self, _stmt: &StatementRef) {} - fn modify_statement(&self, change: &ChangedStatement); + fn modify_statement(&self, _change: &ChangedStatement) {} } diff --git a/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs b/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs index 5518535a..e0e8f120 100644 --- a/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs +++ b/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs @@ -30,7 +30,7 @@ impl TreeSitterStore { } impl Store for TreeSitterStore { - fn fetch(&self, statement: &StatementRef) -> Option> { + fn load(&self, statement: &StatementRef) -> Option> { self.db.get(statement).map(|x| x.clone()) } diff --git a/justfile b/justfile index 5d33f513..3cd149ad 100644 --- a/justfile +++ b/justfile @@ -4,7 +4,7 @@ _default: alias f := format alias t := test # alias r := ready -# alias l := lint +alias l := lint # alias qt := test-quick # Installs the tools needed to develop @@ -18,9 +18,9 @@ upgrade-tools: cargo binstall cargo-insta taplo-cli --force # Generate all files across crates and tools. You rarely want to use it locally. -# gen-all: -# cargo run -p xtask_codegen -- all -# cargo codegen-configuration +gen-all: + cargo run -p xtask_codegen -- all + # cargo codegen-configuration # cargo codegen-migrate # just gen-bindings # just format @@ -31,23 +31,23 @@ upgrade-tools: # cargo codegen-bindings # Generates code generated files for the linter -# gen-lint: -# cargo run -p xtask_codegen -- analyzer -# cargo codegen-configuration -# cargo codegen-migrate -# just gen-bindings -# cargo run -p rules_check -# just format +gen-lint: + cargo run -p xtask_codegen -- analyser + cargo run -p xtask_codegen -- configuration + # cargo codegen-migrate + # just gen-bindings + cargo run -p rules_check + just format # Generates the linter documentation and Rust documentation # documentation: # RUSTDOCFLAGS='-D warnings' cargo documentation -# Creates a new lint rule in the given path, with the given name. Name has to be camel case. -# new-lintrule rulename: -# cargo run -p xtask_codegen -- new-lintrule --kind=js --category=lint --name={{rulename}} -# just gen-lint -# just documentation +# Creates a new lint rule in the given path, with the given name. Name has to be camel case. Group should be lowercase. +new-lintrule group rulename: + cargo run -p xtask_codegen -- new-lintrule --category=lint --name={{rulename}} --group={{group}} + just gen-lint + # just documentation # Creates a new lint rule in the given path, with the given name. Name has to be camel case. # new-assistrule rulename: @@ -55,15 +55,6 @@ upgrade-tools: # just gen-lint # just documentation -# Promotes a rule from the nursery group to a new group -# promote-rule rulename group: -# cargo run -p xtask_codegen -- promote-rule --name={{rulename}} --group={{group}} -# just gen-lint -# just documentation -# -cargo test -p pg_analyze -- {{snakecase(rulename)}} -# cargo insta accept - - # Format Rust files and TOML files format: cargo fmt @@ -114,6 +105,9 @@ test-doc: lint: cargo clippy +lint-fix: + cargo clippy --fix + # When you finished coding, run this command to run the same commands in the CI. # ready: # git diff --exit-code --quiet @@ -147,3 +141,6 @@ clear-branches: reset-git: git checkout main && git pull && pnpm run clear-branches + +merge-main: + git fetch origin main:main && git merge main diff --git a/pglsp.toml b/pglsp.toml index 6055ed23..7e6bcb8e 100644 --- a/pglsp.toml +++ b/pglsp.toml @@ -12,3 +12,15 @@ port = 54322 username = "postgres" password = "postgres" database = "postgres" + +[linter] +enabled = true + +[linter.rules] +recommended = true + +[linter.rules.safety.banDropColumn] +level = "warn" +options = { test = "HELLO" } + + diff --git a/test.sql b/test.sql index d67be983..ea357c14 100644 --- a/test.sql +++ b/test.sql @@ -6,4 +6,6 @@ select * from test; alter tqjable test drop column id; +alter table test drop column id; + select lower(); diff --git a/xtask/codegen/Cargo.toml b/xtask/codegen/Cargo.toml index 7c5ba92b..d571f7df 100644 --- a/xtask/codegen/Cargo.toml +++ b/xtask/codegen/Cargo.toml @@ -5,5 +5,12 @@ publish = false version = "0.0.0" [dependencies] -bpaf = { workspace = true, features = ["derive"] } -xtask = { path = '../', version = "0.0" } +anyhow = { workspace = true } +biome_string_case = { workspace = true } +bpaf = { workspace = true, features = ["derive"] } +pg_analyse = { workspace = true } +pg_analyser = { workspace = true } +proc-macro2 = { workspace = true, features = ["span-locations"] } +pulldown-cmark = { version = "0.12.2" } +quote = "1.0.36" +xtask = { path = '../', version = "0.0" } diff --git a/xtask/codegen/src/generate_analyser.rs b/xtask/codegen/src/generate_analyser.rs new file mode 100644 index 00000000..6ea90907 --- /dev/null +++ b/xtask/codegen/src/generate_analyser.rs @@ -0,0 +1,234 @@ +use std::path::PathBuf; +use std::{collections::BTreeMap, path::Path}; + +use anyhow::{Context, Ok, Result}; +use biome_string_case::Case; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use xtask::{glue::fs2, project_root}; + +pub fn generate_analyser() -> Result<()> { + generate_linter()?; + Ok(()) +} + +fn generate_linter() -> Result<()> { + let base_path = project_root().join("crates/pg_analyser/src"); + let mut analysers = BTreeMap::new(); + generate_category("lint", &mut analysers, &base_path)?; + + generate_options(&base_path)?; + + update_linter_registry_builder(analysers) +} + +fn generate_options(base_path: &Path) -> Result<()> { + let mut rules_options = BTreeMap::new(); + let mut crates = vec![]; + for category in ["lint"] { + let category_path = base_path.join(category); + if !category_path.exists() { + continue; + } + let category_name = format_ident!("{}", filename(&category_path)?); + for group_path in list_entry_paths(&category_path)?.filter(|path| path.is_dir()) { + let group_name = format_ident!("{}", filename(&group_path)?.to_string()); + for rule_path in list_entry_paths(&group_path)?.filter(|path| !path.is_dir()) { + let rule_filename = filename(&rule_path)?; + let rule_name = Case::Pascal.convert(rule_filename); + let rule_module_name = format_ident!("{}", rule_filename); + let rule_name = format_ident!("{}", rule_name); + rules_options.insert(rule_filename.to_string(), quote! { + pub type #rule_name = <#category_name::#group_name::#rule_module_name::#rule_name as pg_analyse::Rule>::Options; + }); + } + } + if category == "lint" { + crates.push(quote! { + use crate::lint; + }) + } + } + let rules_options = rules_options.values(); + let tokens = xtask::reformat(quote! { + #( #crates )* + + #( #rules_options )* + })?; + fs2::write(base_path.join("options.rs"), tokens)?; + + Ok(()) +} + +fn generate_category( + name: &'static str, + entries: &mut BTreeMap<&'static str, TokenStream>, + base_path: &Path, +) -> Result<()> { + let path = base_path.join(name); + + let mut groups = BTreeMap::new(); + for entry in fs2::read_dir(path)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + + let entry = entry.path(); + let file_name = entry + .file_stem() + .context("path has no file name")? + .to_str() + .context("could not convert file name to string")?; + + generate_group(name, file_name, base_path)?; + + let module_name = format_ident!("{}", file_name); + let group_name = format_ident!("{}", Case::Pascal.convert(file_name)); + + groups.insert( + file_name.to_string(), + ( + quote! { + pub mod #module_name; + }, + quote! { + self::#module_name::#group_name + }, + ), + ); + } + + let key = name; + let module_name = format_ident!("{name}"); + + let category_name = Case::Pascal.convert(name); + let category_name = format_ident!("{category_name}"); + + let kind = match name { + "lint" => format_ident!("Lint"), + _ => panic!("unimplemented analyser category {name:?}"), + }; + + entries.insert( + key, + quote! { + registry.record_category::(); + }, + ); + + let (modules, paths): (Vec<_>, Vec<_>) = groups.into_values().unzip(); + let tokens = xtask::reformat(quote! { + #( #modules )* + ::pg_analyse::declare_category! { + pub #category_name { + kind: #kind, + groups: [ + #( #paths, )* + ] + } + } + })?; + + fs2::write(base_path.join(format!("{name}.rs")), tokens)?; + + Ok(()) +} + +fn generate_group(category: &'static str, group: &str, base_path: &Path) -> Result<()> { + let path = base_path.join(category).join(group); + + let mut rules = BTreeMap::new(); + for entry in fs2::read_dir(path)? { + let entry = entry?.path(); + let file_name = entry + .file_stem() + .context("path has no file name")? + .to_str() + .context("could not convert file name to string")?; + + let rule_type = Case::Pascal.convert(file_name); + + let key = rule_type.clone(); + let module_name = format_ident!("{}", file_name); + let rule_type = format_ident!("{}", rule_type); + + rules.insert( + key, + ( + quote! { + pub mod #module_name; + }, + quote! { + self::#module_name::#rule_type + }, + ), + ); + } + + let group_name = format_ident!("{}", Case::Pascal.convert(group)); + + let (rule_imports, rule_names): (Vec<_>, Vec<_>) = rules.into_values().unzip(); + + let (import_macro, use_macro) = match category { + "lint" => ( + quote!( + use pg_analyse::declare_lint_group; + ), + quote!(declare_lint_group), + ), + _ => panic!("Category not supported: {category}"), + }; + let tokens = xtask::reformat(quote! { + #import_macro + + #(#rule_imports)* + + #use_macro! { + pub #group_name { + name: #group, + rules: [ + #(#rule_names,)* + ] + } + } + })?; + + fs2::write(base_path.join(category).join(format!("{group}.rs")), tokens)?; + + Ok(()) +} + +fn update_linter_registry_builder(rules: BTreeMap<&'static str, TokenStream>) -> Result<()> { + let path = project_root().join("crates/pg_analyser/src/registry.rs"); + + let categories = rules.into_values(); + + let tokens = xtask::reformat(quote! { + use pg_analyse::RegistryVisitor; + + pub fn visit_registry(registry: &mut V) { + #( #categories )* + } + })?; + + fs2::write(path, tokens)?; + + Ok(()) +} + +/// Returns file paths of the given directory. +fn list_entry_paths(dir: &Path) -> Result> { + Ok(fs2::read_dir(dir) + .context("A directory is expected")? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path())) +} + +/// Returns filename if any. +fn filename(file: &Path) -> Result<&str> { + file.file_stem() + .context("path has no file name")? + .to_str() + .context("could not convert file name to string") +} diff --git a/xtask/codegen/src/generate_configuration.rs b/xtask/codegen/src/generate_configuration.rs new file mode 100644 index 00000000..ba54a326 --- /dev/null +++ b/xtask/codegen/src/generate_configuration.rs @@ -0,0 +1,744 @@ +use crate::{to_capitalized, update}; +use biome_string_case::Case; +use pg_analyse::{GroupCategory, RegistryVisitor, Rule, RuleCategory, RuleGroup, RuleMetadata}; +use proc_macro2::{Ident, Literal, Span, TokenStream}; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use quote::quote; +use std::collections::BTreeMap; +use std::path::Path; +use xtask::*; + +#[derive(Default)] +struct LintRulesVisitor { + groups: BTreeMap<&'static str, BTreeMap<&'static str, RuleMetadata>>, +} + +impl RegistryVisitor for LintRulesVisitor { + fn record_category(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } +} + +pub fn generate_rules_configuration(mode: Mode) -> Result<()> { + let linter_config_root = project_root().join("crates/pg_configuration/src/analyser/linter"); + let push_rules_directory = project_root().join("crates/pg_configuration/src/generated"); + + let mut lint_visitor = LintRulesVisitor::default(); + pg_analyser::visit_registry(&mut lint_visitor); + + generate_for_groups( + lint_visitor.groups, + linter_config_root.as_path(), + push_rules_directory.as_path(), + &mode, + RuleCategory::Lint, + )?; + Ok(()) +} + +fn generate_for_groups( + groups: BTreeMap<&'static str, BTreeMap<&'static str, RuleMetadata>>, + root: &Path, + push_directory: &Path, + mode: &Mode, + kind: RuleCategory, +) -> Result<()> { + let mut struct_groups = Vec::with_capacity(groups.len()); + let mut group_pascal_idents = Vec::with_capacity(groups.len()); + let mut group_idents = Vec::with_capacity(groups.len()); + let mut group_strings = Vec::with_capacity(groups.len()); + let mut group_as_default_rules = Vec::with_capacity(groups.len()); + for (group, rules) in groups { + let group_pascal_ident = quote::format_ident!("{}", &Case::Pascal.convert(group)); + let group_ident = quote::format_ident!("{}", group); + + let (global_all, global_recommended) = { + ( + quote! { self.is_all_true() }, + quote! { !self.is_recommended_false() }, + ) + }; + group_as_default_rules.push(if kind == RuleCategory::Lint { + quote! { + if let Some(group) = self.#group_ident.as_ref() { + group.collect_preset_rules( + #global_all, + #global_recommended, + &mut enabled_rules, + ); + enabled_rules.extend(&group.get_enabled_rules()); + disabled_rules.extend(&group.get_disabled_rules()); + } else if #global_all { + enabled_rules.extend(#group_pascal_ident::all_rules_as_filters()); + } else if #global_recommended { + enabled_rules.extend(#group_pascal_ident::recommended_rules_as_filters()); + } + } + } else { + quote! { + if let Some(group) = self.#group_ident.as_ref() { + enabled_rules.extend(&group.get_enabled_rules()); + } + } + }); + + group_pascal_idents.push(group_pascal_ident); + group_idents.push(group_ident); + group_strings.push(Literal::string(group)); + struct_groups.push(generate_group_struct(group, &rules, kind)); + } + + let severity_fn = if kind == RuleCategory::Action { + quote! { + /// Given a category coming from [Diagnostic](pg_diagnostics::Diagnostic), this function returns + /// the [Severity](pg_diagnostics::Severity) associated to the rule, if the configuration changed it. + /// If the severity is off or not set, then the function returns the default severity of the rule: + /// [Severity::Error] for recommended rules and [Severity::Warning] for other rules. + /// + /// If not, the function returns [None]. + pub fn get_severity_from_code(&self, category: &Category) -> Option { + let mut split_code = category.name().split('/'); + + let _lint = split_code.next(); + debug_assert_eq!(_lint, Some("assists")); + + let group = ::from_str(split_code.next()?).ok()?; + let rule_name = split_code.next()?; + let rule_name = Self::has_rule(group, rule_name)?; + match group { + #( + RuleGroup::#group_pascal_idents => self + .#group_idents + .as_ref() + .and_then(|group| group.get_rule_configuration(rule_name)) + .filter(|(level, _)| !matches!(level, RuleAssistPlainConfiguration::Off)) + .map(|(level, _)| level.into()) + )* + } + } + + } + } else { + quote! { + + /// Given a category coming from [Diagnostic](pg_diagnostics::Diagnostic), this function returns + /// the [Severity](pg_diagnostics::Severity) associated to the rule, if the configuration changed it. + /// If the severity is off or not set, then the function returns the default severity of the rule: + /// [Severity::Error] for recommended rules and [Severity::Warning] for other rules. + /// + /// If not, the function returns [None]. + pub fn get_severity_from_code(&self, category: &Category) -> Option { + let mut split_code = category.name().split('/'); + + let _lint = split_code.next(); + debug_assert_eq!(_lint, Some("lint")); + + let group = ::from_str(split_code.next()?).ok()?; + let rule_name = split_code.next()?; + let rule_name = Self::has_rule(group, rule_name)?; + let severity = match group { + #( + RuleGroup::#group_pascal_idents => self + .#group_idents + .as_ref() + .and_then(|group| group.get_rule_configuration(rule_name)) + .filter(|(level, _)| !matches!(level, RulePlainConfiguration::Off)) + .map_or_else(|| { + if #group_pascal_idents::is_recommended_rule(rule_name) { + Severity::Error + } else { + Severity::Warning + } + }, |(level, _)| level.into()), + )* + }; + Some(severity) + } + + } + }; + + let use_rule_configuration = if kind == RuleCategory::Action { + quote! { + use crate::analyser::{RuleAssistConfiguration, RuleAssistPlainConfiguration}; + use pg_analyse::{options::RuleOptions, RuleFilter}; + } + } else { + quote! { + use crate::analyser::{RuleConfiguration, RulePlainConfiguration}; + use pg_analyse::{options::RuleOptions, RuleFilter}; + } + }; + + let groups = if kind == RuleCategory::Action { + quote! { + #use_rule_configuration + use biome_deserialize_macros::Merge; + use pg_diagnostics::{Category, Severity}; + use rustc_hash::FxHashSet; + use serde::{Deserialize, Serialize}; + #[cfg(feature = "schema")] + use schemars::JsonSchema; + + #[derive(Clone, Copy, Debug, Eq, Hash, Merge, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase")] + pub enum RuleGroup { + #( #group_pascal_idents ),* + } + impl RuleGroup { + pub const fn as_str(self) -> &'static str { + match self { + #( Self::#group_pascal_idents => #group_pascal_idents::GROUP_NAME, )* + } + } + } + impl std::str::FromStr for RuleGroup { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + #( #group_pascal_idents::GROUP_NAME => Ok(Self::#group_pascal_idents), )* + _ => Err("This rule group doesn't exist.") + } + } + } + + #[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + pub struct Actions { + #( + #[serde(skip_serializing_if = "Option::is_none")] + pub #group_idents: Option<#group_pascal_idents>, + )* + } + + impl Actions { + /// Checks if the code coming from [pg_diagnostics::Diagnostic] corresponds to a rule. + /// Usually the code is built like {group}/{rule_name} + pub fn has_rule( + group: RuleGroup, + rule_name: &str, + ) -> Option<&'static str> { + match group { + #( + RuleGroup::#group_pascal_idents => #group_pascal_idents::has_rule(rule_name), + )* + } + } + + #severity_fn + + /// It returns the enabled rules by default. + /// + /// The enabled rules are calculated from the difference with the disabled rules. + pub fn as_enabled_rules(&self) -> FxHashSet> { + let mut enabled_rules = FxHashSet::default(); + #( #group_as_default_rules )* + enabled_rules + } + } + + #( #struct_groups )* + + #[test] + fn test_order() { + #( + for items in #group_pascal_idents::GROUP_RULES.windows(2) { + assert!(items[0] < items[1], "{} < {}", items[0], items[1]); + } + )* + } + } + } else { + quote! { + #use_rule_configuration + use biome_deserialize_macros::Merge; + use pg_diagnostics::{Category, Severity}; + use rustc_hash::FxHashSet; + use serde::{Deserialize, Serialize}; + #[cfg(feature = "schema")] + use schemars::JsonSchema; + + #[derive(Clone, Copy, Debug, Eq, Hash, Merge, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase")] + pub enum RuleGroup { + #( #group_pascal_idents ),* + } + impl RuleGroup { + pub const fn as_str(self) -> &'static str { + match self { + #( Self::#group_pascal_idents => #group_pascal_idents::GROUP_NAME, )* + } + } + } + impl std::str::FromStr for RuleGroup { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + #( #group_pascal_idents::GROUP_NAME => Ok(Self::#group_pascal_idents), )* + _ => Err("This rule group doesn't exist.") + } + } + } + + #[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + pub struct Rules { + /// It enables the lint rules recommended by Biome. `true` by default. + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + + /// It enables ALL rules. The rules that belong to `nursery` won't be enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + + #( + #[serde(skip_serializing_if = "Option::is_none")] + pub #group_idents: Option<#group_pascal_idents>, + )* + } + + impl Rules { + /// Checks if the code coming from [pg_diagnostics::Diagnostic] corresponds to a rule. + /// Usually the code is built like {group}/{rule_name} + pub fn has_rule( + group: RuleGroup, + rule_name: &str, + ) -> Option<&'static str> { + match group { + #( + RuleGroup::#group_pascal_idents => #group_pascal_idents::has_rule(rule_name), + )* + } + } + + #severity_fn + + /// Ensure that `recommended` is set to `true` or implied. + pub fn set_recommended(&mut self) { + if self.all != Some(true) && self.recommended == Some(false) { + self.recommended = Some(true) + } + #( + if let Some(group) = &mut self.#group_idents { + group.recommended = None; + } + )* + } + + // Note: In top level, it is only considered _not_ recommended + // when the recommended option is false + pub(crate) const fn is_recommended_false(&self) -> bool { + matches!(self.recommended, Some(false)) + } + + pub(crate) const fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + + /// It returns the enabled rules by default. + /// + /// The enabled rules are calculated from the difference with the disabled rules. + pub fn as_enabled_rules(&self) -> FxHashSet> { + let mut enabled_rules = FxHashSet::default(); + let mut disabled_rules = FxHashSet::default(); + #( #group_as_default_rules )* + + enabled_rules.difference(&disabled_rules).copied().collect() + } + } + + #( #struct_groups )* + + #[test] + fn test_order() { + #( + for items in #group_pascal_idents::GROUP_RULES.windows(2) { + assert!(items[0] < items[1], "{} < {}", items[0], items[1]); + } + )* + } + } + }; + + let push_rules = match kind { + RuleCategory::Lint => { + quote! { + use crate::analyser::linter::*; + use pg_analyse::{AnalyserRules, MetadataRegistry}; + + pub fn push_to_analyser_rules( + rules: &Rules, + metadata: &MetadataRegistry, + analyser_rules: &mut AnalyserRules, + ) { + #( + if let Some(rules) = rules.#group_idents.as_ref() { + for rule_name in #group_pascal_idents::GROUP_RULES { + if let Some((_, Some(rule_options))) = rules.get_rule_configuration(rule_name) { + if let Some(rule_key) = metadata.find_rule(#group_strings, rule_name) { + analyser_rules.push_rule(rule_key, rule_options); + } + } + } + } + )* + } + } + } + RuleCategory::Action => { + quote! { + use crate::analyser::assists::*; + use pg_analyse::{AnalyserRules, MetadataRegistry}; + + pub fn push_to_analyser_assists( + rules: &Actions, + metadata: &MetadataRegistry, + analyser_rules: &mut AnalyserRules, + ) { + #( + if let Some(rules) = rules.#group_idents.as_ref() { + for rule_name in #group_pascal_idents::GROUP_RULES { + if let Some((_, Some(rule_options))) = rules.get_rule_configuration(rule_name) { + if let Some(rule_key) = metadata.find_rule(#group_strings, rule_name) { + analyser_rules.push_rule(rule_key, rule_options); + } + } + } + } + )* + } + } + } + RuleCategory::Transformation => unimplemented!(), + }; + + let configuration = groups.to_string(); + let push_rules = push_rules.to_string(); + + let file_name = match kind { + RuleCategory::Lint => &push_directory.join("linter.rs"), + RuleCategory::Action => &push_directory.join("assists.rs"), + RuleCategory::Transformation => unimplemented!(), + }; + + let path = if kind == RuleCategory::Action { + &root.join("actions.rs") + } else { + &root.join("rules.rs") + }; + update(path, &xtask::reformat(configuration)?, mode)?; + update(file_name, &xtask::reformat(push_rules)?, mode)?; + + Ok(()) +} + +fn generate_group_struct( + group: &str, + rules: &BTreeMap<&'static str, RuleMetadata>, + kind: RuleCategory, +) -> TokenStream { + let mut lines_recommended_rule = Vec::new(); + let mut lines_recommended_rule_as_filter = Vec::new(); + let mut lines_all_rule_as_filter = Vec::new(); + let mut lines_rule = Vec::new(); + let mut schema_lines_rules = Vec::new(); + let mut rule_enabled_check_line = Vec::new(); + let mut rule_disabled_check_line = Vec::new(); + let mut get_rule_configuration_line = Vec::new(); + + for (index, (rule, metadata)) in rules.iter().enumerate() { + let summary = { + let mut docs = String::new(); + let parser = Parser::new(metadata.docs); + for event in parser { + match event { + Event::Text(text) => { + docs.push_str(text.as_ref()); + } + Event::Code(text) => { + // Escape `[` and `<` to obtain valid Markdown + docs.push_str(text.replace('[', "\\[").replace('<', "\\<").as_ref()); + } + Event::SoftBreak => { + docs.push(' '); + } + + Event::Start(Tag::Paragraph) => {} + Event::End(TagEnd::Paragraph) => { + break; + } + + Event::Start(tag) => match tag { + Tag::Strong | Tag::Paragraph => { + continue; + } + + _ => panic!("Unimplemented tag {:?}", { tag }), + }, + + Event::End(tag) => match tag { + TagEnd::Strong | TagEnd::Paragraph => { + continue; + } + _ => panic!("Unimplemented tag {:?}", { tag }), + }, + + _ => { + panic!("Unimplemented event {:?}", { event }) + } + } + } + docs + }; + + let rule_position = Literal::u8_unsuffixed(index as u8); + let rule_identifier = quote::format_ident!("{}", Case::Snake.convert(rule)); + let rule_config_type = quote::format_ident!( + "{}", + if kind == RuleCategory::Action { + "RuleAssistConfiguration" + } else { + "RuleConfiguration" + } + ); + let rule_name = Ident::new(&to_capitalized(rule), Span::call_site()); + if metadata.recommended { + lines_recommended_rule_as_filter.push(quote! { + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[#rule_position]) + }); + + lines_recommended_rule.push(quote! { + #rule + }); + } + lines_all_rule_as_filter.push(quote! { + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[#rule_position]) + }); + lines_rule.push(quote! { + #rule + }); + let rule_option_type = quote! { + pg_analyser::options::#rule_name + }; + let rule_option = if kind == RuleCategory::Action { + quote! { Option<#rule_config_type<#rule_option_type>> } + } else { + quote! { + Option<#rule_config_type<#rule_option_type>> + } + }; + schema_lines_rules.push(quote! { + #[doc = #summary] + #[serde(skip_serializing_if = "Option::is_none")] + pub #rule_identifier: #rule_option + }); + + rule_enabled_check_line.push(quote! { + if let Some(rule) = self.#rule_identifier.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule( + Self::GROUP_NAME, + Self::GROUP_RULES[#rule_position], + )); + } + } + }); + rule_disabled_check_line.push(quote! { + if let Some(rule) = self.#rule_identifier.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule( + Self::GROUP_NAME, + Self::GROUP_RULES[#rule_position], + )); + } + } + }); + + if kind == RuleCategory::Action { + get_rule_configuration_line.push(quote! { + #rule => self.#rule_identifier.as_ref().map(|conf| (conf.level(), conf.get_options())) + }); + } else { + get_rule_configuration_line.push(quote! { + #rule => self.#rule_identifier.as_ref().map(|conf| (conf.level(), conf.get_options())) + }); + } + } + + let group_pascal_ident = Ident::new(&to_capitalized(group), Span::call_site()); + + let get_configuration_function = if kind == RuleCategory::Action { + quote! { + pub(crate) fn get_rule_configuration(&self, rule_name: &str) -> Option<(RuleAssistPlainConfiguration, Option)> { + match rule_name { + #( #get_rule_configuration_line ),*, + _ => None + } + } + } + } else { + quote! { + pub(crate) fn get_rule_configuration(&self, rule_name: &str) -> Option<(RulePlainConfiguration, Option)> { + match rule_name { + #( #get_rule_configuration_line ),*, + _ => None + } + } + } + }; + + if kind == RuleCategory::Action { + quote! { + #[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase", default, deny_unknown_fields)] + /// A list of rules that belong to this group + pub struct #group_pascal_ident { + + #( #schema_lines_rules ),* + } + + impl #group_pascal_ident { + + const GROUP_NAME: &'static str = #group; + pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + #( #lines_rule ),* + ]; + + pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + #( #rule_enabled_check_line )* + index_set + } + + /// Checks if, given a rule name, matches one of the rules contained in this category + pub(crate) fn has_rule(rule_name: &str) -> Option<&'static str> { + Some(Self::GROUP_RULES[Self::GROUP_RULES.binary_search(&rule_name).ok()?]) + } + + #get_configuration_function + } + } + } else { + quote! { + #[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] + #[cfg_attr(feature = "schema", derive(JsonSchema))] + #[serde(rename_all = "camelCase", default, deny_unknown_fields)] + /// A list of rules that belong to this group + pub struct #group_pascal_ident { + /// It enables the recommended rules for this group + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended: Option, + + /// It enables ALL rules for this group. + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option, + + #( #schema_lines_rules ),* + } + + impl #group_pascal_ident { + + const GROUP_NAME: &'static str = #group; + pub(crate) const GROUP_RULES: &'static [&'static str] = &[ + #( #lines_rule ),* + ]; + + const RECOMMENDED_RULES: &'static [&'static str] = &[ + #( #lines_recommended_rule ),* + ]; + + const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ + #( #lines_recommended_rule_as_filter ),* + ]; + + const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ + #( #lines_all_rule_as_filter ),* + ]; + + /// Retrieves the recommended rules + pub(crate) fn is_recommended_true(&self) -> bool { + // we should inject recommended rules only when they are set to "true" + matches!(self.recommended, Some(true)) + } + + pub(crate) fn is_recommended_unset(&self) -> bool { + self.recommended.is_none() + } + + pub(crate) fn is_all_true(&self) -> bool { + matches!(self.all, Some(true)) + } + + pub(crate) fn is_all_unset(&self) -> bool { + self.all.is_none() + } + + pub(crate) fn get_enabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + #( #rule_enabled_check_line )* + index_set + } + + pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { + let mut index_set = FxHashSet::default(); + #( #rule_disabled_check_line )* + index_set + } + + /// Checks if, given a rule name, matches one of the rules contained in this category + pub(crate) fn has_rule(rule_name: &str) -> Option<&'static str> { + Some(Self::GROUP_RULES[Self::GROUP_RULES.binary_search(&rule_name).ok()?]) + } + + /// Checks if, given a rule name, it is marked as recommended + pub(crate) fn is_recommended_rule(rule_name: &str) -> bool { + Self::RECOMMENDED_RULES.contains(&rule_name) + } + + pub(crate) fn recommended_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::RECOMMENDED_RULES_AS_FILTERS + } + + pub(crate) fn all_rules_as_filters() -> &'static [RuleFilter<'static>] { + Self::ALL_RULES_AS_FILTERS + } + + /// Select preset rules + // Preset rules shouldn't populate disabled rules + // because that will make specific rules cannot be enabled later. + pub(crate) fn collect_preset_rules( + &self, + parent_is_all: bool, + parent_is_recommended: bool, + enabled_rules: &mut FxHashSet>, + ) { + // The order of the if-else branches MATTERS! + if self.is_all_true() || self.is_all_unset() && parent_is_all { + enabled_rules.extend(Self::all_rules_as_filters()); + } else if self.is_recommended_true() || self.is_recommended_unset() && self.is_all_unset() && parent_is_recommended { + enabled_rules.extend(Self::recommended_rules_as_filters()); + } + } + + #get_configuration_function + } + } + } +} diff --git a/xtask/codegen/src/generate_new_analyser_rule.rs b/xtask/codegen/src/generate_new_analyser_rule.rs new file mode 100644 index 00000000..8760bc60 --- /dev/null +++ b/xtask/codegen/src/generate_new_analyser_rule.rs @@ -0,0 +1,126 @@ +use biome_string_case::Case; +use bpaf::Bpaf; +use std::str::FromStr; +use xtask::project_root; + +#[derive(Debug, Clone, Bpaf)] +pub enum Category { + /// Lint rules + Lint, +} + +impl FromStr for Category { + type Err = &'static str; + + fn from_str(s: &str) -> std::result::Result { + match s { + "lint" => Ok(Self::Lint), + _ => Err("Not supported"), + } + } +} + +fn generate_rule_template( + category: &Category, + rule_name_upper_camel: &str, + rule_name_lower_camel: &str, +) -> String { + let macro_name = match category { + Category::Lint => "declare_lint_rule", + }; + format!( + r#"use pg_analyse::{{ + context::RuleContext, {macro_name}, Rule, RuleDiagnostic +}}; +use pg_console::markup; + +{macro_name}! {{ + /// Succinct description of the rule. + /// + /// Put context and details about the rule. + /// + /// Try to stay consistent with the descriptions of implemented rules. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```sql,expect_diagnostic + /// select 1; + /// ``` + /// + /// ### Valid + /// + /// ``sql` + /// select 2; + /// ``` + /// + pub {rule_name_upper_camel} {{ + version: "next", + name: "{rule_name_lower_camel}", + recommended: false, + }} +}} + +impl Rule for {rule_name_upper_camel} {{ + type Options = (); + + fn run(ctx: &RuleContext) -> Vec {{ + Vec::new() + }} +}} +"# + ) +} + +pub fn generate_new_analyser_rule(category: Category, rule_name: &str, group: &str) { + let rule_name_camel = Case::Camel.convert(rule_name); + let crate_folder = project_root().join("crates/pg_analyser"); + let rule_folder = match &category { + Category::Lint => crate_folder.join(format!("src/lint/{group}")), + }; + // Generate rule code + let code = generate_rule_template( + &category, + Case::Pascal.convert(rule_name).as_str(), + rule_name_camel.as_str(), + ); + if !rule_folder.exists() { + std::fs::create_dir(rule_folder.clone()).expect("To create the rule folder"); + } + let file_name = format!( + "{}/{}.rs", + rule_folder.display(), + Case::Snake.convert(rule_name) + ); + std::fs::write(file_name.clone(), code).unwrap_or_else(|_| panic!("To write {}", &file_name)); + + let categories_path = "crates/pg_diagnostics_categories/src/categories.rs"; + let mut categories = std::fs::read_to_string(categories_path).unwrap(); + + if !categories.contains(&rule_name_camel) { + let kebab_case_rule = Case::Kebab.convert(&rule_name_camel); + // We sort rules to reduce conflicts between contributions made in parallel. + let rule_line = match category { + Category::Lint => format!( + r#" "lint/{group}/{rule_name_camel}": "https://pglsp.dev/linter/rules/{kebab_case_rule}","# + ), + }; + let lint_start = match category { + Category::Lint => "define_categories! {\n", + }; + let lint_end = match category { + Category::Lint => "\n // end lint rules\n", + }; + debug_assert!(categories.contains(lint_start), "{}", lint_start); + debug_assert!(categories.contains(lint_end), "{}", lint_end); + let lint_start_index = categories.find(lint_start).unwrap() + lint_start.len(); + let lint_end_index = categories.find(lint_end).unwrap(); + let lint_rule_text = &categories[lint_start_index..lint_end_index]; + let mut lint_rules: Vec<_> = lint_rule_text.lines().chain(Some(&rule_line[..])).collect(); + lint_rules.sort_unstable(); + let new_lint_rule_text = lint_rules.join("\n"); + categories.replace_range(lint_start_index..lint_end_index, &new_lint_rule_text); + std::fs::write(categories_path, categories).unwrap(); + } +} diff --git a/xtask/codegen/src/lib.rs b/xtask/codegen/src/lib.rs index d16bc644..8cf07590 100644 --- a/xtask/codegen/src/lib.rs +++ b/xtask/codegen/src/lib.rs @@ -1,13 +1,63 @@ //! Codegen tools. Derived from Biome's codegen +mod generate_analyser; +mod generate_configuration; mod generate_crate; +mod generate_new_analyser_rule; +pub use self::generate_analyser::generate_analyser; +pub use self::generate_configuration::generate_rules_configuration; pub use self::generate_crate::generate_crate; +pub use self::generate_new_analyser_rule::generate_new_analyser_rule; use bpaf::Bpaf; +use generate_new_analyser_rule::Category; +use std::path::Path; +use xtask::{glue::fs2, Mode, Result}; + +pub enum UpdateResult { + NotUpdated, + Updated, +} + +/// A helper to update file on disk if it has changed. +/// With verify = false, the contents of the file will be updated to the passed in contents. +/// With verify = true, an Err will be returned if the contents of the file do not match the passed-in contents. +pub fn update(path: &Path, contents: &str, mode: &Mode) -> Result { + if fs2::read_to_string(path).is_ok_and(|old_contents| old_contents == contents) { + return Ok(UpdateResult::NotUpdated); + } + + if *mode == Mode::Verify { + anyhow::bail!("`{}` is not up-to-date", path.display()); + } + + eprintln!("updating {}", path.display()); + if let Some(parent) = path.parent() { + if !parent.exists() { + fs2::create_dir_all(parent)?; + } + } + fs2::write(path, contents)?; + Ok(UpdateResult::Updated) +} + +pub fn to_capitalized(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} #[derive(Debug, Clone, Bpaf)] #[bpaf(options)] pub enum TaskCommand { + /// Generate factory functions for the analyser and the configuration of the analysers + #[bpaf(command)] + Analyser, + /// Generate the part of the configuration that depends on some metadata + #[bpaf(command)] + Configuration, /// Creates a new crate #[bpaf(command, long("new-crate"))] NewCrate { @@ -15,4 +65,19 @@ pub enum TaskCommand { #[bpaf(long("name"), argument("STRING"))] name: String, }, + /// Creates a new lint rule + #[bpaf(command, long("new-lintrule"))] + NewRule { + /// Name of the rule + #[bpaf(long("name"))] + name: String, + + /// Category of the rule + #[bpaf(long("category"))] + category: Category, + + /// Group of the rule + #[bpaf(long("group"))] + group: String, + }, } diff --git a/xtask/codegen/src/main.rs b/xtask/codegen/src/main.rs index 5d6246cc..c432c16e 100644 --- a/xtask/codegen/src/main.rs +++ b/xtask/codegen/src/main.rs @@ -1,15 +1,32 @@ +use xtask::Mode::Overwrite; use xtask::{project_root, pushd, Result}; -use xtask_codegen::{generate_crate, task_command, TaskCommand}; +use xtask_codegen::{ + generate_analyser, generate_crate, generate_new_analyser_rule, generate_rules_configuration, + task_command, TaskCommand, +}; fn main() -> Result<()> { let _d = pushd(project_root()); let result = task_command().fallback_to_usage().run(); match result { + TaskCommand::Analyser => { + generate_analyser()?; + } TaskCommand::NewCrate { name } => { generate_crate(name)?; } + TaskCommand::NewRule { + name, + category, + group, + } => { + generate_new_analyser_rule(category, &name, &group); + } + TaskCommand::Configuration => { + generate_rules_configuration(Overwrite)?; + } } Ok(()) diff --git a/xtask/rules_check/Cargo.toml b/xtask/rules_check/Cargo.toml new file mode 100644 index 00000000..f436855f --- /dev/null +++ b/xtask/rules_check/Cargo.toml @@ -0,0 +1,17 @@ +[package] +description = "Internal script to make sure that the metadata or the rules are correct" +edition = "2021" +name = "rules_check" +publish = false +version = "0.0.0" + +[dependencies] +anyhow = { workspace = true } +pg_analyse = { workspace = true } +pg_analyser = { workspace = true } +pg_console = { workspace = true } +pg_diagnostics = { workspace = true } +pg_query_ext = { workspace = true } +pg_statement_splitter = { workspace = true } +pg_workspace_new = { workspace = true } +pulldown-cmark = "0.12.2" diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs new file mode 100644 index 00000000..ed589252 --- /dev/null +++ b/xtask/rules_check/src/lib.rs @@ -0,0 +1,234 @@ +use std::collections::BTreeMap; +use std::str::FromStr; +use std::{fmt::Write, slice}; + +use anyhow::bail; +use pg_analyse::{ + AnalyserOptions, AnalysisFilter, GroupCategory, RegistryVisitor, Rule, RuleCategory, + RuleFilter, RuleGroup, RuleMetadata, +}; +use pg_analyser::{Analyser, AnalyserConfig}; +use pg_console::{markup, Console}; +use pg_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic}; +use pg_query_ext::diagnostics::SyntaxDiagnostic; +use pg_workspace_new::settings::Settings; +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; + +pub fn check_rules() -> anyhow::Result<()> { + #[derive(Default)] + struct LintRulesVisitor { + groups: BTreeMap<&'static str, BTreeMap<&'static str, RuleMetadata>>, + } + + impl LintRulesVisitor { + fn push_rule(&mut self) + where + R: Rule + 'static, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } + } + + impl RegistryVisitor for LintRulesVisitor { + fn record_category(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule + 'static, + { + self.push_rule::() + } + } + + let mut visitor = LintRulesVisitor::default(); + pg_analyser::visit_registry(&mut visitor); + + let LintRulesVisitor { groups } = visitor; + + for (group, rules) in groups { + for (_, meta) in rules { + parse_documentation(group, meta.name, meta.docs)?; + } + } + + Ok(()) +} + +/// Parse and analyze the provided code block, and asserts that it emits +/// exactly zero or one diagnostic depending on the value of `expect_diagnostic`. +/// That diagnostic is then emitted as text into the `content` buffer +fn assert_lint( + group: &'static str, + rule: &'static str, + test: &CodeBlockTest, + code: &str, +) -> anyhow::Result<()> { + let file_path = format!("code-block.{}", test.tag); + let mut diagnostic_count = 0; + let mut all_diagnostics = vec![]; + let mut has_error = false; + let mut write_diagnostic = |code: &str, diag: pg_diagnostics::Error| { + all_diagnostics.push(diag); + // Fail the test if the analysis returns more diagnostics than expected + if test.expect_diagnostic { + // Print all diagnostics to help the user + if all_diagnostics.len() > 1 { + let mut console = pg_console::EnvConsole::default(); + for diag in all_diagnostics.iter() { + console.println( + pg_console::LogLevel::Error, + markup! { + {PrintDiagnostic::verbose(diag)} + }, + ); + } + has_error = true; + bail!("Analysis of '{group}/{rule}' on the following code block returned multiple diagnostics.\n\n{code}"); + } + } else { + // Print all diagnostics to help the user + let mut console = pg_console::EnvConsole::default(); + for diag in all_diagnostics.iter() { + console.println( + pg_console::LogLevel::Error, + markup! { + {PrintDiagnostic::verbose(diag)} + }, + ); + } + has_error = true; + bail!("Analysis of '{group}/{rule}' on the following code block returned an unexpected diagnostic.\n\n{code}"); + } + diagnostic_count += 1; + Ok(()) + }; + + if test.ignore { + return Ok(()); + } + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + let settings = Settings::default(); + let options = AnalyserOptions::default(); + let analyser = Analyser::new(AnalyserConfig { + options: &options, + filter, + }); + + // split and parse each statement + let stmts = pg_statement_splitter::split(code); + for stmt in stmts.ranges { + match pg_query_ext::parse(&code[stmt]) { + Ok(ast) => { + for rule_diag in analyser.run(pg_analyser::AnalyserContext { root: &ast }) { + let diag = pg_diagnostics::serde::Diagnostic::new(rule_diag); + + let category = diag.category().expect("linter diagnostic has no code"); + let severity = settings.get_severity_from_rule_code(category).expect( + "If you see this error, it means you need to run cargo codegen-configuration", + ); + + let error = diag + .with_severity(severity) + .with_file_path(&file_path) + .with_file_source_code(code); + + write_diagnostic(code, error)?; + } + } + Err(e) => { + let error = SyntaxDiagnostic::from(e) + .with_file_path(&file_path) + .with_file_source_code(code); + write_diagnostic(code, error)?; + } + }; + } + + Ok(()) +} + +struct CodeBlockTest { + tag: String, + expect_diagnostic: bool, + ignore: bool, +} + +impl FromStr for CodeBlockTest { + type Err = anyhow::Error; + + fn from_str(input: &str) -> anyhow::Result { + // This is based on the parsing logic for code block languages in `rustdoc`: + // https://github.com/rust-lang/rust/blob/6ac8adad1f7d733b5b97d1df4e7f96e73a46db42/src/librustdoc/html/markdown.rs#L873 + let tokens = input + .split([',', ' ', '\t']) + .map(str::trim) + .filter(|token| !token.is_empty()); + + let mut test = CodeBlockTest { + tag: String::new(), + expect_diagnostic: false, + ignore: false, + }; + + for token in tokens { + match token { + // Other attributes + "expect_diagnostic" => test.expect_diagnostic = true, + "ignore" => test.ignore = true, + // Regard as language tags, last one wins + _ => test.tag = token.to_string(), + } + } + + Ok(test) + } +} + +/// Parse the documentation fragment for a lint rule (in markdown) and lint the code blcoks. +fn parse_documentation( + group: &'static str, + rule: &'static str, + docs: &'static str, +) -> anyhow::Result<()> { + let parser = Parser::new(docs); + + // Tracks the content of the current code block if it's using a + // language supported for analysis + let mut language = None; + for event in parser { + match event { + // CodeBlock-specific handling + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(meta))) => { + // Track the content of code blocks to pass them through the analyser + let test = CodeBlockTest::from_str(meta.as_ref())?; + language = Some((test, String::new())); + } + Event::End(TagEnd::CodeBlock) => { + if let Some((test, block)) = language.take() { + assert_lint(group, rule, &test, &block)?; + } + } + Event::Text(text) => { + if let Some((_, block)) = &mut language { + write!(block, "{text}")?; + } + } + // We don't care other events + _ => {} + } + } + + Ok(()) +} diff --git a/xtask/rules_check/src/main.rs b/xtask/rules_check/src/main.rs new file mode 100644 index 00000000..1de34236 --- /dev/null +++ b/xtask/rules_check/src/main.rs @@ -0,0 +1,5 @@ +use rules_check::check_rules; + +fn main() -> anyhow::Result<()> { + check_rules() +} diff --git a/xtask/src/install.rs b/xtask/src/install.rs index c149bd5a..b0367b2e 100644 --- a/xtask/src/install.rs +++ b/xtask/src/install.rs @@ -12,7 +12,7 @@ impl flags::Install { if cfg!(target_os = "macos") { fix_path_for_mac(sh).context("Fix path for mac")?; } - if let Some(_) = self.server() { + if self.server().is_some() { install_server(sh).context("install server")?; } if let Some(client) = self.client() {