Skip to content

Commit ab60f27

Browse files
jb.muscatouvreboite
authored andcommitted
✨ Provide the rule's documentation url has codeDescription
Allow to "click to know more" on a problem's rule code. cf https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets#documentation-url
1 parent 73dadc3 commit ab60f27

File tree

3 files changed

+92
-14
lines changed

3 files changed

+92
-14
lines changed

server/__tests__/unit/utils.test.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/* eslint-disable require-jsdoc */
22
import { expect } from 'chai';
33

4-
import type { IRuleResult } from '@stoplight/spectral-core';
4+
import { IRuleResult, Ruleset } from '@stoplight/spectral-core';
5+
import { truthy, falsy } from '@stoplight/spectral-functions';
56
import { DiagnosticSeverity as SpectralDiagnosticSeverity } from '@stoplight/types';
6-
import { makeDiagnostic, makePublishDiagnosticsParams } from '../../src/util';
7+
import { getRuleDocumentationUrl, makeDiagnostic, makePublishDiagnosticsParams } from '../../src/util';
78
import { DiagnosticSeverity as VSCodeDiagnosticSeverity } from 'vscode-languageserver';
89

910
function createResult(source?: string): IRuleResult {
@@ -28,10 +29,33 @@ function createResult(source?: string): IRuleResult {
2829
};
2930
}
3031

32+
function createRuleset(rulesetDoc: string | undefined): Ruleset {
33+
return new Ruleset({
34+
documentationUrl: rulesetDoc,
35+
rules: {
36+
'with-direct-doc': {
37+
documentationUrl: 'https://example.com/direct',
38+
given: '$',
39+
severity: 'error',
40+
then: {
41+
function: truthy,
42+
},
43+
},
44+
'without-direct-doc': {
45+
given: '$',
46+
severity: 'error',
47+
then: {
48+
function: falsy,
49+
},
50+
},
51+
},
52+
});
53+
}
54+
3155
describe('makeDiagnostic', () => {
3256
it('sets the source to spectral', () => {
3357
const result = createResult();
34-
const actual = makeDiagnostic(result);
58+
const actual = makeDiagnostic(result, undefined);
3559
expect(actual.source).to.eql('spectral');
3660
});
3761

@@ -47,13 +71,12 @@ describe('makeDiagnostic', () => {
4771
const result = createResult();
4872
result.severity = input;
4973

50-
const actual = makeDiagnostic(result);
74+
const actual = makeDiagnostic(result, undefined);
5175
expect(actual.severity).to.eql(expected);
5276
});
5377
});
5478
});
5579

