gitbay/Views/Shared/BodyReferences.swift
104 lines · 4746 bytes
9 symbols in this file
1import Foundation
2import SwiftUI
3
4/// A `#N`, `!N`, `owner/name#N` or `owner/name!N` in a body, as the web's
5/// autolink package reads them: `#` is an issue, `!` a merge request.
6/// `range` counts characters.
7nonisolated struct BodyReference: Equatable, Sendable {
8 let range: Range<Int>
9 let destination: NotificationDestination
10}
11
12/// Every reference in `text`, bare ones resolved against `repo`. The
13/// patterns are the web's: a bare reference needs the start of the text,
14/// whitespace or an opening bracket before it, and a cross-repository one
15/// wins where the two overlap. Unlike the web, the target is not checked
16/// to exist.
17nonisolated func bodyReferences(in text: String, repo: String) -> [BodyReference] {
18 let cross = /([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)/
19 let bare = /(^|[\s(\[{])([#!])([0-9]+)(?![0-9A-Za-z_])/
20
21 func offsets(_ range: Range<String.Index>) -> Range<Int> {
22 let lower = text.distance(from: text.startIndex, to: range.lowerBound)
23 return lower..<(lower + text.distance(from: range.lowerBound, to: range.upperBound))
24 }
25 func destination(_ repo: String, _ kind: Substring, _ number: Substring) -> NotificationDestination? {
26 guard let n = Int64(number) else { return nil }
27 return kind == "#" ? .issue(repo: repo, number: n) : .mr(repo: repo, number: n)
28 }
29
30 var found: [BodyReference] = []
31 for match in text.matches(of: cross) {
32 if let d = destination("\(match.1)/\(match.2)", match.3, match.4) {
33 found.append(BodyReference(range: offsets(match.range), destination: d))
34 }
35 }
36 for match in text.matches(of: bare) {
37 let range = offsets(match.2.startIndex..<match.3.endIndex)
38 guard !found.contains(where: { $0.range.overlaps(range) }),
39 let d = destination(repo, match.2, match.3) else { continue }
40 found.append(BodyReference(range: range, destination: d))
41 }
42 return found.sorted { $0.range.lowerBound < $1.range.lowerBound }
43}
44
45/// Links `#N`, `!N` and `owner/name#N` outside code spans and existing
46/// links. Code blocks never reach here: Markdown fences are split off
47/// before inline rendering and org src/example blocks are not prose.
48nonisolated func linkReferences(in attributed: inout AttributedString, repo: String) {
49 for reference in bodyReferences(in: String(attributed.characters), repo: repo) {
50 let lower = attributed.characters.index(attributed.startIndex,
51 offsetBy: reference.range.lowerBound)
52 let upper = attributed.characters.index(lower, offsetBy: reference.range.count)
53 let skip = attributed[lower..<upper].runs.contains {
54 $0.link != nil || $0.inlinePresentationIntent?.contains(.code) == true
55 }
56 if !skip, let url = referenceURL(reference.destination) {
57 attributed[lower..<upper].link = url
58 }
59 }
60}
61
62nonisolated private let referenceScheme = "gitbay-ref"
63
64/// The link a reference carries: the web path under an app-only scheme,
65/// so only `referenceNavigation()` acts on it.
66nonisolated func referenceURL(_ destination: NotificationDestination) -> URL? {
67 let path = switch destination {
68 case .issue(let repo, let number): "\(repo)/issues/\(number)"
69 case .mr(let repo, let number): "\(repo)/mrs/\(number)"
70 case .build(let repo, let number): "\(repo)/builds/\(number)"
71 }
72 return URL(string: "\(referenceScheme):\(path)")
73}
74
75nonisolated func referenceDestination(_ url: URL) -> NotificationDestination? {
76 guard url.scheme == referenceScheme else { return nil }
77 return notificationDestination(forPath: String(url.absoluteString.dropFirst(referenceScheme.count + 1)))
78}
79
80extension View {
81 /// Opens reference links from a body in-app. Attach outside the
82 /// `List`: a navigation destination inside a lazy container is ignored.
83 func referenceNavigation() -> some View {
84 modifier(ReferenceNavigation())
85 }
86}
87
88private struct ReferenceNavigation: ViewModifier {
89
90 @Environment(\.pushRoute) private var pushRoute
91
92 func body(content: Content) -> some View {
93 content
94 .environment(\.openURL, OpenURLAction { url in
95 guard let destination = referenceDestination(url) else { return .systemAction }
96 switch destination {
97 case .issue(let repo, let number): pushRoute(IssueRoute.issue(repo: repo, number: number))
98 case .mr(let repo, let number): pushRoute(MRRoute.mr(repo: repo, number: number))
99 case .build(let repo, let number): pushRoute(BuildRoute.detail(repo: repo, number: number))
100 }
101 return .handled
102 })
103 }
104}