diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac3d90f..190bb9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/jsonLanguageService.ts b/src/jsonLanguageService.ts index 1e2e54fa..0234288f 100644 --- a/src/jsonLanguageService.ts +++ b/src/jsonLanguageService.ts @@ -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 = { @@ -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); @@ -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) }; diff --git a/src/jsonLanguageTypes.ts b/src/jsonLanguageTypes.ts index 7f4ae6f1..790f26e3 100644 --- a/src/jsonLanguageTypes.ts +++ b/src/jsonLanguageTypes.ts @@ -252,6 +252,49 @@ export interface Thenable { then(onfulfilled?: (value: R) => TResult | Thenable, onrejected?: (reason: any) => void): Thenable; } +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; +} + export interface LanguageServiceParams { /** * The schema request service is used to fetch schemas from a URI. The provider returns the schema file content, or, @@ -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. */ diff --git a/src/services/jsonLinks.ts b/src/services/jsonLinks.ts index 3a73e4ff..e5090805 100644 --- a/src/services/jsonLinks.ts +++ b/src/services/jsonLinks.ts @@ -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 { - 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 { + return findLinks(document, doc, this.schemaService, this.fileSystemProvider); + } +} + +export function findLinks(document: TextDocument, doc: JSONDocument, schemaService?: IJSONSchemaService, fileSystemProvider?: FileSystemProvider): Thenable { + const promises: Thenable[] = []; + + 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 { @@ -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); +} diff --git a/src/test/fixtures/uri-reference.txt b/src/test/fixtures/uri-reference.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/test/links.test.ts b/src/test/links.test.ts index 4594703a..faa90715 100644 --- a/src/test/links.test.ts +++ b/src/test/links.test.ts @@ -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 { @@ -26,6 +36,19 @@ suite('JSON Find Links', () => { }); }; + function testFindLinksWithSchema(document: TextDocument, schema: JSONSchema): PromiseLike { + 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); @@ -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); + }); + }); + }); diff --git a/src/test/testUtil/fsProvider.ts b/src/test/testUtil/fsProvider.ts new file mode 100644 index 00000000..8b027081 --- /dev/null +++ b/src/test/testUtil/fsProvider.ts @@ -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 + }); + }); + }); + }, + }; +}