Skip to content

Commit a716706

Browse files
authored
Merge pull request #127 from darkroomengineering/port/jsonc-config
feat(config): accept comments and trailing commas in programa.json
2 parents a0f7c25 + fd9ac58 commit a716706

6 files changed

Lines changed: 407 additions & 126 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
1515
- 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.
1616

1717
### Fixed
18+
- `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.
1819
- 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.
1920
- Debug, Release, and Staging reload entrypoints now prepare GhosttyKit before building; Staging uses the canonical `Programa STAGING` name and `com.darkroom.programa.staging` identity.
2021
- CI now retries only genuine SwiftPM resolution failures and always propagates XCTest failures, including deterministic failures reported as “0 unexpected.”

GhosttyTabs.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; };
146146
NRPA00001 /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00002 /* FileWatcher.swift */; };
147147
A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; };
148+
A5001660 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* JSONCParser.swift */; };
148149
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
149150
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
150151
B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; };
@@ -425,6 +426,7 @@
425426
A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = "<group>"; };
426427
NRPA00002 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = "<group>"; };
427428
A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = "<group>"; };
429+
A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; };
428430
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
429431
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
430432
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
@@ -751,6 +753,7 @@
751753
A5001653 /* ProgramaConfigExecutor.swift */,
752754
NRPA00002 /* FileWatcher.swift */,
753755
A5001655 /* ProgramaDirectoryTrust.swift */,
756+
A5001661 /* JSONCParser.swift */,
754757
);
755758
path = Sources;
756759
sourceTree = "<group>";
@@ -1135,6 +1138,7 @@
11351138
A5001652 /* ProgramaConfigExecutor.swift in Sources */,
11361139
NRPA00001 /* FileWatcher.swift in Sources */,
11371140
A5001654 /* ProgramaDirectoryTrust.swift in Sources */,
1141+
A5001660 /* JSONCParser.swift in Sources */,
11381142
);
11391143
runOnlyForDeploymentPostprocessing = 0;
11401144
};

Sources/JSONCParser.swift

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import Foundation
2+
3+
/// Preprocesses JSONC (JSON with Comments) text into plain JSON `Data` that
4+
/// `JSONDecoder`/`JSONSerialization` can parse.
5+
///
6+
/// Supports the two JSONC conveniences users actually reach for in hand-edited
7+
/// config files: `//` and `/* */` comments, and trailing commas before a
8+
/// closing `}` or `]`. Both are stripped with a string-literal-aware scanner
9+
/// so a comment marker or trailing comma inside a quoted string is left alone.
10+
///
11+
/// Shared by `ProgramaConfigStore` (`~/.config/programa/programa.json` and
12+
/// project-local `programa.json`/`cmux.json`) and `ProgramaSettingsFileStore`
13+
/// (`~/.config/programa/settings.json`) so both config surfaces accept the
14+
/// same JSONC dialect.
15+
enum JSONCParser {
16+
static func preprocess(data: Data) throws -> Data {
17+
let source = try sourceString(from: data)
18+
let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source
19+
let stripped = try stripComments(from: withoutBOM)
20+
let normalized = stripTrailingCommas(from: stripped)
21+
return Data(normalized.utf8)
22+
}
23+
24+
private static func sourceString(from data: Data) throws -> String {
25+
if let encoding = detectedJSONEncoding(for: data),
26+
let source = String(data: data, encoding: encoding) {
27+
return source
28+
}
29+
if let source = String(data: data, encoding: .utf8) {
30+
return source
31+
}
32+
33+
var convertedString: NSString?
34+
var usedLossyConversion = ObjCBool(false)
35+
let encoding = NSString.stringEncoding(
36+
for: data,
37+
encodingOptions: [
38+
.suggestedEncodingsKey: [
39+
String.Encoding.utf8.rawValue,
40+
String.Encoding.utf16BigEndian.rawValue,
41+
String.Encoding.utf16LittleEndian.rawValue,
42+
String.Encoding.utf32BigEndian.rawValue,
43+
String.Encoding.utf32LittleEndian.rawValue,
44+
],
45+
.useOnlySuggestedEncodingsKey: true,
46+
.allowLossyKey: false,
47+
],
48+
convertedString: &convertedString,
49+
usedLossyConversion: &usedLossyConversion
50+
)
51+
52+
if let convertedString, !usedLossyConversion.boolValue {
53+
return convertedString as String
54+
}
55+
if encoding != 0, !usedLossyConversion.boolValue {
56+
let stringEncoding = String.Encoding(rawValue: encoding)
57+
if let source = String(data: data, encoding: stringEncoding) {
58+
return source
59+
}
60+
}
61+
throw JSONCError.invalidTextEncoding
62+
}
63+
64+
private static func detectedJSONEncoding(for data: Data) -> String.Encoding? {
65+
let bytes = Array(data.prefix(4))
66+
if bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian }
67+
if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian }
68+
if bytes.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian }
69+
if bytes.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian }
70+
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 }
71+
guard bytes.count >= 4 else { return nil }
72+
73+
switch (bytes[0] == 0, bytes[1] == 0, bytes[2] == 0, bytes[3] == 0) {
74+
case (true, true, true, false):
75+
return .utf32BigEndian
76+
case (false, true, true, true):
77+
return .utf32LittleEndian
78+
case (true, false, true, false):
79+
return .utf16BigEndian
80+
case (false, true, false, true):
81+
return .utf16LittleEndian
82+
default:
83+
return nil
84+
}
85+
}
86+
87+
private static func stripComments(from source: String) throws -> String {
88+
var result = ""
89+
var index = source.startIndex
90+
var inString = false
91+
var isEscaped = false
92+
93+
while index < source.endIndex {
94+
let character = source[index]
95+
96+
if inString {
97+
result.append(character)
98+
if isEscaped {
99+
isEscaped = false
100+
} else if character == "\\" {
101+
isEscaped = true
102+
} else if character == "\"" {
103+
inString = false
104+
}
105+
index = source.index(after: index)
106+
continue
107+
}
108+
109+
if character == "\"" {
110+
inString = true
111+
result.append(character)
112+
index = source.index(after: index)
113+
continue
114+
}
115+
116+
if character == "/" {
117+
let nextIndex = source.index(after: index)
118+
if nextIndex < source.endIndex {
119+
let next = source[nextIndex]
120+
if next == "/" {
121+
index = source.index(after: nextIndex)
122+
while index < source.endIndex && source[index] != "\n" {
123+
index = source.index(after: index)
124+
}
125+
continue
126+
}
127+
if next == "*" {
128+
index = source.index(after: nextIndex)
129+
var didClose = false
130+
while index < source.endIndex {
131+
let current = source[index]
132+
let followingIndex = source.index(after: index)
133+
if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" {
134+
index = source.index(after: followingIndex)
135+
didClose = true
136+
break
137+
}
138+
index = followingIndex
139+
}
140+
guard didClose else {
141+
throw JSONCError.unterminatedBlockComment
142+
}
143+
continue
144+
}
145+
}
146+
}
147+
148+
result.append(character)
149+
index = source.index(after: index)
150+
}
151+
152+
return result
153+
}
154+
155+
private static func stripTrailingCommas(from source: String) -> String {
156+
var result = ""
157+
var index = source.startIndex
158+
var inString = false
159+
var isEscaped = false
160+
161+
while index < source.endIndex {
162+
let character = source[index]
163+
164+
if inString {
165+
result.append(character)
166+
if isEscaped {
167+
isEscaped = false
168+
} else if character == "\\" {
169+
isEscaped = true
170+
} else if character == "\"" {
171+
inString = false
172+
}
173+
index = source.index(after: index)
174+
continue
175+
}
176+
177+
if character == "\"" {
178+
inString = true
179+
result.append(character)
180+
index = source.index(after: index)
181+
continue
182+
}
183+
184+
if character == "," {
185+
var lookahead = source.index(after: index)
186+
while lookahead < source.endIndex && source[lookahead].isWhitespace {
187+
lookahead = source.index(after: lookahead)
188+
}
189+
if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") {
190+
index = source.index(after: index)
191+
continue
192+
}
193+
}
194+
195+
result.append(character)
196+
index = source.index(after: index)
197+
}
198+
199+
return result
200+
}
201+
202+
enum JSONCError: LocalizedError {
203+
case invalidTextEncoding
204+
case unterminatedBlockComment
205+
206+
var errorDescription: String? {
207+
switch self {
208+
case .invalidTextEncoding:
209+
return "config file text encoding is not supported"
210+
case .unterminatedBlockComment:
211+
return "unterminated block comment"
212+
}
213+
}
214+
}
215+
}

Sources/ProgramaConfig.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,17 @@ final class ProgramaConfigStore: ObservableObject {
401401
!data.isEmpty else {
402402
return nil
403403
}
404+
405+
let sanitized: Data
406+
do {
407+
sanitized = try JSONCParser.preprocess(data: data)
408+
} catch {
409+
NSLog("[ProgramaConfig] JSONC preprocessing error at %@: %@", path, String(describing: error))
410+
return nil
411+
}
412+
404413
do {
405-
return try JSONDecoder().decode(ProgramaConfigFile.self, from: data)
414+
return try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized)
406415
} catch {
407416
NSLog("[ProgramaConfig] parse error at %@: %@", path, String(describing: error))
408417
return nil

0 commit comments

Comments
 (0)