git.iko.soy/parseable-macos
on master help sign in
Add SQL and JSON syntax highlighting (#11)

Replace the plain TextEditor SQL input with an NSTextView-backed editor that highlights keywords (bold blue), functions (teal), strings (red), numbers (purple), and comments (gray) in real time. Add JSON syntax highlighting to the raw JSON detail view, coloring keys (cyan/medium weight), string values (red), numbers (purple), booleans (orange), and null (gray). Update the formatted JSONValueView to use matching colors for consistency across views. https://claude.ai/code/session_01FB8AAR2zyoPA7nebaxgpNz Co-authored-by: Claude <noreply@anthropic.com>

commita395908599bbad6b24ba907c485a9825b5edf9a9 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 12:52 (8 months ago) parent8e59dd6
5 files changed+555−16
ParseableViewer/Views/LogDetailView.swift+6−11 ParseableViewer/Views/QueryView.swift+1−5 ParseableViewer/Views/SQLEditorView.swift+89 ParseableViewer/Views/SyntaxHighlighting.swift+237 ParseableViewerTests/SyntaxHighlightingTests.swift+222
ParseableViewer/Views/LogDetailView.swift+6−11
@@ -141,19 +141,19 @@ struct JSONValueView: View {
141141 case .bool(let v):
142142 Text(v ? "true" : "false")
143143 .font(.system(.caption, design: .monospaced))
144− .foregroundStyle(.purple)
144+ .foregroundStyle(Color(nsColor: .systemOrange))
145145 case .int(let v):
146146 Text(String(v))
147147 .font(.system(.caption, design: .monospaced))
148− .foregroundStyle(.blue)
148+ .foregroundStyle(Color(nsColor: .systemPurple))
149149 case .double(let v):
150150 Text(String(v))
151151 .font(.system(.caption, design: .monospaced))
152− .foregroundStyle(.blue)
152+ .foregroundStyle(Color(nsColor: .systemPurple))
153153 case .string(let v):
154154 Text(v)
155155 .font(.system(.caption, design: .monospaced))
156− .foregroundStyle(.primary)
156+ .foregroundStyle(Color(nsColor: .systemRed))
157157 .textSelection(.enabled)
158158 case .array(let items):
159159 VStack(alignment: .leading, spacing: 2) {
@@ -186,14 +186,9 @@ struct JSONValueView: View {
186186struct RawJSONView: View {
187187 let record: LogRecord
188188
189− var jsonString: String {
190− let obj = JSONValue.object(record)
191− return obj.prettyPrinted()
192− }
193−
194189 var body: some View {
195− Text(jsonString)
196− .font(.system(.caption, design: .monospaced))
190+ let json = JSONValue.object(record).prettyPrinted()
191+ Text(JSONSyntaxHighlighter.highlight(json))
197192 .textSelection(.enabled)
198193 .padding(8)
199194 .frame(maxWidth: .infinity, alignment: .leading)
ParseableViewer/Views/QueryView.swift+1−5
@@ -106,12 +106,8 @@ struct QueryView: View {
106106 .padding(8)
107107
108108 // SQL editor
109− TextEditor(text: $vm.sqlQuery)
110− .font(.system(.body, design: .monospaced))
109+ SQLEditorView(text: $vm.sqlQuery)
111110 .frame(minHeight: 60, maxHeight: 120)
112− .scrollContentBackground(.hidden)
113− .padding(4)
114− .background(Color(nsColor: .textBackgroundColor))
115111 .clipShape(RoundedRectangle(cornerRadius: 4))
116112 .overlay(
117113 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+}