1use std::collections::BTreeMap;
4
5use winnow::{combinator::alt, error::ContextError, prelude::*};
6
7pub(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
15pub(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
23pub(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
32pub(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
41pub(crate) fn is_comment(line: &str) -> bool {
43 matches(line, "//")
44}
45
46pub(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 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 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#[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#[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#[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
134pub(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
256pub(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
288pub(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
306pub(crate) fn is_allow_attr(line: &str) -> bool {
308 matches(line, alt(("#[allow(", "#![allow(")))
309}
310
311pub(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
327pub(crate) fn has_inline_comment(line: &str) -> bool {
329 find_comment_start(line).is_some()
330}
331
332pub(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
341pub(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
352pub(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
361pub(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;