diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 02:45:31 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 02:45:31 -0500 |
| commit | b410fd16b76146f4116538c5b82330818dd47da9 (patch) | |
| tree | 649488b2e5d3d901db8c792c0dc215c835fa9d38 | |
| parent | 0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3 (diff) | |
| download | hutch-b410fd16b76146f4116538c5b82330818dd47da9.tar.gz hutch-b410fd16b76146f4116538c5b82330818dd47da9.tar.bz2 hutch-b410fd16b76146f4116538c5b82330818dd47da9.zip | |
Add multi-language syntax highlighting (#16)
Splash (Swift-only, since removed) left code rendering unhighlighted
everywhere. Add Highlightr (highlight.js) behind a small SyntaxHighlighter
service and use it on both surfaces:
- Native file viewer: highlight NSAttributedString by language inferred
from the filename, picking a light/dark theme from the trait style and
re-highlighting on style changes. Falls back to plain text for unknown
languages or very large files.
- Markdown/org code blocks in the WKWebView (READMEs, tickets, comments):
the web view disables JavaScript, so highlight server-side into inline
color-styled spans. The color scheme is now part of the render/cache
key since colors are baked into the HTML.
Covered by unit tests for language mapping and highlighted output.
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 17 | ||||
| -rw-r--r-- | Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 11 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/FileTreeView.swift | 49 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/MarkdownHTMLRenderer.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/SyntaxHighlighter.swift | 222 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 12 | ||||
| -rw-r--r-- | HutchTests/SyntaxHighlighterTests.swift | 48 |
8 files changed, 377 insertions, 29 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index b7ddbe9..b6c7301 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 8B2FA9D12F7E211800E609E2 /* paste.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C92F7E211800E609E2 /* paste.json */; }; 8B2FA9D22F7E211800E609E2 /* todo.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9CA2F7E211800E609E2 /* todo.json */; }; 8B2FA9D32F7E211800E609E2 /* lists.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C72F7E211800E609E2 /* lists.json */; }; + 8BAA000000000000000000B0 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8BAA000000000000000000B2 /* Highlightr */; }; 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; @@ -148,6 +149,7 @@ buildActionMask = 2147483647; files = ( 8BF100032F9A000100000001 /* Markdown in Frameworks */, + 8BAA000000000000000000B0 /* Highlightr in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -252,6 +254,7 @@ name = Hutch; packageProductDependencies = ( 8BF100022F9A000100000001 /* Markdown */, + 8BAA000000000000000000B2 /* Highlightr */, ); productName = Hutch; productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; @@ -358,6 +361,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */, + 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */, ); preferredProjectObjectVersion = 77; productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; @@ -871,6 +875,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/raspu/Highlightr"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.0; + }; + }; 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-markdown"; @@ -882,6 +894,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 8BAA000000000000000000B2 /* Highlightr */ = { + isa = XCSwiftPackageProductDependency; + package = 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */; + productName = Highlightr; + }; 8BF100022F9A000100000001 /* Markdown */ = { isa = XCSwiftPackageProductDependency; package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */; diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 417e4d7..a268436 100644 --- a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,7 +1,16 @@ { - "originHash" : "65542d8a5fbbad8dd7d07c8901c1cc48b3aba12cef8da1f062bd28d9b3a6ba50", + "originHash" : "4554417ca2693020116bce7cbda415a5e51ada6b4cc4a8b39f7f43a9644bb5e5", "pins" : [ { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, + { "identity" : "swift-cmark", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift index 7bfce17..13f72b8 100644 --- a/Hutch/Views/Repositories/FileTreeView.swift +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -456,12 +456,19 @@ final class CodeFileUIView: UIView { private var wrapLines: Bool private var needsLineLayoutUpdate = true private var lastMeasuredCodeWidth: CGFloat = 0 + private var syntaxHighlighter: SyntaxHighlighter? + private var syntaxHighlighterTheme: SyntaxHighlightTheme? init(text: String, fileName: String, font: UIFont, wrapLines: Bool) { self.currentFont = font self.wrapLines = wrapLines super.init(frame: .zero) setupViews() + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in + view.rebuildRows() + view.needsLineLayoutUpdate = true + view.setNeedsLayout() + } updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines) } @@ -583,11 +590,7 @@ final class CodeFileUIView: UIView { } rows.removeAll() - let attributedText = CodeSyntaxHighlighter.attributedText( - for: currentText, - fileName: currentFileName, - font: currentFont - ) + let attributedText = highlightedAttributedText() let lines = makeLines(from: attributedText, font: currentFont) gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont) @@ -600,6 +603,30 @@ final class CodeFileUIView: UIView { } } + /// Highlighting cap: above this size, skip tokenizing to keep the main + /// thread responsive and render plain, label-colored text instead. + private static let maxHighlightableLength = 100_000 + + private func highlightedAttributedText() -> NSAttributedString { + let plain = NSAttributedString( + string: currentText, + attributes: [.font: currentFont, .foregroundColor: UIColor.label] + ) + + guard currentText.count <= Self.maxHighlightableLength, + let language = SyntaxHighlighter.language(forFileName: currentFileName) else { + return plain + } + + let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle) + if syntaxHighlighterTheme != theme || syntaxHighlighter == nil { + syntaxHighlighter = SyntaxHighlighter(theme: theme) + syntaxHighlighterTheme = theme + } + + return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain + } + private func makeRow(for line: CodeFileLineData) -> LineRow { let row = LineRow() @@ -737,18 +764,6 @@ private struct CodeFileLineData { let text: NSAttributedString } -private enum CodeSyntaxHighlighter { - static func attributedText(for text: String, fileName _: String, font: UIFont) -> NSAttributedString { - NSAttributedString( - string: text, - attributes: [ - .font: font, - .foregroundColor: UIColor.label - ] - ) - } -} - // MARK: - Tree Entry Row private struct TreeEntryRow: View { diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift index bc63f9d..91166ac 100644 --- a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift +++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift @@ -2,11 +2,16 @@ import Markdown nonisolated func markdownToHTML( _ text: String, + codeTheme: SyntaxHighlightTheme = .light, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { let document = Document(parsing: text) - var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + var renderer = MarkdownHTMLRenderer( + imageURLResolver: imageURLResolver, + linkURLResolver: linkURLResolver, + highlighter: SyntaxHighlighter(theme: codeTheme) + ) return renderer.visit(document) } @@ -15,13 +20,19 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { nonisolated(unsafe) let imageURLResolver: ((String) -> String?)? nonisolated(unsafe) let linkURLResolver: ((String) -> String?)? + nonisolated(unsafe) private let highlighter: SyntaxHighlighter nonisolated(unsafe) private var isRenderingTableHead = false nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = [] nonisolated(unsafe) private var currentTableColumnIndex = 0 - nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) { + nonisolated init( + imageURLResolver: ((String) -> String?)?, + linkURLResolver: ((String) -> String?)? = nil, + highlighter: SyntaxHighlighter + ) { self.imageURLResolver = imageURLResolver self.linkURLResolver = linkURLResolver + self.highlighter = highlighter } nonisolated mutating func visit(_ markup: Markup) -> String { @@ -78,7 +89,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor { } else { classAttribute = "" } - return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n" + let inner = highlighter.highlightedHTML(for: codeBlock.code, language: codeBlock.language) + ?? escapeHTML(codeBlock.code) + return "<pre><code\(classAttribute)>\(inner)</code></pre>\n" } nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String { diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b8b9622..722af8d 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -234,15 +234,18 @@ struct RenderedMarkupContentView: View { @State private var renderedHTML: String? private var cacheKey: String { + // Highlighted code colors are baked into the HTML, so the theme is part + // of the cache identity — light and dark must not share an entry. + let theme = colorScheme == .dark ? "dark" : "light" switch content { case .html(let html): - "html:\(readmePath ?? "custom"):\(html)" + return "html:\(readmePath ?? "custom"):\(html)" case .markdown(let text): - "markdown:\(readmePath ?? ""):\(text)" + return "markdown:\(theme):\(readmePath ?? ""):\(text)" case .org(let text): - "org:\(readmePath ?? ""):\(text)" + return "org:\(theme):\(readmePath ?? ""):\(text)" case .plainText(let text): - "plain:\(readmePath ?? ""):\(text)" + return "plain:\(readmePath ?? ""):\(text)" } } @@ -277,9 +280,11 @@ struct RenderedMarkupContentView: View { renderedHTML = cached return } + let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { markdownToHTML( text, + codeTheme: theme, imageURLResolver: { source in resolveRepositoryAssetURL( source, @@ -308,9 +313,11 @@ struct RenderedMarkupContentView: View { renderedHTML = cached return } + let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { orgToHTML( text, + codeTheme: theme, imageURLResolver: { source in resolveRepositoryAssetURL( source, @@ -462,9 +469,11 @@ nonisolated func processInline( nonisolated func orgToHTML( _ text: String, + codeTheme: SyntaxHighlightTheme = .light, imageURLResolver: ((String) -> String?)? = nil, linkURLResolver: ((String) -> String?)? = nil ) -> String { + let highlighter = SyntaxHighlighter(theme: codeTheme) let normalizedText = text .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") @@ -496,6 +505,7 @@ nonisolated func orgToHTML( var inQuoteBlock = false var inPropertyDrawer = false var srcLanguage: String? + var srcLines: [String] = [] var inExampleBlock = false var inCenterBlock = false var inVerseBlock = false @@ -593,9 +603,14 @@ nonisolated func orgToHTML( } func closeSourceBlock() { - if srcLanguage != nil { + if let language = srcLanguage { + let code = srcLines.joined(separator: "\n") + if !code.isEmpty { + html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n" + } html += "</code></pre>\n" srcLanguage = nil + srcLines = [] closePendingBlockWrapper() } } @@ -659,7 +674,7 @@ nonisolated func orgToHTML( if trimmed.lowercased() == "#+end_src" { closeSourceBlock() } else { - html += escapeHTML(line) + "\n" + srcLines.append(line) } continue } @@ -730,6 +745,7 @@ nonisolated func orgToHTML( let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" html += "<pre><code\(classAttribute)>" srcLanguage = language ?? "" + srcLines = [] continue } diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift new file mode 100644 index 0000000..5ae8f58 --- /dev/null +++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift @@ -0,0 +1,222 @@ +import Foundation +import Highlightr +import SwiftUI +import UIKit + +enum SyntaxHighlightTheme { + case light + case dark + + init(userInterfaceStyle: UIUserInterfaceStyle) { + self = userInterfaceStyle == .dark ? .dark : .light + } + + init(colorScheme: ColorScheme) { + self = colorScheme == .dark ? .dark : .light + } + + /// highlight.js theme names bundled with Highlightr. Xcode-like in light, + /// a muted dark palette in dark, so highlighting reads as native on both. + var highlightrName: String { + switch self { + case .light: "xcode" + case .dark: "atom-one-dark" + } + } +} + +/// Multi-language syntax highlighting backed by Highlightr (highlight.js). +/// +/// Highlightr wraps a JavaScriptCore context and is not thread-safe, so each +/// instance must stay on the thread/task that created it. Callers that fail to +/// resolve a language — or hit an unavailable engine — get `nil` and should +/// fall back to plain, escaped text. +final class SyntaxHighlighter { + private let highlightr: Highlightr? + private let supportedLanguages: Set<String> + + init(theme: SyntaxHighlightTheme) { + let engine = Highlightr() + engine?.setTheme(to: theme.highlightrName) + highlightr = engine + supportedLanguages = Set(engine?.supportedLanguages() ?? []) + } + + /// Full-document highlighted string, or `nil` when the language is unknown + /// or the engine is unavailable. + func attributedText(for code: String, language: String?, font: UIFont) -> NSAttributedString? { + guard let highlightr, let language = resolvedLanguage(language) else { return nil } + highlightr.theme.setCodeFont(font) + return highlightr.highlight(code, as: language, fastRender: true) + } + + /// Inner HTML for a `<code>` element — color-styled `<span>`s — or `nil` to + /// fall back. Colors are inlined, so the caller must regenerate when the + /// theme changes. + func highlightedHTML(for code: String, language: String?) -> String? { + guard let attributed = attributedText(for: code, language: language, font: Self.htmlMeasurementFont) else { + return nil + } + return Self.html(from: attributed) + } + + private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + private func resolvedLanguage(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !trimmed.isEmpty else { + return nil + } + let mapped = Self.languageAliases[trimmed] ?? trimmed + return supportedLanguages.contains(mapped) ? mapped : nil + } + + /// Resolves a filename to a highlight.js language identifier, or `nil` when + /// there is no confident match (so we render plain rather than mis-highlight). + static func language(forFileName fileName: String) -> String? { + let base = (fileName as NSString).lastPathComponent.lowercased() + if let byName = fileNameLanguages[base] { + return byName + } + let ext = (fileName as NSString).pathExtension.lowercased() + guard !ext.isEmpty else { return nil } + return extensionLanguages[ext] + } + + private static func html(from attributed: NSAttributedString) -> String { + var html = "" + let range = NSRange(location: 0, length: attributed.length) + attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in + let fragment = (attributed.string as NSString).substring(with: subrange) + let escaped = escapeHTML(fragment) + if let color = value as? UIColor, let hex = color.hexRGBString { + html += "<span style=\"color:\(hex)\">\(escaped)</span>" + } else { + html += escaped + } + } + return html + } + + /// Fence tags / short names that differ from highlight.js identifiers. + private static let languageAliases: [String: String] = [ + "js": "javascript", + "jsx": "javascript", + "ts": "typescript", + "tsx": "typescript", + "py": "python", + "rb": "ruby", + "sh": "bash", + "shell": "bash", + "zsh": "bash", + "yml": "yaml", + "c++": "cpp", + "cc": "cpp", + "h": "cpp", + "hpp": "cpp", + "cs": "csharp", + "objc": "objectivec", + "objective-c": "objectivec", + "obj-c": "objectivec", + "html": "xml", + "htm": "xml", + "kt": "kotlin", + "rs": "rust", + "golang": "go", + "md": "markdown", + "ps1": "powershell", + "yaml": "yaml" + ] + + /// Filenames without a useful extension. + private static let fileNameLanguages: [String: String] = [ + "dockerfile": "dockerfile", + "makefile": "makefile", + "gnumakefile": "makefile", + "cmakelists.txt": "cmake", + "gemfile": "ruby", + "rakefile": "ruby", + "podfile": "ruby", + "package.swift": "swift" + ] + + private static let extensionLanguages: [String: String] = [ + "swift": "swift", + "js": "javascript", + "mjs": "javascript", + "cjs": "javascript", + "jsx": "javascript", + "ts": "typescript", + "tsx": "typescript", + "py": "python", + "rb": "ruby", + "go": "go", + "rs": "rust", + "c": "c", + "h": "c", + "cpp": "cpp", + "cxx": "cpp", + "cc": "cpp", + "hpp": "cpp", + "hxx": "cpp", + "m": "objectivec", + "mm": "objectivec", + "cs": "csharp", + "java": "java", + "kt": "kotlin", + "kts": "kotlin", + "scala": "scala", + "php": "php", + "pl": "perl", + "pm": "perl", + "lua": "lua", + "r": "r", + "dart": "dart", + "groovy": "groovy", + "hs": "haskell", + "ex": "elixir", + "exs": "elixir", + "erl": "erlang", + "clj": "clojure", + "sh": "bash", + "bash": "bash", + "zsh": "bash", + "fish": "bash", + "ps1": "powershell", + "json": "json", + "yaml": "yaml", + "yml": "yaml", + "toml": "ini", + "ini": "ini", + "cfg": "ini", + "conf": "ini", + "xml": "xml", + "html": "xml", + "htm": "xml", + "css": "css", + "scss": "scss", + "sass": "scss", + "less": "less", + "sql": "sql", + "md": "markdown", + "markdown": "markdown", + "diff": "diff", + "patch": "diff", + "cmake": "cmake", + "gradle": "groovy", + "vim": "vim" + ] +} + +private extension UIColor { + /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible. + var hexRGBString: String? { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil } + let clamp: (CGFloat) -> Int = { Int((max(0, min(1, $0)) * 255).rounded()) } + return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue)) + } +} diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 6339776..1ace6b6 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -553,14 +553,22 @@ private struct MarkdownContentView: View { .frame(minHeight: 80) } } - .task(id: markdown) { + .task(id: MarkdownRenderKey(markdown: markdown, isDark: colorScheme == .dark)) { + let theme = SyntaxHighlightTheme(colorScheme: colorScheme) let html = await Task.detached(priority: .userInitiated) { - markdownToHTML(markdown) + markdownToHTML(markdown, codeTheme: theme) }.value guard !Task.isCancelled else { return } renderedHTML = html } } + + /// Re-renders when the markdown or the color scheme changes; highlighted + /// code colors are baked into the HTML, so both must key the render. + private struct MarkdownRenderKey: Equatable { + let markdown: String + let isDark: Bool + } } // MARK: - Event Row diff --git a/HutchTests/SyntaxHighlighterTests.swift b/HutchTests/SyntaxHighlighterTests.swift new file mode 100644 index 0000000..258e81e --- /dev/null +++ b/HutchTests/SyntaxHighlighterTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +import UIKit +@testable import Hutch + +struct SyntaxHighlighterTests { + @Test + func mapsFileNamesToLanguages() { + #expect(SyntaxHighlighter.language(forFileName: "Sources/App/Main.swift") == "swift") + #expect(SyntaxHighlighter.language(forFileName: "server.py") == "python") + #expect(SyntaxHighlighter.language(forFileName: "index.tsx") == "typescript") + #expect(SyntaxHighlighter.language(forFileName: "Dockerfile") == "dockerfile") + #expect(SyntaxHighlighter.language(forFileName: "Makefile") == "makefile") + } + + @Test + func returnsNilForUnknownExtensions() { + #expect(SyntaxHighlighter.language(forFileName: "notes.unknownext") == nil) + #expect(SyntaxHighlighter.language(forFileName: "LICENSE") == nil) + } + + @Test + func highlightsKnownLanguageAsColoredSpans() { + let highlighter = SyntaxHighlighter(theme: .light) + let html = highlighter.highlightedHTML(for: "let answer = 42", language: "swift") + + let unwrapped = try? #require(html) + #expect(unwrapped?.contains("<span style=\"color:") == true) + #expect(unwrapped?.contains("answer") == true) + } + + @Test + func fallsBackToNilForUnsupportedLanguage() { + let highlighter = SyntaxHighlighter(theme: .dark) + #expect(highlighter.highlightedHTML(for: "plain text", language: nil) == nil) + #expect(highlighter.highlightedHTML(for: "plain text", language: "totally-not-a-language") == nil) + } + + @Test + func producesAttributedTextForNativeViewer() { + let highlighter = SyntaxHighlighter(theme: .light) + let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) + let attributed = highlighter.attributedText(for: "print(\"hi\")", language: "python", font: font) + + let unwrapped = try? #require(attributed) + #expect(unwrapped?.string.contains("print") == true) + } +} |
