Skip to content

Commit b393f3d

Browse files
committed
Refactor: closer port of rustc's beautify_doc_string
Per Chayim's review: split push_doc_lines into a one-line push_doc_line and a new push_doc_lines that mirrors rustc's beautify_doc_string. The per-line body (source-map row, trim_end, indent tracking, string push) now lives in one place and is shared with the doc-attr / macro-expanded fast paths. Copies of get_vertical_trim and get_horizontal_trim are close-to-verbatim ports of rustc's helpers, adapted only to take a (line, offset) tuple so per-line source-map offsets stay accurate; the deviation is called out inline. The trim application in push_doc_lines matches rustc's structure (strip horizontal, then strip an additional leading '*' when it's block decoration). Preserves source-map offsets by computing each line's byte offset from the doc start manually (no substr_range, since the workspace MSRV is 1.95) and adding it to the caller's ast_offset when pushing each surviving line. One test expectation moved: with the verbatim horizontal-trim application, a doc line like ' *bar' now becomes '*bar' (rustc strips just the ' ' prefix, since '*bar' doesn't match the block-decoration triple). That pins the block's min indent at 0, so surrounding lines keep their post-horizontal leading whitespace. Behavior matches rustdoc.
1 parent 877fbfa commit b393f3d

1 file changed

Lines changed: 135 additions & 123 deletions

File tree

crates/hir-def/src/attrs/docs.rs

