Skip to content
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# json-cursor-path

Convert a cursor position in a JSON file to the path in the parsed JSON object.
![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
![size](https://img.shields.io/badge/size-~1KB-blue)

Get the JSONPath at any cursor position in JSON text. Perfect for editor plugins, JSON tools, and autocomplete features.

## Getting started

Expand Down
4 changes: 2 additions & 2 deletions dist/json-cursor-path.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion jest.config.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
collectCoverage: true,
collectCoverageFrom: [
'src/**/JsonParser.ts'
'src/**/json-cursor-path.ts'
],
coverageProvider: 'v8',
testMatch: [
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "json-cursor-path",
"version": "1.0.1",
"version": "1.1.0",
"repository": {
"type": "git",
"url": "https://github.com/7PH/json-cursor-path.git"
Expand All @@ -10,7 +10,7 @@
"import": "./build/json-cursor-path.js",
"require": "./build/json-cursor-path.cjs"
},
"description": "Convert a position in a raw JSON to the path in the parsed object",
"description": "Get the JSONPath at any cursor position in JSON text",
"main": "build/json-cursor-path.js",
"types": "build/json-cursor-path.d.ts",
"files": [
Expand All @@ -24,12 +24,18 @@
},
"keywords": [
"json",
"jsonpath",
"cursor",
"editor",
"autocomplete",
"json-cursor",
"json-parser",
"json-path",
"parser"
],
"author": "7PH <b.raymond@protonmail.com",
"homepage": "https://github.com/7PH/json-cursor-path",
"bugs": "https://github.com/7PH/json-cursor-path/issues",
"author": "7PH <b.raymond@protonmail.com>",
"license": "MIT",
"devDependencies": {
"@babel/preset-env": "^7.24.7",
Expand All @@ -48,7 +54,7 @@
},
"size-limit": [
{
"limit": "999 B",
"limit": "1099 B",
"path": "dist/*.min.js"
}
]
Expand Down
109 changes: 79 additions & 30 deletions src/json-cursor-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ type ParseStepResult = {
};

export class JsonCursorPath {
/** First characters of literal values: true, false, null */
private static readonly TOKEN_LITERAL = ['t', 'f', 'n'];

/** Tokens that can start a JSON value */
private static readonly TOKEN_VALUE_START = [
'{',
'[',
'"',
...JsonCursorPath.TOKEN_LITERAL,
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
];

/** Tokens that end a JSON value (separators) */
private static readonly TOKEN_SEPARATOR = [',', '}', ']'];

private options: JsonCursorPathOptions;

/**
Expand Down Expand Up @@ -76,7 +100,7 @@ export class JsonCursorPath {

/**
* Convert a path to a string representation.
* This should be compatible with third party libraries eg
* Output follows JSONPath notation (e.g., $.store.book[0].title).
*/
rawPathToString(path: PathToCursor): string {
let pathStr = '$';
Expand All @@ -103,10 +127,10 @@ export class JsonCursorPath {
private parseArray(startIndex: number, index: number = 0): ParseStepResult {
// Check whether the array is empty
if (index === 0) {
const firstTokenIndex = this.parseUntilToken(
startIndex + 1,
'{["0123456789tf]'.split(''),
);
const firstTokenIndex = this.parseUntilToken(startIndex + 1, [
...JsonCursorPath.TOKEN_VALUE_START,
']',
]);
if (this.code[firstTokenIndex] === ']') {
return {
endIndex: firstTokenIndex,
Expand All @@ -115,8 +139,6 @@ export class JsonCursorPath {
}
}

// TODO: If not empty array, teleport index to `firstTokenIndex` to avoid double traversal

this.path.push({
type: 'array',
index,
Expand All @@ -130,7 +152,8 @@ export class JsonCursorPath {

this.path.pop();

if (this.code[result.endIndex] === ']') {
// End of array or end of input (malformed JSON with trailing comma)
if (this.code[result.endIndex] === ']' || result.endIndex >= this.code.length) {
return result;
}

Expand Down Expand Up @@ -176,14 +199,14 @@ export class JsonCursorPath {
*/
private parseObjectKey(startIndex: number): ParseStepResult & { key?: string } {
const keyStart = this.parseUntilToken(startIndex, ['"', '}']);
if (this.code[keyStart] === '}') {
if (this.code[keyStart] === '}' || keyStart >= this.code.length) {
// No entries in the object
return {
endIndex: keyStart,
found: this.cursorWithin(startIndex, keyStart),
};
}
const keyEnd = this.parseUntilToken(keyStart + 1, '"', true);
const keyEnd = this.parseUntilToken(keyStart + 1, '"');
const key = this.code.slice(keyStart + 1, keyEnd);

const colonIndex = this.parseUntilToken(keyEnd, ':');
Expand All @@ -200,8 +223,7 @@ export class JsonCursorPath {
*/
private parseValue(index: number): ParseStepResult {
// Then, it's either an object, a number or a string
// TODO: We could be more defensive here and accept `undefined`, or any other litteral
const valueStart = this.parseUntilToken(index, '{["0123456789tfn'.split(''));
const valueStart = this.parseUntilToken(index, JsonCursorPath.TOKEN_VALUE_START);
const valueChar = this.code[valueStart];
let valueEnd: number;
if (valueChar === '{') {
Expand All @@ -225,16 +247,21 @@ export class JsonCursorPath {
if (result.found) {
return result;
}
} else if (['t', 'f', 'n'].includes(valueChar)) {
// Litteral
valueEnd = this.parseAnyLitteral(valueStart);
} else if (JsonCursorPath.TOKEN_LITERAL.includes(valueChar)) {
// Literal
valueEnd = this.parseAnyLiteral(valueStart);
} else {
// Number
valueEnd = this.parseUntilToken(valueStart + 1, [',', '}', ']', ' ', '\n']) - 1;
valueEnd =
this.parseUntilToken(valueStart + 1, [
...JsonCursorPath.TOKEN_SEPARATOR,
' ',
'\n',
]) - 1;
}

// Find the next key or end of object/array
const separatorIndex = this.parseUntilToken(valueEnd + 1, [',', '}', ']']);
const separatorIndex = this.parseUntilToken(valueEnd + 1, JsonCursorPath.TOKEN_SEPARATOR);

// Cursor somewhere within the value?
const found = this.cursorWithin(index, valueEnd);
Expand All @@ -244,23 +271,48 @@ export class JsonCursorPath {
};
}

/**
* Get the logical character index within a string, accounting for escape sequences.
* For example, in the string "hello\nworld", the cursor after '\n' is at logical index 6,
* even though the raw byte offset would be 7.
*/
private getStringCursorIndex(firstQuoteIndex: number, endQuoteIndex: number): number {
const cursorOffset = this.cursorPosition - firstQuoteIndex;

// If cursor is on or before the opening quote, return 0
if (cursorOffset <= 0) {
return 0;
}

let logicalIndex = -1;
let rawOffset = 0;

while (rawOffset < cursorOffset) {
// Check if current char is a backslash (escape sequence)
if (this.code[firstQuoteIndex + 1 + rawOffset] === '\\') {
rawOffset += 2; // Skip the escape sequence (e.g., \n, \", \\)
} else {
rawOffset += 1;
}
logicalIndex++;
}

// Clamp to valid range (max is string length - 1)
return Math.max(0, Math.min(logicalIndex, endQuoteIndex - firstQuoteIndex - 2));
}

/**
* Parse a string value. Place the cursor at the end quote.
*/
private parseString(firstQuoteIndex: number): ParseStepResult {
const endQuoteIndex = this.parseUntilToken(firstQuoteIndex + 1, '"', true);
const endQuoteIndex = this.parseUntilToken(firstQuoteIndex + 1, '"');

// Cursor within string value
if (this.options.specifyStringIndex && this.cursorWithin(firstQuoteIndex, endQuoteIndex)) {
if (endQuoteIndex - firstQuoteIndex > 1) {
// We make it such that if the cursor is on a quote, it is considered to be within the string
let index = this.cursorPosition - firstQuoteIndex - 1;
index = Math.min(index, endQuoteIndex - firstQuoteIndex - 2);
index = Math.max(0, index);

this.path.push({
type: 'string',
index,
index: this.getStringCursorIndex(firstQuoteIndex, endQuoteIndex),
});
}

Expand All @@ -277,9 +329,9 @@ export class JsonCursorPath {
}

/**
* Parse any litteral. Place the cursor at the end of the litteral (last char).
* Parse any literal. Place the cursor at the end of the literal (last char).
*/
private parseAnyLitteral(index: number): number {
private parseAnyLiteral(index: number): number {
while (++index < this.code.length) {
const char = this.code[index];
if (!/[a-zA-Z]/.test(char)) {
Expand All @@ -293,14 +345,11 @@ export class JsonCursorPath {
* Return the first index of the next/prev specified token.
* If not found, return the index of the end of the code (code.length) or -1 depending on the direction.
*/
private parseUntilToken(index: number, token: string | string[], ignoreEscaped = true): number {
private parseUntilToken(index: number, token: string | string[]): number {
const tokens = Array.isArray(token) ? token : [token];

while (index < this.code.length && index >= 0) {
if (tokens.includes(this.code[index])) {
if (!ignoreEscaped) {
return index;
}
// Count number of `\` before the token. If there is an even number, the token is not escaped
// eg \\\\" -> 4 slashes, not escaped
// eg \\\" -> 3 slashes, escaped
Expand Down
56 changes: 54 additions & 2 deletions test/json-cursor-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,17 @@ describe('JsonCursorPath', () => {
...OPTIONS,
});
const cursor = 111;
// String index is 17 (logical character index), not 20 (raw byte offset)
// because escape sequences like \" and \\\\ count as single characters
expect(parser.get(cursor)).toBe(
'$["\\"{}}[]]].:-]\\\\\\\\"][0][0]["\\"{}}[]]].:-]\\\\\\\\"][20]',
'$["\\"{}}[]]].:-]\\\\\\\\"][0][0]["\\"{}}[]]].:-]\\\\\\\\"][17]',
);
expect(parser.get(cursor, true)).toEqual([
{ type: 'object', key: '\\"{}}[]]].:-]\\\\\\\\' },
{ type: 'array', index: 0 },
{ type: 'array', index: 0 },
{ type: 'object', key: '\\"{}}[]]].:-]\\\\\\\\' },
{ type: 'string', index: 20 },
{ type: 'string', index: 17 },
]);
});

Expand Down Expand Up @@ -123,6 +125,56 @@ describe('JsonCursorPath', () => {
});
});

describe('should handle malformed JSON gracefully', () => {
it('does not crash on trailing comma in array', () => {
const parser = new JsonCursorPath('[1,]', OPTIONS);
// Should not throw - just verify it returns something without stack overflow
expect(() => parser.get(0)).not.toThrow();
expect(() => parser.get(1)).not.toThrow();
expect(() => parser.get(2)).not.toThrow();
expect(() => parser.get(3)).not.toThrow();
// Cursor on '1' should return the first element path
expect(parser.get(1)).toBe('$[0]');
});

it('does not crash on trailing comma in nested array', () => {
const parser = new JsonCursorPath('[1, 2, [3,]]', OPTIONS);
// Should not throw
for (let i = 0; i < 12; i++) {
expect(() => parser.get(i)).not.toThrow();
}
// Cursor on '3' should return nested path
expect(parser.get(8)).toBe('$[2][0]');
});

it('does not crash on trailing comma in object', () => {
const parser = new JsonCursorPath('{"a": 1,}', OPTIONS);
// Should not throw
for (let i = 0; i < 9; i++) {
expect(() => parser.get(i)).not.toThrow();
}
});
});

describe('should handle edge cases', () => {
it('returns null when cursor is beyond JSON content', () => {
const parser = new JsonCursorPath('{"a": 1}', OPTIONS);
expect(parser.get(100)).toBeNull();
});

it('returns string index 0 when cursor is on opening quote', () => {
const parser = new JsonCursorPath('{"a": "hello"}', { specifyStringIndex: true });
// Cursor on the opening " of "hello" (index 6)
expect(parser.get(6)).toBe('$.a[0]');
});

it('handles JSON that is just a keyword', () => {
expect(new JsonCursorPath('true', OPTIONS).get(2)).toBe('$');
expect(new JsonCursorPath('false', OPTIONS).get(3)).toBe('$');
expect(new JsonCursorPath('null', OPTIONS).get(1)).toBe('$');
});
});

describe('should return correct path in numbers', () => {
it('gets cursor path in a number value', () => {
const parser = new JsonCursorPath(fixtures['00-object-simple.json'], { ...OPTIONS });
Expand Down