#[cfg(test)]
mod tests {
use panache::{Config, linter, parse};
use std::collections::HashMap;
#[tokio::test]
async fn test_jarl_linter_integration() {
if which::which("jarl").is_err() {
println!("Skipping jarl test - jarl not installed");
return;
}
let input = r#"# Test
```r
any(is.na(x))
result <- TRUE
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("r".to_string(), "jarl".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
assert!(!diagnostics.is_empty(), "Expected diagnostics from jarl");
let any_is_na_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == "any_is_na")
.collect();
assert_eq!(any_is_na_diags.len(), 1, "Expected 1 any_is_na diagnostic");
assert_eq!(any_is_na_diags[0].location.line, 4);
assert!(
any_is_na_diags[0].fix.is_some(),
"Auto-fixes should be enabled with byte offset mapping"
);
let fix = any_is_na_diags[0].fix.as_ref().unwrap();
assert_eq!(fix.edits.len(), 1);
assert_eq!(fix.edits[0].replacement, "anyNA(x)");
}
#[tokio::test]
async fn test_multiple_r_blocks_concatenation() {
if which::which("jarl").is_err() {
println!("Skipping jarl test - jarl not installed");
return;
}
let input = r#"```r
any(is.na(x))
```
Some text in between.
```r
any(is.na(y))
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("r".to_string(), "jarl".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let any_is_na_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == "any_is_na")
.collect();
assert_eq!(any_is_na_diags.len(), 2);
assert_eq!(any_is_na_diags[0].location.line, 2); assert_eq!(any_is_na_diags[1].location.line, 8);
assert!(any_is_na_diags[0].fix.is_some());
assert!(any_is_na_diags[1].fix.is_some());
}
#[tokio::test]
async fn test_no_external_linters_configured() {
let input = r#"```r
x = 1
```
"#;
let config = Config::default();
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let external_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == "any_is_na")
.collect();
assert_eq!(external_diags.len(), 0);
}
#[tokio::test]
async fn test_ruff_linter_integration() {
if which::which("ruff").is_err() {
println!("Skipping ruff test - ruff not installed");
return;
}
let input = r#"# Test
```python
import os
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("python".to_string(), "ruff".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let ruff_diags: Vec<_> = diagnostics.iter().filter(|d| d.code == "F401").collect();
assert_eq!(ruff_diags.len(), 1, "Expected 1 Ruff F401 diagnostic");
assert_eq!(ruff_diags[0].location.line, 4); assert_eq!(
ruff_diags[0].origin,
panache::linter::diagnostics::DiagnosticOrigin::External
);
assert!(ruff_diags[0].fix.is_some(), "Ruff fixes should be enabled");
}
#[tokio::test]
async fn test_ruff_fix_application_end_to_end() {
if which::which("ruff").is_err() {
println!("Skipping ruff test - ruff not installed");
return;
}
let input = r#"# Test
```python
import os
print("ok")
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("python".to_string(), "ruff".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let with_fixes: Vec<_> = diagnostics.iter().filter(|d| d.fix.is_some()).collect();
assert!(!with_fixes.is_empty(), "Expected at least one Ruff fix");
use panache::linter::diagnostics::Edit;
let mut edits: Vec<&Edit> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.flat_map(|f| &f.edits)
.collect();
edits.sort_by_key(|e| e.range.start());
let mut output = String::new();
let mut last_end = 0;
for edit in &edits {
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
output.push_str(&input[last_end..start]);
output.push_str(&edit.replacement);
last_end = end;
}
output.push_str(&input[last_end..]);
assert!(
!output.contains("import os"),
"Ruff fix should remove unused import"
);
assert!(output.contains("print(\"ok\")"));
assert!(output.contains("```python"));
}
#[tokio::test]
async fn test_shellcheck_linter_integration() {
if which::which("shellcheck").is_err() {
println!("Skipping shellcheck test - shellcheck not installed");
return;
}
let input = r#"# Test
```sh
echo $UNSET
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("sh".to_string(), "shellcheck".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let shell_diags: Vec<_> = diagnostics.iter().filter(|d| d.code == "SC2086").collect();
assert_eq!(
shell_diags.len(),
1,
"Expected 1 ShellCheck SC2086 diagnostic"
);
assert_eq!(shell_diags[0].location.line, 4); assert_eq!(
shell_diags[0].severity,
panache::linter::diagnostics::Severity::Info
);
assert!(
shell_diags[0].fix.is_some(),
"ShellCheck fixes should be enabled"
);
}
#[tokio::test]
async fn test_shellcheck_sc2148_not_reported_when_shell_is_known() {
if which::which("shellcheck").is_err() {
println!("Skipping shellcheck test - shellcheck not installed");
return;
}
let input = r#"# External Linter Playground
```sh
echo "hello"
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("sh".to_string(), "shellcheck".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let sc2148: Vec<_> = diagnostics.iter().filter(|d| d.code == "SC2148").collect();
assert!(
sc2148.is_empty(),
"SC2148 should be suppressed by passing --shell for known shell languages"
);
}
#[tokio::test]
async fn test_shellcheck_fix_application_end_to_end() {
if which::which("shellcheck").is_err() {
println!("Skipping shellcheck test - shellcheck not installed");
return;
}
let input = r#"# Test
```sh
echo $UNSET
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("sh".to_string(), "shellcheck".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let with_fixes: Vec<_> = diagnostics.iter().filter(|d| d.fix.is_some()).collect();
assert!(
!with_fixes.is_empty(),
"Expected at least one ShellCheck fix"
);
use panache::linter::diagnostics::Edit;
let mut edits: Vec<&Edit> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.flat_map(|f| &f.edits)
.collect();
edits.sort_by_key(|e| e.range.start());
let mut output = String::new();
let mut last_end = 0;
for edit in &edits {
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
output.push_str(&input[last_end..start]);
output.push_str(&edit.replacement);
last_end = end;
}
output.push_str(&input[last_end..]);
assert!(output.contains("echo \"$UNSET\""));
assert!(output.contains("```sh"));
}
#[tokio::test]
async fn test_eslint_linter_integration() {
if which::which("eslint").is_err() {
println!("Skipping eslint test - eslint not installed");
return;
}
let input = r#"# Test
```js
const x = 1;
console.log(1)
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("js".to_string(), "eslint".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let eslint_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == "no-unused-vars")
.collect();
assert_eq!(
eslint_diags.len(),
1,
"Expected 1 ESLint no-unused-vars diagnostic"
);
assert_eq!(eslint_diags[0].location.line, 4);
assert!(
eslint_diags[0].fix.is_some(),
"Expected ESLint fix or suggestion mapping"
);
}
#[tokio::test]
async fn test_eslint_fix_application_end_to_end() {
if which::which("eslint").is_err() {
println!("Skipping eslint test - eslint not installed");
return;
}
let input = r#"# Test
```js
const x = 1;
console.log(1)
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("js".to_string(), "eslint".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let with_fixes: Vec<_> = diagnostics.iter().filter(|d| d.fix.is_some()).collect();
assert!(
!with_fixes.is_empty(),
"Expected at least one ESLint fix or suggestion"
);
use panache::linter::diagnostics::Edit;
let mut edits: Vec<&Edit> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.flat_map(|f| &f.edits)
.collect();
edits.sort_by_key(|e| e.range.start());
let mut output = String::new();
let mut last_end = 0;
for edit in &edits {
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
output.push_str(&input[last_end..start]);
output.push_str(&edit.replacement);
last_end = end;
}
output.push_str(&input[last_end..]);
assert!(!output.contains("const x = 1;"));
assert!(output.contains("console.log(1)"));
assert!(output.contains("```js"));
}
#[tokio::test]
async fn test_staticcheck_linter_integration() {
if which::which("staticcheck").is_err() || which::which("go").is_err() {
println!("Skipping staticcheck test - staticcheck and/or go not installed");
return;
}
let input = r#"# Test
```go
package main
import "fmt"
func main() {
fmt.Printf("%d", "x")
}
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("go".to_string(), "staticcheck".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
assert!(
diagnostics.iter().all(|d| d.code != "compile"),
"Staticcheck should run against the generated Go file, not package fallback"
);
assert!(
diagnostics.iter().any(|d| d.code == "SA5009"),
"Expected staticcheck code-level diagnostic for mismatched Printf format"
);
}
#[tokio::test]
async fn test_clippy_linter_integration() {
if which::which("clippy-driver").is_err() {
println!("Skipping clippy test - clippy-driver not installed");
return;
}
let input = r#"# Test
```rust
fn main() {
let x = vec![1,2,3];
println!("{}", x.len());
}
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("rust".to_string(), "clippy".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
assert!(
diagnostics.iter().any(|d| d.code.starts_with("clippy::")),
"Expected clippy diagnostic code in rust block"
);
}
#[tokio::test]
async fn test_unknown_linter() {
let input = r#"```r
x <- 1
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("r".to_string(), "unknown_linter_12345".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let _diagnostics = linter::lint_with_external(&tree, input, &config).await;
}
#[tokio::test]
async fn test_unsupported_linter_language_mapping_is_skipped() {
let input = r#"# Test
```python
import os
```
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("python".to_string(), "jarl".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
assert!(
diagnostics
.iter()
.all(|d| d.code != "any_is_na" && d.code != "F401"),
"Expected unsupported linter-language mapping to be skipped"
);
}
#[tokio::test]
async fn test_fix_application_end_to_end() {
if which::which("jarl").is_err() {
println!("Skipping jarl test - jarl not installed");
return;
}
let input = r#"# Test Document
Some text here.
```r
any(is.na(x))
any(is.na(y))
```
More text.
"#;
let mut config = Config::default();
let mut linters = HashMap::new();
linters.insert("r".to_string(), "jarl".to_string());
config.linters = linters;
let tree = parse(input, Some(config.clone()));
let diagnostics = linter::lint_with_external(&tree, input, &config).await;
let with_fixes: Vec<_> = diagnostics.iter().filter(|d| d.fix.is_some()).collect();
assert!(!with_fixes.is_empty(), "Expected at least one fix");
use panache::linter::diagnostics::Edit;
let mut edits: Vec<&Edit> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.flat_map(|f| &f.edits)
.collect();
edits.sort_by_key(|e| e.range.start());
let mut output = String::new();
let mut last_end = 0;
for edit in &edits {
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
output.push_str(&input[last_end..start]);
output.push_str(&edit.replacement);
last_end = end;
}
output.push_str(&input[last_end..]);
assert!(
output.contains("anyNA(x)"),
"Fix should replace any(is.na(x)) with anyNA(x)"
);
assert!(
output.contains("anyNA(y)"),
"Fix should replace any(is.na(y)) with anyNA(y)"
);
assert!(output.contains("# Test Document"));
assert!(output.contains("Some text here."));
assert!(output.contains("More text."));
assert!(output.contains("```r"));
}
}