Repository navigation
Parse Ruby with Prism instead of lib-ruby-parser - #73
Merged
Merged
Conversation
dduugg
marked this pull request as ready for review
October 12, 2026 21:43
lib-ruby-parser follows Ruby 3.1's grammar, and its repository was archived in 2024, so syntax from later Rubies doesn't parse. Ruby 3.2's anonymous argument forwarding, `def foo(*) = bar(*)`, is a syntax error to it, and pks lost the references in such a file. Prism is the parser Ruby itself has used since 3.4, and the `ruby-prism` crate is published from ruby/prism. The two tree walkers collect what they did before. lib-ruby-parser gave `foo&.bar` and `foo[1]` node types of their own, while Prism makes them calls, and it made `a.b ||= c` a call where Prism doesn't. The walkers make up for each, so associations and the experimental parser's test for a class that changes behavior see the same calls as before, as unit tests now pin. Prism has ten node types for assigning a constant, which share one macro between the walkers and, as `casgn` did, visit only the value assigned. `module self::Foo` no longer panics in packwerk mode; it is skipped, as a class with such a name was. Both parsers give byte offsets, so lines and columns are unchanged. An association call with a block now ends after the block, but pks uses only where a range starts. Prism reports `yield` outside a method as an error, and the Ruby that pks makes from a template has its `yield` at the top level. Rails runs a template as the body of a method, so that error is ignored there. Otherwise the warning gives Prism's first error, in Prism's words. Its later errors can sit at earlier lines, as the missing `end` of an enclosing class does. On every fixture and three large Rails apps, under both parsers, every file gives the same references and definitions as before, and `check -o json`, `list-references` and `list-definitions` print the same. `check` takes as long and uses about 5% less memory. ruby-prism compiles Prism's C source and generates its bindings with bindgen, so building pks now needs a C compiler and libclang.
dist builds the release binaries only when releasing, so a dependency that doesn't build for one target shows up only then. Prism is C, compiled with `cc` and bound with bindgen, so each runner needs a C compiler and libclang. This builds each target in dist-workspace.toml on the runner dist's plan uses, installing musl-tools for musl as dist does, so a pull request, Dependabot's included, shows whether pks still builds everywhere.
dist built the Apple Silicon binary on macos-14 by default, an image GitHub has deprecated, and Intel Macs no longer need supporting. Releases now leave out x86_64-apple-darwin, and dist builds aarch64-apple-darwin on macos-26. That is the newest arm64 macOS that GitHub's runner images call generally available, named explicitly rather than as macos-latest so that it changes only when we change it. The CI build job uses the same runner, so it keeps building in the release's environment. `dist generate` leaves release.yml as it was, since dist picks runners when it plans a release, and `dist plan` now lists macos-26 for that target and no Intel one. The DotSlash file and rust-toolchain.toml drop the Intel target too.
Prism has ten node types that assign a constant, which the walkers handle in one macro, but only `FOO = BAR` in packwerk mode was tested. Each is now, under both parsers: it defines its targets, and only the value assigned is a reference, not a target or its scope. Visiting one of them with Prism's default instead fails these tests. Packwerk mode gains tests that a class or module named by an expression, as `module self::Foo`, is skipped without upsetting the namespaces after it, and that a `&.` call is no association while a call with a block still starts where it did. A file with a syntax error now has to give the reference that Prism recovers before the error, and a template's error has to be reported when a `yield` comes before it.
Rails runs a Builder template as the body of a method, as it does an ERB one, so `yield` is valid in a Builder layout. pks parses `.builder` files as Ruby, though, and Prism reports a `yield` outside a method as an error, so an app that includes them got a warning that lib-ruby-parser never gave. Such a file is now parsed as a template.
Some of Prism's messages quote the file, as an unterminated heredoc's quotes its name. When a magic comment names an encoding other than UTF-8, such as Windows-31J, Prism reads the file in it, and the quote can end partway through a UTF-8 character. ruby-prism's `Diagnostic::message` then panics, which stopped the whole run without saying which file it was in. lib-ruby-parser warned that it didn't support the encoding and went on. The crate offers no other way to read a message, so pks catches that panic and warns without Prism's message. Rust's panic handler still prints the panic before the warning.
dist builds with the dist profile, and on Windows links the C runtime statically, so cc compiles Prism with /MT. The build job now does both, so it compiles what a release ships. It also runs each binary on a fixture, since otherwise only Test Suite runs Prism, on x86_64 Linux, and a target could build a pks that crashes. Installing the toolchain before adding the target stops rustup's warning that installing it automatically is deprecated.
A Windows build needs LLVM for its libclang, which Visual Studio's build tools don't install, and bindgen's requirements cover other systems. Prism reads Ruby as its newest version does, so pks now warns about syntax that only older Rubies accept, such as `a[k: 1] = v`, and with `--experimental-parser` no longer counts `it` in a block as a method call. The changelog's claim that the references and definitions pks finds are the same now says so.
The Intel macOS target is gone from releases and the DotSlash file, but the installation guide still said both cover macOS.
Main's ruleset requires Lints, Test Suite and Check, so with auto-merge a pull request that breaks a release target can merge, and nobody learns of it until a release fails. The matrix's job names change with its targets, so a new Build job reports whether every target built, giving the ruleset one name to require. GitHub counts a skipped required check as passing, so it runs even when a build fails, and fails then. The installation guide said the `clang` package gives Debian and Ubuntu libclang, which no build had tried. bindgen's requirements name `libclang-dev`, and `build-essential` gives the C compiler that Rust needs there anyway.
dduugg
added a commit
that referenced
this pull request
Oct 12, 2026
pks is a binary, but Cargo.lock was ignored, as the .gitignore that cargo generated for a library said to do. So each build, a release's included, resolved dependencies afresh, and a release could ship versions that no CI run had tried, with nothing in pks changed. Since #73, `cc` and bindgen drive a C compiler and libclang in every build, which makes that likelier, and `cc` has had 21 releases in six months. CI now builds with `--locked`, so it fails when Cargo.toml and Cargo.lock disagree rather than updating the lock unseen. dist doesn't pass `--locked`, but a lock that CI has checked leaves nothing for it to update. Dependabot updates the lock and raises a requirement in Cargo.toml only for a release it doesn't allow, where before, with no lock to update, it raised every requirement. `cargo install --locked` builds from source with the same versions, and `cargo audit` checks them rather than those of a lock it makes itself.
dduugg
added a commit
that referenced
this pull request
Oct 13, 2026
pks is a binary, but Cargo.lock was ignored, as the .gitignore that cargo generated for a library said to do. So each build, a release's included, resolved dependencies afresh, and a release could ship versions that no CI run had tried, with nothing in pks changed. Since #73, `cc` and bindgen drive a C compiler and libclang in every build, which makes that likelier, and `cc` has had 21 releases in six months. CI now builds with `--locked`, so it fails when Cargo.toml and Cargo.lock disagree rather than updating the lock unseen. dist doesn't pass `--locked`, but a lock that CI has checked leaves nothing for it to update. Dependabot updates the lock and raises a requirement in Cargo.toml only for a release it doesn't allow, where before, with no lock to update, it raised every requirement. `cargo install --locked` builds from source with the same versions, and `cargo audit` checks them rather than those of a lock it makes itself.
6 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
def foo(*) = bar(*)). pks lost the references in such files, and since 0.7.0 it warns about them. Prism is the parser Ruby itself has used since 3.4.foo&.barandfoo[1]node types of their own, while Prism makes them calls.a.b ||= ca call, while Prism doesn't.casgnhandling did.yielddoesn't warn. Prism reportsyieldoutside a method as an error, and the Ruby that pks makes from an ERB template has itsyieldat the top level, as a Builder template's is. Rails runs both as the body of a method, so that error is ignored in them.Diagnostic::messagepanics on a message that isn't UTF-8, which happens when a magic comment names another encoding and Prism's quote of the file ends mid-character, so pks catches that and warns without the message.distprofile and, on Windows, a static C runtime, and runs each binary on a fixture. ABuildjob reports whether every target built, so main's ruleset has one name to require once this merges.x86_64-apple-darwin, and the Apple Silicon binary is built onmacos-26, the newest arm64 macOS that GitHub's runner images list as generally available, in place of dist's default, the deprecatedmacos-14.macos-26is named explicitly rather than asmacos-latest.[dist.github-custom-runners]sets it for the release, and the CI job uses the same runner.dist generateleavesrelease.ymlunchanged anddist planlistsmacos-26.Prism version
ruby-prism = "1.9.0", the newest minor release (January 2026). 1.8.1, from March, is a patch release on the older line.parsetakes no options, so Prism reads the newest Ruby syntax it knows, and pks can't pin it to an older version. Dependabot (Warn about files that don't parse, read<%==in ERB, and release 0.7.0 #72) will keep the requirement current.a[k: 1] = v, anditin a block is the block's parameter, not a call, which with--experimental-parsercan stop a class counting as defined. The changelog says so.Build requirements and risk per target
ruby-prism-syscompiles Prism's C source withcc, and generates its bindings with bindgen on every build. So building pks now needs a C compiler and libclang.INSTALLATION.mdsays so.aarch64-apple-darwin(macos-26): built and tested locally on Apple Silicon.x86_64-unknown-linux-gnuandaarch64-unknown-linux-gnu(ubuntu-22.04 and ubuntu-22.04-arm): both images list Clang 13 to 15. The new CI job builds them.x86_64-unknown-linux-musl(ubuntu-22.04): the most uncertain. dist installsmusl-tools, soccusesmusl-gcc, but bindgen has to parse Prism's headers for a musl target. The new job installsmusl-toolsas dist does, and builds it.x86_64-pc-windows-msvc(windows-2022): the image includes LLVM, and CRuby builds Prism with MSVC. The new job builds it.cargo build --profile dist --target …on dist's runners, notdist builditself. This PR's CI run is the evidence for the targets I couldn't build locally. A release dry run on 5ebbfba (run) randist builditself on all five targets, plus the installers, and passed.Equivalence
I built
mainand this branch side by side with an uncommitted probe. On each run it recorded every file's parsed references, with names, namespaces and positions, plus its definitions. I compared those, and the output ofcheck -o json(violations and stale todos),list-referencesandlist-definitions, all read-only with--no-cache, under both parsers.tests/fixtures, 72 runs. All 288 parse results and all three commands' output are identical.maintoo.checkviolations, the 550,054list-referencesentries (byte for byte),list-definitions, and stderr. No new warnings appear.<%==in ERB, and release 0.7.0 #72's<%==fix, no file in these apps failed to parse before, so none gains references.mainpanicked on 6, each with a module named by an expression, asmodule self::Foois.self.a ||= 1,self.a, self.b = 1, 2,/(?<a>.)/ =~ BAR,BAR.[](1),BAR[1],BAR[1] = 2and@a&.b ||= 1.def b(, it still finds a reference earlier in the class that lib-ruby-parser lost.checktakes as long as before. Medians of 8 alternating runs: 5.95 s against 6.03 s by default, 5.23 s against 5.20 s with--experimental-parser. Peak memory is 5 to 7% lower (1,576 MB against 1,655 MB, and 1,130 MB against 1,220 MB). The machine's load average was 30 to 40 throughout, so treat the times as roughly equal rather than exact.Native-extension risk
Linking a second copy of libprism into a Ruby 3.4+ process, which already contains one, could clash on symbols. I found nothing that loads pks into a Ruby process:
rlib, and nothing insrc/uses itsserde_magnusdependency.serde_magnus,code_ownership's, linkscodeowners-rs, not pks.pkscrate on crates.io is the oldalexevanczuk/packs0.2.40, and nothing depends on it.Out of scope
partial_scriptoption would replace matching the "Invalid yield" message, but the crate doesn't expose parse options. A test fails if Prism's wording changes.serde_magnusdependency.FOO = xspans the assignment, whileFOO ||= xspans only the name. Both are kept as they were.Test plan
cargo test, with and withoutRUST_LOG=debug, pluscargo fmt --checkandcargo clippy --all-targets --all-features -- -Dwarningson each commit.*and**forwarding, under both parsers, with no warning (this one fails onmain);<%= yield %>without a warning;module self::Fooandclass self::Fooin packwerk mode,&.and block association calls, and the reference that Prism recovers before a syntax error;yieldin a Builder template, and a Prism message that isn't UTF-8, without a panic.Buildjobs pass on all five targets.