use super::AnalysisProvider;
use anyhow::Result;
use oxc_allocator::Allocator;
use oxc_diagnostics::Severity as OxcSeverity;
use oxc_linter::{
ConfigStore, ConfigStoreBuilder, ContextSubHost, ExternalPluginStore, LintOptions, Linter,
ModuleRecord,
};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use rma_common::{Confidence, Finding, FindingCategory, Language, Severity, SourceLocation};
use std::path::Path;
use std::sync::Arc;
use tracing::{debug, warn};
pub struct OxcNativeProvider {
linter: Linter,
}
impl Default for OxcNativeProvider {
fn default() -> Self {
Self::new()
}
}
impl OxcNativeProvider {
pub fn new() -> Self {
let mut external_plugin_store = ExternalPluginStore::default();
let config = ConfigStoreBuilder::default()
.build(&mut external_plugin_store)
.expect("Failed to build oxc config");
let config_store = ConfigStore::new(
config,
Default::default(), external_plugin_store,
);
let linter = Linter::new(LintOptions::default(), config_store, None);
Self { linter }
}
pub fn lint_file(&self, path: &Path, content: &str) -> Result<Vec<Finding>> {
let allocator = Allocator::default();
let source_type = SourceType::from_path(path).unwrap_or_default();
let parser_ret = Parser::new(&allocator, content, source_type).parse();
if parser_ret.panicked {
debug!("oxc parser panicked on {}", path.display());
return Ok(Vec::new());
}
let semantic_ret = SemanticBuilder::new()
.with_check_syntax_error(true)
.with_cfg(true) .build(&parser_ret.program);
if !semantic_ret.errors.is_empty() {
debug!(
"oxc semantic errors on {}: {}",
path.display(),
semantic_ret.errors.len()
);
}
let module_record = Arc::new(ModuleRecord::new(
path,
&parser_ret.module_record,
&semantic_ret.semantic,
));
let context_sub_host = ContextSubHost::new(
semantic_ret.semantic,
module_record,
0, );
let diagnostics = self.linter.run(path, vec![context_sub_host], &allocator);
let mut findings = Vec::new();
for message in diagnostics {
let rule_name = message
.error
.code
.number
.as_deref()
.or(message.error.code.scope.as_deref())
.unwrap_or("unknown");
let labels: Vec<_> = message.error.labels.clone().unwrap_or_default();
let primary_label = labels.first();
let (start_line, start_col, end_line, end_col, snippet) =
if let Some(label) = primary_label {
let start_offset = label.offset();
let end_offset = label.offset() + label.len();
let (sl, sc) = byte_offset_to_line_col(content, start_offset);
let (el, ec) = byte_offset_to_line_col(content, end_offset);
let snip = if end_offset <= content.len() {
let text = &content[start_offset..end_offset.min(content.len())];
Some(if text.len() > 200 {
format!("{}...", &text[..197])
} else {
text.to_string()
})
} else {
None
};
(sl, sc, el, ec, snip)
} else {
(1, 1, 1, 1, None)
};
let severity = match message.error.severity {
OxcSeverity::Error => Severity::Error,
OxcSeverity::Warning => Severity::Warning,
OxcSeverity::Advice => Severity::Info,
};
let lang_prefix = if source_type.is_typescript() {
"ts"
} else {
"js"
};
let rule_id = format!("{}/oxc/{}", lang_prefix, rule_name);
let category = if rule_name.contains("security")
|| rule_name.contains("eval")
|| rule_name.contains("xss")
{
FindingCategory::Security
} else if rule_name.contains("perf") {
FindingCategory::Performance
} else {
FindingCategory::Quality
};
let confidence = Confidence::High;
let language = if source_type.is_typescript() {
Language::TypeScript
} else {
Language::JavaScript
};
let location =
SourceLocation::new(path.to_path_buf(), start_line, start_col, end_line, end_col);
let msg_text = message.error.to_string();
let suggestion = message.error.help.as_ref().map(|h| h.to_string());
let mut finding = Finding {
id: format!("{}:{}:{}", rule_id, path.display(), start_line),
rule_id,
message: msg_text,
severity,
location,
language,
snippet,
suggestion,
fix: None,
confidence,
category,
fingerprint: None,
properties: None,
};
finding.compute_fingerprint();
findings.push(finding);
}
Ok(findings)
}
}
impl AnalysisProvider for OxcNativeProvider {
fn name(&self) -> &'static str {
"oxc"
}
fn description(&self) -> &'static str {
"Native Rust JS/TS linting using oxc_linter (520+ rules)"
}
fn supports_language(&self, lang: Language) -> bool {
matches!(lang, Language::JavaScript | Language::TypeScript)
}
fn is_available(&self) -> bool {
true }
fn version(&self) -> Option<String> {
Some("0.55.0".to_string()) }
fn analyze_file(&self, path: &Path) -> Result<Vec<Finding>> {
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts") => {}
_ => return Ok(Vec::new()),
}
let content = std::fs::read_to_string(path)?;
self.lint_file(path, &content)
}
fn analyze_directory(&self, path: &Path) -> Result<Vec<Finding>> {
use walkdir::WalkDir;
let mut all_findings = Vec::new();
for entry in WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let file_path = entry.path();
let ext = file_path.extension().and_then(|e| e.to_str());
match ext {
Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts") => {}
_ => continue,
}
let path_str = file_path.to_string_lossy();
if path_str.contains("node_modules")
|| path_str.contains(".git")
|| path_str.contains("/dist/")
|| path_str.contains("/build/")
|| path_str.contains("\\dist\\")
|| path_str.contains("\\build\\")
{
continue;
}
match self.analyze_file(file_path) {
Ok(findings) => all_findings.extend(findings),
Err(e) => {
warn!("Failed to analyze {}: {}", file_path.display(), e);
}
}
}
Ok(all_findings)
}
}
fn byte_offset_to_line_col(content: &str, offset: usize) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
let mut current_offset = 0;
for ch in content.chars() {
if current_offset >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
current_offset += ch.len_utf8();
}
(line, col)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_creation() {
let provider = OxcNativeProvider::new();
assert!(provider.is_available());
assert!(provider.supports_language(Language::JavaScript));
assert!(provider.supports_language(Language::TypeScript));
assert!(!provider.supports_language(Language::Rust));
assert_eq!(provider.name(), "oxc");
}
#[test]
fn test_detect_debugger() {
let provider = OxcNativeProvider::new();
let content = "function test() { debugger; return 1; }";
let findings = provider.lint_file(Path::new("test.js"), content).unwrap();
let debugger_finding = findings.iter().find(|f| f.rule_id.contains("debugger"));
assert!(
debugger_finding.is_some(),
"Should detect debugger statement. Findings: {:?}",
findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn test_typescript_support() {
let provider = OxcNativeProvider::new();
let content = r#"
function greet(name: string): void {
debugger;
console.log(`Hello, ${name}`);
}
"#;
let findings = provider.lint_file(Path::new("test.ts"), content).unwrap();
let ts_finding = findings.iter().find(|f| f.rule_id.starts_with("ts/oxc/"));
assert!(
ts_finding.is_some() || findings.is_empty(),
"TypeScript files should use ts/oxc/ prefix if findings exist"
);
}
#[test]
fn test_tsx_template_literal_no_false_positive() {
let provider = OxcNativeProvider::new();
let content = r#"
const Button = ({ active }) => (
<button className={`btn ${active ? 'active' : 'inactive'}`}>
Click me
</button>
);
"#;
let findings = provider
.lint_file(Path::new("Button.tsx"), content)
.unwrap();
let false_pos = findings.iter().find(|f| {
f.message.contains("btn") || f.message.contains("active") || f.message.contains("h-3")
});
assert!(
false_pos.is_none(),
"TSX template literals should not trigger false positives: {:?}",
false_pos
);
}
#[test]
fn test_clean_code() {
let provider = OxcNativeProvider::new();
let content = r#"
function add(a, b) {
return a + b;
}
export { add };
"#;
let findings = provider.lint_file(Path::new("test.js"), content).unwrap();
assert!(
findings.len() < 5,
"Clean code should have few findings, got: {:?}",
findings.iter().map(|f| &f.rule_id).collect::<Vec<_>>()
);
}
}