git.iko.soy/parseable-macos
on master help sign in
Add SQL and JSON syntax highlighting (#11)
a395908iko
ParseableViewer/Views/LogDetailView.swift+6−11
@@ -141,19 +141,19 @@ struct JSONValueView: View {
141 case .bool(let v):
141 case .bool(let v):
142 Text(v ? "true" : "false")
142 Text(v ? "true" : "false")
143 .font(.system(.caption, design: .monospaced))
143 .font(.system(.caption, design: .monospaced))
144− .foregroundStyle(.purple)
144+ .foregroundStyle(Color(nsColor: .systemOrange))
145 case .int(let v):
145 case .int(let v):
146 Text(String(v))
146 Text(String(v))
147 .font(.system(.caption, design: .monospaced))
147 .font(.system(.caption, design: .monospaced))
148− .foregroundStyle(.blue)
148+ .foregroundStyle(Color(nsColor: .systemPurple))
149 case .double(let v):
149 case .double(let v):
150 Text(String(v))
150 Text(String(v))
151 .font(.system(.caption, design: .monospaced))
151 .font(.system(.caption, design: .monospaced))
152− .foregroundStyle(.blue)
152+ .foregroundStyle(Color(nsColor: .systemPurple))
153 case .string(let v):
153 case .string(let v):
154 Text(v)
154 Text(v)
155 .font(.system(.caption, design: .monospaced))
155 .font(.system(.caption, design: .monospaced))
156− .foregroundStyle(.primary)
156+ .foregroundStyle(Color(nsColor: .systemRed))
157 .textSelection(.enabled)
157 .textSelection(.enabled)
158 case .array(let items):
158 case .array(let items):
159 VStack(alignment: .leading, spacing: 2) {
159 VStack(alignment: .leading, spacing: 2) {
@@ -186,14 +186,9 @@ struct JSONValueView: View {
186struct RawJSONView: View {
186struct RawJSONView: View {
187 let record: LogRecord
187 let record: LogRecord
188
188
189− var jsonString: String {
190− let obj = JSONValue.object(record)
191− return obj.prettyPrinted()
192− }
193−
194 var body: some View {
189 var body: some View {
195− Text(jsonString)
190+ let json = JSONValue.object(record).prettyPrinted()
196− .font(.system(.caption, design: .monospaced))
191+ Text(JSONSyntaxHighlighter.highlight(json))
197 .textSelection(.enabled)
192 .textSelection(.enabled)
198 .padding(8)
193 .padding(8)
199 .frame(maxWidth: .infinity, alignment: .leading)
194 .frame(maxWidth: .infinity, alignment: .leading)
ParseableViewer/Views/QueryView.swift+1−5
@@ -106,12 +106,8 @@ struct QueryView: View {
106 .padding(8)
106 .padding(8)
107
107
108 // SQL editor
108 // SQL editor
109− TextEditor(text: $vm.sqlQuery)
109+ SQLEditorView(text: $vm.sqlQuery)
110− .font(.system(.body, design: .monospaced))
111 .frame(minHeight: 60, maxHeight: 120)
110 .frame(minHeight: 60, maxHeight: 120)
112− .scrollContentBackground(.hidden)
113− .padding(4)
114− .background(Color(nsColor: .textBackgroundColor))
115 .clipShape(RoundedRectangle(cornerRadius: 4))
111 .clipShape(RoundedRectangle(cornerRadius: 4))
116 .overlay(
112 .overlay(
117 RoundedRectangle(cornerRadius: 4)
113 RoundedRectangle(cornerRadius: 4)
ParseableViewer/Views/SQLEditorView.swiftnew file+89
@@ -0,0 +1,89 @@
1+import AppKit
2+import SwiftUI
3+
4+/// A syntax-highlighted SQL editor backed by NSTextView.
5+struct SQLEditorView: NSViewRepresentable {
6+ @Binding var text: String
7+
8+ static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
9+
10+ func makeNSView(context: Context) -> NSScrollView {
11+ let scrollView = NSTextView.scrollableTextView()
12+ guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
13+
14+ textView.font = Self.editorFont
15+ textView.textColor = .labelColor
16+ textView.backgroundColor = .textBackgroundColor
17+ textView.drawsBackground = true
18+ textView.isEditable = true
19+ textView.isSelectable = true
20+ textView.allowsUndo = true
21+ textView.isAutomaticQuoteSubstitutionEnabled = false
22+ textView.isAutomaticDashSubstitutionEnabled = false
23+ textView.isAutomaticTextReplacementEnabled = false
24+ textView.isAutomaticSpellingCorrectionEnabled = false
25+ textView.isAutomaticTextCompletionEnabled = false
26+ textView.isGrammarCheckingEnabled = false
27+ textView.isContinuousSpellCheckingEnabled = false
28+ textView.usesFindPanel = true
29+ textView.textContainerInset = NSSize(width: 4, height: 4)
30+ textView.isHorizontallyResizable = false
31+ textView.isVerticallyResizable = true
32+ textView.autoresizingMask = [.width]
33+ textView.textContainer?.widthTracksTextView = true
34+
35+ textView.delegate = context.coordinator
36+
37+ textView.string = text
38+ context.coordinator.applyHighlighting(to: textView)
39+
40+ scrollView.hasVerticalScroller = true
41+ scrollView.hasHorizontalScroller = false
42+ scrollView.drawsBackground = false
43+
44+ return scrollView
45+ }
46+
47+ func updateNSView(_ nsView: NSScrollView, context: Context) {
48+ guard let textView = nsView.documentView as? NSTextView else { return }
49+
50+ // Only update when text changed externally (not from user typing)
51+ if textView.string != text && !context.coordinator.isEditing {
52+ let selectedRanges = textView.selectedRanges
53+ textView.string = text
54+ context.coordinator.applyHighlighting(to: textView)
55+ let validRanges = selectedRanges.filter { NSMaxRange($0.rangeValue) <= textView.string.count }
56+ if !validRanges.isEmpty {
57+ textView.selectedRanges = validRanges
58+ }
59+ }
60+ }
61+
62+ func makeCoordinator() -> Coordinator {
63+ Coordinator(text: $text)
64+ }
65+
66+ final class Coordinator: NSObject, NSTextViewDelegate {
67+ var text: Binding<String>
68+ var isEditing = false
69+
70+ init(text: Binding<String>) {
71+ self.text = text
72+ }
73+
74+ func textDidChange(_ notification: Notification) {
75+ guard let textView = notification.object as? NSTextView else { return }
76+ isEditing = true
77+ text.wrappedValue = textView.string
78+ applyHighlighting(to: textView)
79+ isEditing = false
80+ }
81+
82+ func applyHighlighting(to textView: NSTextView) {
83+ guard let textStorage = textView.textStorage else { return }
84+ textStorage.beginEditing()
85+ SQLSyntaxHighlighter.highlight(textView.string, in: textStorage, baseFont: SQLEditorView.editorFont)
86+ textStorage.endEditing()
87+ }
88+ }
89+}
ParseableViewer/Views/SyntaxHighlighting.swiftnew file+237
@@ -0,0 +1,237 @@
1+import AppKit
2+import SwiftUI
3+
4+// MARK: - SQL Syntax Highlighting
5+
6+enum SQLSyntaxHighlighter {
7+
8+ static let keywords: Set<String> = [
9+ "ADD", "ALL", "ALTER", "AND", "AS", "ASC",
10+ "BETWEEN", "BY",
11+ "CASE", "CAST", "CREATE", "CROSS", "CUBE", "CURRENT",
12+ "DELETE", "DESC", "DISTINCT", "DROP",
13+ "ELSE", "END", "EXCEPT", "EXISTS", "EXTRACT",
14+ "FALSE", "FETCH", "FILTER", "FIRST", "FOLLOWING", "FOR", "FROM", "FULL",
15+ "GROUP", "GROUPING",
16+ "HAVING",
17+ "IF", "IN", "INNER", "INSERT", "INTERSECT", "INTERVAL", "INTO", "IS",
18+ "JOIN",
19+ "LATERAL", "LEFT", "LIKE", "LIMIT",
20+ "NATURAL", "NEXT", "NOT", "NULL",
21+ "OFFSET", "ON", "ONLY", "OR", "ORDER", "OUTER", "OVER",
22+ "PARTITION", "PERCENT", "PRECEDING",
23+ "RANGE", "RECURSIVE", "RIGHT", "ROLLUP", "ROW", "ROWS",
24+ "SELECT", "SET", "SETS",
25+ "TABLE", "THEN", "TOP", "TRUE",
26+ "UNBOUNDED", "UNION", "UPDATE", "USING",
27+ "VALUES",
28+ "WHEN", "WHERE", "WINDOW", "WITH",
29+ ]
30+
31+ static let functions: Set<String> = [
32+ "ABS", "ARRAY_AGG", "AVG",
33+ "CEIL", "COALESCE", "CONCAT", "COUNT",
34+ "DATE", "DATE_TRUNC", "DENSE_RANK",
35+ "FIRST_VALUE", "FLOOR",
36+ "IIF", "IFNULL",
37+ "JSON_EXTRACT", "JSON_VALUE",
38+ "LAG", "LAST_VALUE", "LEAD", "LENGTH", "LOWER",
39+ "MAX", "MIN",
40+ "NOW", "NTH_VALUE", "NTILE", "NULLIF",
41+ "RANK", "REPLACE", "ROUND", "ROW_NUMBER",
42+ "STRING_AGG", "SUBSTRING", "SUM",
43+ "TIME", "TIMESTAMP", "TO_CHAR", "TO_DATE", "TO_TIMESTAMP", "TRIM",
44+ "UPPER",
45+ ]
46+
47+ /// Applies SQL syntax highlighting to an NSTextStorage.
48+ static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
49+ let fullRange = NSRange(location: 0, length: textStorage.length)
50+ guard fullRange.length > 0 else { return }
51+
52+ // Reset to default styling
53+ textStorage.addAttributes([
54+ .foregroundColor: NSColor.labelColor,
55+ .font: baseFont,
56+ ], range: fullRange)
57+
58+ let nsText = text as NSString
59+ var protectedRanges: [NSRange] = []
60+
61+ // 1. Comments (-- to end of line)
62+ applyPattern("--[^\n]*", to: textStorage, text: nsText,
63+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
64+
65+ // 2. Single-quoted strings
66+ applyPattern("'[^']*(?:''[^']*)*'", to: textStorage, text: nsText,
67+ color: .systemRed, protectedRanges: &protectedRanges)
68+
69+ // 3. Double-quoted identifiers
70+ applyPattern("\"[^\"]*(?:\"\"[^\"]*)*\"", to: textStorage, text: nsText,
71+ color: .systemRed, protectedRanges: &protectedRanges)
72+
73+ // 4. Numbers (outside protected ranges)
74+ applyUnprotectedPattern("\\b\\d+(?:\\.\\d+)?\\b", to: textStorage, text: nsText,
75+ color: .systemPurple, protectedRanges: protectedRanges)
76+
77+ // 5. Keywords (outside protected ranges)
78+ let keywordPattern = "\\b(?:" + keywords.sorted().joined(separator: "|") + ")\\b"
79+ applyUnprotectedKeywordPattern(keywordPattern, to: textStorage, text: nsText,
80+ color: .systemBlue, bold: true, baseFont: baseFont,
81+ protectedRanges: protectedRanges)
82+
83+ // 6. Functions (outside protected ranges)
84+ let functionPattern = "\\b(?:" + functions.sorted().joined(separator: "|") + ")\\b"
85+ applyUnprotectedKeywordPattern(functionPattern, to: textStorage, text: nsText,
86+ color: .systemTeal, bold: false, baseFont: baseFont,
87+ protectedRanges: protectedRanges)
88+ }
89+
90+ private static func applyPattern(
91+ _ pattern: String,
92+ to textStorage: NSTextStorage,
93+ text: NSString,
94+ color: NSColor,
95+ protectedRanges: inout [NSRange]
96+ ) {
97+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
98+ let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
99+ for match in matches {
100+ textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
101+ protectedRanges.append(match.range)
102+ }
103+ }
104+
105+ private static func applyUnprotectedPattern(
106+ _ pattern: String,
107+ to textStorage: NSTextStorage,
108+ text: NSString,
109+ color: NSColor,
110+ protectedRanges: [NSRange]
111+ ) {
112+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
113+ let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
114+ for match in matches where !isProtected(match.range, by: protectedRanges) {
115+ textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
116+ }
117+ }
118+
119+ private static func applyUnprotectedKeywordPattern(
120+ _ pattern: String,
121+ to textStorage: NSTextStorage,
122+ text: NSString,
123+ color: NSColor,
124+ bold: Bool,
125+ baseFont: NSFont,
126+ protectedRanges: [NSRange]
127+ ) {
128+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
129+ let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
130+ let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
131+ for match in matches where !isProtected(match.range, by: protectedRanges) {
132+ textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
133+ if bold {
134+ textStorage.addAttribute(.font, value: boldFont, range: match.range)
135+ }
136+ }
137+ }
138+
139+ private static func isProtected(_ range: NSRange, by protectedRanges: [NSRange]) -> Bool {
140+ for protected in protectedRanges {
141+ if range.location >= protected.location &&
142+ NSMaxRange(range) <= NSMaxRange(protected) {
143+ return true
144+ }
145+ }
146+ return false
147+ }
148+}
149+
150+// MARK: - JSON Syntax Highlighting
151+
152+enum JSONSyntaxHighlighter {
153+
154+ /// Creates a syntax-highlighted AttributedString from a JSON string.
155+ static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
156+ let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
157+ let nsString = json as NSString
158+ let fullRange = NSRange(location: 0, length: nsString.length)
159+
160+ let attributed = NSMutableAttributedString(string: json, attributes: [
161+ .font: resolvedFont,
162+ .foregroundColor: NSColor.labelColor,
163+ ])
164+
165+ guard fullRange.length > 0 else { return AttributedString(attributed) }
166+
167+ // Track string ranges to avoid coloring numbers/bools inside strings
168+ var stringRanges: [NSRange] = []
169+
170+ // 1. Double-quoted strings — distinguish keys from values
171+ if let regex = try? NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", options: []) {
172+ let matches = regex.matches(in: json, range: fullRange)
173+ for match in matches {
174+ stringRanges.append(match.range)
175+
176+ let isKey = isJSONKey(after: match.range, in: nsString)
177+ let color: NSColor = isKey ? .systemCyan : .systemRed
178+ attributed.addAttribute(.foregroundColor, value: color, range: match.range)
179+ if isKey {
180+ attributed.addAttribute(
181+ .font,
182+ value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
183+ range: match.range)
184+ }
185+ }
186+ }
187+
188+ // 2. Booleans (outside strings)
189+ if let regex = try? NSRegularExpression(pattern: "\\b(true|false)\\b", options: []) {
190+ for match in regex.matches(in: json, range: fullRange)
191+ where !isInString(match.range, stringRanges: stringRanges) {
192+ attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
193+ }
194+ }
195+
196+ // 3. Null (outside strings)
197+ if let regex = try? NSRegularExpression(pattern: "\\bnull\\b", options: []) {
198+ for match in regex.matches(in: json, range: fullRange)
199+ where !isInString(match.range, stringRanges: stringRanges) {
200+ attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
201+ }
202+ }
203+
204+ // 4. Numbers (outside strings)
205+ if let regex = try? NSRegularExpression(pattern: "-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b", options: []) {
206+ for match in regex.matches(in: json, range: fullRange)
207+ where !isInString(match.range, stringRanges: stringRanges) {
208+ attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
209+ }
210+ }
211+
212+ return AttributedString(attributed)
213+ }
214+
215+ /// Checks whether a matched string token is a JSON key by scanning forward for a colon.
216+ private static func isJSONKey(after range: NSRange, in text: NSString) -> Bool {
217+ var idx = NSMaxRange(range)
218+ while idx < text.length {
219+ let ch = text.character(at: idx)
220+ if ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D {
221+ idx += 1
222+ } else {
223+ return ch == 0x3A // ':'
224+ }
225+ }
226+ return false
227+ }
228+
229+ private static func isInString(_ range: NSRange, stringRanges: [NSRange]) -> Bool {
230+ for sr in stringRanges {
231+ if range.location >= sr.location && NSMaxRange(range) <= NSMaxRange(sr) {
232+ return true
233+ }
234+ }
235+ return false
236+ }
237+}
ParseableViewerTests/SyntaxHighlightingTests.swiftnew file+222
@@ -0,0 +1,222 @@
1+@testable import ParseableViewer
2+import XCTest
3+import AppKit
4+
5+final class SyntaxHighlightingTests: XCTestCase {
6+
7+ // MARK: - Helpers
8+
9+ private let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
10+
11+ private func sqlHighlight(_ sql: String) -> NSTextStorage {
12+ let ts = NSTextStorage(string: sql)
13+ SQLSyntaxHighlighter.highlight(sql, in: ts, baseFont: font)
14+ return ts
15+ }
16+
17+ private func colorAt(_ ts: NSTextStorage, _ index: Int) -> NSColor? {
18+ ts.attributes(at: index, effectiveRange: nil)[.foregroundColor] as? NSColor
19+ }
20+
21+ // MARK: - SQL: Keywords
22+
23+ func testSQLKeywordsAreHighlighted() {
24+ let ts = sqlHighlight("SELECT * FROM logs")
25+ // SELECT (index 0) and FROM (index 9) should not be default label color
26+ let selectColor = colorAt(ts, 0)
27+ let logsColor = colorAt(ts, 14) // "logs" is plain identifier
28+ XCTAssertNotEqual(selectColor, logsColor,
29+ "Keyword 'SELECT' should be a different color than identifier 'logs'")
30+
31+ let fromColor = colorAt(ts, 9)
32+ XCTAssertEqual(selectColor, fromColor,
33+ "'SELECT' and 'FROM' should share the same keyword color")
34+ }
35+
36+ func testSQLKeywordsAreCaseInsensitive() {
37+ let ts = sqlHighlight("select * from logs")
38+ let selectColor = colorAt(ts, 0)
39+ let logsColor = colorAt(ts, 14)
40+ XCTAssertNotEqual(selectColor, logsColor)
41+ }
42+
43+ func testSQLKeywordsAreBolded() {
44+ let ts = sqlHighlight("SELECT x")
45+ let attrs = ts.attributes(at: 0, effectiveRange: nil)
46+ let usedFont = attrs[.font] as? NSFont
47+ XCTAssertNotNil(usedFont)
48+ // Bold font has weight >= .bold
49+ let traits = usedFont!.fontDescriptor.symbolicTraits
50+ XCTAssertTrue(traits.contains(.bold), "Keywords should be bold")
51+ }
52+
53+ // MARK: - SQL: Strings
54+
55+ func testSQLSingleQuotedStringsHighlighted() {
56+ let sql = "WHERE msg = 'hello'"
57+ let ts = sqlHighlight(sql)
58+ let stringStart = (sql as NSString).range(of: "'hello'").location
59+ let stringColor = colorAt(ts, stringStart)
60+ let whereColor = colorAt(ts, 0) // WHERE keyword
61+ XCTAssertNotEqual(stringColor, whereColor,
62+ "String literal should be a different color than keywords")
63+ }
64+
65+ func testSQLKeywordsInsideStringsAreNotHighlightedAsKeywords() {
66+ let sql = "WHERE msg = 'SELECT FROM'"
67+ let ts = sqlHighlight(sql)
68+ let innerIdx = (sql as NSString).range(of: "'SELECT FROM'").location
69+ let stringColor = colorAt(ts, innerIdx)
70+ // The entire string should have the same color as its opening quote
71+ let innerKeywordIdx = innerIdx + 1 // 'S' in SELECT inside string
72+ let innerColor = colorAt(ts, innerKeywordIdx)
73+ XCTAssertEqual(stringColor, innerColor,
74+ "Keywords inside strings should be colored as string, not as keyword")
75+ }
76+
77+ // MARK: - SQL: Numbers
78+
79+ func testSQLNumbersHighlighted() {
80+ let sql = "LIMIT 100"
81+ let ts = sqlHighlight(sql)
82+ let numIdx = (sql as NSString).range(of: "100").location
83+ let numColor = colorAt(ts, numIdx)
84+ let keyColor = colorAt(ts, 0)
85+ XCTAssertNotEqual(numColor, keyColor,
86+ "Numbers should be a different color than keywords")
87+ }
88+
89+ // MARK: - SQL: Comments
90+
91+ func testSQLLineComments() {
92+ let sql = "SELECT * -- a comment\nFROM logs"
93+ let ts = sqlHighlight(sql)
94+ let commentIdx = (sql as NSString).range(of: "--").location
95+ let commentColor = colorAt(ts, commentIdx)
96+ let fromIdx = (sql as NSString).range(of: "FROM").location
97+ let fromColor = colorAt(ts, fromIdx)
98+ XCTAssertNotEqual(commentColor, fromColor,
99+ "Comment color should differ from keyword color")
100+ }
101+
102+ func testSQLKeywordsInsideCommentsNotHighlighted() {
103+ let sql = "-- SELECT FROM WHERE"
104+ let ts = sqlHighlight(sql)
105+ // All characters should share the comment color
106+ let dashColor = colorAt(ts, 0)
107+ let selectIdx = (sql as NSString).range(of: "SELECT").location
108+ let selectColor = colorAt(ts, selectIdx)
109+ XCTAssertEqual(dashColor, selectColor,
110+ "Keywords inside comments should not be highlighted as keywords")
111+ }
112+
113+ // MARK: - SQL: Functions
114+
115+ func testSQLFunctionsHighlighted() {
116+ let sql = "SELECT COUNT(*) FROM logs"
117+ let ts = sqlHighlight(sql)
118+ let countIdx = (sql as NSString).range(of: "COUNT").location
119+ let countColor = colorAt(ts, countIdx)
120+ let selectColor = colorAt(ts, 0)
121+ XCTAssertNotEqual(countColor, selectColor,
122+ "Functions should be a different color than keywords")
123+ }
124+
125+ // MARK: - SQL: Edge Cases
126+
127+ func testSQLEmptyString() {
128+ let ts = NSTextStorage(string: "")
129+ SQLSyntaxHighlighter.highlight("", in: ts, baseFont: font)
130+ XCTAssertEqual(ts.length, 0)
131+ }
132+
133+ func testSQLDoubleQuotedIdentifiers() {
134+ let sql = "SELECT \"my column\" FROM logs"
135+ let ts = sqlHighlight(sql)
136+ let quoteIdx = (sql as NSString).range(of: "\"my column\"").location
137+ let quoteColor = colorAt(ts, quoteIdx)
138+ let selectColor = colorAt(ts, 0)
139+ XCTAssertNotEqual(quoteColor, selectColor,
140+ "Double-quoted identifiers should differ from keywords")
141+ }
142+
143+ // MARK: - JSON Highlighting
144+
145+ func testJSONHighlightProducesOutput() {
146+ let json = "{\"key\": \"value\", \"n\": 42}"
147+ let result = JSONSyntaxHighlighter.highlight(json)
148+ XCTAssertFalse(result.characters.isEmpty)
149+ }
150+
151+ func testJSONHighlightEmptyString() {
152+ let result = JSONSyntaxHighlighter.highlight("")
153+ XCTAssertTrue(result.characters.isEmpty)
154+ }
155+
156+ func testJSONKeysAndValuesHaveDifferentColors() {
157+ let json = "{\"name\": \"Alice\"}"
158+ let result = JSONSyntaxHighlighter.highlight(json)
159+ let ns = NSAttributedString(result)
160+
161+ let keyIdx = (json as NSString).range(of: "\"name\"").location
162+ let valueIdx = (json as NSString).range(of: "\"Alice\"").location
163+
164+ let keyColor = ns.attributes(at: keyIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
165+ let valueColor = ns.attributes(at: valueIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
166+
167+ XCTAssertNotNil(keyColor)
168+ XCTAssertNotNil(valueColor)
169+ XCTAssertNotEqual(keyColor, valueColor,
170+ "JSON keys and string values should have different colors")
171+ }
172+
173+ func testJSONBooleansHighlighted() {
174+ let json = "{\"flag\": true}"
175+ let result = JSONSyntaxHighlighter.highlight(json)
176+ let ns = NSAttributedString(result)
177+
178+ let boolIdx = (json as NSString).range(of: "true").location
179+ let boolColor = ns.attributes(at: boolIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
180+ // Should not be default label color
181+ XCTAssertNotEqual(boolColor, NSColor.labelColor,
182+ "Booleans should be highlighted")
183+ }
184+
185+ func testJSONNullHighlighted() {
186+ let json = "{\"x\": null}"
187+ let result = JSONSyntaxHighlighter.highlight(json)
188+ let ns = NSAttributedString(result)
189+
190+ let nullIdx = (json as NSString).range(of: "null").location
191+ let nullColor = ns.attributes(at: nullIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
192+ XCTAssertNotEqual(nullColor, NSColor.labelColor,
193+ "Null should be highlighted")
194+ }
195+
196+ func testJSONNumbersHighlighted() {
197+ let json = "{\"count\": 42}"
198+ let result = JSONSyntaxHighlighter.highlight(json)
199+ let ns = NSAttributedString(result)
200+
201+ let numIdx = (json as NSString).range(of: "42").location
202+ let numColor = ns.attributes(at: numIdx, effectiveRange: nil)[.foregroundColor] as? NSColor
203+ XCTAssertNotEqual(numColor, NSColor.labelColor,
204+ "Numbers should be highlighted")
205+ }
206+
207+ func testJSONNestedStructure() {
208+ let json = """
209+ {
210+ "user": {
211+ "name": "Bob",
212+ "age": 30,
213+ "active": true,
214+ "address": null
215+ }
216+ }
217+ """
218+ // Should not crash on nested JSON
219+ let result = JSONSyntaxHighlighter.highlight(json)
220+ XCTAssertFalse(result.characters.isEmpty)
221+ }
222+}