1use rowan::{TextRange, TextSize};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Tool {
42 Both,
44 Format,
46 Lint,
48}
49
50impl Tool {
51 pub fn affects_lint(self) -> bool {
53 matches!(self, Tool::Both | Tool::Lint)
54 }
55
56 pub fn affects_format(self) -> bool {
58 matches!(self, Tool::Both | Tool::Format)
59 }
60
61 pub fn prefix(self) -> &'static str {
63 match self {
64 Tool::Both => "arity",
65 Tool::Format => "arity-format",
66 Tool::Lint => "arity-lint",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Spelling {
75 Canonical,
77 Deprecated,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Verb {
86 Skip,
88 Off,
90 On,
92 SkipFile,
94}
95
96impl Verb {
97 pub fn as_str(self) -> &'static str {
99 match self {
100 Verb::Skip => "skip",
101 Verb::Off => "off",
102 Verb::On => "on",
103 Verb::SkipFile => "skip-file",
104 }
105 }
106
107 fn from_word(word: &str) -> Option<Self> {
108 match word {
109 "skip" => Some(Verb::Skip),
110 "off" => Some(Verb::Off),
111 "on" => Some(Verb::On),
112 "skip-file" => Some(Verb::SkipFile),
113 _ => None,
114 }
115 }
116
117 pub fn wants_reason(self) -> bool {
120 !matches!(self, Verb::On)
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct RuleRef {
127 pub id: String,
128 pub range: TextRange,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Malformed {
138 pub tool: Tool,
139 pub kind: MalformedKind,
140 pub range: TextRange,
142 pub word: String,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum MalformedKind {
148 UnknownVerb,
150 MissingVerb,
152 UnexpectedWord,
156 UnexpectedVerb,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum RuleScope {
170 Rule(RuleRef),
172 All,
175 Unnamed,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Directive<'a> {
182 pub tool: Tool,
183 pub verb: Verb,
184 pub prefix: TextRange,
188 pub scope: RuleScope,
189 pub reason: Option<&'a str>,
192 pub spelling: Spelling,
193}
194
195impl Directive<'_> {
196 pub fn rule(&self) -> Option<&RuleRef> {
198 match &self.scope {
199 RuleScope::Rule(rule) => Some(rule),
200 _ => None,
201 }
202 }
203
204 pub fn has_rule_slot(&self) -> bool {
208 self.tool.affects_lint() && self.verb != Verb::On
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum Parsed<'a> {
215 Directive(Directive<'a>),
216 Malformed(Malformed),
217}
218
219pub fn parse(text: &str) -> Option<Parsed<'_>> {
228 let hash = text.find('#')?;
229 let (body, body_at) = trim_start_at(&text[hash + 1..], hash + 1);
230
231 if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore-file") {
234 return Some(Parsed::Directive(lint_tail(
235 Verb::SkipFile,
236 rest,
237 rest_at,
238 word_range(body_at, "arity-ignore-file"),
239 Spelling::Deprecated,
240 )));
241 }
242 if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore") {
243 return Some(arity_ignore(
244 rest,
245 rest_at,
246 word_range(body_at, "arity-ignore"),
247 ));
248 }
249 for (prefix, tool) in [("arity-lint", Tool::Lint), ("arity-format", Tool::Format)] {
250 if let Some((rest, rest_at)) = strip_word(body, body_at, prefix) {
251 return Some(tool_prefixed(
252 tool,
253 rest,
254 rest_at,
255 word_range(body_at, prefix),
256 ));
257 }
258 }
259 if let Some((rest, rest_at)) = strip_word(body, body_at, "arity") {
260 Verb::from_word(next_word(rest)?)?;
263 return Some(tool_prefixed(
264 Tool::Both,
265 rest,
266 rest_at,
267 word_range(body_at, "arity"),
268 ));
269 }
270 None
271}
272
273fn arity_ignore(rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
276 match next_word(rest) {
277 Some(word) if Verb::from_word(word).is_some() => Parsed::Malformed(Malformed {
278 tool: Tool::Lint,
279 kind: MalformedKind::UnexpectedVerb,
280 range: TextRange::at(size(rest_at), size(word.len())),
281 word: word.to_string(),
282 }),
283 _ => Parsed::Directive(lint_tail(
284 Verb::Skip,
285 rest,
286 rest_at,
287 prefix,
288 Spelling::Deprecated,
289 )),
290 }
291}
292
293fn lint_tail(
298 verb: Verb,
299 rest: &str,
300 rest_at: usize,
301 prefix: TextRange,
302 spelling: Spelling,
303) -> Directive<'_> {
304 let scope = match parse_rule(rest, rest_at) {
305 Some(rule) => RuleScope::Rule(rule),
306 None if rest.starts_with(':') => RuleScope::All,
307 None => RuleScope::Unnamed,
308 };
309 Directive {
310 tool: Tool::Lint,
311 verb,
312 prefix,
313 scope,
314 reason: parse_reason(rest),
315 spelling,
316 }
317}
318
319fn tool_prefixed(tool: Tool, rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
322 let Some(word) = next_word(rest) else {
323 return Parsed::Malformed(Malformed {
324 tool,
325 kind: MalformedKind::MissingVerb,
326 range: prefix,
327 word: tool.prefix().to_string(),
328 });
329 };
330 let Some(verb) = Verb::from_word(word) else {
331 return Parsed::Malformed(Malformed {
332 tool,
333 kind: MalformedKind::UnknownVerb,
334 range: TextRange::at(size(rest_at), size(word.len())),
335 word: word.to_string(),
336 });
337 };
338
339 let (tail, tail_at) = trim_start_at(&rest[word.len()..], rest_at + word.len());
340
341 let takes_rule = tool == Tool::Lint && verb != Verb::On;
346 if !takes_rule && let Some(extra) = next_word(tail) {
347 return Parsed::Malformed(Malformed {
348 tool,
349 kind: MalformedKind::UnexpectedWord,
350 range: TextRange::at(size(tail_at), size(extra.len())),
351 word: extra.to_string(),
352 });
353 }
354 let scope = if takes_rule {
355 match parse_rule(tail, tail_at) {
356 Some(rule) => RuleScope::Rule(rule),
357 None if tail.starts_with(':') => RuleScope::All,
358 None => RuleScope::Unnamed,
359 }
360 } else if tool == Tool::Both {
361 RuleScope::All
362 } else {
363 RuleScope::Unnamed
364 };
365 Parsed::Directive(Directive {
366 tool,
367 verb,
368 prefix,
369 scope,
370 reason: parse_reason(tail),
371 spelling: Spelling::Canonical,
372 })
373}
374
375fn strip_word<'a>(body: &'a str, body_at: usize, word: &str) -> Option<(&'a str, usize)> {
379 let rest = body.strip_prefix(word)?;
380 match rest.chars().next() {
381 None => Some((rest, body_at + word.len())),
382 Some(c) if c.is_whitespace() || c == ':' => Some(trim_start_at(rest, body_at + word.len())),
383 Some(_) => None,
384 }
385}
386
387fn next_word(rest: &str) -> Option<&str> {
391 let end = rest
392 .find(|c: char| c == ':' || c.is_whitespace())
393 .unwrap_or(rest.len());
394 (end > 0).then(|| &rest[..end])
395}
396
397fn parse_rule(rest: &str, rest_at: usize) -> Option<RuleRef> {
399 let word = next_word(rest)?;
400 Some(RuleRef {
401 id: word.to_string(),
402 range: TextRange::at(size(rest_at), size(word.len())),
403 })
404}
405
406fn parse_reason(rest: &str) -> Option<&str> {
408 let (_, reason) = rest.split_once(':')?;
409 let trimmed = reason.trim();
410 (!trimmed.is_empty()).then_some(trimmed)
411}
412
413fn trim_start_at(s: &str, offset: usize) -> (&str, usize) {
415 let trimmed = s.trim_start();
416 (trimmed, offset + (s.len() - trimmed.len()))
417}
418
419fn word_range(at: usize, word: &str) -> TextRange {
421 TextRange::at(size(at), size(word.len()))
422}
423
424fn size(n: usize) -> TextSize {
425 TextSize::from(n as u32)
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn directive(text: &str) -> Directive<'_> {
433 match parse(text) {
434 Some(Parsed::Directive(d)) => d,
435 other => panic!("expected a directive, got {other:?}"),
436 }
437 }
438
439 fn malformed(text: &str) -> Malformed {
440 match parse(text) {
441 Some(Parsed::Malformed(m)) => m,
442 other => panic!("expected a malformed directive, got {other:?}"),
443 }
444 }
445
446 fn rule_id(text: &str) -> Option<String> {
447 directive(text).rule().map(|r| r.id.clone())
448 }
449
450 #[test]
451 fn format_column() {
452 let d = directive("# arity-format skip: hand-aligned");
453 assert_eq!((d.tool, d.verb), (Tool::Format, Verb::Skip));
454 assert_eq!(d.reason, Some("hand-aligned"));
455 assert_eq!(d.scope, RuleScope::Unnamed);
456 assert!(!d.has_rule_slot());
457
458 assert_eq!(
459 (
460 directive("# arity-format off").tool,
461 directive("# arity-format off").verb
462 ),
463 (Tool::Format, Verb::Off)
464 );
465 assert_eq!(directive("# arity-format on").verb, Verb::On);
466 assert_eq!(
467 directive("# arity-format skip-file: generated").verb,
468 Verb::SkipFile
469 );
470 }
471
472 #[test]
473 fn lint_column() {
474 let d = directive("# arity-lint skip unused-binding: documented API");
475 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
476 assert_eq!(d.rule().unwrap().id, "unused-binding");
477 assert_eq!(d.reason, Some("documented API"));
478 assert_eq!(d.spelling, Spelling::Canonical);
479
480 let d = directive("# arity-lint skip-file unused-binding: codegen");
481 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
482 assert_eq!(d.rule().unwrap().id, "unused-binding");
483
484 let d = directive("# arity-lint skip-file: noisy");
485 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
486 assert_eq!(d.scope, RuleScope::All);
487 assert_eq!(d.reason, Some("noisy"));
488 }
489
490 #[test]
491 fn the_deprecated_spellings_still_parse_and_are_tagged() {
492 let d = directive("# arity-ignore unused-binding: documented API");
493 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
494 assert_eq!(d.rule().unwrap().id, "unused-binding");
495 assert_eq!(d.reason, Some("documented API"));
496 assert_eq!(d.spelling, Spelling::Deprecated);
497
498 let d = directive("# arity-ignore-file unused-binding: codegen");
499 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
500 assert_eq!(d.rule().unwrap().id, "unused-binding");
501 assert_eq!(d.spelling, Spelling::Deprecated);
502
503 let d = directive("# arity-ignore-file: noisy");
504 assert_eq!(d.scope, RuleScope::All);
505 assert_eq!(d.spelling, Spelling::Deprecated);
506 }
507
508 #[test]
509 fn lint_regions() {
510 let d = directive("# arity-lint off unused-binding: generated block");
511 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Off));
512 assert_eq!(d.rule().unwrap().id, "unused-binding");
513 assert_eq!(d.reason, Some("generated block"));
514
515 assert_eq!(directive("# arity-lint off: noisy").scope, RuleScope::All);
517 let d = directive("# arity-lint off");
518 assert_eq!(d.verb, Verb::Off);
519 assert_eq!(d.scope, RuleScope::Unnamed);
520
521 assert_eq!(directive("# arity-lint on").verb, Verb::On);
522 }
523
524 #[test]
525 fn both_column() {
526 for (text, verb) in [
527 ("# arity skip: leave it", Verb::Skip),
528 ("# arity off", Verb::Off),
529 ("# arity on", Verb::On),
530 ("# arity skip-file: vendored", Verb::SkipFile),
531 ] {
532 let d = directive(text);
533 assert_eq!((d.tool, d.verb), (Tool::Both, verb), "{text}");
534 assert_eq!(d.scope, RuleScope::All, "{text}");
536 }
537 }
538
539 #[test]
540 fn a_bare_arity_directive_needs_a_verb() {
541 assert_eq!(parse("# arity is great"), None);
544 assert_eq!(parse("# arity formats this badly"), None);
545 assert_eq!(parse("# arity"), None);
546 assert_eq!(parse("# arity: see below"), None);
547 }
548
549 #[test]
550 fn a_plain_comment_is_not_a_directive() {
551 assert_eq!(parse("# TODO: fix this"), None);
552 assert_eq!(parse("#"), None);
553 assert_eq!(parse(""), None);
554 assert_eq!(parse("# arity-formatter is the crate"), None);
556 assert_eq!(parse("# arity-ignored by everyone"), None);
557 }
558
559 #[test]
560 fn an_unknown_verb_is_reported_not_dropped() {
561 let m = malformed("# arity-format skipp: typo");
562 assert_eq!((m.tool, m.kind), (Tool::Format, MalformedKind::UnknownVerb));
563 assert_eq!(m.word, "skipp");
564 }
565
566 #[test]
567 fn a_missing_verb_is_reported() {
568 assert_eq!(malformed("# arity-format").kind, MalformedKind::MissingVerb);
569 assert_eq!(malformed("# arity-lint").kind, MalformedKind::MissingVerb);
570 assert_eq!(
571 malformed("# arity-format: because").kind,
572 MalformedKind::MissingVerb
573 );
574 }
575
576 #[test]
577 fn a_rule_id_on_a_format_directive_is_reported() {
578 let m = malformed("# arity-format skip unused-binding: no");
579 assert_eq!(m.kind, MalformedKind::UnexpectedWord);
580 assert_eq!(m.word, "unused-binding");
581 assert_eq!(
584 malformed("# arity skip unused-binding: no").kind,
585 MalformedKind::UnexpectedWord
586 );
587 assert_eq!(malformed("# arity-format off do not touch").word, "do");
589 }
590
591 #[test]
592 fn a_skip_verb_after_arity_ignore_is_reported() {
593 let m = malformed("# arity-ignore skip unused-binding: mixed spellings");
594 assert_eq!(
595 (m.tool, m.kind),
596 (Tool::Lint, MalformedKind::UnexpectedVerb)
597 );
598 assert_eq!(m.word, "skip");
599 assert_eq!(
600 malformed("# arity-ignore skip-file unused-binding: x").kind,
601 MalformedKind::UnexpectedVerb
602 );
603 }
604
605 #[test]
606 fn a_reason_is_optional_and_trimmed() {
607 assert_eq!(directive("# arity-format skip").reason, None);
608 assert_eq!(directive("# arity-format skip:").reason, None);
609 assert_eq!(directive("# arity-format skip: ").reason, None);
610 assert_eq!(
611 directive("# arity-format skip: spaced ").reason,
612 Some("spaced")
613 );
614 assert_eq!(directive("# arity-lint skip browser").reason, None);
615 }
616
617 #[test]
618 fn a_directive_with_no_rule_named_is_inert() {
619 let d = directive("# arity-lint skip");
621 assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
622 assert_eq!(d.scope, RuleScope::Unnamed);
623 assert!(d.has_rule_slot());
624
625 assert_eq!(
627 directive("# arity-lint skip-file").scope,
628 RuleScope::Unnamed
629 );
630 assert_eq!(
631 directive("# arity-lint skip: because").scope,
632 RuleScope::All
633 );
634 assert_eq!(directive("# arity-ignore").scope, RuleScope::Unnamed);
636 assert_eq!(directive("# arity-ignore-file").scope, RuleScope::Unnamed);
637 }
638
639 #[test]
640 fn an_on_names_nothing_by_design() {
641 let d = directive("# arity-lint on");
642 assert_eq!(d.scope, RuleScope::Unnamed);
643 assert!(!d.has_rule_slot(), "an `on` closes whatever is open");
644 }
645
646 #[test]
647 fn a_comma_list_reads_as_one_rule_id() {
648 assert_eq!(
651 rule_id("# arity-lint skip browser, repeat: debugging").as_deref(),
652 Some("browser,")
653 );
654 }
655
656 #[test]
657 fn ranges_are_relative_to_the_comment_start() {
658 let text = "# arity-lint skip unused-binding: why";
659 let d = directive(text);
660 let rule = d.rule().unwrap();
661 let start: usize = rule.range.start().into();
662 let end: usize = rule.range.end().into();
663 assert_eq!(&text[start..end], "unused-binding");
664
665 let m = malformed("# arity-format skipp: typo");
666 let start: usize = m.range.start().into();
667 let end: usize = m.range.end().into();
668 assert_eq!(&"# arity-format skipp: typo"[start..end], "skipp");
669 }
670
671 #[test]
672 fn a_missing_verb_points_at_the_prefix() {
673 let m = malformed("# arity-format");
674 let start: usize = m.range.start().into();
675 let end: usize = m.range.end().into();
676 assert_eq!(&"# arity-format"[start..end], "arity-format");
677 assert_eq!(m.word, "arity-format");
678 }
679
680 #[test]
681 fn the_prefix_range_spans_exactly_the_prefix() {
682 for text in [
683 "# arity-lint skip browser: r",
684 "# arity-format off",
685 "# arity-ignore-file browser: r",
686 "# arity-ignore browser: r",
687 "# arity skip: r",
688 ] {
689 let d = directive(text);
690 let start: usize = d.prefix.start().into();
691 let end: usize = d.prefix.end().into();
692 let written = &text[start..end];
693 assert!(
694 written.starts_with("arity") && !written.contains(char::is_whitespace),
695 "{text:?} -> {written:?}"
696 );
697 }
698 let d = directive("# arity-ignore-file browser: r");
699 let start: usize = d.prefix.start().into();
700 let end: usize = d.prefix.end().into();
701 assert_eq!(
702 &"# arity-ignore-file browser: r"[start..end],
703 "arity-ignore-file"
704 );
705 }
706
707 #[test]
708 fn a_verb_renders_as_written() {
709 assert_eq!(Verb::Skip.as_str(), "skip");
710 assert_eq!(Verb::SkipFile.as_str(), "skip-file");
711 assert_eq!(Verb::Off.as_str(), "off");
712 assert_eq!(Verb::On.as_str(), "on");
713 }
714
715 #[test]
716 fn tool_reports_what_it_affects() {
717 assert!(Tool::Both.affects_lint() && Tool::Both.affects_format());
718 assert!(Tool::Format.affects_format() && !Tool::Format.affects_lint());
719 assert!(Tool::Lint.affects_lint() && !Tool::Lint.affects_format());
720 }
721}