Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create links for values of format: uri-reference #219

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
5.4.0 / 2023-04-10
================
* Added `FileType`, `FileStat`, and `FileSystemProvider` types to abstract file system access.
* Updated findLinks to recognize `uri-reference` schema values.

5.3.1 / 2023-02-24
================
* Fixing bugs in the sort feature
Expand Down
5 changes: 3 additions & 2 deletions src/jsonLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
Position, CompletionItem, CompletionList, Hover, Range, SymbolInformation, Diagnostic,
TextEdit, FormattingOptions, DocumentSymbol, DefinitionLink, MatchingSchema, JSONLanguageStatus, SortOptions
} from './jsonLanguageTypes';
import { findLinks } from './services/jsonLinks';
import { JSONLinks } from './services/jsonLinks';
import { DocumentLink } from 'vscode-languageserver-types';

export type JSONDocument = {
Expand Down Expand Up @@ -67,6 +67,7 @@ export function getLanguageService(params: LanguageServiceParams): LanguageServi

const jsonCompletion = new JSONCompletion(jsonSchemaService, params.contributions, promise, params.clientCapabilities);
const jsonHover = new JSONHover(jsonSchemaService, params.contributions, promise);
const jsonLinks = new JSONLinks(jsonSchemaService, params.fileSystemProvider);
const jsonDocumentSymbols = new JSONDocumentSymbols(jsonSchemaService);
const jsonValidation = new JSONValidation(jsonSchemaService, promise);

Expand All @@ -92,7 +93,7 @@ export function getLanguageService(params: LanguageServiceParams): LanguageServi
getFoldingRanges,
getSelectionRanges,
findDefinition: () => Promise.resolve([]),
findLinks,
findLinks: jsonLinks.findLinks.bind(jsonLinks),
format: (document: TextDocument, range: Range, options: FormattingOptions) => format(document, options, range),
sort: (document: TextDocument, options: FormattingOptions) => sort(document, options)
};
Expand Down
47 changes: 47 additions & 0 deletions src/jsonLanguageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,49 @@ export interface Thenable<R> {
then<TResult>(onfulfilled?: (value: R) => TResult | Thenable<TResult>, onrejected?: (reason: any) => void): Thenable<TResult>;
}

export enum FileType {
/**
* The file type is unknown.
*/
Unknown = 0,
/**
* A regular file.
*/
File = 1,
/**
* A directory.
*/
Directory = 2,
/**
* A symbolic link to a file.
*/
SymbolicLink = 64
}

export interface FileStat {
/**
* The type of the file, e.g. is a regular file, a directory, or symbolic link
* to a file.
*/
type: FileType;
/**
* The creation timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC.
*/
ctime: number;
/**
* The modification timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC.
*/
mtime: number;
/**
* The size in bytes.
*/
size: number;
}

export interface FileSystemProvider {
stat(uri: DocumentUri): Promise<FileStat>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add specs here too, in particular what happens when the file does not exist.

}

