Skip to content

Commit

Permalink
Allow the visitor to cease callbacks (#88)
Browse files Browse the repository at this point in the history
* Allow visitor to cease callbacks

* polish

* update docs

---------

Co-authored-by: Martin Aeschlimann <martinae@microsoft.com>
  • Loading branch information
Vbbab and aeschli committed Jun 24, 2024
1 parent cfe32ac commit 9e24580
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 53 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
=================
Expand Down
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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}.
Expand All @@ -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
Expand All @@ -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}.
Expand Down Expand Up @@ -306,7 +308,7 @@ export interface Edit {
*/
export interface Range {
/**
* The start offset of the range.
* The start offset of the range.
*/
offset: number;
/**
Expand All @@ -315,7 +317,7 @@ export interface Range {
length: number;
}

/**
/**
* Options used by {@linkcode format} when computing the formatting edit operations
*/
export interface FormattingOptions {
Expand All @@ -333,7 +335,7 @@ export interface FormattingOptions {
eol: string;
}

/**
/**
* Options used by {@linkcode modify} when computing the modification edit operations
*/
export interface ModificationOptions {
Expand Down
40 changes: 30 additions & 10 deletions src/impl/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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<T>(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),
Expand Down
30 changes: 16 additions & 14 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -328,7 +330,7 @@ export interface Edit {
*/
export interface Range {
/**
* The start offset of the range.
* The start offset of the range.
*/
offset: number;
/**
Expand All @@ -337,7 +339,7 @@ export interface Range {
length: number;
}

/**
/**
* Options used by {@linkcode format} when computing the formatting edit operations
*/
export interface FormattingOptions {
Expand Down Expand Up @@ -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}.
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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}.
Expand Down
46 changes: 33 additions & 13 deletions src/test/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit 9e24580

Please sign in to comment.