Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2cf1d08
Use Herb binding for RuboCop extraction
joelhawksley Oct 1, 2026
e8b8f7b
Simplify RuboCop Herb extraction
joelhawksley Oct 1, 2026
75e2ebe
Resolve upstream RuboCop Herb changes
joelhawksley Oct 1, 2026
4240cab
Apply suggestions from code review
joelhawksley Oct 2, 2026
a886d5d
Scan all ERB and Herb templates
joelhawksley Oct 2, 2026
cfcc57d
Use canonical extractor registration
joelhawksley Oct 2, 2026
46d795d
Update gems/rubocop-herb/rubocop-herb.gemspec
joelhawksley Oct 6, 2026
b04c6e8
Use ProcessedSource directly
joelhawksley Oct 6, 2026
c1d679c
Simplify RuboCop Herb plugin
joelhawksley Oct 6, 2026
d0323df
Report embedded Ruby syntax errors
joelhawksley Oct 6, 2026
a28592f
Preserve Unicode character positions in Ruby extraction
joelhawksley Oct 6, 2026
2cd22f2
Remove extract option comment
joelhawksley Oct 6, 2026
92cb268
Regenerate RubyProgram signature
joelhawksley Oct 6, 2026
abf9d5a
Retry CI
joelhawksley Oct 6, 2026
319054b
Merge branch 'main' into joelhawksley-preserve-unicode-character-posi…
joelhawksley Oct 19, 2026
9aa41e8
Merge remote-tracking branch 'origin/joelhawksley-preserve-unicode-ch…
joelhawksley Oct 21, 2026
c0a5b5c
Merge remote-tracking branch 'upstream/main' into joelhawksley-add-er…
joelhawksley Oct 21, 2026
60646f4
Update Gemfile.lock for rubocop-herb 0.11.1
joelhawksley Oct 21, 2026
bf9cc70
Use Herb.extract_ruby directly in RuboCop plugin
joelhawksley Oct 21, 2026
7a8b82f
Address rubocop-herb review findings
joelhawksley Oct 21, 2026
74f01d2
Merge branch 'main' into joelhawksley-add-erb-rubocop
joelhawksley Oct 29, 2026
242a888
Fix template autocorrection safety and RuboCop directives
joelhawksley Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ rubygem:
- changed-files:
- any-glob-to-any-file:
- 'herb.gemspec'
- 'gems/**/*'
- 'lib/**/*'
- 'templates/lib/**/*'

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/build-gems.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
paths:
- "src/**"
- "lib/**"
- "gems/**"
- "ext/**"
- "Gemfile"
- "Gemfile.lock"
Expand All @@ -22,6 +23,7 @@ on:
paths:
- "src/**"
- "lib/**"
- "gems/**"
- "ext/**"
- "Gemfile"
- "Gemfile.lock"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Metrics/ClassLength:
- lib/herb/visitor/stack.rb
- templates/template.rb
- test/**/*_test.rb
- gems/**/test/**/*_test.rb

Metrics/ModuleLength:
Exclude:
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
8 changes: 8 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
44 changes: 44 additions & 0 deletions gems/rubocop-herb/README.md
Original file line number Diff line number Diff line change
@@ -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"
```
146 changes: 146 additions & 0 deletions gems/rubocop-herb/config/default.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions gems/rubocop-herb/lib/rubocop/herb.rb
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions gems/rubocop-herb/lib/rubocop/herb/directive_source.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading