Skip to content

Commit 8e09103

Browse files
authored
Merge pull request #602 from nikomatsakis/main
refactor RFC generation
2 parents faa72ca + 7665103 commit 8e09103

File tree

7 files changed

+502
-39
lines changed

7 files changed

+502
-39
lines changed

crates/mdbook-goals/src/goal_preprocessor.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ impl<'c> GoalPreprocessorWithContext<'c> {
275275
}
276276

277277
/// Shared helper for replacing themed goal list directives (HIGHLIGHT GOALS, GOALS WITH NEEDS).
278-
/// Filters goals by a `Themes` field extracted via `get_themes`, then formats as `####` sections.
278+
/// Filters goals by a `Themes` field extracted via `get_themes`, then formats as heading sections.
279279
fn replace_themed_goal_list(
280280
&mut self,
281281
chapter: &mut Chapter,
@@ -308,7 +308,18 @@ impl<'c> GoalPreprocessorWithContext<'c> {
308308

309309
filtered_goals.sort_by_key(|g| &g.metadata.title);
310310

311-
let output = goal::format_highlight_goal_sections(&filtered_goals).into_anyhow()?;
311+
// Search backwards from the directive for the most recent heading to
312+
// determine the level at which to emit goal sub-headings.
313+
let heading_re = Regex::new(r"(?m)^(#+)\s").unwrap();
314+
let preceding = &chapter.content[..range.start];
315+
let context_level = heading_re
316+
.find_iter(preceding)
317+
.last()
318+
.map(|m| m.as_str().trim().len())
319+
.unwrap_or(1);
320+
321+
let output = goal::format_highlight_goal_sections(&filtered_goals, context_level + 1)
322+
.into_anyhow()?;
312323

313324
chapter.content.replace_range(range, &output);
314325
}

crates/rust-project-goals-cli/src/rfc.rs

Lines changed: 195 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::{
2-
collections::BTreeSet,
2+
collections::{BTreeMap, BTreeSet},
33
fmt::Display,
44
path::{Path, PathBuf},
55
process::Command,
@@ -67,31 +67,210 @@ pub fn generate_comment(path: &Path) -> Result<()> {
6767
Ok(())
6868
}
6969

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: &regex::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+
70176
pub fn generate_rfc(path: &Path) -> Result<()> {
71177
let timeframe = &validate_path(path)?;
72178

73-
// run mdbook build
179+
// Run mdbook build to expand (((directives)))
74180
Command::new("mdbook").arg("build").status()?;
75181

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());
82185
}
83186

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+
}
86206

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+
}
88220

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+
}
93272

94-
println!("{result}");
273+
print!("{final_output}");
95274

96275
Ok(())
97276
}

crates/rust-project-goals/src/goal.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -805,20 +805,24 @@ pub fn format_goal_table(
805805
Ok(util::format_table(&table))
806806
}
807807