export interface LanguageServiceParams {
/**
* The schema request service is used to fetch schemas from a URI. The provider returns the schema file content, or,
Expand All @@ -270,6 +313,10 @@ export interface LanguageServiceParams {
* A promise constructor. If not set, the ES5 Promise will be used.
*/
promiseConstructor?: PromiseConstructor;
/**
* Abstract file system access away from the service.
*/
fileSystemProvider?: FileSystemProvider;
/**
* Describes the LSP capabilities the client supports.
*/
Expand Down
97 changes: 91 additions & 6 deletions src/services/jsonLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,95 @@
*--------------------------------------------------------------------------------------------*/

import { DocumentLink } from 'vscode-languageserver-types';
import { TextDocument, ASTNode, PropertyASTNode, Range, Thenable } from '../jsonLanguageTypes';
import { TextDocument, ASTNode, PropertyASTNode, Range, Thenable, FileSystemProvider, FileType, FileStat } from '../jsonLanguageTypes';
import { JSONDocument } from '../parser/jsonParser';
import { IJSONSchemaService } from './jsonSchemaService';
import { URI, Utils } from 'vscode-uri';

export function findLinks(document: TextDocument, doc: JSONDocument): Thenable<DocumentLink[]> {
const links: DocumentLink[] = [];
export class JSONLinks {
private schemaService: IJSONSchemaService;
private fileSystemProvider: FileSystemProvider | undefined;

constructor(schemaService: IJSONSchemaService, fileSystemProvider?: FileSystemProvider) {
this.schemaService = schemaService;
this.fileSystemProvider = fileSystemProvider;
}

public findLinks(document: TextDocument, doc: JSONDocument): Thenable<DocumentLink[]> {
return findLinks(document, doc, this.schemaService, this.fileSystemProvider);
}
}

export function findLinks(document: TextDocument, doc: JSONDocument, schemaService?: IJSONSchemaService, fileSystemProvider?: FileSystemProvider): Thenable<DocumentLink[]> {
const promises: Thenable<DocumentLink[]>[] = [];

const refLinks: DocumentLink[] = [];
doc.visit(node => {
if (node.type === "property" && node.keyNode.value === "$ref" && node.valueNode?.type === 'string') {
if (node.type === "property" && node.valueNode?.type === 'string' && node.keyNode.value === "$ref") {
const path = node.valueNode.value;
const targetNode = findTargetNode(doc, path);
if (targetNode) {
const targetPos = document.positionAt(targetNode.offset);
links.push({
refLinks.push({
target: `${document.uri}#${targetPos.line + 1},${targetPos.character + 1}`,
range: createRange(document, node.valueNode)
});
}
}
if (node.type === "property" && node.valueNode?.type === 'string' && schemaService && fileSystemProvider) {
const pathNode = node.valueNode;
const promise = schemaService.getSchemaForResource(document.uri, doc).then((schema) => {
const pathLinks: DocumentLink[] = [];
if (!schema) {
return pathLinks;
}

const matchingSchemas = doc.getMatchingSchemas(schema.schema, pathNode.offset);

let resolvedRef = '';
for (const s of matchingSchemas) {
if (s.node !== pathNode || s.inverted || !s.schema) {
continue; // Not an _exact_ schema match.
}
if (s.schema.format !== 'uri-reference') {
continue; // Not a uri-ref.
}
const pathURI = resolveURIRef(pathNode.value, document);
if (pathURI.scheme === 'file') {
resolvedRef = pathURI.toString();
}
}

if (resolvedRef) {
return fileSystemProvider.stat(resolvedRef).then((fs) => {
if (fileExists(fs)) {
pathLinks.push({
target: resolvedRef,
range: createRange(document, pathNode)
});
}
return pathLinks;
});
} else {
return pathLinks;
}
});
promises.push(promise);
}
return true;
});
return Promise.resolve(links);

promises.push(Promise.resolve(refLinks));
return Promise.all(promises).then(values => {
return values.flat();
});
}

function fileExists(stat: FileStat): boolean {
if (stat.type === FileType.Unknown && stat.size === -1) {
return false;
}
return true;
}

function createRange(document: TextDocument, node: ASTNode): Range {
Expand Down Expand Up @@ -81,3 +150,19 @@ function parseJSONPointer(path: string): string[] | null {
function unescape(str: string): string {
return str.replace(/~1/g, '/').replace(/~0/g, '~');
}

function resolveURIRef(ref: string, document: TextDocument): URI {
if (ref.indexOf('://') > 0) {
// Already a fully qualified URI.
return URI.parse(ref);
}

if (ref.startsWith('/')) {
// Already an absolute path, no need to resolve.
return URI.file(ref);
}

// Resolve ref relative to the document.
const docURI = URI.parse(document.uri);
return Utils.joinPath(Utils.dirname(docURI), ref);
}
Empty file.
62 changes: 61 additions & 1 deletion src/test/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@

import * as assert from 'assert';

import { getLanguageService, Range, TextDocument, ClientCapabilities } from '../jsonLanguageService';
import {
ClientCapabilities,
DocumentLink,
getLanguageService,
JSONSchema,
Range,
TextDocument,
} from '../jsonLanguageService';
import { getFsProvider } from './testUtil/fsProvider';
import * as path from 'path';
import { URI } from 'vscode-uri';

suite('JSON Find Links', () => {
const testFindLinksFor = function (value: string, expected: {offset: number, length: number, target: number} | null): PromiseLike<void> {
Expand All @@ -26,6 +36,19 @@ suite('JSON Find Links', () => {
});
};

function testFindLinksWithSchema(document: TextDocument, schema: JSONSchema): PromiseLike<DocumentLink[]> {
const schemaUri = "http://myschemastore/test1";

const ls = getLanguageService({
clientCapabilities: ClientCapabilities.LATEST,
fileSystemProvider: getFsProvider(),
});
ls.configure({ schemas: [{ fileMatch: ["*.json"], uri: schemaUri, schema }] });
const jsonDoc = ls.parseJSONDocument(document);

return ls.findLinks(document, jsonDoc);
}

test('FindDefinition invalid ref', async function () {
await testFindLinksFor('{}', null);
await testFindLinksFor('{"name": "John"}', null);
Expand All @@ -52,4 +75,41 @@ suite('JSON Find Links', () => {
await testFindLinksFor(doc('#/ '), {target: 81, offset: 102, length: 3});
await testFindLinksFor(doc('#/m~0n'), {target: 90, offset: 102, length: 6});
});

test('URI reference link', async function () {
// This test file runs in `./lib/umd/test`, but the fixtures are in `./src`.
const refRelPath = '../../../src/test/fixtures/uri-reference.txt';
const refAbsPath = path.join(__dirname, refRelPath);
const docAbsPath = path.join(__dirname, 'test.json');

const content = `{"stringProp": "string-value", "uriProp": "${refRelPath}", "uriPropNotFound": "./does/not/exist.txt"}`;
const document = TextDocument.create(URI.file(docAbsPath).toString(), 'json', 0, content);
const schema: JSONSchema = {
type: 'object',
properties: {
'stringProp': {
type: 'string',
},
'uriProp': {
type: 'string',
format: 'uri-reference'
},
'uriPropNotFound': {
type: 'string',
format: 'uri-reference'
}
}
};
await testFindLinksWithSchema(document, schema).then((links) => {
assert.notDeepEqual(links, []);

assert.equal(links[0].target, URI.file(refAbsPath).toString());

const startOffset = content.indexOf(refRelPath);
const endOffset = startOffset + refRelPath.length;
const range = Range.create(document.positionAt(startOffset), document.positionAt(endOffset));
assert.deepEqual(links[0].range, range);
});
});

});
52 changes: 52 additions & 0 deletions src/test/testUtil/fsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { FileSystemProvider, FileType } from "../../jsonLanguageTypes";
import { URI } from 'vscode-uri';
import { stat as fsStat } from 'fs';

export function getFsProvider(): FileSystemProvider {
return {
stat(documentUriString: string) {
return new Promise((c, e) => {
const documentUri = URI.parse(documentUriString);
if (documentUri.scheme !== 'file') {
e(new Error('Protocol not supported: ' + documentUri.scheme));
return;
}
fsStat(documentUri.fsPath, (err, stats) => {
if (err) {
if (err.code === 'ENOENT') {
return c({
type: FileType.Unknown,
ctime: -1,
mtime: -1,
size: -1
});
} else {
return e(err);
}
}

let type = FileType.Unknown;
if (stats.isFile()) {
type = FileType.File;
} else if (stats.isDirectory()) {
type = FileType.Directory;
} else if (stats.isSymbolicLink()) {
type = FileType.SymbolicLink;
}

c({
type,
ctime: stats.ctime.getTime(),
mtime: stats.mtime.getTime(),
size: stats.size
});
});
});
},
};
}
Loading