use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct IssueRef {
pub number: u64,
pub owner_repo: Option<(String, String)>,
}
const KEYWORDS: &[&str] = &[
"fix",
"fixes",
"fixed",
"close",
"closes",
"closed",
"resolve",
"resolves",
"resolved",
"ref",
"refs",
"reference",
"references",
"see",
];
const MAX_DIGITS: usize = 18;
#[must_use]
pub fn extract_issue_refs(text: &str) -> Vec<IssueRef> {
let masked = mask_ignored(text);
let mut seen = HashSet::new();
let mut refs = Vec::new();
for line in masked.split('\n') {
let chars: Vec<char> = line.chars().collect();
for found in scan_line(&chars) {
if seen.insert(found.clone()) {
refs.push(found);
}
}
}
refs
}
fn mask_ignored(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut fence: Option<(char, usize)> = None;
let mut in_comment = false;
for line in text.split('\n') {
match fence {
Some((ch, len)) => {
if fence_closes(line, ch, len) {
fence = None;
}
}
None => {
let opener = if in_comment { None } else { fence_opener(line) };
match opener {
Some(open) => fence = Some(open),
None => in_comment = mask_inline(line, in_comment, &mut out),
}
}
}
out.push('\n');
}
out
}
fn fence_opener(line: &str) -> Option<(char, usize)> {
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return None;
}
let rest = &line[indent..];
let ch = rest.chars().next().filter(|c| *c == '`' || *c == '~')?;
let run = rest.chars().take_while(|c| *c == ch).count();
if run < 3 {
return None;
}
if ch == '`' && rest[run..].contains('`') {
return None;
}
Some((ch, run))
}
fn fence_closes(line: &str, ch: char, len: usize) -> bool {
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return false;
}
let rest = &line[indent..];
let run = rest.chars().take_while(|c| *c == ch).count();
run >= len && rest[run..].trim().is_empty()
}
fn mask_inline(line: &str, mut in_comment: bool, out: &mut String) -> bool {
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if in_comment {
match find_seq(&chars, i, &['-', '-', '>']) {
Some(end) => {
in_comment = false;
out.push('\n');
i = end + 3;
}
None => return true,
}
continue;
}
if chars[i..].starts_with(&['<', '!', '-', '-']) {
in_comment = true;
out.push('\n');
i += 4;
continue;
}
if chars[i] == '`' {
let run = run_end(&chars, i, |c| c == '`') - i;
match closing_backticks(&chars, i + run, run) {
Some(close) => {
out.push('\n');
i = close + run;
}
None => {
out.extend(&chars[i..i + run]);
i += run;
}
}
continue;
}
out.push(chars[i]);
i += 1;
}
in_comment
}
fn find_seq(chars: &[char], from: usize, seq: &[char]) -> Option<usize> {
(from..chars.len()).find(|&i| chars[i..].starts_with(seq))
}
fn closing_backticks(chars: &[char], from: usize, len: usize) -> Option<usize> {
let mut i = from;
while i < chars.len() {
if chars[i] != '`' {
i += 1;
continue;
}
let end = run_end(chars, i, |c| c == '`');
if end - i == len {
return Some(i);
}
i = end;
}
None
}
fn scan_line(chars: &[char]) -> Vec<IssueRef> {
let mut found = Vec::new();
let mut i = 0;
while i < chars.len() {
if !is_word(chars[i]) || (i > 0 && is_word(chars[i - 1])) {
i += 1;
continue;
}
let word_end = run_end(chars, i, is_word);
i = match keyword_end(chars, i, word_end) {
Some(kw_end) => parse_ref_list(chars, kw_end, &mut found),
None => word_end,
};
}
found
}
fn keyword_end(chars: &[char], start: usize, word_end: usize) -> Option<usize> {
let word: String = chars[start..word_end].iter().collect();
if KEYWORDS.iter().any(|k| word.eq_ignore_ascii_case(k)) {
return Some(word_end);
}
if !word.eq_ignore_ascii_case("part") {
return None;
}
let of_start = skip_blanks(chars, word_end);
if of_start == word_end {
return None;
}
let of_end = run_end(chars, of_start, is_word);
let of: String = chars[of_start..of_end].iter().collect();
of.eq_ignore_ascii_case("of").then_some(of_end)
}
fn parse_ref_list(chars: &[char], kw_end: usize, found: &mut Vec<IssueRef>) -> usize {
let mut pos = kw_end;
if chars.get(pos) == Some(&':') {
pos += 1;
}
let start = skip_blanks(chars, pos);
if start == pos {
return kw_end;
}
let Some((first, mut end)) = parse_ref(chars, start) else {
return kw_end;
};
found.push(first);
while let Some(next) = separator_end(chars, end) {
match parse_ref(chars, next) {
Some((issue, after)) => {
found.push(issue);
end = after;
}
None => break,
}
}
end
}
fn separator_end(chars: &[char], end: usize) -> Option<usize> {
let p = skip_blanks(chars, end);
match chars.get(p) {
Some(',') => {
let q = skip_blanks(chars, p + 1);
Some(after_and(chars, q).unwrap_or(q))
}
Some('&') => Some(skip_blanks(chars, p + 1)),
_ => after_and(chars, p),
}
}
fn after_and(chars: &[char], p: usize) -> Option<usize> {
if p > 0 && is_word(chars[p - 1]) {
return None;
}
let end = run_end(chars, p, is_word);
let word: String = chars[p..end].iter().collect();
word.eq_ignore_ascii_case("and")
.then(|| skip_blanks(chars, end))
}
fn parse_ref(chars: &[char], p: usize) -> Option<(IssueRef, usize)> {
if p > 0 && is_word(chars[p - 1]) {
return None;
}
if chars.get(p) == Some(&'#') {
let (number, end) = parse_number(chars, p + 1)?;
let issue = IssueRef {
number,
owner_repo: None,
};
return Some((issue, end));
}
let owner_end = run_end(chars, p, |c| c.is_ascii_alphanumeric() || c == '-');
if owner_end == p || chars.get(owner_end) != Some(&'/') {
return None;
}
let repo_start = owner_end + 1;
let repo_end = run_end(chars, repo_start, |c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
});
if repo_end == repo_start || chars.get(repo_end) != Some(&'#') {
return None;
}
let (number, end) = parse_number(chars, repo_end + 1)?;
let owner: String = chars[p..owner_end].iter().collect();
let repo: String = chars[repo_start..repo_end].iter().collect();
let issue = IssueRef {
number,
owner_repo: Some((owner, repo)),
};
Some((issue, end))
}
fn parse_number(chars: &[char], p: usize) -> Option<(u64, usize)> {
let end = run_end(chars, p, |c| c.is_ascii_digit());
let digits = end - p;
if digits == 0 || digits > MAX_DIGITS {
return None;
}
if chars.get(end).is_some_and(|&c| is_word(c)) {
return None;
}
let text: String = chars[p..end].iter().collect();
let number: u64 = text.parse().ok()?;
(number != 0).then_some((number, end))
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn run_end(chars: &[char], from: usize, pred: impl Fn(char) -> bool) -> usize {
(from..chars.len())
.find(|&i| !pred(chars[i]))
.unwrap_or(chars.len())
}
fn skip_blanks(chars: &[char], from: usize) -> usize {
run_end(chars, from, |c| c == ' ' || c == '\t')
}