use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::config::ConfigManager;
use crate::paths::absolute_normalized;
pub const DEFAULT_TAGS: [&str; 4] = ["TODO", "FIXME", "HACK", "XXX"];
pub const DEFAULT_CANONICAL_TAG: &str = "TODO";
pub const DEFAULT_KEY_PATTERN: &str = r"^\s*(?:TODO|FIXME|HACK|XXX)\((?<key>[A-Z][A-Z0-9]+-\d+)\)\s*:";
pub const DEFAULT_BRANCH_PATTERN: &str = r"([A-Z][A-Z0-9]+-\d+)";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warn,
Off,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warn => "warn",
Severity::Off => "off",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Rule {
TagNotCanonical,
TodoMissingKey,
TodoKeyNotUpperCase,
TagFormNotCanonical,
TodoSelfReference,
}
impl Rule {
pub const ALL: [Rule; 5] = [
Rule::TagNotCanonical,
Rule::TodoMissingKey,
Rule::TodoKeyNotUpperCase,
Rule::TagFormNotCanonical,
Rule::TodoSelfReference,
];
pub fn as_str(self) -> &'static str {
match self {
Rule::TagNotCanonical => "tag-not-canonical",
Rule::TodoMissingKey => "todo-missing-key",
Rule::TodoKeyNotUpperCase => "todo-key-not-upper-case",
Rule::TagFormNotCanonical => "tag-form-not-canonical",
Rule::TodoSelfReference => "todo-self-reference",
}
}
fn index(self) -> usize {
match self {
Rule::TagNotCanonical => 0,
Rule::TodoMissingKey => 1,
Rule::TodoKeyNotUpperCase => 2,
Rule::TagFormNotCanonical => 3,
Rule::TodoSelfReference => 4,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RuleTable {
#[serde(rename = "tag-not-canonical", skip_serializing_if = "Option::is_none")]
pub tag_not_canonical: Option<Severity>,
#[serde(rename = "todo-missing-key", skip_serializing_if = "Option::is_none")]
pub todo_missing_key: Option<Severity>,
#[serde(rename = "todo-key-not-upper-case", skip_serializing_if = "Option::is_none")]
pub todo_key_not_upper_case: Option<Severity>,
#[serde(rename = "tag-form-not-canonical", skip_serializing_if = "Option::is_none")]
pub tag_form_not_canonical: Option<Severity>,
#[serde(rename = "todo-self-reference", skip_serializing_if = "Option::is_none")]
pub todo_self_reference: Option<Severity>,
}
impl RuleTable {
fn layer_over(&self, base: &RuleTable) -> RuleTable {
RuleTable {
tag_not_canonical: self.tag_not_canonical.or(base.tag_not_canonical),
todo_missing_key: self.todo_missing_key.or(base.todo_missing_key),
todo_key_not_upper_case: self.todo_key_not_upper_case.or(base.todo_key_not_upper_case),
tag_form_not_canonical: self.tag_form_not_canonical.or(base.tag_form_not_canonical),
todo_self_reference: self.todo_self_reference.or(base.todo_self_reference),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LintTable {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub case_sensitive_tags: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_issue_from_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_doc_comments: Option<bool>,
#[serde(default)]
pub rules: RuleTable,
}
impl LintTable {
pub fn layer_over(&self, base: &LintTable) -> LintTable {
LintTable {
enabled: self.enabled.or(base.enabled),
tags: self.tags.clone().or_else(|| base.tags.clone()),
canonical_tag: self.canonical_tag.clone().or_else(|| base.canonical_tag.clone()),
case_sensitive_tags: self.case_sensitive_tags.or(base.case_sensitive_tags),
key_pattern: self.key_pattern.clone().or_else(|| base.key_pattern.clone()),
current_issue_from_branch: self
.current_issue_from_branch
.clone()
.or_else(|| base.current_issue_from_branch.clone()),
include_doc_comments: self.include_doc_comments.or(base.include_doc_comments),
rules: self.rules.layer_over(&base.rules),
}
}
pub fn validate(&self) -> Result<()> {
if let Some(tags) = &self.tags {
if tags.is_empty() {
bail!("lint.tags is empty: there is nothing to lint for");
}
if let Some(blank) = tags.iter().find(|tag| tag.trim().is_empty()) {
bail!("lint.tags contains a blank tag {blank:?}");
}
}
if let Some(pattern) = &self.key_pattern {
compile(pattern, "lint.key_pattern", "")?;
}
if let Some(pattern) = &self.current_issue_from_branch {
compile(pattern, "lint.current_issue_from_branch", "")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagSpelling {
Canonical,
CanonicalMiscased,
NotCanonical,
}
#[derive(Debug)]
pub struct LintConfig {
pub enabled: bool,
pub tags: Vec<String>,
pub canonical_tag: String,
pub case_sensitive_tags: bool,
pub key_pattern: Regex,
key_pattern_ci: Regex,
pub branch_pattern: Regex,
pub tag_pattern: Regex,
pub include_doc_comments: bool,
severities: [Severity; 5],
pub source: Option<PathBuf>,
}
impl LintConfig {
pub fn defaults() -> Result<Self> {
Self::from_table(&LintTable::default(), None)
}
pub fn from_table(table: &LintTable, source: Option<PathBuf>) -> Result<Self> {
let where_from = || match &source {
Some(path) => format!(" in {}", path.display()),
None => String::new(),
};
let case_sensitive_tags = table.case_sensitive_tags.unwrap_or(false);
let tags: Vec<String> = table
.tags
.clone()
.unwrap_or_else(|| DEFAULT_TAGS.iter().map(|tag| (*tag).to_string()).collect());
if tags.is_empty() {
bail!("lint.tags{} is empty: there is nothing to lint for", where_from());
}
if let Some(blank) = tags.iter().find(|tag| tag.trim().is_empty()) {
bail!("lint.tags{} contains a blank tag {blank:?}", where_from());
}
if !case_sensitive_tags {
for (index, tag) in tags.iter().enumerate() {
if let Some(other) = tags[index + 1..].iter().find(|later| later.eq_ignore_ascii_case(tag)) {
bail!(
"lint.tags{} lists {tag:?} and {other:?}, which differ only in casing: with \
lint.case_sensitive_tags = false they are the same tag",
where_from()
);
}
}
}
let canonical_tag = table
.canonical_tag
.clone()
.unwrap_or_else(|| DEFAULT_CANONICAL_TAG.to_string());
if !tags.iter().any(|tag| tag == &canonical_tag) {
bail!(
"lint.canonical_tag{} is {canonical_tag:?}, which is not one of lint.tags {tags:?}",
where_from()
);
}
let key_pattern_source = table.key_pattern.as_deref().unwrap_or(DEFAULT_KEY_PATTERN);
let key_pattern = compile(key_pattern_source, "lint.key_pattern", &where_from())?;
let key_pattern_ci = compile(&format!("(?i){key_pattern_source}"), "lint.key_pattern", &where_from())?;
let branch_pattern = compile(
table
.current_issue_from_branch
.as_deref()
.unwrap_or(DEFAULT_BRANCH_PATTERN),
"lint.current_issue_from_branch",
&where_from(),
)?;
let mut alternation = String::new();
if !case_sensitive_tags {
alternation.push_str("(?i)");
}
alternation.push_str(r"\b(?:");
let mut ordered = tags.clone();
ordered.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
for (index, tag) in ordered.iter().enumerate() {
if index > 0 {
alternation.push('|');
}
alternation.push_str(®ex::escape(tag));
}
alternation.push_str(r")\b");
let tag_pattern = compile(&alternation, "lint.tags", &where_from())?;
let severities = [
table.rules.tag_not_canonical.unwrap_or(Severity::Error),
table.rules.todo_missing_key.unwrap_or(Severity::Error),
table.rules.todo_key_not_upper_case.unwrap_or(Severity::Error),
table.rules.tag_form_not_canonical.unwrap_or(Severity::Error),
table.rules.todo_self_reference.unwrap_or(Severity::Error),
];
Ok(Self {
enabled: table.enabled.unwrap_or(false),
tags,
canonical_tag,
case_sensitive_tags,
key_pattern,
key_pattern_ci,
branch_pattern,
tag_pattern,
include_doc_comments: table.include_doc_comments.unwrap_or(false),
severities,
source,
})
}
pub fn classify_tag(&self, tag: &str) -> TagSpelling {
if tag == self.canonical_tag {
TagSpelling::Canonical
} else if tag.eq_ignore_ascii_case(&self.canonical_tag) {
TagSpelling::CanonicalMiscased
} else {
TagSpelling::NotCanonical
}
}
pub fn tag_not_canonical_message(&self, tag: &str) -> String {
match self.classify_tag(tag) {
TagSpelling::CanonicalMiscased => {
format!("`{tag}` is `{}` written with the wrong casing", self.canonical_tag)
}
_ => format!("`{tag}` should be written as `{}`", self.canonical_tag),
}
}
pub fn severity(&self, rule: Rule) -> Severity {
self.severities[rule.index()]
}
pub fn is_on(&self, rule: Rule) -> bool {
self.severity(rule) != Severity::Off
}
pub fn extract_key<'t>(&self, haystack: &'t str) -> Option<&'t str> {
if let Some(range) = self.key_range(haystack) {
return haystack.get(range);
}
if self.case_sensitive_tags {
return None;
}
let respelled = self.tag_respelled_as_configured(haystack)?;
let range = self.key_range(&respelled)?;
haystack.get(range)
}
pub fn extract_key_relaxed<'t>(&self, haystack: &'t str) -> Option<(&'t str, std::ops::Range<usize>)> {
if let Some(range) = self.key_range(haystack) {
return Some((&haystack[range.clone()], range));
}
if !self.case_sensitive_tags
&& let Some(respelled) = self.tag_respelled_as_configured(haystack)
&& let Some(range) = self.key_range(&respelled)
{
return Some((&haystack[range.clone()], range));
}
let range = self.key_range_ci(haystack)?;
Some((&haystack[range.clone()], range))
}
fn key_range_ci(&self, haystack: &str) -> Option<std::ops::Range<usize>> {
let captures = self.key_pattern_ci.captures(haystack)?;
captures
.name("key")
.or_else(|| captures.get(1))
.or_else(|| captures.get(0))
.map(|matched| matched.range())
}
fn key_range(&self, haystack: &str) -> Option<std::ops::Range<usize>> {
let captures = self.key_pattern.captures(haystack)?;
captures
.name("key")
.or_else(|| captures.get(1))
.or_else(|| captures.get(0))
.map(|matched| matched.range())
}
fn tag_respelled_as_configured(&self, haystack: &str) -> Option<String> {
let found = self.tag_pattern.find(haystack)?;
let written = found.as_str();
let configured = self.tags.iter().find(|tag| tag.eq_ignore_ascii_case(written))?;
if configured == written || configured.len() != written.len() {
return None;
}
let mut respelled = String::with_capacity(haystack.len());
respelled.push_str(&haystack[..found.start()]);
respelled.push_str(configured);
respelled.push_str(&haystack[found.end()..]);
Some(respelled)
}
}
fn compile(pattern: &str, key: &str, where_from: &str) -> Result<Regex> {
Regex::new(pattern).with_context(|| format!("invalid regex for {key}{where_from}: {pattern}"))
}
#[derive(Debug)]
pub struct Resolver<'manager> {
manager: &'manager ConfigManager,
base: PathBuf,
cache: HashMap<PathBuf, Arc<LintConfig>>,
}
impl<'manager> Resolver<'manager> {
pub fn new(base: &Path, manager: &'manager ConfigManager) -> Self {
Self {
manager,
base: base.to_path_buf(),
cache: HashMap::new(),
}
}
pub fn for_file(&mut self, file: &Path) -> Result<Arc<LintConfig>> {
let absolute = absolute_normalized(&self.base, file);
let dir = absolute.parent().unwrap_or(&absolute).to_path_buf();
if let Some(cached) = self.cache.get(&dir) {
return Ok(Arc::clone(cached));
}
let (table, source) = self.manager.lint_table_for_file(&absolute);
let resolved = Arc::new(LintConfig::from_table(&table.unwrap_or_default(), source)?);
self.cache.insert(dir, Arc::clone(&resolved));
Ok(resolved)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::config::Config;
fn table(toml: &str) -> Result<LintConfig> {
let config: Config = toml::from_str(toml)?;
LintConfig::from_table(&config.lint.unwrap_or_default(), None)
}
fn resolve(root: &Path, file: &str) -> Result<Arc<LintConfig>> {
let manager = ConfigManager::new(root)?;
Resolver::new(root, &manager).for_file(&root.join(file))
}
fn repo() -> TempDir {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".git")).unwrap();
temp
}
#[test]
fn lint_is_inert_until_it_is_enabled() {
let defaults = LintConfig::defaults().unwrap();
assert!(!defaults.enabled);
assert!(table("[lint]\n").unwrap().enabled_is(false));
assert!(table("[lint]\nenabled = true\n").unwrap().enabled);
assert!(!table("[lint]\nenabled = false\n").unwrap().enabled);
}
#[test]
fn enabled_alone_is_enough_to_get_the_documented_defaults() {
let config = table("[lint]\nenabled = true\n").unwrap();
assert_eq!(config.tags, DEFAULT_TAGS);
assert_eq!(config.canonical_tag, "TODO");
for rule in Rule::ALL {
assert_eq!(config.severity(rule), Severity::Error, "{}", rule.as_str());
}
}
#[test]
fn the_default_key_pattern_uses_a_named_group_and_the_crates_regex_accepts_it() {
let config = table("[lint]\nenabled = true\n").unwrap();
assert!(
config.key_pattern.capture_names().any(|name| name == Some("key")),
"the `key` group is what makes the pattern self-documenting"
);
assert_eq!(config.extract_key("TODO(AMVP-160815): migrate"), Some("AMVP-160815"));
assert_eq!(config.extract_key("FIXME(PPSC-42): later"), Some("PPSC-42"));
assert_eq!(config.extract_key("TODO: no key here"), None);
assert_eq!(config.extract_key("TODO(lowercase-1): no"), None);
}
#[test]
fn a_pattern_without_a_named_group_falls_back_to_the_first_group() {
let config = table(
r#"
[lint]
enabled = true
key_pattern = '^\s*(?:TODO)\[([A-Z]+-\d+)\]'
"#,
)
.unwrap();
assert_eq!(config.extract_key("TODO[ABC-7] do it"), Some("ABC-7"));
}
#[test]
fn both_armis_branch_shapes_yield_the_same_key_with_the_default_pattern() {
let config = table("[lint]\nenabled = true\n").unwrap();
for branch in [
"naaman.hirschfeld.AMVP-160815.ai-rulez-migration",
"naaman.AMVP-160815.ai-rulez-migration",
] {
let captures = config.branch_pattern.captures(branch);
let key = captures.as_ref().and_then(|c| c.get(1)).map(|m| m.as_str());
assert_eq!(key, Some("AMVP-160815"), "branch {branch}");
}
assert!(config.branch_pattern.captures("master").is_none());
assert!(config.branch_pattern.captures("feat/comment-inventory").is_none());
}
#[test]
fn an_invalid_key_pattern_is_a_load_error_naming_the_key() {
let err = table("[lint]\nenabled = true\nkey_pattern = '([unclosed'\n").unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("lint.key_pattern"), "{message}");
}
#[test]
fn an_invalid_branch_pattern_is_a_load_error_naming_the_key() {
let err = table("[lint]\nenabled = true\ncurrent_issue_from_branch = '(?P<'\n").unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("lint.current_issue_from_branch"), "{message}");
}
#[test]
fn a_canonical_tag_outside_tags_is_rejected() {
let err = table("[lint]\nenabled = true\ntags = ['TODO']\ncanonical_tag = 'NOTE'\n").unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("lint.canonical_tag"), "{message}");
}
#[test]
fn empty_or_blank_tags_are_rejected() {
assert!(format!("{:#}", table("[lint]\ntags = []\n").unwrap_err()).contains("lint.tags"));
assert!(format!("{:#}", table("[lint]\ntags = ['TODO', ' ']\n").unwrap_err()).contains("lint.tags"));
}
#[test]
fn a_typo_inside_the_lint_table_is_rejected() {
let err = toml::from_str::<Config>("[lint]\nenable = true\n").unwrap_err();
assert!(err.to_string().contains("enable"), "{err}");
}
#[test]
fn one_document_carries_the_removal_settings_and_the_lint_table() {
let config: Config = toml::from_str(
r#"
[global]
remove_todos = true
[lint]
enabled = true
"#,
)
.unwrap();
assert!(config.global.remove_todos);
assert!(LintConfig::from_table(&config.lint.unwrap(), None).unwrap().enabled);
}
#[test]
fn per_rule_severities_override_only_what_they_name() {
let config = table(
r#"
[lint]
enabled = true
[lint.rules]
tag-not-canonical = "warn"
todo-self-reference = "off"
"#,
)
.unwrap();
assert_eq!(config.severity(Rule::TagNotCanonical), Severity::Warn);
assert_eq!(config.severity(Rule::TodoMissingKey), Severity::Error);
assert_eq!(config.severity(Rule::TodoSelfReference), Severity::Off);
assert!(!config.is_on(Rule::TodoSelfReference));
}
#[test]
fn tags_are_matched_longest_first_and_on_word_boundaries() {
let config = table("[lint]\nenabled = true\ntags = ['TODO', 'TODOLATER']\n").unwrap();
let found: Vec<&str> = config
.tag_pattern
.find_iter("TODOLATER and TODO and TODOS")
.map(|m| m.as_str())
.collect();
assert_eq!(found, vec!["TODOLATER", "TODO"]);
}
#[test]
fn a_tag_with_regex_metacharacters_is_matched_literally() {
let config = table("[lint]\nenabled = true\ntags = ['TODO', 'T.DO']\n").unwrap();
assert!(config.tag_pattern.is_match("T.DO: x"));
assert!(!config.tag_pattern.is_match("TXDO: x"));
}
#[test]
fn tags_are_matched_regardless_of_casing_by_default() {
let config = table("[lint]\nenabled = true\n").unwrap();
assert!(!config.case_sensitive_tags, "case-insensitive is the default");
for written in ["TODO", "todo", "Todo", "ToDo", "FIXME", "fixme", "Fixme", "hack", "xXx"] {
assert!(config.tag_pattern.is_match(&format!("// {written}: x")), "{written}");
}
assert!(!config.tag_pattern.is_match("// TODOS are tracked in Jira"));
assert!(!config.tag_pattern.is_match("// todos are tracked in Jira"));
}
#[test]
fn case_sensitive_tags_restores_literal_matching() {
let config = table("[lint]\nenabled = true\ncase_sensitive_tags = true\n").unwrap();
assert!(config.case_sensitive_tags);
assert!(config.tag_pattern.is_match("// TODO: x"));
for written in ["todo", "Todo", "fixme", "xXx"] {
assert!(!config.tag_pattern.is_match(&format!("// {written}: x")), "{written}");
}
}
#[test]
fn a_miscased_tag_is_classified_apart_from_a_differently_spelled_one() {
let config = table("[lint]\nenabled = true\n").unwrap();
assert_eq!(config.classify_tag("TODO"), TagSpelling::Canonical);
assert_eq!(config.classify_tag("todo"), TagSpelling::CanonicalMiscased);
assert_eq!(config.classify_tag("Todo"), TagSpelling::CanonicalMiscased);
assert_eq!(config.classify_tag("FIXME"), TagSpelling::NotCanonical);
assert_eq!(config.classify_tag("fixme"), TagSpelling::NotCanonical);
}
#[test]
fn a_casing_only_violation_does_not_read_as_a_tautology() {
let config = table("[lint]\nenabled = true\n").unwrap();
let miscased = config.tag_not_canonical_message("todo");
assert!(miscased.contains("casing"), "{miscased}");
assert!(miscased.contains("`todo`") && miscased.contains("`TODO`"), "{miscased}");
assert_eq!(
config.tag_not_canonical_message("FIXME"),
"`FIXME` should be written as `TODO`"
);
assert_eq!(
config.tag_not_canonical_message("fixme"),
"`fixme` should be written as `TODO`"
);
}
#[test]
fn a_miscased_tag_does_not_hide_its_key_but_a_miscased_key_is_still_rejected() {
let config = table("[lint]\nenabled = true\n").unwrap();
assert_eq!(config.extract_key("todo(AMVP-1): x"), Some("AMVP-1"));
assert_eq!(config.extract_key("Todo(AMVP-1): x"), Some("AMVP-1"));
assert_eq!(config.extract_key("fixme(PPSC-42): x"), Some("PPSC-42"));
assert_eq!(config.extract_key("todo(amvp-1): x"), None);
assert_eq!(config.extract_key("TODO(amvp-1): x"), None);
assert_eq!(config.extract_key("fixme(ppsc-42): x"), None);
}
#[test]
fn case_sensitive_tags_keeps_the_key_pattern_exactly_as_strict_as_it_is_written() {
let config = table("[lint]\nenabled = true\ncase_sensitive_tags = true\n").unwrap();
assert_eq!(config.extract_key("TODO(AMVP-1): x"), Some("AMVP-1"));
assert_eq!(config.extract_key("todo(AMVP-1): x"), None);
}
#[test]
fn tags_differing_only_in_casing_are_rejected_unless_matching_is_case_sensitive() {
let error = table("[lint]\nenabled = true\ntags = ['TODO', 'todo']\n").unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("lint.tags"), "{message}");
assert!(message.contains("casing"), "{message}");
let config = table("[lint]\nenabled = true\ntags = ['TODO', 'todo']\ncase_sensitive_tags = true\n").unwrap();
assert_eq!(config.tags, vec!["TODO", "todo"]);
}
#[test]
fn a_canonical_tag_that_only_differs_from_tags_in_casing_is_still_rejected() {
let error = table("[lint]\nenabled = true\ntags = ['TODO']\ncanonical_tag = 'todo'\n").unwrap_err();
assert!(format!("{error:#}").contains("lint.canonical_tag"));
}
#[test]
fn case_sensitive_tags_layers_key_by_key_and_absent_is_not_false() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(
root.join(".uncomment.toml"),
"[lint]\nenabled = true\ncase_sensitive_tags = true\n",
)
.unwrap();
fs::write(
root.join("nested/.uncomment.toml"),
"[lint]\ntags = ['NOTE', 'note']\ncanonical_tag = 'NOTE'\n",
)
.unwrap();
assert!(resolve(root, "a.rs").unwrap().case_sensitive_tags);
let below = resolve(root, "nested/b.rs").unwrap();
assert!(
below.case_sensitive_tags,
"an absent key must inherit, not reset to the default"
);
assert_eq!(below.tags, vec!["NOTE", "note"]);
fs::write(
root.join("nested/.uncomment.toml"),
"[lint]\ncase_sensitive_tags = false\n",
)
.unwrap();
assert!(!resolve(root, "nested/b.rs").unwrap().case_sensitive_tags);
}
#[test]
fn a_table_naming_only_case_sensitive_tags_is_valid_on_its_own() {
let table: LintTable = toml::from_str("case_sensitive_tags = true\n").unwrap();
assert!(table.validate().is_ok());
assert_eq!(table.case_sensitive_tags, Some(true));
let error = toml::from_str::<LintTable>("case_sensitive_tag = true\n").unwrap_err();
assert!(error.to_string().contains("case_sensitive_tag"), "{error}");
}
#[test]
fn doc_comments_are_out_of_scope_unless_include_doc_comments_is_set() {
assert!(!table("[lint]\nenabled = true\n").unwrap().include_doc_comments);
assert!(
table("[lint]\nenabled = true\ninclude_doc_comments = true\n")
.unwrap()
.include_doc_comments
);
assert!(
!table("[lint]\nenabled = true\ninclude_doc_comments = false\n")
.unwrap()
.include_doc_comments
);
}
#[test]
fn include_doc_comments_layers_key_by_key_and_absent_is_not_false() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(
root.join(".uncomment.toml"),
"[lint]\nenabled = true\ninclude_doc_comments = true\n",
)
.unwrap();
fs::write(
root.join("nested/.uncomment.toml"),
"[lint.rules]\ntag-not-canonical = 'off'\n",
)
.unwrap();
assert!(resolve(root, "a.rs").unwrap().include_doc_comments);
assert!(
resolve(root, "nested/b.rs").unwrap().include_doc_comments,
"an absent key must inherit, not reset to the default"
);
fs::write(
root.join("nested/.uncomment.toml"),
"[lint]\ninclude_doc_comments = false\n",
)
.unwrap();
assert!(!resolve(root, "nested/b.rs").unwrap().include_doc_comments);
}
#[test]
fn a_table_naming_only_include_doc_comments_is_valid_and_a_typo_in_it_is_not() {
let table: LintTable = toml::from_str("include_doc_comments = true\n").unwrap();
assert!(table.validate().is_ok());
assert_eq!(table.include_doc_comments, Some(true));
let error = toml::from_str::<LintTable>("include_doc_comment = true\n").unwrap_err();
assert!(error.to_string().contains("include_doc_comment"), "{error}");
}
#[test]
fn a_nested_table_overrides_only_the_keys_it_names() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested/deeper")).unwrap();
fs::write(
root.join(".uncomment.toml"),
"[lint]\nenabled = true\ntags = ['TODO']\n\n[lint.rules]\ntag-not-canonical = 'warn'\n",
)
.unwrap();
fs::write(
root.join("nested/.uncomment.toml"),
"[lint]\ntags = ['NOTE']\ncanonical_tag = 'NOTE'\n",
)
.unwrap();
let above = resolve(root, "a.rs").unwrap();
assert_eq!(above.tags, vec!["TODO"]);
assert_eq!(above.canonical_tag, "TODO");
let below = resolve(root, "nested/b.rs").unwrap();
assert_eq!(below.tags, vec!["NOTE"]);
assert!(below.enabled, "`enabled` must come from the config above");
assert_eq!(below.severity(Rule::TagNotCanonical), Severity::Warn);
assert_eq!(below.severity(Rule::TodoMissingKey), Severity::Error);
assert_eq!(resolve(root, "nested/deeper/c.rs").unwrap().tags, vec!["NOTE"]);
}
#[test]
fn a_config_without_a_lint_table_leaves_the_one_above_it_in_force() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join(".uncomment.toml"), "[lint]\nenabled = true\n").unwrap();
fs::write(root.join("nested/.uncomment.toml"), "[global]\nremove_todos = true\n").unwrap();
assert!(resolve(root, "nested/b.rs").unwrap().enabled);
}
#[test]
fn discovery_stops_at_the_git_root() {
let temp = TempDir::new().unwrap();
let outside = temp.path();
let repo = outside.join("repo");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::write(outside.join(".uncomment.toml"), "[lint]\nenabled = true\n").unwrap();
assert!(
!resolve(&repo, "a.rs").unwrap().enabled,
"a config above the repository root must not apply"
);
}
#[test]
fn a_forced_config_file_skips_discovery_entirely() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join(".uncomment.toml"), "[lint]\nenabled = false\n").unwrap();
fs::write(root.join("nested/.uncomment.toml"), "[lint]\ntags = ['XXX']\n").unwrap();
let forced = root.join("forced.toml");
fs::write(
&forced,
"[lint]\nenabled = true\ntags = ['NOTE']\ncanonical_tag = 'NOTE'\n",
)
.unwrap();
let manager = ConfigManager::from_config_file(root, &forced).unwrap();
let mut resolver = Resolver::new(root, &manager);
for file in ["a.rs", "nested/b.rs"] {
let config = resolver.for_file(&root.join(file)).unwrap();
assert!(config.enabled, "{file}");
assert_eq!(config.tags, vec!["NOTE"], "{file}");
}
}
#[test]
fn an_invalid_pattern_in_a_discovered_file_names_that_file() {
let temp = repo();
let root = temp.path();
fs::write(
root.join(".uncomment.toml"),
"[lint]\nenabled = true\nkey_pattern = '([unclosed'\n",
)
.unwrap();
let error = ConfigManager::new(root).unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("lint.key_pattern"), "{message}");
assert!(message.contains(".uncomment.toml"), "{message}");
}
#[test]
fn an_invalid_pattern_below_the_invocation_directory_names_that_file() {
let temp = repo();
let root = temp.path();
fs::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join(".uncomment.toml"), "[lint]\nenabled = true\n").unwrap();
fs::write(
root.join("nested/.uncomment.toml"),
"[lint]\nkey_pattern = '([unclosed'\n",
)
.unwrap();
let manager = ConfigManager::new(root).unwrap();
Resolver::new(root, &manager)
.for_file(&root.join("nested/b.rs"))
.unwrap();
let recorded = manager.deferred_config_error().expect("the broken config was rejected");
assert!(recorded.contains("lint.key_pattern"), "{recorded}");
assert!(recorded.contains("nested"), "{recorded}");
}
#[test]
fn a_table_is_validated_on_its_own_before_it_is_layered() {
let broken: LintTable = toml::from_str("key_pattern = '([unclosed'\n").unwrap();
let message = format!("{:#}", broken.validate().unwrap_err());
assert!(message.contains("lint.key_pattern"), "{message}");
let partial: LintTable = toml::from_str("tags = ['NOTE']\n").unwrap();
assert!(partial.validate().is_ok());
assert!(toml::from_str::<LintTable>("tags = []\n").unwrap().validate().is_err());
}
impl LintConfig {
fn enabled_is(&self, expected: bool) -> bool {
self.enabled == expected
}
}
}