56-
5780
describe('makePublishDiagnosticsParams', () => {
5881
const sources: string[] = [
5982
'file:///c%3A/folder/test.txt',
@@ -63,7 +86,7 @@ describe('makePublishDiagnosticsParams', () => {
6386
describe('returns an empty array of diagnostics for the root file being analyzed even when it has no issues', () => {
6487
sources.forEach((sourceUri) => {
6588
it(sourceUri, () => {
66-
const actual = makePublishDiagnosticsParams(sourceUri, [], []);
89+
const actual = makePublishDiagnosticsParams(sourceUri, [], [], undefined);
6790

6891
expect(actual).to.have.length(1);
6992
expect(actual[0].uri).to.eql(sourceUri);
@@ -76,7 +99,7 @@ describe('makePublishDiagnosticsParams', () => {
7699
sources.forEach((sourceUri) => {
77100
it(sourceUri, () => {
78101
const fakeRoot = 'file:///different/root';
79-
const actual = makePublishDiagnosticsParams(fakeRoot, [sourceUri], []);
102+
const actual = makePublishDiagnosticsParams(fakeRoot, [sourceUri], [], undefined);
80103

81104
expect(actual).to.have.length(2);
82105

@@ -118,7 +141,7 @@ describe('makePublishDiagnosticsParams', () => {
118141
createResult('four'),
119142
];
120143

121-
const actual = makePublishDiagnosticsParams('file:///one', [], problems);
144+
const actual = makePublishDiagnosticsParams('file:///one', [], problems, undefined);
122145

123146
expect(actual).to.have.length(5);
124147

@@ -149,4 +172,27 @@ describe('makePublishDiagnosticsParams', () => {
149172
}
150173
});
151174
});
175+
176+
describe('getRuleDocumentationUrl', () => {
177+
it('uses the rule\'s documentation if it exists', () => {
178+
const ruleset = createRuleset(undefined);
179+
const documentationUrl = getRuleDocumentationUrl(ruleset, 'with-direct-doc');
180+
expect(documentationUrl).to.eql('https://example.com/direct');
181+
});
182+
it('uses the rule\'s documentation if it exists, even if the ruleset has its own', () => {
183+
const ruleset = createRuleset('https://example.com');
184+
const documentationUrl = getRuleDocumentationUrl(ruleset, 'with-direct-doc');
185+
expect(documentationUrl).to.eql('https://example.com/direct');
186+
});
187+
it('uses the ruleset\'s documentation and #code if the rule has no direct doc and the ruleset has one', () => {
188+
const ruleset = createRuleset('https://example.com');
189+
const documentationUrl = getRuleDocumentationUrl(ruleset, 'without-direct-doc');
190+
expect(documentationUrl).to.eql('https://example.com#without-direct-doc');
191+
});
192+
it('returns undefined if neither the rule or ruleset has a documentation', () => {
193+
const ruleset = createRuleset(undefined);
194+
const documentationUrl = getRuleDocumentationUrl(ruleset, 'without-direct-doc');
195+
expect(documentationUrl).to.be.undefined;
196+
});
197+
});
152198
});

server/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ async function lintDocumentOrRoot(document: TextDocument, ruleset: Ruleset | und
364364

365365
connection.console.log(`[DBG] lintDocumentOrRoot. knownDeps=${JSON.stringify([...knownDeps])}`);
366366

367-
const pdps = makePublishDiagnosticsParams(rootDocument.uri, [...knownDeps], results);
367+
const pdps = makePublishDiagnosticsParams(rootDocument.uri, [...knownDeps], results, ruleset);
368368
const deps = pdps.filter((e) => e.uri !== rootDocument.uri).map<[string, string]>((e) => [e.uri, rootDocument.uri]);
369369

370370
return [pdps, deps];

server/src/util.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
PublishDiagnosticsParams,
77
} from 'vscode-languageserver';
88
import { URI } from 'vscode-uri';
9-
import type { ISpectralDiagnostic } from '@stoplight/spectral-core';
9+
import type { ISpectralDiagnostic, Ruleset } from '@stoplight/spectral-core';
1010
import { DiagnosticSeverity as SpectralDiagnosticSeverity } from '@stoplight/types';
1111

1212
/**
@@ -32,10 +32,11 @@ function convertSeverity(severity: SpectralDiagnosticSeverity): DiagnosticSeveri
3232
/**
3333
* Converts a Spectral rule violation to a VS Code diagnostic.
3434
* @param {ISpectralDiagnostic} problem - The Spectral rule result to convert to a VS Code diagnostic message.
35+
* @param {Ruleset | undefined} ruleset - The ruleset that was used to validate the document.
3536
* @return {Diagnostic} The converted VS Code diagnostic to send to the client.
3637
*/
37-
export function makeDiagnostic(problem: ISpectralDiagnostic): Diagnostic {
38-
return {
38+
export function makeDiagnostic(problem: ISpectralDiagnostic, ruleset: Ruleset | undefined): Diagnostic {
39+
const diagnostic: Diagnostic = {
3940
range: {
4041
start: {
4142
line: problem.range.start.line,
@@ -51,9 +52,40 @@ export function makeDiagnostic(problem: ISpectralDiagnostic): Diagnostic {
5152
source: 'spectral',
5253
message: problem.message,
5354
};
55+
56+
const documentationUrl = getRuleDocumentationUrl(ruleset, problem.code);
57+
if (documentationUrl) {
58+
diagnostic.codeDescription = {
59+
href: documentationUrl,
60+
};
61+
}
62+
63+
return diagnostic;
64+
}
65+
66+
/**
67+
* Extract and construct the rule's documentation URL.
68+
* @param {Ruleset | undefined} ruleset - The ruleset that was used to validate the document.
69+
* @param {string | number} ruleCode - The code of the rule to find the documentation URL for.
70+
* @return {string | undefined} The documentation URL for the rule, or undefined if not found.
71+
*/
72+
export function getRuleDocumentationUrl(ruleset: Ruleset | undefined, ruleCode: string | number): string | undefined {
73+
if (!ruleset) {
74+
return undefined;
75+
}
76+
77+
const rule = ruleset.rules[ruleCode];
78+
const ruleDocumentationUrl = rule?.documentationUrl;
79+
const rulesetDocumentationUrl = rule?.owner?.definition.documentationUrl; // allow to find documentation from extended rulesets
80+
81+
if (!ruleDocumentationUrl && !rulesetDocumentationUrl) {
82+
return undefined;
83+
}
84+
85+
return ruleDocumentationUrl ?? rulesetDocumentationUrl + '#' + ruleCode;
5486
}
5587

56-
export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDependencieUris: string[], problems: ISpectralDiagnostic[]): PublishDiagnosticsParams[] {
88+
export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDependencieUris: string[], problems: ISpectralDiagnostic[], ruleset: Ruleset | undefined): PublishDiagnosticsParams[] {
5789
const grouped = problems.reduce<Record<string, ISpectralDiagnostic[]>>((grouped, problem) => {
5890
if (problem.source === undefined) {
5991
return grouped;
@@ -80,7 +112,7 @@ export function makePublishDiagnosticsParams(rootDocumentUri: string, knownDepen
80112
return Object.entries(grouped).map(([source, problems]) => {
81113
return {
82114
uri: source,
83-
diagnostics: problems.map((p) => makeDiagnostic(p)),
115+
diagnostics: problems.map((p) => makeDiagnostic(p, ruleset)),
84116
};
85117
});
86118
}

0 commit comments

Comments
 (0)