Skip to content

Instantly share code, notes, and snippets.

@steveklabnik
Last active February 6, 2025 22:16
Show Gist options
  • Save steveklabnik/589ef02419032e55a070ef6c60c423c6 to your computer and use it in GitHub Desktop.
Save steveklabnik/589ef02419032e55a070ef6c60c423c6 to your computer and use it in GitHub Desktop.
An {#trycmdinclude} for mdbook (based on work by @bryceberger)

This is a little thing I put together to let you write mdbooks where you can test the output of CLI code examples with trycmd.

I've provided an example .trycmd, mdbook chapter page, and the mdbook plugin. Please note that the example .trycmd has a bunch of odd stuff in it; I'm using this for a jj tutorial, and you need to do certain things to ensure reproducibility there.

I'm not making this into a github repo yet because there's a few caveats and i want you to understand them before you give it a go:

  1. this, like trycmd generally, executes whatever you tell it to in a tempdir. Don't try to colorize the output of rm -rf / --no-preserve-root and then get mad at me.
  2. my code is bad. i put it together last night and this morning. no error handling, debug lines still in there, i didn't even run rustfmt or clippy.
  3. unlike {#include in rustdoc, the syntax highlighting can add lines, so the lines don't correspond to the .trycmd directly. It'll take some trial and error to get them right.
  4. other code is good. I based some of this off of @bryceberger's work here http://github.com/bryceberger/mdbook-jj-example/ It would have taken me a while to do this without looking at Bryce's project. Thank you Bryce!
  5. the color choices are the default from ansi_to_html, which means they may look kinda janky depending on what colors and what theme you are using. this is probably fixable, but see #2 for now.

Eventually I'll clean this up and put it on github in case you want to use it, but for now, at least you can this way!

Chapter 1

So first we jj git init:

$ jj git init
Initialized repo in "."

And then we jj new:

{{#trycmdinclude tests/tests/cmd/jj-new.trycmd:6:9}}

Let's take a look at jj log:

{{#trycmdinclude tests/tests/cmd/jj-new.trycmd:11:19}}

```
$ JJ_RANDOMNESS_SEED=12345 jj init
Initialized repo in "."
$ jj config set --repo debug.randomness-seed 12346
$ jj config set --repo ui.color always
$ jj new
Working copy now at: wsxvoskz e5fb0b9a (empty) (no description set)
Parent commit : snwusnyo 785eadda (empty) (no description set)
$ jj config set --repo debug.randomness-seed 12347
$ jj log
@ wsxvoskz [email protected] 2025-02-05 16:43:34 e5fb0b9a
│ (empty) (no description set)
○ snwusnyo [email protected] 2025-02-05 16:43:34 785eadda
│ (empty) (no description set)
◆ zzzzzzzz root() 00000000
```
use std::{
collections::HashMap, fs, io, process::{Command, Stdio}, sync::{LazyLock, Mutex}
};
use mdbook::{BookItem, errors::Result, preprocess::CmdPreprocessor};
use regex::Regex;
use tempfile::TempDir;
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
Some("supports") => {
// Supports all renderers.
return Ok(());
}
Some(arg) => {
eprintln!("unknown argument: {arg}");
std::process::exit(1);
}
None => {}
}
let (_ctx, mut book) = CmdPreprocessor::parse_input(io::stdin().lock())?;
book.for_each_mut(|item| {
let BookItem::Chapter(chapter) = item else {
return;
};
match run_examples(&chapter.content) {
Ok(new_content) => chapter.content = new_content,
Err(e) => eprintln!("could not process chapter: {e}"),
}
});
serde_json::to_writer(io::stdout().lock(), &book)?;
Ok(())
}
struct Cache {
inner: LazyLock<Mutex<HashMap<String, String>>>,
}
static CACHE: Cache = Cache { inner: LazyLock::new(|| Mutex::new(HashMap::new())) };
impl Cache {
fn render(&self, key: &str) -> String {
let mut map = self.inner.lock().unwrap();
map.entry(key.to_string()).or_insert_with(|| {
let contents = fs::read_to_string(key).unwrap();
let contents: String = contents.lines().filter(|line| line.starts_with("$ ")).collect::<Vec<&str>>().join("\n");
eprintln!("contents====");
eprintln!("{}", contents);
eprintln!("contents====");
// let mut rendered = String::from("\n<pre><code class'language-bash hljs'>");
let mut rendered = String::new();
let dir = TempDir::new().unwrap();
for command in contents.lines() {
let command = command.strip_prefix('$').unwrap();
eprintln!("about to run {command}");
let output = Command::new("bash")
.current_dir(&dir)
.arg("-c")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
eprintln!("{}", String::from_utf8(output.stdout.clone()).unwrap());
let render = |s|
ansi_to_html::convert(&String::from_utf8(s).unwrap()).unwrap();
let stdout = render(output.stdout);
let stderr = render(output.stderr);
eprintln!("stdout: {stdout}");
rendered.push('$');
rendered.push_str(command);
rendered.push('\n');
rendered.push_str(&stdout);
rendered.push_str(&stderr);
if !stdout.is_empty() || !stderr.is_empty() {
rendered.push('\n');
}
}
// rendered.push_str("</code></pre>");
eprintln!("rendered====");
eprintln!("{}", rendered);
eprintln!("rendered====");
rendered
}).to_string()
}
}
fn run_examples(content: &str) -> Result<String> {
let mut buf = content.to_string();
let regex = Regex::new(r#"\{\{#trycmdinclude ([\w\/.\-]+):(\d+)(?::(\d+))?\}\}"#).unwrap();
let mut matches: Vec<_> = regex.captures_iter(content).map(|cap| {
let m = cap.get(0).unwrap();
let path = cap.get(1).unwrap();
Match {
contents: CACHE.render(path.as_str()),
pos_start: m.start(),
pos_end: m.end(),
start: cap.get(2).map(|c| c.as_str().parse().unwrap()),
end: cap.get(3).map(|c| c.as_str().parse().unwrap()),
}
}).collect();
eprintln!("{matches:?}");
replace_matches(&mut buf, &mut matches)?;
eprintln!("buf: {buf}");
Ok(buf)
}
#[derive(Debug)]
struct Match {
contents: String,
pos_start: usize,
pos_end: usize,
start: Option<u64>,
end: Option<u64>,
}
impl Match {
fn get_replacement(&self) -> io::Result<String> {
let extracted = self.contents
.lines()
.enumerate()
.filter_map(|(i, line)| {
let line_num = i as u64 + 1;
if let Some(start) = self.start {
if let Some(end) = self.end {
if line_num < start || line_num > end {
return None;
}
} else if line_num != start {
return None;
}
}
Some(line)
})
.collect::<Vec<_>>()
.join("\n");
Ok(extracted)
}
}
fn replace_matches(input: &mut String, matches: &mut Vec<Match>) -> io::Result<()> {
// Sort matches by `pos_start` in descending order to avoid index shifts
matches.sort_by(|a, b| b.pos_start.cmp(&a.pos_start));
for m in matches {
let replacement = m.get_replacement()?;
let replacement = format!("<pre><code class='language-bash hljs'>{replacement}</code></pre>");
eprintln!("going to replace {}", &input[m.pos_start..m.pos_end]);
eprintln!("with: {}", replacement);
input.replace_range(m.pos_start..m.pos_end, &replacement);
}
Ok(())
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment