fix(spell): skip backslash commands like \brief in comments (#985)

Comments and Markdown went through the string-escape rule, which drops only the letter after a backslash, so the Doxygen command `\brief` was reported as the typo "rief". Outside string literals the whole word after a backslash is skipped now (Doxygen / TeX commands, escapes quoted in comments); string literals still skip only the escape letter.

Co-authored-by: fhrmrk <[email protected]>
This commit is contained in:
Park Jong Hyun authored and GitHub committed 2026-11-03 19:29:03 +08:00
1 parent 839bd0cdb0
commit bdeca5803b
2 files changed
+37 -6

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@ use std::time::SystemTime;
pub use words::{MIN_WORD_CHARS, WordSpan, words};
/// Where a region's words come from; strings and comments understand escapes.
/// Where a region's words come from; strings understand escapes, comments and
/// prose skip backslash commands (`\brief`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RegionKind {
Comment,
+35 -5
View File
@@ -17,7 +17,11 @@ pub struct WordSpan {
/// Words of `text[start..end]` in order, as absolute byte ranges.
pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut Vec<WordSpan>) {
let Some(slice) = text.get(start..end) else { return };
let escapes = !matches!(kind, RegionKind::Name);
let escapes = match kind {
RegionKind::Name => Escapes::None,
RegionKind::String => Escapes::Char,
RegionKind::Comment | RegionKind::Prose => Escapes::Word,
};
let mut chunk_start = None;
for (i, c) in slice.char_indices().chain(std::iter::once((slice.len(), ' '))) {
if c.is_whitespace() {
@@ -30,6 +34,18 @@ pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut V
}
}
/// What a `\` hides from the spell checker.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Escapes {
/// Identifiers have no escapes.
None,
/// String literals: the escape letter (`\n`, `\t`), the rest of the word is text.
Char,
/// Comments and prose: the whole word, a Doxygen / TeX command (`\brief`,
/// `\param`) or an escape quoted in a comment (`\n`).
Word,
}
/// URLs, e-mail addresses and the like are not prose.
fn skip_chunk(chunk: &str) -> bool {
if chunk.contains("://") || chunk.starts_with("www.") {
@@ -41,7 +57,7 @@ fn skip_chunk(chunk: &str) -> bool {
}
}
fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &mut Vec<WordSpan>) {
fn chunk(slice: &str, from: usize, to: usize, escapes: Escapes, base: usize, out: &mut Vec<WordSpan>) {
let text = &slice[from..to];
if skip_chunk(text) {
return;
@@ -57,9 +73,15 @@ fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &
if let Some(s) = run.take() {
token(text, s, i, base + from, out);
}
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
if escapes && c == '\\' {
chars.next();
if c == '\\' {
match escapes {
Escapes::None => {}
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
Escapes::Char => {
chars.next();
}
Escapes::Word => while chars.next_if(|&(_, c)| c.is_alphanumeric() || c == '_').is_some() {},
}
}
}
if let Some(s) = run {
@@ -164,6 +186,14 @@ mod tests {
assert_eq!(split("// 中文注释 naïve", RegionKind::Comment), ["naïve"]);
}
#[test]
fn skips_backslash_commands_in_comments() {
assert_eq!(split("/// \\brief Returns the sum.", RegionKind::Comment), ["Returns", "the", "sum"]);
assert_eq!(split("@see \\ref Foo, \\param[in] count", RegionKind::Comment), ["see", "Foo", "count"]);
assert_eq!(split("prints \\nthe line", RegionKind::Comment), ["prints", "line"]);
assert_eq!(split("\\emph{word} text", RegionKind::Prose), ["word", "text"]);
}
#[test]
fn keeps_apostrophes_inside_words() {
assert_eq!(split("'don't' shouldn't", RegionKind::Comment), ["don't", "shouldn't"]);