From ab60f271573c68c147f5401a9eff97b508fca89e Mon Sep 17 00:00:00 2001 From: "jb.muscat" Date: Fri, 26 Jul 2024 16:55:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Provide=20the=20rule's=20documentat?= =?UTF-8?q?ion=20url=20has=20codeDescription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow to "click to know more" on a problem's rule code. cf https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets#documentation-url --- server/__tests__/unit/utils.test.ts | 62 +++++++++++++++++++++++++---- server/src/server.ts | 2 +- server/src/util.ts | 42 ++++++++++++++++--- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/server/__tests__/unit/utils.test.ts b/server/__tests__/unit/utils.test.ts index 07d0ea5..5da16ef 100644 --- a/server/__tests__/unit/utils.test.ts +++ b/server/__tests__/unit/utils.test.ts @@ -1,9 +1,10 @@ /* eslint-disable require-jsdoc */ import { expect } from 'chai'; -import type { IRuleResult } from '@stoplight/spectral-core'; +import { IRuleResult, Ruleset } from '@stoplight/spectral-core'; +import { truthy, falsy } from '@stoplight/spectral-functions'; import { DiagnosticSeverity as SpectralDiagnosticSeverity } from '@stoplight/types'; -import { makeDiagnostic, makePublishDiagnosticsParams } from '../../src/util'; +import { getRuleDocumentationUrl, makeDiagnostic, makePublishDiagnosticsParams } from '../../src/util'; import { DiagnosticSeverity as VSCodeDiagnosticSeverity } from 'vscode-languageserver'; function createResult(source?: string): IRuleResult { @@ -28,10 +29,33 @@ function createResult(source?: string): IRuleResult { }; } +function createRuleset(rulesetDoc: string | undefined): Ruleset { + return new Ruleset({ + documentationUrl: rulesetDoc, + rules: { + 'with-direct-doc': { + documentationUrl: 'https://example.com/direct', + given: '$', + severity: 'error', + then: { + function: truthy, + }, + }, + 'without-direct-doc': { + given: '$', + severity: 'error', + then: { + function: falsy, + }, + }, + }, + }); +} + describe('makeDiagnostic', () => { it('sets the source to spectral', () => { const result = createResult(); - const actual = makeDiagnostic(result); + const actual = makeDiagnostic(result, undefined); expect(actual.source).to.eql('spectral'); }); @@ -47,13 +71,12 @@ describe('makeDiagnostic', () => { const result = createResult(); result.severity = input; - const actual = makeDiagnostic(result); + const actual = makeDiagnostic(result, undefined); expect(actual.severity).to.eql(expected); }); }); }); - describe('makePublishDiagnosticsParams', () => { const sources: string[] = [ 'file:///c%3A/folder/test.txt', @@ -63,7 +86,7 @@ describe('makePublishDiagnosticsParams', () => { describe('returns an empty array of diagnostics for the root file being analyzed even when it has no issues', () => { sources.forEach((sourceUri) => { it(sourceUri, () => { - const actual = makePublishDiagnosticsParams(sourceUri, [], []); + const actual = makePublishDiagnosticsParams(sourceUri, [], [], undefined); expect(actual).to.have.length(1); expect(actual[0].uri).to.eql(sourceUri); @@ -76,7 +99,7 @@ describe('makePublishDiagnosticsParams', () => { sources.forEach((sourceUri) => { it(sourceUri, () => { const fakeRoot = 'file:///different/root'; - const actual = makePublishDiagnosticsParams(fakeRoot, [sourceUri], []); + const actual = makePublishDiagnosticsParams(fakeRoot, [sourceUri], [], undefined); expect(actual).to.have.length(2); @@ -118,7 +141,7 @@ describe('makePublishDiagnosticsParams', () => { createResult('four'), ]; - const actual = makePublishDiagnosticsParams('file:///one', [], problems); + const actual = makePublishDiagnosticsParams('file:///one', [], problems, undefined); expect(actual).to.have.length(5); @@ -149,4 +172,27 @@ describe('makePublishDiagnosticsParams', () => { } }); }); + + describe('getRuleDocumentationUrl', () => { + it('uses the rule\'s documentation if it exists', () => { + const ruleset = createRuleset(undefined); + const documentationUrl = getRuleDocumentationUrl(ruleset, 'with-direct-doc'); + expect(documentationUrl).to.eql('https://example.com/direct'); + }); + it('uses the rule\'s documentation if it exists, even if the ruleset has its own', () => { + const ruleset = createRuleset('https://example.com'); + const documentationUrl = getRuleDocumentationUrl(ruleset, 'with-direct-doc'); + expect(documentationUrl).to.eql('https://example.com/direct'); + }); + it('uses the ruleset\'s documentation and #code if the rule has no direct doc and the ruleset has one', () => { + const ruleset = createRuleset('https://example.com'); + const documentationUrl = getRuleDocumentationUrl(ruleset, 'without-direct-doc'); + expect(documentationUrl).to.eql('https://example.com#without-direct-doc'); + }); + it('returns undefined if neither the rule or ruleset has a documentation', () => { + const ruleset = createRuleset(undefined); + const documentationUrl = getRuleDocumentationUrl(ruleset, 'without-direct-doc'); + expect(documentationUrl).to.be.undefined; + }); + }); }); diff --git a/server/src/server.ts b/server/src/server.ts index 48ca86f..5983870 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -364,7 +364,7 @@ async function lintDocumentOrRoot(document: TextDocument, ruleset: Ruleset | und connection.console.log(`[DBG] lintDocumentOrRoot. knownDeps=${JSON.stringify([...knownDeps])}`); - const pdps = makePublishDiagnosticsParams(rootDocument.uri, [...knownDeps], results); + const pdps = makePublishDiagnosticsParams(rootDocument.uri, [...knownDeps], results, ruleset); const deps = pdps.filter((e) => e.uri !== rootDocument.uri).map<[string, string]>((e) => [e.uri, rootDocument.uri]); return [pdps, deps]; diff --git a/server/src/util.ts b/server/src/util.ts index 6c119ac..8884d65 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -6,7 +6,7 @@ import { PublishDiagnosticsParams, } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; -import type { ISpectralDiagnostic } from '@stoplight/spectral-core'; +import type { ISpectralDiagnostic, Ruleset } from '@stoplight/spectral-core'; import { DiagnosticSeverity as SpectralDiagnosticSeverity } from '@stoplight/types'; /** @@ -32,10 +32,11 @@ function convertSeverity(severity: SpectralDiagnosticSeverity): DiagnosticSeveri /** * Converts a Spectral rule violation to a VS Code diagnostic. * @param {ISpectralDiagnostic} problem - The Spectral rule result to convert to a VS Code diagnostic message. + * @param {Ruleset | undefined} ruleset - The ruleset that was used to validate the document. * @return {Diagnostic} The converted VS Code diagnostic to send to the client. */ -export function makeDiagnostic(problem: ISpectralDiagnostic): Diagnostic { - return { +export function makeDiagnostic(problem: ISpectralDiagnostic, ruleset: Ruleset | undefined): Diagnostic { + const diagnostic: Diagnostic = { range: { start: { line: problem.range.start.line, @@ -51,9 +52,40 @@ export function makeDiagnostic(problem: ISpectralDiagnostic): Diagnostic { source: 'spectral', message: problem.message, }; + + const documentationUrl = getRuleDocumentationUrl(ruleset, problem.code); + if (documentationUrl) { + diagnostic.codeDescription = { + href: documentationUrl, + }; + } + + return diagnostic; +} + +/** + * Extract and construct the rule's documentation URL. + * @param {Ruleset | undefined} ruleset - The ruleset that was used to validate the document. + * @param {string | number} ruleCode - The code of the rule to find the documentation URL for. + * @return {string | undefined} The documentation URL for the rule, or undefined if not found. + */ +export function getRuleDocumentationUrl(ruleset: Ruleset | undefined, ruleCode: string | number): string | undefined { + if (!ruleset) { + return undefined; + } + + const rule = ruleset.rules[ruleCode]; + const ruleDocumentationUrl = rule?.documentationUrl; + const rulesetDocumentationUrl = rule?.owner?.definition.documentationUrl; // allow to find documentation from extended rulesets + + if (!ruleDocumentationUrl && !rulesetDocumentationUrl) { + return undefined; + } + + return ruleDocumentationUrl ?? rulesetDocumentationUrl + '#' + ruleCode; } -export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDependencieUris: string[], problems: ISpectralDiagnostic[]): PublishDiagnosticsParams[] { +export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDependencieUris: string[], problems: ISpectralDiagnostic[], ruleset: Ruleset | undefined): PublishDiagnosticsParams[] { const grouped = problems.reduce>((grouped, problem) => { if (problem.source === undefined) { return grouped; @@ -80,7 +112,7 @@ export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDepen return Object.entries(grouped).map(([source, problems]) => { return { uri: source, - diagnostics: problems.map((p) => makeDiagnostic(p)), + diagnostics: problems.map((p) => makeDiagnostic(p, ruleset)), }; }); }