diff --git a/src/jsonLanguageService.ts b/src/jsonLanguageService.ts index 4ff7e939..f54eec3e 100644 --- a/src/jsonLanguageService.ts +++ b/src/jsonLanguageService.ts @@ -23,8 +23,9 @@ import { FoldingRange, JSONSchema, SelectionRange, FoldingRangesContext, DocumentSymbolsContext, ColorInformationContext as DocumentColorsContext, TextDocument, Position, CompletionItem, CompletionList, Hover, Range, SymbolInformation, Diagnostic, - TextEdit, FormattingOptions, DocumentSymbol + TextEdit, FormattingOptions, DocumentSymbol, DefinitionLink } from './jsonLanguageTypes'; +import { findDefinition } from './services/jsonDefinition'; export type JSONDocument = {}; export * from './jsonLanguageTypes'; @@ -47,6 +48,7 @@ export interface LanguageService { format(document: TextDocument, range: Range, options: FormattingOptions): TextEdit[]; getFoldingRanges(document: TextDocument, context?: FoldingRangesContext): FoldingRange[]; getSelectionRanges(document: TextDocument, positions: Position[], doc: JSONDocument): SelectionRange[]; + findDefinition(document: TextDocument, position: Position, doc: JSONDocument): Thenable; } @@ -85,6 +87,7 @@ export function getLanguageService(params: LanguageServiceParams): LanguageServi doHover: jsonHover.doHover.bind(jsonHover), getFoldingRanges, getSelectionRanges, + findDefinition, format: (d, r, o) => { let range: JSONCRange | undefined = undefined; if (r) { diff --git a/src/services/jsonDefinition.ts b/src/services/jsonDefinition.ts new file mode 100644 index 00000000..dc4362af --- /dev/null +++ b/src/services/jsonDefinition.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { JSONSchemaRef, JSONSchema } from '../jsonSchema'; +import { DefinitionLink, Position, TextDocument, ASTNode, PropertyASTNode, Range, Thenable } from '../jsonLanguageTypes'; +import { JSONDocument } from '../parser/jsonParser'; + +export function findDefinition(document: TextDocument, position: Position, doc: JSONDocument): Thenable { + const offset = document.offsetAt(position); + const node = doc.getNodeFromOffset(offset, true); + if (!node || !isRef(node)) { + return Promise.resolve([]); + } + + const propertyNode: PropertyASTNode = node.parent as PropertyASTNode; + const valueNode = propertyNode.valueNode as ASTNode; + const path = valueNode.value as string; + const targetNode = findTargetNode(doc, path); + if (!targetNode) { + return Promise.resolve([]); + } + const definition: DefinitionLink = { + targetUri: document.uri, + originSelectionRange: createRange(document, valueNode), + targetRange: createRange(document, targetNode), + targetSelectionRange: createRange(document, targetNode) + }; + return Promise.resolve([definition]); +} + +function createRange(document: TextDocument, node: ASTNode): Range { + return Range.create(document.positionAt(node.offset), document.positionAt(node.offset + node.length)); +} + +function isRef(node: ASTNode): boolean { + return node.type === 'string' && + node.parent && + node.parent.type === 'property' && + node.parent.valueNode === node && + node.parent.keyNode.value === "$ref" || + false; +} + +function findTargetNode(doc: JSONDocument, path: string): ASTNode | null { + const tokens = parseJSONPointer(path); + if (!tokens) { + return null; + } + return findNode(tokens, doc.root); +} + +function findNode(pointer: string[], node: ASTNode | null | undefined): ASTNode | null { + if (!node) { + return null; + } + if (pointer.length === 0) { + return node; + } + + const token: string = pointer.shift() as string; + if (node && node.type === 'object') { + const propertyNode: PropertyASTNode | undefined = node.properties.find((propertyNode) => propertyNode.keyNode.value === token); + if (!propertyNode) { + return null; + } + return findNode(pointer, propertyNode.valueNode); + } else if (node && node.type === 'array') { + if (token.match(/^(0|[1-9][0-9]*)$/)) { + const index = Number.parseInt(token); + const arrayItem = node.items[index]; + if (!arrayItem) { + return null; + } + return findNode(pointer, arrayItem); + } + } + return null; +} + +function parseJSONPointer(path: string): string[] | null { + if (path === "#") { + return []; + } + + if (path[0] !== '#' || path[1] !== '/') { + return null; + } + + return path.substring(2).split(/\//).map(unescape); +} + +function unescape(str: string): string { + return str.replace(/~1/g, '/').replace(/~0/g, '~'); +} diff --git a/src/test/definition.test.ts b/src/test/definition.test.ts new file mode 100644 index 00000000..0b15cf0d --- /dev/null +++ b/src/test/definition.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; + +import { getLanguageService, JSONSchema, TextDocument, ClientCapabilities, CompletionList, CompletionItemKind, Position, MarkupContent } from '../jsonLanguageService'; +import { repeat } from '../utils/strings'; +import { DefinitionLink } from 'vscode-languageserver-types'; + +suite('JSON Find Definitions', () => { + const testFindDefinitionFor = function (value: string, expected: {offset: number, length: number} | null): PromiseLike { + const offset = value.indexOf('|'); + value = value.substr(0, offset) + value.substr(offset + 1); + + const ls = getLanguageService({ clientCapabilities: ClientCapabilities.LATEST }); + const document = TextDocument.create('test://test/test.json', 'json', 0, value); + const position = Position.create(0, offset); + const jsonDoc = ls.parseJSONDocument(document); + return ls.findDefinition(document, position, jsonDoc).then(list => { + if (expected) { + assert.notDeepEqual(list, []); + const startOffset = list[0].targetRange.start.character; + assert.equal(startOffset, expected.offset); + assert.equal(list[0].targetRange.end.character - startOffset, expected.length); + } else { + assert.deepEqual(list, []); + } + }); + }; + + test('FindDefinition invalid ref', async function () { + await testFindDefinitionFor('{|}', null); + await testFindDefinitionFor('{"name": |"John"}', null); + await testFindDefinitionFor('{"|name": "John"}', null); + await testFindDefinitionFor('{"name": "|John"}', null); + await testFindDefinitionFor('{"name": "John", "$ref": "#/|john/name"}', null); + await testFindDefinitionFor('{"name": "John", "$ref|": "#/name"}', null); + await testFindDefinitionFor('{"name": "John", "$ref": "#/|"}', null); + }); + + test('FindDefinition valid ref', async function () { + await testFindDefinitionFor('{"name": "John", "$ref": "#/n|ame"}', {offset: 9, length: 6}); + await testFindDefinitionFor('{"name": "John", "$ref": "|#/name"}', {offset: 9, length: 6}); + await testFindDefinitionFor('{"name": "John", "$ref": |"#/name"}', {offset: 9, length: 6}); + await testFindDefinitionFor('{"name": "John", "$ref": "#/name"|}', {offset: 9, length: 6}); + await testFindDefinitionFor('{"name": "John", "$ref": "#/name|"}', {offset: 9, length: 6}); + await testFindDefinitionFor('{"name": "John", "$ref": "#|"}', {offset: 0, length: 29}); + + const doc = (ref: string) => `{"foo": ["bar", "baz"],"": 0,"a/b": 1,"c%d": 2,"e^f": 3,"i\\\\j": 5,"k\\"l": 6," ": 7,"m~n": 8, "$ref": "|${ref}"}`; + await testFindDefinitionFor(doc('#'), {offset: 0, length: 105}); + await testFindDefinitionFor(doc('#/foo'), {offset: 8, length: 14}); + await testFindDefinitionFor(doc('#/foo/0'), {offset: 9, length: 5}); + await testFindDefinitionFor(doc('#/foo/1'), {offset: 16, length: 5}); + await testFindDefinitionFor(doc('#/foo/01'), null); + await testFindDefinitionFor(doc('#/'), {offset: 27, length: 1}); + await testFindDefinitionFor(doc('#/a~1b'), {offset: 36, length: 1}); + await testFindDefinitionFor(doc('#/c%d'), {offset: 45, length: 1}); + await testFindDefinitionFor(doc('#/e^f'), {offset: 54, length: 1}); + await testFindDefinitionFor(doc('#/i\\\\j'), {offset: 64, length: 1}); + await testFindDefinitionFor(doc('#/k\\"l'), {offset: 74, length: 1}); + await testFindDefinitionFor(doc('#/ '), {offset: 81, length: 1}); + await testFindDefinitionFor(doc('#/m~0n'), {offset: 90, length: 1}); + }); +});