Set up literate programming structure

This commit is contained in:
Jon Staab
2026-04-07 09:55:41 -07:00
commit 2d36400ba0
16 changed files with 933 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
use pulldown_cmark::{Event, Parser, Tag, TagEnd, CodeBlockKind};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn extract_file_attr(info_string: &str) -> Option<&str> {
// Parse info strings like "rust {file=coracle-lib/src/event.rs}"
let brace_start = info_string.find('{')?;
let brace_end = info_string.find('}')?;
let attrs = &info_string[brace_start + 1..brace_end];
for attr in attrs.split(',') {
let attr = attr.trim();
if let Some(path) = attr.strip_prefix("file=") {
return Some(path.trim());
}
}
None
}
fn tangle(book_dir: &Path, output_base: &Path) {
let mut files: BTreeMap<PathBuf, String> = BTreeMap::new();
// Collect all .md files sorted by name
let mut md_files: Vec<PathBuf> = fs::read_dir(book_dir)
.expect("Failed to read book directory")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
md_files.sort();
for md_path in &md_files {
let content = fs::read_to_string(md_path)
.unwrap_or_else(|_| panic!("Failed to read {}", md_path.display()));
let parser = Parser::new(&content);
let mut current_file: Option<PathBuf> = None;
let mut in_code_block = false;
for event in parser {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
let info_str = info.as_ref();
if let Some(file_path) = extract_file_attr(info_str) {
current_file = Some(output_base.join(file_path));
in_code_block = true;
}
}
Event::Text(text) if in_code_block => {
if let Some(ref path) = current_file {
files.entry(path.clone())
.or_default()
.push_str(text.as_ref());
}
}
Event::End(TagEnd::CodeBlock) => {
if in_code_block {
// Add a newline between concatenated blocks
if let Some(ref path) = current_file {
files.entry(path.clone())
.or_default()
.push('\n');
}
current_file = None;
in_code_block = false;
}
}
_ => {}
}
}
}
if files.is_empty() {
println!("No tangled files found.");
return;
}
for (path, content) in &files {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|_| panic!("Failed to create directory {}", parent.display()));
}
fs::write(path, content)
.unwrap_or_else(|_| panic!("Failed to write {}", path.display()));
println!(" tangled: {}", path.display());
}
println!("\n{} files tangled.", files.len());
}
fn weave(book_dir: &Path, output_dir: &Path) {
// Preprocess markdown files: transform {file=...} annotations into
// readable captions for mdBook, writing to a temp directory.
let staging = output_dir.join("book-staging");
if staging.exists() {
fs::remove_dir_all(&staging).expect("Failed to clean staging directory");
}
let staging_src = staging.join("src");
fs::create_dir_all(&staging_src).expect("Failed to create staging src directory");
for entry in fs::read_dir(book_dir)
.expect("Failed to read book directory")
.filter_map(|e| e.ok())
{
let path = entry.path();
let filename = path.file_name().unwrap();
if path.file_name().is_some_and(|n| n == "book.toml") {
// book.toml goes at the staging root
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
let dest = staging.join(filename);
fs::write(&dest, content)
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
} else if path.extension().is_some_and(|ext| ext == "md") {
// Markdown files go into staging/src/
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
let transformed = transform_for_weave(&content);
let dest = staging_src.join(filename);
fs::write(&dest, transformed)
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
}
}
println!("Preprocessed markdown written to {}", staging.display());
println!("Run: mdbook build {}", staging.display());
}
fn transform_for_weave(content: &str) -> String {
let mut result = String::with_capacity(content.len());
for line in content.lines() {
if line.starts_with("```") && line.contains("{file=") {
// Extract language and file path
let lang = line.trim_start_matches('`')
.split_whitespace()
.next()
.unwrap_or("rust");
if let Some(file_path) = extract_file_attr(line) {
result.push_str(&format!("```{lang}\n// -> {file_path}\n"));
} else {
result.push_str(line);
result.push('\n');
}
} else {
result.push_str(line);
result.push('\n');
}
}
result
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (command, book_dir) = match args.len() {
1 => ("tangle", "book"),
2 => (args[1].as_str(), "book"),
3 => (args[1].as_str(), args[2].as_str()),
_ => {
eprintln!("Usage: coracle-tangle [tangle|weave] [book-dir]");
std::process::exit(1);
}
};
let book_path = Path::new(book_dir);
let output_base = Path::new(".");
match command {
"tangle" => tangle(book_path, output_base),
"weave" => weave(book_path, Path::new("target")),
_ => {
eprintln!("Unknown command: {command}. Use 'tangle' or 'weave'.");
std::process::exit(1);
}
}
}