krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.0: Hutch/Views/Repositories/SyntaxHighlighter.swift · raw

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