diff --git a/lib/steep.rb b/lib/steep.rb index 2b582cbba..0da69015e 100644 --- a/lib/steep.rb +++ b/lib/steep.rb @@ -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" diff --git a/lib/steep/source.rb b/lib/steep/source.rb index 2a8436a32..8ea08c6d5 100644 --- a/lib/steep/source.rb +++ b/lib/steep/source.rb @@ -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) diff --git a/lib/steep/source/erb_to_ruby_code.rb b/lib/steep/source/erb_to_ruby_code.rb new file mode 100644 index 000000000..c66628949 --- /dev/null +++ b/lib/steep/source/erb_to_ruby_code.rb @@ -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 diff --git a/sig/steep/source/erb_to_ruby_code.rbs b/sig/steep/source/erb_to_ruby_code.rbs new file mode 100644 index 000000000..8c784443c --- /dev/null +++ b/sig/steep/source/erb_to_ruby_code.rbs @@ -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 diff --git a/sig/test/cli_test.rbs b/sig/test/cli_test.rbs index aaf5324d1..57908fb91 100644 --- a/sig/test/cli_test.rbs +++ b/sig/test/cli_test.rbs @@ -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 diff --git a/sig/test/source/erb_to_ruby_code_test.rbs b/sig/test/source/erb_to_ruby_code_test.rbs new file mode 100644 index 000000000..48a4dab16 --- /dev/null +++ b/sig/test/source/erb_to_ruby_code_test.rbs @@ -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 diff --git a/test/cli_test.rb b/test/cli_test.rb index 310ce1720..4316a08f5 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -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) +
Count: <%= 1 + "2" %>
+ 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) +
Count: <%= 1 + 2 %>
+ 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) diff --git a/test/source/erb_to_ruby_code_test.rb b/test/source/erb_to_ruby_code_test.rb new file mode 100644 index 000000000..55f998a51 --- /dev/null +++ b/test/source/erb_to_ruby_code_test.rb @@ -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 = "
Count <% 1 + '2' %>
" + 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 = < +
+ <%= user.name %> + <% if user.admin? %> + <%= link_to "Admin Panel", admin_path %> + <% end %> +

Welcome!

+
+ERB + + ruby_result = Steep::Source::ErbToRubyCode.convert(form_erb) + + expected_ruby_result = < +ERB + + expected_ruby_code = < + <%= t("messages.welcome") %> <%= current_user.name %> + +<%= link_to "Home", root_path %> +ERB + + expected_ruby_with_two_tags = <