Skip to content

Commit

Permalink
✨ Provide the rule's documentation url has codeDescription
Browse files Browse the repository at this point in the history
Allow to "click to know more" on a problem's rule code.

cf https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets#documentation-url
  • Loading branch information
jb.muscat authored and ouvreboite committed Nov 25, 2024
1 parent 73dadc3 commit ab60f27
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 14 deletions.
62 changes: 54 additions & 8 deletions server/__tests__/unit/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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');
});

Expand All @@ -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',
Expand All @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
});
});
});
2 changes: 1 addition & 1 deletion server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
42 changes: 37 additions & 5 deletions server/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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,
Expand All @@ -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<Record<string, ISpectralDiagnostic[]>>((grouped, problem) => {
if (problem.source === undefined) {
return grouped;
Expand All @@ -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)),
};
});
}

0 comments on commit ab60f27

Please sign in to comment.