Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 lib/steep.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
require "steep/module_helper"
require "steep/source"
require "steep/source/ignore_ranges"
require "steep/source/erb_to_ruby_code"
require "steep/annotation_parser"
require "steep/typing"
require "steep/type_construction"
Expand Down
2 changes: 2 additions & 0 deletions lib/steep/source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def self.new_parser
end

def self.parse(source_code, path:, factory:)
source_code = ErbToRubyCode.convert(source_code) if path.to_s.end_with?(".erb")

buffer = ::Parser::Source::Buffer.new(path.to_s, 1, source: source_code)
node, comments = new_parser().parse_with_comments(buffer)

Expand Down
112 changes: 112 additions & 0 deletions lib/steep/source/erb_to_ruby_code.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# frozen_string_literal: true

module Steep
class Source
# Converts ERB template code to Ruby code by replacing ERB tags with Ruby statements
# and HTML content with spaces, preserving line numbers and basic spacing.
#
# Supports all ERB tag variations:
# - `<% ruby_code %>` - execution tags
# - `<%= ruby_code %>` - output tags
# - `<%- ruby_code %>` - execution tags with leading whitespace control
# - `<% ruby_code -%>` - execution tags with trailing whitespace control
# - `<%- ruby_code -%>` - execution tags with both leading and trailing whitespace control
#
# Adds semicolons after each ERB tag to separate multiple statements on the same line,
# except for comments (lines starting with #).
module ErbToRubyCode
ERB_TAG_PREFIX_POSITION_REGEX = /<%[-=]?/
ERB_TAG_SUFIX_POSITION_REGEX = /%>/
ERB_TAG_PREFIX_REGEX = /^<%[-=]?\s*/
ERB_TAG_SUFFIX_REGEX = /\s*-?%>$/
NON_NEWLINE_REGEX = /[^\n]/

private_constant :ERB_TAG_PREFIX_POSITION_REGEX,
:ERB_TAG_SUFIX_POSITION_REGEX,
:ERB_TAG_SUFFIX_REGEX,
:NON_NEWLINE_REGEX

class << self
def convert(source_code)
idx = 0

while idx < source_code.length
erb_tag_prefix_position = source_code.index(ERB_TAG_PREFIX_POSITION_REGEX, idx)
break unless erb_tag_prefix_position

replace_everything_before_erb_tag_with_whitespace(erb_tag_prefix_position:, idx:, source_code:)

erb_tag_sufix_position = source_code.index(ERB_TAG_SUFIX_POSITION_REGEX, erb_tag_prefix_position)
if erb_tag_sufix_position.nil?
# Incomplete ERB tag, replace rest with spaces, preserving newlines
remaining = source_code[erb_tag_prefix_position..]
source_code[erb_tag_prefix_position..-1] = remaining&.gsub(NON_NEWLINE_REGEX, ' ') || ''
break
end

erb_tag_full_content = source_code[erb_tag_prefix_position..(erb_tag_sufix_position + 1)]
unless erb_tag_full_content
raise 'Internal error: erb_tag_full_content should not be nil after finding start and end tags'
end

erb_tag_prefix_length = erb_tag_prefix_length(erb_tag_full_content:)
erb_tag_sufix_length = erb_tag_sufix_length(erb_tag_full_content:)

replacement_erb_tag = generate_replacement(erb_tag_full_content:, erb_tag_prefix_length:,
erb_tag_sufix_length:)

source_code[erb_tag_prefix_position..(erb_tag_sufix_position + 1)] = replacement_erb_tag
idx = erb_tag_prefix_position + replacement_erb_tag.length
end

replace_everything_after_erb_tag_with_whitespace(idx:, source_code:)
end

private

def replace_everything_before_erb_tag_with_whitespace(erb_tag_prefix_position:, idx:, source_code:)
before_erb = source_code[idx...erb_tag_prefix_position] || ''
source_code[idx...erb_tag_prefix_position] = before_erb.gsub(NON_NEWLINE_REGEX, ' ')
end

