Files
huskies/crates/source-map-gen/src/ts_adapter.rs
T
2026-05-13 13:51:05 +00:00

296 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 JSDocitem 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);
}
}