Lines changed: 135 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -201,14 +201,8 @@ impl Docs {
201201

202202
fn extend_with_doc_comment(&mut self, comment: ast::Comment, indent: &mut usize) {
203203
let Some((doc, offset)) = comment.doc_comment() else { return };
204-
// Multiline block doc comments are usually decorated with a leading `*` on every line,
205-
// and may be framed by lines of all `*`s. Mirror rustdoc's beautification.
206-
let trim = match comment.kind().shape {
207-
ast::CommentShape::Block => compute_block_doc_trim(doc),
208-
ast::CommentShape::Line => None,
209-
};
210204
let offset = comment.syntax().text_range().start() + offset;
211-
self.push_doc_lines(doc, Some(offset), indent, trim.as_ref());
205+
self.push_doc_lines(doc, Some(offset), indent, Some(comment.kind().shape));
212206
}
213207

214208
fn extend_with_doc_attr(&mut self, value: ast::String, indent: &mut usize) {
@@ -232,48 +226,83 @@ impl Docs {
232226
self.push_doc_lines(doc, None, indent, None);
233227
}
234228

235-
/// `trim` is the vertical + horizontal trim for a block doc comment, as computed by
236-
/// [`compute_block_doc_trim()`]. `None` means the doc is emitted verbatim (line docs, doc
237-
/// attributes, macro-expanded doc strings).
229+
/// Beautifies `doc` and appends the result to `self.docs`, one line at a time via
230+
/// [`Docs::push_doc_line`]. Mirrors rustc's [`beautify_doc_string`] when `shape` is
231+
/// `Some`, delegating to [`get_vertical_trim`] and [`get_horizontal_trim`]. When `shape`
232+
/// is `None` (doc attributes and macro-expanded doc strings) the beautifier is skipped
233+
/// and every line is emitted verbatim.
234+
///
235+
/// Individual `///` line comments always reach us as a single-line `doc`, so the
236+
/// `!doc.contains('\n')` fast path fires and the block-shape logic never runs on them.
237+
///
238+
/// Unlike rustc's version, which joins the beautified lines into a new interned `Symbol`,
239+
/// this port carries each line's byte offset relative to `doc`'s start so
240+
/// [`Docs::push_doc_line`] can record accurate per-line source-map offsets.
241+
///
242+
/// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L37
238243
fn push_doc_lines(
239244
&mut self,
240245
doc: &str,
241-
mut ast_offset: Option<TextSize>,
246+
ast_offset: Option<TextSize>,
242247
indent: &mut usize,
243-
trim: Option<&BlockDocTrim<'_>>,
248+
shape: Option<ast::CommentShape>,
244249
) {
245-
for (idx, raw) in doc.split('\n').enumerate() {
246-
let source_len = TextSize::of(raw);
247-
248-
// Vertical trim: drop lines outside `keep`, but keep advancing `ast_offset` by
249-
// `source_len + "\n"` so later kept lines still map back to the right source byte.
250-
let dropped = trim.is_some_and(|t| !t.keep.contains(&idx));
251-
if !dropped {
252-
// Horizontal trim: `*`, `* ` and `**` are decoration, but `*foo` is content.
253-
let line = trim
254-
.and_then(|t| t.horizontal)
255-
.and_then(|prefix| raw.strip_prefix(prefix))
256-
.filter(|&rest| rest == "*" || rest.starts_with("* ") || rest.starts_with("**"))
257-
.map_or(raw, |rest| &rest[1..]);
258-
259-
self.docs_source_map.push(DocsSourceMapLine {
260-
string_offset: TextSize::of(&self.docs),
261-
ast_offset: ast_offset.map(|it| it + (source_len - TextSize::of(line))),
262-
});
263-
264-
let line = line.trim_end();
265-
if let Some(line_indent) = line.chars().position(|ch| !ch.is_whitespace()) {
266-
// Empty lines are handled because `position()` returns `None` for them.
267-
*indent = std::cmp::min(*indent, line_indent);
250+
if !doc.contains('\n') {
251+
self.push_doc_line(doc, ast_offset, indent);
252+
return;
253+
}
254+
255+
// Each entry is `(line, offset_from_doc_start)`. The offset stays in sync with the
256+
// string as we strip its prefix, so the caller can add it to `ast_offset` for the
257+
// source map. Computed manually rather than via `str::substr_range` to stay compatible
258+
// with the workspace's MSRV.
259+
let mut lines: Vec<(&str, TextSize)> = Vec::new();
260+
let mut cursor = TextSize::new(0);
261+
for line in doc.split('\n') {
262+
lines.push((line, cursor));
263+
cursor += TextSize::of(line) + TextSize::of("\n");
264+
}
265+
266+
let lines = match get_vertical_trim(&lines) {
267+
Some((i, j)) => &mut lines[i..j],
268+
None => &mut lines[..],
269+
};
270+
if let Some(shape) = shape
271+
&& let Some(horizontal) = get_horizontal_trim(lines, shape)
272+
{
273+
let horizontal_len = TextSize::of(horizontal.as_str());
274+
// Strip `"[ \t]*\*"` from each line where present, exactly like rustc.
275+
for (line, line_offset) in lines.iter_mut() {
276+
if let Some(rest) = line.strip_prefix(horizontal.as_str()) {
277+
*line = rest;
278+
*line_offset += horizontal_len;
279+
if shape == ast::CommentShape::Block
280+
&& (*line == "*" || line.starts_with("* ") || line.starts_with("**"))
281+
{
282+
*line = &line[1..];
283+
*line_offset += TextSize::of("*");
284+
}
268285
}
269-
self.docs.push_str(line);
270-
self.docs.push('\n');
271286
}
287+
}
272288

273-
if let Some(offset) = ast_offset.as_mut() {
274-
*offset += source_len + TextSize::of("\n");
275-
}
289+
for (line, line_offset) in lines.iter().copied() {
290+
self.push_doc_line(line, ast_offset.map(|it| it + line_offset), indent);
291+
}
292+
}
293+
294+
/// Appends a single beautified line to `self.docs` and records its source-map row.
295+
fn push_doc_line(&mut self, line: &str, ast_offset: Option<TextSize>, indent: &mut usize) {
296+
self.docs_source_map
297+
.push(DocsSourceMapLine { string_offset: TextSize::of(&self.docs), ast_offset });
298+
299+
let line = line.trim_end();
300+
if let Some(line_indent) = line.chars().position(|ch| !ch.is_whitespace()) {
301+
// Empty lines are handled because `position()` returns `None` for them.
302+
*indent = std::cmp::min(*indent, line_indent);
276303
}
304+
self.docs.push_str(line);
305+
self.docs.push('\n');
277306
}
278307

279308
fn remove_indent(&mut self, indent: usize, start_source_map_index: usize) {
@@ -380,97 +409,76 @@ impl Docs {
380409
}
381410
}
382411

383-
/// The vertical and horizontal trim to apply to a block doc comment, mirroring rustdoc's
384-
/// beautification. `keep` is the half-open range of surviving *input* line indices (relative to
385-
/// `doc.split('\n')`); `horizontal` is the common `[ \t]*` prefix stripped from each kept line
386-
/// before its `*` decoration, if the block is uniformly decorated.
387-
#[derive(Debug)]
388-
struct BlockDocTrim<'a> {
389-
keep: std::ops::Range<usize>,
390-
horizontal: Option<&'a str>,
391-
}
392-
393-
/// Computes the trim to apply to a block doc comment. Returns `None` when no trimming would take
394-
/// place, so the caller stays on the fast path (single-line blocks, blocks without a consistent
395-
/// star column, etc.).
396-
///
397-
/// Adapted from rustc's [`beautify_doc_string`], keeping the `CommentKind::Block` behavior only.
398-
/// The port operates on borrowed line slices instead of interning a new `Symbol`, so
399-
/// [`Docs::push_doc_lines`] can compute per-line source-map offsets.
400-
///
401-
/// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L37
402-
fn compute_block_doc_trim(doc: &str) -> Option<BlockDocTrim<'_>> {
403-
if !doc.contains('\n') {
404-
// Single-line block doc comments are left alone, matching rustdoc.
405-
return None;
412+
/// Copied verbatim from rustc's [`beautify_doc_string`] (`compiler/rustc_ast/src/util/comments.rs`
413+
/// @ [`16a623ad`](https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L38)),
414+
/// except that the input is `&[(&str, TextSize)]` — the paired offset is ignored here.
415+
fn get_vertical_trim(lines: &[(&str, TextSize)]) -> Option<(usize, usize)> {
416+
let mut i = 0;
417+
let mut j = lines.len();
418+
// first line of all-stars should be omitted
419+
if lines.first().is_some_and(|(line, _)| !line.is_empty() && line.chars().all(|c| c == '*')) {
420+
i += 1;
406421
}
407-
let lines: Vec<&str> = doc.split('\n').collect();
408422

409-
// Vertical trim: drop leading/trailing all-`*` fence lines (rustc `get_vertical_trim`).
410-
let mut v_start = 0usize;
411-
let mut v_end = lines.len();
412-
if lines[v_start].chars().all(|c| c == '*') && !lines[v_start].is_empty() {
413-
v_start += 1;
414-
}
415-
if v_end > v_start && !lines[v_end - 1].is_empty() && lines[v_end - 1].chars().all(|c| c == '*')
416-
{
417-
v_end -= 1;
423+
// like the first, a last line of all stars should be omitted
424+
if j > i && !lines[j - 1].0.is_empty() && lines[j - 1].0.chars().all(|c| c == '*') {
425+
j -= 1;
418426
}
419427

420-
// Horizontal trim: only scan lines that carry decoration. Skip the first kept line (it
421-
// follows `/**` and typically contains prose) unless it already begins with `*`, and skip
422-
// whitespace-only lines at the boundaries.
423-
let horizontal = {
424-
let kept = &lines[v_start..v_end];
425-
let mut i =
426-
kept.first().map(|l| if l.trim_start().starts_with('*') { 0 } else { 1 }).unwrap_or(0);
427-
let mut j = kept.len();
428-
while i < j && kept[i].trim().is_empty() {
429-
i += 1;
430-
}
431-
while j > i && kept[j - 1].trim().is_empty() {
432-
j -= 1;
433-
}
434-
let scan = &kept[i..j];
435-
436-
// Only whitespace + `*` is allowed in the prefix, and the `*` must sit at the same
437-
// column on every scanned line. Empty scan means no decoration to strip.
438-
let mut prefix_col: Option<usize> = None;
439-
let mut ok = !scan.is_empty();
440-
'outer: for line in scan {
441-
let mut hit_star = false;
442-
for (col, c) in line.chars().enumerate() {
443-
if prefix_col.is_some_and(|p| col > p) || !matches!(c, '*' | ' ' | '\t') {
444-
ok = false;
445-
break 'outer;
446-
}
447-
if c == '*' {
448-
match prefix_col {
449-
None => prefix_col = Some(col),
450-
Some(p) if p != col => {
451-
ok = false;
452-
break 'outer;
453-
}
454-
_ => {}
455-
}
456-
hit_star = true;
457-
break;
458-
}
428+
if i != 0 || j != lines.len() { Some((i, j)) } else { None }
429+
}
430+
431+
/// Copied verbatim from rustc's [`beautify_doc_string`] (`compiler/rustc_ast/src/util/comments.rs`
432+
/// @ [`16a623ad`](https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L54)),
433+
/// except that the input is `&[(&str, TextSize)]` (the offset is ignored) and the return type is
434+
/// `Option<String>` for parity with the upstream signature.
435+
fn get_horizontal_trim(lines: &[(&str, TextSize)], kind: ast::CommentShape) -> Option<String> {
436+
let mut i = usize::MAX;
437+
let mut first = true;
438+
439+
// In case we have doc comments like `/**` or `/*!`, we want to remove stars if they are
440+
// present. However, we first need to strip the empty lines so they don't get in the middle
441+
// when we try to compute the "horizontal trim".
442+
let lines = match kind {
443+
ast::CommentShape::Block => {
444+
// Whatever happens, we skip the first line.
445+
let mut i = lines
446+
.first()
447+
.map(|(l, _)| if l.trim_start().starts_with('*') { 0 } else { 1 })
448+
.unwrap_or(0);
449+
let mut j = lines.len();
450+
451+
while i < j && lines[i].0.trim().is_empty() {
452+
i += 1;
459453
}
460-
if !hit_star {
461-
// A scanned line without a `*` at the expected column means the block is not
462-
// uniformly decorated.
463-
ok = false;
464-
break;
454+
while j > i && lines[j - 1].0.trim().is_empty() {
455+
j -= 1;
465456
}
457+
&lines[i..j]
466458
}
467-
prefix_col.filter(|_| ok).map(|p| &scan[0][..p])
459+
ast::CommentShape::Line => lines,
468460
};
469461

470-
if v_start == 0 && v_end == lines.len() && horizontal.is_none() {
471-
return None;
462+
for (line, _) in lines {
463+
for (j, c) in line.chars().enumerate() {
464+
if j > i || !"* \t".contains(c) {
465+
return None;
466+
}
467+
if c == '*' {
468+
if first {
469+
i = j;
470+
first = false;
471+
} else if i != j {
472+
return None;
473+
}
474+
break;
475+
}
476+
}
477+
if i >= line.len() {
478+
return None;
479+
}
472480
}
473-
Some(BlockDocTrim { keep: v_start..v_end, horizontal })
481+
Some(lines.first()?.0[..i].to_string())
474482
}
475483

476484
struct DocMacroExpander<'db> {
@@ -925,13 +933,17 @@ mod tests {
925933
}
926934

927935
// The decoration is stripped, but markdown bullets and `*foo` are content.
936+
// `*bar` doesn't start with `* ` / `**`, so rustc's beautifier only strips the
937+
// horizontal `[ \t]*` prefix (here a single space) and leaves the leading `*` in
938+
// place. That in turn pins the block's minimum indent at 0, so surrounding lines
939+
// aren't re-indented.
928940
check(
929941
"/**\n * foo\n *\n * * bullet\n *bar\n */",
930942
expect![[r#"
931943
932-
foo
944+
foo
933945
934-
* bullet
946+
* bullet
935947
*bar
936948
"#]],
937949
);

0 commit comments

Comments
 (0)