diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 883e5355..a7de9bb4 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -324,7 +324,7 @@ const completionSpec: Fig.Spec = { { name: "--out-file", description: - "File path where the generated Fig spec will be saved", + 'File path where the generated Fig spec will be saved, or "-" for stdout', isRepeatable: false, args: { name: "out_file", @@ -380,7 +380,7 @@ const completionSpec: Fig.Spec = { }, { name: ["-o", "--out-file"], - description: "Output file path (defaults to stdout)", + description: 'Output file path, or "-" for stdout (default)', isRepeatable: false, args: { name: "out_file", @@ -434,7 +434,7 @@ const completionSpec: Fig.Spec = { { name: "--out-file", description: - "Output file path for single-file markdown generation", + 'Output file path for single-file markdown generation, or "-" for stdout (default)', isRepeatable: false, args: { name: "out_file", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index fe006c63..52215452 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -258,7 +258,7 @@ Generate Fig completion spec for Amazon Q / Fig A usage spec taken in as a file, use "\-" to read from stdin .TP \fB\-\-out\-file\fR \fI\fR -File path where the generated Fig spec will be saved +File path where the generated Fig spec will be saved, or "\-" for stdout .TP \fB\-\-spec\fR \fI\fR Raw string spec input @@ -285,7 +285,7 @@ raw string spec input A usage spec taken in as a file, use "\-" to read from stdin .TP \fB\-o, \-\-out\-file\fR \fI\fR -Output file path (defaults to stdout) +Output file path, or "\-" for stdout (default) .TP \fB\-s, \-\-section\fR \fI
\fR Manual section number (default: 1) @@ -315,7 +315,7 @@ Escape HTML in markdown Output markdown files to this directory (required when using \-\-multi) .TP \fB\-\-out\-file\fR \fI\fR -Output file path for single\-file markdown generation +Output file path for single\-file markdown generation, or "\-" for stdout (default) .TP \fB\-\-replace\-pre\-with\-code\-fences\fR Replace `
` tags with markdown code fences
diff --git a/cli/src/cli/generate/fig.rs b/cli/src/cli/generate/fig.rs
index 7d0896a9..0f80bfe8 100644
--- a/cli/src/cli/generate/fig.rs
+++ b/cli/src/cli/generate/fig.rs
@@ -52,7 +52,7 @@ pub struct Fig {
     #[clap(short, long)]
     file: Option,
 
-    /// File path where the generated Fig spec will be saved
+    /// File path where the generated Fig spec will be saved, or "-" for stdout
     #[clap(long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
@@ -355,8 +355,7 @@ impl FigCommand {
 impl Fig {
     pub fn run(&self) -> miette::Result<()> {
         let write = |path: &PathBuf, md: &str| -> miette::Result<()> {
-            println!("writing to {}", path.display());
-            xx::file::write(path, format!("{}\n", md.trim()))?;
+            generate::write_or_stdout(Some(path), &format!("{}\n", md.trim()))?;
             Ok(())
         };
         let spec = generate::file_or_spec(&self.file, &self.spec)?;
@@ -394,6 +393,10 @@ impl Fig {
                 )
             });
 
+        // Only the file path wraps the spec in the prescript/postscript that make it usable
+        // on its own; bare `usage g fig` prints the spec object alone. `--out-file -` follows
+        // the file path, since it means "the bytes a file would have received". Whether the
+        // two should agree is a separate question, left alone here.
         if let Some(path) = &self.out_file {
             let prescript = if let Some(source_file) = &self.file {
                 let source_label = if source_file.as_os_str() == "-" {
diff --git a/cli/src/cli/generate/manpage.rs b/cli/src/cli/generate/manpage.rs
index 797e36be..218c5db5 100644
--- a/cli/src/cli/generate/manpage.rs
+++ b/cli/src/cli/generate/manpage.rs
@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-use super::parse_file_or_stdin;
+use super::{parse_file_or_stdin, write_or_stdout};
 use clap::Args;
 use usage::docs::manpage::ManpageRenderer;
 
@@ -11,7 +11,7 @@ pub struct Manpage {
     #[clap(short, long)]
     file: PathBuf,
 
-    /// Output file path (defaults to stdout)
+    /// Output file path, or "-" for stdout (default)
     #[clap(short, long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
@@ -32,12 +32,7 @@ impl Manpage {
         let renderer = ManpageRenderer::new(spec).with_section(self.section);
         let manpage = renderer.render()?;
 
-        if let Some(out_file) = &self.out_file {
-            println!("writing to {}", out_file.display());
-            xx::file::write(out_file, &manpage)?;
-        } else {
-            print!("{}", manpage);
-        }
+        write_or_stdout(self.out_file.as_deref(), &manpage)?;
 
         Ok(())
     }
diff --git a/cli/src/cli/generate/markdown.rs b/cli/src/cli/generate/markdown.rs
index 6938386f..66455f6b 100644
--- a/cli/src/cli/generate/markdown.rs
+++ b/cli/src/cli/generate/markdown.rs
@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-use super::parse_file_or_stdin;
+use super::{parse_file_or_stdin, write_or_stdout};
 use clap::Args;
 use usage::docs::markdown::MarkdownRenderer;
 
@@ -23,11 +23,11 @@ pub struct Markdown {
     html_encode: bool,
 
     /// Output markdown files to this directory (required when using --multi)
-    #[clap(long, value_hint = clap::ValueHint::DirPath)]
+    #[clap(long, value_hint = clap::ValueHint::DirPath, requires = "multi")]
     out_dir: Option,
 
-    /// Output file path for single-file markdown generation
-    #[clap(long, value_hint = clap::ValueHint::FilePath, required_unless_present = "multi")]
+    /// Output file path for single-file markdown generation, or "-" for stdout (default)
+    #[clap(long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
     /// Replace `
` tags with markdown code fences
@@ -41,15 +41,19 @@ pub struct Markdown {
 
 impl Markdown {
     pub fn run(&self) -> miette::Result<()> {
+        // The banner belongs to every generated document, so build it in one place rather
+        // than once per output path.
+        let render = |md: &str| {
+            format!(
+                "\n{}\n",
+                md.trim()
+            )
+        };
+        // File-only, deliberately: every path this receives is a join onto `--out-dir`, so a
+        // `-` meaning stdout cannot turn up here.
         let write = |path: &PathBuf, md: &str| -> miette::Result<()> {
-            println!("writing to {}", path.display());
-            xx::file::write(
-                path,
-                format!(
-                    "\n{}\n",
-                    md.trim()
-                ),
-            )?;
+            eprintln!("writing to {}", path.display());
+            xx::file::write(path, render(md))?;
             Ok(())
         };
         let spec = parse_file_or_stdin(&self.file)?;
@@ -84,8 +88,7 @@ impl Markdown {
             write(&path_idx, &md_idx)?;
         } else {
             let md = ctx.render_spec()?;
-            let path = self.out_file.as_ref().unwrap();
-            write(path, &md)?;
+            write_or_stdout(self.out_file.as_deref(), &render(&md))?;
         }
         Ok(())
     }
diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs
index 38d6b5e5..131750d2 100644
--- a/cli/src/cli/generate/mod.rs
+++ b/cli/src/cli/generate/mod.rs
@@ -1,4 +1,4 @@
-use std::io::Read;
+use std::io::{Read, Write};
 use std::path::{Path, PathBuf};
 use usage::error::UsageErr;
 
@@ -65,6 +65,45 @@ pub fn parse_file_or_stdin(file: &Path) -> Result {
     }
 }
 
+/// The mirror of [`parse_file_or_stdin`]: `-` means stdout, and so does no path at all.
+///
+/// Both spellings are collapsed here so a caller never has to ask which of the two it is
+/// looking at. To write to a file actually named `-`, spell it `./-`.
+///
+/// The progress line goes to stderr. It used to go to stdout, where it ended up inside the
+/// document whenever the document itself was going to stdout.
+pub fn write_or_stdout(out_file: Option<&Path>, contents: &str) -> Result<(), UsageErr> {
+    match out_file {
+        Some(path) if path.as_os_str() != "-" => {
+            eprintln!("writing to {}", path.display());
+            xx::file::write(path, contents)?;
+        }
+        _ => write_stdout(contents)?,
+    }
+    Ok(())
+}
+
+/// Write a generated document to stdout, reporting a failed write rather than panicking.
+///
+/// `print!` panics if the write fails, and these documents are big enough to outlast a pipe
+/// buffer: `usage g markdown -f mise.usage.kdl --out-file - | head -1` produces 100 KB and
+/// used to end in `failed printing to stdout … (os error 109)` and exit 101.
+///
+/// A reader that closed early is not a failure to report, though. Rust ignores `SIGPIPE`, so
+/// what would end the process silently in C arrives here as an ordinary write error; treating
+/// it as success is what makes `| head` behave the way it does everywhere else.
+fn write_stdout(contents: &str) -> Result<(), UsageErr> {
+    let mut stdout = std::io::stdout().lock();
+    let wrote = stdout.write_all(contents.as_bytes()).and_then(|_| {
+        // Explicitly, because the flush at process exit discards whatever it hits.
+        stdout.flush()
+    });
+    match wrote {
+        Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
+        other => Ok(other?),
+    }
+}
+
 fn read_spec_from_stdin() -> Result {
     let mut input = String::new();
     std::io::stdin().read_to_string(&mut input)?;
diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs
index 76949eb6..cbbcee32 100644
--- a/cli/src/cli/generate/sdk.rs
+++ b/cli/src/cli/generate/sdk.rs
@@ -57,7 +57,7 @@ impl Sdk {
 
         for file in &output.files {
             let path = self.output.join(&file.path);
-            println!("writing to {}", path.display());
+            eprintln!("writing to {}", path.display());
             xx::file::write(&path, &file.content)?;
         }
 
diff --git a/cli/tests/manpage.rs b/cli/tests/manpage.rs
index cce4b436..1d305ec1 100644
--- a/cli/tests/manpage.rs
+++ b/cli/tests/manpage.rs
@@ -97,6 +97,21 @@ fn test_generate_manpage_output_to_file() {
     std::fs::remove_file(&out_file).unwrap();
 }
 
+#[test]
+fn test_generate_manpage_out_file_dash_is_stdout() {
+    let run = |args: &[&str]| {
+        let mut cmd = usage_cmd();
+        cmd.args(["generate", "manpage", "-f"]);
+        cmd.arg(example_path("basic.usage.kdl"));
+        cmd.args(args);
+        let output = cmd.output().unwrap();
+        assert!(output.status.success());
+        String::from_utf8(output.stdout).unwrap()
+    };
+
+    assert_eq!(run(&[]), run(&["-o", "-"]));
+}
+
 #[test]
 fn test_manpage_output_first_50_lines() {
     // Test first 50 lines of mise manpage to avoid huge snapshot
diff --git a/cli/tests/markdown.rs b/cli/tests/markdown.rs
index b8621a85..ec47e622 100644
--- a/cli/tests/markdown.rs
+++ b/cli/tests/markdown.rs
@@ -177,7 +177,8 @@ fn test_generate_markdown_basic() {
         out_file.to_str().unwrap(),
     ]);
 
-    cmd.assert().success();
+    let output = cmd.output().unwrap();
+    assert!(output.status.success());
 
     // Verify file was created
     assert!(out_file.exists());
@@ -186,25 +187,65 @@ fn test_generate_markdown_basic() {
     let content = fs::read_to_string(&out_file).unwrap();
     assert!(content.contains("# `basic.usage.kdl`"));
 
+    // The progress line belongs on stderr; on stdout it lands inside the document whenever
+    // the document is going to stdout.
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let stderr = String::from_utf8(output.stderr).unwrap();
+    assert!(!stdout.contains("writing to"), "stdout was: {stdout:?}");
+    assert!(stderr.contains("writing to"), "stderr was: {stderr:?}");
+
     // Clean up
     std::fs::remove_file(&out_file).unwrap();
 }
 
+fn markdown_stdout(args: &[&str]) -> String {
+    let mut cmd = usage_cmd();
+    cmd.args(["generate", "markdown", "-f"]);
+    cmd.arg(example_path("with-examples.usage.kdl"));
+    cmd.args(args);
+
+    let output = cmd.output().unwrap();
+    assert!(output.status.success());
+    String::from_utf8(output.stdout).unwrap()
+}
+
 #[test]
 fn test_markdown_snapshot_with_examples() {
+    insta::assert_snapshot!(markdown_stdout(&["--out-file", "-"]));
+}
+
+#[test]
+fn test_markdown_stdout_when_out_file_omitted() {
+    // The two spellings of "stdout" have to agree; `--out-file -` exists for callers that
+    // build the path in a variable.
+    assert_eq!(markdown_stdout(&[]), markdown_stdout(&["--out-file", "-"]));
+}
+
+#[test]
+fn test_markdown_out_file_dash_writes_no_file() {
+    // The bug this guards: `--out-file` was resolved as a path unconditionally, and
+    // `xx::file::write` creates the parent directory before writing. `/dev/stdout` therefore
+    // produced a real `C:\dev\stdout` on Windows; `-` would likewise leave a file named `-`.
+    let dir = std::env::temp_dir().join(format!("usage_md_dash_{}", std::process::id()));
+    let _ = fs::remove_dir_all(&dir);
+    fs::create_dir_all(&dir).unwrap();
+
     let mut cmd = usage_cmd();
-    cmd.args([
+    cmd.current_dir(&dir).args([
         "generate",
         "markdown",
         "-f",
         &example_path("with-examples.usage.kdl"),
         "--out-file",
-        "/dev/stdout",
+        "-",
     ]);
+    cmd.assert().success();
 
-    let output = cmd.output().unwrap();
-    assert!(output.status.success());
+    let leftovers: Vec<_> = fs::read_dir(&dir)
+        .unwrap()
+        .map(|e| e.unwrap().path())
+        .collect();
+    assert!(leftovers.is_empty(), "wrote files: {leftovers:?}");
 
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    insta::assert_snapshot!(stdout);
+    fs::remove_dir_all(&dir).unwrap();
 }
diff --git a/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap b/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
index dab6e235..65cbdc0c 100644
--- a/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
+++ b/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
@@ -1,8 +1,7 @@
 ---
 source: cli/tests/markdown.rs
-expression: stdout
+expression: "markdown_stdout(&[\"--out-file\", \"-\"])"
 ---
-writing to /dev/stdout
 
 # `demo`
 
diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl
index 7346ee54..0dbe7329 100644
--- a/cli/usage.usage.kdl
+++ b/cli/usage.usage.kdl
@@ -124,7 +124,7 @@ You may need to set this if you have a different bin named "usage"
         flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" {
             arg 
         }
-        flag --out-file help="File path where the generated Fig spec will be saved" effect=write {
+        flag --out-file help="File path where the generated Fig spec will be saved, or \"-\" for stdout" effect=write {
             arg 
         }
         flag --spec help="Raw string spec input" {
@@ -144,7 +144,7 @@ You may need to set this if you have a different bin named "usage"
         flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" required=#true {
             arg 
         }
-        flag "-o --out-file" help="Output file path (defaults to stdout)" effect=write {
+        flag "-o --out-file" help="Output file path, or \"-\" for stdout (default)" effect=write {
             arg 
         }
         flag "-s --section" help="Manual section number (default: 1)" default="1" {
@@ -166,7 +166,7 @@ Common sections: - 1: User commands - 5: File formats - 7: Miscellaneous - 8: Sy
         flag --out-dir help="Output markdown files to this directory (required when using --multi)" effect=write {
             arg 
         }
-        flag --out-file help="Output file path for single-file markdown generation" effect=write {
+        flag --out-file help="Output file path for single-file markdown generation, or \"-\" for stdout (default)" effect=write {
             arg 
         }
         flag --replace-pre-with-code-fences help="Replace `
` tags with markdown code fences"
diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json
index 41f3b5c0..c31e4a7d 100644
--- a/docs/cli/reference/commands.json
+++ b/docs/cli/reference/commands.json
@@ -473,8 +473,8 @@
               {
                 "name": "out-file",
                 "usage": "--out-file ",
-                "help": "File path where the generated Fig spec will be saved",
-                "help_first_line": "File path where the generated Fig spec will be saved",
+                "help": "File path where the generated Fig spec will be saved, or \"-\" for stdout",
+                "help_first_line": "File path where the generated Fig spec will be saved, or \"-\" for stdout",
                 "short": [],
                 "long": ["out-file"],
                 "hide": false,
@@ -592,8 +592,8 @@
               {
                 "name": "out-file",
                 "usage": "-o --out-file ",
-                "help": "Output file path (defaults to stdout)",
-                "help_first_line": "Output file path (defaults to stdout)",
+                "help": "Output file path, or \"-\" for stdout (default)",
+                "help_first_line": "Output file path, or \"-\" for stdout (default)",
                 "short": ["o"],
                 "long": ["out-file"],
                 "hide": false,
@@ -700,8 +700,8 @@
               {
                 "name": "out-file",
                 "usage": "--out-file ",
-                "help": "Output file path for single-file markdown generation",
-                "help_first_line": "Output file path for single-file markdown generation",
+                "help": "Output file path for single-file markdown generation, or \"-\" for stdout (default)",
+                "help_first_line": "Output file path for single-file markdown generation, or \"-\" for stdout (default)",
                 "short": [],
                 "long": ["out-file"],
                 "hide": false,
diff --git a/docs/cli/reference/generate/fig.md b/docs/cli/reference/generate/fig.md
index 18d90c6b..34d306fd 100644
--- a/docs/cli/reference/generate/fig.md
+++ b/docs/cli/reference/generate/fig.md
@@ -18,7 +18,7 @@ A usage spec taken in as a file, use "-" to read from stdin
 
 **Effect**: modifies state
 
-File path where the generated Fig spec will be saved
+File path where the generated Fig spec will be saved, or "-" for stdout
 
 ### `--spec `
 
diff --git a/docs/cli/reference/generate/manpage.md b/docs/cli/reference/generate/manpage.md
index fc3821dc..7f0d4e27 100644
--- a/docs/cli/reference/generate/manpage.md
+++ b/docs/cli/reference/generate/manpage.md
@@ -17,7 +17,7 @@ A usage spec taken in as a file, use "-" to read from stdin
 
 **Effect**: modifies state
 
-Output file path (defaults to stdout)
+Output file path, or "-" for stdout (default)
 
 ### `-s --section 
` diff --git a/docs/cli/reference/generate/markdown.md b/docs/cli/reference/generate/markdown.md index aa06f74a..508a7f78 100644 --- a/docs/cli/reference/generate/markdown.md +++ b/docs/cli/reference/generate/markdown.md @@ -33,7 +33,7 @@ Output markdown files to this directory (required when using --multi) **Effect**: modifies state -Output file path for single-file markdown generation +Output file path for single-file markdown generation, or "-" for stdout (default) ### `--replace-pre-with-code-fences`