Skip to main content

htmlp/
files.rs

1use crate::{Diagnostic, Document, Position, Report, TokenCounter, lint, parse};
2use std::{
3    fs,
4    path::{Path, PathBuf},
5};
6
7/// A per-file report; files in a directory are never merged or inherited.
8#[derive(Debug)]
9#[cfg_attr(feature = "json", derive(serde::Serialize))]
10pub struct FileReport {
11    pub file: PathBuf,
12    pub report: Report,
13}
14
15/// Read UTF-8 source with a 4 MiB cap before allocating the full input.
16pub fn parse_file(path: impl AsRef<Path>) -> Result<Document, Diagnostic> {
17    parse(&read_source(path.as_ref())?)
18}
19
20fn read_source(path: &Path) -> Result<String, Diagnostic> {
21    use std::io::Read;
22    let failure = |e: std::io::Error| {
23        Diagnostic::new(
24            "io",
25            format!("{}: {e}", path.display()),
26            Position::default(),
27        )
28    };
29    let file = fs::File::open(path).map_err(failure)?;
30    let mut source = String::new();
31    file.take(4 * 1024 * 1024 + 1)
32        .read_to_string(&mut source)
33        .map_err(failure)?;
34    Ok(source)
35}
36
37/// Recursively check `.htmlp` files independently, in sorted order. Skip symlinks
38/// and `.git`, `node_modules`, `target`, `dist`, and `vendor`. An explicit file
39/// must have `.htmlp` extension. No matches is an error, avoiding empty CI passes.
40pub fn check_path(
41    path: impl AsRef<Path>,
42    counter: &impl TokenCounter,
43) -> Result<Vec<FileReport>, String> {
44    let paths = source_paths(path.as_ref())?;
45    Ok(paths
46        .into_iter()
47        .map(|file| {
48            let report = match parse_file(&file) {
49                Ok(doc) => lint(&doc, counter),
50                Err(error) => Report {
51                    diagnostics: vec![error],
52                    measurements: Vec::new(),
53                },
54            };
55            FileReport { file, report }
56        })
57        .collect())
58}
59
60fn source_paths(path: &Path) -> Result<Vec<PathBuf>, String> {
61    fn visit(path: &Path, files: &mut Vec<PathBuf>, depth: usize) -> Result<(), String> {
62        if depth > 128 {
63            return Err("Directory nesting exceeds 128 levels".into());
64        }
65        let metadata =
66            fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
67        if metadata.file_type().is_symlink() {
68            return Ok(());
69        }
70        if metadata.is_file() {
71            if path.extension().is_some_and(|e| e == "htmlp") {
72                files.push(path.to_path_buf());
73            }
74        } else if metadata.is_dir() {
75            let entries = fs::read_dir(path).map_err(|e| e.to_string())?;
76            for entry in entries {
77                let entry = entry.map_err(|e| e.to_string())?;
78                if matches!(
79                    entry.file_name().to_str(),
80                    Some(".git" | "node_modules" | "target" | "dist" | "vendor")
81                ) {
82                    continue;
83                }
84                visit(&entry.path(), files, depth + 1)?;
85            }
86        }
87        Ok(())
88    }
89    let mut paths = Vec::new();
90    visit(path, &mut paths, 0)?;
91    paths.sort();
92    if paths.is_empty() {
93        return Err(format!("No .htmlp files found at {}", path.display()));
94    }
95    Ok(paths)
96}
97
98/// Sign all `.htmlp` files using the same traversal as [`check_path`]. All files
99/// are parsed before any writes. Changed files are replaced atomically, keeping
100/// permissions. Returns changed paths. An I/O failure can leave earlier files
101/// updated; a directory is not a transaction. Avoid concurrent source edits.
102pub fn sign_path(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, String> {
103    let mut changes = Vec::new();
104    for file in source_paths(path.as_ref())? {
105        let before = read_source(&file).map_err(|e| format!("{}: {e}", file.display()))?;
106        let after = crate::sign_source(&before).map_err(|e| format!("{}: {e}", file.display()))?;
107        if before != after {
108            changes.push((file, before, after));
109        }
110    }
111    let mut changed = Vec::new();
112    for (file, before, after) in changes {
113        replace_source(&file, &before, &after).map_err(|e| format!("{}: {e}", file.display()))?;
114        changed.push(file);
115    }
116    Ok(changed)
117}
118
119fn replace_source(path: &Path, before: &str, after: &str) -> std::io::Result<()> {
120    use std::io::{Error, Write};
121    use std::sync::atomic::{AtomicU64, Ordering};
122    static NEXT: AtomicU64 = AtomicU64::new(0);
123    let metadata = fs::symlink_metadata(path)?;
124    if !metadata.is_file() || metadata.file_type().is_symlink() {
125        return Err(Error::other("Source is no longer a regular file"));
126    }
127    let temporary = path.with_file_name(format!(
128        ".htmlp-sign-{}-{}.tmp",
129        std::process::id(),
130        NEXT.fetch_add(1, Ordering::Relaxed)
131    ));
132    let mut output = fs::OpenOptions::new()
133        .write(true)
134        .create_new(true)
135        .open(&temporary)?;
136    let result = (|| {
137        output.write_all(after.as_bytes())?;
138        output.set_permissions(metadata.permissions())?;
139        output.sync_all()?;
140        drop(output);
141        if read_source(path).map_err(Error::other)? != before {
142            return Err(Error::other(
143                "Source changed while signing; rerun htmlp sign",
144            ));
145        }
146        fs::rename(&temporary, path)
147    })();
148    if result.is_err() {
149        let _ = fs::remove_file(&temporary);
150    }
151    result
152}