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}