From fd9ac58b45368c6eb42b70d30b5846d14c8c1171 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 13 Jul 2026 18:32:01 -0300 Subject: [PATCH] feat(config): accept comments and trailing commas in programa.json Config parsing now runs through a shared JSONCParser before decoding, so hand-edited ~/.config/programa/programa.json and project-local programa.json/cmux.json files can use // and /* */ comments and trailing commas without breaking. This fixes a real parse failure a user hit on their ~/.config/cmux/cmux.json (a stray comment triggered "Unexpected character '/'"). The parser also replaces the private, less-robust copy that lived inside ProgramaSettingsFileStore.swift (settings.json parsing), so both config surfaces now share one implementation with encoding detection and unterminated-comment detection. Ported from upstream cmux commit 4d0b1dbd1 ("Make cmux.json the canonical settings file (#3409)"), parser and config-merge logic only. The settings-window UI redesign that shipped in the same upstream commit is out of scope here and was not ported. --- CHANGELOG.md | 1 + GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/JSONCParser.swift | 215 ++++++++++++++++++++++++ Sources/ProgramaConfig.swift | 11 +- Sources/ProgramaSettingsFileStore.swift | 127 +------------- programaTests/ProgramaConfigTests.swift | 175 +++++++++++++++++++ 6 files changed, 407 insertions(+), 126 deletions(-) create mode 100644 Sources/JSONCParser.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb47ea9..dc58c25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Whole-codebase restructuring pass (internal, no behavior change): the remote-daemon stack moved out of `Workspace.swift`, browser data-import out of `BrowserPanel.swift`, v2 browser automation out of `TerminalController.swift`, UI-test harnesses out of `AppDelegate.swift`, and `TabManager`/`GhosttyNSView`/`ContentView` split into per-concern files — the largest source files shrank by 3,000–5,000 lines each, cutting incremental build times. The copy-pasted v1 telemetry-handler skeleton, agent-wrapper commands (Go and Swift), and boilerplate settings accessors were each collapsed onto single shared implementations. ### Fixed +- `programa.json`/`cmux.json` command configs now accept `//` and `/* */` comments and trailing commas, so a hand-edited config with a note like `// dev commands` no longer fails to load with a cryptic parse error. - Release signing now proceeds inside-out without `--deep`, so the bundled `programa` and `ghostty` tools no longer inherit the app's camera, microphone, automation, JIT, or library-validation entitlements; the signed artifact is gated before notarization. - Debug, Release, and Staging reload entrypoints now prepare GhosttyKit before building; Staging uses the canonical `Programa STAGING` name and `com.darkroom.programa.staging` identity. - CI now retries only genuine SwiftPM resolution failures and always propagates XCTest failures, including deterministic failures reported as “0 unexpected.” diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 28305026..65bb5100 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; }; NRPA00001 /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00002 /* FileWatcher.swift */; }; A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; }; + A5001660 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* JSONCParser.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; }; @@ -425,6 +426,7 @@ A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = ""; }; NRPA00002 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = ""; }; A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = ""; }; + A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; @@ -751,6 +753,7 @@ A5001653 /* ProgramaConfigExecutor.swift */, NRPA00002 /* FileWatcher.swift */, A5001655 /* ProgramaDirectoryTrust.swift */, + A5001661 /* JSONCParser.swift */, ); path = Sources; sourceTree = ""; @@ -1135,6 +1138,7 @@ A5001652 /* ProgramaConfigExecutor.swift in Sources */, NRPA00001 /* FileWatcher.swift in Sources */, A5001654 /* ProgramaDirectoryTrust.swift in Sources */, + A5001660 /* JSONCParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/JSONCParser.swift b/Sources/JSONCParser.swift new file mode 100644 index 00000000..dfde79d9 --- /dev/null +++ b/Sources/JSONCParser.swift @@ -0,0 +1,215 @@ +import Foundation + +/// Preprocesses JSONC (JSON with Comments) text into plain JSON `Data` that +/// `JSONDecoder`/`JSONSerialization` can parse. +/// +/// Supports the two JSONC conveniences users actually reach for in hand-edited +/// config files: `//` and `/* */` comments, and trailing commas before a +/// closing `}` or `]`. Both are stripped with a string-literal-aware scanner +/// so a comment marker or trailing comma inside a quoted string is left alone. +/// +/// Shared by `ProgramaConfigStore` (`~/.config/programa/programa.json` and +/// project-local `programa.json`/`cmux.json`) and `ProgramaSettingsFileStore` +/// (`~/.config/programa/settings.json`) so both config surfaces accept the +/// same JSONC dialect. +enum JSONCParser { + static func preprocess(data: Data) throws -> Data { + let source = try sourceString(from: data) + let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source + let stripped = try stripComments(from: withoutBOM) + let normalized = stripTrailingCommas(from: stripped) + return Data(normalized.utf8) + } + + private static func sourceString(from data: Data) throws -> String { + if let encoding = detectedJSONEncoding(for: data), + let source = String(data: data, encoding: encoding) { + return source + } + if let source = String(data: data, encoding: .utf8) { + return source + } + + var convertedString: NSString? + var usedLossyConversion = ObjCBool(false) + let encoding = NSString.stringEncoding( + for: data, + encodingOptions: [ + .suggestedEncodingsKey: [ + String.Encoding.utf8.rawValue, + String.Encoding.utf16BigEndian.rawValue, + String.Encoding.utf16LittleEndian.rawValue, + String.Encoding.utf32BigEndian.rawValue, + String.Encoding.utf32LittleEndian.rawValue, + ], + .useOnlySuggestedEncodingsKey: true, + .allowLossyKey: false, + ], + convertedString: &convertedString, + usedLossyConversion: &usedLossyConversion + ) + + if let convertedString, !usedLossyConversion.boolValue { + return convertedString as String + } + if encoding != 0, !usedLossyConversion.boolValue { + let stringEncoding = String.Encoding(rawValue: encoding) + if let source = String(data: data, encoding: stringEncoding) { + return source + } + } + throw JSONCError.invalidTextEncoding + } + + private static func detectedJSONEncoding(for data: Data) -> String.Encoding? { + let bytes = Array(data.prefix(4)) + if bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian } + if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian } + if bytes.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian } + if bytes.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian } + if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 } + guard bytes.count >= 4 else { return nil } + + switch (bytes[0] == 0, bytes[1] == 0, bytes[2] == 0, bytes[3] == 0) { + case (true, true, true, false): + return .utf32BigEndian + case (false, true, true, true): + return .utf32LittleEndian + case (true, false, true, false): + return .utf16BigEndian + case (false, true, false, true): + return .utf16LittleEndian + default: + return nil + } + } + + private static func stripComments(from source: String) throws -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "/" { + let nextIndex = source.index(after: index) + if nextIndex < source.endIndex { + let next = source[nextIndex] + if next == "/" { + index = source.index(after: nextIndex) + while index < source.endIndex && source[index] != "\n" { + index = source.index(after: index) + } + continue + } + if next == "*" { + index = source.index(after: nextIndex) + var didClose = false + while index < source.endIndex { + let current = source[index] + let followingIndex = source.index(after: index) + if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" { + index = source.index(after: followingIndex) + didClose = true + break + } + index = followingIndex + } + guard didClose else { + throw JSONCError.unterminatedBlockComment + } + continue + } + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + private static func stripTrailingCommas(from source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "," { + var lookahead = source.index(after: index) + while lookahead < source.endIndex && source[lookahead].isWhitespace { + lookahead = source.index(after: lookahead) + } + if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") { + index = source.index(after: index) + continue + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + enum JSONCError: LocalizedError { + case invalidTextEncoding + case unterminatedBlockComment + + var errorDescription: String? { + switch self { + case .invalidTextEncoding: + return "config file text encoding is not supported" + case .unterminatedBlockComment: + return "unterminated block comment" + } + } + } +} diff --git a/Sources/ProgramaConfig.swift b/Sources/ProgramaConfig.swift index 45472780..4d62a046 100644 --- a/Sources/ProgramaConfig.swift +++ b/Sources/ProgramaConfig.swift @@ -401,8 +401,17 @@ final class ProgramaConfigStore: ObservableObject { !data.isEmpty else { return nil } + + let sanitized: Data + do { + sanitized = try JSONCParser.preprocess(data: data) + } catch { + NSLog("[ProgramaConfig] JSONC preprocessing error at %@: %@", path, String(describing: error)) + return nil + } + do { - return try JSONDecoder().decode(ProgramaConfigFile.self, from: data) + return try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized) } catch { NSLog("[ProgramaConfig] parse error at %@: %@", path, String(describing: error)) return nil diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 0ffab559..1aae6f15 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -1434,131 +1434,8 @@ private enum BackupValue: Codable, Equatable { } } -private enum JSONCParser { - static func preprocess(data: Data) throws -> Data { - guard let source = String(data: data, encoding: .utf8) else { - throw JSONCError.invalidUTF8 - } - let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source - let stripped = stripComments(from: withoutBOM) - let normalized = stripTrailingCommas(from: stripped) - return Data(normalized.utf8) - } - - private static func stripComments(from source: String) -> String { - var result = "" - var index = source.startIndex - var inString = false - var isEscaped = false - - while index < source.endIndex { - let character = source[index] - - if inString { - result.append(character) - if isEscaped { - isEscaped = false - } else if character == "\\" { - isEscaped = true - } else if character == "\"" { - inString = false - } - index = source.index(after: index) - continue - } - - if character == "\"" { - inString = true - result.append(character) - index = source.index(after: index) - continue - } - - if character == "/" { - let nextIndex = source.index(after: index) - if nextIndex < source.endIndex { - let next = source[nextIndex] - if next == "/" { - index = source.index(after: nextIndex) - while index < source.endIndex && source[index] != "\n" { - index = source.index(after: index) - } - continue - } - if next == "*" { - index = source.index(after: nextIndex) - while index < source.endIndex { - let current = source[index] - let followingIndex = source.index(after: index) - if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" { - index = source.index(after: followingIndex) - break - } - index = followingIndex - } - continue - } - } - } - - result.append(character) - index = source.index(after: index) - } - - return result - } - - private static func stripTrailingCommas(from source: String) -> String { - var result = "" - var index = source.startIndex - var inString = false - var isEscaped = false - - while index < source.endIndex { - let character = source[index] - - if inString { - result.append(character) - if isEscaped { - isEscaped = false - } else if character == "\\" { - isEscaped = true - } else if character == "\"" { - inString = false - } - index = source.index(after: index) - continue - } - - if character == "\"" { - inString = true - result.append(character) - index = source.index(after: index) - continue - } - - if character == "," { - var lookahead = source.index(after: index) - while lookahead < source.endIndex && source[lookahead].isWhitespace { - lookahead = source.index(after: lookahead) - } - if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") { - index = source.index(after: index) - continue - } - } - - result.append(character) - index = source.index(after: index) - } - - return result - } - - private enum JSONCError: Error { - case invalidUTF8 - } -} +// JSONCParser (comments + trailing-comma stripping) now lives in Sources/JSONCParser.swift, +// shared with ProgramaConfigStore's programa.json/cmux.json parsing. // NOTE (drift, refs #100): unlike ProgramaConfigStore's local/global config watchers, this // watcher has no delayed retry/backoff at all — on delete/rename it re-evaluates and diff --git a/programaTests/ProgramaConfigTests.swift b/programaTests/ProgramaConfigTests.swift index 17359481..d5e0acb0 100644 --- a/programaTests/ProgramaConfigTests.swift +++ b/programaTests/ProgramaConfigTests.swift @@ -510,6 +510,181 @@ final class ProgramaConfigDecodingTests: XCTestCase { } } +// MARK: - JSONC (comments + trailing commas) config parsing +// +// Regression coverage for a real user pain point: a project's programa.json (ported from +// ~/.config/cmux/cmux.json) failed to parse with "Unexpected character '/' at line 5 col 4" +// because plain JSONDecoder rejects `//`/`/* */` comments and trailing commas. These tests +// exercise the same JSONCParser.preprocess -> JSONDecoder pipeline ProgramaConfigStore uses +// at runtime, not just the parser in isolation. +final class ProgramaConfigJSONCDecodingTests: XCTestCase { + + /// Mirrors what `ProgramaConfigStore.parseConfig(at:)` does at runtime: run the raw + /// file text through `JSONCParser.preprocess` before handing it to `JSONDecoder`. + private func decodeJSONC(_ jsonc: String) throws -> ProgramaConfigFile { + let data = jsonc.data(using: .utf8)! + let sanitized = try JSONCParser.preprocess(data: data) + return try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized) + } + + func testDecodeWithLineComments() throws { + let jsonc = """ + { + // top-level config for this project + "commands": [{ + "name": "Run tests", // inline comment after a value + "command": "npm test" + }] + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands.count, 1) + XCTAssertEqual(config.commands[0].name, "Run tests") + XCTAssertEqual(config.commands[0].command, "npm test") + } + + func testDecodeWithBlockComments() throws { + let jsonc = """ + { + /* This project's dev commands. + Multi-line block comment. */ + "commands": [{ + "name": "Deploy" /* inline block comment */, + "command": "make deploy" + }] + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands[0].name, "Deploy") + XCTAssertEqual(config.commands[0].command, "make deploy") + } + + func testDecodeWithTrailingCommaInArray() throws { + let jsonc = """ + { + "commands": [ + { "name": "Build", "command": "make build" }, + { "name": "Test", "command": "make test" }, + ] + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands.map(\.name), ["Build", "Test"]) + } + + func testDecodeWithTrailingCommaInObject() throws { + let jsonc = """ + { + "commands": [{ + "name": "Deploy", + "command": "make deploy", + }], + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands[0].name, "Deploy") + XCTAssertEqual(config.commands[0].command, "make deploy") + } + + func testDecodeWithMixedCommentsAndTrailingCommas() throws { + // Same shape as the config that motivated this port: comments describing each + // command, plus trailing commas left over from copy-pasting entries. + let jsonc = """ + { + "commands": [ + { + // Runs the full test suite + "name": "Test", + "command": "npm test", + }, + { + /* Starts the dev server on the default port */ + "name": "Dev", + "workspace": { + "name": "Dev", + "cwd": "~/projects/app", + }, + }, + ], + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands.map(\.name), ["Test", "Dev"]) + XCTAssertEqual(config.commands[0].command, "npm test") + XCTAssertEqual(config.commands[1].workspace?.cwd, "~/projects/app") + } + + func testDecodeIgnoresCommentLikeSequencesInsideStrings() throws { + // A command string containing "//" or "/*" must not be treated as a comment. + let jsonc = """ + { + "commands": [{ + "name": "URL", + "command": "curl https://example.com/*.json" + }] + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands[0].command, "curl https://example.com/*.json") + } + + func testDecodeIgnoresTrailingCommaLikeSequenceInsideStrings() throws { + let jsonc = """ + { + "commands": [{ + "name": "test", + "command": "echo 'a, b,'" + }] + } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands[0].command, "echo 'a, b,'") + } + + func testDecodeStripsUTF8BOM() throws { + let jsonc = "\u{feff}{ \"commands\": [{ \"name\": \"Build\", \"command\": \"make\" }] }" + let data = jsonc.data(using: .utf8)! + let sanitized = try JSONCParser.preprocess(data: data) + let config = try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized) + XCTAssertEqual(config.commands[0].name, "Build") + } + + func testDecodePlainJSONWithoutCommentsStillWorks() throws { + // Strict JSON (no comments, no trailing commas) must keep working unmodified. + let jsonc = """ + { "commands": [{ "name": "Build", "command": "make build" }] } + """ + let config = try decodeJSONC(jsonc) + XCTAssertEqual(config.commands[0].name, "Build") + } + + // MARK: Error cases + + func testUnterminatedBlockCommentThrows() { + let jsonc = "{\n/* missing close\n\"commands\": []\n}" + let data = jsonc.data(using: .utf8)! + XCTAssertThrowsError(try JSONCParser.preprocess(data: data)) { error in + XCTAssertEqual((error as? LocalizedError)?.errorDescription, "unterminated block comment") + } + } + + func testCommentOnlyContentStillFailsJSONDecodingAfterPreprocessing() { + // JSONCParser only strips comments/trailing commas; it does not make invalid JSON + // valid. A comments-only file has nothing left to decode as an object. + let jsonc = "// just a comment, no actual config\n" + XCTAssertThrowsError(try decodeJSONC(jsonc)) + } + + func testMalformedJSONAfterCommentStrippingStillThrows() { + let jsonc = """ + { + // this command is missing its closing brace + "commands": [{ "name": "Broken", "command": "echo hi" } + """ + XCTAssertThrowsError(try decodeJSONC(jsonc)) + } +} + // MARK: - Command identity final class ProgramaCommandIdentityTests: XCTestCase {