#![forbid(clippy::indexing_slicing)]
use std::any::Any;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use crate::BumpSeverity;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LintSeverity {
#[default]
Off,
Warning,
Error,
}
impl LintSeverity {
#[must_use]
pub fn is_enabled(self) -> bool {
matches!(self, Self::Warning | Self::Error)
}
#[must_use]
pub fn is_error(self) -> bool {
matches!(self, Self::Error)
}
}
impl std::fmt::Display for LintSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Off => write!(f, "off"),
Self::Warning => write!(f, "warning"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LintCategory {
Style,
Correctness,
Performance,
Suspicious,
BestPractice,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LintMaturity {
#[default]
Stable,
Strict,
Experimental,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LintOptionKind {
Boolean,
String,
StringList,
Integer,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintOptionDefinition {
pub name: String,
pub description: String,
pub kind: LintOptionKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<serde_json::Value>,
}
impl LintOptionDefinition {
#[must_use]
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
kind: LintOptionKind,
) -> Self {
Self {
name: name.into(),
description: description.into(),
kind,
default_value: None,
}
}
#[must_use]
pub fn with_default(mut self, value: serde_json::Value) -> Self {
self.default_value = Some(value);
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintRule {
pub id: String,
pub name: String,
pub description: String,
pub category: LintCategory,
pub maturity: LintMaturity,
pub autofixable: bool,
#[serde(default)]
pub options: Vec<LintOptionDefinition>,
}
impl LintRule {
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
category: LintCategory,
maturity: LintMaturity,
autofixable: bool,
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
category,
maturity,
autofixable,
options: Vec::new(),
}
}
#[must_use]
pub fn with_options(mut self, options: Vec<LintOptionDefinition>) -> Self {
self.options = options;
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintPreset {
pub id: String,
pub name: String,
pub description: String,
pub maturity: LintMaturity,
#[serde(default)]
pub rules: BTreeMap<String, LintRuleConfig>,
}
impl LintPreset {
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
maturity: LintMaturity,
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
maturity,
rules: BTreeMap::new(),
}
}
#[must_use]
pub fn with_rules(mut self, rules: BTreeMap<String, LintRuleConfig>) -> Self {
self.rules = rules;
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintLocation {
pub file_path: PathBuf,
pub line: usize,
pub column: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<(usize, usize)>,
}
impl LintLocation {
#[must_use]
pub fn new(file_path: impl Into<PathBuf>, line: usize, column: usize) -> Self {
Self {
file_path: file_path.into(),
line,
column,
span: None,
}
}
#[must_use]
pub fn with_span(mut self, start: usize, end: usize) -> Self {
self.span = Some((start, end));
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintEdit {
pub span: (usize, usize),
pub replacement: String,
}
impl LintEdit {
#[must_use]
pub fn new(span: (usize, usize), replacement: impl Into<String>) -> Self {
Self {
span,
replacement: replacement.into(),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintFix {
pub description: String,
pub edits: Vec<LintEdit>,
}
impl LintFix {
#[must_use]
pub fn single(
description: impl Into<String>,
span: (usize, usize),
replacement: impl Into<String>,
) -> Self {
Self {
description: description.into(),
edits: vec![LintEdit::new(span, replacement)],
}
}
#[must_use]
pub fn multiple(description: impl Into<String>, edits: Vec<LintEdit>) -> Self {
Self {
description: description.into(),
edits,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LintResult {
pub rule_id: String,
pub location: LintLocation,
pub message: String,
pub severity: LintSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<LintFix>,
}
impl LintResult {
#[must_use]
pub fn new(
rule_id: impl Into<String>,
location: LintLocation,
message: impl Into<String>,
severity: LintSeverity,
) -> Self {
Self {
rule_id: rule_id.into(),
location,
message: message.into(),
severity,
fix: None,
}
}
#[must_use]
pub fn with_fix(mut self, fix: LintFix) -> Self {
self.fix = Some(fix);
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
#[allow(variant_size_differences)]
pub enum LintRuleConfig {
Severity(LintSeverity),
Detailed {
level: LintSeverity,
#[serde(flatten)]
options: BTreeMap<String, serde_json::Value>,
},
}
impl LintRuleConfig {
#[must_use]
pub fn severity(&self) -> LintSeverity {
match self {
Self::Severity(severity) => *severity,
Self::Detailed { level, .. } => *level,
}
}
pub fn option(&self, key: &str) -> Option<&serde_json::Value> {
match self {
Self::Severity(_) => None,
Self::Detailed { options, .. } => options.get(key),
}
}
pub fn bool_option(&self, key: &str, default: bool) -> bool {
self.option(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default)
}
pub fn string_option(&self, key: &str) -> Option<String> {
self.option(key).and_then(|v| v.as_str()).map(String::from)
}
pub fn string_list_option(&self, key: &str) -> Option<Vec<String>> {
self.option(key).and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|value| value.as_str().map(String::from))
.collect()
})
}
#[must_use]
pub fn with_severity(&self, severity: LintSeverity) -> Self {
match self {
Self::Severity(_) => Self::Severity(severity),
Self::Detailed { options, .. } => {
Self::Detailed {
level: severity,
options: options.clone(),
}
}
}
}
#[must_use]
pub fn merged_with(&self, other: &Self) -> Self {
match (self, other) {
(_, Self::Severity(severity)) => Self::Severity(*severity),
(Self::Severity(_), Self::Detailed { level, options }) => {
Self::Detailed {
level: *level,
options: options.clone(),
}
}
(
Self::Detailed {
options: left_options,
..
},
Self::Detailed {
level: right_level,
options: right_options,
},
) => {
let mut merged = left_options.clone();
merged.extend(right_options.clone());
Self::Detailed {
level: *right_level,
options: merged,
}
}
}
}
}
impl Default for LintRuleConfig {
fn default() -> Self {
Self::Severity(LintSeverity::Off)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct LintSelector {
#[serde(default)]
pub ecosystems: Vec<String>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub package_ids: Vec<String>,
#[serde(default)]
pub group_ids: Vec<String>,
#[serde(default)]
pub managed: Option<bool>,
#[serde(default)]
pub private: Option<bool>,
#[serde(default)]
pub publishable: Option<bool>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct LintScopeConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default, rename = "match")]
pub selector: LintSelector,
#[serde(default, rename = "use")]
pub presets: Vec<String>,
#[serde(default)]
pub rules: BTreeMap<String, LintRuleConfig>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct ChangesetSummaryLintSettings {
#[serde(default)]
pub required: bool,
#[serde(default)]
pub heading_level: Option<usize>,
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub forbid_trailing_period: bool,
#[serde(default)]
pub forbid_conventional_commit_prefix: bool,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct ChangesetScopedLintSettings {
#[serde(default)]
pub required_sections: Vec<String>,
#[serde(default)]
pub min_body_chars: Option<usize>,
#[serde(default)]
pub max_body_chars: Option<usize>,
#[serde(default)]
pub require_code_block: bool,
#[serde(default)]
pub required_bump: Option<BumpSeverity>,
#[serde(default)]
pub forbidden_headings: Vec<String>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct ChangesetLintSettings {
#[serde(default)]
pub no_section_headings: bool,
#[serde(default)]
pub summary: ChangesetSummaryLintSettings,
#[serde(default)]
pub bump: BTreeMap<BumpSeverity, ChangesetScopedLintSettings>,
#[serde(default)]
pub types: BTreeMap<String, ChangesetScopedLintSettings>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct WorkspaceLintSettings {
#[serde(default, rename = "use")]
pub presets: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub disable_gitignore: bool,
#[serde(default)]
pub rules: BTreeMap<String, LintRuleConfig>,
#[serde(default)]
pub scopes: Vec<LintScopeConfig>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct LintTargetMetadata {
pub ecosystem: String,
pub relative_path: PathBuf,
#[serde(default)]
pub package_name: Option<String>,
#[serde(default)]
pub package_id: Option<String>,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub managed: bool,
#[serde(default)]
pub private: Option<bool>,
#[serde(default)]
pub publishable: Option<bool>,
}
pub struct LintTarget {
pub workspace_root: PathBuf,
pub manifest_path: PathBuf,
pub contents: String,
pub metadata: LintTargetMetadata,
pub parsed: Box<dyn Any>,
}
impl std::fmt::Debug for LintTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LintTarget")
.field("workspace_root", &self.workspace_root)
.field("manifest_path", &self.manifest_path)
.field("metadata", &self.metadata)
.field("contents_len", &self.contents.len())
.field("parsed", &"<opaque>")
.finish()
}
}
impl LintTarget {
#[must_use]
pub fn new(
workspace_root: impl Into<PathBuf>,
manifest_path: impl Into<PathBuf>,
contents: impl Into<String>,
metadata: LintTargetMetadata,
parsed: Box<dyn Any>,
) -> Self {
Self {
workspace_root: workspace_root.into(),
manifest_path: manifest_path.into(),
contents: contents.into(),
metadata,
parsed,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub struct LintReport {
pub results: Vec<LintResult>,
pub warnings: Vec<String>,
pub error_count: usize,
pub warning_count: usize,
}
impl LintReport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, result: LintResult) {
match result.severity {
LintSeverity::Error => self.error_count += 1,
LintSeverity::Warning => self.warning_count += 1,
LintSeverity::Off => {}
}
self.results.push(result);
}
pub fn warn(&mut self, message: impl Into<String>) {
self.warnings.push(message.into());
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.error_count > 0
}
#[must_use]
pub fn has_issues(&self) -> bool {
self.error_count > 0 || self.warning_count > 0
}
pub fn merge(&mut self, other: Self) {
self.results.extend(other.results);
self.warnings.extend(other.warnings);
self.error_count += other.error_count;
self.warning_count += other.warning_count;
}
#[must_use]
pub fn autofixable(&self) -> Vec<&LintResult> {
self.results
.iter()
.filter(|result| result.fix.is_some())
.collect()
}
}
pub trait LintProgressReporter: Send + Sync {
fn planning_started(&self, suites: &[&str]);
fn planning_finished(&self, total_files: usize, total_rules: usize);
fn suite_started(&self, suite_id: &str, file_count: usize, rule_count: usize);
fn suite_finished(&self, suite_id: &str, result_count: usize, fixable_count: usize);
fn file_started(&self, file_path: &Path, rule_count: usize);
fn file_rule_started(&self, file_path: &Path, rule_id: &str);
fn file_rule_finished(&self, file_path: &Path, rule_id: &str, result_count: usize);
fn file_finished(&self, file_path: &Path, result_count: usize);
fn fix_started(&self, file_count: usize);
fn fix_applied(&self, file_path: &Path, description: &str);
fn fix_finished(&self, files_fixed: usize);
fn summary(&self, errors: usize, warnings: usize, fixable: usize, fixed: bool);
}
#[derive(Debug, Clone, Copy)]
pub struct NoopLintProgressReporter;
impl LintProgressReporter for NoopLintProgressReporter {
fn planning_started(&self, _suites: &[&str]) {}
fn planning_finished(&self, _total_files: usize, _total_rules: usize) {}
fn suite_started(&self, _suite_id: &str, _file_count: usize, _rule_count: usize) {}
fn suite_finished(&self, _suite_id: &str, _result_count: usize, _fixable_count: usize) {}
fn file_started(&self, _file_path: &Path, _rule_count: usize) {}
fn file_rule_started(&self, _file_path: &Path, _rule_id: &str) {}
fn file_rule_finished(&self, _file_path: &Path, _rule_id: &str, _result_count: usize) {}
fn file_finished(&self, _file_path: &Path, _result_count: usize) {}
fn fix_started(&self, _file_count: usize) {}
fn fix_applied(&self, _file_path: &Path, _description: &str) {}
fn fix_finished(&self, _files_fixed: usize) {}
fn summary(&self, _errors: usize, _warnings: usize, _fixable: usize, _fixed: bool) {}
}
pub struct LintContext<'a> {
pub workspace_root: &'a Path,
pub manifest_path: &'a Path,
pub contents: &'a str,
pub metadata: &'a LintTargetMetadata,
pub parsed: &'a dyn Any,
}
impl LintContext<'_> {
pub fn parsed_as<T: Any>(&self) -> Option<&T> {
self.parsed.downcast_ref::<T>()
}
}
impl std::fmt::Debug for LintContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LintContext")
.field("workspace_root", &self.workspace_root)
.field("manifest_path", &self.manifest_path)
.field("contents_len", &self.contents.len())
.field("metadata", &self.metadata)
.finish()
}
}
pub trait LintRuleRunner: Send + Sync {
fn rule(&self) -> &LintRule;
fn applies_to(&self, _target: &LintTarget) -> bool {
true
}
fn run(&self, ctx: &LintContext<'_>, config: &LintRuleConfig) -> Vec<LintResult>;
}
pub trait LintSuite: Send + Sync {
fn suite_id(&self) -> &'static str;
fn rules(&self) -> Vec<Box<dyn LintRuleRunner>>;
fn presets(&self) -> Vec<LintPreset> {
Vec::new()
}
fn collect_targets(
&self,
workspace_root: &Path,
configuration: &crate::WorkspaceConfiguration,
) -> crate::MonochangeResult<Vec<LintTarget>>;
}
#[derive(Default)]
pub struct LintRuleRegistry {
rules: Vec<Box<dyn LintRuleRunner>>,
}
impl std::fmt::Debug for LintRuleRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LintRuleRegistry")
.field("rule_count", &self.rules.len())
.finish()
}
}
impl LintRuleRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, rule: Box<dyn LintRuleRunner>) {
self.rules.push(rule);
}
#[must_use]
pub fn rules(&self) -> &[Box<dyn LintRuleRunner>] {
&self.rules
}
pub fn find(&self, id: &str) -> Option<&dyn LintRuleRunner> {
self.rules
.iter()
.find(|rule| rule.rule().id == id)
.map(AsRef::as_ref)
}
#[must_use]
pub fn applicable_rules(&self, target: &LintTarget) -> Vec<&dyn LintRuleRunner> {
self.rules
.iter()
.filter(|rule| rule.applies_to(target))
.map(AsRef::as_ref)
.collect()
}
}
impl std::fmt::Display for LintResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let fix_indicator = if self.fix.is_some() { " [fixable]" } else { "" };
write!(
f,
"{}: {} at {}:{}:{}{}",
self.severity,
self.message,
self.location.file_path.display(),
self.location.line,
self.location.column,
fix_indicator
)
}
}
#[cfg(test)]
#[path = "__tests__/lint_tests.rs"]
mod tests;