aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src/markdown/project.rs
blob: d781b7eeb6a853cbce0c1ad118c7611babd0de8e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
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
}