Hutch/Views/Repositories/SyntaxHighlighter.swift

268 lines · 9201 bytes

  1import Foundation
  2import Highlightr
  3import OrgSwift
  4import OrgSwiftUI
  5import SwiftUI
  6import UIKit
  7
  8nonisolated enum SyntaxHighlightTheme {
  9    case light
 10    case dark
 11
 12    init(userInterfaceStyle: UIUserInterfaceStyle) {
 13        self = userInterfaceStyle == .dark ? .dark : .light
 14    }
 15
 16    init(colorScheme: ColorScheme) {
 17        self = colorScheme == .dark ? .dark : .light
 18    }
 19
 20    /// highlight.js theme names bundled with Highlightr. Xcode-like in light,
 21    /// a muted dark palette in dark, so highlighting reads as native on both.
 22    var highlightrName: String {
 23        switch self {
 24        case .light: "xcode"
 25        case .dark: "atom-one-dark"
 26        }
 27    }
 28}
 29
 30/// Multi-language syntax highlighting backed by Highlightr (highlight.js).
 31///
 32/// Highlightr wraps a JavaScriptCore context and is not thread-safe, so each
 33/// instance must stay on the thread/task that created it. Callers that fail to
 34/// resolve a language — or hit an unavailable engine — get `nil` and should
 35/// fall back to plain, escaped text.
 36nonisolated final class SyntaxHighlighter: CodeHighlighter {
 37    private let highlightr: Highlightr?
 38    private let supportedLanguages: Set<String>
 39
 40    init(theme: SyntaxHighlightTheme) {
 41        let engine = Highlightr()
 42        engine?.setTheme(to: theme.highlightrName)
 43        highlightr = engine
 44        supportedLanguages = Set(engine?.supportedLanguages() ?? [])
 45    }
 46
 47    /// Full-document highlighted string, or `nil` when the language is unknown
 48    /// or the engine is unavailable.
 49    func attributedText(for code: String, language: String?, font: UIFont) -> NSAttributedString? {
 50        guard let highlightr, let language = resolvedLanguage(language) else { return nil }
 51        highlightr.theme.setCodeFont(font)
 52        return highlightr.highlight(code, as: language, fastRender: true)
 53    }
 54
 55    /// Inner HTML for a `<code>` element — color-styled `<span>`s — or `nil` to
 56    /// fall back. Colors are inlined, so the caller must regenerate when the
 57    /// theme changes.
 58    func highlightedHTML(for code: String, language: String?) -> String? {
 59        guard let attributed = attributedText(for: code, language: language, font: Self.htmlMeasurementFont) else {
 60            return nil
 61        }
 62        return Self.html(from: attributed)
 63    }
 64
 65    /// `OrgSwift.CodeHighlighter` conformance — delegates to the existing method.
 66    func highlightedHTML(code: String, language: String?) -> String? {
 67        highlightedHTML(for: code, language: language)
 68    }
 69
 70    private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
 71
 72    private func resolvedLanguage(_ raw: String?) -> String? {
 73        guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
 74              !trimmed.isEmpty else {
 75            return nil
 76        }
 77        let mapped = Self.languageAliases[trimmed] ?? trimmed
 78        return supportedLanguages.contains(mapped) ? mapped : nil
 79    }
 80
 81    /// Resolves a filename to a highlight.js language identifier, or `nil` when
 82    /// there is no confident match (so we render plain rather than mis-highlight).
 83    static func language(forFileName fileName: String) -> String? {
 84        let base = (fileName as NSString).lastPathComponent.lowercased()
 85        if let byName = fileNameLanguages[base] {
 86            return byName
 87        }
 88        let ext = (fileName as NSString).pathExtension.lowercased()
 89        guard !ext.isEmpty else { return nil }
 90        return extensionLanguages[ext]
 91    }
 92
 93    private static func html(from attributed: NSAttributedString) -> String {
 94        var html = ""
 95        let range = NSRange(location: 0, length: attributed.length)
 96        attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
 97            let fragment = (attributed.string as NSString).substring(with: subrange)
 98            let escaped = escapeHTML(fragment)
 99            if let color = value as? UIColor, let hex = color.hexRGBString {
100                html += "<span style=\"color:\(hex)\">\(escaped)</span>"
101            } else {
102                html += escaped
103            }
104        }
105        return html
106    }
107
108    /// Fence tags / short names that differ from highlight.js identifiers.
109    private static let languageAliases: [String: String] = [
110        "js": "javascript",
111        "jsx": "javascript",
112        "ts": "typescript",
113        "tsx": "typescript",
114        "py": "python",
115        "rb": "ruby",
116        "sh": "bash",
117        "shell": "bash",
118        "zsh": "bash",
119        "yml": "yaml",
120        "c++": "cpp",
121        "cc": "cpp",
122        "h": "cpp",
123        "hpp": "cpp",
124        "cs": "csharp",
125        "objc": "objectivec",
126        "objective-c": "objectivec",
127        "obj-c": "objectivec",
128        "html": "xml",
129        "htm": "xml",
130        "kt": "kotlin",
131        "rs": "rust",
132        "golang": "go",
133        "md": "markdown",
134        "ps1": "powershell",
135        "yaml": "yaml"
136    ]
137
138    /// Filenames without a useful extension.
139    private static let fileNameLanguages: [String: String] = [
140        "dockerfile": "dockerfile",
141        "makefile": "makefile",
142        "gnumakefile": "makefile",
143        "cmakelists.txt": "cmake",
144        "gemfile": "ruby",
145        "rakefile": "ruby",
146        "podfile": "ruby",
147        "package.swift": "swift"
148    ]
149
150    private static let extensionLanguages: [String: String] = [
151        "swift": "swift",
152        "js": "javascript",
153        "mjs": "javascript",
154        "cjs": "javascript",
155        "jsx": "javascript",
156        "ts": "typescript",
157        "tsx": "typescript",
158        "py": "python",
159        "rb": "ruby",
160        "go": "go",
161        "rs": "rust",
162        "c": "c",
163        "h": "c",
164        "cpp": "cpp",
165        "cxx": "cpp",
166        "cc": "cpp",
167        "hpp": "cpp",
168        "hxx": "cpp",
169        "m": "objectivec",
170        "mm": "objectivec",
171        "cs": "csharp",
172        "java": "java",
173        "kt": "kotlin",
174        "kts": "kotlin",
175        "scala": "scala",
176        "php": "php",
177        "pl": "perl",
178        "pm": "perl",
179        "lua": "lua",
180        "r": "r",
181        "dart": "dart",
182        "groovy": "groovy",
183        "hs": "haskell",
184        "ex": "elixir",
185        "exs": "elixir",
186        "erl": "erlang",
187        "clj": "clojure",
188        "sh": "bash",
189        "bash": "bash",
190        "zsh": "bash",
191        "fish": "bash",
192        "ps1": "powershell",
193        "json": "json",
194        "yaml": "yaml",
195        "yml": "yaml",
196        "toml": "ini",
197        "ini": "ini",
198        "cfg": "ini",
199        "conf": "ini",
200        "xml": "xml",
201        "html": "xml",
202        "htm": "xml",
203        "css": "css",
204        "scss": "scss",
205        "sass": "scss",
206        "less": "less",
207        "sql": "sql",
208        "md": "markdown",
209        "markdown": "markdown",
210        "diff": "diff",
211        "patch": "diff",
212        "cmake": "cmake",
213        "gradle": "groovy",
214        "vim": "vim"
215    ]
216}
217
218nonisolated private extension UIColor {
219    /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
220    var hexRGBString: String? {
221        var red: CGFloat = 0
222        var green: CGFloat = 0
223        var blue: CGFloat = 0
224        var alpha: CGFloat = 0
225        guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil }
226        let clamp: (CGFloat) -> Int = { Int((max(0, min(1, $0)) * 255).rounded()) }
227        return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue))
228    }
229}
230
231// MARK: - Native rendering
232
233extension SyntaxHighlighter: OrgCodeStyler {
234    /// `OrgSwiftUI.OrgCodeStyler` conformance: the native counterpart to
235    /// `highlightedHTML(code:language:)`. Highlightr already produces an
236    /// `NSAttributedString`, so the native path skips the round-trip through
237    /// color-styled `<span>`s entirely.
238    nonisolated func highlighted(code: String, language: String?) -> AttributedString? {
239        guard let attributed = attributedText(
240            for: code, language: language, font: Self.nativeMeasurementFont
241        ) else {
242            return nil
243        }
244        return Self.attributedString(from: attributed)
245    }
246
247    nonisolated private static var nativeMeasurementFont: UIFont {
248        .monospacedSystemFont(ofSize: 13, weight: .regular)
249    }
250
251    /// Carry Highlightr's colors across, in a monospaced font that scales with
252    /// Dynamic Type — which the fixed-size HTML never did.
253    nonisolated private static func attributedString(from attributed: NSAttributedString) -> AttributedString {
254        var result = AttributedString()
255        let range = NSRange(location: 0, length: attributed.length)
256        attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
257            var fragment = AttributedString(
258                (attributed.string as NSString).substring(with: subrange)
259            )
260            fragment.font = .system(.footnote, design: .monospaced)
261            if let color = value as? UIColor {
262                fragment.foregroundColor = Color(uiColor: color)
263            }
264            result += fragment
265        }
266        return result
267    }
268}