This document details the design and implementation plan for adding an External Rule API to Jarl.
The goal is to allow developers to implement custom, out-of-crate linting rules in their own wrapper binaries or libraries, depending on jarl_core as a library, without modifying Jarl's internal codebase for every domain-specific rule.
This plan assumes that all external rules only offer safe fixes (or no fixes), which allows us to simplify the integration and reuse Jarl's existing configuration-level filters.
Create crates/jarl-core/src/external_rule.rs (and register as pub mod external_rule; in src/lib.rs):
// File: crates/jarl-core/src/external_rule.rs
use crate::checker::Checker;
use crate::diagnostic::Diagnostic;
use air_r_syntax::AnyRExpression;
pub trait ExternalRule: Send + Sync + std::fmt::Debug {
/// Unique name of the custom rule (e.g., "character_only").
fn name(&self) -> &str;
/// Run the rule on the given AST node.
/// Returns `Some(Diagnostic)` if a violation is found, or `None`.
fn run(&self, node: &AnyRExpression, checker: &Checker) -> anyhow::Result<Option<Diagnostic>>;
}Update crates/jarl-core/src/config.rs to store and accept external rules:
// File: crates/jarl-core/src/config.rs
use crate::external_rule::ExternalRule;
use std::sync::Arc;
pub struct Config {
// ... existing fields ...
pub external_rules: Vec<Arc<dyn ExternalRule>>,
}
pub fn build_config(
check_config: &ArgsConfig,
toml_settings: Option<&Settings>,
paths: Vec<PathBuf>,
external_rules: Vec<Arc<dyn ExternalRule>>, // New argument
) -> Result<Config> {
// ...
Ok(Config {
paths,
// ...
external_rules, // Store in Config
})
}Update crates/jarl-core/src/checker.rs to hold references to external rules:
// File: crates/jarl-core/src/checker.rs
use crate::external_rule::ExternalRule;
use std::sync::Arc;
pub struct Checker {
// ... existing fields ...
pub external_rules: Vec<Arc<dyn ExternalRule>>,
}
impl Checker {
pub(crate) fn new(
suppression: SuppressionManager,
rule_options: Arc<ResolvedRuleOptions>,
) -> Self {
Self {
// ...
external_rules: Vec::new(),
}
}
}And populate it in get_checks in crates/jarl-core/src/check.rs:
// File: crates/jarl-core/src/check.rs
let mut checker = Checker::new(suppression, config.rule_options.clone());
checker.external_rules = config.external_rules.clone(); // NewUpdate crates/jarl-core/src/analyze/expression.rs to run external rules on every node:
// File: crates/jarl-core/src/analyze/expression.rs
pub(crate) fn check_expression(
expression: &air_r_syntax::AnyRExpression,
checker: &mut Checker,
) -> anyhow::Result<()> {
// 1. Run external rules
let external_rules = checker.external_rules.clone();
for rule in &external_rules {
if let Some(diagnostic) = rule.run(expression, checker)? {
checker.report_diagnostic(Some(diagnostic));
}
}
// 2. Run built-in rules (existing match block)
match expression {
// ...
}
Ok(())
}Update crates/jarl-core/src/check.rs inside lint_fix loop to check if any diagnostic has a non-empty fix content. This bypasses the static Rule enum lookup, allowing custom external rules to apply fixes:
// File: crates/jarl-core/src/check.rs (inside lint_fix)
// Before: let has_fixable = checks.iter().any(|d| d.has_safe_fix() || d.has_unsafe_fix());
let has_fixable = checks.iter().any(|d| !d.fix.content.is_empty());(This is safe because Jarl's existing get_checks mapping loop already clears x.fix if the rule is configured as unfixable or ignored in the user's config).
With the changes above, you can implement a separate linter wrapper crate that imports jarl_core as a library and defines custom rules.
// File: custom_linter/src/rules/character_only.rs
use jarl_core::diagnostic::{Diagnostic, Violation, Fix, ViolationData};
use jarl_core::external_rule::ExternalRule;
use jarl_core::checker::Checker;
use air_r_syntax::{AnyRExpression, AnyRValue, RCall};
use jarl_core::utils::get_function_name;
pub struct CharacterOnly;
impl Violation for CharacterOnly {
fn name(&self) -> String { "character_only".to_string() }
fn body(&self) -> String { "Enforce library/require calls using symbols instead of strings.".to_string() }
}
impl ExternalRule for CharacterOnly {
fn name(&self) -> &str { "character_only" }
fn run(&self, node: &AnyRExpression, _checker: &Checker) -> anyhow::Result<Option<Diagnostic>> {
let Some(call) = node.as_r_call() else { return Ok(None) };
let Ok(function) = call.function() else { return Ok(None) };
let fn_name = get_function_name(function);
if fn_name != "library" && fn_name != "require" {
return Ok(None);
}
let Ok(args) = call.arguments() else { return Ok(None) };
let first_arg = args.items().into_iter().find_map(|item| {
item.as_ref().ok().and_then(|arg| {
if arg.name_clause().is_none() { arg.value() } else { None }
})
});
let Some(first_arg) = first_arg else { return Ok(None) };
if let AnyRExpression::AnyRValue(AnyRValue::RStringValue(s)) = first_arg {
let text = s.to_trimmed_string();
let unquoted = text.trim_matches('"').trim_matches('\'');
let arg_range = s.syntax().text_trimmed_range();
let diag = Diagnostic::new(
ViolationData::new(
"character_only".to_string(),
format!("Use symbols, not strings, in {} calls.", fn_name),
None,
),
arg_range,
Fix {
content: unquoted.to_string(),
start: arg_range.start().into(),
end: arg_range.end().into(),
to_skip: false,
},
);
return Ok(Some(diag));
}
Ok(None)
}
}// File: custom_linter/src/main.rs
use std::sync::Arc;
use clap::Parser;
use jarl_core::config::{ArgsConfig, build_config};
use jarl_core::check::check;
use jarl_core::external_rule::ExternalRule;
mod rules;
#[derive(Parser, Debug)]
struct Cli {
#[arg(required = true)]
files: Vec<std::path::PathBuf>,
#[arg(long, default_value = "false")]
fix: bool,
#[arg(long, default_value = "false")]
unsafe_fixes: bool,
}
fn main() -> anyhow::Result<()> {
// 1. Parse CLI arguments using clap
let cli = Cli::parse();
// 2. Map CLI arguments to Jarl's ArgsConfig
let args_config = ArgsConfig {
files: cli.files.clone(),
fix: cli.fix,
unsafe_fixes: cli.unsafe_fixes,
fix_only: false,
select: String::new(),
extend_select: String::new(),
ignore: String::new(),
min_r_version: None,
allow_dirty: true,
allow_no_vcs: true,
assignment: None,
};
// 3. Register custom rules
let custom_rules: Vec<Arc<dyn ExternalRule>> = vec![
Arc::new(rules::character_only::CharacterOnly),
];
// 4. Build config, passing custom rules
let config = build_config(&args_config, None, cli.files, custom_rules)?;
// 5. Run Jarl check
let results = check(config);
// 6. Emit results
for (path, result) in results {
if let Ok(diagnostics) = result {
for d in diagnostics {
println!("{}: {:?}", path, d);
}
}
}
Ok(())
}External rules can be tested using standard Rust test configurations.
You can write unit tests that run the rule against small R code fragments and assert the generated diagnostic and fix behavior via jarl_core::check::check:
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::Builder;
use jarl_core::config::{ArgsConfig, build_config};
use jarl_core::check::check;
fn run_rule(code: &str, fix: bool) -> (Option<Diagnostic>, String) {
let temp_file = Builder::new().suffix(".R").tempfile().unwrap();
fs::write(temp_file.path(), code).unwrap();
let args = ArgsConfig {
files: vec![temp_file.path().to_path_buf()],
fix,
select: String::new(),
// ... default fields ...
};
let rules: Vec<std::sync::Arc<dyn ExternalRule>> = vec![std::sync::Arc::new(CharacterOnly)];
let config = build_config(&args, None, vec![temp_file.path().to_path_buf()], rules).unwrap();
let mut results = check(config);
let mut diag = None;
for (_path, result) in results {
if let Ok(mut diagnostics) = result {
if !diagnostics.is_empty() {
diag = Some(diagnostics.remove(0));
}
}
}
let final_content = fs::read_to_string(temp_file.path()).unwrap();
(diag, final_content)
}
#[test]
fn test_blocks_disallowed_library_string() {
let (diag, _) = run_rule("library(\"dplyr\")\n", false);
let diag = diag.unwrap();
assert_eq!(diag.message.name, "character_only");
let (_, fixed_content) = run_rule("library(\"dplyr\")\n", true);
assert_eq!(fixed_content, "library(dplyr)\n");
}
}To run the unit tests:
cargo test