Skip to content

Commit 2f099de

Browse files
fix: handle JSONC in VS Code settings.json and use atomic write
VS Code settings.json is JSONC (JSON with Comments) which allows line comments (//), block comments (/* */), and trailing commas. The previous implementation used plain json.Unmarshal which fails on any JSONC input. Add stripJSONC() to remove comments and trailing commas before parsing, preserving comment-like content inside string literals. Also make writeSettings() atomic: write to a temp file then rename, with fallback to direct write if rename fails. This prevents settings.json corruption if the process is interrupted mid-write.
1 parent 675a0df commit 2f099de

3 files changed

Lines changed: 241 additions & 1 deletion

File tree

cmd/modern/root/open/jsonc.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package open
5+
6+
// stripJSONC removes comments (// and /* */) and trailing commas from JSONC
7+
// data, producing valid JSON. String literals are preserved as-is.
8+
func stripJSONC(data []byte) []byte {
9+
var result []byte
10+
i := 0
11+
n := len(data)
12+
13+
for i < n {
14+
// String literal: copy verbatim, respecting escape sequences
15+
if data[i] == '"' {
16+
result = append(result, data[i])
17+
i++
18+
for i < n && data[i] != '"' {
19+
if data[i] == '\\' && i+1 < n {
20+
result = append(result, data[i], data[i+1])
21+
i += 2
22+
continue
23+
}
24+
result = append(result, data[i])
25+
i++
26+
}
27+
if i < n {
28+
result = append(result, data[i]) // closing "
29+
i++
30+
}
31+
continue
32+
}
33+
34+
// Line comment: skip to end of line
35+
if i+1 < n && data[i] == '/' && data[i+1] == '/' {
36+
i += 2
37+
for i < n && data[i] != '\n' {
38+
i++
39+
}
40+
continue
41+
}
42+
43+
// Block comment: skip to closing */
44+
if i+1 < n && data[i] == '/' && data[i+1] == '*' {
45+
i += 2
46+
for i+1 < n {
47+
if data[i] == '*' && data[i+1] == '/' {
48+
i += 2
49+
break
50+
}
51+
i++
52+
}
53+
continue
54+
}
55+
56+
result = append(result, data[i])
57+
i++
58+
}
59+
60+
// Second pass: remove trailing commas before ] or }
61+
cleaned := make([]byte, 0, len(result))
62+
for i := 0; i < len(result); i++ {
63+
if result[i] == ',' {
64+
j := i + 1
65+
for j < len(result) && (result[j] == ' ' || result[j] == '\t' || result[j] == '\n' || result[j] == '\r') {
66+
j++
67+
}
68+
if j < len(result) && (result[j] == ']' || result[j] == '}') {
69+
continue // skip trailing comma
70+
}
71+
}
72+
cleaned = append(cleaned, result[i])
73+
}
74+
75+
return cleaned
76+
}

cmd/modern/root/open/jsonc_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package open
5+
6+
import (
7+
"encoding/json"
8+
"testing"
9+
)
10+
11+
func TestStripJSONC_LineComments(t *testing.T) {
12+
input := []byte(`{
13+
// This is a comment
14+
"key": "value" // inline comment
15+
}`)
16+
result := stripJSONC(input)
17+
var m map[string]interface{}
18+
if err := json.Unmarshal(result, &m); err != nil {
19+
t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result)
20+
}
21+
if m["key"] != "value" {
22+
t.Errorf("Expected 'value', got %v", m["key"])
23+
}
24+
}
25+
26+
func TestStripJSONC_BlockComments(t *testing.T) {
27+
input := []byte(`{
28+
/* block comment */
29+
"key": "value",
30+
/*
31+
* multi-line
32+
* block comment
33+
*/
34+
"other": 42
35+
}`)
36+
result := stripJSONC(input)
37+
var m map[string]interface{}
38+
if err := json.Unmarshal(result, &m); err != nil {
39+
t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result)
40+
}
41+
if m["key"] != "value" {
42+
t.Errorf("Expected 'value', got %v", m["key"])
43+
}
44+
if m["other"] != float64(42) {
45+
t.Errorf("Expected 42, got %v", m["other"])
46+
}
47+
}
48+
49+
func TestStripJSONC_TrailingCommas(t *testing.T) {
50+
input := []byte(`{
51+
"a": 1,
52+
"b": [1, 2, 3,],
53+
"c": {"x": 1,},
54+
}`)
55+
result := stripJSONC(input)
56+
var m map[string]interface{}
57+
if err := json.Unmarshal(result, &m); err != nil {
58+
t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result)
59+
}
60+
if m["a"] != float64(1) {
61+
t.Errorf("Expected 1, got %v", m["a"])
62+
}
63+
}
64+
65+
func TestStripJSONC_CommentsInStringsPreserved(t *testing.T) {
66+
input := []byte(`{
67+
"url": "http://example.com",
68+
"note": "has // slashes and /* stars */",
69+
"path": "C:\\Users\\test"
70+
}`)
71+
result := stripJSONC(input)
72+
var m map[string]interface{}
73+
if err := json.Unmarshal(result, &m); err != nil {
74+
t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result)
75+
}
76+
if m["url"] != "http://example.com" {
77+
t.Errorf("URL mangled: %v", m["url"])
78+
}
79+
if m["note"] != "has // slashes and /* stars */" {
80+
t.Errorf("String with comment-like content mangled: %v", m["note"])
81+
}
82+
if m["path"] != `C:\Users\test` {
83+
t.Errorf("Escaped path mangled: %v", m["path"])
84+
}
85+
}
86+
87+
func TestStripJSONC_RealWorldVSCodeSettings(t *testing.T) {
88+
// Realistic VS Code settings.json with JSONC features
89+
input := []byte(`{
90+
// Editor settings
91+
"editor.fontSize": 14,
92+
"editor.tabSize": 2,
93+
94+
/* Database connections */
95+
"mssql.connections": [
96+
{
97+
"server": "localhost,1433",
98+
"profileName": "my-db",
99+
"encrypt": "Optional",
100+
"trustServerCertificate": true,
101+
},
102+
],
103+
104+
// Terminal settings
105+
"terminal.integrated.fontSize": 12,
106+
}`)
107+
result := stripJSONC(input)
108+
var m map[string]interface{}
109+
if err := json.Unmarshal(result, &m); err != nil {
110+
t.Fatalf("Failed to parse real-world JSONC: %v\nResult: %s", err, result)
111+
}
112+
if m["editor.fontSize"] != float64(14) {
113+
t.Errorf("Expected fontSize 14, got %v", m["editor.fontSize"])
114+
}
115+
conns, ok := m["mssql.connections"].([]interface{})
116+
if !ok || len(conns) != 1 {
117+
t.Fatalf("Expected 1 connection, got %v", m["mssql.connections"])
118+
}
119+
}
120+
121+
func TestStripJSONC_EmptyInput(t *testing.T) {
122+
result := stripJSONC([]byte{})
123+
if len(result) != 0 {
124+
t.Errorf("Expected empty result, got %s", result)
125+
}
126+
}
127+
128+
func TestStripJSONC_PureJSON(t *testing.T) {
129+
// No comments, no trailing commas - should pass through cleanly
130+
input := []byte(`{"key": "value", "num": 42}`)
131+
result := stripJSONC(input)
132+
var m map[string]interface{}
133+
if err := json.Unmarshal(result, &m); err != nil {
134+
t.Fatalf("Failed to parse pure JSON: %v", err)
135+
}
136+
if m["key"] != "value" || m["num"] != float64(42) {
137+
t.Errorf("Values changed: %v", m)
138+
}
139+
}

