@@ -20,41 +20,89 @@ import {
2020} from 'jsonc-parser' ;
2121import { readFileSync , writeFileSync } from 'node:fs' ;
2222import { getEOL } from './eol' ;
23+ import { assertIsError } from './error' ;
2324
25+ /** A function that returns an index to insert a new property in a JSON object. */
2426export type InsertionIndex = ( properties : string [ ] ) => number ;
27+
28+ /** A JSON path. */
2529export type JSONPath = ( string | number ) [ ] ;
2630
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+ */
2837export 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 = '' ;
3877 }
3978
40- this . eol = getEOL ( this . content ) ;
79+ this . #eol = getEOL ( this . #content) ;
80+ this . #detectIndentation( ) ;
4181 }
4282
43- private _jsonAst : Node | undefined ;
83+ /**
84+ * Gets the parsed JSON abstract syntax tree.
85+ * The AST is lazily parsed and cached.
86+ */
4487 private get JsonAst ( ) : Node | undefined {
45- if ( this . _jsonAst ) {
46- return this . _jsonAst ;
88+ if ( this . #jsonAst ) {
89+ return this . #jsonAst ;
4790 }
4891
4992 const errors : ParseError [ ] = [ ] ;
50- this . _jsonAst = parseTree ( this . content , errors , { allowTrailingComma : true } ) ;
93+ this . #jsonAst = parseTree ( this . # content, errors , { allowTrailingComma : true } ) ;
5194 if ( errors . length ) {
52- formatError ( this . path , errors ) ;
95+ formatError ( this . # path, errors ) ;
5396 }
5497
55- return this . _jsonAst ;
98+ return this . #jsonAst ;
5699 }
57100
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+ */
58106 get ( jsonPath : JSONPath ) : unknown {
59107 const jsonAstNode = this . JsonAst ;
60108 if ( ! jsonAstNode ) {
@@ -70,6 +118,13 @@ export class JSONFile {
70118 return node === undefined ? undefined : getNodeValue ( node ) ;
71119 }
72120
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+ */
73128 modify (
74129 jsonPath : JSONPath ,
75130 value : JsonValue | undefined ,
@@ -89,42 +144,70 @@ export class JSONFile {
89144 getInsertionIndex = insertInOrder ;
90145 }
91146
92- const edits = modify ( this . content , jsonPath , value , {
147+ const edits = modify ( this . # content, jsonPath , value , {
93148 getInsertionIndex,
94- // TODO: use indentation from original file.
95149 formattingOptions : {
96- insertSpaces : true ,
97- tabSize : 2 ,
98- eol : this . eol ,
150+ insertSpaces : this . #insertSpaces ,
151+ tabSize : this . #tabSize ,
152+ eol : this . # eol,
99153 } ,
100154 } ) ;
101155
102156 if ( edits . length === 0 ) {
103157 return false ;
104158 }
105159
106- this . content = applyEdits ( this . content , edits ) ;
107- this . _jsonAst = undefined ;
160+ this . # content = applyEdits ( this . # content, edits ) ;
161+ this . #jsonAst = undefined ;
108162
109163 return true ;
110164 }
111165
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. */
112176 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+ }
114188 }
115189}
116190
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 {
119197 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 ;
121199 if ( errors . length ) {
122200 formatError ( path , errors ) ;
123201 }
124202
125203 return content ;
126204}
127205
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+ */
128211function formatError ( path : string , errors : ParseError [ ] ) : never {
129212 const { error, offset } = errors [ 0 ] ;
130213 throw new Error (
@@ -134,7 +217,11 @@ function formatError(path: string, errors: ParseError[]): never {
134217 ) ;
135218}
136219
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 ;
140227}
0 commit comments