gitbay/Views/Shared/BodyReferences.swift

v1.12.0
gitbay-ios/gitbay/Views/Shared/BodyReferences.swift history · blame · raw

104 lines · 4746 bytes

  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}