//! TypeScript documentation coverage adapter. //! //! Checks for: //! - A leading file-level JSDoc comment (`/** … */`) at the top of every //! `.ts` / `.tsx` file. //! - A JSDoc comment before every exported declaration (`export function`, //! `export class`, `export type`, `export interface`, `export const`, etc.). use std::fs; use std::path::Path; use crate::{CheckFailure, CheckResult, LanguageAdapter}; /// TypeScript documentation coverage adapter. pub struct TypeScriptAdapter; impl TypeScriptAdapter { fn check_file(&self, path: &Path) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], }; let lines: Vec<&str> = content.lines().collect(); let mut failures = Vec::new(); // File-level JSDoc: first non-empty line must start with "/**" if !has_file_level_jsdoc(&content) { failures.push(CheckFailure { file_path: path.to_path_buf(), line: 1, item_kind: "file".to_string(), item_name: file_stem(path), }); } // Exported items missing JSDoc for (i, &line) in lines.iter().enumerate() { if let Some((kind, name)) = parse_exported_item(line) && !has_jsdoc_before(&lines, i) { failures.push(CheckFailure { file_path: path.to_path_buf(), line: i + 1, item_kind: kind, item_name: name, }); } } failures } /// Extract exported item signatures from a TypeScript file as `"kind name"` strings. pub(crate) fn extract_items(path: &Path) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], }; content .lines() .filter_map(|line| { let (kind, name) = parse_exported_item(line)?; Some(format!("{kind} {name}")) }) .collect() } } impl LanguageAdapter for TypeScriptAdapter { fn check(&self, files: &[&Path]) -> CheckResult { let failures: Vec = files.iter().flat_map(|&f| self.check_file(f)).collect(); if failures.is_empty() { CheckResult::Ok } else { CheckResult::Failures(failures) } } fn update_source_map( &self, passing_files: &[&Path], source_map_path: &Path, ) -> Result<(), String> { let mut map = crate::read_map(source_map_path)?; for &file in passing_files { let key = file.to_string_lossy().to_string(); let items: Vec = Self::extract_items(file) .into_iter() .map(serde_json::Value::String) .collect(); map.insert(key, serde_json::Value::Array(items)); } crate::write_map(source_map_path, map) } } fn file_stem(path: &Path) -> String { path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string() } /// Return `true` if the file starts with a JSDoc block comment (`/**`). fn has_file_level_jsdoc(content: &str) -> bool { for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } return trimmed.starts_with("/**"); } false } /// Parse a line as an exported TypeScript declaration. /// /// Returns `(kind, name)` for supported export forms, `None` otherwise. fn parse_exported_item(line: &str) -> Option<(String, String)> { let trimmed = line.trim(); // Strip "export default" or "export" let rest = if let Some(r) = trimmed.strip_prefix("export default ") { r.trim_start() } else if let Some(r) = trimmed.strip_prefix("export ") { r.trim_start() } else { return None; }; // Strip optional "async" let rest = if let Some(r) = rest.strip_prefix("async ") { r.trim_start() } else { rest }; let (kind, name_part) = if let Some(r) = rest.strip_prefix("function ") { ("function", r.trim_start()) } else if let Some(r) = rest.strip_prefix("class ") { ("class", r.trim_start()) } else if let Some(r) = rest.strip_prefix("type ") { ("type", r.trim_start()) } else if let Some(r) = rest.strip_prefix("interface ") { ("interface", r.trim_start()) } else if let Some(r) = rest.strip_prefix("const ") { ("const", r.trim_start()) } else if let Some(r) = rest.strip_prefix("let ") { ("let", r.trim_start()) } else if let Some(r) = rest.strip_prefix("enum ") { ("enum", r.trim_start()) } else { return None; }; let name: String = name_part .chars() .take_while(|&c| c.is_alphanumeric() || c == '_') .collect(); if name.is_empty() { // "export default function() {}" — anonymous default export return Some((kind.to_string(), "default".to_string())); } Some((kind.to_string(), name)) } /// Return `true` if a JSDoc comment appears before the item at `item_idx`. /// /// Scans backward, skipping blank lines and decorator lines (`@…`). Returns /// `true` if the first substantive line ends with `*/` (closing a JSDoc block) /// or starts with `/**` (single-line JSDoc). fn has_jsdoc_before(lines: &[&str], item_idx: usize) -> bool { let mut i = item_idx; while i > 0 { i -= 1; let line = lines[i].trim(); if line.is_empty() { continue; } if line.starts_with('@') { // Decorator — keep scanning upward continue; } return line.ends_with("*/") || line.starts_with("/**"); } false } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn write_ts(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { let path = dir.join(name); std::fs::write(&path, content).unwrap(); path } #[test] fn check_fully_documented_file_returns_ok() { let tmp = TempDir::new().unwrap(); let path = write_ts( tmp.path(), "app.ts", "/**\n * File doc.\n */\n\n/** Does something. */\nexport function hello(): void {}\n", ); let adapter = TypeScriptAdapter; assert_eq!(adapter.check(&[&path]), CheckResult::Ok); } #[test] fn check_detects_missing_file_jsdoc() { let tmp = TempDir::new().unwrap(); let path = write_ts( tmp.path(), "app.ts", "/** Does something. */\nexport function hello(): void {}\n", ); // First non-empty line IS "/**", so this file passes the file-level check. // Use a file that starts with code instead. let path2 = write_ts( tmp.path(), "app2.ts", "import { foo } from './foo';\n/** A function. */\nexport function hello(): void {}\n", ); let adapter = TypeScriptAdapter; let result = adapter.check(&[&path2]); assert!( matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")), "expected file failure, got {result:?}" ); // The first file (starts with /**) should pass the file-level check let result2 = adapter.check(&[&path]); // It may still fail on the export if there's no separate export doc, // but the file-level check itself should pass (first line is /**) assert!( !matches!(&result2, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")), "file starting with /** should not have file-level failure" ); } #[test] fn check_detects_missing_export_jsdoc_with_correct_fields() { let tmp = TempDir::new().unwrap(); let path = write_ts( tmp.path(), "app.ts", "/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n", ); let adapter = TypeScriptAdapter; let result = adapter.check(&[&path]); if let CheckResult::Failures(failures) = result { let f = failures.iter().find(|f| f.item_kind == "function").unwrap(); assert_eq!(f.item_name, "undocumented"); assert_eq!(f.file_path, path); } else { panic!("expected failures"); } } #[test] fn parse_exported_item_recognises_various_kinds() { assert_eq!( parse_exported_item("export function foo()"), Some(("function".into(), "foo".into())) ); assert_eq!( parse_exported_item("export async function bar()"), Some(("function".into(), "bar".into())) ); assert_eq!( parse_exported_item("export class Baz"), Some(("class".into(), "Baz".into())) ); assert_eq!( parse_exported_item("export type Qux = string;"), Some(("type".into(), "Qux".into())) ); assert_eq!( parse_exported_item("export interface IFoo"), Some(("interface".into(), "IFoo".into())) ); assert_eq!( parse_exported_item("export const MY_CONST = 1;"), Some(("const".into(), "MY_CONST".into())) ); assert_eq!(parse_exported_item("function notExported()"), None); assert_eq!(parse_exported_item("const x = 1;"), None); } }