def erb_tag_prefix_length(erb_tag_full_content:)
tag_prefix_match = erb_tag_full_content.match(ERB_TAG_PREFIX_REGEX) or raise
tag_prefix_string = tag_prefix_match[0] or raise

tag_prefix_string.length
end

def erb_tag_sufix_length(erb_tag_full_content:)
tag_suffix_match = erb_tag_full_content.match(ERB_TAG_SUFFIX_REGEX) or raise
tag_suffix_string = tag_suffix_match[0] or raise

tag_suffix_string.length
end

def generate_replacement(erb_tag_full_content:, erb_tag_prefix_length:, erb_tag_sufix_length:)
inner_with_tags_removed = inner_with_tags_removed(erb_tag_full_content:, erb_tag_prefix_length:,
erb_tag_sufix_length:)

if inner_with_tags_removed.start_with?('#')
(' ' * erb_tag_prefix_length) + inner_with_tags_removed + (' ' * erb_tag_sufix_length)
else
"#{' ' * erb_tag_prefix_length}#{inner_with_tags_removed}#{' ' * (erb_tag_sufix_length - 1)};"
end
end

def inner_with_tags_removed(erb_tag_full_content:, erb_tag_prefix_length:, erb_tag_sufix_length:)
erb_tag_full_content[erb_tag_prefix_length...-erb_tag_sufix_length] or raise
end

def replace_everything_after_erb_tag_with_whitespace(idx:, source_code:)
return source_code if idx >= source_code.length

remaining = source_code[idx..]
source_code[idx..-1] = remaining&.gsub(NON_NEWLINE_REGEX, ' ') || ''

source_code
end
end
end
end
end
27 changes: 27 additions & 0 deletions sig/steep/source/erb_to_ruby_code.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Steep
class Source
module ErbToRubyCode
def self.convert: (String erb_code) -> String

private

ERB_TAG_PREFIX_POSITION_REGEX: Regexp
ERB_TAG_SUFIX_POSITION_REGEX: Regexp
ERB_TAG_PREFIX_REGEX: Regexp
ERB_TAG_SUFFIX_REGEX: Regexp
NON_NEWLINE_REGEX: Regexp

def self.replace_everything_before_erb_tag_with_whitespace: (idx: Integer, erb_tag_prefix_position: Integer, source_code: String) -> String

def self.erb_tag_prefix_length: (erb_tag_full_content: String) -> Integer

def self.erb_tag_sufix_length: (erb_tag_full_content: String) -> Integer

def self.inner_with_tags_removed: (erb_tag_full_content: String, erb_tag_prefix_length: Integer, erb_tag_sufix_length: Integer) -> String

def self.generate_replacement: (erb_tag_full_content: String, erb_tag_prefix_length: Integer, erb_tag_sufix_length: Integer) -> String

def self.replace_everything_after_erb_tag_with_whitespace: (idx: Integer, source_code: String) -> String
end
end
end
4 changes: 4 additions & 0 deletions sig/test/cli_test.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class CLITest < Minitest::Test

def test_check_failure: () -> untyped

def test_erb_check_failure: () -> untyped

def test_erb_check_success: () -> untyped

def test_check_failure_with_formatter: () -> untyped

def test_check_group__target: () -> untyped
Expand Down
19 changes: 19 additions & 0 deletions sig/test/source/erb_to_ruby_code_test.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class Steep::Source::ErbToRubyCodeTest < Minitest::Test
include TestHelper

def test_erb_output_tag_to_ruby_code: () -> untyped

def test_erb_output_tag_without_begin_space_to_ruby_code: () -> untyped

def test_erb_output_tag_without_end_space_to_ruby_code: () -> untyped

def test_erb_execution_tag_to_ruby_code: () -> untyped

def test_erb_with_dash_to_ruby_code: () -> untyped

def test_erb_to_ruby_code_handles_comments_html_and_multiple_tags: () -> untyped

def test_erb_multiline_tag_with_closing_on_separate_line: () -> untyped

def test_erb_multiple_tags_same_line_conversion: () -> untyped
end
46 changes: 46 additions & 0 deletions test/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,52 @@ def test_check_failure
end
end

