diff options
Diffstat (limited to 'mingling_ci/src/markdown')
| -rw-r--r-- | mingling_ci/src/markdown/compare.rs | 203 | ||||
| -rw-r--r-- | mingling_ci/src/markdown/project.rs | 347 | ||||
| -rw-r--r-- | mingling_ci/src/markdown/test.rs | 152 |
3 files changed, 702 insertions, 0 deletions
diff --git a/mingling_ci/src/markdown/compare.rs b/mingling_ci/src/markdown/compare.rs new file mode 100644 index 0000000..1bf3c57 --- /dev/null +++ b/mingling_ci/src/markdown/compare.rs @@ -0,0 +1,203 @@ +//! Structural comparison of markdown docs (reference vs translation). +//! +//! For each file pair the comparison uses a *structural signature*: one token +//! per line, classifying headings (both Markdown `#` and HTML `<hN>`), fenced +//! code blocks (including their language tag), `@@@` hidden-compilation lines, +//! blank lines, blockquotes, lists and plain text. Translated text is allowed +//! to differ; the structure is not. + +use std::path::{Path, PathBuf}; + +/// Collects all `.md` files under `dir`, returned relative to it. +pub(crate) fn collect_md_files(dir: &Path) -> Vec<PathBuf> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "md") { + out.push(path.strip_prefix(dir).unwrap_or(&path).to_path_buf()); + } + } + } + out.sort(); + out +} + +/// Compares the structural signatures of two markdown files. +/// +/// Returns the human-readable diff lines (up to a small window) on the first +/// structural difference. +pub(crate) fn compare_signature(ref_path: &Path, lang_path: &Path) -> Result<(), Vec<String>> { + let ref_content = std::fs::read_to_string(ref_path).unwrap_or_default(); + let lang_content = std::fs::read_to_string(lang_path).unwrap_or_default(); + + let ref_sig = signature_of(&ref_content); + let lang_sig = signature_of(&lang_content); + + if ref_sig == lang_sig { + return Ok(()); + } + + let ref_lines: Vec<&str> = ref_content.lines().collect(); + let lang_lines: Vec<&str> = lang_content.lines().collect(); + + let mut diffs = Vec::new(); + let mut window = 0; + let max = ref_sig.len().max(lang_sig.len()); + for i in 0..max { + let ref_tok = ref_sig.get(i); + let lang_tok = lang_sig.get(i); + if ref_tok == lang_tok { + continue; + } + if window >= 5 { + diffs.push(format!("... ({}-line window truncated)", max - i)); + break; + } + window += 1; + let ref_line = ref_lines.get(i).copied().unwrap_or("<missing>"); + let lang_line = lang_lines.get(i).copied().unwrap_or("<missing>"); + diffs.push(format!("line {}", i + 1)); + diffs.push(format!( + "expect `{}` {}", + token_label(ref_tok.map_or("<eof>", String::as_str)), + display_line(ref_line) + )); + diffs.push(format!( + "found `{}` {}", + token_label(lang_tok.map_or("<eof>", String::as_str)), + display_line(lang_line) + )); + if ref_sig.len() != lang_sig.len() && window >= 5 { + diffs.push(format!( + "note: reference has {} lines, translation has {} lines", + ref_sig.len(), + lang_sig.len() + )); + break; + } + } + if diffs.is_empty() { + diffs.push("signatures differ in length (see line count note)".to_string()); + } + Err(diffs) +} + +/// Builds the structural signature of a markdown file. +fn signature_of(content: &str) -> Vec<String> { + let mut sig = Vec::new(); + let mut in_fence = false; + let mut fence_lang = String::new(); + + for raw_line in content.lines() { + let line = raw_line.trim(); + + if in_fence { + if line.starts_with("```") { + in_fence = false; + sig.push(format!("F:{fence_lang}")); + } else if line.starts_with("@@@") { + sig.push("A".to_string()); + } else if line.is_empty() { + sig.push("B".to_string()); + } else { + sig.push("P".to_string()); + } + continue; + } + + if line.starts_with("```") { + in_fence = true; + fence_lang = line.trim_start_matches("```").trim().to_string(); + sig.push(format!("F:{fence_lang}")); + } else if line.starts_with('#') { + let level = line.chars().take_while(|c| *c == '#').count(); + sig.push(format!("H{level}")); + } else if line.starts_with("<h") || line.starts_with("</h") { + // HTML headings (e.g. `<h1 align="center">` / `</h1>`) + let level = line + .trim_start_matches(['<', '/']) + .chars() + .next() + .and_then(|c| c.to_digit(10)) + .unwrap_or(1); + sig.push(format!("H{level}")); + } else if line.starts_with("@@@") { + sig.push("A".to_string()); + } else if line.is_empty() { + sig.push("B".to_string()); + } else if line.starts_with('>') { + sig.push("Q".to_string()); + } else if is_list_line(line) { + sig.push("L".to_string()); + } else { + sig.push("P".to_string()); + } + } + sig +} + +/// Human-readable label for a structural token. +fn token_label(token: &str) -> String { + match token { + "B" => "blank".to_string(), + "A" => "@@@".to_string(), + "Q" => "quote".to_string(), + "L" => "list".to_string(), + "P" => "text".to_string(), + t if t.starts_with('H') => format!("heading-{}", &t[1..]), + t if t.starts_with("F:") => { + let lang = &t[2..]; + if lang.is_empty() { + "fence".to_string() + } else { + format!("fence:{lang}") + } + } + _ => token.to_string(), + } +} + +/// Renders a source line for display: blank lines become `<blank>`. +fn display_line(line: &str) -> String { + if line.trim().is_empty() { + "<blank>".to_string() + } else { + truncate(line) + } +} + +fn truncate(line: &str) -> String { + const MAX: usize = 60; + if line.chars().count() <= MAX { + line.to_string() + } else { + let cut: String = line.chars().take(MAX).collect(); + format!("{cut}...") + } +} + +fn is_list_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("- ") + || trimmed.starts_with("* ") + || trimmed.starts_with("+ ") + || is_numbered_list(trimmed) +} + +/// A numbered list item: `1. text`, `1) text`, `10. text`, ... +fn is_numbered_list(line: &str) -> bool { + let digit_count = line.chars().take_while(char::is_ascii_digit).count(); + if digit_count == 0 { + return false; + } + let rest = &line[digit_count..]; + (rest.starts_with(". ") || rest.starts_with(") ")) + && rest.chars().nth(1).is_some_and(|c| c == ' ' || c == '\t') +} diff --git a/mingling_ci/src/markdown/project.rs b/mingling_ci/src/markdown/project.rs new file mode 100644 index 0000000..d781b7e --- /dev/null +++ b/mingling_ci/src/markdown/project.rs @@ -0,0 +1,347 @@ +//! Model of a testable rust code block extracted from markdown: its dependency +//! configuration (features + deps) and the code itself. + +use std::fmt::Write as _; +use std::path::Path; + +/// A single testable `rust` code block, modeled as a test project. +pub(crate) struct MarkdownTestProject { + pub features: Vec<String>, + pub deps: Vec<(String, String)>, + pub code: String, + pub is_build_time: bool, + pub has_main: bool, + pub has_gen_program: bool, + pub source_file: String, + pub line: usize, +} + +impl MarkdownTestProject { + /// FNV-1a 64-bit hash over the dependency configuration (features + deps). + /// + /// Blocks with the same hash share one temporary crate and avoid redundant + /// recompilation. The input is sorted so the hash is stable. + #[must_use] + pub fn compute_hash(&self) -> String { + let mut features: Vec<&str> = self.features.iter().map(String::as_str).collect(); + features.sort_unstable(); + let mut dep_names: Vec<&str> = self.deps.iter().map(|(n, _)| n.as_str()).collect(); + dep_names.sort_unstable(); + let mut dep_versions: Vec<&str> = self.deps.iter().map(|(_, v)| v.as_str()).collect(); + dep_versions.sort_unstable(); + let mut deps: Vec<String> = self.deps.iter().map(|(n, v)| format!("{n}={v}")).collect(); + deps.sort(); + + let canonical = format!( + "{}\n{}\n{}\n{}", + features.join(","), + dep_names.join(","), + dep_versions.join(","), + deps.join(",") + ); + + // FNV-1a 64-bit — stable across runs (no random seed). + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &byte in canonical.as_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") + } +} + +/// Parses all fenced `rust` blocks from markdown content. +/// +/// Blocks marked `// NOT VERIFIED` are skipped. +pub(crate) fn parse_markdown(content: &str, source_file: &str) -> Vec<MarkdownTestProject> { + let mut projects = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + let mut i = 0; + while i < lines.len() { + if lines[i].trim() == "```rust" { + if let Some(proj) = parse_block(&lines, i, source_file) { + projects.push(proj); + } + while i < lines.len() && lines[i].trim() != "```" { + i += 1; + } + } + i += 1; + } + projects +} + +/// Parses a single code block starting at a `rust` fence line. +fn parse_block(lines: &[&str], start: usize, source_file: &str) -> Option<MarkdownTestProject> { + let mut code_lines: Vec<String> = Vec::new(); + let mut features: Vec<String> = Vec::new(); + let mut not_verified = false; + let mut deps: Vec<(String, String)> = Vec::new(); + let mut has_main = false; + let mut has_gen_program = false; + let mut is_build_time = false; + + let mut idx = start + 1; + let mut in_header = true; + + while idx < lines.len() { + let raw_line = lines[idx]; + let trimmed = raw_line.trim(); + + if trimmed == "```" { + break; + } + + // `@@@` lines: hidden in the rendered docs (filtered by a docsify + // plugin) but must still compile. + if let Some(stripped) = trimmed.strip_prefix("@@@") { + in_header = false; + let code = stripped.trim_start(); + if code.contains("fn main") { + has_main = true; + } + if code.contains("gen_program!") { + has_gen_program = true; + } + code_lines.push(code.to_string()); + idx += 1; + continue; + } + + if in_header && trimmed == "// NOT VERIFIED" { + not_verified = true; + idx += 1; + continue; + } + if in_header && trimmed == "// BUILD TIME" { + is_build_time = true; + idx += 1; + continue; + } + if in_header && trimmed.starts_with("// ") { + if let Some(feat_str) = trimmed.strip_prefix("// Features:") { + let feat_str = feat_str.trim(); + if feat_str.starts_with('[') && feat_str.ends_with(']') { + let inner = &feat_str[1..feat_str.len() - 1]; + if !inner.is_empty() { + features = inner + .split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + idx += 1; + continue; + } + if trimmed == "// Dependencies:" { + idx += 1; + while idx < lines.len() { + let next = lines[idx].trim(); + if next == "```" { + break; + } + if let Some(dep_line) = next.strip_prefix("// ") { + if let Some((name, ver)) = dep_line.split_once(" = ") { + deps.push(( + name.trim().to_string(), + ver.trim().trim_matches('"').to_string(), + )); + } + idx += 1; + } else { + break; + } + } + continue; + } + } + + in_header = false; + if raw_line.contains("fn main") { + has_main = true; + } + if raw_line.contains("gen_program!") { + has_gen_program = true; + } + code_lines.push(raw_line.to_string()); + idx += 1; + } + + if code_lines.is_empty() || not_verified { + return None; + } + + Some(MarkdownTestProject { + features, + deps, + code: code_lines.join("\n"), + is_build_time, + has_main, + has_gen_program, + source_file: source_file.to_string(), + line: start + 1, + }) +} + +/// Builds the extra `[dependencies]` entries declared by a block's +/// `// Dependencies:` header comments. +/// +/// Markdown blocks declare companion crates like this: +/// +/// ```text +/// // Dependencies: +/// // serde = "1" +/// // clap = "4" +/// // tokio = { version = "1", features = ["full"] } +/// ``` +/// +/// Each `name = value` pair becomes one dependency of the generated test +/// crate (in addition to `mingling` itself), so doc blocks can freely use +/// external crates without repeating the whole manifest. +/// +/// # Special case: serde / clap +/// +/// Doc blocks pervasively derive serialization and argument parsing: +/// structural-renderer examples use `#[derive(Serialize)]`, the clap examples +/// use `#[derive(Parser)]` — and those derives live behind the `derive` +/// feature of `serde` / `clap`. Requiring every block to spell out +/// `// serde = { version = "1", features = ["derive"] }` would be +/// boilerplate repeated dozens of times, so the two crates automatically get +/// `features = ["derive"]` appended. +/// +/// Version values starting with `{` are inline tables (e.g. `tokio` with a +/// `features` list above) and are passed through verbatim — they already +/// carry their own features and must not be rewritten. +fn build_extra_deps(proj: &MarkdownTestProject) -> String { + let mut extra_deps = String::new(); + for (name, version) in &proj.deps { + if version.starts_with('{') { + // Inline table (path/features/…): the block already expressed its + // full dependency, so emit it unchanged. + let _ = writeln!(extra_deps, "{name} = {version}"); + } else if name == "serde" || name == "clap" { + // serde/clap derive: `#[derive(Serialize, Deserialize)]` and + // `#[derive(Parser)]` are used everywhere in the docs; auto-enable + // the `derive` feature to keep blocks terse. + let _ = writeln!( + extra_deps, + "{name} = {{ version = \"{version}\", features = [\"derive\"] }}" + ); + } else { + // Plain `name = "version"`. + let _ = writeln!(extra_deps, "{name} = \"{version}\""); + } + } + extra_deps +} + +/// Generates the `Cargo.toml` for a project. +/// +/// `manifest_path` is used to compute the relative path to the `mingling` crate. +pub(crate) fn generate_cargo_toml(proj: &MarkdownTestProject, manifest_path: &Path) -> String { + let features_str = if proj.features.is_empty() { + String::new() + } else { + let feats: Vec<String> = proj.features.iter().map(|f| format!("\"{f}\"")).collect(); + format!("features = [{}]", feats.join(", ")) + }; + + let extra_deps = build_extra_deps(proj); + + let mingling_path = find_mingling_relative_path(manifest_path); + let deps_section = if proj.features.is_empty() { + format!("[dependencies]\nmingling = {{ path = \"{mingling_path}\" }}\n{extra_deps}") + } else { + format!( + "[dependencies]\nmingling = {{ path = \"{mingling_path}\", {features_str} }}\n{extra_deps}" + ) + }; + + // Build-time projects mirror the features into [build-dependencies] so + // build.rs sees the same feature set. + let build_deps_section = if proj.is_build_time { + let feats: Vec<String> = proj.features.iter().map(|f| format!("\"{f}\"")).collect(); + let build_feats = if feats.is_empty() { + String::new() + } else { + format!("features = [{}]", feats.join(", ")) + }; + format!( + "\n[build-dependencies]\nmingling = {{ path = \"{mingling_path}\", {build_feats} }}\n" + ) + } else { + String::new() + }; + + format!( + r#"[package] + name = "test-doc" + version = "0.0.0" + edition = "2024" + +{deps_section}{build_deps_section} +[workspace] +"# + ) +} + +/// Computes the relative path from a manifest's parent directory to `mingling`. +/// +/// The process current directory is expected to be the project root. +fn find_mingling_relative_path(manifest_path: &Path) -> String { + let manifest_dir = manifest_path + .parent() + .expect("manifest path has no parent directory"); + let cwd = std::env::current_dir().expect("failed to get current directory"); + + let relative_to_root = manifest_dir.strip_prefix(&cwd).unwrap_or(manifest_dir); + let depth = relative_to_root.components().count(); + + let mut result = String::new(); + for _ in 0..depth { + result.push_str("../"); + } + result.push_str("mingling"); + result +} + +/// Generates `main.rs` for a project. +/// +/// Automatically prepends `use mingling::prelude::*;` and appends `fn main() {}` +/// and `gen_program!()` when the block does not provide them. +pub(crate) fn generate_main_rs(proj: &MarkdownTestProject) -> String { + let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); + + if !proj.code.contains("use mingling::prelude::*;") { + output.push_str("#[allow(unused_imports)]\nuse mingling::prelude::*;\n\n"); + } + output.push_str(&proj.code); + output.push('\n'); + + if !proj.has_main { + output.push_str("\nfn main() {}\n"); + } + if !proj.has_gen_program { + output.push_str("\nmingling::macros::gen_program!();\n"); + } + output +} + +/// Generates `build.rs` for a build-time project: the code wrapped in +/// `fn main() { }` unless the block already provides one. +pub(crate) fn generate_build_rs(proj: &MarkdownTestProject) -> String { + let mut output = String::from("#![allow(dead_code)]\n#![allow(unused)]\n"); + if proj.has_main { + output.push_str(&proj.code); + } else { + output.push_str("fn main() {\n"); + for line in proj.code.lines() { + output.push_str(" "); + output.push_str(line); + output.push('\n'); + } + output.push_str("}\n"); + } + output +} diff --git a/mingling_ci/src/markdown/test.rs b/mingling_ci/src/markdown/test.rs new file mode 100644 index 0000000..8ecf18d --- /dev/null +++ b/mingling_ci/src/markdown/test.rs @@ -0,0 +1,152 @@ +//! Parallel execution of markdown test projects. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use colored::Colorize; + +use crate::progress::task_progress_bar; + +use super::project::{ + MarkdownTestProject, generate_build_rs, generate_cargo_toml, generate_main_rs, +}; + +/// Temporary root for the generated test crates. +const TEMP_BASE: &str = ".temp/doc-test"; + +/// Outcome of testing one code block. +pub(crate) struct MarkdownBlockOutcome { + pub source_file: String, + pub line: usize, + pub ok: bool, + /// Failure detail; empty when `ok`. + pub output: String, +} + +/// Runs the given projects in parallel. +/// +/// Projects sharing a dependency hash share one temporary crate (written +/// serially within the group); groups run in parallel. Progress is shown on +/// stderr; failures print there too. Returns one outcome per block. +pub(crate) async fn try_test_markdown_project( + projs: Vec<MarkdownTestProject>, +) -> Vec<MarkdownBlockOutcome> { + // Group by dependency hash for crate sharing. + let mut groups: BTreeMap<String, Vec<MarkdownTestProject>> = BTreeMap::new(); + for proj in projs { + groups.entry(proj.compute_hash()).or_default().push(proj); + } + + let total: usize = groups.values().map(Vec::len).sum(); + let pb = task_progress_bar(total, "Testing"); + pb.set_message("blocks"); + + // One blocking task per group; blocks within a group are serial because + // they share the same crate directory. + let mut handles = Vec::new(); + for (hash, blocks) in groups { + let pb = pb.clone(); + handles.push(tokio::task::spawn_blocking(move || { + let crate_dir = PathBuf::from(TEMP_BASE).join(&hash); + let src_dir = crate_dir.join("src"); + let manifest_path = crate_dir.join("Cargo.toml"); + let cargo_toml = generate_cargo_toml(&blocks[0], &manifest_path); + + let mut group_outcomes = Vec::new(); + for proj in &blocks { + let label = format!("{}:{}", proj.source_file, proj.line); + pb.set_message(label.clone()); + + let main_rs = if proj.is_build_time { + generate_build_rs(proj) + } else { + generate_main_rs(proj) + }; + let (ok, err) = build_block( + &src_dir, + &manifest_path, + &cargo_toml, + &main_rs, + proj.is_build_time, + ); + pb.inc(1); + + if !ok { + // Plain stderr: `pb.println` is swallowed on non-TTY (CI). + eprintln!(" {} {label}", "failed".bold().bright_red()); + eprintln!(" {label} FAILED:\n{err}"); + } + group_outcomes.push(MarkdownBlockOutcome { + source_file: proj.source_file.clone(), + line: proj.line, + ok, + output: err, + }); + } + group_outcomes + })); + } + + let mut all_outcomes = Vec::new(); + for handle in handles { + if let Ok(group_outcomes) = handle.await { + all_outcomes.extend(group_outcomes); + } + } + + pb.finish_and_clear(); + all_outcomes +} + +/// Writes the temporary crate files and runs `cargo check`. +/// +/// When `is_build_time` is true, the content goes to `build.rs` with a stub +/// `main.rs`; otherwise it goes to `src/main.rs`. +fn build_block( + src_dir: &Path, + manifest_path: &Path, + cargo_toml: &str, + content: &str, + is_build_time: bool, +) -> (bool, String) { + if let Err(e) = std::fs::create_dir_all(src_dir) { + return (false, format!("mkdir: {e}")); + } + if let Err(e) = std::fs::write(manifest_path, cargo_toml) { + return (false, format!("write Cargo.toml: {e}")); + } + + if is_build_time { + let crate_dir = manifest_path + .parent() + .expect("manifest path has a parent directory"); + if let Err(e) = std::fs::write(crate_dir.join("build.rs"), content) { + return (false, format!("write build.rs: {e}")); + } + if let Err(e) = std::fs::write(src_dir.join("main.rs"), "fn main() {}\n") { + return (false, format!("write main.rs: {e}")); + } + } else if let Err(e) = std::fs::write(src_dir.join("main.rs"), content) { + return (false, format!("write main.rs: {e}")); + } + + let output = std::process::Command::new("cargo") + .args(["check", "--color=always", "--manifest-path"]) + .arg(manifest_path) + .output(); + match output { + Ok(output) if output.status.success() => (true, String::new()), + Ok(output) => { + let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + let lines: Vec<&str> = log.lines().collect(); + let tail = &lines[lines.len().saturating_sub(20)..]; + let exit = output + .status + .code() + .map_or_else(|| "?".to_string(), |c| c.to_string()); + (false, format!("exit code {exit}\n{}", tail.join("\n"))) + } + Err(e) => (false, format!("failed to run cargo: {e}")), + } +} |
