diff --git a/README.md b/README.md index abdaeba..0a9f978 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dist/json-cursor-path.min.js b/dist/json-cursor-path.min.js index eaba529..f5d81cc 100644 --- a/dist/json-cursor-path.min.js +++ b/dist/json-cursor-path.min.js @@ -1,2 +1,2 @@ -(function(o){"use strict";class h{constructor(e,t){this.cursorPosition=0,this.path=[],this.code=e,this.options={specifyStringIndex:!1,...t}}get(e,t){if(this.cursorPosition=e,this.path=[],!this.parseValue(0).found)return null;const r=[...this.path];return this.cursorPosition=0,this.path=[],t?r:this.rawPathToString(r)}rawPathToString(e){let t="$";for(const s of e)(s.type==="array"||s.type==="string")&&(t+=`[${s.index}]`),s.type==="object"&&(/[^\w]/.test(s.key)||s.key===""?t+=`["${s.key}"]`:t+=`.${s.key}`);return t}parseArray(e,t=0){if(t===0){const r=this.parseUntilToken(e+1,'{["0123456789tf]'.split(""));if(this.code[r]==="]")return{endIndex:r,found:this.cursorWithin(e,r)}}this.path.push({type:"array",index:t});const s=this.parseValue(e+1);return s.found||(this.path.pop(),this.code[s.endIndex]==="]")?s:this.parseArray(s.endIndex+1,t+1)}parseObject(e){const t=this.parseObjectKey(e);if(typeof t.key>"u"||(this.path.push({type:"object",key:t.key}),t.found))return t;const s=this.parseValue(t.endIndex+1);return s.found||(this.path.pop(),this.code[s.endIndex]==="}")?s:this.parseObject(s.endIndex+1)}parseObjectKey(e){const t=this.parseUntilToken(e,['"',"}"]);if(this.code[t]==="}")return{endIndex:t,found:this.cursorWithin(e,t)};const s=this.parseUntilToken(t+1,'"',!0),r=this.code.slice(t+1,s),i=this.parseUntilToken(s,":");return{key:r,endIndex:i,found:this.cursorWithin(t,i)}}parseValue(e){const t=this.parseUntilToken(e,'{["0123456789tfn'.split("")),s=this.code[t];let r;if(s==="{"){const n=this.parseObject(t);if(r=n.endIndex,n.found)return n}else if(s==="["){const n=this.parseArray(t);if(r=n.endIndex,n.found)return n}else if(s==='"'){const n=this.parseString(t);if(r=n.endIndex,n.found)return n}else["t","f","n"].includes(s)?r=this.parseAnyLitteral(t):r=this.parseUntilToken(t+1,[",","}","]"," ",` -`])-1;const i=this.parseUntilToken(r+1,[",","}","]"]);return{found:this.cursorWithin(e,r),endIndex:i}}parseString(e){const t=this.parseUntilToken(e+1,'"',!0);if(this.options.specifyStringIndex&&this.cursorWithin(e,t)){if(t-e>1){let s=this.cursorPosition-e-1;s=Math.min(s,t-e-2),s=Math.max(0,s),this.path.push({type:"string",index:s})}return{found:!0,endIndex:t}}return{found:this.cursorWithin(e,t),endIndex:t}}parseAnyLitteral(e){for(;++e=0;){if(r.includes(this.code[e])){if(!s)return e;let i=0;for(;this.code[e-1-i]==="\\";)i+=1;if(i%2===0)return e}e+=1}return e}cursorWithin(e,t){return e<=this.cursorPosition&&this.cursorPosition<=t}}o.JsonCursorPath=h})(this.window=this.window||{}); +(function(u){"use strict";const h=class o{constructor(e,t){this.cursorPosition=0,this.path=[],this.code=e,this.options={specifyStringIndex:!1,...t}}get(e,t){if(this.cursorPosition=e,this.path=[],!this.parseValue(0).found)return null;const r=[...this.path];return this.cursorPosition=0,this.path=[],t?r:this.rawPathToString(r)}rawPathToString(e){let t="$";for(const s of e)(s.type==="array"||s.type==="string")&&(t+=`[${s.index}]`),s.type==="object"&&(/[^\w]/.test(s.key)||s.key===""?t+=`["${s.key}"]`:t+=`.${s.key}`);return t}parseArray(e,t=0){if(t===0){const r=this.parseUntilToken(e+1,[...o.TOKEN_VALUE_START,"]"]);if(this.code[r]==="]")return{endIndex:r,found:this.cursorWithin(e,r)}}this.path.push({type:"array",index:t});const s=this.parseValue(e+1);return s.found||(this.path.pop(),this.code[s.endIndex]==="]"||s.endIndex>=this.code.length)?s:this.parseArray(s.endIndex+1,t+1)}parseObject(e){const t=this.parseObjectKey(e);if(typeof t.key>"u"||(this.path.push({type:"object",key:t.key}),t.found))return t;const s=this.parseValue(t.endIndex+1);return s.found||(this.path.pop(),this.code[s.endIndex]==="}")?s:this.parseObject(s.endIndex+1)}parseObjectKey(e){const t=this.parseUntilToken(e,['"',"}"]);if(this.code[t]==="}"||t>=this.code.length)return{endIndex:t,found:this.cursorWithin(e,t)};const s=this.parseUntilToken(t+1,'"'),r=this.code.slice(t+1,s),i=this.parseUntilToken(s,":");return{key:r,endIndex:i,found:this.cursorWithin(t,i)}}parseValue(e){const t=this.parseUntilToken(e,o.TOKEN_VALUE_START),s=this.code[t];let r;if(s==="{"){const n=this.parseObject(t);if(r=n.endIndex,n.found)return n}else if(s==="["){const n=this.parseArray(t);if(r=n.endIndex,n.found)return n}else if(s==='"'){const n=this.parseString(t);if(r=n.endIndex,n.found)return n}else o.TOKEN_LITERAL.includes(s)?r=this.parseAnyLiteral(t):r=this.parseUntilToken(t+1,[...o.TOKEN_SEPARATOR," ",` +`])-1;const i=this.parseUntilToken(r+1,o.TOKEN_SEPARATOR);return{found:this.cursorWithin(e,r),endIndex:i}}getStringCursorIndex(e,t){const s=this.cursorPosition-e;if(s<=0)return 0;let r=-1,i=0;for(;i1&&this.path.push({type:"string",index:this.getStringCursorIndex(e,t)}),{found:!0,endIndex:t}):{found:this.cursorWithin(e,t),endIndex:t}}parseAnyLiteral(e){for(;++e=0;){if(s.includes(this.code[e])){let r=0;for(;this.code[e-1-r]==="\\";)r+=1;if(r%2===0)return e}e+=1}return e}cursorWithin(e,t){return e<=this.cursorPosition&&this.cursorPosition<=t}};h.TOKEN_LITERAL=["t","f","n"],h.TOKEN_VALUE_START=["{","[",'"',...h.TOKEN_LITERAL,"0","1","2","3","4","5","6","7","8","9"],h.TOKEN_SEPARATOR=[",","}","]"];let a=h;u.JsonCursorPath=a})(this.window=this.window||{}); diff --git a/jest.config.cjs b/jest.config.cjs index 9328b00..aa87462 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,7 +1,7 @@ module.exports = { collectCoverage: true, collectCoverageFrom: [ - 'src/**/JsonParser.ts' + 'src/**/json-cursor-path.ts' ], coverageProvider: 'v8', testMatch: [ diff --git a/package-lock.json b/package-lock.json index 6cc3e8b..93de3fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "json-cursor-path", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "json-cursor-path", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@babel/preset-env": "^7.24.7", diff --git a/package.json b/package.json index 8fd7742..32132e8 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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": [ @@ -24,12 +24,18 @@ }, "keywords": [ "json", + "jsonpath", + "cursor", + "editor", + "autocomplete", "json-cursor", "json-parser", "json-path", "parser" ], - "author": "7PH ", "license": "MIT", "devDependencies": { "@babel/preset-env": "^7.24.7", @@ -48,7 +54,7 @@ }, "size-limit": [ { - "limit": "999 B", + "limit": "1099 B", "path": "dist/*.min.js" } ] diff --git a/src/json-cursor-path.ts b/src/json-cursor-path.ts index 5eeea03..fc82b09 100644 --- a/src/json-cursor-path.ts +++ b/src/json-cursor-path.ts @@ -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; /** @@ -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 = '$'; @@ -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, @@ -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, @@ -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; } @@ -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, ':'); @@ -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 === '{') { @@ -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); @@ -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), }); } @@ -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)) { @@ -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 diff --git a/test/json-cursor-path.ts b/test/json-cursor-path.ts index 502fcb2..193f826 100644 --- a/test/json-cursor-path.ts +++ b/test/json-cursor-path.ts @@ -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 }, ]); }); @@ -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 });