diff --git a/CHANGELOG.md b/CHANGELOG.md index ce442ed..03250e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the "PHP Sniffer" extension will be documented in this fi - Reword ENOENT errors - Add new setting `disableWhenDebugging` to disable `phpcs` when any debug session is active (#42) - Add option to disable validation (#38) +- Add setting for running on non-PHP files (#16) ### Fixed - Avoid "write EPIPE" error (#35) diff --git a/README.md b/README.md index c468930..a4a20a7 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ [![PHP Sniffer on the Visual Studio Marketplace](https://vsmarketplacebadge.apphb.com/version-short/wongjn.php-sniffer.svg)](https://marketplace.visualstudio.com/items?itemName=wongjn.php-sniffer) Uses [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) to format -and lint PHP code. +and lint (mainly) PHP code. ## Features -- Runs `phpcs` to lint PHP code. -- Runs `phpcbf` to format fixable PHP code validation errors, using the built-in - commands "Format Document" or "Format Selection". - - One may need to set this extension as the default PHP language formatter if - you have more than one PHP language extension enabled. Use the following - snippet in a `settings.json`: +- Runs `phpcs` to lint code. +- Runs `phpcbf` to format fixable code validation errors, using the built-in + commands "Format Document" or "Format Selection" (PHP only). + - One may need to set this extension as the default language formatter for + some languages. The following snippet is an example for PHP that can be + added in a `settings.json`: ```json { "[php]": { @@ -63,6 +63,11 @@ or `never`. amount of milliseconds the validator will wait after typing has stopped before it will run. The validator will also cancel an older run if the run is on the same file. +* `phpSniffer.extraFiles`: [Glob patterns](https://code.visualstudio.com/api/references/vscode-api#GlobPattern) +of extra files to match that this extension should run on. Useful for standards +that don't just validate PHP files. This extension will **always** run on PHP +files — be sure to have your `files.associations` setting correctly setup for +PHP files. * `phpSniffer.executablesFolder`: The **folder** where both `phpcs` and `phpcbf` executables are. Use this to specify a different executable if it is not in your global `PATH`, such as when using `PHP_Codesniffer` as a project-scoped @@ -79,8 +84,8 @@ at the root of the currently open file's workspace folder in the following order 2. `phpcs.xml` 3. `.phpcs.xml.dist` 4. `phpcs.xml.dist` -* `phpSniffer.snippetExcludeSniffs`: Sniffs to exclude when formatting a code -snippet (such as when _formatting on paste_ or on the command +* `phpSniffer.snippetExcludeSniffs`: Sniffs to exclude when formatting a PHP +code snippet (such as when _formatting on paste_ or on the command `format on selection`). This is passed to the `phpcbf` command as the value for `--exclude` when **not** formatting a whole file. * `phpSniffer.disableWhenDebugging`: Disable sniffing when any debug session is diff --git a/extension.js b/extension.js index 55f80d4..6a805d2 100644 --- a/extension.js +++ b/extension.js @@ -3,8 +3,8 @@ * Extension entry. */ -const vscode = require('vscode'); -const { Formatter } = require('./lib/formatter'); +const { languages } = require('vscode'); +const { activateGenericFormatter, Formatter } = require('./lib/formatter'); const { createValidator } = require('./lib/validator'); module.exports = { @@ -16,10 +16,8 @@ module.exports = { */ activate(context) { context.subscriptions.push( - vscode.languages.registerDocumentRangeFormattingEditProvider( - { language: 'php', scheme: 'file' }, - Formatter, - ), + languages.registerDocumentRangeFormattingEditProvider({ language: 'php', scheme: 'file' }, Formatter), + activateGenericFormatter(), createValidator(), ); }, diff --git a/lib/files.js b/lib/files.js new file mode 100644 index 0000000..44c008f --- /dev/null +++ b/lib/files.js @@ -0,0 +1,17 @@ +/** + * @file + * Utilities relating to files. + */ + +const { workspace } = require('vscode'); + +/** + * Returns the `extraFiles` configuration as an array of document filters. + * + * @return {import('vscode').DocumentFilter[]} + * Document filters. + */ +module.exports.getExtraFileSelectors = () => workspace + .getConfiguration('phpSniffer') + .get('extraFiles', []) + .map((pattern) => ({ pattern, scheme: 'file' })); diff --git a/lib/formatter.js b/lib/formatter.js index f4c7e81..673aa7a 100644 --- a/lib/formatter.js +++ b/lib/formatter.js @@ -3,9 +3,24 @@ * Contains the Formatter class. */ -const { Position, ProgressLocation, Range, TextEdit, window } = require('vscode'); +const { languages, Position, ProgressLocation, Range, TextEdit, window, workspace } = require('vscode'); const { processSnippet } = require('./strings'); const { createRunner } = require('./runner'); +const { getExtraFileSelectors } = require('./files'); + +/** + * Gets a full range of a document. + * + * @param {import('vscode').TextDocument} document + * The document to get the full range of. + * + * @return {import('vscode').Range} + * The range that covers the whole document. + */ +const documentFullRange = (document) => new Range( + new Position(0, 0), + document.lineAt(document.lineCount - 1).range.end, +); /** * Tests whether a range is for the full document. @@ -18,32 +33,10 @@ const { createRunner } = require('./runner'); * @return {boolean} * `true` if the given `range` is the full `document`. */ -function isFullDocumentRange(range, document) { - const documentRange = new Range( - new Position(0, 0), - document.lineAt(document.lineCount - 1).range.end, - ); - - return range.isEqual(documentRange); -} - -/** - * A formatter function to format text via PHPCBF. - * - * @callback Format - * - * @param {string} text - * The string to format. - * - * @return {Promise} - * A promise that resolves to the formatted text. - * - * @throws {Error} - * When there is an error with executing the formatting command. - */ +const isFullDocumentRange = (range, document) => range.isEqual(documentFullRange(document)); /** - * Formatter provider. + * Formatter provider for PHP files. * * @type {import('vscode').DocumentRangeFormattingEditProvider} */ @@ -65,3 +58,56 @@ module.exports.Formatter = { return replacement ? [new TextEdit(range, replacement)] : []; }, }; + +/** + * Formatter provider for non-PHP files. + * + * @type {import('vscode').DocumentFormattingEditProvider} + */ +const GenericFormatter = { + /** + * {@inheritDoc} + */ + provideDocumentFormattingEdits(document, formatOptions, token) { + return module.exports.Formatter.provideDocumentRangeFormattingEdits( + document, + documentFullRange(document), + formatOptions, + token, + ); + }, +}; + +/** + * Registers the generic formatter. + * + * @return {import('vscode').Disposable} + * Disposable for the formatter. + */ +const registerGenericFormatter = () => languages.registerDocumentFormattingEditProvider( + getExtraFileSelectors(), + GenericFormatter, +); + +/** + * Formatter provider for any file type. + * + * @return {import('vscode').Disposable} + */ +module.exports.activateGenericFormatter = () => { + let formatter = registerGenericFormatter(); + + const onConfigChange = workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration('phpSniffer.extraFiles')) { + formatter.dispose(); + formatter = registerGenericFormatter(); + } + }); + + return { + dispose() { + onConfigChange.dispose(); + formatter.dispose(); + }, + }; +}; diff --git a/lib/runner.js b/lib/runner.js index 6dfcd7b..779dcc8 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -114,7 +114,12 @@ const createRunner = (token, uri, fullDocument = true) => { ['report', 'json'], ['bootstrap', resolve(__dirname, 'files.php')], ]); - if (uri.scheme === 'file') args.set('stdin-path', uri.fsPath); + if (uri.scheme === 'file') { + args.set('stdin-path', uri.fsPath); + // Use the same logic that is in PHP_CodeSniffer to parse the file's + // extension (see https://github.com/squizlabs/PHP_CodeSniffer/blob/3.5.8/src/Files/File.php#L242). + args.set('extensions', uri.path.split('.').pop() || ''); + } if (standard) args.set('standard', standard); /** @type string[] */ diff --git a/lib/validator.js b/lib/validator.js index b873210..73957c9 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -8,6 +8,7 @@ const { languages, window, workspace, CancellationTokenSource, ProgressLocation, const { reportFlatten } = require('./phpcs-report'); const { createRunner } = require('./runner'); const { createTokenManager } = require('./tokens'); +const { getExtraFileSelectors } = require('./files'); /** * @typedef {import('vscode').Diagnostic} Diagnostic @@ -108,13 +109,22 @@ const onDocumentClose = (diagnostics, tokenManager) => ({ uri }) => { }; /** - * Whether validation is disabled currently with contextual circumstances. + * Whether validation should run for the given document. * - * @return {boolean} True if no validation should run, false otherwise. + * @param {import('vscode').TextDocument} document + * The document to validate. + * + * @return {boolean} + * True if validation should run, false otherwise. */ -const validationDisabled = () => { - const config = workspace.getConfiguration('phpSniffer'); - return config.get('disableWhenDebugging', false) && !!debug.activeDebugSession; +const shouldValidate = (document) => { + const config = workspace.getConfiguration('phpSniffer', document.uri); + + return ( + !document.isClosed + && (document.languageId === 'php' || languages.match(getExtraFileSelectors(), document) > 0) + && (!config.get('disableWhenDebugging', false) || !debug.activeDebugSession) + ); }; /** @@ -129,7 +139,7 @@ const validationDisabled = () => { * The validator function. */ const validateDocument = (diagnostics, tokenManager) => (document) => { - if (document.languageId !== 'php' || document.isClosed || validationDisabled()) { + if (!shouldValidate(document)) { return; } diff --git a/package.json b/package.json index e89258f..fa69b26 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "phpcbf" ], "activationEvents": [ - "onLanguage:php" + "onStartupFinished" ], "main": "./extension", "contributes": { @@ -47,6 +47,13 @@ "minimum": 0, "markdownDescription": "When `phpSniffer.run` is `onType`, this sets the amount of milliseconds the validator will wait after typing has stopped before it will run." }, + "phpSniffer.extraFiles": { + "type": "array", + "uniqueItems": true, + "default": [], + "markdownDescription": "[Glob patterns](https://code.visualstudio.com/api/references/vscode-api#GlobPattern) of extra files to match that this extension should run on. Useful for standards that don't just validate PHP files.", + "items": { "type": "string" } + }, "phpSniffer.executablesFolder": { "scope": "resource", "type": "string", diff --git a/test/fixtures/.vscode/settings.json b/test/fixtures/.vscode/settings.json index 90bf05f..5291b71 100644 --- a/test/fixtures/.vscode/settings.json +++ b/test/fixtures/.vscode/settings.json @@ -1,6 +1,5 @@ { "php.validate.enable": false, - "[php]": { - "editor.defaultFormatter": "wongjn.php-sniffer" - } + "css.validate": false, + "editor.defaultFormatter": "wongjn.php-sniffer" } diff --git a/test/fixtures/css.xml b/test/fixtures/css.xml new file mode 100644 index 0000000..4b6f95b --- /dev/null +++ b/test/fixtures/css.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/functional/extra-files.test.js b/test/functional/extra-files.test.js new file mode 100644 index 0000000..8768138 --- /dev/null +++ b/test/functional/extra-files.test.js @@ -0,0 +1,50 @@ +const path = require('path'); +const assert = require('assert'); +const { commands, window, workspace, Uri } = require('vscode'); +const { execPromise, FIXTURES_PATH } = require('../utils'); +const { getNextDiagnostics } = require('./utils'); + +suite('`extraFiles` handling', function () { + const fixtureUri = Uri.file(path.join(FIXTURES_PATH, 'style.css')); + const textEncoder = new TextEncoder(); + + suiteSetup(function () { + this.timeout(60000); + + const config = workspace.getConfiguration('phpSniffer', fixtureUri); + return Promise.all([ + execPromise('composer install --no-dev', { cwd: FIXTURES_PATH }), + workspace.fs.writeFile(fixtureUri, textEncoder.encode('a{margin : 0}')), + config.update('executablesFolder', `vendor${path.sep}bin${path.sep}`), + config.update('standard', './css.xml'), + config.update('extraFiles', ['**/*.css']), + ]); + }); + + suiteTeardown(function () { + const config = workspace.getConfiguration('phpSniffer', fixtureUri); + return Promise.all([ + workspace.fs.delete(fixtureUri), + config.update('executablesFolder', undefined), + config.update('standard', undefined), + config.update('extraFiles', undefined), + ]); + }); + + teardown(() => commands.executeCommand('workbench.action.closeAllEditors')); + + test('Validation errors reported', async function () { + const diagnosticsWatch = getNextDiagnostics(fixtureUri); + workspace.openTextDocument(fixtureUri); + + assert.strictEqual(1, (await diagnosticsWatch).length); + }); + + test('Formatting is applied', async function () { + const document = await workspace.openTextDocument(fixtureUri); + await window.showTextDocument(document); + await commands.executeCommand('editor.action.formatDocument'); + + assert.strictEqual(document.getText(), 'a{margin: 0}'); + }); +}); diff --git a/test/functional/locations.test.js b/test/functional/locations.test.js index 583219b..14535a3 100644 --- a/test/functional/locations.test.js +++ b/test/functional/locations.test.js @@ -1,8 +1,105 @@ -const { workspace, Uri } = require('vscode'); -const { sep } = require('path'); -const { testCase, hasGlobalPHPCS } = require('./utils'); +const assert = require('assert'); +const { commands, languages, window, workspace, Uri } = require('vscode'); +const path = require('path'); +const { createFile, writeFile, unlink } = require('fs-extra'); +const { hasGlobalPHPCS } = require('./utils'); const { execPromise, FIXTURES_PATH } = require('../utils'); +/** + * Test case function call. + * + * @param {object} options + * Options for the test case. + * @param {string} options.description + * Description of the suite. + * @param {string} options.content + * The content of the file for validation and before formatting. + * @param {{ row: number, column: number }[]} options.expectedValidationErrors + * Expected errors that should be should be in diagnostics. + * @param {string} options.expectedFormattedResult + * Expected file content after running formatting. + * @param {string} [options.standard] + * The standard to test with. + * @param {Function} [options.testSetup] + * Optional function to run on suiteSetup, with an optional returned function + * to run on teardown. + */ +function testCase({ + description, + content, + expectedValidationErrors, + expectedFormattedResult, + standard, + testSetup, +}) { + const filePath = path.join(FIXTURES_PATH, `index${Math.floor(Math.random() * 3000)}.php`); + const fileUri = Uri.file(filePath); + + suite(description, function () { + // Possible teardown callback. + let tearDown; + + suiteSetup(async function () { + await Promise.all([ + createFile(filePath), + workspace.getConfiguration('phpSniffer', fileUri).update('standard', standard), + ]); + + await writeFile(filePath, content); + if (testSetup) tearDown = await testSetup.call(this, fileUri); + }); + + suiteTeardown(async function () { + await Promise.all([ + workspace.getConfiguration('phpSniffer', fileUri).update('standard', undefined), + unlink(filePath), + ]); + if (tearDown) await tearDown.call(this); + }); + + test('Validation errors are reported', async function () { + const diagnosticsPromise = new Promise((resolve) => { + const subscription = languages.onDidChangeDiagnostics(({ uris }) => { + const list = uris.map((uri) => uri.toString()); + if (list.indexOf(fileUri.toString()) === -1) return; + + const diagnostics = languages.getDiagnostics(fileUri); + if (diagnostics.length === 0) return; + + subscription.dispose(); + resolve(diagnostics); + }); + }); + + workspace.openTextDocument(fileUri); + const diagnostics = await diagnosticsPromise; + + assert.strictEqual( + diagnostics.length, + expectedValidationErrors.length, + 'Correct number of diagnostics are created.', + ); + diagnostics.forEach((diagnostic, i) => { + const { row, column } = expectedValidationErrors[i]; + const { start } = diagnostic.range; + + assert.strictEqual(start.line, row, `Diagnostic ${i + 1} line number is correct`); + assert.strictEqual(start.character, column, `Diagnostic ${i + 1} character position is correct`); + }); + }); + + test('Fixable validation errors are fixed via formatting', async function () { + // Visually open the document so commands can be run on it. + const document = await workspace.openTextDocument(fileUri); + await window.showTextDocument(document); + + await commands.executeCommand('editor.action.formatDocument'); + assert.strictEqual(document.getText(), expectedFormattedResult); + await commands.executeCommand('workbench.action.closeAllEditors'); + }); + }); +} + /** * Runs test cases for two files for preset and a local ruleset. */ @@ -74,7 +171,7 @@ suite('Executable & ruleset locations', function () { await execPromise('composer install --no-dev', { cwd: FIXTURES_PATH }); await workspace .getConfiguration('phpSniffer', Uri.file(FIXTURES_PATH)) - .update('executablesFolder', `vendor${sep}bin${sep}`); + .update('executablesFolder', `vendor${path.sep}bin${path.sep}`); }); suiteTeardown(async function () { diff --git a/test/functional/run.test.js b/test/functional/run.test.js index ad5ab64..46ec405 100644 --- a/test/functional/run.test.js +++ b/test/functional/run.test.js @@ -3,31 +3,7 @@ const path = require('path'); const { commands, languages, Range, window, workspace, Uri } = require('vscode'); const { createFile, writeFile, unlink } = require('fs-extra'); const { execPromise, FIXTURES_PATH } = require('../utils'); - -/** - * Get diagnostics for a file. - * - * @param {Uri} fileUri - * The URI of the file to get diagnostics of. - * @return {Promise} - * Diagnostics for the file. - */ -const getNextDiagnostics = (fileUri) => { - const existingCount = languages.getDiagnostics(fileUri).length; - - return new Promise((resolve) => { - const subscription = languages.onDidChangeDiagnostics(({ uris }) => { - const list = uris.map((uri) => uri.toString()); - if (list.indexOf(fileUri.toString()) === -1) return; - - const diagnostics = languages.getDiagnostics(fileUri); - if (diagnostics.length !== existingCount) { - resolve(diagnostics); - subscription.dispose(); - } - }); - }); -}; +const { getNextDiagnostics } = require('./utils'); /** * Constructs a promise that waits for the given length of time. diff --git a/test/functional/utils.js b/test/functional/utils.js index 2995465..5f9c6d2 100644 --- a/test/functional/utils.js +++ b/test/functional/utils.js @@ -3,11 +3,8 @@ * Utilities for tests. */ -const assert = require('assert'); -const path = require('path'); -const { createFile, writeFile, unlink } = require('fs-extra'); -const { commands, languages, window, workspace, Uri } = require('vscode'); -const { execPromise, FIXTURES_PATH } = require('../utils'); +const { languages } = require('vscode'); +const { execPromise } = require('../utils'); /** * Tests whether there is a global PHPCS on the current machine. @@ -27,98 +24,26 @@ async function hasGlobalPHPCS() { module.exports.hasGlobalPHPCS = hasGlobalPHPCS; /** - * Test case function call. + * Get diagnostics for a file. * - * @param {object} options - * Options for the test case. - * @param {string} options.description - * Description of the suite. - * @param {string} options.content - * The content of the file for validation and before formatting. - * @param {{ row: number, column: number }[]} options.expectedValidationErrors - * Expected errors that should be should be in diagnostics. - * @param {string} options.expectedFormattedResult - * Expected file content after running formatting. - * @param {string} [options.standard] - * The standard to test with. - * @param {Function} [options.testSetup] - * Optional function to run on suiteSetup, with an optional returned function - * to run on teardown. + * @param {import('vscode').Uri} fileUri + * The URI of the file to get diagnostics of. + * @return {Promise} + * Diagnostics for the file. */ -function testCase({ - description, - content, - expectedValidationErrors, - expectedFormattedResult, - standard, - testSetup, -}) { - const filePath = path.join(FIXTURES_PATH, `index${Math.floor(Math.random() * 3000)}.php`); - const fileUri = Uri.file(filePath); - - suite(description, function () { - // Possible teardown callback. - let tearDown; - - suiteSetup(async function () { - await Promise.all([ - createFile(filePath), - workspace.getConfiguration('phpSniffer', fileUri).update('standard', standard), - ]); - - await writeFile(filePath, content); - if (testSetup) tearDown = await testSetup.call(this, fileUri); - }); - - suiteTeardown(async function () { - await Promise.all([ - workspace.getConfiguration('phpSniffer', fileUri).update('standard', undefined), - unlink(filePath), - ]); - if (tearDown) await tearDown.call(this); - }); - - test('Validation errors are reported', async function () { - const diagnosticsPromise = new Promise((resolve) => { - const subscription = languages.onDidChangeDiagnostics(({ uris }) => { - const list = uris.map((uri) => uri.toString()); - if (list.indexOf(fileUri.toString()) === -1) return; - - const diagnostics = languages.getDiagnostics(fileUri); - if (diagnostics.length === 0) return; - - subscription.dispose(); - resolve(diagnostics); - }); - }); - - workspace.openTextDocument(fileUri); - const diagnostics = await diagnosticsPromise; - - assert.strictEqual( - diagnostics.length, - expectedValidationErrors.length, - 'Correct number of diagnostics are created.', - ); - diagnostics.forEach((diagnostic, i) => { - const { row, column } = expectedValidationErrors[i]; - const { start } = diagnostic.range; - - assert.strictEqual(start.line, row, `Diagnostic ${i + 1} line number is correct`); - assert.strictEqual(start.character, column, `Diagnostic ${i + 1} character position is correct`); - }); - }); - - test('Fixable validation errors are fixed via formatting', async function () { - // Visually open the document so commands can be run on it. - const document = await workspace.openTextDocument(fileUri); - await window.showTextDocument(document); - - await commands.executeCommand('editor.action.formatDocument'); - assert.strictEqual(document.getText(), expectedFormattedResult); - await commands.executeCommand('workbench.action.closeAllEditors'); +module.exports.getNextDiagnostics = (fileUri) => { + const existingCount = languages.getDiagnostics(fileUri).length; + + return new Promise((resolve) => { + const subscription = languages.onDidChangeDiagnostics(({ uris }) => { + const list = uris.map((uri) => uri.toString()); + if (list.indexOf(fileUri.toString()) === -1) return; + + const diagnostics = languages.getDiagnostics(fileUri); + if (diagnostics.length !== existingCount) { + resolve(diagnostics); + subscription.dispose(); + } }); }); -} - -module.exports.testCase = testCase; +}; diff --git a/test/integration/files.test.js b/test/integration/files.test.js new file mode 100644 index 0000000..1862987 --- /dev/null +++ b/test/integration/files.test.js @@ -0,0 +1,27 @@ +const assert = require('assert'); +const { workspace } = require('vscode'); +const { getExtraFileSelectors } = require('../../lib/files'); + +suite('getExtraFileSelectors()', function () { + suiteSetup(function () { + return workspace + .getConfiguration('phpSniffer') + .update('extraFiles', ['**/*.css', '**/*.md']); + }); + + suiteTeardown(function () { + return workspace + .getConfiguration('phpSniffer') + .update('extraFiles', undefined); + }); + + test('Returns document filters', function () { + assert.deepStrictEqual( + getExtraFileSelectors(), + [ + { scheme: 'file', pattern: '**/*.css' }, + { scheme: 'file', pattern: '**/*.md' }, + ], + ); + }); +});