diff --git a/README.md b/README.md index 43aae7d3..1b9b8bef 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,13 @@ Default: `"npm"` Controls the package manager to be used to resolve the stylelint library. This has only an influence if the stylelint library is resolved globally. Valid values are `"npm"` or `"yarn"` or `"pnpm"`. +#### stylelint.snippet + +Type: `string[]` +Default: `["css","less","postcss","scss"]` + +An array of language identifiers specifying the files to enable snippets. + #### editor.codeActionsOnSave This setting supports the entry `source.fixAll.stylelint`. If set to `true` all auto-fixable stylelint errors will be fixed on save. diff --git a/package.json b/package.json index d309456b..197f79bd 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,20 @@ "xsl" ], "description": "An array of language ids which should be validated by stylelint." + }, + "stylelint.snippet": { + "scope": "resource", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "css", + "less", + "postcss", + "scss" + ], + "description": "An array of language ids which snippets are provided by stylelint." } } }, @@ -149,24 +163,6 @@ ".stylelintignore" ] } - ], - "snippets": [ - { - "language": "css", - "path": "./snippets/stylelint-disable.json" - }, - { - "language": "less", - "path": "./snippets/stylelint-disable.json" - }, - { - "language": "postcss", - "path": "./snippets/stylelint-disable.json" - }, - { - "language": "scss", - "path": "./snippets/stylelint-disable.json" - } ] }, "dependencies": { diff --git a/server.js b/server.js index 247b7fd0..7828c60c 100644 --- a/server.js +++ b/server.js @@ -19,12 +19,18 @@ const { CodeActionKind, TextDocumentEdit, CodeAction, + CompletionItemKind, + DiagnosticCode, + MarkupKind, + InsertTextFormat, } = require('vscode-languageserver'); const { TextDocument } = require('vscode-languageserver-textdocument'); /** * @typedef { import('vscode-languageserver').DocumentUri } DocumentUri * @typedef { import('vscode-languageserver').Diagnostic } Diagnostic + * @typedef { import('vscode-languageserver').CompletionItem } CompletionItem + * @typedef { import('vscode-languageserver').CompletionParams } CompletionParams * @typedef { import('vscode-languageserver-textdocument').TextDocument } TextDocument * @typedef { import('./lib/stylelint-vscode').DisableReportRange } DisableReportRange * @typedef { import('stylelint').Configuration } StylelintConfiguration @@ -57,10 +63,16 @@ let reportInvalidScopeDisables; let stylelintPath; /** @type {string[]} */ let validateLanguages; +/** @type {string[]} */ +let snippetLanguages; const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); +/** + * @type {Map} + */ +const documentDiagnostics = new Map(); /** * @type {Map} */ @@ -187,6 +199,7 @@ async function validate(document) { uri: document.uri, diagnostics: result.diagnostics, }); + documentDiagnostics.set(document.uri, result.diagnostics); if (result.needlessDisables) { needlessDisableReports.set(document.uri, result.needlessDisables); @@ -251,6 +264,7 @@ function clearDiagnostics(document) { uri: document.uri, diagnostics: [], }); + documentDiagnostics.delete(document.uri); needlessDisableReports.delete(document.uri); invalidScopeDisableReports.delete(document.uri); } @@ -276,6 +290,7 @@ connection.onInitialize(() => { commands: [CommandIds.applyAutoFix], }, codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix, StylelintSourceFixAll] }, + completionProvider: {}, }, }; }); @@ -291,6 +306,7 @@ connection.onDidChangeConfiguration(({ settings }) => { stylelintPath = settings.stylelint.stylelintPath; packageManager = settings.stylelint.packageManager || 'npm'; validateLanguages = settings.stylelint.validate || []; + snippetLanguages = settings.stylelint.snippet || ['css', 'less', 'postcss', 'scss']; const removeLanguages = oldValidateLanguages.filter((lang) => !validateLanguages.includes(lang)); @@ -429,6 +445,8 @@ connection.onCodeAction(async (params) => { } }); +connection.onCompletion(onCompletion); + documents.listen(connection); connection.listen(); @@ -499,6 +517,182 @@ function replaceEdits(document, newText) { return edits; } +/** + * @param {CompletionParams} params + * @returns {CompletionItem[]} + */ +function onCompletion(params) { + const uri = params.textDocument.uri; + const document = documents.get(uri); + + if (!document || !isValidateOn(document) || !snippetLanguages.includes(document.languageId)) { + return []; + } + + const diagnostics = documentDiagnostics.get(uri); + + if (!diagnostics) { + return [ + createDisableLineCompletionItem('stylelint-disable-line'), + createDisableLineCompletionItem('stylelint-disable-next-line'), + createDisableEnableCompletionItem(), + ]; + } + + /** @type {Set} */ + const needlessDisablesKeys = new Set(); + const needlessDisables = needlessDisableReports.get(uri); + + if (needlessDisables) { + for (const needlessDisable of needlessDisables) { + needlessDisablesKeys.add(computeKey(needlessDisable.diagnostic)); + } + } + + const thisLineRules = new Set(); + const nextLineRules = new Set(); + + for (const diagnostic of diagnostics) { + if (needlessDisablesKeys.has(computeKey(diagnostic))) { + continue; + } + + const start = diagnostic.range.start; + + const rule = String( + (DiagnosticCode.is(diagnostic.code) ? diagnostic.code.value : diagnostic.code) || '', + ); + + if (start.line === params.position.line) { + thisLineRules.add(rule); + } else if (start.line === params.position.line + 1) { + nextLineRules.add(rule); + } + } + + thisLineRules.delete(''); + thisLineRules.delete('CssSyntaxError'); + nextLineRules.delete(''); + nextLineRules.delete('CssSyntaxError'); + + /** @type {CompletionItem[]} */ + const results = []; + + const disableKind = getStyleLintDisableKind(document, params.position); + + if (disableKind) { + if (disableKind === 'stylelint-disable-line') { + for (const rule of thisLineRules) { + results.push({ + label: rule, + kind: CompletionItemKind.Snippet, + detail: `disable ${rule} rule. (stylelint)`, + }); + } + } else if ( + disableKind === 'stylelint-disable' || + disableKind === 'stylelint-disable-next-line' + ) { + for (const rule of nextLineRules) { + results.push({ + label: rule, + kind: CompletionItemKind.Snippet, + detail: `disable ${rule} rule. (stylelint)`, + }); + } + } + } else { + if (thisLineRules.size === 1) { + results.push( + createDisableLineCompletionItem('stylelint-disable-line', [...thisLineRules][0]), + ); + } else { + results.push(createDisableLineCompletionItem('stylelint-disable-line')); + } + + if (nextLineRules.size === 1) { + results.push( + createDisableLineCompletionItem('stylelint-disable-next-line', [...nextLineRules][0]), + ); + } else { + results.push(createDisableLineCompletionItem('stylelint-disable-next-line')); + } + + results.push(createDisableEnableCompletionItem()); + } + + return results; +} + +/** + * @param { 'stylelint-disable-line' | 'stylelint-disable-next-line' } kind + * @param {string} rule + * @returns {CompletionItem} + */ +function createDisableLineCompletionItem(kind, rule = '') { + return { + label: kind, + kind: CompletionItemKind.Snippet, + insertText: `/* ${kind} \${0:${rule || 'rule'}} */`, + insertTextFormat: InsertTextFormat.Snippet, + detail: + kind === 'stylelint-disable-line' + ? 'Turn off stylelint rules for individual lines only, after which you do not need to explicitly re-enable them. (stylelint)' + : 'Turn off stylelint rules for the next line only, after which you do not need to explicitly re-enable them. (stylelint)', + documentation: { + kind: MarkupKind.Markdown, + value: `\`\`\`css\n/* ${kind} ${rule || 'rule'} */\n\`\`\``, + }, + }; +} + +/** + * @returns {CompletionItem} + */ +function createDisableEnableCompletionItem() { + return { + label: 'stylelint-disable', + kind: CompletionItemKind.Snippet, + insertText: `/* stylelint-disable \${0:rule} */\n/* stylelint-enable \${0:rule} */`, + insertTextFormat: InsertTextFormat.Snippet, + detail: + 'Turn off all stylelint or individual rules, after which you do not need to re-enable stylelint. (stylelint)', + documentation: { + kind: MarkupKind.Markdown, + value: `\`\`\`css\n/* stylelint-disable rule */\n/* stylelint-enable rule */\n\`\`\``, + }, + }; +} + +/** + * Check if the given position is in the stylelint-disable comment. + * If inside a comment, return the kind of disable. + * @param {TextDocument} document + * @param {Position} position + */ +function getStyleLintDisableKind(document, position) { + const lineStartOffset = document.offsetAt(Position.create(position.line, 0)); + const lineEndOffset = document.offsetAt(Position.create(position.line + 1, 0)) - 1; + const line = document.getText().slice(lineStartOffset, lineEndOffset); + + const before = line.slice(0, position.character); + const after = line.slice(position.character); + + const disableKindResult = /\/\*\s*(stylelint-disable(?:(?:-next)?-line)?)\s+[a-z\-/\s,]*$/i.exec( + before, + ); + + if (!disableKindResult) { + return null; + } + + if (/^[a-z\-/\s,]*\*\//i.test(after)) { + return disableKindResult[1]; + } + + return null; +} + /** * @param {TextDocument} document * @param {DisableReportRange} range diff --git a/snippets/stylelint-disable.json b/snippets/stylelint-disable.json deleted file mode 100644 index b8fb215c..00000000 --- a/snippets/stylelint-disable.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "stylelint-disable": { - "prefix": "stylelint-disable", - "body": ["/* stylelint-disable ${1:rule} */", "$0/* stylelint-enable ${1:rule} */"], - "description": "Turn off all stylelint or individual rules, after which you do not need to re-enable stylelint." - }, - "stylelint-disable-line": { - "prefix": "stylelint-disable-line", - "body": ["/* stylelint-disable-line ${0:rule} */"], - "description": "Turn off stylelint rules for individual lines only, after which you do not need to explicitly re-enable them." - }, - "stylelint-disable-next-line": { - "prefix": "stylelint-disable-next-line", - "body": ["/* stylelint-disable-next-line ${0:rule} */"], - "description": "Turn off stylelint rules for the next line only, after which you do not need to explicitly re-enable them." - } -} diff --git a/snippets/stylelint-disable.test.js b/snippets/stylelint-disable.test.js deleted file mode 100644 index 6810e9d4..00000000 --- a/snippets/stylelint-disable.test.js +++ /dev/null @@ -1,49 +0,0 @@ -'use strict'; - -const snippets = require('./stylelint-disable.json'); - -const unique = (xs) => [...new Set(xs)]; - -describe('Snippets', () => { - const keys = Object.keys(snippets); - - it('should not be empty', () => { - expect(keys.length).toBeGreaterThan(0); - }); - - it('is sorted by key', () => { - const sortedKeys = [...keys].sort(); - - expect(keys).toEqual(sortedKeys); - }); - - it('has unique prefixes', () => { - const prefixes = Object.values(snippets).map((x) => x.prefix); - - expect(prefixes).toEqual(unique(prefixes)); - }); - - describe.each(keys)('%s', (key) => { - it('should have a prefix', () => { - const { prefix } = snippets[key]; - - expect(prefix).toBeDefined(); - expect(prefix.length).toBeGreaterThan(0); - expect(prefix.startsWith('stylelint-disable')).toBe(true); - }); - - it('should have a body', () => { - const { body } = snippets[key]; - - expect(body).toBeDefined(); - expect(body.length).toBeGreaterThan(0); - }); - - it('should have a description', () => { - const { description } = snippets[key]; - - expect(description).toBeDefined(); - expect(description.length).toBeGreaterThan(0); - }); - }); -});