use rowan::{TextRange, TextSize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
Both,
Format,
Lint,
}
impl Tool {
pub fn affects_lint(self) -> bool {
matches!(self, Tool::Both | Tool::Lint)
}
pub fn affects_format(self) -> bool {
matches!(self, Tool::Both | Tool::Format)
}
pub fn prefix(self) -> &'static str {
match self {
Tool::Both => "arity",
Tool::Format => "arity-format",
Tool::Lint => "arity-lint",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Spelling {
Canonical,
Deprecated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Skip,
Off,
On,
SkipFile,
}
impl Verb {
pub fn as_str(self) -> &'static str {
match self {
Verb::Skip => "skip",
Verb::Off => "off",
Verb::On => "on",
Verb::SkipFile => "skip-file",
}
}
fn from_word(word: &str) -> Option<Self> {
match word {
"skip" => Some(Verb::Skip),
"off" => Some(Verb::Off),
"on" => Some(Verb::On),
"skip-file" => Some(Verb::SkipFile),
_ => None,
}
}
pub fn wants_reason(self) -> bool {
!matches!(self, Verb::On)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleRef {
pub id: String,
pub range: TextRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed {
pub tool: Tool,
pub kind: MalformedKind,
pub range: TextRange,
pub word: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MalformedKind {
UnknownVerb,
MissingVerb,
UnexpectedWord,
UnexpectedVerb,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleScope {
Rule(RuleRef),
All,
Unnamed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Directive<'a> {
pub tool: Tool,
pub verb: Verb,
pub prefix: TextRange,
pub scope: RuleScope,
pub reason: Option<&'a str>,
pub spelling: Spelling,
}
impl Directive<'_> {
pub fn rule(&self) -> Option<&RuleRef> {
match &self.scope {
RuleScope::Rule(rule) => Some(rule),
_ => None,
}
}
pub fn has_rule_slot(&self) -> bool {
self.tool.affects_lint() && self.verb != Verb::On
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Parsed<'a> {
Directive(Directive<'a>),
Malformed(Malformed),
}
pub fn parse(text: &str) -> Option<Parsed<'_>> {
let hash = text.find('#')?;
let (body, body_at) = trim_start_at(&text[hash + 1..], hash + 1);
if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore-file") {
return Some(Parsed::Directive(lint_tail(
Verb::SkipFile,
rest,
rest_at,
word_range(body_at, "arity-ignore-file"),
Spelling::Deprecated,
)));
}
if let Some((rest, rest_at)) = strip_word(body, body_at, "arity-ignore") {
return Some(arity_ignore(
rest,
rest_at,
word_range(body_at, "arity-ignore"),
));
}
for (prefix, tool) in [("arity-lint", Tool::Lint), ("arity-format", Tool::Format)] {
if let Some((rest, rest_at)) = strip_word(body, body_at, prefix) {
return Some(tool_prefixed(
tool,
rest,
rest_at,
word_range(body_at, prefix),
));
}
}
if let Some((rest, rest_at)) = strip_word(body, body_at, "arity") {
Verb::from_word(next_word(rest)?)?;
return Some(tool_prefixed(
Tool::Both,
rest,
rest_at,
word_range(body_at, "arity"),
));
}
None
}
fn arity_ignore(rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
match next_word(rest) {
Some(word) if Verb::from_word(word).is_some() => Parsed::Malformed(Malformed {
tool: Tool::Lint,
kind: MalformedKind::UnexpectedVerb,
range: TextRange::at(size(rest_at), size(word.len())),
word: word.to_string(),
}),
_ => Parsed::Directive(lint_tail(
Verb::Skip,
rest,
rest_at,
prefix,
Spelling::Deprecated,
)),
}
}
fn lint_tail(
verb: Verb,
rest: &str,
rest_at: usize,
prefix: TextRange,
spelling: Spelling,
) -> Directive<'_> {
let scope = match parse_rule(rest, rest_at) {
Some(rule) => RuleScope::Rule(rule),
None if rest.starts_with(':') => RuleScope::All,
None => RuleScope::Unnamed,
};
Directive {
tool: Tool::Lint,
verb,
prefix,
scope,
reason: parse_reason(rest),
spelling,
}
}
fn tool_prefixed(tool: Tool, rest: &str, rest_at: usize, prefix: TextRange) -> Parsed<'_> {
let Some(word) = next_word(rest) else {
return Parsed::Malformed(Malformed {
tool,
kind: MalformedKind::MissingVerb,
range: prefix,
word: tool.prefix().to_string(),
});
};
let Some(verb) = Verb::from_word(word) else {
return Parsed::Malformed(Malformed {
tool,
kind: MalformedKind::UnknownVerb,
range: TextRange::at(size(rest_at), size(word.len())),
word: word.to_string(),
});
};
let (tail, tail_at) = trim_start_at(&rest[word.len()..], rest_at + word.len());
let takes_rule = tool == Tool::Lint && verb != Verb::On;
if !takes_rule && let Some(extra) = next_word(tail) {
return Parsed::Malformed(Malformed {
tool,
kind: MalformedKind::UnexpectedWord,
range: TextRange::at(size(tail_at), size(extra.len())),
word: extra.to_string(),
});
}
let scope = if takes_rule {
match parse_rule(tail, tail_at) {
Some(rule) => RuleScope::Rule(rule),
None if tail.starts_with(':') => RuleScope::All,
None => RuleScope::Unnamed,
}
} else if tool == Tool::Both {
RuleScope::All
} else {
RuleScope::Unnamed
};
Parsed::Directive(Directive {
tool,
verb,
prefix,
scope,
reason: parse_reason(tail),
spelling: Spelling::Canonical,
})
}
fn strip_word<'a>(body: &'a str, body_at: usize, word: &str) -> Option<(&'a str, usize)> {
let rest = body.strip_prefix(word)?;
match rest.chars().next() {
None => Some((rest, body_at + word.len())),
Some(c) if c.is_whitespace() || c == ':' => Some(trim_start_at(rest, body_at + word.len())),
Some(_) => None,
}
}
fn next_word(rest: &str) -> Option<&str> {
let end = rest
.find(|c: char| c == ':' || c.is_whitespace())
.unwrap_or(rest.len());
(end > 0).then(|| &rest[..end])
}
fn parse_rule(rest: &str, rest_at: usize) -> Option<RuleRef> {
let word = next_word(rest)?;
Some(RuleRef {
id: word.to_string(),
range: TextRange::at(size(rest_at), size(word.len())),
})
}
fn parse_reason(rest: &str) -> Option<&str> {
let (_, reason) = rest.split_once(':')?;
let trimmed = reason.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
fn trim_start_at(s: &str, offset: usize) -> (&str, usize) {
let trimmed = s.trim_start();
(trimmed, offset + (s.len() - trimmed.len()))
}
fn word_range(at: usize, word: &str) -> TextRange {
TextRange::at(size(at), size(word.len()))
}
fn size(n: usize) -> TextSize {
TextSize::from(n as u32)
}
#[cfg(test)]
mod tests {
use super::*;
fn directive(text: &str) -> Directive<'_> {
match parse(text) {
Some(Parsed::Directive(d)) => d,
other => panic!("expected a directive, got {other:?}"),
}
}
fn malformed(text: &str) -> Malformed {
match parse(text) {
Some(Parsed::Malformed(m)) => m,
other => panic!("expected a malformed directive, got {other:?}"),
}
}
fn rule_id(text: &str) -> Option<String> {
directive(text).rule().map(|r| r.id.clone())
}
#[test]
fn format_column() {
let d = directive("# arity-format skip: hand-aligned");
assert_eq!((d.tool, d.verb), (Tool::Format, Verb::Skip));
assert_eq!(d.reason, Some("hand-aligned"));
assert_eq!(d.scope, RuleScope::Unnamed);
assert!(!d.has_rule_slot());
assert_eq!(
(
directive("# arity-format off").tool,
directive("# arity-format off").verb
),
(Tool::Format, Verb::Off)
);
assert_eq!(directive("# arity-format on").verb, Verb::On);
assert_eq!(
directive("# arity-format skip-file: generated").verb,
Verb::SkipFile
);
}
#[test]
fn lint_column() {
let d = directive("# arity-lint skip unused-binding: documented API");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
assert_eq!(d.rule().unwrap().id, "unused-binding");
assert_eq!(d.reason, Some("documented API"));
assert_eq!(d.spelling, Spelling::Canonical);
let d = directive("# arity-lint skip-file unused-binding: codegen");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
assert_eq!(d.rule().unwrap().id, "unused-binding");
let d = directive("# arity-lint skip-file: noisy");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
assert_eq!(d.scope, RuleScope::All);
assert_eq!(d.reason, Some("noisy"));
}
#[test]
fn the_deprecated_spellings_still_parse_and_are_tagged() {
let d = directive("# arity-ignore unused-binding: documented API");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
assert_eq!(d.rule().unwrap().id, "unused-binding");
assert_eq!(d.reason, Some("documented API"));
assert_eq!(d.spelling, Spelling::Deprecated);
let d = directive("# arity-ignore-file unused-binding: codegen");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::SkipFile));
assert_eq!(d.rule().unwrap().id, "unused-binding");
assert_eq!(d.spelling, Spelling::Deprecated);
let d = directive("# arity-ignore-file: noisy");
assert_eq!(d.scope, RuleScope::All);
assert_eq!(d.spelling, Spelling::Deprecated);
}
#[test]
fn lint_regions() {
let d = directive("# arity-lint off unused-binding: generated block");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Off));
assert_eq!(d.rule().unwrap().id, "unused-binding");
assert_eq!(d.reason, Some("generated block"));
assert_eq!(directive("# arity-lint off: noisy").scope, RuleScope::All);
let d = directive("# arity-lint off");
assert_eq!(d.verb, Verb::Off);
assert_eq!(d.scope, RuleScope::Unnamed);
assert_eq!(directive("# arity-lint on").verb, Verb::On);
}
#[test]
fn both_column() {
for (text, verb) in [
("# arity skip: leave it", Verb::Skip),
("# arity off", Verb::Off),
("# arity on", Verb::On),
("# arity skip-file: vendored", Verb::SkipFile),
] {
let d = directive(text);
assert_eq!((d.tool, d.verb), (Tool::Both, verb), "{text}");
assert_eq!(d.scope, RuleScope::All, "{text}");
}
}
#[test]
fn a_bare_arity_directive_needs_a_verb() {
assert_eq!(parse("# arity is great"), None);
assert_eq!(parse("# arity formats this badly"), None);
assert_eq!(parse("# arity"), None);
assert_eq!(parse("# arity: see below"), None);
}
#[test]
fn a_plain_comment_is_not_a_directive() {
assert_eq!(parse("# TODO: fix this"), None);
assert_eq!(parse("#"), None);
assert_eq!(parse(""), None);
assert_eq!(parse("# arity-formatter is the crate"), None);
assert_eq!(parse("# arity-ignored by everyone"), None);
}
#[test]
fn an_unknown_verb_is_reported_not_dropped() {
let m = malformed("# arity-format skipp: typo");
assert_eq!((m.tool, m.kind), (Tool::Format, MalformedKind::UnknownVerb));
assert_eq!(m.word, "skipp");
}
#[test]
fn a_missing_verb_is_reported() {
assert_eq!(malformed("# arity-format").kind, MalformedKind::MissingVerb);
assert_eq!(malformed("# arity-lint").kind, MalformedKind::MissingVerb);
assert_eq!(
malformed("# arity-format: because").kind,
MalformedKind::MissingVerb
);
}
#[test]
fn a_rule_id_on_a_format_directive_is_reported() {
let m = malformed("# arity-format skip unused-binding: no");
assert_eq!(m.kind, MalformedKind::UnexpectedWord);
assert_eq!(m.word, "unused-binding");
assert_eq!(
malformed("# arity skip unused-binding: no").kind,
MalformedKind::UnexpectedWord
);
assert_eq!(malformed("# arity-format off do not touch").word, "do");
}
#[test]
fn a_skip_verb_after_arity_ignore_is_reported() {
let m = malformed("# arity-ignore skip unused-binding: mixed spellings");
assert_eq!(
(m.tool, m.kind),
(Tool::Lint, MalformedKind::UnexpectedVerb)
);
assert_eq!(m.word, "skip");
assert_eq!(
malformed("# arity-ignore skip-file unused-binding: x").kind,
MalformedKind::UnexpectedVerb
);
}
#[test]
fn a_reason_is_optional_and_trimmed() {
assert_eq!(directive("# arity-format skip").reason, None);
assert_eq!(directive("# arity-format skip:").reason, None);
assert_eq!(directive("# arity-format skip: ").reason, None);
assert_eq!(
directive("# arity-format skip: spaced ").reason,
Some("spaced")
);
assert_eq!(directive("# arity-lint skip browser").reason, None);
}
#[test]
fn a_directive_with_no_rule_named_is_inert() {
let d = directive("# arity-lint skip");
assert_eq!((d.tool, d.verb), (Tool::Lint, Verb::Skip));
assert_eq!(d.scope, RuleScope::Unnamed);
assert!(d.has_rule_slot());
assert_eq!(
directive("# arity-lint skip-file").scope,
RuleScope::Unnamed
);
assert_eq!(
directive("# arity-lint skip: because").scope,
RuleScope::All
);
assert_eq!(directive("# arity-ignore").scope, RuleScope::Unnamed);
assert_eq!(directive("# arity-ignore-file").scope, RuleScope::Unnamed);
}
#[test]
fn an_on_names_nothing_by_design() {
let d = directive("# arity-lint on");
assert_eq!(d.scope, RuleScope::Unnamed);
assert!(!d.has_rule_slot(), "an `on` closes whatever is open");
}
#[test]
fn a_comma_list_reads_as_one_rule_id() {
assert_eq!(
rule_id("# arity-lint skip browser, repeat: debugging").as_deref(),
Some("browser,")
);
}
#[test]
fn ranges_are_relative_to_the_comment_start() {
let text = "# arity-lint skip unused-binding: why";
let d = directive(text);
let rule = d.rule().unwrap();
let start: usize = rule.range.start().into();
let end: usize = rule.range.end().into();
assert_eq!(&text[start..end], "unused-binding");
let m = malformed("# arity-format skipp: typo");
let start: usize = m.range.start().into();
let end: usize = m.range.end().into();
assert_eq!(&"# arity-format skipp: typo"[start..end], "skipp");
}
#[test]
fn a_missing_verb_points_at_the_prefix() {
let m = malformed("# arity-format");
let start: usize = m.range.start().into();
let end: usize = m.range.end().into();
assert_eq!(&"# arity-format"[start..end], "arity-format");
assert_eq!(m.word, "arity-format");
}
#[test]
fn the_prefix_range_spans_exactly_the_prefix() {
for text in [
"# arity-lint skip browser: r",
"# arity-format off",
"# arity-ignore-file browser: r",
"# arity-ignore browser: r",
"# arity skip: r",
] {
let d = directive(text);
let start: usize = d.prefix.start().into();
let end: usize = d.prefix.end().into();
let written = &text[start..end];
assert!(
written.starts_with("arity") && !written.contains(char::is_whitespace),
"{text:?} -> {written:?}"
);
}
let d = directive("# arity-ignore-file browser: r");
let start: usize = d.prefix.start().into();
let end: usize = d.prefix.end().into();
assert_eq!(
&"# arity-ignore-file browser: r"[start..end],
"arity-ignore-file"
);
}
#[test]
fn a_verb_renders_as_written() {
assert_eq!(Verb::Skip.as_str(), "skip");
assert_eq!(Verb::SkipFile.as_str(), "skip-file");
assert_eq!(Verb::Off.as_str(), "off");
assert_eq!(Verb::On.as_str(), "on");
}
#[test]
fn tool_reports_what_it_affects() {
assert!(Tool::Both.affects_lint() && Tool::Both.affects_format());
assert!(Tool::Format.affects_format() && !Tool::Format.affects_lint());
assert!(Tool::Lint.affects_lint() && !Tool::Lint.affects_format());
}
}