///|
/// Parse CODEOWNERS text. Invalid lines are skipped and collected in
/// `issues`, mirroring GitHub's behavior.
pub fn Codeowners::parse(text : String) -> Self {
let rules : Array[Rule] = []
let issues : Array[ParseIssue] = []
let mut line_number = 0
for line_view in text.split("\n") {
line_number = line_number + 1
let original = line_view.trim().to_owned()
if original.is_empty() {
continue
}
let line = remove_comment(original)
let tokens = split_whitespace(line)
if tokens.is_empty() {
continue
}
let pattern = tokens[0]
let issue = match validate_pattern(pattern) {
Some(message) => Some(message)
None => None
}
if issue is Some(message) {
issues.push({ line: line_number, source: original, message, })
continue
}
if tokens.length() < 2 {
issues.push({
line: line_number,
source: original,
message: "pattern has no owners",
})
continue
}
let owners = []
let mut valid = true
for index in 1.. owners.push(owner)
None => valid = false
}
}
if !valid || owners.is_empty() {
issues.push({
line: line_number,
source: original,
message: "owner must be @user, @org/team, or an email address",
})
continue
}
rules.push({ pattern, owners, line: line_number, })
}
{ rules, issues, }
}
///|
fn validate_pattern(pattern : String) -> String? {
if pattern == "/" {
return Some("pattern must identify a file or directory")
}
if pattern.has_prefix("!") {
return Some("negated patterns are not supported by CODEOWNERS")
}
if pattern.contains("[") || pattern.contains("]") {
return Some("character classes are not supported by CODEOWNERS")
}
if pattern.contains("\\") {
return Some("backslash escapes are not supported by CODEOWNERS")
}
None
}
///|
fn parse_owner(token : String) -> Owner? {
if token.length() < 2 {
return None
}
if token.has_prefix("@") {
let name = token.exact_view(start=1).to_owned()
if name.is_empty() {
return None
}
if name.contains("/") {
let pieces = name.split("/").to_array()
if pieces.length() != 2 || pieces[0].is_empty() || pieces[1].is_empty() {
return None
}
return Some({ raw: token, kind: Team, })
}
return Some({ raw: token, kind: User, })
}
let at = token.find("@")
if at is Some(index) &&
count_char(token, '@') == 1 &&
index > 0 &&
index + 1 < token.length() &&
token.exact_view(start=index + 1).contains(".") {
return Some({ raw: token, kind: Email, })
}
None
}
///|
fn count_char(text : String, expected : Char) -> Int32 {
let mut count = 0
for char in text {
if char == expected {
count = count + 1
}
}
count
}
///|
fn remove_comment(line : String) -> String {
let chars = line.to_array()
let builder = StringBuilder()
for char in chars {
if char == '#' {
break
}
builder.write_char(char)
}
builder.to_string().trim().to_owned()
}
///|
fn split_whitespace(line : String) -> Array[String] {
let tokens = []
let builder = StringBuilder()
let mut in_token = false
for char in line {
if is_whitespace(char) {
if in_token {
tokens.push(builder.to_string())
builder.reset()
in_token = false
}
} else {
builder.write_char(char)
in_token = true
}
}
if in_token {
tokens.push(builder.to_string())
}
tokens
}
///|
fn is_whitespace(char : Char) -> Bool {
char == ' ' || char == '\t' || char == '\r' || char == '\n'
}