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}