use regex::Regex;
use ropey::Rope;
use std::ops::Range;
use std::sync::LazyLock;
use tree_sitter::Node;
use crate::github::GitHubValidationCache;
use crate::parser::MarkdownTree;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubContext {
pub owner: String,
pub repo: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum GitHubRef {
Issue {
owner: String,
repo: String,
number: u64,
},
User { username: String },
Commit {
owner: String,
repo: String,
sha: String,
},
Compare {
owner: String,
repo: String,
base: String,
head: String,
},
File {
owner: String,
repo: String,
sha: String,
path: String,
lines: Option<String>,
},
}
impl GitHubRef {
pub fn url(&self) -> String {
match self {
GitHubRef::Issue {
owner,
repo,
number,
} => {
format!("https://github.com/{owner}/{repo}/issues/{number}")
}
GitHubRef::User { username } => {
format!("https://github.com/{username}")
}
GitHubRef::Commit { owner, repo, sha } => {
format!("https://github.com/{owner}/{repo}/commit/{sha}")
}
GitHubRef::Compare {
owner,
repo,
base,
head,
} => {
format!("https://github.com/{owner}/{repo}/compare/{base}...{head}")
}
GitHubRef::File {
owner,
repo,
sha,
path,
lines,
} => {
let base = format!("https://github.com/{owner}/{repo}/blob/{sha}/{path}");
match lines {
Some(l) => format!("{base}#{l}"),
None => base,
}
}
}
}
pub fn short_display(&self, context: Option<&GitHubContext>) -> String {
let is_same_repo = |owner: &str, repo: &str| {
context.is_some_and(|ctx| ctx.owner == owner && ctx.repo == repo)
};
match self {
GitHubRef::Issue {
owner,
repo,
number,
} => {
if is_same_repo(owner, repo) {
format!("#{number}")
} else {
format!("{owner}/{repo}#{number}")
}
}
GitHubRef::User { username } => format!("@{username}"),
GitHubRef::Commit { owner, repo, sha } => {
let short_sha = &sha[..sha.len().min(7)];
if is_same_repo(owner, repo) {
format!("@{short_sha}")
} else {
format!("{owner}/{repo}@{short_sha}")
}
}
GitHubRef::Compare {
owner,
repo,
base,
head,
} => {
if is_same_repo(owner, repo) {
format!("@{base}...{head}")
} else {
format!("{owner}/{repo}@{base}...{head}")
}
}
GitHubRef::File {
owner,
repo,
sha,
path,
lines,
} => {
let short_sha = &sha[..sha.len().min(7)];
let display = if is_same_repo(owner, repo) {
format!("@{short_sha}:{path}")
} else {
format!("{owner}/{repo}@{short_sha}:{path}")
};
match lines {
Some(l) => format!("{display}#{l}"),
None => display,
}
}
}
}
fn from_cross_repo_issue_capture(cap: ®ex::Captures) -> Self {
GitHubRef::Issue {
owner: cap[2].to_string(),
repo: cap[3].to_string(),
number: cap[4].parse().expect("regex guarantees valid number"),
}
}
fn from_issue_capture(cap: ®ex::Captures, ctx: &GitHubContext) -> Self {
GitHubRef::Issue {
owner: ctx.owner.clone(),
repo: ctx.repo.clone(),
number: cap[1].parse().expect("regex guarantees valid number"),
}
}
fn from_cross_repo_commit_capture(cap: ®ex::Captures) -> Self {
GitHubRef::Commit {
owner: cap[2].to_string(),
repo: cap[3].to_string(),
sha: cap[4].to_string(),
}
}
fn from_sha_capture(cap: ®ex::Captures, ctx: &GitHubContext) -> Self {
GitHubRef::Commit {
owner: ctx.owner.clone(),
repo: ctx.repo.clone(),
sha: cap[1].to_string(),
}
}
fn from_user_capture(cap: ®ex::Captures) -> Self {
GitHubRef::User {
username: cap[2].to_string(),
}
}
pub fn from_url(url: &str) -> Option<Self> {
if let Some(cap) = GITHUB_ISSUE_URL_RE.captures(url) {
return Some(GitHubRef::Issue {
owner: cap[1].to_string(),
repo: cap[2].to_string(),
number: cap[3].parse().ok()?,
});
}
if let Some(cap) = GITHUB_COMPARE_URL_RE.captures(url) {
return Some(GitHubRef::Compare {
owner: cap[1].to_string(),
repo: cap[2].to_string(),
base: cap[3].to_string(),
head: cap[4].to_string(),
});
}
if let Some(cap) = GITHUB_FILE_URL_RE.captures(url) {
return Some(GitHubRef::File {
owner: cap[1].to_string(),
repo: cap[2].to_string(),
sha: cap[3].to_string(),
path: cap[4].to_string(),
lines: cap.get(5).map(|m| m.as_str().to_string()),
});
}
None
}
}
static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"#(\d{1,10})").unwrap());
static GH_ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)GH-(\d{1,10})").unwrap());
static USER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?))(?:[^a-zA-Z0-9/]|$)").unwrap()
});
static CROSS_REPO_ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)#(\d{1,10}))(?:[^a-zA-Z0-9]|$)").unwrap()
});
static SHA_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b([0-9a-f]{7,40})\b").unwrap());
static CROSS_REPO_COMMIT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)@([0-9a-f]{7,40}))(?:[^a-zA-Z0-9]|$)").unwrap()
});
static GITHUB_ISSUE_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"https?://github\.com/([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)/(?:issues|pull)/(\d+)")
.unwrap()
});
static GITHUB_COMPARE_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"https?://github\.com/([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)/compare/(.+?)\.\.\.([^\s]+)",
)
.unwrap()
});
static GITHUB_FILE_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"https?://github\.com/([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)/blob/([0-9a-f]+)/([^#\s]+)(?:#(L\d+(?:-L\d+)?))?",
)
.unwrap()
});
static NAKED_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"https?://[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+(?::\d+)?(?:/[^\s<>\[\]()]*)?").unwrap()
});
#[derive(Debug, Clone)]
pub struct RawGitHubMatch {
pub reference: GitHubRef,
pub byte_range: Range<usize>,
}
#[derive(Debug, Clone)]
pub struct NakedUrl {
pub url: String,
pub byte_range: Range<usize>,
pub github_ref: Option<GitHubRef>,
}
pub fn detect_naked_urls(
line: &str,
line_byte_offset: usize,
code_ranges: &[Range<usize>],
link_ranges: &[Range<usize>],
) -> Vec<NakedUrl> {
let mut urls = Vec::new();
let is_in_code = |abs_pos: usize| -> bool { code_ranges.iter().any(|r| r.contains(&abs_pos)) };
let is_in_link = |abs_pos: usize| -> bool { link_ranges.iter().any(|r| r.contains(&abs_pos)) };
for m in NAKED_URL_RE.find_iter(line) {
let abs_range = (line_byte_offset + m.start())..(line_byte_offset + m.end());
if is_in_code(abs_range.start) || is_in_link(abs_range.start) {
continue;
}
let url = m.as_str().to_string();
let github_ref = GitHubRef::from_url(&url);
urls.push(NakedUrl {
url,
byte_range: abs_range,
github_ref,
});
}
urls
}
pub fn detect_github_references_in_line(
line: &str,
line_byte_offset: usize,
github_context: Option<&GitHubContext>,
code_ranges: &[Range<usize>],
) -> Vec<RawGitHubMatch> {
let mut matches = Vec::new();
let mut matched_ranges: Vec<Range<usize>> = Vec::new();
let is_in_code = |abs_pos: usize| -> bool { code_ranges.iter().any(|r| r.contains(&abs_pos)) };
let overlaps_matched = |range: &Range<usize>, matched: &[Range<usize>]| -> bool {
matched
.iter()
.any(|r| range.start < r.end && range.end > r.start)
};
let is_word_boundary = |pos: usize| -> bool {
if pos >= line.len() {
return true;
}
!line.as_bytes()[pos].is_ascii_alphanumeric()
};
for cap in CROSS_REPO_ISSUE_RE.captures_iter(line) {
let full = cap.get(1).unwrap();
let abs_range = (line_byte_offset + full.start())..(line_byte_offset + full.end());
if is_in_code(abs_range.start) {
continue;
}
matched_ranges.push(abs_range.clone());
matches.push(RawGitHubMatch {
reference: GitHubRef::from_cross_repo_issue_capture(&cap),
byte_range: abs_range,
});
}
for cap in CROSS_REPO_COMMIT_RE.captures_iter(line) {
let full = cap.get(1).unwrap();
let abs_range = (line_byte_offset + full.start())..(line_byte_offset + full.end());
if is_in_code(abs_range.start) {
continue;
}
matched_ranges.push(abs_range.clone());
matches.push(RawGitHubMatch {
reference: GitHubRef::from_cross_repo_commit_capture(&cap),
byte_range: abs_range,
});
}
for cap in USER_RE.captures_iter(line) {
let full = cap.get(1).unwrap();
let abs_range = (line_byte_offset + full.start())..(line_byte_offset + full.end());
if is_in_code(abs_range.start) {
continue;
}
if overlaps_matched(&abs_range, &matched_ranges) {
continue;
}
matched_ranges.push(abs_range.clone());
matches.push(RawGitHubMatch {
reference: GitHubRef::from_user_capture(&cap),
byte_range: abs_range,
});
}
if let Some(ctx) = github_context {
for cap in ISSUE_RE.captures_iter(line) {
let full_match = cap.get(0).unwrap();
let match_start = full_match.start();
let match_end = full_match.end();
let abs_start = line_byte_offset + match_start;
if is_in_code(abs_start) {
continue;
}
if match_start > 0 && !is_word_boundary(match_start - 1) {
continue;
}
if match_end < line.len() && !is_word_boundary(match_end) {
continue;
}
let abs_range = abs_start..(line_byte_offset + match_end);
if overlaps_matched(&abs_range, &matched_ranges) {
continue;
}
matched_ranges.push(abs_range.clone());
matches.push(RawGitHubMatch {
reference: GitHubRef::from_issue_capture(&cap, ctx),
byte_range: abs_range,
});
}
for cap in GH_ISSUE_RE.captures_iter(line) {
let full_match = cap.get(0).unwrap();
let match_start = full_match.start();
let match_end = full_match.end();
let abs_start = line_byte_offset + match_start;
if is_in_code(abs_start) {
continue;
}
if match_start > 0 && !is_word_boundary(match_start - 1) {
continue;
}
if match_end < line.len() && !is_word_boundary(match_end) {
continue;
}
let abs_range = abs_start..(line_byte_offset + match_end);
if overlaps_matched(&abs_range, &matched_ranges) {
continue;
}
matched_ranges.push(abs_range.clone());
matches.push(RawGitHubMatch {
reference: GitHubRef::from_issue_capture(&cap, ctx),
byte_range: abs_range,
});
}
for cap in SHA_RE.captures_iter(line) {
let m = cap.get(1).unwrap();
let start = m.start();
let abs_start = line_byte_offset + start;
if is_in_code(abs_start) {
continue;
}
let abs_range = abs_start..(line_byte_offset + m.end());
if overlaps_matched(&abs_range, &matched_ranges) {
continue;
}
matches.push(RawGitHubMatch {
reference: GitHubRef::from_sha_capture(&cap, ctx),
byte_range: abs_range,
});
}
}
matches
}
pub fn github_refs_to_styled_regions(
matches: &[RawGitHubMatch],
cache: &GitHubValidationCache,
) -> Vec<StyledRegion> {
matches
.iter()
.filter(|m| cache.is_valid(&m.reference))
.map(|m| StyledRegion {
full_range: m.byte_range.clone(),
content_range: m.byte_range.clone(),
style: TextStyle::default(),
link_url: Some(m.reference.url()),
is_image: false,
checkbox: None,
display_text: None,
})
.collect()
}
pub fn naked_urls_to_styled_regions(
urls: &[NakedUrl],
cache: &GitHubValidationCache,
context: Option<&GitHubContext>,
) -> Vec<StyledRegion> {
urls.iter()
.map(|u| {
let display_text = u.github_ref.as_ref().and_then(|ref_| {
if cache.is_valid(ref_) {
Some(ref_.short_display(context))
} else {
None
}
});
StyledRegion {
full_range: u.byte_range.clone(),
content_range: u.byte_range.clone(),
style: TextStyle::default(),
link_url: Some(u.url.clone()),
is_image: false,
checkbox: None,
display_text,
}
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TextStyle {
pub bold: bool,
pub italic: bool,
pub code: bool,
pub strikethrough: bool,
pub heading_level: u8,
}
impl TextStyle {
pub fn bold() -> Self {
Self {
bold: true,
..Default::default()
}
}
pub fn italic() -> Self {
Self {
italic: true,
..Default::default()
}
}
pub fn code() -> Self {
Self {
code: true,
..Default::default()
}
}
pub fn strikethrough() -> Self {
Self {
strikethrough: true,
..Default::default()
}
}
pub fn heading(level: u8) -> Self {
Self {
heading_level: level,
bold: true,
..Default::default()
}
}
pub fn merge(&self, other: &TextStyle) -> Self {
Self {
bold: self.bold || other.bold,
italic: self.italic || other.italic,
code: self.code || other.code,
strikethrough: self.strikethrough || other.strikethrough,
heading_level: self.heading_level.max(other.heading_level),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyledRegion {
pub full_range: Range<usize>,
pub content_range: Range<usize>,
pub style: TextStyle,
pub link_url: Option<String>,
pub is_image: bool,
pub checkbox: Option<bool>,
pub display_text: Option<String>,
}
pub fn extract_all_inline_styles(tree: &MarkdownTree, rope: &Rope) -> Vec<StyledRegion> {
let mut styles = Vec::new();
let block_root = tree.block_tree().root_node();
collect_from_block_tree(&block_root, tree, rope, &mut styles);
styles.sort_by_key(|s| s.full_range.start);
styles
}
fn collect_from_block_tree(
node: &Node,
tree: &MarkdownTree,
rope: &Rope,
styles: &mut Vec<StyledRegion>,
) {
if (node.kind() == "inline" || node.kind() == "pipe_table_cell")
&& let Some(inline_tree) = tree.inline_tree(node)
{
collect_from_inline_tree(inline_tree.root_node(), rope, styles);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_from_block_tree(&child, tree, rope, styles);
}
}
fn collect_from_inline_tree(node: Node, rope: &Rope, styles: &mut Vec<StyledRegion>) {
collect_from_inline_tree_inner(node, rope, styles, false);
}
fn collect_from_inline_tree_inner(
node: Node,
rope: &Rope,
styles: &mut Vec<StyledRegion>,
in_strikethrough: bool,
) {
let mut child_in_strikethrough = in_strikethrough;
match node.kind() {
"emphasis" => {
if let Some(region) = extract_emphasis_region(&node, TextStyle::italic()) {
styles.push(region);
}
}
"strong_emphasis" => {
if let Some(region) = extract_emphasis_region(&node, TextStyle::bold()) {
styles.push(region);
}
}
"code_span" => {
if let Some(region) = extract_code_span_region(&node) {
styles.push(region);
}
}
"strikethrough" => {
if !in_strikethrough {
if let Some(region) = extract_emphasis_region(&node, TextStyle::strikethrough()) {
styles.push(region);
}
child_in_strikethrough = true;
}
}
"inline_link" | "full_reference_link" | "collapsed_reference_link" | "shortcut_link" => {
if let Some(region) = extract_link_region(&node, rope) {
styles.push(region);
}
}
"image" => {
if let Some(region) = extract_image_region(&node, rope) {
styles.push(region);
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_from_inline_tree_inner(child, rope, styles, child_in_strikethrough);
}
}
fn extract_emphasis_region(node: &Node, style: TextStyle) -> Option<StyledRegion> {
let full_start = node.start_byte();
let full_end = node.end_byte();
let mut content_start = full_start;
let mut content_end = full_end;
fn collect_delimiters(node: &Node, delimiters: &mut Vec<(usize, usize)>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "emphasis_delimiter" || kind.ends_with("_delimiter") {
delimiters.push((child.start_byte(), child.end_byte()));
}
if kind == node.kind() {
collect_delimiters(&child, delimiters);
}
}
}
let mut delimiters: Vec<(usize, usize)> = Vec::new();
collect_delimiters(node, &mut delimiters);
delimiters.sort_by_key(|(start, _)| *start);
for &(start, end) in &delimiters {
if start == content_start {
content_start = end;
}
}
for &(start, end) in delimiters.iter().rev() {
if end == content_end {
content_end = start;
}
}
Some(StyledRegion {
full_range: full_start..full_end,
content_range: content_start..content_end,
style,
link_url: None,
is_image: false,
checkbox: None,
display_text: None,
})
}
fn extract_code_span_region(node: &Node) -> Option<StyledRegion> {
let full_start = node.start_byte();
let full_end = node.end_byte();
let mut content_start = full_start;
let mut content_end = full_end;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "code_span_delimiter" {
if child.start_byte() == full_start {
content_start = child.end_byte();
} else if child.end_byte() == full_end {
content_end = child.start_byte();
}
}
}
Some(StyledRegion {
full_range: full_start..full_end,
content_range: content_start..content_end,
style: TextStyle::code(),
link_url: None,
is_image: false,
checkbox: None,
display_text: None,
})
}
fn extract_link_region(node: &Node, rope: &Rope) -> Option<StyledRegion> {
let full_start = node.start_byte();
let full_end = node.end_byte();
if node.kind() == "shortcut_link" {
let start = rope.byte_to_char(full_start);
let end = rope.byte_to_char(full_end);
let text = rope.slice(start..end).to_string();
if text == "[ ]" || text == "[x]" || text == "[X]" {
return None;
}
}
let mut content_start = full_start;
let mut content_end = full_end;
let mut url: Option<String> = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"link_text" => {
content_start = child.start_byte();
content_end = child.end_byte();
}
"link_destination" => {
let start = rope.byte_to_char(child.start_byte());
let end = rope.byte_to_char(child.end_byte());
url = Some(rope.slice(start..end).to_string());
}
_ => {}
}
}
if url.is_none() {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "[" {
content_start = child.end_byte();
} else if child.kind() == "]" {
content_end = child.start_byte();
}
}
}
Some(StyledRegion {
full_range: full_start..full_end,
content_range: content_start..content_end,
style: TextStyle::default(),
link_url: url,
is_image: false,
checkbox: None,
display_text: None,
})
}
fn extract_image_region(node: &Node, rope: &Rope) -> Option<StyledRegion> {
let full_start = node.start_byte();
let full_end = node.end_byte();
let mut alt_start = full_start;
let mut alt_end = full_end;
let mut url: Option<String> = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"image_description" => {
alt_start = child.start_byte();
alt_end = child.end_byte();
}
"link_destination" => {
let start = rope.byte_to_char(child.start_byte());
let end = rope.byte_to_char(child.end_byte());
url = Some(rope.slice(start..end).to_string());
}
_ => {}
}
}
let url = url?;
Some(StyledRegion {
full_range: full_start..full_end,
content_range: alt_start..alt_end,
style: TextStyle::default(),
link_url: Some(url),
is_image: true,
checkbox: None,
display_text: None,
})
}
pub fn styles_in_range<'a>(
styles: &'a [StyledRegion],
range: &Range<usize>,
) -> Vec<&'a StyledRegion> {
if styles.is_empty() {
return Vec::new();
}
let start_idx = styles
.binary_search_by_key(&range.start, |s| s.full_range.start)
.unwrap_or_else(|idx| idx.saturating_sub(1));
let mut result = Vec::new();
for style in &styles[start_idx..] {
if style.full_range.start >= range.end {
break;
}
if style.full_range.end > range.start {
result.push(style);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Buffer;
fn get_styles(text: &str) -> Vec<StyledRegion> {
let buf: Buffer = text.parse().unwrap();
extract_all_inline_styles(buf.tree().unwrap(), buf.rope())
}
#[test]
fn test_bold() {
let styles = get_styles("**bold** text\n");
assert_eq!(styles.len(), 1);
assert!(styles[0].style.bold);
assert_eq!(styles[0].full_range, 0..8);
assert_eq!(styles[0].content_range, 2..6);
}
#[test]
fn test_italic() {
let styles = get_styles("*italic* text\n");
assert_eq!(styles.len(), 1);
assert!(styles[0].style.italic);
assert_eq!(styles[0].full_range, 0..8);
assert_eq!(styles[0].content_range, 1..7);
}
#[test]
fn test_code() {
let styles = get_styles("`code` text\n");
assert_eq!(styles.len(), 1);
assert!(styles[0].style.code);
assert_eq!(styles[0].full_range, 0..6);
assert_eq!(styles[0].content_range, 1..5);
}
#[test]
fn test_link() {
let styles = get_styles("[text](http://example.com)\n");
assert_eq!(styles.len(), 1);
assert_eq!(styles[0].link_url, Some("http://example.com".to_string()));
assert_eq!(styles[0].full_range, 0..26);
assert_eq!(styles[0].content_range, 1..5);
}
#[test]
fn test_nested_bold_italic() {
let styles = get_styles("***bold italic***\n");
assert!(!styles.is_empty());
}
#[test]
fn test_multiple_lines() {
let styles = get_styles("**bold**\n*italic*\n`code`\n");
assert_eq!(styles.len(), 3);
assert!(styles[0].style.bold);
assert!(styles[1].style.italic);
assert!(styles[2].style.code);
}
#[test]
fn test_styles_in_range() {
let styles = get_styles("**bold**\n*italic*\n`code`\n");
let line1_styles = styles_in_range(&styles, &(0..8));
assert_eq!(line1_styles.len(), 1);
assert!(line1_styles[0].style.bold);
let line2_styles = styles_in_range(&styles, &(9..17));
assert_eq!(line2_styles.len(), 1);
assert!(line2_styles[0].style.italic);
}
#[test]
fn test_blockquote_inline() {
let styles = get_styles("> **bold** in quote\n");
assert_eq!(styles.len(), 1);
assert!(styles[0].style.bold);
}
#[test]
fn test_list_inline() {
let styles = get_styles("- **bold** in list\n- *italic* too\n");
assert_eq!(styles.len(), 2);
assert!(styles[0].style.bold);
assert!(styles[1].style.italic);
}
#[test]
fn test_strikethrough() {
let styles = get_styles("~~hey~~\n");
assert_eq!(styles.len(), 1);
assert!(styles[0].style.strikethrough);
assert_eq!(styles[0].full_range, 0..7);
assert_eq!(styles[0].content_range, 2..5);
}
fn github_ctx() -> GitHubContext {
GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
}
}
#[test]
fn test_github_issue_ref() {
let line = "See #123 for details";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Issue { owner, repo, number }
if owner == "rust-lang" && repo == "rust" && *number == 123
));
assert_eq!(matches[0].byte_range, 4..8); }
#[test]
fn test_github_issue_at_start() {
let line = "#456 is fixed";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Issue { number, .. } if *number == 456
));
assert_eq!(matches[0].byte_range, 0..4);
}
#[test]
fn test_github_gh_format() {
let line = "Fixed in GH-789";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Issue { number, .. } if *number == 789
));
}
#[test]
fn test_github_user_mention() {
let line = "Thanks @torvalds for the review";
let matches = detect_github_references_in_line(line, 0, None, &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::User { username } if username == "torvalds"
));
assert_eq!(matches[0].byte_range, 7..16); }
#[test]
fn test_github_cross_repo_issue() {
let line = "See tokio-rs/tokio#1234";
let matches = detect_github_references_in_line(line, 0, None, &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Issue { owner, repo, number }
if owner == "tokio-rs" && repo == "tokio" && *number == 1234
));
}
#[test]
fn test_github_sha_ref() {
let line = "Fixed in a1b2c3d";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Commit { sha, .. } if sha == "a1b2c3d"
));
}
#[test]
fn test_github_cross_repo_commit() {
let line = "See tokio-rs/tokio@abc1234";
let matches = detect_github_references_in_line(line, 0, None, &[]);
assert_eq!(matches.len(), 1);
assert!(matches!(
&matches[0].reference,
GitHubRef::Commit { owner, repo, sha }
if owner == "tokio-rs" && repo == "tokio" && sha == "abc1234"
));
}
#[test]
fn test_github_skip_code_span() {
let line = "Use `#123` in code";
let ctx = github_ctx();
let code_range = 4..10;
let matches = detect_github_references_in_line(
line,
0,
Some(&ctx),
std::slice::from_ref(&code_range),
);
assert!(matches.is_empty(), "Should not match inside code span");
}
#[test]
fn test_github_no_context_no_simple_refs() {
let line = "Issue #123 and commit a1b2c3d";
let matches = detect_github_references_in_line(line, 0, None, &[]);
assert!(matches.is_empty(), "Simple refs need GitHub context");
}
#[test]
fn test_github_multiple_refs() {
let line = "#1 #2 @user rust-lang/rust#3";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
assert_eq!(matches.len(), 4);
}
#[test]
fn test_github_line_byte_offset() {
let line = "See #123";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 100, Some(&ctx), &[]);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].byte_range, 104..108);
}
#[test]
fn test_github_ref_url() {
let issue = GitHubRef::Issue {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
number: 123,
};
assert_eq!(issue.url(), "https://github.com/rust-lang/rust/issues/123");
let user = GitHubRef::User {
username: "torvalds".to_string(),
};
assert_eq!(user.url(), "https://github.com/torvalds");
let commit = GitHubRef::Commit {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
sha: "abc1234".to_string(),
};
assert_eq!(
commit.url(),
"https://github.com/rust-lang/rust/commit/abc1234"
);
}
#[test]
fn test_github_refs_to_styled_regions() {
let line = "See #123";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
let cache = GitHubValidationCache::new();
cache.set_valid(
GitHubRef::Issue {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
number: 123,
},
None,
);
let regions = github_refs_to_styled_regions(&matches, &cache);
assert_eq!(regions.len(), 1);
assert_eq!(
regions[0].link_url,
Some("https://github.com/rust-lang/rust/issues/123".to_string())
);
}
#[test]
fn test_github_unvalidated_ref_not_styled() {
let line = "See #999999";
let ctx = github_ctx();
let matches = detect_github_references_in_line(line, 0, Some(&ctx), &[]);
let cache = GitHubValidationCache::new();
let regions = github_refs_to_styled_regions(&matches, &cache);
assert!(regions.is_empty(), "Unvalidated refs should not be styled");
}
#[test]
fn test_naked_url_detection() {
let line = "See https://example.com/page for details";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].url, "https://example.com/page");
assert_eq!(urls[0].byte_range, 4..28);
assert!(urls[0].github_ref.is_none());
}
#[test]
fn test_naked_url_skips_code_span() {
let line = "Use `https://example.com` in code";
let code_range = 4..25;
let urls = detect_naked_urls(line, 0, &[code_range], &[]);
assert!(urls.is_empty(), "Should not match inside code span");
}
#[test]
fn test_naked_url_skips_markdown_link() {
let line = "See [link](https://example.com) here";
let link_range = 4..31;
let urls = detect_naked_urls(line, 0, &[], &[link_range]);
assert!(urls.is_empty(), "Should not match inside markdown link");
}
#[test]
fn test_naked_github_issue_url() {
let line = "See https://github.com/rust-lang/rust/issues/123 for details";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(matches!(
&urls[0].github_ref,
Some(GitHubRef::Issue { owner, repo, number })
if owner == "rust-lang" && repo == "rust" && *number == 123
));
assert_eq!(urls[0].byte_range, 4..48);
}
#[test]
fn test_naked_github_pr_url() {
let line = "Fixed in https://github.com/tokio-rs/tokio/pull/456";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(matches!(
&urls[0].github_ref,
Some(GitHubRef::Issue { owner, repo, number })
if owner == "tokio-rs" && repo == "tokio" && *number == 456
));
}
#[test]
fn test_naked_github_compare_url() {
let line = "Changes: https://github.com/rust-lang/rust/compare/v1.0...v2.0";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(matches!(
&urls[0].github_ref,
Some(GitHubRef::Compare { owner, repo, base, head })
if owner == "rust-lang" && repo == "rust" && base == "v1.0" && head == "v2.0"
));
}
#[test]
fn test_naked_github_file_url() {
let line = "See https://github.com/rust-lang/rust/blob/abc1234def/src/main.rs#L10-L20";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(matches!(
&urls[0].github_ref,
Some(GitHubRef::File { owner, repo, sha, path, lines })
if owner == "rust-lang" && repo == "rust" && sha == "abc1234def"
&& path == "src/main.rs" && lines.as_deref() == Some("L10-L20")
));
}
#[test]
fn test_naked_github_file_url_no_lines() {
let line = "File: https://github.com/owner/repo/blob/abc1234/path/to/file.rs";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(matches!(
&urls[0].github_ref,
Some(GitHubRef::File { path, lines, .. })
if path == "path/to/file.rs" && lines.is_none()
));
}
#[test]
fn test_non_github_url_has_no_ref() {
let line = "See https://example.com/page";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert!(urls[0].github_ref.is_none());
}
#[test]
fn test_invalid_urls_not_matched() {
let line = "http://g is not a valid URL";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert!(urls.is_empty(), "http://g should not match");
let line = "https://x should not match";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert!(urls.is_empty(), "https://x should not match");
let line = "http://localhost is common but no TLD";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert!(
urls.is_empty(),
"http://localhost should not match (no TLD)"
);
}
#[test]
fn test_valid_urls_matched() {
let line = "Visit https://example.com for info";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].url, "https://example.com");
let line = "See http://foo.bar/path/to/page";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].url, "http://foo.bar/path/to/page");
let line = "Dev server at http://example.com:8080/api";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].url, "http://example.com:8080/api");
let line = "Check https://sub.domain.example.org/page";
let urls = detect_naked_urls(line, 0, &[], &[]);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].url, "https://sub.domain.example.org/page");
}
#[test]
fn test_github_ref_from_url() {
let issue = GitHubRef::from_url("https://github.com/rust-lang/rust/issues/123");
assert!(matches!(
issue,
Some(GitHubRef::Issue { owner, repo, number })
if owner == "rust-lang" && repo == "rust" && number == 123
));
let pr = GitHubRef::from_url("https://github.com/tokio-rs/tokio/pull/456");
assert!(matches!(
pr,
Some(GitHubRef::Issue { number, .. })
if number == 456
));
let compare = GitHubRef::from_url("https://github.com/owner/repo/compare/v1.0...v2.0");
assert!(matches!(
compare,
Some(GitHubRef::Compare { base, head, .. })
if base == "v1.0" && head == "v2.0"
));
let file = GitHubRef::from_url("https://github.com/owner/repo/blob/abc123/src/lib.rs#L5");
assert!(matches!(
file,
Some(GitHubRef::File { sha, path, lines, .. })
if sha == "abc123" && path == "src/lib.rs" && lines.as_deref() == Some("L5")
));
let other = GitHubRef::from_url("https://example.com/page");
assert!(other.is_none());
}
#[test]
fn test_short_display_issue() {
let issue = GitHubRef::Issue {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
number: 123,
};
assert_eq!(issue.short_display(None), "rust-lang/rust#123");
let ctx = GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
};
assert_eq!(issue.short_display(Some(&ctx)), "#123");
let other_ctx = GitHubContext {
owner: "other".to_string(),
repo: "repo".to_string(),
};
assert_eq!(issue.short_display(Some(&other_ctx)), "rust-lang/rust#123");
}
#[test]
fn test_short_display_user() {
let user = GitHubRef::User {
username: "torvalds".to_string(),
};
assert_eq!(user.short_display(None), "@torvalds");
}
#[test]
fn test_short_display_commit() {
let commit = GitHubRef::Commit {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
sha: "abc1234567890".to_string(),
};
assert_eq!(commit.short_display(None), "rust-lang/rust@abc1234");
let ctx = GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
};
assert_eq!(commit.short_display(Some(&ctx)), "@abc1234");
}
#[test]
fn test_short_display_compare() {
let compare = GitHubRef::Compare {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
base: "v1.0".to_string(),
head: "v2.0".to_string(),
};
assert_eq!(compare.short_display(None), "rust-lang/rust@v1.0...v2.0");
let ctx = GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
};
assert_eq!(compare.short_display(Some(&ctx)), "@v1.0...v2.0");
}
#[test]
fn test_short_display_file() {
let file = GitHubRef::File {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
sha: "abc1234567890".to_string(),
path: "src/main.rs".to_string(),
lines: Some("L10-L20".to_string()),
};
assert_eq!(
file.short_display(None),
"rust-lang/rust@abc1234:src/main.rs#L10-L20"
);
let ctx = GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
};
assert_eq!(
file.short_display(Some(&ctx)),
"@abc1234:src/main.rs#L10-L20"
);
}
#[test]
fn test_short_display_file_no_lines() {
let file = GitHubRef::File {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
sha: "abc1234".to_string(),
path: "README.md".to_string(),
lines: None,
};
assert_eq!(file.short_display(None), "rust-lang/rust@abc1234:README.md");
}
#[test]
fn test_url_and_short_display_for_new_variants() {
let compare = GitHubRef::Compare {
owner: "owner".to_string(),
repo: "repo".to_string(),
base: "main".to_string(),
head: "feature".to_string(),
};
assert_eq!(
compare.url(),
"https://github.com/owner/repo/compare/main...feature"
);
let file = GitHubRef::File {
owner: "owner".to_string(),
repo: "repo".to_string(),
sha: "abc1234".to_string(),
path: "src/lib.rs".to_string(),
lines: Some("L5".to_string()),
};
assert_eq!(
file.url(),
"https://github.com/owner/repo/blob/abc1234/src/lib.rs#L5"
);
}
#[test]
fn test_naked_urls_to_styled_regions() {
let urls = vec![
NakedUrl {
url: "https://example.com/page".to_string(),
byte_range: 4..27,
github_ref: None,
},
NakedUrl {
url: "https://github.com/rust-lang/rust/issues/123".to_string(),
byte_range: 30..74,
github_ref: Some(GitHubRef::Issue {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
number: 123,
}),
},
];
let cache = GitHubValidationCache::new();
let regions = naked_urls_to_styled_regions(&urls, &cache, None);
assert_eq!(regions.len(), 2);
assert_eq!(regions[0].full_range, 4..27);
assert_eq!(
regions[0].link_url,
Some("https://example.com/page".to_string())
);
assert!(regions[0].display_text.is_none());
assert_eq!(regions[1].full_range, 30..74);
assert_eq!(
regions[1].link_url,
Some("https://github.com/rust-lang/rust/issues/123".to_string())
);
assert!(regions[1].display_text.is_none());
}
#[test]
fn test_naked_urls_with_validated_github_ref() {
let github_ref = GitHubRef::Issue {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
number: 123,
};
let urls = vec![NakedUrl {
url: "https://github.com/rust-lang/rust/issues/123".to_string(),
byte_range: 0..44,
github_ref: Some(github_ref.clone()),
}];
let cache = GitHubValidationCache::new();
cache.set_valid(github_ref, None);
let regions = naked_urls_to_styled_regions(&urls, &cache, None);
assert_eq!(regions.len(), 1);
assert_eq!(
regions[0].display_text,
Some("rust-lang/rust#123".to_string())
);
let ctx = GitHubContext {
owner: "rust-lang".to_string(),
repo: "rust".to_string(),
};
let regions = naked_urls_to_styled_regions(&urls, &cache, Some(&ctx));
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].display_text, Some("#123".to_string()));
}
}