Skip to main content

lanekeep_core/
rule_id.rs

1//! Rule identity.
2//!
3//! A rule ID appears in four places that are expensive to change once users exist: config
4//! files, suppression comments in source, JSON output consumed by other tools, and CI
5//! configuration filtering on specific rules. Architecture §14 lists namespacing as the
6//! most expensive of the one-way doors for exactly that reason.
7//!
8//! Parsing is therefore strict. Every rejection here is a case that would otherwise become
9//! two IDs users believe are one, or one ID they believe is two.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use thiserror::Error;
16
17/// Where a rule came from.
18///
19/// Open at the syntax level and closed at the config level, which is not the same thing as
20/// being open. Parsing accepts any well-formed namespace so that a team can group its rules
21/// under its own name — `pera/no-numeric-sizes` rather than a `local/` bucket shared with
22/// everything else. What keeps a typo from becoming a valid-but-inert ID is that the config
23/// refuses a namespace nobody declared: `lanekep/foo` fails at load, naming the namespaces
24/// that do exist.
25///
26/// `lanekeep` stays reserved for rules shipped here, so a rule's origin is still readable
27/// from its ID alone — the property §14.1 locked in.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct Namespace(String);
30
31impl Namespace {
32    /// Rules shipped with lanekeep and reviewed by a maintainer.
33    pub const LANEKEEP: &'static str = "lanekeep";
34    /// The default for rules authored in the project being checked.
35    pub const LOCAL: &'static str = "local";
36
37    /// The namespace as it appears in a rule ID.
38    #[must_use]
39    pub fn as_str(&self) -> &str {
40        &self.0
41    }
42
43    /// Whether this is one of the two lanekeep defines, which never need declaring.
44    #[must_use]
45    pub fn is_built_in(&self) -> bool {
46        self.0 == Self::LANEKEEP || self.0 == Self::LOCAL
47    }
48
49    /// Whether this is the reserved namespace for rules shipped with lanekeep.
50    #[must_use]
51    pub fn is_lanekeep(&self) -> bool {
52        self.0 == Self::LANEKEEP
53    }
54
55    /// The namespaces that need no declaring, for diagnostics that list the valid options.
56    #[must_use]
57    pub const fn built_ins() -> &'static [&'static str] {
58        &[Self::LANEKEEP, Self::LOCAL]
59    }
60}
61
62impl fmt::Display for Namespace {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(&self.0)
65    }
66}
67
68/// Why a string is not a valid rule ID.
69///
70/// Each variant carries what was actually seen. A diagnostic that says only "invalid rule
71/// ID" makes the reader diff two strings by eye.
72#[derive(Debug, Clone, PartialEq, Eq, Error)]
73pub enum ParseRuleIdError {
74    /// No `/` separating namespace from name.
75    #[error(
76        "rule ID `{0}` has no namespace: write `lanekeep/{0}` for a built-in rule or \
77         `local/{0}` for one defined in this project"
78    )]
79    MissingNamespace(String),
80
81    /// More than one `/`.
82    #[error("rule ID `{0}` contains more than one `/`")]
83    TooManySeparators(String),
84
85    /// The namespace is not spelled like a namespace.
86    #[error("invalid rule namespace `{name}` in `{id}`: {reason}")]
87    InvalidNamespace {
88        /// The namespace portion as written.
89        name: String,
90        /// The whole ID as written.
91        id: String,
92        /// What specifically is wrong.
93        reason: &'static str,
94    },
95
96    /// Nothing after the separator.
97    #[error("rule ID `{0}` has an empty name")]
98    EmptyName(String),
99
100    /// The name violates the naming rules.
101    #[error("invalid rule name `{name}` in `{id}`: {reason}")]
102    InvalidName {
103        /// The name portion as written.
104        name: String,
105        /// The whole ID as written.
106        id: String,
107        /// What specifically is wrong.
108        reason: &'static str,
109    },
110}
111
112/// A namespaced rule identifier, such as `lanekeep/no-default-export`.
113///
114/// Construct one by parsing: `"local/no-numeric-sizes".parse::<RuleId>()`.
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub struct RuleId {
117    namespace: Namespace,
118    name: String,
119}
120
121impl RuleId {
122    /// Which namespace this rule belongs to.
123    #[must_use]
124    pub const fn namespace(&self) -> &Namespace {
125        &self.namespace
126    }
127
128    /// The name portion, without the namespace or separator.
129    #[must_use]
130    pub fn name(&self) -> &str {
131        &self.name
132    }
133
134    /// Whether this is a rule shipped with lanekeep.
135    #[must_use]
136    pub fn is_built_in(&self) -> bool {
137        self.namespace.is_lanekeep()
138    }
139
140    /// Build an ID from parts, validating the name.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`ParseRuleIdError`] when the name is empty or not in the required form.
145    pub fn new(namespace: Namespace, name: &str) -> Result<Self, ParseRuleIdError> {
146        let id = format!("{}/{name}", namespace.as_str());
147        validate_name(name, &id)?;
148        Ok(Self {
149            namespace,
150            name: name.to_owned(),
151        })
152    }
153
154    /// A namespace from its written form, validating its shape.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`ParseRuleIdError`] when it is empty or not lowercase kebab-case.
159    pub fn namespace_from_str(namespace: &str) -> Result<Namespace, ParseRuleIdError> {
160        let id = format!("{namespace}/x");
161        validate_name(namespace, &id).map_err(|e| match e {
162            ParseRuleIdError::InvalidName { name, id, reason } => {
163                ParseRuleIdError::InvalidNamespace { name, id, reason }
164            }
165            other => other,
166        })?;
167        Ok(Namespace(namespace.to_owned()))
168    }
169}
170
171/// Rule names are lowercase kebab-case: `no-default-export`.
172///
173/// Strictness buys one specific thing. These strings are typed by hand into suppression
174/// comments, and a suppression that silently fails to match is worse than no suppression —
175/// the violation reappears and the author believes they already handled it. Permitting
176/// `No_Default_Export` alongside `no-default-export` would make near-miss IDs
177/// indistinguishable from correct ones at a glance.
178fn validate_name(name: &str, id: &str) -> Result<(), ParseRuleIdError> {
179    if name.is_empty() {
180        return Err(ParseRuleIdError::EmptyName(id.to_owned()));
181    }
182
183    let invalid = |reason: &'static str| {
184        Err(ParseRuleIdError::InvalidName {
185            name: name.to_owned(),
186            id: id.to_owned(),
187            reason,
188        })
189    };
190
191    if !name.is_ascii() {
192        return invalid("only ASCII letters, digits and hyphens are allowed");
193    }
194    if name.chars().any(|c| c.is_ascii_uppercase()) {
195        return invalid("must be lowercase");
196    }
197    if !name
198        .chars()
199        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
200    {
201        return invalid("only lowercase letters, digits and hyphens are allowed");
202    }
203    if name.starts_with('-') || name.ends_with('-') {
204        return invalid("must not start or end with a hyphen");
205    }
206    if name.contains("--") {
207        return invalid("must not contain consecutive hyphens");
208    }
209
210    Ok(())
211}
212
213impl FromStr for RuleId {
214    type Err = ParseRuleIdError;
215
216    fn from_str(s: &str) -> Result<Self, Self::Err> {
217        let mut parts = s.split('/');
218        let (Some(namespace), Some(name)) = (parts.next(), parts.next()) else {
219            return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
220        };
221        if parts.next().is_some() {
222            return Err(ParseRuleIdError::TooManySeparators(s.to_owned()));
223        }
224
225        // A namespace is spelled like a name, so a malformed one is caught here and an
226        // *undeclared* one is caught by the config, which is the only place that knows what
227        // this project declared.
228        if namespace.is_empty() {
229            return Err(ParseRuleIdError::MissingNamespace(s.to_owned()));
230        }
231        validate_name(namespace, s).map_err(|e| match e {
232            ParseRuleIdError::InvalidName { name, id, reason } => {
233                ParseRuleIdError::InvalidNamespace { name, id, reason }
234            }
235            other => other,
236        })?;
237
238        validate_name(name, s)?;
239        Ok(Self {
240            namespace: Namespace(namespace.to_owned()),
241            name: name.to_owned(),
242        })
243    }
244}
245
246impl fmt::Display for RuleId {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        write!(f, "{}/{}", self.namespace.as_str(), self.name)
249    }
250}
251
252/// Ordering is over the rendered string, not over `(Namespace, name)`.
253///
254/// The distinction matters because violations are sorted by `(ruleId, file, line, column)`
255/// and that order is part of lanekeep's output contract. Deriving `Ord` would order by the
256/// `Namespace` enum's declaration order, so adding a variant — say, one sorting between the
257/// existing two — would silently reorder every report without any output-producing code
258/// changing. Comparing rendered strings is stable against that, and is also the order a
259/// reader expects from looking at the output.
260impl Ord for RuleId {
261    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
262        self.namespace
263            .as_str()
264            .cmp(other.namespace.as_str())
265            .then_with(|| self.name.cmp(&other.name))
266    }
267}
268
269impl PartialOrd for RuleId {
270    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
271        Some(self.cmp(other))
272    }
273}
274
275impl Serialize for RuleId {
276    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
277        serializer.collect_str(self)
278    }
279}
280
281impl<'de> Deserialize<'de> for RuleId {
282    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
283        let raw = String::deserialize(deserializer)?;
284        raw.parse().map_err(serde::de::Error::custom)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn local() -> Namespace {
293        RuleId::namespace_from_str("local").expect("valid")
294    }
295
296    fn lanekeep_ns() -> Namespace {
297        RuleId::namespace_from_str("lanekeep").expect("valid")
298    }
299
300    fn parse(s: &str) -> Result<RuleId, ParseRuleIdError> {
301        s.parse()
302    }
303
304    #[test]
305    fn parses_a_built_in_id() {
306        let id = parse("lanekeep/no-default-export").expect("valid");
307        assert!(id.namespace().is_lanekeep());
308        assert_eq!(id.name(), "no-default-export");
309        assert!(id.is_built_in());
310    }
311
312    #[test]
313    fn parses_a_project_id() {
314        let id = parse("local/no-numeric-sizes").expect("valid");
315        assert_eq!(id.namespace().as_str(), "local");
316        assert_eq!(id.name(), "no-numeric-sizes");
317        assert!(!id.is_built_in());
318    }
319
320    #[test]
321    fn accepts_digits_in_names() {
322        assert!(parse("local/no-utf8-bom").is_ok());
323        assert!(parse("local/rule2").is_ok());
324    }
325
326    #[test]
327    fn round_trips_through_display() {
328        for raw in ["lanekeep/no-default-export", "local/a", "local/x-1-y"] {
329            let id = parse(raw).expect("valid");
330            assert_eq!(id.to_string(), raw);
331            assert_eq!(parse(&id.to_string()).expect("valid"), id);
332        }
333    }
334
335    #[test]
336    fn rejects_a_bare_name() {
337        let err = parse("no-default-export").expect_err("must be namespaced");
338        assert!(matches!(err, ParseRuleIdError::MissingNamespace(_)));
339        // The message has to teach the fix, since this is the mistake everyone
340        // migrating from another linter makes first.
341        let msg = err.to_string();
342        assert!(msg.contains("lanekeep/no-default-export"), "{msg}");
343        assert!(msg.contains("local/no-default-export"), "{msg}");
344    }
345
346    /// A namespace nobody declared is the config's business — it is the only layer that
347    /// knows what this project declared. What is rejected here is a namespace that is not
348    /// shaped like one at all.
349    #[test]
350    fn accepts_a_project_namespace() {
351        let id = parse("pera/no-numeric-sizes").expect("a team may use its own namespace");
352        assert_eq!(id.namespace().as_str(), "pera");
353        assert!(!id.is_built_in());
354    }
355
356    #[test]
357    fn rejects_a_malformed_namespace() {
358        for bad in ["Pera/no-x", "pera_wallet/no-x", "-pera/no-x", "/no-x"] {
359            assert!(
360                parse(bad).is_err(),
361                "`{bad}` is not shaped like a namespace and should be refused"
362            );
363        }
364    }
365
366    #[test]
367    fn rejects_extra_separators() {
368        let err = parse("local/nested/rule").expect_err("one separator only");
369        assert!(matches!(err, ParseRuleIdError::TooManySeparators(_)));
370    }
371
372    #[test]
373    fn rejects_empty_parts() {
374        assert!(matches!(
375            parse("local/"),
376            Err(ParseRuleIdError::EmptyName(_))
377        ));
378        assert!(matches!(
379            parse("/rule"),
380            Err(ParseRuleIdError::MissingNamespace(_))
381        ));
382        assert!(matches!(
383            parse(""),
384            Err(ParseRuleIdError::MissingNamespace(_))
385        ));
386        assert!(matches!(
387            parse("/"),
388            Err(ParseRuleIdError::MissingNamespace(_))
389        ));
390    }
391
392    #[test]
393    fn rejects_names_that_are_not_kebab_case() {
394        // Each of these would otherwise be a second spelling of an existing rule, and a
395        // suppression comment using the wrong spelling fails silently.
396        for bad in [
397            "No-Default-Export",
398            "no_default_export",
399            "no default export",
400            "-leading",
401            "trailing-",
402            "double--hyphen",
403            "no.default.export",
404            "café",
405            "rule!",
406        ] {
407            let raw = format!("local/{bad}");
408            assert!(parse(&raw).is_err(), "should have rejected {raw}");
409        }
410    }
411
412    #[test]
413    fn constructor_validates_the_same_way_as_parsing() {
414        assert!(RuleId::new(local(), "ok-name").is_ok());
415        assert!(RuleId::new(local(), "Bad_Name").is_err());
416        assert!(RuleId::new(local(), "").is_err());
417
418        let built = RuleId::new(lanekeep_ns(), "no-default-export").expect("valid");
419        let parsed = parse("lanekeep/no-default-export").expect("valid");
420        assert_eq!(built, parsed);
421    }
422
423    #[test]
424    fn orders_by_rendered_string() {
425        let mut ids: Vec<RuleId> = ["local/b", "lanekeep/z", "local/a", "lanekeep/a"]
426            .iter()
427            .map(|s| parse(s).expect("valid"))
428            .collect();
429        ids.sort();
430
431        let rendered: Vec<String> = ids.iter().map(ToString::to_string).collect();
432        assert_eq!(rendered, ["lanekeep/a", "lanekeep/z", "local/a", "local/b"]);
433    }
434
435    #[test]
436    fn ordering_matches_string_ordering_exactly() {
437        // The property that protects the output contract: however `Namespace` is
438        // declared or extended, sorting rule IDs must agree with sorting their rendered
439        // forms. If this ever fails, reports have silently reordered.
440        let ids: Vec<RuleId> = [
441            "lanekeep/a",
442            "lanekeep/no-default-export",
443            "local/a",
444            "local/zzz",
445            "lanekeep/zzz",
446        ]
447        .iter()
448        .map(|s| parse(s).expect("valid"))
449        .collect();
450
451        for a in &ids {
452            for b in &ids {
453                assert_eq!(
454                    a.cmp(b),
455                    a.to_string().cmp(&b.to_string()),
456                    "ordering disagreed for {a} vs {b}"
457                );
458            }
459        }
460    }
461
462    #[test]
463    fn serializes_as_a_plain_string() {
464        let id = parse("lanekeep/no-default-export").expect("valid");
465        let json = serde_json::to_string(&id).expect("serializes");
466        assert_eq!(json, "\"lanekeep/no-default-export\"");
467
468        let back: RuleId = serde_json::from_str(&json).expect("deserializes");
469        assert_eq!(back, id);
470    }
471
472    #[test]
473    fn deserializing_rejects_an_invalid_id() {
474        // Config and cache entries both arrive through serde, so validation cannot live
475        // only in `FromStr` or a malformed ID enters through the side door.
476        let err = serde_json::from_str::<RuleId>("\"nonsense\"").expect_err("invalid");
477        assert!(err.to_string().contains("nonsense"), "{err}");
478    }
479}