diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ce1bb04..4812324d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,12 +125,18 @@ jobs: restore-keys: | ${{ runner.os }}-gems- - - name: Install Ruby dependencies + - name: Install Ruby dependencies (Rubocop) run: | cd ./test/linters/projects/rubocop/ bundle config path vendor/bundle bundle install --jobs 4 --retry 3 + - name: Install Ruby dependencies (ERB Lint) + run: | + cd ./test/linters/projects/erblint/ + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 + # Swift (only on Linux) - name: Set up Swift cache (Linux) diff --git a/README.md b/README.md index 46dd8727..1663e984 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ _**Note:** The behavior of actions like this one is currently limited in the con - [Mypy](https://mypy.readthedocs.io/) - [oitnb](https://pypi.org/project/oitnb/) - **Ruby:** + - [ERB Lint](https://github.com/Shopify/erb-lint) - [RuboCop](https://rubocop.readthedocs.io) - **Swift:** - [swift-format](https://github.com/apple/swift-format) (official) @@ -247,7 +248,7 @@ jobs: ### Linter-specific options -`[linter]` can be one of `black`, `eslint`, `flake8`, `gofmt`, `golint`, `mypy`, `oitnb`, `php_codesniffer`, `prettier`, `rubocop`, `stylelint`, `swift_format_official`, `swift_format_lockwood`, `swiftlint` and `xo`: +`[linter]` can be one of `black`, `erblint`, `eslint`, `flake8`, `gofmt`, `golint`, `mypy`, `oitnb`, `php_codesniffer`, `prettier`, `rubocop`, `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: `""` @@ -286,6 +287,7 @@ Some options are not be available for specific linters: | Linter | auto-fixing | extensions | | --------------------- | :---------: | :--------: | | black | ✅ | ✅ | +| erblint | ❌ | ❌ (erb) | | eslint | ✅ | ✅ | | flake8 | ❌ | ✅ | | gofmt | ✅ | ❌ (go) | diff --git a/action.yml b/action.yml index 00ef7aa8..46ddc187 100644 --- a/action.yml +++ b/action.yml @@ -292,6 +292,26 @@ inputs: required: false default: "" + erblint: + description: Enable or disable ERB Lint checks + required: false + default: "false" + erblint_args: + description: Additional arguments to pass to the linter + required: false + default: "" + erblint_dir: + description: Directory where the ERB Lint command should be run + required: false + erblint_extensions: + description: Extensions of files to check with ERB Lint + required: false + default: "erb" + erblint_command_prefix: + description: Shell command to prepend to the linter command + required: false + default: "" + # Swift # Alias of `swift_format_lockwood` (for backward compatibility) diff --git a/dist/index.js b/dist/index.js index 559a4218..c7ac86e6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2156,6 +2156,112 @@ class Black { module.exports = Black; +/***/ }), + +/***/ 9674: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const core = __nccwpck_require__(2186); + +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 */ + +/** + * https://https://github.com/Shopify/erb-lint + */ +class Erblint { + static get name() { + return "ERB Lint"; + } + + /** + * 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 erblint) + if (!(await commandExists("ruby"))) { + throw new Error("Ruby is not installed"); + } + // Verify that erblint is installed + try { + run(`${prefix} erblint -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 = "--lint-all", fix = false, prefix = "") { + if (extensions.length !== 1 || extensions[0] !== "erb") { + throw new Error(`${this.name} error: File extensions are not configurable`); + } + if (fix) { + core.warning(`${this.name} does not support auto-fixing`); + } + + return run(`${prefix} erblint --format json ${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 { message, linter, corrected, location } = offense; + if (!corrected) { + // ERB Lint does not provide severities in its JSON output + lintResult.error.push({ + path, + firstLine: location.start_line, + lastLine: location.last_line, + message: `${removeTrailingPeriod(message)} (${linter})`, + }); + } + } + } + + return lintResult; + } +} + +module.exports = Erblint; + + /***/ }), /***/ 7169: @@ -2561,6 +2667,7 @@ module.exports = Golint; /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { const Black = __nccwpck_require__(9844); +const Erblint = __nccwpck_require__(9674); const ESLint = __nccwpck_require__(7169); const Flake8 = __nccwpck_require__(3636); const Gofmt = __nccwpck_require__(7796); @@ -2578,6 +2685,7 @@ const XO = __nccwpck_require__(728); const linters = { // Linters + erblint: Erblint, eslint: ESLint, flake8: Flake8, golint: Golint, @@ -4084,7 +4192,8 @@ async function runAction() { // Lint and optionally auto-fix the matching files, parse code style violations core.info( - `Linting ${autoFix ? "and auto-fixing " : ""}files in ${lintDirAbs} with ${linter.name}…`, + `Linting ${autoFix ? "and auto-fixing " : ""}files in ${lintDirAbs} ` + + `with ${linter.name} ${args ? `and args: ${args}` : ""}…`, ); const lintOutput = linter.lint(lintDirAbs, fileExtList, args, autoFix, prefix); diff --git a/src/index.js b/src/index.js index 1880a304..d914f8d6 100644 --- a/src/index.js +++ b/src/index.js @@ -81,7 +81,8 @@ async function runAction() { // Lint and optionally auto-fix the matching files, parse code style violations core.info( - `Linting ${autoFix ? "and auto-fixing " : ""}files in ${lintDirAbs} with ${linter.name}…`, + `Linting ${autoFix ? "and auto-fixing " : ""}files in ${lintDirAbs} ` + + `with ${linter.name} ${args ? `and args: ${args}` : ""}…`, ); const lintOutput = linter.lint(lintDirAbs, fileExtList, args, autoFix, prefix); diff --git a/src/linters/erblint.js b/src/linters/erblint.js new file mode 100644 index 00000000..d9022625 --- /dev/null +++ b/src/linters/erblint.js @@ -0,0 +1,99 @@ +const core = require("@actions/core"); + +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 */ + +/** + * https://https://github.com/Shopify/erb-lint + */ +class Erblint { + static get name() { + return "ERB Lint"; + } + + /** + * 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 erblint) + if (!(await commandExists("ruby"))) { + throw new Error("Ruby is not installed"); + } + // Verify that erblint is installed + try { + run(`${prefix} erblint -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 = "--lint-all", fix = false, prefix = "") { + if (extensions.length !== 1 || extensions[0] !== "erb") { + throw new Error(`${this.name} error: File extensions are not configurable`); + } + if (fix) { + core.warning(`${this.name} does not support auto-fixing`); + } + + return run(`${prefix} erblint --format json ${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 { message, linter, corrected, location } = offense; + if (!corrected) { + // ERB Lint does not provide severities in its JSON output + lintResult.error.push({ + path, + firstLine: location.start_line, + lastLine: location.last_line, + message: `${removeTrailingPeriod(message)} (${linter})`, + }); + } + } + } + + return lintResult; + } +} + +module.exports = Erblint; diff --git a/src/linters/index.js b/src/linters/index.js index 22bfd182..a69a12ec 100644 --- a/src/linters/index.js +++ b/src/linters/index.js @@ -1,4 +1,5 @@ const Black = require("./black"); +const Erblint = require("./erblint"); const ESLint = require("./eslint"); const Flake8 = require("./flake8"); const Gofmt = require("./gofmt"); @@ -16,6 +17,7 @@ const XO = require("./xo"); const linters = { // Linters + erblint: Erblint, eslint: ESLint, flake8: Flake8, golint: Golint, diff --git a/test/linters/linters.test.js b/test/linters/linters.test.js index dee9bae5..b491d246 100644 --- a/test/linters/linters.test.js +++ b/test/linters/linters.test.js @@ -4,6 +4,7 @@ const { copy, remove } = require("fs-extra"); const { normalizeDates, normalizePaths, createTmpDir } = require("../test-utils"); const blackParams = require("./params/black"); +const erblintParams = require("./params/erblint"); const eslintParams = require("./params/eslint"); const eslintTypescriptParams = require("./params/eslint-typescript"); const flake8Params = require("./params/flake8"); @@ -21,6 +22,7 @@ const xoParams = require("./params/xo"); const linterParams = [ blackParams, + erblintParams, eslintParams, eslintTypescriptParams, flake8Params, diff --git a/test/linters/params/erblint.js b/test/linters/params/erblint.js new file mode 100644 index 00000000..9bfd0281 --- /dev/null +++ b/test/linters/params/erblint.js @@ -0,0 +1,51 @@ +const Erblint = require("../../../src/linters/erblint"); + +const testName = "erblint"; +const linter = Erblint; +const commandPrefix = "bundle exec"; +const extensions = ["erb"]; + +// Linting without auto-fixing +function getLintParams(dir) { + const stdout1 = + '{"path":"file1.erb","offenses":[{"linter":"SpaceAroundErbTag","message":"Use 1 space before `%>` instead of 2 space.","location":{"start_line":3,"start_column":6,"last_line":3,"last_column":8,"length":2}}]}'; + const stdout2 = + '{"path":"file2.erb","offenses":[{"linter":"SpaceInHtmlTag","message":"No space detected where there should be a single space.","location":{"start_line":2,"start_column":3,"last_line":2,"last_column":3,"length":0}},{"linter":"SelfClosingTag","message":"Tag `br` is a void element, it must end with `>` and not `/>`.","location":{"start_line":2,"start_column":3,"last_line":2,"last_column":4,"length":1}}]}'; + return { + // Expected output of the linting function + cmdOutput: { + status: 1, + stdoutParts: [], + stdout: `{"metadata":{"erb_lint_version":"0.1.1","ruby_engine":"ruby","ruby_version":"2.6.8","ruby_patchlevel":"205","ruby_platform":"x86_64-darwin20"},"files":[${stdout1}, ${stdout2}],"summary":{"offenses":3,"inspected_files":2,"corrected":0}}`, + }, + // Expected output of the parsing function + lintResult: { + isSuccess: false, + error: [ + { + path: "file1.erb", + firstLine: 3, + lastLine: 3, + message: "Use 1 space before `%>` instead of 2 space (SpaceAroundErbTag)", + }, + { + path: "file2.erb", + firstLine: 2, + lastLine: 2, + message: "No space detected where there should be a single space (SpaceInHtmlTag)", + }, + { + path: "file2.erb", + firstLine: 2, + lastLine: 2, + message: "Tag `br` is a void element, it must end with `>` and not `/>` (SelfClosingTag)", + }, + ], + warning: [], + }, + }; +} + +const getFixParams = getLintParams; // Does not support auto-fixing -> option has no effect + +module.exports = [testName, linter, commandPrefix, extensions, getLintParams, getFixParams]; diff --git a/test/linters/projects/erblint/.erb-lint.yml b/test/linters/projects/erblint/.erb-lint.yml new file mode 100644 index 00000000..9c25199f --- /dev/null +++ b/test/linters/projects/erblint/.erb-lint.yml @@ -0,0 +1,22 @@ +--- +EnableDefaultLinters: true +linters: + Rubocop: + enabled: true + rubocop_config: + inherit_from: + - .rubocop.yml + Layout/InitialIndentation: + Enabled: false + Layout/LineLength: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Naming/FileName: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Lint/UselessAssignment: + Enabled: false diff --git a/test/linters/projects/erblint/.gitignore b/test/linters/projects/erblint/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/test/linters/projects/erblint/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/test/linters/projects/erblint/.rubocop.yml b/test/linters/projects/erblint/.rubocop.yml new file mode 100644 index 00000000..25476d53 --- /dev/null +++ b/test/linters/projects/erblint/.rubocop.yml @@ -0,0 +1,12 @@ +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 2.4 + NewCops: enable + Exclude: + - vendor/bundle/**/* + +Layout/EndOfLine: + Enabled: false diff --git a/test/linters/projects/erblint/Gemfile b/test/linters/projects/erblint/Gemfile new file mode 100644 index 00000000..e0f59c41 --- /dev/null +++ b/test/linters/projects/erblint/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rubocop', '~> 1.12.0' +gem 'erb_lint', '~> 0.1.1', require: false diff --git a/test/linters/projects/erblint/Gemfile.lock b/test/linters/projects/erblint/Gemfile.lock new file mode 100644 index 00000000..df3a84bf --- /dev/null +++ b/test/linters/projects/erblint/Gemfile.lock @@ -0,0 +1,86 @@ +GEM + remote: https://rubygems.org/ + specs: + actionview (6.1.4.4) + activesupport (= 6.1.4.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activesupport (6.1.4.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + ast (2.4.2) + better_html (1.0.16) + actionview (>= 4.0) + activesupport (>= 4.0) + ast (~> 2.0) + erubi (~> 1.4) + html_tokenizer (~> 0.0.6) + parser (>= 2.4) + smart_properties + builder (3.2.4) + concurrent-ruby (1.1.9) + crass (1.0.6) + erb_lint (0.1.1) + activesupport + better_html (~> 1.0.7) + html_tokenizer + parser (>= 2.7.1.4) + rainbow + rubocop + smart_properties + erubi (1.10.0) + html_tokenizer (0.0.7) + i18n (1.8.11) + concurrent-ruby (~> 1.0) + loofah (2.13.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + mini_portile2 (2.6.1) + minitest (5.15.0) + nokogiri (1.12.5) + mini_portile2 (~> 2.6.1) + racc (~> 1.4) + parallel (1.21.0) + parser (3.0.3.2) + ast (~> 2.4.1) + racc (1.6.0) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.2) + loofah (~> 2.3) + rainbow (3.0.0) + regexp_parser (2.2.0) + rexml (3.2.5) + rubocop (1.12.1) + parallel (~> 1.10) + parser (>= 3.0.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.2.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.15.1) + parser (>= 3.0.1.1) + ruby-progressbar (1.11.0) + smart_properties (1.17.0) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + unicode-display_width (2.1.0) + zeitwerk (2.5.2) + +PLATFORMS + ruby + +DEPENDENCIES + erb_lint (~> 0.1.1) + rubocop (~> 1.12.0) + +BUNDLED WITH + 2.1.4 diff --git a/test/linters/projects/erblint/file1.erb b/test/linters/projects/erblint/file1.erb new file mode 100644 index 00000000..7a252092 --- /dev/null +++ b/test/linters/projects/erblint/file1.erb @@ -0,0 +1,3 @@ +<% for @item in @shopping_list %> + <%= @item %> +<% end %> diff --git a/test/linters/projects/erblint/file2.erb b/test/linters/projects/erblint/file2.erb new file mode 100644 index 00000000..23a108e9 --- /dev/null +++ b/test/linters/projects/erblint/file2.erb @@ -0,0 +1,3 @@ +Hello, <%= @name %>. +
+Today is <%= Time.now.strftime('%A') %>.