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#[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#[derive(Debug)]
109pub struct Formatter {
110 workspace_root: PathBuf,
111 identity: Option<Checksum>,
112 worker: Mutex<Option<Worker>>,
113}
114
115impl Formatter {
116 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 #[must_use]
143 pub const fn identity(&self) -> Option<Checksum> {
144 self.identity
145 }
146
147 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}