Skip to content
Draft
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
162 changes: 143 additions & 19 deletions packages/extension-vscode/src/quickfix-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic, TextDocuments } from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { getFeatureNameFromDiagnostic } from './utils/problems';

import type { Problem } from '@hint/utils-types';

import { getFeatureNameFromDiagnostic, WebhintDiagnosticData } from './utils/problems';

export class QuickFixActionProvider {
private documents: TextDocuments<TextDocument>;
Expand All @@ -11,16 +14,116 @@ export class QuickFixActionProvider {
this.sourceName = sourceName;
}

private createCodeFixAction(hintName: string, diagnostic: Diagnostic, problem: Problem): CodeAction {
if (!problem.fixes) {
throw new Error('Unable to determine which fixes to apply');
}

const action = CodeAction.create(
`Fix '${hintName}' issue`,
{
arguments: [hintName, hintName],
command: 'vscode-webhint/apply-code-fix',
title: hintName
},
CodeActionKind.QuickFix
);

action.diagnostics = [diagnostic];

action.edit = {
changes: {
[problem.resource]: problem.fixes.map((fix) => {
return {
newText: fix.text,
range: {
end: {
character: fix.location.endColumn ?? 0,
line: fix.location.endLine ?? 0
},
start: {
character: fix.location.column,
line: fix.location.line
}
}
};
})
}
};

return action;
}

private createIgnoreAxeRuleAction(hintName: string, diagnostic: Diagnostic): CodeAction {
const command = 'vscode-webhint/ignore-axe-rule-project';
const url = diagnostic.codeDescription?.href;
const ruleName = url && url.substring(url.lastIndexOf('/') + 1, url.indexOf('?'));

/* istanbul ignore next */
if (!ruleName) {
throw new Error('Unable to determine which axe-core rule to ignore');
}

const action = CodeAction.create(
`Ignore '${ruleName}' accessibility in this project`,
{
arguments: [ruleName, hintName],
command,
title: ruleName
},
CodeActionKind.QuickFix
);

/*
* TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed
* action.diagnostics = [diagnostic];
*/

return action;
}

private createIgnoreBrowsersAction(hintName: string, diagnostic: Diagnostic, problem: Problem): CodeAction {
const command = 'vscode-webhint/ignore-browsers-project';
const browsers = problem.browsers;

// TODO: Consider passing the friendly browser names in the problem to avoid i18n issues.
const [, firstBrowser, separator] = diagnostic.message.match(/ by (.+?)(,|\. |\.$)/) || [];
const hasMore = separator === ',';

/* istanbul ignore next */
if (!browsers || !firstBrowser) {
throw new Error('Unable to determine which browsers to ignore');
}

const action = CodeAction.create(
`Ignore '${firstBrowser}${hasMore ? ', \u2026' : ''}' compatibility in this project`,
{
arguments: ['browsers', hintName, problem],
command,
title: 'browsers'
},
CodeActionKind.QuickFix
);

/*
* TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed
* action.diagnostics = [diagnostic];
*/

return action;
}

private createIgnoreFeatureAction(hintName: string, diagnostic: Diagnostic): CodeAction {
const command = 'vscode-webhint/ignore-feature-project';
const featureName = getFeatureNameFromDiagnostic(diagnostic);

/* istanbul ignore next */
if (!featureName) {
throw new Error('Unable to determine which HTML/CSS feature to ignore');
}

const action = CodeAction.create(
`Ignore '${featureName}' in this project`,
`Ignore '${featureName}' compatibility in this project`,
{
arguments: [featureName, hintName],
command,
Expand All @@ -29,7 +132,10 @@ export class QuickFixActionProvider {
CodeActionKind.QuickFix
);

action.diagnostics = [diagnostic];
/*
* TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed
* action.diagnostics = [diagnostic];
*/

return action;
}
Expand All @@ -46,7 +152,10 @@ export class QuickFixActionProvider {
CodeActionKind.QuickFix
);

action.diagnostics = [diagnostic];
/*
* TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed
* action.diagnostics = [diagnostic];
*/

return action;
}
Expand All @@ -58,31 +167,46 @@ export class QuickFixActionProvider {
return null;
}

const webhintDiagnostics = params.context.diagnostics.filter((diagnostic) => {
return diagnostic.data && diagnostic.source && diagnostic.source === this.sourceName;
});

if (webhintDiagnostics.length === 0) {
return null;
}

const results: CodeAction[] = [];

params.context.diagnostics.forEach((currentDiagnostic) => {
// only respond to requests for specified source.
if (!currentDiagnostic.source || currentDiagnostic.source !== this.sourceName) {
return;
}
// First add options to ignore reported diagnostics (if available).
webhintDiagnostics.forEach((diagnostic) => {
const hintName = `${diagnostic.code}`;
const data = diagnostic.data as WebhintDiagnosticData;

const hintName = `${currentDiagnostic.code}`;
if (data.problem.fixes?.length) {
results.push(this.createCodeFixAction(hintName, diagnostic, data.problem));
}

if (hintName.startsWith('axe/')) {
results.push(this.createIgnoreAxeRuleAction(hintName, diagnostic));
}
if (hintName.startsWith('compat-api/')) {
// Prefer ignoring specific HTML/CSS features when possible.
results.push(this.createIgnoreFeatureAction(hintName, currentDiagnostic));
results.push(this.createIgnoreFeatureAction(hintName, diagnostic));
}
if (data.problem.browsers) {
results.push(this.createIgnoreBrowsersAction(hintName, diagnostic, data.problem));
}
});

// Offer to disable the entire hint.
results.push(this.createIgnoreHintAction(hintName, currentDiagnostic));
// Then add options to disable the hints that reported the diagnostics.
webhintDiagnostics.forEach((diagnostic) => {
results.push(this.createIgnoreHintAction(`${diagnostic.code}`, diagnostic));
});

if (results.length > 0) {
const editCurrentProjectConfigTitle = 'Edit .hintrc for current project';
const editCurrentProjectConfig: Command = { command: 'vscode-webhint/edit-hintrc-project', title: editCurrentProjectConfigTitle };
// Finally, add a shortcut to edit the .hintrc file.
const editCurrentProjectConfigTitle = 'Edit .hintrc for current project';
const editCurrentProjectConfig: Command = { command: 'vscode-webhint/edit-hintrc-project', title: editCurrentProjectConfigTitle };

results.push(CodeAction.create(editCurrentProjectConfigTitle, editCurrentProjectConfig, CodeActionKind.QuickFix));
}
results.push(CodeAction.create(editCurrentProjectConfigTitle, editCurrentProjectConfig, CodeActionKind.QuickFix));

return results;
}
Expand Down
18 changes: 16 additions & 2 deletions packages/extension-vscode/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';

import { pathToFileURL } from 'node:url';

import type { Problem } from '@hint/utils-types';

import { Analyzer } from './utils/analyze';
import { QuickFixActionProvider } from './quickfix-provider';
import {WebhintConfiguratorParser} from './utils/webhint-utils';
import { WebhintConfiguratorParser } from './utils/webhint-utils';

import * as path from 'path';

Expand All @@ -28,7 +30,10 @@ connection.onInitialize((params) => {
codeActionProvider: true,
executeCommandProvider: {
commands: [
'vscode-webhint/apply-code-fix',
'vscode-webhint/ignore-hint-project',
'vscode-webhint/ignore-axe-rule-project',
'vscode-webhint/ignore-browsers-project',
'vscode-webhint/ignore-feature-project',
'vscode-webhint/edit-hintrc-project'
]
Expand All @@ -54,6 +59,7 @@ connection.onExecuteCommand(async (params) => {
const args = params.arguments ?? [];
const problemName = args[0] as string;
const hintName = args[1] as string;
const problem = args[2] as Problem;
const configurationParser = new WebhintConfiguratorParser();
const configFilePath = path.join(workspace, '.hintrc');

Expand All @@ -64,6 +70,14 @@ connection.onExecuteCommand(async (params) => {
await configurationParser.ignoreHintPerProject(hintName);
break;
}
case 'vscode-webhint/ignore-axe-rule-project': {
await configurationParser.addAxeRuleToIgnoredHintsConfig(hintName, problemName);
break;
}
case 'vscode-webhint/ignore-browsers-project': {
await configurationParser.addBrowsersToIgnoredHintsConfig(hintName, problem);
break;
}
case 'vscode-webhint/ignore-feature-project': {
await configurationParser.addFeatureToIgnoredHintsConfig(hintName, problemName);
break;
Expand Down
5 changes: 5 additions & 0 deletions packages/extension-vscode/src/utils/problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { TextDocument } from 'vscode-languageserver-textdocument';

import { Problem, Severity } from '@hint/utils-types';

export type WebhintDiagnosticData = {
problem: Problem;
};

// Translate a webhint severity into the VSCode DiagnosticSeverity format.
const webhintToDiagnosticServerity = (severity: Severity): DiagnosticSeverity => {
switch (severity) {
Expand Down Expand Up @@ -52,6 +56,7 @@ export const problemToDiagnostic = (problem: Problem, textDocument: TextDocument
return {
code: problem.hintId,
codeDescription: { href: docHref },
data: { problem } as WebhintDiagnosticData,
message: `${problem.message}`,
range: {
end: { character: endColumn, line: endLine },
Expand Down
85 changes: 82 additions & 3 deletions packages/extension-vscode/src/utils/webhint-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as fs from 'fs';

import type { Problem } from '@hint/utils-types';

import { hasFile } from './fs';
import type { HintConfig, UserConfig as WebhintUserConfig } from '@hint/utils';
import type { HintConfig, HintSeverity, UserConfig as WebhintUserConfig } from '@hint/utils';

export class WebhintConfiguratorParser {

Expand All @@ -14,9 +16,9 @@ export class WebhintConfiguratorParser {

if (!fileExists) {
// .hintrc does not exists so create one with the default config
const defaultConfig = { extends: ['development'] };
this.userConfig = { extends: ['development'] };

await fs.promises.writeFile(this.configFilePath, JSON.stringify(defaultConfig), 'utf-8');
await this.saveConfiguration();
}

// user config file is guaranteed to exist at this point, now read it.
Expand All @@ -27,6 +29,83 @@ export class WebhintConfiguratorParser {
return this.userConfig;
}

public async addAxeRuleToIgnoredHintsConfig(hintName: string, ruleName: string): Promise<void> {
/* istanbul ignore next */
if (!this.isInitialized() || (!hintName || !ruleName)) {
return;
}

if (!this.userConfig.hints) {
this.userConfig.hints = {};
}

// TODO: support array syntax
/* istanbul ignore next */
if (Array.isArray(this.userConfig.hints)) {
throw new Error('Cannot alter hints collection written as an array');
}

const hint = this.userConfig.hints[hintName];
const config: [HintSeverity, any] = ['default', {}];

if (typeof hint === 'string' || typeof hint === 'number') {
config[0] = hint;
} else if (Array.isArray(hint)) {
config[0] = hint[0];
config[1] = hint[1] || {};
}

const rulesConfig = config[1];

/* istanbul ignore next */
if (Array.isArray(rulesConfig)) {
throw new Error('Cannot alter axe-core rules collection written as an array');
}

rulesConfig[ruleName as keyof typeof rulesConfig] = 'off';

this.userConfig.hints[hintName] = config;

await this.saveConfiguration();
}

public async addBrowsersToIgnoredHintsConfig(hintName: string, problem: Problem): Promise<void> {
/* istanbul ignore next */
if (!this.isInitialized() || (!hintName || !problem || !problem.browsers)) {
return;
}

if (!this.userConfig.browserslist) {
this.userConfig.browserslist = ['defaults', 'not ie 11'];
}

if (typeof this.userConfig.browserslist === 'string') {
this.userConfig.browserslist = [this.userConfig.browserslist];
}

const browsers = new Map<string, number>();

// Keep only the highest version number to ignore per browser
for (const browser of problem.browsers) {
const [name, versions] = browser.split(' ');
const maxVersion = parseFloat(versions.split('-').pop() || '1');

if (maxVersion > (browsers.get(name) ?? 1)) {
browsers.set(name, maxVersion);
}
}

// Ignore everything below the highest target version number per browser
const ignoredBrowsers = Array.from(browsers.entries()).map(([name, version]) => {
return `not ${name} <= ${version}`;
});

// TODO: remove unnecessary entries (e.g. if both 'ie <= 9' and 'ie <= 11' are present).
this.userConfig.browserslist = [...this.userConfig.browserslist, ...ignoredBrowsers];

await this.saveConfiguration();
}

public async addFeatureToIgnoredHintsConfig(hintName: string, featureName: string): Promise<void> {
if (!this.isInitialized() || (!hintName || !featureName)) {
return;
Expand Down
4 changes: 4 additions & 0 deletions packages/extension-vscode/tests/fixtures/browsers/.hintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["development"],
"browserslist": ["defaults"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["development"]
}
Loading