|
1 | 1 | use std::{ |
2 | | - collections::BTreeSet, |
| 2 | + collections::{BTreeMap, BTreeSet}, |
3 | 3 | fmt::Display, |
4 | 4 | path::{Path, PathBuf}, |
5 | 5 | process::Command, |
@@ -67,31 +67,210 @@ pub fn generate_comment(path: &Path) -> Result<()> { |
67 | 67 | Ok(()) |
68 | 68 | } |
69 | 69 |
|
| 70 | +/// Split markdown content into body text and reference link definitions. |
| 71 | +/// Reference links are lines matching `[label]: URL`. |
| 72 | +fn separate_reference_links(text: &str) -> (String, Vec<String>) { |
| 73 | + let ref_link_re = Regex::new(r"^\[([^\]]+)\]:\s+\S").unwrap(); |
| 74 | + let mut body_lines = Vec::new(); |
| 75 | + let mut ref_links = Vec::new(); |
| 76 | + |
| 77 | + for line in text.lines() { |
| 78 | + if ref_link_re.is_match(line) { |
| 79 | + ref_links.push(line.to_string()); |
| 80 | + } else { |
| 81 | + body_lines.push(line); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + // Trim trailing blank lines from body |
| 86 | + while body_lines.last().map_or(false, |l| l.trim().is_empty()) { |
| 87 | + body_lines.pop(); |
| 88 | + } |
| 89 | + |
| 90 | + (body_lines.join("\n"), ref_links) |
| 91 | +} |
| 92 | + |
| 93 | +/// Rewrite `.md` links to GitHub Pages URLs. |
| 94 | +fn rewrite_md_links(text: &str, timeframe: &str) -> String { |
| 95 | + let link_re = Regex::new(r"\]\((\./)?([^)#]*?)\.md(#[^)]*)?\)").unwrap(); |
| 96 | + |
| 97 | + link_re |
| 98 | + .replace_all(text, |caps: ®ex::Captures| { |
| 99 | + let path_stem = &caps[2]; |
| 100 | + let fragment = caps.get(3).map_or("", |m| m.as_str()); |
| 101 | + |
| 102 | + let clean_path = path_stem.trim_start_matches("../"); |
| 103 | + if clean_path.contains('/') || path_stem.starts_with("../") { |
| 104 | + format!("](https://rust-lang.github.io/rust-project-goals/{clean_path}.html{fragment})") |
| 105 | + } else { |
| 106 | + format!("](https://rust-lang.github.io/rust-project-goals/{timeframe}/{clean_path}.html{fragment})") |
| 107 | + } |
| 108 | + }) |
| 109 | + .to_string() |
| 110 | +} |
| 111 | + |
| 112 | +/// Rewrite .md URLs inside reference link definitions to GitHub Pages URLs. |
| 113 | +fn rewrite_ref_link_urls(ref_links: &mut BTreeMap<String, String>, timeframe: &str) { |
| 114 | + let md_url_re = Regex::new(r"^(\[[^\]]+\]:\s+)(\./)?(\S*?)\.md(\s.*)?$").unwrap(); |
| 115 | + for def in ref_links.values_mut() { |
| 116 | + let def_clone = def.clone(); |
| 117 | + if let Some(caps) = md_url_re.captures(&def_clone) { |
| 118 | + let prefix = &caps[1]; |
| 119 | + let path_stem = &caps[3]; |
| 120 | + let rest = caps.get(4).map_or("", |m| m.as_str()); |
| 121 | + let clean_path = path_stem.trim_start_matches("../"); |
| 122 | + if clean_path.contains('/') || path_stem.starts_with("../") { |
| 123 | + *def = format!("{prefix}https://rust-lang.github.io/rust-project-goals/{clean_path}.html{rest}"); |
| 124 | + } else { |
| 125 | + *def = format!("{prefix}https://rust-lang.github.io/rust-project-goals/{timeframe}/{clean_path}.html{rest}"); |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +/// Inline a rendered markdown file: strip the top heading and adjust all |
| 132 | +/// remaining heading levels relative to the insertion context. |
| 133 | +/// |
| 134 | +/// `context_level` is the heading level at the point of insertion in the |
| 135 | +/// parent document. Headings in the inlined file are shifted so that the |
| 136 | +/// file's top heading level maps to `context_level + 1`. |
| 137 | +fn inline_rendered_file(text: &str, context_level: usize) -> String { |
| 138 | + let heading_re = Regex::new(r"^(#+)\s").unwrap(); |
| 139 | + let mut lines = text.lines(); |
| 140 | + |
| 141 | + // Find and consume the top-level heading, recording its level |
| 142 | + let mut top_level = None; |
| 143 | + for line in &mut lines { |
| 144 | + if let Some(caps) = heading_re.captures(line) { |
| 145 | + top_level = Some(caps[1].len()); |
| 146 | + break; |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + let top_level = top_level.unwrap_or(1); |
| 151 | + // delta: how much to shift remaining headings |
| 152 | + // e.g. if top heading was # (1) and context is # (1), delta = 1 - 1 = 0 |
| 153 | + // e.g. if top heading was # (1) and context is ## (2), delta = 2 - 1 = 1 |
| 154 | + let delta: isize = context_level as isize - top_level as isize; |
| 155 | + |
| 156 | + let mut result = Vec::new(); |
| 157 | + for line in lines { |
| 158 | + if let Some(caps) = heading_re.captures(line) { |
| 159 | + let old_level = caps[1].len() as isize; |
| 160 | + let new_level = (old_level + delta).max(1) as usize; |
| 161 | + let rest = &line[caps[1].len()..]; |
| 162 | + result.push(format!("{}{}", "#".repeat(new_level), rest)); |
| 163 | + } else { |
| 164 | + result.push(line.to_string()); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + // Trim leading blank lines |
| 169 | + while result.first().map_or(false, |l| l.trim().is_empty()) { |
| 170 | + result.remove(0); |
| 171 | + } |
| 172 | + |
| 173 | + result.join("\n") |
| 174 | +} |
| 175 | + |
70 | 176 | pub fn generate_rfc(path: &Path) -> Result<()> { |
71 | 177 | let timeframe = &validate_path(path)?; |
72 | 178 |
|
73 | | - // run mdbook build |
| 179 | + // Run mdbook build to expand (((directives))) |
74 | 180 | Command::new("mdbook").arg("build").status()?; |
75 | 181 |
|
76 | | - // find the markdown output |
77 | | - let generated_path = PathBuf::from("book/markdown") |
78 | | - .join(timeframe) |
79 | | - .join("index.md"); |
80 | | - if !generated_path.exists() { |
81 | | - spanned::bail_here!("no markdown generated at {}", generated_path.display()); |
| 182 | + let book_dir = PathBuf::from("book/markdown").join(timeframe); |
| 183 | + if !book_dir.exists() { |
| 184 | + spanned::bail_here!("no markdown generated at {}", book_dir.display()); |
82 | 185 | } |
83 | 186 |
|
84 | | - let generated_text = std::fs::read_to_string(&generated_path) |
85 | | - .with_path_context(&generated_path, "reading generated markdown")?; |
| 187 | + // Read the rendered README (index.md) as our skeleton |
| 188 | + let index_path = book_dir.join("index.md"); |
| 189 | + let index_text = std::fs::read_to_string(&index_path) |
| 190 | + .with_path_context(&index_path, "reading rendered index.md")?; |
| 191 | + |
| 192 | + // Separate reference links from the index |
| 193 | + let (index_body, index_ref_links) = separate_reference_links(&index_text); |
| 194 | + |
| 195 | + // Collect all reference link definitions for deduplication |
| 196 | + let ref_label_re = Regex::new(r"^\[([^\]]+)\]:\s").unwrap(); |
| 197 | + let mut all_ref_links: BTreeMap<String, String> = BTreeMap::new(); |
| 198 | + for ref_link in &index_ref_links { |
| 199 | + if let Some(caps) = ref_label_re.captures(ref_link) { |
| 200 | + let label = caps[1].to_string(); |
| 201 | + all_ref_links |
| 202 | + .entry(label) |
| 203 | + .or_insert_with(|| ref_link.clone()); |
| 204 | + } |
| 205 | + } |
86 | 206 |
|
87 | | - let regex = Regex::new(r"\]\(([^(]*)\.md(#[^)]*)?\)").unwrap(); |
| 207 | + // Pattern for link-list lines: `- [Text](./relative/path.md)` |
| 208 | + // Captures the relative path (e.g., `./highlights.md` or `./2026/highlights.md`) |
| 209 | + let link_line_re = Regex::new(r"^- \[[^\]]+\]\((\./[^)]+\.md)\)\s*$").unwrap(); |
| 210 | + let heading_re = Regex::new(r"^(#+)\s").unwrap(); |
| 211 | + |
| 212 | + // Process the index body line by line, inlining linked files |
| 213 | + let mut current_heading_level: usize = 1; |
| 214 | + let mut output = String::new(); |
| 215 | + for line in index_body.lines() { |
| 216 | + // Track the current heading level |
| 217 | + if let Some(caps) = heading_re.captures(line) { |
| 218 | + current_heading_level = caps[1].len(); |
| 219 | + } |
88 | 220 |
|
89 | | - let result = regex.replace_all( |
90 | | - &generated_text, |
91 | | - format!("](https://rust-lang.github.io/rust-project-goals/{timeframe}/$1.html$2)"), |
92 | | - ); |
| 221 | + if let Some(caps) = link_line_re.captures(line) { |
| 222 | + let rel_path = Path::new(&caps[1]); |
| 223 | + let chapter_path = book_dir.join(rel_path); |
| 224 | + if !chapter_path.exists() { |
| 225 | + eprintln!( |
| 226 | + "warning: linked file not found: {}, skipping", |
| 227 | + chapter_path.display() |
| 228 | + ); |
| 229 | + continue; |
| 230 | + } |
| 231 | + |
| 232 | + let chapter_text = std::fs::read_to_string(&chapter_path) |
| 233 | + .with_path_context(&chapter_path, "reading linked file")?; |
| 234 | + |
| 235 | + // Collect reference links from the chapter |
| 236 | + let (chapter_body, chapter_ref_links) = separate_reference_links(&chapter_text); |
| 237 | + for ref_link in &chapter_ref_links { |
| 238 | + if let Some(caps) = ref_label_re.captures(ref_link) { |
| 239 | + let label = caps[1].to_string(); |
| 240 | + all_ref_links |
| 241 | + .entry(label) |
| 242 | + .or_insert_with(|| ref_link.clone()); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + // Inline: strip top heading, adjust remaining headings relative to context |
| 247 | + let inlined = inline_rendered_file(&chapter_body, current_heading_level); |
| 248 | + output.push_str(&inlined); |
| 249 | + output.push('\n'); |
| 250 | + } else { |
| 251 | + output.push_str(line); |
| 252 | + output.push('\n'); |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + // Rewrite .md links to GitHub Pages URLs |
| 257 | + let rewritten = rewrite_md_links(&output, timeframe); |
| 258 | + |
| 259 | + // Rewrite .md URLs inside reference link definitions |
| 260 | + rewrite_ref_link_urls(&mut all_ref_links, timeframe); |
| 261 | + |
| 262 | + // Assemble final output: body + reference links |
| 263 | + let mut final_output = rewritten; |
| 264 | + if !final_output.ends_with('\n') { |
| 265 | + final_output.push('\n'); |
| 266 | + } |
| 267 | + final_output.push('\n'); |
| 268 | + for ref_link in all_ref_links.values() { |
| 269 | + final_output.push_str(ref_link); |
| 270 | + final_output.push('\n'); |
| 271 | + } |
93 | 272 |
|
94 | | - println!("{result}"); |
| 273 | + print!("{final_output}"); |
95 | 274 |
|
96 | 275 | Ok(()) |
97 | 276 | } |
|
0 commit comments