Skip to content

Commit b2b3fef

Browse files
committed
imp: inline parser
1 parent 76adbd9 commit b2b3fef

File tree

4 files changed

+384
-0
lines changed

4 files changed

+384
-0
lines changed

src/core/inline_parser.rs

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
use crate::utils::error::{Error, Result};
2+
use std::path::Path;
3+
use std::io::{BufRead, BufReader};
4+
use std::fs::File;
5+
6+
use super::types::{InlineCodeownersEntry, Owner, Tag};
7+
use super::parser::parse_owner;
8+
9+
/// Detects inline CODEOWNERS declaration in the first 50 lines of a file
10+
pub fn detect_inline_codeowners(file_path: &Path) -> Result<Option<InlineCodeownersEntry>> {
11+
let file = match File::open(file_path) {
12+
Ok(f) => f,
13+
Err(_) => return Ok(None), // File doesn't exist or can't be read
14+
};
15+
16+
let reader = BufReader::new(file);
17+
let lines = reader.lines().take(50);
18+
19+
for (line_num, line_result) in lines.enumerate() {
20+
let line = match line_result {
21+
Ok(l) => l,
22+
Err(_) => continue, // Skip lines that can't be read
23+
};
24+
25+
if let Some(entry) = parse_inline_codeowners_line(&line, line_num + 1, file_path)? {
26+
return Ok(Some(entry));
27+
}
28+
}
29+
30+
Ok(None)
31+
}
32+
33+
/// Parse a single line for inline CODEOWNERS declaration
34+
fn parse_inline_codeowners_line(
35+
line: &str,
36+
line_number: usize,
37+
file_path: &Path,
38+
) -> Result<Option<InlineCodeownersEntry>> {
39+
// Look for !!!CODEOWNERS marker
40+
if let Some(marker_pos) = line.find("!!!CODEOWNERS") {
41+
// Extract everything after the marker
42+
let after_marker = &line[marker_pos + "!!!CODEOWNERS".len()..];
43+
44+
// Split by whitespace to get tokens
45+
let tokens: Vec<&str> = after_marker.split_whitespace().collect();
46+
47+
if tokens.is_empty() {
48+
return Ok(None);
49+
}
50+
51+
let mut owners: Vec<Owner> = Vec::new();
52+
let mut tags: Vec<Tag> = Vec::new();
53+
let mut i = 0;
54+
55+
// Collect owners until a token starts with '#'
56+
while i < tokens.len() && !tokens[i].starts_with('#') {
57+
owners.push(parse_owner(tokens[i])?);
58+
i += 1;
59+
}
60+
61+
// Collect tags
62+
while i < tokens.len() {
63+
let token = tokens[i];
64+
if token.starts_with('#') {
65+
if token == "#" {
66+
// Standalone # means comment starts, break
67+
break;
68+
} else {
69+
// Extract tag name, but check if this might be a comment
70+
let tag_part = &token[1..];
71+
72+
// If the tag part is empty, it's probably a comment marker
73+
if tag_part.is_empty() {
74+
break;
75+
}
76+
77+
// Special handling for common comment patterns
78+
// If the next token looks like end of comment (like "-->"), still treat as tag
79+
let next_token = if i + 1 < tokens.len() { Some(tokens[i + 1]) } else { None };
80+
81+
match next_token {
82+
Some("-->") | Some("*/") => {
83+
// This is likely the end of a comment block, so the tag is valid
84+
tags.push(Tag(tag_part.to_string()));
85+
i += 1;
86+
break; // Stop after this tag since we hit comment end
87+
}
88+
Some(next) if next.starts_with('#') => {
89+
// Next token is also a tag, so this is definitely a tag
90+
tags.push(Tag(tag_part.to_string()));
91+
i += 1;
92+
}
93+
Some(_) => {
94+
// Next token doesn't start with # and isn't a comment ender
95+
// This could be a comment, but we'll be conservative and treat as tag
96+
// if it looks like a valid tag name (alphanumeric + common chars)
97+
if tag_part.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
98+
tags.push(Tag(tag_part.to_string()));
99+
i += 1;
100+
break; // Stop here as next token is likely a comment
101+
} else {
102+
break; // This is probably a comment
103+
}
104+
}
105+
None => {
106+
// This is the last token, treat as tag
107+
tags.push(Tag(tag_part.to_string()));
108+
i += 1;
109+
}
110+
}
111+
}
112+
} else {
113+
// Non-# token, this is part of a comment
114+
break;
115+
}
116+
}
117+
118+
// Only return an entry if we have at least one owner
119+
if !owners.is_empty() {
120+
return Ok(Some(InlineCodeownersEntry {
121+
file_path: file_path.to_path_buf(),
122+
line_number,
123+
owners,
124+
tags,
125+
}));
126+
}
127+
}
128+
129+
Ok(None)
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
use crate::core::types::{Owner, OwnerType, Tag};
136+
use std::fs;
137+
use tempfile::TempDir;
138+
139+
#[test]
140+
fn test_detect_inline_codeowners_rust_comment() -> Result<()> {
141+
let temp_dir = TempDir::new().unwrap();
142+
let file_path = temp_dir.path().join("test.rs");
143+
144+
let content = r#"// This is a Rust file
145+
// !!!CODEOWNERS @user1 @org/team2 #tag1 #tag2
146+
fn main() {
147+
println!("Hello world");
148+
}
149+
"#;
150+
fs::write(&file_path, content).unwrap();
151+
152+
let result = detect_inline_codeowners(&file_path)?;
153+
assert!(result.is_some());
154+
155+
let entry = result.unwrap();
156+
assert_eq!(entry.file_path, file_path);
157+
assert_eq!(entry.line_number, 2);
158+
assert_eq!(entry.owners.len(), 2);
159+
assert_eq!(entry.owners[0].identifier, "@user1");
160+
assert_eq!(entry.owners[1].identifier, "@org/team2");
161+
assert_eq!(entry.tags.len(), 2);
162+
assert_eq!(entry.tags[0].0, "tag1");
163+
assert_eq!(entry.tags[1].0, "tag2");
164+
165+
Ok(())
166+
}
167+
168+
#[test]
169+
fn test_detect_inline_codeowners_javascript_comment() -> Result<()> {
170+
let temp_dir = TempDir::new().unwrap();
171+
let file_path = temp_dir.path().join("test.js");
172+
173+
let content = r#"/*
174+
* !!!CODEOWNERS @frontend-team #javascript
175+
*/
176+
function hello() {
177+
console.log("Hello");
178+
}
179+
"#;
180+
fs::write(&file_path, content).unwrap();
181+
182+
let result = detect_inline_codeowners(&file_path)?;
183+
assert!(result.is_some());
184+
185+
let entry = result.unwrap();
186+
assert_eq!(entry.owners.len(), 1);
187+
assert_eq!(entry.owners[0].identifier, "@frontend-team");
188+
assert_eq!(entry.tags.len(), 1);
189+
assert_eq!(entry.tags[0].0, "javascript");
190+
191+
Ok(())
192+
}
193+
194+
#[test]
195+
fn test_detect_inline_codeowners_python_comment() -> Result<()> {
196+
let temp_dir = TempDir::new().unwrap();
197+
let file_path = temp_dir.path().join("test.py");
198+
199+
let content = r#"#!/usr/bin/env python3
200+
# !!!CODEOWNERS @python-team @user1 #backend #critical
201+
"""
202+
This is a Python module
203+
"""
204+
205+
def main():
206+
pass
207+
"#;
208+
fs::write(&file_path, content).unwrap();
209+
210+
let result = detect_inline_codeowners(&file_path)?;
211+
assert!(result.is_some());
212+
213+
let entry = result.unwrap();
214+
assert_eq!(entry.line_number, 2);
215+
assert_eq!(entry.owners.len(), 2);
216+
assert_eq!(entry.owners[0].identifier, "@python-team");
217+
assert_eq!(entry.owners[1].identifier, "@user1");
218+
assert_eq!(entry.tags.len(), 2);
219+
assert_eq!(entry.tags[0].0, "backend");
220+
assert_eq!(entry.tags[1].0, "critical");
221+
222+
Ok(())
223+
}
224+
225+
#[test]
226+
fn test_detect_inline_codeowners_html_comment() -> Result<()> {
227+
let temp_dir = TempDir::new().unwrap();
228+
let file_path = temp_dir.path().join("test.html");
229+
230+
let content = r#"<!DOCTYPE html>
231+
<html>
232+
<!-- !!!CODEOWNERS @web-team #frontend -->
233+
<head>
234+
<title>Test</title>
235+
</head>
236+
</html>
237+
"#;
238+
fs::write(&file_path, content).unwrap();
239+
240+
let result = detect_inline_codeowners(&file_path)?;
241+
assert!(result.is_some());
242+
243+
let entry = result.unwrap();
244+
assert_eq!(entry.owners.len(), 1);
245+
assert_eq!(entry.owners[0].identifier, "@web-team");
246+
assert_eq!(entry.tags.len(), 1);
247+
assert_eq!(entry.tags[0].0, "frontend");
248+
249+
Ok(())
250+
}
251+
252+
#[test]
253+
fn test_detect_inline_codeowners_no_marker() -> Result<()> {
254+
let temp_dir = TempDir::new().unwrap();
255+
let file_path = temp_dir.path().join("test.rs");
256+
257+
let content = r#"// This is a regular file
258+
fn main() {
259+
println!("No CODEOWNERS marker here");
260+
}
261+
"#;
262+
fs::write(&file_path, content).unwrap();
263+
264+
let result = detect_inline_codeowners(&file_path)?;
265+
assert!(result.is_none());
266+
267+
Ok(())
268+
}
269+
270+
#[test]
271+
fn test_detect_inline_codeowners_no_owners() -> Result<()> {
272+
let temp_dir = TempDir::new().unwrap();
273+
let file_path = temp_dir.path().join("test.rs");
274+
275+
let content = r#"// !!!CODEOWNERS #just-tags
276+
fn main() {
277+
println!("Only tags, no owners");
278+
}
279+
"#;
280+
fs::write(&file_path, content).unwrap();
281+
282+
let result = detect_inline_codeowners(&file_path)?;
283+
assert!(result.is_none());
284+
285+
Ok(())
286+
}
287+
288+
#[test]
289+
fn test_detect_inline_codeowners_first_occurrence_only() -> Result<()> {
290+
let temp_dir = TempDir::new().unwrap();
291+
let file_path = temp_dir.path().join("test.rs");
292+
293+
let content = r#"// !!!CODEOWNERS @first-owner #first-tag
294+
fn main() {
295+
// !!!CODEOWNERS @second-owner #second-tag
296+
println!("Should only detect first occurrence");
297+
}
298+
"#;
299+
fs::write(&file_path, content).unwrap();
300+
301+
let result = detect_inline_codeowners(&file_path)?;
302+
assert!(result.is_some());
303+
304+
let entry = result.unwrap();
305+
assert_eq!(entry.line_number, 1);
306+
assert_eq!(entry.owners[0].identifier, "@first-owner");
307+
assert_eq!(entry.tags[0].0, "first-tag");
308+
309+
Ok(())
310+
}
311+
312+
#[test]
313+
fn test_detect_inline_codeowners_beyond_50_lines() -> Result<()> {
314+
let temp_dir = TempDir::new().unwrap();
315+
let file_path = temp_dir.path().join("test.rs");
316+
317+
let mut content = String::new();
318+
// Add 51 lines, with the marker on line 51
319+
for i in 1..=50 {
320+
content.push_str(&format!("// Line {}\n", i));
321+
}
322+
content.push_str("// !!!CODEOWNERS @should-not-be-found #beyond-limit\n");
323+
content.push_str("fn main() {}\n");
324+
325+
fs::write(&file_path, content).unwrap();
326+
327+
let result = detect_inline_codeowners(&file_path)?;
328+
assert!(result.is_none());
329+
330+
Ok(())
331+
}
332+
333+
#[test]
334+
fn test_detect_inline_codeowners_with_comment_after() -> Result<()> {
335+
let temp_dir = TempDir::new().unwrap();
336+
let file_path = temp_dir.path().join("test.rs");
337+
338+
let content = r#"// !!!CODEOWNERS @user1 #tag1 # this is a comment after
339+
fn main() {}
340+
"#;
341+
fs::write(&file_path, content).unwrap();
342+
343+
let result = detect_inline_codeowners(&file_path)?;
344+
assert!(result.is_some());
345+
346+
let entry = result.unwrap();
347+
assert_eq!(entry.owners.len(), 1);
348+
assert_eq!(entry.owners[0].identifier, "@user1");
349+
assert_eq!(entry.tags.len(), 1);
350+
assert_eq!(entry.tags[0].0, "tag1");
351+
352+
Ok(())
353+
}
354+
355+
#[test]
356+
fn test_detect_inline_codeowners_nonexistent_file() -> Result<()> {
357+
let temp_dir = TempDir::new().unwrap();
358+
let file_path = temp_dir.path().join("nonexistent.rs");
359+
360+
let result = detect_inline_codeowners(&file_path)?;
361+
assert!(result.is_none());
362+
363+
Ok(())
364+
}
365+
366+
}

src/core/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub(crate) mod cache;
22
pub(crate) mod commands;
33
pub(crate) mod common;
44
pub(crate) mod display;
5+
pub(crate) mod inline_parser;
56
pub mod owner_resolver;
67
pub(crate) mod parse;
78
pub mod parser;

0 commit comments

Comments
 (0)