use super::is_test_file;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::LazyLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TsLintIssue {
pub file: String,
pub line: usize,
pub column: usize,
pub rule: String,
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TsLintRule {
ExplicitAny,
TsIgnore,
TsExpectError,
TsNocheck,
}
impl TsLintRule {
pub fn as_str(&self) -> &'static str {
match self {
Self::ExplicitAny => "ts/explicit-any",
Self::TsIgnore => "ts/ts-ignore",
Self::TsExpectError => "ts/ts-expect-error",
Self::TsNocheck => "ts/ts-nocheck",
}
}
pub fn message(&self) -> &'static str {
match self {
Self::ExplicitAny => "Explicit `any` type weakens type safety",
Self::TsIgnore => "@ts-ignore suppresses all TypeScript errors on next line",
Self::TsExpectError => "@ts-expect-error suppresses expected error",
Self::TsNocheck => "@ts-nocheck disables type checking for entire file",
}
}
pub fn base_severity(&self) -> &'static str {
match self {
Self::ExplicitAny => "high",
Self::TsIgnore => "high",
Self::TsExpectError => "medium",
Self::TsNocheck => "high",
}
}
}
static ANY_TYPE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?::\s*any\s*[;,)\]\s>]|:\s*any$|as\s+any\b|<any[,>]|\bany\[\])").unwrap()
});
static TS_IGNORE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@ts-ignore\b").unwrap());
static TS_EXPECT_ERROR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"@ts-expect-error\b").unwrap());
static TS_NOCHECK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@ts-nocheck\b").unwrap());
fn adjust_severity(base_severity: &str, is_test: bool) -> String {
if is_test && base_severity == "high" {
"low".to_string()
} else {
base_severity.to_string()
}
}
pub fn lint_ts_file(path: &Path, content: &str) -> Vec<TsLintIssue> {
let mut issues = Vec::new();
let file_str = path.to_string_lossy().to_string();
let is_test = is_test_file(&file_str);
for (line_idx, line) in content.lines().enumerate() {
let line_num = line_idx + 1;
for mat in ANY_TYPE_REGEX.find_iter(line) {
issues.push(TsLintIssue {
file: file_str.clone(),
line: line_num,
column: mat.start() + 1,
rule: TsLintRule::ExplicitAny.as_str().to_string(),
severity: adjust_severity(TsLintRule::ExplicitAny.base_severity(), is_test),
message: TsLintRule::ExplicitAny.message().to_string(),
snippet: Some(line.trim().to_string()),
});
}
if let Some(mat) = TS_IGNORE_REGEX.find(line) {
issues.push(TsLintIssue {
file: file_str.clone(),
line: line_num,
column: mat.start() + 1,
rule: TsLintRule::TsIgnore.as_str().to_string(),
severity: adjust_severity(TsLintRule::TsIgnore.base_severity(), is_test),
message: TsLintRule::TsIgnore.message().to_string(),
snippet: Some(line.trim().to_string()),
});
}
if let Some(mat) = TS_EXPECT_ERROR_REGEX.find(line) {
issues.push(TsLintIssue {
file: file_str.clone(),
line: line_num,
column: mat.start() + 1,
rule: TsLintRule::TsExpectError.as_str().to_string(),
severity: adjust_severity(TsLintRule::TsExpectError.base_severity(), is_test),
message: TsLintRule::TsExpectError.message().to_string(),
snippet: Some(line.trim().to_string()),
});
}
if let Some(mat) = TS_NOCHECK_REGEX.find(line) {
issues.push(TsLintIssue {
file: file_str.clone(),
line: line_num,
column: mat.start() + 1,
rule: TsLintRule::TsNocheck.as_str().to_string(),
severity: adjust_severity(TsLintRule::TsNocheck.base_severity(), is_test),
message: TsLintRule::TsNocheck.message().to_string(),
snippet: Some(line.trim().to_string()),
});
}
}
issues
}
pub fn lint_ts_files(files: &[(String, String)]) -> Vec<TsLintIssue> {
files
.iter()
.flat_map(|(path, content)| lint_ts_file(Path::new(path), content))
.collect()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TsLintSummary {
pub total_issues: usize,
pub by_severity: std::collections::HashMap<String, usize>,
pub by_rule: std::collections::HashMap<String, usize>,
pub affected_files: usize,
pub test_files_issues: usize,
pub prod_files_issues: usize,
}
impl TsLintSummary {
pub fn from_issues(issues: &[TsLintIssue]) -> Self {
use std::collections::{HashMap, HashSet};
let files: HashSet<_> = issues.iter().map(|i| &i.file).collect();
let mut by_severity = HashMap::new();
for issue in issues {
*by_severity.entry(issue.severity.clone()).or_insert(0) += 1;
}
let mut by_rule = HashMap::new();
for issue in issues {
*by_rule.entry(issue.rule.clone()).or_insert(0) += 1;
}
let test_files_issues = issues.iter().filter(|i| is_test_file(&i.file)).count();
let prod_files_issues = issues.len() - test_files_issues;
Self {
total_issues: issues.len(),
by_severity,
by_rule,
affected_files: files.len(),
test_files_issues,
prod_files_issues,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detects_colon_any() {
let content = r#"
function process(data: any) {
return data;
}
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/explicit-any");
assert_eq!(issues[0].severity, "high");
}
#[test]
fn test_detects_as_any() {
let content = r#"
const value = response as any;
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/explicit-any");
}
#[test]
fn test_detects_any_array() {
let content = r#"
const items: any[] = [];
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/explicit-any");
}
#[test]
fn test_detects_generic_any() {
let content = r#"
const map = new Map<any, string>();
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/explicit-any");
}
#[test]
fn test_ignores_any_in_words() {
let content = r#"
const company = "Acme";
const anyway = true;
const anyone = "person";
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 0, "Should not match 'any' inside words");
}
#[test]
fn test_detects_ts_ignore() {
let content = r#"
// @ts-ignore
const x = badCode();
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/ts-ignore");
assert_eq!(issues[0].severity, "high");
}
#[test]
fn test_detects_ts_expect_error() {
let content = r#"
// @ts-expect-error - intentional for testing
const x = badCode();
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/ts-expect-error");
assert_eq!(issues[0].severity, "medium");
}
#[test]
fn test_detects_ts_nocheck() {
let content = r#"
// @ts-nocheck
const x = anything;
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "ts/ts-nocheck");
assert_eq!(issues[0].severity, "high");
}
#[test]
fn test_severity_lowered_for_test_files() {
let content = "function mock(data: any) {}";
let prod_issues = lint_ts_file(Path::new("src/service.ts"), content);
assert_eq!(prod_issues[0].severity, "high");
let test_issues = lint_ts_file(Path::new("src/service.test.ts"), content);
assert_eq!(test_issues[0].severity, "low");
let tests_dir_issues = lint_ts_file(Path::new("src/__tests__/service.ts"), content);
assert_eq!(tests_dir_issues[0].severity, "low");
}
#[test]
fn test_multiple_issues_per_file() {
let content = r#"
// @ts-ignore
function bad(x: any, y: any): any {
return x as any;
}
"#;
let issues = lint_ts_file(Path::new("test.ts"), content);
assert!(issues.len() >= 4, "Should detect multiple issues");
}
#[test]
fn test_summary_separates_test_and_prod() {
let issues = vec![
TsLintIssue {
file: "src/app.ts".to_string(),
line: 1,
column: 1,
rule: "ts/explicit-any".to_string(),
severity: "high".to_string(),
message: "test".to_string(),
snippet: None,
},
TsLintIssue {
file: "src/app.test.ts".to_string(),
line: 1,
column: 1,
rule: "ts/explicit-any".to_string(),
severity: "low".to_string(),
message: "test".to_string(),
snippet: None,
},
];
let summary = TsLintSummary::from_issues(&issues);
assert_eq!(summary.prod_files_issues, 1);
assert_eq!(summary.test_files_issues, 1);
}
#[test]
fn test_column_position_correct() {
let content = "const x: any = 1;";
let issues = lint_ts_file(Path::new("test.ts"), content);
assert_eq!(issues.len(), 1);
assert!(issues[0].column > 0);
}
}