Skip to content

Ruby: Add standalone herb-linter, herb-formatter and herb-highlighter gems - #2857

Open
excid3 wants to merge 2 commits into
marcoroth:mainfrom
excid3:standalone-tool-gems
Open

excid3 wants to merge 2 commits into
marcoroth:mainfrom
excid3:standalone-tool-gems

Conversation

@excid3

@excid3 excid3 commented Oct 28, 2026

Copy link
Copy Markdown

Builds on #2856, which should be merged first (this branch includes its commit).

This packages herb-lint, herb-format and herb-highlight as standalone executables, so Ruby projects can lint and format templates without Node.js, in CI and in local development. They're published as the herb-linter, herb-formatter and herb-highlighter gems you reserved:

bundle add herb-linter --group development
bundle exec herb-lint

herb lint, herb format and herb highlight pick up the gem automatically.

@marcoroth Before you spend time reviewing: is Bun the direction you'd like to go for this, or would you rather port it to something else? I compared the alternatives below and Bun came out ahead for now, but this is your call, and I'm happy to rework it or close it.

How it works

  • Compiling: bun build --compile compiles each CLI, including the WASM parser and highlighter themes, into a single executable. Bun cross-compiles every platform from one Linux machine.
  • Platform gems: rake standalone:gems builds one gem per platform, plus a plain ruby gem. The gems' version follows Herb::VERSION.
  • Custom rules and rewriters: files in .herb/rules and .herb/rewriters that import @herb-tools/linter, @herb-tools/rewriter and so on by name get the copies embedded in the binary. This uses Bun.plugin, and it also works in the linter's worker threads. The entrypoints are in javascript/packages/{linter,formatter}/src/standalone/.
  • Alpine: the musl binaries need libstdc++, which Alpine doesn't install by default. The musl gems bundle libstdc++ and libgcc from Alpine's packages (pinned with checksums), and their RUNPATH points at them, so they run on a stock ruby:*-alpine image. These libraries are GPL-3.0 with the GCC Runtime Library Exception. The musl gems list that license and include a NOTICE with a link to the source.
  • CLI lookup: herb lint and the other commands try, in order: the project's node_modules/.bin, then the gem's executable, then the PATH, then npx.

Platforms

Platform herb Standalone gems
x86_64-linux-gnu, aarch64-linux-gnu ✓ ✓
x86_64-linux-musl, aarch64-linux-musl ✓ ✓
arm64-darwin, x86_64-darwin ✓ ✓
x64-mingw-ucrt built from source ✓
arm-linux-*, x86-linux-* (32-bit) ✓ ✗, Bun has no 32-bit targets

On unsupported platforms, the ruby gem still installs, so a shared Gemfile.lock keeps working. Its herb-lint runs the npm package via npx when Node.js is available, and otherwise explains what to install.

Alternatives considered

  • Node.js single executable applications (Node 26): the main advantage is that the executable would run the same runtime as the npm packages. I tried it in a prototype: custom rules work with module.registerHooks and worker threads work, so the entrypoints would carry over almost one-to-one. But SEA doesn't support Intel macOS (nodejs/node#62893), it skips Alpine in its tests, Node has no official arm64 musl build, and the glibc binaries also need libatomic. The SEA virtual file system is also still marked "early development".
  • Deno (deno compile): has no musl targets.
  • QuickJS and other small engines: they can't run the WASM parser.
  • Native Ruby or Rust versions of the tools: the only option without a bundled runtime, but it means a second implementation of every rule.

Things to decide

  1. Size: each binary is about 95–110 MB on disk and each gem about 40–50 MB compressed, because every binary includes Bun. A release is 21 platform gems. Shipping one binary that picks its tool from the command name, in a shared gem, would cut that roughly by three, but it needs a fourth gem name.
  2. Lockfiles: projects need their CI platform in Gemfile.lock (bundle lock --add-platform x86_64-linux). Otherwise Bundler resolves the ruby gem in CI, which then needs Node. This should probably go in the CI integration docs.
  3. Docs: I've only updated the package READMEs. The CI integration pages in docs/ could get a "Ruby project without Node.js" example.
  4. CI cost: the workflow compiles 21 binaries and runs 7 smoke jobs whenever javascript/packages/** or src/** changes. It could build fewer platforms on pull requests.

Setup before the first release

The push job uses trusted publishing, the same as build-gems.yml, so standalone-gems.yml needs to be added as a trusted publisher for all three gems on RubyGems.org.

Testing

  • bin/standalone-smoke-test runs the installed gems without Node.js. It checks built-in rules, a custom rule both in parallel workers and with --jobs 1, --fix, herb-format --check, formatting with a custom rewriter, and highlighting. --compare-node also checks that the output matches the npm packages.
  • Passed locally on arm64 macOS, including --compare-node, and on stock ruby:3.4-alpine on arm64 without libstdc++ installed.
  • All seven targets cross-compile from macOS, and the arm64 Linux binaries run on Debian without Node.js. I couldn't run x86_64 macOS, x86_64 musl or Windows locally; the workflow's smoke matrix covers those.
  • 37 Ruby CLI tests pass (8 new for the lookup order), and RuboCop is clean.

https://claude.ai/code/session_017jYmXZLoC2KgCkEWLEQqyy

Glob results come back in filesystem and runtime dependent order, so the
linter's and formatter's output order could differ between machines. Sort
them in `Config#findFilesForTool` and in the linter's and formatter's own
glob paths.

Cap the linter's default number of workers at 8. Each worker loads its own
WASM parser and config, so on machines with many cores the startup cost of
one worker per core outweighs the extra parallelism.

Also declare the linter's missing build dependency on @herb-tools/client,
which it imports from `@herb-tools/client/directives`.

Claude-Session: https://claude.ai/code/session_017jYmXZLoC2KgCkEWLEQqyy
…ghter` gems

Package `herb-lint`, `herb-format` and `herb-highlight` as standalone
executables compiled with `bun build --compile`, distributed as platform
gems so Ruby projects can lint and format templates without Node.js.

- `rake standalone:gems` builds gems for arm64-darwin, x86_64-darwin,
  aarch64-linux-gnu, aarch64-linux-musl, x86_64-linux-gnu,
  x86_64-linux-musl and x64-mingw-ucrt, plus a plain `ruby` gem that falls
  back to `npx` on other platforms.
- The musl gems bundle libstdc++ and libgcc from Alpine Linux, so they run
  on a stock Alpine image.
- Custom rules and rewriters that import Herb packages by name are served
  the copies embedded in the binary, including in the linter's workers.
- `herb lint`, `herb format` and `herb highlight` use the gem's executable
  when the project doesn't have the npm package installed.
- `bin/standalone-smoke-test` checks the installed gems without Node.js,
  and with `--compare-node` against the npm packages.
- The Standalone Gems workflow builds the gems, smoke tests them on every
  platform and pushes them on release.

Claude-Session: https://claude.ai/code/session_017jYmXZLoC2KgCkEWLEQqyy
@github-actions github-actions Bot added linter @herb-tools/linter for HTML+ERB templates formatter @herb-tools/formatter for HTML+ERB templates ruby Ruby source for the gem and its libraries typescript TypeScript source across the javascript/ packages highlighter @herb-tools/highlighter syntax highlighting and diagnostic rendering rubygem The herb RubyGem and its packaging config Herb configuration file handling and config options labels Oct 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config Herb configuration file handling and config options formatter @herb-tools/formatter for HTML+ERB templates highlighter @herb-tools/highlighter syntax highlighting and diagnostic rendering linter @herb-tools/linter for HTML+ERB templates ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging typescript TypeScript source across the javascript/ packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant