@@ -20,41 +20,89 @@ import {
20
20
} from 'jsonc-parser' ;
21
21
import { readFileSync , writeFileSync } from 'node:fs' ;
22
22
import { getEOL } from './eol' ;
23
+ import { assertIsError } from './error' ;
23
24
25
+ /** A function that returns an index to insert a new property in a JSON object. */
24
26
export type InsertionIndex = ( properties : string [ ] ) => number ;
27
+
28
+ /** A JSON path. */
25
29
export type JSONPath = ( string | number ) [ ] ;
26
30
27
- /** @internal */
31
+ /**
32
+ * Represents a JSON file, allowing for reading, modifying, and saving.
33
+ * This class uses `jsonc-parser` to preserve comments and formatting, including
34
+ * indentation and end-of-line sequences.
35
+ * @internal
36
+ */
28
37
export class JSONFile {
29
- content : string ;
30
- private eol : string ;
31
-
32
- constructor ( private readonly path : string ) {
33
- const buffer = readFileSync ( this . path ) ;
34
- if ( buffer ) {
35
- this . content = buffer . toString ( ) ;
36
- } else {
37
- throw new Error ( `Could not read '${ path } '.` ) ;
38
+ /** The raw content of the JSON file. */
39
+ #content: string ;
40
+
41
+ /** The end-of-line sequence used in the file. */
42
+ #eol: string ;
43
+
44
+ /** Whether the file uses spaces for indentation. */
45
+ #insertSpaces = true ;
46
+
47
+ /** The number of spaces or tabs used for indentation. */
48
+ #tabSize = 2 ;
49
+
50
+ /** The path to the JSON file. */
51
+ #path: string ;
52
+
53
+ /** The parsed JSON abstract syntax tree. */
54
+ #jsonAst: Node | undefined ;
55
+
56
+ /** The raw content of the JSON file. */
57
+ public get content ( ) : string {
58
+ return this . #content;
59
+ }
60
+
61
+ /**
62
+ * Creates an instance of JSONFile.
63
+ * @param path The path to the JSON file.
64
+ */
65
+ constructor ( path : string ) {
66
+ this . #path = path ;
67
+ try {
68
+ this . #content = readFileSync ( this . #path, 'utf-8' ) ;
69
+ } catch ( e ) {
70
+ assertIsError ( e ) ;
71
+ // We don't have to worry about ENOENT, since we'll be creating the file.
72
+ if ( e . code !== 'ENOENT' ) {
73
+ throw e ;
74
+ }
75
+
76
+ this . #content = '' ;
38
77
}
39
78
40
- this . eol = getEOL ( this . content ) ;
79
+ this . #eol = getEOL ( this . #content) ;
80
+ this . #detectIndentation( ) ;
41
81
}
42
82
43
- private _jsonAst : Node | undefined ;
83
+ /**
84
+ * Gets the parsed JSON abstract syntax tree.
85
+ * The AST is lazily parsed and cached.
86
+ */
44
87
private get JsonAst ( ) : Node | undefined {
45
- if ( this . _jsonAst ) {
46
- return this . _jsonAst ;
88
+ if ( this . #jsonAst ) {
89
+ return this . #jsonAst ;
47
90
}
48
91
49
92
const errors : ParseError [ ] = [ ] ;
50
- this . _jsonAst = parseTree ( this . content , errors , { allowTrailingComma : true } ) ;
93
+ this . #jsonAst = parseTree ( this . # content, errors , { allowTrailingComma : true } ) ;
51
94
if ( errors . length ) {
52
- formatError ( this . path , errors ) ;
95
+ formatError ( this . # path, errors ) ;
53
96
}
54
97
55
- return this . _jsonAst ;
98
+ return this . #jsonAst ;
56
99
}
57
100
101
+ /**
102
+ * Gets a value from the JSON file at a specific path.
103
+ * @param jsonPath The path to the value.
104
+ * @returns The value at the given path, or `undefined` if not found.
105
+ */
58
106
get ( jsonPath : JSONPath ) : unknown {
59
107
const jsonAstNode = this . JsonAst ;
60
108
if ( ! jsonAstNode ) {
@@ -70,6 +118,13 @@ export class JSONFile {
70
118
return node === undefined ? undefined : getNodeValue ( node ) ;
71
119
}
72
120
121
+ /**
122
+ * Modifies a value in the JSON file.
123
+ * @param jsonPath The path to the value to modify.
124
+ * @param value The new value to insert.
125
+ * @param insertInOrder A function to determine the insertion index, or `false` to insert at the end.
126
+ * @returns `true` if the modification was successful, `false` otherwise.
127
+ */
73
128
modify (
74
129
jsonPath : JSONPath ,
75
130
value : JsonValue | undefined ,
@@ -89,42 +144,70 @@ export class JSONFile {
89
144
getInsertionIndex = insertInOrder ;
90
145
}
91
146
92
- const edits = modify ( this . content , jsonPath , value , {
147
+ const edits = modify ( this . # content, jsonPath , value , {
93
148
getInsertionIndex,
94
- // TODO: use indentation from original file.
95
149
formattingOptions : {
96
- insertSpaces : true ,
97
- tabSize : 2 ,
98
- eol : this . eol ,
150
+ insertSpaces : this . #insertSpaces ,
151
+ tabSize : this . #tabSize ,
152
+ eol : this . # eol,
99
153
} ,
100
154
} ) ;
101
155
102
156
if ( edits . length === 0 ) {
103
157
return false ;
104
158
}
105
159
106
- this . content = applyEdits ( this . content , edits ) ;
107
- this . _jsonAst = undefined ;
160
+ this . # content = applyEdits ( this . # content, edits ) ;
161
+ this . #jsonAst = undefined ;
108
162
109
163
return true ;
110
164
}
111
165
166
+ /**
167
+ * Deletes a value from the JSON file at a specific path.
168
+ * @param jsonPath The path to the value to delete.
169
+ * @returns `true` if the deletion was successful, `false` otherwise.
170
+ */
171
+ delete ( jsonPath : JSONPath ) : boolean {
172
+ return this . modify ( jsonPath , undefined ) ;
173
+ }
174
+
175
+ /** Saves the modified content back to the file. */
112
176
save ( ) : void {
113
- writeFileSync ( this . path , this . content ) ;
177
+ writeFileSync ( this . #path, this . #content) ;
178
+ }
179
+
180
+ /** Detects the indentation of the file. */
181
+ #detectIndentation( ) : void {
182
+ // Find the first line that has indentation.
183
+ const match = this . #content. match ( / ^ (?: ( ) + | \t + ) \S / m) ;
184
+ if ( match ) {
185
+ this . #insertSpaces = ! ! match [ 1 ] ;
186
+ this . #tabSize = match [ 0 ] . length - 1 ;
187
+ }
114
188
}
115
189
}
116
190
117
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- export function readAndParseJson ( path : string ) : any {
191
+ /**
192
+ * Reads and parses a JSON file, supporting comments and trailing commas.
193
+ * @param path The path to the JSON file.
194
+ * @returns The parsed JSON object.
195
+ */
196
+ export function readAndParseJson < T extends JsonValue > ( path : string ) : T {
119
197
const errors : ParseError [ ] = [ ] ;
120
- const content = parse ( readFileSync ( path , 'utf-8' ) , errors , { allowTrailingComma : true } ) ;
198
+ const content = parse ( readFileSync ( path , 'utf-8' ) , errors , { allowTrailingComma : true } ) as T ;
121
199
if ( errors . length ) {
122
200
formatError ( path , errors ) ;
123
201
}
124
202
125
203
return content ;
126
204
}
127
205
206
+ /**
207
+ * Formats a JSON parsing error and throws an exception.
208
+ * @param path The path to the file that failed to parse.
209
+ * @param errors The list of parsing errors.
210
+ */
128
211
function formatError ( path : string , errors : ParseError [ ] ) : never {
129
212
const { error, offset } = errors [ 0 ] ;
130
213
throw new Error (
@@ -134,7 +217,11 @@ function formatError(path: string, errors: ParseError[]): never {
134
217
) ;
135
218
}
136
219
137
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
- export function parseJson ( content : string ) : any {
139
- return parse ( content , undefined , { allowTrailingComma : true } ) ;
220
+ /**
221
+ * Parses a JSON string, supporting comments and trailing commas.
222
+ * @param content The JSON string to parse.
223
+ * @returns The parsed JSON object.
224
+ */
225
+ export function parseJson < T extends JsonValue > ( content : string ) : T {
226
+ return parse ( content , undefined , { allowTrailingComma : true } ) as T ;
140
227
}
0 commit comments