diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts new file mode 100644 index 0000000..c77d8b5 --- /dev/null +++ b/server/src/analyzer.ts @@ -0,0 +1,39 @@ +import { Node, parseTree, ParseError } from 'jsonc-parser'; +import { SymbolTable, JRefSymbol } from './symbolTable'; +import { visit, VisitCallback } from './visitor'; + +export interface DocumentAnalysis { + symbols: SymbolTable; + errors: ParseError[]; +} + +export function analyze(content: string): DocumentAnalysis { + const errors: ParseError[] = []; + const ast = parseTree(content, errors); + const symbols: SymbolTable = new Map(); + + visit(ast, (node, path) => { + const symbol = createJRefSymbol(node, path); + symbols.set(path, symbol); + }); + + return { symbols, errors }; +} + +function createJRefSymbol(node: Node, pointer: string): JRefSymbol { + const isReference = isReferenceValue(node); + return { + pointer, + node, + isReference, + refersTo: isReference ? node.value : null, + }; +} + +function isReferenceValue(node: Node): boolean { + return ( + node.type === 'string' && + node.parent?.type === 'property' && + node.parent.children?.[0].value === '$ref' + ); +} diff --git a/server/src/providers/definition.ts b/server/src/providers/definition.ts index 2c88530..b836d28 100644 --- a/server/src/providers/definition.ts +++ b/server/src/providers/definition.ts @@ -1,11 +1,12 @@ import path from 'path'; import { URI } from 'vscode-uri'; +import * as fs from 'fs'; import { DefinitionParams, DefinitionLink, Range } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { JRefSymbol } from '../visitor'; -import { analyze, ServerContext } from '../utils'; -import * as fs from 'fs'; +import { JRefSymbol, SymbolTable } from '../symbolTable'; +import { ServerContext } from '../utils'; +import { analyze } from '../analyzer'; const defaultTargetRange: Range = { start: { line: 0, character: 0 }, @@ -37,50 +38,72 @@ function createDefinitionLink( document: TextDocument, ref: JRefSymbol, context: ServerContext, -): DefinitionLink[] | undefined { - const { documents, documentSymbols } = context; - const refValueNode = ref.node; - const targetPath = refValueNode.value; - const uri = URI.parse(targetPath); - const currentDir = path.dirname(URI.parse(document.uri).fsPath); - const absolutePath = path.resolve(currentDir, uri.path.slice(1)); - const targetUri = URI.file(absolutePath).toString(); - const targetRange = getTargetRange(targetUri); - - function getTargetRange(targetUri: string): Range { - let targetDocument = documents.get(targetUri); - if (!targetDocument) { - try { - const filePath = URI.parse(targetUri).fsPath; - const content = fs.readFileSync(filePath, 'utf8'); - targetDocument = TextDocument.create(targetUri, 'jref', 1, content); - } catch (e) { - return defaultTargetRange; - } - } - let targetSymbolTable = documentSymbols.get(targetDocument); - if (!targetSymbolTable) { - const { symbols } = analyze(targetDocument.getText()); - documentSymbols.set(targetDocument, symbols); - targetSymbolTable = symbols; - } - const targetSymbol = targetSymbolTable?.get(uri.fragment); - if (!targetSymbol) return defaultTargetRange; - return { - start: targetDocument.positionAt(targetSymbol.node.offset), - end: targetDocument.positionAt(targetSymbol.node.offset + targetSymbol.node.length), - }; - } +): DefinitionLink[] { + const { targetUri, fragment } = resolveTargetUriAndFragment(document.uri, ref.node.value); + + const targetRange = findTargetRange(targetUri, fragment, context); return [ { - originSelectionRange: { - start: document.positionAt(refValueNode.offset + 1), // +1 to skip the opening quote - end: document.positionAt(refValueNode.offset + refValueNode.length - 1), // -1 to skip the closing quote - }, - targetUri: URI.file(absolutePath).toString(), - targetRange: targetRange, + originSelectionRange: createOriginSelectionRange(document, ref), + targetUri, + targetRange, targetSelectionRange: targetRange, }, ]; } + +function resolveTargetUriAndFragment(documentUri: string, targetPath: string) { + const uri = URI.parse(targetPath); + const currentDir = path.dirname(URI.parse(documentUri).fsPath); + const absolutePath = path.resolve(currentDir, uri.path.slice(1)); + const targetUri = URI.file(absolutePath).toString(); + return { targetUri, fragment: uri.fragment }; +} + +function getOrLoadDocument(targetUri: string, context: ServerContext): TextDocument | undefined { + const { documents } = context; + const targetDocument = documents.get(targetUri); + if (targetDocument) return targetDocument; + + try { + const filePath = URI.parse(targetUri).fsPath; + const content = fs.readFileSync(filePath, 'utf8'); + return TextDocument.create(targetUri, 'jref', 1, content); + } catch (e) { + return; + } +} + +function getOrAnalyzeSymbols(targetDocument: TextDocument, context: ServerContext): SymbolTable { + const { documentSymbols } = context; + let targetSymbolTable = documentSymbols.get(targetDocument); + if (targetSymbolTable) return targetSymbolTable; + + const { symbols } = analyze(targetDocument.getText()); + documentSymbols.set(targetDocument, symbols); + return symbols; +} + +function findTargetRange(targetUri: string, fragment: string, context: ServerContext): Range { + const targetDocument = getOrLoadDocument(targetUri, context); + if (!targetDocument) return defaultTargetRange; + + const targetSymbolTable = getOrAnalyzeSymbols(targetDocument, context); + const targetSymbol = targetSymbolTable.get(fragment || ''); // Default to empty string if no fragment + + if (!targetSymbol) return defaultTargetRange; + + return { + start: targetDocument.positionAt(targetSymbol.node.offset), + end: targetDocument.positionAt(targetSymbol.node.offset + targetSymbol.node.length), + }; +} + +function createOriginSelectionRange(document: TextDocument, ref: JRefSymbol): Range { + const refValueNode = ref.node; + return { + start: document.positionAt(refValueNode.offset + 1), // +1 to skip the opening quote + end: document.positionAt(refValueNode.offset + refValueNode.length - 1), // -1 to skip the closing quote + }; +} diff --git a/server/src/server.ts b/server/src/server.ts index e4508c9..e7e3b85 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -14,9 +14,9 @@ import { ParseError } from 'jsonc-parser'; import { createParseErrorDiagnostic } from './providers/diagnostics'; import { onDefinition } from './providers/definition'; -import { SymbolTable } from './visitor'; +import { SymbolTable } from './symbolTable'; import { handleSemanticTokens, tokenTypes } from './providers/semanticTokens'; -import { analyze } from './utils'; +import { analyze } from './analyzer'; // Create a connection for the server, using Node's IPC as a transport. // Also include all preview / proposed LSP features. diff --git a/server/src/symbolTable.ts b/server/src/symbolTable.ts new file mode 100644 index 0000000..a81cc76 --- /dev/null +++ b/server/src/symbolTable.ts @@ -0,0 +1,10 @@ +import { Node } from 'jsonc-parser'; + +export interface JRefSymbol { + pointer: string; + node: Node; + isReference: boolean; + refersTo: string | null; +} + +export type SymbolTable = Map; diff --git a/server/src/test/analyzer.test.ts b/server/src/test/analyzer.test.ts new file mode 100644 index 0000000..2ae8a58 --- /dev/null +++ b/server/src/test/analyzer.test.ts @@ -0,0 +1,91 @@ +import * as assert from 'assert'; +import { analyze } from '../analyzer.js'; + +suite('Analyzer Test Suite', () => { + test('Should find a single $ref in a simple object', () => { + const text = '{"$ref": "path/to/schema.json"}'; + const { symbols } = analyze(text); + + assert.strictEqual(symbols.get('/$ref')?.node.type, 'string'); + assert.strictEqual(symbols.get('/$ref')?.isReference, true); + assert.strictEqual(symbols.get('/$ref')?.refersTo, 'path/to/schema.json'); + }); + + test('Should find multiple $refs in nested objects', () => { + const text = `{ + "first": { "$ref": "one.json" }, + "second": { "inner": { "$ref": "two.json" } } + }`; + const { symbols } = analyze(text); + + assert.strictEqual(symbols.get('/first/$ref')?.node.type, 'string'); + assert.strictEqual(symbols.get('/first/$ref')?.isReference, true); + assert.strictEqual(symbols.get('/first/$ref')?.refersTo, 'one.json'); + + assert.strictEqual(symbols.get('/second/inner/$ref')?.node.type, 'string'); + assert.strictEqual(symbols.get('/second/inner/$ref')?.isReference, true); + assert.strictEqual(symbols.get('/second/inner/$ref')?.refersTo, 'two.json'); + }); + + test('Should find $refs inside arrays', () => { + const text = `[ + { "$ref": "item1.json" }, + { "other": "value" }, + { "$ref": "item2.json" } + ]`; + const { symbols } = analyze(text); + + assert.strictEqual(symbols.get('/0/$ref')?.node.type, 'string'); + assert.strictEqual(symbols.get('/0/$ref')?.isReference, true); + assert.strictEqual(symbols.get('/0/$ref')?.refersTo, 'item1.json'); + + assert.strictEqual(symbols.get('/1/other')?.node.type, 'string'); + assert.strictEqual(symbols.get('/1/other')?.isReference, false); + assert.strictEqual(symbols.get('/1/other')?.refersTo, null); + + assert.strictEqual(symbols.get('/2/$ref')?.node.type, 'string'); + assert.strictEqual(symbols.get('/2/$ref')?.isReference, true); + assert.strictEqual(symbols.get('/2/$ref')?.refersTo, 'item2.json'); + }); + + test('Should NOT pick up $ref if the value is not a string', () => { + const text = '{"$ref": 123}'; + const { symbols } = analyze(text); + + assert.strictEqual(symbols.get('/$ref')?.isReference, false); + assert.strictEqual(symbols.get('/$ref')?.refersTo, null); + }); + + test('Should handle empty objects and arrays', () => { + const text = '{"obj": {}, "arr": []}'; + const { symbols } = analyze(text); + + assert.ok(symbols.has('/obj'), 'Should have a symbol for the empty object'); + assert.strictEqual(symbols.get('/obj')?.node.type, 'object'); + + assert.ok(symbols.has('/arr'), 'Should have a symbol for the empty array'); + assert.strictEqual(symbols.get('/arr')?.node.type, 'array'); + }); + + test('Should handle undefined or null nodes gracefully', () => { + const { symbols } = analyze(''); + assert.strictEqual(symbols.size, 0); + }); + + test('Should handle array elements', () => { + const text = '{"arr": ["a", "b", "c"]}'; + const { symbols } = analyze(text); + + assert.ok(symbols.has('/arr'), 'Should have a symbol for the array'); + assert.strictEqual(symbols.get('/arr')?.node.type, 'array'); + + assert.ok(symbols.has('/arr/0'), 'Should have a symbol for the first element'); + assert.strictEqual(symbols.get('/arr/0')?.node.type, 'string'); + + assert.ok(symbols.has('/arr/1'), 'Should have a symbol for the second element'); + assert.strictEqual(symbols.get('/arr/1')?.node.type, 'string'); + + assert.ok(symbols.has('/arr/2'), 'Should have a symbol for the third element'); + assert.strictEqual(symbols.get('/arr/2')?.node.type, 'string'); + }); +}); diff --git a/server/src/test/definition.test.ts b/server/src/test/definition.test.ts index 833b198..4229aca 100644 --- a/server/src/test/definition.test.ts +++ b/server/src/test/definition.test.ts @@ -2,8 +2,7 @@ import * as assert from 'assert'; import { URI } from 'vscode-uri'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { onDefinition } from '../providers/definition.js'; -import { parseTree } from 'jsonc-parser'; -import { SymbolTable, visit } from '../visitor.js'; +import { analyze } from '../analyzer.js'; import { DefinitionParams } from 'vscode-languageserver/node'; import path from 'path'; @@ -23,10 +22,7 @@ suite('Definition Test Suite', () => { const uri = URI.file(path.resolve('/abs/path/main.jref')).toString(); const doc = TextDocument.create(uri, 'jref', 1, text); - const errors: any[] = []; - const ast = parseTree(text, errors); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); + const { symbols } = analyze(text); const context = { documents: new MockTextDocuments([doc]) as any, @@ -50,10 +46,7 @@ suite('Definition Test Suite', () => { const uri = URI.file(path.resolve('/abs/path/main.jref')).toString(); const doc = TextDocument.create(uri, 'jref', 1, text); - const errors: any[] = []; - const ast = parseTree(text, errors); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); + const { symbols } = analyze(text); const context = { documents: new MockTextDocuments([doc]) as any, @@ -78,13 +71,9 @@ suite('Definition Test Suite', () => { const schemaUri = URI.file(path.resolve('/abs/path/schema.jref')).toString(); const schemaDoc = TextDocument.create(schemaUri, 'jref', 1, schemaText); - const mainAst = parseTree(mainText); - const mainSymbols: SymbolTable = new Map(); - visit(mainAst, mainSymbols); + const { symbols: mainSymbols } = analyze(mainText); - const schemaAst = parseTree(schemaText); - const schemaSymbols: SymbolTable = new Map(); - visit(schemaAst, schemaSymbols); + const { symbols: schemaSymbols } = analyze(schemaText); const context = { documents: new MockTextDocuments([mainDoc, schemaDoc]) as any, diff --git a/server/src/test/visitor.test.ts b/server/src/test/visitor.test.ts index 6e5b02f..be94de8 100644 --- a/server/src/test/visitor.test.ts +++ b/server/src/test/visitor.test.ts @@ -1,105 +1,19 @@ import * as assert from 'assert'; import { parseTree } from 'jsonc-parser'; -import { SymbolTable, visit } from '../visitor.js'; +import { visit } from '../visitor.js'; -suite('Visitor Test Suite', () => { - test('Should find a single $ref in a simple object', () => { - const text = '{"$ref": "path/to/schema.json"}'; +suite('Visitor Pattern Test Suite', () => { + test('Should call visit callback for each node in correct order', () => { + const text = '{"a": 1, "b": [2]}'; const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - assert.strictEqual(symbols.get('/$ref')?.node.type, 'string'); - assert.strictEqual(symbols.get('/$ref')?.isReference, true); - assert.strictEqual(symbols.get('/$ref')?.refersTo, 'path/to/schema.json'); - }); - - test('Should find multiple $refs in nested objects', () => { - const text = `{ - "first": { "$ref": "one.json" }, - "second": { "inner": { "$ref": "two.json" } } - }`; - const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - - assert.strictEqual(symbols.get('/first/$ref')?.node.type, 'string'); - assert.strictEqual(symbols.get('/first/$ref')?.isReference, true); - assert.strictEqual(symbols.get('/first/$ref')?.refersTo, 'one.json'); - - assert.strictEqual(symbols.get('/second/inner/$ref')?.node.type, 'string'); - assert.strictEqual(symbols.get('/second/inner/$ref')?.isReference, true); - assert.strictEqual(symbols.get('/second/inner/$ref')?.refersTo, 'two.json'); - }); - - test('Should find $refs inside arrays', () => { - const text = `[ - { "$ref": "item1.json" }, - { "other": "value" }, - { "$ref": "item2.json" } - ]`; - const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - - assert.strictEqual(symbols.get('/0/$ref')?.node.type, 'string'); - assert.strictEqual(symbols.get('/0/$ref')?.isReference, true); - assert.strictEqual(symbols.get('/0/$ref')?.refersTo, 'item1.json'); - - assert.strictEqual(symbols.get('/1/other')?.node.type, 'string'); - assert.strictEqual(symbols.get('/1/other')?.isReference, false); - assert.strictEqual(symbols.get('/1/other')?.refersTo, null); - - assert.strictEqual(symbols.get('/2/$ref')?.node.type, 'string'); - assert.strictEqual(symbols.get('/2/$ref')?.isReference, true); - assert.strictEqual(symbols.get('/2/$ref')?.refersTo, 'item2.json'); - }); - - test('Should NOT pick up $ref if the value is not a string', () => { - const text = '{"$ref": 123}'; - const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - - assert.strictEqual(symbols.get('/$ref')?.isReference, false); - assert.strictEqual(symbols.get('/$ref')?.refersTo, null); - }); - - test('Should handle empty objects and arrays', () => { - const text = '{"obj": {}, "arr": []}'; - const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - - assert.ok(symbols.has('/obj'), 'Should have a symbol for the empty object'); - assert.strictEqual(symbols.get('/obj')?.node.type, 'object'); - - assert.ok(symbols.has('/arr'), 'Should have a symbol for the empty array'); - assert.strictEqual(symbols.get('/arr')?.node.type, 'array'); - }); - - test('Should handle undefined or null nodes gracefully', () => { - const symbols: SymbolTable = new Map(); - visit(undefined, symbols); - assert.strictEqual(symbols.size, 0); - }); - - test('Should handle array elements', () => { - const text = '{"arr": ["a", "b", "c"]}'; - const ast = parseTree(text); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - - assert.ok(symbols.has('/arr'), 'Should have a symbol for the array'); - assert.strictEqual(symbols.get('/arr')?.node.type, 'array'); - - assert.ok(symbols.has('/arr/0'), 'Should have a symbol for the first element'); - assert.strictEqual(symbols.get('/arr/0')?.node.type, 'string'); + const trace: string[] = []; + visit(ast, (node, path) => { + trace.push(`visit: ${path || '/'}`); + }); - assert.ok(symbols.has('/arr/1'), 'Should have a symbol for the second element'); - assert.strictEqual(symbols.get('/arr/1')?.node.type, 'string'); + const expected = ['visit: /', 'visit: /a', 'visit: /b', 'visit: /b/0']; - assert.ok(symbols.has('/arr/2'), 'Should have a symbol for the third element'); - assert.strictEqual(symbols.get('/arr/2')?.node.type, 'string'); + assert.deepStrictEqual(trace, expected); }); }); diff --git a/server/src/utils.ts b/server/src/utils.ts index 6785899..9d3f110 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -1,22 +1,9 @@ import { TextDocuments } from 'vscode-languageserver/node'; -import { SymbolTable, visit } from './visitor'; +import { visit } from './visitor'; +import { SymbolTable } from './symbolTable'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { ParseError, parseTree } from 'jsonc-parser'; export interface ServerContext { documents: TextDocuments; documentSymbols: WeakMap; } - -export interface DocumentAnalysis { - symbols: SymbolTable; - errors: ParseError[]; -} - -export function analyze(content: string): DocumentAnalysis { - const errors: ParseError[] = []; - const ast = parseTree(content, errors); - const symbols: SymbolTable = new Map(); - visit(ast, symbols); - return { symbols, errors }; -} diff --git a/server/src/visitor.ts b/server/src/visitor.ts index 0208d43..7b72cee 100644 --- a/server/src/visitor.ts +++ b/server/src/visitor.ts @@ -1,65 +1,43 @@ import { Node } from 'jsonc-parser'; -export interface JRefSymbol { - pointer: string; - node: Node; - isReference: boolean; - refersTo: string | null; -} +export type VisitCallback = (node: Node, path: string) => void; + +export function visit(node: Node | undefined, cb: VisitCallback, path: string = '') { + if (!node?.type) { + return; + } -export type SymbolTable = Map; + const isProperty = node.type === 'property'; -const visitFunctions: Record void> = { + if (!isProperty) { + cb(node, path); + } + + const visitChildren = visitFunctions[node.type]; + visitChildren?.(node, cb, path); +} + +const visitFunctions: Record void> = { object: visitObject, array: visitArray, property: visitProperty, }; -function visitObject(node: Node, acc: SymbolTable, path: string) { +function visitObject(node: Node, cb: VisitCallback, path: string) { node?.children?.forEach((child) => { - visit(child, acc, path); + visit(child, cb, path); }); } -function visitArray(node: Node, acc: SymbolTable, path: string) { +function visitArray(node: Node, cb: VisitCallback, path: string) { node?.children?.forEach((child, index) => { - visit(child, acc, path + '/' + index); + visit(child, cb, path + '/' + index); }); } -function visitProperty(node: Node, acc: SymbolTable, path: string) { +function visitProperty(node: Node, cb: VisitCallback, path: string) { if (node.children?.length !== 2) return; const key = node.children[0].value; const value = node.children[1]; - visit(value, acc, `${path}/${key}`); -} - -export function visit(node: Node | undefined, acc: SymbolTable, path: string = '') { - if (!node?.type) { - console.error('Node type is undefined'); - return; - } - const symbol = createJRefSymbol(node, path); - acc.set(path, symbol); - - const visit = visitFunctions[node.type]; - visit?.(node, acc, path); -} - -function createJRefSymbol(node: Node, pointer: string): JRefSymbol { - const isReference = isReferenceValue(node); - return { - pointer, - node, - isReference, - refersTo: isReference ? node.value : null, - }; -} - -function isReferenceValue(node: Node): boolean { - return ( - node.type === 'string' && - node.parent?.type === 'property' && - node.parent.children?.[0].value === '$ref' - ); + visit(value, cb, `${path}/${key}`); }