Skip to content

Instantly share code, notes, and snippets.

@randy3k
Last active May 21, 2026 20:56
Show Gist options
  • Select an option

  • Save randy3k/f4161fa1d098674389d65dc7e5314230 to your computer and use it in GitHub Desktop.

Select an option

Save randy3k/f4161fa1d098674389d65dc7e5314230 to your computer and use it in GitHub Desktop.
Proposal: Jarl External Rule API (Plug-in Rules)

Proposal: Jarl External Rule API (Plug-in Rules)

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.


1. Jarl Core Modifications (crates/jarl-core/)

1.1. Define ExternalRule Trait

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>>;
}

1.2. Pass Rules via Config

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
    })
}

1.3. Pass Rules to Checker

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(); // New

1.4. Dispatch External Rules in check_expression

Update 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(())
}

1.5. Simplify Fix Loop Check in lint_fix

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).


2. Example: Implementing a Custom Linter Package

With the changes above, you can implement a separate linter wrapper crate that imports jarl_core as a library and defines custom rules.

2.1. Define the Custom Rule

// 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)
    }
}

2.2. Main Entry Point of Custom Linter

// 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(())
}

3. Testing Strategy

External rules can be tested using standard Rust test configurations.

3.1. Unit Testing

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment