Skip to main content

wowlab_tidy/infra/
parse.rs

1//! Shared winnow-based parsers for source code patterns.
2
3use std::collections::BTreeMap;
4
5use winnow::{combinator::alt, error::ContextError, prelude::*};
6
7/// Try to consume `parser` from `input`, advancing it on success.
8pub(crate) fn try_parse<'a, O>(
9    input: &mut &'a str,
10    mut parser: impl Parser<&'a str, O, ContextError>,
11) -> Option<O> {
12    parser.parse_next(input).ok()
13}
14
15/// Check if a parser matches the start of `input` without consuming it.
16pub(crate) fn matches<'a, O>(
17    input: &'a str,
18    mut parser: impl Parser<&'a str, O, ContextError>,
19) -> bool {
20    parser.parse_peek(input).is_ok()
21}
22
23/// Strip any comment prefix (`///`, `//!`, `//`) from a trimmed line, `None` if not a comment.
24pub(crate) fn comment_content(line: &str) -> Option<&str> {
25    let mut input = line;
26
27    try_parse(&mut input, alt(("///", "//!", "//")))?;
28
29    Some(input)
30}
31
32/// Strip a doc comment prefix (`///` or `//!`) from a trimmed line, `None` if not a doc comment.
33pub(crate) fn doc_comment_content(line: &str) -> Option<&str> {
34    let mut input = line;
35
36    try_parse(&mut input, alt(("///", "//!")))?;
37
38    Some(input)
39}
40
41/// Return `true` if a trimmed line starts with `//`.
42pub(crate) fn is_comment(line: &str) -> bool {
43    matches(line, "//")
44}
45
46/// Find `prefix(...)` in a line with balanced parentheses.
47pub(crate) fn balanced_extract<'a>(
48    line: &'a str,
49    prefix: &str,
50) -> Option<(&'a str, &'a str, &'a str)> {
51    use winnow::token::take_until;
52    let mut input = line;
53    let before: &str = try_parse(&mut input, take_until(0.., prefix))?;
54    let plen = prefix.len();
55    // BOUNDS: take_until matched prefix in input, so input.len() >= plen
56    let rest = &input[plen..];
57
58    let mut depth: u32 = 1;
59    let mut scan = rest;
60    let mut pos = 0;
61
62    while let Some(ch) = try_parse(&mut scan, winnow::token::any) {
63        match ch {
64            '"' => {
65                super::scanner::skip_string(&mut scan);
66                pos = rest.len() - scan.len();
67                continue;
68            }
69            '\'' => {
70                super::scanner::skip_char_literal(&mut scan);
71                pos = rest.len() - scan.len();
72                continue;
73            }
74            '(' => depth += 1,
75            ')' => {
76                depth -= 1;
77
78                if depth == 0 {
79                    // BOUNDS: pos is a valid byte offset within rest; pos+1 is safe because ')' is 1 byte
80                    return Some((before, &rest[..pos], &rest[pos + 1..]));
81                }
82            }
83            _ => {}
84        }
85
86        pos += ch.len_utf8();
87    }
88
89    None
90}
91
92/// Scope of a tidy suppression directive.
93#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
94#[non_exhaustive]
95pub(crate) enum Scope {
96    NextLine,
97    File,
98    Block,
99    Fn,
100}
101
102impl Scope {
103    pub(crate) fn prefix(&self) -> &'static str {
104        match self {
105            Scope::NextLine => "#t()",
106            Scope::File => "#t(file:)",
107            Scope::Block => "#t(block:)",
108            Scope::Fn => "#t(fn:)",
109        }
110    }
111}
112
113/// A parsed `#t(...)` suppression directive with its scope, rules, and reason text.
114#[derive(Debug)]
115pub(crate) struct Directive {
116    pub scope: Scope,
117    pub rules: Vec<String>,
118    pub values: BTreeMap<String, usize>,
119    pub wildcard: bool,
120    pub reason: String,
121}
122
123/// Outcome of parsing a candidate directive line: valid, missing reason, or malformed.
124#[derive(Debug)]
125#[non_exhaustive]
126pub(crate) enum DirectiveResult {
127    Valid(Directive),
128    MissingReason(Scope),
129    Malformed(String),
130}
131
132const DIRECTIVE_PREFIX: &str = "// #t(";
133
134/// Parse a tidy suppression directive from a source line, `None` if it is not one.
135// #t(fn: rust_cyclomatic_complexity) directive grammar validates scopes, wildcards, values, and reasons in one parser
136pub(crate) fn directive(line: &str) -> Option<DirectiveResult> {
137    use winnow::token::take_until;
138
139    let trimmed = line.trim();
140    let mut input = trimmed;
141
142    try_parse(&mut input, DIRECTIVE_PREFIX)?;
143
144    let Some(inside) = try_parse(&mut input, take_until(0.., ")")) else {
145        return Some(DirectiveResult::Malformed("missing closing `)`".into()));
146    };
147    let _ = try_parse(&mut input, ")");
148    let after = input.trim();
149
150    if inside.is_empty() {
151        return Some(DirectiveResult::Malformed(
152            "empty `#t()` — need rule names".into(),
153        ));
154    }
155
156    let (scope, rule_part) = parse_scope(inside);
157
158    if rule_part == "*" {
159        if after.is_empty() {
160            return Some(DirectiveResult::MissingReason(scope));
161        }
162
163        return Some(DirectiveResult::Valid(Directive {
164            scope,
165            rules: Vec::new(),
166            values: BTreeMap::new(),
167            wildcard: true,
168            reason: after.to_string(),
169        }));
170    }
171
172    let mut rules = Vec::new();
173    let mut values = BTreeMap::new();
174
175    for target in rule_part
176        .split(',')
177        .map(str::trim)
178        .filter(|target| !target.is_empty())
179    {
180        let (rule, value) = target
181            .split_once('=')
182            .map_or((target, None), |(rule, value)| {
183                (rule.trim(), Some(value.trim()))
184            });
185
186        if rule.is_empty() {
187            return Some(DirectiveResult::Malformed(
188                "empty rule name before `=`".into(),
189            ));
190        }
191
192        if let Some(value) = value {
193            if scope != Scope::File {
194                return Some(DirectiveResult::Malformed(
195                    "numeric directive values require file scope".into(),
196                ));
197            }
198
199            let Ok(value) = value.parse::<usize>() else {
200                return Some(DirectiveResult::Malformed(
201                    "directive value must be a positive integer".into(),
202                ));
203            };
204
205            if value == 0 {
206                return Some(DirectiveResult::Malformed(
207                    "directive value must be a positive integer".into(),
208                ));
209            }
210
211            values.insert(rule.to_owned(), value);
212        }
213
214        rules.push(rule.to_owned());
215    }
216
217    if rules.is_empty() {
218        return Some(DirectiveResult::Malformed("no rule names in `#t()`".into()));
219    }
220
221    if rules.iter().any(|rule| rule == "*") {
222        return Some(DirectiveResult::Malformed(
223            "wildcard `*` must be the only target in `#t()`".into(),
224        ));
225    }
226
227    if after.is_empty() {
228        return Some(DirectiveResult::MissingReason(scope));
229    }
230
231    Some(DirectiveResult::Valid(Directive {
232        scope,
233        rules,
234        values,
235        wildcard: false,
236        reason: after.to_string(),
237    }))
238}
239
240fn parse_scope(inside: &str) -> (Scope, &str) {
241    let mut input = inside;
242
243    match try_parse(
244        &mut input,
245        alt((
246            "file:".value(Scope::File),
247            "block:".value(Scope::Block),
248            "fn:".value(Scope::Fn),
249        )),
250    ) {
251        Some(scope) => (scope, input.trim()),
252        None => (Scope::NextLine, inside.trim()),
253    }
254}
255
256/// Rewrite only a directive's target list while preserving its surrounding syntax.
257pub(crate) fn rewrite_directive_rules(line: &str, scope: &Scope, rules: &[&str]) -> Option<String> {
258    if rules.is_empty() {
259        return None;
260    }
261
262    let marker = line.find(DIRECTIVE_PREFIX)?;
263    let inside_start = marker + DIRECTIVE_PREFIX.len();
264    let close_offset = line.get(inside_start..)?.find(')')?;
265    let inside_end = inside_start + close_offset;
266    let inside = line.get(inside_start..inside_end)?;
267    let scope_len = match scope {
268        Scope::NextLine => 0,
269        Scope::File => "file:".len(),
270        Scope::Block => "block:".len(),
271        Scope::Fn => "fn:".len(),
272    };
273    let targets = inside.get(scope_len..)?;
274    let leading = targets.len() - targets.trim_start().len();
275    let trailing = targets.len() - targets.trim_end().len();
276    let targets_start = inside_start + scope_len + leading;
277    let targets_end = inside_end.checked_sub(trailing)?;
278    let replacement = rules.join(", ");
279
280    Some(format!(
281        "{}{}{}",
282        line.get(..targets_start)?,
283        replacement,
284        line.get(targets_end..)?
285    ))
286}
287
288/// Check whether a string contains an http(s) URL, returning the start position.
289// #t(fn: rust_hardcoded_url) URL scheme literals are the thing being detected
290pub(crate) fn find_url(line: &str) -> Option<usize> {
291    use winnow::token::take_until;
292    let mut remaining = line;
293
294    loop {
295        let _skipped: &str = try_parse(&mut remaining, take_until(0.., "http"))?;
296        let offset = line.len() - remaining.len();
297
298        if matches(remaining, alt(("https://", "http://"))) {
299            return Some(offset);
300        }
301
302        let _ = try_parse(&mut remaining, "http");
303    }
304}
305
306/// Return `true` if the line starts with `#[allow(` or `#![allow(`.
307pub(crate) fn is_allow_attr(line: &str) -> bool {
308    matches(line, alt(("#[allow(", "#![allow(")))
309}
310
311/// Extract comment text after a `// ` prefix, `""` for bare `//`, `None` if not a `// ` comment.
312pub(crate) fn prose_comment_content(line: &str) -> Option<&str> {
313    let trimmed = line.trim();
314    let mut input = trimmed;
315
316    try_parse(&mut input, "//")?;
317
318    if input.is_empty() {
319        return Some("");
320    }
321
322    try_parse(&mut input, " ")?;
323
324    Some(input)
325}
326
327/// Return `true` if the line contains a `//` comment anywhere after code.
328pub(crate) fn has_inline_comment(line: &str) -> bool {
329    find_comment_start(line).is_some()
330}
331
332/// Parse the character immediately after the `//` prefix on a trimmed line.
333pub(crate) fn char_after_slashes(line: &str) -> Option<char> {
334    let mut input = line;
335
336    try_parse(&mut input, "//")?;
337
338    try_parse(&mut input, winnow::token::any)
339}
340
341/// Extract the field name from a `redundant field initializer` violation message.
342pub(crate) fn redundant_field_name(message: &str) -> Option<&str> {
343    use winnow::token::take_until;
344    let mut input = message;
345
346    try_parse(&mut input, "redundant field initializer `")?;
347    let name: &str = try_parse(&mut input, take_until(0.., ":"))?;
348
349    if name.is_empty() { None } else { Some(name) }
350}
351
352/// Return the portion of `line` starting from a `//` comment (may be inline).
353pub(crate) fn find_comment_start(line: &str) -> Option<&str> {
354    use winnow::token::take_until;
355    let mut input = line;
356    let _: &str = try_parse(&mut input, take_until(0.., "//"))?;
357
358    Some(input)
359}
360
361/// Find `keyword` in `haystack` and return the substring following it, or `None` if not found.
362pub(crate) fn find_keyword_suffix<'a>(haystack: &'a str, keyword: &str) -> Option<&'a str> {
363    use winnow::token::take_until;
364    let mut input = haystack;
365    let _: &str = try_parse(&mut input, take_until(0.., keyword))?;
366
367    input.get(keyword.len()..)
368}
369
370#[cfg(test)]
371mod tests;