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
54 changes: 53 additions & 1 deletion client/src/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ suite('Extension Test Suite', () => {

test('Extension should be present', () => {
assert.ok(vscode.extensions.getExtension('ntoulasm.jref-language-server-extension'));
});
}).timeout(5000);

test('Should activate the extension when a .jref file is opened', async () => {
const ext = vscode.extensions.getExtension('ntoulasm.jref-language-server-extension');
Expand Down Expand Up @@ -108,4 +108,56 @@ suite('Extension Test Suite', () => {
const hasResults = locations && locations.length > 0;
assert.strictEqual(hasResults, false, 'Should not return a definition for non $ref keys');
}).timeout(5000);

test('Should provide a definition for $ref values with fragments', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jref-test-fragment-'));
const targetFile = path.join(tmpDir, 'schema.jref');
const sourceFile = path.join(tmpDir, 'main.jref');

const schemaContent = '{"definitions": {"target": {}}}';
fs.writeFileSync(targetFile, schemaContent);
fs.writeFileSync(sourceFile, '{"$ref": "schema.jref#/definitions/target"}');

// Open the target schema document first so the server indexes it
const schemaDoc = await vscode.workspace.openTextDocument(targetFile);
await vscode.window.showTextDocument(schemaDoc);

const doc = await vscode.workspace.openTextDocument(sourceFile);
await vscode.window.showTextDocument(doc);

await sleep(2000);

// Character 12 is inside "schema.jref#/definitions/target"
const position = new vscode.Position(0, 12);

const locations = await vscode.commands.executeCommand<vscode.LocationLink[]>(
'vscode.executeDefinitionProvider',
doc.uri,
position,
);

assert.ok(
locations && Array.isArray(locations) && locations.length > 0,
'Should return an array of LocationLinks',
);

const link = locations[0];
const resolvedPath = link.targetUri.fsPath.toLowerCase();
const expectedPath = targetFile.toLowerCase();

assert.strictEqual(
resolvedPath,
expectedPath,
`Expected to point to ${expectedPath} but got ${resolvedPath}`,
);

// The target range should point to the value of "target" property in schema.jref
// {"definitions": {"target": {}}}
// ^--- {} starts at character 27
assert.strictEqual(
link.targetRange.start.character,
27,
`Expected target character 27, but got ${link.targetRange.start.character}`,
);
}).timeout(10000);
});
60 changes: 40 additions & 20 deletions server/src/definition.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,76 @@
import path from 'path';
import { URI } from 'vscode-uri';

import { DefinitionParams, DefinitionLink, TextDocuments } from 'vscode-languageserver/node';
import { Node } from 'jsonc-parser';
import { DefinitionParams, DefinitionLink, TextDocuments, Range } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { JRefSymbol, SymbolTable } from './visitor';

interface ServerContext {
documents: TextDocuments<TextDocument>;
documentRefs: WeakMap<TextDocument, Array<Node>>;
documentSymbols: WeakMap<TextDocument, SymbolTable>;
}

const defaultTargetRange: Range = {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
};

export function onDefinition(
params: DefinitionParams,
context: ServerContext,
): DefinitionLink[] | undefined {
const { documents, documentRefs } = context;
const { documents, documentSymbols } = context;
const document = documents.get(params.textDocument.uri);
if (!document) return;

const refs = documentRefs.get(document);
if (!refs || refs.length === 0) return;
const symbols = documentSymbols.get(document);
if (!symbols || symbols.size === 0) return;

const refs = Array.from(symbols.values()).filter((symbol) => symbol.isReference);
const offset = document.offsetAt(params.position);
const targetRef = refs.find((ref) => {
const value = ref.children![1];
return offset >= value.offset && offset <= value.offset + value.length;
return offset >= ref.node.offset && offset <= ref.node.offset + ref.node.length;
});
if (!targetRef) return;

return createDefinitionLink(document, targetRef);
return createDefinitionLink(document, targetRef, context);
}

function createDefinitionLink(document: TextDocument, ref: Node): DefinitionLink[] | undefined {
const refValueNode = ref.children![1];
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, targetPath);
const absolutePath = path.resolve(currentDir, uri.path.slice(1));
const targetDocument = documents.get(URI.file(absolutePath).toString());
const targetRange = getTargetRange(targetDocument);