808-
/// Format highlight goals as `####` sections with people and summary.
809-
pub fn format_highlight_goal_sections(goals: &[&GoalDocument]) -> Result<String> {
808+
/// Format highlight goals as sections with people and summary at the given heading level.
809+
pub fn format_highlight_goal_sections(
810+
goals: &[&GoalDocument],
811+
heading_level: usize,
812+
) -> Result<String> {
810813
let mut output = String::new();
814+
let hashes = "#".repeat(heading_level);
811815

812816
for goal in goals {
813817
if goal.metadata.is_help_wanted() {
814818
output.push_str(&format!(
815-
"#### [{}]({}) ![Help wanted][]\n\n",
819+
"{hashes} [{}]({}) ![Help wanted][]\n\n",
816820
*goal.metadata.title,
817821
goal.link_path.display()
818822
));
819823
} else {
820824
output.push_str(&format!(
821-
"#### [{}]({})\n\n",
825+
"{hashes} [{}]({})\n\n",
822826
*goal.metadata.title,
823827
goal.link_path.display()
824828
));

src/2026/README.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
# Rust project goals 2026
1+
- Feature Name: `project_goals_2026`
2+
- Start Date: 2026-02-23
3+
- RFC PR: [rust-lang/rfcs#0000](https://github.com/rust-lang/rfcs/pull/0000)
24

3-
## Summary
5+
# Summary
6+
[summary]: #summary
47

5-
*![Status: Revising first draft](https://img.shields.io/badge/Status-Revising%20first%20draft-yellow) We are in the process of revising the first draft of the goal slate. New proposals are still accepted but they must already have team consensus and appropriate champions.*
8+
Establish the initial round of Rust Project Goals for 2026 along with a set of current roadmaps, which describe multi-year development arcs.
69

7-
This is a draft for the eventual RFC proposing the 2026 goals.
10+
New Rust Project Goals may be added over the course of the year but only if all required resources (champions, funding, etc) are already known.
811

9-
## Motivation
12+
# Motivation
13+
[motivation]: #motivation
1014

1115
The 2026 goal slate consists of (((#GOALS))) project goals. In comparison to prior rounds, we have changed to *annual* goals rather than six-month goal periods. Annuals goals give us more time to discuss and organize.
1216

13-
### Why we set goals
17+
## Why we set goals
1418

1519
Goals serve multiple purposes.
1620

@@ -20,23 +24,41 @@ For users, goals serve as a roadmap, giving you an early picture of what work we
2024

2125
For Rust maintainers, goals help to surface interactions across teams. They aid in coordination because people know what work others are aiming to do and where they may need to offer support.
2226

23-
### Goals are *proposed* by contributors and *accepted* by teams
27+
## Goals are *proposed* by contributors and *accepted* by teams
2428

2529
As an open-source project, Rust's goal process works differently than a company's. In a company, leadership sets goals and assigns employees to execute them. Rust doesn't have employees - we have contributors who volunteer their time and energy. So in our process, goals begin with the *contributor*: the person (or company) that wants to do the work.
2630

2731
Contributors *propose* goals; Rust teams *accept* them. When you propose a goal, you're saying you're prepared to invest the time to make it happen. When a team accepts, they're committing to support that work - doing reviews, engaging in RFC discussions, and providing the guidance needed to land it in Rust.
2832

29-
### How these goals were selected
33+
## How these goals were selected
3034

3135
Goal proposals were accepted during the month of January. Many of the goals are continuing goals that are carried over from the previous year, but others goal are new.
3236

3337
In February, an *alpha* version of this RFC is reviewed with teams. Teams vet the goals to determine if they are realistic and to make sure that goal have champions from the team. A *champion* is a Rust team member that will mentor and guide the contributor as they do their work. Champions keep up with progress on the goal, help the champion figure out technical challenges, and also help the contributor to navigate the Rust team(s). Champions also field questions from others in the project who want to understand the goal.
3438

35-
### How to follow along with a goal's progress
39+
## How to follow along with a goal's progress
3640

3741
Once the Goals RFC is accepted, you can follow along with the progress on a goal in a few different ways:
3842

3943
* Each goal has a tracking issue. Goal contributors and champions are expected to post regular updates. These updates are also posted to Zulip in the `#project-goals` channel.
4044
* Regular blog posts cover major happenings in goals.
4145

46+
# Guide-level explanation
47+
[guide-level-explanation]: #guide-level-explanation
48+
49+
- [Highlights](./highlights.md)
50+
- [Help wanted](./help-wanted.md)
51+
- [Roadmaps](./roadmaps.md)
52+
53+
# Reference-level explanation
54+
[reference-level-explanation]: #reference-level-explanation
55+
56+
- [Goals](./goals.md)
57+
58+
# Frequently asked questions
59+
[rationale-and-alternatives]: #frequently-asked-questions
60+
[faq]: #frequently-asked-questions
61+
62+
- [Frequently asked questions](./faq.md)
63+
4264
<!-- GitHub usernames -->

src/2026/highlights.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ There are a total of (((#GOALS))) planned for this year. That's a lot! You can s
44

55
**Important:** You have to understand the nature of a Rust goal. Rust is an open-source project, which means that progress only happens when contributors come and *make* it happen. When the Rust project declares a goal, that means that (a) contributors, who we call the *task owners*, have said they want to do the work and (b) members of the Rust team members have promised to support them. Sometimes those task owners are volunteers, sometimes they are paid by a company, and sometimes they supported by grants. But no matter which category they are, if they ultimately are not able to do the work (say, because something else comes up that is higher priority for them in their lives), then the goal won't happen. That's ok, there's always next year!
66

7-
### Cargo improvements
7+
## Cargo improvements
88

99
(((HIGHLIGHT GOALS: Cargo improvements)))
1010

11-
### Language changes
11+
## Language changes
1212

1313
(((HIGHLIGHT GOALS: Language changes)))
1414

15-
### Other
15+
## Other
1616

1717
(((HIGHLIGHT GOALS: Other)))
18-

0 commit comments

Comments
 (0)