1use socketry_markdown::{
5 MarkdownOptions, ParseOptions,
6 mdast::{Html, InlineCode, Link, List, ListItem, Node, Paragraph, Text},
7 to_mdast,
8};
9use std::ops::Range;
10
11const RELEASES_SECTION: &str =
12 "## Releases\n\nSee [releases.md](releases.md) for the release history.";
13const RELEASES_START: &str = "<!-- bake-readme:releases:start -->";
14const RELEASES_END: &str = "<!-- bake-readme:releases:end -->";
15const PACKAGE_MARKER: &str = "<!-- bake-readme:package -->";
16const RECENT_RELEASE_COUNT: usize = 3;
17
18struct Heading {
19 title: String,
20 level: u8,
21 start: usize,
22 body_start: usize,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct PackageMetadata {
28 pub name: String,
30 pub description: Option<String>,
32 pub repository: Option<String>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Release {
39 pub name: String,
41 pub notes: String,
43}
44
45fn parse_markdown(document: &str) -> Node {
46 to_mdast(document, &ParseOptions::default())
47 .expect("CommonMark input should always parse into an AST")
48}
49
50fn heading_text(node: &Node) -> String {
51 node.text_content()
52}
53
54fn after_line_ending(document: &str, offset: usize) -> usize {
55 let rest = &document[offset..];
56
57 if rest.starts_with("\r\n") {
58 offset + 2
59 } else if rest.starts_with('\r') || rest.starts_with('\n') {
60 offset + 1
61 } else {
62 offset
63 }
64}
65
66fn headings(document: &str) -> Vec<Heading> {
67 let root = parse_markdown(document);
68 let children = root
69 .children()
70 .expect("a Markdown document AST should be a root node");
71 let mut headings = Vec::new();
72
73 for node in children {
74 let Node::Heading(heading) = node else {
75 continue;
76 };
77 let position = heading
78 .position
79 .as_ref()
80 .expect("parsed Markdown headings should include source positions");
81 headings.push(Heading {
82 title: heading_text(node),
83 level: heading.depth,
84 start: position.start.offset,
85 body_start: after_line_ending(document, position.end.offset),
86 });
87 }
88
89 headings
90}
91
92fn trailing_blank_lines_start(document: &str, position: usize) -> Range<usize> {
93 let prefix = &document[..position];
94 let mut start = position;
95 for line in prefix.split_inclusive('\n').rev() {
96 if line.trim().is_empty() {
97 start -= line.len();
98 } else {
99 break;
100 }
101 }
102 start..position
103}
104
105fn newline(document: &str) -> &'static str {
106 if document.contains("\r\n") {
107 "\r\n"
108 } else {
109 "\n"
110 }
111}
112
113fn rendered_package_entry(package: &PackageMetadata) -> String {
114 let name = package.name.trim();
115 let name_node = match package
116 .repository
117 .as_deref()
118 .filter(|value| !value.trim().is_empty())
119 {
120 Some(repository) => Node::Link(Link {
121 children: vec![Node::Text(Text {
122 value: name.to_owned(),
123 position: None,
124 })],
125 position: None,
126 url: repository.to_owned(),
127 title: None,
128 }),
129 None => Node::InlineCode(InlineCode {
130 value: name.to_owned(),
131 position: None,
132 lang: None,
133 }),
134 };
135 let description = package
136 .description
137 .as_deref()
138 .map(str::split_whitespace)
139 .map(|words| words.collect::<Vec<_>>())
140 .filter(|words| !words.is_empty())
141 .map(|words| words.join(" "));
142
143 let mut children = vec![name_node];
144 if let Some(description) = description {
145 children.push(Node::Text(Text {
146 value: format!(" — {description}"),
147 position: None,
148 }));
149 }
150 children.push(Node::Html(Html {
151 value: PACKAGE_MARKER.to_owned(),
152 position: None,
153 }));
154
155 let options = MarkdownOptions {
156 bullet: '-',
157 ..MarkdownOptions::default()
158 };
159
160 Node::List(List {
161 children: vec![Node::ListItem(ListItem {
162 children: vec![Node::Paragraph(Paragraph {
163 children,
164 position: None,
165 })],
166 position: None,
167 spread: false,
168 checked: None,
169 })],
170 position: None,
171 ordered: false,
172 start: None,
173 spread: false,
174 })
175 .to_markdown_with_options(&options)
176 .expect("generated package entries are valid Markdown")
177 .trim_end()
178 .to_owned()
179}
180
181fn section_end(document: &str, headings: &[Heading], index: usize) -> usize {
182 let heading = &headings[index];
183 headings
184 .iter()
185 .skip(index + 1)
186 .find(|next| next.level <= heading.level)
187 .map_or(document.len(), |next| next.start)
188}
189
190pub fn recent_releases(document: &str) -> Vec<Release> {
192 let headings = headings(document);
193 let Some((releases_index, releases_heading)) = headings
194 .iter()
195 .enumerate()
196 .find(|(_, heading)| heading.level == 1 && heading.title == "Releases")
197 else {
198 return Vec::new();
199 };
200 let releases_end = section_end(document, &headings, releases_index);
201
202 headings
203 .iter()
204 .enumerate()
205 .filter(|(_, heading)| {
206 heading.level == 2
207 && heading.start >= releases_heading.body_start
208 && heading.start < releases_end
209 && !heading.title.eq_ignore_ascii_case("Unreleased")
210 })
211 .take(RECENT_RELEASE_COUNT)
212 .map(|(index, heading)| Release {
213 name: heading.title.to_owned(),
214 notes: document[heading.body_start..section_end(document, &headings, index)]
215 .trim()
216 .to_owned(),
217 })
218 .collect()
219}
220
221fn rendered_recent_releases(releases: &[Release], line_ending: &str) -> String {
222 let mut content = String::from("See [releases.md](releases.md) for the full release history.");
223
224 for release in releases {
225 content.push_str(line_ending);
226 content.push_str(line_ending);
227 content.push_str("### ");
228 content.push_str(&release.name);
229 if !release.notes.is_empty() {
230 content.push_str(line_ending);
231 content.push_str(line_ending);
232 content.push_str(
233 &release
234 .notes
235 .replace("\r\n", "\n")
236 .replace('\n', line_ending),
237 );
238 }
239 }
240
241 content
242}
243
244fn marker_line(document: &str, range: Range<usize>, marker: &str) -> Option<(usize, usize)> {
245 let root = parse_markdown(document);
246 let mut found = None;
247 root.walk(|node| {
248 if found.is_some() {
249 return;
250 }
251 let Node::Html(html) = node else {
252 return;
253 };
254 if html.value.trim() != marker {
255 return;
256 }
257 let position = node
258 .position()
259 .expect("parsed Markdown nodes should include source positions");
260 let offset = position.start.offset;
261 if offset < range.start || offset >= range.end {
262 return;
263 }
264 found = Some(source_line_range(document, offset));
265 });
266 found
267}
268
269fn source_line_range(document: &str, offset: usize) -> (usize, usize) {
270 let start = document[..offset]
271 .rfind('\n')
272 .map_or(0, |newline| newline + 1);
273 let end = document[offset..]
274 .find('\n')
275 .map_or(document.len(), |newline| offset + newline + 1);
276 (start, end)
277}
278
279fn contains_link_to(document: &str, range: Range<usize>, destination: &str) -> bool {
280 let root = parse_markdown(document);
281 let mut definitions = Vec::new();
282 root.walk(|node| {
283 if let Node::Definition(definition) = node
284 && definition.url == destination
285 {
286 definitions.push(definition.identifier.clone());
287 }
288 });
289 let mut found = false;
290 root.walk(|node| {
291 if found {
292 return;
293 }
294 let matches = match node {
295 Node::Link(link) => link.url == destination,
296 Node::LinkReference(reference) => definitions.contains(&reference.identifier),
297 _ => false,
298 };
299 if matches
300 && range.contains(
301 &node
302 .position()
303 .expect("parsed Markdown nodes should include source positions")
304 .start
305 .offset,
306 )
307 {
308 found = true;
309 }
310 });
311 found
312}
313
314fn replace_generated_releases(
315 document: &str,
316 body: Range<usize>,
317 releases: &[Release],
318) -> Option<String> {
319 let start = marker_line(document, body.clone(), RELEASES_START);
320 let end = marker_line(document, body.clone(), RELEASES_END);
321 let line_ending = newline(document);
322 let content = rendered_recent_releases(releases, line_ending);
323
324 if let (Some((_, start_end)), Some((end_start, _))) = (start, end) {
325 if start_end <= end_start {
326 let mut updated = String::with_capacity(document.len() + content.len());
327 updated.push_str(&document[..start_end]);
328 updated.push_str(&content);
329 updated.push_str(line_ending);
330 updated.push_str(&document[end_start..]);
331 return Some(updated);
332 }
333 return None;
334 }
335
336 if document[body.clone()].trim() == "See [releases.md](releases.md) for the release history." {
338 let replacement = format!(
339 "{line_ending}{RELEASES_START}{line_ending}{content}{line_ending}{RELEASES_END}{line_ending}{line_ending}"
340 );
341 let mut updated = String::with_capacity(document.len() + replacement.len());
342 updated.push_str(&document[..body.start]);
343 updated.push_str(&replacement);
344 updated.push_str(&document[body.end..]);
345 return Some(updated);
346 }
347
348 None
349}
350
351pub fn update_releases_section(document: &str, releases: &[Release]) -> String {
356 let document_headings = headings(document);
357 if let Some((index, heading)) = document_headings
358 .iter()
359 .enumerate()
360 .find(|(_, heading)| heading.title == "Releases")
361 {
362 let body = heading.body_start..section_end(document, &document_headings, index);
363 return replace_generated_releases(document, body, releases)
364 .unwrap_or_else(|| document.to_owned());
365 }
366
367 let document = ensure_releases_section(document);
368 let generated_headings = headings(&document);
369 let (index, heading) = generated_headings
370 .iter()
371 .enumerate()
372 .find(|(_, heading)| heading.title == "Releases")
373 .expect("ensure_releases_section should create a Releases heading");
374 let body = heading.body_start..section_end(&document, &generated_headings, index);
375 replace_generated_releases(&document, body, releases).unwrap_or(document)
376}
377
378fn update_see_also_section(document: &str, package: Option<&PackageMetadata>) -> String {
379 let headings = headings(document);
380 let Some(package) = package else {
381 return document.to_owned();
382 };
383 let entry = rendered_package_entry(package);
384 let newline = newline(document);
385
386 if let Some((index, heading)) = headings
387 .iter()
388 .enumerate()
389 .find(|(_, heading)| heading.title == "See Also")
390 {
391 let end = section_end(document, &headings, index);
392 let body = &document[heading.body_start..end];
393 let repository_is_linked_elsewhere = package
394 .repository
395 .as_deref()
396 .filter(|repository| !repository.trim().is_empty())
397 .is_some_and(|repository| {
398 contains_link_to(document, 0..heading.start, repository)
399 || contains_link_to(document, end..document.len(), repository)
400 });
401 if let Some((line_start, line_end)) =
402 marker_line(document, heading.body_start..end, PACKAGE_MARKER)
403 {
404 let line = &document[line_start..line_end];
405 let line_ending = if line.ends_with("\r\n") {
406 "\r\n"
407 } else if line.ends_with('\n') {
408 "\n"
409 } else {
410 ""
411 };
412 if repository_is_linked_elsewhere
413 && package.repository.as_deref().is_some_and(|repository| {
414 contains_link_to(document, line_start..line_end, repository)
415 })
416 {
417 let remaining_body = format!(
418 "{}{}",
419 &document[heading.body_start..line_start],
420 &document[line_end..end]
421 );
422
423 if remaining_body.trim().is_empty() {
424 let section_start = trailing_blank_lines_start(document, heading.start).start;
425 let mut updated = String::with_capacity(document.len());
426 updated.push_str(&document[..section_start]);
427 if !updated.is_empty() {
428 updated.push_str(newline);
429 }
430 updated.push_str(&document[end..]);
431 return updated;
432 }
433
434 let mut updated = String::with_capacity(document.len());
435 updated.push_str(&document[..line_start]);
436 updated.push_str(&document[line_end..]);
437 return updated;
438 }
439
440 let mut updated = String::with_capacity(document.len() + entry.len());
441 updated.push_str(&document[..line_start]);
442 updated.push_str(&entry);
443 updated.push_str(line_ending);
444 updated.push_str(&document[line_end..]);
445 return updated;
446 }
447
448 if repository_is_linked_elsewhere
449 || package.repository.as_deref().is_some_and(|repository| {
450 contains_link_to(document, heading.body_start..end, repository)
451 })
452 {
453 return document.to_owned();
454 }
455
456 let mut insertion = heading.body_start;
457 while insertion < end {
458 let remaining = &document[insertion..end];
459 let Some(line_end) = remaining.find('\n').map(|index| insertion + index + 1) else {
460 break;
461 };
462 if document[insertion..line_end].trim().is_empty() {
463 insertion = line_end;
464 } else {
465 break;
466 }
467 }
468 let prefix = &document[..insertion];
469 let leading = if insertion == heading.body_start
470 && (!prefix.ends_with('\n') || !body.starts_with('\n'))
471 {
472 newline
473 } else {
474 ""
475 };
476 let mut updated = String::with_capacity(document.len() + entry.len() + 4);
477 updated.push_str(prefix);
478 updated.push_str(leading);
479 updated.push_str(&entry);
480 updated.push_str(newline);
481 updated.push_str(newline);
482 updated.push_str(&document[insertion..]);
483 return updated;
484 }
485
486 let target = headings
487 .iter()
488 .find(|heading| heading.title == "Contributing");
489 if package
490 .repository
491 .as_deref()
492 .is_some_and(|repository| contains_link_to(document, 0..document.len(), repository))
493 {
494 return document.to_owned();
495 }
496 let section = format!("## See Also{newline}{newline}{entry}");
497
498 if let Some(target) = target {
499 let insertion = trailing_blank_lines_start(document, target.start).start;
500 let prefix = &document[..insertion];
501 let preceding_newlines = prefix
502 .chars()
503 .rev()
504 .take_while(|character| *character == '\n')
505 .count();
506 let leading = if insertion == 0 || preceding_newlines >= 2 {
507 ""
508 } else {
509 newline
510 };
511 let mut updated = String::with_capacity(document.len() + section.len() + 8);
512 updated.push_str(prefix);
513 updated.push_str(leading);
514 updated.push_str(§ion);
515 updated.push_str(newline);
516 updated.push_str(newline);
517 updated.push_str(&document[target.start..]);
518 updated
519 } else {
520 let mut updated = String::with_capacity(document.len() + section.len() + 4);
521 updated.push_str(document);
522 if !document.is_empty() && !document.ends_with("\n\n") {
523 updated.push_str(newline);
524 if !document.ends_with('\n') {
525 updated.push_str(newline);
526 }
527 }
528 updated.push_str(§ion);
529 updated.push_str(newline);
530 updated
531 }
532}
533
534pub fn ensure_releases_section(document: &str) -> String {
541 let headings = headings(document);
542 if headings.iter().any(|heading| heading.title == "Releases") {
543 return document.to_owned();
544 }
545
546 let target = headings
547 .iter()
548 .find(|heading| matches!(heading.title.as_str(), "See Also" | "Contributing"));
549
550 let newline = newline(document);
551 let section = RELEASES_SECTION.replace('\n', newline);
552
553 if let Some(target) = target {
554 let insertion = trailing_blank_lines_start(document, target.start).start;
555 let prefix = &document[..insertion];
556 let preceding_newlines = prefix
557 .chars()
558 .rev()
559 .take_while(|character| *character == '\n')
560 .count();
561 let leading = if insertion == 0 || preceding_newlines >= 2 {
562 ""
563 } else {
564 newline
565 };
566 let mut updated = String::with_capacity(document.len() + section.len() + 8);
567 updated.push_str(prefix);
568 updated.push_str(leading);
569 updated.push_str(§ion);
570 updated.push_str(newline);
571 updated.push_str(newline);
572 updated.push_str(&document[target.start..]);
573 updated
574 } else {
575 let mut updated = String::with_capacity(document.len() + section.len() + 2 * newline.len());
576 updated.push_str(document);
577 if !document.is_empty() && !document.ends_with("\n\n") {
578 updated.push_str(newline);
579 if !document.ends_with('\n') {
580 updated.push_str(newline);
581 }
582 }
583 updated.push_str(§ion);
584 updated.push_str(newline);
585 updated
586 }
587}
588
589pub fn update_document(document: &str, package: Option<&PackageMetadata>) -> String {
594 let updated = update_see_also_section(document, package);
595 ensure_releases_section(&updated)
596}
597
598pub fn update_document_with_releases(
600 document: &str,
601 package: Option<&PackageMetadata>,
602 releases: &[Release],
603) -> String {
604 let updated = update_see_also_section(document, package);
605 update_releases_section(&updated, releases)
606}
607
608#[cfg(test)]
609#[path = "document_tests.rs"]
610mod tests;