diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44738f8..74485c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,10 +194,14 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true + # spec:standalone compiles first: on JRuby that builds the Java escaper + # jar (ext/java), on TruffleRuby extconf.rb no-ops (pure Ruby is faster). - name: RSpec (spec/gem without Rails) run: bundle exec rake spec:standalone + # On JRuby this builds and renders from the -java gem (jar inside); on + # TruffleRuby the pure-Ruby gem. - name: Packaged gem installs and renders - run: ruby bin/gem-smoke + run: bundle exec ruby bin/gem-smoke rails-versions: name: Matrix / Rails versions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa8ac3b..6b21f73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,21 +78,33 @@ jobs: run: bundle exec rake smoke jruby: - name: Verify install on JRuby + name: Verify + build the -java gem on JRuby needs: version if: needs.version.outputs.due == 'true' runs-on: ubuntu-latest timeout-minutes: 15 + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/standalone.gemfile steps: - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: ruby-version: jruby - bundler: none - # Builds the gem with JRuby, installs it (extconf.rb must not fail there) - # and renders with the pure-Ruby implementation. - - name: Build + install + render - run: ruby bin/gem-smoke + bundler-cache: true + # Compile the Java escaper, then build + install + render the -java gem + # (gem-smoke builds a ruby2html--java.gem and renders from it). + - name: Compile Java escaper + run: bundle exec rake compile + - name: Build + install + render the -java gem + run: bundle exec ruby bin/gem-smoke + - name: Build the -java gem for publishing + run: gem build ruby2html.gemspec + - uses: actions/upload-artifact@v7 + with: + name: java-gem + path: ruby2html-*-java.gem + retention-days: 7 + if-no-files-found: error release: name: Publish to RubyGems, tag, GitHub release @@ -112,15 +124,22 @@ jobs: with: ruby-version: .ruby-version + - name: Download the JRuby-built -java gem + uses: actions/download-artifact@v7 + with: + name: java-gem + - name: Exchange the OIDC token for RubyGems credentials uses: rubygems/configure-rubygems-credentials@v2.1.0 # Gem first: a tag and release with no gem behind them would advertise a - # version `gem install` cannot serve. - - name: Build + publish gem + # version `gem install` cannot serve. Two artifacts: the C-extension gem + # (built here on CRuby) and the prebuilt -java gem (from the jruby job). + - name: Build + publish both gems run: | gem build ruby2html.gemspec - gem push ruby2html-*.gem + gem push ruby2html-*-java.gem + gem push "$(ls ruby2html-*.gem | grep -v -- -java)" - name: Tag + GitHub release from CHANGELOG env: diff --git a/.gitignore b/.gitignore index 58a34a7..9b8eca5 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,11 @@ /ext/ruby2html/extconf.h *.dSYM/ compile_commands.json + +# JRuby Java escaper build artifacts (rake compile on JRuby; the -java gem +# ships this jar, but it is rebuilt from ext/java and never committed) +*.jar +*.class .cache/clangd/ # Editors / IDEs diff --git a/.ruby-version b/.ruby-version index 2aa5131..7919852 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +4.0.9 diff --git a/AGENTS.md b/AGENTS.md index 83a6fd1..6286923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,14 +12,15 @@ Internals: [`docs/architecture.md`](docs/architecture.md). Releases: [`docs/rele | Part | Path | Notes | |---|---|---| | Gem (Ruby) | `lib/gem/ruby2html.rb`, `lib/gem/ruby2html/**` | Packaged. `spec.require_paths = ['lib/gem']`; no runtime dependencies | -| C extension | `ext/ruby2html/*.{c,h}`, `extconf.rb` | Defines `Ruby2html::Native`. `rake compile` → `lib/ruby2html/ruby2html.so` (gitignored, found by `render.rb` via `require_relative`); installed gems get it under `lib/gem/ruby2html/` | -| Pure-Ruby twin | `lib/gem/ruby2html/render/pure_ruby.rb` | `Ruby2html::PureRuby`, used when the `.so` is missing (JRuby, failed build) or `RUBY2HTML_DISABLE_EXT=1` | +| C extension | `ext/ruby2html/*.{c,h}`, `extconf.rb` | Defines `Ruby2html::Native` (CRuby only). `rake compile` → `lib/ruby2html/ruby2html.so` (gitignored, found by `render.rb` via `require_relative`); installed gems get it under `lib/gem/ruby2html/` | +| Pure-Ruby twin | `lib/gem/ruby2html/render/pure_ruby.rb` | `Ruby2html::PureRuby`, used when the `.so` is missing (JRuby, failed build), on TruffleRuby (pure Ruby beats Sulong there), or with `RUBY2HTML_DISABLE_EXT=1` | +| Java escaper (JRuby) | `ext/java/Ruby2htmlJrubyService.java` | Defines `Ruby2html::JavaNative.escape` (byte-level HTML escape, the hot path). JRuby's only native code; `PureRuby.escape` uses it when present. `rake compile` on JRuby → `lib/gem/ruby2html/ruby2html_jruby.jar` (gitignored); shipped in the `-java` platform gem | | Demo Rails app | `app/`, `config/`, `db/` | Not packaged; loads the gem via `require_relative '../lib/gem/ruby2html'` | | Specs | `spec/gem/` (renderer, attributes, escaping, `render_parity_spec.rb`; `render_spec_helper.rb` runs each group against Native **and** PureRuby in one process), `spec/gem/rails/` (handler, compiler, components, middleware), `spec/features/`, `spec/benchmarks/` | Rails-booting via `spec/rails_helper.rb`; `rake spec:standalone` runs `spec/gem` minus `rails/` without Rails | | Benchmarks | `bench/*.rb` (standalone), `spec/benchmarks/requests_spec.rb` (request-level vs ERB/Slim/Phlex) | | | Scripts | `bin/gem-smoke`, `bin/changelog-notes`, `bin/build-ext` | Packaging smoke test; print a CHANGELOG section; CI-style extension builds (`--werror`, `--cc=clang`, `--sanitize`) | -Supported: all maintained CRuby versions (>= 3.1, gemspec floor), JRuby (PureRuby), TruffleRuby, Rails 7.2+. Dev Ruby: `.ruby-version`; rubocop `TargetRubyVersion` matches the floor (no newer syntax in `lib/`). Dependency versions: `Gemfile.lock` and `gemfiles/*.gemfile` (CI matrix gemfiles, unlocked). +Supported: all maintained CRuby versions (>= 3.1, gemspec floor; CI tracks the official list — currently 3.3, 3.4, 4.0), JRuby (pure Ruby + the Java escaper), TruffleRuby (pure Ruby), Rails 7.2+. Dev Ruby: `.ruby-version` (latest stable). The CRuby CI matrix is generated automatically from the maintained-versions list, so a new release (e.g. a new 4.x) joins CI with no edit. rubocop `TargetRubyVersion` matches the floor (no newer syntax in `lib/`). Dependency versions: `Gemfile.lock` and `gemfiles/*.gemfile` (CI matrix gemfiles, unlocked). ## Commands @@ -47,15 +48,16 @@ bin/rails server # demo: /, /rb_files, /form, /benchmark/{html, | File | Responsibility | |---|---| | `lib/gem/ruby2html.rb` | Entry point: eager-loads only `version` + `render`; everything else is `autoload`ed (Component, ComponentHelper, RailsHelper, HtmlBeautifierMiddleware, `RailsComponents::*`); requires `railtie` only if ActiveSupport is loaded. Keep `require 'ruby2html'` light (no Prism, no ERB engine, no htmlbeautifier) | -| `lib/gem/ruby2html/render.rb` | Loads Native (or PureRuby) into `Ruby2html::IMPLEMENTATION`; `Ruby2html.native?`, `Ruby2html.escape_html`; `Render`: `HTML5_TAGS`/`VOID_ELEMENTS`, defines tags on both implementations via `define_tag`, includes the implementation; `render`, `method_missing` → context, Rails helper wrappers, ivar copying | -| `lib/gem/ruby2html/render/pure_ruby.rb` | Ruby twin of Native: tag methods, `plain`, `component`, text/attribute rules, `escape_html` | +| `lib/gem/ruby2html/render.rb` | Loads Native (or PureRuby) into `Ruby2html::IMPLEMENTATION`; `load_native_extension` returns false on JRuby/TruffleRuby (no C ext there); `Ruby2html.native?`, `Ruby2html.escape_html`; `Render`: `HTML5_TAGS`/`VOID_ELEMENTS`, defines tags on both implementations via `define_tag`, includes the implementation; `render`, `method_missing` → context, Rails helper wrappers, ivar copying | +| `lib/gem/ruby2html/render/pure_ruby.rb` | Ruby twin of Native: tag methods, `plain`, `component`, text/attribute rules, `escape_html`. `escape` picks the fastest available: ERB's C escaper on CRuby, `Ruby2html::JavaNative` (the Java escaper) on JRuby, else a regex — all byte-identical | | `ext/ruby2html/ruby2html.c` | `Init_ruby2html`: defines `Ruby2html::Native` | | `ext/ruby2html/tag_render.c` | `Native.define_tag(method, tag, void)`: binds each tag to one of 200 C trampolines (`R2H_MAX_TAGS`), tag name `[a-z0-9-]`, max 32 chars; `render_tag` writes open tag, yields or appends content, close tag | | `ext/ruby2html/content.c` | Text rules (`plain`, `component`, tag content, block return values), `html_safe?` detection | | `ext/ruby2html/attributes.c` | Attribute rules (nil/boolean/Hash/Array/escape), name validation; no caching | | `ext/ruby2html/html_escape.c` | Escapes `& < > " '`; SSE2 / NEON 16-byte scan, scalar fallback; UTF-8 coercion (`r2h_utf8_compatible`); `Native.escape_html` | | `ext/ruby2html/buffer.h` | Inline buffer primitives (reserve ≥2x growth, memcpy, commit) | -| `ext/ruby2html/extconf.rb` | `append_cflags -O3 -std=gnu99 -fvisibility=hidden -Wall -Wextra`, `-fno-plt` on Linux, `-DR2H_NO_SIMD` via env; compiles all `*.c` in the dir | +| `ext/ruby2html/extconf.rb` | `append_cflags -O3 -std=gnu99 -fvisibility=hidden -Wall -Wextra`, `-fno-plt` on Linux, `-DR2H_NO_SIMD` via env; compiles all `*.c` in the dir. Writes a no-op Makefile (pure Ruby) on JRuby and TruffleRuby | +| `ext/java/Ruby2htmlJrubyService.java` | JRuby `BasicLibraryService`: defines `Ruby2html::JavaNative.escape`, a byte-level HTML escaper byte-identical with `html_escape.c` and `PureRuby.escape`. Built by `Rake::JavaExtensionTask` (Rakefile). Default package + the `*Service` name are load-bearing (JRuby's `require` convention) | | `lib/gem/ruby2html/railtie.rb` | `TemplateHandler` (`.rb` templates): compiled strategy via `TemplateCompiler` (template runs with self = view/component, renderer in `@__r2h`), closure fallback (no Prism / parse failure: `instance_exec` on a `Render` with ivar copying); filename annotations; Zeitwerk ignore `app/**/*.html.rb` | | `lib/gem/ruby2html/rails_components/template_compiler.rb`, `template_collector.rb` | Prism rewrite of receiverless tag/`plain`/`component`/`render`/helper calls in templates. Prism is required lazily on first compile (`available?`); the Railtie's `before_eager_load` loads it at boot in eager-loading (production) apps | | `lib/gem/ruby2html/rails_components/template_helpers.rb` | Receiver for `link_to button_to image_tag form_with render capture content_for provide` in compiled templates | @@ -67,7 +69,7 @@ bin/rails server # demo: /, /rb_files, /form, /benchmark/{html, ## Sharp edges / invariants -1. **Native and PureRuby must produce byte-identical output** (HTML, encoding, raised errors) for every input. Any rule change goes into `content.c`/`attributes.c`/`tag_render.c` **and** `pure_ruby.rb`, with specs in the `RenderSpecHelper.describe_each` groups / `render_parity_spec.rb`. Exceptions while writing a start tag roll the buffer back to before the tag (both sides). +1. **Native and PureRuby must produce byte-identical output** (HTML, encoding, raised errors) for every input. Any rule change goes into `content.c`/`attributes.c`/`tag_render.c` **and** `pure_ruby.rb`, with specs in the `RenderSpecHelper.describe_each` groups / `render_parity_spec.rb`. Exceptions while writing a start tag roll the buffer back to before the tag (both sides). The JRuby Java escaper (`ext/java`) is a third side of the escape rule only: keep it byte-identical with `html_escape.c` / `PureRuby.escape` (the JRuby standalone run exercises it, and a `render_spec` example checks it is loaded and matches). 2. **Never cache by `Object#hash`.** Hash collisions return another input's output (the old `ATTRIBUTE_CACHE` did exactly this and was removed). Cache only with exact-equality keys, and only with a benchmark proving a win. 3. **GC safety in C**: any `VALUE` whose `RSTRING_PTR`/internal pointer is used after a call that can allocate or yield (`rb_funcall`, `rb_yield`, `rb_str_*`, `rb_obj_as_string`) must be kept alive with `RB_GC_GUARD(v)` after the last use. Re-read `RSTRING_PTR(buf)` after anything that can grow `buf`. Use `buffer.h` (`r2h_buf_reserve`/`commit`), not manual `rb_str_set_len` arithmetic. 4. **Escaping is a security boundary.** Text and attribute values escaped by default; raw only for `html_safe?` objects and `component`. Plain `String` (exact class) is never treated as safe. Attribute and tag **names** are validated (raise), not escaped. Every escaping change needs specs (both modes) and an `xss-auditor` pass. @@ -79,7 +81,7 @@ bin/rails server # demo: /, /rb_files, /form, /benchmark/{html, 10. Adding a tag: `HTML5_TAGS` (+ `VOID_ELEMENTS`) only; no C change unless exceeding 200 tags or 32-char names (`.claude/skills/add-html-tag`). 11. Public API (`Render.new(ctx, &blk)`, `#render`, `#plain`, `#component`, tag methods, `Ruby2html.escape_html`, `Ruby2html.native?`, `Ruby2html::Component`, `RailsHelper#html`, `ComponentHelper#html` / `#__ruby2html_renderer__`, the `.rb` handler, `HtmlBeautifierMiddleware`) is used downstream: breaking changes need a CHANGELOG entry ("Upgrading to 2.0") and a major bump. 12. Style: rubocop-rails_config, single quotes, `# frozen_string_literal: true`, nested modules, Rails-style indented `private` methods. C: gnu99, no warnings under `-Wall -Wextra`. -13. Never commit `lib/ruby2html/*.so`, `tmp/`, `pkg/`, `*.gem`, `log/`. +13. Never commit `lib/ruby2html/*.so`, `*.jar`, `*.class`, `tmp/`, `pkg/`, `*.gem`, `log/`. ## How to verify a change @@ -91,6 +93,7 @@ bin/rails server # demo: /, /rb_files, /form, /benchmark/{html, | Escaping / attributes (either side) | both spec runs + new specs for the case | `xss-auditor` agent | | `railtie.rb`, `template_compiler.rb`, `rails_components/**`, `component.rb`, `component_helper.rb` | `CI=1 bundle exec rspec spec/gem/rails spec/features` | try the demo pages (`bin/rails server`) | | `html_beautifier_middleware.rb` | `bundle exec rspec spec/gem/rails/html_beautifier_middleware_spec.rb` | | +| `ext/java/**` (JRuby escaper) | on JRuby: `bundle exec rake compile` → `bundle exec rake spec:standalone` → `bundle exec ruby bin/gem-smoke` | keep byte-identical with `html_escape.c` / `PureRuby.escape`; `xss-auditor` pass | | gemspec, `Rakefile`, packaging | `bundle exec rake smoke` | `tar -xOf pkg/*.gem data.tar.gz \| tar -tz` | | `Gemfile` / deps | `upgrade-deps` skill: full suite + benchmarks | | | Docs only | check every claim against code; run snippets with `bundle exec ruby -Ilib/gem -Ilib -e '...'` | | @@ -101,12 +104,12 @@ bin/rails server # demo: /, /rb_files, /form, /benchmark/{html, Goal: the repo needs no hand maintenance except major-version dependency upgrades. Automated: -- **Maintainer agent**: the repo is managed by [developerz.ai](https://developerz.ai) (policy: `.dz/maintainer/maintainer.yml`). It triages issues, reviews every PR, merges every PR (contributors, members, bots) once its author marks it ready (out of draft), it is reviewed and `CI OK` is green, runs a daily `health` and weekday `release-due` checkup and a monthly `improve` pass (`.dz/briefs/`: canary fixes, dependency majors, speed with benchmark evidence, correctness, docs drift), and opens release PRs. Breaking changes escalate to the owner. +- **Maintainer agent**: the repo is managed by [developerz.ai](https://developerz.ai) (policy: `.dz/maintainer/maintainer.yml`). It triages issues, reviews every PR, merges every PR (contributors, members, bots) once its author marks it ready (out of draft), it is reviewed and `CI OK` is green, runs a daily `health` and weekday `release-due` checkup and a monthly `improve` pass (`.dz/briefs/`: canary fixes, dependency majors, speed with benchmark evidence, correctness, docs drift), and opens release PRs. Breaking changes escalate to the owner. (Sibling projects such as [wurk](https://github.com/sebyx07/wurk) run under the same developerz.ai maintenance model.) - **Dependencies**: Dependabot (`.github/dependabot.yml`) opens grouped weekly PRs after a 14-day cooldown (security updates skip it); the developerz.ai agent reviews and merges them on green (it is the only merger). -- **CI** (`.github/workflows/ci.yml`; branch protection requires only the aggregate `CI OK` job): RuboCop, bundle-audit, full suite + Brakeman, gem specs across the Ruby matrix (generated automatically from maintained CRuby versions), native builds on Linux/macOS arm64, `-Werror` builds with gcc and clang, ASAN/UBSAN (`use_sigaltstack=0`), the pure-Ruby fallback, JRuby/TruffleRuby via `gemfiles/standalone.gemfile`, Rails integration per `gemfiles/rails_*.gemfile`, and `bin/gem-smoke` packaging on plain RubyGems. Every job is required: nothing is allowed to fail and there is no `continue-on-error`. +- **CI** (`.github/workflows/ci.yml`; branch protection requires only the aggregate `CI OK` job): RuboCop, bundle-audit, full suite + Brakeman, gem specs across the Ruby matrix (generated automatically from maintained CRuby versions), native builds on Linux/macOS arm64, `-Werror` builds with gcc and clang, ASAN/UBSAN (`use_sigaltstack=0`), the pure-Ruby fallback, JRuby (builds the Java escaper jar + renders the `-java` gem) and TruffleRuby (pure Ruby) via `gemfiles/standalone.gemfile`, Rails integration per `gemfiles/rails_*.gemfile`, and `bin/gem-smoke` packaging on plain RubyGems. Every job is required: nothing is allowed to fail and there is no `continue-on-error`. - **Canary** (`.github/workflows/canary.yml`, monthly on the 1st, not gating): Ruby head and Rails edge, so upstream breaks surface before a release ships them; the monthly `improve` checkup fixes what it finds. - **Benchmarks** (`.github/workflows/benchmark.yml`, non-gating): every push to `main` and PR renders a table into the job summary and uploads JSON; PRs get a sticky comment with the delta vs `main`. Set repo variable `BENCH_RUNNER` for a stable self-hosted runner. README holds only a dated release snapshot. -- **Releases** (`.github/workflows/release.yml`): merging a `version.rb` bump to `main` (no `v` tag yet) runs the tests, `rake smoke` and a JRuby install, publishes the gem via RubyGems trusted publishing (environment `release`), then creates the tag and the GitHub release from `bin/changelog-notes`. The release PR is opened by the developerz.ai agent or the `release` skill (`docs/release.md`). +- **Releases** (`.github/workflows/release.yml`): merging a `version.rb` bump to `main` (no `v` tag yet) runs the tests and `rake smoke`, builds the `-java` gem on JRuby (jar inside), publishes **both** the C-extension gem and the `-java` gem via RubyGems trusted publishing (environment `release`), then creates the tag and the GitHub release from `bin/changelog-notes`. The release PR is opened by the developerz.ai agent or the `release` skill (`docs/release.md`). - **Local hooks**: lefthook pre-commit autocorrects staged Ruby files with RuboCop; the Claude Code PostToolUse hook reminds to recompile after `ext/` edits. Manual (the owner's only jobs): **breaking changes and major releases** (escalated by the agent), and one-time repo setup (RubyGems trusted publisher; ruleset requiring `CI OK`). Major dependency bumps are handled by the agent like any other PR; use the `upgrade-deps` skill when doing one by hand. diff --git a/CHANGELOG.md b/CHANGELOG.md index 188cc47..3f90450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ Entries before 1.6.6 were reconstructed from `git log` and RubyGems release date ## [Unreleased] +## [2.1.0] - 2026-10-31 + +### Added + +- JRuby now ships a small Java extension (`Ruby2html::JavaNative`) that accelerates HTML escaping — the renderer's hot path — over the pure-Ruby implementation. About 1.9x faster end-to-end rendering on JRuby; output is byte-identical. Distributed as a `ruby2html--java` platform gem with the jar prebuilt (no JDK needed to install); everything else stays pure Ruby. + ### Changed - `Ruby2html::Component#render_in` accepts Rails' keyword options (`locals:`), as Rails main (8.2) passes them and deprecates `render_in` without options. +- TruffleRuby now uses the pure-Ruby implementation instead of the C extension. TruffleRuby runs C extensions through Sulong but JITs pure Ruby with Graal, which here renders ~28x faster; `Ruby2html.native?` is now `false` on TruffleRuby. - On CRuby, a C extension that fails to load now prints one warning with the reason (`ruby2html: C extension not loaded (...); using the pure-Ruby renderer`) instead of silently rendering about 3x slower. JRuby/TruffleRuby and `RUBY2HTML_DISABLE_EXT=1` stay silent. +- Development and single-version CI jobs now run on the latest stable Ruby (4.0). The CRuby test matrix already tracks the official maintained-versions list automatically (currently 3.3, 3.4, 4.0). ## [2.0.0] - 2026-10-31 @@ -175,7 +183,8 @@ Published to RubyGems; no separate commits in the repository history. ### Added - Initial release: `Ruby2html::Render` DSL, `Ruby2html::RailsHelper#html` for ERB. -[Unreleased]: https://github.com/sebyx07/ruby2html/compare/v2.0.0...HEAD +[Unreleased]: https://github.com/sebyx07/ruby2html/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/sebyx07/ruby2html/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/sebyx07/ruby2html/releases/tag/v2.0.0 [1.6.6]: https://rubygems.org/gems/ruby2html/versions/1.6.6 [1.6.4]: https://rubygems.org/gems/ruby2html/versions/1.6.4 diff --git a/Gemfile.lock b/Gemfile.lock index f4acf59..08f3f44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -544,7 +544,7 @@ CHECKSUMS zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 RUBY VERSION - ruby 3.4.7 + ruby 4.0.9 BUNDLED WITH 4.0.10 diff --git a/README.md b/README.md index 9322a4a..bb27070 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ end - **Fastest Ruby HTML renderer we've measured.** The page benchmark renders 1.35x faster than Erubi (Rails' ERB engine) and 2.4–16x faster than Phlex 2, with 40–90% fewer allocations; in a full Rails request it leads ERB, Slim and Phlex ([numbers](#performance)). - **Light.** No runtime dependencies, a 32 KB gem, and `require 'ruby2html'` costs about 0.4 MB and 19 ms. Prism, the Rails glue and the optional beautifier load only when used, or at boot in eager-loading production apps. - **Plain Ruby views.** `.html.rb` templates, ERB interop, ViewComponent, or the built-in `Ruby2html::Component`. Escaped by default. -- **Runs everywhere.** CRuby ≥ 3.1 with a C extension (SIMD escaping on x86_64 and arm64), and JRuby/TruffleRuby through a byte-identical pure-Ruby implementation. Rails 7.2+. +- **Runs everywhere.** CRuby ≥ 3.1 with a C extension (SIMD escaping on x86_64 and arm64), JRuby with a Java escaper over the pure-Ruby implementation, and TruffleRuby on pure Ruby (faster there than the C extension) — all byte-identical. Rails 7.2+. - **AI-ready.** One-file reference for coding agents: [`llms.txt`](llms.txt). ## Installation -Supports all maintained CRuby versions (>= 3.1), JRuby (via the pure-Ruby implementation), TruffleRuby, and Rails 7.2+; the CI matrix is generated automatically. +Supports all maintained CRuby versions (>= 3.1; CI currently covers 3.3, 3.4 and 4.0), JRuby (pure Ruby plus a Java escaper), TruffleRuby (pure Ruby), and Rails 7.2+; the CI matrix is generated automatically, so new Ruby releases are picked up without edits. ```ruby # Gemfile @@ -208,7 +208,7 @@ This is a dated snapshot. CI has the live numbers. Measured on Ruby 3.4.7 x86_64 |---|---|---|---| | **ruby2html 2.0 (C extension)** | **2,676** | **0.37 ms** | **419** | | Erubi 1.x (Rails' ERB engine) | 1,988 (1.35x slower) | 0.50 ms | 713 | -| ruby2html 2.0 (pure Ruby: JRuby, no compiler) | ~820 | ~1.2 ms | 1,029 | +| ruby2html 2.0 (pure Ruby, no C extension; CRuby) | ~820 | ~1.2 ms | 1,029 | | Phlex 2.4, enlarged attribute cache¹ | ~1,250 (≈2.4x slower) | ~0.8 ms | 831 | | Phlex 2.4, defaults | 164 (16x slower) | 6.1 ms | 6,410 | | ruby2html 1.6.6 | ~620 | ~1.6 ms | 3,531 | diff --git a/Rakefile b/Rakefile index 077fce6..c4e6f61 100644 --- a/Rakefile +++ b/Rakefile @@ -30,8 +30,29 @@ end # to that path in a checkout); an installed gem gets it in lib/gem/ruby2html/. # Strict/clang/sanitizer variants: bin/build-ext --help if RUBY_ENGINE == 'jruby' - desc 'No-op on JRuby (the pure-Ruby implementation is used)' - task(:compile) { puts 'JRuby: skipping the C extension, using the pure-Ruby implementation' } + # JRuby can't load the C extension, but it builds a tiny Java one: the HTML + # escaper (ext/java), the renderer's hot path. Everything else stays pure + # Ruby. Output: lib/gem/ruby2html/ruby2html_jruby.jar (on the require path in + # a checkout and in the -java gem). render.rb requires 'ruby2html/ruby2html_jruby'. + begin + require 'rake/javaextensiontask' + Rake::JavaExtensionTask.new('ruby2html_jruby') do |ext| + ext.lib_dir = 'lib/gem/ruby2html' + ext.ext_dir = 'ext/java' + ext.tmp_dir = 'tmp/ext' + ext.source_version = '8' + ext.target_version = '8' + end + rescue LoadError + desc 'Compile the Java extension (requires rake-compiler)' + task(:compile) { abort 'rake-compiler is not installed: bundle install' } + end +elsif RUBY_ENGINE == 'truffleruby' + # TruffleRuby runs the C extension through Sulong but is ~28x faster on pure + # Ruby under Graal, so extconf.rb skips the build there. rake-compiler's + # ExtensionTask would still expect a .so and fail, so don't define it. + desc 'No-op on TruffleRuby (the pure-Ruby implementation is faster than the C extension)' + task(:compile) { puts 'TruffleRuby: skipping the C extension, using the pure-Ruby implementation' } else begin require 'rake/extensiontask' diff --git a/bin/gem-smoke b/bin/gem-smoke index 161371a..82aaea2 100755 --- a/bin/gem-smoke +++ b/bin/gem-smoke @@ -43,14 +43,21 @@ Dir.mktmpdir('ruby2html-smoke') do |tmp| %w[lib/gem/ruby2html.rb lib/gem/ruby2html/render.rb ext/ruby2html/extconf.rb README.md LICENSE CHANGELOG.md].each do |f| abort "Gem is missing #{f}" unless files.include?(f) end + # The -java platform gem must ship the prebuilt Java escaper jar; a jar-less + # -java gem would silently drop to the slower regex escaper. Compile first. + if Gem::Package.new(gem_path).spec.platform.to_s == 'java' + jar = 'lib/gem/ruby2html/ruby2html_jruby.jar' + abort "The -java gem is missing #{jar}: run `rake compile` on JRuby before building" unless files.include?(jar) + end puts "== File list OK (#{files.size} files)" gem_home = File.join(tmp, 'gems') run!(clean_env, Gem.ruby, '-S', 'gem', 'install', '--no-document', '--install-dir', gem_home, gem_path, chdir: tmp) puts '== Installed into temporary GEM_HOME' - # Renders with the C extension (except on JRuby) and with the pure-Ruby - # implementation (RUBY2HTML_DISABLE_EXT=1); both must produce the same HTML. + # Renders with the C extension (CRuby only -- JRuby uses the Java escaper over + # pure Ruby, TruffleRuby pure Ruby) and with the pure-Ruby implementation + # (RUBY2HTML_DISABLE_EXT=1); both must produce the same HTML. script = <<~'RUBY' require 'ruby2html' expect_native = ENV['EXPECT_NATIVE'] == '1' @@ -71,7 +78,7 @@ Dir.mktmpdir('ruby2html-smoke') do |tmp| puts "native=#{native} #{$LOADED_FEATURES.grep(/ruby2html\.(so|bundle|dll)\z/).first}" RUBY env = clean_env.merge('GEM_HOME' => gem_home, 'GEM_PATH' => gem_home) - native_env = env.merge('RUBY2HTML_DISABLE_EXT' => nil, 'EXPECT_NATIVE' => RUBY_ENGINE == 'jruby' ? '0' : '1') + native_env = env.merge('RUBY2HTML_DISABLE_EXT' => nil, 'EXPECT_NATIVE' => RUBY_ENGINE == 'ruby' ? '1' : '0') pure_env = env.merge('RUBY2HTML_DISABLE_EXT' => '1', 'EXPECT_NATIVE' => '0') native_out = run!(native_env, RbConfig.ruby, '-e', script, chdir: tmp) pure_out = run!(pure_env, RbConfig.ruby, '-e', script, chdir: tmp) diff --git a/ext/java/Ruby2htmlJrubyService.java b/ext/java/Ruby2htmlJrubyService.java new file mode 100644 index 0000000..b20d4ab --- /dev/null +++ b/ext/java/Ruby2htmlJrubyService.java @@ -0,0 +1,95 @@ +// JRuby's only native code: a byte-level HTML escaper, the renderer's hottest +// primitive. Everything else (tag methods, attributes, content rules) stays in +// the pure-Ruby twin (lib/gem/ruby2html/render/pure_ruby.rb), which JRuby's JIT +// optimizes well; on TruffleRuby even the C extension loses to pure Ruby, so +// only JRuby gets this. The C extension (ext/ruby2html/html_escape.c) is the +// reference; this must stay byte-identical with it and with PureRuby.escape. +// +// require 'ruby2html/ruby2html_jruby' loads this: JRuby maps the base name +// ruby2html_jruby to the class Ruby2htmlJrubyService (CamelCase + "Service") in +// the default package, so the name and package here are load-bearing. +// +// Built by rake-compiler's Rake::JavaExtensionTask (see Rakefile) into +// lib/gem/ruby2html/ruby2html_jruby.jar and shipped in the -java platform gem. + +import org.jruby.Ruby; +import org.jruby.RubyModule; +import org.jruby.RubyString; +import org.jruby.anno.JRubyMethod; +import org.jruby.runtime.ThreadContext; +import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.runtime.load.BasicLibraryService; +import org.jruby.util.ByteList; + +public class Ruby2htmlJrubyService implements BasicLibraryService { + // getOrCreateModule/defineModuleUnder/defineAnnotatedMethods are marked + // deprecated on JRuby 10 but remain the load-service API and still work; the + // context-taking replacements are not available on the JRuby versions in CI. + @SuppressWarnings("deprecation") + public boolean basicLoad(final Ruby runtime) { + final RubyModule javaNative = + runtime.getOrCreateModule("Ruby2html").defineModuleUnder("JavaNative"); + javaNative.defineAnnotatedMethods(Escaper.class); + return true; + } + + public static final class Escaper { + private static final byte AMP = '&', LT = '<', GT = '>', QUOT = '"', APOS = '\''; + private static final byte[] E_AMP = bytes("&"); + private static final byte[] E_LT = bytes("<"); + private static final byte[] E_GT = bytes(">"); + private static final byte[] E_QUOT = bytes("""); + private static final byte[] E_APOS = bytes("'"); + + private static byte[] bytes(final String s) { + final byte[] b = new byte[s.length()]; + for (int i = 0; i < s.length(); i++) b[i] = (byte) s.charAt(i); + return b; + } + + private static boolean escapable(final byte b) { + return b == AMP || b == LT || b == GT || b == QUOT || b == APOS; + } + + // Ruby2html::JavaNative.escape(str) -> String + // + // str has already been run through PureRuby.utf8_compatible, so its + // encoding is UTF-8, US-ASCII or BINARY. All five escapable characters + // are ASCII, so escaping byte by byte is identical across those + // encodings and for bytes that are invalid UTF-8 (the C extension and + // PureRuby both pass such bytes through). The result keeps the input's + // encoding. A clean string is returned unchanged (same object), which + // PureRuby.escape_html relies on to avoid a copy. + @JRubyMethod(name = "escape", module = true) + public static IRubyObject escape(final ThreadContext ctx, final IRubyObject self, + final IRubyObject arg) { + final RubyString str = (RubyString) arg; + final ByteList in = str.getByteList(); + final byte[] src = in.getUnsafeBytes(); + final int begin = in.getBegin(); + final int len = in.getRealSize(); + + int first = -1; + for (int i = 0; i < len; i++) { + if (escapable(src[begin + i])) { first = i; break; } + } + if (first < 0) return str; // nothing to escape: hand back the same string + + final ByteList out = new ByteList(len + 16); + out.setEncoding(in.getEncoding()); + out.append(src, begin, first); + for (int i = first; i < len; i++) { + final byte b = src[begin + i]; + switch (b) { + case AMP: out.append(E_AMP); break; + case LT: out.append(E_LT); break; + case GT: out.append(E_GT); break; + case QUOT: out.append(E_QUOT); break; + case APOS: out.append(E_APOS); break; + default: out.append(b); + } + } + return RubyString.newString(ctx.runtime, out); + } + } +} diff --git a/ext/ruby2html/extconf.rb b/ext/ruby2html/extconf.rb index 320914e..bf26cff 100644 --- a/ext/ruby2html/extconf.rb +++ b/ext/ruby2html/extconf.rb @@ -12,6 +12,10 @@ def skip_native_extension(reason) skip_native_extension('RUBY2HTML_DISABLE_EXT=1') if ENV['RUBY2HTML_DISABLE_EXT'] == '1' skip_native_extension('JRuby does not load C extensions') if RUBY_ENGINE == 'jruby' +# TruffleRuby runs C extensions through Sulong (an LLVM interpreter) but gives +# pure Ruby the full Graal JIT, which here renders ~28x faster than the +# extension. Building it would only slow TruffleRuby down, so don't. +skip_native_extension('TruffleRuby is faster on the pure-Ruby renderer') if RUBY_ENGINE == 'truffleruby' require 'mkmf' diff --git a/lib/gem/ruby2html/render.rb b/lib/gem/ruby2html/render.rb index 6bea2e9..d98502e 100644 --- a/lib/gem/ruby2html/render.rb +++ b/lib/gem/ruby2html/render.rb @@ -9,6 +9,10 @@ module Ruby2html # into lib/ruby2html. def self.load_native_extension return false if ENV['RUBY2HTML_DISABLE_EXT'] == '1' + # JRuby has no C extension (it uses a Java escaper over the pure-Ruby twin, + # see load_java_escaper). TruffleRuby *can* load it via Sulong, but renders + # ~28x slower that way than on pure Ruby under the Graal JIT, so never do. + return false if RUBY_ENGINE == 'jruby' || RUBY_ENGINE == 'truffleruby' begin require 'ruby2html/ruby2html' @@ -17,9 +21,9 @@ def self.load_native_extension end defined?(Native) ? true : false rescue LoadError => e - # Expected on JRuby/TruffleRuby. On CRuby it means a broken or missing build: say so instead of + # On CRuby a missing extension means a broken build: say so instead of # silently rendering about 3x slower. - warn "ruby2html: C extension not loaded (#{e.message}); using the pure-Ruby renderer" if RUBY_ENGINE == 'ruby' + warn "ruby2html: C extension not loaded (#{e.message}); using the pure-Ruby renderer" false end private_class_method :load_native_extension diff --git a/lib/gem/ruby2html/render/pure_ruby.rb b/lib/gem/ruby2html/render/pure_ruby.rb index fd164a9..8215fc4 100644 --- a/lib/gem/ruby2html/render/pure_ruby.rb +++ b/lib/gem/ruby2html/render/pure_ruby.rb @@ -8,6 +8,16 @@ nil end +if RUBY_ENGINE == 'jruby' && ENV['RUBY2HTML_DISABLE_EXT'] != '1' + begin + # Defines Ruby2html::JavaNative (a byte-level HTML escaper, ext/java). + # JRuby's only native code; PureRuby.escape uses it when present. + require 'ruby2html/ruby2html_jruby' + rescue LoadError + nil + end +end + module Ruby2html # Pure-Ruby twin of the C extension's Ruby2html::Native (ext/ruby2html), # used on JRuby, when the extension isn't built, or with @@ -33,6 +43,13 @@ class << self def escape(str) ERB::Escape.html_escape(utf8_compatible(str)) end + elsif defined?(Ruby2html::JavaNative) + # JRuby: the Java escaper (ext/java). Byte-identical with the regex + # branch below (and with the C extension); also returns str itself + # when clean. utf8_compatible first, so it sees UTF-8/US-ASCII/BINARY. + def escape(str) + Ruby2html::JavaNative.escape(utf8_compatible(str)) + end else def escape(str) str = utf8_compatible(str) diff --git a/lib/gem/ruby2html/version.rb b/lib/gem/ruby2html/version.rb index 08e1fe4..8d219c1 100644 --- a/lib/gem/ruby2html/version.rb +++ b/lib/gem/ruby2html/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Ruby2html - VERSION = '2.0.0' + VERSION = '2.1.0' end diff --git a/llms.txt b/llms.txt index c804645..8c1b93e 100644 --- a/llms.txt +++ b/llms.txt @@ -1,8 +1,8 @@ # ruby2html -> Ruby gem that renders HTML from a pure-Ruby block DSL (`div class: 'card' do h2 @title end`). Tag methods are C (with an output-identical pure-Ruby fallback used on JRuby or when the extension can't load). Works standalone, as Rails `.html.rb` templates, inside ERB, in ViewComponent, and as lightweight `Ruby2html::Component` classes. Text and attribute values are escaped by default. No runtime dependencies. Performance at 2.0.0: page rendering 1.35x faster than Erubi and 2.4–16x faster than Phlex 2, with fewer allocations than either; `require` costs about 0.4 MB (README "Performance"). +> Ruby gem that renders HTML from a pure-Ruby block DSL (`div class: 'card' do h2 @title end`). Tag methods are C (with an output-identical pure-Ruby fallback used on JRuby and TruffleRuby or when the extension can't load; JRuby adds a Java escaper for the hot path). Works standalone, as Rails `.html.rb` templates, inside ERB, in ViewComponent, and as lightweight `Ruby2html::Component` classes. Text and attribute values are escaped by default. No runtime dependencies. Performance at 2.0.0: page rendering 1.35x faster than Erubi and 2.4–16x faster than Phlex 2, with fewer allocations than either; `require` costs about 0.4 MB (README "Performance"). -Install: `gem 'ruby2html'`. CRuby >= 3.1 (builds a C extension), JRuby/TruffleRuby (pure Ruby fallback), Rails 7.2+ (optional). MIT. Source: https://github.com/sebyx07/ruby2html. Upgrading from 1.x: CHANGELOG "Upgrading to 2.0". Maintained by developerz.ai (AI maintainer agent: triage, review, merges, releases). +Install: `gem 'ruby2html'`. CRuby >= 3.1 (builds a C extension), JRuby (pure Ruby + a prebuilt Java escaper, shipped as the `-java` platform gem), TruffleRuby (pure Ruby, faster than the C extension there), Rails 7.2+ (optional). MIT. Source: https://github.com/sebyx07/ruby2html. Upgrading from 1.x: CHANGELOG "Upgrading to 2.0". Maintained by developerz.ai (AI maintainer agent: triage, review, merges, releases). ## DSL rules diff --git a/ruby2html.gemspec b/ruby2html.gemspec index 03ea5ea..876b70d 100644 --- a/ruby2html.gemspec +++ b/ruby2html.gemspec @@ -25,19 +25,33 @@ Gem::Specification.new do |spec| spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" spec.metadata['rubygems_mfa_required'] = 'true' - # C extension. RubyGems compiles it on install and places ruby2html.so into - # the require path (lib/gem/ruby2html/), where render.rb's - # `require 'ruby2html/ruby2html'` finds it. On JRuby extconf.rb writes a no-op - # Makefile and the pure-Ruby implementation is used. - spec.extensions = ['ext/ruby2html/extconf.rb'] + # Two builds. On CRuby/TruffleRuby the C extension is compiled on install and + # ruby2html.so lands on the require path (lib/gem/ruby2html/), where + # render.rb's `require 'ruby2html/ruby2html'` finds it (TruffleRuby's + # extconf.rb then no-ops it: pure Ruby is faster there). On JRuby a `-java` + # platform gem instead ships the prebuilt Java escaper jar + # (lib/gem/ruby2html/ruby2html_jruby.jar), the only native code JRuby runs; + # tag/attribute logic stays pure Ruby. `gem build` on JRuby (RUBY_PLATFORM + # 'java') produces that gem; anywhere else, the C-extension gem. + # The gemspec is loaded on every rake run (Bundler), including the `rake + # compile` that builds the jar, so it must never raise when the jar is + # absent. bin/gem-smoke enforces that a *built* -java gem actually ships it. + jar = 'lib/gem/ruby2html/ruby2html_jruby.jar' + if RUBY_PLATFORM == 'java' + spec.platform = 'java' + else + spec.extensions = ['ext/ruby2html/extconf.rb'] + end # Ship only the gem library and the extension sources. The repository root is # also a Rails demo app, so never glob all of lib/ (lib/assets, lib/tasks, # and the locally compiled lib/ruby2html/ruby2html.so must stay out). root = __dir__ spec.files = Dir.chdir(root) do - Dir.glob(%w[lib/gem/**/*.rb ext/ruby2html/*.{c,h,rb}]) + - %w[README.md LICENSE CHANGELOG.md].select { |f| File.file?(f) } + files = Dir.glob(%w[lib/gem/**/*.rb ext/ruby2html/*.{c,h,rb} ext/java/*.java]) + + %w[README.md LICENSE CHANGELOG.md].select { |f| File.file?(f) } + files << jar if RUBY_PLATFORM == 'java' && File.file?(jar) # the prebuilt escaper, shipped in the -java gem + files end.sort # lib/gem holds the Ruby code and receives the compiled extension on install. diff --git a/spec/gem/render_spec.rb b/spec/gem/render_spec.rb index 8e4af69..cbd8707 100644 --- a/spec/gem/render_spec.rb +++ b/spec/gem/render_spec.rb @@ -295,6 +295,17 @@ expect(Ruby2html::Render.include?(Ruby2html::PureRuby)).to eq(!described_class.native?) end + it 'loads the Java escaper on JRuby (fail loudly, not a silent slow fallback)' do + skip 'not JRuby' unless RUBY_ENGINE == 'jruby' + skip 'extension disabled' if ENV['RUBY2HTML_DISABLE_EXT'] == '1' + + expect(defined?(Ruby2html::JavaNative)).to be_truthy + # Byte-identical with the pure-Ruby escaping it accelerates. + ['x', 'a&"\'c', 'λ', 'emoji 😀 &
'].each do |s| + expect(Ruby2html::JavaNative.escape(s.dup)).to eq(s.gsub(/[&<>"']/, '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''')) + end + end + it 'defines the same methods in the C extension and the pure-Ruby fallback' do skip 'C extension not loaded' unless described_class.native?