Skip to main content

codeowner/
lib.rs

1//! Parse GitHub `CODEOWNERS` files and answer the only question that matters:
2//! **who owns this file?**
3//!
4//! ```
5//! use codeowner::{CodeOwners, Owner};
6//!
7//! let owners = CodeOwners::parse("\
8//! *                @org/everyone
9//! /src/parser/     @alice
10//! *.md             docs@example.com
11//! ");
12//!
13//! assert_eq!(owners.of("src/parser/lexer.rs"), Some(&[Owner::user("alice")][..]));
14//! assert_eq!(owners.of("README.md"),           Some(&[Owner::email("docs@example.com")][..]));
15//! assert_eq!(owners.of("build.sh"),            Some(&[Owner::team("org", "everyone")][..]));
16//! ```
17//!
18//! # Three things implementations usually get wrong
19//!
20//! **1. Unowned is not the same as unmatched.** A rule with no owners
21//! deliberately *clears* ownership. GitHub documents this. Collapsing the two
22//! cases into one silently reassigns files to the wrong team.
23//!
24//! ```
25//! use codeowner::CodeOwners;
26//!
27//! let owners = CodeOwners::parse("/apps/ @octocat\n/apps/github\n");
28//!
29//! assert!(owners.of("apps/main/index.js").is_some()); // owned by @octocat
30//! assert_eq!(owners.of("apps/github/index.js").map(<[_]>::len), Some(0)); // matched, no owner
31//! assert_eq!(owners.of("README.md"), None);           // no rule matched at all
32//! ```
33//!
34//! **2. CODEOWNERS is not gitignore.** GitHub documents three gitignore
35//! features as non-functional here: `!` negation, `[ ]` character ranges, and
36//! `\` escaping of a leading `#`. Lines using them are invalid and skipped —
37//! this crate reports them rather than silently mis-parsing.
38//!
39//! **3. `docs/*` does not match nested files.** Under gitignore rules it would,
40//! by matching the intermediate directory. GitHub says it does not.
41//!
42//! ```
43//! use codeowner::CodeOwners;
44//!
45//! let owners = CodeOwners::parse("docs/* docs@example.com\n");
46//! assert!(owners.of("docs/getting-started.md").is_some());
47//! assert!(owners.of("docs/build-app/troubleshooting.md").is_none());
48//! ```
49//!
50//! # Errors are data, not failures
51//!
52//! GitHub skips invalid lines rather than rejecting the file, so
53//! [`CodeOwners::parse`] never fails. Bad lines land in
54//! [`errors`](CodeOwners::errors) with line numbers, which is what you want if
55//! you are writing a linter.
56//!
57//! ```
58//! use codeowner::CodeOwners;
59//!
60//! let owners = CodeOwners::parse("*.rs @alice\n![!]bad @bob\n");
61//! assert_eq!(owners.rules().len(), 1);
62//! assert_eq!(owners.errors().len(), 1);
63//! assert_eq!(owners.errors()[0].line, 2);
64//! ```
65//!
66//! # Scope
67//!
68//! GitHub CODEOWNERS syntax, zero dependencies, `no_std` (needs `alloc`).
69//! GitLab's section headers (`[Backend][2]`) are not supported yet.
70
71#![no_std]
72#![forbid(unsafe_code)]
73#![deny(missing_docs)]
74
75extern crate alloc;
76
77use alloc::borrow::ToOwned;
78use alloc::string::String;
79use alloc::vec::Vec;
80use core::fmt;
81
82mod pattern;
83
84pub use pattern::{Pattern, PatternError};
85
86/// The paths GitHub searches, in priority order.
87///
88/// The first file that exists wins; the others are ignored entirely.
89pub const SEARCH_PATHS: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
90
91/// GitHub refuses to load a CODEOWNERS file above this size.
92pub const MAX_FILE_SIZE: usize = 3 * 1024 * 1024;
93
94/// A single code owner.
95#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
96pub enum Owner {
97    /// `@username`
98    User(String),
99    /// `@org/team-name`
100    Team {
101        /// The organization, without the leading `@`.
102        org: String,
103        /// The team slug.
104        team: String,
105    },
106    /// A bare email address.
107    Email(String),
108}
109
110impl Owner {
111    /// Construct a [`Owner::User`].
112    #[must_use]
113    pub fn user(name: &str) -> Self {
114        Self::User(name.to_owned())
115    }
116
117    /// Construct a [`Owner::Team`].
118    #[must_use]
119    pub fn team(org: &str, team: &str) -> Self {
120        Self::Team {
121            org: org.to_owned(),
122            team: team.to_owned(),
123        }
124    }
125
126    /// Construct a [`Owner::Email`].
127    #[must_use]
128    pub fn email(addr: &str) -> Self {
129        Self::Email(addr.to_owned())
130    }
131
132    /// Parse one owner token.
133    ///
134    /// ```
135    /// use codeowner::Owner;
136    ///
137    /// assert_eq!(Owner::parse("@alice"),     Some(Owner::user("alice")));
138    /// assert_eq!(Owner::parse("@org/team"),  Some(Owner::team("org", "team")));
139    /// assert_eq!(Owner::parse("a@b.com"),    Some(Owner::email("a@b.com")));
140    /// assert_eq!(Owner::parse("nonsense"),   None);
141    /// ```
142    #[must_use]
143    pub fn parse(token: &str) -> Option<Self> {
144        if let Some(rest) = token.strip_prefix('@') {
145            if rest.is_empty() {
146                return None;
147            }
148            return match rest.split_once('/') {
149                Some((org, team)) => {
150                    if is_login(org) && is_slug(team) {
151                        Some(Self::team(org, team))
152                    } else {
153                        None
154                    }
155                }
156                None if is_login(rest) => Some(Self::User(rest.to_owned())),
157                None => None,
158            };
159        }
160        // Deliberately permissive: GitHub resolves the address against account
161        // emails, so rejecting exotic-but-valid addresses would be worse than
162        // accepting a few junk ones.
163        let (local, domain) = token.split_once('@')?;
164        if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
165            return None;
166        }
167        Some(Self::Email(token.to_owned()))
168    }
169}
170
171/// A GitHub account name: alphanumeric and hyphens, no leading or trailing
172/// hyphen, at most 39 characters.
173fn is_login(s: &str) -> bool {
174    !s.is_empty()
175        && s.len() <= 39
176        && !s.starts_with('-')
177        && !s.ends_with('-')
178        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
179}
180
181/// A team slug. Slightly looser than a login: underscores and dots occur in
182/// real team slugs.
183fn is_slug(s: &str) -> bool {
184    !s.is_empty()
185        && s.chars()
186            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
187}
188
189impl fmt::Display for Owner {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            Self::User(u) => write!(f, "@{u}"),
193            Self::Team { org, team } => write!(f, "@{org}/{team}"),
194            Self::Email(e) => f.write_str(e),
195        }
196    }
197}
198
199/// One `pattern owners...` line.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Rule {
202    /// The compiled path pattern.
203    pub pattern: Pattern,
204    /// Owners, in the order written. Empty means ownership is explicitly cleared.
205    pub owners: Vec<Owner>,
206    /// 1-based line number in the source file.
207    pub line: usize,
208}
209
210impl Rule {
211    /// True if this rule deliberately leaves matching paths unowned.
212    #[must_use]
213    pub fn is_unowned(&self) -> bool {
214        self.owners.is_empty()
215    }
216}
217
218/// What was wrong with a line.
219#[derive(Debug, Clone, PartialEq, Eq)]
220#[non_exhaustive]
221pub enum ErrorKind {
222    /// The path pattern could not be compiled.
223    BadPattern(PatternError),
224    /// A token after the pattern was not a recognisable owner.
225    BadOwner(String),
226}
227
228impl fmt::Display for ErrorKind {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match self {
231            Self::BadPattern(e) => write!(f, "{e}"),
232            Self::BadOwner(t) => write!(f, "`{t}` is not a valid owner"),
233        }
234    }
235}
236
237/// A skipped line, with enough context to point at it in an editor.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct ParseError {
240    /// 1-based line number.
241    pub line: usize,
242    /// What went wrong.
243    pub kind: ErrorKind,
244    /// The offending line, trimmed.
245    pub text: String,
246}
247
248impl fmt::Display for ParseError {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        write!(f, "line {}: {}", self.line, self.kind)
251    }
252}
253
254/// A parsed CODEOWNERS file.
255#[derive(Debug, Clone, Default, PartialEq, Eq)]
256pub struct CodeOwners {
257    rules: Vec<Rule>,
258    errors: Vec<ParseError>,
259}
260
261impl CodeOwners {
262    /// Parse a CODEOWNERS file. Never fails; invalid lines are collected in
263    /// [`errors`](Self::errors), matching GitHub's own behaviour.
264    #[must_use]
265    pub fn parse(text: &str) -> Self {
266        let mut rules = Vec::new();
267        let mut errors = Vec::new();
268
269        for (idx, raw_line) in text.lines().enumerate() {
270            let line = idx + 1;
271
272            // No escaping: `#` always starts a comment, anywhere on the line.
273            let content = raw_line.split('#').next().unwrap_or("").trim();
274            if content.is_empty() {
275                continue;
276            }
277
278            let mut tokens = content.split_whitespace();
279            let Some(pattern_str) = tokens.next() else {
280                continue;
281            };
282
283            let pattern = match Pattern::new(pattern_str) {
284                Ok(p) => p,
285                Err(e) => {
286                    errors.push(ParseError {
287                        line,
288                        kind: ErrorKind::BadPattern(e),
289                        text: content.to_owned(),
290                    });
291                    continue;
292                }
293            };
294
295            let mut owners = Vec::new();
296            let mut bad = None;
297            for token in tokens {
298                match Owner::parse(token) {
299                    Some(o) => owners.push(o),
300                    None => {
301                        bad = Some(token.to_owned());
302                        break;
303                    }
304                }
305            }
306
307            if let Some(token) = bad {
308                errors.push(ParseError {
309                    line,
310                    kind: ErrorKind::BadOwner(token),
311                    text: content.to_owned(),
312                });
313                continue;
314            }
315
316            rules.push(Rule {
317                pattern,
318                owners,
319                line,
320            });
321        }
322
323        Self { rules, errors }
324    }
325
326    /// Owners of `path`, or `None` if no rule matched.
327    ///
328    /// `Some(&[])` means a rule matched and explicitly left the path unowned.
329    /// That distinction is the whole point; see the crate docs.
330    #[must_use]
331    pub fn of(&self, path: &str) -> Option<&[Owner]> {
332        self.rule_for(path).map(|r| r.owners.as_slice())
333    }
334
335    /// The rule that decides `path` — the **last** matching rule in the file.
336    #[must_use]
337    pub fn rule_for(&self, path: &str) -> Option<&Rule> {
338        self.rules.iter().rev().find(|r| r.pattern.matches(path))
339    }
340
341    /// Every rule that matches `path`, in file order.
342    ///
343    /// Only the last one takes effect, but a linter wants to show the rest.
344    #[must_use]
345    pub fn all_matching(&self, path: &str) -> Vec<&Rule> {
346        self.rules
347            .iter()
348            .filter(|r| r.pattern.matches(path))
349            .collect()
350    }
351
352    /// Rules that can never take effect, because a later rule always wins for
353    /// everything they match.
354    ///
355    /// A cheap, useful lint: it catches the classic mistake of putting a
356    /// specific rule above the catch-all instead of below it.
357    #[must_use]
358    pub fn shadowed(&self) -> Vec<&Rule> {
359        self.rules
360            .iter()
361            .enumerate()
362            .filter(|(i, rule)| {
363                self.rules[i + 1..]
364                    .iter()
365                    .any(|later| later.pattern.as_str() == rule.pattern.as_str())
366            })
367            .map(|(_, rule)| rule)
368            .collect()
369    }
370
371    /// All rules, in file order.
372    #[must_use]
373    pub fn rules(&self) -> &[Rule] {
374        &self.rules
375    }
376
377    /// Lines that were skipped.
378    #[must_use]
379    pub fn errors(&self) -> &[ParseError] {
380        &self.errors
381    }
382
383    /// True if no rule parsed successfully.
384    #[must_use]
385    pub fn is_empty(&self) -> bool {
386        self.rules.is_empty()
387    }
388}