diff --git a/README.md b/README.md index 6f71ee9..1ae100c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ Syntax validation and error reporting for malformed JRef structure. The Language Server understands the JRef schema. It surgically identifies `$ref` keys and their destination paths to provide a clear visual distinction. +### Document Symbols + +The Language Server implements a Document Symbol Provider that enables the Outline View, Breadcrumbs and Symbol Search +![Jref Document Symbols](images/documentSymbols.png) + ## Requirements 1. Node diff --git a/images/documentSymbols.png b/images/documentSymbols.png new file mode 100644 index 0000000..40cf7b0 Binary files /dev/null and b/images/documentSymbols.png differ diff --git a/server/src/providers/definition.ts b/server/src/providers/definition.ts index b836d28..9b965db 100644 --- a/server/src/providers/definition.ts +++ b/server/src/providers/definition.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import { DefinitionParams, DefinitionLink, Range } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { JRefSymbol, SymbolTable } from '../symbolTable'; -import { ServerContext } from '../utils'; +import { ServerContext, createRange } from '../utils'; import { analyze } from '../analyzer'; const defaultTargetRange: Range = { @@ -93,11 +93,7 @@ function findTargetRange(targetUri: string, fragment: string, context: ServerCon 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), - }; + return createRange(targetDocument, targetSymbol.node); } function createOriginSelectionRange(document: TextDocument, ref: JRefSymbol): Range { diff --git a/server/src/providers/documentSymbols.ts b/server/src/providers/documentSymbols.ts new file mode 100644 index 0000000..34a3f94 --- /dev/null +++ b/server/src/providers/documentSymbols.ts @@ -0,0 +1,74 @@ +import { DocumentSymbol, SymbolKind, DocumentSymbolParams } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Node } from 'jsonc-parser'; +import { ServerContext, createRange } from '../utils'; + +export function onDocumentSymbol( + params: DocumentSymbolParams, + context: ServerContext, +): DocumentSymbol[] { + const { documents, documentSymbols } = context; + const document = documents.get(params.textDocument.uri); + if (!document) return []; + + const symbols = documentSymbols.get(document); + if (!symbols) return []; + + const rootSymbol = symbols.get(''); + if (!rootSymbol) return []; + + return createSymbols(rootSymbol.node, document); +} + +function createSymbols(node: Node, document: TextDocument): DocumentSymbol[] { + const symbols: DocumentSymbol[] = []; + + if (node.type === 'object') { + node.children?.forEach((child) => { + if (child.type === 'property' && child.children?.length === 2) { + const keyNode = child.children[0]; + const valueNode = child.children[1]; + + const symbol: DocumentSymbol = { + name: keyNode.value, + kind: getSymbolKind(valueNode), + range: createRange(document, child), + selectionRange: createRange(document, keyNode), + children: createSymbols(valueNode, document), + }; + symbols.push(symbol); + } + }); + } else if (node.type === 'array') { + node.children?.forEach((child, index) => { + const range = createRange(document, child); + const symbol: DocumentSymbol = { + name: index.toString(), + kind: getSymbolKind(child), + range: range, + selectionRange: range, + children: createSymbols(child, document), + }; + symbols.push(symbol); + }); + } + + return symbols; +} + +function getSymbolKind(node: Node): SymbolKind { + switch (node.type) { + case 'object': + return SymbolKind.Object; + case 'array': + return SymbolKind.Array; + case 'string': + return SymbolKind.String; + case 'number': + return SymbolKind.Number; + case 'boolean': + return SymbolKind.Boolean; + default: + return SymbolKind.Property; + } +} diff --git a/server/src/server.ts b/server/src/server.ts index e7e3b85..4d716a1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -16,6 +16,7 @@ import { createParseErrorDiagnostic } from './providers/diagnostics'; import { onDefinition } from './providers/definition'; import { SymbolTable } from './symbolTable'; import { handleSemanticTokens, tokenTypes } from './providers/semanticTokens'; +import { onDocumentSymbol } from './providers/documentSymbols'; import { analyze } from './analyzer'; // Create a connection for the server, using Node's IPC as a transport. @@ -30,6 +31,7 @@ connection.onInitialize((params: InitializeParams) => { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, definitionProvider: true, + documentSymbolProvider: true, semanticTokensProvider: { legend: { tokenTypes, @@ -59,6 +61,8 @@ function sendDiagnostics(document: TextDocument, parseErrors: ParseError[]) { connection.onDefinition((params) => onDefinition(params, { documents, documentSymbols })); +connection.onDocumentSymbol((params) => onDocumentSymbol(params, { documents, documentSymbols })); + connection.languages.semanticTokens.on((params) => handleSemanticTokens(params, { documents, documentSymbols }), ); diff --git a/server/src/test/documentSymbols.test.ts b/server/src/test/documentSymbols.test.ts new file mode 100644 index 0000000..0e8ea2d --- /dev/null +++ b/server/src/test/documentSymbols.test.ts @@ -0,0 +1,93 @@ +import * as assert from 'assert'; +import { URI } from 'vscode-uri'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { onDocumentSymbol } from '../providers/documentSymbols.js'; +import { analyze } from '../analyzer.js'; +import { DocumentSymbolParams, SymbolKind } from 'vscode-languageserver/node'; + +class MockTextDocuments { + private docs = new Map(); + constructor(docs: TextDocument[]) { + docs.forEach((doc) => this.docs.set(doc.uri, doc)); + } + get(uri: string) { + return this.docs.get(uri); + } +} + +suite('Document Symbols Test Suite', () => { + test('Should return symbols for a simple object', () => { + const text = '{"name": "Jason", "age": 30}'; + const uri = URI.file('/abs/path/main.jref').toString(); + const doc = TextDocument.create(uri, 'jref', 1, text); + + const { symbols } = analyze(text); + const context = { + documents: new MockTextDocuments([doc]) as any, + documentSymbols: new WeakMap([[doc, symbols]]), + }; + + const params: DocumentSymbolParams = { + textDocument: { uri }, + }; + + const result = onDocumentSymbol(params, context); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].name, 'name'); + assert.strictEqual(result[0].kind, SymbolKind.String); + assert.strictEqual(result[1].name, 'age'); + assert.strictEqual(result[1].kind, SymbolKind.Number); + }); + + test('Should return symbols for nested objects', () => { + const text = '{"person": {"name": "Jason"}}'; + const uri = URI.file('/abs/path/main.jref').toString(); + const doc = TextDocument.create(uri, 'jref', 1, text); + + const { symbols } = analyze(text); + const context = { + documents: new MockTextDocuments([doc]) as any, + documentSymbols: new WeakMap([[doc, symbols]]), + }; + + const params: DocumentSymbolParams = { + textDocument: { uri }, + }; + + const result = onDocumentSymbol(params, context); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'person'); + assert.strictEqual(result[0].kind, SymbolKind.Object); + assert.ok(result[0].children); + assert.strictEqual(result[0].children!.length, 1); + assert.strictEqual(result[0].children![0].name, 'name'); + }); + + test('Should return symbols for arrays', () => { + const text = '{"tags": ["a", "b"]}'; + const uri = URI.file('/abs/path/main.jref').toString(); + const doc = TextDocument.create(uri, 'jref', 1, text); + + const { symbols } = analyze(text); + const context = { + documents: new MockTextDocuments([doc]) as any, + documentSymbols: new WeakMap([[doc, symbols]]), + }; + + const params: DocumentSymbolParams = { + textDocument: { uri }, + }; + + const result = onDocumentSymbol(params, context); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'tags'); + assert.strictEqual(result[0].kind, SymbolKind.Array); + assert.ok(result[0].children); + assert.strictEqual(result[0].children!.length, 2); + assert.strictEqual(result[0].children![0].name, '0'); + assert.strictEqual(result[0].children![1].name, '1'); + }); +}); diff --git a/server/src/utils.ts b/server/src/utils.ts index 9d3f110..f27b2ca 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -1,9 +1,16 @@ -import { TextDocuments } from 'vscode-languageserver/node'; -import { visit } from './visitor'; +import { TextDocuments, Range } from 'vscode-languageserver/node'; import { SymbolTable } from './symbolTable'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Node } from 'jsonc-parser'; export interface ServerContext { documents: TextDocuments; documentSymbols: WeakMap; } + +export function createRange(document: TextDocument, node: Node): Range { + return { + start: document.positionAt(node.offset), + end: document.positionAt(node.offset + node.length), + }; +} diff --git a/testfiles/test.json b/testfiles/test.json new file mode 100644 index 0000000..71c48f7 --- /dev/null +++ b/testfiles/test.json @@ -0,0 +1,5 @@ +{ + "name": "Jason", + "x": "2", + "y": ["1"] +}