diff --git a/.github/labeler.yml b/.github/labeler.yml index 4da50d1f4..cac25a8b9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -266,6 +266,7 @@ rubygem: - changed-files: - any-glob-to-any-file: - 'herb.gemspec' + - 'gems/**/*' - 'lib/**/*' - 'templates/lib/**/*' diff --git a/.github/workflows/build-gems.yml b/.github/workflows/build-gems.yml index 546389a9b..7511c82f1 100644 --- a/.github/workflows/build-gems.yml +++ b/.github/workflows/build-gems.yml @@ -10,6 +10,7 @@ on: paths: - "src/**" - "lib/**" + - "gems/**" - "ext/**" - "Gemfile" - "Gemfile.lock" @@ -22,6 +23,7 @@ on: paths: - "src/**" - "lib/**" + - "gems/**" - "ext/**" - "Gemfile" - "Gemfile.lock" @@ -89,6 +91,9 @@ jobs: run: | if [ "${{ matrix.target }}" = "default" ]; then bundle exec rake build + mkdir -p pkg + cd gems/rubocop-herb + gem build rubocop-herb.gemspec --output ../../pkg/rubocop-herb.gem else bundle exec rake gem:${{ matrix.target }} fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f41d75a42..a9ddd3929 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,7 +83,12 @@ jobs: run: bundle exec rake compile - name: Run Ruby Tests - run: bundle exec rake test:all + run: bundle exec rake test:all test:rubocop_herb + + - name: Test minimum supported RuboCop + run: | + bundle add rubocop --version 1.92.0 + bundle exec rake test:rubocop_herb - name: Run C tests run: ./run_herb_tests diff --git a/.rubocop.yml b/.rubocop.yml index 5306e2a1b..aed5124f8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -158,6 +158,7 @@ Metrics/ClassLength: - lib/herb/visitor/stack.rb - templates/template.rb - test/**/*_test.rb + - gems/**/test/**/*_test.rb Metrics/ModuleLength: Exclude: diff --git a/Gemfile b/Gemfile index 19f515205..68085dcb5 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,8 @@ source "https://rubygems.org" gemspec +gem "rubocop-herb", path: "gems/rubocop-herb" + gem "prism", github: "ruby/prism", tag: "v1.9.0" gem "actionview", "~> 8.1", require: "action_view" diff --git a/Gemfile.lock b/Gemfile.lock index 0a3369fc0..d0bede135 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,14 @@ PATH specs: herb (0.11.1) +PATH + remote: gems/rubocop-herb + specs: + rubocop-herb (0.11.1) + herb (= 0.11.1) + lint_roller (~> 1.1) + rubocop (>= 1.92.0, < 2) + GEM remote: https://rubygems.org/ specs: @@ -368,6 +376,7 @@ DEPENDENCIES reline (~> 0.7) rolldown (~> 1.2) rubocop (~> 1.72) + rubocop-herb! rubocop-performance (~> 1.26.0) sorbet steep (~> 2.1) diff --git a/README.md b/README.md index a7796a04e..bb0b83712 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,27 @@ Run the linter and the formatter: ```sh npx @herb-tools/linter +``` + +Run your project's configured RuboCop rules against Ruby in `.erb` and `.herb` templates with the `rubocop-herb` plugin: + +```sh +bundle add rubocop-herb +``` + +```yaml +# .rubocop.yml +plugins: + - rubocop-herb +``` + +```sh +bundle exec rubocop app/views +``` + +Format your HTML+ERB templates: + +```sh npx @herb-tools/formatter ``` diff --git a/Rakefile b/Rakefile index ab561137a..a4f1b2be8 100644 --- a/Rakefile +++ b/Rakefile @@ -28,6 +28,14 @@ Rake::TestTask.new("test:all") do |t| t.test_files = FileList["test/**/*_test.rb"] end +Rake::TestTask.new("test:rubocop_herb") do |t| + t.libs << "gems/rubocop-herb/test" + t.libs << "gems/rubocop-herb/lib" + t.test_files = FileList["gems/rubocop-herb/test/**/*_test.rb"] +end + +task test: "test:rubocop_herb" + task "make" do puts "Running make..." IO.popen("make") do |output| diff --git a/gems/rubocop-herb/README.md b/gems/rubocop-herb/README.md new file mode 100644 index 000000000..4bc60f5ea --- /dev/null +++ b/gems/rubocop-herb/README.md @@ -0,0 +1,44 @@ +# rubocop-herb + +`rubocop-herb` runs a project's configured RuboCop rules against Ruby embedded in `.erb` and `.herb` templates. Herb builds a position-preserving Ruby representation of the entire template so RuboCop can understand control flow and variable usage across ERB tags while reporting offenses at their original template locations. + +Requires RuboCop 1.92 or newer. Add the gem to your bundle: + +```sh +bundle add rubocop-herb +``` + +Then enable the plugin in `.rubocop.yml`: + +```yaml +plugins: + - rubocop-herb +``` + +Then use RuboCop normally: + +```sh +bundle exec rubocop app/views +bundle exec rubocop -a app/views +bundle exec rubocop -A app/views +``` + +The plugin honors the project's RuboCop configuration, including custom cops, inherited configuration, target Ruby versions, file exclusions, and autocorrection. Cops that depend on physical Ruby file contents or layout, or that misread template control flow, are [excluded for template files by default](config/default.yml). + +Template-local `rubocop:disable`, `rubocop:enable`, and `rubocop:todo` directives work inside ERB comments: + +```erb +<%# rubocop:disable Layout/SpaceAroundOperators %> +<%= x=1 %> +<%# rubocop:enable Layout/SpaceAroundOperators %> +``` + +Offenses are only reported for Ruby inside ERB tags, and autocorrections that would touch markup are discarded, so `rubocop -a` and `rubocop -A` never rewrite HTML or ERB delimiters. + +Every `.erb` and `.herb` file is inspected, including non-HTML templates. To skip some of them, exclude them in `.rubocop.yml`: + +```yaml +AllCops: + Exclude: + - "**/*.yml.erb" +``` diff --git a/gems/rubocop-herb/config/default.yml b/gems/rubocop-herb/config/default.yml new file mode 100644 index 000000000..33f6421e5 --- /dev/null +++ b/gems/rubocop-herb/config/default.yml @@ -0,0 +1,146 @@ +inherit_mode: + merge: + - Exclude + - Include + +AllCops: + Include: &template_files + - '**/*.erb' + - '**/*.herb' + +# These cops depend on physical Ruby file contents or layout that is obscured by the +# position-preserving template representation, or misread markup between ERB tags as +# empty or missing Ruby, such as the markup inside a `do` block. Redundancy cops +# can also mistake implicit template output for unused expressions or unreachable code. +Layout/ArgumentAlignment: + Exclude: *template_files +Layout/ArrayAlignment: + Exclude: *template_files +Layout/BlockAlignment: + Exclude: *template_files +Layout/CaseIndentation: + Exclude: *template_files +Layout/ClosingParenthesisIndentation: + Exclude: *template_files +Layout/CommentIndentation: + Exclude: *template_files +Layout/ElseAlignment: + Exclude: *template_files +Layout/EmptyLineAfterGuardClause: + Exclude: *template_files +Layout/EndAlignment: + Exclude: *template_files +Layout/FirstArgumentIndentation: + Exclude: *template_files +Layout/FirstArrayElementIndentation: + Exclude: *template_files +Layout/FirstArrayElementLineBreak: + Exclude: *template_files +Layout/FirstHashElementIndentation: + Exclude: *template_files +Layout/FirstHashElementLineBreak: + Exclude: *template_files +Layout/FirstMethodArgumentLineBreak: + Exclude: *template_files +Layout/FirstParameterIndentation: + Exclude: *template_files +Layout/HashAlignment: + Exclude: *template_files +Layout/IndentationConsistency: + Exclude: *template_files +Layout/IndentationWidth: + Exclude: *template_files +Layout/InitialIndentation: + Exclude: *template_files +Layout/LeadingEmptyLines: + Exclude: *template_files +Layout/LineEndStringConcatenationIndentation: + Exclude: *template_files +Layout/LineLength: + Exclude: *template_files +Layout/MultilineArrayBraceLayout: + Exclude: *template_files +Layout/MultilineAssignmentLayout: + Exclude: *template_files +Layout/MultilineHashBraceLayout: + Exclude: *template_files +Layout/MultilineMethodCallBraceLayout: + Exclude: *template_files +Layout/MultilineMethodCallIndentation: + Exclude: *template_files +Layout/MultilineMethodDefinitionBraceLayout: + Exclude: *template_files +Layout/MultilineOperationIndentation: + Exclude: *template_files +Layout/ParameterAlignment: + Exclude: *template_files +Layout/SpaceBeforeSemicolon: + Exclude: *template_files +Layout/TrailingEmptyLines: + Exclude: *template_files +Layout/TrailingWhitespace: + Exclude: *template_files +Lint/DuplicateBranch: + Exclude: *template_files +Lint/EmptyBlock: + Exclude: *template_files +Lint/EmptyConditionalBody: + Exclude: *template_files +Lint/EmptyEnsure: + Exclude: *template_files +Lint/EmptyFile: + Exclude: *template_files +Lint/EmptyInPattern: + Exclude: *template_files +Lint/EmptyWhen: + Exclude: *template_files +Lint/SuppressedException: + Exclude: *template_files +Lint/Void: + Exclude: *template_files +Metrics/BlockLength: + Exclude: *template_files +Naming/FileName: + Exclude: *template_files +Rails/Presence: + Exclude: *template_files +Style/BlockDelimiters: + Exclude: *template_files +Style/CombinableLoops: + Exclude: *template_files +Style/EmptyElse: + Exclude: *template_files +Style/FrozenStringLiteralComment: + Exclude: *template_files +Style/IdenticalConditionalBranches: + Exclude: *template_files +Style/IfUnlessModifier: + Exclude: *template_files +Style/IfWithSemicolon: + Exclude: *template_files +Style/MultilineIfModifier: + Exclude: *template_files +Style/MultilineTernaryOperator: + Exclude: *template_files +Style/NestedTernaryOperator: + Exclude: *template_files +Style/Next: + Exclude: *template_files +Style/RedundantAssignment: + Exclude: *template_files +Style/RedundantBegin: + Exclude: *template_files +Style/RedundantCondition: + Exclude: *template_files +Style/RedundantReturn: + Exclude: *template_files +Style/SafeNavigation: + Exclude: *template_files +Style/Semicolon: + Exclude: *template_files +Style/SingleLineDoEndBlock: + Exclude: *template_files +Style/SoleNestedConditional: + Exclude: *template_files +Style/WhileUntilModifier: + Exclude: *template_files diff --git a/gems/rubocop-herb/lib/rubocop/herb.rb b/gems/rubocop-herb/lib/rubocop/herb.rb new file mode 100644 index 000000000..f0a11f019 --- /dev/null +++ b/gems/rubocop-herb/lib/rubocop/herb.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "herb" +require "rubocop" + +require_relative "herb/directive_source" +require_relative "herb/template_source" +require_relative "herb/offense_guard" +require_relative "herb/plugin" + +RuboCop::Cop::Base.prepend(RuboCop::Herb::OffenseGuard) diff --git a/gems/rubocop-herb/lib/rubocop/herb/directive_source.rb b/gems/rubocop-herb/lib/rubocop/herb/directive_source.rb new file mode 100644 index 000000000..fb8722d93 --- /dev/null +++ b/gems/rubocop-herb/lib/rubocop/herb/directive_source.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "delegate" +require "prism" + +module RuboCop + module Herb + # Keep directives separate from parsed Ruby so a comment cannot hide a later tag. + class DirectiveSource < SimpleDelegator + RUBY_OPENERS = ["<%", "<%=", "<%==", "<%-", "<%#"].freeze + + attr_reader :raw_source, :comments + + def initialize(source, original) + super(source) + @raw_source = original.raw_source + @comments = source.comments.reject { |comment| comment.text.include?("rubocop:") } + @comments.concat(directive_comments(original)) + @comments.sort_by! { |comment| comment.loc.expression.begin_pos } + end + + private + + def directive_comments(original) + opener = nil + + ::Herb.lex(raw_source).value.flat_map do |token| + case token.type + when "TOKEN_ERB_START" + opener = token.value + [] + when "TOKEN_ERB_CONTENT" + next [] unless token.value.include?("rubocop:") + + comments_for(token, opener, original.buffer) + else + [] + end + end + end + + def comments_for(token, opener, buffer) + return [] unless RUBY_OPENERS.include?(opener) + + comment_tag = opener == "<%#" + content = comment_tag ? "##{token.value}" : token.value + offset = token.range.from - (comment_tag ? 1 : 0) + + Prism.parse_comments(content).filter_map do |comment| + next unless comment.location.slice.include?("rubocop:") + + comment_from_location(buffer, offset, comment.location) + end + end + + def comment_from_location(buffer, offset, location) + first = raw_source.byteslice(0, offset + location.start_offset).length + last = raw_source.byteslice(0, offset + location.end_offset).length + ::Parser::Source::Comment.new(::Parser::Source::Range.new(buffer, first, last)) + end + end + end +end diff --git a/gems/rubocop-herb/lib/rubocop/herb/offense_guard.rb b/gems/rubocop-herb/lib/rubocop/herb/offense_guard.rb new file mode 100644 index 000000000..1494037ab --- /dev/null +++ b/gems/rubocop-herb/lib/rubocop/herb/offense_guard.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RuboCop + module Herb + # Drops offenses that point at markup and autocorrections that would edit it. + module OffenseGuard + def add_offense(node_or_range, message: nil, severity: nil, &block) + source = processed_source + return super unless source.is_a?(TemplateSource) && !is_a?(::RuboCop::Cop::Lint::Syntax) + return unless source.anchored_in_ruby?(range_from_node_or_range(node_or_range)) + return super unless block + + super do |corrector| + candidate = ::RuboCop::Cop::Corrector.new(corrector.source_buffer) + yield(candidate) + + corrector.merge!(candidate) if candidate.as_replacements.all? { |range, _| source.within_ruby?(range) } + end + end + end + end +end diff --git a/gems/rubocop-herb/lib/rubocop/herb/plugin.rb b/gems/rubocop-herb/lib/rubocop/herb/plugin.rb new file mode 100644 index 000000000..93758018f --- /dev/null +++ b/gems/rubocop-herb/lib/rubocop/herb/plugin.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "lint_roller" + +module RuboCop + module Herb + class Plugin < LintRoller::Plugin + def about + LintRoller::About.new( + description: "Run configured RuboCop rules against Ruby in ERB templates.", + homepage: "https://github.com/marcoroth/herb", + name: "rubocop-herb", + version: ::Herb::VERSION + ) + end + + def rules(_context) + extractors = RuboCop::Runner.ruby_extractors + extractors.unshift(EXTRACT_RUBY) unless extractors.include?(EXTRACT_RUBY) + + LintRoller::Rules.new( + config_format: :rubocop, + type: :path, + value: File.expand_path("../../../config/default.yml", __dir__) + ) + end + + def supported?(context) + context.engine == :rubocop + end + + def self.extract_ruby(processed_source) + return unless TemplateSource.template?(processed_source.path) + + source = TemplateSource.from(processed_source) + return [] if source.valid_syntax? && !source.ast + + [{ offset: 0, processed_source: source }] + end + + EXTRACT_RUBY = method(:extract_ruby) + end + end +end diff --git a/gems/rubocop-herb/lib/rubocop/herb/template_source.rb b/gems/rubocop-herb/lib/rubocop/herb/template_source.rb new file mode 100644 index 000000000..d2b42f74d --- /dev/null +++ b/gems/rubocop-herb/lib/rubocop/herb/template_source.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module RuboCop + module Herb + # The Ruby of a template, aligned character for character with the template, plus the + # character ranges that hold the Ruby of each ERB tag (without surrounding whitespace). + class TemplateSource < ::RuboCop::ProcessedSource + EXTENSIONS = [".erb", ".herb"].freeze + + def self.template?(path) + path&.end_with?(*EXTENSIONS) + end + + def self.from(original) + template = original.raw_source + code = align(template, ::Herb.extract_ruby(template)) + + new(code, original.ruby_version, original.path, parser_engine: original.parser_engine).tap do |source| + source.config = original.config + source.registry = original.registry + source.ruby_ranges = ruby_ranges(template, code) + source.original_source = original + end + end + + # `Herb.extract_ruby` preserves byte positions; RuboCop addresses characters. Collapse each + # masked multibyte character to one space and keep every newline so lines stay aligned. + def self.align(template, extracted) + offset = 0 + + template.each_char.map { |char| + slice = extracted.byteslice(offset, char.bytesize) + offset += char.bytesize + + if char == "\n" || slice == char then char + elsif char.bytesize == 1 then slice + else " " + end + }.join + end + + # Ruby characters are the non-whitespace template characters that survive extraction. ERB + # delimiters never survive, so they split the template into one range per tag. + def self.ruby_ranges(template, code) + ranges = [] + start = finish = nil + + template.each_char.zip(code.each_char).each_with_index do |(original, extracted), index| + next if original.match?(/\s/) + + if original == extracted + start ||= index + finish = index + 1 + elsif start + ranges << (start...finish) + start = nil + end + end + + ranges << (start...finish) if start + ranges + end + + attr_accessor :ruby_ranges, :original_source + + def comment_config + return super unless original_source.raw_source.include?("rubocop:") + + @comment_config ||= ::RuboCop::CommentConfig.new(DirectiveSource.new(self, original_source)) + end + + # Both ends of the range touch Ruby, so the offense points at code rather than at markup. + def anchored_in_ruby?(range) + touches_ruby?(range.begin_pos) && touches_ruby?(range.end_pos) + end + + # The range lies within the Ruby of a single tag, so editing it cannot alter markup. + def within_ruby?(range) + ruby_ranges.any? { |ruby| ruby.begin <= range.begin_pos && range.end_pos <= ruby.end } + end + + private + + def touches_ruby?(position) + ruby_ranges.any? { |ruby| position.between?(ruby.begin, ruby.end) } + end + end + end +end diff --git a/gems/rubocop-herb/rubocop-herb.gemspec b/gems/rubocop-herb/rubocop-herb.gemspec new file mode 100644 index 000000000..18d6ad988 --- /dev/null +++ b/gems/rubocop-herb/rubocop-herb.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../../lib/herb/version" + +Gem::Specification.new do |spec| + spec.name = "rubocop-herb" + spec.version = Herb::VERSION + spec.authors = ["Marco Roth"] + spec.email = ["marco.roth@intergga.ch"] + + spec.summary = "Run RuboCop against Ruby embedded in Herb templates" + spec.description = "A RuboCop plugin that uses Herb to extract Ruby from ERB templates." + spec.homepage = "https://herb-tools.dev" + spec.license = "MIT" + + spec.required_ruby_version = ">= 3.2.0" + spec.require_paths = ["lib"] + spec.files = Dir[ + "README.md", + "config/**/*.yml", + "lib/**/*.rb" + ] + + spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["rubygems_mfa_required"] = "true" + spec.metadata["default_lint_roller_plugin"] = "RuboCop::Herb::Plugin" + spec.metadata["homepage_uri"] = "https://herb-tools.dev" + spec.metadata["source_code_uri"] = "https://github.com/marcoroth/herb" + spec.metadata["bug_tracker_uri"] = "https://github.com/marcoroth/herb/issues" + + spec.add_dependency "herb", "= #{Herb::VERSION}" + spec.add_dependency "lint_roller", "~> 1.1" + spec.add_dependency "rubocop", ">= 1.92.0", "< 2" +end diff --git a/gems/rubocop-herb/test/integration_test.rb b/gems/rubocop-herb/test/integration_test.rb new file mode 100644 index 000000000..68baacc04 --- /dev/null +++ b/gems/rubocop-herb/test/integration_test.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "erb" + +class RuboCopHerbIntegrationTest < Minitest::Spec + include RuboCopHerbTestHelpers + + CLEAN_TEMPLATE = <<~ERB +
Café <%= user.name %> and <%= count + 1 %>
+ <% if admin? %> + Admin + <% else %> + User + <% end %> +Open
+ <% when :closed %> +Closed
+ <% end %> + <% value = compute %> +<%= value %>
+ ERB + + def setup + @directory = Dir.mktmpdir("rubocop-herb") + end + + def teardown + FileUtils.remove_entry(@directory) + end + + test "runs configured built-in and custom cops on ERB Ruby" do + write("custom_cop.rb", <<~RUBY) + module RuboCop + module Cop + module HerbTest + class NoFoo < Base + MSG = "Do not call foo." + + def on_send(node) + add_offense(node) if node.method_name == :foo + end + end + end + end + end + RUBY + write_config(<<~YAML) + require: + - ./custom_cop + Layout/SpaceAroundOperators: + Enabled: true + HerbTest/NoFoo: + Enabled: true + YAML + write("example.html.erb", "Café <%= foo(x=1) %>
\n") + + output, error, status = run_rubocop("example.html.erb") + + refute_equal 0, status + assert_empty error + assert_includes output, "Layout/SpaceAroundOperators" + assert_includes output, "HerbTest/NoFoo" + assert_includes output, "example.html.erb:1:13" + end + + test "autocorrects only the embedded Ruby" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + write("example.html.erb", "Café
<%= a %> <%= b %>\n<%= c + d %>\n") + + run_rubocop("-A", "example.html.erb") + + assert_equal "Café
<%= a %> <%= b %>\n<%= c + d %>\n", read("example.html.erb") + end + + test "does not run cops excluded for ERB fragments" do + write_config(<<~YAML) + Style/FrozenStringLiteralComment: + Enabled: true + YAML + write("example.html.erb", "<%= user.name %>\n") + + output, error, status = run_rubocop("example.html.erb") + + assert_equal 0, status, output + assert_empty error + refute_includes output, "Style/FrozenStringLiteralComment" + end + + test "honors project exclusions for template filenames" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + Exclude: + - example.html.erb + YAML + write("example.html.erb", "<%= x=1 %>\n") + + output, error, status = run_rubocop("example.html.erb") + + assert_equal 0, status, output + assert_empty error + refute_includes output, "Layout/SpaceAroundOperators" + end + + test "tracks variable usage across ERB tags" do + write_config(<<~YAML) + Lint/UselessAssignment: + Enabled: true + YAML + write("example.html.erb", "<% value = compute %>\n<%= value %>
\n") + + output, error, status = run_rubocop("example.html.erb") + + assert_equal 0, status, output + assert_empty error + refute_includes output, "Lint/UselessAssignment" + end + + test "reports Ruby syntax errors" do + write_config(<<~YAML) + Lint/Syntax: + Enabled: true + YAML + write("example.html.erb", "<%= user. %>\n") + + output, error, status = run_rubocop("example.html.erb") + + refute_equal 0, status + assert_empty error + assert_includes output, "Lint/Syntax" + assert_includes output, "example.html.erb:1" + end + + test "runs on non-HTML ERB files" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + write("example.rss.erb", "<%= rss=1 %>\n") + write("example.erb", "<%= generic=1 %>\n") + + output, error, status = run_rubocop(".") + + refute_equal 0, status + assert_empty error + assert_includes output, "example.rss.erb" + assert_includes output, "example.erb" + end + + test "runs on Herb files" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + write("example.herb", "<%= herb=1 %>\n") + + output, error, status = run_rubocop("example.herb") + + refute_equal 0, status + assert_empty error + assert_includes output, "example.herb" + assert_includes output, "Layout/SpaceAroundOperators" + end + + test "leaves ordinary Ruby inspection unchanged" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + write("example.rb", "x=1\n") + + output, error, status = run_rubocop("example.rb") + + refute_equal 0, status + assert_empty error + assert_includes output, "Layout/SpaceAroundOperators" + end + + test "does not remove returns before template output" do + write_config(<<~YAML) + Style/RedundantReturn: + Enabled: true + YAML + template = "<% greeting = lambda do %><% return 'Hi' %>ignored<% end %><%= greeting.call %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template, read("example.html.erb") + assert_equal "Hi\n", ERB.new(read("example.html.erb")).result + end + + test "still corrects redundant returns in ordinary Ruby" do + write_config(<<~YAML) + Style/RedundantReturn: + Enabled: true + YAML + write("example.rb", "def greeting\n return 'Hi'\nend\n") + + output, error, status = run_rubocop("-a", "example.rb") + + assert_equal 0, status, output + assert_empty error + assert_equal "def greeting\n 'Hi'\nend\n", read("example.rb") + end + + test "does not remove assignments consumed by output tags" do + write_config(<<~YAML) + Style/RedundantAssignment: + Enabled: true + YAML + template = "<% def greeting %><% x = 'Hi' %><%= x %><% end %><%= greeting %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template, read("example.html.erb") + assert_equal "HiHi\n", ERB.new(read("example.html.erb"), eoutvar: "@output").result(Object.new.instance_eval { binding }) + end + + test "does not remove begin and end across template tags" do + write_config(<<~YAML) + Style/RedundantBegin: + Enabled: true + YAML + template = "<% begin %>Hi<%= 'there' %><% end %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template, read("example.html.erb") + assert_equal "Hithere\n", ERB.new(read("example.html.erb")).result + end + + test "honors disable and enable directives in ERB comments" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + template = <<~ERB +Caf\u00e9
<%# rubocop:disable Layout/SpaceAroundOperators %> + <%= x=1 %> + <%# rubocop:enable Layout/SpaceAroundOperators %> + <%= y=2 %> + ERB + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template.sub("y=2", "y = 2"), read("example.html.erb") + end + + test "does not let a same-line directive hide later Ruby tags" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + Style/StringLiterals: + Enabled: true + YAML + template = "<%# rubocop:todo Layout/SpaceAroundOperators %><%= x=1 %><%= \"hello\" %>\n<%= y=2 %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template.sub('"hello"', "'hello'").sub("y=2", "y = 2"), read("example.html.erb") + end + + test "honors directives in Ruby comment tags" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + template = "<% # rubocop:disable Layout/SpaceAroundOperators %>\n<%= x=1 %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template, read("example.html.erb") + end + + test "honors disable-next directives without losing the following statement" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + template = "<%# rubocop:disable-next Layout/SpaceAroundOperators %>\n<%= x=1 %>\n<%= y=2 %>\n" + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template.sub("y=2", "y = 2"), read("example.html.erb") + end + + test "ignores directives in strings markup and escaped tags" do + write_config(<<~YAML) + Layout/SpaceAroundOperators: + Enabled: true + YAML + template = <<~ERB +# rubocop:disable Layout/SpaceAroundOperators
+ <%%# rubocop:disable Layout/SpaceAroundOperators %> + <%= '# rubocop:disable Layout/SpaceAroundOperators' %> + <%= x=1 %> + ERB + write("example.html.erb", template) + + output, error, status = run_rubocop("-a", "example.html.erb") + + assert_equal 0, status, output + assert_empty error + assert_equal template.sub("x=1", "x = 1"), read("example.html.erb") + end + + private + + def write_config(cops) + write(".rubocop.yml", <<~YAML) + plugins: + - rubocop-herb + AllCops: + DisabledByDefault: true + SuggestExtensions: false + #{cops} + YAML + end + + def write(path, contents) + File.write(File.join(@directory, path), contents) + end + + def read(path) + File.read(File.join(@directory, path)) + end +end diff --git a/gems/rubocop-herb/test/plugin_extraction_test.rb b/gems/rubocop-herb/test/plugin_extraction_test.rb new file mode 100644 index 000000000..6a9beff6c --- /dev/null +++ b/gems/rubocop-herb/test/plugin_extraction_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +module RuboCop + module Herb + class PluginExtractionTest < Minitest::Spec + test "builds one character-aligned Ruby source" do + template = "Café <%= x=1 %>
\n" + fragment = extract(template).first + source = fragment[:processed_source].raw_source + + assert_equal 0, fragment[:offset] + assert_equal template.length, source.length + assert_equal "x=1", source[template.index("x=1"), 3] + assert fragment[:processed_source].valid_syntax? + end + + test "keeps lines aligned after multi-line escaped ERB" do + template = "<%% a\nb %%>\nÜnï
<%= x %>\n" + source = extract(template).first[:processed_source].raw_source + + assert_equal template.length, source.length + assert_equal template.index("x"), source.index("x") + assert_equal template.count("\n"), source.count("\n") + end + + test "preserves Ruby control flow across ERB tags" do + fragment = extract(<<~ERB).first + <% case status %> + <% when "open", "closed" %> + <% if user.admin? %> + <%= users.each do |user| %> + <%= user.name %> + <% end %> + <% end %> + <% end %> + ERB + + source = fragment[:processed_source] + + assert source.valid_syntax? + assert_equal :case, source.ast.type + assert_includes source.raw_source, "when \"open\", \"closed\"" + assert_includes source.raw_source, "users.each do |user|" + end + + test "separates adjacent Ruby tags on the same line" do + source = extract("<%= first %><%= second %>\n").first[:processed_source] + + assert source.valid_syntax? + assert_equal 2, source.ast.each_node(:send).count + end + + test "preserves multibyte characters in Ruby" do + source = extract("<%= name == \"José\" %>\n").first[:processed_source] + + assert_includes source.raw_source, "name == \"José\"" + end + + test "records the Ruby of each tag without surrounding whitespace" do + template = "Café
<%= a + b %>\n<% if c %>\n<%# note %>\n" + source = extract(template).first[:processed_source] + + assert_equal(["a + b", "if c"], source.ruby_ranges.map { |range| template[range] }) + end + + test "skips comments and escaped ERB" do + fragments = extract(<<~ERB) + <%# x=1 %> + <%% x=1 %%> + ERB + + assert_empty fragments + end + + test "returns nil for non-template files" do + assert_nil Plugin.extract_ruby(processed_source("x=1\n", "example.rb")) + end + + test "extracts Ruby from every template extension" do + ["example.rss.erb", "example.erb", "example.herb"].each do |path| + refute_empty Plugin.extract_ruby(processed_source("<%= x=1 %>\n", path)) + end + end + + private + + def extract(source) + Plugin.extract_ruby(processed_source(source, "example.html.erb")) + end + + def processed_source(source, path) + ::RuboCop::ProcessedSource.new(source, 3.2, path) + end + end + end +end diff --git a/gems/rubocop-herb/test/plugin_test.rb b/gems/rubocop-herb/test/plugin_test.rb new file mode 100644 index 000000000..896e5c127 --- /dev/null +++ b/gems/rubocop-herb/test/plugin_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "yaml" + +module RuboCop + module Herb + class PluginTest < Minitest::Spec + test "registers the extractor once" do + extractors = ::RuboCop::Runner.ruby_extractors + extractors.delete(Plugin::EXTRACT_RUBY) + context = LintRoller::Context.new(engine: :rubocop, engine_version: ::RuboCop::Version::STRING) + + 2.times { Plugin.new.rules(context) } + + assert_equal Plugin::EXTRACT_RUBY, extractors.first + assert_equal 1, extractors.count(Plugin::EXTRACT_RUBY) + end + + test "includes exactly the template extensions the extractor handles" do + assert_equal(TemplateSource::EXTENSIONS.map { |extension| "**/*#{extension}" }, config.dig("AllCops", "Include")) + end + + test "excludes only cops that exist" do + cops = config.keys.grep(%r{/}).reject { |name| name.start_with?("Rails/") } + + assert_empty(cops.reject { |name| ::RuboCop::Cop::Registry.global.find_by_cop_name(name) }) + end + + private + + def config + YAML.load_file(File.expand_path("../config/default.yml", __dir__), aliases: true) + end + end + end +end diff --git a/gems/rubocop-herb/test/test_helper.rb b/gems/rubocop-herb/test/test_helper.rb new file mode 100644 index 000000000..c1ad3d8fa --- /dev/null +++ b/gems/rubocop-herb/test/test_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "fileutils" +require "maxitest/autorun" +require "open3" +require "rubocop/herb" +require "tmpdir" + +Minitest::Spec::DSL.send(:alias_method, :test, :it) + +module RuboCopHerbTestHelpers + LOAD_PATHS = [File.expand_path("../../../lib", __dir__), File.expand_path("../lib", __dir__)].freeze + + def run_rubocop(*args) + command = [Gem.ruby, *LOAD_PATHS.map { |path| "-I#{path}" }, Gem.bin_path("rubocop", "rubocop"), "--cache", "false", *args] + output, error, status = Open3.capture3(*command, chdir: @directory) + + [output, error, status.exitstatus] + end +end