def test_erb_check_failure
in_tmpdir do
(current_dir + "Steepfile").write(<<-EOF)
target :app do
check "app/views/**/*.erb"
end
EOF

(current_dir + "app").mkdir
(current_dir + "app/views").mkdir
(current_dir + "app/views/companies").mkdir

(current_dir + "app/views/companies/_form.html.erb").write(<<-EOF)
<div> Count: <%= 1 + "2" %> </div>
EOF

stdout, status = sh(*steep, "check")

refute_predicate status, :success?, stdout
assert_match(/Detected 1 problem from 1 file/, stdout)
end
end

def test_erb_check_success
in_tmpdir do
(current_dir + "Steepfile").write(<<-EOF)
target :app do
check "app/views/**/*.erb"
end
EOF

(current_dir + "app").mkdir
(current_dir + "app/views").mkdir
(current_dir + "app/views/companies").mkdir

(current_dir + "app/views/companies/_form.html.erb").write(<<-EOF)
<div> Count: <%= 1 + 2 %> </div>
EOF

stdout, status = sh(*steep, "check")

assert_predicate status, :success?, stdout
assert_match(/No type error detected\./, stdout)
end
end

def test_check_failure_with_formatter
in_tmpdir do
(current_dir + "Steepfile").write(<<-EOF)
Expand Down
115 changes: 115 additions & 0 deletions test/source/erb_to_ruby_code_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
require_relative "../test_helper"

class Steep::Source::ErbToRubyCodeTest < Minitest::Test
include TestHelper

def test_erb_output_tag_to_ruby_code
erb_source_code = "<%= if order.with_error? %>"
expected_ruby_code = " if order.with_error? ;"

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end

def test_erb_output_tag_without_begin_space_to_ruby_code
erb_source_code = "<%=if order.with_error? %>"
expected_ruby_code = " if order.with_error? ;"

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end

def test_erb_output_tag_without_end_space_to_ruby_code
erb_source_code = "<%=if order.with_error?%>"
expected_ruby_code = " if order.with_error? ;"

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end

def test_erb_execution_tag_to_ruby_code
erb_source_code = "<div> Count <% 1 + '2' %> </div>"
expected_ruby_code = " 1 + '2' ; "

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end

def test_erb_to_ruby_code_handles_comments_html_and_multiple_tags
form_erb = <<ERB
<% # This is a comment %>
<div class="container">
<%= user.name %>
<% if user.admin? %>
<%= link_to "Admin Panel", admin_path %>
<% end %>
<p>Welcome!</p>
</div>
ERB

ruby_result = Steep::Source::ErbToRubyCode.convert(form_erb)

expected_ruby_result = <<RUBY
# This is a comment

user.name ;
if user.admin? ;
link_to "Admin Panel", admin_path ;
end ;


RUBY

assert_equal expected_ruby_result, ruby_result
end

def test_erb_multiline_tag_with_closing_on_separate_line
erb_source_code = <<ERB
<%= form_with model: @user,
url: users_path,
local: true
%>
ERB

expected_ruby_code = <<RUBY
form_with model: @user,
url: users_path,
local: true ;
RUBY

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end

def test_erb_multiple_tags_same_line_conversion
erb_with_two_tags_ruby = <<ERB
<div class="alert">
<strong><%= t("messages.welcome") %></strong> <%= current_user.name %>
</div>
<%= link_to "Home", root_path %>
ERB

expected_ruby_with_two_tags = <<RUBY

t("messages.welcome") ; current_user.name ;

link_to "Home", root_path ;
RUBY

assert_equal expected_ruby_with_two_tags, Steep::Source::ErbToRubyCode.convert(erb_with_two_tags_ruby)
end

def test_erb_with_dash_to_ruby_code
erb_source_code = "<%- if foo -%>"
expected_ruby_code = " if foo ;"

ruby_code = Steep::Source::ErbToRubyCode.convert(erb_source_code)

assert_equal expected_ruby_code, ruby_code
end
end