function getTargetRange(targetDocument: TextDocument | undefined): Range {
if (!targetDocument) return defaultTargetRange;
const targetSymbolTable = documentSymbols.get(targetDocument);
if (!targetSymbolTable) return defaultTargetRange;
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),
};
}

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: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
targetSelectionRange: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
targetRange: targetRange,
targetSelectionRange: targetRange,
},
];
}
12 changes: 6 additions & 6 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import { Node, ParseError, parseTree } from 'jsonc-parser';
import { createParseErrorDiagnostic } from './diagnostics';

import { onDefinition } from './definition';
import { visit } from './visitor';
import { JRefSymbol, SymbolTable, visit } from './visitor';

// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);
// Create a simple text document manager.
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
const documentRefs: WeakMap<TextDocument, Array<Node>> = new WeakMap();
const documentSymbols: WeakMap<TextDocument, SymbolTable> = new WeakMap();

connection.onInitialize((params: InitializeParams) => {
const result: InitializeResult = {
Expand All @@ -38,9 +38,9 @@ connection.onInitialize((params: InitializeParams) => {
documents.onDidChangeContent((change: TextDocumentChangeEvent<TextDocument>) => {
const errors: ParseError[] = [];
const ast: Node | undefined = parseTree(change.document.getText(), errors);
const references: Array<Node> = [];
visit(ast, references);
documentRefs.set(change.document, references);
const symbols: SymbolTable = new Map();
visit(ast, symbols);
documentSymbols.set(change.document, symbols);
sendDiagnostics(change.document, errors);
});

Expand All @@ -51,7 +51,7 @@ function sendDiagnostics(document: TextDocument, parseErrors: ParseError[]) {
});
}

connection.onDefinition((params) => onDefinition(params, { documents, documentRefs }));
connection.onDefinition((params) => onDefinition(params, { documents, documentSymbols }));

// Make the text document manager listen on the connection
// for open, change and close text document events
Expand Down
57 changes: 49 additions & 8 deletions server/src/test/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import * as assert from 'assert';
import { URI } from 'vscode-uri';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { onDefinition } from '../definition.js';
import { Node, parseTree } from 'jsonc-parser';
import { visit } from '../visitor.js';
import { parseTree } from 'jsonc-parser';
import { SymbolTable, visit } from '../visitor.js';
import { DefinitionParams } from 'vscode-languageserver/node';
import path from 'path';

Expand All @@ -25,12 +25,12 @@ suite('Definition Test Suite', () => {

const errors: any[] = [];
const ast = parseTree(text, errors);
const refs: Node[] = [];
visit(ast, refs);
const symbols: SymbolTable = new Map();
visit(ast, symbols);

const context = {
documents: new MockTextDocuments([doc]) as any,
documentRefs: new WeakMap([[doc, refs]]),
documentSymbols: new WeakMap([[doc, symbols]]),
};

const params: DefinitionParams = {
Expand All @@ -52,12 +52,12 @@ suite('Definition Test Suite', () => {

const errors: any[] = [];
const ast = parseTree(text, errors);
const refs: Node[] = [];
visit(ast, refs);
const symbols: SymbolTable = new Map();
visit(ast, symbols);

const context = {
documents: new MockTextDocuments([doc]) as any,
documentRefs: new WeakMap([[doc, refs]]),
documentSymbols: new WeakMap([[doc, symbols]]),
};

const params: DefinitionParams = {
Expand All @@ -68,4 +68,45 @@ suite('Definition Test Suite', () => {
const result = onDefinition(params, context);
assert.ok(!result);
});

test('Should return a definition for $ref with fragment', () => {
const mainText = '{"$ref": "schema.jref#/definitions/target"}';
const mainUri = URI.file(path.resolve('/abs/path/main.jref')).toString();
const mainDoc = TextDocument.create(mainUri, 'jref', 1, mainText);

const schemaText = '{"definitions": {"target": {}}}';
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 schemaAst = parseTree(schemaText);
const schemaSymbols: SymbolTable = new Map();
visit(schemaAst, schemaSymbols);

const context = {
documents: new MockTextDocuments([mainDoc, schemaDoc]) as any,
documentSymbols: new WeakMap([
[mainDoc, mainSymbols],
[schemaDoc, schemaSymbols],
]),
};

const params: DefinitionParams = {
textDocument: { uri: mainUri },
position: { line: 0, character: 12 }, // Inside "schema.jref#/definitions/target"
};

const result = onDefinition(params, context);

assert.ok(result && result.length > 0);
const link = result![0];
assert.ok(link.targetUri.endsWith('schema.jref'));
// Target range should point to the "target" property value in schema.jref
// {"definitions": {"target": {}}}
// ^--- value {} is at character 27
assert.strictEqual(link.targetRange.start.character, 27);
});
});
Loading