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