diff options
Diffstat (limited to 'mingling_ci/src/markdown/project.rs')
| -rw-r--r-- | mingling_ci/src/markdown/project.rs | 347 |
1 files changed, 347 insertions, 0 deletions
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 +} |
