Skip to main content

codeowners_parser/
lib.rs

1//! # codeowners-parser
2//!
3//! Parse, match against, and validate GitHub CODEOWNERS files — with the
4//! position information editor tooling needs.
5//!
6//! This crate exists because the Rust ecosystem's original `codeowners` crate
7//! was abandoned in 2018 with two open RUSTSEC advisories in its transitive
8//! deps, and nothing public replaced it with LSP-grade features. This one is
9//! extracted from [`codeowners-lsp`](https://github.com/radiosilence/codeowners-lsp),
10//! where it has been hardened against real-world CODEOWNERS files from large
11//! monorepos.
12//!
13//! ## What it does
14//!
15//! - **Parse** CODEOWNERS into structured lines, with optional character
16//!   offsets for every pattern, owner, and inline comment — exactly what an
17//!   LSP needs for hover, rename, and go-to-definition.
18//! - **Match** file paths against rules using GitHub's glob semantics
19//!   (`*`, `**`, `?`, anchored `/prefix`, trailing-slash directories).
20//!   The matcher compiles each pattern once and picks a specialized
21//!   implementation based on shape — `*.rs` becomes a raw `ends_with` check,
22//!   not a regex.
23//! - **Resolve ownership** for a path following "last match wins" semantics.
24//!   Pre-parse once for hot loops via [`check_file_ownership_parsed`].
25//! - **Detect dead rules** via [`pattern_subsumes`] — used by linters to find
26//!   patterns that will never match because a later rule shadows them.
27//! - **Validate** owner format (`@user`, `@org/team`, `email@host`) and glob
28//!   syntax — with the specific subset of globs CODEOWNERS actually supports
29//!   (no `[...]` character classes, no `!` negation).
30//! - **Locate** the CODEOWNERS file in a repo (`.github/CODEOWNERS`,
31//!   `CODEOWNERS`, or `docs/CODEOWNERS`) via [`find_codeowners`].
32//!
33//! ## What it doesn't do
34//!
35//! - No file-system enumeration. Matchers take a `file_path: &str`; callers
36//!   supply paths however they like (walkdir, git ls-files, HTTP API, etc.).
37//! - No GitHub API calls. Validation is syntactic — `@ghost` is accepted
38//!   even though GitHub would reject it.
39//! - No diagnostic rendering. You get `Option<String>` from validators and
40//!   structured results from matchers; turn those into whatever diagnostic
41//!   format your tool needs.
42//!
43//! ## Quick start
44//!
45//! ```
46//! use codeowners_parser::{check_file_ownership, validate_owner, validate_pattern};
47//!
48//! let codeowners = "\
49//! # Default owners
50//! *              @core-team
51//! /docs/         @docs-team
52//! *.rs           @rust-team @reviewer
53//! ";
54//!
55//! // Who owns a file?
56//! let result = check_file_ownership(codeowners, "src/lib.rs").unwrap();
57//! assert_eq!(result.pattern, "*.rs");
58//! assert_eq!(result.owners, vec!["@rust-team", "@reviewer"]);
59//!
60//! // Validate owner syntax
61//! assert!(validate_owner("@rust-team").is_none());
62//! assert!(validate_owner("@rust_team").is_some()); // underscore not allowed
63//! assert!(validate_owner("user@example.com").is_none());
64//!
65//! // Validate glob syntax
66//! assert!(validate_pattern("*.rs").is_none());
67//! assert!(validate_pattern("").is_some());
68//! ```
69//!
70//! ## Parsing with positions (for editor tooling)
71//!
72//! ```
73//! use codeowners_parser::parser::{parse_codeowners_file_with_positions, CodeownersLine};
74//!
75//! let lines = parse_codeowners_file_with_positions("*.rs @owner\n");
76//! let line = &lines[0];
77//!
78//! assert_eq!(line.line_number, 0);
79//! assert_eq!(line.pattern_start, 0);
80//! assert_eq!(line.pattern_end, 4);       // points just past "*.rs"
81//! assert_eq!(line.owners_start, 5);       // points at "@owner"
82//!
83//! if let CodeownersLine::Rule { pattern, owners } = &line.content {
84//!     assert_eq!(pattern, "*.rs");
85//!     assert_eq!(owners, &vec!["@owner".to_string()]);
86//! }
87//! ```
88//!
89//! ## Hot-loop ownership checks
90//!
91//! When you need to check many paths against the same CODEOWNERS content,
92//! parse once with [`parser::parse_codeowners_file_with_positions`] and reuse
93//! the result, rather than paying to re-parse per path.
94//!
95//! [`check_file_ownership_parsed`] still classifies each pattern on every
96//! call. If that shows up in a profile, hoist the work yourself by compiling
97//! the patterns once into [`pattern::CompiledPattern`] and matching against
98//! those.
99//!
100//! ```
101//! use codeowners_parser::{check_file_ownership_parsed, parser::parse_codeowners_file_with_positions};
102//!
103//! let parsed = parse_codeowners_file_with_positions(
104//!     "*.rs @rust\n*.md @docs\n/src/ @core\n",
105//! );
106//!
107//! for path in ["src/main.rs", "README.md", "src/lib.rs"] {
108//!     if let Some(result) = check_file_ownership_parsed(&parsed, path) {
109//!         println!("{}: {} (from `{}`)", path, result.owners.join(" "), result.pattern);
110//!     }
111//! }
112//! ```
113//!
114//! ## Detecting dead rules
115//!
116//! Linters can spot patterns that are shadowed by later rules. The call
117//! is `pattern_subsumes(a, b)` — "is everything `a` matches also matched
118//! by `b`?". If a later rule subsumes an earlier one, the earlier rule is
119//! dead code (last match wins).
120//!
121//! ```
122//! use codeowners_parser::pattern_subsumes;
123//!
124//! // `*` subsumes everything — any rule before a final `*` is dead.
125//! assert!(pattern_subsumes("*.rs", "*"));
126//! assert!(pattern_subsumes("/src/", "*"));
127//!
128//! // `/src/lib/` is subsumed by `/src/` (parent directory contains child).
129//! assert!(pattern_subsumes("/src/lib/", "/src/"));
130//!
131//! // Anchored dir is subsumed by the unanchored version (unanchored matches more).
132//! assert!(pattern_subsumes("/docs/", "docs/"));
133//! assert!(!pattern_subsumes("docs/", "/docs/"));
134//! ```
135//!
136//! ## Design notes
137//!
138//! - **Zero-copy is not a goal.** We return `String` / `Vec<String>` for
139//!   owners and patterns. The alternative (`&str` tied to input lifetime)
140//!   is hostile for interactive tooling that edits CODEOWNERS content.
141//! - **Patterns are not compiled behind your back.** The matching helpers
142//!   classify the pattern string each time they are called. For repeated
143//!   matching, build a [`pattern::CompiledPattern::new`] once and keep it.
144//! - **Glob semantics match `git check-ignore` closely**, not gitignore's
145//!   full spec. CODEOWNERS is a subset: no `[...]` character classes,
146//!   no `!` negation. Patterns containing `[` or `!` at the start are
147//!   rejected by [`validate_pattern`].
148
149#![deny(missing_docs)]
150
151pub mod ownership;
152pub mod parser;
153pub mod pattern;
154pub mod validation;
155
156pub use ownership::{
157    check_file_ownership, check_file_ownership_parsed, find_codeowners, get_repo_root,
158    OwnershipResult,
159};
160pub use parser::{
161    find_inline_comment_start, find_insertion_point, find_insertion_point_with_owner,
162    find_owner_at_position, format_codeowners, parse_codeowners_file,
163    parse_codeowners_file_with_positions, serialize_codeowners, CodeownersLine, ParsedLine,
164};
165pub use pattern::{pattern_matches, pattern_subsumes, CompiledPattern};
166pub use validation::{validate_owner, validate_pattern};