uira-commit-hook-cli 0.1.1

Standalone CLI for git hooks and AI-assisted dev workflows
use anyhow::Result;
use colored::Colorize;
use oxc::allocator::Allocator;
use oxc::ast::ast::{CallExpression, Expression};
use oxc::ast_visit::{walk, Visit};
use oxc::parser::Parser;
use oxc::span::SourceType;
use rayon::prelude::*;
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};

#[derive(Debug, Clone)]
pub struct LintDiagnostic {
    pub file: String,
    pub line: u32,
    pub column: u32,
    pub message: String,
    pub rule: String,
    pub severity: Severity,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Severity {
    Error,
    Warning,
}

pub struct Linter {
    pub no_console: bool,
    pub no_debugger: bool,
}

impl Default for Linter {
    fn default() -> Self {
        Self {
            no_console: true,
            no_debugger: true,
        }
    }
}

impl Linter {
    pub fn lint_files(&self, files: &[String]) -> Result<Vec<LintDiagnostic>> {
        let lintable_files: Vec<_> = files.iter().filter(|f| is_lintable(f)).collect();

        if lintable_files.is_empty() {
            return Ok(vec![]);
        }

        let diagnostics: Vec<LintDiagnostic> = lintable_files
            .par_iter()
            .flat_map(|file| self.lint_file(file).unwrap_or_default())
            .collect();

        Ok(diagnostics)
    }

    pub fn lint_file(&self, path: &str) -> Result<Vec<LintDiagnostic>> {
        let source = fs::read_to_string(path)?;
        let allocator = Allocator::default();
        let source_type = SourceType::from_path(path).unwrap_or_default();

        let parser = Parser::new(&allocator, &source, source_type);
        let ret = parser.parse();

        if !ret.errors.is_empty() {
            return Ok(vec![]);
        }

        let mut visitor = LintVisitor::new(path.to_string(), &source, self);
        visitor.visit_program(&ret.program);

        Ok(visitor.diagnostics)
    }

    pub fn run(&self, files: &[String]) -> Result<bool> {
        let error_count = AtomicUsize::new(0);
        let warning_count = AtomicUsize::new(0);

        let diagnostics = self.lint_files(files)?;

        for d in &diagnostics {
            match d.severity {
                Severity::Error => error_count.fetch_add(1, Ordering::Relaxed),
                Severity::Warning => warning_count.fetch_add(1, Ordering::Relaxed),
            };

            let severity_str = match d.severity {
                Severity::Error => "error".red().bold(),
                Severity::Warning => "warning".yellow().bold(),
            };

            println!(
                "{}:{}:{}: {} [{}]",
                d.file.dimmed(),
                d.line,
                d.column,
                severity_str,
                d.rule.cyan()
            );
            println!("  {}", d.message);
            println!();
        }

        let errors = error_count.load(Ordering::Relaxed);
        let warnings = warning_count.load(Ordering::Relaxed);

        if errors > 0 || warnings > 0 {
            println!(
                "{} {} error(s), {} warning(s)",
                "✗".red().bold(),
                errors,
                warnings
            );
        } else if !files.is_empty() {
            println!("{} No issues found", "✓".green().bold());
        }

        Ok(errors == 0)
    }
}

struct LintVisitor<'a> {
    file: String,
    source: &'a str,
    config: &'a Linter,
    diagnostics: Vec<LintDiagnostic>,
}

impl<'a> LintVisitor<'a> {
    fn new(file: String, source: &'a str, config: &'a Linter) -> Self {
        Self {
            file,
            source,
            config,
            diagnostics: Vec::new(),
        }
    }

    fn get_line_col(&self, offset: u32) -> (u32, u32) {
        let mut line = 1u32;
        let mut col = 1u32;

        for (i, ch) in self.source.char_indices() {
            if i as u32 >= offset {
                break;
            }
            if ch == '\n' {
                line += 1;
                col = 1;
            } else {
                col += 1;
            }
        }

        (line, col)
    }

    fn add_diagnostic(&mut self, offset: u32, message: &str, rule: &str, severity: Severity) {
        let (line, column) = self.get_line_col(offset);
        self.diagnostics.push(LintDiagnostic {
            file: self.file.clone(),
            line,
            column,
            message: message.to_string(),
            rule: rule.to_string(),
            severity,
        });
    }
}

impl<'a> Visit<'a> for LintVisitor<'a> {
    fn visit_debugger_statement(&mut self, stmt: &oxc::ast::ast::DebuggerStatement) {
        if self.config.no_debugger {
            self.add_diagnostic(
                stmt.span.start,
                "Unexpected 'debugger' statement",
                "no-debugger",
                Severity::Error,
            );
        }
    }

    fn visit_call_expression(&mut self, expr: &CallExpression<'a>) {
        if self.config.no_console {
            if let Expression::StaticMemberExpression(member) = &expr.callee {
                if let Expression::Identifier(id) = &member.object {
                    if id.name == "console" {
                        self.add_diagnostic(
                            expr.span.start,
                            "Unexpected console statement",
                            "no-console",
                            Severity::Warning,
                        );
                    }
                }
            }
        }

        walk::walk_call_expression(self, expr);
    }
}

fn is_lintable(path: &str) -> bool {
    let path = Path::new(path);
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");

    matches!(
        ext,
        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_console() {
        let linter = Linter::default();
        let source = r#"console.log("hello");"#;

        let allocator = Allocator::default();
        let source_type = SourceType::from_path("test.js").unwrap();
        let parser = Parser::new(&allocator, source, source_type);
        let ret = parser.parse();

        let mut visitor = LintVisitor::new("test.js".to_string(), source, &linter);
        visitor.visit_program(&ret.program);

        assert_eq!(visitor.diagnostics.len(), 1);
        assert_eq!(visitor.diagnostics[0].rule, "no-console");
    }

    #[test]
    fn test_no_debugger() {
        let linter = Linter::default();
        let source = r#"debugger;"#;

        let allocator = Allocator::default();
        let source_type = SourceType::from_path("test.js").unwrap();
        let parser = Parser::new(&allocator, source, source_type);
        let ret = parser.parse();

        let mut visitor = LintVisitor::new("test.js".to_string(), source, &linter);
        visitor.visit_program(&ret.program);

        assert_eq!(visitor.diagnostics.len(), 1);
        assert_eq!(visitor.diagnostics[0].rule, "no-debugger");
    }

    #[test]
    fn test_clean_code() {
        let linter = Linter::default();
        let source = r#"const x = 1;"#;

        let allocator = Allocator::default();
        let source_type = SourceType::from_path("test.js").unwrap();
        let parser = Parser::new(&allocator, source, source_type);
        let ret = parser.parse();

        let mut visitor = LintVisitor::new("test.js".to_string(), source, &linter);
        visitor.visit_program(&ret.program);

        assert!(visitor.diagnostics.is_empty());
    }
}