diff --git a/CHANGELOG.md b/CHANGELOG.md index 5452da96..e420bdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB) + ## [0.23.2] - 2026-03-24 ### Fixed diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift index c4ae800d..a7501384 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift @@ -32,6 +32,8 @@ class HighlightProviderState { /// The length to chunk ranges into when passing to the highlighter. private static let rangeChunkLimit = 4096 + private static let largeDocThreshold = 50_000 + // MARK: - State /// A unique identifier for this provider. Used by the delegate to determine the source of results. @@ -120,8 +122,11 @@ class HighlightProviderState { /// Accumulates all pending ranges and calls `queryHighlights`. func highlightInvalidRanges() { + let docLength = visibleRangeProvider?.documentRange.length ?? 0 + let maxRanges = docLength > Self.largeDocThreshold ? 2 : Int.max + var ranges: [NSRange] = [] - while let nextRange = getNextRange() { + while ranges.count < maxRanges, let nextRange = getNextRange() { ranges.append(nextRange) pendingSet.insert(range: nextRange) } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index cefd82bc..a2599f71 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -81,6 +81,8 @@ class Highlighter: NSObject { /// Counts upwards to provide unique IDs for new highlight providers. private var providerIdCounter: Int + public var maxHighlightableLength: Int = 5_000_000 + // MARK: - Init init( @@ -226,6 +228,7 @@ extension Highlighter: @preconcurrency NSTextStorageDelegate { // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document // each time an attribute is applied, we check to make sure this is in response to an edit. guard editedMask.contains(.editedCharacters) else { return } + guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return } styleContainer.storageUpdated(editedRange: editedRange, changeInLength: delta) @@ -276,6 +279,7 @@ extension Highlighter: StyledRangeContainerDelegate { extension Highlighter: VisibleRangeProviderDelegate { func visibleSetDidUpdate(_ newIndices: IndexSet) { + guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return } highlightProviders.forEach { $0.highlightInvalidRanges() } } } diff --git a/TablePro/Theme/ResolvedThemeColors.swift b/TablePro/Theme/ResolvedThemeColors.swift index 156b387e..e99da931 100644 --- a/TablePro/Theme/ResolvedThemeColors.swift +++ b/TablePro/Theme/ResolvedThemeColors.swift @@ -16,6 +16,8 @@ struct ResolvedEditorColors { let lineNumberSwiftUI: Color let invisibles: NSColor let invisiblesSwiftUI: Color + let currentStatementHighlight: NSColor + let currentStatementHighlightSwiftUI: Color let keyword: NSColor let keywordSwiftUI: Color @@ -49,6 +51,8 @@ struct ResolvedEditorColors { lineNumberSwiftUI = colors.lineNumber.swiftUIColor invisibles = colors.invisibles.nsColor invisiblesSwiftUI = colors.invisibles.swiftUIColor + currentStatementHighlight = colors.currentStatementHighlight.nsColor + currentStatementHighlightSwiftUI = colors.currentStatementHighlight.swiftUIColor keyword = colors.syntax.keyword.nsColor keywordSwiftUI = colors.syntax.keyword.swiftUIColor diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index ea7e1d80..3f586afa 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -177,6 +177,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { var selection: String var lineNumber: String var invisibles: String + /// Reserved for future current-statement background highlight in the query editor. + var currentStatementHighlight: String var syntax: SyntaxColors static let defaultLight = EditorThemeColors( @@ -187,6 +189,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { selection: "#B4D8FD", lineNumber: "#747478", invisibles: "#D6D6D6", + currentStatementHighlight: "#F0F4FA", syntax: .defaultLight ) @@ -198,6 +201,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { selection: String, lineNumber: String, invisibles: String, + currentStatementHighlight: String, syntax: SyntaxColors ) { self.background = background @@ -207,6 +211,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { self.selection = selection self.lineNumber = lineNumber self.invisibles = invisibles + self.currentStatementHighlight = currentStatementHighlight self.syntax = syntax } @@ -222,6 +227,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable { selection = try container.decodeIfPresent(String.self, forKey: .selection) ?? fallback.selection lineNumber = try container.decodeIfPresent(String.self, forKey: .lineNumber) ?? fallback.lineNumber invisibles = try container.decodeIfPresent(String.self, forKey: .invisibles) ?? fallback.invisibles + currentStatementHighlight = try container.decodeIfPresent(String.self, forKey: .currentStatementHighlight) + ?? fallback.currentStatementHighlight syntax = try container.decodeIfPresent(SyntaxColors.self, forKey: .syntax) ?? fallback.syntax } } diff --git a/TableProTests/Core/Storage/TabDiskActorTests.swift b/TableProTests/Core/Storage/TabDiskActorTests.swift index 19f714f2..b36a14b0 100644 --- a/TableProTests/Core/Storage/TabDiskActorTests.swift +++ b/TableProTests/Core/Storage/TabDiskActorTests.swift @@ -36,12 +36,12 @@ struct TabDiskActorTests { // MARK: - save / load round-trip @Test("Save then load round-trips correctly") - func saveAndLoadRoundTrip() async { + func saveAndLoadRoundTrip() async throws { let connectionId = UUID() let tabId = UUID() let tab = makeTab(id: tabId, title: "My Tab", query: "SELECT * FROM users") - await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) + try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) let state = await actor.load(connectionId: connectionId) #expect(state != nil) @@ -58,7 +58,7 @@ struct TabDiskActorTests { // MARK: - load returns nil for unknown connectionId @Test("Load returns nil for unknown connectionId") - func loadReturnsNilForUnknown() async { + func loadReturnsNilForUnknown() async throws { let result = await actor.load(connectionId: UUID()) #expect(result == nil) } @@ -66,13 +66,13 @@ struct TabDiskActorTests { // MARK: - save overwrites previous state @Test("Save overwrites previous state") - func saveOverwritesPreviousState() async { + func saveOverwritesPreviousState() async throws { let connectionId = UUID() let tab1 = makeTab(title: "First") let tab2 = makeTab(title: "Second") - await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id) - await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id) + try await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id) + try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id) let state = await actor.load(connectionId: connectionId) @@ -86,11 +86,11 @@ struct TabDiskActorTests { // MARK: - clear removes saved state @Test("Clear removes saved state") - func clearRemovesSavedState() async { + func clearRemovesSavedState() async throws { let connectionId = UUID() let tab = makeTab() - await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id) + try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id) await actor.clear(connectionId: connectionId) let state = await actor.load(connectionId: connectionId) @@ -100,21 +100,21 @@ struct TabDiskActorTests { // MARK: - clear on non-existent connectionId does not crash @Test("Clear on non-existent connectionId does not crash") - func clearNonExistentDoesNotCrash() async { + func clearNonExistentDoesNotCrash() async throws { await actor.clear(connectionId: UUID()) } // MARK: - Multiple connections are independent @Test("Multiple connections are independent") - func multipleConnectionsAreIndependent() async { + func multipleConnectionsAreIndependent() async throws { let connA = UUID() let connB = UUID() let tabA = makeTab(title: "Tab A") let tabB = makeTab(title: "Tab B") - await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id) - await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id) + try await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id) + try await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id) let stateA = await actor.load(connectionId: connA) let stateB = await actor.load(connectionId: connB) @@ -135,18 +135,18 @@ struct TabDiskActorTests { // MARK: - selectedTabId preservation @Test("selectedTabId is preserved correctly including nil") - func selectedTabIdPreserved() async { + func selectedTabIdPreserved() async throws { let connectionId = UUID() let tab = makeTab() - await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil) + try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil) let stateNil = await actor.load(connectionId: connectionId) #expect(stateNil?.selectedTabId == nil) #expect(stateNil?.tabs.count == 1) let specificId = UUID() let tab2 = makeTab(id: specificId) - await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId) + try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId) let stateWithId = await actor.load(connectionId: connectionId) #expect(stateWithId?.selectedTabId == specificId) @@ -156,7 +156,7 @@ struct TabDiskActorTests { // MARK: - saveLastQuery / loadLastQuery round-trip @Test("saveLastQuery then loadLastQuery round-trips") - func lastQueryRoundTrip() async { + func lastQueryRoundTrip() async throws { let connectionId = UUID() let query = "SELECT * FROM products WHERE active = true" @@ -171,7 +171,7 @@ struct TabDiskActorTests { // MARK: - loadLastQuery returns nil for unknown connectionId @Test("loadLastQuery returns nil for unknown connectionId") - func loadLastQueryReturnsNilForUnknown() async { + func loadLastQueryReturnsNilForUnknown() async throws { let result = await actor.loadLastQuery(for: UUID()) #expect(result == nil) } @@ -179,7 +179,7 @@ struct TabDiskActorTests { // MARK: - saveLastQuery with empty string removes the file @Test("saveLastQuery with empty string removes the file") - func saveLastQueryEmptyRemovesFile() async { + func saveLastQueryEmptyRemovesFile() async throws { let connectionId = UUID() await actor.saveLastQuery("SELECT 1", for: connectionId) @@ -193,7 +193,7 @@ struct TabDiskActorTests { // MARK: - saveLastQuery with whitespace-only string removes the file @Test("saveLastQuery with whitespace-only string removes the file") - func saveLastQueryWhitespaceOnlyRemovesFile() async { + func saveLastQueryWhitespaceOnlyRemovesFile() async throws { let connectionId = UUID() await actor.saveLastQuery("SELECT 1", for: connectionId) @@ -206,7 +206,7 @@ struct TabDiskActorTests { // MARK: - saveLastQuery skips queries exceeding 500KB @Test("saveLastQuery skips queries exceeding 500KB") - func saveLastQuerySkipsLargeQueries() async { + func saveLastQuerySkipsLargeQueries() async throws { let connectionId = UUID() let smallQuery = "SELECT 1" @@ -225,7 +225,7 @@ struct TabDiskActorTests { // MARK: - Tab with all fields round-trips @Test("Tab with all fields including isView and databaseName round-trips") - func tabWithAllFieldsRoundTrips() async { + func tabWithAllFieldsRoundTrips() async throws { let connectionId = UUID() let tabId = UUID() let tab = makeTab( @@ -238,7 +238,7 @@ struct TabDiskActorTests { databaseName: "production" ) - await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) + try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId) let state = await actor.load(connectionId: connectionId) #expect(state != nil) @@ -257,13 +257,13 @@ struct TabDiskActorTests { // MARK: - Multiple tabs in single save @Test("Multiple tabs in a single save round-trip correctly") - func multipleTabsRoundTrip() async { + func multipleTabsRoundTrip() async throws { let connectionId = UUID() let tab1 = makeTab(title: "Tab 1", tabType: .query) let tab2 = makeTab(title: "Tab 2", tabType: .table, tableName: "orders") let tab3 = makeTab(title: "Tab 3", tabType: .query) - await actor.save(connectionId: connectionId, tabs: [tab1, tab2, tab3], selectedTabId: tab2.id) + try await actor.save(connectionId: connectionId, tabs: [tab1, tab2, tab3], selectedTabId: tab2.id) let state = await actor.load(connectionId: connectionId) #expect(state?.tabs.count == 3) @@ -278,7 +278,7 @@ struct TabDiskActorTests { // MARK: - saveSync writes data readable by load @Test("saveSync writes data that load can read back") - func saveSyncWritesReadableData() async { + func saveSyncWritesReadableData() async throws { let connectionId = UUID() let tabId = UUID() let tab = makeTab(id: tabId, title: "Sync Tab", query: "SELECT 42", tabType: .table, tableName: "orders") @@ -301,10 +301,10 @@ struct TabDiskActorTests { // MARK: - Empty tabs array @Test("Saving empty tabs array round-trips") - func emptyTabsArrayRoundTrips() async { + func emptyTabsArrayRoundTrips() async throws { let connectionId = UUID() - await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil) + try await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil) let state = await actor.load(connectionId: connectionId) #expect(state != nil) diff --git a/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift b/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift new file mode 100644 index 00000000..aceed913 --- /dev/null +++ b/TableProTests/Core/Utilities/SQLStatementScannerLocatedTests.swift @@ -0,0 +1,182 @@ +// +// SQLStatementScannerLocatedTests.swift +// TableProTests +// +// Focused tests on locatedStatementAtCursor, the key function +// powering the current statement highlighter. +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("SQL Statement Scanner — locatedStatementAtCursor") +struct SQLStatementScannerLocatedTests { + + // MARK: - Offset correctness + + @Test("Returns correct offset for each statement in multi-statement string") + func correctOffsetsForMultipleStatements() { + let sql = "SELECT 1; UPDATE t SET x=1; DELETE FROM t" + // 0123456789... + + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.offset == 0) + #expect(first.sql == "SELECT 1;") + + let second = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 12) + #expect(second.offset == 9) + #expect(second.sql == " UPDATE t SET x=1;") + + let third = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 30) + #expect(third.offset == 27) + #expect(third.sql == " DELETE FROM t") + } + + @Test("offset + sql.count covers the full statement range") + func offsetPlusSqlLengthCoversRange() { + let sql = "INSERT INTO t VALUES(1); SELECT * FROM t; DROP TABLE t" + + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 5) + let firstEnd = first.offset + (first.sql as NSString).length + #expect(firstEnd == 24) // "INSERT INTO t VALUES(1);" is 24 chars + + let second = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 30) + let secondEnd = second.offset + (second.sql as NSString).length + #expect(secondEnd == 41) // up to and including the second semicolon + + let third = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 50) + let thirdEnd = third.offset + (third.sql as NSString).length + #expect(thirdEnd == (sql as NSString).length) + } + + // MARK: - Leading whitespace handling + + @Test("Offset accounts for leading whitespace between statements") + func leadingWhitespaceIncludedInOffset() { + let sql = "SELECT 1; SELECT 2" + // ^ offset 9, then " SELECT 2" starts at 9 + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 15) + #expect(located.offset == 9) + // The raw SQL includes the leading spaces + #expect(located.sql.hasPrefix(" ")) + } + + @Test("Offset accounts for newlines between statements") + func newlinesBetweenStatements() { + let sql = "SELECT 1;\n\nSELECT 2" + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 15) + #expect(located.offset == 9) + #expect(located.sql == "\n\nSELECT 2") + } + + // MARK: - Trailing whitespace handling + + @Test("Trailing whitespace before semicolon is included in statement") + func trailingWhitespaceBeforeSemicolon() { + let sql = "SELECT 1 ; SELECT 2" + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 5) + #expect(located.sql == "SELECT 1 ;") + } + + // MARK: - Comment styles + + @Test("Works with line comments containing semicolons") + func lineCommentWithSemicolon() { + let sql = "SELECT 1 -- drop; table\n; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + // The semicolon in the comment is not a delimiter + #expect(first.sql == "SELECT 1 -- drop; table\n;") + #expect(first.offset == 0) + } + + @Test("Works with block comments containing semicolons") + func blockCommentWithSemicolon() { + let sql = "SELECT /* ; */ 1; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.sql == "SELECT /* ; */ 1;") + #expect(first.offset == 0) + } + + @Test("Works with mixed comment styles") + func mixedComments() { + // Semicolons inside comments are ignored; real delimiter is at pos 31 + let sql = "SELECT 1 /* block; */ -- line;\n; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.offset == 0) + #expect(first.sql.contains("SELECT 1")) + + let second = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 38) + #expect(second.sql.contains("SELECT 2")) + } + + // MARK: - Backtick-quoted identifiers + + @Test("Backtick-quoted identifiers containing semicolons do not split") + func backtickWithSemicolon() { + let sql = "SELECT `col;name`; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 5) + #expect(first.sql == "SELECT `col;name`;") + #expect(first.offset == 0) + } + + // MARK: - Edge cases + + @Test("Cursor at exact semicolon position belongs to current statement") + func cursorAtSemicolon() { + let sql = "SELECT 1; SELECT 2" + // Position 8 is the semicolon character + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 8) + #expect(located.offset == 0) + #expect(located.sql == "SELECT 1;") + } + + @Test("Cursor beyond end of string is clamped") + func cursorBeyondEnd() { + let sql = "SELECT 1; SELECT 2" + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 9999) + #expect(located.offset == 9) + #expect(located.sql == " SELECT 2") + } + + @Test("Handles very large input without crashing") + func largeInput() { + var parts: [String] = [] + for i in 0..<200 { + parts.append("SELECT \(i) FROM very_long_table_name_for_testing;") + } + let sql = parts.joined(separator: " ") + let nsSQL = sql as NSString + #expect(nsSQL.length > 10_000) + + let midpoint = nsSQL.length / 2 + let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: midpoint) + #expect(!located.sql.isEmpty) + #expect(located.offset >= 0) + #expect(located.offset < nsSQL.length) + } + + @Test("Multiple consecutive semicolons produce empty-ish segments") + func consecutiveSemicolons() { + let sql = "SELECT 1;;; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.sql == "SELECT 1;") + #expect(first.offset == 0) + } + + @Test("Escaped quote inside string does not break parsing") + func escapedQuoteInString() { + let sql = "SELECT 'it\\'s here'; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.sql == "SELECT 'it\\'s here';") + #expect(first.offset == 0) + } + + @Test("Doubled quote escape inside string does not break parsing") + func doubledQuoteInString() { + let sql = "SELECT 'it''s here'; SELECT 2" + let first = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 0) + #expect(first.sql == "SELECT 'it''s here';") + #expect(first.offset == 0) + } +} diff --git a/TableProTests/Theme/ThemeDefinitionTests.swift b/TableProTests/Theme/ThemeDefinitionTests.swift new file mode 100644 index 00000000..a394d4bc --- /dev/null +++ b/TableProTests/Theme/ThemeDefinitionTests.swift @@ -0,0 +1,137 @@ +// +// ThemeDefinitionTests.swift +// TableProTests +// +// Tests for ThemeDefinition and EditorThemeColors, focusing on the +// currentStatementHighlight field and Codable backward compatibility. +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Theme Definition") +struct ThemeDefinitionTests { + + // MARK: - Default light theme + + @Test("Default light editor colors include currentStatementHighlight") + func defaultLightHasCurrentStatementHighlight() { + let colors = EditorThemeColors.defaultLight + #expect(colors.currentStatementHighlight == "#F0F4FA") + } + + @Test("Default light editor colors have expected background") + func defaultLightBackground() { + let colors = EditorThemeColors.defaultLight + #expect(colors.background == "#FFFFFF") + } + + // MARK: - Codable round-trip + + @Test("EditorThemeColors survives encode-decode round-trip") + func editorThemeColorsRoundTrip() throws { + let original = EditorThemeColors.defaultLight + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoder = JSONDecoder() + let decoded = try decoder.decode(EditorThemeColors.self, from: data) + + #expect(decoded.currentStatementHighlight == original.currentStatementHighlight) + #expect(decoded.background == original.background) + #expect(decoded.text == original.text) + #expect(decoded.cursor == original.cursor) + #expect(decoded.currentLineHighlight == original.currentLineHighlight) + #expect(decoded.selection == original.selection) + #expect(decoded.lineNumber == original.lineNumber) + #expect(decoded.invisibles == original.invisibles) + #expect(decoded == original) + } + + @Test("Full ThemeDefinition survives encode-decode round-trip") + func themeDefinitionRoundTrip() throws { + let original = ThemeDefinition.default + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoder = JSONDecoder() + let decoded = try decoder.decode(ThemeDefinition.self, from: data) + + #expect(decoded.editor.currentStatementHighlight == original.editor.currentStatementHighlight) + #expect(decoded == original) + } + + // MARK: - Backward compatibility + + @Test("Decoding JSON missing currentStatementHighlight falls back to default") + func backwardCompatibilityMissingField() throws { + // JSON with all editor fields EXCEPT currentStatementHighlight + let json = """ + { + "background": "#1E1E1E", + "text": "#D4D4D4", + "cursor": "#AEAFAD", + "currentLineHighlight": "#2A2D2E", + "selection": "#264F78", + "lineNumber": "#858585", + "invisibles": "#3B3B3B", + "syntax": { + "keyword": "#569CD6", + "string": "#CE9178", + "number": "#B5CEA8", + "comment": "#6A9955", + "null": "#569CD6", + "operator": "#D4D4D4", + "function": "#DCDCAA", + "type": "#4EC9B0" + } + } + """ + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) + + // Should fall back to defaultLight's value + #expect(decoded.currentStatementHighlight == EditorThemeColors.defaultLight.currentStatementHighlight) + // Other fields should use the provided values + #expect(decoded.background == "#1E1E1E") + #expect(decoded.text == "#D4D4D4") + } + + @Test("Decoding empty JSON falls back to all defaults") + func emptyJsonFallsBackToDefaults() throws { + let json = "{}" + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) + + #expect(decoded == EditorThemeColors.defaultLight) + } + + @Test("Decoding JSON with currentStatementHighlight preserves custom value") + func customCurrentStatementHighlight() throws { + let json = """ + { + "background": "#FFFFFF", + "text": "#000000", + "cursor": "#000000", + "currentLineHighlight": "#ECF5FF", + "selection": "#B4D8FD", + "lineNumber": "#747478", + "invisibles": "#D6D6D6", + "currentStatementHighlight": "#AABBCC", + "syntax": { + "keyword": "#9B2393", + "string": "#C41A16", + "number": "#1C00CF", + "comment": "#5D6C79", + "null": "#9B2393", + "operator": "#000000", + "function": "#326D74", + "type": "#3F6E74" + } + } + """ + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) + + #expect(decoded.currentStatementHighlight == "#AABBCC") + } +}