cmd/modern/root/open/vscode.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ func (c *VSCode) readSettings(path string) map[string]interface{} {
163163
}
164164

165165
if len(data) > 0 {
166-
if err := json.Unmarshal(data, &settings); err != nil {
166+
// VS Code settings.json is JSONC (allows comments and trailing commas).
167+
// Strip those before parsing so standard json.Unmarshal succeeds.
168+
clean := stripJSONC(data)
169+
if err := json.Unmarshal(clean, &settings); err != nil {
167170
output := c.Output()
168171
output.FatalWithHintExamples([][]string{
169172
{localizer.Sprintf("Error"), err.Error()},
@@ -184,6 +187,28 @@ func (c *VSCode) writeSettings(path string, settings map[string]interface{}) {
184187
}, localizer.Sprintf("Failed to encode VS Code settings"))
185188
}
186189

190+
// Append a final newline for consistency with VS Code's own formatting
191+
data = append(data, '\n')
192+
193+
// Atomic write: write to a temp file in the same directory, then rename.
194+
// If rename fails (e.g. another process holds the file), fall back to
195+
// a direct write so the command still succeeds.
196+
dir := filepath.Dir(path)
197+
tmp, tmpErr := os.CreateTemp(dir, ".settings-*.tmp")
198+
if tmpErr == nil {
199+
tmpPath := tmp.Name()
200+
_, writeErr := tmp.Write(data)
201+
closeErr := tmp.Close()
202+
if writeErr != nil || closeErr != nil {
203+
os.Remove(tmpPath)
204+
} else if renameErr := os.Rename(tmpPath, path); renameErr != nil {
205+
os.Remove(tmpPath)
206+
} else {
207+
return // atomic write succeeded
208+
}
209+
}
210+
211+
// Fallback: direct write
187212
if err := os.WriteFile(path, data, 0600); err != nil {
188213
output.FatalWithHintExamples([][]string{
189214
{localizer.Sprintf("Error"), err.Error()},

0 commit comments

Comments
 (0)