Skip to main content

wowlab_docgen_cli/infra/
prettier.rs

1use std::{
2    io::{BufRead as _, BufReader, BufWriter, Read as _, Write as _},
3    process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio},
4    sync::Mutex,
5};
6
7use serde::{Deserialize, Serialize};
8use wowlab_fs::{
9    checksum::{self, Checksum, TreeSnapshot},
10    directory::{self, EntryKind},
11    path::{Path, PathBuf},
12};
13
14const FORMATTER_DOMAIN: &[u8] = b"wowlab-docgen:prettier:v1\0";
15const WORKER_SOURCE: &str = include_str!("prettier_worker.mjs");
16
17const CONFIG_FILENAMES: &[&str] = &[
18    ".editorconfig",
19    ".prettierignore",
20    ".prettierrc",
21    ".prettierrc.cjs",
22    ".prettierrc.js",
23    ".prettierrc.json",
24    ".prettierrc.json5",
25    ".prettierrc.mjs",
26    ".prettierrc.toml",
27    ".prettierrc.yaml",
28    ".prettierrc.yml",
29    "package-lock.json",
30    "package.json",
31    "pnpm-lock.yaml",
32    "prettier.config.cjs",
33    "prettier.config.cts",
34    "prettier.config.js",
35    "prettier.config.mjs",
36    "prettier.config.mts",
37    "prettier.config.ts",
38    "yarn.lock",
39];
40
41/// Failure to execute the workspace-pinned Prettier formatter.
42#[derive(Debug, thiserror::Error)]
43#[error("{kind}")]
44pub struct PrettierError {
45    #[source]
46    kind: PrettierErrorKind,
47}
48
49#[derive(Debug, thiserror::Error)]
50enum PrettierErrorKind {
51    #[error("workspace-pinned Prettier is missing: {}", path.display())]
52    Missing { path: PathBuf },
53    #[error("failed to start Prettier worker with {}: {source}", path.display())]
54    Spawn {
55        path: PathBuf,
56        #[source]
57        source: std::io::Error,
58    },
59    #[error("failed to initialize Prettier worker: {detail}")]
60    Initialize { detail: Box<str> },
61    #[error("Prettier worker failed while formatting {}: {detail}", file_path.display())]
62    Worker {
63        file_path: PathBuf,
64        detail: Box<str>,
65    },
66    #[error("Prettier failed for {}: {message}", file_path.display())]
67    Failed {
68        file_path: PathBuf,
69        message: Box<str>,
70    },
71}
72
73impl PrettierError {
74    const fn new(kind: PrettierErrorKind) -> Self {
75        Self { kind }
76    }
77
78    fn missing(path: PathBuf) -> Self {
79        Self::new(PrettierErrorKind::Missing { path })
80    }
81
82    fn spawn(path: PathBuf, source: std::io::Error) -> Self {
83        Self::new(PrettierErrorKind::Spawn { path, source })
84    }
85
86    fn initialize(detail: impl Into<Box<str>>) -> Self {
87        Self::new(PrettierErrorKind::Initialize {
88            detail: detail.into(),
89        })
90    }
91
92    fn worker(file_path: &Path, detail: impl Into<Box<str>>) -> Self {
93        Self::new(PrettierErrorKind::Worker {
94            file_path: file_path.to_path_buf(),
95            detail: detail.into(),
96        })
97    }
98
99    fn failed(file_path: &Path, message: impl Into<Box<str>>) -> Self {
100        Self::new(PrettierErrorKind::Failed {
101            file_path: file_path.to_path_buf(),
102            message: message.into(),
103        })
104    }
105}
106
107/// Workspace-pinned Prettier installation backed by one persistent Node worker.
108#[derive(Debug)]
109pub struct Formatter {
110    workspace_root: PathBuf,
111    identity: Option<Checksum>,
112    worker: Mutex<Option<Worker>>,
113}
114
115impl Formatter {
116    /// Resolve the workspace-pinned formatter and its cache identity.
117    ///
118    /// Formatter identity failures disable formatter-result reuse without preventing formatting.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error when the workspace-pinned formatter is unavailable.
123    pub fn load(workspace_root: &Path, workspace_paths: &[PathBuf]) -> Result<Self, PrettierError> {
124        let module = workspace_root.join("node_modules/prettier/index.mjs");
125
126        if !directory::inspect(&module)
127            .is_ok_and(|entry| entry.is_some_and(|entry| entry.kind() == EntryKind::File))
128        {
129            return Err(PrettierError::missing(module));
130        }
131
132        let identity = formatter_identity(workspace_root, &module, workspace_paths);
133
134        Ok(Self {
135            workspace_root: workspace_root.to_path_buf(),
136            identity,
137            worker: Mutex::new(None),
138        })
139    }
140
141    /// Return the exact formatter and effective-configuration identity when cacheable.
142    #[must_use]
143    pub const fn identity(&self) -> Option<Checksum> {
144        self.identity
145    }
146
147    /// Format content as the given workspace output path.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if Prettier fails or produces non-UTF-8 output.
152    pub fn format(&self, content: &str, file_path: &Path) -> Result<String, PrettierError> {
153        let mut worker = self
154            .worker
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner);
157
158        if worker.is_none() {
159            *worker = Some(Worker::spawn(&self.workspace_root)?);
160        }
161
162        let result = worker
163            .as_mut()
164            .ok_or_else(|| PrettierError::initialize("worker was not initialized"))?
165            .format(content, file_path);
166
167        if result.is_err() {
168            *worker = None;
169        }
170
171        result
172    }
173}
174
175#[derive(Debug)]
176struct Worker {
177    child: Child,
178    stdin: BufWriter<ChildStdin>,
179    stdout: BufReader<ChildStdout>,
180    stderr: ChildStderr,
181    next_id: u64,
182}
183
184impl Worker {
185    fn spawn(workspace_root: &Path) -> Result<Self, PrettierError> {
186        let program = PathBuf::from("node");
187        let mut child = Command::new(&program)
188            .args(["--input-type=module", "--eval", WORKER_SOURCE])
189            .current_dir(workspace_root)
190            .stdin(Stdio::piped())
191            .stdout(Stdio::piped())
192            .stderr(Stdio::piped())
193            .spawn()
194            .map_err(|source| PrettierError::spawn(program, source))?;
195        let (Some(stdin), Some(stdout), Some(stderr)) =
196            (child.stdin.take(), child.stdout.take(), child.stderr.take())
197        else {
198            let _result = child.kill();
199            let _result = child.wait();
200
201            return Err(PrettierError::initialize(
202                "worker standard streams were unavailable",
203            ));
204        };
205
206        Ok(Self {
207            child,
208            stdin: BufWriter::new(stdin),
209            stdout: BufReader::new(stdout),
210            stderr,
211            next_id: 0,
212        })
213    }
214
215    fn format(&mut self, content: &str, file_path: &Path) -> Result<String, PrettierError> {
216        let id = self.next_id;
217
218        self.next_id = self.next_id.wrapping_add(1);
219
220        serde_json::to_writer(
221            &mut self.stdin,
222            &WorkerRequest {
223                id,
224                path: file_path.to_string_lossy().as_ref(),
225                contents: content,
226            },
227        )
228        .map_err(|error| PrettierError::worker(file_path, error.to_string()))?;
229        self.stdin
230            .write_all(b"\n")
231            .and_then(|()| self.stdin.flush())
232            .map_err(|error| PrettierError::worker(file_path, error.to_string()))?;
233
234        let mut line = String::new();
235
236        self.stdout
237            .read_line(&mut line)
238            .map_err(|error| PrettierError::worker(file_path, error.to_string()))?;
239
240        if line.is_empty() {
241            return Err(PrettierError::worker(file_path, self.failure_detail()));
242        }
243
244        let response: WorkerResponse = serde_json::from_str(&line)
245            .map_err(|error| PrettierError::worker(file_path, error.to_string()))?;
246
247        if response.id != Some(id) {
248            return Err(PrettierError::worker(
249                file_path,
250                format!(
251                    "response id mismatch: expected {id}, received {:?}",
252                    response.id
253                ),
254            ));
255        }
256
257        match (response.contents, response.error) {
258            (Some(contents), None) => Ok(contents),
259            (None, Some(error)) => Err(PrettierError::failed(file_path, error)),
260            _ => Err(PrettierError::worker(
261                file_path,
262                "worker returned an invalid response",
263            )),
264        }
265    }
266
267    fn failure_detail(&mut self) -> Box<str> {
268        let _result = self.child.kill();
269        let status = self.child.wait().ok();
270        let mut stderr = String::new();
271        let _result = self.stderr.read_to_string(&mut stderr);
272        let stderr = stderr.trim();
273
274        if stderr.is_empty() {
275            format!("worker exited unexpectedly with {status:?}").into_boxed_str()
276        } else {
277            stderr.to_owned().into_boxed_str()
278        }
279    }
280}
281
282impl Drop for Worker {
283    fn drop(&mut self) {
284        let _result = self.child.kill();
285        let _result = self.child.wait();
286    }
287}
288
289#[derive(Serialize)]
290struct WorkerRequest<'a> {
291    id: u64,
292    path: &'a str,
293    contents: &'a str,
294}
295
296#[derive(Deserialize)]
297struct WorkerResponse {
298    id: Option<u64>,
299    contents: Option<String>,
300    error: Option<String>,
301}
302
303fn node_identity() -> Option<Vec<u8>> {
304    let output = Command::new("node")
305        .arg("--version")
306        .stdin(Stdio::null())
307        .stdout(Stdio::piped())
308        .stderr(Stdio::piped())
309        .output()
310        .ok()?;
311
312    if !output.status.success() {
313        return None;
314    }
315
316    Some(output.stdout)
317}
318
319fn formatter_identity(
320    workspace_root: &Path,
321    module: &Path,
322    workspace_paths: &[PathBuf],
323) -> Option<Checksum> {
324    let module = directory::canonicalize(module).ok()?;
325    let module = checksum::file(&module).ok()?;
326    let node = node_identity()?;
327    let config_paths = workspace_paths.iter().filter(|path| {
328        path.file_name()
329            .and_then(|name| name.to_str())
330            .is_some_and(|name| CONFIG_FILENAMES.contains(&name))
331    });
332    let configuration = TreeSnapshot::capture(workspace_root, config_paths)
333        .ok()?
334        .checksum();
335    let mut material = Vec::from(FORMATTER_DOMAIN);
336
337    material.extend_from_slice(module.as_bytes());
338    material.extend_from_slice(configuration.as_bytes());
339    material.extend_from_slice(checksum::bytes(WORKER_SOURCE).as_bytes());
340    material.extend_from_slice(checksum::bytes(node).as_bytes());
341
342    Some(checksum::bytes(material))
343}
344
345#[cfg(all(test, unix))]
346mod tests {
347    use googletest::prelude::*;
348    use wowlab_fs::{file, temporary::Directory};
349
350    use super::*;
351
352    fn install_formatter(root: &Path, module: &str) -> Result<()> {
353        let package = root.join("node_modules/prettier");
354
355        directory::ensure(&package).or_fail()?;
356        file::write_text(
357            &package.join("package.json"),
358            r#"{"name":"prettier","type":"module","exports":"./index.mjs"}"#,
359        )
360        .or_fail()?;
361
362        file::write_text(&package.join("index.mjs"), module).or_fail()
363    }
364
365    fn formatter(root: &Path, module: &str) -> Result<Formatter> {
366        install_formatter(root, module)?;
367
368        Formatter::load(root, &[]).or_fail()
369    }
370
371    fn input_larger_than_a_pipe_buffer() -> String {
372        "input".repeat(256 * 1024)
373    }
374
375    #[gtest]
376    fn missing_pinned_formatter_is_an_error() -> Result<()> {
377        let directory = Directory::new().or_fail()?;
378        let error = Formatter::load(directory.path(), &[]).unwrap_err();
379
380        verify_that!(
381            error.to_string(),
382            contains_substring("workspace-pinned Prettier is missing")
383        )
384    }
385
386    #[gtest]
387    fn formatter_failure_preserves_prettier_diagnostic() -> Result<()> {
388        let directory = Directory::new().or_fail()?;
389        let formatter = formatter(
390            directory.path(),
391            "export async function resolveConfig() { return null; }\n\
392             export async function format() { throw new Error('fixture formatter rejected input'); }\n",
393        )?;
394
395        let error = formatter
396            .format("input", Path::new("docs/README.md"))
397            .unwrap_err();
398
399        verify_that!(
400            error.to_string(),
401            contains_substring("fixture formatter rejected input")
402        )
403    }
404
405    #[gtest]
406    fn formatter_keeps_one_worker_alive_across_requests() -> Result<()> {
407        let directory = Directory::new().or_fail()?;
408        let formatter = formatter(
409            directory.path(),
410            "let calls = 0;\n\
411             export async function resolveConfig() { return null; }\n\
412             export async function format(contents) { calls += 1; return `${calls}:${contents}`; }\n",
413        )?;
414        let input = input_larger_than_a_pipe_buffer();
415        let first = formatter
416            .format(&input, Path::new("docs/README.md"))
417            .or_fail()?;
418        let second = formatter
419            .format("second", Path::new("docs/SECOND.md"))
420            .or_fail()?;
421
422        verify_true!(first.starts_with("1:input"))?;
423
424        verify_that!(second, eq("2:second"))
425    }
426}