Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the visitor to cease callbacks #88

Merged
merged 4 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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