| 1 | //! What `#N` in text names, the one set of rules every reader of references |
| 2 | //! follows: closing keywords in pull requests and commits (work), Markdown |
| 3 | //! on the site, chat and the CLI. `apps/web/app/lib/refs.ts` mirrors it. |
| 4 | //! |
| 5 | //! A reference is `#N`, `owner/name#N` (read in that repository's context) |
| 6 | //! or `workspace#N` (in that workspace's). Read in a repository R of |
| 7 | //! workspace W, `#N` is the first of: |
| 8 | //! |
| 9 | //! 1. a pull request of R numbered N; |
| 10 | //! 2. a work item filed in R whose number there was N (an old issue); |
| 11 | //! 3. the work item numbered N in W; |
| 12 | //! 4. a pull request numbered N in another repository of W, when N is |
| 13 | //! above W's floor: new pull requests are numbered in the workspace, so |
| 14 | //! those are unique; the numbers its repositories used on their own |
| 15 | //! before collide, and name none of them from elsewhere. |
| 16 | //! |
| 17 | //! Read in a workspace alone, `#N` is the work item N in W, else a pull |
| 18 | //! request numbered N in W, above its floor. The work service's `resolve_ref` does the |
| 19 | //! lookups. |
| 20 | //! |
| 21 | //! What sits in code, a `span` or a fenced block, is no reference: it is |
| 22 | //! usually an example. |
| 23 | |
| 24 | use crate::repos::RepoPath; |
| 25 | |
| 26 | /// Where a reference is read. |
| 27 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 28 | pub enum RefScope { |
| 29 | /// `#N`: in the context it is written in. |
| 30 | Here, |
| 31 | /// `owner/name#N`: in that repository. |
| 32 | Repo(RepoPath), |
| 33 | /// `workspace#N`: in that workspace. |
| 34 | Workspace(String), |
| 35 | } |
| 36 | |
| 37 | /// A reference found in text. |
| 38 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 39 | pub struct Ref { |
| 40 | pub scope: RefScope, |
| 41 | pub number: u32, |
| 42 | } |
| 43 | |
| 44 | impl Ref { |
| 45 | /// The repository it is read in, given the one it was written in: |
| 46 | /// its own for `owner/name#N`, `here` for `#N`, none for `workspace#N`. |
| 47 | pub fn repo<'a>(&'a self, here: Option<&'a RepoPath>) -> Option<&'a RepoPath> { |
| 48 | match &self.scope { |
| 49 | RefScope::Here => here, |
| 50 | RefScope::Repo(repo) => Some(repo), |
| 51 | RefScope::Workspace(_) => None, |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | /// The workspace it is read in, given the one it was written in. |
| 56 | pub fn workspace<'a>(&'a self, here: &'a str) -> &'a str { |
| 57 | match &self.scope { |
| 58 | RefScope::Here => here, |
| 59 | RefScope::Repo(repo) => &repo.namespace, |
| 60 | RefScope::Workspace(workspace) => workspace, |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// One place `#N` may name something, in the order [`lookup_order`] gives. |
| 66 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 67 | pub enum Lookup { |
| 68 | /// A pull request of the repository it is read in. |
| 69 | PullInRepo, |
| 70 | /// A work item filed in that repository, by the number it had there. |
| 71 | LegacyItem, |
| 72 | /// The work item of the workspace with that number. |
| 73 | Item, |
| 74 | /// A pull request of another repository of the workspace. |
| 75 | PullInWorkspace, |
| 76 | } |
| 77 | |
| 78 | /// Where to look for `#N`, first to last: read in a repository, or in a |
| 79 | /// workspace alone. |
| 80 | pub fn lookup_order(in_repo: bool) -> &'static [Lookup] { |
| 81 | if in_repo { |
| 82 | &[Lookup::PullInRepo, Lookup::LegacyItem, Lookup::Item, Lookup::PullInWorkspace] |
| 83 | } else { |
| 84 | &[Lookup::Item, Lookup::PullInWorkspace] |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// The first of `lookup_order(in_repo)` that `found` finds. |
| 89 | pub fn first_found<T>(in_repo: bool, mut found: impl FnMut(Lookup) -> Option<T>) -> Option<T> { |
| 90 | lookup_order(in_repo).iter().find_map(|lookup| found(*lookup)) |
| 91 | } |
| 92 | |
| 93 | /// The words that close a work item: `Fixes #12`. |
| 94 | pub const CLOSING_KEYWORDS: [&str; 9] = ["close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"]; |
| 95 | |
| 96 | /// The words that tie work to a work item without closing it: `Part of #12`, |
| 97 | /// `Refs #12`. |
| 98 | pub const PART_KEYWORDS: [&str; 3] = ["part of", "refs", "ref"]; |
| 99 | |
| 100 | /// The most references one text closes or is part of. |
| 101 | pub const MAX_REFS: usize = 50; |
| 102 | |
| 103 | /// What a pull request or commit says about the work items it names. |
| 104 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 105 | pub struct Named { |
| 106 | /// After a closing keyword, in the order named, each once. |
| 107 | pub closes: Vec<Ref>, |
| 108 | /// After `Part of` or `Refs`, in the order named, each once, leaving |
| 109 | /// out what it also closes. |
| 110 | pub parts: Vec<Ref>, |
| 111 | } |
| 112 | |
| 113 | /// The references `text` names after closing keywords and after `Part of` |
| 114 | /// or `Refs`, outside code. |
| 115 | pub fn named(text: &str) -> Named { |
| 116 | let mut named = Named::default(); |
| 117 | for prose in outside_code(text) { |
| 118 | scan(&prose, &mut named); |
| 119 | } |
| 120 | named.parts.retain(|part| !named.closes.contains(part)); |
| 121 | named.closes.truncate(MAX_REFS); |
| 122 | named.parts.truncate(MAX_REFS); |
| 123 | named |
| 124 | } |
| 125 | |
| 126 | /// Every reference in `text` outside code, in order, each once: `#12`, |
| 127 | /// `owner/name#12` and `workspace#12`, for rendering them as links. |
| 128 | pub fn refs_in_text(text: &str) -> Vec<Ref> { |
| 129 | let mut found: Vec<Ref> = Vec::new(); |
| 130 | for prose in outside_code(text) { |
| 131 | let chars: Vec<char> = prose.chars().collect(); |
| 132 | let mut i = 0; |
| 133 | while i < chars.len() { |
| 134 | let starts = i == 0 || !is_name(chars[i - 1]) && chars[i - 1] != '#' && chars[i - 1] != '&'; |
| 135 | if starts && let Some((reference, after)) = reference_at(&chars, i) { |
| 136 | if !found.contains(&reference) { |
| 137 | found.push(reference); |
| 138 | } |
| 139 | i = after; |
| 140 | continue; |
| 141 | } |
| 142 | i += 1; |
| 143 | } |
| 144 | } |
| 145 | found |
| 146 | } |
| 147 | |
| 148 | /// A single reference, the whole of `text`: `#12`, `owner/name#12` or |
| 149 | /// `workspace#12`. |
| 150 | pub fn parse_ref(text: &str) -> Option<Ref> { |
| 151 | let chars: Vec<char> = text.trim().chars().collect(); |
| 152 | match reference_at(&chars, 0) { |
| 153 | Some((reference, after)) if after == chars.len() => Some(reference), |
| 154 | _ => None, |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// The parts of Markdown outside fenced code blocks and code spans, each |
| 159 | /// with code replaced by a space so words on either side stay apart. |
| 160 | pub fn outside_code(text: &str) -> Vec<String> { |
| 161 | let mut parts = Vec::new(); |
| 162 | let mut current = String::new(); |
| 163 | // The fence that opened the block being skipped: its character and length. |
| 164 | let mut fence: Option<(char, usize)> = None; |
| 165 | for line in text.lines() { |
| 166 | let trimmed = line.trim_start(); |
| 167 | let opener = ['`', '~'].into_iter().find_map(|mark| { |
| 168 | let run = trimmed.chars().take_while(|c| *c == mark).count(); |
| 169 | (run >= 3).then_some((mark, run)) |
| 170 | }); |
| 171 | match (fence, opener) { |
| 172 | (None, Some(opened)) => { |
| 173 | fence = Some(opened); |
| 174 | parts.push(std::mem::take(&mut current)); |
| 175 | } |
| 176 | (Some((mark, length)), Some((closing, run))) |
| 177 | if closing == mark && run >= length && trimmed.trim_end().chars().all(|c| c == mark) => |
| 178 | { |
| 179 | fence = None; |
| 180 | } |
| 181 | (Some(_), _) => {} |
| 182 | (None, None) => { |
| 183 | current.push_str(&without_spans(line)); |
| 184 | current.push('\n'); |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | parts.push(current); |
| 189 | parts |
| 190 | } |
| 191 | |
| 192 | /// A line with its code spans (between matching runs of backticks) blanked. |
| 193 | fn without_spans(line: &str) -> String { |
| 194 | let chars: Vec<char> = line.chars().collect(); |
| 195 | let mut out = String::with_capacity(line.len()); |
| 196 | let mut i = 0; |
| 197 | while i < chars.len() { |
| 198 | if chars[i] != '`' { |
| 199 | out.push(chars[i]); |
| 200 | i += 1; |
| 201 | continue; |
| 202 | } |
| 203 | let run = chars[i..].iter().take_while(|c| **c == '`').count(); |
| 204 | // The closing run of the same length, if there is one. |
| 205 | let mut j = i + run; |
| 206 | let mut close = None; |
| 207 | while j < chars.len() { |
| 208 | if chars[j] == '`' { |
| 209 | let other = chars[j..].iter().take_while(|c| **c == '`').count(); |
| 210 | if other == run { |
| 211 | close = Some(j + other); |
| 212 | break; |
| 213 | } |
| 214 | j += other; |
| 215 | } else { |
| 216 | j += 1; |
| 217 | } |
| 218 | } |
| 219 | match close { |
| 220 | Some(end) => { |
| 221 | out.push(' '); |
| 222 | i = end; |
| 223 | } |
| 224 | // An unmatched run is literal backticks. |
| 225 | None => { |
| 226 | out.extend(&chars[i..i + run]); |
| 227 | i += run; |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | out |
| 232 | } |
| 233 | |
| 234 | fn is_word(c: char) -> bool { |
| 235 | c.is_alphanumeric() || c == '_' |
| 236 | } |
| 237 | |
| 238 | /// A character of a workspace or repository name. |
| 239 | fn is_name(c: char) -> bool { |
| 240 | c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' |
| 241 | } |
| 242 | |
| 243 | /// Every keyword-and-reference pair in `text`. |
| 244 | fn scan(text: &str, named: &mut Named) { |
| 245 | let chars: Vec<char> = text.chars().collect(); |
| 246 | let mut i = 0; |
| 247 | while i < chars.len() { |
| 248 | // A word starts here. |
| 249 | if !is_word(chars[i]) || (i > 0 && is_word(chars[i - 1])) { |
| 250 | i += 1; |
| 251 | continue; |
| 252 | } |
| 253 | let end = chars[i..].iter().position(|c| !is_word(*c)).map_or(chars.len(), |n| i + n); |
| 254 | let word: String = chars[i..end].iter().collect::<String>().to_lowercase(); |
| 255 | let (closing, end) = if CLOSING_KEYWORDS.contains(&word.as_str()) { |
| 256 | (true, end) |
| 257 | } else if PART_KEYWORDS.contains(&word.as_str()) { |
| 258 | (false, end) |
| 259 | } else if word == "part" { |
| 260 | // `part of`, with spaces between. |
| 261 | let spaces = chars[end..].iter().take_while(|c| **c == ' ' || **c == '\t').count(); |
| 262 | let of: String = chars[(end + spaces).min(chars.len())..].iter().take(2).collect::<String>().to_lowercase(); |
| 263 | let after = end + spaces + 2; |
| 264 | if spaces == 0 || of != "of" || chars.get(after).is_some_and(|c| is_word(*c)) { |
| 265 | i = end; |
| 266 | continue; |
| 267 | } |
| 268 | (false, after) |
| 269 | } else { |
| 270 | i = end; |
| 271 | continue; |
| 272 | }; |
| 273 | i = end; |
| 274 | // An optional colon, then spaces on the same line; without a colon, |
| 275 | // at least one space. |
| 276 | let mut at = end; |
| 277 | let colon = chars.get(at) == Some(&':'); |
| 278 | if colon { |
| 279 | at += 1; |
| 280 | } |
| 281 | let spaces = chars[at..].iter().take_while(|c| **c == ' ' || **c == '\t').count(); |
| 282 | if spaces == 0 && !colon { |
| 283 | continue; |
| 284 | } |
| 285 | at += spaces; |
| 286 | if let Some((reference, after)) = reference_at(&chars, at) { |
| 287 | let list = if closing { &mut named.closes } else { &mut named.parts }; |
| 288 | if !list.contains(&reference) { |
| 289 | list.push(reference); |
| 290 | } |
| 291 | i = after; |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | /// A reference at `at`: `#12`, `owner/name#12` or `workspace#12`, ending at |
| 297 | /// a word boundary. The reference and where it ends. |
| 298 | fn reference_at(chars: &[char], at: usize) -> Option<(Ref, usize)> { |
| 299 | let length = chars[at..].iter().position(|c| *c == '#' || !(is_name(*c) || *c == '/')).unwrap_or(chars.len() - at); |
| 300 | let hash = at + length; |
| 301 | if chars.get(hash) != Some(&'#') { |
| 302 | return None; |
| 303 | } |
| 304 | let named: String = chars[at..hash].iter().collect(); |
| 305 | let scope = if named.is_empty() { |
| 306 | RefScope::Here |
| 307 | } else { |
| 308 | match named.split_once('/') { |
| 309 | Some((owner, name)) => { |
| 310 | if owner.is_empty() || name.is_empty() || name.contains('/') { |
| 311 | return None; |
| 312 | } |
| 313 | RefScope::Repo(RepoPath { namespace: owner.to_lowercase(), name: name.to_owned() }) |
| 314 | } |
| 315 | None => RefScope::Workspace(named.to_lowercase()), |
| 316 | } |
| 317 | }; |
| 318 | let digits = chars[hash + 1..].iter().take_while(|c| c.is_ascii_digit()).count(); |
| 319 | if digits == 0 || digits > 9 { |
| 320 | return None; |
| 321 | } |
| 322 | let after = hash + 1 + digits; |
| 323 | if chars.get(after).is_some_and(|c| is_word(*c)) { |
| 324 | return None; |
| 325 | } |
| 326 | let number: u32 = chars[hash + 1..after].iter().collect::<String>().parse().ok()?; |
| 327 | (number > 0).then_some((Ref { scope, number }, after)) |
| 328 | } |
| 329 | |
| 330 | #[cfg(test)] |
| 331 | mod tests { |
| 332 | use super::*; |
| 333 | |
| 334 | fn here(number: u32) -> Ref { |
| 335 | Ref { scope: RefScope::Here, number } |
| 336 | } |
| 337 | |
| 338 | fn repo(owner: &str, name: &str, number: u32) -> Ref { |
| 339 | Ref { scope: RefScope::Repo(RepoPath { namespace: owner.into(), name: name.into() }), number } |
| 340 | } |
| 341 | |
| 342 | fn closes(text: &str) -> Vec<Ref> { |
| 343 | named(text).closes |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn every_keyword_closes_whatever_its_case() { |
| 348 | for keyword in CLOSING_KEYWORDS { |
| 349 | assert_eq!(closes(&format!("{keyword} #7")), vec![here(7)], "{keyword}"); |
| 350 | assert_eq!(closes(&format!("{} #7", keyword.to_uppercase())), vec![here(7)], "{keyword}"); |
| 351 | } |
| 352 | assert_eq!(closes("Fixes: #12"), vec![here(12)]); |
| 353 | assert_eq!(closes("fixes:#12"), vec![here(12)]); |
| 354 | assert_eq!(closes("This fixes #3 and resolves #4, closes #3 again."), vec![here(3), here(4)]); |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn only_whole_words_and_whole_numbers() { |
| 359 | assert!(closes("prefixes #3").is_empty()); |
| 360 | assert!(closes("fixesthis #3").is_empty()); |
| 361 | assert!(closes("fixes#3").is_empty(), "no space and no colon"); |
| 362 | assert!(closes("fixes #3a").is_empty()); |
| 363 | assert!(closes("fixes #").is_empty()); |
| 364 | assert!(closes("fixes #0").is_empty()); |
| 365 | assert!(closes("fixes\n#3").is_empty(), "on the same line"); |
| 366 | assert_eq!(closes("(fixes #3)."), vec![here(3)]); |
| 367 | assert!(closes("fixes issue #3").is_empty(), "the reference follows the keyword"); |
| 368 | } |
| 369 | |
| 370 | #[test] |
| 371 | fn a_reference_names_its_repository_or_workspace() { |
| 372 | assert_eq!(closes("Closes flagon-io/g1t#40"), vec![repo("flagon-io", "g1t", 40)]); |
| 373 | assert_eq!(closes("closes Flagon-IO/g1t#40"), vec![repo("flagon-io", "g1t", 40)]); |
| 374 | assert_eq!(closes("closes acme#9"), vec![Ref { scope: RefScope::Workspace("acme".into()), number: 9 }]); |
| 375 | assert!(closes("closes a/b/c#4").is_empty()); |
| 376 | assert_eq!(parse_ref("acme/web#3"), Some(repo("acme", "web", 3))); |
| 377 | assert_eq!(parse_ref(" #3 "), Some(here(3))); |
| 378 | assert_eq!(parse_ref("#3 and more"), None); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn part_of_and_refs_link_without_closing() { |
| 383 | let found = named("Part of #12, refs acme/api#3. Fixes #4; part of #4"); |
| 384 | assert_eq!(found.closes, vec![here(4)]); |
| 385 | assert_eq!(found.parts, vec![here(12), repo("acme", "api", 3)]); |
| 386 | assert!(named("partly #3").parts.is_empty()); |
| 387 | assert!(named("part #3").parts.is_empty()); |
| 388 | assert_eq!(named("Part of: #8").parts, vec![here(8)]); |
| 389 | } |
| 390 | |
| 391 | #[test] |
| 392 | fn code_says_nothing() { |
| 393 | assert!(closes("Write `fixes #3` to close a work item.").is_empty()); |
| 394 | assert!(closes("Write ``fixes #3 `x` `` here.").is_empty()); |
| 395 | assert!(closes("```\nfixes #3\n```").is_empty()); |
| 396 | assert!(closes("~~~md\nfixes #3\n~~~").is_empty()); |
| 397 | assert_eq!(closes("```\nfixes #3\n```\nfixes #4"), vec![here(4)]); |
| 398 | // An unclosed span is just a backtick. |
| 399 | assert_eq!(closes("a ` fixes #5"), vec![here(5)]); |
| 400 | // An unclosed fence hides the rest. |
| 401 | assert!(closes("````\nfixes #6\n```\nfixes #7").is_empty()); |
| 402 | } |
| 403 | |
| 404 | #[test] |
| 405 | fn no_more_than_fifty() { |
| 406 | let many: String = (1..=80).map(|n| format!("fixes #{n} ")).collect(); |
| 407 | assert_eq!(closes(&many).len(), MAX_REFS); |
| 408 | } |
| 409 | |
| 410 | #[test] |
| 411 | fn references_in_text_are_found_once_each() { |
| 412 | let text = "See #4, acme/web#7 and acme#9; not ', #4a or `#5`. #4 again."; |
| 413 | assert_eq!( |
| 414 | refs_in_text(text), |
| 415 | vec![here(4), repo("acme", "web", 7), Ref { scope: RefScope::Workspace("acme".into()), number: 9 }] |
| 416 | ); |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn in_a_repository_its_own_pull_requests_and_old_issues_come_first() { |
| 421 | assert_eq!( |
| 422 | lookup_order(true), |
| 423 | [Lookup::PullInRepo, Lookup::LegacyItem, Lookup::Item, Lookup::PullInWorkspace] |
| 424 | ); |
| 425 | assert_eq!(lookup_order(false), [Lookup::Item, Lookup::PullInWorkspace]); |
| 426 | // An old issue of the repository wins over the workspace's item of |
| 427 | // the same number; a pull request of the repository over both. |
| 428 | let found = |have: &[Lookup]| first_found(true, |lookup| have.contains(&lookup).then_some(lookup)); |
| 429 | assert_eq!(found(&[Lookup::Item, Lookup::LegacyItem]), Some(Lookup::LegacyItem)); |
| 430 | assert_eq!(found(&[Lookup::Item, Lookup::PullInRepo, Lookup::LegacyItem]), Some(Lookup::PullInRepo)); |
| 431 | assert_eq!(found(&[Lookup::PullInWorkspace]), Some(Lookup::PullInWorkspace)); |
| 432 | assert_eq!(found(&[]), None); |
| 433 | let written = repo("acme", "api", 3); |
| 434 | let web = RepoPath { namespace: "acme".into(), name: "web".into() }; |
| 435 | assert_eq!(written.repo(Some(&web)).map(|r| r.name.as_str()), Some("api")); |
| 436 | assert_eq!(here(3).workspace("acme"), "acme"); |
| 437 | } |
| 438 | } |