|
| 1 | +import AppKit |
| 2 | +import Foundation |
| 3 | + |
| 4 | +enum DiffSyntaxHighlighter { |
| 5 | + private static let regexCache = RegexCache() |
| 6 | + struct Theme { |
| 7 | + let font: NSFont |
| 8 | + let textColor: NSColor |
| 9 | + let secondaryTextColor: NSColor |
| 10 | + let hunkColor: NSColor |
| 11 | + let addBackground: NSColor |
| 12 | + let deleteBackground: NSColor |
| 13 | + let addPrefix: NSColor |
| 14 | + let deletePrefix: NSColor |
| 15 | + let keywordColor: NSColor |
| 16 | + let stringColor: NSColor |
| 17 | + let commentColor: NSColor |
| 18 | + let numberColor: NSColor |
| 19 | + let typeColor: NSColor |
| 20 | + |
| 21 | + static var `default`: Theme { |
| 22 | + Theme( |
| 23 | + font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), |
| 24 | + textColor: .labelColor, |
| 25 | + secondaryTextColor: .secondaryLabelColor, |
| 26 | + hunkColor: .systemPurple, |
| 27 | + addBackground: NSColor.systemGreen.withAlphaComponent(0.15), |
| 28 | + deleteBackground: NSColor.systemRed.withAlphaComponent(0.15), |
| 29 | + addPrefix: .systemGreen, |
| 30 | + deletePrefix: .systemRed, |
| 31 | + keywordColor: .systemPurple, |
| 32 | + stringColor: .systemGreen, |
| 33 | + commentColor: .systemGray, |
| 34 | + numberColor: .systemOrange, |
| 35 | + typeColor: .systemTeal |
| 36 | + ) |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + static func highlightedDiff( |
| 41 | + text: String, |
| 42 | + filePath: String?, |
| 43 | + theme: Theme = .default |
| 44 | + ) -> NSAttributedString { |
| 45 | + let output = NSMutableAttributedString() |
| 46 | + let language = Language.from(filePath: filePath) |
| 47 | + |
| 48 | + var currentLocation = 0 |
| 49 | + let lines = splitLines(text) |
| 50 | + for line in lines { |
| 51 | + let lineAttr = NSMutableAttributedString(string: line) |
| 52 | + applyBaseAttributes(lineAttr, theme: theme) |
| 53 | + |
| 54 | + let isHeader = line.hasPrefix("diff --git") |
| 55 | + || line.hasPrefix("index ") |
| 56 | + || line.hasPrefix("+++ ") |
| 57 | + || line.hasPrefix("--- ") |
| 58 | + let isHunk = line.hasPrefix("@@") |
| 59 | + let isAdd = line.hasPrefix("+") && !line.hasPrefix("+++ ") |
| 60 | + let isDel = line.hasPrefix("-") && !line.hasPrefix("--- ") |
| 61 | + |
| 62 | + if isHeader { |
| 63 | + lineAttr.addAttribute(.foregroundColor, value: theme.secondaryTextColor, range: NSRange(location: 0, length: lineAttr.length)) |
| 64 | + } else if isHunk { |
| 65 | + lineAttr.addAttribute(.foregroundColor, value: theme.hunkColor, range: NSRange(location: 0, length: lineAttr.length)) |
| 66 | + } else if isAdd { |
| 67 | + lineAttr.addAttribute(.backgroundColor, value: theme.addBackground, range: NSRange(location: 0, length: lineAttr.length)) |
| 68 | + if lineAttr.length > 0 { |
| 69 | + lineAttr.addAttribute(.foregroundColor, value: theme.addPrefix, range: NSRange(location: 0, length: 1)) |
| 70 | + } |
| 71 | + let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1)) |
| 72 | + applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme) |
| 73 | + } else if isDel { |
| 74 | + lineAttr.addAttribute(.backgroundColor, value: theme.deleteBackground, range: NSRange(location: 0, length: lineAttr.length)) |
| 75 | + if lineAttr.length > 0 { |
| 76 | + lineAttr.addAttribute(.foregroundColor, value: theme.deletePrefix, range: NSRange(location: 0, length: 1)) |
| 77 | + } |
| 78 | + let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1)) |
| 79 | + applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme) |
| 80 | + } else if line.hasPrefix(" ") { |
| 81 | + let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1)) |
| 82 | + applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme) |
| 83 | + } else { |
| 84 | + applySyntax(lineAttr, language: language, contentRange: NSRange(location: 0, length: lineAttr.length), theme: theme) |
| 85 | + } |
| 86 | + |
| 87 | + output.append(lineAttr) |
| 88 | + currentLocation += line.count |
| 89 | + } |
| 90 | + |
| 91 | + return output |
| 92 | + } |
| 93 | + |
| 94 | + private static func applyBaseAttributes(_ attr: NSMutableAttributedString, theme: Theme) { |
| 95 | + attr.addAttribute(.font, value: theme.font, range: NSRange(location: 0, length: attr.length)) |
| 96 | + attr.addAttribute(.foregroundColor, value: theme.textColor, range: NSRange(location: 0, length: attr.length)) |
| 97 | + } |
| 98 | + |
| 99 | + private static func applySyntax( |
| 100 | + _ attr: NSMutableAttributedString, |
| 101 | + language: Language, |
| 102 | + contentRange: NSRange, |
| 103 | + theme: Theme |
| 104 | + ) { |
| 105 | + guard contentRange.length > 0 else { return } |
| 106 | + let string = attr.string as NSString |
| 107 | + let patterns = regexCache.patterns(for: language) |
| 108 | + |
| 109 | + for range in matchRanges(patterns.comment, in: string, range: contentRange) { |
| 110 | + attr.addAttribute(.foregroundColor, value: theme.commentColor, range: range) |
| 111 | + } |
| 112 | + |
| 113 | + for range in matchRanges(patterns.string, in: string, range: contentRange) { |
| 114 | + attr.addAttribute(.foregroundColor, value: theme.stringColor, range: range) |
| 115 | + } |
| 116 | + |
| 117 | + for range in matchRanges(patterns.number, in: string, range: contentRange) { |
| 118 | + attr.addAttribute(.foregroundColor, value: theme.numberColor, range: range) |
| 119 | + } |
| 120 | + |
| 121 | + for range in matchRanges(patterns.type, in: string, range: contentRange) { |
| 122 | + attr.addAttribute(.foregroundColor, value: theme.typeColor, range: range) |
| 123 | + } |
| 124 | + |
| 125 | + for range in matchRanges(patterns.keyword, in: string, range: contentRange) { |
| 126 | + attr.addAttribute(.foregroundColor, value: theme.keywordColor, range: range) |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + private static func matchRanges(_ regex: NSRegularExpression, in string: NSString, range: NSRange) -> [NSRange] { |
| 131 | + regex.matches(in: string as String, options: [], range: range).map { $0.range } |
| 132 | + } |
| 133 | + |
| 134 | + private static func splitLines(_ text: String) -> [String] { |
| 135 | + if text.isEmpty { return [""] } |
| 136 | + var lines: [String] = [] |
| 137 | + var current = "" |
| 138 | + for ch in text { |
| 139 | + current.append(ch) |
| 140 | + if ch == "\n" { |
| 141 | + lines.append(current) |
| 142 | + current = "" |
| 143 | + } |
| 144 | + } |
| 145 | + if !current.isEmpty { |
| 146 | + lines.append(current) |
| 147 | + } |
| 148 | + return lines |
| 149 | + } |
| 150 | + |
| 151 | + enum Language { |
| 152 | + case swift |
| 153 | + case js |
| 154 | + case ts |
| 155 | + case python |
| 156 | + case go |
| 157 | + case rust |
| 158 | + case zig |
| 159 | + case cpp |
| 160 | + case c |
| 161 | + case yaml |
| 162 | + case json |
| 163 | + case shell |
| 164 | + case unknown |
| 165 | + |
| 166 | + static func from(filePath: String?) -> Language { |
| 167 | + guard let filePath else { return .unknown } |
| 168 | + let ext = URL(fileURLWithPath: filePath).pathExtension.lowercased() |
| 169 | + switch ext { |
| 170 | + case "swift": return .swift |
| 171 | + case "js", "jsx": return .js |
| 172 | + case "ts", "tsx": return .ts |
| 173 | + case "py": return .python |
| 174 | + case "go": return .go |
| 175 | + case "rs": return .rust |
| 176 | + case "zig": return .zig |
| 177 | + case "c", "h": return .c |
| 178 | + case "cc", "cpp", "cxx", "hpp", "hh", "hxx": return .cpp |
| 179 | + case "yml", "yaml": return .yaml |
| 180 | + case "json": return .json |
| 181 | + case "sh", "bash", "zsh": return .shell |
| 182 | + default: return .unknown |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +private final class RegexCache { |
| 190 | + struct Patterns { |
| 191 | + let keyword: NSRegularExpression |
| 192 | + let type: NSRegularExpression |
| 193 | + let string: NSRegularExpression |
| 194 | + let comment: NSRegularExpression |
| 195 | + let number: NSRegularExpression |
| 196 | + } |
| 197 | + |
| 198 | + private var cache: [DiffSyntaxHighlighter.Language: Patterns] = [:] |
| 199 | + |
| 200 | + func patterns(for language: DiffSyntaxHighlighter.Language) -> Patterns { |
| 201 | + if let cached = cache[language] { return cached } |
| 202 | + let patterns = makePatterns(for: language) |
| 203 | + cache[language] = patterns |
| 204 | + return patterns |
| 205 | + } |
| 206 | + |
| 207 | + private func makePatterns(for language: DiffSyntaxHighlighter.Language) -> Patterns { |
| 208 | + let keywordPattern: String |
| 209 | + switch language { |
| 210 | + case .swift: |
| 211 | + keywordPattern = "\\b(class|struct|enum|protocol|extension|func|let|var|if|else|for|while|switch|case|default|return|break|continue|import|guard|throw|throws|try|catch|public|private|fileprivate|internal|open|static|mutating|inout|where|as|is|nil|true|false)\\b" |
| 212 | + case .js: |
| 213 | + keywordPattern = "\\b(function|const|let|var|if|else|for|while|switch|case|default|return|break|continue|import|from|export|class|extends|new|try|catch|finally|throw|async|await|this|super|null|true|false)\\b" |
| 214 | + case .ts: |
| 215 | + keywordPattern = "\\b(function|const|let|var|if|else|for|while|switch|case|default|return|break|continue|import|from|export|class|extends|new|try|catch|finally|throw|async|await|this|super|null|true|false|interface|type|implements|enum)\\b" |
| 216 | + case .python: |
| 217 | + keywordPattern = "\\b(def|class|import|from|as|if|elif|else|for|while|return|try|except|finally|with|yield|lambda|pass|break|continue|None|True|False)\\b" |
| 218 | + case .go: |
| 219 | + keywordPattern = "\\b(func|package|import|if|else|for|range|switch|case|default|return|break|continue|type|struct|interface|map|chan|go|defer|select|const|var)\\b" |
| 220 | + case .rust: |
| 221 | + keywordPattern = "\\b(fn|let|mut|pub|struct|enum|impl|trait|use|mod|crate|if|else|match|while|for|in|loop|return|break|continue|self|super|crate|const|static|ref)\\b" |
| 222 | + case .zig: |
| 223 | + keywordPattern = "\\b(const|var|fn|struct|enum|union|if|else|switch|while|for|break|continue|return|try|catch|async|await|comptime|anytype)\\b" |
| 224 | + case .cpp, .c: |
| 225 | + keywordPattern = "\\b(auto|bool|break|case|catch|class|const|constexpr|continue|default|delete|do|else|enum|explicit|extern|false|for|friend|goto|if|inline|namespace|new|nullptr|operator|private|protected|public|return|sizeof|static|struct|switch|template|this|throw|true|try|typedef|typename|union|using|virtual|void|volatile|while)\\b" |
| 226 | + case .yaml: |
| 227 | + keywordPattern = "^(\\s*)([\\w\\-]+)(?=\\:)" |
| 228 | + case .json: |
| 229 | + keywordPattern = "\"(\\\\.|[^\"])*\"(?=\\s*\\:)" |
| 230 | + case .shell: |
| 231 | + keywordPattern = "\\b(if|then|else|fi|for|in|do|done|case|esac|while|until|function|select|time|return|break|continue)\\b" |
| 232 | + case .unknown: |
| 233 | + keywordPattern = "$^" |
| 234 | + } |
| 235 | + |
| 236 | + let typePattern: String = switch language { |
| 237 | + case .swift, .ts, .js: "\\b[A-Z][A-Za-z0-9_]*\\b" |
| 238 | + default: "$^" |
| 239 | + } |
| 240 | + |
| 241 | + let stringPattern: String = switch language { |
| 242 | + case .python: "(\"\"\"[\\s\\S]*?\"\"\"|'''[\\s\\S]*?'''|\"(\\\\.|[^\"])*\"|'(\\\\.|[^'])*')" |
| 243 | + default: "(\"(\\\\.|[^\"])*\"|'(\\\\.|[^'])*')" |
| 244 | + } |
| 245 | + |
| 246 | + let commentPattern: String = switch language { |
| 247 | + case .python, .yaml, .shell: "#.*$" |
| 248 | + case .json: "$^" |
| 249 | + default: "(//.*$|/\\*[\\s\\S]*?\\*/)" |
| 250 | + } |
| 251 | + |
| 252 | + let numberPattern = "\\b\\d+(\\.\\d+)?\\b" |
| 253 | + |
| 254 | + let keyword = (try? NSRegularExpression(pattern: keywordPattern, options: [.anchorsMatchLines])) ?? NSRegularExpression() |
| 255 | + let type = (try? NSRegularExpression(pattern: typePattern, options: [])) ?? NSRegularExpression() |
| 256 | + let string = (try? NSRegularExpression(pattern: stringPattern, options: [])) ?? NSRegularExpression() |
| 257 | + let comment = (try? NSRegularExpression(pattern: commentPattern, options: [.anchorsMatchLines])) ?? NSRegularExpression() |
| 258 | + let number = (try? NSRegularExpression(pattern: numberPattern, options: [])) ?? NSRegularExpression() |
| 259 | + |
| 260 | + return Patterns(keyword: keyword, type: type, string: string, comment: comment, number: number) |
| 261 | + } |
| 262 | +} |
0 commit comments