From e45390269d6ba54e92fd567dcc87da5278f6f4d2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 15:49:01 +0700 Subject: [PATCH 1/7] feat(datagrid): pretty-print JSON cells by default without marking them changed --- CHANGELOG.md | 1 + .../Services/Formatting/JsonReindenter.swift | 86 ++++++ .../Formatting/JsonSyntaxParser.swift | 248 ++++++++++++++++++ TablePro/Extensions/String+JSON.swift | 12 +- TablePro/Models/UI/JSONTreeNode.swift | 81 +++--- TablePro/Models/UI/MultiRowEditState.swift | 29 +- .../Views/Results/JSONEditorContentView.swift | 6 +- .../Views/Results/JSONSyntaxTextView.swift | 2 + .../Views/Results/JSONViewerContentView.swift | 2 +- TablePro/Views/Results/JSONViewerView.swift | 72 ++--- .../Results/JSONViewerWindowController.swift | 6 +- .../FieldEditors/JsonEditorView.swift | 20 +- .../Formatting/JsonReindenterTests.swift | 135 ++++++++++ .../Extensions/StringJsonTests.swift | 47 ++-- .../UI/MultiRowEditStateJsonTests.swift | 85 ++++++ docs/features/json-viewer.mdx | 8 +- 16 files changed, 688 insertions(+), 152 deletions(-) create mode 100644 TablePro/Core/Services/Formatting/JsonReindenter.swift create mode 100644 TablePro/Core/Services/Formatting/JsonSyntaxParser.swift create mode 100644 TableProTests/Core/Services/Formatting/JsonReindenterTests.swift create mode 100644 TableProTests/Models/UI/MultiRowEditStateJsonTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 664619fb3..8a0502267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Click the page indicator in the pagination bar to jump to a specific page. (#1364) - Pagination now appears for filtered tables whose total row count is unknown, so you can page through them instead of seeing only the first page. (#1364) - First Page and Last Page keyboard actions, unbound by default and assignable in Settings > Keyboard. (#1364) +- JSON and JSONB cells now display pretty-printed by default, keeping your original key order and exact numbers. Viewing or reformatting a value no longer marks the row as changed, and saving no longer reorders keys or rounds large integers. ### Fixed diff --git a/TablePro/Core/Services/Formatting/JsonReindenter.swift b/TablePro/Core/Services/Formatting/JsonReindenter.swift new file mode 100644 index 000000000..a34098ad0 --- /dev/null +++ b/TablePro/Core/Services/Formatting/JsonReindenter.swift @@ -0,0 +1,86 @@ +// +// JsonReindenter.swift +// TablePro +// + +import Foundation + +internal enum JsonReindenter { + private static let maxLength = 500_000 + private static let defaultIndent = " " + + static func reindentIfValid(_ source: String, indent: String = defaultIndent) -> String? { + guard (source as NSString).length <= maxLength else { return nil } + guard let node = JsonSyntaxParser.parse(source) else { return nil } + var output = "" + writePretty(node, into: &output, indent: indent, depth: 0) + return output + } + + static func reindent(_ source: String, indent: String = defaultIndent) -> String { + reindentIfValid(source, indent: indent) ?? source + } + + static func normalize(_ source: String) -> String { + guard (source as NSString).length <= maxLength else { return source } + guard let node = JsonSyntaxParser.parse(source) else { return source } + var output = "" + writeCompact(node, into: &output) + return output + } + + private static func writePretty(_ node: JsonSyntaxNode, into output: inout String, indent: String, depth: Int) { + switch node { + case .string(let raw), .number(let raw), .literal(let raw): + output += raw + case .object(let pairs): + guard !pairs.isEmpty else { + output += "{}" + return + } + output += "{\n" + let childPad = String(repeating: indent, count: depth + 1) + for (offset, pair) in pairs.enumerated() { + output += childPad + pair.key + ": " + writePretty(pair.value, into: &output, indent: indent, depth: depth + 1) + output += offset == pairs.count - 1 ? "\n" : ",\n" + } + output += String(repeating: indent, count: depth) + "}" + case .array(let elements): + guard !elements.isEmpty else { + output += "[]" + return + } + output += "[\n" + let childPad = String(repeating: indent, count: depth + 1) + for (offset, element) in elements.enumerated() { + output += childPad + writePretty(element, into: &output, indent: indent, depth: depth + 1) + output += offset == elements.count - 1 ? "\n" : ",\n" + } + output += String(repeating: indent, count: depth) + "]" + } + } + + private static func writeCompact(_ node: JsonSyntaxNode, into output: inout String) { + switch node { + case .string(let raw), .number(let raw), .literal(let raw): + output += raw + case .object(let pairs): + output += "{" + for (offset, pair) in pairs.enumerated() { + output += pair.key + ":" + writeCompact(pair.value, into: &output) + if offset != pairs.count - 1 { output += "," } + } + output += "}" + case .array(let elements): + output += "[" + for (offset, element) in elements.enumerated() { + writeCompact(element, into: &output) + if offset != elements.count - 1 { output += "," } + } + output += "]" + } + } +} diff --git a/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift b/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift new file mode 100644 index 000000000..b8da99e37 --- /dev/null +++ b/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift @@ -0,0 +1,248 @@ +// +// JsonSyntaxParser.swift +// TablePro +// + +import Foundation + +internal indirect enum JsonSyntaxNode { + case object([(key: String, value: JsonSyntaxNode)]) + case array([JsonSyntaxNode]) + case string(String) + case number(String) + case literal(String) +} + +internal enum JsonSyntaxParser { + static func parse(_ source: String) -> JsonSyntaxNode? { + var parser = Parser(scalars: Array(source.unicodeScalars)) + parser.skipWhitespace() + guard let node = parser.parseValue() else { return nil } + parser.skipWhitespace() + guard parser.isAtEnd else { return nil } + return node + } + + static func decodeStringLiteral(_ raw: String) -> String { + let scalars = Array(raw.unicodeScalars) + guard scalars.count >= 2, scalars.first == "\"", scalars.last == "\"" else { return raw } + + var output = String.UnicodeScalarView() + var index = 1 + let end = scalars.count - 1 + + while index < end { + let scalar = scalars[index] + if scalar != "\\" { + output.append(scalar) + index += 1 + continue + } + index += 1 + guard index < end else { break } + switch scalars[index] { + case "\"": output.append("\"") + case "\\": output.append("\\") + case "/": output.append("/") + case "b": output.append(Unicode.Scalar(UInt8(8))) + case "f": output.append(Unicode.Scalar(UInt8(12))) + case "n": output.append("\n") + case "r": output.append("\r") + case "t": output.append("\t") + case "u": + if let decoded = Self.decodeUnicodeEscape(scalars, at: &index, end: end) { + output.append(decoded) + } + default: + output.append(scalars[index]) + } + index += 1 + } + + return String(output) + } + + private static func decodeUnicodeEscape(_ scalars: [Unicode.Scalar], at index: inout Int, end: Int) -> Unicode.Scalar? { + guard let high = hexValue(scalars, uAt: index, end: end) else { return nil } + index += 4 + + if high >= 0xD800, high <= 0xDBFF, + index + 2 < end, scalars[index + 1] == "\\", scalars[index + 2] == "u", + let low = hexValue(scalars, uAt: index + 2, end: end), low >= 0xDC00, low <= 0xDFFF { + index += 6 + let combined = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00) + return Unicode.Scalar(combined) + } + + return Unicode.Scalar(high) + } + + private static func hexValue(_ scalars: [Unicode.Scalar], uAt index: Int, end: Int) -> Int? { + guard index + 4 < end else { return nil } + var value = 0 + for offset in 1...4 { + guard let digit = hexDigit(scalars[index + offset]) else { return nil } + value = value * 16 + digit + } + return value + } + + private static func hexDigit(_ scalar: Unicode.Scalar) -> Int? { + let value = scalar.value + if value >= 0x30, value <= 0x39 { return Int(value - 0x30) } + if value >= 0x61, value <= 0x66 { return Int(value - 0x61 + 10) } + if value >= 0x41, value <= 0x46 { return Int(value - 0x41 + 10) } + return nil + } + + private struct Parser { + let scalars: [Unicode.Scalar] + var index = 0 + + var isAtEnd: Bool { index >= scalars.count } + + mutating func skipWhitespace() { + while index < scalars.count { + switch scalars[index] { + case " ", "\t", "\n", "\r": index += 1 + default: return + } + } + } + + mutating func parseValue() -> JsonSyntaxNode? { + guard index < scalars.count else { return nil } + switch scalars[index] { + case "{": return parseObject() + case "[": return parseArray() + case "\"": return parseString().map { .string($0) } + case "t", "f", "n": return parseLiteral() + default: return parseNumber() + } + } + + mutating func parseObject() -> JsonSyntaxNode? { + index += 1 + var pairs: [(key: String, value: JsonSyntaxNode)] = [] + skipWhitespace() + if index < scalars.count, scalars[index] == "}" { + index += 1 + return .object(pairs) + } + while true { + skipWhitespace() + guard index < scalars.count, scalars[index] == "\"", let key = parseString() else { return nil } + skipWhitespace() + guard index < scalars.count, scalars[index] == ":" else { return nil } + index += 1 + skipWhitespace() + guard let value = parseValue() else { return nil } + pairs.append((key, value)) + skipWhitespace() + guard index < scalars.count else { return nil } + if scalars[index] == "," { + index += 1 + continue + } + if scalars[index] == "}" { + index += 1 + return .object(pairs) + } + return nil + } + } + + mutating func parseArray() -> JsonSyntaxNode? { + index += 1 + var elements: [JsonSyntaxNode] = [] + skipWhitespace() + if index < scalars.count, scalars[index] == "]" { + index += 1 + return .array(elements) + } + while true { + skipWhitespace() + guard let value = parseValue() else { return nil } + elements.append(value) + skipWhitespace() + guard index < scalars.count else { return nil } + if scalars[index] == "," { + index += 1 + continue + } + if scalars[index] == "]" { + index += 1 + return .array(elements) + } + return nil + } + } + + mutating func parseString() -> String? { + let start = index + index += 1 + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\\" { + index += 2 + continue + } + if scalar == "\"" { + index += 1 + return substring(from: start, to: index) + } + index += 1 + } + return nil + } + + mutating func parseNumber() -> JsonSyntaxNode? { + let start = index + if index < scalars.count, scalars[index] == "-" { index += 1 } + guard consumeDigits() else { return nil } + if index < scalars.count, scalars[index] == "." { + index += 1 + guard consumeDigits() else { return nil } + } + if index < scalars.count, scalars[index] == "e" || scalars[index] == "E" { + index += 1 + if index < scalars.count, scalars[index] == "+" || scalars[index] == "-" { index += 1 } + guard consumeDigits() else { return nil } + } + return .number(substring(from: start, to: index)) + } + + mutating func consumeDigits() -> Bool { + guard index < scalars.count, isDigit(scalars[index]) else { return false } + while index < scalars.count, isDigit(scalars[index]) { index += 1 } + return true + } + + mutating func parseLiteral() -> JsonSyntaxNode? { + for literal in ["true", "false", "null"] where matches(literal) { + index += literal.unicodeScalars.count + return .literal(literal) + } + return nil + } + + func matches(_ literal: String) -> Bool { + let scalarsToMatch = Array(literal.unicodeScalars) + guard index + scalarsToMatch.count <= scalars.count else { return false } + for (offset, scalar) in scalarsToMatch.enumerated() where scalars[index + offset] != scalar { + return false + } + return true + } + + func isDigit(_ scalar: Unicode.Scalar) -> Bool { + scalar >= "0" && scalar <= "9" + } + + func substring(from start: Int, to end: Int) -> String { + var view = String.UnicodeScalarView() + view.append(contentsOf: scalars[start.. String? { - guard let data = data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data), - let prettyData = try? JSONSerialization.data( - withJSONObject: jsonObject, - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ), - let prettyString = String(data: prettyData, encoding: .utf8) else { - return nil - } - return prettyString + guard !isEmpty else { return nil } + return JsonReindenter.reindentIfValid(self) } } diff --git a/TablePro/Models/UI/JSONTreeNode.swift b/TablePro/Models/UI/JSONTreeNode.swift index 5946aa5d3..552aa0038 100644 --- a/TablePro/Models/UI/JSONTreeNode.swift +++ b/TablePro/Models/UI/JSONTreeNode.swift @@ -62,97 +62,82 @@ internal enum JSONTreeParser { guard (jsonString as NSString).length <= maxInputLength else { return .failure(.tooLarge) } - guard let data = jsonString.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data) else { + guard let node = JsonSyntaxParser.parse(jsonString) else { return .failure(.invalidJSON) } var nodeCount = 0 - let root = buildNode(key: nil, keyPath: "$", value: jsonObject, nodeCount: &nodeCount) + let root = buildNode(key: nil, keyPath: "$", node: node, nodeCount: &nodeCount) return .success(root) } - private static func buildNode(key: String?, keyPath: String, value: Any, nodeCount: inout Int) -> JSONTreeNode { + private static func buildNode(key: String?, keyPath: String, node: JsonSyntaxNode, nodeCount: inout Int) -> JSONTreeNode { nodeCount += 1 - if let dict = value as? [String: Any] { - let sortedKeys = dict.keys.sorted() + switch node { + case .object(let pairs): var children: [JSONTreeNode] = [] - for k in sortedKeys { + for pair in pairs { guard nodeCount < maxNodes else { - children.append(truncationNode(remaining: dict.count - children.count)) + children.append(truncationNode(remaining: pairs.count - children.count)) break } - let childPath = keyPath + "." + k - if let childValue = dict[k] { - children.append(buildNode(key: k, keyPath: childPath, value: childValue, nodeCount: &nodeCount)) - } + let decodedKey = JsonSyntaxParser.decodeStringLiteral(pair.key) + let childPath = keyPath + "." + decodedKey + children.append(buildNode(key: decodedKey, keyPath: childPath, node: pair.value, nodeCount: &nodeCount)) } return JSONTreeNode( key: key, keyPath: keyPath, valueType: .object, - displayValue: "{\(dict.count) keys}", rawValue: nil, children: children + displayValue: "{\(pairs.count) keys}", rawValue: nil, children: children ) - } - if let arr = value as? [Any] { + case .array(let elements): var children: [JSONTreeNode] = [] - for (i, item) in arr.enumerated() { + for (index, element) in elements.enumerated() { guard nodeCount < maxNodes else { - children.append(truncationNode(remaining: arr.count - i)) + children.append(truncationNode(remaining: elements.count - index)) break } - let childPath = keyPath + "[\(i)]" - children.append(buildNode(key: "[\(i)]", keyPath: childPath, value: item, nodeCount: &nodeCount)) + let childPath = keyPath + "[\(index)]" + children.append(buildNode(key: "[\(index)]", keyPath: childPath, node: element, nodeCount: &nodeCount)) } return JSONTreeNode( key: key, keyPath: keyPath, valueType: .array, - displayValue: "[\(arr.count) items]", rawValue: nil, children: children + displayValue: "[\(elements.count) items]", rawValue: nil, children: children ) - } - if let str = value as? String { - let escaped = str.replacingOccurrences(of: "\"", with: "\\\"") + case .string(let raw): + let decoded = JsonSyntaxParser.decodeStringLiteral(raw) + let escaped = decoded.replacingOccurrences(of: "\"", with: "\\\"") let display: String let nsLen = (escaped as NSString).length if nsLen > 80 { - let truncated = (escaped as NSString).substring(to: 80) - display = "\"\(truncated)...\"" + display = "\"\((escaped as NSString).substring(to: 80))...\"" } else { display = "\"\(escaped)\"" } return JSONTreeNode( key: key, keyPath: keyPath, valueType: .string, - displayValue: display, rawValue: str, children: [] + displayValue: display, rawValue: decoded, children: [] ) - } - if let num = value as? NSNumber { - if CFBooleanGetTypeID() == CFGetTypeID(num) { - let boolVal = num.boolValue - return JSONTreeNode( - key: key, keyPath: keyPath, valueType: .boolean, - displayValue: boolVal ? "true" : "false", - rawValue: boolVal ? "true" : "false", children: [] - ) - } - let numStr = "\(num)" + case .number(let raw): return JSONTreeNode( key: key, keyPath: keyPath, valueType: .number, - displayValue: numStr, rawValue: numStr, children: [] + displayValue: raw, rawValue: raw, children: [] ) - } - if value is NSNull { + case .literal(let raw): + if raw == "null" { + return JSONTreeNode( + key: key, keyPath: keyPath, valueType: .null, + displayValue: "null", rawValue: nil, children: [] + ) + } return JSONTreeNode( - key: key, keyPath: keyPath, valueType: .null, - displayValue: "null", rawValue: nil, children: [] + key: key, keyPath: keyPath, valueType: .boolean, + displayValue: raw, rawValue: raw, children: [] ) } - - let fallback = "\(value)" - return JSONTreeNode( - key: key, keyPath: keyPath, valueType: .string, - displayValue: fallback, rawValue: fallback, children: [] - ) } private static func truncationNode(remaining: Int) -> JSONTreeNode { diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index b48f68f93..08caeedbf 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -17,6 +17,7 @@ struct FieldEditState: Identifiable { let columnName: String let columnTypeEnum: ColumnType let isLongText: Bool + let isJson: Bool var isPrimaryKey: Bool = false var isForeignKey: Bool = false @@ -129,11 +130,14 @@ final class MultiRowEditState { pendingValue = originalValue ?? "" } + let isJson = columnTypeEnum.isJsonType || (originalValue ?? "").looksLikeJson + var newField = FieldEditState( columnIndex: colIndex, columnName: columnName, columnTypeEnum: columnTypeEnum, isLongText: isLongText, + isJson: isJson, isPrimaryKey: primaryKeyColumns.contains(columnName), isForeignKey: foreignKeyColumns.contains(columnName), originalValue: originalValue, @@ -156,16 +160,27 @@ final class MultiRowEditState { guard index < fields.count else { return } let hadPendingEdit = fields[index].hasEdit let original = fields[index].originalValue - if value == original || (original == nil && value == "") { - fields[index].pendingValue = nil - } else { - fields[index].pendingValue = value - } + let pending = Self.resolvePendingValue(value, original: original, isJson: fields[index].isJson) + fields[index].pendingValue = pending fields[index].isPendingNull = false fields[index].isPendingDefault = false - if fields[index].pendingValue != nil || hadPendingEdit { - onFieldChanged?(index, PluginCellValue.fromOptional(value)) + if pending != nil || hadPendingEdit { + onFieldChanged?(index, PluginCellValue.fromOptional(pending ?? original)) + } + } + + private static func resolvePendingValue(_ value: String?, original: String?, isJson: Bool) -> String? { + if isJson, let value, !value.isEmpty { + let normalized = JsonReindenter.normalize(value) + if let original, JsonReindenter.normalize(original) == normalized { + return nil + } + return normalized + } + if value == original || (original == nil && value == "") { + return nil } + return value } func setFieldToBytes(at index: Int, data: Data) { diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index cbdac1fb4..261f93450 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -26,7 +26,7 @@ struct JSONEditorContentView: View { self.onCommit = onCommit self.onDismiss = onDismiss self.onPopOut = onPopOut - self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + self._text = State(initialValue: initialValue ?? "") } var body: some View { @@ -36,9 +36,7 @@ struct JSONEditorContentView: View { onDismiss: onDismiss, onCommit: { newValue in if newValue.isEmpty && initialValue == nil { return } - let normalizedNew = JSONViewerView.compact(newValue) - let normalizedOld = JSONViewerView.compact(initialValue) - if normalizedNew != normalizedOld { + if newValue != JsonReindenter.normalize(initialValue ?? "") { onCommit(newValue) } }, diff --git a/TablePro/Views/Results/JSONSyntaxTextView.swift b/TablePro/Views/Results/JSONSyntaxTextView.swift index f232241c6..1354eac94 100644 --- a/TablePro/Views/Results/JSONSyntaxTextView.swift +++ b/TablePro/Views/Results/JSONSyntaxTextView.swift @@ -26,6 +26,8 @@ internal struct JSONSyntaxTextView: NSViewRepresentable { textView.isEditable = isEditable textView.isSelectable = true + textView.isRichText = false + textView.usesFindBar = true textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) textView.textContainerInset = NSSize(width: 4, height: 4) textView.backgroundColor = .textBackgroundColor diff --git a/TablePro/Views/Results/JSONViewerContentView.swift b/TablePro/Views/Results/JSONViewerContentView.swift index 428f384ca..11e126c2f 100644 --- a/TablePro/Views/Results/JSONViewerContentView.swift +++ b/TablePro/Views/Results/JSONViewerContentView.swift @@ -23,7 +23,7 @@ struct JSONViewerContentView: View { self.columnName = columnName self.onDismiss = onDismiss self.onPopOut = onPopOut - self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + self._text = State(initialValue: initialValue ?? "") } var body: some View { diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index 5c336307e..7521595a5 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -16,7 +16,7 @@ internal struct JSONViewerView: View { @State private var treeSearchText = "" @State private var parsedTree: JSONTreeNode? @State private var parseError: JSONTreeParseError? - @State private var prettyText = "" + @State private var displayText = "" @State private var showInvalidAlert = false init( @@ -34,6 +34,10 @@ internal struct JSONViewerView: View { self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } + private var isLiveBinding: Bool { + isEditable && onCommit == nil + } + var body: some View { VStack(spacing: 0) { viewerToolbar @@ -45,12 +49,13 @@ internal struct JSONViewerView: View { } } .onAppear { initializeView() } - .onChange(of: text) { reparseIfNeeded() } + .onChange(of: text) { syncFromExternal() } + .onChange(of: displayText) { handleDisplayTextChange() } .onChange(of: viewMode) { AppSettingsManager.shared.editor.jsonViewerPreferredMode = viewMode } .alert("Invalid JSON", isPresented: $showInvalidAlert) { - Button(String(localized: "Save Anyway")) { commitAndClose(text) } + Button(String(localized: "Save Anyway")) { commitAndClose(displayText) } Button(String(localized: "Cancel"), role: .cancel) { } } message: { Text("The text is not valid JSON. Save anyway?") @@ -69,23 +74,12 @@ internal struct JSONViewerView: View { .fixedSize() Spacer() if let onPopOut { - Button { onPopOut(text) } label: { + Button { onPopOut(displayText) } label: { Image(systemName: "arrow.up.forward.app") } .buttonStyle(.borderless) .help(String(localized: "Open in Window")) } - if viewMode == .text && isEditable { - Button { - if let formatted = text.prettyPrintedAsJson() { - text = formatted - } - } label: { - Image(systemName: "curlybraces") - } - .buttonStyle(.borderless) - .help(String(localized: "Format JSON")) - } } .padding(.horizontal, 10) .padding(.vertical, 6) @@ -97,11 +91,7 @@ internal struct JSONViewerView: View { private var viewerContent: some View { switch viewMode { case .text: - JSONSyntaxTextView( - text: isEditable ? $text : $prettyText, - isEditable: isEditable, - wordWrap: true - ) + JSONSyntaxTextView(text: $displayText, isEditable: isEditable, wordWrap: true) case .tree: if let tree = parsedTree { JSONTreeView(rootNode: tree, searchText: $treeSearchText) @@ -147,19 +137,24 @@ internal struct JSONViewerView: View { // MARK: - Logic private func initializeView() { - prettyText = text.prettyPrintedAsJson() ?? text + displayText = JsonReindenter.reindent(text) parseTree() } - private func reparseIfNeeded() { - if !isEditable { - prettyText = text.prettyPrintedAsJson() ?? text - } + private func syncFromExternal() { + guard JsonReindenter.normalize(text) != JsonReindenter.normalize(displayText) else { return } + displayText = JsonReindenter.reindent(text) + } + + private func handleDisplayTextChange() { parseTree() + guard isLiveBinding, + JsonReindenter.normalize(displayText) != JsonReindenter.normalize(text) else { return } + text = displayText } private func parseTree() { - switch JSONTreeParser.parse(text) { + switch JSONTreeParser.parse(displayText) { case .success(let tree): parsedTree = tree parseError = nil @@ -170,34 +165,19 @@ internal struct JSONViewerView: View { } private func saveJSON() { - guard !text.isEmpty else { - commitAndClose(text) + guard !displayText.isEmpty else { + commitAndClose("") return } - guard let data = text.data(using: .utf8), - (try? JSONSerialization.jsonObject(with: data)) != nil else { + guard JsonSyntaxParser.parse(displayText) != nil else { showInvalidAlert = true return } - commitAndClose(text) + commitAndClose(displayText) } private func commitAndClose(_ value: String) { - let saveValue = Self.compact(value) ?? value - onCommit?(saveValue) + onCommit?(JsonReindenter.normalize(value)) onDismiss?() } - - static func compact(_ jsonString: String?) -> String? { - guard let data = jsonString?.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data), - let compactData = try? JSONSerialization.data( - withJSONObject: jsonObject, - options: [.sortedKeys, .withoutEscapingSlashes] - ), - let compactString = String(data: compactData, encoding: .utf8) else { - return nil - } - return compactString - } } diff --git a/TablePro/Views/Results/JSONViewerWindowController.swift b/TablePro/Views/Results/JSONViewerWindowController.swift index 04ccf5c70..1e52d4ed1 100644 --- a/TablePro/Views/Results/JSONViewerWindowController.swift +++ b/TablePro/Views/Results/JSONViewerWindowController.swift @@ -96,7 +96,7 @@ private struct JSONViewerWindowContent: View { self.isEditable = isEditable self.onCommit = onCommit self.onDismiss = onDismiss - self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + self._text = State(initialValue: initialValue ?? "") } var body: some View { @@ -106,9 +106,7 @@ private struct JSONViewerWindowContent: View { onDismiss: onDismiss, onCommit: isEditable ? { newValue in if newValue.isEmpty && initialValue == nil { return } - let normalizedNew = JSONViewerView.compact(newValue) - let normalizedOld = JSONViewerView.compact(initialValue) - if normalizedNew != normalizedOld { + if newValue != JsonReindenter.normalize(initialValue ?? "") { onCommit?(newValue) } } : nil diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index fa8e08b05..0851372a3 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -10,15 +10,17 @@ internal struct JsonEditorView: View { var onExpand: (() -> Void)? var onPopOut: ((String) -> Void)? + @State private var displayText = "" + var body: some View { - JSONSyntaxTextView(text: context.value, isEditable: !context.isReadOnly, wordWrap: true) + JSONSyntaxTextView(text: $displayText, isEditable: !context.isReadOnly, wordWrap: true) .frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 120) .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .overlay(alignment: .bottomTrailing) { HStack(spacing: 2) { if let onPopOut { - Button { onPopOut(context.value.wrappedValue) } label: { + Button { onPopOut(displayText) } label: { Image(systemName: "arrow.up.forward.app") .font(.caption2) .padding(4) @@ -40,5 +42,19 @@ internal struct JsonEditorView: View { } .padding(4) } + .onAppear { displayText = JsonReindenter.reindent(context.value.wrappedValue) } + .onChange(of: displayText) { propagateEdit() } + .onChange(of: context.value.wrappedValue) { syncFromBinding() } + } + + private func propagateEdit() { + guard !context.isReadOnly, + JsonReindenter.normalize(displayText) != JsonReindenter.normalize(context.value.wrappedValue) else { return } + context.value.wrappedValue = displayText + } + + private func syncFromBinding() { + guard JsonReindenter.normalize(context.value.wrappedValue) != JsonReindenter.normalize(displayText) else { return } + displayText = JsonReindenter.reindent(context.value.wrappedValue) } } diff --git a/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift b/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift new file mode 100644 index 000000000..eb14a6794 --- /dev/null +++ b/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift @@ -0,0 +1,135 @@ +// +// JsonReindenterTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("JsonReindenter") +struct JsonReindenterTests { + @Test("Reindent preserves original key order") + func reindentPreservesKeyOrder() throws { + let input = "{\"z\":1,\"a\":2,\"m\":3}" + let result = JsonReindenter.reindent(input) + let zRange = try #require(result.range(of: "\"z\"")) + let aRange = try #require(result.range(of: "\"a\"")) + let mRange = try #require(result.range(of: "\"m\"")) + #expect(zRange.lowerBound < aRange.lowerBound) + #expect(aRange.lowerBound < mRange.lowerBound) + } + + @Test("Reindent preserves large integer precision") + func reindentPreservesLargeInteger() { + let input = "{\"id\":9007199254740993}" + let result = JsonReindenter.reindent(input) + #expect(result.contains("9007199254740993")) + } + + @Test("Reindent preserves high-precision decimal token") + func reindentPreservesDecimalToken() { + let input = "{\"value\":3.141592653589793238}" + let result = JsonReindenter.reindent(input) + #expect(result.contains("3.141592653589793238")) + } + + @Test("Reindent handles top-level primitives") + func reindentTopLevelPrimitives() { + #expect(JsonReindenter.reindent("\"hello\"") == "\"hello\"") + #expect(JsonReindenter.reindent("42") == "42") + #expect(JsonReindenter.reindent("true") == "true") + #expect(JsonReindenter.reindent("null") == "null") + #expect(JsonReindenter.reindent(" -3.5e10 ") == "-3.5e10") + } + + @Test("Reindent formats top-level array") + func reindentTopLevelArray() { + let expected = """ + [ + 1, + 2, + 3 + ] + """ + #expect(JsonReindenter.reindent("[1,2,3]") == expected) + } + + @Test("Reindent formats empty containers inline") + func reindentEmptyContainers() { + #expect(JsonReindenter.reindent("{}") == "{}") + #expect(JsonReindenter.reindent("[]") == "[]") + #expect(JsonReindenter.reindent("{\"a\":{},\"b\":[]}") == "{\n \"a\": {},\n \"b\": []\n}") + } + + @Test("Reindent passes invalid JSON through unchanged") + func reindentInvalidPassthrough() { + let input = "not json {" + #expect(JsonReindenter.reindent(input) == input) + #expect(JsonReindenter.reindentIfValid(input) == nil) + } + + @Test("Reindent rejects trailing garbage") + func reindentTrailingGarbage() { + #expect(JsonReindenter.reindentIfValid("{\"a\":1} extra") == nil) + } + + @Test("Reindent is idempotent") + func reindentIdempotent() { + let input = "{\"a\":[1,{\"b\":2}],\"c\":\"x\"}" + let once = JsonReindenter.reindent(input) + let twice = JsonReindenter.reindent(once) + #expect(once == twice) + } + + @Test("Reindent preserves escaped string contents byte for byte") + func reindentPreservesEscapes() { + let input = "{\"path\":\"a\\/b\",\"emoji\":\"\\uD83D\\uDE00\"}" + let result = JsonReindenter.reindent(input) + #expect(result.contains("\"a\\/b\"")) + #expect(result.contains("\"\\uD83D\\uDE00\"")) + } + + @Test("Reindent does not split braces inside strings") + func reindentBracesInStrings() { + let input = "{\"text\":\"{not:structure}\"}" + let result = JsonReindenter.reindent(input) + #expect(result == "{\n \"text\": \"{not:structure}\"\n}") + } + + @Test("Normalize strips insignificant whitespace") + func normalizeStripsWhitespace() { + let input = "{\n \"a\": 1,\n \"b\": [ 2, 3 ]\n}" + #expect(JsonReindenter.normalize(input) == "{\"a\":1,\"b\":[2,3]}") + } + + @Test("Normalize is equal across pretty and compact forms") + func normalizeEquivalence() { + let compact = "{\"a\":1,\"b\":2}" + let pretty = JsonReindenter.reindent(compact) + #expect(JsonReindenter.normalize(compact) == JsonReindenter.normalize(pretty)) + } + + @Test("Normalize preserves key order and large integers") + func normalizePreservesOrderAndPrecision() { + let input = "{ \"z\": 9007199254740993, \"a\": 2 }" + #expect(JsonReindenter.normalize(input) == "{\"z\":9007199254740993,\"a\":2}") + } + + @Test("Oversized input is returned unchanged") + func sizeCap() { + let big = "{\"a\":\"" + String(repeating: "x", count: 500_001) + "\"}" + #expect(JsonReindenter.reindentIfValid(big) == nil) + #expect(JsonReindenter.reindent(big) == big) + #expect(JsonReindenter.normalize(big) == big) + } + + @Test("decodeStringLiteral decodes escapes and surrogate pairs") + func decodeStringLiteral() { + #expect(JsonSyntaxParser.decodeStringLiteral("\"a\\/b\"") == "a/b") + #expect(JsonSyntaxParser.decodeStringLiteral("\"line\\nbreak\"") == "line\nbreak") + #expect(JsonSyntaxParser.decodeStringLiteral("\"\\u0041\"") == "A") + #expect(JsonSyntaxParser.decodeStringLiteral("\"\\uD83D\\uDE00\"") == "😀") + } +} diff --git a/TableProTests/Extensions/StringJsonTests.swift b/TableProTests/Extensions/StringJsonTests.swift index bf6afb87a..4220f25f5 100644 --- a/TableProTests/Extensions/StringJsonTests.swift +++ b/TableProTests/Extensions/StringJsonTests.swift @@ -13,27 +13,20 @@ import Testing @Suite("String+JSON") struct StringJsonTests { - @Test("Valid JSON object is pretty-printed with sorted keys") - func validJsonObject() { + @Test("Valid JSON object is pretty-printed preserving key order") + func validJsonObject() throws { let input = "{\"name\":\"Alice\",\"age\":30}" - let result = input.prettyPrintedAsJson() + let result = try #require(input.prettyPrintedAsJson()) - #expect(result != nil) - #expect(result!.contains("\n")) - let ageRange = result!.range(of: "age")! - let nameRange = result!.range(of: "name")! - #expect(ageRange.lowerBound < nameRange.lowerBound) + #expect(result.contains("\n")) + let nameRange = try #require(result.range(of: "name")) + let ageRange = try #require(result.range(of: "age")) + #expect(nameRange.lowerBound < ageRange.lowerBound) } @Test("Valid JSON array is pretty-printed") - func validJsonArray() { - let input = "[1,2,3]" - let result = input.prettyPrintedAsJson() - - #expect(result != nil) - #expect(result!.contains("\n")) - #expect(result!.contains("[")) - #expect(result!.contains("]")) + func validJsonArray() throws { + let result = try #require("[1,2,3]".prettyPrintedAsJson()) let expected = """ [ 1, @@ -61,16 +54,15 @@ struct StringJsonTests { } @Test("Nested objects are correctly indented") - func nestedObjects() { + func nestedObjects() throws { let input = "{\"user\":{\"address\":{\"city\":\"Hanoi\"}}}" - let result = input.prettyPrintedAsJson() + let result = try #require(input.prettyPrintedAsJson()) - #expect(result != nil) let expected = """ { - "user" : { - "address" : { - "city" : "Hanoi" + "user": { + "address": { + "city": "Hanoi" } } } @@ -78,13 +70,12 @@ struct StringJsonTests { #expect(result == expected) } - @Test("URLs are not escaped due to withoutEscapingSlashes") - func urlsNotEscaped() { + @Test("Slashes are preserved as written") + func slashesPreserved() throws { let input = "{\"url\":\"https://example.com/path/to/resource\"}" - let result = input.prettyPrintedAsJson() + let result = try #require(input.prettyPrintedAsJson()) - #expect(result != nil) - #expect(result!.contains("https://example.com/path/to/resource")) - #expect(!result!.contains("\\/")) + #expect(result.contains("https://example.com/path/to/resource")) + #expect(!result.contains("\\/")) } } diff --git a/TableProTests/Models/UI/MultiRowEditStateJsonTests.swift b/TableProTests/Models/UI/MultiRowEditStateJsonTests.swift new file mode 100644 index 000000000..e3abb26fd --- /dev/null +++ b/TableProTests/Models/UI/MultiRowEditStateJsonTests.swift @@ -0,0 +1,85 @@ +// +// MultiRowEditStateJsonTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("MultiRowEditState JSON change detection") +@MainActor +struct MultiRowEditStateJsonTests { + private func makeState(value: String, type: ColumnType, column: String = "data") -> MultiRowEditState { + let state = MultiRowEditState() + state.configure( + selectedRowIndices: [0], + allRows: [[value]], + columns: [column], + columnTypes: [type] + ) + return state + } + + @Test("Reformatting a JSONB field does not mark it changed") + func jsonReformatNotDirty() { + let original = "{\"b\":1,\"a\":2}" + let state = makeState(value: original, type: .json(rawType: "jsonb")) + state.updateField(at: 0, value: JsonReindenter.reindent(original)) + #expect(state.fields[0].hasEdit == false) + #expect(state.hasEdits == false) + } + + @Test("Reordering keys is treated as a real change") + func jsonKeyReorderIsChange() { + let state = makeState(value: "{\"a\":1,\"b\":2}", type: .json(rawType: "jsonb")) + state.updateField(at: 0, value: "{\"b\":2,\"a\":1}") + #expect(state.fields[0].hasEdit == true) + } + + @Test("A real JSON value change stores the normalized form") + func jsonContentChangeStoresNormalized() { + let state = makeState(value: "{\"a\":1}", type: .json(rawType: "jsonb")) + state.updateField(at: 0, value: "{\n \"a\": 2\n}") + #expect(state.fields[0].hasEdit == true) + #expect(state.fields[0].pendingValue == "{\"a\":2}") + } + + @Test("Editing back to the original content clears the pending change") + func jsonRevertClearsChange() { + let original = "{\"a\":1}" + let state = makeState(value: original, type: .json(rawType: "jsonb")) + state.updateField(at: 0, value: "{\"a\":2}") + #expect(state.fields[0].hasEdit == true) + state.updateField(at: 0, value: JsonReindenter.reindent(original)) + #expect(state.fields[0].hasEdit == false) + } + + @Test("Large integers survive a JSONB edit without precision loss") + func jsonLargeIntegerPreserved() { + let state = makeState(value: "{\"id\":1}", type: .json(rawType: "jsonb")) + state.updateField(at: 0, value: "{\"id\":9007199254740993}") + #expect(state.fields[0].pendingValue == "{\"id\":9007199254740993}") + } + + @Test("A text column holding JSON is compared semantically") + func textColumnJsonSemantics() { + let original = "{\"a\":1,\"b\":2}" + let state = makeState(value: original, type: .text(rawType: "text"), column: "payload") + #expect(state.fields[0].isJson == true) + state.updateField(at: 0, value: JsonReindenter.reindent(original)) + #expect(state.fields[0].hasEdit == false) + } + + @Test("Non-JSON fields use exact string comparison") + func nonJsonExactComparison() { + let state = makeState(value: "hello", type: .text(rawType: "varchar"), column: "name") + #expect(state.fields[0].isJson == false) + state.updateField(at: 0, value: "hello ") + #expect(state.fields[0].hasEdit == true) + state.updateField(at: 0, value: "hello") + #expect(state.fields[0].hasEdit == false) + } +} diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index cd286c6fd..b08995c6c 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -18,9 +18,11 @@ The viewer opens as a popover anchored to the cell. Switch with the segmented control in the viewer toolbar. -- **Text**: syntax-highlighted JSON. Editable when the cell is editable. Use the `{}` button to format (pretty-print). +- **Text**: syntax-highlighted JSON, pretty-printed by default. Editable when the cell is editable. Press `Cmd+F` to find within the value. - **Tree**: collapsible tree with a search field. Read-only navigation. +Both modes keep your original key order and exact number values, including integers larger than JavaScript can represent. Formatting is display only: it never reorders keys or changes the stored value. + The default mode is set in Settings (`Cmd+,`) > Editor > **JSON Viewer** > **Default view**. If the document is too large to render as a tree, the tree mode shows an unavailability message and you stay in Text mode. @@ -31,7 +33,9 @@ Click the pop-out button in the toolbar to detach the viewer into its own resiza ## Editing -In Text mode, edits are live in the binding. Click **Save** to commit the change through the standard change-tracking flow: the edit becomes a pending change in the data grid, previewed and applied via Save Changes. Click **Cancel** to drop the edit. +Opening a value and viewing it pretty-printed is not an edit. The row is marked changed only when you alter the content. Reindented whitespace and a different layout on screen do not count, so browsing JSONB row by row stays clean. + +In the pop-out and cell editors, click **Save** to commit through the standard change-tracking flow: the edit becomes a pending change in the data grid, previewed and applied via Save Changes. Click **Cancel** to drop the edit. The saved value is compact, with your key order and numbers preserved. If the edited text is not valid JSON when you save, the viewer prompts for confirmation before committing. From 878fee68f057dd33519dfc3cbfc0c173018c81ad Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 12:11:26 +0700 Subject: [PATCH 2/7] fix(datagrid): keep JSON viewer popover on screen and restore syntax font --- TablePro/Views/Results/Extensions/DataGridView+Popovers.swift | 4 ++-- TablePro/Views/Results/JSONSyntaxTextView.swift | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 5a7ff8605..a6fd445b6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -128,7 +128,7 @@ extension TableViewCoordinator { PopoverPresenter.show( relativeTo: cellRect, of: tableView, - contentSize: nil + contentSize: NSSize(width: 560, height: 420) ) { [weak self] dismiss in JSONEditorContentView( initialValue: currentValue, @@ -328,7 +328,7 @@ extension TableViewCoordinator { PopoverPresenter.show( relativeTo: cellRect, of: tableView, - contentSize: nil + contentSize: NSSize(width: 560, height: 360) ) { dismiss in JSONViewerContentView( initialValue: currentValue, diff --git a/TablePro/Views/Results/JSONSyntaxTextView.swift b/TablePro/Views/Results/JSONSyntaxTextView.swift index 1354eac94..f232241c6 100644 --- a/TablePro/Views/Results/JSONSyntaxTextView.swift +++ b/TablePro/Views/Results/JSONSyntaxTextView.swift @@ -26,8 +26,6 @@ internal struct JSONSyntaxTextView: NSViewRepresentable { textView.isEditable = isEditable textView.isSelectable = true - textView.isRichText = false - textView.usesFindBar = true textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) textView.textContainerInset = NSSize(width: 4, height: 4) textView.backgroundColor = .textBackgroundColor From c84d67ee1f156a224759a9cf86d800d30a86a5ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 12:43:39 +0700 Subject: [PATCH 3/7] refactor(datagrid): render JSON with shared tree-sitter editor; vendor tree-sitter-json grammar --- .../CodeLanguage+Definitions.swift | 10 + .../CodeEditLanguages/CodeLanguage.swift | 4 +- .../Resources/tree-sitter-json/highlights.scm | 16 + .../TreeSitterLanguage.swift | 1 + .../CodeEditLanguages/TreeSitterModel.swift | 7 + .../include/TreeSitterGrammars.h | 1 + .../Sources/TreeSitterGrammars/json/parser.c | 1065 +++++++++++++++++ .../Results/JSONBraceMatchingHelper.swift | 178 --- TablePro/Views/Results/JSONCodeEditor.swift | 60 + .../Views/Results/JSONHighlightPatterns.swift | 23 - .../Views/Results/JSONSyntaxTextView.swift | 225 ---- TablePro/Views/Results/JSONViewerView.swift | 2 +- TablePro/Views/Results/ResultsJsonView.swift | 6 +- .../FieldEditors/JsonEditorView.swift | 2 +- .../Views/Components/HighlightCapTests.swift | 165 +-- .../Results/JSONEditorHighlightTests.swift | 122 -- 16 files changed, 1169 insertions(+), 718 deletions(-) create mode 100644 LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/Resources/tree-sitter-json/highlights.scm create mode 100644 LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/json/parser.c delete mode 100644 TablePro/Views/Results/JSONBraceMatchingHelper.swift create mode 100644 TablePro/Views/Results/JSONCodeEditor.swift delete mode 100644 TablePro/Views/Results/JSONHighlightPatterns.swift delete mode 100644 TablePro/Views/Results/JSONSyntaxTextView.swift delete mode 100644 TableProTests/Views/Results/JSONEditorHighlightTests.swift diff --git a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage+Definitions.swift b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage+Definitions.swift index 5124aea0b..4ba1d2566 100644 --- a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage+Definitions.swift +++ b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage+Definitions.swift @@ -13,6 +13,7 @@ public extension CodeLanguage { static let allLanguages: [CodeLanguage] = [ .bash, .javascript, + .json, .jsx, .sql ] @@ -67,6 +68,15 @@ public extension CodeLanguage { highlights: ["highlights-jsx", "injections"] ) + /// A language structure for `JSON` + static let json: CodeLanguage = .init( + id: .json, + tsName: "json", + extensions: ["json"], + lineCommentString: "", + rangeCommentStrings: ("", "") + ) + /// A language structure for `SQL` static let sql: CodeLanguage = .init( id: .sql, diff --git a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage.swift b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage.swift index 89648afdb..f3003752f 100644 --- a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage.swift +++ b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/CodeLanguage.swift @@ -82,13 +82,15 @@ public struct CodeLanguage { .appendingPathComponent("Resources/tree-sitter-\(tsName)/\(highlights).scm") } - /// Gets the TSLanguage from `tree-sitter` — only SQL, Bash, and JavaScript are supported + /// Gets the TSLanguage from `tree-sitter`. Only SQL, Bash, JavaScript, and JSON are supported private var tsLanguage: OpaquePointer? { switch id { case .bash: return tree_sitter_bash() case .javascript, .jsx: return tree_sitter_javascript() + case .json: + return tree_sitter_json() case .sql: return tree_sitter_sql() default: diff --git a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/Resources/tree-sitter-json/highlights.scm b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/Resources/tree-sitter-json/highlights.scm new file mode 100644 index 000000000..ece8392f0 --- /dev/null +++ b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/Resources/tree-sitter-json/highlights.scm @@ -0,0 +1,16 @@ +(pair + key: (_) @string.special.key) + +(string) @string + +(number) @number + +[ + (null) + (true) + (false) +] @constant.builtin + +(escape_sequence) @escape + +(comment) @comment diff --git a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterLanguage.swift b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterLanguage.swift index 7679f9fd3..c6cc84960 100644 --- a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterLanguage.swift +++ b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterLanguage.swift @@ -13,6 +13,7 @@ public enum TreeSitterLanguage: String { case html case javascript case jsdoc + case json case jsx case python case ruby diff --git a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterModel.swift b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterModel.swift index 4148ac5c1..9d3f14fb4 100644 --- a/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterModel.swift +++ b/LocalPackages/CodeEditLanguages/Sources/CodeEditLanguages/TreeSitterModel.swift @@ -25,6 +25,8 @@ public class TreeSitterModel { return javascriptQuery case .jsx: return jsxQuery + case .json: + return jsonQuery case .sql: return sqlQuery default: @@ -47,6 +49,11 @@ public class TreeSitterModel { return queryFor(.jsx) }() + /// Query for `JSON` files. + public private(set) lazy var jsonQuery: Query? = { + return queryFor(.json) + }() + /// Query for `SQL` files. public private(set) lazy var sqlQuery: Query? = { return queryFor(.sql) diff --git a/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/include/TreeSitterGrammars.h b/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/include/TreeSitterGrammars.h index f553a99e4..82ee82347 100644 --- a/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/include/TreeSitterGrammars.h +++ b/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/include/TreeSitterGrammars.h @@ -6,5 +6,6 @@ typedef struct TSLanguage TSLanguage; const TSLanguage *tree_sitter_sql(void); const TSLanguage *tree_sitter_bash(void); const TSLanguage *tree_sitter_javascript(void); +const TSLanguage *tree_sitter_json(void); #endif /* TreeSitterGrammars_h */ diff --git a/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/json/parser.c b/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/json/parser.c new file mode 100644 index 000000000..5c0ccc772 --- /dev/null +++ b/LocalPackages/CodeEditLanguages/Sources/TreeSitterGrammars/json/parser.c @@ -0,0 +1,1065 @@ +/* Automatically @generated by tree-sitter v0.25.8 */ + +#include "tree_sitter/parser.h" + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic ignored "-Wmissing-field-initializers" +#endif + +#define LANGUAGE_VERSION 14 +#define STATE_COUNT 32 +#define LARGE_STATE_COUNT 7 +#define SYMBOL_COUNT 25 +#define ALIAS_COUNT 0 +#define TOKEN_COUNT 15 +#define EXTERNAL_TOKEN_COUNT 0 +#define FIELD_COUNT 2 +#define MAX_ALIAS_SEQUENCE_LENGTH 4 +#define MAX_RESERVED_WORD_SET_SIZE 0 +#define PRODUCTION_ID_COUNT 2 +#define SUPERTYPE_COUNT 0 + +enum ts_symbol_identifiers { + anon_sym_LBRACE = 1, + anon_sym_COMMA = 2, + anon_sym_RBRACE = 3, + anon_sym_COLON = 4, + anon_sym_LBRACK = 5, + anon_sym_RBRACK = 6, + anon_sym_DQUOTE = 7, + sym_string_content = 8, + sym_escape_sequence = 9, + sym_number = 10, + sym_true = 11, + sym_false = 12, + sym_null = 13, + sym_comment = 14, + sym_document = 15, + sym__value = 16, + sym_object = 17, + sym_pair = 18, + sym_array = 19, + sym_string = 20, + aux_sym__string_content = 21, + aux_sym_document_repeat1 = 22, + aux_sym_object_repeat1 = 23, + aux_sym_array_repeat1 = 24, +}; + +static const char * const ts_symbol_names[] = { + [ts_builtin_sym_end] = "end", + [anon_sym_LBRACE] = "{", + [anon_sym_COMMA] = ",", + [anon_sym_RBRACE] = "}", + [anon_sym_COLON] = ":", + [anon_sym_LBRACK] = "[", + [anon_sym_RBRACK] = "]", + [anon_sym_DQUOTE] = "\"", + [sym_string_content] = "string_content", + [sym_escape_sequence] = "escape_sequence", + [sym_number] = "number", + [sym_true] = "true", + [sym_false] = "false", + [sym_null] = "null", + [sym_comment] = "comment", + [sym_document] = "document", + [sym__value] = "_value", + [sym_object] = "object", + [sym_pair] = "pair", + [sym_array] = "array", + [sym_string] = "string", + [aux_sym__string_content] = "_string_content", + [aux_sym_document_repeat1] = "document_repeat1", + [aux_sym_object_repeat1] = "object_repeat1", + [aux_sym_array_repeat1] = "array_repeat1", +}; + +static const TSSymbol ts_symbol_map[] = { + [ts_builtin_sym_end] = ts_builtin_sym_end, + [anon_sym_LBRACE] = anon_sym_LBRACE, + [anon_sym_COMMA] = anon_sym_COMMA, + [anon_sym_RBRACE] = anon_sym_RBRACE, + [anon_sym_COLON] = anon_sym_COLON, + [anon_sym_LBRACK] = anon_sym_LBRACK, + [anon_sym_RBRACK] = anon_sym_RBRACK, + [anon_sym_DQUOTE] = anon_sym_DQUOTE, + [sym_string_content] = sym_string_content, + [sym_escape_sequence] = sym_escape_sequence, + [sym_number] = sym_number, + [sym_true] = sym_true, + [sym_false] = sym_false, + [sym_null] = sym_null, + [sym_comment] = sym_comment, + [sym_document] = sym_document, + [sym__value] = sym__value, + [sym_object] = sym_object, + [sym_pair] = sym_pair, + [sym_array] = sym_array, + [sym_string] = sym_string, + [aux_sym__string_content] = aux_sym__string_content, + [aux_sym_document_repeat1] = aux_sym_document_repeat1, + [aux_sym_object_repeat1] = aux_sym_object_repeat1, + [aux_sym_array_repeat1] = aux_sym_array_repeat1, +}; + +static const TSSymbolMetadata ts_symbol_metadata[] = { + [ts_builtin_sym_end] = { + .visible = false, + .named = true, + }, + [anon_sym_LBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_COMMA] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACE] = { + .visible = true, + .named = false, + }, + [anon_sym_COLON] = { + .visible = true, + .named = false, + }, + [anon_sym_LBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_RBRACK] = { + .visible = true, + .named = false, + }, + [anon_sym_DQUOTE] = { + .visible = true, + .named = false, + }, + [sym_string_content] = { + .visible = true, + .named = true, + }, + [sym_escape_sequence] = { + .visible = true, + .named = true, + }, + [sym_number] = { + .visible = true, + .named = true, + }, + [sym_true] = { + .visible = true, + .named = true, + }, + [sym_false] = { + .visible = true, + .named = true, + }, + [sym_null] = { + .visible = true, + .named = true, + }, + [sym_comment] = { + .visible = true, + .named = true, + }, + [sym_document] = { + .visible = true, + .named = true, + }, + [sym__value] = { + .visible = false, + .named = true, + .supertype = true, + }, + [sym_object] = { + .visible = true, + .named = true, + }, + [sym_pair] = { + .visible = true, + .named = true, + }, + [sym_array] = { + .visible = true, + .named = true, + }, + [sym_string] = { + .visible = true, + .named = true, + }, + [aux_sym__string_content] = { + .visible = false, + .named = false, + }, + [aux_sym_document_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_object_repeat1] = { + .visible = false, + .named = false, + }, + [aux_sym_array_repeat1] = { + .visible = false, + .named = false, + }, +}; + +enum ts_field_identifiers { + field_key = 1, + field_value = 2, +}; + +static const char * const ts_field_names[] = { + [0] = NULL, + [field_key] = "key", + [field_value] = "value", +}; + +static const TSMapSlice ts_field_map_slices[PRODUCTION_ID_COUNT] = { + [1] = {.index = 0, .length = 2}, +}; + +static const TSFieldMapEntry ts_field_map_entries[] = { + [0] = + {field_key, 0}, + {field_value, 2}, +}; + +static const TSSymbol ts_alias_sequences[PRODUCTION_ID_COUNT][MAX_ALIAS_SEQUENCE_LENGTH] = { + [0] = {0}, +}; + +static const uint16_t ts_non_terminal_alias_map[] = { + 0, +}; + +static const TSStateId ts_primary_state_ids[STATE_COUNT] = { + [0] = 0, + [1] = 1, + [2] = 2, + [3] = 3, + [4] = 4, + [5] = 5, + [6] = 6, + [7] = 7, + [8] = 8, + [9] = 9, + [10] = 10, + [11] = 11, + [12] = 12, + [13] = 13, + [14] = 14, + [15] = 15, + [16] = 16, + [17] = 17, + [18] = 18, + [19] = 19, + [20] = 20, + [21] = 21, + [22] = 22, + [23] = 23, + [24] = 24, + [25] = 25, + [26] = 26, + [27] = 27, + [28] = 28, + [29] = 29, + [30] = 30, + [31] = 31, +}; + +static bool ts_lex(TSLexer *lexer, TSStateId state) { + START_LEXER(); + eof = lexer->eof(lexer); + switch (state) { + case 0: + if (eof) ADVANCE(21); + ADVANCE_MAP( + '"', 28, + ',', 23, + '-', 7, + '/', 3, + '0', 35, + ':', 25, + '[', 26, + '\\', 18, + ']', 27, + 'f', 8, + 'n', 17, + 't', 14, + '{', 22, + '}', 24, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(20); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(36); + END_STATE(); + case 1: + if (lookahead == '\n') SKIP(2); + if (lookahead == '"') ADVANCE(28); + if (lookahead == '/') ADVANCE(29); + if (lookahead == '\\') ADVANCE(18); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(32); + if (lookahead != 0) ADVANCE(33); + END_STATE(); + case 2: + if (lookahead == '"') ADVANCE(28); + if (lookahead == '/') ADVANCE(3); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(2); + END_STATE(); + case 3: + if (lookahead == '*') ADVANCE(5); + if (lookahead == '/') ADVANCE(43); + END_STATE(); + case 4: + if (lookahead == '*') ADVANCE(4); + if (lookahead == '/') ADVANCE(42); + if (lookahead != 0) ADVANCE(5); + END_STATE(); + case 5: + if (lookahead == '*') ADVANCE(4); + if (lookahead != 0) ADVANCE(5); + END_STATE(); + case 6: + if (lookahead == '-') ADVANCE(19); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); + END_STATE(); + case 7: + if (lookahead == '0') ADVANCE(35); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(36); + END_STATE(); + case 8: + if (lookahead == 'a') ADVANCE(11); + END_STATE(); + case 9: + if (lookahead == 'e') ADVANCE(39); + END_STATE(); + case 10: + if (lookahead == 'e') ADVANCE(40); + END_STATE(); + case 11: + if (lookahead == 'l') ADVANCE(15); + END_STATE(); + case 12: + if (lookahead == 'l') ADVANCE(41); + END_STATE(); + case 13: + if (lookahead == 'l') ADVANCE(12); + END_STATE(); + case 14: + if (lookahead == 'r') ADVANCE(16); + END_STATE(); + case 15: + if (lookahead == 's') ADVANCE(10); + END_STATE(); + case 16: + if (lookahead == 'u') ADVANCE(9); + END_STATE(); + case 17: + if (lookahead == 'u') ADVANCE(13); + END_STATE(); + case 18: + ADVANCE_MAP( + '"', 34, + '/', 34, + '\\', 34, + 'b', 34, + 'f', 34, + 'n', 34, + 'r', 34, + 't', 34, + 'u', 34, + ); + END_STATE(); + case 19: + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); + END_STATE(); + case 20: + if (eof) ADVANCE(21); + ADVANCE_MAP( + '"', 28, + ',', 23, + '-', 7, + '/', 3, + '0', 35, + ':', 25, + '[', 26, + ']', 27, + 'f', 8, + 'n', 17, + 't', 14, + '{', 22, + '}', 24, + ); + if (('\t' <= lookahead && lookahead <= '\r') || + lookahead == ' ') SKIP(20); + if (('1' <= lookahead && lookahead <= '9')) ADVANCE(36); + END_STATE(); + case 21: + ACCEPT_TOKEN(ts_builtin_sym_end); + END_STATE(); + case 22: + ACCEPT_TOKEN(anon_sym_LBRACE); + END_STATE(); + case 23: + ACCEPT_TOKEN(anon_sym_COMMA); + END_STATE(); + case 24: + ACCEPT_TOKEN(anon_sym_RBRACE); + END_STATE(); + case 25: + ACCEPT_TOKEN(anon_sym_COLON); + END_STATE(); + case 26: + ACCEPT_TOKEN(anon_sym_LBRACK); + END_STATE(); + case 27: + ACCEPT_TOKEN(anon_sym_RBRACK); + END_STATE(); + case 28: + ACCEPT_TOKEN(anon_sym_DQUOTE); + END_STATE(); + case 29: + ACCEPT_TOKEN(sym_string_content); + if (lookahead == '*') ADVANCE(31); + if (lookahead == '/') ADVANCE(33); + if (lookahead != 0 && + lookahead != '\n' && + lookahead != '"' && + lookahead != '\\') ADVANCE(33); + END_STATE(); + case 30: + ACCEPT_TOKEN(sym_string_content); + if (lookahead == '*') ADVANCE(30); + if (lookahead == '/') ADVANCE(33); + if (lookahead != 0 && + lookahead != '\n' && + lookahead != '"' && + lookahead != '\\') ADVANCE(31); + END_STATE(); + case 31: + ACCEPT_TOKEN(sym_string_content); + if (lookahead == '*') ADVANCE(30); + if (lookahead != 0 && + lookahead != '\n' && + lookahead != '"' && + lookahead != '\\') ADVANCE(31); + END_STATE(); + case 32: + ACCEPT_TOKEN(sym_string_content); + if (lookahead == '/') ADVANCE(29); + if (lookahead == '\t' || + (0x0b <= lookahead && lookahead <= '\r') || + lookahead == ' ') ADVANCE(32); + if (lookahead != 0 && + (lookahead < '\t' || '\r' < lookahead) && + lookahead != '"' && + lookahead != '\\') ADVANCE(33); + END_STATE(); + case 33: + ACCEPT_TOKEN(sym_string_content); + if (lookahead != 0 && + lookahead != '\n' && + lookahead != '"' && + lookahead != '\\') ADVANCE(33); + END_STATE(); + case 34: + ACCEPT_TOKEN(sym_escape_sequence); + END_STATE(); + case 35: + ACCEPT_TOKEN(sym_number); + if (lookahead == '.') ADVANCE(37); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(6); + END_STATE(); + case 36: + ACCEPT_TOKEN(sym_number); + if (lookahead == '.') ADVANCE(37); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(6); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(36); + END_STATE(); + case 37: + ACCEPT_TOKEN(sym_number); + if (lookahead == 'E' || + lookahead == 'e') ADVANCE(6); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(37); + END_STATE(); + case 38: + ACCEPT_TOKEN(sym_number); + if (('0' <= lookahead && lookahead <= '9')) ADVANCE(38); + END_STATE(); + case 39: + ACCEPT_TOKEN(sym_true); + END_STATE(); + case 40: + ACCEPT_TOKEN(sym_false); + END_STATE(); + case 41: + ACCEPT_TOKEN(sym_null); + END_STATE(); + case 42: + ACCEPT_TOKEN(sym_comment); + END_STATE(); + case 43: + ACCEPT_TOKEN(sym_comment); + if (lookahead != 0 && + lookahead != '\n') ADVANCE(43); + END_STATE(); + default: + return false; + } +} + +static const TSLexMode ts_lex_modes[STATE_COUNT] = { + [0] = {.lex_state = 0}, + [1] = {.lex_state = 0}, + [2] = {.lex_state = 0}, + [3] = {.lex_state = 0}, + [4] = {.lex_state = 0}, + [5] = {.lex_state = 0}, + [6] = {.lex_state = 0}, + [7] = {.lex_state = 0}, + [8] = {.lex_state = 0}, + [9] = {.lex_state = 0}, + [10] = {.lex_state = 0}, + [11] = {.lex_state = 0}, + [12] = {.lex_state = 0}, + [13] = {.lex_state = 0}, + [14] = {.lex_state = 0}, + [15] = {.lex_state = 0}, + [16] = {.lex_state = 0}, + [17] = {.lex_state = 1}, + [18] = {.lex_state = 1}, + [19] = {.lex_state = 1}, + [20] = {.lex_state = 0}, + [21] = {.lex_state = 0}, + [22] = {.lex_state = 0}, + [23] = {.lex_state = 0}, + [24] = {.lex_state = 0}, + [25] = {.lex_state = 0}, + [26] = {.lex_state = 0}, + [27] = {.lex_state = 0}, + [28] = {.lex_state = 0}, + [29] = {.lex_state = 0}, + [30] = {.lex_state = 0}, + [31] = {.lex_state = 0}, +}; + +static const uint16_t ts_parse_table[LARGE_STATE_COUNT][SYMBOL_COUNT] = { + [STATE(0)] = { + [ts_builtin_sym_end] = ACTIONS(1), + [anon_sym_LBRACE] = ACTIONS(1), + [anon_sym_COMMA] = ACTIONS(1), + [anon_sym_RBRACE] = ACTIONS(1), + [anon_sym_COLON] = ACTIONS(1), + [anon_sym_LBRACK] = ACTIONS(1), + [anon_sym_RBRACK] = ACTIONS(1), + [anon_sym_DQUOTE] = ACTIONS(1), + [sym_escape_sequence] = ACTIONS(1), + [sym_number] = ACTIONS(1), + [sym_true] = ACTIONS(1), + [sym_false] = ACTIONS(1), + [sym_null] = ACTIONS(1), + [sym_comment] = ACTIONS(3), + }, + [STATE(1)] = { + [sym_document] = STATE(30), + [sym__value] = STATE(2), + [sym_object] = STATE(8), + [sym_array] = STATE(8), + [sym_string] = STATE(8), + [aux_sym_document_repeat1] = STATE(2), + [ts_builtin_sym_end] = ACTIONS(5), + [anon_sym_LBRACE] = ACTIONS(7), + [anon_sym_LBRACK] = ACTIONS(9), + [anon_sym_DQUOTE] = ACTIONS(11), + [sym_number] = ACTIONS(13), + [sym_true] = ACTIONS(13), + [sym_false] = ACTIONS(13), + [sym_null] = ACTIONS(13), + [sym_comment] = ACTIONS(3), + }, + [STATE(2)] = { + [sym__value] = STATE(3), + [sym_object] = STATE(8), + [sym_array] = STATE(8), + [sym_string] = STATE(8), + [aux_sym_document_repeat1] = STATE(3), + [ts_builtin_sym_end] = ACTIONS(15), + [anon_sym_LBRACE] = ACTIONS(7), + [anon_sym_LBRACK] = ACTIONS(9), + [anon_sym_DQUOTE] = ACTIONS(11), + [sym_number] = ACTIONS(13), + [sym_true] = ACTIONS(13), + [sym_false] = ACTIONS(13), + [sym_null] = ACTIONS(13), + [sym_comment] = ACTIONS(3), + }, + [STATE(3)] = { + [sym__value] = STATE(3), + [sym_object] = STATE(8), + [sym_array] = STATE(8), + [sym_string] = STATE(8), + [aux_sym_document_repeat1] = STATE(3), + [ts_builtin_sym_end] = ACTIONS(17), + [anon_sym_LBRACE] = ACTIONS(19), + [anon_sym_LBRACK] = ACTIONS(22), + [anon_sym_DQUOTE] = ACTIONS(25), + [sym_number] = ACTIONS(28), + [sym_true] = ACTIONS(28), + [sym_false] = ACTIONS(28), + [sym_null] = ACTIONS(28), + [sym_comment] = ACTIONS(3), + }, + [STATE(4)] = { + [sym__value] = STATE(21), + [sym_object] = STATE(8), + [sym_array] = STATE(8), + [sym_string] = STATE(8), + [anon_sym_LBRACE] = ACTIONS(7), + [anon_sym_LBRACK] = ACTIONS(9), + [anon_sym_RBRACK] = ACTIONS(31), + [anon_sym_DQUOTE] = ACTIONS(11), + [sym_number] = ACTIONS(13), + [sym_true] = ACTIONS(13), + [sym_false] = ACTIONS(13), + [sym_null] = ACTIONS(13), + [sym_comment] = ACTIONS(3), + }, + [STATE(5)] = { + [ts_builtin_sym_end] = ACTIONS(33), + [anon_sym_LBRACE] = ACTIONS(33), + [anon_sym_COMMA] = ACTIONS(33), + [anon_sym_RBRACE] = ACTIONS(33), + [anon_sym_COLON] = ACTIONS(33), + [anon_sym_LBRACK] = ACTIONS(33), + [anon_sym_RBRACK] = ACTIONS(33), + [anon_sym_DQUOTE] = ACTIONS(33), + [sym_number] = ACTIONS(33), + [sym_true] = ACTIONS(33), + [sym_false] = ACTIONS(33), + [sym_null] = ACTIONS(33), + [sym_comment] = ACTIONS(3), + }, + [STATE(6)] = { + [ts_builtin_sym_end] = ACTIONS(35), + [anon_sym_LBRACE] = ACTIONS(35), + [anon_sym_COMMA] = ACTIONS(35), + [anon_sym_RBRACE] = ACTIONS(35), + [anon_sym_COLON] = ACTIONS(35), + [anon_sym_LBRACK] = ACTIONS(35), + [anon_sym_RBRACK] = ACTIONS(35), + [anon_sym_DQUOTE] = ACTIONS(35), + [sym_number] = ACTIONS(35), + [sym_true] = ACTIONS(35), + [sym_false] = ACTIONS(35), + [sym_null] = ACTIONS(35), + [sym_comment] = ACTIONS(3), + }, +}; + +static const uint16_t ts_small_parse_table[] = { + [0] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(37), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [17] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(39), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [34] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(41), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [51] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_LBRACE, + ACTIONS(9), 1, + anon_sym_LBRACK, + ACTIONS(11), 1, + anon_sym_DQUOTE, + STATE(29), 1, + sym__value, + STATE(8), 3, + sym_object, + sym_array, + sym_string, + ACTIONS(13), 4, + sym_number, + sym_true, + sym_false, + sym_null, + [78] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(43), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [95] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(45), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [112] = 7, + ACTIONS(3), 1, + sym_comment, + ACTIONS(7), 1, + anon_sym_LBRACE, + ACTIONS(9), 1, + anon_sym_LBRACK, + ACTIONS(11), 1, + anon_sym_DQUOTE, + STATE(28), 1, + sym__value, + STATE(8), 3, + sym_object, + sym_array, + sym_string, + ACTIONS(13), 4, + sym_number, + sym_true, + sym_false, + sym_null, + [139] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(47), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [156] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(49), 11, + ts_builtin_sym_end, + anon_sym_LBRACE, + anon_sym_COMMA, + anon_sym_RBRACE, + anon_sym_LBRACK, + anon_sym_RBRACK, + anon_sym_DQUOTE, + sym_number, + sym_true, + sym_false, + sym_null, + [173] = 5, + ACTIONS(3), 1, + sym_comment, + ACTIONS(11), 1, + anon_sym_DQUOTE, + ACTIONS(51), 1, + anon_sym_RBRACE, + STATE(20), 1, + sym_pair, + STATE(31), 1, + sym_string, + [189] = 4, + ACTIONS(53), 1, + anon_sym_DQUOTE, + ACTIONS(57), 1, + sym_comment, + STATE(18), 1, + aux_sym__string_content, + ACTIONS(55), 2, + sym_string_content, + sym_escape_sequence, + [203] = 4, + ACTIONS(57), 1, + sym_comment, + ACTIONS(59), 1, + anon_sym_DQUOTE, + STATE(19), 1, + aux_sym__string_content, + ACTIONS(61), 2, + sym_string_content, + sym_escape_sequence, + [217] = 4, + ACTIONS(57), 1, + sym_comment, + ACTIONS(63), 1, + anon_sym_DQUOTE, + STATE(19), 1, + aux_sym__string_content, + ACTIONS(65), 2, + sym_string_content, + sym_escape_sequence, + [231] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(68), 1, + anon_sym_COMMA, + ACTIONS(70), 1, + anon_sym_RBRACE, + STATE(22), 1, + aux_sym_object_repeat1, + [244] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(72), 1, + anon_sym_COMMA, + ACTIONS(74), 1, + anon_sym_RBRACK, + STATE(24), 1, + aux_sym_array_repeat1, + [257] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(68), 1, + anon_sym_COMMA, + ACTIONS(76), 1, + anon_sym_RBRACE, + STATE(25), 1, + aux_sym_object_repeat1, + [270] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(11), 1, + anon_sym_DQUOTE, + STATE(27), 1, + sym_pair, + STATE(31), 1, + sym_string, + [283] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(72), 1, + anon_sym_COMMA, + ACTIONS(78), 1, + anon_sym_RBRACK, + STATE(26), 1, + aux_sym_array_repeat1, + [296] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(80), 1, + anon_sym_COMMA, + ACTIONS(83), 1, + anon_sym_RBRACE, + STATE(25), 1, + aux_sym_object_repeat1, + [309] = 4, + ACTIONS(3), 1, + sym_comment, + ACTIONS(85), 1, + anon_sym_COMMA, + ACTIONS(88), 1, + anon_sym_RBRACK, + STATE(26), 1, + aux_sym_array_repeat1, + [322] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(83), 2, + anon_sym_COMMA, + anon_sym_RBRACE, + [330] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(90), 2, + anon_sym_COMMA, + anon_sym_RBRACE, + [338] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(88), 2, + anon_sym_COMMA, + anon_sym_RBRACK, + [346] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(92), 1, + ts_builtin_sym_end, + [353] = 2, + ACTIONS(3), 1, + sym_comment, + ACTIONS(94), 1, + anon_sym_COLON, +}; + +static const uint32_t ts_small_parse_table_map[] = { + [SMALL_STATE(7)] = 0, + [SMALL_STATE(8)] = 17, + [SMALL_STATE(9)] = 34, + [SMALL_STATE(10)] = 51, + [SMALL_STATE(11)] = 78, + [SMALL_STATE(12)] = 95, + [SMALL_STATE(13)] = 112, + [SMALL_STATE(14)] = 139, + [SMALL_STATE(15)] = 156, + [SMALL_STATE(16)] = 173, + [SMALL_STATE(17)] = 189, + [SMALL_STATE(18)] = 203, + [SMALL_STATE(19)] = 217, + [SMALL_STATE(20)] = 231, + [SMALL_STATE(21)] = 244, + [SMALL_STATE(22)] = 257, + [SMALL_STATE(23)] = 270, + [SMALL_STATE(24)] = 283, + [SMALL_STATE(25)] = 296, + [SMALL_STATE(26)] = 309, + [SMALL_STATE(27)] = 322, + [SMALL_STATE(28)] = 330, + [SMALL_STATE(29)] = 338, + [SMALL_STATE(30)] = 346, + [SMALL_STATE(31)] = 353, +}; + +static const TSParseActionEntry ts_parse_actions[] = { + [0] = {.entry = {.count = 0, .reusable = false}}, + [1] = {.entry = {.count = 1, .reusable = false}}, RECOVER(), + [3] = {.entry = {.count = 1, .reusable = true}}, SHIFT_EXTRA(), + [5] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_document, 0, 0, 0), + [7] = {.entry = {.count = 1, .reusable = true}}, SHIFT(16), + [9] = {.entry = {.count = 1, .reusable = true}}, SHIFT(4), + [11] = {.entry = {.count = 1, .reusable = true}}, SHIFT(17), + [13] = {.entry = {.count = 1, .reusable = true}}, SHIFT(8), + [15] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_document, 1, 0, 0), + [17] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_document_repeat1, 2, 0, 0), + [19] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_document_repeat1, 2, 0, 0), SHIFT_REPEAT(16), + [22] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_document_repeat1, 2, 0, 0), SHIFT_REPEAT(4), + [25] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_document_repeat1, 2, 0, 0), SHIFT_REPEAT(17), + [28] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_document_repeat1, 2, 0, 0), SHIFT_REPEAT(8), + [31] = {.entry = {.count = 1, .reusable = true}}, SHIFT(9), + [33] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_string, 2, 0, 0), + [35] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_string, 3, 0, 0), + [37] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_object, 2, 0, 0), + [39] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym__value, 1, 0, 0), + [41] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_array, 2, 0, 0), + [43] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_object, 3, 0, 0), + [45] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_object, 4, 0, 0), + [47] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_array, 3, 0, 0), + [49] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_array, 4, 0, 0), + [51] = {.entry = {.count = 1, .reusable = true}}, SHIFT(7), + [53] = {.entry = {.count = 1, .reusable = false}}, SHIFT(5), + [55] = {.entry = {.count = 1, .reusable = true}}, SHIFT(18), + [57] = {.entry = {.count = 1, .reusable = false}}, SHIFT_EXTRA(), + [59] = {.entry = {.count = 1, .reusable = false}}, SHIFT(6), + [61] = {.entry = {.count = 1, .reusable = true}}, SHIFT(19), + [63] = {.entry = {.count = 1, .reusable = false}}, REDUCE(aux_sym__string_content, 2, 0, 0), + [65] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym__string_content, 2, 0, 0), SHIFT_REPEAT(19), + [68] = {.entry = {.count = 1, .reusable = true}}, SHIFT(23), + [70] = {.entry = {.count = 1, .reusable = true}}, SHIFT(11), + [72] = {.entry = {.count = 1, .reusable = true}}, SHIFT(10), + [74] = {.entry = {.count = 1, .reusable = true}}, SHIFT(14), + [76] = {.entry = {.count = 1, .reusable = true}}, SHIFT(12), + [78] = {.entry = {.count = 1, .reusable = true}}, SHIFT(15), + [80] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_object_repeat1, 2, 0, 0), SHIFT_REPEAT(23), + [83] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_object_repeat1, 2, 0, 0), + [85] = {.entry = {.count = 2, .reusable = true}}, REDUCE(aux_sym_array_repeat1, 2, 0, 0), SHIFT_REPEAT(10), + [88] = {.entry = {.count = 1, .reusable = true}}, REDUCE(aux_sym_array_repeat1, 2, 0, 0), + [90] = {.entry = {.count = 1, .reusable = true}}, REDUCE(sym_pair, 3, 0, 1), + [92] = {.entry = {.count = 1, .reusable = true}}, ACCEPT_INPUT(), + [94] = {.entry = {.count = 1, .reusable = true}}, SHIFT(13), +}; + +#ifdef __cplusplus +extern "C" { +#endif +#ifdef TREE_SITTER_HIDE_SYMBOLS +#define TS_PUBLIC +#elif defined(_WIN32) +#define TS_PUBLIC __declspec(dllexport) +#else +#define TS_PUBLIC __attribute__((visibility("default"))) +#endif + +TS_PUBLIC const TSLanguage *tree_sitter_json(void) { + static const TSLanguage language = { + .abi_version = LANGUAGE_VERSION, + .symbol_count = SYMBOL_COUNT, + .alias_count = ALIAS_COUNT, + .token_count = TOKEN_COUNT, + .external_token_count = EXTERNAL_TOKEN_COUNT, + .state_count = STATE_COUNT, + .large_state_count = LARGE_STATE_COUNT, + .production_id_count = PRODUCTION_ID_COUNT, + .field_count = FIELD_COUNT, + .max_alias_sequence_length = MAX_ALIAS_SEQUENCE_LENGTH, + .parse_table = &ts_parse_table[0][0], + .small_parse_table = ts_small_parse_table, + .small_parse_table_map = ts_small_parse_table_map, + .parse_actions = ts_parse_actions, + .symbol_names = ts_symbol_names, + .field_names = ts_field_names, + .field_map_slices = ts_field_map_slices, + .field_map_entries = ts_field_map_entries, + .symbol_metadata = ts_symbol_metadata, + .public_symbol_map = ts_symbol_map, + .alias_map = ts_non_terminal_alias_map, + .alias_sequences = &ts_alias_sequences[0][0], + .lex_modes = (const void*)ts_lex_modes, + .lex_fn = ts_lex, + .primary_state_ids = ts_primary_state_ids, + }; + return &language; +} +#ifdef __cplusplus +} +#endif diff --git a/TablePro/Views/Results/JSONBraceMatchingHelper.swift b/TablePro/Views/Results/JSONBraceMatchingHelper.swift deleted file mode 100644 index ac73d4b2a..000000000 --- a/TablePro/Views/Results/JSONBraceMatchingHelper.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// JSONBraceMatchingHelper.swift -// TablePro -// -// Highlights matching {}/[] braces when the cursor is adjacent to one. -// - -import AppKit - -final class JSONBraceMatchingHelper { - private weak var textView: NSTextView? - private var lastHighlightedRanges: [NSRange] = [] - private static let highlightColor = NSColor.systemYellow.withAlphaComponent(0.3) - private static let maxScanLength = 10_000 - - init(textView: NSTextView) { - self.textView = textView - } - - func updateBraceHighlight() { - clearHighlights() - - guard let textView else { return } - guard let layoutManager = textView.layoutManager else { return } - - let text = textView.string as NSString - let length = text.length - guard length > 0 else { return } - - let cursor = textView.selectedRange().location - guard cursor != NSNotFound else { return } - - var bracePosition: Int? - - if let pos = braceAt(position: cursor, in: text) { - bracePosition = pos - } else if cursor > 0, let pos = braceAt(position: cursor - 1, in: text) { - bracePosition = pos - } - - guard let position = bracePosition else { return } - guard let matchPosition = findMatchingBrace(from: position, in: text) else { return } - - let ranges = [ - NSRange(location: position, length: 1), - NSRange(location: matchPosition, length: 1) - ] - - for range in ranges { - layoutManager.addTemporaryAttribute( - .backgroundColor, - value: Self.highlightColor, - forCharacterRange: range - ) - } - - lastHighlightedRanges = ranges - } - - private func clearHighlights() { - guard let layoutManager = textView?.layoutManager else { return } - for range in lastHighlightedRanges { - layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range) - } - lastHighlightedRanges = [] - } - - private func findMatchingBrace(from position: Int, in text: NSString) -> Int? { - let length = text.length - guard position >= 0, position < length else { return nil } - - let char = text.character(at: position) - let openBrace: unichar - let closeBrace: unichar - let forward: Bool - - switch char { - case leftCurly: - openBrace = leftCurly; closeBrace = rightCurly; forward = true - case leftSquare: - openBrace = leftSquare; closeBrace = rightSquare; forward = true - case rightCurly: - openBrace = leftCurly; closeBrace = rightCurly; forward = false - case rightSquare: - openBrace = leftSquare; closeBrace = rightSquare; forward = false - default: - return nil - } - - var depth = 1 - var inString = false - let maxScan = Self.maxScanLength - - if forward { - var i = position + 1 - var scanned = 0 - while i < length, scanned < maxScan { - let ch = text.character(at: i) - - if ch == quote, !isEscaped(at: i, in: text) { - inString.toggle() - } else if !inString { - if ch == openBrace { - depth += 1 - } else if ch == closeBrace { - depth -= 1 - if depth == 0 { return i } - } - } - - i += 1 - scanned += 1 - } - // Backward scan: first determine string-state at each position via forward pass, - // then walk backward using the precomputed state. - } else { - // Build in-string map from start to target position via forward scan - var stringState = [Bool](repeating: false, count: min(position + 1, length)) - var fwdInString = false - for j in 0..= 0, scanned < maxScan { - if !stringState[i] { - let ch = text.character(at: i) - if ch == closeBrace { - depth += 1 - } else if ch == openBrace { - depth -= 1 - if depth == 0 { return i } - } - } - i -= 1 - scanned += 1 - } - } - - return nil - } - - private func braceAt(position: Int, in text: NSString) -> Int? { - guard position >= 0, position < text.length else { return nil } - let ch = text.character(at: position) - if ch == leftCurly || ch == rightCurly || ch == leftSquare || ch == rightSquare { - return position - } - return nil - } - - // Checks if the quote at `position` is preceded by an odd number of backslashes - private func isEscaped(at position: Int, in text: NSString) -> Bool { - var backslashCount = 0 - var i = position - 1 - while i >= 0, text.character(at: i) == backslash { - backslashCount += 1 - i -= 1 - } - return backslashCount % 2 != 0 - } -} - -// MARK: - Character Constants - -private extension JSONBraceMatchingHelper { - var leftCurly: unichar { 0x7B } // { - var rightCurly: unichar { 0x7D } // } - var leftSquare: unichar { 0x5B } // [ - var rightSquare: unichar { 0x5D } // ] - var quote: unichar { 0x22 } // " - var backslash: unichar { 0x5C } // \ -} diff --git a/TablePro/Views/Results/JSONCodeEditor.swift b/TablePro/Views/Results/JSONCodeEditor.swift new file mode 100644 index 000000000..e45f901bd --- /dev/null +++ b/TablePro/Views/Results/JSONCodeEditor.swift @@ -0,0 +1,60 @@ +// +// JSONCodeEditor.swift +// TablePro +// +// JSON text view backed by CodeEditSourceEditor (tree-sitter), sharing the +// app's editor theme and font with the SQL editor. +// + +import AppKit +import CodeEditLanguages +import CodeEditSourceEditor +import SwiftUI + +internal struct JSONCodeEditor: View { + @Binding var text: String + let isEditable: Bool + + @State private var editorState = SourceEditorState() + @State private var configuration: SourceEditorConfiguration + @Environment(\.colorScheme) private var colorScheme + + init(text: Binding, isEditable: Bool) { + self._text = text + self.isEditable = isEditable + self._configuration = State(wrappedValue: Self.makeConfiguration(isEditable: isEditable)) + } + + var body: some View { + SourceEditor( + $text, + language: .json, + configuration: configuration, + state: $editorState + ) + .onChange(of: colorScheme) { + configuration = Self.makeConfiguration(isEditable: isEditable) + } + } + + private static func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: TableProEditorTheme.make(), + font: ThemeEngine.shared.editorFonts.font, + wrapLines: true + ), + behavior: .init( + isEditable: isEditable + ), + layout: .init( + contentInsets: NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) + ), + peripherals: .init( + showGutter: false, + showMinimap: false, + showFoldingRibbon: false + ) + ) + } +} diff --git a/TablePro/Views/Results/JSONHighlightPatterns.swift b/TablePro/Views/Results/JSONHighlightPatterns.swift deleted file mode 100644 index 4b7f8471e..000000000 --- a/TablePro/Views/Results/JSONHighlightPatterns.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// JSONHighlightPatterns.swift -// TablePro - -import Foundation -import os - -private let patternLogger = Logger(subsystem: "com.TablePro", category: "JSONHighlightPatterns") - -private func compileJSONRegex(_ pattern: String) -> NSRegularExpression { - if let regex = try? NSRegularExpression(pattern: pattern) { - return regex - } - patternLogger.fault("Failed to compile JSON highlight pattern: \(pattern, privacy: .public)") - return NSRegularExpression() -} - -internal enum JSONHighlightPatterns { - static let string = compileJSONRegex("\"(?:[^\"\\\\]|\\\\.)*\"") - static let key = compileJSONRegex("(\"(?:[^\"\\\\]|\\\\.)*\")\\s*:") - static let number = compileJSONRegex("(?<=[\\s,:\\[{])-?\\d+\\.?\\d*(?:[eE][+-]?\\d+)?(?=[\\s,\\]}])") - static let booleanNull = compileJSONRegex("\\b(?:true|false|null)\\b") -} diff --git a/TablePro/Views/Results/JSONSyntaxTextView.swift b/TablePro/Views/Results/JSONSyntaxTextView.swift deleted file mode 100644 index f232241c6..000000000 --- a/TablePro/Views/Results/JSONSyntaxTextView.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// JSONSyntaxTextView.swift -// TablePro -// -// Reusable NSTextView-backed JSON viewer with syntax highlighting. -// Supports editable and read-only modes with brace matching. -// - -import AppKit -import SwiftUI - -internal struct JSONSyntaxTextView: NSViewRepresentable { - @Binding var text: String - var isEditable: Bool = true - var wordWrap: Bool = false - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { - return scrollView - } - - textView.isEditable = isEditable - textView.isSelectable = true - textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - textView.textContainerInset = NSSize(width: 4, height: 4) - textView.backgroundColor = .textBackgroundColor - textView.textColor = NSColor.labelColor - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isGrammarCheckingEnabled = false - textView.allowsUndo = isEditable - - if wordWrap { - textView.textContainer?.widthTracksTextView = true - textView.isHorizontallyResizable = false - } else { - textView.textContainer?.widthTracksTextView = false - textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = true - scrollView.hasHorizontalScroller = true - } - - textView.delegate = context.coordinator - textView.string = text - - context.coordinator.braceHelper = JSONBraceMatchingHelper(textView: textView) - context.coordinator.observeScroll(of: scrollView) - - DispatchQueue.main.async { [coordinator = context.coordinator] in - coordinator.highlightVisible() - } - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text, !context.coordinator.isUpdating { - let fullRange = NSRange(location: 0, length: (textView.string as NSString).length) - if isEditable, - textView.shouldChangeText(in: fullRange, replacementString: text) { - context.coordinator.isUpdating = true - textView.textStorage?.replaceCharacters(in: fullRange, with: text) - textView.didChangeText() - context.coordinator.isUpdating = false - } else { - textView.string = text - } - context.coordinator.highlightedSet = IndexSet() - context.coordinator.highlightVisible() - } - } - - // MARK: - Syntax Highlighting - - static func applyHighlighting(to textView: NSTextView, range highlightRange: NSRange, highlightedSet: inout IndexSet) { - guard let textStorage = textView.textStorage else { return } - let length = textStorage.length - guard length > 0 else { return } - - let clamped = NSIntersectionRange(highlightRange, NSRange(location: 0, length: length)) - guard clamped.length > 0 else { return } - - let requestedIndices = IndexSet(integersIn: clamped.location..<(clamped.location + clamped.length)) - let newIndices = requestedIndices.subtracting(highlightedSet) - guard !newIndices.isEmpty else { return } - - let maxBatchSize = 20_000 - let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - let content = textStorage.string - - textStorage.beginEditing() - - var processed = 0 - for range in newIndices.rangeView { - if processed >= maxBatchSize { break } - let cappedLength = min(range.count, maxBatchSize - processed) - let nsRange = NSRange(location: range.lowerBound, length: cappedLength) - textStorage.addAttribute(.font, value: font, range: nsRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: nsRange) - - applyPattern(JSONHighlightPatterns.string, color: .systemRed, in: textStorage, content: content, range: nsRange) - - for match in JSONHighlightPatterns.key.matches(in: content, range: nsRange) { - let captureRange = match.range(at: 1) - if captureRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) - } - } - - applyPattern(JSONHighlightPatterns.number, color: .systemPurple, in: textStorage, content: content, range: nsRange) - applyPattern(JSONHighlightPatterns.booleanNull, color: .systemOrange, in: textStorage, content: content, range: nsRange) - - highlightedSet.insert(integersIn: nsRange.location..<(nsRange.location + nsRange.length)) - processed += cappedLength - } - - textStorage.endEditing() - } - - static func visibleCharacterRange(for textView: NSTextView) -> NSRange? { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { return nil } - let visibleRect = textView.visibleRect - let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: textContainer) - return layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - } - - private static func applyPattern( - _ regex: NSRegularExpression, - color: NSColor, - in textStorage: NSTextStorage, - content: String, - range: NSRange - ) { - for match in regex.matches(in: content, range: range) { - textStorage.addAttribute(.foregroundColor, value: color, range: match.range) - } - } - - // MARK: - Coordinator - - internal final class Coordinator: NSObject, NSTextViewDelegate { - var parent: JSONSyntaxTextView - var isUpdating = false - var braceHelper: JSONBraceMatchingHelper? - private var highlightTask: Task? - private var scrollObserver: NSObjectProtocol? - - init(_ parent: JSONSyntaxTextView) { - self.parent = parent - } - - deinit { - highlightTask?.cancel() - if let observer = scrollObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - weak var scrollView: NSScrollView? - var highlightedSet = IndexSet() - - func observeScroll(of scrollView: NSScrollView) { - self.scrollView = scrollView - scrollView.contentView.postsBoundsChangedNotifications = true - scrollObserver = NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.highlightVisible() - } - } - - func highlightVisible() { - guard let textView = scrollView?.documentView as? NSTextView, - let visible = JSONSyntaxTextView.visibleCharacterRange(for: textView) else { - return - } - let nsString = textView.string as NSString - let length = nsString.length - let buffer = 8_000 - let rawStart = max(0, visible.location - buffer) - let rawEnd = min(length, visible.location + visible.length + buffer) - - let lineStart = nsString.lineRange(for: NSRange(location: rawStart, length: 0)).location - let lineEndRange = nsString.lineRange(for: NSRange(location: rawEnd > 0 ? rawEnd - 1 : 0, length: 0)) - let lineEnd = min(length, lineEndRange.location + lineEndRange.length) - - let buffered = NSRange(location: lineStart, length: lineEnd - lineStart) - JSONSyntaxTextView.applyHighlighting(to: textView, range: buffered, highlightedSet: &highlightedSet) - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - isUpdating = true - parent.text = textView.string - isUpdating = false - - highlightedSet = IndexSet() - highlightTask?.cancel() - highlightTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: .milliseconds(100)) - } catch { - return - } - self?.highlightVisible() - } - } - - func textViewDidChangeSelection(_ notification: Notification) { - braceHelper?.updateBraceHighlight() - } - } -} diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index 7521595a5..5d0c698f4 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -91,7 +91,7 @@ internal struct JSONViewerView: View { private var viewerContent: some View { switch viewMode { case .text: - JSONSyntaxTextView(text: $displayText, isEditable: isEditable, wordWrap: true) + JSONCodeEditor(text: $displayText, isEditable: isEditable) case .tree: if let tree = parsedTree { JSONTreeView(rootNode: tree, searchText: $treeSearchText) diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index 7f5e5bb85..b8b9ac07a 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -111,11 +111,7 @@ internal struct ResultsJsonView: View { } else { switch viewMode { case .text: - JSONSyntaxTextView( - text: $prettyText, - isEditable: false, - wordWrap: true - ) + JSONCodeEditor(text: $prettyText, isEditable: false) case .tree: if let tree = parsedTree { JSONTreeView(rootNode: tree, searchText: $treeSearchText) diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index 0851372a3..c9f50a00e 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -13,7 +13,7 @@ internal struct JsonEditorView: View { @State private var displayText = "" var body: some View { - JSONSyntaxTextView(text: $displayText, isEditable: !context.isReadOnly, wordWrap: true) + JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) .frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 120) .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) diff --git a/TableProTests/Views/Components/HighlightCapTests.swift b/TableProTests/Views/Components/HighlightCapTests.swift index 3bd5504af..fe6342caa 100644 --- a/TableProTests/Views/Components/HighlightCapTests.swift +++ b/TableProTests/Views/Components/HighlightCapTests.swift @@ -10,9 +10,10 @@ import AppKit import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @MainActor @Suite("Highlight Capping") struct HighlightCapTests { @@ -131,98 +132,13 @@ struct HighlightCapTests { #expect(textView.string.isEmpty) } - // MARK: - JSON Highlighting Cap - - @Test("Short JSON is fully highlighted") - func jsonShortTextFullyHighlighted() { - let json = "{\"key\": true, \"num\": 42}" - let textView = makeHighlightedJSONView(json: json) - - guard let textStorage = textView.textStorage else { - Issue.record("No text storage") - return - } - - let range = NSRange(location: 0, length: textStorage.length) - var hasColorAttribute = false - textStorage.enumerateAttribute(.foregroundColor, in: range) { value, _, _ in - if let color = value as? NSColor, color != .labelColor { - hasColorAttribute = true - } - } - - #expect(hasColorAttribute, "Short JSON should have syntax highlighting colors") - } - - @Test("JSON highlighting caps at 10K characters") - func jsonCapsAt10K() { - let chunk = "{\"key\": \"value\"}, " - let repeatCount = (Self.maxHighlightLength / (chunk as NSString).length) + 200 - let longJson = String(repeating: chunk, count: repeatCount) - let nsJson = longJson as NSString - #expect(nsJson.length > Self.maxHighlightLength + 1_000) - - let textView = makeHighlightedJSONView(json: longJson) - - guard let textStorage = textView.textStorage else { - Issue.record("No text storage") - return - } - - let positionBeyondCap = Self.maxHighlightLength + 500 - guard positionBeyondCap < textStorage.length else { - Issue.record("Text too short") - return - } - - var hasHighlightBeyondCap = false - let beyondRange = NSRange(location: positionBeyondCap, length: min(500, textStorage.length - positionBeyondCap)) - textStorage.enumerateAttribute(.foregroundColor, in: beyondRange) { value, _, _ in - if let color = value as? NSColor, color != .labelColor { - hasHighlightBeyondCap = true - } - } - - #expect(!hasHighlightBeyondCap, "JSON beyond 10K should not have syntax highlighting") - } - - @Test("JSON within cap region is highlighted for long text") - func jsonWithinCapHighlighted() { - let chunk = "{\"key\": \"value\"}, " - let repeatCount = (Self.maxHighlightLength / (chunk as NSString).length) + 200 - let longJson = String(repeating: chunk, count: repeatCount) - - let textView = makeHighlightedJSONView(json: longJson) - - guard let textStorage = textView.textStorage else { - Issue.record("No text storage") - return - } - - var hasHighlightWithinCap = false - let withinRange = NSRange(location: 0, length: min(100, textStorage.length)) - textStorage.enumerateAttribute(.foregroundColor, in: withinRange) { value, _, _ in - if let color = value as? NSColor, color != .labelColor { - hasHighlightWithinCap = true - } - } - - #expect(hasHighlightWithinCap, "JSON within 10K should have syntax highlighting") - } - - @Test("Empty JSON does not crash") - func jsonEmptyNoCrash() { - let textView = makeHighlightedJSONView(json: "") - #expect(textView.string.isEmpty) - } - // MARK: - AIChatCodeBlockView SQL/JS Highlighting Cap @Test("Large SQL code block AttributedString caps highlighting at 10K") func aiChatSQLCapsAt10K() { let filler = String(repeating: "x", count: Self.maxHighlightLength) let code = filler + " SELECT id FROM users" - let attributed = highlightedSQLViaPatterns(code) + _ = highlightedSQLViaPatterns(code) let keywordPos = Self.maxHighlightLength + 1 let nsCode = code as NSString @@ -271,30 +187,6 @@ struct HighlightCapTests { _ = AIChatCodeBlockView(code: "", language: nil) } - // MARK: - JSON Pattern Capping Contract - - @Test("JSONHighlightPatterns with capped range produces fewer matches than full range") - func jsonPatternsCappedVsFull() { - let chunk = "\"hello\" " - let repeatCount = (Self.maxHighlightLength / (chunk as NSString).length) + 200 - let longText = String(repeating: chunk, count: repeatCount) - let nsText = longText as NSString - #expect(nsText.length > Self.maxHighlightLength) - - let cappedRange = NSRange(location: 0, length: Self.maxHighlightLength) - let fullRange = NSRange(location: 0, length: nsText.length) - - let cappedMatches = JSONHighlightPatterns.string.matches(in: longText, range: cappedRange) - let fullMatches = JSONHighlightPatterns.string.matches(in: longText, range: fullRange) - - #expect(cappedMatches.count < fullMatches.count, "Capped range should yield fewer matches") - - for match in cappedMatches { - let matchEnd = match.range.location + match.range.length - #expect(matchEnd <= Self.maxHighlightLength, "All capped matches must end within 10K") - } - } - // MARK: - Helpers private func makeHighlightedSQLView(sql: String) -> (NSTextView, NSScrollView) { @@ -315,19 +207,6 @@ struct HighlightCapTests { return (textView, scrollView) } - private func makeHighlightedJSONView(json: String) -> NSTextView { - let textView = NSTextView() - textView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - textView.textColor = NSColor.labelColor - textView.string = json - - if !json.isEmpty { - applyJSONHighlightingWithCap(to: textView) - } - - return textView - } - private func applySQLHighlightingWithCap(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } let length = textStorage.length @@ -370,44 +249,6 @@ struct HighlightCapTests { textStorage.endEditing() } - private func applyJSONHighlightingWithCap(to textView: NSTextView) { - guard let textStorage = textView.textStorage else { return } - let length = textStorage.length - guard length > 0 else { return } - - let fullRange = NSRange(location: 0, length: length) - let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - let content = textStorage.string - - textStorage.beginEditing() - textStorage.addAttribute(.font, value: font, range: fullRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) - - let highlightLength = min(length, Self.maxHighlightLength) - let highlightRange = NSRange(location: 0, length: highlightLength) - - for match in JSONHighlightPatterns.string.matches(in: content, range: highlightRange) { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: match.range) - } - - for match in JSONHighlightPatterns.key.matches(in: content, range: highlightRange) { - let captureRange = match.range(at: 1) - if captureRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) - } - } - - for match in JSONHighlightPatterns.number.matches(in: content, range: highlightRange) { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range) - } - - for match in JSONHighlightPatterns.booleanNull.matches(in: content, range: highlightRange) { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range) - } - - textStorage.endEditing() - } - private func highlightedSQLViaPatterns(_ code: String) -> NSAttributedString { let attributed = NSMutableAttributedString( string: code, diff --git a/TableProTests/Views/Results/JSONEditorHighlightTests.swift b/TableProTests/Views/Results/JSONEditorHighlightTests.swift deleted file mode 100644 index 3453a97f8..000000000 --- a/TableProTests/Views/Results/JSONEditorHighlightTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// JSONEditorHighlightTests.swift -// TablePro - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -@Suite("JSON Editor Highlighting") -struct JSONEditorHighlightTests { - // MARK: - String Pattern - - @Test("String pattern matches simple quoted string") - func stringPatternMatchesSimpleString() { - let matches = findMatches(JSONHighlightPatterns.string, in: "\"hello\"") - #expect(matches == ["\"hello\""]) - } - - @Test("String pattern matches escaped quote inside string") - func stringPatternMatchesEscapedQuote() { - let matches = findMatches(JSONHighlightPatterns.string, in: "\"escaped \\\"quote\\\"\"") - #expect(matches == ["\"escaped \\\"quote\\\"\""]) - } - - @Test("String pattern does not match unquoted text") - func stringPatternIgnoresUnquotedText() { - let matches = findMatches(JSONHighlightPatterns.string, in: "hello world") - #expect(matches.isEmpty) - } - - @Test("String pattern matches multiple strings") - func stringPatternMatchesMultiple() { - let matches = findMatches(JSONHighlightPatterns.string, in: "\"a\", \"b\"") - #expect(matches == ["\"a\"", "\"b\""]) - } - - // MARK: - Key Pattern - - @Test("Key pattern matches key followed by colon") - func keyPatternMatchesKeyColon() { - let regex = JSONHighlightPatterns.key - let input = "\"name\": \"value\"" - let nsInput = input as NSString - let results = regex.matches(in: input, range: NSRange(location: 0, length: nsInput.length)) - #expect(results.count == 1) - let captureRange = results[0].range(at: 1) - #expect(nsInput.substring(with: captureRange) == "\"name\"") - } - - @Test("Key pattern matches key with space before colon") - func keyPatternMatchesKeySpaceColon() { - let regex = JSONHighlightPatterns.key - let input = "\"key\" : 42" - let nsInput = input as NSString - let results = regex.matches(in: input, range: NSRange(location: 0, length: nsInput.length)) - #expect(results.count == 1) - let captureRange = results[0].range(at: 1) - #expect(nsInput.substring(with: captureRange) == "\"key\"") - } - - // MARK: - Number Pattern - - @Test("Number pattern matches integer in JSON context") - func numberPatternMatchesInteger() { - let matches = findMatches(JSONHighlightPatterns.number, in: " 123 ") - #expect(matches == ["123"]) - } - - @Test("Number pattern matches negative decimal") - func numberPatternMatchesNegativeDecimal() { - let matches = findMatches(JSONHighlightPatterns.number, in: ":-3.14}") - #expect(matches == ["-3.14"]) - } - - @Test("Number pattern matches scientific notation") - func numberPatternMatchesScientific() { - let matches = findMatches(JSONHighlightPatterns.number, in: " 1e10 ") - #expect(matches == ["1e10"]) - } - - @Test("Number pattern matches negative exponent") - func numberPatternMatchesNegativeExponent() { - let matches = findMatches(JSONHighlightPatterns.number, in: "[2.5E-3]") - #expect(matches == ["2.5E-3"]) - } - - // MARK: - Boolean/Null Pattern - - @Test("BooleanNull pattern matches true") - func booleanNullMatchesTrue() { - let matches = findMatches(JSONHighlightPatterns.booleanNull, in: "true") - #expect(matches == ["true"]) - } - - @Test("BooleanNull pattern matches false") - func booleanNullMatchesFalse() { - let matches = findMatches(JSONHighlightPatterns.booleanNull, in: "false") - #expect(matches == ["false"]) - } - - @Test("BooleanNull pattern matches null") - func booleanNullMatchesNull() { - let matches = findMatches(JSONHighlightPatterns.booleanNull, in: "null") - #expect(matches == ["null"]) - } - - @Test("BooleanNull pattern does not match partial words") - func booleanNullIgnoresPartialWords() { - let matches = findMatches(JSONHighlightPatterns.booleanNull, in: "trueish falsehood nullable") - #expect(matches.isEmpty) - } - - // MARK: - Helpers - - private func findMatches(_ regex: NSRegularExpression, in input: String) -> [String] { - let nsInput = input as NSString - let range = NSRange(location: 0, length: nsInput.length) - return regex.matches(in: input, range: range).map { nsInput.substring(with: $0.range) } - } -} From 40d2d500f6922683a1b208b008d69cd4be363b1f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 13:49:53 +0700 Subject: [PATCH 4/7] fix(datagrid): seed JSON editor text at init so content shows on open --- TablePro/Views/Results/JSONCodeEditor.swift | 1 + TablePro/Views/Results/JSONViewerView.swift | 3 ++- .../RightSidebar/FieldEditors/JsonEditorView.swift | 10 ++++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Results/JSONCodeEditor.swift b/TablePro/Views/Results/JSONCodeEditor.swift index e45f901bd..7316a8c43 100644 --- a/TablePro/Views/Results/JSONCodeEditor.swift +++ b/TablePro/Views/Results/JSONCodeEditor.swift @@ -32,6 +32,7 @@ internal struct JSONCodeEditor: View { configuration: configuration, state: $editorState ) + .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: colorScheme) { configuration = Self.makeConfiguration(isEditable: isEditable) } diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index 5d0c698f4..5160e2dd6 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -16,7 +16,7 @@ internal struct JSONViewerView: View { @State private var treeSearchText = "" @State private var parsedTree: JSONTreeNode? @State private var parseError: JSONTreeParseError? - @State private var displayText = "" + @State private var displayText: String @State private var showInvalidAlert = false init( @@ -31,6 +31,7 @@ internal struct JSONViewerView: View { self.onDismiss = onDismiss self.onCommit = onCommit self.onPopOut = onPopOut + self._displayText = State(wrappedValue: JsonReindenter.reindent(text.wrappedValue)) self._viewMode = State(initialValue: AppSettingsManager.shared.editor.jsonViewerPreferredMode) } diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift index c9f50a00e..f79cdaf40 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -10,7 +10,14 @@ internal struct JsonEditorView: View { var onExpand: (() -> Void)? var onPopOut: ((String) -> Void)? - @State private var displayText = "" + @State private var displayText: String + + init(context: FieldEditorContext, onExpand: (() -> Void)? = nil, onPopOut: ((String) -> Void)? = nil) { + self.context = context + self.onExpand = onExpand + self.onPopOut = onPopOut + self._displayText = State(wrappedValue: JsonReindenter.reindent(context.value.wrappedValue)) + } var body: some View { JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) @@ -42,7 +49,6 @@ internal struct JsonEditorView: View { } .padding(4) } - .onAppear { displayText = JsonReindenter.reindent(context.value.wrappedValue) } .onChange(of: displayText) { propagateEdit() } .onChange(of: context.value.wrappedValue) { syncFromBinding() } } From 5931d317d90bf55aa0353cc70afec4426734bb79 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 13:57:03 +0700 Subject: [PATCH 5/7] fix(datagrid): cap JSON parser recursion depth to prevent stack overflow on deeply nested input --- TablePro/Core/Services/Formatting/JsonSyntaxParser.swift | 8 ++++++++ .../Core/Services/Formatting/JsonReindenterTests.swift | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift b/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift index 8e35cb8a7..e43e1a4f1 100644 --- a/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift +++ b/TablePro/Core/Services/Formatting/JsonSyntaxParser.swift @@ -103,6 +103,8 @@ internal enum JsonSyntaxParser { private struct Parser { let scalars: [Unicode.Scalar] var index = 0 + var depth = 0 + let maxDepth = 512 var isAtEnd: Bool { index >= scalars.count } @@ -127,6 +129,9 @@ internal enum JsonSyntaxParser { } mutating func parseObject() -> JsonSyntaxNode? { + guard depth < maxDepth else { return nil } + depth += 1 + defer { depth -= 1 } index += 1 var pairs: [JsonObjectMember] = [] skipWhitespace() @@ -158,6 +163,9 @@ internal enum JsonSyntaxParser { } mutating func parseArray() -> JsonSyntaxNode? { + guard depth < maxDepth else { return nil } + depth += 1 + defer { depth -= 1 } index += 1 var elements: [JsonSyntaxNode] = [] skipWhitespace() diff --git a/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift b/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift index eb14a6794..7a3e7212e 100644 --- a/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift +++ b/TableProTests/Core/Services/Formatting/JsonReindenterTests.swift @@ -132,4 +132,13 @@ struct JsonReindenterTests { #expect(JsonSyntaxParser.decodeStringLiteral("\"\\u0041\"") == "A") #expect(JsonSyntaxParser.decodeStringLiteral("\"\\uD83D\\uDE00\"") == "😀") } + + @Test("Deeply nested JSON is rejected instead of overflowing the stack") + func deepNestingDoesNotCrash() { + let depth = 20_000 + let deep = String(repeating: "[", count: depth) + String(repeating: "]", count: depth) + #expect(JsonReindenter.reindentIfValid(deep) == nil) + #expect(JsonReindenter.reindent(deep) == deep) + #expect(JsonReindenter.normalize(deep) == deep) + } } From 2b25cb6e61462301be3b2c877064f29e0e695643 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 14:03:22 +0700 Subject: [PATCH 6/7] refactor(datagrid): open JSON cells in the resizable window instead of a popover --- .../Extensions/DataGridView+Popovers.swift | 65 ++++--------------- .../Views/Results/JSONEditorContentView.swift | 48 -------------- .../Views/Results/JSONViewerContentView.swift | 39 ----------- 3 files changed, 14 insertions(+), 138 deletions(-) delete mode 100644 TablePro/Views/Results/JSONEditorContentView.swift delete mode 100644 TablePro/Views/Results/JSONViewerContentView.swift diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index a6fd445b6..b090b74d5 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -122,34 +122,14 @@ extension TableViewCoordinator { guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[columnIndex] - guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - - let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( - relativeTo: cellRect, - of: tableView, - contentSize: NSSize(width: 560, height: 420) - ) { [weak self] dismiss in - JSONEditorContentView( - initialValue: currentValue, - columnName: columnName, - onCommit: { newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - }, - onDismiss: dismiss, - onPopOut: { currentText in - dismiss() - JSONViewerWindowController.open( - text: currentText, - columnName: columnName, - isEditable: true, - onCommit: { newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - } - ) - } - ) - } + JSONViewerWindowController.open( + text: currentValue, + columnName: columnName, + isEditable: true, + onCommit: { [weak self] newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + } + ) } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { @@ -322,29 +302,12 @@ extension TableViewCoordinator { guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[columnIndex] - guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } - - let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( - relativeTo: cellRect, - of: tableView, - contentSize: NSSize(width: 560, height: 360) - ) { dismiss in - JSONViewerContentView( - initialValue: currentValue, - columnName: columnName, - onDismiss: dismiss, - onPopOut: { currentText in - dismiss() - JSONViewerWindowController.open( - text: currentText, - columnName: columnName, - isEditable: false, - onCommit: nil - ) - } - ) - } + JSONViewerWindowController.open( + text: currentValue, + columnName: columnName, + isEditable: false, + onCommit: nil + ) } func showBlobViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift deleted file mode 100644 index 261f93450..000000000 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// JSONEditorContentView.swift -// TablePro -// - -import SwiftUI - -struct JSONEditorContentView: View { - let initialValue: String? - let columnName: String? - let onCommit: (String) -> Void - let onDismiss: () -> Void - var onPopOut: ((String) -> Void)? - - @State private var text: String - - init( - initialValue: String?, - columnName: String? = nil, - onCommit: @escaping (String) -> Void, - onDismiss: @escaping () -> Void, - onPopOut: ((String) -> Void)? = nil - ) { - self.initialValue = initialValue - self.columnName = columnName - self.onCommit = onCommit - self.onDismiss = onDismiss - self.onPopOut = onPopOut - self._text = State(initialValue: initialValue ?? "") - } - - var body: some View { - JSONViewerView( - text: $text, - isEditable: true, - onDismiss: onDismiss, - onCommit: { newValue in - if newValue.isEmpty && initialValue == nil { return } - if newValue != JsonReindenter.normalize(initialValue ?? "") { - onCommit(newValue) - } - }, - onPopOut: onPopOut - ) - .frame(width: 560) - .frame(minHeight: 200, maxHeight: 480) - } -} diff --git a/TablePro/Views/Results/JSONViewerContentView.swift b/TablePro/Views/Results/JSONViewerContentView.swift deleted file mode 100644 index 11e126c2f..000000000 --- a/TablePro/Views/Results/JSONViewerContentView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// JSONViewerContentView.swift -// TablePro -// - -import SwiftUI - -struct JSONViewerContentView: View { - let initialValue: String? - let columnName: String? - let onDismiss: () -> Void - var onPopOut: ((String) -> Void)? - - @State private var text: String - - init( - initialValue: String?, - columnName: String? = nil, - onDismiss: @escaping () -> Void, - onPopOut: ((String) -> Void)? = nil - ) { - self.initialValue = initialValue - self.columnName = columnName - self.onDismiss = onDismiss - self.onPopOut = onPopOut - self._text = State(initialValue: initialValue ?? "") - } - - var body: some View { - JSONViewerView( - text: $text, - isEditable: false, - onDismiss: onDismiss, - onPopOut: onPopOut - ) - .frame(width: 560) - .frame(minHeight: 200, maxHeight: 480) - } -} From c4cdd50457752e29bf2d91492ea9923f0346aac8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 25 May 2026 14:13:31 +0700 Subject: [PATCH 7/7] Revert "refactor(datagrid): open JSON cells in the resizable window instead of a popover" This reverts commit 2b25cb6e61462301be3b2c877064f29e0e695643. --- .../Extensions/DataGridView+Popovers.swift | 65 +++++++++++++++---- .../Views/Results/JSONEditorContentView.swift | 48 ++++++++++++++ .../Views/Results/JSONViewerContentView.swift | 39 +++++++++++ 3 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 TablePro/Views/Results/JSONEditorContentView.swift create mode 100644 TablePro/Views/Results/JSONViewerContentView.swift diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index b090b74d5..a6fd445b6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -122,14 +122,34 @@ extension TableViewCoordinator { guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[columnIndex] - JSONViewerWindowController.open( - text: currentValue, - columnName: columnName, - isEditable: true, - onCommit: { [weak self] newValue in - self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) - } - ) + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView, + contentSize: NSSize(width: 560, height: 420) + ) { [weak self] dismiss in + JSONEditorContentView( + initialValue: currentValue, + columnName: columnName, + onCommit: { newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss, + onPopOut: { currentText in + dismiss() + JSONViewerWindowController.open( + text: currentText, + columnName: columnName, + isEditable: true, + onCommit: { newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + } + ) + } + ) + } } func showBlobEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { @@ -302,12 +322,29 @@ extension TableViewCoordinator { guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[columnIndex] - JSONViewerWindowController.open( - text: currentValue, - columnName: columnName, - isEditable: false, - onCommit: nil - ) + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView, + contentSize: NSSize(width: 560, height: 360) + ) { dismiss in + JSONViewerContentView( + initialValue: currentValue, + columnName: columnName, + onDismiss: dismiss, + onPopOut: { currentText in + dismiss() + JSONViewerWindowController.open( + text: currentText, + columnName: columnName, + isEditable: false, + onCommit: nil + ) + } + ) + } } func showBlobViewerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift new file mode 100644 index 000000000..261f93450 --- /dev/null +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -0,0 +1,48 @@ +// +// JSONEditorContentView.swift +// TablePro +// + +import SwiftUI + +struct JSONEditorContentView: View { + let initialValue: String? + let columnName: String? + let onCommit: (String) -> Void + let onDismiss: () -> Void + var onPopOut: ((String) -> Void)? + + @State private var text: String + + init( + initialValue: String?, + columnName: String? = nil, + onCommit: @escaping (String) -> Void, + onDismiss: @escaping () -> Void, + onPopOut: ((String) -> Void)? = nil + ) { + self.initialValue = initialValue + self.columnName = columnName + self.onCommit = onCommit + self.onDismiss = onDismiss + self.onPopOut = onPopOut + self._text = State(initialValue: initialValue ?? "") + } + + var body: some View { + JSONViewerView( + text: $text, + isEditable: true, + onDismiss: onDismiss, + onCommit: { newValue in + if newValue.isEmpty && initialValue == nil { return } + if newValue != JsonReindenter.normalize(initialValue ?? "") { + onCommit(newValue) + } + }, + onPopOut: onPopOut + ) + .frame(width: 560) + .frame(minHeight: 200, maxHeight: 480) + } +} diff --git a/TablePro/Views/Results/JSONViewerContentView.swift b/TablePro/Views/Results/JSONViewerContentView.swift new file mode 100644 index 000000000..11e126c2f --- /dev/null +++ b/TablePro/Views/Results/JSONViewerContentView.swift @@ -0,0 +1,39 @@ +// +// JSONViewerContentView.swift +// TablePro +// + +import SwiftUI + +struct JSONViewerContentView: View { + let initialValue: String? + let columnName: String? + let onDismiss: () -> Void + var onPopOut: ((String) -> Void)? + + @State private var text: String + + init( + initialValue: String?, + columnName: String? = nil, + onDismiss: @escaping () -> Void, + onPopOut: ((String) -> Void)? = nil + ) { + self.initialValue = initialValue + self.columnName = columnName + self.onDismiss = onDismiss + self.onPopOut = onPopOut + self._text = State(initialValue: initialValue ?? "") + } + + var body: some View { + JSONViewerView( + text: $text, + isEditable: false, + onDismiss: onDismiss, + onPopOut: onPopOut + ) + .frame(width: 560) + .frame(minHeight: 200, maxHeight: 480) + } +}