296 lines
9.6 KiB
Rust
296 lines
9.6 KiB
Rust
//! 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, relative_key};
|
||
|
||
/// TypeScript documentation coverage adapter.
|
||
pub struct TypeScriptAdapter;
|
||
|
||
impl TypeScriptAdapter {
|
||
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||
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<String> {
|
||
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<CheckFailure> = 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,
|
||
root: Option<&Path>,
|
||
) -> Result<(), String> {
|
||
let mut map = crate::read_map(source_map_path)?;
|
||
for &file in passing_files {
|
||
let key = relative_key(file, root);
|
||
let items: Vec<serde_json::Value> = 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() {
|
||
// A blank line breaks the JSDoc–item adjacency: stop searching.
|
||
return false;
|
||
}
|
||
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);
|
||
}
|
||
}
|