diff --git a/CHANGELOG.md b/CHANGELOG.md index b43a083..3414a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +3.3.0 2022-06-24 +================= +- `JSONVisitor.onObjectBegin` and `JSONVisitor.onArrayBegin` can now return `false` to instruct the visitor that no children should be visited. + 3.2.0 2022-08-30 ================= diff --git a/README.md b/README.md index 5fd92d9..d569b70 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ JSONC is JSON with JavaScript style comments. This node module provides a scanne - the *scanner* tokenizes the input string into tokens and token offsets - the *visit* function implements a 'SAX' style parser with callbacks for the encountered properties and values. - the *parseTree* function computes a hierarchical DOM with offsets representing the encountered properties and values. - - the *parse* function evaluates the JavaScript object represented by JSON string in a fault tolerant fashion. + - the *parse* function evaluates the JavaScript object represented by JSON string in a fault tolerant fashion. - the *getLocation* API returns a location object that describes the property or value located at a given offset in a JSON document. - the *findNodeAtLocation* API finds the node at a given location path in a JSON DOM. - the *format* API computes edits to format a JSON document. @@ -37,7 +37,7 @@ API * If ignoreTrivia is set, whitespaces or comments are ignored. */ export function createScanner(text: string, ignoreTrivia: boolean = false): JSONScanner; - + /** * The scanner object, representing a JSON scanner at a position in the input string. */ @@ -106,20 +106,21 @@ export declare function visit(text: string, visitor: JSONVisitor, options?: Pars /** * Visitor called by {@linkcode visit} when parsing JSON. - * + * * The visitor functions have the following common parameters: * - `offset`: Global offset within the JSON document, starting at 0 * - `startLine`: Line number, starting at 0 * - `startCharacter`: Start character (column) within the current line, starting at 0 - * + * * Additionally some functions have a `pathSupplier` parameter which can be used to obtain the * current `JSONPath` within the document. */ export interface JSONVisitor { /** * Invoked when an open brace is encountered and an object is started. The offset and length represent the location of the open brace. + * When `false` is returned, the array items will not be visited. */ - onObjectBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void; + onObjectBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void | boolean; /** * Invoked when a property is encountered. The offset and length represent the location of the property name. @@ -133,8 +134,9 @@ export interface JSONVisitor { onObjectEnd?: (offset: number, length: number, startLine: number, startCharacter: number) => void; /** * Invoked when an open bracket is encountered. The offset and length represent the location of the open bracket. + * When `false` is returned, the array items will not be visited.* */ - onArrayBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void; + onArrayBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void | boolean; /** * Invoked when a closing bracket is encountered. The offset and length represent the location of the closing bracket. */ @@ -233,14 +235,14 @@ export function findNodeAtOffset(root: Node, offset: number, includeRightBound?: export function getNodePath(node: Node): JSONPath; /** - * Evaluates the JavaScript object of the given JSON DOM node + * Evaluates the JavaScript object of the given JSON DOM node */ export function getNodeValue(node: Node): any; /** * Computes the edit operations needed to format a JSON document. - * - * @param documentText The input text + * + * @param documentText The input text * @param range The range to format or `undefined` to format the full content * @param options The formatting options * @returns The edit operations describing the formatting changes to the original document following the format described in {@linkcode EditResult}. @@ -250,10 +252,10 @@ export function format(documentText: string, range: Range, options: FormattingOp /** * Computes the edit operations needed to modify a value in the JSON document. - * - * @param documentText The input text + * + * @param documentText The input text * @param path The path of the value to change. The path represents either to the document root, a property or an array item. - * If the path points to an non-existing property or item, it will be created. + * If the path points to an non-existing property or item, it will be created. * @param value The new value for the specified property or item. If the value is undefined, * the property or item will be removed. * @param options Options @@ -264,7 +266,7 @@ export function modify(text: string, path: JSONPath, value: any, options: Modifi /** * Applies edits to an input string. - * @param text The input text + * @param text The input text * @param edits Edit operations following the format described in {@linkcode EditResult}. * @returns The text with the applied edits. * @throws An error if the edit operations are not well-formed as described in {@linkcode EditResult}. @@ -306,7 +308,7 @@ export interface Edit { */ export interface Range { /** - * The start offset of the range. + * The start offset of the range. */ offset: number; /** @@ -315,7 +317,7 @@ export interface Range { length: number; } -/** +/** * Options used by {@linkcode format} when computing the formatting edit operations */ export interface FormattingOptions { @@ -333,7 +335,7 @@ export interface FormattingOptions { eol: string; } -/** +/** * Options used by {@linkcode modify} when computing the modification edit operations */ export interface ModificationOptions { diff --git a/src/impl/parser.ts b/src/impl/parser.ts index 3680431..08697f3 100644 --- a/src/impl/parser.ts +++ b/src/impl/parser.ts @@ -390,24 +390,44 @@ export function visit(text: string, visitor: JSONVisitor, options: ParseOptions // to not affect visitor functions which stored a reference to a previous JSONPath const _jsonPath: JSONPath = []; + // Depth of onXXXBegin() callbacks suppressed. onXXXEnd() decrements this if it isn't 0 already. + // Callbacks are only called when this value is 0. + let suppressedCallbacks = 0; + function toNoArgVisit(visitFunction?: (offset: number, length: number, startLine: number, startCharacter: number) => void): () => void { - return visitFunction ? () => visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; - } - function toNoArgVisitWithPath(visitFunction?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void): () => void { - return visitFunction ? () => visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) : () => true; + return visitFunction ? () => suppressedCallbacks === 0 && visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; } function toOneArgVisit(visitFunction?: (arg: T, offset: number, length: number, startLine: number, startCharacter: number) => void): (arg: T) => void { - return visitFunction ? (arg: T) => visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; + return visitFunction ? (arg: T) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true; } function toOneArgVisitWithPath(visitFunction?: (arg: T, offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void): (arg: T) => void { - return visitFunction ? (arg: T) => visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) : () => true; + return visitFunction ? (arg: T) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) : () => true; + } + function toBeginVisit(visitFunction?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => boolean | void): () => void { + return visitFunction ? + () => { + if (suppressedCallbacks > 0) { suppressedCallbacks++; } + else { + let cbReturn = visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()); + if (cbReturn === false) { suppressedCallbacks = 1; } + } + } + : () => true; + } + function toEndVisit(visitFunction?: (offset: number, length: number, startLine: number, startCharacter: number) => void): () => void { + return visitFunction ? + () => { + if (suppressedCallbacks > 0) { suppressedCallbacks--; } + if (suppressedCallbacks === 0) { visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()); } + } + : () => true; } - const onObjectBegin = toNoArgVisitWithPath(visitor.onObjectBegin), + const onObjectBegin = toBeginVisit(visitor.onObjectBegin), onObjectProperty = toOneArgVisitWithPath(visitor.onObjectProperty), - onObjectEnd = toNoArgVisit(visitor.onObjectEnd), - onArrayBegin = toNoArgVisitWithPath(visitor.onArrayBegin), - onArrayEnd = toNoArgVisit(visitor.onArrayEnd), + onObjectEnd = toEndVisit(visitor.onObjectEnd), + onArrayBegin = toBeginVisit(visitor.onArrayBegin), + onArrayEnd = toEndVisit(visitor.onArrayEnd), onLiteralValue = toOneArgVisitWithPath(visitor.onLiteralValue), onSeparator = toOneArgVisit(visitor.onSeparator), onComment = toNoArgVisit(visitor.onComment), diff --git a/src/main.ts b/src/main.ts index 9852b67..f26bf20 100644 --- a/src/main.ts +++ b/src/main.ts @@ -124,7 +124,7 @@ export const findNodeAtOffset: (root: Node, offset: number, includeRightBound?: export const getNodePath: (node: Node) => JSONPath = parser.getNodePath; /** - * Evaluates the JavaScript object of the given JSON DOM node + * Evaluates the JavaScript object of the given JSON DOM node */ export const getNodeValue: (node: Node) => any = parser.getNodeValue; @@ -235,20 +235,21 @@ export interface ParseOptions { /** * Visitor called by {@linkcode visit} when parsing JSON. - * + * * The visitor functions have the following common parameters: * - `offset`: Global offset within the JSON document, starting at 0 * - `startLine`: Line number, starting at 0 * - `startCharacter`: Start character (column) within the current line, starting at 0 - * + * * Additionally some functions have a `pathSupplier` parameter which can be used to obtain the * current `JSONPath` within the document. */ export interface JSONVisitor { /** * Invoked when an open brace is encountered and an object is started. The offset and length represent the location of the open brace. + * When `false` is returned, the object properties will not be visited. */ - onObjectBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void; + onObjectBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => boolean | void; /** * Invoked when a property is encountered. The offset and length represent the location of the property name. @@ -264,8 +265,9 @@ export interface JSONVisitor { /** * Invoked when an open bracket is encountered. The offset and length represent the location of the open bracket. + * When `false` is returned, the array items will not be visited. */ - onArrayBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => void; + onArrayBegin?: (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => boolean | void; /** * Invoked when a closing bracket is encountered. The offset and length represent the location of the closing bracket. @@ -328,7 +330,7 @@ export interface Edit { */ export interface Range { /** - * The start offset of the range. + * The start offset of the range. */ offset: number; /** @@ -337,7 +339,7 @@ export interface Range { length: number; } -/** +/** * Options used by {@linkcode format} when computing the formatting edit operations */ export interface FormattingOptions { @@ -365,8 +367,8 @@ export interface FormattingOptions { /** * Computes the edit operations needed to format a JSON document. - * - * @param documentText The input text + * + * @param documentText The input text * @param range The range to format or `undefined` to format the full content * @param options The formatting options * @returns The edit operations describing the formatting changes to the original document following the format described in {@linkcode EditResult}. @@ -376,7 +378,7 @@ export function format(documentText: string, range: Range | undefined, options: return formatter.format(documentText, range, options); } -/** +/** * Options used by {@linkcode modify} when computing the modification edit operations */ export interface ModificationOptions { @@ -397,10 +399,10 @@ export interface ModificationOptions { /** * Computes the edit operations needed to modify a value in the JSON document. - * - * @param documentText The input text + * + * @param documentText The input text * @param path The path of the value to change. The path represents either to the document root, a property or an array item. - * If the path points to an non-existing property or item, it will be created. + * If the path points to an non-existing property or item, it will be created. * @param value The new value for the specified property or item. If the value is undefined, * the property or item will be removed. * @param options Options @@ -413,7 +415,7 @@ export function modify(text: string, path: JSONPath, value: any, options: Modifi /** * Applies edits to an input string. - * @param text The input text + * @param text The input text * @param edits Edit operations following the format described in {@linkcode EditResult}. * @returns The text with the applied edits. * @throws An error if the edit operations are not well-formed as described in {@linkcode EditResult}. diff --git a/src/test/json.test.ts b/src/test/json.test.ts index 4cf15c9..8779169 100644 --- a/src/test/json.test.ts +++ b/src/test/json.test.ts @@ -79,22 +79,22 @@ interface VisitorError extends ParseError { startCharacter: number; } -function assertVisit(input: string, expected: VisitorCallback[], expectedErrors: VisitorError[] = [], disallowComments = false): void { +function assertVisit(input: string, expected: VisitorCallback[], expectedErrors: VisitorError[] = [], disallowComments = false, stopOffsets?: number[]): void { let errors: VisitorError[] = []; let actuals: VisitorCallback[] = []; - let noArgHalder = (id: keyof JSONVisitor) => (offset: number, length: number, startLine: number, startCharacter: number) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter }); - let noArgHalderWithPath = (id: keyof JSONVisitor) => (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, path: pathSupplier() }); - let oneArgHalder = (id: keyof JSONVisitor) => (arg: any, offset: number, length: number, startLine: number, startCharacter: number) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, arg }); - let oneArgHalderWithPath = (id: keyof JSONVisitor) => (arg: any, offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, arg, path: pathSupplier() }); + let noArgHandler = (id: keyof JSONVisitor) => (offset: number, length: number, startLine: number, startCharacter: number) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter }); + let oneArgHandler = (id: keyof JSONVisitor) => (arg: any, offset: number, length: number, startLine: number, startCharacter: number) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, arg }); + let oneArgHandlerWithPath = (id: keyof JSONVisitor) => (arg: any, offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, arg, path: pathSupplier() }); + let beginHandler = (id: keyof JSONVisitor) => (offset: number, length: number, startLine: number, startCharacter: number, pathSupplier: () => JSONPath) => { actuals.push({ id, text: input.substr(offset, length), startLine, startCharacter, path: pathSupplier() }); return !stopOffsets || (stopOffsets.indexOf(offset) === -1); }; visit(input, { - onObjectBegin: noArgHalderWithPath('onObjectBegin'), - onObjectProperty: oneArgHalderWithPath('onObjectProperty'), - onObjectEnd: noArgHalder('onObjectEnd'), - onArrayBegin: noArgHalderWithPath('onArrayBegin'), - onArrayEnd: noArgHalder('onArrayEnd'), - onLiteralValue: oneArgHalderWithPath('onLiteralValue'), - onSeparator: oneArgHalder('onSeparator'), - onComment: noArgHalder('onComment'), + onObjectBegin: beginHandler('onObjectBegin'), + onObjectProperty: oneArgHandlerWithPath('onObjectProperty'), + onObjectEnd: noArgHandler('onObjectEnd'), + onArrayBegin: beginHandler('onArrayBegin'), + onArrayEnd: noArgHandler('onArrayEnd'), + onLiteralValue: oneArgHandlerWithPath('onLiteralValue'), + onSeparator: oneArgHandler('onSeparator'), + onComment: noArgHandler('onComment'), onError: (error: ParseErrorCode, offset: number, length: number, startLine: number, startCharacter: number) => { errors.push({ error, offset, length, startLine, startCharacter }); } @@ -458,6 +458,18 @@ suite('JSON', () => { { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 20 }, { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 22 }, ]); + assertVisit('{ "foo": "bar", "a": {"b": "c"} }', [ + { id: 'onObjectBegin', text: '{', startLine: 0, startCharacter: 0, path: [] }, + { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 32 }, + ], [], false, [0]); + assertVisit('{ "a": { "b": "c", "d": { "e": "f" } } }', [ + { id: 'onObjectBegin', text: '{', startLine: 0, startCharacter: 0, path: [] }, + { id: 'onObjectProperty', text: '"a"', startLine: 0, startCharacter: 2, arg: 'a', path: [] }, + { id: 'onSeparator', text: ':', startLine: 0, startCharacter: 5, arg: ':' }, + { id: 'onObjectBegin', text: '{', startLine: 0, startCharacter: 7, path: ['a'] }, + { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 37 }, + { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 39 } + ], [], true, [7]); }); test('visit: array', () => { @@ -514,6 +526,14 @@ suite('JSON', () => { { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 58 }, { id: 'onArrayEnd', text: ']', startLine: 0, startCharacter: 60 }, ]); + assertVisit('{ "foo": [ { "a": "b", "c:": "d", "d": { "e": "f" } } ] }', [ + { id: 'onObjectBegin', text: '{', startLine: 0, startCharacter: 0, path: [] }, + { id: 'onObjectProperty', text: '"foo"', startLine: 0, startCharacter: 2, arg: 'foo', path: [] }, + { id: 'onSeparator', text: ':', startLine: 0, startCharacter: 7, arg: ':' }, + { id: 'onArrayBegin', text: '[', startLine: 0, startCharacter: 9, path: ['foo'] }, + { id: 'onArrayEnd', text: ']', startLine: 0, startCharacter: 54 }, + { id: 'onObjectEnd', text: '}', startLine: 0, startCharacter: 56 } + ], [], true, [9]); }); test('visit: comment', () => {