Skip to main content

native/oxc_lint_nif/src/lib.rs

use std::path::Path;
use std::sync::Arc;

use oxc_allocator::Allocator;
use oxc_linter::{
    AllowWarnDeny, ConfigStore, ConfigStoreBuilder, ExternalPluginStore, FixKind, LintFilter,
    LintFilterKind, LintOptions, LintPlugins, Linter, ModuleRecord,
};
use oxc_parser::{ParseOptions, Parser};
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use rustler::{Binary, Encoder, Env, Error, NifMap, NifResult, Term};

mod atoms {
    rustler::atoms! {
        ok,
        error,
        warn,
        deny,
        allow,
    }
}

#[derive(NifMap)]
struct Diagnostic {
    rule: String,
    message: String,
    severity: rustler::Atom,
    span: (u32, u32),
    labels: Vec<(u32, u32)>,
    help: Option<String>,
}

fn parse_plugins(plugin_strs: &[String]) -> LintPlugins {
    let mut plugins = LintPlugins::empty();
    for plugin in plugin_strs {
        match plugin.as_str() {
            "react" => plugins |= LintPlugins::REACT,
            "unicorn" => plugins |= LintPlugins::UNICORN,
            "typescript" => plugins |= LintPlugins::TYPESCRIPT,
            "oxc" => plugins |= LintPlugins::OXC,
            "import" => plugins |= LintPlugins::IMPORT,
            "jsdoc" => plugins |= LintPlugins::JSDOC,
            "jest" => plugins |= LintPlugins::JEST,
            "vitest" => plugins |= LintPlugins::VITEST,
            "jsx_a11y" | "jsx-a11y" => plugins |= LintPlugins::JSX_A11Y,
            "nextjs" | "next" => plugins |= LintPlugins::NEXTJS,
            "react_perf" | "react-perf" => plugins |= LintPlugins::REACT_PERF,
            "promise" => plugins |= LintPlugins::PROMISE,
            "node" => plugins |= LintPlugins::NODE,
            "vue" => plugins |= LintPlugins::VUE,
            _ => {}
        }
    }
    plugins
}

fn severity_atom(_env: Env<'_>, severity: AllowWarnDeny) -> rustler::Atom {
    match severity {
        AllowWarnDeny::Allow => atoms::allow(),
        AllowWarnDeny::Warn => atoms::warn(),
        AllowWarnDeny::Deny => atoms::deny(),
    }
}

fn parse_severity(s: &str) -> AllowWarnDeny {
    match s {
        "deny" | "error" => AllowWarnDeny::Deny,
        "warn" => AllowWarnDeny::Warn,
        "allow" | "off" => AllowWarnDeny::Allow,
        _ => AllowWarnDeny::Warn,
    }
}

struct LintConfig {
    config_store: ConfigStore,
    rule_severity_map: std::collections::HashMap<String, AllowWarnDeny>,
}

fn build_lint_config(plugins: &[String], rules: &[(String, String)]) -> Result<LintConfig, String> {
    let lint_plugins = if plugins.is_empty() {
        LintPlugins::default()
    } else {
        parse_plugins(plugins)
    };

    let mut external_plugin_store = ExternalPluginStore::default();
    let mut builder = ConfigStoreBuilder::default().with_builtin_plugins(lint_plugins);

    for (rule_name, severity_str) in rules {
        let severity = parse_severity(severity_str.as_str());
        let filter_kind = LintFilterKind::parse(std::borrow::Cow::Owned(rule_name.clone()))
            .map_err(|e| format!("Invalid rule filter '{rule_name}': {e}"))?;
        let filter = LintFilter::new(severity, filter_kind)
            .map_err(|e| format!("Invalid lint filter '{rule_name}': {e}"))?;
        builder = builder.with_filters([&filter]);
    }

    let config = builder
        .build(&mut external_plugin_store)
        .map_err(|e| format!("Failed to build linter config: {e}"))?;

    let rule_severity_map = config
        .rules()
        .iter()
        .map(|(rule, severity)| (format_rule_enum_name(rule), *severity))
        .collect();

    Ok(LintConfig {
        config_store: ConfigStore::new(config, Default::default(), external_plugin_store),
        rule_severity_map,
    })
}

