Skip to content

Commit b78da19

Browse files
committed
Use shared primitives for docstring backticks
1 parent 4642f5c commit b78da19

2 files changed

Lines changed: 92 additions & 108 deletions

File tree

crates/ty_ide/src/docstring/document/syntax.rs

Lines changed: 67 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -62,56 +62,13 @@ pub(in crate::docstring) fn starts_with_markdown_list_item(line: &str) -> bool {
6262
///
6363
/// For example, this returns `true` for ``"`value`"`` and `false` for
6464
/// ``"`value` trailing"``.
65-
pub(in crate::docstring) fn is_markdown_code_span(text: &str) -> bool {
66-
find_backtick_run(text, TextSize::ZERO).and_then(|opening| markdown_code_span(text, opening))
67-
== Some(TextRange::up_to(TextSize::of(text)))
68-
}
69-
70-
/// Returns the byte range of the first consecutive backtick run at or after `from`.
71-
///
72-
/// For example, searching ``"value `code`"`` from the start returns the range covering the
73-
/// opening ``"`"``.
74-
pub(in crate::docstring) fn find_backtick_run(text: &str, from: TextSize) -> Option<TextRange> {
75-
let from = from.to_usize();
76-
let start = from + text.get(from..)?.find('`')?;
77-
let len = text[start..]
78-
.bytes()
79-
.take_while(|byte| *byte == b'`')
80-
.count();
81-
Some(TextRange::new(
82-
TextSize::of(&text[..start]),
83-
TextSize::of(&text[..start + len]),
84-
))
85-
}
86-
87-
/// Returns the Markdown code span delimited by `opening`, if it has a matching closing run.
88-
///
89-
/// For example, the opening run in "``value`with:ticks`` trailing" produces the range covering
90-
/// "``value`with:ticks``".
91-
pub(in crate::docstring) fn markdown_code_span(
92-
text: &str,
93-
opening: TextRange,
94-
) -> Option<TextRange> {
95-
let mut search_from = opening.end();
96-
loop {
97-
let closing = find_backtick_run(text, search_from)?;
98-
if closing.len() == opening.len() {
99-
return Some(opening.cover(closing));
100-
}
101-
search_from = closing.end();
102-
}
103-
}
65+
pub(crate) fn is_markdown_code_span(text: &str) -> bool {
66+
let mut fragments = BacktickFragments::new(text);
67+
let Some(BacktickFragment::Span(_)) = fragments.next() else {
68+
return false;
69+
};
10470

105-
/// Returns whether the backtick run at `index` is escaped by a preceding backslash.
106-
///
107-
/// For example, the backtick in ``"\`"`` is escaped, while the backtick in ``"\\`"`` is not.
108-
pub(in crate::docstring) fn is_backtick_run_escaped(text: &str, index: usize) -> bool {
109-
!text[..index]
110-
.bytes()
111-
.rev()
112-
.take_while(|byte| *byte == b'\\')
113-
.count()
114-
.is_multiple_of(2)
71+
fragments.next().is_none()
11572
}
11673

11774
/// Losslessly partitions source text around complete, unescaped backtick spans.
@@ -155,33 +112,29 @@ impl<'a> Iterator for BacktickFragments<'a> {
155112
type Item = BacktickFragment<'a>;
156113

157114
fn next(&mut self) -> Option<Self::Item> {
158-
if self.last_fragment_end == TextSize::of(self.scanner.source) {
159-
return None;
160-
}
161-
162-
let span = loop {
115+
let span = if let Some(span) = self.pending_span.take() {
163116
// Emit the span saved while returning its preceding text on the previous call.
164-
if let Some(span) = self.pending_span.take() {
165-
break span;
166-
}
167-
168-
// Without another backtick run, the remaining source is all plain text.
169-
let Some(opening) = self.scanner.next() else {
170-
return self.take_remaining_text();
171-
};
117+
span
118+
} else {
119+
loop {
120+
// Without another backtick run, the remaining source is all plain text.
121+
let Some(opening) = self.scanner.next() else {
122+
return self.take_remaining_text();
123+
};
124+
125+
// Escaped runs are literal source text, so continue looking for the next possible
126+
// opening without emitting a fragment boundary.
127+
if opening.is_escaped() {
128+
continue;
129+
}
172130

173-
// Escaped runs are literal source text, so continue looking for the next possible
174-
// opening without emitting a fragment boundary.
175-
if opening.is_escaped() {
176-
continue;
131+
// Without a closing delimiter, callers cannot treat the opening or any later runs as
132+
// structured markup. Emit the remainder as one text fragment.
133+
let Some(span) = self.scanner.eat_span(opening) else {
134+
return self.take_remaining_text();
135+
};
136+
break span;
177137
}
178-
179-
// Without a closing delimiter, callers cannot treat the opening or any later runs as
180-
// structured markup. Emit the remainder as one text fragment.
181-
let Some(span) = self.scanner.eat_span(opening) else {
182-
return self.take_remaining_text();
183-
};
184-
break span;
185138
};
186139

187140
if self.last_fragment_end < span.start() {
@@ -329,11 +282,6 @@ impl<'a> BacktickScanner<'a> {
329282
delimiter_len: opening.range.len(),
330283
})
331284
}
332-
333-
/// Returns the scanner's cursor at its current position.
334-
pub(in crate::docstring) fn into_cursor(self) -> Cursor<'a> {
335-
self.cursor
336-
}
337285
}
338286

339287
impl Iterator for BacktickScanner<'_> {
@@ -514,28 +462,41 @@ pub(super) fn split_trailing_parenthetical(value: &str) -> Option<(&str, &str)>
514462
let mut outermost_opening = None;
515463
let mut cursor = Cursor::new(value);
516464

517-
while let Some(character) = cursor.bump() {
518-
let index = cursor.offset().to_usize() - character.len_utf8();
465+
loop {
466+
let start = cursor.offset();
467+
let Some(character) = cursor.bump() else {
468+
break;
469+
};
470+
519471
match character {
520-
'\'' | '"' => consume_quoted_string(&mut cursor, character),
521-
'`' if !is_backtick_run_escaped(value, index) => {
522-
let opening = find_backtick_run(value, TextSize::of(&value[..index]))?;
523-
let span = markdown_code_span(value, opening).unwrap_or(opening);
524-
cursor.skip_bytes((span.end() - cursor.offset()).to_usize());
472+
quote @ ('\'' | '"') => consume_quoted_string(&mut cursor, quote),
473+
'`' => {
474+
let mut scanner = BacktickScanner::starts_at(start, value);
475+
let opening = scanner.next()?;
476+
if opening.is_escaped() {
477+
// The loop has consumed only the first, escaped backtick. Leave the rest of
478+
// the run for the next iteration, where it may open a shorter span.
479+
continue;
480+
}
481+
482+
let end = scanner
483+
.eat_span(opening)
484+
.map_or_else(|| opening.end(), |span| span.end());
485+
cursor.skip_bytes((end - cursor.offset()).to_usize());
525486
}
526487
'(' => {
527488
if depth == 0 {
528-
outermost_opening = Some(index);
489+
outermost_opening = Some(start);
529490
}
530491
depth += 1;
531492
}
532493
')' => {
533494
depth = depth.checked_sub(1)?;
534495
if depth == 0 && cursor.is_eof() {
535496
let opening = outermost_opening?;
536-
let prefix = value[..opening].trim();
537-
let contents = value[opening + '('.len_utf8()..index].trim();
538-
return Some((prefix, contents));
497+
let (prefix, parenthetical) = value.split_at(opening.to_usize());
498+
let contents = parenthetical.strip_prefix('(')?.strip_suffix(')')?;
499+
return Some((prefix.trim(), contents.trim()));
539500
}
540501
}
541502
_ => {}
@@ -576,7 +537,7 @@ mod tests {
576537
let span = scanner.eat_span(opening).expect("a matching backtick run");
577538
assert!(!span.is_single());
578539
assert_eq!(span.content(), "code");
579-
assert_eq!(scanner.into_cursor().as_str(), " suffix");
540+
assert_eq!(scanner.as_str(), " suffix");
580541
}
581542

582543
#[test]
@@ -700,6 +661,22 @@ mod tests {
700661
);
701662
}
702663

664+
#[test]
665+
fn ignores_parentheses_inside_code_spans_after_escaped_backtick() {
666+
assert_eq!(
667+
split_trailing_parenthetical(r"value (\``)`)"),
668+
Some(("value", r"\``)`"))
669+
);
670+
}
671+
672+
#[test]
673+
fn treats_unmatched_backticks_as_plain_parenthetical_text() {
674+
assert_eq!(
675+
split_trailing_parenthetical("value (`unfinished)"),
676+
Some(("value", "`unfinished"))
677+
);
678+
}
679+
703680
#[test]
704681
fn ignores_parentheses_after_escaped_quotes() {
705682
assert_eq!(

crates/ty_ide/src/docstring/markdown/general/inline.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,9 @@
4949
5050
use std::borrow::Cow;
5151

52-
use ruff_text_size::TextSize;
52+
use ruff_text_size::{Ranged, TextSize};
5353

54-
use crate::docstring::document::syntax::{
55-
find_backtick_run, is_backtick_run_escaped, markdown_code_span,
56-
};
54+
use crate::docstring::document::syntax::BacktickScanner;
5755

5856
/// Exposes an interface for rendering a line of prose that may contain a hyperlink.
5957
#[derive(Default)]
@@ -278,28 +276,26 @@ enum Candidate<'a> {
278276

279277
/// Finds the first complete hyperlink or plausible wrapped candidate in `input`.
280278
fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> {
281-
let mut offset = TextSize::ZERO;
279+
let mut scanner = BacktickScanner::new(input);
282280

283281
// Visit each backtick run that could delimit inline markup.
284-
while let Some(run) = find_backtick_run(input, offset) {
282+
while let Some(run) = scanner.next() {
285283
let index = run.start().to_usize();
286284

287285
// An escaped run is literal text, so continue immediately after it.
288-
if is_backtick_run_escaped(input, index) {
289-
offset = run.end();
286+
if run.is_escaped() {
290287
continue;
291288
}
292289

293-
// Try parsing a link only when a single backtick has valid surrounding characters.
294-
if run.len() == TextSize::new(1)
295-
&& is_link_start(input, index)
290+
// Try parsing a link only when the backtick run has valid surrounding characters.
291+
if is_link_start(input, index)
296292
&& let Some(candidate) = parse_candidate(&input[index..])
297293
{
298294
return Some((index, candidate));
299295
}
300296

301297
// Skip other backtick-delimited spans rather than searching inside them.
302-
offset = markdown_code_span(input, run)?.end();
298+
scanner.eat_span(run)?;
303299
}
304300

305301
None
@@ -310,7 +306,13 @@ fn find_link(input: &str) -> Option<(usize, Candidate<'_>)> {
310306
/// Plausible wrapped labels without a closing backtick remain pending;
311307
/// malformed or unsupported forms return `None`.
312308
fn parse_candidate(input: &str) -> Option<Candidate<'_>> {
313-
let after_opening = input.strip_prefix('`')?;
309+
let mut scanner = BacktickScanner::new(input);
310+
let opening = scanner.next()?;
311+
if opening.start() != TextSize::ZERO {
312+
return None;
313+
}
314+
315+
let after_opening = scanner.as_str();
314316
if after_opening
315317
.chars()
316318
.next()
@@ -319,7 +321,11 @@ fn parse_candidate(input: &str) -> Option<Candidate<'_>> {
319321
return None;
320322
}
321323

322-
let Some(closing) = find_backtick_run(input, TextSize::new(1)) else {
324+
let Some(closing) = scanner.next() else {
325+
if !opening.is_single() {
326+
return None;
327+
}
328+
323329
// Eliminate candidates whose content already contains a disallowed
324330
// backslash or closing `>`, or whose target cannot become HTTP(S). A
325331
// partial URI scheme remains valid so it can wrap immediately after
@@ -333,21 +339,22 @@ fn parse_candidate(input: &str) -> Option<Candidate<'_>> {
333339
}
334340
return Some(Candidate::Pending);
335341
};
336-
if closing.len() != TextSize::new(1) {
342+
let span = scanner.span(opening, closing)?;
343+
if !span.is_single() {
337344
return None;
338345
}
339346

340-
let content = &input[1..closing.start().to_usize()];
347+
let content = span.content();
341348
if content.contains('\\') {
342349
return None;
343350
}
344351

345-
let after_closing = &input[closing.end().to_usize()..];
352+
let after_closing = scanner.as_str();
346353
let underscore_count = after_closing
347354
.bytes()
348355
.take_while(|byte| *byte == b'_')
349356
.count();
350-
let len = closing.end().to_usize() + underscore_count;
357+
let len = span.end().to_usize() + underscore_count;
351358
if !(1..=2).contains(&underscore_count) || !is_link_suffix(&after_closing[underscore_count..]) {
352359
return None;
353360
}

0 commit comments

Comments
 (0)