diff --git a/README.md b/README.md index e4a08da4..2666bef8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ _**Note:** The behavior of actions like this one is currently limited in the con - **Ruby:** - [ERB Lint](https://github.com/Shopify/erb-lint) - [RuboCop](https://rubocop.readthedocs.io) + - [StandardRB](https://github.com/standardrb/standard) - **Rust:** - [clippy](https://github.com/rust-lang/rust-clippy) - [rustfmt](https://github.com/rust-lang/rustfmt) @@ -386,7 +387,7 @@ With `auto_fix` set to `true`, by default the action will try and fix code issue ### Linter-specific options -`[linter]` can be one of `autopep8`, `black`, `clang_format`, `dotnet_format`, `erblint`, `eslint`, `flake8`, `gofmt`, `golint`, `mypy`, `oitnb`, `php_codesniffer`, `prettier`, `pylint`, `rubocop`, `stylelint`, `swift_format_official`, `swift_format_lockwood`, `swiftlint` and `xo`: +`[linter]` can be one of `autopep8`, `black`, `clang_format`, `dotnet_format`, `erblint`, `eslint`, `flake8`, `gofmt`, `golint`, `mypy`, `oitnb`, `php_codesniffer`, `prettier`, `pylint`, `rubocop`, `standardrb`, `stylelint`, `swift_format_official`, `swift_format_lockwood`, `swiftlint` and `xo`: - **`[linter]`:** Enables the linter in your repository. Default: `false` - **`[linter]_args`**: Additional arguments to pass to the linter. Example: `eslint_args: "--max-warnings 0"` if ESLint checks should fail even if there are no errors and only warnings. Default: `""` @@ -444,6 +445,7 @@ Some options are not available for specific linters: | pylint | ❌ | ❌ (py) | | rubocop | ✅ | ❌ (rb) | | rustfmt | ✅ | ❌ (rs) | +| standardrb | ✅ | ❌ (rb) | | stylelint | ✅ | ✅ | | swift_format_official | ✅ | ✅ | | swift_format_lockwood | ✅ | ❌ (swift) | diff --git a/action.yml b/action.yml index 0bc82a37..896d989a 100644 --- a/action.yml +++ b/action.yml @@ -418,6 +418,30 @@ inputs: # Ruby + standardrb: + description: Enable or disable StandardRB checks + required: false + default: "false" + standardrb_args: + description: Additional arguments to pass to the linter + required: false + default: "" + standardrb_dir: + description: Directory where the standardrb command should be run + required: false + standardrb_extensions: + description: Extensions of files to check with standardrb + required: false + default: "rb" + standardrb_command_prefix: + description: Shell command to prepend to the linter command + required: false + default: "bundle exec" + standardrb_auto_fix: + description: Whether this linter should try to fix code style issues automatically. If set to `true`, it will only work if "auto_fix" is set to `true` as well + required: false + default: "true" + rubocop: description: Enable or disable RuboCop checks required: false diff --git a/dist/index.js b/dist/index.js index cf6190a9..3ef46e9d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7843,6 +7843,7 @@ const PHPCodeSniffer = __nccwpck_require__(5405); const Prettier = __nccwpck_require__(3460); const Pylint = __nccwpck_require__(4963); const RuboCop = __nccwpck_require__(1399); +const StandardRB = __nccwpck_require__(2790); const RustFmt = __nccwpck_require__(3421); const Stylelint = __nccwpck_require__(194); const SwiftFormatLockwood = __nccwpck_require__(8983); @@ -7862,6 +7863,7 @@ const linters = { php_codesniffer: PHPCodeSniffer, pylint: Pylint, rubocop: RuboCop, + standardrb: StandardRB, stylelint: Stylelint, swiftlint: SwiftLint, xo: XO, @@ -8584,6 +8586,119 @@ class RustFmt { module.exports = RustFmt; +/***/ }), + +/***/ 2790: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const { run } = __nccwpck_require__(9575); +const commandExists = __nccwpck_require__(5265); +const { initLintResult } = __nccwpck_require__(9149); +const { removeTrailingPeriod } = __nccwpck_require__(9321); + +/** @typedef {import('../utils/lint-result').LintResult} LintResult */ + +// Mapping of standard severities to severities used for GitHub commit annotations +const severityMap = { + refactor: "warning", + info: "warning", + convention: "warning", + refactor: "warning", + warning: "warning", + error: "error", + fatal: "error", +}; + +/** + * https://github.com/standardrb/standard/ + */ +class StandardRB { + static get name() { + return "Standardrb"; + } + + /** + * Verifies that all required programs are installed. Throws an error if programs are missing + * @param {string} dir - Directory to run the linting program in + * @param {string} prefix - Prefix to the lint command + */ + static async verifySetup(dir, prefix = "") { + // Verify that Ruby is installed (required to execute Standardrb) + if (!(await commandExists("ruby"))) { + throw new Error("Ruby is not installed"); + } + // Verify that Standardrb is installed + try { + run(`${prefix} standardrb -v`, { dir }); + } catch (err) { + throw new Error(`${this.name} is not installed`); + } + } + + /** + * Runs the linting program and returns the command output + * @param {string} dir - Directory to run the linter in + * @param {string[]} extensions - File extensions which should be linted + * @param {string} args - Additional arguments to pass to the linter + * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically + * @param {string} prefix - Prefix to the lint command + * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command + */ + static lint(dir, extensions, args = "", fix = false, prefix = "") { + if (extensions.length !== 1 || extensions[0] !== "rb") { + throw new Error(`${this.name} error: File extensions are not configurable`); + } + + const fixArg = fix ? "--fix" : ""; + return run(`${prefix} standardrb --format json ${fixArg} ${args}`, { + dir, + ignoreErrors: true, + }); + } + + /** + * Parses the output of the lint command. Determines the success of the lint process and the + * severity of the identified code style violations + * @param {string} dir - Directory in which the linter has been run + * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command + * @returns {LintResult} - Parsed lint result + */ + static parseOutput(dir, output) { + const lintResult = initLintResult(); + lintResult.isSuccess = output.status === 0; + + let outputJson; + try { + outputJson = JSON.parse(output.stdout); + } catch (err) { + throw Error( + `Error parsing ${this.name} JSON output: ${err.message}. Output: "${output.stdout}"`, + ); + } + + for (const file of outputJson.files) { + const { path, offenses } = file; + for (const offense of offenses) { + const { severity, message, cop_name: rule, corrected, location } = offense; + if (!corrected) { + const mappedSeverity = severityMap[severity] || "error"; + lintResult[mappedSeverity].push({ + path, + firstLine: location.start_line, + lastLine: location.last_line, + message: `${removeTrailingPeriod(message)} (${rule})`, + }); + } + } + } + + return lintResult; + } +} + +module.exports = StandardRB; + + /***/ }), /***/ 194: diff --git a/src/linters/index.js b/src/linters/index.js index 800ce798..ea517ff0 100644 --- a/src/linters/index.js +++ b/src/linters/index.js @@ -15,6 +15,7 @@ const Prettier = require("./prettier"); const Pylint = require("./pylint"); const RuboCop = require("./rubocop"); const RustFmt = require("./rustfmt"); +const StandardRB = require("./standardrb"); const Stylelint = require("./stylelint"); const SwiftFormatLockwood = require("./swift-format-lockwood"); const SwiftFormatOfficial = require("./swift-format-official"); @@ -33,6 +34,7 @@ const linters = { php_codesniffer: PHPCodeSniffer, pylint: Pylint, rubocop: RuboCop, + standardrb: StandardRB, stylelint: Stylelint, swiftlint: SwiftLint, xo: XO, diff --git a/src/linters/standardrb.js b/src/linters/standardrb.js new file mode 100644 index 00000000..761df190 --- /dev/null +++ b/src/linters/standardrb.js @@ -0,0 +1,105 @@ +const { run } = require("../utils/action"); +const commandExists = require("../utils/command-exists"); +const { initLintResult } = require("../utils/lint-result"); +const { removeTrailingPeriod } = require("../utils/string"); + +/** @typedef {import('../utils/lint-result').LintResult} LintResult */ + +// Mapping of standard severities to severities used for GitHub commit annotations +const severityMap = { + info: "warning", + convention: "warning", + refactor: "warning", + warning: "warning", + error: "error", + fatal: "error", +}; + +/** + * https://github.com/standardrb/standard/ + */ +class StandardRB { + static get name() { + return "Standardrb"; + } + + /** + * Verifies that all required programs are installed. Throws an error if programs are missing + * @param {string} dir - Directory to run the linting program in + * @param {string} prefix - Prefix to the lint command + */ + static async verifySetup(dir, prefix = "") { + // Verify that Ruby is installed (required to execute Standardrb) + if (!(await commandExists("ruby"))) { + throw new Error("Ruby is not installed"); + } + // Verify that Standardrb is installed + try { + run(`${prefix} standardrb -v`, { dir }); + } catch (err) { + throw new Error(`${this.name} is not installed`); + } + } + + /** + * Runs the linting program and returns the command output + * @param {string} dir - Directory to run the linter in + * @param {string[]} extensions - File extensions which should be linted + * @param {string} args - Additional arguments to pass to the linter + * @param {boolean} fix - Whether the linter should attempt to fix code style issues automatically + * @param {string} prefix - Prefix to the lint command + * @returns {{status: number, stdout: string, stderr: string}} - Output of the lint command + */ + static lint(dir, extensions, args = "", fix = false, prefix = "") { + if (extensions.length !== 1 || extensions[0] !== "rb") { + throw new Error(`${this.name} error: File extensions are not configurable`); + } + + const fixArg = fix ? "--fix" : ""; + return run(`${prefix} standardrb --format json ${fixArg} ${args}`, { + dir, + ignoreErrors: true, + }); + } + + /** + * Parses the output of the lint command. Determines the success of the lint process and the + * severity of the identified code style violations + * @param {string} dir - Directory in which the linter has been run + * @param {{status: number, stdout: string, stderr: string}} output - Output of the lint command + * @returns {LintResult} - Parsed lint result + */ + static parseOutput(dir, output) { + const lintResult = initLintResult(); + lintResult.isSuccess = output.status === 0; + + let outputJson; + try { + outputJson = JSON.parse(output.stdout); + } catch (err) { + throw Error( + `Error parsing ${this.name} JSON output: ${err.message}. Output: "${output.stdout}"`, + ); + } + + for (const file of outputJson.files) { + const { path, offenses } = file; + for (const offense of offenses) { + const { severity, message, cop_name: rule, corrected, location } = offense; + if (!corrected) { + const mappedSeverity = severityMap[severity] || "error"; + lintResult[mappedSeverity].push({ + path, + firstLine: location.start_line, + lastLine: location.last_line, + message: `${removeTrailingPeriod(message)} (${rule})`, + }); + } + } + } + + return lintResult; + } +} + +module.exports = StandardRB; diff --git a/test/linters/linters.test.js b/test/linters/linters.test.js index 6c7366b9..b14e41d1 100644 --- a/test/linters/linters.test.js +++ b/test/linters/linters.test.js @@ -20,6 +20,7 @@ const prettierParams = require("./params/prettier"); const pylintParams = require("./params/pylint"); const ruboCopParams = require("./params/rubocop"); const rustfmtParams = require("./params/rustfmt"); +const standardRBParams = require("./params/standardrb"); const stylelintParams = require("./params/stylelint"); const swiftFormatLockwood = require("./params/swift-format-lockwood"); // const swiftFormatOfficial = require("./params/swift-format-official"); @@ -45,6 +46,7 @@ const linterParams = [ pylintParams, ruboCopParams, rustfmtParams, + standardRBParams, stylelintParams, tscParams, xoParams, diff --git a/test/linters/params/standardrb.js b/test/linters/params/standardrb.js new file mode 100644 index 00000000..24c4b192 --- /dev/null +++ b/test/linters/params/standardrb.js @@ -0,0 +1,69 @@ +const StandardRB = require("../../../src/linters/standardrb"); + +const testName = "standardrb"; +const linter = StandardRB; +const args = ""; +const commandPrefix = "bundle exec"; +const extensions = ["rb"]; + +// Linting without auto-fixing +function getLintParams(dir) { + const stdoutFile1 = `{"path":"file1.rb","offenses":[{"severity":"convention","message":"Redundant \`return\` detected.","cop_name":"Style/RedundantReturn","corrected":false,"correctable":true,"location":{"start_line":5,"start_column":3,"last_line":5,"last_column":8,"length":6,"line":5,"column":3}}]}`; + const stdoutFile2 = `{"path":"file2.rb","offenses":[{"severity":"warning","message":"Useless assignment to variable - \`x\`.","cop_name":"Lint/UselessAssignment","corrected":false,"correctable":true,"location":{"start_line":4,"start_column":1,"last_line":4,"last_column":1,"length":1,"line":4,"column":1}}]}`; + return { + // Expected output of the linting function + cmdOutput: { + status: 1, + stdoutParts: [stdoutFile1, stdoutFile2], + stdout: `{"metadata":{"rubocop_version":"0.93.0","ruby_engine":"ruby","ruby_version":"2.5.3","ruby_patchlevel":"105","ruby_platform":"x86_64-darwin18"},"files":[${stdoutFile1},${stdoutFile2}],"summary":{"offense_count":2,"target_file_count":2,"inspected_file_count":2}}`, + }, + // Expected output of the parsing function + lintResult: { + isSuccess: false, + warning: [ + { + path: "file1.rb", + firstLine: 5, + lastLine: 5, + message: "Redundant `return` detected (Style/RedundantReturn)", + }, + { + path: "file2.rb", + firstLine: 4, + lastLine: 4, + message: "Useless assignment to variable - `x` (Lint/UselessAssignment)", + }, + ], + error: [], + }, + }; +} + +// Linting with auto-fixing +function getFixParams(dir) { + const stdoutFile1 = `{"path":"file1.rb","offenses":[{"severity":"convention","message":"Redundant \`return\` detected.","cop_name":"Style/RedundantReturn","corrected":true,"correctable":true,"location":{"start_line":5,"start_column":3,"last_line":5,"last_column":8,"length":6,"line":5,"column":3}}]}`; + const stdoutFile2 = `{"path":"file2.rb","offenses":[{"severity":"warning","message":"Useless assignment to variable - \`x\`.","cop_name":"Lint/UselessAssignment","corrected":false,"correctable":true,"location":{"start_line":4,"start_column":1,"last_line":4,"last_column":1,"length":1,"line":4,"column":1}}]}`; + return { + // Expected output of the linting function + cmdOutput: { + status: 1, + stdoutParts: [stdoutFile1, stdoutFile2], + stdout: `{"metadata":{"rubocop_version":"0.93.0","ruby_engine":"ruby","ruby_version":"2.5.3","ruby_patchlevel":"105","ruby_platform":"x86_64-darwin18"},"files":[${stdoutFile1},${stdoutFile2}],"summary":{"offense_count":2,"target_file_count":2,"inspected_file_count":2}}`, + }, + // Expected output of the parsing function + lintResult: { + isSuccess: false, + warning: [ + { + path: "file2.rb", + firstLine: 4, + lastLine: 4, + message: "Useless assignment to variable - `x` (Lint/UselessAssignment)", + }, + ], + error: [], + }, + }; +} + +module.exports = [testName, linter, commandPrefix, extensions, args, getLintParams, getFixParams]; diff --git a/test/linters/projects/standardrb/.gitignore b/test/linters/projects/standardrb/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/test/linters/projects/standardrb/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/test/linters/projects/standardrb/Gemfile b/test/linters/projects/standardrb/Gemfile new file mode 100644 index 00000000..665514df --- /dev/null +++ b/test/linters/projects/standardrb/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "standard", "~> 1.31.0" diff --git a/test/linters/projects/standardrb/Gemfile.lock b/test/linters/projects/standardrb/Gemfile.lock new file mode 100644 index 00000000..41837381 --- /dev/null +++ b/test/linters/projects/standardrb/Gemfile.lock @@ -0,0 +1,56 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + base64 (0.1.1) + json (2.6.3) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + parallel (1.23.0) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + racc (1.7.3) + rainbow (3.1.1) + regexp_parser (2.8.2) + rexml (3.2.6) + rubocop (1.56.4) + base64 (~> 0.1.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.3) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + ruby-progressbar (1.13.0) + standard (1.31.2) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.56.4) + standard-custom (~> 1.0.0) + standard-performance (~> 1.2) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.2.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.19.1) + unicode-display_width (2.5.0) + +PLATFORMS + ruby + +DEPENDENCIES + standard (~> 1.31.0) + +BUNDLED WITH + 2.1.4 diff --git a/test/linters/projects/standardrb/file1.rb b/test/linters/projects/standardrb/file1.rb new file mode 100644 index 00000000..075f970d --- /dev/null +++ b/test/linters/projects/standardrb/file1.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +def method + # "Style/RedundantReturn" convention + return "words" +end diff --git a/test/linters/projects/standardrb/file2.rb b/test/linters/projects/standardrb/file2.rb new file mode 100644 index 00000000..c485bada --- /dev/null +++ b/test/linters/projects/standardrb/file2.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# "Lint/UselessAssignment" warning +x = 1