From 79b66c6be8f440d99aec83bd29d1ae95f7c5e685 Mon Sep 17 00:00:00 2001 From: Lucas Borges Date: Fri, 17 Oct 2025 16:07:26 -0300 Subject: [PATCH 01/10] feat: add method `extract_ruby_with_semicolons` to extension.c on herb --- ext/herb/extension.c | 15 +++++++++++++++ sig/herb_c_extension.rbs | 3 +++ 2 files changed, 18 insertions(+) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 155908ac3..e48902005 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -99,6 +99,20 @@ static VALUE Herb_extract_ruby(VALUE self, VALUE source) { return result; } +static VALUE Herb_extract_ruby_with_semicolons(VALUE self, VALUE source) { + char* string = (char*) check_string(source); + hb_buffer_T output; + + if (!hb_buffer_init(&output, strlen(string))) { return Qnil; } + + herb_extract_ruby_to_buffer_with_semicolons(string, &output); + + VALUE result = rb_utf8_str_new_cstr(output.value); + free(output.value); + + return result; +} + static VALUE Herb_extract_html(VALUE self, VALUE source) { char* string = (char*) check_string(source); hb_buffer_T output; @@ -137,6 +151,7 @@ void Init_herb(void) { rb_define_singleton_method(mHerb, "parse_file", Herb_parse_file, 1); rb_define_singleton_method(mHerb, "lex_file", Herb_lex_file, 1); rb_define_singleton_method(mHerb, "extract_ruby", Herb_extract_ruby, 1); + rb_define_singleton_method(mHerb, "extract_ruby_with_semicolons", Herb_extract_ruby_with_semicolons, 1); rb_define_singleton_method(mHerb, "extract_html", Herb_extract_html, 1); rb_define_singleton_method(mHerb, "version", Herb_version, 0); } diff --git a/sig/herb_c_extension.rbs b/sig/herb_c_extension.rbs index e5c3c05f1..938e679c6 100644 --- a/sig/herb_c_extension.rbs +++ b/sig/herb_c_extension.rbs @@ -4,4 +4,7 @@ module Herb def self.parse: (String input) -> ParseResult def self.lex: (String input) -> LexResult + def self.extract_ruby: (String source) -> String + def self.extract_ruby_with_semicolons: (String source) -> String + def self.extract_html: (String source) -> String end From a53adec0014aebda72b26b685c6424c57c4afcb4 Mon Sep 17 00:00:00 2001 From: Lucas Borges Date: Fri, 17 Oct 2025 16:07:42 -0300 Subject: [PATCH 02/10] feat: add `extract_ruby_with_semicolons` method to Herb module --- lib/herb/libherb.rb | 1 + lib/herb/libherb/libherb.rb | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lib/herb/libherb.rb b/lib/herb/libherb.rb index ea670bc00..7230efc47 100644 --- a/lib/herb/libherb.rb +++ b/lib/herb/libherb.rb @@ -29,6 +29,7 @@ def self.library_path attach_function :herb_parse, [:pointer], :pointer attach_function :herb_extract_ruby_to_buffer, [:pointer, :pointer], :void attach_function :herb_extract_html_to_buffer, [:pointer, :pointer], :void + attach_function :herb_extract_ruby_to_buffer_with_semicolons, [:pointer, :pointer], :void attach_function :herb_version, [], :pointer end end diff --git a/lib/herb/libherb/libherb.rb b/lib/herb/libherb/libherb.rb index 03c3b77d7..f48ee06ed 100644 --- a/lib/herb/libherb/libherb.rb +++ b/lib/herb/libherb/libherb.rb @@ -34,6 +34,14 @@ def self.extract_ruby(source) end end + def self.extract_ruby_with_semicolons(source) + LibHerb::Buffer.with do |output| + LibHerb.herb_extract_ruby_to_buffer_with_semicolons(source, output.pointer) + + output.read + end + end + def self.extract_html(source) LibHerb::Buffer.with do |output| LibHerb.herb_extract_html_to_buffer(source, output.pointer) From b2f1026b7ca43a3c31de081299cf4071f0b3ea1b Mon Sep 17 00:00:00 2001 From: Lucas Borges Date: Fri, 17 Oct 2025 16:12:38 -0300 Subject: [PATCH 03/10] test: add tests for `extract_ruby_with_semicolons` method in Extractor module Co-authored-by: Felipe Felix Co-authored-by: Everton --- .../extractor/extract_ruby_semicolons_test.rb | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/extractor/extract_ruby_semicolons_test.rb diff --git a/test/extractor/extract_ruby_semicolons_test.rb b/test/extractor/extract_ruby_semicolons_test.rb new file mode 100644 index 000000000..2579e8fad --- /dev/null +++ b/test/extractor/extract_ruby_semicolons_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module Extractor + class ExtractRubySemicolonsTest < Minitest::Spec + test "basic silent" do + ruby = Herb.extract_ruby_with_semicolons("

<% RUBY_VERSION %>

") + + assert_equal " RUBY_VERSION ; ", ruby + end + + test "basic loud" do + ruby = Herb.extract_ruby_with_semicolons("

<%= RUBY_VERSION %>

") + + assert_equal " RUBY_VERSION ; ", ruby + end + + test "with newlines" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) +

+ <% RUBY_VERSION %> +

+ HTML + + assert_equal " \n RUBY_VERSION ; \n \n", actual + end + + test "nested" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <% array = [1, 2, 3] %> + +
    + <% array.each do |item| %> +
  • <%= item %>
  • + <% end %> +
+ HTML + + expected = " array = [1, 2, 3] ; \n\n \n array.each do |item| ; \n item ; \n end ; \n \n" + + assert_equal expected, actual + end + + test "erb comment" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <%# comment ' %> + HTML + + expected = " ; \n" + + assert_equal expected, actual + end + + test "erb comment with ruby keyword" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <%# end %> + HTML + + expected = " ; \n" + + assert_equal expected, actual + end + + test "erb comment broken up over multiple lines" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <%# + end + %> + HTML + + expected = " ; \n" + + assert_equal expected, actual + end + + test "multi-line erb comment" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <%# + end + end + end + end + %> + HTML + + expected = " ; \n" + + assert_equal expected, actual + end + + test "erb if/end and comment on same line" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <% if %><%# comment %><% end %> + HTML + + expected = " if ; ; end ; \n" + + assert_equal expected, actual + end + + xtest "erb if/end and Ruby comment on same line" do + actual = Herb.extract_ruby_with_semicolons(<<~HTML) + <% if %><% # comment %><% end %> + HTML + + expected = " if # comment end \n" + + assert_equal expected, actual + end + end +end From c239764d6867cb719d222470aa7534bd3cc803e9 Mon Sep 17 00:00:00 2001 From: Felipe Felix Date: Mon, 3 Nov 2025 00:03:10 -0300 Subject: [PATCH 04/10] add with semicolons argument to extract ruby --- ext/herb/extension.c | 36 ++++++++++--------- lib/herb/libherb/libherb.rb | 16 ++++----- sig/herb_c_extension.rbs | 3 +- .../extractor/extract_ruby_semicolons_test.rb | 20 +++++------ 4 files changed, 37 insertions(+), 38 deletions(-) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index e48902005..69d6397a8 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -85,27 +85,32 @@ static VALUE Herb_parse_file(VALUE self, VALUE path) { return result; } -static VALUE Herb_extract_ruby(VALUE self, VALUE source) { +static VALUE Herb_extract_ruby(int argc, VALUE* argv, VALUE self) { + VALUE source, options; + rb_scan_args(argc, argv, "1:", &source, &options); + char* string = (char*) check_string(source); hb_buffer_T output; if (!hb_buffer_init(&output, strlen(string))) { return Qnil; } - herb_extract_ruby_to_buffer(string, &output); - - VALUE result = rb_utf8_str_new_cstr(output.value); - free(output.value); - - return result; -} - -static VALUE Herb_extract_ruby_with_semicolons(VALUE self, VALUE source) { - char* string = (char*) check_string(source); - hb_buffer_T output; + bool with_semicolon = false; + if (!NIL_P(options)) { + VALUE with_semicolon_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("with_semicolon")); + if (NIL_P(with_semicolon_value)) { + with_semicolon_value = rb_hash_lookup(options, ID2SYM(rb_intern("with_semicolon"))); + } - if (!hb_buffer_init(&output, strlen(string))) { return Qnil; } + if (!NIL_P(with_semicolon_value) && RTEST(with_semicolon_value)) { + with_semicolon = true; + } + } - herb_extract_ruby_to_buffer_with_semicolons(string, &output); + if (with_semicolon) { + herb_extract_ruby_to_buffer_with_semicolons(string, &output); + } else { + herb_extract_ruby_to_buffer(string, &output); + } VALUE result = rb_utf8_str_new_cstr(output.value); free(output.value); @@ -150,8 +155,7 @@ void Init_herb(void) { rb_define_singleton_method(mHerb, "lex", Herb_lex, 1); rb_define_singleton_method(mHerb, "parse_file", Herb_parse_file, 1); rb_define_singleton_method(mHerb, "lex_file", Herb_lex_file, 1); - rb_define_singleton_method(mHerb, "extract_ruby", Herb_extract_ruby, 1); - rb_define_singleton_method(mHerb, "extract_ruby_with_semicolons", Herb_extract_ruby_with_semicolons, 1); + rb_define_singleton_method(mHerb, "extract_ruby", Herb_extract_ruby, -1); rb_define_singleton_method(mHerb, "extract_html", Herb_extract_html, 1); rb_define_singleton_method(mHerb, "version", Herb_version, 0); } diff --git a/lib/herb/libherb/libherb.rb b/lib/herb/libherb/libherb.rb index f48ee06ed..911f49289 100644 --- a/lib/herb/libherb/libherb.rb +++ b/lib/herb/libherb/libherb.rb @@ -26,17 +26,13 @@ def self.lex(source) ) end - def self.extract_ruby(source) + def self.extract_ruby(source, with_semicolon: false) LibHerb::Buffer.with do |output| - LibHerb.herb_extract_ruby_to_buffer(source, output.pointer) - - output.read - end - end - - def self.extract_ruby_with_semicolons(source) - LibHerb::Buffer.with do |output| - LibHerb.herb_extract_ruby_to_buffer_with_semicolons(source, output.pointer) + if with_semicolon + LibHerb.herb_extract_ruby_to_buffer_with_semicolons(source, output.pointer) + else + LibHerb.herb_extract_ruby_to_buffer(source, output.pointer) + end output.read end diff --git a/sig/herb_c_extension.rbs b/sig/herb_c_extension.rbs index 938e679c6..7ffd75ec1 100644 --- a/sig/herb_c_extension.rbs +++ b/sig/herb_c_extension.rbs @@ -4,7 +4,6 @@ module Herb def self.parse: (String input) -> ParseResult def self.lex: (String input) -> LexResult - def self.extract_ruby: (String source) -> String - def self.extract_ruby_with_semicolons: (String source) -> String + def self.extract_ruby: (String source, ?with_semicolon: bool) -> String def self.extract_html: (String source) -> String end diff --git a/test/extractor/extract_ruby_semicolons_test.rb b/test/extractor/extract_ruby_semicolons_test.rb index 2579e8fad..7e9ad24ec 100644 --- a/test/extractor/extract_ruby_semicolons_test.rb +++ b/test/extractor/extract_ruby_semicolons_test.rb @@ -5,19 +5,19 @@ module Extractor class ExtractRubySemicolonsTest < Minitest::Spec test "basic silent" do - ruby = Herb.extract_ruby_with_semicolons("

<% RUBY_VERSION %>

") + ruby = Herb.extract_ruby("

<% RUBY_VERSION %>

", with_semicolon: true) assert_equal " RUBY_VERSION ; ", ruby end test "basic loud" do - ruby = Herb.extract_ruby_with_semicolons("

<%= RUBY_VERSION %>

") + ruby = Herb.extract_ruby("

<%= RUBY_VERSION %>

", with_semicolon: true) assert_equal " RUBY_VERSION ; ", ruby end test "with newlines" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true)

<% RUBY_VERSION %>

@@ -27,7 +27,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "nested" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <% array = [1, 2, 3] %>
    @@ -43,7 +43,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "erb comment" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <%# comment ' %> HTML @@ -53,7 +53,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "erb comment with ruby keyword" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <%# end %> HTML @@ -63,7 +63,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "erb comment broken up over multiple lines" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <%# end %> @@ -75,7 +75,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "multi-line erb comment" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <%# end end @@ -90,7 +90,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end test "erb if/end and comment on same line" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <% if %><%# comment %><% end %> HTML @@ -100,7 +100,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec end xtest "erb if/end and Ruby comment on same line" do - actual = Herb.extract_ruby_with_semicolons(<<~HTML) + actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) <% if %><% # comment %><% end %> HTML From aa42d20f09c06a43af6c97a60a6fa6f04747358c Mon Sep 17 00:00:00 2001 From: Felipe Felix Date: Sun, 9 Nov 2025 18:08:25 -0300 Subject: [PATCH 05/10] Use the same tests of test_extract.c in extract_ruby_semicolons_test.rb --- .../extractor/extract_ruby_semicolons_test.rb | 144 +++++++++--------- 1 file changed, 75 insertions(+), 69 deletions(-) diff --git a/test/extractor/extract_ruby_semicolons_test.rb b/test/extractor/extract_ruby_semicolons_test.rb index 7e9ad24ec..ff12c9bc7 100644 --- a/test/extractor/extract_ruby_semicolons_test.rb +++ b/test/extractor/extract_ruby_semicolons_test.rb @@ -4,109 +4,115 @@ module Extractor class ExtractRubySemicolonsTest < Minitest::Spec - test "basic silent" do - ruby = Herb.extract_ruby("

    <% RUBY_VERSION %>

    ", with_semicolon: true) + test "extract_ruby_single_erb_no_semicolon" do + source = "<% if %>\n<% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal " RUBY_VERSION ; ", ruby + expected = " if \n end " + assert_equal expected, result end - test "basic loud" do - ruby = Herb.extract_ruby("

    <%= RUBY_VERSION %>

    ", with_semicolon: true) + test "extract_ruby_multiple_erb_same_line_with_semicolon" do + source = "<% x = 1 %> <% y = 2 %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal " RUBY_VERSION ; ", ruby + assert_equal " x = 1 ; y = 2 ", result end - test "with newlines" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) -

    - <% RUBY_VERSION %> -

    - HTML + test "extract_ruby_three_erb_same_line_with_semicolons" do + source = "<% a = 1 %> <% b = 2 %> <% c = 3 %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal " \n RUBY_VERSION ; \n \n", actual + assert_equal " a = 1 ; b = 2 ; c = 3 ", result end - test "nested" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <% array = [1, 2, 3] %> + test "extract_ruby_different_lines_no_semicolons" do + source = "<% x = 1 %>\n<% y = 2 %>" + result = Herb.extract_ruby(source, with_semicolon: true) -
      - <% array.each do |item| %> -
    • <%= item %>
    • - <% end %> -
    - HTML + expected = " x = 1 \n y = 2 " + assert_equal expected, result + end - expected = " array = [1, 2, 3] ; \n\n \n array.each do |item| ; \n item ; \n end ; \n \n" + test "extract_ruby_mixed_lines" do + source = "<% a = 1 %> <% b = 2 %>\n<% c = 3 %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + expected = " a = 1 ; b = 2 \n c = 3 " + assert_equal expected, result end - test "erb comment" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <%# comment ' %> - HTML - - expected = " ; \n" + test "extract_ruby_output_tags_same_line" do + source = "<%= x %> <%= y %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + assert_equal " x ; y ", result end - test "erb comment with ruby keyword" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <%# end %> - HTML - - expected = " ; \n" + test "extract_ruby_empty_erb_same_line" do + source = "<% %> <% %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + assert_equal " ; ", result end - test "erb comment broken up over multiple lines" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <%# - end - %> - HTML + test "extract_ruby_comments_skipped" do + source = "<%# comment %> <% code %>" + result = Herb.extract_ruby(source, with_semicolon: true) - expected = " ; \n" + assert_equal " code ", result + end + + test "extract_ruby_issue_135_if_without_condition" do + source = "<% if %>\n<% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + expected = " if \n end " + assert_equal expected, result end - test "multi-line erb comment" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <%# - end - end - end - end - %> - HTML + test "extract_ruby_inline_comment_same_line" do + source = "<% if true %><% # Comment here %><% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) - expected = " ; \n" + assert_equal " if true ; end ", result + end - assert_equal expected, actual + test "extract_ruby_inline_comment_with_newline" do + source = "<% if true %><% # Comment here %>\n<% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) + + expected = " if true ; \n end " + assert_equal expected, result end - test "erb if/end and comment on same line" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <% if %><%# comment %><% end %> - HTML + test "extract_ruby_inline_comment_with_spaces" do + source = "<% # Comment %> <% code %>" + result = Herb.extract_ruby(source, with_semicolon: true) + + assert_equal " code ", result + end - expected = " if ; ; end ; \n" + test "extract_ruby_inline_comment_multiline" do + source = "<% # Comment\nmore %> <% code %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + expected = " # Comment\nmore ; code " + assert_equal expected, result end - xtest "erb if/end and Ruby comment on same line" do - actual = Herb.extract_ruby(<<~HTML, with_semicolon: true) - <% if %><% # comment %><% end %> - HTML + test "extract_ruby_inline_comment_between_code" do + source = "<% if true %><% # Comment here %><%= hello %><% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) + + assert_equal " if true ; hello ; end ", result + end - expected = " if # comment end \n" + test "extract_ruby_inline_comment_complex" do + source = "<% # Comment here %><% if true %><% # Comment here %><%= hello %><% end %>" + result = Herb.extract_ruby(source, with_semicolon: true) - assert_equal expected, actual + assert_equal " if true ; hello ; end ", result end end end From 7e64745c7ae91290b077d71e5c3a557a5ca792a3 Mon Sep 17 00:00:00 2001 From: Felipe Felix Date: Sun, 9 Nov 2025 19:22:34 -0300 Subject: [PATCH 06/10] rename keywordarg with_semicolumn to semicolons --- ext/herb/extension.c | 10 +++---- sig/herb_c_extension.rbs | 2 +- .../extractor/extract_ruby_semicolons_test.rb | 30 +++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 33bfd95ad..4a8912b20 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -96,19 +96,19 @@ static VALUE Herb_extract_ruby(int argc, VALUE* argv, VALUE self) { if (!hb_buffer_init(&output, strlen(string))) { return Qnil; } - bool with_semicolon = false; + bool semicolons = false; if (!NIL_P(options)) { - VALUE with_semicolon_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("with_semicolon")); + VALUE with_semicolon_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("semicolons")); if (NIL_P(with_semicolon_value)) { - with_semicolon_value = rb_hash_lookup(options, ID2SYM(rb_intern("with_semicolon"))); + with_semicolon_value = rb_hash_lookup(options, ID2SYM(rb_intern("semicolons"))); } if (!NIL_P(with_semicolon_value) && RTEST(with_semicolon_value)) { - with_semicolon = true; + semicolons = true; } } - if (with_semicolon) { + if (semicolons) { herb_extract_ruby_to_buffer_with_semicolons(string, &output); } else { herb_extract_ruby_to_buffer(string, &output); diff --git a/sig/herb_c_extension.rbs b/sig/herb_c_extension.rbs index 7ffd75ec1..e86810b53 100644 --- a/sig/herb_c_extension.rbs +++ b/sig/herb_c_extension.rbs @@ -4,6 +4,6 @@ module Herb def self.parse: (String input) -> ParseResult def self.lex: (String input) -> LexResult - def self.extract_ruby: (String source, ?with_semicolon: bool) -> String + def self.extract_ruby: (String source, ?semicolons: bool) -> String def self.extract_html: (String source) -> String end diff --git a/test/extractor/extract_ruby_semicolons_test.rb b/test/extractor/extract_ruby_semicolons_test.rb index ff12c9bc7..77c83b56a 100644 --- a/test/extractor/extract_ruby_semicolons_test.rb +++ b/test/extractor/extract_ruby_semicolons_test.rb @@ -6,7 +6,7 @@ module Extractor class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_single_erb_no_semicolon" do source = "<% if %>\n<% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " if \n end " assert_equal expected, result @@ -14,21 +14,21 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_multiple_erb_same_line_with_semicolon" do source = "<% x = 1 %> <% y = 2 %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " x = 1 ; y = 2 ", result end test "extract_ruby_three_erb_same_line_with_semicolons" do source = "<% a = 1 %> <% b = 2 %> <% c = 3 %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " a = 1 ; b = 2 ; c = 3 ", result end test "extract_ruby_different_lines_no_semicolons" do source = "<% x = 1 %>\n<% y = 2 %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " x = 1 \n y = 2 " assert_equal expected, result @@ -36,7 +36,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_mixed_lines" do source = "<% a = 1 %> <% b = 2 %>\n<% c = 3 %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " a = 1 ; b = 2 \n c = 3 " assert_equal expected, result @@ -44,28 +44,28 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_output_tags_same_line" do source = "<%= x %> <%= y %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " x ; y ", result end test "extract_ruby_empty_erb_same_line" do source = "<% %> <% %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " ; ", result end test "extract_ruby_comments_skipped" do source = "<%# comment %> <% code %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " code ", result end test "extract_ruby_issue_135_if_without_condition" do source = "<% if %>\n<% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " if \n end " assert_equal expected, result @@ -73,14 +73,14 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_inline_comment_same_line" do source = "<% if true %><% # Comment here %><% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " if true ; end ", result end test "extract_ruby_inline_comment_with_newline" do source = "<% if true %><% # Comment here %>\n<% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " if true ; \n end " assert_equal expected, result @@ -88,14 +88,14 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_inline_comment_with_spaces" do source = "<% # Comment %> <% code %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " code ", result end test "extract_ruby_inline_comment_multiline" do source = "<% # Comment\nmore %> <% code %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) expected = " # Comment\nmore ; code " assert_equal expected, result @@ -103,14 +103,14 @@ class ExtractRubySemicolonsTest < Minitest::Spec test "extract_ruby_inline_comment_between_code" do source = "<% if true %><% # Comment here %><%= hello %><% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " if true ; hello ; end ", result end test "extract_ruby_inline_comment_complex" do source = "<% # Comment here %><% if true %><% # Comment here %><%= hello %><% end %>" - result = Herb.extract_ruby(source, with_semicolon: true) + result = Herb.extract_ruby(source, semicolons: true) assert_equal " if true ; hello ; end ", result end From cb8536013082ce421a1bd62c8ca69ec9e2e9d836 Mon Sep 17 00:00:00 2001 From: Felipe Felix Date: Sun, 9 Nov 2025 20:13:37 -0300 Subject: [PATCH 07/10] apply clang-format --- ext/herb/extension.c | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 4a8912b20..60486d89c 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -103,9 +103,7 @@ static VALUE Herb_extract_ruby(int argc, VALUE* argv, VALUE self) { with_semicolon_value = rb_hash_lookup(options, ID2SYM(rb_intern("semicolons"))); } - if (!NIL_P(with_semicolon_value) && RTEST(with_semicolon_value)) { - semicolons = true; - } + if (!NIL_P(with_semicolon_value) && RTEST(with_semicolon_value)) { semicolons = true; } } if (semicolons) { From ec5e24a002a3fb20b504f99bd35f64d5da02dab4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 13 Feb 2026 17:43:24 +0100 Subject: [PATCH 08/10] Add `extract_ruby_options` --- docs/docs/bindings/java/reference.md | 97 +++++++++++++- docs/docs/bindings/javascript/reference.md | 62 ++++++++- docs/docs/bindings/ruby/reference.md | 52 +++++++- docs/docs/bindings/rust/reference.md | 121 ++++++++++++++++- ext/herb/extension.c | 26 ++-- java/herb_jni.c | 29 +++- java/herb_jni.h | 2 +- java/org/herb/ExtractRubyOptions.java | 40 ++++++ java/org/herb/Herb.java | 6 +- javascript/packages/core/src/backend.ts | 3 +- .../packages/core/src/extract-ruby-options.ts | 11 ++ javascript/packages/core/src/herb-backend.ts | 9 +- javascript/packages/core/src/index.ts | 1 + .../packages/node-wasm/test/node-wasm.test.ts | 24 ++++ javascript/packages/node/extension/herb.cpp | 43 +++++- javascript/packages/node/test/node.test.ts | 24 ++++ rust/build.rs | 5 + rust/src/ffi.rs | 3 +- rust/src/herb.rs | 53 ++++++-- rust/src/lib.rs | 4 +- rust/tests/extract_ruby_options_test.rs | 53 ++++++++ sig/herb_c_extension.rbs | 2 +- src/extract.c | 82 +++++++++--- src/include/extract.h | 15 +++ .../extractor/extract_ruby_semicolons_test.rb | 126 +++++++++++++++--- wasm/herb-wasm.cpp | 20 ++- 26 files changed, 838 insertions(+), 75 deletions(-) create mode 100644 java/org/herb/ExtractRubyOptions.java create mode 100644 javascript/packages/core/src/extract-ruby-options.ts create mode 100644 rust/tests/extract_ruby_options_test.rs diff --git a/docs/docs/bindings/java/reference.md b/docs/docs/bindings/java/reference.md index 3cc841892..4abcdda4c 100644 --- a/docs/docs/bindings/java/reference.md +++ b/docs/docs/bindings/java/reference.md @@ -14,6 +14,7 @@ The `Herb` class provides the following static methods: * `Herb.parse(source)` * `Herb.parse(source, options)` * `Herb.extractRuby(source)` +* `Herb.extractRuby(source, options)` * `Herb.extractHTML(source)` * `Herb.version()` * `Herb.herbVersion()` @@ -154,10 +155,104 @@ String source = "

    Hello <%= user.name %>

    "; String ruby = Herb.extractRuby(source); System.out.println(ruby); -// Output: " user.name " +// Output: " user.name ; " ``` ::: +### `Herb.extractRuby(String source, ExtractRubyOptions options)` + +Extract Ruby with custom options. + +#### Default behavior + +By default, the output is position-preserving with semicolons: + +:::code-group +```java +import org.herb.Herb; + +String source = "<% x = 1 %> <% y = 2 %>"; +String ruby = Herb.extractRuby(source); + +System.out.println(ruby); +// Output: " x = 1 ; y = 2 ;" +``` +::: + +#### Without semicolons + +:::code-group +```java +import org.herb.Herb; +import org.herb.ExtractRubyOptions; + +String source = "<% x = 1 %> <% y = 2 %>"; +ExtractRubyOptions options = ExtractRubyOptions.create().semicolons(false); +String ruby = Herb.extractRuby(source, options); + +System.out.println(ruby); +// Output: " x = 1 y = 2 " +``` +::: + +#### Including ERB comments + +:::code-group +```java +import org.herb.Herb; +import org.herb.ExtractRubyOptions; + +String source = "<%# comment %>\n<% code %>"; +ExtractRubyOptions options = ExtractRubyOptions.create().comments(true); +String ruby = Herb.extractRuby(source, options); + +System.out.println(ruby); +// Output: " # comment \n code ;" +``` +::: + +#### Without position preservation + +Use `preservePositions(false)` for readable output where each ERB tag is placed on its own line: + +:::code-group +```java +import org.herb.Herb; +import org.herb.ExtractRubyOptions; + +String source = "<%# comment %><%= something %>"; +ExtractRubyOptions options = ExtractRubyOptions.create().preservePositions(false).comments(true); +String ruby = Herb.extractRuby(source, options); + +System.out.println(ruby); +// Output: "# comment \n something " +``` +::: + +### `ExtractRubyOptions` + +The `ExtractRubyOptions` class provides fluent configuration: + +```java +public class ExtractRubyOptions { + public ExtractRubyOptions semicolons(boolean value); + public ExtractRubyOptions comments(boolean value); + public ExtractRubyOptions preservePositions(boolean value); + + public static ExtractRubyOptions create(); +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `semicolons` | `true` | Add ` ;` at the end of each ERB tag to separate statements | +| `comments` | `false` | Include ERB comments (`<%# %>`) in the output | +| `preservePositions` | `true` | Maintain character positions by padding with whitespace | + +> [!TIP] +> Use `preservePositions(false)` when you need readable Ruby output. +> Use `preservePositions(true)` (default) when you need accurate error position mapping. + ### `Herb.extractHTML(String source)` The `extractHTML` method extracts only the HTML parts of an HTML document with embedded Ruby. diff --git a/docs/docs/bindings/javascript/reference.md b/docs/docs/bindings/javascript/reference.md index cbf5361d4..80b5d558f 100644 --- a/docs/docs/bindings/javascript/reference.md +++ b/docs/docs/bindings/javascript/reference.md @@ -42,7 +42,7 @@ Learn more on [how to install and load the NPM packages](/bindings/javascript/#i - **`Herb.lexFile(path: string): LexResult`** - **`Herb.parse(source: string): ParseResult`** - **`Herb.parseFile(path: string): ParseResult`** -- **`Herb.extractRuby(source: string): string`** +- **`Herb.extractRuby(source: string, options?: ExtractRubyOptions): string`** - **`Herb.extractHTML(source: string): string`** - **`Herb.version: string`** @@ -134,7 +134,7 @@ console.log(result) Herb allows you to extract either Ruby or HTML from mixed content. -### `Herb.extractRuby(source)` +### `Herb.extractRuby(source, options?)` The `Herb.extractRuby` method allows you to extract only the Ruby parts of an HTML document with embedded Ruby. @@ -148,10 +148,66 @@ const source = "

    Hello <%= user.name %>

    " const ruby = Herb.extractRuby(source) console.log(ruby); -// Outputs: " user.name " +// Outputs: " user.name ; " ``` ::: +#### Options + +```typescript +interface ExtractRubyOptions { + semicolons?: boolean // default: true + comments?: boolean // default: false + preserve_positions?: boolean // default: true +} +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `semicolons` | `boolean` | `true` | Add ` ;` at the end of each ERB tag to separate statements | +| `comments` | `boolean` | `false` | Include ERB comments (`<%# %>`) in the output | +| `preserve_positions` | `boolean` | `true` | Maintain character positions by padding with whitespace | + +#### Examples + +**Default behavior** (position-preserving with semicolons): + +```js +const source = "<% x = 1 %> <% y = 2 %>" + +Herb.extractRuby(source) +// => " x = 1 ; y = 2 ;" +``` + +**Without semicolons:** + +```js +Herb.extractRuby(source, { semicolons: false }) +// => " x = 1 y = 2 " +``` + +**Including ERB comments:** + +```js +const source = "<%# comment %>\n<% code %>" + +Herb.extractRuby(source, { comments: true }) +// => " # comment \n code ;" +``` + +**Without position preservation** (readable output, each tag on its own line): + +```js +const source = "<%# comment %><%= something %>" + +Herb.extractRuby(source, { preserve_positions: false, comments: true }) +// => "# comment \n something " +``` + +> [!TIP] +> Use `preserve_positions: false` when you need readable Ruby output. +> Use `preserve_positions: true` (default) when you need accurate error position mapping. + ### `Herb.extractHTML(source)` The `Herb.extractHTML` method allows you to extract only the HTML parts of an HTML document with embedded Ruby. diff --git a/docs/docs/bindings/ruby/reference.md b/docs/docs/bindings/ruby/reference.md index 59b062018..b6546f0b9 100644 --- a/docs/docs/bindings/ruby/reference.md +++ b/docs/docs/bindings/ruby/reference.md @@ -118,7 +118,7 @@ Herb.parse_file("./index.html.erb").value ## Extracting Code -### `Herb.extract_ruby(source)` +### `Herb.extract_ruby(source, **options)` The `Herb.extract_ruby` method allows you to extract only the Ruby parts of an HTML document with embedded Ruby. @@ -127,10 +127,58 @@ The `Herb.extract_ruby` method allows you to extract only the Ruby parts of an H source = %(

    Hello <%= user.name %>

    ) Herb.extract_ruby(source) -# => " user.name " +# => " user.name ; " ``` ::: +#### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `semicolons` | `Boolean` | `true` | Add ` ;` at the end of each ERB tag to separate statements | +| `comments` | `Boolean` | `false` | Include ERB comments (`<%# %>`) in the output | +| `preserve_positions` | `Boolean` | `true` | Maintain character positions by padding with whitespace | + +#### Examples + +**Default behavior** (position-preserving with semicolons): + +```ruby +source = "<% x = 1 %> <% y = 2 %>" + +Herb.extract_ruby(source) +# => " x = 1 ; y = 2 ;" +``` + +**Without semicolons:** + +```ruby +Herb.extract_ruby(source, semicolons: false) +# => " x = 1 y = 2 " +``` + +**Including ERB comments:** + +```ruby +source = "<%# comment %>\n<% code %>" + +Herb.extract_ruby(source, comments: true) +# => " # comment \n code ;" +``` + +**Without position preservation** (readable output, each tag on its own line): + +```ruby +source = "<%# comment %><%= something %>" + +Herb.extract_ruby(source, preserve_positions: false, comments: true) +# => "# comment \n something " +``` + +> [!TIP] +> Use `preserve_positions: false` when you need readable Ruby output. +> Use `preserve_positions: true` (default) when you need accurate error position mapping. + ### `Herb.extract_html(source)` The `Herb.extract_html` method allows you to extract only the HTML parts of an HTML document with embedded Ruby. diff --git a/docs/docs/bindings/rust/reference.md b/docs/docs/bindings/rust/reference.md index 94d2c4c37..0bccfa2ab 100644 --- a/docs/docs/bindings/rust/reference.md +++ b/docs/docs/bindings/rust/reference.md @@ -12,7 +12,9 @@ The `herb` crate exposes functions for lexing, parsing, and extracting Ruby and * `herb::lex(source)` * `herb::parse(source)` +* `herb::parse_with_options(source, options)` * `herb::extract_ruby(source)` +* `herb::extract_ruby_with_options(source, options)` * `herb::extract_html(source)` * `herb::version()` * `herb::herb_version()` @@ -196,10 +198,127 @@ match extract_ruby(source) { Ok(ruby) => println!("{}", ruby), Err(e) => eprintln!("Error: {}", e), } -// Output: " user.name " +// Output: " user.name ; " ``` ::: +### `herb::extract_ruby_with_options(source: &str, options: &ExtractRubyOptions) -> Result` + +Extract Ruby with custom options. + +#### Default behavior + +By default, the output is position-preserving with semicolons: + +:::code-group +```rust +use herb::extract_ruby; + +let source = "<% x = 1 %> <% y = 2 %>"; + +match extract_ruby(source) { + Ok(ruby) => println!("{:?}", ruby), + Err(e) => eprintln!("Error: {}", e), +} +// Output: " x = 1 ; y = 2 ;" +``` +::: + +#### Without semicolons + +:::code-group +```rust +use herb::{extract_ruby_with_options, ExtractRubyOptions}; + +let source = "<% x = 1 %> <% y = 2 %>"; +let options = ExtractRubyOptions { + semicolons: false, + ..Default::default() +}; + +match extract_ruby_with_options(source, &options) { + Ok(ruby) => println!("{:?}", ruby), + Err(e) => eprintln!("Error: {}", e), +} +// Output: " x = 1 y = 2 " +``` +::: + +#### Including ERB comments + +:::code-group +```rust +use herb::{extract_ruby_with_options, ExtractRubyOptions}; + +let source = "<%# comment %>\n<% code %>"; +let options = ExtractRubyOptions { + comments: true, + ..Default::default() +}; + +match extract_ruby_with_options(source, &options) { + Ok(ruby) => println!("{:?}", ruby), + Err(e) => eprintln!("Error: {}", e), +} +// Output: " # comment \n code ;" +``` +::: + +#### Without position preservation + +Use `preserve_positions: false` for readable output where each ERB tag is placed on its own line: + +:::code-group +```rust +use herb::{extract_ruby_with_options, ExtractRubyOptions}; + +let source = "<%# comment %><%= something %>"; +let options = ExtractRubyOptions { + preserve_positions: false, + comments: true, + ..Default::default() +}; + +match extract_ruby_with_options(source, &options) { + Ok(ruby) => println!("{:?}", ruby), + Err(e) => eprintln!("Error: {}", e), +} +// Output: "# comment \n something " +``` +::: + +### `ExtractRubyOptions` + +The `ExtractRubyOptions` struct provides configuration for Ruby extraction: + +```rust +pub struct ExtractRubyOptions { + pub semicolons: bool, + pub comments: bool, + pub preserve_positions: bool, +} + +impl Default for ExtractRubyOptions { + fn default() -> Self { + Self { + semicolons: true, + comments: false, + preserve_positions: true, + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `semicolons` | `true` | Add `;` at the end of each ERB tag to separate statements | +| `comments` | `false` | Include ERB comments (`<%# %>`) in the output | +| `preserve_positions` | `true` | Maintain character positions by padding with whitespace | + +> [!TIP] +> Use `preserve_positions: false` when you need readable Ruby output. +> Use `preserve_positions: true` (default) when you need accurate error position mapping. + ### `herb::extract_html(source: &str) -> Result` The `extract_html` function extracts only the HTML parts of an HTML document with embedded Ruby. diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 962053a60..94f8b1bd6 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -89,22 +89,26 @@ static VALUE Herb_extract_ruby(int argc, VALUE* argv, VALUE self) { if (!hb_buffer_init(&output, strlen(string))) { return Qnil; } - bool semicolons = false; + herb_extract_ruby_options_T extract_options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + if (!NIL_P(options)) { - VALUE with_semicolon_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("semicolons")); - if (NIL_P(with_semicolon_value)) { - with_semicolon_value = rb_hash_lookup(options, ID2SYM(rb_intern("semicolons"))); - } + VALUE semicolons_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("semicolons")); + if (NIL_P(semicolons_value)) { semicolons_value = rb_hash_lookup(options, ID2SYM(rb_intern("semicolons"))); } + if (!NIL_P(semicolons_value)) { extract_options.semicolons = RTEST(semicolons_value); } - if (!NIL_P(with_semicolon_value) && RTEST(with_semicolon_value)) { semicolons = true; } - } + VALUE comments_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("comments")); + if (NIL_P(comments_value)) { comments_value = rb_hash_lookup(options, ID2SYM(rb_intern("comments"))); } + if (!NIL_P(comments_value)) { extract_options.comments = RTEST(comments_value); } - if (semicolons) { - herb_extract_ruby_to_buffer_with_semicolons(string, &output); - } else { - herb_extract_ruby_to_buffer(string, &output); + VALUE preserve_positions_value = rb_hash_lookup(options, rb_utf8_str_new_cstr("preserve_positions")); + if (NIL_P(preserve_positions_value)) { + preserve_positions_value = rb_hash_lookup(options, ID2SYM(rb_intern("preserve_positions"))); + } + if (!NIL_P(preserve_positions_value)) { extract_options.preserve_positions = RTEST(preserve_positions_value); } } + herb_extract_ruby_to_buffer_with_options(string, &output, &extract_options); + VALUE result = rb_utf8_str_new_cstr(output.value); free(output.value); diff --git a/java/herb_jni.c b/java/herb_jni.c index 0ecb3b766..8164093f5 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -1,6 +1,7 @@ #include "herb_jni.h" #include "extension_helpers.h" +#include "../../src/include/extract.h" #include "../../src/include/herb.h" #include "../../src/include/util/hb_buffer.h" @@ -77,7 +78,7 @@ Java_org_herb_Herb_lex(JNIEnv* env, jclass clazz, jstring source) { } JNIEXPORT jstring JNICALL -Java_org_herb_Herb_extractRuby(JNIEnv* env, jclass clazz, jstring source) { +Java_org_herb_Herb_extractRuby(JNIEnv* env, jclass clazz, jstring source, jobject options) { const char* src = (*env)->GetStringUTFChars(env, source, 0); hb_buffer_T output; @@ -88,7 +89,31 @@ Java_org_herb_Herb_extractRuby(JNIEnv* env, jclass clazz, jstring source) { return NULL; } - herb_extract_ruby_to_buffer(src, &output); + herb_extract_ruby_options_T extract_options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + + if (options != NULL) { + jclass optionsClass = (*env)->GetObjectClass(env, options); + + jmethodID getSemicolons = (*env)->GetMethodID(env, optionsClass, "isSemicolons", "()Z"); + if (getSemicolons != NULL) { + jboolean semicolons = (*env)->CallBooleanMethod(env, options, getSemicolons); + extract_options.semicolons = (semicolons == JNI_TRUE); + } + + jmethodID getComments = (*env)->GetMethodID(env, optionsClass, "isComments", "()Z"); + if (getComments != NULL) { + jboolean comments = (*env)->CallBooleanMethod(env, options, getComments); + extract_options.comments = (comments == JNI_TRUE); + } + + jmethodID getPreservePositions = (*env)->GetMethodID(env, optionsClass, "isPreservePositions", "()Z"); + if (getPreservePositions != NULL) { + jboolean preservePositions = (*env)->CallBooleanMethod(env, options, getPreservePositions); + extract_options.preserve_positions = (preservePositions == JNI_TRUE); + } + } + + herb_extract_ruby_to_buffer_with_options(src, &output, &extract_options); jstring result = (*env)->NewStringUTF(env, output.value); diff --git a/java/herb_jni.h b/java/herb_jni.h index 8895209cc..8dbee1ff0 100644 --- a/java/herb_jni.h +++ b/java/herb_jni.h @@ -11,7 +11,7 @@ JNIEXPORT jstring JNICALL Java_org_herb_Herb_herbVersion(JNIEnv*, jclass); JNIEXPORT jstring JNICALL Java_org_herb_Herb_prismVersion(JNIEnv*, jclass); JNIEXPORT jobject JNICALL Java_org_herb_Herb_parse(JNIEnv*, jclass, jstring, jobject); JNIEXPORT jobject JNICALL Java_org_herb_Herb_lex(JNIEnv*, jclass, jstring); -JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractRuby(JNIEnv*, jclass, jstring); +JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractRuby(JNIEnv*, jclass, jstring, jobject); JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractHTML(JNIEnv*, jclass, jstring); #ifdef __cplusplus diff --git a/java/org/herb/ExtractRubyOptions.java b/java/org/herb/ExtractRubyOptions.java new file mode 100644 index 000000000..966ed4bbb --- /dev/null +++ b/java/org/herb/ExtractRubyOptions.java @@ -0,0 +1,40 @@ +package org.herb; + +public class ExtractRubyOptions { + private boolean semicolons = true; + private boolean comments = false; + private boolean preservePositions = true; + + public ExtractRubyOptions() {} + + public ExtractRubyOptions semicolons(boolean value) { + this.semicolons = value; + return this; + } + + public boolean isSemicolons() { + return semicolons; + } + + public ExtractRubyOptions comments(boolean value) { + this.comments = value; + return this; + } + + public boolean isComments() { + return comments; + } + + public ExtractRubyOptions preservePositions(boolean value) { + this.preservePositions = value; + return this; + } + + public boolean isPreservePositions() { + return preservePositions; + } + + public static ExtractRubyOptions create() { + return new ExtractRubyOptions(); + } +} diff --git a/java/org/herb/Herb.java b/java/org/herb/Herb.java index e1a739449..2f9115924 100644 --- a/java/org/herb/Herb.java +++ b/java/org/herb/Herb.java @@ -18,13 +18,17 @@ public class Herb { public static native String prismVersion(); public static native ParseResult parse(String source, ParserOptions options); public static native LexResult lex(String source); - public static native String extractRuby(String source); + public static native String extractRuby(String source, ExtractRubyOptions options); public static native String extractHTML(String source); public static ParseResult parse(String source) { return parse(source, null); } + public static String extractRuby(String source) { + return extractRuby(source, null); + } + public static String version() { return String.format("herb java v%s, libprism v%s, libherb v%s (Java JNI)", herbVersion(), prismVersion(), herbVersion()); } diff --git a/javascript/packages/core/src/backend.ts b/javascript/packages/core/src/backend.ts index 587280adf..a00ca103d 100644 --- a/javascript/packages/core/src/backend.ts +++ b/javascript/packages/core/src/backend.ts @@ -1,6 +1,7 @@ import type { SerializedParseResult } from "./parse-result.js" import type { SerializedLexResult } from "./lex-result.js" import type { ParserOptions } from "./parser-options.js" +import type { ExtractRubyOptions } from "./extract-ruby-options.js" interface LibHerbBackendFunctions { lex: (source: string) => SerializedLexResult @@ -9,7 +10,7 @@ interface LibHerbBackendFunctions { parse: (source: string, options?: ParserOptions) => SerializedParseResult parseFile: (path: string) => SerializedParseResult - extractRuby: (source: string) => string + extractRuby: (source: string, options?: ExtractRubyOptions) => string extractHTML: (source: string) => string version: () => string diff --git a/javascript/packages/core/src/extract-ruby-options.ts b/javascript/packages/core/src/extract-ruby-options.ts new file mode 100644 index 000000000..224c81d46 --- /dev/null +++ b/javascript/packages/core/src/extract-ruby-options.ts @@ -0,0 +1,11 @@ +export interface ExtractRubyOptions { + semicolons?: boolean + comments?: boolean + preserve_positions?: boolean +} + +export const DEFAULT_EXTRACT_RUBY_OPTIONS: ExtractRubyOptions = { + semicolons: true, + comments: false, + preserve_positions: true, +} diff --git a/javascript/packages/core/src/herb-backend.ts b/javascript/packages/core/src/herb-backend.ts index 700c18301..a6a77c13d 100644 --- a/javascript/packages/core/src/herb-backend.ts +++ b/javascript/packages/core/src/herb-backend.ts @@ -4,9 +4,11 @@ import { ensureString } from "./util.js" import { LexResult } from "./lex-result.js" import { ParseResult } from "./parse-result.js" import { DEFAULT_PARSER_OPTIONS } from "./parser-options.js" +import { DEFAULT_EXTRACT_RUBY_OPTIONS } from "./extract-ruby-options.js" import type { LibHerbBackend, BackendPromise } from "./backend.js" import type { ParserOptions } from "./parser-options.js" +import type { ExtractRubyOptions } from "./extract-ruby-options.js" /** * The main Herb parser interface, providing methods to lex and parse input. @@ -93,13 +95,16 @@ export abstract class HerbBackend { /** * Extracts embedded Ruby code from the given source. * @param source - The source code to extract Ruby from. + * @param options - Optional extraction options. * @returns The extracted Ruby code as a string. * @throws Error if the backend is not loaded. */ - extractRuby(source: string): string { + extractRuby(source: string, options?: ExtractRubyOptions): string { this.ensureBackend() - return this.backend.extractRuby(ensureString(source)) + const mergedOptions = { ...DEFAULT_EXTRACT_RUBY_OPTIONS, ...options } + + return this.backend.extractRuby(ensureString(source), mergedOptions) } /** diff --git a/javascript/packages/core/src/index.ts b/javascript/packages/core/src/index.ts index cd7125815..b8c2d488f 100644 --- a/javascript/packages/core/src/index.ts +++ b/javascript/packages/core/src/index.ts @@ -3,6 +3,7 @@ export * from "./backend.js" export * from "./diagnostic.js" export * from "./didyoumean.js" export * from "./errors.js" +export * from "./extract-ruby-options.js" export * from "./herb-backend.js" export * from "./levenshtein.js" export * from "./lex-result.js" diff --git a/javascript/packages/node-wasm/test/node-wasm.test.ts b/javascript/packages/node-wasm/test/node-wasm.test.ts index ab2ec3250..5732bf6b2 100644 --- a/javascript/packages/node-wasm/test/node-wasm.test.ts +++ b/javascript/packages/node-wasm/test/node-wasm.test.ts @@ -40,6 +40,30 @@ describe("@herb-tools/node-wasm", () => { expect(ruby).toBe(' "Hello World" ; ') }) + test("extractRuby() with semicolons: false", async () => { + const source = "<% x = 1 %> <% y = 2 %>" + const ruby = Herb.extractRuby(source, { semicolons: false }) + expect(ruby).toBe(" x = 1 y = 2 ") + }) + + test("extractRuby() with comments: true", async () => { + const source = "<%# comment %>\n<% code %>" + const ruby = Herb.extractRuby(source, { comments: true }) + expect(ruby).toBe(" # comment \n code ;") + }) + + test("extractRuby() with preserve_positions: false", async () => { + const source = "<% x = 1 %> <% y = 2 %>" + const ruby = Herb.extractRuby(source, { preserve_positions: false }) + expect(ruby).toBe(" x = 1 \n y = 2 ") + }) + + test("extractRuby() with preserve_positions: false and comments: true", async () => { + const source = "<%# comment %><%= something %>" + const ruby = Herb.extractRuby(source, { preserve_positions: false, comments: true }) + expect(ruby).toBe("# comment \n something ") + }) + test("extractHTML() extracts HTML content", async () => { const simpleHtml = '
    <%= "Hello World" %>
    ' const html = Herb.extractHTML(simpleHtml) diff --git a/javascript/packages/node/extension/herb.cpp b/javascript/packages/node/extension/herb.cpp index 207aaa691..65c2a6080 100644 --- a/javascript/packages/node/extension/herb.cpp +++ b/javascript/packages/node/extension/herb.cpp @@ -1,5 +1,6 @@ extern "C" { #include "../extension/libherb/include/ast_nodes.h" +#include "../extension/libherb/include/extract.h" #include "../extension/libherb/include/herb.h" #include "../extension/libherb/include/location.h" #include "../extension/libherb/include/range.h" @@ -153,8 +154,8 @@ napi_value Herb_parse_file(napi_env env, napi_callback_info info) { } napi_value Herb_extract_ruby(napi_env env, napi_callback_info info) { - size_t argc = 1; - napi_value args[1]; + size_t argc = 2; + napi_value args[2]; napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); if (argc < 1) { @@ -172,7 +173,43 @@ napi_value Herb_extract_ruby(napi_env env, napi_callback_info info) { return nullptr; } - herb_extract_ruby_to_buffer(string, &output); + herb_extract_ruby_options_T extract_options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + + if (argc >= 2) { + napi_valuetype valuetype; + napi_typeof(env, args[1], &valuetype); + + if (valuetype == napi_object) { + napi_value prop; + bool has_prop; + + napi_has_named_property(env, args[1], "semicolons", &has_prop); + if (has_prop) { + napi_get_named_property(env, args[1], "semicolons", &prop); + bool value; + napi_get_value_bool(env, prop, &value); + extract_options.semicolons = value; + } + + napi_has_named_property(env, args[1], "comments", &has_prop); + if (has_prop) { + napi_get_named_property(env, args[1], "comments", &prop); + bool value; + napi_get_value_bool(env, prop, &value); + extract_options.comments = value; + } + + napi_has_named_property(env, args[1], "preserve_positions", &has_prop); + if (has_prop) { + napi_get_named_property(env, args[1], "preserve_positions", &prop); + bool value; + napi_get_value_bool(env, prop, &value); + extract_options.preserve_positions = value; + } + } + } + + herb_extract_ruby_to_buffer_with_options(string, &output, &extract_options); napi_value result; napi_create_string_utf8(env, output.value, NAPI_AUTO_LENGTH, &result); diff --git a/javascript/packages/node/test/node.test.ts b/javascript/packages/node/test/node.test.ts index 496761e34..fe3fdd6dc 100644 --- a/javascript/packages/node/test/node.test.ts +++ b/javascript/packages/node/test/node.test.ts @@ -38,6 +38,30 @@ describe("@herb-tools/node", () => { expect(ruby).toBe(' "Hello World" ; ') }) + test("extractRuby() with semicolons: false", async () => { + const source = "<% x = 1 %> <% y = 2 %>" + const ruby = Herb.extractRuby(source, { semicolons: false }) + expect(ruby).toBe(" x = 1 y = 2 ") + }) + + test("extractRuby() with comments: true", async () => { + const source = "<%# comment %>\n<% code %>" + const ruby = Herb.extractRuby(source, { comments: true }) + expect(ruby).toBe(" # comment \n code ;") + }) + + test("extractRuby() with preserve_positions: false", async () => { + const source = "<% x = 1 %> <% y = 2 %>" + const ruby = Herb.extractRuby(source, { preserve_positions: false }) + expect(ruby).toBe(" x = 1 \n y = 2 ") + }) + + test("extractRuby() with preserve_positions: false and comments: true", async () => { + const source = "<%# comment %><%= something %>" + const ruby = Herb.extractRuby(source, { preserve_positions: false, comments: true }) + expect(ruby).toBe("# comment \n something ") + }) + test("extractHTML() extracts HTML content", async () => { const simpleHtml = '
    <%= "Hello World" %>
    ' const html = Herb.extractHTML(simpleHtml) diff --git a/rust/build.rs b/rust/build.rs index 19f65cc1f..0c4cd0cb0 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -81,13 +81,16 @@ fn main() { .header(include_dir.join("ast_nodes.h").to_str().unwrap()) .header(include_dir.join("errors.h").to_str().unwrap()) .header(include_dir.join("element_source.h").to_str().unwrap()) + .header(include_dir.join("extract.h").to_str().unwrap()) .header(include_dir.join("token_struct.h").to_str().unwrap()) .header(include_dir.join("util/hb_string.h").to_str().unwrap()) .header(include_dir.join("util/hb_array.h").to_str().unwrap()) + .header(include_dir.join("util/hb_buffer.h").to_str().unwrap()) .clang_arg(format!("-I{}", include_dir.display())) .clang_arg(format!("-I{}", prism_include.display())) .allowlist_function("herb_.*") .allowlist_function("hb_array_.*") + .allowlist_function("hb_buffer_.*") .allowlist_function("token_type_to_string") .allowlist_function("ast_node_free") .allowlist_function("element_source_to_string") @@ -98,11 +101,13 @@ fn main() { .allowlist_type("ast_node_type_T") .allowlist_type("error_type_T") .allowlist_type("hb_array_T") + .allowlist_type("hb_buffer_T") .allowlist_type("hb_string_T") .allowlist_type("token_T") .allowlist_type("position_T") .allowlist_type("location_T") .allowlist_type("herb_extract_language_T") + .allowlist_type("herb_extract_ruby_options_T") .allowlist_type("parser_options_T") .allowlist_var("AST_.*") .allowlist_var("ERROR_.*") diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 5a2c98e50..191f7a9ba 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -1,4 +1,5 @@ pub use crate::bindings::{ - ast_node_free, element_source_to_string, hb_array_get, hb_array_size, hb_string_T, herb_extract, + ast_node_free, element_source_to_string, hb_array_get, hb_array_size, hb_buffer_init, + hb_buffer_value, hb_string_T, herb_extract, herb_extract_ruby_to_buffer_with_options, herb_free_tokens, herb_lex, herb_parse, herb_prism_version, herb_version, token_type_to_string, }; diff --git a/rust/src/herb.rs b/rust/src/herb.rs index baa09d070..fc4a88860 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -1,4 +1,4 @@ -use crate::bindings::{hb_array_T, token_T}; +use crate::bindings::{hb_array_T, hb_buffer_T, token_T}; use crate::convert::token_from_c; use crate::{LexResult, ParseResult}; use std::ffi::CString; @@ -18,6 +18,23 @@ impl Default for ParserOptions { } } +#[derive(Debug, Clone)] +pub struct ExtractRubyOptions { + pub semicolons: bool, + pub comments: bool, + pub preserve_positions: bool, +} + +impl Default for ExtractRubyOptions { + fn default() -> Self { + Self { + semicolons: true, + comments: false, + preserve_positions: true, + } + } +} + pub fn lex(source: &str) -> Result { unsafe { let c_source = CString::new(source).map_err(|e| e.to_string())?; @@ -76,21 +93,39 @@ pub fn parse_with_options(source: &str, options: &ParserOptions) -> Result Result { + extract_ruby_with_options(source, &ExtractRubyOptions::default()) +} + +pub fn extract_ruby_with_options( + source: &str, + options: &ExtractRubyOptions, +) -> Result { unsafe { let c_source = CString::new(source).map_err(|e| e.to_string())?; - let result = crate::ffi::herb_extract( - c_source.as_ptr(), - crate::bindings::HERB_EXTRACT_LANGUAGE_RUBY, - ); - if result.is_null() { - return Ok(String::new()); + let mut output: hb_buffer_T = std::mem::zeroed(); + let init_result = crate::ffi::hb_buffer_init(&mut output, source.len()); + + if !init_result { + return Err("Failed to initialize buffer".to_string()); } - let c_str = std::ffi::CStr::from_ptr(result); + let c_options = crate::bindings::herb_extract_ruby_options_T { + semicolons: options.semicolons, + comments: options.comments, + preserve_positions: options.preserve_positions, + }; + + crate::ffi::herb_extract_ruby_to_buffer_with_options( + c_source.as_ptr(), + &mut output, + &c_options, + ); + + let c_str = std::ffi::CStr::from_ptr(crate::ffi::hb_buffer_value(&output)); let rust_str = c_str.to_string_lossy().into_owned(); - libc::free(result as *mut std::ffi::c_void); + libc::free(output.value as *mut std::ffi::c_void); Ok(rust_str) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b6d044d6d..1f5e2e225 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,8 +15,8 @@ pub mod union_types; pub use errors::{AnyError, ErrorNode, ErrorType}; pub use herb::{ - extract_html, extract_ruby, herb_version, lex, parse, parse_with_options, prism_version, version, - ParserOptions, + extract_html, extract_ruby, extract_ruby_with_options, herb_version, lex, parse, + parse_with_options, prism_version, version, ExtractRubyOptions, ParserOptions, }; pub use lex_result::LexResult; pub use location::Location; diff --git a/rust/tests/extract_ruby_options_test.rs b/rust/tests/extract_ruby_options_test.rs new file mode 100644 index 000000000..b4463e9e9 --- /dev/null +++ b/rust/tests/extract_ruby_options_test.rs @@ -0,0 +1,53 @@ +use herb::{extract_ruby, extract_ruby_with_options, ExtractRubyOptions}; + +#[test] +fn test_extract_ruby_default() { + let source = "<% x = 1 %> <% y = 2 %>"; + let result = extract_ruby(source).unwrap(); + assert_eq!(result, " x = 1 ; y = 2 ;"); +} + +#[test] +fn test_extract_ruby_without_semicolons() { + let source = "<% x = 1 %> <% y = 2 %>"; + let options = ExtractRubyOptions { + semicolons: false, + ..Default::default() + }; + let result = extract_ruby_with_options(source, &options).unwrap(); + assert_eq!(result, " x = 1 y = 2 "); +} + +#[test] +fn test_extract_ruby_with_comments() { + let source = "<%# comment %>\n<% code %>"; + let options = ExtractRubyOptions { + comments: true, + ..Default::default() + }; + let result = extract_ruby_with_options(source, &options).unwrap(); + assert_eq!(result, " # comment \n code ;"); +} + +#[test] +fn test_extract_ruby_without_preserve_positions() { + let source = "<% x = 1 %> <% y = 2 %>"; + let options = ExtractRubyOptions { + preserve_positions: false, + ..Default::default() + }; + let result = extract_ruby_with_options(source, &options).unwrap(); + assert_eq!(result, " x = 1 \n y = 2 "); +} + +#[test] +fn test_extract_ruby_without_preserve_positions_with_comments() { + let source = "<%# comment %><%= something %>"; + let options = ExtractRubyOptions { + preserve_positions: false, + comments: true, + ..Default::default() + }; + let result = extract_ruby_with_options(source, &options).unwrap(); + assert_eq!(result, "# comment \n something "); +} diff --git a/sig/herb_c_extension.rbs b/sig/herb_c_extension.rbs index 5447a004f..fed3b0471 100644 --- a/sig/herb_c_extension.rbs +++ b/sig/herb_c_extension.rbs @@ -4,6 +4,6 @@ module Herb def self.parse: (String input, ?track_whitespace: bool) -> ParseResult def self.lex: (String input) -> LexResult - def self.extract_ruby: (String source, ?semicolons: bool) -> String + def self.extract_ruby: (String source, ?semicolons: bool, ?comments: bool, ?preserve_positions: bool) -> String def self.extract_html: (String source) -> String end diff --git a/src/extract.c b/src/extract.c index 0b322017d..1fc715bd0 100644 --- a/src/extract.c +++ b/src/extract.c @@ -9,10 +9,24 @@ #include #include -void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { +const herb_extract_ruby_options_T HERB_EXTRACT_RUBY_DEFAULT_OPTIONS = { + .semicolons = true, + .comments = false, + .preserve_positions = true +}; + +void herb_extract_ruby_to_buffer_with_options( + const char* source, + hb_buffer_T* output, + const herb_extract_ruby_options_T* options +) { + herb_extract_ruby_options_T extract_options = options ? *options : HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + hb_array_T* tokens = herb_lex(source); bool skip_erb_content = false; bool is_comment_tag = false; + bool is_erb_comment_tag = false; + bool need_newline = false; for (size_t i = 0; i < hb_array_size(tokens); i++) { const token_T* token = hb_array_get(tokens, i); @@ -20,23 +34,48 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { switch (token->type) { case TOKEN_NEWLINE: { hb_buffer_append(output, token->value); + need_newline = false; break; } case TOKEN_ERB_START: { - if (string_equals(token->value, "<%#")) { - skip_erb_content = true; - is_comment_tag = true; + is_erb_comment_tag = string_equals(token->value, "<%#"); + + if (is_erb_comment_tag) { + if (extract_options.comments) { + skip_erb_content = false; + is_comment_tag = false; + + if (extract_options.preserve_positions) { + hb_buffer_append_whitespace(output, 2); + hb_buffer_append_char(output, '#'); + } else { + if (need_newline) { hb_buffer_append_char(output, '\n'); } + hb_buffer_append_char(output, '#'); + need_newline = true; + } + } else { + skip_erb_content = true; + is_comment_tag = true; + if (extract_options.preserve_positions) { hb_buffer_append_whitespace(output, range_length(token->range)); } + } } else if (string_equals(token->value, "<%%") || string_equals(token->value, "<%%=") || string_equals(token->value, "<%graphql")) { skip_erb_content = true; is_comment_tag = false; + if (extract_options.preserve_positions) { hb_buffer_append_whitespace(output, range_length(token->range)); } } else { skip_erb_content = false; is_comment_tag = false; + + if (extract_options.preserve_positions) { + hb_buffer_append_whitespace(output, range_length(token->range)); + } else if (need_newline) { + hb_buffer_append_char(output, '\n'); + need_newline = false; + } } - hb_buffer_append_whitespace(output, range_length(token->range)); break; } @@ -44,7 +83,7 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { if (skip_erb_content == false) { bool is_inline_comment = false; - if (!is_comment_tag && token->value != NULL) { + if (!extract_options.comments && !is_comment_tag && token->value != NULL) { const char* content = token->value; while (*content == ' ' || *content == '\t') { @@ -58,12 +97,13 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { } if (is_inline_comment) { - hb_buffer_append_whitespace(output, range_length(token->range)); + if (extract_options.preserve_positions) { hb_buffer_append_whitespace(output, range_length(token->range)); } } else { hb_buffer_append(output, token->value); + if (!extract_options.preserve_positions) { need_newline = true; } } } else { - hb_buffer_append_whitespace(output, range_length(token->range)); + if (extract_options.preserve_positions) { hb_buffer_append_whitespace(output, range_length(token->range)); } } break; @@ -71,22 +111,30 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { case TOKEN_ERB_END: { bool was_comment = is_comment_tag; + bool was_erb_comment = is_erb_comment_tag; skip_erb_content = false; is_comment_tag = false; + is_erb_comment_tag = false; - if (was_comment) { - hb_buffer_append_whitespace(output, range_length(token->range)); - break; + if (extract_options.preserve_positions) { + if (was_comment) { + hb_buffer_append_whitespace(output, range_length(token->range)); + } else if (was_erb_comment && extract_options.comments) { + hb_buffer_append_whitespace(output, range_length(token->range)); + } else if (extract_options.semicolons) { + hb_buffer_append_char(output, ' '); + hb_buffer_append_char(output, ';'); + hb_buffer_append_whitespace(output, range_length(token->range) - 2); + } else { + hb_buffer_append_whitespace(output, range_length(token->range)); + } } - hb_buffer_append_char(output, ' '); - hb_buffer_append_char(output, ';'); - hb_buffer_append_whitespace(output, range_length(token->range) - 2); break; } default: { - hb_buffer_append_whitespace(output, range_length(token->range)); + if (extract_options.preserve_positions) { hb_buffer_append_whitespace(output, range_length(token->range)); } } } } @@ -94,6 +142,10 @@ void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { herb_free_tokens(&tokens); } +void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output) { + herb_extract_ruby_to_buffer_with_options(source, output, NULL); +} + void herb_extract_html_to_buffer(const char* source, hb_buffer_T* output) { hb_array_T* tokens = herb_lex(source); diff --git a/src/include/extract.h b/src/include/extract.h index 36f0b5028..0adf4e6d2 100644 --- a/src/include/extract.h +++ b/src/include/extract.h @@ -3,11 +3,26 @@ #include "util/hb_buffer.h" +#include + typedef enum { HERB_EXTRACT_LANGUAGE_RUBY, HERB_EXTRACT_LANGUAGE_HTML, } herb_extract_language_T; +typedef struct { + bool semicolons; + bool comments; + bool preserve_positions; +} herb_extract_ruby_options_T; + +extern const herb_extract_ruby_options_T HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + +void herb_extract_ruby_to_buffer_with_options( + const char* source, + hb_buffer_T* output, + const herb_extract_ruby_options_T* options +); void herb_extract_ruby_to_buffer(const char* source, hb_buffer_T* output); void herb_extract_html_to_buffer(const char* source, hb_buffer_T* output); diff --git a/test/extractor/extract_ruby_semicolons_test.rb b/test/extractor/extract_ruby_semicolons_test.rb index 77c83b56a..d4cfc6d33 100644 --- a/test/extractor/extract_ruby_semicolons_test.rb +++ b/test/extractor/extract_ruby_semicolons_test.rb @@ -4,11 +4,11 @@ module Extractor class ExtractRubySemicolonsTest < Minitest::Spec - test "extract_ruby_single_erb_no_semicolon" do + test "extract_ruby_single_erb_with_semicolon" do source = "<% if %>\n<% end %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " if \n end " + expected = " if ;\n end ;" assert_equal expected, result end @@ -16,21 +16,21 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<% x = 1 %> <% y = 2 %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " x = 1 ; y = 2 ", result + assert_equal " x = 1 ; y = 2 ;", result end test "extract_ruby_three_erb_same_line_with_semicolons" do source = "<% a = 1 %> <% b = 2 %> <% c = 3 %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " a = 1 ; b = 2 ; c = 3 ", result + assert_equal " a = 1 ; b = 2 ; c = 3 ;", result end - test "extract_ruby_different_lines_no_semicolons" do + test "extract_ruby_different_lines_with_semicolons" do source = "<% x = 1 %>\n<% y = 2 %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " x = 1 \n y = 2 " + expected = " x = 1 ;\n y = 2 ;" assert_equal expected, result end @@ -38,7 +38,7 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<% a = 1 %> <% b = 2 %>\n<% c = 3 %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " a = 1 ; b = 2 \n c = 3 " + expected = " a = 1 ; b = 2 ;\n c = 3 ;" assert_equal expected, result end @@ -46,28 +46,28 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<%= x %> <%= y %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " x ; y ", result + assert_equal " x ; y ;", result end test "extract_ruby_empty_erb_same_line" do source = "<% %> <% %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " ; ", result + assert_equal " ; ;", result end test "extract_ruby_comments_skipped" do source = "<%# comment %> <% code %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " code ", result + assert_equal " code ;", result end test "extract_ruby_issue_135_if_without_condition" do source = "<% if %>\n<% end %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " if \n end " + expected = " if ;\n end ;" assert_equal expected, result end @@ -75,14 +75,14 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<% if true %><% # Comment here %><% end %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " if true ; end ", result + assert_equal " if true ; end ;", result end test "extract_ruby_inline_comment_with_newline" do source = "<% if true %><% # Comment here %>\n<% end %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " if true ; \n end " + expected = " if true ; \n end ;" assert_equal expected, result end @@ -90,14 +90,14 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<% # Comment %> <% code %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " code ", result + assert_equal " code ;", result end test "extract_ruby_inline_comment_multiline" do source = "<% # Comment\nmore %> <% code %>" result = Herb.extract_ruby(source, semicolons: true) - expected = " # Comment\nmore ; code " + expected = " # Comment\nmore ; code ;" assert_equal expected, result end @@ -105,14 +105,106 @@ class ExtractRubySemicolonsTest < Minitest::Spec source = "<% if true %><% # Comment here %><%= hello %><% end %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " if true ; hello ; end ", result + assert_equal " if true ; hello ; end ;", result end test "extract_ruby_inline_comment_complex" do source = "<% # Comment here %><% if true %><% # Comment here %><%= hello %><% end %>" result = Herb.extract_ruby(source, semicolons: true) - assert_equal " if true ; hello ; end ", result + assert_equal " if true ; hello ; end ;", result + end + + test "extract_ruby_without_semicolons" do + source = "<% x = 1 %> <% y = 2 %>" + result = Herb.extract_ruby(source, semicolons: false) + + assert_equal " x = 1 y = 2 ", result + end + + test "extract_ruby_without_semicolons_multiline" do + source = "<% if %>\n<% end %>" + result = Herb.extract_ruby(source, semicolons: false) + + expected = " if \n end " + assert_equal expected, result + end + + test "extract_ruby_with_comments_included" do + source = "<%# comment %>" + result = Herb.extract_ruby(source, comments: true) + + assert_equal " # comment ", result + end + + test "extract_ruby_with_comments_and_code_same_line" do + source = "<%# comment %> <% code %>" + result = Herb.extract_ruby(source, comments: true) + + assert_equal " # comment code ;", result + end + + test "extract_ruby_with_comments_on_separate_lines" do + source = "<%# comment %>\n<% code %>" + result = Herb.extract_ruby(source, comments: true) + + assert_equal " # comment \n code ;", result + end + + test "extract_ruby_without_semicolons_with_comments" do + source = "<%# comment %> <% code %>" + result = Herb.extract_ruby(source, semicolons: false, comments: true) + + assert_equal " # comment code ", result + end + + test "extract_ruby_without_preserve_positions_single_tag" do + source = "<% code %>" + result = Herb.extract_ruby(source, preserve_positions: false) + + assert_equal " code ", result + end + + test "extract_ruby_without_preserve_positions_multiple_tags_same_line" do + source = "<% x = 1 %> <% y = 2 %>" + result = Herb.extract_ruby(source, preserve_positions: false) + + assert_equal " x = 1 \n y = 2 ", result + end + + test "extract_ruby_without_preserve_positions_with_html" do + source = "
    <% code %>
    " + result = Herb.extract_ruby(source, preserve_positions: false) + + assert_equal " code ", result + end + + test "extract_ruby_without_preserve_positions_multiline" do + source = "<% if true %>\n <% x = 1 %>\n<% end %>" + result = Herb.extract_ruby(source, preserve_positions: false) + + assert_equal " if true \n x = 1 \n end ", result + end + + test "extract_ruby_without_preserve_positions_with_comments" do + source = "<%# comment %><%= something_else %>" + result = Herb.extract_ruby(source, preserve_positions: false, comments: true) + + assert_equal "# comment \n something_else ", result + end + + test "extract_ruby_without_preserve_positions_comments_dont_affect_code" do + source = "<%# this is a comment %><% code %>" + result = Herb.extract_ruby(source, preserve_positions: false, comments: true) + + assert_equal "# this is a comment \n code ", result + end + + test "extract_ruby_without_preserve_positions_skips_comments_by_default" do + source = "<%# comment %><% code %>" + result = Herb.extract_ruby(source, preserve_positions: false) + + assert_equal " code ", result end end end diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index 261083e6c..d778fef17 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -61,11 +61,27 @@ val Herb_parse(const std::string& source, val options) { return result; } -std::string Herb_extract_ruby(const std::string& source) { +std::string Herb_extract_ruby(const std::string& source, val options) { hb_buffer_T output; hb_buffer_init(&output, source.length()); - herb_extract_ruby_to_buffer(source.c_str(), &output); + herb_extract_ruby_options_T extract_options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + + if (!options.isUndefined() && !options.isNull() && options.typeOf().as() == "object") { + if (options.hasOwnProperty("semicolons")) { + extract_options.semicolons = options["semicolons"].as(); + } + + if (options.hasOwnProperty("comments")) { + extract_options.comments = options["comments"].as(); + } + + if (options.hasOwnProperty("preserve_positions")) { + extract_options.preserve_positions = options["preserve_positions"].as(); + } + } + + herb_extract_ruby_to_buffer_with_options(source.c_str(), &output, &extract_options); std::string result(hb_buffer_value(&output)); free(output.value); return result; From 2d9c6dc212ef6c774eb4b73e24f52f2cf9d01255 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 13 Feb 2026 17:48:25 +0100 Subject: [PATCH 09/10] Format --- src/extract.c | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/extract.c b/src/extract.c index 1fc715bd0..3032e0a56 100644 --- a/src/extract.c +++ b/src/extract.c @@ -9,11 +9,9 @@ #include #include -const herb_extract_ruby_options_T HERB_EXTRACT_RUBY_DEFAULT_OPTIONS = { - .semicolons = true, - .comments = false, - .preserve_positions = true -}; +const herb_extract_ruby_options_T HERB_EXTRACT_RUBY_DEFAULT_OPTIONS = { .semicolons = true, + .comments = false, + .preserve_positions = true }; void herb_extract_ruby_to_buffer_with_options( const char* source, From 7d8f3d7c90697f9009215bb2ea5b4e2dea905467 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 13 Feb 2026 17:50:16 +0100 Subject: [PATCH 10/10] Add C tests --- test/c/test_extract.c | 86 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/test/c/test_extract.c b/test/c/test_extract.c index 0d268f01b..061a6ba17 100644 --- a/test/c/test_extract.c +++ b/test/c/test_extract.c @@ -1,9 +1,10 @@ #include "include/test.h" -#include "../../src/include/herb.h" #include "../../src/include/extract.h" #include "../../src/include/util/hb_buffer.h" +#include + TEST(extract_ruby_single_erb_with_semicolons) char* source = "<% if %>\n<% end %>"; char* result = herb_extract_ruby_with_semicolons(source); @@ -145,6 +146,84 @@ TEST(extract_ruby_inline_comment_complex) free(result); END +TEST(extract_ruby_with_options_semicolons_false) + char* source = "<% x = 1 %> <% y = 2 %>"; + + hb_buffer_T output; + hb_buffer_init(&output, strlen(source)); + + herb_extract_ruby_options_T options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + options.semicolons = false; + + herb_extract_ruby_to_buffer_with_options(source, &output, &options); + + ck_assert_str_eq(output.value, " x = 1 y = 2 "); + + free(output.value); +END + +TEST(extract_ruby_with_options_comments_true) + char* source = "<%# comment %>\n<% code %>"; + + hb_buffer_T output; + hb_buffer_init(&output, strlen(source)); + + herb_extract_ruby_options_T options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + options.comments = true; + + herb_extract_ruby_to_buffer_with_options(source, &output, &options); + + ck_assert_str_eq(output.value, " # comment \n code ;"); + + free(output.value); +END + +TEST(extract_ruby_with_options_preserve_positions_false) + char* source = "<% x = 1 %> <% y = 2 %>"; + + hb_buffer_T output; + hb_buffer_init(&output, strlen(source)); + + herb_extract_ruby_options_T options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + options.preserve_positions = false; + + herb_extract_ruby_to_buffer_with_options(source, &output, &options); + + ck_assert_str_eq(output.value, " x = 1 \n y = 2 "); + + free(output.value); +END + +TEST(extract_ruby_with_options_preserve_positions_false_and_comments_true) + char* source = "<%# comment %><%= something %>"; + + hb_buffer_T output; + hb_buffer_init(&output, strlen(source)); + + herb_extract_ruby_options_T options = HERB_EXTRACT_RUBY_DEFAULT_OPTIONS; + options.preserve_positions = false; + options.comments = true; + + herb_extract_ruby_to_buffer_with_options(source, &output, &options); + + ck_assert_str_eq(output.value, "# comment \n something "); + + free(output.value); +END + +TEST(extract_ruby_with_options_default) + char* source = "<% x = 1 %> <% y = 2 %>"; + + hb_buffer_T output; + hb_buffer_init(&output, strlen(source)); + + herb_extract_ruby_to_buffer_with_options(source, &output, &HERB_EXTRACT_RUBY_DEFAULT_OPTIONS); + + ck_assert_str_eq(output.value, " x = 1 ; y = 2 ;"); + + free(output.value); +END + TCase *extract_tests(void) { TCase *extract = tcase_create("Extract"); @@ -163,6 +242,11 @@ TCase *extract_tests(void) { tcase_add_test(extract, extract_ruby_inline_comment_multiline); tcase_add_test(extract, extract_ruby_inline_comment_between_code); tcase_add_test(extract, extract_ruby_inline_comment_complex); + tcase_add_test(extract, extract_ruby_with_options_semicolons_false); + tcase_add_test(extract, extract_ruby_with_options_comments_true); + tcase_add_test(extract, extract_ruby_with_options_preserve_positions_false); + tcase_add_test(extract, extract_ruby_with_options_preserve_positions_false_and_comments_true); + tcase_add_test(extract, extract_ruby_with_options_default); return extract; }