fn format_rule_enum_name(rule: &oxc_linter::rules::RuleEnum) -> String {
    let name = rule.name();
    let plugin = rule.plugin_name();
    if plugin == "eslint" {
        format!("eslint({name})")
    } else {
        format!("{plugin}({name})")
    }
}

fn format_rule_name(code: &oxc_diagnostics::OxcCode) -> String {
    let scope = code.scope.as_deref().unwrap_or("");
    let number = code.number.as_deref().unwrap_or("");
    if scope.is_empty() {
        number.to_string()
    } else if number.is_empty() {
        scope.to_string()
    } else {
        format!("{scope}({number})")
    }
}

fn source_from_term<'a>(term: Term<'a>) -> NifResult<Binary<'a>> {
    term.decode_as_binary()
}

fn binary_to_str<'a, 'b>(binary: &'b Binary<'a>) -> NifResult<&'b str> {
    std::str::from_utf8(binary).map_err(|_| Error::BadArg)
}

#[rustler::nif(schedule = "DirtyCpu")]
fn lint<'a>(
    env: Env<'a>,
    source_term: Term<'a>,
    filename: &str,
    plugins: Vec<String>,
    rules: Vec<(String, String)>,
    fix: bool,
) -> NifResult<Term<'a>> {
    let source_binary = source_from_term(source_term)?;
    let source = binary_to_str(&source_binary)?;
    let path = Path::new(filename);
    let source_type = SourceType::from_path(path).unwrap_or_default();

    let allocator = Allocator::default();
    let ret = Parser::new(&allocator, source, source_type)
        .with_options(ParseOptions {
            parse_regular_expression: true,
            ..ParseOptions::default()
        })
        .parse();

    if !ret.errors.is_empty() {
        let error_msgs: Vec<String> = ret.errors.iter().map(|e| e.message.to_string()).collect();
        return Ok((atoms::error(), error_msgs).encode(env));
    }

    let lint_config = match build_lint_config(&plugins, &rules) {
        Ok(v) => v,
        Err(e) => return Ok((atoms::error(), vec![e]).encode(env)),
    };

    let fix_kind = if fix { FixKind::SafeFix } else { FixKind::None };

    let linter = Linter::new(
        LintOptions {
            fix: fix_kind,
            ..LintOptions::default()
        },
        lint_config.config_store,
        None,
    );

    let semantic = SemanticBuilder::new()
        .with_cfg(true)
        .build(&ret.program)
        .semantic;

    let module_record = Arc::new(ModuleRecord::default());
    let ctx_host = oxc_linter::ContextSubHost::new(semantic, module_record, 0, Default::default());
    let messages = linter.run(path, vec![ctx_host], &allocator);

    let diagnostics: Vec<Diagnostic> = messages
        .iter()
        .map(|msg| {
            let full_rule = format_rule_name(&msg.error.code);
            let severity = lint_config
                .rule_severity_map
                .get(&full_rule)
                .copied()
                .unwrap_or(AllowWarnDeny::Warn);

            Diagnostic {
                rule: full_rule,
                message: msg.error.message.to_string(),
                severity: severity_atom(env, severity),
                span: (msg.span.start, msg.span.end),
                labels: msg
                    .error
                    .labels
                    .as_ref()
                    .map(|labels| {
                        labels
                            .iter()
                            .map(|l| (l.offset() as u32, (l.offset() + l.len()) as u32))
                            .collect()
                    })
                    .unwrap_or_default(),
                help: msg.error.help.as_ref().map(|h| h.to_string()),
            }
        })
        .collect();

    Ok((atoms::ok(), diagnostics).encode(env))
}

rustler::init!("Elixir.OXC.Lint.Native");