diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21c689682..432683df1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,9 @@ on: value: ${{ jobs.compact-required.outputs.passed == 'true' && jobs.assemble-site.outputs.ready == 'true' }} # Separate from the full-ci caller's lock: workflow_call inherits its context. -# Push-to-main now runs full coverage; cancel superseded SHAs so merge trains -# do not stack 50-job matrices. Scheduled/manual full-ci keeps its own lock. +# Push-to-main is Ruby+Spinel, not the extra-language matrix; cancel +# superseded SHAs so merge trains do not stack. Scheduled/manual full-ci +# is the extra-SDK ledger and keeps its own lock. concurrency: group: validation-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} @@ -113,9 +114,7 @@ jobs: env: WRITEBOOK_ROOT: /tmp/writebook WRITEBOOK_INVENTORY_REPORT: writebook-inventory-current.json - run: | - cargo build --locked --release --bin roundhouse - cargo test --locked --test writebook --release -- --ignored --nocapture + run: cargo test --locked --release --bin roundhouse --test writebook -- --ignored --nocapture - name: Save complete check report id: report if: always() && (steps.reuse.outcome != 'success' || steps.reuse.outputs.hit != 'true') @@ -310,16 +309,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python3 scripts/ci-reuse.py probe --job store-check --input fixtures/store + # Debug, not release: the analyzer claim is the same IR. On PR + # 37439350975 this job spent 95s of 113s compiling a release + # binary for a check that finishes in seconds. - name: cargo build id: build if: steps.reuse.outcome != 'success' || steps.reuse.outputs.hit != 'true' - run: cargo build --locked --release --bin roundhouse + run: cargo build --locked --bin roundhouse - name: The analyzer reports no errors, no warnings and no ingest gaps on the store id: check if: steps.reuse.outcome != 'success' || steps.reuse.outputs.hit != 'true' run: | set -euo pipefail - target/release/roundhouse check --continue fixtures/store 2>&1 | tee /tmp/store-check.txt + target/debug/roundhouse check --continue fixtures/store 2>&1 | tee /tmp/store-check.txt grep -E "^roundhouse-check: .* 0 error\(s\), 0 warning\(s\), 0 gap-attributed note\(s\), 0 survey gap\(s\)" /tmp/store-check.txt - name: Record actual successful execution id: execution @@ -431,6 +433,7 @@ jobs: # Preprocess each time: Spinel uses conditional __has_include headers. SCCACHE_DIRECT: "false" SCCACHE_IDLE_TIMEOUT: "0" + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 with: @@ -579,33 +582,43 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ env.MRI_RUBY }} - - name: Install the gem the overlay escape surface uses - run: gem install rails-html-sanitizer --no-document - # The native Date reference uses ActiveRecord against in-memory - # SQLite. Fixture generation ships source, not its installed gems. - - name: Install ActiveRecord for the native Date reference - run: gem install activerecord -v '~> 8.1.0' --no-document - # `tests/emit_and_run.rs` and `tests/relation_runtime_cruby.rs` - # run the emitted Ruby tree, whose database layer is the sqlite3 - # gem. The second skipped without it and passed: before this step - # the unit job ran it as a no-op. The first refuses to skip. + # libvips42: `tests/vips_loader_policy.rs` boots an emitted variant + # app and asks `Vips.vips_foreign_find_load` after the app's loader + # policy. CRuby binds through FFI, so the library is enough; headers + # are the compiled Spinel job. + - name: System libvips for the emitted ruby-vips processor + run: | + "$GITHUB_WORKSPACE/scripts/ci-apt-install" libvips42 + # One gem process: three sequential `gem install` steps were paying + # RubyGems startup separately for the same default gem path. # - # Campfire launcher regressions (`tests/ci_campfire_optimization_test.py`, - # invoked from `tests/ci_policy_workflow.rs`) preload - # `scripts/campfire-test-bcrypt.rb`, which `require "bcrypt"`. A - # runner without the gem fails those tests; a dev box that has it - # hides the gap. + # rails-html-sanitizer — `tests/overlay_sanitize_autolink.rs` shells + # out to ruby and loads the CRuby overlay's escape surface. Without + # the gem the overlay falls back to the shared scanner (which refuses + # markup by design) and the test fails naming this step. + # setup-ruby, not the runner's system ruby: `gem install` against + # system ruby is a Gem::FilePermissionError, and the test asserts + # EXACT sanitizer output, so the ruby it runs on is part of the + # assertion. 3.4 is the version campfire-conformance pins. # - # `tests/vips_loader_policy.rs` boots an emitted variant app and - # asks `Vips.vips_foreign_find_load` after the app's loader policy. - # That require is ruby-vips over the system libvips: without both, - # the test dies on `cannot load such file -- vips` while a machine - # that already has the gem hides the gap. CRuby binds through FFI, - # so libvips42 is enough; headers are the compiled Spinel job. - - name: System libvips for the emitted ruby-vips processor - run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips42 + # activerecord — the native Date reference uses ActiveRecord against + # in-memory SQLite. Fixture generation ships source, not its gems. + # + # sqlite3 — `tests/emit_and_run.rs` and `tests/relation_runtime_cruby.rs` + # run the emitted Ruby tree. The second skipped without the gem and + # passed: before this install the unit job ran it as a no-op. + # + # bcrypt — Campfire launcher regressions + # (`tests/ci_campfire_optimization_test.py`) preload + # `scripts/campfire-test-bcrypt.rb`. A runner without the gem fails + # those tests; a dev box that has it hides the gap. + # + # ruby-vips — pairs with libvips42 above; without the gem the vips + # loader test dies on `cannot load such file -- vips`. - name: Install gems used by emitted Ruby and Campfire harness tests - run: gem install sqlite3 bcrypt ruby-vips --no-document + run: | + gem install --no-document rails-html-sanitizer sqlite3 bcrypt ruby-vips + gem install --no-document activerecord -v '~> 8.1.0' # Bounded build → run → free waves: every --all-targets identity still # goes through Cargo (lib, bins, each integration target). Finished # integration executables and their own split-DWARF sidecars are freed @@ -636,8 +649,9 @@ jobs: run: | python3 scripts/ci-resources.py --out "$RUNNER_TEMP/unit-resources/bench" -- \ bash -euo pipefail -c ' + test -x target/debug/emit_preview for target in typescript crystal rust python elixir go kotlin swift csharp; do - cargo run --quiet --bin emit_preview -- --target "$target" fixtures/real-blog >/dev/null + target/debug/emit_preview --target "$target" fixtures/real-blog >/dev/null done ' - name: Upload unit resource samples @@ -961,6 +975,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 continue-on-error: true + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -1084,7 +1100,8 @@ jobs: # app's own hardening line. # CRuby binds libvips through FFI; only compiled Spinel needs headers. - name: System libvips - run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips42 sqlite3 + run: | + "$GITHUB_WORKSPACE/scripts/ci-apt-install" libvips42 sqlite3 - name: Cache the prepared oracle id: oracle uses: actions/cache@v6 @@ -1149,6 +1166,8 @@ jobs: outputs: artifact-id: ${{ steps.binary.outputs.artifact-id }} execution: ${{ steps.result.outputs.status }} + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -1162,7 +1181,8 @@ jobs: chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract echo "$PWD/spinel-dist" >> $GITHUB_PATH - name: System libvips + libsqlite3-dev + libjemalloc-dev - run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips-dev libsqlite3-dev libjemalloc-dev + run: | + "$GITHUB_WORKSPACE/scripts/ci-apt-install" libvips-dev libsqlite3-dev libjemalloc-dev - name: Fetch pinned once-campfire run: | curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz @@ -1301,6 +1321,7 @@ jobs: ports: ['6379:6379'] env: BUNDLE_PATH: vendor/bundle + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -1329,7 +1350,8 @@ jobs: # this program's allocator and `spin build` links `-ljemalloc`. # The DEV package, not libjemalloc2: the runtime library LD_PRELOAD # uses cannot be linked against. - run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips-dev libsqlite3-dev libjemalloc-dev sqlite3 + run: | + "$GITHUB_WORKSPACE/scripts/ci-apt-install" libvips-dev libsqlite3-dev libjemalloc-dev sqlite3 - name: Fetch pinned once-campfire run: | curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz @@ -1371,6 +1393,7 @@ jobs: ports: ['6379:6379'] env: BUNDLE_PATH: vendor/bundle + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -1387,7 +1410,8 @@ jobs: chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract echo "$PWD/spinel-dist" >> $GITHUB_PATH - name: System libvips + libsqlite3-dev + libjemalloc-dev - run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips-dev libsqlite3-dev libjemalloc-dev sqlite3 + run: | + "$GITHUB_WORKSPACE/scripts/ci-apt-install" libvips-dev libsqlite3-dev libjemalloc-dev sqlite3 - name: Fetch pinned once-campfire run: | curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz @@ -1881,6 +1905,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 continue-on-error: true + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -2029,6 +2055,19 @@ jobs: if: matrix.target == 'swift' run: | "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev + - &swiftpm-cache + name: Cache SwiftPM checkouts + if: matrix.target == 'swift' + uses: actions/cache@v6 + with: + # Cloned Hummingbird/NIO trees. Object files stay per-package in + # .build/; sharing those across emit dirs is not reliable. + path: | + ~/.cache/org.swift.swiftpm + ~/.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-swift6.1-${{ hashFiles('src/emit/swift/package.rs') }} + restore-keys: | + swiftpm-${{ runner.os }}-${{ runner.arch }}-swift6.1- - name: Install .NET (C#) if: matrix.target == 'csharp' uses: actions/setup-dotnet@v6 @@ -2094,7 +2133,11 @@ jobs: run: cargo test --test framework_tests_kotlin -- --ignored --nocapture - name: cargo test --test framework_tests_swift if: ${{ !cancelled() && matrix.target == 'swift' }} - run: cargo test --test framework_tests_swift -- --ignored --nocapture + run: | + # scripts/compare then runs emit_preview; compiling it here reuses + # the libroundhouse just built for the test harness. + cargo build --bin emit_preview --quiet + cargo test --test framework_tests_swift -- --ignored --nocapture - name: cargo test --test typescript_toolchain real_blog -- --ignored if: ${{ !cancelled() && matrix.target == 'typescript' }} run: cargo test --test typescript_toolchain real_blog -- --ignored --nocapture @@ -2379,6 +2422,7 @@ jobs: if: matrix.target == 'swift' run: | "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev + - *swiftpm-cache - name: Install .NET (C#) if: matrix.target == 'csharp' uses: actions/setup-dotnet@v6 @@ -2513,6 +2557,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 continue-on-error: true + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 @@ -2597,6 +2643,8 @@ jobs: # 26 of its 30 minutes on 2026-09-12 (run 34725916085). A publish # that times out four minutes short is a flake waiting to happen. timeout-minutes: 60 + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust @@ -2780,6 +2828,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 continue-on-error: true + env: + MAKEFLAGS: "-j4" steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 diff --git a/README.md b/README.md index f8c8c60d3..2e2828910 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ oracle: the same URL fetched from Rails and from each target must produce the same response — emitted tests, a differential compare against live Rails (DOM node for DOM node, JSON value for value), and browser end-to-end tests for what a static diff can't reach — and -the full target matrix runs after merge to `main` and in scheduled -validation, with a Ruby floor and targeted additions on pull requests. → +the full target matrix runs in scheduled validation, with a Ruby floor +and targeted additions on pull requests (canonical `main` keeps Ruby +plus Spinel). → [`--target`](docs/guide/transpile.md) · [targets](docs/guide/targets.md) · [what of Rails comes through](docs/guide/rails-coverage.md) · [verifying](docs/guide/verifying.md) diff --git a/SECURITY.md b/SECURITY.md index b4d57183b..a5bd5d05b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ from everyone. - If you have Hardening ideas, found a weakness in tooling or low level vulnerability, please open a public issue or pull request as usual. - If you found a high level security issue that could be used to exploit a - compiled app and cause serious damage, please report privately throigh + compiled app and cause serious damage, please report privately through [GitHub's private vulnerability reporting](https://github.com/rubys/roundhouse/security/advisories/new). Please include reproduction steps and the targets you checked and if you diff --git a/docs/ci/README.md b/docs/ci/README.md index 1d7aa97ae..e975e29cf 100644 --- a/docs/ci/README.md +++ b/docs/ci/README.md @@ -14,6 +14,8 @@ labels and changed paths; draft and ready PRs use the same policy: | **Draft or ready**, no special label | Path-selected coverage on the Ruby floor | | **Draft or ready** + `ci:spinel` | Ruby floor plus the full Spinel suite; no other language SDKs | | **Draft or ready** + `ci:full` | Full validation (all targets, WASM, Writebook, Spinel) | +| **Push to canonical `main`** | Ruby floor plus the full Spinel suite; extra-language SDKs wait for the schedule | +| **Scheduled / manual Full validation** | Full validation (the extra-language ledger and publication cycle) | PRs without a special label run a Ruby floor: fixture preparation, unit tests, Store analysis, the CRuby comparison against Rails, and Campfire @@ -24,10 +26,13 @@ standalone checks. That floor is the merge claim for ordinary analyzer, lowerer, and runtime work: the Ruby shape runs, and Campfire still matches Rails. Crystal, Go, -Swift, Kotlin, C#, Elixir, Python, JRuby, Rust, TypeScript, WASM, Writebook, -and Spinel do **not** start on that path unless the diff owns them or a -maintainer applies `ci:full` / `ci:spinel`. Extra-language failures after merge -are a main ledger, not a reason to block the next Ruby PR. +Swift, Kotlin, C#, Elixir, Python, JRuby, Rust, TypeScript, WASM, and +Writebook do **not** start on that path unless the diff owns them or a +maintainer applies `ci:full`. JRuby stays with Ruby-family path ownership +(`src/emit/ruby.rs`, interpreter-only runtime files, proven `src/project.rs` +bodies). Spinel starts when the diff owns it, or via `ci:spinel` / `ci:full`. +Extra-language failures after merge are a scheduled-ledger item, not a +reason to block the next Ruby PR. Selected lanes start once their inputs are ready, without waiting for unit tests to pass. Campfire consumes an independently built same-run debug compiler. @@ -35,15 +40,19 @@ Speculative work may therefore finish even when a unit shard fails; the final gate still requires all selected non-advisory checks, including the unit matrix. Additional checks are selected from the changed inputs. Target-specific -changes select owning lanes; shared emit, build, packaging, and CI-policy -implementation changes can select full coverage. Changes only to CI contract -tests retain the Ruby floor rather than expanding to every target. -Analyzer/lowerer changes do not automatically select every target: request -full coverage when the risk warrants it. CLI help and other -`src/bin/roundhouse.rs` edits stay on the Ruby floor. +changes select owning lanes. Shared emit (`src/emit/shared/`), the compare +harness, and the smoke harness still select every extra target they own. +CI files, Cargo manifests, `src/project.rs`, and unknown changed inputs do +**not** fan out extra-language SDKs — they stay on the Ruby floor (unknown +inputs keep Spinel too). Apply `ci:full` when a workflow edit must prove +`compare-extra` / `smoke` steps. Changes only to CI contract tests retain +the Ruby floor. Analyzer/lowerer changes do not automatically select every +target. CLI help and other `src/bin/roundhouse.rs` edits stay on the Ruby +floor. The planner diffs the PR merge tree (or the PR head) against its base, -includes both sides of a rename, and expands only when the trees cannot be -identified. A newer main than the event's `base.sha` is not unknown input. +includes both sides of a rename, and does not expand to extra SDKs when +the trees cannot be identified. A newer main than the event's `base.sha` +is not unknown input. See the run's **plan** job for its selected jobs and reasons. Changing draft status does not restart checks or change coverage. `ci:draft` @@ -51,10 +60,13 @@ has no effect. Stacked labels prefer the broader lane: `ci:full` > `ci:spinel`. Documentation-only PRs still receive checks; changes to the rendered user guide also select site/browser coverage. -Pushes to canonical `main` run full validation and cancel a superseded SHA -on the same ref. Extra-target red on that run is follow-up work on main, -not a merge gate for later Ruby PRs. The four-hour scheduled cycle remains -the publication and floating-pin catch-up. +Pushes to canonical `main` run the Ruby floor plus the full Spinel suite +and cancel a superseded SHA on the same ref. They do **not** run Crystal, +Go, Swift, Kotlin, C#, Elixir, Python, Rust, TypeScript, WASM, Writebook, +or the extra-language smoke matrix. Extra-target red is follow-up work on +the four-hour scheduled Full validation cycle, not a merge gate for later +Ruby PRs. That schedule remains the extra-language ledger, publication +path, and floating-pin catch-up. ## Request full, Spinel, or fresh validation @@ -75,8 +87,8 @@ the publication and floating-pin catch-up. Leave `publish` unchecked. This executes freshly on the chosen ref; a branch-head dispatch is not a substitute for a PR merge-tree check. -Superseded PR runs cancel. Push-to-main full runs also cancel a superseded -SHA; the scheduled full-ci lock does not. Neither dependency-cache hits nor +Superseded PR runs cancel. Push-to-main Ruby+Spinel runs also cancel a +superseded SHA; the scheduled full-ci lock does not. Neither dependency-cache hits nor restored fixture source are test results; check the job summary for any explicitly reused execution evidence. The compact and summary gates run only when `plan` succeeded and the workflow has not been cancelled. Their explicit @@ -104,10 +116,10 @@ broaden comparison masks merely to turn CI green. ## Publication is separate -PR checks never deploy Pages. Pushes to canonical `main` run full validation +PR checks never deploy Pages. Pushes to canonical `main` run Ruby+Spinel without publication. The four-hour scheduled cycle on canonical -`rubys/roundhouse` main is what requests publication. Manual publication is -opt-in on canonical main. +`rubys/roundhouse` main is what runs the extra-language matrix and requests +publication. Manual publication is opt-in on canonical main. Pages requires the compact publication floor (Ruby plus any selected Rust/TypeScript lanes), verified same-run assembly, and a live-main SHA diff --git a/docs/guide/rails-coverage.md b/docs/guide/rails-coverage.md index 11272415f..5dca256e9 100644 --- a/docs/guide/rails-coverage.md +++ b/docs/guide/rails-coverage.md @@ -24,9 +24,10 @@ tiers. **The blog** (`fixtures/real-blog`, the Rails 8 scaffold with articles, comments, nested routes, validations, Turbo Streams, Action Cable, Tailwind, JSON endpoints) is the shared DOM-equivalence fixture for -**every server target** in full validation. Ordinary PRs run the Ruby -floor plus selected target lanes; pushes to canonical `main` run the full -matrix. See [CI coverage](../ci/README.md). +**every server target** in scheduled full validation. Ordinary PRs run the +Ruby floor plus selected target lanes; pushes to canonical `main` run Ruby +plus Spinel. The extra-language matrix waits for the four-hour schedule or +`ci:full`. See [CI coverage](../ci/README.md). A passing target's comparison proves the blog's features on that target. **Campfire** (Basecamp's chat product — file attachments with image diff --git a/docs/guide/spinel.md b/docs/guide/spinel.md index 81adbd0d2..e2e7e76ab 100644 --- a/docs/guide/spinel.md +++ b/docs/guide/spinel.md @@ -103,6 +103,14 @@ translated — to its own binary and diffs its output against a `.expected` snapshot. The `e2e/` Playwright suite is the same one the other server targets ship. +The emitted Ruby carries `#` comments naming the app +file and line each statement came from (`/app/views/rooms/show.html.erb:12`), +so C compiler errors, `-g`/`--debug` stepping, `perf` and Spinel's +`--warn-widen` report positions in your `.rb` and `.erb` sources rather +than in the lowered tree. The paths start with the app directory's +name; point a debugger at its parent (gdb `directory`). Code with no +source line of its own reports the nearest marked line above it. + ## One command: OCRAN [OCRAN](https://github.com/Largo/ocran) packages Ruby programs for diff --git a/docs/guide/targets.md b/docs/guide/targets.md index 0e368c3da..d4e23fe95 100644 --- a/docs/guide/targets.md +++ b/docs/guide/targets.md @@ -36,10 +36,11 @@ app's model and controller tests and a Playwright `e2e/` suite; the ## How far each is tested -The lanes below run across targets in full validation: after merge to -canonical `main`, on the four-hour schedule, or when requested with `ci:full`. -Ordinary PRs use a Ruby floor plus targeted additions for the files they -change; maintainers can request full PR coverage with `ci:full`. +The lanes below run across targets in scheduled full validation, or when +requested with `ci:full`. Ordinary PRs use a Ruby floor plus targeted +additions for the files they change. Pushes to canonical `main` run that +Ruby floor plus the full Spinel suite; extra-language SDKs wait for the +four-hour schedule. Maintainers can request full PR coverage with `ci:full`. See [CI coverage](../ci/README.md). The lanes use the blog fixture (`fixtures/real-blog`: articles, comments, nested routes, validations, Turbo Streams over Action Cable, Tailwind) diff --git a/docs/guide/verifying.md b/docs/guide/verifying.md index f384fc55c..41d7bdec7 100644 --- a/docs/guide/verifying.md +++ b/docs/guide/verifying.md @@ -118,9 +118,10 @@ which: The third kind is what the oracle exists to find, and the project's position is that every one of them is a roundhouse bug, never an acceptable difference. [CI](../ci/README.md) selects comparison lanes on -PRs/main pushes and runs the complete coverage in full validation. A green -selected subset is not evidence that an unselected target passed; check the -target's actual results before treating it as supported. +PRs (path-owned or `ci:full`) and runs the complete extra-language +coverage in scheduled full validation. Main pushes keep Ruby+Spinel. A +green selected subset is not evidence that an unselected target passed; +check the target's actual results before treating it as supported. ## Beyond the page diff --git a/docs/writebook.md b/docs/writebook.md index f2e72a953..c3bc63681 100644 --- a/docs/writebook.md +++ b/docs/writebook.md @@ -100,8 +100,12 @@ At pin `f3fadd21907ad9b18cb23800d971c2cc25045e2a`, the installer in `lib/rails_ext/action_text_has_markdown.rb` is a direct `include ActionText::HasMarkdown` inside `ActiveSupport.on_load :active_record`. The survey now reports that dropped installation with its hook and source file. -This is a reporting milestone, **not Markdown support**: no mixin is installed, -no string `class_eval` is expanded, and `has_markdown` remains unclaimed. +This is a reporting milestone, **not Markdown support**: no mixin instance +methods are installed. Interpolatable `class_eval` heredocs can expand at +ingest when every leftover statement is ingestible; interpolated association +or scope names abort the whole expansion (including the rewritten methods). +`has_markdown` remains unclaimed because of that abort. The inventory records +it as an ingest gap on `app/models/page.rb`. Coverage is bounded to direct receiverless includes in the top-level hooks already scanned; conditional/nested includes and other hook execution remain unsupported. `tests/on_load_includes.rs` proves unchanged IR/emission and executes diff --git a/runtime/ruby/action_controller/base.rb b/runtime/ruby/action_controller/base.rb index 9e555d4c0..54672eebf 100644 --- a/runtime/ruby/action_controller/base.rb +++ b/runtime/ruby/action_controller/base.rb @@ -3,6 +3,192 @@ require_relative "../action_view" module ActionController + # One-slot array so class-level CSRF state is a store every target + # can index, not a `self` ivar or `class << self` writer. + FORGERY_SLOT = [true] + + def self.forgery_flag + FORGERY_SLOT[0] == true + end + + def self.set_forgery_flag(value) + FORGERY_SLOT[0] = value + end + + # WHATWG URL-parser preprocessing: drop tab/CR/LF/NUL anywhere, then + # strip leading and trailing C0 controls and spaces. A tab in the + # middle (`/\t/evil`) becomes `//evil` so host classification sees it. + REDIRECT_LINE_BREAKS = { "\r" => "", "\n" => "", "\0" => "", "\t" => "" }.freeze + REDIRECT_LINE_BREAK_PATTERN = /[\r\n\0\t]/.freeze + + # Puma's illegal-header rule: drop a key/value that cannot be one + # HTTP/1.1 line. Character walks (`[i, 1]`), not `getbyte`/`bytesize` + # — those do not exist on strict-target strings. + def self.header_key_ok?(k) + return false if k.nil? + n = k.length + return false if n == 0 + i = 0 + while i < n + c = k[i, 1].to_s + return false if c == "\"" || c == ":" || c == " " || header_control?(c) + i += 1 + end + true + end + + def self.header_value_ok?(v) + # Nil is an unset (`headers["X-Rev"] = ENV["GIT_REVISION"]` when + # the env is absent). Drop it; do not ask it for length. + return false if v.nil? + n = v.length + i = 0 + while i < n + c = v[i, 1].to_s + return false if c != "\t" && header_control?(c) + i += 1 + end + true + end + + def self.header_control?(c) + c == "\0" || c == "\r" || c == "\n" || c == "\x01" || c == "\x02" || + c == "\x03" || c == "\x04" || c == "\x05" || c == "\x06" || c == "\x07" || + c == "\x08" || c == "\t" || c == "\x0b" || c == "\x0c" || c == "\x0e" || + c == "\x0f" || c == "\x10" || c == "\x11" || c == "\x12" || c == "\x13" || + c == "\x14" || c == "\x15" || c == "\x16" || c == "\x17" || c == "\x18" || + c == "\x19" || c == "\x1a" || c == "\x1b" || c == "\x1c" || c == "\x1d" || + c == "\x1e" || c == "\x1f" || c == "\x7f" + end + + def self.sanitize_location(path) + s = path.to_s + if s.include?("\r") || s.include?("\n") || s.include?("\0") || s.include?("\t") + s = s.gsub(REDIRECT_LINE_BREAK_PATTERN, REDIRECT_LINE_BREAKS) + end + s = s.tr("\\", "/") + # No `break`: go/typescript emit cannot lower it (MCP wont_lower + # and the TS real-blog gate both flagged this walk). + keep = true + while keep && s.length > 0 + c = s[0, 1].to_s + if c == " " || header_control?(c) + s = s[1, s.length].to_s + else + keep = false + end + end + keep = true + while keep && s.length > 0 + c = s[s.length - 1, 1].to_s + if c == " " || header_control?(c) + s = s[0, s.length - 1].to_s + else + keep = false + end + end + s + end + + # Host of an absolute URL (`http://h/path`), or "" when the value is + # a relative path. Protocol-relative `//host/...` is a host. A + # backslash or interior tab is normalized in `sanitize_location` + # first so `/\evil` and `/\t/evil` become `//evil`. + def self.location_host(url) + s = url.to_s + return "" if s.empty? + rest = s + if s.start_with?("//") + rest = s[2, s.length].to_s + # `///path` has no host; treat as a dummy host so same-host + # refuses it instead of classifying it as relative. + return "." if rest.empty? || rest.start_with?("/") + else + at = find_substr(s, "://") + return "" if at < 0 + rest = s[at + 3, s.length].to_s + end + slash = find_substr(rest, "/") + hostport = slash < 0 ? rest : rest[0, slash].to_s + q = find_substr(hostport, "?") + hostport = hostport[0, q].to_s unless q < 0 + hash = find_substr(hostport, "#") + hostport = hostport[0, hash].to_s unless hash < 0 + user = find_last(hostport, "@") + hostport = hostport[user + 1, hostport.length].to_s unless user < 0 + hostport.downcase + end + + def self.find_substr(hay, needle) + n = needle.length + i = 0 + last = hay.length - n + while i <= last + return i if hay[i, n].to_s == needle + i += 1 + end + -1 + end + + def self.find_last(hay, needle) + n = needle.length + i = hay.length - n + while i >= 0 + return i if hay[i, n].to_s == needle + i -= 1 + end + -1 + end + + class HeaderStore + def initialize + @keys = [] + @vals = [] + end + + def [](key) + i = 0 + while i < @keys.length + return @vals[i] if @keys[i] == key + i += 1 + end + nil + end + + # Void: a writer that returns the stored value would leak a + # dropped line back to the caller, and rust emit of `[]=` is + # `()` not `Option`. + def []=(key, value) + if ActionController.header_key_ok?(key) && ActionController.header_value_ok?(value) + i = 0 + found = false + while i < @keys.length + if @keys[i] == key + @vals[i] = value + found = true + end + i += 1 + end + unless found + @keys << key + @vals << value + end + end + end + + def size + @keys.length + end + + def key_at(i) + @keys[i].to_s + end + + def val_at(i) + @vals[i].to_s + end + end + # Symbol form (`status: :see_other`) to integer code — Rack's # `SYMBOL_TO_STATUS_CODE`, PORTED whole rather than grown entry by # entry as apps surfaced them. It used to be "an ad-hoc subset; grow @@ -95,6 +281,14 @@ module ActionController # file would have to satisfy every strict target's type system for # a feature none of them exercise yet. class Base + def self.allow_forgery_protection + ActionController.forgery_flag + end + + def self.allow_forgery_protection=(value) + ActionController.set_forgery_flag(value) + end + attr_accessor :params, :session, :flash, :request_method, :request_path, :request_format # True when the request's Accept is a bare `*/*` — an # XMLHttpRequest or fetch that set none. Rails reads that as "any @@ -127,10 +321,12 @@ def initialize @status = 200 @body = +"" @location = nil + @request_method = +"" + @request_path = +"" @request_format = :html @accepts_any_format = false @content_type = "text/html; charset=utf-8" - @headers = {} + @headers = ActionController::HeaderStore.new @performed = false # Set unconditionally, not on first `expires_in`: an ivar a strict # target never sees assigned has no type to infer, and the readers @@ -208,7 +404,7 @@ def render(body, status: :ok, content_type: nil, location: nil) @status = resolve_status(status) @performed = true @content_type = content_type unless content_type.nil? - @location = location unless location.nil? + @location = ActionController.sanitize_location(location) unless location.nil? nil end @@ -216,8 +412,17 @@ def render(body, status: :ok, content_type: nil, location: nil) # status; surfaces flash messages via the flash hash. Default # status 302 (Found). Real-blog uses 303 (See Other) on # PATCH/DELETE responses; pass `status: :see_other` to match. + # + # CR, LF and NUL are DELETED from the location, as Rails' + # `_compute_redirect_to_location` does (`.delete("\0\r\n")`). + # An absolute URL whose host is not this request's Host is refused + # (`raise_on_open_redirects`): `redirect_to params[:back]` and a + # stored `request.url` from a spoofed `HTTP_HOST` must not become + # Location on another origin. def redirect_to(path, notice: nil, alert: nil, status: :found) - @location = path + loc = ActionController.sanitize_location(path) + loc = same_host_location(loc) + @location = loc @status = resolve_status(status) @performed = true @flash[:notice] = notice unless notice.nil? @@ -238,7 +443,7 @@ def redirect_to(path, notice: nil, alert: nil, status: :found) # on a 3xx status, so setting the location beside a 201 records the # URL without turning the response into one. def head(status, content_type: nil, location: nil) - @location = location unless location.nil? + @location = ActionController.sanitize_location(location) unless location.nil? @status = resolve_status(status) @body = +"" @performed = true @@ -343,9 +548,58 @@ def send_data(data, type: "application/octet-stream", disposition: "attachment") @body = data @content_type = type @performed = true + disp = disposition.to_s == "inline" ? "inline" : "attachment" + @headers["Content-Disposition"] = disp + nil + end + + # Rails' `verify_authenticity_token`: GET/HEAD pass; anything else + # must carry the session token as `authenticity_token` or + # `X-CSRF-Token`. An empty session token matches nothing (fail + # closed). Tests set `allow_forgery_protection = false`. + def verify_authenticity_token + unless verified_request? + render "

422 Unprocessable Content

", status: :unprocessable_content + end nil end + def verified_request? + return true unless ActionController.forgery_flag + verb = @request_method.to_s + return true if verb == "" || verb == "GET" || verb == "HEAD" + expected = session[:_csrf_token].to_s + return false if expected.empty? + given = params["authenticity_token"].to_s + return true if given.length > 0 && given == expected + header = csrf_header_token + header.length > 0 && header == expected + end + + def csrf_header_token + "" + end + + def request_for_csrf + nil + end + + # Relative locations (`/path`, not `//host`) pass. An absolute URL + # must name this request's host; a missing request refuses any host. + def same_host_location(loc) + host = ActionController.location_host(loc) + return loc if host.empty? + req_host = request_host_for_redirect + if req_host.empty? || host != req_host.downcase + raise ArgumentError, "Unsafe redirect to \"" + loc + "\", pass allow_other_host: true to redirect anyway." + end + loc + end + + def request_host_for_redirect + "" + end + # Monomorphic on Symbol — real-blog never passes a literal Integer # status, so the previous `is_a?(Integer)` pass-through branch is # contracted away. Symbol -> Integer via the STATUS_CODES table. diff --git a/runtime/ruby/action_controller/base.rbs b/runtime/ruby/action_controller/base.rbs index 59e87c7b4..ea33c4cda 100644 --- a/runtime/ruby/action_controller/base.rbs +++ b/runtime/ruby/action_controller/base.rbs @@ -1,4 +1,24 @@ module ActionController + def self.header_key_ok?: (String? k) -> bool + def self.header_value_ok?: (String? v) -> bool + def self.header_control?: (String c) -> bool + def self.sanitize_location: (String path) -> String + def self.location_host: (String url) -> String + def self.find_substr: (String hay, String needle) -> Integer + def self.find_last: (String hay, String needle) -> Integer + def self.forgery_flag: () -> bool + def self.set_forgery_flag: (bool value) -> void + + class HeaderStore + @keys: Array[String] + @vals: Array[String] + def initialize: () -> void + def []: (String key) -> String? + def []=: (String key, String? value) -> void + def size: () -> Integer + def key_at: (Integer i) -> String + def val_at: (Integer i) -> String + end # Symbolic-status to integer-code mapping (`STATUS_CODES`) is a # Ruby-level frozen Hash literal — module-level constant, not a # method. RBS doesn't require declaring constants for the strict @@ -11,6 +31,8 @@ module ActionController # CRuby-only overlay feature — see runtime/action_controller_cookies.rb # — so it isn't declared on the shared/transpiled Base.) class Base + def self.allow_forgery_protection: () -> bool + def self.allow_forgery_protection=: (bool) -> void # `attr_accessor` produces getter+setter pairs for each name. # `params` is a String-keyed Hash whose values are recursive # `Roundhouse::ParamValue` — String leaves, nested Hashes, and @@ -38,6 +60,7 @@ module ActionController def accepts_any_format=: (bool) -> bool def path_parameters: () -> Hash[String, String] def path_parameters=: (Hash[String, String]) -> Hash[String, String] + @headers: ActionController::HeaderStore # `attr_reader` — getter only. Setters are internal (assigned via # `@status =`, `@body =`, `@location =` from render/redirect_to/head). @@ -94,8 +117,14 @@ module ActionController def expires_in: (Integer seconds, ?public: bool, ?stale_while_revalidate: Integer) -> void def cache_control_max_age: () -> Integer def cache_control_public: () -> bool - def headers: () -> Hash[String, String] + def headers: () -> ActionController::HeaderStore def send_data: (String data, ?type: String, ?disposition: String) -> void + def verify_authenticity_token: () -> void + def verified_request?: () -> bool + def csrf_header_token: () -> String + def request_for_csrf: () -> untyped + def same_host_location: (String loc) -> String + def request_host_for_redirect: () -> String # Monomorphic on Symbol — body looks up STATUS_CODES. Integer # pass-through was previously supported via `is_a?(Integer)` diff --git a/runtime/ruby/action_controller/cookies.rb b/runtime/ruby/action_controller/cookies.rb index 777b79936..85659a3d9 100644 --- a/runtime/ruby/action_controller/cookies.rb +++ b/runtime/ruby/action_controller/cookies.rb @@ -1,6 +1,7 @@ # Controller-level cookie access — Rails' `cookies` CookieJar. `cookies[:k]` -# reads the inbound cookie; `cookies[:k] = v` (and `cookies.permanent[:k] = v`) -# records a write the dispatcher serializes as Set-Cookie. +# reads the inbound cookie; `cookies[:k] = v` records a write the dispatcher +# serializes as Set-Cookie, and `cookies.permanent[:k] = v` records one that +# expires twenty years out, as Rails' does. # # Ruby-family only, like current.rb beside this file: a CookieJar-typed field # on Base must NOT transpile to the strict targets (they don't exercise @@ -19,6 +20,10 @@ class CookieJar def initialize(inbound = {}) @inbound = {} @out = {} + @flag_httponly = {} + @flag_samesite = {} + @flag_secure = {} + @flag_expires = {} # Copy via `.each` (pair iteration), not `.keys`: the inbound hash is # the request's `Tep.str_hash` (a `Hash.new("")`), whose `.keys` # intrinsic yields a null array through the loosely-typed `req.cookies` @@ -40,10 +45,14 @@ def []=(key, value) raw_set(key, value) end - # `cookies.permanent[:k] = v` — expiry is not modeled; permanence is a - # no-op returning the same jar so the index-assign lands on `[]=`. + # `cookies.permanent[:k] = v` — a view whose writes carry Rails' + # twenty-year expiry. This returned `self` until 2026-10-06, which + # made every "permanent" cookie a session cookie: campfire's sign-in + # ended when the browser closed (found running the Deccan Queen on + # Rails chat room). A view for the same reason `signed` is one: it + # keeps this jar's stores single-assignment. def permanent - self + ActionController::PermanentCookieJar.new(self) end # `cookies.signed[:k]` — a view that signs on the way out and @@ -92,11 +101,65 @@ def raw_set(key, value) @out[key.to_s] end + def record_flags(key, httponly, same_site, secure) + k = key.to_s + @flag_httponly[k] = httponly ? "1" : "" + ss = same_site.to_s + ss = "Lax" if ss == "lax" || ss == "Lax" + ss = "Strict" if ss == "strict" || ss == "Strict" + ss = "None" if ss == "none" || ss == "None" + @flag_samesite[k] = ss + @flag_secure[k] = secure ? "1" : "" + k + end + + # Rails defaults HttpOnly on. A bare `cookies[:k] = v` (or + # `cookies.signed[:k] = v`) never calls `record_flags`, so a missing + # entry means the default, not an opt-out. Explicit `httponly: false` + # records "" and stays off. + def flag_httponly?(key) + k = key.to_s + return true unless @flag_httponly.key?(k) + @flag_httponly[k] == "1" + end + + def flag_samesite(key) + @flag_samesite[key.to_s].to_s + end + + def flag_secure?(key) + @flag_secure[key.to_s] == "1" + end + + # Marks a write as permanent; the `permanent` views call it after + # storing the value. + def record_permanent(key) + k = key.to_s + @flag_expires[k] = ActionController::CookieJar.permanent_expires + k + end + + # The `Expires` a permanent write recorded, as an HTTP date; "" for a + # cookie that ends with the browser session, which is every write + # that didn't go through `permanent`. + def flag_expires(key) + @flag_expires[key.to_s].to_s + end + + # `20.years.from_now`, which is what Rails' permanent jar writes, + # formatted the way Rack writes `expires=`. A calendar twenty years + # rather than a count of seconds, as ActiveSupport's `years` is. + def self.permanent_expires + n = Time.now.utc + Time.utc(n.year + 20, n.month, n.day, n.hour, n.min, n.sec).strftime("%a, %d %b %Y %H:%M:%S GMT") + end + # Removing a cookie is recorded as an empty write; the dispatcher emits a # cleared Set-Cookie. (No separate tombstone type keeps @out a plain # String→String map for the strict typer.) def delete(key) @out[key.to_s] = "" + @flag_expires.delete(key.to_s) "" end @@ -157,8 +220,12 @@ def each # signature, a value signed for a different cookie name, and a string # that is not of the form at all. class SignedCookieJar - def initialize(jar) + # `permanent` is true for `cookies.signed.permanent` and + # `cookies.permanent.signed`: same signing, writes expire with + # Rails' twenty years. + def initialize(jar, permanent = false) @jar = jar + @permanent = permanent end # NIL for an absent cookie and for anything that does not verify — @@ -191,10 +258,10 @@ def [](key) end # Rails takes either a bare value or an options Hash carrying - # `value:` beside `httponly:`/`same_site:` — campfire writes the - # latter. The transport attributes are not modeled (the dispatcher - # emits Path=/ + HttpOnly for every cookie it writes), so what - # survives is the value. + # `value:` beside `httponly:`/`same_site:`/`secure:` — campfire + # writes the latter. Bare writes leave flags unrecorded so + # `flag_httponly?` keeps the HttpOnly default; an options Hash + # records the explicit bits the dispatcher reads. def []=(key, value) signed = ActionController::MessageVerifier.generate( Rails.application.secret_key_base, @@ -203,14 +270,21 @@ def []=(key, value) "cookie." + key.to_s, true ) @jar.raw_set(key, signed) + @jar.record_permanent(key) if @permanent + if value.is_a?(Hash) + ss = value[:same_site] + ss = "" if ss.nil? + # Omitted httponly is the default (on). Only an explicit + # `httponly: false` records an opt-out; `== true` treated a + # missing key as off and dropped HttpOnly on Rack. + @jar.record_flags(key, value[:httponly] != false, ss.to_s, value[:secure] == true) + end value end - # `cookies.signed.permanent[:k] = v` — permanence is not modeled - # (same as the unsigned jar's), so this is the identity that keeps - # the index-assign landing on `[]=` above. + # `cookies.signed.permanent[:k] = v` — campfire's session cookie. def permanent - self + ActionController::SignedCookieJar.new(@jar, true) end def delete(key) @@ -228,6 +302,34 @@ def self.value_of(value) end end + # The `cookies.permanent` view: the unsigned jar's reads and writes, + # with each write recorded as expiring in twenty years. Rails' + # PermanentCookieJar, which is also where `cookies.permanent.signed` + # starts. + class PermanentCookieJar + def initialize(jar) + @jar = jar + end + + def [](key) + @jar.raw(key) + end + + def []=(key, value) + stored = @jar.raw_set(key, value) + @jar.record_permanent(key) + stored + end + + def signed + ActionController::SignedCookieJar.new(@jar, true) + end + + def delete(key) + @jar.delete(key) + end + end + class Base # Lazily initialised so a read before the dispatcher assigns the real jar # (e.g. a unit-constructed controller) still returns a usable empty diff --git a/runtime/ruby/action_controller/cookies.rbs b/runtime/ruby/action_controller/cookies.rbs index acbb04b30..0b220ee1c 100644 --- a/runtime/ruby/action_controller/cookies.rbs +++ b/runtime/ruby/action_controller/cookies.rbs @@ -1,6 +1,8 @@ # Typing contract for cookies.rb beside this file. Getter+setter def pairs -# per the runtime .rbs convention; keys are untyped (controllers index with -# Symbol constants and String literals both). +# per the runtime .rbs convention; keys are String | Symbol (controllers +# index with Symbol constants and String literals both). Values written +# stay untyped — campfire assigns `@room.id` (Integer) and `raw_set` +# coerces with `to_s`. module ActionController class CookieJar # THE STORES, DECLARED. Both are seeded `{}` in the constructor, and @@ -17,20 +19,31 @@ module ActionController # comment below already says. @inbound: Hash[String, String] @out: Hash[String, String] + @flag_httponly: Hash[String, String] + @flag_samesite: Hash[String, String] + @flag_secure: Hash[String, String] + @flag_expires: Hash[String, String] def initialize: (?Hash[String, String] inbound) -> void - def []: (untyped key) -> String + def []: (String | Symbol key) -> String # The written value is untyped and the STORE is String: `raw_set` # coerces, because a cookie is a String on the wire and campfire # writes `cookies.permanent[:last_room] = @room.id`, an Integer. # Returning the stored String rather than the argument keeps the # return concrete; Ruby's `x[k] = v` evaluates to `v` regardless. - def []=: (untyped key, untyped value) -> String - def permanent: () -> ActionController::CookieJar + def []=: (String | Symbol key, untyped value) -> String + def permanent: () -> ActionController::PermanentCookieJar def signed: () -> ActionController::SignedCookieJar - def raw: (untyped key) -> String - def raw_set: (untyped key, untyped value) -> String - def delete: (untyped key) -> String + def raw: (String | Symbol key) -> String + def raw_set: (String | Symbol key, untyped value) -> String + def record_flags: (String | Symbol key, bool httponly, String | Symbol same_site, bool secure) -> String + def flag_httponly?: (String | Symbol key) -> bool + def flag_samesite: (String | Symbol key) -> String + def flag_secure?: (String | Symbol key) -> bool + def record_permanent: (String | Symbol key) -> String + def flag_expires: (String | Symbol key) -> String + def self.permanent_expires: () -> String + def delete: (String | Symbol key) -> String def pending: () -> Hash[String, String] def to_h: () -> Hash[String, String] def to_hash: () -> Hash[String, String] @@ -38,13 +51,30 @@ module ActionController end class SignedCookieJar - def initialize: (ActionController::CookieJar jar) -> void + @jar: ActionController::CookieJar + @permanent: bool + + def initialize: (ActionController::CookieJar jar, ?bool permanent) -> void # NULLABLE, unlike the unsigned jar's read — see the .rb. - def []: (untyped key) -> String? - def []=: (untyped key, untyped value) -> untyped + def []: (String | Symbol key) -> String? + def []=: (String | Symbol key, untyped value) -> untyped def permanent: () -> ActionController::SignedCookieJar - def delete: (untyped key) -> String - def self.value_of: (untyped value) -> String + def delete: (String | Symbol key) -> String + def self.value_of: (Hash[Symbol, untyped] | String | Integer | Symbol | bool value) -> String + end + + class PermanentCookieJar + @jar: ActionController::CookieJar + + def initialize: (ActionController::CookieJar jar) -> void + def []: (String | Symbol key) -> String + # The value union `SignedCookieJar.value_of` already names, not + # `untyped`: a new writer must not add to the Bar B residual + # (tests/runtime_src_integration.rs), and these are the shapes a + # cookie write takes. + def []=: (String | Symbol key, Hash[Symbol, untyped] | String | Integer | Symbol | bool value) -> String + def signed: () -> ActionController::SignedCookieJar + def delete: (String | Symbol key) -> String end class Base @@ -58,7 +88,7 @@ end module ActionDispatch module Cookies class CookieJar - def self.build: (untyped request, Hash[String, String] cookies) -> ActionController::CookieJar + def self.build: (ActionDispatch::Request request, Hash[String, String] cookies) -> ActionController::CookieJar end end end diff --git a/runtime/ruby/action_controller/current.rb b/runtime/ruby/action_controller/current.rb index e527f80c6..905c62961 100644 --- a/runtime/ruby/action_controller/current.rb +++ b/runtime/ruby/action_controller/current.rb @@ -15,6 +15,22 @@ module ActionController class Base attr_accessor :request + + def request_host_for_redirect + r = @request + return "" if r.nil? + r.host.to_s + end + + def request_for_csrf + @request + end + + def csrf_header_token + req = @request + return "" if req.nil? + req.env.fetch("HTTP_X_CSRF_TOKEN", "").to_s + end end module Current diff --git a/runtime/ruby/action_controller/current.rbs b/runtime/ruby/action_controller/current.rbs index ba8059fb0..5495db49f 100644 --- a/runtime/ruby/action_controller/current.rbs +++ b/runtime/ruby/action_controller/current.rbs @@ -5,6 +5,9 @@ module ActionController class Base def request: () -> ActionDispatch::Request? def request=: (ActionDispatch::Request?) -> ActionDispatch::Request? + def request_host_for_redirect: () -> String + def request_for_csrf: () -> ActionDispatch::Request? + def csrf_header_token: () -> String end module Current diff --git a/runtime/ruby/action_dispatch/flash.rbs b/runtime/ruby/action_dispatch/flash.rbs index 4d3d02202..a06893488 100644 --- a/runtime/ruby/action_dispatch/flash.rbs +++ b/runtime/ruby/action_dispatch/flash.rbs @@ -1,8 +1,7 @@ module ActionDispatch # Per-app flash store with typed `notice`/`alert` fields plus HWIA- # shape shim methods. Field-set is closed (mirrors the lowerer's - # `is_flash_name`); shim signatures take `untyped` keys to accept - # Symbol|String. + # `is_flash_name`); shim keys are String | Symbol. class Flash @notice: String? @alert: String? @@ -23,14 +22,14 @@ module ActionDispatch def initialize: (?Hash[String, String]? other) -> void def now: () -> FlashNow - def mark_shown: (untyped key) -> void - def []: (untyped key) -> String? - def []=: (untyped key, String? value) -> String? - def fetch: (untyped key, ?String? default) -> String? - def key?: (untyped key) -> bool - def has_key?: (untyped key) -> bool - def include?: (untyped key) -> bool - def delete: (untyped key) -> String? + def mark_shown: (String | Symbol key) -> void + def []: (String | Symbol key) -> String? + def []=: (String | Symbol key, String? value) -> String? + def fetch: (String | Symbol key, ?String? default) -> String? + def key?: (String | Symbol key) -> bool + def has_key?: (String | Symbol key) -> bool + def include?: (String | Symbol key) -> bool + def delete: (String | Symbol key) -> String? def length: () -> Integer def size: () -> Integer def empty?: () -> bool @@ -48,7 +47,7 @@ module ActionDispatch @flash: Flash def initialize: (Flash flash) -> void - def []=: (untyped key, String? value) -> String? - def []: (untyped key) -> String? + def []=: (String | Symbol key, String? value) -> String? + def []: (String | Symbol key) -> String? end end diff --git a/runtime/ruby/action_dispatch/session.rbs b/runtime/ruby/action_dispatch/session.rbs index 6a964b1e9..aed35a5d9 100644 --- a/runtime/ruby/action_dispatch/session.rbs +++ b/runtime/ruby/action_dispatch/session.rbs @@ -9,13 +9,13 @@ module ActionDispatch def initialize: (?Hash[String, untyped]? other) -> void - def []: (untyped key) -> untyped - def []=: (untyped key, untyped value) -> untyped - def fetch: (untyped key, ?untyped default) -> untyped - def key?: (untyped key) -> bool - def has_key?: (untyped key) -> bool - def include?: (untyped key) -> bool - def delete: (untyped key) -> untyped + def []: (String | Symbol key) -> untyped + def []=: (String | Symbol key, untyped value) -> untyped + def fetch: (String | Symbol key, ?untyped default) -> untyped + def key?: (String | Symbol key) -> bool + def has_key?: (String | Symbol key) -> bool + def include?: (String | Symbol key) -> bool + def delete: (String | Symbol key) -> untyped def length: () -> Integer def size: () -> Integer def empty?: () -> bool diff --git a/runtime/ruby/action_view/view_helpers.rb b/runtime/ruby/action_view/view_helpers.rb index 5f4c68805..2307305ba 100644 --- a/runtime/ruby/action_view/view_helpers.rb +++ b/runtime/ruby/action_view/view_helpers.rb @@ -206,10 +206,10 @@ def self.h(value) "<" => "%3C", "=" => "%3D", ">" => "%3E", "?" => "%3F", "@" => "%40", "[" => "%5B", "\\" => "%5C", "]" => "%5D", "^" => "%5E", "`" => "%60", "{" => "%7B", "|" => "%7C", - "}" => "%7D", + "}" => "%7D", "\r" => "%0D", "\n" => "%0A", "\0" => "%00", }.freeze - URL_ESCAPE_PATTERN = /[ !"\#$%&'()*+,\/:;<=>?@\[\\\]^`{|}]/.freeze + URL_ESCAPE_PATTERN = /[\x00\r\n !"\#$%&'()*+,\/:;<=>?@\[\\\]^`{|}]/.freeze # Same include? probe as html_escape (no portable match? emit). def self.url_encode(s) @@ -277,7 +277,7 @@ def self.to_query_pairs(params, namespace) "<" => "%3C", "=" => "%3D", ">" => "%3E", "?" => "%3F", "@" => "%40", "[" => "%5B", "\\" => "%5C", "]" => "%5D", "^" => "%5E", "`" => "%60", "{" => "%7B", "|" => "%7C", - "}" => "%7D", + "}" => "%7D", "\r" => "%0D", "\n" => "%0A", "\0" => "%00", }.freeze # `URL_ESCAPE_PATTERN` minus the `@`, for `mail_to`'s address. @@ -308,7 +308,7 @@ def self.needs_url_escape?(s) s.include?("<") || s.include?("=") || s.include?(">") || s.include?("?") || s.include?("@") || s.include?("[") || s.include?("\\") || s.include?("]") || s.include?("^") || s.include?("`") || s.include?("{") || s.include?("|") || - s.include?("}") + s.include?("}") || s.include?("\r") || s.include?("\n") || s.include?("\0") end # Like `needs_url_escape?` but without `@` — `MAILTO_ESCAPE_PATTERN`. @@ -571,18 +571,14 @@ def self.button_to(text, href, opts = EMPTY_HTML_OPTS) # `authenticity_token` value is the form-field name; the token value # is empty here because spinel-blog doesn't sign sessions. def self.csrf_meta_tags - %(\n) + %(\n) end # The per-request CSRF token every csrf-emitting helper # (csrf_meta_tags / csrf_token_hidden_input / button_to) reads. - # Empty in the shared runtime — targets without session-backed - # token generation render the same empty value they always have, - # and the compare harness blanks the attribute either way. The - # CRuby overlay overrides this with a session-backed lazy - # generator (runtime/action_controller_session.rb), which is how - # real tokens reach lobsters' login form without the shared - # runtime needing SecureRandom or a session on every target. + # Session-backed and lazy: a page with no form does not grow a + # session. Empty when no controller is parked (unit helpers, or a + # target whose dispatcher does not assign Current.controller). def self.form_authenticity_token "" end @@ -853,7 +849,7 @@ def self.csrf_token_hidden_input # on that lane). The explicit comparison is false for every # target's unset shape and for `false` alike. return "" if @broadcast_rendering == true - %() + %() end # Bracket a broadcast partial render (the lowered @@ -974,8 +970,12 @@ def self.form_with(opts = EMPTY_HTML_OPTS) attrs.delete(:builder) url = opts.fetch(:url, nil) action = url.nil? ? "" : %( action="#{html_escape(url.to_s)}") + # Stringify at this boundary: `opts.fetch` is Hash[Symbol, untyped] + # and a gradual Value must not cross into `method_override_input`'s + # `String | Symbol` param (rust `&str`). Same shape as `mail_to`. + method = opts.fetch(:method, :post) %() + - method_override_input(opts.fetch(:method, :post)) + + method_override_input(method.to_s) + csrf_token_hidden_input + "" end diff --git a/runtime/ruby/action_view/view_helpers.rbs b/runtime/ruby/action_view/view_helpers.rbs index 264974f58..961f7404b 100644 --- a/runtime/ruby/action_view/view_helpers.rbs +++ b/runtime/ruby/action_view/view_helpers.rbs @@ -119,11 +119,7 @@ module ActionView def self.path_to_javascript: (String source, ?skip_pipeline: bool) -> String def self.javascript_path: (String source, ?skip_pipeline: bool) -> String - # Generic element helper: `content_tag :span, text, title: "..."`. - # `content` is untyped — call sites pass strings, but Rails accepts - # anything stringifiable and the body goes through `.to_s` (nil - # default renders ""). - def self.content_tag: (Symbol name, ?untyped? content, ?Hash[Symbol, untyped] opts) -> String + def self.content_tag: (Symbol name, ?(String | Integer | nil) content, ?Hash[Symbol, untyped] opts) -> String # `pins` is the importmap pin array — each entry a record with # fixed `:name` and `:path` fields. Record (not `Hash[Symbol, @@ -137,11 +133,9 @@ module ActionView ?String entry ) -> String - # `stream` is genuinely polymorphic — Rails' turbo_stream_from - # accepts a String channel name OR an Array (composed channel). - # JSON.generate handles either, so untyped here. - def self.turbo_stream_from: (untyped stream, String channel) -> String - def self.signed_stream_name: (untyped stream) -> String + # String channel name or composed Array; JSON.generate handles either. + def self.turbo_stream_from: (String | Array[untyped] stream, String channel) -> String + def self.signed_stream_name: (String | Array[untyped] stream) -> String # ── form_with primitives ─────────────────────────────────────── # Small typed-scalar helpers callable from macro-inlined form_with @@ -151,23 +145,17 @@ module ActionView def self.cache_scope: () -> String def self.begin_broadcast_render: () -> String def self.broadcast_render: (String _armed, String html) -> String - # `untyped`, not `Symbol`: the body's whole use of it is `to_s` and - # two string comparisons, and `form_with` below reads the method out - # of an untyped options hash. Declaring `Symbol` made the honest - # caller the illegal one. - def self.method_override_input: (untyped method) -> String + # HTML method name. Literal callers may pass a Symbol; `form_with` + # stringifies the Hash fetch so a gradual opts value never crosses + # this boundary. + def self.method_override_input: (String | Symbol method) -> String # Blockless `form_with` — the runtime fallback for a `form_with` # written in a HELPER body, where the options are a runtime value. # See the .rb for what it deliberately does not do. def self.form_with: (?Hash[Symbol, untyped] opts) -> String - # `value` accepts the abstract Base#[] return type (the union of - # all schema column types — Int64/String/Float/Bool plus Nil). - # Both helper bodies already nil-check and stringify uniformly, - # so widening to `untyped` keeps the call-site shape simple - # (`optional_value_attr(record[:field])`) without per-column - # casts. - def self.optional_value_attr: (untyped value) -> String - def self.escape_or_empty: (untyped value) -> String + # Schema column union (plus nil). Bodies nil-check and stringify. + def self.optional_value_attr: (String | Integer | Float | bool | nil value) -> String + def self.escape_or_empty: (String | Integer | Float | bool | nil value) -> String # ── attribute rendering ──────────────────────────────────────── # `attrs` is a Symbol-keyed Hash — the shape every caller already diff --git a/runtime/ruby/action_view/view_helpers_ext.rbs b/runtime/ruby/action_view/view_helpers_ext.rbs index 6387384d5..26d0ce24e 100644 --- a/runtime/ruby/action_view/view_helpers_ext.rbs +++ b/runtime/ruby/action_view/view_helpers_ext.rbs @@ -2,15 +2,15 @@ module ActionView module ViewHelpers def self.content_security_policy_nonce: () -> String - def self.class_names: (*untyped args) -> String + def self.class_names: (*(String | Symbol | Integer | bool | Hash[String | Symbol, untyped] | Array[untyped] | nil) args) -> String def self.number_with_delimiter: (Integer value) -> String def self.sanitize_to_id: (String name) -> String def self.capture: () { () -> untyped } -> String def self.concat: (String string) -> String def self.polymorphic_url: (untyped record, ?only_path: bool) -> String - def self.hidden_field_tag: (untyped name, ?untyped value, ?Hash[Symbol, untyped] opts) -> String + def self.hidden_field_tag: (String | Symbol name, ?(String | Integer | Float | bool | nil) value, ?Hash[Symbol, untyped] opts) -> String def self.text_area_tag: (String | Symbol name, ?String? content, ?Hash[Symbol, untyped] opts) -> String - def self.number_with_precision: (untyped value, ?precision: Integer) -> String + def self.number_with_precision: (Integer | Float value, ?precision: Integer) -> String def self.number_to_human: (Integer value, ?format: String) -> String # `::Time`, not `Time` — these sigs sit inside `module ActionView`, # where a bare `Time` resolves to `ActionView::Time` and types the diff --git a/runtime/ruby/active_record/arel.rbs b/runtime/ruby/active_record/arel.rbs index 1d68f80f6..3e9c9207c 100644 --- a/runtime/ruby/active_record/arel.rbs +++ b/runtime/ruby/active_record/arel.rbs @@ -22,9 +22,9 @@ module Arel def initialize: (String name) -> void - def attribute: (untyped column) -> Arel::Attribute + def attribute: (String | Symbol column) -> Arel::Attribute - def project: (untyped column) -> String + def project: (String | Symbol column) -> String end class Attribute @@ -34,9 +34,9 @@ module Arel def to_s: () -> String - def in: (untyped subquery) -> String + def in: (ActiveRecord::Relation | SelectManager subquery) -> String - def not_in: (untyped subquery) -> String + def not_in: (ActiveRecord::Relation | SelectManager subquery) -> String end end diff --git a/runtime/ruby/active_record/connection.rb b/runtime/ruby/active_record/connection.rb index d3606f51f..54ecc0e94 100644 --- a/runtime/ruby/active_record/connection.rb +++ b/runtime/ruby/active_record/connection.rb @@ -402,13 +402,17 @@ def self.none ActiveRecord::Relation.new(self).none end - # Kaminari's class-side entry point, `Model.page(n)`: the same page - # of a fresh Relation (Relation#page). Ruby-family-only for the - # reason `where` above is. + # Class-side `Model.page(n)` / `Model.paginate(...)`: the same + # page of a fresh Relation (`Relation#page` / `#paginate`). + # Ruby-family-only for the reason `where` above is. def self.page(num = nil) ActiveRecord::Relation.new(self).page(num) end + def self.paginate(num = nil, page: nil, per_page: nil) + ActiveRecord::Relation.new(self).paginate(num, page: page, per_page: per_page) + end + # Rails-shape `first` fallback, same story as `where`/`all` above: # spec/dynamic call sites reach the class method directly # (`Category.first` in lobsters' specs); lowered call sites don't diff --git a/runtime/ruby/active_record/connection.rbs b/runtime/ruby/active_record/connection.rbs index 80eb9df0d..19a166c46 100644 --- a/runtime/ruby/active_record/connection.rbs +++ b/runtime/ruby/active_record/connection.rbs @@ -14,15 +14,15 @@ module ActiveRecord class Connection def adapter_name: () -> String - def quote: (untyped value) -> String + def quote: (String | Symbol | Integer | Float | bool | nil value) -> String def quote_string: (String str) -> String def execute: (String sql) -> Result def select_rows: (String sql) -> Array[Array[untyped]] def exec_query: (String sql) -> Result def select_all: (String sql) -> Result - def exec_update: (String sql, ?untyped name, ?Array[untyped] binds) -> Integer - def exec_delete: (String sql, ?untyped name, ?Array[untyped] binds) -> Integer - def exec_insert: (String sql, ?untyped name, ?Array[untyped] binds) -> Result + def exec_update: (String sql, ?String? name, ?Array[String | Integer | Float | bool | nil] binds) -> Integer + def exec_delete: (String sql, ?String? name, ?Array[String | Integer | Float | bool | nil] binds) -> Integer + def exec_insert: (String sql, ?String? name, ?Array[String | Integer | Float | bool | nil] binds) -> Result end class Base @@ -30,27 +30,28 @@ module ActiveRecord def self.sanitize_sql: (Array[untyped] statement) -> String def self.sanitize_sql_array: (Array[untyped] statement) -> String def self.transaction: () { () -> untyped } -> untyped - def self.update_counters: (untyped id, Hash[Symbol, untyped] counters) -> Integer - def self.upsert: (Hash[Symbol, untyped] attrs, ?unique_by: untyped, ?on_duplicate: String?, ?returning: untyped) -> Integer + def self.update_counters: (Integer | String id, Hash[Symbol, untyped] counters) -> Integer + def self.upsert: (Hash[Symbol, untyped] attrs, ?unique_by: (String | Symbol | Array[Symbol]), ?on_duplicate: String?, ?returning: untyped) -> Integer def self._conflict_predicate: (String columns) -> String - def self.upsert_all: (Array[Hash[Symbol, untyped]] rows, ?unique_by: untyped, ?on_duplicate: String?, ?returning: untyped) -> Integer - def self.record_timestamps=: (untyped value) -> untyped + def self.upsert_all: (Array[Hash[Symbol, untyped]] rows, ?unique_by: (String | Symbol | Array[Symbol]), ?on_duplicate: String?, ?returning: untyped) -> Integer + def self.record_timestamps=: (bool value) -> bool def self.record_timestamps: () -> bool - def update_column: (untyped name, untyped value) -> bool + def update_column: (String | Symbol name, untyped value) -> bool def _as_json_only: (Array[Symbol] only) -> Hash[String, untyped] # Ruby-family overrides of base.rb's Array / COUNT fallbacks. def self.where: (Hash[Symbol, untyped] conditions) -> Relation def self.all: () -> Relation def self.none: () -> Relation - def self.page: (?untyped num) -> Relation + def self.page: (?(Integer | String | nil) num) -> Relation + def self.paginate: (?(Integer | String | nil) num, ?page: (Integer | String | nil), ?per_page: (Integer | String | nil)) -> Relation def self.any?: () -> bool def self.none?: () -> bool def _note_hydrated: () -> void def _note_unloaded: (Hash[String, untyped] row) -> nil def has_attribute?: (Symbol name) -> bool def attribute_previously_was: (String name) -> untyped - def self.first: () -> untyped - def self.take: () -> untyped + def self.first: () -> Base? + def self.take: () -> Base? def self._hydrate_all: (String sql) -> Array[Base] def self._cast_primary_key: (Integer | String id) -> (Integer | String | nil) def update_attribute: (Symbol name, untyped value) -> bool diff --git a/runtime/ruby/active_record/relation.rb b/runtime/ruby/active_record/relation.rb index b9cdb03a9..dbb4ba33b 100644 --- a/runtime/ruby/active_record/relation.rb +++ b/runtime/ruby/active_record/relation.rb @@ -177,11 +177,11 @@ def not(condition = nil, *args) # branch, and wrapped in an array it would reach the IN-list branch # and escape a Relation object into the SQL text. def excluding(*records) - if records.length == 1 - self.not(@model.primary_key => records[0]) - else - self.not(@model.primary_key => records) - end + val = records.length == 1 ? records[0] : records + @records = nil + pred = column_predicate(@model.primary_key.to_s, val) + @wheres << "NOT (#{pred})" unless pred.empty? + self end # `without` — Rails' own alias for `excluding`, on both Relation and @@ -423,45 +423,65 @@ def compare_order_keys(a, b) a <=> b end + # The value is a request param as often as a literal + # (`limit(params[:per_page])`), and it is spliced into the SQL, so + # both pass through Rails' own casts here: `limit` is + # `sanitize_limit` (`Integer()`, which raises on anything that is + # not an integer) and `offset` is `build_arel`'s `to_i`. nil clears + # either, as in Rails. def limit(n) @records = nil - @limit = n + # Split rather than `n.nil? ? nil : sql_limit(n)`: Spinel cannot + # unify nil with Integer in a conditional expression. + if n.nil? + @limit = nil + else + @limit = sql_limit(n) + end self end def offset(n) @records = nil - @offset = n + if n.nil? + @offset = nil + else + @offset = n.to_i + end self end - # ---- Kaminari's page / per, over LIMIT / OFFSET ------------------ + # ---- page / per / paginate: LIMIT / OFFSET arithmetic ------------ # - # The catalog types `page` and `per` as builders and the readers - # below as terminals; this is the runtime behind them, with - # Kaminari's arithmetic. `count` already leaves LIMIT and OFFSET out - # of its SQL (`count_sql`), which is exactly Kaminari's - # `total_count`. Not modeled: `padding`, `without_count`, - # `max_per_page` / `max_pages`, and the `Kaminari.paginate_array` - # wrapper for a loaded Array. + # The catalog types `page`, `per`, and `paginate` as builders and + # the readers below as terminals. `count` already leaves LIMIT and + # OFFSET out of its SQL (`count_sql`), which is the unpaginated + # total `total_count` needs. Not modeled: `padding`, `without_count`, + # `max_per_page` / `max_pages`, and wrapping a loaded Array. # # The readers go through locals rather than doing arithmetic on the # ivars: a runtime ivar reads as `T | Nil` (see `ActionController:: - # Page`), and Kaminari's own nil cases are the ones guarded here. + # Page`). # `page(n)`: page `n` at the app's default page size. A nil, blank, - # non-numeric or non-positive `n` is page 1, as Kaminari's `to_i` - # makes it. + # non-numeric or non-positive `n` is page 1 (`to_i`). def page(num = nil) - per_page = Rails.application.kaminari_default_per_page + per_page = Rails.application.default_per_page n = num.to_s.to_i n = 1 if n < 1 limit(per_page) offset((n - 1) * per_page) end - # `per(n)`: the same page at `n` rows. Kaminari leaves the relation - # as it is for a nil, blank or negative `n` (its `/^\d/` test), so + # Same builder as `page` under the `paginate` spelling. A + # positional page number, or `page:` / `per_page:` keywords. + # `per(nil)` is a no-op (`per` only applies a numeric string). + def paginate(num = nil, page: nil, per_page: nil) + self.page(page || num).per(per_page) + end + + # `per(n)`: the same page at `n` rows. A nil, blank or negative `n` + # (the `/^\d/` test) leaves the relation as it is, so # `per(params[:per])` without the parameter keeps the default size; # `per(0)` is `limit(0)`. def per(num) @@ -482,9 +502,8 @@ def offset_value @offset end - # 1 for a relation that was never paged, where Kaminari divides by - # a nil limit; `per(0)` raises, as Kaminari's ZeroPerPageOperation - # (a ZeroDivisionError) does. + # 1 for a relation that was never paged (divide-by-nil limit); + # `per(0)` raises ZeroDivisionError. def current_page per_page = @limit return 1 if per_page.nil? @@ -498,9 +517,9 @@ def total_count count end - # Rounded up; 0 for an empty relation, as in Kaminari, which makes - # page 1 of nothing out of range rather than the last page. A - # relation that was never paged is one page. + # Rounded up; 0 for an empty relation, which makes page 1 of + # nothing out of range rather than the last page. A relation that + # was never paged is one page. def total_pages per_page = @limit return 1 if per_page.nil? @@ -862,13 +881,7 @@ def ==(other) theirs = other.is_a?(ActiveRecord::Relation) ? other.to_a : other return false if !theirs.is_a?(Array) return false if mine.length != theirs.length - i = 0 - same = true - while i < mine.length - same = false if mine[i].id != theirs[i].id - i += 1 - end - same + ids_of(mine) == ids_of(theirs) end # `filter { |r| … }` — Enumerable's filter over the loaded records. @@ -1123,6 +1136,7 @@ def last # left memoized. Otherwise a relation that is paged and then # counted carries the page size into the count. def first_n(n) + n = sql_limit(n) prior = @limit @limit = n rows = to_a @@ -1135,6 +1149,7 @@ def first_n(n) # Unloaded and unwindowed: reverse ORDER BY, LIMIT n, reverse rows. # Loaded or already LIMIT/OFFSET: in-memory tail of that window. def last_n(n) + n = sql_limit(n) loaded = @records unless loaded.nil? return loaded_tail(loaded, n) @@ -1326,7 +1341,7 @@ def length # and unbounded. A LIMIT/OFFSET window must not answer the table # total — `limit(2).size` is at most 2 — so the limited path counts # a `SELECT 1` subquery rather than `count_sql` (which omits LIMIT - # on purpose for Kaminari `total_count`). + # so page totals see the unpaginated result set). def size r = @records return r.length unless r.nil? @@ -1823,15 +1838,26 @@ def column_predicate(col, val) end end - # Replace `?` placeholders in a raw fragment with escaped args, in - # order. A fragment with no `?` returns unchanged. Each `sub` rewrites - # the leftmost remaining `?`, so iterating the args consumes them in - # order. + # For positional binds, split only the original `?` placeholders, + # keeping escaped values verbatim. Preserve trailing empty parts so + # missing binds leave their `?` intact. (`String#sub` used to search + # already-inserted values, so a `?` or backslash in a bind leaked + # into the next placeholder.) def substitute_binds(sql, args) first = args[0] return substitute_named_binds(sql, first) if args.length == 1 && first.is_a?(Hash) - result = sql - args.each { |a| result = result.sub("?", ActiveRecord.adapter.escape_value(a)) } + parts = sql.split("?", -1) + result = parts[0].to_s + index = 0 + while index < parts.length - 1 + if index < args.length + result = result + ActiveRecord.adapter.escape_value(args[index]) + else + result = result + "?" + end + result = result + parts[index + 1].to_s + index += 1 + end result end @@ -1883,17 +1909,87 @@ def escape_list(vals) out.join(", ") end + # Rails' `sanitize_limit`: `Integer(n)`. An integer, or a String + # spelling one (surrounding space allowed), is that integer; a Float + # truncates; anything else is the ArgumentError `Integer()` raises — + # never text in the LIMIT clause. + def sql_limit(n) + return n.to_i if n.is_a?(Float) + text = n.to_s.strip + unless text.match?(/\A[+-]?\d+\z/) + raise ArgumentError, "invalid value for Integer(): \"" + n.to_s + "\"" + end + text.to_i + end + + # An `order` hash's direction: Rails' `VALID_DIRECTIONS`, else the + # ArgumentError `validate_order_args` raises. + def order_direction(dir) + d = dir.to_s + return d.upcase if d == "asc" || d == "desc" || d == "ASC" || d == "DESC" + raise ArgumentError, "Direction \"" + d + "\" is invalid. Valid directions are: " \ + "[:asc, :desc, :ASC, :DESC, \"asc\", \"desc\", \"ASC\", \"DESC\"]" + end + + # An identifier written into SQL: `col` or `table.col`. This runtime + # writes names bare, so anything else is ArgumentError rather than a + # fragment. + def sql_ident(name) + c = name.to_s + unless c.match?(/\A[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?\z/) + raise ArgumentError, "SQL identifier \"" + c + "\" is not a column name" + end + c + end + + # An `order` hash's KEY. Same allowlist as `sql_ident`. + def order_hash_column(col) + sql_ident(col) + end + + # Developer SQL like campfire's `order("LOWER(name)")` / + # `order("LOWER(rooms.name)")`, plus the documented zero-arg + # `RANDOM()` / `random()`. A request-steered fragment such as + # `(SELECT 1)` or `SLEEP()` does not match. + def order_fn_term?(c) + return true if c == "RANDOM()" || c == "random()" + c.match?(/\A[A-Za-z_][A-Za-z0-9_]*\([A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?\)\z/) + end + + # A string `order` argument: comma-separated `col` / `table.col` + # with optional ASC/DESC. Static corpus strings (`"id desc"`, + # `"category asc, tags.tag asc"`) pass; `order(params[:sort])` with + # SQL does not. + def order_string(s) + bits = s.split(",") + raise ArgumentError, "Order \"" + s + "\" is not a column name" if bits.length == 0 + parts = [] + bits.each do |bit| + words = bit.strip.split(/\s+/) + if words.length == 0 || words.length > 2 + raise ArgumentError, "Order \"" + s + "\" is not a column name" + end + col = words[0] + term = order_fn_term?(col) ? col : order_hash_column(col) + if words.length == 2 + parts << "#{term} #{order_direction(words[1])}" + else + parts << term + end + end + parts.join(", ") + end + # `order(:col)` / `order("col DESC")` / `order(col: :desc)` / # `order(rooms: { updated_at: :desc })` — Rails' nested-hash form # for a table-qualified column (campfire's direct-room sidebar). - # A nested value that is itself a Hash is `table.col DIR`, not the - # Hash's `to_s` (which reached SQLite as `rooms {UPDATED_AT: :DESC}` - # and raised `unrecognized token: "{"`). + # Nested values go through `sql_ident` + `order_direction` so a + # request-steered hash cannot splice SQL. def order_term(p) if p.is_a?(Hash) format_order_hash(p) else - p.to_s + order_string(p.to_s) end end @@ -1905,9 +2001,9 @@ def format_order_hash(h) h.each do |col, dir| if dir.is_a?(Hash) inner_col = dir.keys[0] - parts << "#{col}.#{inner_col} #{dir[inner_col].to_s.upcase}" + parts << "#{sql_ident(col)}.#{sql_ident(inner_col)} #{order_direction(dir[inner_col])}" else - parts << "#{col} #{dir.to_s.upcase}" + parts << "#{sql_ident(col)} #{order_direction(dir)}" end end parts.join(", ") diff --git a/runtime/ruby/active_record/relation.rbs b/runtime/ruby/active_record/relation.rbs index 264d4654a..52e850ed2 100644 --- a/runtime/ruby/active_record/relation.rbs +++ b/runtime/ruby/active_record/relation.rbs @@ -1,8 +1,9 @@ module ActiveRecord # Lazy chainable query builder. Chain methods return `self` (typed as - # Relation); terminals execute and return rows / scalars. The element - # type is `untyped` in v1 — the lowered model methods carry the model - # identity; per-element typing is a later (generic-Relation) refinement. + # Relation); terminals execute and return rows / scalars. Loaded + # records are `Base` as an interim (same as first/take) — not + # per-model. `Relation[T]` is the later refinement. `preloaded` and + # set-operator operands stay `untyped` for Spinel integer-seed arrays. class Relation # Model handle is `Base` (class object collapsed onto `Ty::Class{Base}` # in send.rs). Do not spell `Class` — that leaves `@model` as Ty::Var @@ -22,14 +23,14 @@ module ActiveRecord Array[String] groups, Array[String] havings, Array[String] ctes, - Array[Symbol] includes, + Array[Symbol | Hash[Symbol, Symbol]] includes, Hash[Symbol, untyped] attrs ) -> Relation def copy_string_list: (Array[String] xs) -> Array[String] - def copy_symbol_list: (Array[Symbol] xs) -> Array[Symbol] + def copy_symbol_list: (Array[Symbol | Hash[Symbol, Symbol]] xs) -> Array[Symbol | Hash[Symbol, Symbol]] def copy_scope_attributes: (Hash[Symbol, untyped] attrs) -> Hash[Symbol, untyped] - def where: (?untyped condition, *untyped args) -> Relation + def where: (?(Hash[String | Symbol, untyped] | String | nil) condition, *untyped args) -> Relation # Query ivars the body reads after builders write them. Declared so # probes / RBS-seeded typing do not freeze on `initialize`'s `nil` @@ -37,7 +38,8 @@ module ActiveRecord # / loaded Enumerable terminals as Ty::Var. @limit: Integer? @offset: Integer? - @records: Array[untyped]? + @records: Array[Base]? + @includes: Array[Symbol | Hash[Symbol, Symbol]] @from: String? @ctes: Array[String] @@ -54,36 +56,38 @@ module ActiveRecord @scope_attributes: Hash[Symbol, untyped] def where_scope: (Hash[Symbol, untyped] condition) -> Relation def scope_attributes: () -> Hash[Symbol, untyped] - def not: (?untyped condition, *untyped args) -> Relation + def not: (?(Hash[String | Symbol, untyped] | String | nil) condition, *untyped args) -> Relation def excluding: (*untyped records) -> Relation def without: (*untyped records) -> Relation def or: (Relation other) -> Relation - def add_condition: (untyped condition, Array[untyped] args, bool negate) -> bool - def order: (*untyped parts) -> Relation - # The in-memory sort `order` applies to a LOADED relation (see the - # .rb). Element type stays untyped until Relation[T]; the array - # representation is honest — only index/length/map appear here - # (unlike `preloaded`, which must stay bare for Spinel integer seeds). - def sort_in_place!: (Array[untyped] sorted, Array[String] terms) -> bool + def add_condition: (Hash[String | Symbol, untyped] | String | nil condition, Array[untyped] args, bool negate) -> bool + def order: (*(String | Symbol | Hash[Symbol, Symbol | Hash[Symbol, Symbol]]) parts) -> Relation + # In-memory sort of a LOADED relation. Records are `Base` (same + # interim as first/take); `preloaded` stays bare for Spinel integer + # seeds, but the loaded cache itself is a record array. + def sort_in_place!: (Array[Base] sorted, Array[String] terms) -> bool def order_column: (String term) -> String? def order_descending?: (String term) -> bool def reverse_order_term: (String term) -> String def reverse_one_order_term: (String term) -> String - def loaded_tail: (Array[untyped] loaded, Integer n) -> Array[untyped] + def loaded_tail: (Array[Base] loaded, Integer n) -> Array[Base] # The keys are whatever the ordered column holds, and `<=>` on two # of them is an Integer or nil -- the only honest spellings. def compare_order_keys: (untyped a, untyped b) -> Integer? def order_key_of: (Base record, String col) -> untyped - def where!: (?untyped condition, *untyped args) -> Relation - def order!: (*untyped parts) -> Relation - def reorder: (*untyped parts) -> Relation - def limit: (Integer n) -> Relation - def offset: (Integer n) -> Relation - # Kaminari's pagination surface (see the .rb): `page` / `per` take + def where!: (?(Hash[String | Symbol, untyped] | String | nil) condition, *untyped args) -> Relation + def order!: (*(String | Symbol | Hash[Symbol, Symbol | Hash[Symbol, Symbol]]) parts) -> Relation + def reorder: (*(String | Symbol | Hash[Symbol, Symbol | Hash[Symbol, Symbol]]) parts) -> Relation + # Request params (`limit(params[:n])`) are Strings; `sql_limit` + # is Rails' Integer() and rejects SQL. nil clears, as in Rails. + def limit: (Integer | Float | String | nil n) -> Relation + def offset: (Integer | String | nil n) -> Relation + # LIMIT/OFFSET page arithmetic: `page` / `per` / `paginate` take # whatever the request handed over (a params String, an Integer or # nil) and read it through `to_s`. - def page: (?untyped num) -> Relation - def per: (untyped num) -> Relation + def page: (?(Integer | String | nil) num) -> Relation + def paginate: (?(Integer | String | nil) num, ?page: (Integer | String | nil), ?per_page: (Integer | String | nil)) -> Relation + def per: (Integer | String | nil num) -> Relation def limit_value: () -> Integer? def offset_value: () -> Integer? def current_page: () -> Integer @@ -94,12 +98,12 @@ module ActiveRecord def out_of_range?: () -> bool def next_page: () -> Integer? def prev_page: () -> Integer? - def group: (*untyped parts) -> Relation - def having: (untyped condition, *untyped args) -> Relation - def joins: (untyped spec) -> Relation - def join_fragment: (untyped spec) -> String - def left_outer_joins: (untyped spec) -> Relation - def left_joins: (untyped spec) -> Relation + def group: (*(String | Symbol) parts) -> Relation + def having: (String condition, *untyped args) -> Relation + def joins: (String | Symbol spec) -> Relation + def join_fragment: (String | Symbol spec) -> String + def left_outer_joins: (String | Symbol spec) -> Relation + def left_joins: (String | Symbol spec) -> Relation # Two methods sharing a name: field args PROJECT columns and keep # the Relation; a block is Enumerable's filter over the loaded # records and answers an Array (zero args means the block form — @@ -108,14 +112,14 @@ module ActiveRecord # call site in the corpus is the projection. `filter` below is the # same method under Ruby's other name and DOES declare the block # shape, so a strict target that needs it has a spelling. - def select: (*untyped specs) -> Relation + def select: (*(String | Symbol) specs) -> Relation def distinct: () -> Relation - def includes: (*untyped names) -> Relation - def preload: (*untyped names) -> Relation - def eager_load: (*untyped names) -> Relation + def includes: (*(Symbol | Hash[Symbol, Symbol]) names) -> Relation + def preload: (*(Symbol | Hash[Symbol, Symbol]) names) -> Relation + def eager_load: (*(Symbol | Hash[Symbol, Symbol]) names) -> Relation def skip_preloading!: () -> Relation - def preload_associations: (Array[untyped] records) -> Array[untyped] - def references: (*untyped names) -> Relation + def preload_associations: (Array[Base] records) -> Array[Base] + def references: (*Symbol names) -> Relation def merge: (Relation other) -> Relation def where_clauses: () -> Array[String] def arel: () -> Arel::SelectManager @@ -134,15 +138,15 @@ module ActiveRecord def preloaded: (untyped records, bool loaded) -> Relation def klass: () -> Class - def to_a: () -> Array[untyped] - def load_records: () -> Array[untyped] - def loaded_records: () -> Array[untyped] - def to_set: () -> Set[untyped] - def to_ary: () -> Array[untyped] + def to_a: () -> Array[Base] + def load_records: () -> Array[Base] + def loaded_records: () -> Array[Base] + def to_set: () -> Set[Base] + def to_ary: () -> Array[Base] def ==: (untyped other) -> bool - def filter: () { (untyped) -> bool } -> Array[untyped] - def map: () { (untyped) -> untyped } -> untyped - def collect: () { (untyped) -> untyped } -> untyped + def filter: () { (Base) -> bool } -> Array[Base] + def map: () { (Base) -> untyped } -> untyped + def collect: () { (Base) -> untyped } -> untyped # `other` is `untyped`, NOT `Array[untyped]`, on all four set # operators: each body only hands the argument on to Array's own # operator (`to_a + other`), so it does not care about the array's @@ -160,10 +164,10 @@ module ActiveRecord def ids_of: (Array[untyped] records) -> Array[untyped] def id_filter: (Array[untyped] records, Array[untyped] ids, bool keep) -> Array[untyped] def include?: (Base? record) -> bool - def each: () { (untyped) -> void } -> Relation - def index_by: () { (untyped) -> untyped } -> Hash[untyped, untyped] - def find_each: () { (untyped) -> void } -> Relation - def find_in_batches: () { (Array[untyped]) -> void } -> Relation + def each: () { (Base) -> void } -> Relation + def index_by: () { (Base) -> untyped } -> Hash[untyped, Base] + def find_each: () { (Base) -> void } -> Relation + def find_in_batches: () { (Array[Base]) -> void } -> Relation # `map`/`collect` return `untyped`, NOT `Array[untyped]`: the result's # element type is whatever the BLOCK returns, which this RBS has no # type variable to say. `Array[untyped]` is a seed spinel TRUSTS, so @@ -172,20 +176,20 @@ module ActiveRecord # stopped with no diagnostic naming the seed. The two agreed only for # as long as every id was boxed. Widen to `untyped` so the body # decides the shape; `Array[U]` waits on Relation[T]. - def group_by: () { (untyped) -> untyped } -> Hash[untyped, Array[untyped]] - def partition: () { (untyped) -> bool } -> Array[Array[untyped]] - def detect: () { (untyped) -> bool } -> untyped - def sort_by: () { (untyped) -> untyped } -> Array[untyped] - def inject: (untyped initial) { (untyped, untyped) -> untyped } -> untyped - def each_with_object: (untyped memo) { (untyped, untyped) -> untyped } -> untyped + def group_by: () { (Base) -> untyped } -> Hash[untyped, Array[Base]] + def partition: () { (Base) -> bool } -> Array[Array[Base]] + def detect: () { (Base) -> bool } -> Base? + def sort_by: () { (Base) -> untyped } -> Array[Base] + def inject: (untyped initial) { (untyped, Base) -> untyped } -> untyped + def each_with_object: (untyped memo) { (Base, untyped) -> untyped } -> untyped def first: () -> Base? def take: () -> Base? def first!: () -> Base def last: () -> Base? - def first_n: (Integer n) -> Array[untyped] - def last_n: (Integer n) -> Array[untyped] + def first_n: (Integer | Float | String n) -> Array[Base] + def last_n: (Integer | Float | String n) -> Array[Base] def count: () -> Integer - def sum: (untyped expr) -> Float + def sum: (String | Symbol expr) -> Float def group_count: () -> Hash[untyped, Integer] def empty?: () -> bool def any?: () -> bool @@ -195,17 +199,17 @@ module ActiveRecord def none?: () -> bool def one?: () -> bool def many?: () -> bool - def all?: () { (untyped) -> bool } -> bool + def all?: () { (Base) -> bool } -> bool def exists?: (?Integer? id) -> bool def probe_existence: (Integer n) -> Integer def more_than?: (Integer n) -> bool def length: () -> Integer def size: () -> Integer def delete_all: () -> Integer - def destroy_all: () -> Array[untyped] - def destroy_by: (untyped conditions) -> Array[untyped] - def delete_by: (untyped conditions) -> Integer - def update_all: (untyped updates) -> Integer + def destroy_all: () -> Array[Base] + def destroy_by: (Hash[Symbol, untyped] | String conditions) -> Array[Base] + def delete_by: (Hash[Symbol, untyped] | String conditions) -> Integer + def update_all: (Hash[Symbol, untyped] | String updates) -> Integer def touch_all: (?Symbol? name) -> Integer # The WHERE a bulk write takes — a plain clause, or an # `IN (SELECT …)` subquery when the scope carries a join. @@ -226,13 +230,13 @@ module ActiveRecord # lowerer monomorphizing per call site from the schema. # `ids` is Array[Integer] for integer-key apps; string/uuid apps # widen it via `project::widen_key_contract` (#310). - def pluck: (untyped col) -> Array[untyped] - def pick: (untyped col) -> untyped + def pluck: (String | Symbol col) -> Array[untyped] + def pick: (String | Symbol col) -> untyped def ids: () -> Array[Integer] def find: (Integer | String | Array[Integer | String] id) -> untyped - def find_ids: (Array[Integer | String] ids) -> Array[untyped] - def find_by: (untyped conditions) -> Base? - def find_by!: (untyped conditions) -> Base + def find_ids: (Array[Integer | String] ids) -> Array[Base] + def find_by: (Hash[Symbol, untyped] | String | nil conditions) -> Base? + def find_by!: (Hash[Symbol, untyped] | String | nil conditions) -> Base def first_or_initialize: () -> Base def find_or_create_by: (Hash[Symbol, untyped] conditions) -> Base @@ -243,14 +247,20 @@ module ActiveRecord def append_group_having: (String sql) -> String def count_sql: () -> String def exists_sql: (Integer n) -> String - def hash_conditions: (untyped hash) -> String - def column_predicate: (String col, untyped val) -> String + def hash_conditions: (Hash[String | Symbol, untyped] hash) -> String + def column_predicate: (String col, Relation | Array[untyped] | Base | String | Integer | Float | bool | nil val) -> String def substitute_binds: (String sql, Array[untyped] args) -> String def substitute_named_binds: (String sql, Hash[Symbol, untyped] binds) -> String def named_bind_start?: (String ch) -> bool def named_bind_char?: (String ch) -> bool def escape_list: (Array[untyped] vals) -> String - def order_term: (Hash[Symbol, untyped] | String p) -> String + def order_term: (String | Symbol | Hash[Symbol, Symbol | Hash[Symbol, Symbol]] p) -> String def format_order_hash: (Hash[Symbol, untyped] h) -> String + def sql_limit: (Integer | Float | String n) -> Integer + def order_direction: (String | Symbol dir) -> String + def sql_ident: (String | Symbol name) -> String + def order_hash_column: (String | Symbol col) -> String + def order_fn_term?: (String c) -> bool + def order_string: (String s) -> String end end diff --git a/runtime/ruby/active_storage.rb b/runtime/ruby/active_storage.rb index 91bee90ae..599b01e73 100644 --- a/runtime/ruby/active_storage.rb +++ b/runtime/ruby/active_storage.rb @@ -753,7 +753,14 @@ def self.expiry "\"" + ActionController::MessageVerifier.iso8601_ms(Time.now + 300) + "\"" end + # Only `inline` or `attachment` is ever signed — Rails' + # `content_disposition_with` (`presence_in(%w[attachment inline]) || + # "inline"`). The blob redirect route takes the disposition from a + # query param, and what is signed here comes back out as the disk + # route's Content-Disposition header: anything else signed as asked + # was a header line of the requester's choosing. def self.encode(key, disposition) + disposition = "inline" unless disposition == "attachment" ActionController::MessageVerifier.data_envelope( Rails.application.secret_key_base, "ActiveStorage", ActionController::MessageVerifier.json_string(key + "|" + disposition), diff --git a/runtime/ruby/rails.rb b/runtime/ruby/rails.rb index 7e0933ddd..0e513a2ee 100644 --- a/runtime/ruby/rails.rb +++ b/runtime/ruby/rails.rb @@ -498,11 +498,11 @@ def vips_blocked_operations [] end - # `config.default_per_page = N` inside `Kaminari.configure` in an - # initializer: the page size `Relation#page` applies. Lifted at - # ingest onto the reopen like the settings above; Kaminari's own - # default when the app configures none. - def kaminari_default_per_page + # Default page size for `Relation#page`. Ingest lifts a literal + # `config.default_per_page = N` from a `Kaminari.configure` block + # (one input spelling) onto this reopen; 25 when the app configures + # none. + def default_per_page 25 end end diff --git a/runtime/ruby/rails.rbs b/runtime/ruby/rails.rbs index 29805abed..f60288edb 100644 --- a/runtime/ruby/rails.rbs +++ b/runtime/ruby/rails.rbs @@ -35,7 +35,7 @@ module Rails @entries: Hash[String, String] @expires_at: Hash[String, Integer] def initialize: () -> void - def fetch: (untyped key, ?Hash[untyped, untyped] opts) { () -> untyped } -> untyped + def fetch: (String | Symbol | Array[untyped] key, ?Hash[untyped, untyped] opts) { () -> untyped } -> untyped def fetch_str: (String key, Integer ttl) { () -> String } -> String # `String?` because nil is the MISS. An empty String is a HIT — a # fragment can legitimately render to nothing, and reading that back @@ -45,10 +45,10 @@ module Rails def increment_str: (String key, Integer ttl) -> Integer def forget: (String k) -> void def clear: () -> void - def read: (untyped key) -> untyped - def write: (untyped key, untyped value) -> untyped - def delete: (untyped key) -> untyped - def exist?: (untyped key) -> bool + def read: (String | Symbol | Array[untyped] key) -> untyped + def write: (String | Symbol | Array[untyped] key, untyped value) -> untyped + def delete: (String | Symbol | Array[untyped] key) -> untyped + def exist?: (String | Symbol | Array[untyped] key) -> bool end class Logger @@ -70,7 +70,7 @@ module Rails def active_storage_excluded_content_types: () -> Array[String] def vips_block_untrusted: () -> bool def vips_blocked_operations: () -> Array[String] - def kaminari_default_per_page: () -> Integer + def default_per_page: () -> Integer end end diff --git a/runtime/ruby/security_utils.rb b/runtime/ruby/security_utils.rb new file mode 100644 index 000000000..315736e66 --- /dev/null +++ b/runtime/ruby/security_utils.rb @@ -0,0 +1,9 @@ +require_relative "action_controller/message_verifier" + +module ActiveSupport + module SecurityUtils + def self.secure_compare(a, b) + ActionController::MessageVerifier.secure_compare(a, b) + end + end +end diff --git a/runtime/ruby/security_utils.rbs b/runtime/ruby/security_utils.rbs new file mode 100644 index 000000000..1b4246fbe --- /dev/null +++ b/runtime/ruby/security_utils.rbs @@ -0,0 +1,5 @@ +module ActiveSupport + module SecurityUtils + def self.secure_compare: (String a, String b) -> bool + end +end diff --git a/runtime/ruby/test/action_controller/cookies_test.rb b/runtime/ruby/test/action_controller/cookies_test.rb index 6d6e97554..3ed0bb7c3 100644 --- a/runtime/ruby/test/action_controller/cookies_test.rb +++ b/runtime/ruby/test/action_controller/cookies_test.rb @@ -109,9 +109,68 @@ def test_signed_write_then_read_round_trips def test_signed_accepts_the_options_hash_write_form # `cookies.signed.permanent[:k] = {value:, httponly:, same_site:}` # — how campfire's Authentication concern writes the session. + jar = ActionController::CookieJar.new({}) + jar.signed.permanent[:session_token] = { value: "tok", httponly: true, same_site: :lax, secure: true } + assert_equal "tok", jar.signed[:session_token] + assert jar.flag_httponly?(:session_token) + assert_equal "Lax", jar.flag_samesite(:session_token) + assert jar.flag_secure?(:session_token) + end + + # ── permanence ─────────────────────────────────────────────── + # `permanent` was the identity until 2026-10-06, so every "permanent" + # cookie went out without Expires and died with the browser session: + # campfire's sign-in did not survive a restart. Each spelling Rails + # accepts must record the twenty-year expiry, and nothing else may. + + def test_permanent_writes_carry_a_twenty_year_expiry + jar = ActionController::CookieJar.new({}) + jar.permanent[:last_room] = 7 + assert_equal "7", jar[:last_room], "a permanent write is still an ordinary write" + assert_equal "7", jar.permanent[:last_room] + exp = jar.flag_expires(:last_room) + assert_match(/\A\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT\z/, exp) + assert_equal (Time.now.utc.year + 20).to_s, exp.split(" ")[3] + end + + def test_signed_permanent_in_either_order_carries_the_expiry jar = ActionController::CookieJar.new({}) jar.signed.permanent[:session_token] = { value: "tok", httponly: true, same_site: :lax } + jar.permanent.signed[:remember] = "me" assert_equal "tok", jar.signed[:session_token] + assert_equal "me", jar.signed[:remember] + refute_equal "", jar.flag_expires(:session_token) + refute_equal "", jar.flag_expires(:remember) + assert_equal "Lax", jar.flag_samesite(:session_token), "options still apply under permanent" + end + + def test_only_permanent_writes_expire + jar = ActionController::CookieJar.new({}) + jar[:plain] = "x" + jar.signed[:signed_only] = "y" + jar.permanent[:kept] = "z" + assert_equal "", jar.flag_expires(:plain) + assert_equal "", jar.flag_expires(:signed_only) + jar.delete(:kept) + assert_equal "", jar.flag_expires(:kept), "a deleted cookie is cleared, not kept for twenty years" + end + + def test_bare_writes_default_httponly_and_honor_an_explicit_opt_out + jar = ActionController::CookieJar.new({}) + jar[:last_room] = 7 + jar.signed[:session_token] = "abc123" + assert jar.flag_httponly?(:last_room), "unsigned bare write keeps the HttpOnly default" + assert jar.flag_httponly?(:session_token), "signed bare write keeps the HttpOnly default" + jar.signed[:open_token] = { value: "tok", httponly: false } + refute jar.flag_httponly?(:open_token) + refute jar.flag_secure?(:last_room) + jar.signed[:cross] = { value: "tok", same_site: :none, secure: true } + assert_equal "None", jar.flag_samesite(:cross) + assert jar.flag_secure?(:cross) + assert jar.flag_httponly?(:cross), "options hash without httponly keeps the default" + jar.signed[:strict] = { value: "tok", same_site: :strict } + assert_equal "Strict", jar.flag_samesite(:strict) + assert jar.flag_httponly?(:strict) end # NIL, as Rails answers — the signed read is the one nullable read in diff --git a/runtime/ruby/test/action_view/view_helpers_test.rb b/runtime/ruby/test/action_view/view_helpers_test.rb index 57f168169..edcc337a9 100644 --- a/runtime/ruby/test/action_view/view_helpers_test.rb +++ b/runtime/ruby/test/action_view/view_helpers_test.rb @@ -393,6 +393,12 @@ def test_method_override_input_empty_for_get_post assert_equal "", ViewHelpers.method_override_input(:post) end + def test_form_with_stringifies_method_from_opts + out = ViewHelpers.form_with(url: "/articles", method: :patch) + assert_includes out, %(name="_method") + assert_includes out, %(value="patch") + end + def test_optional_value_attr_emits_for_non_empty assert_equal %( value="hello"), ViewHelpers.optional_value_attr("hello") end diff --git a/runtime/ruby/test/active_record/base_test.rb b/runtime/ruby/test/active_record/base_test.rb index 473223cc2..edf9d530a 100644 --- a/runtime/ruby/test/active_record/base_test.rb +++ b/runtime/ruby/test/active_record/base_test.rb @@ -331,7 +331,7 @@ def test_relation_last_page_short_loaded_page_skips_count_path def test_relation_last_page_empty_out_of_range_is_not_last it = Item.new; it.title = "A"; it.save() - # Page past the end loads empty; Kaminari's last_page? is false there + # Page past the end loads empty; last_page? is false there # (current_page > total_pages), not true via the short-page shortcut. rel = ActiveRecord::Relation.new(Item).order("id").limit(10).offset(10) rel.to_a diff --git a/runtime/spinel/active_storage_disk.rb b/runtime/spinel/active_storage_disk.rb index ce148f203..011e6ade1 100644 --- a/runtime/spinel/active_storage_disk.rb +++ b/runtime/spinel/active_storage_disk.rb @@ -442,10 +442,12 @@ def show head(:not_found) return nil end - @headers["Content-Disposition"] = disposition + "; filename=\"" + - ActiveStorage.url_filename(blob.filename.to_s) + "\"" - @headers["Cache-Control"] = "max-age=3600, public" + headers["Cache-Control"] = "max-age=3600, public" send_data(service.download(key), type: blob.content_type, disposition: disposition) + # send_data writes only `inline`/`attachment`; restore the + # sanitized filename after that overwrite. + headers["Content-Disposition"] = (disposition == "attachment" ? "attachment" : "inline") + "; filename=\"" + + ActiveStorage.url_filename(blob.filename.to_s) + "\"" nil end end diff --git a/runtime/spinel/active_storage_disk.rbs b/runtime/spinel/active_storage_disk.rbs index 4ec799400..36410edfe 100644 --- a/runtime/spinel/active_storage_disk.rbs +++ b/runtime/spinel/active_storage_disk.rbs @@ -53,6 +53,7 @@ module ActiveStorage end class DiskController < ActionController::Base + @headers: ActionController::HeaderStore def process_action: (Symbol action_name) -> nil def show: () -> nil end diff --git a/runtime/spinel/cgi_io.rb b/runtime/spinel/cgi_io.rb index bc3a44652..79e96cab6 100644 --- a/runtime/spinel/cgi_io.rb +++ b/runtime/spinel/cgi_io.rb @@ -23,6 +23,7 @@ # Hash mutation. No metaprogramming. require "json" +require_relative "http_headers" module CgiIo REASON_PHRASES = { @@ -111,14 +112,14 @@ def self.write_response(io, status, body, location: nil, content_type: "text/htm code = status.is_a?(Integer) ? status : status.to_i reason = REASON_PHRASES.fetch(code, "OK") io.write("Status: #{code} #{reason}\r\n") - io.write("Content-Type: #{content_type}\r\n") - io.write("Location: #{location}\r\n") unless location.nil? - extra_headers.each { |k, v| io.write("#{k}: #{v}\r\n") unless v.nil? } + write_header(io, "Content-Type", content_type.to_s) + write_header(io, "Location", location.to_s) unless location.nil? + extra_headers.each { |k, v| write_header(io, k.to_s, v.to_s) unless v.nil? } set_cookies.each do |name, val| if val.nil? - io.write("Set-Cookie: #{name}=; Path=/; Max-Age=0\r\n") + write_header(io, "Set-Cookie", "#{name}=; Path=/; Max-Age=0") else - io.write("Set-Cookie: #{name}=#{url_encode(val.to_s)}; Path=/; HttpOnly\r\n") + write_header(io, "Set-Cookie", "#{name}=#{url_encode(val.to_s)}; Path=/; HttpOnly") end end io.write("\r\n") @@ -126,6 +127,20 @@ def self.write_response(io, status, body, location: nil, content_type: "text/htm nil end + # One header line, or nothing when it cannot be one line. A value can + # carry request data (a redirect Location, a Content-Disposition, a + # blob's Content-Type), and a CR or LF in it would end the header and + # write the rest as a header the app never set. Puma's rule, the same + # one the spinel servers apply (`Tep.header_lines` — this lane does + # not load Tep, so the two predicates are twins): DROP a key holding a + # control character, space, `"` or `:`, or a value holding a control + # character other than tab. + def self.write_header(io, key, value) + return nil unless HttpHeaders.key_ok?(key) && HttpHeaders.value_ok?(value) + io.write(key + ": " + value + "\r\n") + nil + end + # ── cookie parsing ────────────────────────────────────────────── # Parse a `Cookie:` header value into a Hash[Symbol => String]. diff --git a/runtime/spinel/http_headers.rb b/runtime/spinel/http_headers.rb new file mode 100644 index 000000000..fec5d6ca9 --- /dev/null +++ b/runtime/spinel/http_headers.rb @@ -0,0 +1,27 @@ +# One copy of Puma's illegal-header predicates for every Spinel HTTP/1.1 +# writer (Tep servers and CGI). A key or value that cannot be one line is +# dropped, not rewritten. +module HttpHeaders + def self.key_ok?(k) + n = k.bytesize + return false if n == 0 + i = 0 + while i < n + b = k.getbyte(i) + return false if b <= 32 || b == 127 || b == 34 || b == 58 + i += 1 + end + true + end + + def self.value_ok?(v) + n = v.bytesize + i = 0 + while i < n + b = v.getbyte(i) + return false if (b < 32 && b != 9) || b == 127 + i += 1 + end + true + end +end diff --git a/runtime/spinel/scaffold/main.rb b/runtime/spinel/scaffold/main.rb index 0d2ec6958..170d9625d 100644 --- a/runtime/spinel/scaffold/main.rb +++ b/runtime/spinel/scaffold/main.rb @@ -543,8 +543,13 @@ def self.dispatch(req, res) # value is a header the app UNSET (campfire's `X-Rev` is # `ENV["GIT_REVISION"]`, absent outside its own deploy) and is not # written: the wire has no spelling for it. - controller.headers.each do |k, v| + i = 0 + n = controller.headers.size + while i < n + k = controller.headers.key_at(i) + v = controller.headers.val_at(i) res.headers[k] = v unless v.nil? + i += 1 end # Outbound flash: persist messages set THIS request for the NEXT one. @@ -554,13 +559,13 @@ def self.dispatch(req, res) persisted = controller.flash.to_persisted pn = persisted.fetch("notice", "") if pn.length > 0 - Main.set_flash_cookie(res, "flash_notice", ActionDispatch::SignedCookie.sign(pn, "flash_notice")) + Main.set_flash_cookie(res, "flash_notice", ActionDispatch::SignedCookie.sign(pn, "flash_notice"), request_obj.ssl?) elsif req.cookies.fetch("flash_notice", "").length > 0 Main.clear_flash_cookie(res, "flash_notice") end pa = persisted.fetch("alert", "") if pa.length > 0 - Main.set_flash_cookie(res, "flash_alert", ActionDispatch::SignedCookie.sign(pa, "flash_alert")) + Main.set_flash_cookie(res, "flash_alert", ActionDispatch::SignedCookie.sign(pa, "flash_alert"), request_obj.ssl?) elsif req.cookies.fetch("flash_alert", "").length > 0 Main.clear_flash_cookie(res, "flash_alert") end @@ -571,10 +576,19 @@ def self.dispatch(req, res) out_cookies = controller.cookies.pending ock = out_cookies.keys ci = 0 + jar = controller.cookies while ci < ock.length cname = ock[ci] copts = Tep.str_hash copts["Path"] = "/" + copts["HttpOnly"] = +"" if jar.flag_httponly?(cname) + ss = jar.flag_samesite(cname) + copts["SameSite"] = ss if ss.length > 0 + # SameSite=None is ignored by browsers unless Secure is set. + copts["Secure"] = +"" if jar.flag_secure?(cname) || request_obj.ssl? || ss == "None" + # `cookies.permanent` — without it the cookie ends with the browser. + exp = jar.flag_expires(cname) + copts["Expires"] = exp if exp.length > 0 res.set_cookie(cname, out_cookies[cname], copts) ci += 1 end @@ -594,7 +608,7 @@ def self.dispatch(req, res) Main.clear_flash_cookie(res, session_cookie) else Main.set_flash_cookie(res, session_cookie, - ActionDispatch::Session.signed_cookie(session_out, session_cookie)) + ActionDispatch::Session.signed_cookie(session_out, session_cookie), request_obj.ssl?) end end end @@ -602,10 +616,12 @@ def self.dispatch(req, res) # Flash cookies are HttpOnly + Path=/; the read side is server-only # (no JS access). A set carries the message to the next request; a # clear (empty value + Max-Age=0) expires a consumed one. - def self.set_flash_cookie(res, name, value) + def self.set_flash_cookie(res, name, value, secure) opts = Tep.str_hash opts["Path"] = "/" opts["HttpOnly"] = +"" + opts["SameSite"] = "Lax" + opts["Secure"] = +"" if secure res.set_cookie(name, value, opts) end diff --git a/runtime/spinel/scaffold/ruby_overlay/main.rb b/runtime/spinel/scaffold/ruby_overlay/main.rb index 702ecc41c..1eaa2b6e7 100644 --- a/runtime/spinel/scaffold/ruby_overlay/main.rb +++ b/runtime/spinel/scaffold/ruby_overlay/main.rb @@ -34,9 +34,12 @@ module Main # Dispatch one request to a response descriptor — the single source # of routing / controller / flash / redirect logic. Returns the - # 6-tuple `[status, body, content_type, location, set_cookies, extra_headers]` (the - # exact argument shape `CgiIo.write_response` consumes), leaving - # serialization to the caller. Two thin wrappers sit on top: + # 10-tuple `[status, body, content_type, location, set_cookies, + # extra_headers, secure_cookies, samesite_cookies, httponly_cookies, + # expires_cookies]` + # (the first six are the exact argument shape `CgiIo.write_response` + # consumes; the rest are the explicit cookie-flag maps `run_rack` + # needs). Two thin wrappers sit on top: # `run` (CGI byte stream — tests + one-shot script mode) and # `run_rack` (a Rack tuple — the Puma serving path), so neither the # CGI string nor the Rack hash is the canonical form and the dispatch @@ -119,7 +122,7 @@ def self.dispatch_core_inner(env, stdin) matched = ActionDispatch::Router.match(request[:method], request_path, route_table) if matched.nil? - return [404, "

404 Not Found

", "text/html; charset=utf-8", nil, {}, {}] + return [404, "

404 Not Found

", "text/html; charset=utf-8", nil, {}, {}, {}, {}, {}] end # A `(.:format)` EXTENSION the router stripped off the path # (`/rooms/3/refresh.turbo_stream`). The `.json` sniff above runs @@ -218,7 +221,7 @@ def self.dispatch_core_inner(env, stdin) begin controller.process_action(matched.action) rescue ActiveRecord::RecordNotFound - return [404, "

404 Not Found

", "text/html; charset=utf-8", nil, {}, {}] + return [404, "

404 Not Found

", "text/html; charset=utf-8", nil, {}, {}, {}, {}, {}] end # Dispatch on status, not on @location nil-ness: redirect_to @@ -243,8 +246,22 @@ def self.dispatch_core_inner(env, stdin) out_cookies[:flash_alert] = nil end # Cookies the action wrote (`cookies[:k] = v` / `cookies.permanent`) - # ride out alongside the flash cookies. - controller.cookies.pending.each { |k, v| out_cookies[k] = v } + # ride out alongside the flash cookies. Flag maps stay off the + # value so `run_rack` can emit Secure / the requested SameSite. + jar = controller.cookies + secure_cookies = {} + samesite_cookies = {} + httponly_cookies = {} + expires_cookies = {} + jar.pending.each do |k, v| + out_cookies[k] = v + secure_cookies[k] = true if jar.flag_secure?(k) + httponly_cookies[k] = jar.flag_httponly?(k) + ss = jar.flag_samesite(k).to_s + samesite_cookies[k] = ss if ss.length > 0 + exp = jar.flag_expires(k) + expires_cookies[k] = exp if exp.length > 0 + end # Session persistence: re-encode whatever the action (or a lazy # CSRF token generation during render) left in the session, and # Set-Cookie only on change. An emptied session (reset_session @@ -260,11 +277,17 @@ def self.dispatch_core_inner(env, stdin) # Headers the action set beyond Content-Type/Location — a # `Content-Disposition` on a download, the Cache-Control a blob # route asks for — ride as the tuple's sixth element. - extra_headers = controller.headers + extra_headers = {} + hi = 0 + hn = controller.headers.size + while hi < hn + extra_headers[controller.headers.key_at(hi)] = controller.headers.val_at(hi) + hi += 1 + end if is_redirect [controller.status, %(Redirecting), - "text/html; charset=utf-8", controller.location, out_cookies, extra_headers] + "text/html; charset=utf-8", controller.location, out_cookies, extra_headers, secure_cookies, samesite_cookies, httponly_cookies, expires_cookies] else # The controller body IS the full page: the Ruby emit path's # `apply_layout_lowering` wraps each html action render in @@ -285,13 +308,13 @@ def self.dispatch_core_inner(env, stdin) controller.request_format == :turbo_stream || controller.content_type != "text/html; charset=utf-8" [controller.status, controller.body, - controller.content_type, controller.location, out_cookies, extra_headers] + controller.content_type, controller.location, out_cookies, extra_headers, secure_cookies, samesite_cookies, httponly_cookies, expires_cookies] elsif controller.request_format == :rss [controller.status, controller.body, - "application/rss+xml; charset=utf-8", controller.location, out_cookies, extra_headers] + "application/rss+xml; charset=utf-8", controller.location, out_cookies, extra_headers, secure_cookies, samesite_cookies, httponly_cookies, expires_cookies] else [controller.status, controller.body, - "text/html; charset=utf-8", controller.location, out_cookies, extra_headers] + "text/html; charset=utf-8", controller.location, out_cookies, extra_headers, secure_cookies, samesite_cookies, httponly_cookies, expires_cookies] end end end @@ -320,7 +343,7 @@ def self.run(env, stdin, stdout) # entry per cookie) and reuses `CgiIo.url_encode` so values match the # CGI path exactly. def self.run_rack(env) - status, body, content_type, location, set_cookies, extra_headers = + status, body, content_type, location, set_cookies, extra_headers, secure_cookies, samesite_cookies, httponly_cookies, expires_cookies = dispatch_core(env, env["rack.input"] || StringIO.new("")) headers = { "content-type" => content_type } headers["location"] = location unless location.nil? @@ -328,11 +351,21 @@ def self.run_rack(env) # with GIT_REVISION) — Rack 3 refuses a nil, so it is not written. extra_headers.each { |k, v| headers[k.to_s.downcase] = v unless v.nil? } cookies = [] + https = env["HTTPS"].to_s == "on" || env.fetch("HTTP_X_FORWARDED_PROTO", "").to_s.split(",").first.to_s.strip.downcase == "https" set_cookies.each do |name, val| cookies << if val.nil? "#{name}=; Path=/; Max-Age=0" else - "#{name}=#{CgiIo.url_encode(val.to_s)}; Path=/; HttpOnly" + ss = (samesite_cookies && samesite_cookies[name]).to_s + ss = "Lax" if ss.empty? + line = "#{name}=#{CgiIo.url_encode(val.to_s)}; Path=/" + # Default HttpOnly. Session/flash cookies are not in the map. + # An explicit httponly: false records false and is omitted. + line = line + "; HttpOnly" unless httponly_cookies && httponly_cookies.key?(name) && !httponly_cookies[name] + line = line + "; SameSite=#{ss}" + line = line + "; Expires=#{expires_cookies[name]}" if expires_cookies && expires_cookies.key?(name) + line = line + "; Secure" if https || (secure_cookies && secure_cookies[name]) || ss == "None" + line end end headers["set-cookie"] = cookies unless cookies.empty? diff --git a/runtime/spinel/signed_cookies.rb b/runtime/spinel/signed_cookies.rb index a302a9b2d..bce0a9ef4 100644 --- a/runtime/spinel/signed_cookies.rb +++ b/runtime/spinel/signed_cookies.rb @@ -18,11 +18,9 @@ # the "authenticated encrypted cookie" salt), so a client cannot read # its own session either. Here it can: the session payload is base64 # of the url-encoded `k=v` pairs, the flash payload the message text. -# The session holds the CSRF token (already in every page it renders) -# and app keys such as campfire's `return_to_after_authenticating`; -# a flash message is text the next page shows the same user anyway. -# Integrity is what the CSRF check and the app need; confidentiality -# is the gap. +# Integrity is HMAC; confidentiality is a named residual until an +# AES-GCM CookieStore lands in this runtime. Dispatch now emits +# `Secure` on HTTPS and `SameSite=Lax` on the session/flash cookies. # * NOT INTEROPERABLE. The session payload is this runtime's `k=v` # encoding, not Rails' JSON, so a Rails session cookie does not # restore here (it reads as empty) and ours would not restore in diff --git a/runtime/spinel/tep/server.rb b/runtime/spinel/tep/server.rb index 0c60c9cb1..5c4b45fcf 100644 --- a/runtime/spinel/tep/server.rb +++ b/runtime/spinel/tep/server.rb @@ -263,15 +263,8 @@ def write_response(client, req, res, keep_alive) def build_head(req, res) reason = Tep.reason(res.status) head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n" - res.headers.each do |k, v| - head << k + ": " + v + "\r\n" - end - # Set-Cookie can repeat; emit each on its own line. - ci = 0 - while ci < res.set_cookies.length - head << "Set-Cookie: " + res.set_cookies[ci] + "\r\n" - ci += 1 - end + # Set-Cookie can repeat; header_lines emits each on its own line. + head << Tep.header_lines(res) head + "\r\n" end diff --git a/runtime/spinel/tep/server_scheduled.rb b/runtime/spinel/tep/server_scheduled.rb index 26c519521..2e1cbb3f0 100644 --- a/runtime/spinel/tep/server_scheduled.rb +++ b/runtime/spinel/tep/server_scheduled.rb @@ -304,12 +304,7 @@ def self.write_response(client, req, res, keep_alive) end reason = Tep.reason(res.status) head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n" - res.headers.each do |k, v| - head << k + ": " + v + "\r\n" - end - res.set_cookies.each do |line| - head << "Set-Cookie: " + line + "\r\n" - end + head << Tep.header_lines(res) head << "Connection: close\r\n\r\n" Sock.sphttp_write_str(client, head) out = Tep::Stream.new(client) @@ -328,12 +323,7 @@ def self.write_response(client, req, res, keep_alive) Tep.maybe_gzip!(req, res) reason = Tep.reason(res.status) head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n" - res.headers.each do |k, v| - head << k + ": " + v + "\r\n" - end - res.set_cookies.each do |line| - head << "Set-Cookie: " + line + "\r\n" - end + head << Tep.header_lines(res) if keep_alive head << "Connection: keep-alive\r\n" else diff --git a/runtime/spinel/tep/server_threaded.rb b/runtime/spinel/tep/server_threaded.rb index 6c52a3243..698a05ed4 100644 --- a/runtime/spinel/tep/server_threaded.rb +++ b/runtime/spinel/tep/server_threaded.rb @@ -299,12 +299,7 @@ def self.write_response(client, io, req, res, keep_alive) end reason = Tep.reason(res.status) head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n" - res.headers.each do |k, v| - head << k + ": " + v + "\r\n" - end - res.set_cookies.each do |line| - head << "Set-Cookie: " + line + "\r\n" - end + head << Tep.header_lines(res) head << "Connection: close\r\n\r\n" Sock.sphttp_write_str(client, head) out = Tep::Stream.new(client) @@ -320,12 +315,7 @@ def self.write_response(client, io, req, res, keep_alive) Tep.maybe_gzip!(req, res) reason = Tep.reason(res.status) head = req.http_version + " " + res.status.to_s + " " + reason + "\r\n" - res.headers.each do |k, v| - head << k + ": " + v + "\r\n" - end - res.set_cookies.each do |line| - head << "Set-Cookie: " + line + "\r\n" - end + head << Tep.header_lines(res) if keep_alive head << "Connection: keep-alive\r\n" else diff --git a/runtime/spinel/tep/tep_core.rb b/runtime/spinel/tep/tep_core.rb index 2f357ebcd..c0bacecdd 100644 --- a/runtime/spinel/tep/tep_core.rb +++ b/runtime/spinel/tep/tep_core.rb @@ -1,5 +1,6 @@ require "digest" require "zlib" +require_relative "../http_headers" module Tep # The name the server announces itself by. scaffold/main.rb sets @@ -82,6 +83,38 @@ def self.decimal_byte_count(s) v end + # The response's header lines and Set-Cookie lines, each ending in + # CRLF — every server's head is its status line, this, and its own + # framing headers. A value the app wrote can come from a request + # (a redirect Location built from a param, a Content-Disposition, a + # cookie option), and a CR or LF inside one ends the header early and + # writes whatever follows as a header — or a body — of the attacker's + # choosing. So a header that cannot be written as ONE line is DROPPED, + # Puma's rule (`illegal_header_key?` / `illegal_header_value?`, puma + # 8.0): a key with a control character, space, `"` or `:`, or a value + # with a control character other than tab. The rest of the response + # goes out. + def self.header_lines(res) + out = +"" + res.headers.each do |k, v| + if HttpHeaders.key_ok?(k) && HttpHeaders.value_ok?(v) + out << k + ": " + v + "\r\n" + end + end + res.set_cookies.each do |line| + out << "Set-Cookie: " + line + "\r\n" if HttpHeaders.value_ok?(line) + end + out + end + + def self.header_key_ok?(k) + HttpHeaders.key_ok?(k) + end + + def self.header_value_ok?(v) + HttpHeaders.value_ok?(v) + end + # The largest request body the servers will read, in bytes. Headers # were always capped (MAX_REQUEST_BYTES); the body was not, and every # drain held the whole of it in one String before the app saw the diff --git a/runtime/spinel/test/cgi_io_test.rb b/runtime/spinel/test/cgi_io_test.rb index 8c2b6d841..db16fa33e 100644 --- a/runtime/spinel/test/cgi_io_test.rb +++ b/runtime/spinel/test/cgi_io_test.rb @@ -46,6 +46,31 @@ def test_parse_request_handles_extra_whitespace # ── write_response: set_cookies ────────────────────────────────── + # ── write_response: header injection ───────────────────────────── + + # Every header the app hands over can carry request data — the + # Location of `redirect_to params[:back]`, a Content-Disposition, the + # blob's own Content-Type — and a CR or LF in it would end the header + # and write the rest as one the app never set. Puma's rule, as the + # spinel servers apply it (Tep.header_lines): a header that cannot be + # one line is dropped, and the response still goes out. + def test_write_response_drops_a_header_carrying_a_control_character + io = StringIO.new + CgiIo.write_response(io, 302, "

", + location: "/next\r\nSet-Cookie: pwned=1", + content_type: "text/html\r\nX-Type-Injected: 1", + extra_headers: { "Content-Disposition" => "inline\nX-Injected: 1", + "X-Bad\r\nX-Key-Injected" => "v", + "X-Ok" => "fine\tstill fine" }) + head = io.string.split("\r\n\r\n", 2).first + lines = head.split("\r\n") + assert lines.none? { |l| l.include?("\n") }, head.inspect + assert lines.none? { |l| l.start_with?("Set-Cookie: pwned", "X-Injected", "X-Key-Injected", "X-Type-Injected") }, head.inspect + assert lines.none? { |l| l.start_with?("Location:", "Content-Disposition:", "Content-Type:") }, head.inspect + assert_includes lines, "X-Ok: fine\tstill fine" + assert_includes lines, "Status: 302 Found" + end + def test_write_response_no_cookies_emits_no_set_cookie_header io = StringIO.new CgiIo.write_response(io, 200, "

") diff --git a/runtime/spinel/test/test_helper.rb b/runtime/spinel/test/test_helper.rb index c96c0de3f..d337c77f8 100644 --- a/runtime/spinel/test/test_helper.rb +++ b/runtime/spinel/test/test_helper.rb @@ -1816,6 +1816,13 @@ def dispatch_request(method, path, params, headers = {}, as = nil) @__cookies = ActionController::CookieJar.new( accept_cookies(cookies.to_h, controller.cookies.pending) ) + copied_headers = {} + hi = 0 + hn = controller.headers.size + while hi < hn + copied_headers[controller.headers.key_at(hi)] = controller.headers.val_at(hi) + hi += 1 + end @__response = ActionResponse.new( status: controller.status, body: controller.body, @@ -1825,7 +1832,7 @@ def dispatch_request(method, path, params, headers = {}, as = nil) content_type: controller.content_type, cache_control_max_age: controller.cache_control_max_age, cache_control_public: controller.cache_control_public, - headers: controller.headers, + headers: copied_headers, ) # Rails' OWN names, alongside the `__`-prefixed ones the harness # methods read. An integration test writes `@response.body` and diff --git a/scripts/ci-apt-install b/scripts/ci-apt-install index ca1844fd4..d8daac455 100755 --- a/scripts/ci-apt-install +++ b/scripts/ci-apt-install @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# `apt-get update` + `apt-get install`, BOUNDED ON THE NETWORK. +# `apt-get install`, BOUNDED ON THE NETWORK, without a mandatory update. # # Usage: scripts/ci-apt-install libsqlite3-dev [more packages...] # @@ -21,16 +21,50 @@ # the wasted minutes: run 32207123547 reported "cancelled" while # campfire's floor and every toolchain in it were green. A run # conclusion that says nothing is worse than one that says no. +# +# Hosted Ubuntu images already carry an apt index. `apt-get update` was +# 13–50s of several jobs (and the hang surface above) before any +# package work. Skip packages that dpkg already has, try the image +# index first, and only refresh when that install cannot find a package. set -euo pipefail if [ "$#" -eq 0 ]; then - echo "usage: $0 [package...]" >&2 - exit 2 + echo "usage: $0 [package...]" >&2 + exit 2 +fi + +installed() { + local status + status="$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null || true)" + [ "$status" = installed ] +} + +need=() +for pkg in "$@"; do + if installed "$pkg"; then + echo "ci-apt-install: $pkg already installed" + else + need+=("$pkg") + fi +done + +if [ "${#need[@]}" -eq 0 ]; then + echo "ci-apt-install: nothing to install" + exit 0 fi # The bounds live in an apt.conf.d drop-in rather than on this command # line, so the apt calls we do NOT make (playwright's) get them too. "$(dirname "$0")/ci-apt-bound" +install_pkgs() { + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends "$@" +} + +if install_pkgs "${need[@]}"; then + exit 0 +fi + +echo "ci-apt-install: image index missed packages; apt-get update and retry" >&2 sudo DEBIAN_FRONTEND=noninteractive apt-get update -q -sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q "$@" +install_pkgs "${need[@]}" diff --git a/scripts/ci-plan.py b/scripts/ci-plan.py index 988aeb703..a7b2efcd1 100755 --- a/scripts/ci-plan.py +++ b/scripts/ci-plan.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""Select coverage, not test results. Unknown inputs expand to full validation.""" +"""Select coverage, not test results. + +Extra-language SDKs need a path owner, `ci:full`, or scheduled full +validation. Unknown inputs keep the Ruby floor plus Spinel. +""" import argparse import json @@ -33,8 +37,8 @@ "campfire-compare", ] # Compact publication additionally waits on Rust/TS when those jobs were -# selected (full/main, or a change that owns them). Unselected skips must -# not fail the Ruby PR floor. +# selected (scheduled full, or a change that owns them). Unselected skips +# must not fail the Ruby PR floor. PUBLICATION = [*BASE, "compare", "browser-smoke-typescript"] CORE = ["build-spinel", "toolchain-spinel", "compare-spinel"] SPINEL_TESTS = [ @@ -104,6 +108,7 @@ def native_coverage(path): suites.add("db_sqlite_concurrency") if path in { "tests/param_binds_emit.rb", + "tests/param_binds_raw_where.rb", "tests/param_binds_runtime.rb", "tests/support/emit_and_run.rs", "src/lower/model_to_library/adapter_emit.rs", @@ -246,19 +251,6 @@ def select( spinel_tests.update(SPINEL_TESTS) reasons.append(f"{path}: proven {project_scope} assembly bodies only") continue - if path.startswith((".github/", ".cargo/")) or path in { - "scripts/ci-plan.py", - "scripts/ci-reuse.py", - "scripts/ci-archive-evidence.py", - "src/project.rs", - "Cargo.toml", - "Cargo.lock", - "build.rs", - "rust-toolchain.toml", - ".cargo/config.toml", - }: - full = True - reasons.append(f"{path}: validation/packaging policy") match = re.match(r"(?:src/emit/|runtime/)([^/.]+)(?:[/.]|$)", path) test = re.match( r"tests/(?:framework_tests_)?([a-z]+)_toolchain\.rs$|tests/framework_tests_([a-z]+)\.rs$", @@ -304,15 +296,6 @@ def select( elif target == "shared": full = True reasons.append(f"{path}: shared code generation") - elif target and target not in { - "ruby", - "ruby_family", - "roda", - "mod", - "rails", - }: - full = True - reasons.append(f"{path}: unknown target ownership") if path.startswith("wasm/"): wasm = True reasons.append(f"{path}: WASM/browser compiler") @@ -441,7 +424,8 @@ def project_change_scope(before, after): """Narrow only body-only edits in known builders; all other bytes must match. This is not a Rust parser. Only indented bodies without raw strings or - block comments qualify; unknown shapes/signatures/items retain full CI. + block comments qualify; unknown shapes/signatures/items do not narrow + and stay on the Ruby floor. """ pattern = re.compile( r"(?P

^fn (?P" @@ -628,8 +612,10 @@ def main(): event_name == "push" and os.environ.get("GITHUB_REF") == "refs/heads/main" and not pr + and not full ): - full = True + # Extra-language SDKs are the scheduled full-ci ledger, not every merge. + spinel_lane = True reason = None try: # project_scope only narrows path selection; spinel/full short-circuit @@ -641,12 +627,15 @@ def main(): need_project_scope=not full and not spinel_lane, ) except (KeyError, ValueError, UnicodeError, subprocess.CalledProcessError) as e: - paths, project_scope, full, reason = ( - [], - None, - True, - f"Unknown changed inputs: {e}; running full validation", - ) + paths, project_scope = [], None + if full: + reason = f"Unknown changed inputs: {e}; running full validation" + else: + spinel_lane = True + reason = ( + f"Unknown changed inputs: {e}; " + "Ruby+Spinel only (extra-language SDKs not selected)" + ) publish = os.environ.get("CI_PUBLISH") == "true" if publish and ( os.environ["GITHUB_REPOSITORY"] != "rubys/roundhouse" diff --git a/scripts/ci-unit-tests.py b/scripts/ci-unit-tests.py index ed59e3e26..b51a7cda9 100755 --- a/scripts/ci-unit-tests.py +++ b/scripts/ci-unit-tests.py @@ -174,15 +174,13 @@ def selectors(names: list[str]) -> list[str]: def build_and_run_lib_bins(*, timings: bool) -> int: - build = ["test", "--locked", "--lib", "--bins", "--no-run"] + # One cargo invocation: a split --no-run + run pair re-fingerprints + # the same artifacts and was paying a second process on every shard 0. + command = ["test", "--locked", "--lib", "--bins"] if timings: - build.append("--timings") - print("== unit batch: build library + binaries ==", flush=True) - status = run_cargo(build) - if status != 0: - return status - print("== unit batch: run library + binaries ==", flush=True) - return run_cargo(["test", "--locked", "--lib", "--bins"]) + command.append("--timings") + print("== unit batch: library + binaries ==", flush=True) + return run_cargo(command) def build_and_run_integration_batch( @@ -196,16 +194,17 @@ def build_and_run_integration_batch( label = f"{batch_index}/{batch_count}" flags = selectors(names) print( - f"== unit batch {label}: build {len(names)} integration target(s) ==", + f"== unit batch {label}: {len(names)} integration target(s) ==", flush=True, ) - build = ["test", "--locked", *flags, "--no-run"] + command = ["test", "--locked", *flags] if timings and batch_index == 1: # One timings report for the first integration wave; later waves would # overwrite cargo-timing.html and add little signal for disk work. - build.append("--timings") - status = run_cargo(build) + command.append("--timings") + status = run_cargo(command) if status != 0: + # Keep failing artifacts for local inspection; do not free on failure. return status missing = [name for name in names if not integration_executables(deps, name)] if missing: @@ -214,14 +213,6 @@ def build_and_run_integration_batch( file=sys.stderr, ) return 1 - print( - f"== unit batch {label}: run {len(names)} integration target(s) ==", - flush=True, - ) - status = run_cargo(["test", "--locked", *flags]) - if status != 0: - # Keep failing artifacts for local inspection; do not free on failure. - return status freed = free_integration_targets(deps, names) print( f"== unit batch {label}: freed {freed} bytes of finished integration artifacts ==", diff --git a/scripts/compare b/scripts/compare index 11aae762d..642703ac6 100755 --- a/scripts/compare +++ b/scripts/compare @@ -311,10 +311,10 @@ ok "fixture ready ($FIXTURE)" # -------- 2. compare tool -------- -if [[ ! -x "$COMPARE_BIN" ]]; then - step "building roundhouse-compare" - (cd "$REPO_ROOT/tools/compare" && cargo build --release --quiet) -fi +# Always invoke Cargo so a restored rust-cache binary cannot skip a +# source rebuild (the executable-exists check is not a freshness check). +step "building roundhouse-compare" +(cd "$REPO_ROOT/tools/compare" && cargo build --release --quiet) ok "compare tool ready" # -------- 3. regenerate target -------- @@ -345,7 +345,8 @@ if (( SKIP_EMIT == 0 )); then # per-target /tmp/rh-*-pass2 dir. For go that includes the # v2 overlay (always on); the build step below builds the # v2 binary from cmd/v2/. - (cd "$REPO_ROOT" && cargo run --quiet --bin emit_preview -- --target "$TARGET" "$FIXTURE" >/dev/null) + (cd "$REPO_ROOT" && cargo build --quiet --bin emit_preview) + (cd "$REPO_ROOT" && "$REPO_ROOT/target/debug/emit_preview" --target "$TARGET" "$FIXTURE" >/dev/null) ;; esac fi @@ -394,9 +395,11 @@ case "$TARGET" in (cd "$BUILD_DIR" && JAVA_HOME="${KOTLIN_JAVA_HOME:-${JAVA_HOME:-/opt/homebrew/opt/openjdk}}" PATH="${KOTLIN_JAVA_HOME:-${JAVA_HOME:-/opt/homebrew/opt/openjdk}}/bin:$PATH" gradle installDist --console=plain -q) ;; swift) - # SPM release build → a single binary at .build/release/App. - step "building swift target (swift build -c release)" - (cd "$BUILD_DIR" && swift build -c release 2>&1 | tail -2) + # Debug is the same check as tests/swift_toolchain.rs and the + # published README (`swift build`): type-check, link, boot. Release + # WMO was ~150s of compare-extra (swift) with no extra verification. + step "building swift target (swift build)" + (cd "$BUILD_DIR" && swift build -Xswiftc -gnone 2>&1 | tail -2) ;; csharp) # `dotnet build -c Release` → bin/Release/net10.0/roundhouse-app.dll, @@ -495,8 +498,8 @@ if [[ -z "$TARGET_CMD" ]]; then TARGET_PID=$! ;; swift) - # The release binary reads BLOG_DB + PORT (main.swift). - (cd "$BUILD_DIR" && BLOG_DB="$DB_DEST" PORT="$TARGET_PORT" ./.build/release/App >"$TARGET_LOG" 2>&1) & + # The debug binary reads BLOG_DB + PORT (main.swift). + (cd "$BUILD_DIR" && BLOG_DB="$DB_DEST" PORT="$TARGET_PORT" ./.build/debug/App >"$TARGET_LOG" 2>&1) & TARGET_PID=$! ;; csharp) diff --git a/scripts/e2e b/scripts/e2e index 256ebee5b..a175d689c 100755 --- a/scripts/e2e +++ b/scripts/e2e @@ -238,9 +238,8 @@ case "$TARGET" in fail "build failed"; tail -30 "$LOG_DIR/build.log" >&2; exit 1; } ;; swift) - # SPM release build -> a single binary at .build/release/App. - # Mirrors `scripts/compare swift`. - (cd "$APP_DIR" && swift build -c release) \ + # Debug SPM build -> .build/debug/App. Mirrors `scripts/compare swift`. + (cd "$APP_DIR" && swift build -Xswiftc -gnone) \ >"$LOG_DIR/build.log" 2>&1 || { fail "build failed"; tail -30 "$LOG_DIR/build.log" >&2; exit 1; } ;; @@ -347,9 +346,9 @@ case "$TARGET" in TARGET_PID=$! ;; swift) - # The release binary reads BLOG_DB + PORT (main.swift). + # The debug binary reads BLOG_DB + PORT (main.swift). (cd "$APP_DIR" && BLOG_DB="$DB_REL" PORT="$PORT" \ - ./.build/release/App >"$TARGET_LOG" 2>&1) & + ./.build/debug/App >"$TARGET_LOG" 2>&1) & TARGET_PID=$! ;; esac diff --git a/src/analyze/body/mod.rs b/src/analyze/body/mod.rs index 2f99af328..3957915ca 100644 --- a/src/analyze/body/mod.rs +++ b/src/analyze/body/mod.rs @@ -22,6 +22,20 @@ use crate::expr::{Expr, ExprNode, HashRest, LValue, Literal, MatchPattern}; use crate::ident::{ClassId, Symbol, TyVar}; use crate::ty::{Row, Ty}; +/// A break in a bytes block can replace the method's String result. Nested +/// lambdas/iterators and while/until loops own their breaks independently. +fn bytes_block_has_escaping_break(e: &Expr) -> bool { + match &*e.node { + ExprNode::Break { .. } => true, + ExprNode::Lambda { .. } | ExprNode::While { .. } => false, + _ => { + let mut found = false; + e.node.for_each_child(&mut |child| found |= bytes_block_has_escaping_break(child)); + found + } + } +} + mod diagnostic; mod const_resolution; pub(crate) use const_resolution::{ConstResolver, ConstResolverTask}; @@ -929,6 +943,17 @@ impl<'a> BodyTyper<'a> { } else { None }; + if recv_ty.as_ref() == Some(&Ty::Str) && method.as_str() == "bytes" + && let Some(b) = block.as_ref() + && let ExprNode::Lambda { body, .. } = &*b.node + && bytes_block_has_escaping_break(body) + { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: Symbol::from("String#bytes block break"), + detail: "an escaping break can replace the receiver result; its return type is not modeled".into(), + }); + } if method.as_str() == "new" && matches!(&recv_ty, Some(Ty::Class { id, .. }) if id.0.as_str() == "Data") && self.const_resolver.as_ref().is_some_and(|resolver| !resolver.has_source_namespace("Data")) @@ -3220,6 +3245,18 @@ mod tests { } } + #[test] + fn union_of_bool_int_nil_is_structural_not_debug_order() { + // Debug-string sort put Bool before Int; `ty_tag` puts Int first. + let got = union_of(union_of(Ty::Bool, Ty::Int), Ty::Nil); + assert_eq!( + got, + Ty::Union { + variants: vec![Ty::Int, Ty::Bool, Ty::Nil] + } + ); + } + #[test] fn union_of_is_associative() { let universe = law_universe(); diff --git a/src/analyze/body/send.rs b/src/analyze/body/send.rs index 32a11d208..fe9445326 100644 --- a/src/analyze/body/send.rs +++ b/src/analyze/body/send.rs @@ -318,6 +318,7 @@ impl<'a> BodyTyper<'a> { return self.block_params_for(Some(&as_array), method); } match recv_ty { + Ty::Str if method.as_str() == "bytes" => Some(vec![Ty::Int]), Ty::Array { elem } => match method.as_str() { "each" | "map" | "collect" | "flat_map" | "collect_concat" | "select" | "filter" | "reject" @@ -1461,6 +1462,7 @@ impl<'a> BodyTyper<'a> { Some(Ty::Record { row }) => record_method(method, row, args), // A method the app adds by reopening `String` (campfire's // `all_emoji?`) answers where the builtin table has nothing. + Some(Ty::Str) if method.as_str() == "bytes" && block_ret.is_some() => Ty::Str, Some(Ty::Str) => match str_method(method) { Ty::Var { .. } => self .lookup_string_instance(method) @@ -2443,7 +2445,8 @@ pub(super) fn str_method(method: &Symbol) -> Ty { "=~" => Ty::Union { variants: vec![Ty::Int, Ty::Nil] }, // `index`/`rindex` → the substring position or nil. "index" | "rindex" => Ty::Union { variants: vec![Ty::Int, Ty::Nil] }, - "chars" | "lines" | "split" | "bytes" | "scan" => Ty::Array { elem: Box::new(Ty::Str) }, + "bytes" => Ty::Array { elem: Box::new(Ty::Int) }, + "chars" | "lines" | "split" | "scan" => Ty::Array { elem: Box::new(Ty::Str) }, "empty?" | "blank?" | "present?" | "include?" | "start_with?" | "end_with?" | "match?" => Ty::Bool, // ActiveSupport `Object#presence_in(collection)` — the receiver diff --git a/src/analyze/class_configuration.rs b/src/analyze/class_configuration.rs index 0956fa0dc..7ed3acbc7 100644 --- a/src/analyze/class_configuration.rs +++ b/src/analyze/class_configuration.rs @@ -101,7 +101,6 @@ impl Analyzer { } seed_empty_hashes(&mut method.body, &ty); self.body_typer().analyze_expr(&mut method.body, &ctx); - method.effects = self.collect_effects(&mut method.body, &ctx); method.signature = Some(Ty::Fn { params: method .params diff --git a/src/analyze/effects.rs b/src/analyze/effects.rs index 34502e24a..cbcd69cc5 100644 --- a/src/analyze/effects.rs +++ b/src/analyze/effects.rs @@ -3,17 +3,117 @@ //! controller render/redirect). Inherent `Analyzer` methods extracted //! verbatim from `src/analyze/mod.rs` (pure code motion). -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use crate::adapter::ArMethodKind; +use crate::App; +use crate::dialect::ControllerBodyItem; use crate::effect::{Effect, EffectSet}; use crate::expr::{Expr, ExprNode, LValue}; -use crate::ident::Symbol; +use crate::ident::{ClassId, Symbol}; use crate::ty::Ty; use super::Ctx; impl super::Analyzer { + /// Walk every live body once after the typing fixpoint. Effect + /// annotation does not feed harvest/unify/`inference_sig`, so doing + /// it inside each typing round (Campfire: 1 initial + 8 retypes, + /// and controllers collect on Phase A plus each Phase B sweep) + /// only repeated the same tree walk. `visit_effects` reads `self_ty` + /// for implicit-self Sends and the already-typed receiver `ty`; it + /// does not read ivar/local bindings. + pub(super) fn stamp_body_effects(&self, app: &mut App) { + let _stamp = crate::timings::begin("stamp effects"); + for controller in &mut app.controllers { + let id = controller.name.clone(); + for item in &mut controller.body { + match item { + ControllerBodyItem::Action { action, .. } => { + action.effects = self.stamp_expr(&id, &mut action.body); + } + ControllerBodyItem::ClassMethod { method, .. } => { + self.stamp_method(&id, method); + } + _ => {} + } + } + } + for model in &mut app.models { + let id = model.name.clone(); + for method in model.methods_mut() { + self.stamp_method(&id, method); + } + } + for lc in &mut app.library_classes { + let id = lc.name.clone(); + for method in &mut lc.methods { + self.stamp_method(&id, method); + } + } + for module in &mut app.test_modules { + let id = module.name.clone(); + for method in &mut module.helpers { + self.stamp_method(&id, method); + } + } + if let Some(expr) = app.seeds.as_mut() { + let _ = self.collect_effects(expr, &Ctx::default()); + } + + let mut by_key: HashMap<(ClassId, Symbol), EffectSet> = HashMap::new(); + for controller in &app.controllers { + for action in controller.actions() { + by_key.insert( + (controller.name.clone(), action.name.clone()), + action.effects.clone(), + ); + } + for method in controller.class_methods() { + by_key.insert( + (controller.name.clone(), method.name.clone()), + method.effects.clone(), + ); + } + } + for lc in &app.library_classes { + for method in &lc.methods { + by_key.insert((lc.name.clone(), method.name.clone()), method.effects.clone()); + } + } + let parents: HashMap> = app + .controllers + .iter() + .map(|c| (c.name.clone(), c.parent.clone())) + .collect(); + for resolution in app.controller_resolutions.values_mut() { + for rf in &mut resolution.filter_chain { + if rf.filter.kind.is_skip() { + continue; + } + rf.effects = lookup_filter_effects(&by_key, &parents, rf); + } + } + } + + fn stamp_expr(&self, class: &ClassId, expr: &mut Expr) -> EffectSet { + let ctx = Ctx { + self_ty: Some(Ty::Class { + id: class.clone(), + args: vec![], + }), + ..Ctx::default() + }; + self.collect_effects(expr, &ctx) + } + + fn stamp_method(&self, class: &ClassId, method: &mut crate::dialect::MethodDef) { + method.effects = self.stamp_expr(class, &mut method.body); + if let Some(Ty::Fn { effects, .. }) = &mut method.signature { + *effects = method.effects.clone(); + } + } + pub(super) fn collect_effects(&self, expr: &mut Expr, ctx: &Ctx) -> EffectSet { let mut set = BTreeSet::new(); self.visit_effects(expr, ctx, &mut set); @@ -300,3 +400,35 @@ impl super::Analyzer { } } } + +/// Filter-target effects follow Ruby method lookup: the class that +/// carried the filter into the chain, then its parent controllers +/// nearest-first, then `defined_in` (concern modules). A subclass +/// `before_action :load_room` whose method lives on the parent would +/// miss both `(included_via, target)` and `(defined_in, target)` — +/// those names are the subclass, where the method was never stamped. +fn lookup_filter_effects( + by_key: &HashMap<(ClassId, Symbol), EffectSet>, + parents: &HashMap>, + rf: &crate::app::ResolvedFilter, +) -> EffectSet { + let target = &rf.filter.target; + if let Some(effects) = by_key.get(&(rf.included_via.clone(), target.clone())) { + return effects.clone(); + } + let mut current = rf.included_via.clone(); + let mut seen = std::collections::HashSet::new(); + while seen.insert(current.clone()) { + let Some(parent) = parents.get(¤t).and_then(|p| p.clone()) else { + break; + }; + if let Some(effects) = by_key.get(&(parent.clone(), target.clone())) { + return effects.clone(); + } + current = parent; + } + by_key + .get(&(rf.defined_in.clone(), target.clone())) + .cloned() + .unwrap_or_default() +} diff --git a/src/analyze/mod.rs b/src/analyze/mod.rs index da1fb4b0a..e510b996f 100644 --- a/src/analyze/mod.rs +++ b/src/analyze/mod.rs @@ -66,8 +66,8 @@ use rubydex::model::ids::DeclarationId; use crate::adapter::{DatabaseAdapter, SqliteAdapter}; use crate::App; use crate::dialect::{ - Action, Controller, ControllerBodyItem, Filter, FilterKind, LayoutDecl, ModelBodyItem, - RenderTarget, + Action, Controller, ControllerBodyItem, Filter, FilterKind, LayoutDecl, MethodDef, Model, + ModelBodyItem, RenderTarget, }; use crate::effect::EffectSet; use crate::expr::{Expr, ExprNode, LValue, Literal}; @@ -129,6 +129,28 @@ pub struct Analyzer { data_factories: HashMap, } +/// Snapshot of the data the fixpoint refines: per-class instance/class +/// method return types plus `inferred_params`. HashMap equality is +/// order-independent, so this matches the previous sorted-string +/// fingerprint without `Debug`-formatting every `Ty` on every round. +#[derive(Clone)] +struct InferenceSig { + instance: HashMap>, + class_methods: HashMap>, + params: HashMap<(ClassId, Symbol), Vec>, +} + +/// Which call-site trees `unify_params_from_call_sites` walks. +/// Production rounds skip views/tests/seeds: those trees are still +/// ingest-shaped until after the production fixpoint (wave 12). +/// Test sites are overlaid separately so later test-only rounds can +/// reuse a production+view param snapshot. +#[derive(Clone, Copy)] +enum UnifyScope { + Production, + WithViews, +} + impl Analyzer { /// Build an analyzer with the default database adapter /// (`SqliteAdapter`). Matches pre-adapter-refactor behavior — @@ -175,7 +197,7 @@ impl Analyzer { // those fabricates methods the class does not have, and the // fabrication is not inert: an instance receiver resolves // `class_methods` BEFORE `instance_methods` (the parent-chain - // walk in `body/send.rs`), so `@search.page` reached kaminari's + // walk in `body/send.rs`), so `@search.page` reached the // class-side `page` builder instead of the `attr_accessor`. // That mistyped as `Array[Search]` for as long as chain starts // were Array-shaped — wrong, but renderable. Convergence @@ -844,10 +866,50 @@ impl Analyzer { /// types from method bodies into the dispatch registry, (b) unifies /// parameter types across call sites, and (c) re-runs typing with /// the refined registry. Iterates to a fixed point (capped; see - /// `FIXPOINT_CAP`) using a signature fingerprint to detect convergence. + /// `FIXPOINT_CAP`) using a structural registry snapshot to detect convergence. pub fn analyze(&mut self, app: &mut App) { const FIXPOINT_CAP: usize = 12; - crate::timings::phase("typing passes (initial)", || self.run_typing_passes(app)); + // View-name and dynamic-render ivar sets are invariant across + // fixpoint rounds — they read source views, not the registry. + let mut dynamic_render_ivars: std::collections::HashSet = + std::collections::HashSet::new(); + for view in &app.views { + collect_dynamic_render_ivars(&view.body, &mut dynamic_render_ivars); + } + let existing_view_names: std::collections::HashSet = + app.views.iter().map(|v| v.name.clone()).collect(); + // Module method tables and controller parent links are invariant + // across fixpoint rounds — clone once instead of rebuilding them + // on every `run_typing_passes` (Campfire: 1 initial + 8 rounds). + let module_methods: HashMap> = app + .library_classes + .iter() + .filter(|lc| lc.is_module) + .map(|lc| (lc.name.clone(), lc.methods.clone())) + .collect(); + let module_includes: HashMap> = app + .library_classes + .iter() + .filter(|lc| lc.is_module) + .map(|lc| (lc.name.clone(), lc.includes.clone())) + .collect(); + let parent_link_by_name: HashMap> = app + .controllers + .iter() + .map(|c| (c.name.clone(), c.parent.clone())) + .collect(); + + crate::timings::phase("typing passes (initial)", || { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + false, + ) + }); // Whole-program fixpoint: harvest returns + unify params, re-type, // repeat until the registry signature stabilizes. Each round @@ -861,28 +923,168 @@ impl Analyzer { // `with_pagination_info` → `get` → `paginate` → the // `get_from_cache` block → its return → the destructuring, which // settles on round 9. - let mut prev_sig = self.inference_signature(); + let mut prev_sig = self.capture_inference_sig(); for round in 0..FIXPOINT_CAP { - crate::timings::phase(format_args!("round {round}: harvest returns"), || self.harvest_returns_to_registry(app)); - crate::timings::phase(format_args!("round {round}: unify params"), || self.unify_params_from_call_sites(app)); - let cur_sig = self.inference_signature(); - if cur_sig == prev_sig { + crate::timings::phase(format_args!("round {round}: harvest returns"), || { + self.harvest_returns_to_registry(app, false) + }); + crate::timings::phase(format_args!("round {round}: unify params"), || { + self.unify_params_from_call_sites(app, UnifyScope::Production) + }); + if self.inference_matches(&prev_sig) { break; } - prev_sig = cur_sig; + prev_sig = self.capture_inference_sig(); // Re-type the whole app with the refined registry. Idempotent // BodyTyper means a second pass simply resolves dispatches // and Var bindings the first pass couldn't. - crate::timings::phase(format_args!("round {round}: typing passes"), || self.run_typing_passes(app)); + crate::timings::phase(format_args!("round {round}: typing passes"), || { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + false, + ) + }); } - // The loop's last act is a typing pass whose results nothing - // harvested — either it hit the cap, or it ran on the round - // that then converged. Either way `self.classes` is one round - // behind the bodies in `app`, and the signature stamping below - // reads the registry. Harvesting once more costs no re-typing - // and makes the two agree. - self.harvest_returns_to_registry(app); + // Intermediate rounds skip views/tests: production does not + // read test helper returns, and unifying from still-untyped view + // trees for seven rounds was wasted work. After production + // converges, type views once and then harvest/unify/retype + // *tests* until helper chains settle. Later test rounds reuse + // the production+view param snapshot instead of re-walking + // those trees. If view/test sites moved a production param, + // retype production until harvested returns stabilize. Nested + // constructors live in serializer bodies (AuthorResource.new + // inside ArticleResource.to_h), so each absorb pass must + // unify WithViews again — Production unify would drop the + // view sites, and skipping unify leaves nested initialize + // params as Var. After helper returns absorb those params, + // views are typed once more so template calls see the + // harvested helper surface rather than leftover untyped. + let production_sig = prev_sig.clone(); + let mut production_view_params = None; + for round in 0..FIXPOINT_CAP { + crate::timings::phase( + if round == 0 { + "typing passes (views after fixpoint)".to_string() + } else { + format!("round {round}: tests") + }, + || { + if round == 0 { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + true, + ); + } else { + self.type_tests_only(app); + } + }, + ); + self.harvest_returns_to_registry(app, true); + crate::timings::phase( + if round == 0 { + "unify params (after views)".to_string() + } else { + format!("round {round}: unify params (tests)") + }, + || { + if round == 0 { + self.unify_params_from_call_sites(app, UnifyScope::WithViews); + production_view_params = Some(self.inferred_params.clone()); + self.overlay_test_params(app); + } else if let Some(snapshot) = production_view_params.as_ref() { + self.unify_test_params_onto(app, snapshot); + } + }, + ); + if self.inference_matches(&prev_sig) { + break; + } + prev_sig = self.capture_inference_sig(); + } + if !self.inference_matches(&production_sig) { + let mut absorb_sig = self.capture_inference_sig(); + for round in 0..FIXPOINT_CAP { + crate::timings::phase( + if round == 0 { + "typing passes (after view unify)".to_string() + } else { + format!("round {round}: absorb production") + }, + || { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + false, + ) + }, + ); + self.harvest_returns_to_registry(app, true); + self.unify_params_from_call_sites(app, UnifyScope::WithViews); + if let Some(snapshot) = production_view_params.as_mut() { + snapshot.clone_from(&self.inferred_params); + } + self.overlay_test_params(app); + if self.inference_matches(&absorb_sig) { + break; + } + absorb_sig = self.capture_inference_sig(); + } + } else { + // View unify did not move production signatures, but helper + // *bodies* still need a pass against view-inferred params + // (`highlight_searched_content`'s `content` is only called + // from templates) before views are restamped. + crate::timings::phase("typing passes (helpers after view unify)", || { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + false, + ) + }); + self.harvest_returns_to_registry(app, true); + } + // Wave 12 types views once against production-only helper + // returns, then unifies helper params from those sites. Helper + // returns therefore settle only after the absorb/harvest above. + // Stamp view trees again so template Sends are not leftover + // `gradual_untyped` against the pre-unify helper registry + // (Writebook `leafables/show` `highlight_searched_content`). + crate::timings::phase("typing passes (views after helper harvest)", || { + self.run_typing_passes( + app, + &dynamic_render_ivars, + &existing_view_names, + &module_methods, + &module_includes, + &parent_link_by_name, + true, + ) + }); + // Effects are a function of the converged typed trees, not of + // the fixpoint. Collecting inside every typing round walked + // the same bodies two or three times per round for no harvest + // or unify input. + self.stamp_body_effects(app); // Publish the converged param table onto the App for lowerings // that build signatures after analysis (the controller lowering @@ -1342,11 +1544,22 @@ impl Analyzer { /// (controller→view ivar channel, before_action seeding, /// per-model two-pass ivar discovery, partial locals threading) /// stays internal to this method; the fixpoint just calls it. - fn run_typing_passes(&mut self, app: &mut App) { + fn run_typing_passes( + &mut self, + app: &mut App, + dynamic_render_ivars: &std::collections::HashSet, + existing_view_names: &std::collections::HashSet, + module_methods: &HashMap>, + module_includes: &HashMap>, + parent_link_by_name: &HashMap>, + type_views_and_tests: bool, + ) { // Source-backed constant reads use Rubydex declaration IDs. // A bare-name fallback remains for generated expressions without // a Ruby source reference. - let (fallback, resolved_values) = self.build_constant_registry(app); + let (fallback, resolved_values) = crate::timings::phase("typing: constants", || { + self.build_constant_registry(app) + }); self.typed_constants = resolved_values; let global_constants = body::ConstScope::global(fallback); // Controller→view ivar channel: as each action is analyzed, we harvest @@ -1376,15 +1589,8 @@ impl Analyzer { // partial view name (`home/_for_domain`) to the union of ivars // from every action that names it (`@above = 'for_domain'`). // Built during Pass B, consumed when seeding partials below. - let dynamic_render_ivars: std::collections::HashSet = { - let mut set = std::collections::HashSet::new(); - for view in &app.views { - collect_dynamic_render_ivars(&view.body, &mut set); - } - set - }; - let existing_view_names: std::collections::HashSet = - app.views.iter().map(|v| v.name.clone()).collect(); + // The two sets are computed once in `analyze` and reused every + // round — they do not depend on the refined registry. let mut content_partial_ivars: HashMap> = HashMap::new(); // Per-controller metadata captured during Pass A so Pass B @@ -1426,38 +1632,154 @@ impl Analyzer { layout: LayoutDecl, } let mut meta_by_name: HashMap = HashMap::new(); - // Snapshot parent links separately so Phase B can walk - // arbitrary-depth chains without re-borrowing `app.controllers` - // (which is mutably borrowed inside the analysis loops). - let parent_link_by_name: HashMap> = app - .controllers - .iter() - .map(|c| (c.name.clone(), c.parent.clone())) - .collect(); - - // Concern-module metadata for the mixed-in expansion inside - // Phase A: each module's method defs (cloned so their bodies can - // be typed against each includer's own self) and its `include`s - // (concerns include concerns; the expansion chases the closure). - // Filters captured from `included do` blocks ride on - // `App::concern_filters`. - let module_methods: HashMap> = app - .library_classes - .iter() - .filter(|lc| lc.is_module) - .map(|lc| (lc.name.clone(), lc.methods.clone())) - .collect(); - let module_includes: HashMap> = app - .library_classes - .iter() - .filter(|lc| lc.is_module) - .map(|lc| (lc.name.clone(), lc.includes.clone())) - .collect(); + // Parent links and concern-module tables are cloned once in + // `analyze` and reused every round — they do not depend on the + // refined registry. self.analyze_class_configuration(&mut app.controllers); + // Models do not read `controller_ivar_env`. Type and harvest + // each one before controllers so a round carries model returns + // into controller bodies. Dispatch reads the registry. + let _typing_models = crate::timings::begin("typing: models"); + for model in &mut app.models { + // Seed class ivars for the body-typer. Three shapes in play: + // 1. `@attributes` — the legacy Hash-storage access path + // (some transpiled patterns still use it). + // 2. Per-schema-column ivars (`@title`, `@body`, ...) — the + // typed-field representation. `attr_accessor :title, ...` + // in a transpiled model generates accessors that read/ + // write these ivars, but the generated methods aren't + // `def` nodes so flow-sensitive typing can't discover + // them — seed directly from schema metadata. + // 3. Memoization ivars (`@_comments`) — discovered by the + // flow-sensitive pre-pass below. + let mut class_ivars: HashMap = HashMap::new(); + class_ivars.insert( + Symbol::from("attributes"), + Ty::Hash { + key: Box::new(Ty::Sym), + value: Box::new(Ty::Var { var: crate::ident::TyVar(0) }), + }, + ); + for (name, ty) in &model.attributes.fields { + // Ivar reads may observe nil before the first write; + // union with Nil reflects that. The column's declared + // type from schema covers the post-initialization case. + // union_of so an already-nilable column type dedups + // instead of nesting. + class_ivars.insert( + name.clone(), + crate::analyze::body::union_of(ty.clone(), Ty::Nil), + ); + } + // `attr_accessor :edit_user_id` virtual attributes: real + // ivars, untyped, absent from the schema. Seed as gradual + // so a direct `@edit_user_id` read resolves (don't clobber + // a schema column of the same name). + for name in collect_attr_accessor_names(&model.body) { + class_ivars.entry(name).or_insert(Ty::Untyped); + } + + // Phase 0: type the model's `Unknown` body items so the + // RHS of in-class constant assignments (`FLAGGABLE_DAYS = 7`, + // `MIN_KARMA_TO_SUGGEST = 50`, `COMMENT_REASONS = {...}`) + // gets `value.ty` populated. Without this, the subsequent + // const-table extraction sees `None` and the body-typer + // falls through to `Ty::Class { id: ConstName }` for every + // read — observable as `incompatible_binop` errors + // (`Int > Class { MIN_KARMA }`) and `send_dispatch_failed` + // (`days` on `Class { NEW_USER_DAYS }`). + let const_ctx = Ctx { + self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), + ivar_bindings: class_ivars.clone(), + local_bindings: HashMap::new(), + constants: global_constants.clone(), + annotate_self_dispatch: false, in_view: false, + }; + for item in model.body.iter_mut() { + if let ModelBodyItem::Unknown { expr, .. } = item { + self.body_typer().analyze_expr(expr, &const_ctx); + } + } + // Own constants layered over the global registry (own shadows). + let class_constants = global_constants.with_own(extract_const_assignments(&model.body)); + + let class_ctx = Ctx { + self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), + ivar_bindings: class_ivars.clone(), + local_bindings: HashMap::new(), + constants: class_constants.clone(), + annotate_self_dispatch: false, in_view: false, + }; + + // Pass A: type every method body with only `@attributes` + // seeded. Assignments inside bodies (e.g. `@_comments = ...` + // in a memoizing getter) populate `value.ty` on those + // assignments, which Pass B harvests. + for scope in model.scopes_mut() { + self.body_typer().analyze_expr(&mut scope.body, &class_ctx); + } + let model_name = model.name.clone(); + for method in model.methods_mut() { + // A default is part of the parameter's type. Typed before + // seeding so `value = nil` is `Nil` when no call site has + // said otherwise, and a later site can union with it. + for param in &mut method.params { + if let Some(default) = &mut param.default { + self.body_typer().analyze_expr(default, &class_ctx); + } + } + let mctx = self.seed_method_params(&class_ctx, &model_name, method); + self.body_typer().analyze_expr(&mut method.body, &mctx); + } + + // Pass B: gather every ivar assignment across the model's + // methods. Each discovered `@x = value` seeds the ivar's + // type for the second typing pass, so reads that occur + // *before* the assignment lexically (e.g. the left side of + // `@x ||= ...` lowered to `@x || (@x = ...)`) still resolve + // cleanly. + let mut flow_ivars: HashMap = HashMap::new(); + for method in model.methods() { + extract_ivar_assignments(&method.body, &mut flow_ivars); + } + for scope in model.scopes() { + extract_ivar_assignments(&scope.body, &mut flow_ivars); + } + + if !flow_ivars.is_empty() { + // Re-seed ctx with discovered ivars alongside @attributes. + // Memoizing ivars become `Union` to reflect that + // the read can be nil before the first assignment. + let mut reseeded = class_ivars; + for (name, ty) in flow_ivars { + let union_ty = crate::analyze::body::union_of(ty, Ty::Nil); + reseeded.insert(name, union_ty); + } + let reseeded_ctx = Ctx { + self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), + ivar_bindings: reseeded, + local_bindings: HashMap::new(), + constants: class_constants.clone(), + annotate_self_dispatch: false, in_view: false, + }; + + for scope in model.scopes_mut() { + self.body_typer().analyze_expr(&mut scope.body, &reseeded_ctx); + } + for method in model.methods_mut() { + let mctx = self.seed_method_params(&reseeded_ctx, &model_name, method); + self.body_typer().analyze_expr(&mut method.body, &mctx); + } + } + self.harvest_one_model(model); + } + drop(_typing_models); + // ── Phase A: type Unknown body items + every action body // ── once per controller, with no parent inheritance. + let _typing_controllers_a = crate::timings::begin("typing: controllers A"); for controller in &mut app.controllers { // Phase 0: type the controller's `Unknown` body items so // in-class constants (`COMMENTS_PER_PAGE = 20`, @@ -1537,7 +1859,6 @@ impl Analyzer { action.block_param.as_ref(), ); self.body_typer().analyze_expr(&mut action.body, &mctx); - action.effects = self.collect_effects(&mut action.body, &mctx); } // Snapshot each action's ivar bindings (this controller's @@ -1613,7 +1934,6 @@ impl Analyzer { } } } - let mut concern_effects: HashMap = HashMap::new(); for module_id in &mixed_in { let Some(methods) = module_methods.get(module_id) else { continue }; for method in methods { @@ -1633,20 +1953,13 @@ impl Analyzer { // as a resolution target for // `collect_transitive_filter_ivars`. action_bodies.entry(method.name.clone()).or_insert_with(|| body.clone()); - let effects = self.collect_effects(&mut body, &ctx); - if !effects.is_pure() { - concern_effects.entry(method.name.clone()).or_insert(effects); - } } } - // Per-method effect snapshot for the persisted chain: own - // methods (typed by Pass A) overwrite concern methods — - // a shadowed module method never runs. - let mut action_effects = concern_effects; - for action in controller.actions() { - action_effects.insert(action.name.clone(), action.effects.clone()); - } + // Effect sets for the persisted chain are stamped once after + // the typing fixpoint (`stamp_body_effects`); this snapshot + // is empty here and patched from the converged trees. + let action_effects: HashMap = HashMap::new(); let layout = controller.layout.clone(); meta_by_name.insert( @@ -1662,6 +1975,7 @@ impl Analyzer { }, ); } + drop(_typing_controllers_a); // Controller→layout-view ivar channel: every action that // renders also flows its ivars into whatever layout wraps it @@ -1702,6 +2016,7 @@ impl Analyzer { // Action bindings are merged with NEAREST parent first so the // closest definition wins on name conflicts (mirrors Ruby // method-resolution order). + let _typing_controllers_b = crate::timings::begin("typing: controllers B"); for controller in &mut app.controllers { let ctrl_name = controller.name.clone(); let Some(meta) = meta_by_name.get(&ctrl_name) else { continue }; @@ -1822,15 +2137,16 @@ impl Analyzer { // Phase-B refinement layer — it exists only to give // `collect_transitive_filter_ivars` a body to resolve a // filter target's own receiverless calls into, immediately - // below. - let mut chained_bodies: HashMap = HashMap::new(); + // below. Borrowed from `action_bodies` so we do not clone + // every ancestor filter-target tree per controller per round. + let mut chained_bodies: HashMap = HashMap::new(); for (_, ancestor) in ancestors.iter().rev() { for (name, body) in &ancestor.action_bodies { - chained_bodies.insert(name.clone(), body.clone()); + chained_bodies.insert(name.clone(), body); } } for (name, body) in &meta.action_bodies { - chained_bodies.insert(name.clone(), body.clone()); + chained_bodies.insert(name.clone(), body); } // Transitive filter-target ivar writes: a Before/Around @@ -1975,7 +2291,6 @@ impl Analyzer { action.block_param.as_ref(), ); self.body_typer().analyze_expr(&mut action.body, &inner_ctx); - action.effects = self.collect_effects(&mut action.body, &inner_ctx); } } @@ -2370,6 +2685,7 @@ impl Analyzer { } } + drop(_typing_controllers_b); // Flush Phase B's refinements. Later rounds of the whole-program // fixpoint read them back through `layer` above, which is what // carries a parent's refined binding down to a subclass. @@ -2403,6 +2719,7 @@ impl Analyzer { // the controller's own binding still wins wherever it has one, and // only names the includer lacks are filled from the concern. if !app.concern_spliced_actions.is_empty() { + let _typing_concerns = crate::timings::begin("typing: concern splice"); let concern_env = concern_ivar_env_of(app, &controller_ivar_env, &module_includes); let origins = app.concern_spliced_actions.clone(); @@ -2458,149 +2775,9 @@ impl Analyzer { action.block_param.as_ref(), ); self.body_typer().analyze_expr(&mut action.body, &inner_ctx); - action.effects = self.collect_effects(&mut action.body, &inner_ctx); } } } - for model in &mut app.models { - // Seed class ivars for the body-typer. Three shapes in play: - // 1. `@attributes` — the legacy Hash-storage access path - // (some transpiled patterns still use it). - // 2. Per-schema-column ivars (`@title`, `@body`, ...) — the - // typed-field representation. `attr_accessor :title, ...` - // in a transpiled model generates accessors that read/ - // write these ivars, but the generated methods aren't - // `def` nodes so flow-sensitive typing can't discover - // them — seed directly from schema metadata. - // 3. Memoization ivars (`@_comments`) — discovered by the - // flow-sensitive pre-pass below. - let mut class_ivars: HashMap = HashMap::new(); - class_ivars.insert( - Symbol::from("attributes"), - Ty::Hash { - key: Box::new(Ty::Sym), - value: Box::new(Ty::Var { var: crate::ident::TyVar(0) }), - }, - ); - for (name, ty) in &model.attributes.fields { - // Ivar reads may observe nil before the first write; - // union with Nil reflects that. The column's declared - // type from schema covers the post-initialization case. - // union_of so an already-nilable column type dedups - // instead of nesting. - class_ivars.insert( - name.clone(), - crate::analyze::body::union_of(ty.clone(), Ty::Nil), - ); - } - // `attr_accessor :edit_user_id` virtual attributes: real - // ivars, untyped, absent from the schema. Seed as gradual - // so a direct `@edit_user_id` read resolves (don't clobber - // a schema column of the same name). - for name in collect_attr_accessor_names(&model.body) { - class_ivars.entry(name).or_insert(Ty::Untyped); - } - - // Phase 0: type the model's `Unknown` body items so the - // RHS of in-class constant assignments (`FLAGGABLE_DAYS = 7`, - // `MIN_KARMA_TO_SUGGEST = 50`, `COMMENT_REASONS = {...}`) - // gets `value.ty` populated. Without this, the subsequent - // const-table extraction sees `None` and the body-typer - // falls through to `Ty::Class { id: ConstName }` for every - // read — observable as `incompatible_binop` errors - // (`Int > Class { MIN_KARMA }`) and `send_dispatch_failed` - // (`days` on `Class { NEW_USER_DAYS }`). - let const_ctx = Ctx { - self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), - ivar_bindings: class_ivars.clone(), - local_bindings: HashMap::new(), - constants: global_constants.clone(), - annotate_self_dispatch: false, in_view: false, - }; - for item in model.body.iter_mut() { - if let ModelBodyItem::Unknown { expr, .. } = item { - self.body_typer().analyze_expr(expr, &const_ctx); - } - } - // Own constants layered over the global registry (own shadows). - let class_constants = global_constants.with_own(extract_const_assignments(&model.body)); - - let class_ctx = Ctx { - self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), - ivar_bindings: class_ivars.clone(), - local_bindings: HashMap::new(), - constants: class_constants.clone(), - annotate_self_dispatch: false, in_view: false, - }; - - // Pass A: type every method body with only `@attributes` - // seeded. Assignments inside bodies (e.g. `@_comments = ...` - // in a memoizing getter) populate `value.ty` on those - // assignments, which Pass B harvests. - for scope in model.scopes_mut() { - self.body_typer().analyze_expr(&mut scope.body, &class_ctx); - } - let model_name = model.name.clone(); - for method in model.methods_mut() { - // A default is part of the parameter's type. Typed before - // seeding so `value = nil` is `Nil` when no call site has - // said otherwise, and a later site can union with it. - for param in &mut method.params { - if let Some(default) = &mut param.default { - self.body_typer().analyze_expr(default, &class_ctx); - } - } - let mctx = self.seed_method_params(&class_ctx, &model_name, method); - self.body_typer().analyze_expr(&mut method.body, &mctx); - } - - // Pass B: gather every ivar assignment across the model's - // methods. Each discovered `@x = value` seeds the ivar's - // type for the second typing pass, so reads that occur - // *before* the assignment lexically (e.g. the left side of - // `@x ||= ...` lowered to `@x || (@x = ...)`) still resolve - // cleanly. - let mut flow_ivars: HashMap = HashMap::new(); - for method in model.methods() { - extract_ivar_assignments(&method.body, &mut flow_ivars); - } - for scope in model.scopes() { - extract_ivar_assignments(&scope.body, &mut flow_ivars); - } - - if !flow_ivars.is_empty() { - // Re-seed ctx with discovered ivars alongside @attributes. - // Memoizing ivars become `Union` to reflect that - // the read can be nil before the first assignment. - let mut reseeded = class_ivars; - for (name, ty) in flow_ivars { - let union_ty = crate::analyze::body::union_of(ty, Ty::Nil); - reseeded.insert(name, union_ty); - } - let reseeded_ctx = Ctx { - self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), - ivar_bindings: reseeded, - local_bindings: HashMap::new(), - constants: class_constants.clone(), - annotate_self_dispatch: false, in_view: false, - }; - - for scope in model.scopes_mut() { - self.body_typer().analyze_expr(&mut scope.body, &reseeded_ctx); - } - for method in model.methods_mut() { - let mctx = self.seed_method_params(&reseeded_ctx, &model_name, method); - self.body_typer().analyze_expr(&mut method.body, &mctx); - method.effects = self.collect_effects(&mut method.body, &mctx); - } - } else { - for method in model.methods_mut() { - let mctx = self.seed_method_params(&class_ctx, &model_name, method); - method.effects = self.collect_effects(&mut method.body, &mctx); - } - } - } - // Library classes (non-model classes under app/models/): mirror // the per-model body typing pass on a smaller surface — no // schema attributes, no associations, just methods. Two-pass @@ -2703,6 +2880,7 @@ impl Analyzer { let mailer_with_params = harvest_mailer_with_params(app, &mailer_names); let mut mailer_params_by_view: HashMap = HashMap::new(); + let _typing_library = crate::timings::begin("typing: library"); for lc in &mut app.library_classes { if let Some(row) = mailer_with_params.get(&lc.name) { self.classes @@ -2857,15 +3035,14 @@ impl Analyzer { for method in &mut lc.methods { let mctx = self.seed_method_params(&reseeded_ctx, &lc_name, method); self.body_typer().analyze_expr(&mut method.body, &mctx); - method.effects = self.collect_effects(&mut method.body, &mctx); - } - } else { - for method in &mut lc.methods { - let mctx = self.seed_method_params(&class_ctx, &lc_name, method); - method.effects = self.collect_effects(&mut method.body, &mctx); } } } + drop(_typing_library); + + if !type_views_and_tests { + return; + } // Partial-locals channel: we need action/top-level views analyzed first // so their expression types are known at each `render` call site. We @@ -2894,6 +3071,7 @@ impl Analyzer { // Phase 3a: non-partial views (action views + layouts). Analyze with // the controller→view ivar seed, then walk the body to record every // `render` call's effect on partial_locals_by_name. + let _typing_views = crate::timings::begin("typing: views"); for view in &mut app.views { if is_partial_view_name(&view.name) { continue; @@ -3130,10 +3308,13 @@ impl Analyzer { .iter() .map(|(view, locals)| (view.clone(), locals.clone())) .collect(); + drop(_typing_views); // Type the ORIGINAL test scopes, before source contracts inspect // them. Emission later clones/rewrites these bodies; it is too late // for that typing to establish a source admission fact. + + let _typing_tests = crate::timings::begin("typing: tests"); self.type_test_modules(app, &global_constants); // Seeds body (db/seeds.rb). Top-level Ruby: no `self`, no @@ -3141,13 +3322,14 @@ impl Analyzer { // that references model classes. Types so that Send effects // flow (DbWrite on `Article.create!`, DbRead on // `Article.count`), which the emitter uses for await - // placement under async adapters. + // placement under async adapters. Effects themselves are + // stamped once after the typing fixpoint. if let Some(expr) = app.seeds.as_mut() { let mut ctx = Ctx::default(); ctx.constants = global_constants.clone(); self.body_typer().analyze_expr(expr, &ctx); - let _ = self.collect_effects(expr, &ctx); } + drop(_typing_tests); } @@ -3275,36 +3457,35 @@ impl Analyzer { ctx } - /// Fingerprint of the data the fixpoint refines: per-class - /// instance/class method return types in `self.classes` plus the - /// parameter-type table in `self.inferred_params`. The fixpoint - /// loop in `analyze` compares fingerprints between iterations and - /// stops when they match. Order-independent so HashMap iteration - /// order doesn't perturb results: keys are sorted before - /// stringification. - fn inference_signature(&self) -> String { - let mut parts: Vec = Vec::new(); - let mut class_keys: Vec<&ClassId> = self.classes.keys().collect(); - class_keys.sort_by_key(|k| k.0.as_str().to_string()); - for cid in class_keys { - let cls = &self.classes[cid]; - let mut method_keys: Vec<&Symbol> = cls.instance_methods.keys().collect(); - method_keys.sort_by_key(|k| k.as_str().to_string()); - for m in method_keys { - parts.push(format!("{}#{}={:?}", cid.0.as_str(), m.as_str(), cls.instance_methods[m])); - } - let mut cmethod_keys: Vec<&Symbol> = cls.class_methods.keys().collect(); - cmethod_keys.sort_by_key(|k| k.as_str().to_string()); - for m in cmethod_keys { - parts.push(format!("{}.{}={:?}", cid.0.as_str(), m.as_str(), cls.class_methods[m])); - } - } - let mut param_keys: Vec<&(ClassId, Symbol)> = self.inferred_params.keys().collect(); - param_keys.sort_by_key(|(c, m)| (c.0.as_str().to_string(), m.as_str().to_string())); - for k in param_keys { - parts.push(format!("{}#{}~{:?}", k.0.0.as_str(), k.1.as_str(), self.inferred_params[k])); - } - parts.join("|") + fn capture_inference_sig(&self) -> InferenceSig { + let mut instance = HashMap::with_capacity(self.classes.len()); + let mut class_methods = HashMap::with_capacity(self.classes.len()); + for (id, cls) in &self.classes { + instance.insert(id.clone(), cls.instance_methods.clone()); + class_methods.insert(id.clone(), cls.class_methods.clone()); + } + InferenceSig { + instance, + class_methods, + params: self.inferred_params.clone(), + } + } + + fn inference_matches(&self, prev: &InferenceSig) -> bool { + if self.classes.len() != prev.instance.len() || self.inferred_params != prev.params { + return false; + } + for (id, cls) in &self.classes { + match prev.instance.get(id) { + Some(methods) if methods == &cls.instance_methods => {} + _ => return false, + } + match prev.class_methods.get(id) { + Some(methods) if methods == &cls.class_methods => {} + _ => return false, + } + } + true } /// Walk every model + library_class method body and write its @@ -3315,8 +3496,8 @@ impl Analyzer { /// RBS-derived `Ty::Fn` is preserved — its return is already what /// dispatch resolves to via `unwrap_fn_ret`). Skip methods whose /// body is `Ty::Var` (no information gained). - fn harvest_returns_to_registry(&mut self, app: &App) { - self.harvest_method_returns(app); + fn harvest_returns_to_registry(&mut self, app: &App, harvest_tests: bool) { + self.harvest_method_returns(app, harvest_tests); // Rails' `helper_method :name` makes a controller (or concern) // method callable from templates. The names were ingested from // both spellings (`App::view_visible_controller_methods`); the @@ -3339,36 +3520,59 @@ impl Analyzer { } } - fn harvest_method_returns(&mut self, app: &App) { - for model in &app.models { - let class_id = &model.name; - let scope_names: std::collections::HashSet = - model.scopes().map(|s| s.name.clone()).collect(); - for method in model.methods() { - let ret = self.method_return_ty(class_id, method); - let target = match method.receiver { - crate::dialect::MethodReceiver::Instance => { - &mut self.classes.entry(class_id.clone()).or_default().instance_methods - } - crate::dialect::MethodReceiver::Class => { - &mut self.classes.entry(class_id.clone()).or_default().class_methods - } - }; - // A class method whose body tail is a query-builder - // chain declares `Relation { of: Self }` to callers — - // the same relation type a scope seeds — overriding - // the body's `Array` typing (inside the body the - // chain keeps the inline-chain Array representation; - // the relation type is introduced at the boundary). - // This is what lets `Story.recent.for_user(u)` - // delegate `for_user` on the relation receiver. - if method.receiver == crate::dialect::MethodReceiver::Class - && body_tail_yields_relation(&method.body, class_id, &scope_names) + fn harvest_one_model(&mut self, model: &Model) { + let class_id = &model.name; + let scope_names: std::collections::HashSet = + model.scopes().map(|s| s.name.clone()).collect(); + for method in model.methods() { + let ret = self.method_return_ty(class_id, method); + let target = match method.receiver { + crate::dialect::MethodReceiver::Instance => { + &mut self.classes.entry(class_id.clone()).or_default().instance_methods + } + crate::dialect::MethodReceiver::Class => { + &mut self.classes.entry(class_id.clone()).or_default().class_methods + } + }; + // A class method whose body tail is a query-builder + // chain declares `Relation { of: Self }` to callers — + // the same relation type a scope seeds — overriding + // the body's `Array` typing (inside the body the + // chain keeps the inline-chain Array representation; + // the relation type is introduced at the boundary). + // This is what lets `Story.recent.for_user(u)` + // delegate `for_user` on the relation receiver. + if method.receiver == crate::dialect::MethodReceiver::Class + && body_tail_yields_relation(&method.body, class_id, &scope_names) + { + Self::insert_inferred_return( + target, + &method.name, + Ty::Relation { of: class_id.clone() }, + ); + self.classes + .entry(class_id.clone()) + .or_default() + .relation_derived + .insert(method.name.clone()); + continue; + } + // …and the half that TERMINATES the chain rather than + // extending it: `def self.original; order(:created_at) + // .first; end` returns a record, not a relation, but it + // is just as much a query over this model and Rails + // delegates it on a relation receiver the same way. + // Marked `relation_derived` so that delegation can tell + // it apart from a class method that merely happens to + // return a record. + if method.receiver == crate::dialect::MethodReceiver::Class { + if let Some(kind) = + body_tail_terminal_kind(&method.body, class_id, &scope_names) { Self::insert_inferred_return( target, &method.name, - Ty::Relation { of: class_id.clone() }, + instantiate_return_kind(kind, class_id), ); self.classes .entry(class_id.clone()) @@ -3377,33 +3581,14 @@ impl Analyzer { .insert(method.name.clone()); continue; } - // …and the half that TERMINATES the chain rather than - // extending it: `def self.original; order(:created_at) - // .first; end` returns a record, not a relation, but it - // is just as much a query over this model and Rails - // delegates it on a relation receiver the same way. - // Marked `relation_derived` so that delegation can tell - // it apart from a class method that merely happens to - // return a record. - if method.receiver == crate::dialect::MethodReceiver::Class { - if let Some(kind) = - body_tail_terminal_kind(&method.body, class_id, &scope_names) - { - Self::insert_inferred_return( - target, - &method.name, - instantiate_return_kind(kind, class_id), - ); - self.classes - .entry(class_id.clone()) - .or_default() - .relation_derived - .insert(method.name.clone()); - continue; - } - } - Self::register_method_return(target, &method.name, ret.as_ref()); } + Self::register_method_return(target, &method.name, ret.as_ref()); + } + } + + fn harvest_method_returns(&mut self, app: &App, harvest_tests: bool) { + for model in &app.models { + self.harvest_one_model(model); } for lc in &app.library_classes { let class_id = &lc.name; @@ -3426,15 +3611,17 @@ impl Analyzer { Self::register_method_return(target, &method.name, ret.as_ref()); } } - for module in &app.test_modules { - for method in &module.helpers { - let ret = self.method_return_ty(&module.name, method); - let class = self.classes.entry(module.name.clone()).or_default(); - let table = match method.receiver { - crate::dialect::MethodReceiver::Instance => &mut class.instance_methods, - crate::dialect::MethodReceiver::Class => &mut class.class_methods, - }; - Self::register_method_return(table, &method.name, ret.as_ref()); + if harvest_tests { + for module in &app.test_modules { + for method in &module.helpers { + let ret = self.method_return_ty(&module.name, method); + let class = self.classes.entry(module.name.clone()).or_default(); + let table = match method.receiver { + crate::dialect::MethodReceiver::Instance => &mut class.instance_methods, + crate::dialect::MethodReceiver::Class => &mut class.class_methods, + }; + Self::register_method_return(table, &method.name, ret.as_ref()); + } } } // Controllers: harvest each action/helper method's return type so a @@ -3901,7 +4088,7 @@ impl Analyzer { /// at a higher level — we work with structured `Ty` values rather /// than string fingerprints, so unification is direct: same type → /// keep; nil + T → T?; otherwise → union widen. - fn unify_params_from_call_sites(&mut self, app: &App) { + fn unify_params_from_call_sites(&mut self, app: &App, scope: UnifyScope) { // Rebuilt from scratch every fixpoint round. The table is pure // derived state — a function of the types the last typing pass // wrote onto the call-site argument expressions — and carrying @@ -3930,8 +4117,8 @@ impl Analyzer { for method in model.methods() { self.collect_send_sites(&method.body, Some(&model.name), helpers, &mut sites); } - for scope in model.scopes() { - self.collect_send_sites(&scope.body, Some(&model.name), helpers, &mut sites); + for scope_item in model.scopes() { + self.collect_send_sites(&scope_item.body, Some(&model.name), helpers, &mut sites); } } for lc in &app.library_classes { @@ -3939,6 +4126,55 @@ impl Analyzer { self.collect_send_sites(&method.body, Some(&lc.name), helpers, &mut sites); } } + for controller in &app.controllers { + for action in controller.actions() { + self.collect_send_sites(&action.body, Some(&controller.name), helpers, &mut sites); + } + for method in controller.class_methods() { + self.collect_send_sites(&method.body, Some(&controller.name), helpers, &mut sites); + } + } + if matches!(scope, UnifyScope::WithViews) { + for view in &app.views { + self.collect_send_sites(&view.body, None, helpers, &mut sites); + } + if let Some(seeds) = &app.seeds { + self.collect_send_sites(seeds, None, helpers, &mut sites); + } + } + + self.apply_param_sites(sites, ¶ms_by_method, &defined); + // Production signatures keep their production callers' shape. + // Fold before adding test-owned observations: a same-named test + // helper must not feed an included production concern either. + self.fold_concern_param_sites(app); + } + + /// Replay production+view param observations, then overlay typed + /// test call sites. Test-only retype rounds do not walk view trees + /// again — those bodies did not change. + fn unify_test_params_onto( + &mut self, + app: &App, + snapshot: &HashMap<(ClassId, Symbol), Vec>, + ) { + self.inferred_params.clone_from(snapshot); + self.overlay_test_params(app); + } + + fn overlay_test_params(&mut self, app: &App) { + let helpers = &app.helper_method_index; + let params_by_method = Self::param_shapes(app); + let defined = Self::defined_methods(app); + let test_sites = self.collect_test_param_sites(app, helpers); + self.apply_param_sites(test_sites, ¶ms_by_method, &defined); + } + + fn collect_test_param_sites( + &self, + app: &App, + helpers: &HashMap, + ) -> Vec<(ClassId, Symbol, Vec, SiteKeywords)> { let mut test_sites = Vec::new(); for module in &app.test_modules { if let Some(setup) = &module.setup { @@ -3952,57 +4188,35 @@ impl Analyzer { self.collect_send_sites(body, Some(&module.name), helpers, &mut test_sites); } } - let test_sites = test_sites.into_iter().filter_map(|(class, method, args, kwargs)| { + test_sites.into_iter().filter_map(|(class, method, args, kwargs)| { self.test_helper_owner(app, &class, &method) .map(|owner| (owner, method, args, kwargs)) - }).collect(); - for controller in &app.controllers { - for action in controller.actions() { - self.collect_send_sites(&action.body, Some(&controller.name), helpers, &mut sites); - } - for method in controller.class_methods() { - self.collect_send_sites(&method.body, Some(&controller.name), helpers, &mut sites); - } - } - for view in &app.views { - self.collect_send_sites(&view.body, None, helpers, &mut sites); - } - if let Some(seeds) = &app.seeds { - self.collect_send_sites(seeds, None, helpers, &mut sites); - } - - for (phase, sites) in [sites, test_sites].into_iter().enumerate() { - if phase == 1 { - // Production signatures keep their production callers' shape. - // Fold before adding test-owned observations: a same-named test - // helper must not feed an included production concern either. - self.fold_concern_param_sites(app); - } - for (class_id, method, arg_tys, kw_tys) in sites { - // Before the keywords are placed: the callee's shape is - // keyed to the class that defines it. - let class_id = self.inherited_param_owner(&defined, class_id, &method); - let arg_tys = Self::place_keyword_args( - params_by_method.get(&(class_id.clone(), method.clone())), - arg_tys, - kw_tys, - ); - // Cross-reference against MethodDef.params to know the - // arity. If the called method's params can't be located, - // still accumulate up to arg count under the same key — - // RBS-only methods don't have a MethodDef but do have an - // Fn signature, and inferred_params can extend either way. - let arity = arg_tys.len(); - let entry = self - .inferred_params - .entry((class_id.clone(), method.clone())) - .or_insert_with(|| (0..arity).map(|_| Ty::Var { var: crate::ident::TyVar(0) }).collect()); - if entry.len() < arity { - entry.resize(arity, Ty::Var { var: crate::ident::TyVar(0) }); - } - for (slot, observed) in entry.iter_mut().zip(arg_tys.into_iter()) { - *slot = unify_param_ty(slot.clone(), observed); - } + }).collect() + } + + fn apply_param_sites( + &mut self, + sites: Vec<(ClassId, Symbol, Vec, SiteKeywords)>, + params_by_method: &HashMap<(ClassId, Symbol), ParamShape>, + defined: &BTreeSet<(ClassId, Symbol)>, + ) { + for (class_id, method, arg_tys, kw_tys) in sites { + let class_id = self.inherited_param_owner(defined, class_id, &method); + let arg_tys = Self::place_keyword_args( + params_by_method.get(&(class_id.clone(), method.clone())), + arg_tys, + kw_tys, + ); + let arity = arg_tys.len(); + let entry = self + .inferred_params + .entry((class_id.clone(), method.clone())) + .or_insert_with(|| (0..arity).map(|_| Ty::Var { var: crate::ident::TyVar(0) }).collect()); + if entry.len() < arity { + entry.resize(arity, Ty::Var { var: crate::ident::TyVar(0) }); + } + for (slot, observed) in entry.iter_mut().zip(arg_tys.into_iter()) { + *slot = unify_param_ty(slot.clone(), observed); } } } @@ -5085,7 +5299,7 @@ const MAX_FILTER_CALL_DEPTH: usize = 4; /// the backstop for a long-but-non-cyclic chain. fn collect_transitive_filter_ivars( expr: &Expr, - bodies: &HashMap, + bodies: &HashMap, depth: usize, visited: &mut BTreeSet, own: bool, diff --git a/src/analyze/registry/view.rs b/src/analyze/registry/view.rs index f5dc0f9d3..3bcbeb8fd 100644 --- a/src/analyze/registry/view.rs +++ b/src/analyze/registry/view.rs @@ -1,6 +1,6 @@ //! ActionView view-context surface: the `FormBuilder`, the mime-responds //! `Collector`, the `ActionView::Base` flat-helper accumulator (links, tags, -//! flash accessors, jbuilder `json`, route helpers, kaminari, simple_form, +//! flash accessors, jbuilder `json`, route helpers, paginate, simple_form, //! and the app/helpers fold), and the `ActionDispatch::Flash::FlashHash` //! class. Extracted verbatim from `Analyzer::with_adapter`. @@ -202,7 +202,7 @@ pub(in crate::analyze) fn register( .entry(Symbol::from(name.as_str())) .or_insert(Ty::Str); } - // Kaminari's view-side paginator renders to a SafeBuffer string, + // View-side paginator helper renders to a SafeBuffer string, // like the tag helpers above. action_view .instance_methods diff --git a/src/analyze/test_module.rs b/src/analyze/test_module.rs index c2d0ba80e..193092ca2 100644 --- a/src/analyze/test_module.rs +++ b/src/analyze/test_module.rs @@ -83,6 +83,16 @@ impl Analyzer { None } + /// Retype original test scopes only. View trees stay as the last + /// full views/tests pass typed them; helper-chain rounds must not + /// walk every template. + pub(super) fn type_tests_only(&mut self, app: &mut App) { + let (fallback, resolved_values) = self.build_constant_registry(app); + self.typed_constants = resolved_values; + let global_constants = ConstScope::global(fallback); + self.type_test_modules(app, &global_constants); + } + pub(super) fn type_test_modules(&self, app: &mut App, constants: &ConstScope) { for module in &mut app.test_modules { let mut ctx = Ctx { @@ -124,7 +134,6 @@ impl Analyzer { let method_ctx = self.seed_method_params(&ctx, &module.name, method); self.body_typer() .analyze_expr(&mut method.body, &method_ctx); - method.effects = self.collect_effects(&mut method.body, &method_ctx); } for test in &mut module.tests { // Setup's ivars survive, its locals do not. Keep bare source diff --git a/src/app.rs b/src/app.rs index ada91203f..624b9a3e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -168,6 +168,12 @@ pub struct App { /// as it is in stock Rails. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachable_unsigned_models: Vec, + /// Modules `include`d inside `ActiveSupport.on_load(:active_record)` + /// that provide class-method macros. Mixin instance methods are not + /// installed. Expansion treats these as an explicit provider origin + /// (not a seeded `include` set on every model). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub load_hook_class_macros: Vec, /// Partial → local name → type, harvested by the analyzer from the /// RENDER SITES that pass each local (`render partial: "form", /// locals: { new_message: @new_message }` with `@new_message` typed @@ -680,6 +686,7 @@ impl App { view_visible_controller_methods: BTreeSet::new(), global_id_locate_models: BTreeSet::new(), attachable_unsigned_models: Vec::new(), + load_hook_class_macros: Vec::new(), partial_local_types: HashMap::new(), view_ivar_types: HashMap::new(), html_safe_methods: BTreeSet::new(), diff --git a/src/bin/dump_ir.rs b/src/bin/dump_ir.rs index 551316d9c..d3d344459 100644 --- a/src/bin/dump_ir.rs +++ b/src/bin/dump_ir.rs @@ -73,7 +73,7 @@ use roundhouse::lower::{ class_info_from_library_class, lower_fixtures_to_library_classes, lower_jbuilder_to_library_classes, lower_models_with_registry, lower_test_modules_to_library_classes, - lower_views_to_library_classes, + preliminary_view_classes, type_view_library_classes, }; use roundhouse::ty::Ty; @@ -127,7 +127,7 @@ fn dump() { return; } - let lcs = lower_all(&app); + let lcs = roundhouse::timings::phase("construct-to-library", || lower_all(&app)); let mut printed = 0usize; let mut sep = ""; @@ -351,53 +351,65 @@ fn print_usage() { // ── Pipeline (mirrors tests/model_lowerer.rs::lowered_real_blog_typing_residual) ─ fn lower_all(app: &roundhouse::App) -> Vec { - let vctx = roundhouse::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = build_class_info_extras(&preliminary_views); + let vctx = roundhouse::timings::phase("lower: view ctx", || { + roundhouse::lower::ViewLowerCtx::new(app) + }); + let mut view_lcs = roundhouse::timings::phase("lower: preliminary views", || { + preliminary_view_classes(&app.views, &vctx) + }); + let view_extras = build_class_info_extras(&view_lcs); let (model_lcs, model_registry) = - lower_models_with_registry(&app.models, &app.schema, view_extras); - let view_lcs = lower_views_to_library_classes( - &app.views, - app, - model_registry.clone().into_iter().collect(), - ); - let jbuilder_lcs = lower_jbuilder_to_library_classes( - &app.views, - app, - model_registry.clone().into_iter().collect(), - ); + roundhouse::timings::phase("lower: models", || { + lower_models_with_registry(&app.models, &app.schema, view_extras) + }); + roundhouse::timings::phase("lower: views type", || { + type_view_library_classes( + &mut view_lcs, + app, + model_registry.clone().into_iter().collect(), + ); + }); + let jbuilder_lcs = roundhouse::timings::phase("lower: jbuilder", || { + lower_jbuilder_to_library_classes( + &app.views, + app, + model_registry.clone().into_iter().collect(), + ) + }); let mut controller_extras: Vec<(ClassId, roundhouse::analyze::ClassInfo)> = model_registry.clone().into_iter().collect(); controller_extras.extend(build_class_info_extras(&view_lcs)); - let controller_lcs = roundhouse::lower::lower_controllers_with_arel_and_views( - &app.controllers, - controller_extras, - Some(&app.schema), - &app.views, - ); + let controller_lcs = roundhouse::timings::phase("lower: controllers", || { + roundhouse::lower::lower_controllers_with_arel_and_views( + &app.controllers, + controller_extras, + Some(&app.schema), + &app.views, + ) + }); // Test modules — same shared-registry pattern. Test bodies dispatch // on models (`@article.title`), Comment.where(…), assertions on // self (Minitest::Test), so the registry needs all of: models + // views + controllers. - let fixture_lcs = lower_fixtures_to_library_classes(app); + let fixture_lcs = roundhouse::timings::phase("lower: fixtures", || { + lower_fixtures_to_library_classes(app) + }); let mut test_extras: Vec<(ClassId, roundhouse::analyze::ClassInfo)> = model_registry.into_iter().collect(); test_extras.extend(build_class_info_extras(&view_lcs)); test_extras.extend(build_class_info_extras(&controller_lcs)); test_extras.extend(build_class_info_extras(&fixture_lcs)); - let test_lcs = lower_test_modules_to_library_classes( - &app.test_modules, - &app.fixtures, - &app.models, - test_extras, - &roundhouse::lower::routes::helper_id_segments(app), - ); + let test_lcs = roundhouse::timings::phase("lower: tests", || { + lower_test_modules_to_library_classes( + &app.test_modules, + &app.fixtures, + &app.models, + test_extras, + &roundhouse::lower::routes::helper_id_segments(app), + ) + }); let mut all = Vec::new(); all.extend(model_lcs); @@ -406,9 +418,11 @@ fn lower_all(app: &roundhouse::App) -> Vec { all.extend(controller_lcs); all.extend(fixture_lcs); all.extend(test_lcs); - for lc in &app.library_classes { - all.push(lc.clone()); - } + roundhouse::timings::phase("lower: copy library classes", || { + for lc in &app.library_classes { + all.push(lc.clone()); + } + }); all } diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index cdfe77583..1ba216f67 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -153,7 +153,7 @@ pub enum ReturnKind { SelfOrNil, /// Returns `Int`. Example: `Model.count`. Int, - /// Returns `Int | Nil`. Example: Kaminari's `relation.next_page`, + /// Returns `Int | Nil`. Example: `relation.next_page`, /// nil on the last page. IntOrNil, /// Returns `Bool`. Example: `Model.exists?`, `#save`, @@ -315,12 +315,12 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[ chain: ChainKind::NotApplicable, return_kind: Some(ReturnKind::SelfType), }, - // Kaminari's pagination entry point (`Model.page(n)`). Not core AR, - // but it shares the relation-builder shape and is called on every - // model class, so the AR catalog is its mechanical home (the - // gem catalog keys on concrete class names and can't say "every - // model"). The Array receiver form lives in `array_method`'s - // relation branch alongside `per`/`padding`/`without_count`. + // Pagination entry point (`Model.page(n)`). Not core AR, but it + // shares the relation-builder shape and is called on every model + // class, so the AR catalog is its mechanical home (the gem catalog + // keys on concrete class names and can't say "every model"). The + // Array receiver form lives in `array_method`'s relation + // branch alongside `per`/`padding`/`without_count`. CatalogedMethod { name: "page", receiver: ReceiverContext::Class, @@ -328,9 +328,9 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[ chain: ChainKind::Builder, return_kind: Some(ReturnKind::RelationOfSelf), }, - // will_paginate's entry point, `Model.paginate(page: n)` — kaminari's - // `page` under another gem's name, same builder shape, same - // reasoning for living here. + // `Model.paginate` — `page` under another spelling. Accepts a + // positional page number or `page:` / `per_page:` keywords (the + // LIMIT/OFFSET window under the kwargs form). CatalogedMethod { name: "paginate", receiver: ReceiverContext::Class, @@ -1227,8 +1227,7 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[ chain: ChainKind::Builder, return_kind: Some(ReturnKind::RelationOfSelf), }, - // will_paginate's `paginate(page:)` on a relation — same builder - // shape as kaminari's `page` below. + // `paginate` on a relation — same LIMIT/OFFSET builder as `page`. CatalogedMethod { name: "paginate", receiver: ReceiverContext::Relation, @@ -1243,7 +1242,7 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[ chain: ChainKind::Terminal, return_kind: Some(ReturnKind::SelfOrNil), }, - // Kaminari's pagination chain — same builder shape. + // Pagination chain — same builder shape. CatalogedMethod { name: "page", receiver: ReceiverContext::Relation, @@ -1272,10 +1271,10 @@ pub const AR_CATALOG: &[CatalogedMethod] = &[ chain: ChainKind::Builder, return_kind: Some(ReturnKind::RelationOfSelf), }, - // Kaminari's paginator readers on a paged relation + // Paginator readers on a paged relation // (runtime/ruby/active_record/relation.rb). The page arithmetic is - // pure over LIMIT/OFFSET; the readers that need the total run the - // COUNT. + // LIMIT/OFFSET; the readers that need the total run COUNT without + // that window. CatalogedMethod { name: "limit_value", receiver: ReceiverContext::Relation, diff --git a/src/dialect.rs b/src/dialect.rs index 3671a740e..2b1c3e272 100644 --- a/src/dialect.rs +++ b/src/dialect.rs @@ -381,6 +381,15 @@ pub enum Association { /// See `HasMany::as_interface`. #[serde(default, skip_serializing_if = "Option::is_none")] as_interface: Option, + /// Association scope lambda body, same contract as + /// [`HasMany::scope`] (`has_one :x, -> { where(name: "body") }`). + #[serde(default, skip_serializing_if = "Option::is_none")] + scope: Option, + /// `autosave: true` — persist a built/assigned child after the + /// owner saves. Default false, matching Rails. Carried on IR; + /// has_one autosave lowering is still a separate claim. + #[serde(default, skip_serializing_if = "is_false")] + autosave: bool, }, HasAndBelongsToMany { name: Symbol, diff --git a/src/emit/crystal.rs b/src/emit/crystal.rs index de86f7787..3eeb3ca04 100644 --- a/src/emit/crystal.rs +++ b/src/emit/crystal.rs @@ -204,12 +204,8 @@ pub fn emit(app: &App) -> Vec { crate::lower::controller_to_library::params::collect_specs(&app.controllers); let vctx = crate::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = crate::lower::extras_from_lcs(&preliminary_views); + let mut view_lcs = crate::lower::preliminary_view_classes(&app.views, &vctx); + let view_extras = crate::lower::extras_from_lcs(&view_lcs); let route_helper_funcs_for_extras = crate::lower::lower_routes_to_library_functions(app); let route_helper_extras = crate::lower::extras_from_funcs(&route_helper_funcs_for_extras); @@ -293,8 +289,7 @@ pub fn emit(app: &App) -> Vec { let mut view_lower_extras: Vec<(crate::ident::ClassId, crate::analyze::ClassInfo)> = model_registry.clone().into_iter().collect(); view_lower_extras.extend(route_helper_extras.clone()); - let view_lcs = - crate::lower::lower_views_to_library_classes(&app.views, app, view_lower_extras.clone()); + crate::lower::type_view_library_classes(&mut view_lcs, app, view_lower_extras.clone()); let jbuilder_lcs = crate::lower::lower_jbuilder_to_library_classes(&app.views, app, view_lower_extras); diff --git a/src/emit/crystal/expr.rs b/src/emit/crystal/expr.rs index 3a9b33696..1b278988e 100644 --- a/src/emit/crystal/expr.rs +++ b/src/emit/crystal/expr.rs @@ -37,7 +37,11 @@ where r } +/// Render a Crystal expression, recognizing whole-call primitives before node dispatch. pub fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Crystal, emit_expr) { + return s; + } // IrHint::StringBuilder* — lowerer-tagged accumulator triple // (`io = String.new; io << "..."; io`). Crystal's `String + String` // is O(n²) per append (immutable Strings reallocate); swap to diff --git a/src/emit/csharp.rs b/src/emit/csharp.rs index de3758c3b..e2fabe9ea 100644 --- a/src/emit/csharp.rs +++ b/src/emit/csharp.rs @@ -64,12 +64,8 @@ pub fn emit(app: &App) -> Vec { // Preliminary view pass: seeds the model lowerer's association element // types, and gives the view-module method names for the Phase-2 stubs. let vctx = crate::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = crate::lower::extras_from_lcs(&preliminary_views); + let mut view_lcs = crate::lower::preliminary_view_classes(&app.views, &vctx); + let view_extras = crate::lower::extras_from_lcs(&view_lcs); // Permitted-params specs → each model gains a typed `from_params`. let params_specs = @@ -91,7 +87,7 @@ pub fn emit(app: &App) -> Vec { // function signatures. let mut view_lower_extras: Vec<(crate::ident::ClassId, crate::analyze::ClassInfo)> = model_registry.into_iter().collect(); - view_lower_extras.extend(crate::lower::extras_from_lcs(&preliminary_views)); + view_lower_extras.extend(crate::lower::extras_from_lcs(&view_lcs)); // Route helpers (`RouteHelpers.article_path(id)`) → a `static class`. let route_helper_funcs = crate::lower::lower_routes_to_library_functions(app); @@ -112,8 +108,7 @@ pub fn emit(app: &App) -> Vec { // methods on the same module. C# static classes can't be reopened, so the // per-template LibraryClasses merge into one `static class ` per // module, emitted to app/views/. - let view_lcs = - crate::lower::lower_views_to_library_classes(&app.views, app, view_lower_extras.clone()); + crate::lower::type_view_library_classes(&mut view_lcs, app, view_lower_extras.clone()); let jbuilder_lcs = crate::lower::lower_jbuilder_to_library_classes(&app.views, app, view_lower_extras.clone()); let mut all_view_lcs = view_lcs.clone(); diff --git a/src/emit/csharp/expr.rs b/src/emit/csharp/expr.rs index badaeee88..c689a6dd9 100644 --- a/src/emit/csharp/expr.rs +++ b/src/emit/csharp/expr.rs @@ -748,7 +748,11 @@ fn children(e: &Expr) -> Vec<&Expr> { // ---- Expression rendering (value position) ---- +/// Render a C# value expression after shared primitive and string-builder selection. pub fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::CSharp, emit_expr) { + return s; + } if let Some(s) = try_string_builder(e) { return s; } @@ -1356,7 +1360,7 @@ fn emit_send( if let ExprNode::Range { begin, end, exclusive } = &*args[0].node { return emit_slice_range(&rs, begin.as_ref(), end.as_ref(), *exclusive); } - if matches!(r.ty.as_ref(), Some(crate::ty::Ty::Array { .. })) { + if recv_is_array(r) { return format!("{rs}[(int)({})]", args_s[0]); } // Ruby `Hash#[]` returns nil for a missing key; C#'s Dictionary @@ -1402,7 +1406,12 @@ fn emit_send( } if let (Some(r), 2) = (recv, args.len()) { if method == "[]=" { - return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]); + let idx = if list_index_needs_int_cast(r, &args[0]) { + format!("(int)({})", args_s[0]) + } else { + args_s[0].clone() + }; + return format!("{}[{}] = {}", emit_expr(r), idx, args_s[1]); } if method == "fetch" { return format!("({}.GetValueOrDefault({}, {}))", emit_expr(r), args_s[0], args_s[1]); @@ -1874,11 +1883,27 @@ fn lvalue_ref(target: &LValue) -> String { LValue::Var { name, .. } => camel(name.as_str()), LValue::Ivar { name } => format!("this.{}", ivar_name(name.as_str())), LValue::Attr { recv, name } => format!("{}.{}", emit_expr(recv), pascal(name.as_str())), - LValue::Index { recv, index } => format!("{}[{}]", emit_expr(recv), emit_expr(index)), + LValue::Index { recv, index } => { + let idx = if list_index_needs_int_cast(recv, index) { + format!("(int)({})", emit_expr(index)) + } else { + emit_expr(index) + }; + format!("{}[{}]", emit_expr(recv), idx) + } LValue::Const { path } => path.iter().map(|s| s.to_string()).collect::>().join("."), } } +/// True when `recv[index]` is a List/Array access (not a Dictionary) +/// that needs a C# `(int)` cast from the IR's `long` index. +fn list_index_needs_int_cast(recv: &Expr, index: &Expr) -> bool { + if recv_is_hash(recv) { + return false; + } + recv_is_array(recv) || matches!(index.ty.as_ref(), Some(crate::ty::Ty::Int)) +} + fn emit_op_assign(target: &LValue, op: OpAssignOp, value: &Expr) -> String { let lhs = lvalue_ref(target); let v = emit_expr(value); diff --git a/src/emit/elixir/expr.rs b/src/emit/elixir/expr.rs index 6ba785187..c5dd90b90 100644 --- a/src/emit/elixir/expr.rs +++ b/src/emit/elixir/expr.rs @@ -678,7 +678,11 @@ fn emit_block_with_value(e: &Expr) -> String { // ---- expression emit ------------------------------------------------ +/// Render an Elixir expression while preserving complete-call primitive semantics. pub(super) fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Elixir, emit_expr) { + return s; + } // String-builder hint sites (`io = String.new; io << "..."; io`, // tagged by the view/jbuilder lowerer) → the iolist idiom. One hook // covers the Append + terminal-Result sites; Init is intercepted in diff --git a/src/emit/go/expr.rs b/src/emit/go/expr.rs index 82d2f3522..5951fe8e6 100644 --- a/src/emit/go/expr.rs +++ b/src/emit/go/expr.rs @@ -159,9 +159,23 @@ impl EmitCtx { child.declared = Rc::new(RefCell::new(snapshot)); child } + + /// Value-position IIFE body (`func() T { … }()`). Cleared of + /// `void_method` so string/ternary tails emit `return "…"`, even + /// when the enclosing Ruby method is `() -> void` (e.g. `send_data` + /// assigning `disp = cond ? "inline" : "attachment"`). + pub fn value_iife(&self) -> Self { + let mut child = self.enter_scope(); + child.void_method = false; + child + } } +/// Render a Go expression in its declaration context, selecting whole-call primitives first. pub(super) fn emit_expr(ctx: &EmitCtx, e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Go, |recv| emit_expr(ctx, recv)) { + return s; + } // IrHint::StringBuilder* — lowerer-tagged accumulator triple // (`io = String.new; io << "..."; io`). Go's immutable strings // make `io = io + "..."` O(n²); swap to `strings.Builder` which @@ -1122,7 +1136,7 @@ pub(super) fn emit_send( let value = &args[0]; let v = if matches!(&*value.node, ExprNode::If { .. } | ExprNode::Case { .. }) { let ret_ty = super::ty::go_ty_stub(value.ty.as_ref()); - let body = emit_return_body(ctx, value); + let body = emit_return_body(&ctx.value_iife(), value); let indented = body .lines() .map(|l| format!("\t{l}")) @@ -2631,7 +2645,7 @@ fn emit_assign(ctx: &EmitCtx, target: &crate::expr::LValue, value: &Expr) -> Str // above — using the narrow type lets the resulting assignment // typecheck against typed slots without callsite assertion. let ret_ty = super::ty::go_ty_stub(value.ty.as_ref()); - let body = emit_return_body(ctx, value); + let body = emit_return_body(&ctx.value_iife(), value); let indented = body .lines() .map(|l| format!("\t{l}")) @@ -3417,7 +3431,7 @@ pub(super) fn emit_return_body(ctx: &EmitCtx, e: &Expr) -> String { pub(super) fn emit_expr_as_value(ctx: &EmitCtx, e: &Expr) -> String { if matches!(&*e.node, ExprNode::If { .. } | ExprNode::Case { .. }) { let ret_ty = super::ty::go_ty_stub(e.ty.as_ref()); - let body = emit_return_body(ctx, e); + let body = emit_return_body(&ctx.value_iife(), e); let indented = body .lines() .map(|l| format!("\t{l}")) diff --git a/src/emit/go/library.rs b/src/emit/go/library.rs index 06c58dd98..a10ea9a43 100644 --- a/src/emit/go/library.rs +++ b/src/emit/go/library.rs @@ -1537,7 +1537,7 @@ fn emit_module_singleton_method(class_name: &str, m: &MethodDef) -> String { #[cfg(test)] mod predicate_naming_tests { - use super::sanitize_method_name; + use super::{emit_library_class, sanitize_method_name}; /// Regression guard: the bare-fn name path affixes `?` → `_pred` so a /// predicate (`exists?`) never collides with a same-named reader @@ -1549,4 +1549,39 @@ mod predicate_naming_tests { assert_eq!(sanitize_method_name("exists?"), "exists_pred"); assert_eq!(sanitize_method_name("save!"), "save_bang"); } + + /// HeaderStore keys/vals must be `[]string` (RBS ivar seed), and + /// void `send_data`'s disposition ternary IIFE must `return` strings. + #[test] + fn action_controller_go_emit_typechecks_hotspots() { + let ruby = include_str!("../../../runtime/ruby/action_controller/base.rb"); + let rbs = include_str!("../../../runtime/ruby/action_controller/base.rbs"); + let classes = crate::runtime_src::parse_library_with_rbs( + ruby.as_bytes(), + rbs, + "action_controller/base.rb", + ) + .expect("action_controller/base parses and types"); + let mut src = String::new(); + for c in &classes { + src.push_str(&emit_library_class(c).expect("emits")); + src.push('\n'); + } + assert!( + src.contains("Keys []string") && src.contains("Vals []string"), + "HeaderStore must emit []string fields:\n{src}" + ); + assert!( + !src.contains("Keys []interface{}") && !src.contains("Vals []interface{}"), + "HeaderStore must not erase keys/vals to interface{{}}:\n{src}" + ); + let send = src + .find("func (self *ActionControllerBase) SendData") + .and_then(|i| src[i..].find("func (self *ActionControllerBase) Verify").map(|j| &src[i..i + j])) + .expect("SendData method"); + assert!( + send.contains("return \"inline\"") && send.contains("return \"attachment\""), + "void send_data IIFE must return disposition strings:\n{send}" + ); + } } diff --git a/src/emit/kotlin.rs b/src/emit/kotlin.rs index 4f31ef1d4..bc1e99bb4 100644 --- a/src/emit/kotlin.rs +++ b/src/emit/kotlin.rs @@ -66,12 +66,8 @@ pub fn emit(app: &App) -> Vec { // registry, then models lower to LibraryClasses (including synthesized // `Row` siblings). let vctx = crate::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = crate::lower::extras_from_lcs(&preliminary_views); + let mut view_lcs = crate::lower::preliminary_view_classes(&app.views, &vctx); + let view_extras = crate::lower::extras_from_lcs(&view_lcs); // Permitted-params specs (resource → fields) collected from the // controllers, so each model gains a typed `from_params(p: Params)` // factory the controllers call. @@ -115,11 +111,7 @@ pub fn emit(app: &App) -> Vec { if let Some(f) = library::emit_function_module(&importmap_funcs) { files.push(f); } - let view_lcs = crate::lower::lower_views_to_library_classes( - &app.views, - app, - view_lower_extras.clone(), - ); + crate::lower::type_view_library_classes(&mut view_lcs, app, view_lower_extras.clone()); // Jbuilder (json-format) views lower to `_json` methods on the same // `Views::` module; merge them into the html view objects (Kotlin // objects can't be reopened) so a controller's JSON branch resolves diff --git a/src/emit/kotlin/expr.rs b/src/emit/kotlin/expr.rs index 5feeb3ca6..1e4017cf8 100644 --- a/src/emit/kotlin/expr.rs +++ b/src/emit/kotlin/expr.rs @@ -794,7 +794,11 @@ fn children(e: &Expr) -> Vec<&Expr> { v } +/// Render a Kotlin value expression after shared primitive and string-builder selection. pub fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Kotlin, emit_expr) { + return s; + } if let Some(s) = try_string_builder(e) { return s; } @@ -1267,11 +1271,30 @@ fn lvalue_ref(target: &LValue) -> String { LValue::Var { name, .. } => camel(name.as_str()), LValue::Ivar { name } => format!("this.{}", camel(name.as_str())), LValue::Attr { recv, name } => format!("{}.{}", emit_expr(recv), camel(name.as_str())), - LValue::Index { recv, index } => format!("{}[{}]", emit_expr(recv), emit_expr(index)), + LValue::Index { recv, index } => { + // List indices are `Long` in the IR; Kotlin wants `Int`. + // Also cover module constants like `FORGERY_SLOT[0] =` whose + // recv ty may not be stamped as Array yet. + let idx = if list_index_needs_int_cast(recv, index) { + format!("({}).toInt()", emit_expr(index)) + } else { + emit_expr(index) + }; + format!("{}[{}]", emit_expr(recv), idx) + } LValue::Const { path } => path.iter().map(|s| s.to_string()).collect::>().join("."), } } +/// True when `recv[index]` is a List/Array access (not a Hash) that +/// needs a Kotlin `Int` index cast from the IR's `Long`. +fn list_index_needs_int_cast(recv: &Expr, index: &Expr) -> bool { + if recv_is_hash(recv) { + return false; + } + recv_is_array(recv) || matches!(index.ty.as_ref(), Some(crate::ty::Ty::Int)) +} + fn emit_op_assign(target: &LValue, op: OpAssignOp, value: &Expr) -> String { let lhs = lvalue_ref(target); let v = emit_expr(value); @@ -1660,7 +1683,7 @@ fn emit_send( return emit_slice_range(&rs, begin.as_ref(), end.as_ref(), *exclusive); } // List/Array index needs an Int (indices are `Long`). - if matches!(r.ty.as_ref(), Some(crate::ty::Ty::Array { .. })) { + if recv_is_array(r) { return format!("{rs}[({}).toInt()]", args_s[0]); } return format!("{rs}[{}]", args_s[0]); @@ -1714,7 +1737,12 @@ fn emit_send( } if let (Some(r), 2) = (recv, args.len()) { if method == "[]=" { - return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]); + let idx = if list_index_needs_int_cast(r, &args[0]) { + format!("({}).toInt()", args_s[0]) + } else { + args_s[0].clone() + }; + return format!("{}[{}] = {}", emit_expr(r), idx, args_s[1]); } // `Hash#fetch(k, default)` → `(recv[k] ?: default)` (Ruby returns // the value or the default; Kotlin map-get is null for missing). diff --git a/src/emit/python/expr.rs b/src/emit/python/expr.rs index a2c895986..85d521890 100644 --- a/src/emit/python/expr.rs +++ b/src/emit/python/expr.rs @@ -800,7 +800,11 @@ fn emit_block_body(e: &Expr, void: bool) -> String { super::shared::indent_py(&inner) } +/// Render a Python expression, retaining diagnostics and complete-call primitive handling. pub(super) fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Python, emit_expr) { + return s; + } // Analyze may have annotated this expression as a user error // (e.g., Incompatible `+`). If so, emit the target raise- // equivalent instead of the normal rendering — matches Ruby's diff --git a/src/emit/ruby.rs b/src/emit/ruby.rs index 7b892a233..697efeeda 100644 --- a/src/emit/ruby.rs +++ b/src/emit/ruby.rs @@ -26,6 +26,7 @@ mod library; mod rbs; pub mod shake; mod shared; +pub mod source_markers; /// Render a `Ty` to its RBS string form (`String`, `Array[Comment]`, /// `Article`, `Integer?`). Re-exported for non-emit consumers — e.g. the @@ -72,7 +73,24 @@ pub fn emit_method(m: &MethodDef) -> String { format!("({})", ps.join(", ")) }; let mut out = String::new(); + // The body's first statement: a Seq writes markers only between + // its statements, so the first is named here. + let first = match &*m.body.node { + crate::expr::ExprNode::Seq { exprs } => exprs.first().unwrap_or(&m.body), + _ => &m.body, + }; + let body_marker = source_markers::marker_for(&first.span); + // A marker ABOVE the def, so the def line never reports the previous + // method's held position: Spinel names a --debug backtrace frame by + // its def line (spinel#7658). A synthesized method has no name span; + // its body's first statement stands in. + if let Some(mk) = source_markers::marker_for(&m.name_span).or_else(|| body_marker.clone()) { + writeln!(out, "{mk}").unwrap(); + } writeln!(out, "def {prefix}{}{}", m.name, params).unwrap(); + if let Some(mk) = body_marker { + writeln!(out, "{mk}").unwrap(); + } let body_text = emit_expr(&m.body); fn emit_default(default: &crate::expr::Expr) -> String { let src = expr::emit_expr(default); diff --git a/src/emit/ruby/expr.rs b/src/emit/ruby/expr.rs index 0a2f56453..6bf7f7b23 100644 --- a/src/emit/ruby/expr.rs +++ b/src/emit/ruby/expr.rs @@ -34,6 +34,8 @@ pub(super) fn with_core_class_reopen(yes: bool, f: impl FnOnce() -> R) -> R { r } +/// Emit Ruby-family syntax while retaining diagnostics and typed primitive +/// semantics, including the no-block form of String#bytes with literal &nil. pub fn emit_expr(e: &Expr) -> String { // A site a lowering replaced with a stub — `lower::object_extend`, // the arel `ColumnSpec::Named` placeholder — renders as the raise @@ -48,6 +50,14 @@ pub fn emit_expr(e: &Expr) -> String { .render(&crate::diagnostic::Diagnostic::stub_text(kind)); return format!("({stub})"); } + if crate::emit::shared::string_bytes::materializes_array(e) { + if let ExprNode::Send { recv, method, args, parenthesized, .. } = &*e.node { + // Literal &nil supplies no block. Canonicalize it here so Spinel + // takes the array-returning native bytes path too; arbitrary block + // expressions retain their effects through the ordinary emitter. + return emit_send_base(recv.as_ref(), method, args, *parenthesized); + } + } if is_mutable_string_literal(e) { return format!("+{}", emit_node(&e.node)); } @@ -238,6 +248,13 @@ fn emit_node(n: &ExprNode) -> String { if e.leading_blank_line { out.push('\n'); } + // Not before the first: a value-site Seq renders as + // `(a\nb)`, and the marker must start its line. The + // enclosing statement's or def's marker covers it. + if let Some(m) = super::source_markers::marker_for(&e.span) { + out.push_str(&m); + out.push('\n'); + } } out.push_str(&emit_expr(e)); } diff --git a/src/emit/ruby/library.rs b/src/emit/ruby/library.rs index fbe2bd3a5..a9f87678f 100644 --- a/src/emit/ruby/library.rs +++ b/src/emit/ruby/library.rs @@ -6946,6 +6946,9 @@ fn require_path_for_body_const( // body to need this: `created_at: <%= 1.hour.ago %>` grounds to // `ActiveSupport::Duration.hour(1)` and `test/fixtures/.rb` is // reached from the test harness, not from main.rb's require chain. + if joined == "ActiveSupport::SecurityUtils" { + return Some("runtime/security_utils".to_string()); + } if joined == "ActiveSupport::Duration" { return Some("runtime/active_support_duration".to_string()); } diff --git a/src/emit/ruby/source_markers.rs b/src/emit/ruby/source_markers.rs new file mode 100644 index 000000000..a4ce9897d --- /dev/null +++ b/src/emit/ruby/source_markers.rs @@ -0,0 +1,142 @@ +//! `#file:line` markers: the Spinel tree's source map. +//! +//! Spinel's `#line` directives (and with them `-g`/`--debug` stepping, +//! `perf`/`addr2line`, `--warn-widen`, `--check-stores`) name the .rb +//! they were compiled from. For our tree that is lowered Ruby, not the +//! app's `.rb`/`.erb`. A whole-line `#path:line` comment +//! (spinel#7630, docs/profiling.md) pins the reported position for the +//! lines after it, held rather than counted up, until the next marker +//! or the end of the file. CRuby reads it as a comment. +//! +//! Emission is two halves. While `with_source_markers` is in effect, +//! the expression emitter writes a marker before each statement of a +//! multi-statement `Seq` and `emit_method` writes one at each `def`, +//! from the IR span (view spans already index the raw template). Those +//! strings are built inside-out and indented by their callers, so they +//! cannot know what the previous marker in the FILE was. `finish` does +//! that afterwards: it moves each marker to column 0 (Spinel matches it +//! only there) and drops the ones that repeat the position in effect. +//! +//! Spinel has no "unpin" form, so code with no span after a marker +//! reports the last pinned position. Synthesized methods still get +//! their own `def` marker when the lowerer gave them a span. + +use std::cell::RefCell; + +use crate::App; +use crate::span::Span; + +pub(crate) const MARKER_PREFIX: &str = "#"; + +struct Sources { + /// `FileId(n)` → entry `n - 1`: the app-relative path and the byte + /// offset each line starts at. + files: Vec<(String, Vec)>, +} + +thread_local! { + static SOURCES: RefCell> = const { RefCell::new(None) }; +} + +/// Run `f` with marker emission on, resolving spans against `app`'s +/// sources. Paths are app-relative (`App::root` stripped) under the +/// app directory's own name: `real-blog/app/models/article.rb`. Bare +/// `app/models/article.rb` is also the emitted file's PHYSICAL path, +/// which Spinel reports for lines no marker covers, so the two would +/// read as one file with two sets of line numbers. +pub fn with_source_markers(app: &App, f: impl FnOnce() -> R) -> R { + let root = app.root.as_str(); + let label = std::path::Path::new(root.trim_end_matches('/')) + .file_name() + .and_then(|n| n.to_str()) + .map(|n| format!("{n}/")) + .unwrap_or_default(); + let files = app + .sources + .iter() + .map(|s| { + let rel = s + .path + .strip_prefix(root) + .map(|r| r.trim_start_matches('/')) + .unwrap_or(&s.path); + let rel = format!("{label}{rel}"); + let mut starts = vec![0u32]; + starts.extend( + s.text.bytes().enumerate().filter(|(_, b)| *b == b'\n').map(|(i, _)| i as u32 + 1), + ); + (rel, starts) + }) + .collect(); + let prev = SOURCES.with(|c| c.replace(Some(Sources { files }))); + let r = f(); + SOURCES.with(|c| *c.borrow_mut() = prev); + r +} + +/// The marker naming `span`'s start, or `None` when markers are off, +/// the span is synthetic, or it names no registered file. +pub(super) fn marker_for(span: &Span) -> Option { + if span.is_synthetic() || span.file.0 == 0 { + return None; + } + SOURCES.with(|c| { + let c = c.borrow(); + let (path, starts) = c.as_ref()?.files.get(span.file.0 as usize - 1)?; + let line = starts.partition_point(|&s| s <= span.start); + Some(format!("{MARKER_PREFIX}{path}:{line}")) + }) +} + +/// Normalize the markers in one emitted file: each to column 0, and only +/// where the position changes. A marker immediately followed by another +/// (no code between) is superseded and dropped, as is one at the end. +pub fn finish(content: &str) -> String { + if !content.contains(MARKER_PREFIX) { + return content.to_string(); + } + let mut out: Vec<&str> = Vec::new(); + let mut active: Option<&str> = None; + // The marker waiting for a code line to make it effective. + let mut pending: Option<&str> = None; + for line in content.split('\n') { + let trimmed = line.trim_start(); + if trimmed.starts_with(MARKER_PREFIX) { + pending = Some(trimmed); + continue; + } + if let Some(m) = pending.take() { + if !trimmed.is_empty() && active != Some(m) { + out.push(m); + active = Some(m); + } else if trimmed.is_empty() { + // A blank line is not code: keep waiting. + pending = Some(m); + } + } + out.push(line); + } + out.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finish_moves_to_column_zero_and_drops_repeats() { + let src = "def a\n #x.rb:3\n b\n #x.rb:3\n c\nend\n"; + assert_eq!(finish(src), "def a\n#x.rb:3\n b\n c\nend\n"); + } + + #[test] + fn finish_drops_superseded_and_trailing_markers() { + let src = "#x.rb:1\n#x.rb:2\n\na\n#x.rb:9\n"; + assert_eq!(finish(src), "\n#x.rb:2\na\n"); + } + + #[test] + fn finish_leaves_files_without_markers_alone() { + assert_eq!(finish("a\n b\n"), "a\n b\n"); + } +} diff --git a/src/emit/rust/expr/assign.rs b/src/emit/rust/expr/assign.rs index f8dd3245a..5a5aa421e 100644 --- a/src/emit/rust/expr/assign.rs +++ b/src/emit/rust/expr/assign.rs @@ -153,6 +153,18 @@ pub(super) fn emit_assign(target: &LValue, value: &Expr) -> String { emit_expr(index), ); } + let leaf = cls.rsplit("::").next().unwrap_or(cls); + if leaf == "HeaderStore" { + let opt_str = crate::ty::Ty::Union { + variants: vec![crate::ty::Ty::Str, crate::ty::Ty::Nil], + }; + let wrapped = super::coerce_arg_for_param_ty(value, &opt_str); + return format!( + "{}.set_index({}, {wrapped})", + emit_expr(recv), + emit_expr(index), + ); + } // Other Ty::Class receivers route through `set_index` // (per the operator-method rewrite in `sanitize_ident`). // Wrap String RHS with `serde_json::Value::from` diff --git a/src/emit/rust/expr/control.rs b/src/emit/rust/expr/control.rs index c74d32a23..005a63e64 100644 --- a/src/emit/rust/expr/control.rs +++ b/src/emit/rust/expr/control.rs @@ -105,7 +105,12 @@ pub(super) fn emit_if(cond: &Expr, then_branch: &Expr, else_branch: &Expr) -> St pub(super) fn emit_while(cond: &Expr, body: &Expr, until_form: bool) -> String { // Rust has no `until`; rewrite to `while !cond` for parity. let cond_s = emit_expr(cond); - let body_s = emit_expr(body); + // Snapshot declared-vars like If branches: a `let mut c` inside + // the first while must not suppress `let mut c` in a sibling + // while (`sanitize_location` walks leading then trailing chars). + // Locals assigned *before* the loop stay visible (the snapshot + // includes them). + let body_s = with_declared_vars_scope(|| emit_expr(body)); let cond_clause = if until_form { format!("!({cond_s})") } else { @@ -205,6 +210,11 @@ pub(super) fn emit_return(value: &Expr) -> String { // outside `()` / Unit returns. if current_return_is_option() { "return None".to_string() + } else if current_return_ty() + .as_ref() + .is_some_and(|t| super::super::ty::rust_value_shaped(t)) + { + "return serde_json::Value::Null".to_string() } else { "return".to_string() } diff --git a/src/emit/rust/expr/literal.rs b/src/emit/rust/expr/literal.rs index 32adeb724..7e1fd0881 100644 --- a/src/emit/rust/expr/literal.rs +++ b/src/emit/rust/expr/literal.rs @@ -321,35 +321,42 @@ pub(super) fn string_interp_fmt_and_args(parts: &[InterpPart]) -> (String, Vec { + args.push(format!("({arg}).ruby_to_s()")); + } + Some(ty) + if super::super::ty::rust_value_shaped(ty) + && matches!( + &*expr.node, + crate::expr::ExprNode::Var { .. } + | crate::expr::ExprNode::Ivar { .. } + ) => + { + args.push(format!( + "::ruby_to_s(&({arg}))" + )); + } + _ if expr_recv_is_value(expr) => { + args.push(format!("({arg}).ruby_to_s()")); + } + _ => args.push(arg), } } } @@ -362,7 +369,10 @@ pub(super) fn string_interp_fmt_and_args(parts: &[InterpPart]) -> (String, Vec`. fn expr_recv_is_value(expr: &Expr) -> bool { use crate::expr::ExprNode; let recv_opt: Option<&Expr> = match &*expr.node { @@ -382,7 +392,18 @@ fn expr_recv_is_value(expr: &Expr) -> bool { /// floating-typed when the value has no fractional part. pub(crate) fn emit_literal(lit: &Literal) -> String { match lit { - Literal::Nil => "None".to_string(), + Literal::Nil => { + // Tail `nil` in an untyped/Value-returning method is + // `Value::Null`, not Option::None (`request_for_csrf`). + if in_return_tail() { + if let Some(ty) = current_return_ty() { + if super::super::ty::rust_value_shaped(&ty) { + return "serde_json::Value::Null".to_string(); + } + } + } + "None".to_string() + } Literal::Bool { value } => value.to_string(), Literal::Int { value } => format!("{value}_i64"), Literal::Float { value } => { diff --git a/src/emit/rust/expr/mod.rs b/src/emit/rust/expr/mod.rs index 47d53232e..d08967913 100644 --- a/src/emit/rust/expr/mod.rs +++ b/src/emit/rust/expr/mod.rs @@ -789,7 +789,11 @@ pub(super) fn has_str_coercion(e: &Expr) -> bool { != 0 } +/// Render a Rust expression node after shared complete-call primitive classification. fn emit_expr_inner(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Rust, emit_expr) { + return s; + } match &*e.node { ExprNode::Lit { value } => emit_literal(value), ExprNode::Var { name, .. } => { diff --git a/src/emit/rust/expr/send/coerce.rs b/src/emit/rust/expr/send/coerce.rs index 47040353f..2cbbde7ac 100644 --- a/src/emit/rust/expr/send/coerce.rs +++ b/src/emit/rust/expr/send/coerce.rs @@ -63,14 +63,29 @@ pub(crate) fn coerce_arg_for_param_ty(arg: &Expr, param_ty: &crate::ty::Ty) -> S // positions). Pre-Stage-6 unwrapped args hit this same path via // Family 2 below — peek-through here keeps the emit identical. if let ExprNode::Cast { value, target_ty } = &*arg.node { - if target_ty.is_scalar() - && value.ty.as_ref().map(ty_contains_untyped).unwrap_or(false) - { + let inner_untyped = value.ty.as_ref().map(ty_contains_untyped).unwrap_or(false); + if target_ty.is_scalar() && inner_untyped { + if target_ty.is_stringish() { + if json_value_arg_for_string_param(value) { + let inner_raw = emit_expr(value); + return json_value_as_str(inner_raw, value); + } + // Class-method Sends can be IR-Untyped while rust emits + // `String`. Borrow rather than calling Value::as_str. + return format!("&({})", emit_expr(value)); + } if let Some(coerce) = super::super::util::value_narrowing_coercion(target_ty) { let inner_raw = emit_expr(value); return format!("({inner_raw}).{coerce}"); } } + // Stringish unions (`String | Symbol`) are not `is_scalar` but + // rust emits them as `&str` params. Only Hash/Value bags + // narrow with `as_str()`; see `json_value_arg_for_string_param`. + if target_ty.is_stringish() && json_value_arg_for_string_param(value) { + let inner_raw = emit_expr(value); + return json_value_as_str(inner_raw, value); + } } let raw = emit_expr(arg); let arg_ty_peeled = arg.ty.as_ref().map(peel_nil); @@ -168,6 +183,31 @@ pub(crate) fn coerce_arg_for_param_ty(arg: &Expr, param_ty: &crate::ty::Ty) -> S return format!("Some({payload})"); } } + // Class-method / Const-recv sites the lowerer registry can + // miss (`ActionController.header_key_ok?(key)` with String + // vs `String?`). Rust-only; do not lift Option wrapping into + // the shared lowerer. + if !matches!(&*arg.node, ExprNode::Cast { .. }) + && !arg.ty.as_ref().is_some_and(is_option_ty) + { + let inner = peel_nil(param_ty); + if inner.is_stringish() { + let needs_to_string = matches!( + &*arg.node, + ExprNode::Lit { value: Literal::Str { .. } | Literal::Sym { .. } } + ) && !super::super::has_str_coercion(arg); + let payload = if needs_to_string { + format!("{raw}.to_string()") + } else if matches!(arg_ty_peeled, Some(Ty::Str | Ty::Sym)) + || matches!(&*arg.node, ExprNode::Var { .. } | ExprNode::Send { .. }) + { + format!("({raw}).to_string()") + } else { + raw.clone() + }; + return format!("Some({payload})"); + } + } } // Family 1 — Hash widening to `HashMap`. @@ -242,12 +282,15 @@ pub(crate) fn coerce_arg_for_param_ty(arg: &Expr, param_ty: &crate::ty::Ty) -> S // Ivar args with non-Copy fields need `.clone()` first because // `Value::from` takes by value and would move out of `&self` // (E0507). Integer/Float/Bool fields are Copy so skip the clone. + // `Untyped` / Record stay Value. Heterogeneous unions such as + // `turbo_stream_from`'s `String | Array[untyped]` also render as + // `serde_json::Value` — wrap scalar args here, rust-only. Do not + // lift that to the shared lowerer: other targets do not box those + // unions as Value, and treating them as value-shaped Casts broke + // ActionController emit (crystal Int32/Int64, go/kotlin/csharp + // index types, python `.tr` on str). let value_target = |ty: &Ty| -> bool { - matches!(ty, Ty::Untyped | Ty::Record { .. }) - || matches!( - ty, - Ty::Class { id, .. } if id.0.as_str() == "Roundhouse::ParamValue" - ) + super::super::super::ty::rust_value_shaped(ty) }; if let ExprNode::Cast { value, target_ty } = &*arg.node { if value_target(target_ty) { @@ -292,6 +335,15 @@ pub(crate) fn coerce_arg_for_param_ty(arg: &Expr, param_ty: &crate::ty::Ty) -> S if matches!( arg_ty_peeled, Some(Ty::Str | Ty::Sym | Ty::Int | Ty::Float | Ty::Bool) + ) || matches!( + &*arg.node, + ExprNode::Lit { + value: Literal::Str { .. } + | Literal::Sym { .. } + | Literal::Int { .. } + | Literal::Float { .. } + | Literal::Bool { .. } + } | ExprNode::StringInterp { .. } ) { let needs_clone = matches!(&*arg.node, ExprNode::Ivar { .. }) && !matches!(arg_ty_peeled, Some(Ty::Int | Ty::Float | Ty::Bool)); @@ -394,6 +446,16 @@ pub(crate) fn coerce_arg_for_param_ty(arg: &Expr, param_ty: &crate::ty::Ty) -> S } if param_ty.is_stringish() && !super::super::has_str_coercion(arg) { + // Value-shaped arg → `&str` param. Hash#fetch / Hash#[] on an + // untyped bag (the `form_with` → `method_override_input` + // path) and Value-typed locals/ivars need `.as_str()`. Do + // not key off Untyped Sends in general: a Const-recv helper + // like `Views::Articles::article(...)` can be typed Untyped + // in IR while rust emits `String`/`&str`, and wrapping that + // in `.as_str().unwrap()` is E0599. + if json_value_arg_for_string_param(arg) { + return json_value_as_str(raw, arg); + } // Peek through `Cast` wrappers — the model lowerer wraps row // accessors in `Cast { Send(row.col), col_ty }` to bridge // Crystal's nilable row holder, but rust's row class is @@ -620,6 +682,76 @@ pub(crate) fn coerce_arg_for_field_ty(arg: &Expr, field_ty: &crate::ty::Ty) -> S raw } +/// True when `arg` rust-emits as `serde_json::Value` and is being +/// passed to a stringish (`&str`) param. Hash#[] / Hash#fetch on an +/// untyped bag, plus Value-typed locals/ivars. Class-method Sends are +/// excluded even when IR types them Untyped — their rust emit is the +/// callee's concrete return (`String`, a model, …). +fn json_value_arg_for_string_param(arg: &Expr) -> bool { + let inner = if let ExprNode::Cast { value, .. } = &*arg.node { + value + } else { + arg + }; + let value_shaped = |t: &crate::ty::Ty| { + let peeled = peel_nil(t); + super::super::super::ty::rust_value_shaped(t) + || super::super::super::ty::rust_value_shaped(peeled) + || ty_contains_untyped(t) + }; + match &*inner.node { + ExprNode::Var { .. } | ExprNode::Ivar { .. } => { + // `Union` rust-emits `Option`. Peel + // would call that a Value and emit `.as_str()` on Option. + inner.ty.as_ref().is_some_and(|t| !is_option_ty(t) && value_shaped(t)) + } + ExprNode::Send { method, recv, .. } => { + if !matches!(method.as_str(), "[]" | "fetch" | "get") { + return false; + } + if matches!( + recv.as_ref().map(|r| &*r.node), + Some(ExprNode::Const { .. }) + ) { + return false; + } + // Hash#[] on an untyped bag is often typed `Untyped | Nil` + // (Ruby nil-on-miss) while the rust peephole emits a + // `Value` (`get().cloned().unwrap_or(Null)`), not Option. + inner.ty.as_ref().is_some_and(value_shaped) + } + _ => false, + } +} + +/// `.as_str()` needs an owned `Value`. HashMap#get rust-emits +/// `Option<&Value>`; Hash#fetch(k, nil) peepholes to the same +/// `get().cloned()` Option. Materialize Null-on-miss first. +fn json_value_as_str(raw: String, arg: &Expr) -> String { + let inner = if let ExprNode::Cast { value, .. } = &*arg.node { + value + } else { + arg + }; + let owned = if let ExprNode::Send { method, recv, args, .. } = &*inner.node { + let hash_recv = matches!( + recv.as_ref().and_then(|r| r.ty.as_ref()).map(peel_nil), + Some(crate::ty::Ty::Hash { .. }) + ); + let fetch_nil = method.as_str() == "fetch" + && args.len() == 2 + && matches!(&*args[1].node, ExprNode::Lit { value: Literal::Nil }); + if hash_recv && (method.as_str() == "get" || fetch_nil) { + format!("{raw}.cloned().unwrap_or(serde_json::Value::Null)") + } else { + raw + } + } else { + raw + }; + format!("({owned}).as_str().unwrap()") +} + /// serde_json accessor that yields `Option` directly — the nullable /// twin of `util::value_narrowing_coercion`, which appends `.unwrap()`. fn value_narrowing_coercion_opt(inner: &crate::ty::Ty) -> Option<&'static str> { diff --git a/src/emit/rust/expr/send/dispatch.rs b/src/emit/rust/expr/send/dispatch.rs index 10ca5a110..5d99f51f9 100644 --- a/src/emit/rust/expr/send/dispatch.rs +++ b/src/emit/rust/expr/send/dispatch.rs @@ -26,6 +26,9 @@ pub(super) fn external_class_method_param_tys(class: &str, method: &str) -> Opti key: Box::new(Ty::Str), value: Box::new(Ty::Untyped), }; + let str_opt = || Ty::Union { + variants: vec![Ty::Str, Ty::Nil], + }; match (class, method) { ("Db", "prepare") => Some(vec![Ty::Str]), ("Db", "exec") => Some(vec![Ty::Str]), @@ -68,6 +71,17 @@ pub(super) fn external_class_method_param_tys(class: &str, method: &str) -> Opti // these declared types. ("ViewHelpers", "broadcast_render") => Some(vec![Ty::Str, Ty::Str]), ("ViewHelpers", "begin_broadcast_render") => Some(vec![]), + // Runtime files emit under an empty EmitCtx (see rust.rs + // rust_units), so Const-recv ActionController helpers are + // invisible to the global registry. Pin RBS param Tys here + // so Family 4/6 fire for HeaderStore/Base call sites. + ("ActionController", "header_key_ok?") => Some(vec![str_opt()]), + ("ActionController", "header_value_ok?") => Some(vec![str_opt()]), + ("ActionController", "header_control?") => Some(vec![Ty::Str]), + ("ActionController", "sanitize_location") => Some(vec![Ty::Str]), + ("ActionController", "location_host") => Some(vec![Ty::Str]), + ("ActionController", "find_substr") => Some(vec![Ty::Str, Ty::Str]), + ("ActionController", "find_last") => Some(vec![Ty::Str, Ty::Str]), _ => None, } } @@ -121,6 +135,74 @@ pub(super) fn controller_shim_arity(method: &str) -> Option { } } +/// `to_s` on a Value-shaped recv. Hash/Session `#get` rust-emits +/// `Option` even when IR types the result as Untyped/Union — use +/// `unwrap_or_default` (Ruby `nil.to_s` is `""`). Union params and +/// Hash `[]`/`fetch` that rust-emit as `serde_json::Value` use UFCS +/// so files without `use RubyToS` compile. Untyped/Record and other +/// Sends keep method-style `.ruby_to_s()`. +fn ruby_to_s_emit(recv: &Expr, recv_s: &str, ufcs: bool) -> String { + use crate::ty::Ty; + use super::super::util::is_option_ty; + match &*recv.node { + ExprNode::Send { method: m, .. } if m.as_str() == "get" => { + format!("{recv_s}.clone().unwrap_or_default()") + } + ExprNode::Send { + method: m, + recv: inner, + .. + } if m.as_str() == "[]" => { + let ivar_ty = match inner.as_ref().map(|e| &*e.node) { + Some(ExprNode::Ivar { name }) => super::super::ivar_field_ty(name.as_str()), + _ => None, + }; + // Prefer the struct-field table: HeaderStore `@vals` is + // Array[Option] there, while the body-typer still + // reports Array[Untyped] from `@vals = []`. + let recv_ty = ivar_ty + .as_ref() + .or_else(|| inner.as_ref().and_then(|e| e.ty.as_ref())) + .map(peel_nil); + match recv_ty { + Some(Ty::Class { id, .. }) + if { + let s = id.0.as_str(); + let leaf = s.rsplit("::").next().unwrap_or(s); + matches!(leaf, "Session" | "Flash") + } => + { + format!("{recv_s}.clone().unwrap_or_default()") + } + Some(Ty::Array { elem }) if is_option_ty(elem) => { + format!("{recv_s}.clone().unwrap_or_default()") + } + Some(Ty::Array { .. }) => format!("{recv_s}.to_string()"), + Some(ty) if ufcs && (super::super::super::ty::rust_value_shaped(ty) + || matches!(ty, Ty::Hash { .. })) => + { + format!( + "::ruby_to_s(&({recv_s}))" + ) + } + _ if ufcs => format!( + "::ruby_to_s(&({recv_s}))" + ), + _ => format!("{recv_s}.ruby_to_s()"), + } + } + ExprNode::Var { .. } | ExprNode::Ivar { .. } if ufcs => { + format!("::ruby_to_s(&({recv_s}))") + } + ExprNode::Send { method: m, .. } + if ufcs && matches!(m.as_str(), "fetch") => + { + format!("::ruby_to_s(&({recv_s}))") + } + _ => format!("{recv_s}.ruby_to_s()"), + } +} + /// Recv-Ty-aware method bridges — Ruby method calls whose Rust analog /// differs by receiver type. Predicates retain their trailing `?` at /// this level. Where Ruby methods return Integer (`.length`, `.size`, @@ -305,14 +387,12 @@ pub(super) fn dispatch_method_by_recv_ty( // else JSON-encode. Rust's `serde_json::Value::to_string()` // unconditionally JSON-encodes, which breaks attribute // emission (`data-turbo-track="reload"` becomes - // `data-turbo-track="\"reload\""`). Route through the - // `RubyToS` trait (defined in `runtime/rust/http.rs`): - // compile-time dispatch picks the right impl for `str` / - // `String` / `serde_json::Value`, so the same emit shape - // works whether the recv ends up being a closure param - // typed `&String` (Map iter keys) or a genuine - // `&serde_json::Value` (Map iter values). Avoids the - // false-positive E0599 from a Var-only narrowing rule. + // `data-turbo-track="\"reload\""`, and form `value=` quotes + // a title). Route through the `RubyToS` trait (defined in + // `runtime/rust/http.rs`): compile-time dispatch picks the + // right impl for `str` / `String` / `serde_json::Value`. + // Files that import the trait (json_builder, view_helpers) + // can use method-style `.ruby_to_s()`. // `Integer#to_i` is the identity. Its common receiver is an // index read (`arr[i].to_i`), which types `Integer | nil` but // renders as the `i64` itself (see the nil peel above). @@ -321,14 +401,16 @@ pub(super) fn dispatch_method_by_recv_ty( _ => None, }, Some(Ty::Untyped) | Some(Ty::Record { .. }) => match method { - "to_s" if args.is_empty() => { - // `recv_s` is already wrap-aware via `emit_send_recv` - // at the top of this function: non-primary recvs - // (e.g. `x.len() as i64`) get the bit-driven wrap, - // primary recvs (method chains, var reads) stay bare. - // `.ruby_to_s()` itself is a method call — primary. - Some(format!("{recv_s}.ruby_to_s()")) - } + "to_s" if args.is_empty() => Some(ruby_to_s_emit(recv, &recv_s, false)), + _ => None, + }, + // Heterogeneous unions / ParamValue / Var that rust-emit as + // Value (e.g. `optional_value_attr`'s column union). Fully- + // qualified so files that don't `use RubyToS` still compile + // — a blanket `.ruby_to_s()` here broke ActionController on + // compare rust. + Some(ty) if super::super::super::ty::rust_value_shaped(ty) => match method { + "to_s" if args.is_empty() => Some(ruby_to_s_emit(recv, &recv_s, true)), _ => None, }, Some(Ty::Hash { .. }) => match method { diff --git a/src/emit/rust/expr/send/index.rs b/src/emit/rust/expr/send/index.rs index efbdab0f3..5f08317b6 100644 --- a/src/emit/rust/expr/send/index.rs +++ b/src/emit/rust/expr/send/index.rs @@ -32,7 +32,11 @@ pub(super) fn try_recv_typed_method( // Ruby-semantics view) match the same branches as the // plain receiver case. Emit chose panic-on-miss for `[]`, // so the runtime value really is T. - let recv_ty = r.ty.as_ref().map(peel_nil); + let ivar_fallback = match &*r.node { + ExprNode::Ivar { name } => ivar_field_ty(name.as_str()), + _ => None, + }; + let recv_ty = ivar_fallback.as_ref().or(r.ty.as_ref()).map(peel_nil); let arg_ty = args[0].ty.as_ref().map(peel_nil); // Range index on Str/Vec receiver — `pp[1..]`. The Range // node emits its endpoints unmodified (`1_i64..`), but @@ -109,24 +113,14 @@ pub(super) fn try_recv_typed_method( // is already the right type). if let Some(crate::ty::Ty::Array { elem }) = recv_ty { if matches!(arg_ty, Some(crate::ty::Ty::Int)) { - // `Vec::Index` returns `&T`; passing the result - // to a function taking `T` by value (the typical - // Ruby-emit consuming-arg shape) requires - // materializing an owned T. Append `.clone()` for - // non-Copy element types — mirrors the negative- - // index branch's clone (the same E0507 motivates - // both). Copy elems (i64, f64, bool) need no - // suffix. let suffix = if is_copy_ty(elem) { "" } else { ".clone()" }; - // The receiver is borrowed by the index, so it emits - // without the multi-read `.clone()` a bare Var read - // takes: `v.clone()[i]` copied the whole Vec to read - // one element, which made an index loop over it - // quadratic (the `includes` distribute walks its - // loaded rows this way). + let recv_s = if matches!(&*r.node, ExprNode::Const { .. }) { + format!("{}.lock().unwrap()", super::super::emit_send_recv(r)) + } else { + super::super::emit_send_recv(r) + }; return Some(format!( - "{}[({}) as usize]{}", - super::super::emit_send_recv(r), + "{recv_s}[({}) as usize]{}", emit_expr(&args[0]), suffix )); @@ -170,6 +164,13 @@ pub(super) fn try_recv_typed_method( emit_expr(&args[0]) )); } + if matches!(&*r.node, ExprNode::Const { .. }) { + return Some(format!( + "{}.lock().unwrap()[({}) as usize]", + super::super::emit_send_recv(r), + emit_expr(&args[0]), + )); + } return Some(format!("{}[{}]", emit_expr(r), emit_expr(&args[0]))); } // Ruby `String#[](start, length)` — byte-slice with start + @@ -287,6 +288,20 @@ pub(super) fn try_recv_typed_method( emit_expr(&args[1]), )); } + let leaf = cls.rsplit("::").next().unwrap_or(cls); + if leaf == "HeaderStore" { + // RBS `[]=: (String, String?)` — rust emits + // `set_index(&str, Option)`, not Value. + let opt_str = crate::ty::Ty::Union { + variants: vec![crate::ty::Ty::Str, crate::ty::Ty::Nil], + }; + let wrapped = super::coerce::coerce_arg_for_param_ty(&args[1], &opt_str); + return Some(format!( + "{}.set_index({}, {wrapped})", + emit_expr(r), + emit_expr(&args[0]), + )); + } // Non-builtin Ty::Class — route through `set_index` // (the `[]=` operator-method rewrite). Wrap value RHS // with Value::from when its Ty isn't already @@ -365,6 +380,27 @@ pub(super) fn try_recv_typed_method( let place = recv_s.strip_suffix(".clone()").unwrap_or(&recv_s); return Some(format!("{{ {place}.insert({kk}, {vv}); }}")); } + // Vec index-assign, including Const arrays (`FORGERY_SLOT[0] + // =`) and HeaderStore `@vals[i] =`. Same usize cast as `[]`. + let ivar_ty = match &*r.node { + ExprNode::Ivar { name } => ivar_field_ty(name.as_str()), + _ => None, + }; + let recv_ty = ivar_ty.as_ref().or(r.ty.as_ref()).map(peel_nil); + if matches!(recv_ty, Some(crate::ty::Ty::Array { .. })) + || matches!(&*r.node, ExprNode::Const { .. }) + { + let recv_s = if matches!(&*r.node, ExprNode::Const { .. }) { + format!("{}.lock().unwrap()", super::super::emit_send_recv(r)) + } else { + super::super::emit_send_recv(r) + }; + return Some(format!( + "{recv_s}[({}) as usize] = {}", + emit_expr(&args[0]), + emit_expr(&args[1]), + )); + } return Some(format!("{}[{}] = {}", emit_expr(r), emit_expr(&args[0]), emit_expr(&args[1]))); } // Ruby `value.is_a?(Class)` runtime type check. Rust has no @@ -604,22 +640,20 @@ pub(super) fn try_recv_typed_method( }} }}" )); } - // `value.nil?` on a `Ty::Untyped` or unresolved-Var receiver — - // `serde_json::Value` exposes `.is_null()` (not `.is_none`, - // which is the Option method the generic `nil?` bridge below - // produces). The Var-typed case covers receivers the body- - // typer didn't fully resolve (e.g. `value = @model[field]` - // where `@model[field]` is typed Untyped per Base's RBS but - // the local-let propagation leaves `value`'s recv ty - // unresolved at the emit-walk's view of the Var-read site). - // The generic bridge stays in place for true Option-typed - // receivers (typical Ruby `attr_reader` getters typed `T?`). + // `value.nil?` on a receiver that rust emits as + // `serde_json::Value` — `.is_null()` (not `.is_none`, which + // is the Option method the generic `nil?` bridge below + // produces). Untyped/Var are the gradual-escape cases; RBS + // heterogeneous unions (`String | Integer | Float | bool | + // nil` on `optional_value_attr`) also render as Value, and + // must take the same predicate or rustc E0599s. The generic + // bridge stays in place for true Option-typed receivers + // (typical Ruby `attr_reader` getters typed `T?`). if method == "nil?" && args.is_empty() - && matches!( - r.ty.as_ref(), - Some(crate::ty::Ty::Untyped) | Some(crate::ty::Ty::Var { .. }) - ) + && r.ty + .as_ref() + .is_some_and(super::super::super::ty::rust_value_shaped) { return Some(format!("{}.is_null()", emit_expr(r))); } diff --git a/src/emit/rust/expr/send/mod.rs b/src/emit/rust/expr/send/mod.rs index 02386afd2..f3dbb3cd6 100644 --- a/src/emit/rust/expr/send/mod.rs +++ b/src/emit/rust/expr/send/mod.rs @@ -221,6 +221,33 @@ pub(super) fn emit_send( ExprNode::Ivar { name } => super::ivar_field_ty(name.as_str()) .map(|t| super::util::is_option_ty(&t)) .unwrap_or(false), + // Array#[] types as `T | Nil` (past-the-end), but rust + // emits a bare `T` (`vec[i].clone()`). Prefer the field- + // table elem type when the recv is an ivar — HeaderStore + // `@keys` is `Array[String]` (no Option) while `@vals` is + // `Array[String?]` (Option). Body-typer `String?` on both + // would Option-map a plain `String` and fail to compile. + // Mirrors `ruby_to_s_emit`'s ivar-array preference. + ExprNode::Send { + method: m, + recv: Some(inner), + .. + } if m.as_str() == "[]" => { + let array_ty = match &*inner.node { + ExprNode::Ivar { name } => super::ivar_field_ty(name.as_str()), + _ => inner.ty.clone(), + }; + match array_ty.as_ref().map(super::util::peel_nil) { + Some(crate::ty::Ty::Array { elem }) => { + super::util::is_option_ty(elem) + } + _ => r + .ty + .as_ref() + .map(super::util::is_option_ty) + .unwrap_or(false), + } + } // A call's `ty` comes from the callee's declared signature // (`article.title()` on a nullable column reads `Option // `), so it is trustworthy here. diff --git a/src/emit/rust/expr/send/ops.rs b/src/emit/rust/expr/send/ops.rs index eb06a96ce..1c425a5e8 100644 --- a/src/emit/rust/expr/send/ops.rs +++ b/src/emit/rust/expr/send/ops.rs @@ -244,7 +244,13 @@ pub(super) fn try_array_push( return None; } let r = recv?; - let Some(crate::ty::Ty::Array { elem }) = r.ty.as_ref() else { + let ivar_ty = match &*r.node { + ExprNode::Ivar { name } => ivar_field_ty(name.as_str()), + _ => None, + }; + let Some(crate::ty::Ty::Array { elem }) = + ivar_ty.as_ref().or(r.ty.as_ref()).map(peel_nil) + else { return None; }; let arg_rendered = match (elem.as_ref(), args[0].ty.as_ref()) { diff --git a/src/emit/rust/library.rs b/src/emit/rust/library.rs index a8d531d35..51b00c85e 100644 --- a/src/emit/rust/library.rs +++ b/src/emit/rust/library.rs @@ -244,6 +244,29 @@ pub fn emit_library_class(class: &LibraryClass) -> Result { Ok::<(), String>(()) }))); body_result?; + if name == "Base" + && class + .methods + .iter() + .any(|m| m.name.as_str() == "render") + { + // 2-arg `self.render(body, opts)` rewrites to `render_with`. + // Controller subclasses get that shim from rust.rs; Base + // itself must unpack opts onto its 4-arg `render`. + writeln!(out).unwrap(); + writeln!( + out, + " pub fn render_with(&mut self, content: &str, opts: std::collections::HashMap) {{" + ) + .unwrap(); + writeln!( + out, + " let status = opts.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"ok\");" + ) + .unwrap(); + writeln!(out, " self.render(content, status, None, None);").unwrap(); + writeln!(out, " }}").unwrap(); + } out.push_str("}\n"); Ok(out) } @@ -585,6 +608,17 @@ fn unify_ivar_tys(tys: &[Ty]) -> Ty { let v = !matches!(**value, Ty::Untyped); (k as u8) + (v as u8) } + // Empty `[]` seeds Array[Untyped]; a later `ivar << s` + // (HeaderStore `@keys << key`) is Array[String]. Prefer + // the concrete elem so rust emits `Vec` not + // `Vec`. + Ty::Array { elem } => { + if matches!(elem.as_ref(), Ty::Untyped) { + 1 + } else { + 2 + } + } _ => 0, }) .cloned() @@ -637,6 +671,23 @@ fn walk_collect_ivars( // `@flash` is the typed `Flash` struct, not a HashMap) would // be misread as a Hash assignment and widen the ivar type // away from the concrete struct. + ExprNode::Send { recv: Some(recv), method, args, .. } + if method.as_str() == "<<" && args.len() == 1 => + { + if let ExprNode::Ivar { name } = &*recv.node { + let elem = args[0].ty.clone().unwrap_or(Ty::Untyped); + record( + name.as_str(), + Ty::Array { + elem: Box::new(elem), + }, + order, + observed, + ); + } + walk_collect_ivars(recv, order, observed); + args.iter().for_each(|a| walk_collect_ivars(a, order, observed)); + } ExprNode::Send { recv: Some(recv), method, args, .. } if method.as_str() == "[]=" && args.len() == 2 => { @@ -824,8 +875,8 @@ fn format_array_constant(name: &str, elements: &[Expr]) -> String { let items: Vec = elements.iter().map(render_constant_value).collect(); let items_s = items.join(", "); format!( - "static {name}: std::sync::LazyLock> = \ - std::sync::LazyLock::new(|| vec![{items_s}]);" + "static {name}: std::sync::LazyLock>> = \ + std::sync::LazyLock::new(|| std::sync::Mutex::new(vec![{items_s}]));" ) } @@ -905,3 +956,235 @@ mod op_assign_tests { assert!(out.contains("let mut s"), "`+=` local not declared mut:\n{out}"); } } + +#[cfg(test)] +mod value_union_emit_tests { + use super::emit_library_class; + + fn emit(ruby: &str, rbs: &str) -> String { + let classes = crate::runtime_src::parse_library_with_rbs(ruby.as_bytes(), rbs, "value_union.rb") + .expect("snippet parses and types"); + crate::emit::rust::expr::with_emit_ctx(crate::emit::rust::EmitCtx::default(), || { + classes.iter().map(|c| emit_library_class(c).expect("emits")).collect() + }) + } + + /// Heterogeneous RBS unions rust-emit as `serde_json::Value`. Display + /// JSON-quotes strings; interpolation must use `ruby_to_s`. + #[test] + fn value_shaped_interpolation_uses_ruby_to_s() { + let out = emit( + r#"module ValueUnion + def self.wrap(value) + "[#{value}]" + end +end +"#, + r#"module ValueUnion + def self.wrap: (String | Integer | Float | bool | nil value) -> String +end +"#, + ); + assert!( + out.contains("ruby_to_s"), + "Value-shaped interp must not use Display JSON quotes:\n{out}" + ); + } + + /// Hash#fetch(k, nil) peepholes to `Option`. A stringish param + /// must materialize Null-on-miss before `.as_str()`. + #[test] + fn fetch_nil_at_string_param_materializes_owned_value() { + let out = emit( + r#"module ValueUnion + def self.take_str(s) + s + end + def self.escape_fetch(opts) + take_str(opts.fetch(:title, nil)) + end +end +"#, + r#"module ValueUnion + def self.take_str: (String s) -> String + def self.escape_fetch: (Hash[Symbol, untyped] opts) -> String +end +"#, + ); + assert!( + out.contains("unwrap_or(serde_json::Value::Null)") && out.contains("as_str()"), + "fetch-nil at a String param must not call as_str on Option:\n{out}" + ); + } + + fn emit_view_helpers() -> String { + let ruby = include_str!("../../../runtime/ruby/action_view/view_helpers.rb"); + let rbs = include_str!("../../../runtime/ruby/action_view/view_helpers.rbs"); + let classes = crate::runtime_src::parse_library_with_rbs( + ruby.as_bytes(), + rbs, + "action_view/view_helpers.rb", + ) + .expect("view_helpers parses and types"); + crate::emit::rust::expr::with_emit_ctx(crate::emit::rust::EmitCtx::default(), || { + classes + .iter() + .map(|c| emit_library_class(c).expect("emits")) + .collect() + }) + } + + fn emit_action_controller() -> String { + let ruby = include_str!("../../../runtime/ruby/action_controller/base.rb"); + let rbs = include_str!("../../../runtime/ruby/action_controller/base.rbs"); + let classes = crate::runtime_src::parse_library_with_rbs( + ruby.as_bytes(), + rbs, + "action_controller/base.rb", + ) + .expect("action_controller/base parses and types"); + crate::emit::rust::expr::with_emit_ctx(crate::emit::rust::EmitCtx::default(), || { + classes + .iter() + .map(|c| emit_library_class(c).expect("emits")) + .collect() + }) + } + + fn method_body<'a>(src: &'a str, name: &str) -> &'a str { + let needle = format!("pub fn {name}("); + let start = src.find(&needle).unwrap_or_else(|| panic!("missing {name}:\n{src}")); + let rest = &src[start..]; + let next = rest[needle.len()..] + .find("\n pub fn ") + .map(|i| needle.len() + i) + .unwrap_or(rest.len()); + &rest[..next] + } + + /// `form_with` must stringify the Hash fetch before + /// `method_override_input(&str)`. + #[test] + fn form_with_stringifies_method_before_override_input() { + let src = emit_view_helpers(); + let body = method_body(&src, "form_with"); + assert!( + body.contains("method_override_input") && body.contains("ruby_to_s"), + "form_with must stringify the Hash fetch:\n{body}" + ); + assert!( + !body.contains("method_override_input(opts"), + "gradual opts must not cross method_override_input:\n{body}" + ); + } + + /// Column-union params render as `serde_json::Value`; `nil?` is + /// `.is_null()`, not Option `.is_none()`. + #[test] + fn optional_value_attr_nil_check_uses_is_null() { + let src = emit_view_helpers(); + let body = method_body(&src, "optional_value_attr"); + assert!( + body.contains("is_null()"), + "optional_value_attr nil? should be Value::is_null:\n{body}" + ); + assert!( + !body.contains("is_none()"), + "optional_value_attr must not emit Option::is_none on Value:\n{body}" + ); + } + + /// `to_s` on the column union must use fully-qualified UFCS. + /// Method-style `.ruby_to_s()` needs `use RubyToS` in the file; + /// ActionController does not import that trait, and a blanket + /// `.ruby_to_s()` on every Value-rendering type broke compare rust. + #[test] + fn optional_value_attr_to_s_uses_ufcs() { + let out = emit_view_helpers(); + let body = method_body(&out, "optional_value_attr"); + assert!( + body.contains("::ruby_to_s"), + "optional_value_attr to_s should be UFCS ruby_to_s:\n{body}" + ); + assert!( + !body.contains(".ruby_to_s()"), + "method-style ruby_to_s needs the trait in scope:\n{body}" + ); + } + + /// `turbo_stream_from`'s `String | Array[untyped]` renders as Value + /// on rust only. Wrap scalar call-site args here, not in the shared + /// lowerer — that Cast wrapping broke ActionController on extras. + #[test] + fn union_param_string_arg_wraps_as_value() { + let ruby = r#"module VH + def self.turbo_stream_from(stream, channel) + stream.to_s + channel + end + def self.call + turbo_stream_from("articles", "Turbo::StreamsChannel") + end +end +"#; + let rbs = r#"module VH + def self.turbo_stream_from: (String | Array[untyped] stream, String channel) -> String + def self.call: () -> String +end +"#; + let classes = crate::runtime_src::parse_library_with_rbs(ruby.as_bytes(), rbs, "vh.rb") + .expect("snippet parses and types"); + let out = crate::emit::rust::expr::with_emit_ctx(crate::emit::rust::EmitCtx::default(), || { + classes + .iter() + .map(|c| emit_library_class(c).expect("emits")) + .collect::() + }); + let body = method_body(&out, "call"); + assert!( + body.contains("serde_json::Value::from") && body.contains("articles"), + "string arg to String|Array union must wrap Value::from:\n{body}" + ); + } + + /// Runtime emit uses an empty EmitCtx, so ActionController Const + /// helpers must coerce from `external_class_method_param_tys`. + /// HeaderStore `[]=` takes `Option`, not Value. + #[test] + fn action_controller_runtime_emit_typechecks_hotspots() { + let src = emit_action_controller(); + let set_index = method_body(&src, "set_index"); + assert!( + set_index.contains("header_key_ok_pred(Some("), + "header_key_ok? takes String?, wrap &str:\n{set_index}" + ); + let key_at = method_body(&src, "key_at"); + assert!( + !key_at.contains(".map("), + "keys[i].to_s on Array[String] must not Option-map a plain String:\n{key_at}" + ); + let val_at = method_body(&src, "val_at"); + assert!( + val_at.contains("unwrap_or_default()"), + "vals[i].to_s on Option must unwrap_or_default:\n{val_at}" + ); + let render = method_body(&src, "render"); + assert!( + render.contains("sanitize_location(&("), + "sanitize_location takes &str, borrow the unwrapped String:\n{render}" + ); + let send_data = method_body(&src, "send_data"); + assert!( + send_data.contains("set_index(") && send_data.contains("Some("), + "HeaderStore []= must wrap Option, not Value::from:\n{send_data}" + ); + assert!( + !send_data.contains("Value::from"), + "HeaderStore []= must not coerce through Value:\n{send_data}" + ); + let csrf = method_body(&src, "request_for_csrf"); + assert!( + csrf.contains("serde_json::Value::Null"), + "untyped nil tail is Value::Null:\n{csrf}" + ); + } +} diff --git a/src/emit/rust/ty.rs b/src/emit/rust/ty.rs index a41779cf4..70ff11ac9 100644 --- a/src/emit/rust/ty.rs +++ b/src/emit/rust/ty.rs @@ -101,3 +101,60 @@ fn option_shape(variants: &[Ty]) -> Option { _ => None, } } + +/// True when this IR type is emitted as `serde_json::Value` (or the +/// `Roundhouse::ParamValue` alias of it). Heterogeneous unions that +/// are neither stringish nor a 2-variant `T | nil` Option land here, +/// matching `rust_ty`. Callers that need Value APIs (`is_null`, +/// `Value::from`, `as_str`) key off this rather than listing +/// Untyped/Var by hand — RBS `String | Integer | Float | bool | nil` +/// and `String | Array[untyped]` are Unions, not Untyped. +pub(crate) fn rust_value_shaped(ty: &Ty) -> bool { + match ty { + Ty::Untyped | Ty::Var { .. } | Ty::Record { .. } => true, + Ty::Union { variants } => !ty.is_stringish() && option_shape(variants).is_none(), + Ty::Class { id, .. } => id.0.as_str() == "Roundhouse::ParamValue", + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heterogeneous_nilable_union_is_value_shaped() { + let ty = Ty::Union { + variants: vec![Ty::Str, Ty::Int, Ty::Float, Ty::Bool, Ty::Nil], + }; + assert!(rust_value_shaped(&ty)); + assert_eq!(rust_ty(&ty), "serde_json::Value"); + } + + #[test] + fn string_or_array_union_is_value_shaped() { + let ty = Ty::Union { + variants: vec![Ty::Str, Ty::Array { elem: Box::new(Ty::Untyped) }], + }; + assert!(rust_value_shaped(&ty)); + assert_eq!(rust_ty(&ty), "serde_json::Value"); + } + + #[test] + fn string_symbol_union_is_not_value_shaped() { + let ty = Ty::Union { + variants: vec![Ty::Str, Ty::Sym], + }; + assert!(!rust_value_shaped(&ty)); + assert!(ty.is_stringish()); + } + + #[test] + fn option_string_is_not_value_shaped() { + let ty = Ty::Union { + variants: vec![Ty::Str, Ty::Nil], + }; + assert!(!rust_value_shaped(&ty)); + assert!(rust_ty(&ty).starts_with("Option<")); + } +} diff --git a/src/emit/shared/mod.rs b/src/emit/shared/mod.rs index 4be6fc834..2af2ebb46 100644 --- a/src/emit/shared/mod.rs +++ b/src/emit/shared/mod.rs @@ -21,4 +21,5 @@ pub mod regex_literal; pub mod ops; pub mod schema_sql; pub mod seed_sql; +pub mod string_bytes; pub mod sub; diff --git a/src/emit/shared/string_bytes.rs b/src/emit/shared/string_bytes.rs new file mode 100644 index 000000000..55b52711f --- /dev/null +++ b/src/emit/shared/string_bytes.rs @@ -0,0 +1,127 @@ +//! String#bytes materializes the receiver once as unsigned byte integers. +use crate::expr::{Expr, ExprNode, Literal}; +use crate::ty::Ty; + +/// Targets whose string representation needs a byte-array bridge. +#[derive(Clone, Copy)] +pub enum Target { + Rust, + TypeScript, + Crystal, + Python, + Kotlin, + Swift, + CSharp, + Go, + Elixir, +} + +impl Target { + /// Use the diagnostic ledger's target name when refusing a call shape. + fn name(self) -> &'static str { + match self { + Self::Rust => "rust", + Self::TypeScript => "typescript", + Self::Crystal => "crystal", + Self::Python => "python", + Self::Kotlin => "kotlin", + Self::Swift => "swift", + Self::CSharp => "csharp", + Self::Go => "go", + Self::Elixir => "elixir", + } + } +} + +enum Call<'a> { + Array(&'a Expr), + Unsupported(&'static str), +} + +/// Keep call recognition and support policy shared by project validation and +/// backend dispatch. Literal `&nil` passes no block in Ruby; arbitrary block +/// operands still need an implementation and must never disappear. +fn classify(e: &Expr) -> Option> { + if e.diagnostic.is_some() { + return None; + } + let ExprNode::Send { + recv: Some(recv), + method, + args, + block, + .. + } = &*e.node + else { + return None; + }; + if method.as_str() != "bytes" || recv.ty.as_ref() != Some(&Ty::Str) { + return None; + } + Some(if !args.is_empty() { + Call::Unsupported("String#bytes accepts no positional arguments") + } else if block.as_ref().is_some_and(|block| { + block.diagnostic.is_some() + || !matches!( + &*block.node, + ExprNode::Lit { + value: Literal::Nil + } + ) + }) { + Call::Unsupported("String#bytes with a block is not implemented for this target") + } else { + Call::Array(recv) + }) +} + +/// Let the project guard recognize this implemented no-block primitive and +/// Ruby-family emission canonicalize literal &nil without accepting unrelated +/// or effectful Proc forwarding. +pub fn materializes_array(e: &Expr) -> bool { + matches!(classify(e), Some(Call::Array(_))) +} + +/// Classify the complete call before a backend separates its block from its +/// receiver. Omitted blocks and literal `&nil` materialize an array; supplied +/// blocks remain explicit refusals. Ruby/Spinel keep native byte behavior and +/// canonicalize literal &nil to an omitted block through the same classifier. +pub fn emit( + e: &Expr, + target: Target, + emit_receiver: impl FnOnce(&Expr) -> String, +) -> Option { + Some(match classify(e)? { + Call::Unsupported(detail) => crate::emit::diagnostics::report_unsupported( + e.span, + target.name(), + "String#bytes", + detail, + ), + Call::Array(recv) => render(target, &emit_receiver(recv)), + }) +} + +/// Preserve one receiver evaluation and one encoding pass. Unicode-native +/// strings expose their UTF-8 representation; Ruby/Spinel retain raw bytes. +pub fn render(target: Target, recv: &str) -> String { + match target { + Target::Rust => { + format!("({recv}).as_bytes().iter().map(|byte| i64::from(*byte)).collect::>()") + } + Target::TypeScript => format!("Array.from(new TextEncoder().encode({recv}))"), + Target::Crystal => format!("({recv}).bytes.map {{ |byte| byte.to_i64 }}"), + Target::Python => format!("list(({recv}).encode(\"utf-8\"))"), + Target::Kotlin => format!( + "({recv}).toByteArray(Charsets.UTF_8).map {{ byte -> (byte.toInt() and 255).toLong() }}.toMutableList()" + ), + Target::Swift => format!("({recv}).utf8.map {{ Int($0) }}"), + Target::CSharp => { + format!("System.Text.Encoding.UTF8.GetBytes({recv}).Select(b => (long)b).ToList()") + } + Target::Go => format!( + "func(text string) []int64 {{ out := make([]int64, len(text)); for i := 0; i < len(text); i++ {{ out[i] = int64(text[i]) }}; return out }}({recv})" + ), + Target::Elixir => format!(":binary.bin_to_list({recv})"), + } +} diff --git a/src/emit/swift.rs b/src/emit/swift.rs index bf3197193..88229c147 100644 --- a/src/emit/swift.rs +++ b/src/emit/swift.rs @@ -65,12 +65,8 @@ pub fn emit(app: &App) -> Vec { // registry, then models lower to LibraryClasses (including // synthesized `Row` siblings). let vctx = crate::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = crate::lower::extras_from_lcs(&preliminary_views); + let mut view_lcs = crate::lower::preliminary_view_classes(&app.views, &vctx); + let view_extras = crate::lower::extras_from_lcs(&view_lcs); // Permitted-params specs (resource → fields) collected from the // controllers, so each model gains a typed `fromParams(Params)` // factory the controllers call. @@ -109,8 +105,7 @@ pub fn emit(app: &App) -> Vec { if let Some(f) = library::emit_function_module(&importmap_funcs) { files.push(f); } - let view_lcs = - crate::lower::lower_views_to_library_classes(&app.views, app, view_lower_extras.clone()); + crate::lower::type_view_library_classes(&mut view_lcs, app, view_lower_extras.clone()); // Jbuilder (json-format) views lower to `_json` methods on the // same `Views::` module; merge them into the html view enums // so a controller's JSON branch resolves `Articles.indexJson(...)`. diff --git a/src/emit/swift/expr.rs b/src/emit/swift/expr.rs index 500b27e71..646a257f0 100644 --- a/src/emit/swift/expr.rs +++ b/src/emit/swift/expr.rs @@ -1143,7 +1143,11 @@ fn children(e: &Expr) -> Vec<&Expr> { v } +/// Render a Swift value expression after shared primitive and string-builder selection. pub fn emit_expr(e: &Expr) -> String { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::Swift, emit_expr) { + return s; + } if let Some(s) = try_string_builder(e) { return s; } diff --git a/src/emit/typescript.rs b/src/emit/typescript.rs index a3dd78667..e600029e3 100644 --- a/src/emit/typescript.rs +++ b/src/emit/typescript.rs @@ -274,18 +274,13 @@ pub fn emit(app: &App) -> Vec { // ── Lowering pipeline ─────────────────────────────────────────── // Order matters because each step's output feeds the next's - // shared registry. Views are lowered twice — once preliminarily - // (without model knowledge) so models can dispatch on Views::*, - // then again with the full model registry so view bodies can - // dispatch on models. + // shared registry. Views are lowered untyped first so models can + // dispatch on Views::* signatures, then the same LibraryClasses + // are body-typed with the full model registry. let vctx = crate::lower::ViewLowerCtx::new(app); - let preliminary_views: Vec = app - .views - .iter() - .map(|v| vctx.lower(v)) - .collect(); - let view_extras = library::extras_from_lcs(&preliminary_views); + let mut view_lcs = crate::lower::preliminary_view_classes(&app.views, &vctx); + let view_extras = library::extras_from_lcs(&view_lcs); let route_helper_funcs = crate::lower::lower_routes_to_library_functions(app); let route_helper_extras = library::extras_from_funcs(&route_helper_funcs); @@ -307,11 +302,7 @@ pub fn emit(app: &App) -> Vec { let mut view_lower_extras: Vec<(crate::ident::ClassId, crate::analyze::ClassInfo)> = model_registry.clone().into_iter().collect(); view_lower_extras.extend(route_helper_extras.clone()); - let mut view_lcs = crate::lower::lower_views_to_library_classes( - &app.views, - app, - view_lower_extras.clone(), - ); + crate::lower::type_view_library_classes(&mut view_lcs, app, view_lower_extras.clone()); let jbuilder_lcs = crate::lower::lower_jbuilder_to_library_classes( &app.views, app, diff --git a/src/emit/typescript/expr.rs b/src/emit/typescript/expr.rs index 50f6edf9e..5d2e3c470 100644 --- a/src/emit/typescript/expr.rs +++ b/src/emit/typescript/expr.rs @@ -1059,7 +1059,11 @@ pub(super) fn emit_expr(e: &Expr) -> String { super::printer::render_expr(&js_expr(e)) } +/// Build a JavaScript expression with its source span and shared primitive behavior. pub(super) fn js_expr(e: &Expr) -> Js { + if let Some(s) = crate::emit::shared::string_bytes::emit(e, crate::emit::shared::string_bytes::Target::TypeScript, emit_expr) { + return Js::new(e.span, JsExpr::Raw(s)); + } // Analyzer-set diagnostic annotations short-circuit to a target // raise-equivalent (preserves Ruby's runtime-raise semantics). if let Some(kind) = &e.diagnostic { diff --git a/src/gems.rs b/src/gems.rs index 629d4de84..81444542c 100644 --- a/src/gems.rs +++ b/src/gems.rs @@ -282,7 +282,7 @@ const FATES: &[(&str, GemFate)] = &[ ("faker", GemFate::Modeled), // catalog/gems: Faker::* ("geared_pagination", GemFate::Modeled), // registry/controllers: set_page_and_extract_portion_from ("image_processing", GemFate::Modeled), // active_storage variants seam - ("kaminari", GemFate::Modeled), // catalog: page / per / padding / without_count + ("kaminari", GemFate::Modeled), // Relation#page / per / paginate ("mail", GemFate::Modeled), // catalog/gems: Mail::Address; ActionMailer ("mocha", GemFate::Modeled), // lower/mocha bridge ("nokogiri", GemFate::Modeled), // catalog/gems: Nokogiri diff --git a/src/ingest/app.rs b/src/ingest/app.rs index ac95f718a..bc2f42936 100644 --- a/src/ingest/app.rs +++ b/src/ingest/app.rs @@ -893,10 +893,10 @@ end } } } - // `Kaminari.configure { |config| config.default_per_page = N }` - // — the page size `Relation#page` applies. Synthesized as - // `kaminari_default_per_page` on the reopen, over Kaminari's - // own default (25) in runtime/ruby/rails.rb. + // Default page size for `Relation#page`. A `Kaminari.configure` + // block's literal `default_per_page = N` is one input spelling; + // synthesized as `default_per_page` on the reopen, over the + // runtime default of 25 in runtime/ruby/rails.rb. { let init_dir = dir.join("config/initializers"); let mut per_page: Option = None; @@ -904,13 +904,13 @@ end for entry in read_rb_files(vfs, &init_dir)? { if let Ok(bytes) = vfs.read(&entry) { let file = entry.display().to_string(); - per_page = extract_kaminari_default_per_page(&bytes, &file).or(per_page); + per_page = extract_default_per_page(&bytes, &file).or(per_page); } } } if let Some(n) = per_page { if let Ok(mut synth) = crate::runtime_src::parse_methods(&format!( - "def kaminari_default_per_page\n {n}\nend\n" + "def default_per_page\n {n}\nend\n" )) { methods.append(&mut synth); } @@ -3106,11 +3106,82 @@ fn substitute_params( } let span = macro_def.body.span; + // `options = actions.extract_options!` on the `*actions` parameter: + // the trailing Hash of the call is the options, everything before it + // the actions. Bound here because after substitution the receiver is + // an Array literal and `actions` below must not still hold the Hash. + let mut body = macro_def.body.clone(); + let mut extracted: Vec<(crate::ident::Symbol, crate::expr::Expr)> = Vec::new(); + if let (ExprNode::Seq { exprs }, Some((rest_index, rest))) = ( + &mut *body.node, + macro_def.params.iter().enumerate().find(|(_, p)| p.rest), + ) { + let taken = match exprs.first().map(|e| &*e.node) { + Some(ExprNode::Assign { + target: crate::expr::LValue::Var { name, .. }, + value, + }) => match &*value.node { + ExprNode::Send { recv: Some(r), method, args: a, block: None, .. } + if method.as_str() == "extract_options!" + && a.is_empty() + && matches!(&*r.node, ExprNode::Var { name: n, .. } if n == &rest.name) => + { + Some(name.clone()) + } + _ => None, + }, + _ => None, + }; + // `*%i[a b]` spreads its elements; any other splat's actions are + // unknown here, so the statement stays and the macro is refused + // rather than expanded with those actions missing. + let spread: Option> = args + .get(rest_index..) + .unwrap_or(&[]) + .iter() + .map(|a| match &*a.node { + ExprNode::Splat { value } => match &*value.node { + ExprNode::Array { elements, .. } => Some(elements.clone()), + _ => None, + }, + _ => Some(vec![a.clone()]), + }) + .collect::>>() + .map(|groups| groups.concat()); + if let (Some(options_name), Some(mut positional)) = (taken, spread) { + exprs.remove(0); + let options = match positional.last().map(|a| match &*a.node { + ExprNode::KeywordSplat { value } => matches!(&*value.node, ExprNode::Hash { .. }), + ExprNode::Hash { .. } => true, + _ => false, + }) { + Some(true) => { + let last = positional.pop().expect("checked"); + match &*last.node { + ExprNode::KeywordSplat { value } => value.clone(), + _ => last, + } + } + _ => crate::expr::Expr::new(span, ExprNode::Hash { entries: vec![], kwargs: false }), + }; + extracted.push((options_name, options)); + extracted.push(( + rest.name.clone(), + crate::expr::Expr::new( + span, + ExprNode::Array { elements: positional, style: Default::default() }, + ), + )); + } + } let bindings: Vec<(crate::ident::Symbol, crate::expr::Expr)> = macro_def .params .iter() .enumerate() .map(|(i, p)| { + if let Some((_, v)) = extracted.iter().find(|(n, _)| n == &p.name) { + return (p.name.clone(), v.clone()); + } let value = match args.get(i) { Some(a) => match &*a.node { ExprNode::KeywordSplat { value } => value.clone(), @@ -3129,11 +3200,45 @@ fn substitute_params( (p.name.clone(), value) }) .collect(); - let mut body = macro_def.body.clone(); + let mut bindings = bindings; + let extra: Vec<_> = extracted + .into_iter() + .filter(|(n, _)| !bindings.iter().any(|(b, _)| b == n)) + .collect(); + bindings.extend(extra); replace(&mut body, &bindings); + fold_literal_hash_reads(&mut body); body } +/// `{only: [:a]}[:if]` → the value, or nil for an absent key — what the +/// read means once `options` is bound to the call's literal Hash. +fn fold_literal_hash_reads(expr: &mut crate::expr::Expr) { + use crate::expr::{ExprNode, Literal}; + expr.node.for_each_child_mut(&mut |c| fold_literal_hash_reads(c)); + let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &*expr.node else { + return; + }; + let (ExprNode::Hash { entries, .. }, [key]) = (&*r.node, args.as_slice()) else { return }; + let ExprNode::Lit { value: Literal::Sym { value: k } } = &*key.node else { return }; + if method.as_str() != "[]" { + return; + } + // A computed or `**` key might be this one: leave the read, and the + // filter it feeds is refused instead of losing its guard. + if entries.iter().any(|(ek, _)| !matches!(&*ek.node, ExprNode::Lit { .. })) { + return; + } + // A repeated key reads its last value, as in Ruby. + let found = entries.iter().rev().find_map(|(ek, ev)| match &*ek.node { + ExprNode::Lit { value: Literal::Sym { value } } if value == k => Some(ev.clone()), + _ => None, + }); + *expr = found.unwrap_or_else(|| { + crate::expr::Expr::new(expr.span, ExprNode::Lit { value: Literal::Nil }) + }); +} + /// Every filter the macro body declares, or None if any statement in it /// is something else. The IR twin of `parse_filter_call`, which reads /// prism nodes — by this point the concern's body is already lowered. @@ -3178,10 +3283,18 @@ fn filter_from_send( ExprNode::Lit { value: Literal::Sym { value } } => Some(value.clone()), _ => None, }; - let sym_list = |e: &crate::expr::Expr| -> Vec { + // `only:` / `except:` actions, a Symbol or String each as Rails takes + // them. None when any is something else: dropping it would narrow + // the list, or empty it into an unscoped filter. + let action_of = |e: &crate::expr::Expr| match &*e.node { + ExprNode::Lit { value: Literal::Sym { value } } => Some(value.clone()), + ExprNode::Lit { value: Literal::Str { value } } => Some(crate::ident::Symbol::from(value.as_str())), + _ => None, + }; + let sym_list = |e: &crate::expr::Expr| -> Option> { match &*e.node { - ExprNode::Array { elements, .. } => elements.iter().filter_map(&sym_of).collect(), - _ => sym_of(e).into_iter().collect(), + ExprNode::Array { elements, .. } => elements.iter().map(&action_of).collect(), + _ => action_of(e).map(|a| vec![a]), } }; @@ -3207,13 +3320,17 @@ fn filter_from_send( }; for (key, value) in entries { match sym_of(key).as_ref().map(|k| k.as_str().to_string()).as_deref() { - Some("only") => only = sym_list(value), - Some("except") => except = sym_list(value), + Some("only") => only = sym_list(value)?, + Some("except") => except = sym_list(value)?, // if:/unless: guards on a macro-expanded filter would // need the predicate to resolve in the INCLUDER; not // modeled, and silently dropping a guard changes when a // filter fires. - Some("if") | Some("unless") => return None, + Some("if") | Some("unless") + if !matches!(&*value.node, ExprNode::Lit { value: Literal::Nil }) => + { + return None + } _ => {} } } @@ -5729,15 +5846,16 @@ fn extract_vips_loader_policy(source: &[u8]) -> (bool, Vec) { /// `.default_per_page = N` inside a `Kaminari.configure do /// || … end` block (top level or under `to_prepare`), the last -/// one winning as it does when Ruby runs the block. Read off the parse -/// rather than the lines, so an assignment in another config block of -/// the same file (`Rails.application.configure do |config|`) is not -/// mistaken for Kaminari's. +/// one winning as it does when Ruby runs the block. `Kaminari.configure` +/// is an input spelling of the default page size, not the feature name. +/// Read off the parse rather than the lines, so an assignment in another +/// config block of the same file (`Rails.application.configure do +/// |config|`) is not mistaken for this default. /// /// A value that is not a positive Integer literal (a constant, an ENV /// read) is recognized but not evaluated: it is a survey gap, and this /// file contributes no page size rather than a guessed one. -fn extract_kaminari_default_per_page(source: &[u8], file: &str) -> Option { +fn extract_default_per_page(source: &[u8], file: &str) -> Option { let src = String::from_utf8_lossy(source); // Cheap skip before parsing: this runs over every initializer, and // most never name Kaminari. @@ -5794,7 +5912,7 @@ fn extract_kaminari_default_per_page(source: &[u8], file: &str) -> Option { survey::record(&IngestError::Unsupported { file: file.to_string(), message: format!( - "Kaminari default_per_page is not a positive Integer literal (`{}`); the emitted app does not apply it", + "default_per_page is not a positive Integer literal (`{}`); the emitted app does not apply it", &src[loc.start_offset()..loc.end_offset()] ), }); diff --git a/src/ingest/model.rs b/src/ingest/model.rs index a12396fb7..99755a7dd 100644 --- a/src/ingest/model.rs +++ b/src/ingest/model.rs @@ -2067,6 +2067,7 @@ fn parse_association( let mut as_interface: Option = None; let mut belongs_to_default: Option = None; let mut touch: Option = None; + let mut autosave: Option = None; for arg in iter { // Positional lambda between name and kwargs — the association @@ -2119,6 +2120,7 @@ fn parse_association( "join_table" => join_table = string_value(&value), "polymorphic" => polymorphic = bool_value(&value), "as" => as_interface = symbol_value(&value), + "autosave" => autosave = bool_value(&value), // `default: -> { Current.user }` — the lambda BODY, not // the lambda. Rails calls it with `instance_exec`, so // the body is already written against the record; a @@ -2227,6 +2229,8 @@ fn parse_association( foreign_key_explicit, dependent: dependent.unwrap_or_default(), as_interface: as_interface.as_deref().map(Symbol::from), + scope, + autosave: autosave.unwrap_or(false), }), "belongs_to" => Some(Association::BelongsTo { name: name.clone(), diff --git a/src/ingest/model_macros.rs b/src/ingest/model_macros.rs index 941a5b9f4..53001ae99 100644 --- a/src/ingest/model_macros.rs +++ b/src/ingest/model_macros.rs @@ -1,32 +1,61 @@ -//! Specialize concern macros that define instance methods from symbols. +//! Specialize concern macros that define instance methods from symbols +//! or from a statically interpolatable `class_eval` string/heredoc. //! //! Writebook's `positioned_within` closes over three symbol arguments in //! parameterless `define_method` blocks, then marks the helpers private. -//! Turn that compile-time work into ordinary methods BEFORE inference, -//! once for every target. This is deliberately not a Ruby evaluator: -//! mutable captures, block parameters, nested blocks, control flow, -//! side effects outside the definitions and redefinitions stay unexpanded. +//! The same pass expands a class-body `class_eval <<-CODE` whose +//! interpolations are those bound symbols — without executing Ruby. +//! Dynamic `class_eval` (non-literal, unknown interpolations) stays +//! unexpanded. This is deliberately not a Ruby evaluator: mutable +//! captures, block parameters, nested blocks, control flow, side +//! effects outside the definitions and redefinitions stay unexpanded. use std::collections::{HashMap, HashSet}; -use crate::App; use crate::dialect::{MethodDef, MethodReceiver, MethodVisibility, ModelBodyItem}; use crate::expr::{Expr, ExprNode, Literal}; use crate::ident::{ClassId, Symbol}; use crate::span::SourceFile; +use crate::App; -pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> super::IngestResult<()> { - if app.concern_spliced_class_methods.is_empty() { +mod class_eval; + +pub(crate) fn expand_model_macros( + app: &mut App, + sources: &[SourceFile], +) -> super::IngestResult<()> { + if app.concern_spliced_class_methods.is_empty() && app.load_hook_class_macros.is_empty() { return Ok(()); } let mut params_specs = crate::lower::controller_to_library::params::collect_specs(&app.controllers); params_specs.mark_file_fields(&app.models); + // Index once. Do not clone these defs onto every model, and do not + // pretend the hook mixin was `include`d — availability is an + // explicit load-hook origin check below. + let hook_macros: HashMap = { + let mut map = HashMap::new(); + for id in &app.load_hook_class_macros { + let Some(class) = app.library_classes.iter().find(|c| &c.name == id) else { + continue; + }; + for m in &class.methods { + if m.receiver == MethodReceiver::Class && has_definition(&m.body) { + map.entry(m.name.clone()) + .or_insert_with(|| (id.clone(), m.clone())); + } + } + } + map + }; + let hook_origins: HashSet = app.load_hook_class_macros.iter().cloned().collect(); for model_index in 0..app.models.len() { let model = &app.models[model_index]; - let Some(origins) = app.concern_spliced_class_methods.get(&model.name) else { - continue; - }; + let origins = app + .concern_spliced_class_methods + .get(&model.name) + .cloned() + .unwrap_or_default(); let macros: HashMap<_, _> = model .methods() .filter(|m| { @@ -36,6 +65,9 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe }) .map(|m| (m.name.clone(), m.clone())) .collect(); + if macros.is_empty() && hook_macros.is_empty() { + continue; + } let mut candidates = Vec::new(); let mut calls = HashMap::::new(); let mut included = HashSet::new(); @@ -44,7 +76,7 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe continue; }; let mut occurrences = Vec::new(); - macro_calls(expr, ¯os, &mut occurrences); + macro_calls(expr, ¯os, &hook_macros, &mut occurrences); for name in &occurrences { *calls.entry(name.clone()).or_default() += 1; } @@ -61,7 +93,7 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe }; // Root calls are the only admitted placement. Calls nested // in conditionals/blocks must still poison partial expansion. - let root_macro = macros.contains_key(method) + let root_macro = (macros.contains_key(method) || hook_macros.contains_key(method)) && recv .as_ref() .is_none_or(|r| matches!(&*r.node, ExprNode::SelfRef)); @@ -93,34 +125,40 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe { continue; } - let Some(def) = macros.get(method) else { + let Some((origin, def)) = macro_provider(method, ¯os, &origins, &hook_macros) + else { continue; }; - let origin = &origins[method]; let providers = app .library_classes .iter() - .filter(|c| included.contains(&c.name)) + .filter(|c| included.contains(&c.name) || hook_origins.contains(&c.name)) .flat_map(|c| &c.methods) .filter(|m| m.receiver == MethodReceiver::Class && &m.name == method) .count(); let methods = (recv.is_none() && block.is_none() - && included.contains(origin) + && (included.contains(origin) || hook_origins.contains(origin)) && providers == 1 - && ["define_method", "private", "protected", "public"] - .iter() - .all(|name| { - !crate::lower::scope_chain::app_method( - app, - &model.name, - &Symbol::from(*name), - MethodReceiver::Class, - ) - }) + && [ + "define_method", + "private", + "protected", + "public", + "class_eval", + ] + .iter() + .all(|name| { + !crate::lower::scope_chain::app_method( + app, + &model.name, + &Symbol::from(*name), + MethodReceiver::Class, + ) + }) && supported_source_signature(def, sources) && supported_source_call(expr, sources)) - .then(|| expand(def, args, sources)) + .then(|| expand(def, args, sources, &model.name)) .flatten(); candidates.push((index, method.clone(), methods)); } @@ -156,8 +194,8 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe // first one become a misleading partial expansion either. let mut names = HashMap::::new(); for (_, _, methods) in &candidates { - let Some(methods) = methods else { continue }; - for m in methods { + let Some(expansion) = methods else { continue }; + for m in &expansion.methods { *names.entry(m.name.clone()).or_default() += 1; } } @@ -166,10 +204,10 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe // invent a partial class when one candidate's effects are unknown. let opaque = candidates.iter().any(|(_, _, methods)| methods.is_none()); for (index, name, methods) in candidates { - let methods = methods.filter(|methods| { + let methods = methods.filter(|expansion| { !opaque && calls[&name] == 1 - && methods.iter().all(|m| { + && expansion.methods.iter().all(|m| { names[&m.name] == 1 && !reserved.contains(&m.name) && !crate::lower::scope_chain::app_method( @@ -205,7 +243,7 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe let model = &mut app.models[model_index]; let mut body = Vec::new(); for (index, item) in std::mem::take(&mut model.body).into_iter().enumerate() { - let Some(methods) = expansions.remove(&index) else { + let Some(expansion) = expansions.remove(&index) else { body.push(item); continue; }; @@ -217,7 +255,7 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe else { unreachable!() }; - for mut method in methods { + for mut method in expansion.methods { method.enclosing_class = Some(model.name.0.clone()); body.push(ModelBodyItem::Method { method, @@ -225,12 +263,25 @@ pub(crate) fn expand_model_macros(app: &mut App, sources: &[SourceFile]) -> supe leading_blank_line: std::mem::take(&mut leading_blank_line), }); } + body.extend(expansion.items); } model.body = body; } Ok(()) } +fn macro_provider<'a>( + method: &Symbol, + macros: &'a HashMap, + origins: &'a HashMap, + hook_macros: &'a HashMap, +) -> Option<(&'a ClassId, &'a MethodDef)> { + if let Some(def) = macros.get(method) { + return origins.get(method).map(|origin| (origin, def)); + } + hook_macros.get(method).map(|(id, def)| (id, def)) +} + fn include_closure(app: &App, id: ClassId, included: &mut HashSet) { if !included.insert(id.clone()) { return; @@ -276,8 +327,7 @@ fn supported_source_signature(def: &MethodDef, sources: &[SourceFile]) -> bool { .all(|p| p.as_required_parameter_node().is_some()) && params.keywords().iter().all(|p| { p.as_required_keyword_parameter_node().is_some() - || p.as_optional_keyword_parameter_node() - .is_some_and(|p| p.value().as_symbol_node().is_some()) + || p.as_optional_keyword_parameter_node().is_some() }) }); } @@ -343,7 +393,9 @@ fn supported_source_call(expr: &Expr, sources: &[SourceFile]) -> bool { } fn has_definition(body: &Expr) -> bool { - if matches!(&*body.node, ExprNode::Send { method, .. } if method.as_str() == "define_method") { + if matches!(&*body.node, ExprNode::Send { method, .. } + if matches!(method.as_str(), "define_method" | "class_eval")) + { return true; } let mut found = false; @@ -352,9 +404,14 @@ fn has_definition(body: &Expr) -> bool { found } -fn macro_calls(expr: &Expr, macros: &HashMap, out: &mut Vec) { +fn macro_calls( + expr: &Expr, + macros: &HashMap, + hook_macros: &HashMap, + out: &mut Vec, +) { if let ExprNode::Send { recv, method, .. } = &*expr.node { - if macros.contains_key(method) + if (macros.contains_key(method) || hook_macros.contains_key(method)) && recv .as_ref() .is_none_or(|r| matches!(&*r.node, ExprNode::SelfRef)) @@ -363,10 +420,10 @@ fn macro_calls(expr: &Expr, macros: &HashMap, out: &mut Vec Option<&Symbol> { +pub(super) fn symbol(expr: &Expr) -> Option<&Symbol> { match &*expr.node { ExprNode::Lit { value: Literal::Sym { value }, @@ -378,8 +435,11 @@ fn symbol(expr: &Expr) -> Option<&Symbol> { /// Required positionals plus required/optional keywords. Optional /// positionals, rest and forwarding need a fuller Ruby argument binder. /// Keywords retain their SOURCE kind even where library ingest flattened -/// an optional keyword into a positional (`from_keyword`). -fn bindings(def: &MethodDef, args: &[Expr]) -> Option> { +/// an optional keyword into a positional (`from_keyword`). A required +/// keyword with no default fails closed (`None`); an optional keyword +/// whose default is not a substitutable symbol is omitted so later +/// statements that need it decline. +pub(super) fn bindings(def: &MethodDef, args: &[Expr]) -> Option> { if def.block_param.is_some() || def.params.iter().any(|p| { p.rest || p.from_kwrest || (!p.keyword && !p.from_keyword && p.default.is_some()) @@ -410,9 +470,21 @@ fn bindings(def: &MethodDef, args: &[Expr]) -> Option> { let mut out = HashMap::new(); for param in &def.params { let value = if param.keyword || param.from_keyword { - keywords - .remove(¶m.name) - .or_else(|| param.default.clone())? + match keywords.remove(¶m.name) { + Some(value) => { + symbol(&value)?; + value + } + None => match ¶m.default { + Some(default) => { + if symbol(default).is_none() { + continue; + } + default.clone() + } + None => return None, + }, + } } else { positional.next()?.clone() }; @@ -425,7 +497,31 @@ fn bindings(def: &MethodDef, args: &[Expr]) -> Option> { (positional.next().is_none() && keywords.is_empty()).then_some(out) } -fn expand(def: &MethodDef, args: &[Expr], sources: &[SourceFile]) -> Option> { +pub(super) struct Expansion { + pub(super) methods: Vec, + pub(super) items: Vec, +} + +fn expand( + def: &MethodDef, + args: &[Expr], + sources: &[SourceFile], + owner: &ClassId, +) -> Option { + if let Some(methods) = expand_define_methods(def, args, sources) { + return Some(Expansion { + methods, + items: Vec::new(), + }); + } + class_eval::expand(def, args, sources, owner) +} + +fn expand_define_methods( + def: &MethodDef, + args: &[Expr], + sources: &[SourceFile], +) -> Option> { let bindings = bindings(def, args)?; let statements = match &*def.body.node { ExprNode::Seq { exprs } => exprs.as_slice(), diff --git a/src/ingest/model_macros/class_eval.rs b/src/ingest/model_macros/class_eval.rs new file mode 100644 index 000000000..8dba077a7 --- /dev/null +++ b/src/ingest/model_macros/class_eval.rs @@ -0,0 +1,278 @@ +//! Interpolatable `class_eval` string/heredoc expansion for model macros. +//! +//! Isolated from the `define_method` IR-substitution engine: this path +//! interpolates `#{param}`, rewrites remaining bound locals to symbol +//! literals by byte offset, re-parses, then re-ingests through +//! [`crate::ingest::model::ingest_model_body_items`]. Dynamic +//! `class_eval` and unknown interpolations stay unexpanded. + +use std::collections::{HashMap, HashSet}; + +use crate::dialect::{MethodDef, ModelBodyItem}; +use crate::ident::ClassId; +use crate::ingest::model::ingest_model_body_items; +use crate::ingest::prism::parse_silent; +use crate::ingest::util::constant_id_str; +use crate::span::SourceFile; + +use super::{bindings, symbol, Expansion}; + +pub(super) fn expand( + def: &MethodDef, + args: &[crate::expr::Expr], + sources: &[SourceFile], + owner: &ClassId, +) -> Option { + let bindings = bindings(def, args)?; + let mut idents = HashMap::new(); + for (k, v) in &bindings { + idents.insert(k.as_str().to_string(), symbol(v)?.as_str().to_string()); + } + // Optional non-symbol kwargs are omitted from `bindings`. A later + // receiverless read of that name must decline, not drop the option. + let param_names: HashSet = def + .params + .iter() + .map(|p| p.name.as_str().to_string()) + .collect(); + let source = def + .name_span + .file + .0 + .checked_sub(1) + .and_then(|i| sources.get(i as usize))?; + let parsed = ruby_prism::parse(source.text.as_bytes()); + if parsed.errors().next().is_some() { + return None; + } + enum Piece { + ClassEval(String), + Stmt(String), + } + struct Collect { + offset: usize, + idents: HashMap, + pieces: Vec, + } + impl<'pr> ruby_prism::Visit<'pr> for Collect { + fn visit_def_node(&mut self, defn: &ruby_prism::DefNode<'pr>) { + if defn.name_loc().start_offset() != self.offset { + return; + } + let Some(body) = defn.body() else { + return; + }; + for stmt in crate::ingest::util::flatten_statements(body) { + if let Some(call) = stmt.as_call_node() { + if call.receiver().is_none() && constant_id_str(&call.name()) == "class_eval" { + if let Some(template) = class_eval_template(&call, &self.idents) { + self.pieces.push(Piece::ClassEval(template)); + continue; + } + } + } + let loc = stmt.location(); + self.pieces.push(Piece::Stmt( + String::from_utf8_lossy(loc.as_slice()).into_owned(), + )); + } + } + } + let mut collect = Collect { + offset: def.name_span.start as usize, + idents: idents.clone(), + pieces: Vec::new(), + }; + ruby_prism::Visit::visit(&mut collect, &parsed.node()); + let mut methods = Vec::new(); + let mut items = Vec::new(); + let file = source.path.as_str(); + for piece in collect.pieces { + let rewritten = match piece { + Piece::ClassEval(template) => template, + Piece::Stmt(src) => bind_local_reads(&src, &idents, ¶m_names)?, + }; + ingest_rewritten_body(&rewritten, owner, file, &mut methods, &mut items)?; + } + // Any leftover Unknown (interpolated association name, `scope`, + // non-symbol kwarg) declines the *entire* expansion, including + // already-rewritten class_eval methods. That is fail-closed, not + // a support claim — pin with a negative overlay. + if items + .iter() + .any(|item| matches!(item, ModelBodyItem::Unknown { .. })) + { + return None; + } + (!methods.is_empty() || !items.is_empty()).then_some(Expansion { methods, items }) +} + +fn ingest_rewritten_body( + src: &str, + owner: &ClassId, + file: &str, + methods: &mut Vec, + items: &mut Vec, +) -> Option<()> { + let parsed = parse_silent(src.as_bytes()); + if parsed.errors().next().is_some() { + return None; + } + let program = parsed.node().as_program_node()?; + for stmt in program.statements().body().iter() { + absorb_items( + ingest_model_body_items(&stmt, owner, file, Vec::new()).ok()?, + methods, + items, + ); + } + Some(()) +} + +fn absorb_items( + ingested: Vec, + methods: &mut Vec, + items: &mut Vec, +) { + for item in ingested { + match item { + ModelBodyItem::Method { method, .. } => methods.push(method), + other => items.push(other), + } + } +} + +fn class_eval_template( + call: &ruby_prism::CallNode<'_>, + idents: &HashMap, +) -> Option { + let args = call.arguments()?; + let first = args.arguments().iter().next()?; + if let Some(s) = first.as_string_node() { + return Some(String::from_utf8_lossy(s.unescaped()).into_owned()); + } + interpolate_string_node(&first, idents) +} + +fn interpolate_string_node( + node: &ruby_prism::Node<'_>, + idents: &HashMap, +) -> Option { + if let Some(s) = node.as_string_node() { + return Some(String::from_utf8_lossy(s.unescaped()).into_owned()); + } + let interp = node.as_interpolated_string_node()?; + let mut out = String::new(); + for part in interp.parts().iter() { + if let Some(s) = part.as_string_node() { + out.push_str(&String::from_utf8_lossy(s.unescaped())); + } else if let Some(es) = part.as_embedded_statements_node() { + let stmts = es.statements()?; + let nodes: Vec<_> = stmts.body().iter().collect(); + let [only] = nodes.as_slice() else { + return None; + }; + let name = only + .as_local_variable_read_node() + .map(|n| String::from_utf8_lossy(n.name().as_slice()).into_owned()) + .or_else(|| { + only.as_call_node().and_then(|c| { + (c.receiver().is_none() && c.arguments().is_none() && c.block().is_none()) + .then(|| String::from_utf8_lossy(c.name().as_slice()).into_owned()) + }) + })?; + out.push_str(idents.get(&name)?); + } else if part.as_interpolated_string_node().is_some() { + out.push_str(&interpolate_string_node(&part, idents)?); + } else { + return None; + } + } + Some(out) +} + +/// After `#{param}` has been substituted, remaining local reads of a +/// bound param become symbol literals (`name` → `:body`). A statement +/// sliced out of its `def` parses those names as receiverless calls, +/// which must get the same rewrite. A `LocalVariableReadNode` of a +/// bound name is slice-local (block/lambda shadow) and declines. +/// Offset surgery stays in this module: the orchestrator never sees +/// the source rewrite. +fn bind_local_reads( + src: &str, + idents: &HashMap, + param_names: &HashSet, +) -> Option { + let parsed = ruby_prism::parse(src.as_bytes()); + if parsed.errors().next().is_some() { + return None; + } + struct Locals { + hits: Vec<(usize, usize, String)>, + idents: HashMap, + param_names: HashSet, + decline: bool, + } + impl Locals { + fn record(&mut self, name: String, loc: ruby_prism::Location<'_>) { + if self.idents.contains_key(&name) { + self.hits.push((loc.start_offset(), loc.end_offset(), name)); + } else if self.param_names.contains(&name) { + self.decline = true; + } + } + } + impl<'pr> ruby_prism::Visit<'pr> for Locals { + fn visit_local_variable_read_node( + &mut self, + node: &ruby_prism::LocalVariableReadNode<'pr>, + ) { + // Standalone-slice local reads are bound inside the slice + // (block/lambda param) and shadow the macro parameter. + let name = String::from_utf8_lossy(node.name().as_slice()).into_owned(); + if self.idents.contains_key(&name) { + self.decline = true; + } + } + fn visit_call_node(&mut self, node: &ruby_prism::CallNode<'pr>) { + if node.receiver().is_none() && node.arguments().is_none() && node.block().is_none() { + self.record( + String::from_utf8_lossy(node.name().as_slice()).into_owned(), + node.location(), + ); + } + if let Some(recv) = node.receiver() { + self.visit(&recv); + } + if let Some(args) = node.arguments() { + for arg in args.arguments().iter() { + self.visit(&arg); + } + } + if let Some(block) = node.block() { + self.visit(&block); + } + } + } + let mut locals = Locals { + hits: Vec::new(), + idents: idents.clone(), + param_names: param_names.clone(), + decline: false, + }; + ruby_prism::Visit::visit(&mut locals, &parsed.node()); + if locals.decline { + return None; + } + let mut out = src.to_string(); + locals.hits.sort_by_key(|(start, _, _)| *start); + locals.hits.reverse(); + for (start, end, name) in locals.hits { + let ident = idents.get(&name)?; + if start > out.len() || end > out.len() || start > end { + return None; + } + out.replace_range(start..end, &format!(":{ident}")); + } + Some(out) +} diff --git a/src/ingest/on_load_reopen.rs b/src/ingest/on_load_reopen.rs index 0b04114c6..1bd7daa72 100644 --- a/src/ingest/on_load_reopen.rs +++ b/src/ingest/on_load_reopen.rs @@ -47,7 +47,7 @@ use ruby_prism::Node; use crate::app::App; -use crate::ident::Symbol; +use crate::ident::{ClassId, Symbol}; use super::util::{ class_name_path, constant_id_str, constant_path_of, find_all_classes_with_scope, @@ -103,6 +103,20 @@ pub(super) fn ingest_on_load_reopens(source: &[u8], file: &str, app: &mut App) { load-hook mixin installation is unsupported" ), }); + // Class-method macros may still expand at ingest + // (`App.load_hook_class_macros`). Mixin instance + // methods stay uninstalled — that is the gap this + // survey line names. + if hook == "active_record" { + for arg in include.arguments().iter().flat_map(|a| a.arguments().iter()) { + if let Some(path) = constant_path_of(&arg) { + let id = ClassId(Symbol::from(path.join("::"))); + if !app.load_hook_class_macros.contains(&id) { + app.load_hook_class_macros.push(id); + } + } + } + } } } diff --git a/src/ingest/sequel_model.rs b/src/ingest/sequel_model.rs index d7fe4a1e7..b493f4331 100644 --- a/src/ingest/sequel_model.rs +++ b/src/ingest/sequel_model.rs @@ -254,6 +254,8 @@ fn parse_sequel_association( .unwrap_or_else(|| Symbol::from(format!("{owner_snake}_id"))), dependent: Dependent::None, as_interface: None, + scope: None, + autosave: false, }, "many_to_one" => Association::BelongsTo { name: name.clone(), diff --git a/src/lower/and_return.rs b/src/lower/and_return.rs index b799fa3de..dc2e37e15 100644 --- a/src/lower/and_return.rs +++ b/src/lower/and_return.rs @@ -31,6 +31,10 @@ pub fn apply_and_return_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { if matches!( &*expr.node, ExprNode::BoolOp { op: BoolOpKind::And, right, .. } diff --git a/src/lower/arel/mod.rs b/src/lower/arel/mod.rs index 766ef2318..e2599b29c 100644 --- a/src/lower/arel/mod.rs +++ b/src/lower/arel/mod.rs @@ -47,18 +47,21 @@ pub fn rewrite_arel_in_expr( schema: &Schema, registry: &HashMap, ) { - rewrite_arel_in_expr_with_assocs(expr, schema, registry, &[]); + let _ = rewrite_arel_in_expr_with_assocs(expr, schema, registry, &[]); } /// As `rewrite_arel_in_expr`, but with the app's association graph so /// `includes(:assoc)` chains lower to eager-load preloads (issue #27). /// The 3-arg wrapper passes an empty graph → legacy drop-includes. +/// +/// Returns whether any node was rewritten so callers can skip a +/// follow-up type pass on an unchanged body. pub fn rewrite_arel_in_expr_with_assocs( expr: &mut Expr, schema: &Schema, registry: &HashMap, assocs: &[crate::lower::model_associations::AssociationEdge], -) { +) -> bool { // Names (ivars/locals) the body later refines with relation-chain // methods (`@moderations.where(...)` after `@moderations = // Moderation.all...`). Materializing the assigned chain here would @@ -66,7 +69,7 @@ pub fn rewrite_arel_in_expr_with_assocs( // runtime Relation path. let mut refined = std::collections::HashSet::new(); collect_relation_refined_names(expr, &mut refined); - rewrite_arel_inner(expr, schema, registry, assocs, &refined); + let mut changed = rewrite_arel_inner(expr, schema, registry, assocs, &refined); // Both call sites hand us a METHOD BODY, and a body that is a // single statement is not a `Seq` — so the hoist post-pass inside // `rewrite_arel_inner`, which walks a Seq's statement list, had no @@ -82,7 +85,7 @@ pub fn rewrite_arel_in_expr_with_assocs( // there is something to hoist into it. if !matches!(&*expr.node, ExprNode::Seq { .. }) { let mut hoisted = Vec::new(); - hoist_value_seqs(expr, &mut hoisted); + let replaced = hoist_value_seqs(expr, &mut hoisted); if !hoisted.is_empty() { let span = expr.span; let placeholder = Expr::new( @@ -91,8 +94,12 @@ pub fn rewrite_arel_in_expr_with_assocs( ); hoisted.push(std::mem::replace(expr, placeholder)); *expr = Expr::new(span, ExprNode::Seq { exprs: hoisted }); + changed = true; + } else if replaced { + changed = true; } } + changed } const RELATION_REFINERS: &[&str] = &[ @@ -185,7 +192,7 @@ fn rewrite_arel_inner( registry: &HashMap, assocs: &[crate::lower::model_associations::AssociationEdge], refined: &std::collections::HashSet, -) { +) -> bool { if let ExprNode::Assign { target, .. } = expr.node.as_ref() { let name = match target { crate::expr::LValue::Ivar { name } => Some(name), @@ -193,7 +200,7 @@ fn rewrite_arel_inner( _ => None, }; if name.is_some_and(|n| refined.contains(n)) { - return; + return false; } } if let ExprNode::Send { .. } = expr.node.as_ref() { @@ -207,7 +214,7 @@ fn rewrite_arel_inner( // keep their own, tighter spans. replacement.inherit_span(expr.span); *expr = replacement; - return; + return true; } } // Inline sibling of the refined-names guard above: this Send is a @@ -234,14 +241,15 @@ fn rewrite_arel_inner( let ExprNode::Send { recv: Some(recv), args, .. } = &mut *expr.node else { unreachable!("matched Send with recv above"); }; - rewrite_arel_spine_args(recv, schema, registry, assocs, refined); + let mut changed = rewrite_arel_spine_args(recv, schema, registry, assocs, refined); for a in args { - rewrite_arel_inner(a, schema, registry, assocs, refined); + changed |= rewrite_arel_inner(a, schema, registry, assocs, refined); } - return; + return changed; } + let mut changed = false; walk_subexprs_mut(expr, &mut |e| { - rewrite_arel_inner(e, schema, registry, assocs, refined) + changed |= rewrite_arel_inner(e, schema, registry, assocs, refined) }); // Post-pass: when an Arel rewrite landed a multi-stmt hydrate Seq // in a *value* position — directly as an Assign value @@ -254,8 +262,9 @@ fn rewrite_arel_inner( // inline multi-stmt value (`x = (a; b; c)`), so normalize // structurally. if let ExprNode::Seq { exprs } = &mut *expr.node { - hoist_value_seqs_in_stmts(exprs); + changed |= hoist_value_seqs_in_stmts(exprs); } + changed } /// Rewrite value positions inside a chain-receiver spine WITHOUT @@ -272,38 +281,46 @@ fn rewrite_arel_spine_args( registry: &HashMap, assocs: &[crate::lower::model_associations::AssociationEdge], refined: &std::collections::HashSet, -) { +) -> bool { + let mut changed = false; if let ExprNode::Send { recv, args, block, .. } = &mut *expr.node { if let Some(r) = recv { - rewrite_arel_spine_args(r, schema, registry, assocs, refined); + changed |= rewrite_arel_spine_args(r, schema, registry, assocs, refined); } for a in args { - rewrite_arel_inner(a, schema, registry, assocs, refined); + changed |= rewrite_arel_inner(a, schema, registry, assocs, refined); } if let Some(b) = block { - rewrite_arel_inner(b, schema, registry, assocs, refined); + changed |= rewrite_arel_inner(b, schema, registry, assocs, refined); } } + changed } /// For each statement in a Seq's stmt list, hoist any multi-stmt Seq an /// Arel rewrite landed in one of its value positions (see /// [`hoist_value_seqs`]), inserting the hoisted stmts ahead of it. -fn hoist_value_seqs_in_stmts(stmts: &mut Vec) { +fn hoist_value_seqs_in_stmts(stmts: &mut Vec) -> bool { + let mut changed = false; let mut i = 0; while i < stmts.len() { let mut hoisted = Vec::new(); - hoist_value_seqs(&mut stmts[i], &mut hoisted); + let replaced = hoist_value_seqs(&mut stmts[i], &mut hoisted); if hoisted.is_empty() { + if replaced { + changed = true; + } i += 1; continue; } + changed = true; let added = hoisted.len(); for (j, stmt) in hoisted.into_iter().enumerate() { stmts.insert(i + j, stmt); } i += added + 1; } + changed } /// Recurse through the *value* positions of `e` (call recv/args, assign @@ -316,59 +333,61 @@ fn hoist_value_seqs_in_stmts(stmts: &mut Vec) { /// visitor's fixed `stmt`/`results` locals and collide; that multi-query- /// per-statement case is a pre-existing visitor-naming limitation, not /// introduced here (every recognized site uses the same var names). -fn hoist_value_seqs(e: &mut Expr, hoisted: &mut Vec) { +fn hoist_value_seqs(e: &mut Expr, hoisted: &mut Vec) -> bool { + let mut changed = false; match &mut *e.node { ExprNode::Send { recv, args, .. } => { if let Some(r) = recv { - hoist_value_child(r, hoisted); + changed |= hoist_value_child(r, hoisted); } for a in args { - hoist_value_child(a, hoisted); + changed |= hoist_value_child(a, hoisted); } } ExprNode::Apply { fun, args, .. } => { - hoist_value_child(fun, hoisted); + changed |= hoist_value_child(fun, hoisted); for a in args { - hoist_value_child(a, hoisted); + changed |= hoist_value_child(a, hoisted); } } ExprNode::Assign { value, .. } | ExprNode::OpAssign { value, .. } => { - hoist_value_child(value, hoisted); + changed |= hoist_value_child(value, hoisted); } ExprNode::BoolOp { left, right, .. } => { - hoist_value_child(left, hoisted); - hoist_value_child(right, hoisted); + changed |= hoist_value_child(left, hoisted); + changed |= hoist_value_child(right, hoisted); } ExprNode::Array { elements, .. } => { for el in elements { - hoist_value_child(el, hoisted); + changed |= hoist_value_child(el, hoisted); } } ExprNode::Hash { entries, .. } => { for (_, v) in entries { - hoist_value_child(v, hoisted); + changed |= hoist_value_child(v, hoisted); } } ExprNode::Return { value } | ExprNode::Raise { value } | ExprNode::Splat { value } | ExprNode::KeywordSplat { value } => { - hoist_value_child(value, hoisted); + changed |= hoist_value_child(value, hoisted); } ExprNode::Yield { args } => { for a in args { - hoist_value_child(a, hoisted); + changed |= hoist_value_child(a, hoisted); } } _ => {} } + changed } /// Process one value-position child: recurse into its own value /// positions, then — if the child is itself a Seq — move its leading /// statements into `hoisted` and collapse it to its final expression. -fn hoist_value_child(child: &mut Expr, hoisted: &mut Vec) { - hoist_value_seqs(child, hoisted); +fn hoist_value_child(child: &mut Expr, hoisted: &mut Vec) -> bool { + let mut changed = hoist_value_seqs(child, hoisted); if matches!(&*child.node, ExprNode::Seq { .. }) { let placeholder = Expr::new( crate::span::Span::synthetic(), @@ -383,7 +402,11 @@ fn hoist_value_child(child: &mut Expr, hoisted: &mut Vec) { } // Empty Seq → keep the nil placeholder. } + // Replacement itself is a tree change even when nothing is + // hoisted (empty `begin; end` → nil). Callers retype on this. + changed = true; } + changed } /// Mutable visitor for every direct sub-Expr of `expr`. Caller @@ -646,4 +669,21 @@ mod tests { let ExprNode::Assign { value, .. } = &*stmts[2].node else { panic!() }; assert!(matches!(&*value.node, ExprNode::Var { .. }), "binds to the results var"); } + + #[test] + fn empty_value_seq_replacement_is_a_change() { + // `x = begin; end` is an empty Seq in value position. Hoisting + // replaces it with nil and adds no statements; that still has + // to count as a rewrite so the controller retypes the body. + let mut stmts = vec![assign("x", seq_node(vec![]))]; + assert!( + hoist_value_seqs_in_stmts(&mut stmts), + "Seq-to-nil must set changed even with an empty hoist list" + ); + let ExprNode::Assign { value, .. } = &*stmts[0].node else { panic!("expected assign") }; + assert!( + matches!(&*value.node, ExprNode::Lit { value: Literal::Nil }), + "empty Seq collapsed to nil" + ); + } } diff --git a/src/lower/arel_attribute.rs b/src/lower/arel_attribute.rs index 63a1b6dd9..5a1eb4253 100644 --- a/src/lower/arel_attribute.rs +++ b/src/lower/arel_attribute.rs @@ -50,6 +50,10 @@ fn is_arel_table(ty: Option<&Ty>) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &mut *expr.node else { return; diff --git a/src/lower/array_ordinal.rs b/src/lower/array_ordinal.rs index ae28a48c6..fe899e497 100644 --- a/src/lower/array_ordinal.rs +++ b/src/lower/array_ordinal.rs @@ -45,30 +45,40 @@ pub fn apply_array_ordinal_lowering(app: &mut App) { /// to run once that body is typed — a test's `@messages` is bound in /// its inlined setup and typed there, nowhere earlier. campfire's /// messages_controller_test pages through `@messages.third` / -/// `.fourth` / `.fifth` over a `to_a`. -pub(crate) fn rewrite_body(expr: &mut Expr) { - rewrite(expr); +/// `.fourth` / `.fifth` over a `to_a`. Returns whether any ordinal +/// was rewritten so the caller can skip a follow-up type. +pub(crate) fn rewrite_body(expr: &mut Expr) -> bool { + let mut changed = false; + expr.node.for_each_child_mut(&mut |c| { + if rewrite_body(c) { + changed = true; + } + }); + rewrite_node(expr) || changed } fn rewrite(expr: &mut Expr) { - expr.node.for_each_child_mut(&mut rewrite); + let _ = rewrite_body(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) -> bool { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &mut *expr.node else { - return; + return false; }; if !args.is_empty() { - return; + return false; } let Some((_, index)) = ORDINALS.iter().find(|(name, _)| *name == method.as_str()) else { - return; + return false; }; if !matches!(recv.ty, Some(Ty::Array { .. })) { - return; + return false; } let span = expr.span; let mut idx = Expr::new(span, ExprNode::Lit { value: Literal::Int { value: *index } }); idx.ty = Some(Ty::Int); *method = Symbol::from("[]"); - let ExprNode::Send { args, .. } = &mut *expr.node else { return }; + let ExprNode::Send { args, .. } = &mut *expr.node else { return false }; args.push(idx); + true } diff --git a/src/lower/assoc_loaded.rs b/src/lower/assoc_loaded.rs index 3f5b9fa61..c4431f8f3 100644 --- a/src/lower/assoc_loaded.rs +++ b/src/lower/assoc_loaded.rs @@ -30,7 +30,7 @@ use crate::ty::Ty; /// Per-model has_many names that have a synthesized `_loaded?` /// reader. Keyed by owner ClassId so `room.boosts.loaded?` is not /// rewritten when only `Message` declares `has_many :boosts`. -fn has_many_by_model(app: &App) -> HashMap> { +pub(crate) fn has_many_by_model(app: &App) -> HashMap> { let mut out: HashMap> = HashMap::new(); for model in &app.models { // Every model gets a key so `in_model` owner checks don't treat @@ -51,7 +51,7 @@ fn has_many_by_model(app: &App) -> HashMap> { /// Per-model association / rich-text reader names `association(:x).target` /// may collapse onto. Keyed by owner so `room.association(:boosts).target` /// is not rewritten when only `Message` declares `:boosts`. -fn association_readers_by_model(app: &App) -> HashMap> { +pub(crate) fn association_readers_by_model(app: &App) -> HashMap> { let mut out: HashMap> = HashMap::new(); for model in &app.models { let entry = out.entry(model.name.clone()).or_default(); @@ -178,6 +178,16 @@ fn rewrite( ) { expr.node .for_each_child_mut(&mut |c| rewrite(c, enclosing, sole_includer, by_model, readers)); + rewrite_node(expr, enclosing, sole_includer, by_model, readers); +} + +pub(crate) fn rewrite_node( + expr: &mut Expr, + enclosing: Option<&ClassId>, + sole_includer: &HashMap, + by_model: &HashMap>, + readers: &HashMap>, +) { if rewrite_association_target(expr, enclosing, sole_includer, readers) { return; } diff --git a/src/lower/assoc_pluck.rs b/src/lower/assoc_pluck.rs index 4f67e9fe1..acadec757 100644 --- a/src/lower/assoc_pluck.rs +++ b/src/lower/assoc_pluck.rs @@ -90,7 +90,7 @@ pub fn apply_assoc_pluck_lowering(app: &mut App) { /// plain on one model and `:through` on another is EXCLUDED rather than /// guessed at — being wrong in the Relation direction costs a /// single-column SELECT, and this pass is not worth that. -fn materialized_assoc_names(app: &App) -> std::collections::HashSet { +pub(crate) fn materialized_assoc_names(app: &App) -> std::collections::HashSet { use crate::lower::model_associations::AssocKind; let mut plain = std::collections::HashSet::new(); let mut relational = std::collections::HashSet::new(); @@ -120,7 +120,10 @@ fn materialized_assoc_names(app: &App) -> std::collections::HashSet { fn rewrite(expr: &mut Expr, materialized: &std::collections::HashSet) { expr.node.for_each_child_mut(&mut |c| rewrite(c, materialized)); + rewrite_node(expr, materialized); +} +pub(crate) fn rewrite_node(expr: &mut Expr, materialized: &std::collections::HashSet) { let ExprNode::Send { recv: Some(recv), method, args, block, .. } = &*expr.node else { return; }; diff --git a/src/lower/attachables_grep.rs b/src/lower/attachables_grep.rs index 4d175a551..e7ef48ad5 100644 --- a/src/lower/attachables_grep.rs +++ b/src/lower/attachables_grep.rs @@ -49,6 +49,10 @@ pub fn apply_attachables_grep_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // `.attachables.grep()` — zero-arg, block-free on // the inner hop, one Const argument on the outer. let parts = match &*expr.node { diff --git a/src/lower/attr_or_assign.rs b/src/lower/attr_or_assign.rs index 6f54da053..3c35710cc 100644 --- a/src/lower/attr_or_assign.rs +++ b/src/lower/attr_or_assign.rs @@ -20,6 +20,7 @@ use crate::app::App; use crate::expr::{BoolOpKind, BoolOpSurface, Expr, ExprNode, LValue, OpAssignOp}; +#[allow(dead_code)] pub fn apply_attr_or_assign_lowering(app: &mut App) { super::for_each_hook_body(app, &mut rewrite); } @@ -42,8 +43,13 @@ fn one_class(ty: Option<&crate::ty::Ty>) -> bool { } } +#[allow(dead_code)] fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::OpAssign { target: LValue::Attr { recv, name }, op, value } = &*expr.node else { return; }; diff --git a/src/lower/attribute_aliases.rs b/src/lower/attribute_aliases.rs index 651673312..5455d6764 100644 --- a/src/lower/attribute_aliases.rs +++ b/src/lower/attribute_aliases.rs @@ -4,6 +4,7 @@ use crate::expr::{Expr, ExprNode, Literal}; use crate::ident::Symbol; use crate::ty::Ty; +#[allow(dead_code)] pub fn apply_attribute_alias_lowering(app: &mut App) { let models: std::collections::HashSet = app.models.iter().map(|m| m.name.0.as_str().to_string()).collect(); for model in &mut app.models { @@ -37,6 +38,10 @@ pub fn apply_attribute_alias_lowering(app: &mut App) { fn rewrite(expr: &mut Expr, models: &std::collections::HashSet, in_model: bool) { expr.node.for_each_child_mut(&mut |c| rewrite(c, models, in_model)); + rewrite_node(expr, models, in_model); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, models: &std::collections::HashSet, in_model: bool) { let ExprNode::Send { recv, method, args, block: None, .. } = &mut *expr.node else { return }; let to = match (method.as_str(), args.len()) { ("read_attribute", 1) => "[]", diff --git a/src/lower/blank.rs b/src/lower/blank.rs index 642405270..6611e7763 100644 --- a/src/lower/blank.rs +++ b/src/lower/blank.rs @@ -69,9 +69,7 @@ use crate::app::App; use crate::diagnostic::{Diagnostic, DiagnosticKind}; -use crate::expr::{ - Arm, Expr, ExprNode, InterpPart, LValue, Literal, RescueClause, -}; +use crate::expr::{Expr, ExprNode, LValue, Literal}; use crate::ident::Symbol; use crate::ty::Ty; use std::collections::HashSet; @@ -93,7 +91,9 @@ pub fn apply_blank_lowering(app: &mut App) -> Vec { // with closed vocabularies (python's unemittable-cond fallback is a // silent `False`; the flash-notice smoke caught it). Views rejoin // when the view pipeline migrates to shared lowerings. - super::for_each_hook_body(app, &mut |body| walk(body, &defs, &mut diags)); + super::for_each_hook_body(app, &mut |body| { + walk(body, &defs, &mut diags); + }); // TEST BODIES TOO, asked for by name — the widening // `for_each_test_body` exists to make reviewable. @@ -111,7 +111,9 @@ pub fn apply_blank_lowering(app: &mut App) -> Vec { // an app body; there is no test-specific vocabulary here. On CRuby // the overlay served these already and the rewrite is neutral — it // is the AOT and strict targets that could not dispatch them. - super::for_each_test_body(app, &mut |body| walk(body, &defs, &mut diags)); + super::for_each_test_body(app, &mut |body| { + walk(body, &defs, &mut diags); + }); diags } @@ -130,12 +132,13 @@ pub fn apply_blank_lowering(app: &mut App) -> Vec { /// Diagnostics go to the emit-time sink, the way every other lowering /// that runs this late reports (`lower::time_current`, /// `view_to_library`). -pub(crate) fn ground_body(body: &mut Expr, defs: &AppDefinitions) { +pub(crate) fn ground_body(body: &mut Expr, defs: &AppDefinitions) -> bool { let mut diags = Vec::new(); - walk(body, defs, &mut diags); + let changed = walk(body, defs, &mut diags); for d in diags { crate::emit::diagnostics::push(d); } + changed } /// Which classes define their own `blank?`/`present?`/`presence` @@ -363,191 +366,20 @@ pub(crate) fn is_effect_free_reader(e: &Expr) -> bool { } } -fn walk(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec) { - // Children first: a rewritten site's receiver subtree never needs - // revisiting, and inner blank-predicates (e.g. in an interpolation - // inside a `present?` receiver) are already grounded by the time - // the outer node is considered. - match &mut *expr.node { - ExprNode::Send { recv, args, block, .. } => { - if let Some(r) = recv { - walk(r, defs, diags); - } - for a in args { - walk(a, defs, diags); - } - if let Some(b) = block { - walk(b, defs, diags); - } - } - ExprNode::Apply { fun, args, block } => { - walk(fun, defs, diags); - for a in args { - walk(a, defs, diags); - } - if let Some(b) = block { - walk(b, defs, diags); - } - } - ExprNode::Seq { exprs } => { - for e in exprs { - walk(e, defs, diags); - } - } - ExprNode::Array { elements, .. } => { - for e in elements { - walk(e, defs, diags); - } - } - ExprNode::Hash { entries, .. } => { - for (k, v) in entries { - walk(k, defs, diags); - walk(v, defs, diags); - } - } - ExprNode::StringInterp { parts } => { - for p in parts { - if let InterpPart::Expr { expr: e } = p { - walk(e, defs, diags); - } - } - } - ExprNode::BoolOp { left, right, .. } => { - walk(left, defs, diags); - walk(right, defs, diags); - } - ExprNode::Let { value, body, .. } => { - walk(value, defs, diags); - walk(body, defs, diags); - } - ExprNode::Lambda { body, .. } => walk(body, defs, diags), - ExprNode::MethodRef { recv, .. } => { - if let Some(r) = recv { - walk(r, defs, diags); - } - } - ExprNode::If { cond, then_branch, else_branch } => { - walk(cond, defs, diags); - walk(then_branch, defs, diags); - walk(else_branch, defs, diags); - } - ExprNode::Case { scrutinee, arms } => { - walk(scrutinee, defs, diags); - for Arm { guard, body, .. } in arms { - if let Some(g) = guard { - walk(g, defs, diags); - } - walk(body, defs, diags); - } - } - ExprNode::CaseMatch { scrutinee, arms, else_body } => { - walk(scrutinee, defs, diags); - for arm in arms { - arm.pattern.for_each_expr_mut(&mut |e| walk(e, defs, diags)); - if let Some((_, g)) = &mut arm.guard { - walk(g, defs, diags); - } - walk(&mut arm.body, defs, diags); - } - if let Some(e) = else_body { - walk(e, defs, diags); - } - } - ExprNode::MatchPredicate { value, pattern } | ExprNode::MatchRequired { value, pattern } => { - walk(value, defs, diags); - pattern.for_each_expr_mut(&mut |e| walk(e, defs, diags)); - } - ExprNode::Assign { target, value } => { - walk_lvalue(target, defs, diags); - walk(value, defs, diags); - } - ExprNode::OpAssign { target, value, .. } => { - walk_lvalue(target, defs, diags); - walk(value, defs, diags); +fn walk(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec) -> bool { + let mut changed = false; + expr.node.for_each_child_mut(&mut |c| { + if walk(c, defs, diags) { + changed = true; } - ExprNode::MultiAssign { targets, value } => { - for t in targets { - walk_lvalue(t, defs, diags); - } - walk(value, defs, diags); - } - ExprNode::Yield { args } => { - for a in args { - walk(a, defs, diags); - } - } - ExprNode::Raise { value } => walk(value, defs, diags), - ExprNode::RescueModifier { expr: e, fallback } => { - walk(e, defs, diags); - walk(fallback, defs, diags); - } - ExprNode::Return { value } => walk(value, defs, diags), - ExprNode::Super { args } => { - if let Some(args) = args { - for a in args { - walk(a, defs, diags); - } - } - } - ExprNode::Next { value } | ExprNode::Break { value } => { - if let Some(v) = value { - walk(v, defs, diags); - } - } - ExprNode::Splat { value } | ExprNode::KeywordSplat { value } => walk(value, defs, diags), - ExprNode::While { cond, body, .. } => { - walk(cond, defs, diags); - walk(body, defs, diags); - } - ExprNode::Range { begin, end, .. } => { - if let Some(b) = begin { - walk(b, defs, diags); - } - if let Some(e) = end { - walk(e, defs, diags); - } - } - ExprNode::BeginRescue { body, rescues, else_branch, ensure, .. } => { - walk(body, defs, diags); - for RescueClause { classes, body, .. } in rescues { - for c in classes { - walk(c, defs, diags); - } - walk(body, defs, diags); - } - if let Some(e) = else_branch { - walk(e, defs, diags); - } - if let Some(e) = ensure { - walk(e, defs, diags); - } - } - ExprNode::Cast { value, .. } => walk(value, defs, diags), - ExprNode::Lit { .. } - | ExprNode::Var { .. } - | ExprNode::Ivar { .. } - | ExprNode::Const { .. } - | ExprNode::SelfRef - | ExprNode::Retry - | ExprNode::ForwardArgs - | ExprNode::ForwardKeywords - | ExprNode::Defined { .. } - | ExprNode::Redo => {} + }); + if try_rewrite(expr, defs, diags) { + changed = true; } - - try_rewrite(expr, defs, diags); - try_rewrite_compact_blank(expr, defs, diags); -} - -fn walk_lvalue(target: &mut LValue, defs: &AppDefinitions, diags: &mut Vec) { - match target { - LValue::Attr { recv, .. } => walk(recv, defs, diags), - LValue::Index { recv, index } => { - walk(recv, defs, diags); - walk(index, defs, diags); - } - _ => {} + if try_rewrite_compact_blank(expr, defs, diags) { + changed = true; } + changed } /// The three predicates, owned so no borrow of the Send node outlives @@ -559,23 +391,23 @@ enum Pred { Presence, } -fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec) { +fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec) -> bool { use Grounding::*; // Decision phase: everything read out of the node is owned before // any mutation. let (pred, grounding, recv_ty, pure_recv) = { let ExprNode::Send { recv: Some(r), method, args, block, .. } = &*expr.node else { - return; + return false; }; let pred = match method.as_str() { "blank?" => Pred::Blank, "present?" => Pred::Present, "presence" => Pred::Presence, - _ => return, + _ => return false, }; if !args.is_empty() || block.is_some() { - return; + return false; } // A `params[:x]` read is typed `String?`, and for a form field // that is what it is — but campfire's `params[:attachment]` is @@ -594,7 +426,7 @@ fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec = { - let ExprNode::Send { recv: Some(r), .. } = &*expr.node else { return }; + let ExprNode::Send { recv: Some(r), .. } = &*expr.node else { return false }; match &*r.node { ExprNode::Assign { target, value } if is_effect_free_reader(value) => match target { LValue::Var { id, name } => Some(mk( @@ -620,10 +452,10 @@ fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec return, + OwnDispatch => return false, Skip(reason) => { diags.push(unlowered(expr, recv_ty.as_ref(), method_name, reason)); - return; + return false; } // The receiver moves into argument position, so it is read once // however impure it is — the effect-free gate below does not @@ -639,7 +471,7 @@ fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec {} } @@ -686,7 +518,7 @@ fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec, -) { +) -> bool { enum Shape { Array { elem: Ty }, Hash { key: Ty, value: Ty }, } let (shape, grounding, recv_ty) = { let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &*expr.node else { - return; + return false; }; if method.as_str() != "compact_blank" || !args.is_empty() { - return; + return false; } let shape = match r.ty.as_ref() { Some(Ty::Array { elem }) => Shape::Array { elem: (**elem).clone() }, @@ -776,7 +609,7 @@ fn try_rewrite_compact_blank( "compact_blank", "receiver is not a typed Array or Hash", )); - return; + return false; } }; let inner = match &shape { @@ -801,7 +634,7 @@ fn try_rewrite_compact_blank( "compact_blank", "element type has no `empty?` grounding", )); - return; + return false; } _ => { // Untyped / open-var / never-blank / bool: the runtime @@ -854,6 +687,7 @@ fn try_rewrite_compact_blank( out_ty, ); expr.leading_blank_line = leading_blank_line; + true } fn compact_blank_value_var(span: crate::span::Span, ty: Ty) -> Expr { diff --git a/src/lower/boolean_cast.rs b/src/lower/boolean_cast.rs index 3d6991e24..4ff1a99c9 100644 --- a/src/lower/boolean_cast.rs +++ b/src/lower/boolean_cast.rs @@ -13,6 +13,10 @@ pub fn apply_boolean_cast_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, diff --git a/src/lower/byte_size.rs b/src/lower/byte_size.rs index a70576182..23ae60cb2 100644 --- a/src/lower/byte_size.rs +++ b/src/lower/byte_size.rs @@ -63,7 +63,10 @@ fn factor(method: &str) -> Option { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let Some(mult) = byte_factor(expr) else { return }; let ExprNode::Send { recv, .. } = &mut *expr.node else { unreachable!() }; diff --git a/src/lower/capture_inline.rs b/src/lower/capture_inline.rs index 57bf994df..f345e8d7e 100644 --- a/src/lower/capture_inline.rs +++ b/src/lower/capture_inline.rs @@ -28,6 +28,10 @@ pub fn apply_capture_inline(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let is_capture = matches!( &*expr.node, ExprNode::Send { recv: None, method, args, block: Some(block), .. } diff --git a/src/lower/case_lambda.rs b/src/lower/case_lambda.rs index f9b901ba6..209ac493c 100644 --- a/src/lower/case_lambda.rs +++ b/src/lower/case_lambda.rs @@ -37,6 +37,10 @@ pub fn apply_case_lambda_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { if !claims(expr) { return; } diff --git a/src/lower/config_reader.rs b/src/lower/config_reader.rs index b22cdfcd0..b051704ce 100644 --- a/src/lower/config_reader.rs +++ b/src/lower/config_reader.rs @@ -61,8 +61,6 @@ use crate::expr::{Expr, ExprNode, Literal}; const CABLE_MOUNT_PATH: &str = "/cable"; pub fn apply_config_reader_lowering(app: &mut App) { - ground_cable_mount_path(app); - ground_credentials_readers(app); // Reader name -> the type of the value it answers, from the lifted // method's own body. Stamped onto each rewritten read for the same // reason the synthesized `application` hop below is stamped: this @@ -91,19 +89,64 @@ pub fn apply_config_reader_lowering(app: &mut App) { (m.name.clone(), ty) }) .collect(), - None => return, + None => Vec::new(), }; - if lifted.is_empty() { - return; + let lifted = (!lifted.is_empty()).then_some(lifted); + // Cable, credentials, and lifted config reads used to be three + // hook+view walks. They match disjoint chains, so one walk is + // equivalent: config peels outermost-first (longest chain wins), + // then cable/credentials apply post-order on the way back. Tests + // only ever saw the config rewrite. + super::for_each_hook_body(app, &mut |e| rewrite_fused(e, lifted.as_deref(), true)); + if let Some(lifted) = lifted.as_deref() { + super::for_each_test_body(app, &mut |e| rewrite(e, lifted)); } - super::for_each_hook_body(app, &mut |e| rewrite(e, &lifted)); - super::for_each_test_body(app, &mut |e| rewrite(e, &lifted)); - let lifted_for_views = lifted.clone(); for view in &mut app.views { - rewrite(&mut view.body, &lifted_for_views); + rewrite_fused(&mut view.body, lifted.as_deref(), true); } } +/// Combined cable + credentials + (optional) config rewrite. `config` +/// is outermost-first; cable/credentials are post-order. Surfaces that +/// never ran config pass `lifted = None`. +fn rewrite_fused( + expr: &mut Expr, + lifted: Option<&[(crate::ident::Symbol, Option)]>, + cable_and_credentials: bool, +) { + if let Some(lifted) = lifted { + unwrap_config_tap(expr); + if rewrite_write_here(expr, lifted) { + super::symbolize_keys::rewrite_node(expr); + // rewrite_write_here only config-rewrites the value. Cable, + // credentials, and nested symbolize_keys used to reach it + // on their own walks; apply them to the new arguments. + if let ExprNode::Send { args, .. } = &mut *expr.node { + for arg in args { + rewrite_fused(arg, Some(lifted), cable_and_credentials); + } + } + return; + } + if rewrite_here(expr, lifted) { + super::symbolize_keys::rewrite_node(expr); + return; + } + } + expr.node + .for_each_child_mut(&mut |c| rewrite_fused(c, lifted, cable_and_credentials)); + if cable_and_credentials { + rewrite_cable_node(expr); + rewrite_credentials_node(expr); + } + // After `config_reader`: a lifted group reader is the receiver + // `symbolize_keys` keys on, and that receiver does not exist until + // this walk peels the `config` chain. Post-order on the parent + // (`hash.symbolize_keys`) sees the rewritten, typed child. Tests + // never ran `symbolize_keys`, so they stay on `rewrite` above. + super::symbolize_keys::rewrite_node(expr); +} + /// The type a reader answers: its body's, or its last statement's when /// the body is a sequence. fn body_ty(body: &Expr) -> Option { @@ -127,15 +170,7 @@ fn body_ty(body: &Expr) -> Option { /// index is the same question, asked of the store we have. A `!` key /// (`credentials.x!`, "raise when absent") and the Hash's own methods /// are left alone. -fn ground_credentials_readers(app: &mut App) { - super::for_each_hook_body(app, &mut rewrite_credentials_read); - for view in &mut app.views { - rewrite_credentials_read(&mut view.body); - } -} - -fn rewrite_credentials_read(expr: &mut Expr) { - expr.node.for_each_child_mut(&mut rewrite_credentials_read); +fn rewrite_credentials_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &*expr.node else { return; }; @@ -185,16 +220,8 @@ fn is_credentials_root(e: &Expr) -> bool { && matches!(&*rr.node, ExprNode::Const { path } if path.len() == 1 && path[0].as_str() == "Rails") } -fn ground_cable_mount_path(app: &mut App) { - super::for_each_hook_body(app, &mut rewrite_cable_mount_path); - for view in &mut app.views { - rewrite_cable_mount_path(&mut view.body); - } -} - /// `ActionCable.server.config.mount_path` → `"/cable"`. -fn rewrite_cable_mount_path(expr: &mut Expr) { - expr.node.for_each_child_mut(&mut rewrite_cable_mount_path); +fn rewrite_cable_node(expr: &mut Expr) { if !is_cable_mount_path(expr) { return; } diff --git a/src/lower/controller_class_render.rs b/src/lower/controller_class_render.rs index 537687937..c596286f7 100644 --- a/src/lower/controller_class_render.rs +++ b/src/lower/controller_class_render.rs @@ -43,12 +43,17 @@ use crate::app::App; use crate::expr::{Expr, ExprNode, Literal}; use crate::ident::Symbol; -pub fn apply_controller_class_render(app: &mut App) { - let contracts = crate::lower::view_to_library::partial_call_contracts( +pub(crate) fn call_contracts(app: &App) -> Contracts { + crate::lower::view_to_library::partial_call_contracts( &app.views, &app.controllers, &app.library_classes, - ); + ) +} + +#[allow(dead_code)] +pub fn apply_controller_class_render(app: &mut App) { + let contracts = call_contracts(app); if contracts.is_empty() { return; } @@ -81,7 +86,7 @@ pub fn apply_controller_class_render(app: &mut App) { /// `attachments_for` answers `[attribute_attachment_for(…), /// content_attachment_for(…)]`, each of which ends in /// `attachment_from(…)`, which ends in `from_node`. -struct Builders { +pub(crate) struct Builders { one: std::collections::HashSet, many: std::collections::HashSet, } @@ -92,7 +97,7 @@ impl Builders { } } -fn attachment_builders(helpers: &[crate::dialect::MethodDef]) -> Builders { +pub(crate) fn attachment_builders(helpers: &[crate::dialect::MethodDef]) -> Builders { let mut b = Builders { one: Default::default(), many: Default::default() }; loop { let before = (b.one.len(), b.many.len()); @@ -138,7 +143,7 @@ fn builds_attachment(tail: &Expr) -> bool { /// Locals in `body` holding an attachment: assigned from a call to a /// one-attachment builder, or the block parameter of `.map` / `.each` /// over a call to an Array one (`attachments_for(…).map do |attachment|`). -fn attachment_locals(body: &Expr, builders: &Builders) -> std::collections::HashSet { +pub(crate) fn attachment_locals(body: &Expr, builders: &Builders) -> std::collections::HashSet { let mut out = std::collections::HashSet::new(); if builders.is_empty() { return out; @@ -164,13 +169,21 @@ fn attachment_locals(body: &Expr, builders: &Builders) -> std::collections::Hash out } -type Contracts = std::collections::HashMap< +fn rewrite(expr: &mut Expr, contracts: &Contracts, attachment_locals: &std::collections::HashSet) { + expr.node.for_each_child_mut(&mut |c| rewrite(c, contracts, attachment_locals)); + rewrite_node(expr, contracts, attachment_locals); +} + +pub(crate) type Contracts = std::collections::HashMap< (String, String), crate::lower::view_to_library::PartialCallContract, >; -fn rewrite(expr: &mut Expr, contracts: &Contracts, attachment_locals: &std::collections::HashSet) { - expr.node.for_each_child_mut(&mut |c| rewrite(c, contracts, attachment_locals)); +pub(crate) fn rewrite_node( + expr: &mut Expr, + contracts: &Contracts, + attachment_locals: &std::collections::HashSet, +) { // `render_action_text_attachment(attachment)` — Action Text's own // helper for the same render: `ActionText::ContentHelper`'s, which // renders the attachment's partial with the attachment as its local. diff --git a/src/lower/controller_to_library/broadcasts.rs b/src/lower/controller_to_library/broadcasts.rs index d145c5be7..2b50390b7 100644 --- a/src/lower/controller_to_library/broadcasts.rs +++ b/src/lower/controller_to_library/broadcasts.rs @@ -36,15 +36,23 @@ use crate::span::Span; use crate::ty::Ty; use super::rewrites::partial_view_call_with_record; -use super::util::map_expr; /// Rewrite every recognized broadcast call in a controller method body. +#[allow(dead_code)] pub(super) fn rewrite_broadcast_to( expr: &Expr, module_name: Option<&str>, partials: &super::PartialMap, ) -> Expr { - map_expr(expr, &|e| try_rewrite(e, module_name, partials)) + super::util::map_expr(expr, &|e| try_rewrite(e, module_name, partials)) +} + +pub(super) fn rewrite_broadcast_to_in_place( + expr: &mut Expr, + module_name: Option<&str>, + partials: &super::PartialMap, +) -> bool { + super::util::map_expr_mut(expr, &|e| try_rewrite(e, module_name, partials)) } fn try_rewrite( diff --git a/src/lower/controller_to_library/mod.rs b/src/lower/controller_to_library/mod.rs index 63f9cdd50..713dfb42c 100644 --- a/src/lower/controller_to_library/mod.rs +++ b/src/lower/controller_to_library/mod.rs @@ -323,18 +323,20 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( crate::lower::view_to_library::partial_call_contracts(views, controllers, library_classes); let mut all_methods: Vec<(Vec, &Controller)> = Vec::new(); - for controller in controllers { - let json_actions = json_actions_for(controller, views); - let text_format_actions = text_format_actions_for(controller, views); - // `Some(map)` → this controller's routed actions (empty set if it - // has no routes, e.g. a base controller → all publics are helpers). - // `None` → legacy: every public method is an action. - let routed = routed_by_controller - .map(|m| m.get(&controller.name).cloned().unwrap_or_default()); - let methods = build_methods(controller, controllers, ¶ms_specs, &json_actions, &text_format_actions, routed.as_ref(), &view_ivars, &partials, format_breadth, route_id_segments, inferred_params); - all_methods.push((methods, controller)); - } - subclass_template_hooks(&mut all_methods, controllers, &view_ivars, &partials); + crate::timings::phase("lower: controllers build", || { + for controller in controllers { + let json_actions = json_actions_for(controller, views); + let text_format_actions = text_format_actions_for(controller, views); + // `Some(map)` → this controller's routed actions (empty set if it + // has no routes, e.g. a base controller → all publics are helpers). + // `None` → legacy: every public method is an action. + let routed = routed_by_controller + .map(|m| m.get(&controller.name).cloned().unwrap_or_default()); + let methods = build_methods(controller, controllers, ¶ms_specs, &json_actions, &text_format_actions, routed.as_ref(), &view_ivars, &partials, format_breadth, route_id_segments, inferred_params); + all_methods.push((methods, controller)); + } + subclass_template_hooks(&mut all_methods, controllers, &view_ivars, &partials); + }); let mut classes: std::collections::HashMap = std::collections::HashMap::new(); @@ -392,8 +394,11 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( // AFTER `extras` so the existing `Views::Articles` entry (from // view_to_library, with `show`/`index`/`new`/`edit`) gets the // `_json` siblings merged in rather than overwritten. + // Signatures only — body typing belongs to the jbuilder lowerer, + // which dump_ir / emit already ran (or will run) separately. let app_stub = crate::App::new(); - for lc in crate::lower::lower_jbuilder_to_library_classes(views, &app_stub, Vec::new()) { + crate::timings::phase("lower: controllers jbuilder sigs", || { + for lc in crate::lower::jbuilder_signature_classes(views, &app_stub) { let info = classes.entry(lc.name.clone()).or_default(); for m in &lc.methods { if let Some(sig) = &m.signature { @@ -424,7 +429,8 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( } } } - } + } + }); // Ivar bindings: `@params` is framework-guaranteed (the lowerer // itself rewrites bare `params` → `@params` in action bodies, so @@ -483,7 +489,10 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( .map(|(n, _)| n.clone()) .collect(); + let permitted_fields = self::params::permitted_field_tys(¶ms_specs); + let mut out = Vec::new(); + crate::timings::phase("lower: controllers type", || { for (mut methods, controller) in all_methods { // Surveyed over the WHOLE controller before any body is // rewritten: which of its own methods does it call and then @@ -509,58 +518,28 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( continue; } crate::lower::typing::type_method_body(method, &classes, &framework_ivars); - // Stage 3: now that bodies are typed, rewrite - // `[:field]` → `.field`. - // Re-type after the rewrite so the synthesized - // attr_reader Send carries its return type and any - // chained dispatch picks up the concrete `Str`. - method.body = self::params::rewrite_typed_bracket_to_field( - &method.body, ¶ms_specs, + // Bracket, broadcast, and arel all need the first typing + // pass and do not consume each other's newly-stamped types, + // so they share one follow-up type when any of them fires. + let mut rewritten = self::params::rewrite_typed_bracket_to_field_in_place( + &mut method.body, &permitted_fields, ); - crate::lower::typing::type_method_body(method, &classes, &framework_ivars); - // `broadcast_prepend_to user, :rooms, target: [@room, :list], - // partial: …` — the Rails broadcast API written from a - // CONTROLLER, a home `lower::broadcast_calls` (models and - // the concerns beside them) never visits. Emitted verbatim - // until now, i.e. an undefined method: those actions raise - // in a real server, not only under test. - // - // HERE, in the typed loop, and not in `lower_action_body`: - // the model-side rewriter resolves its record from a - // `belongs_to` on the owning model, and a controller has no - // owner — what it has is the analyzer's `Ty::Class` stamp on - // `user` / `@room`, which does not exist until - // `type_method_body` has run. Placed before the arel pass - // for the same reason its neighbours are: the pass re-types - // afterwards, so the synthesized `Views::…` payload and - // `.id` reads carry their types downstream. - method.body = self::broadcasts::rewrite_broadcast_to( - &method.body, + rewritten |= self::broadcasts::rewrite_broadcast_to_in_place( + &mut method.body, views_module_name(controller).as_deref(), &partials, ); - crate::lower::typing::type_method_body(method, &classes, &framework_ivars); - // Arel pass — when schema is provided, lift recognized - // AR call chains into inline SELECT/hydrate over the Db - // primitive surface. Re-type after so the body-typer's - // earlier annotations on the rewritten subtree refresh. - // A method whose RESULT this controller refines with a - // relation method keeps its body on the Relation path. - // Lifting a chain to a materializing hydrate loop is a - // decision the CONSUMER licenses, and the consumer of a - // return value is in another body — see - // `relation_refined_method_names`, which is the same guard - // the pass already applies to a name refined within one - // body, asked one scope out. let refined_across_methods = refined_result_methods.contains(&method.name); if let Some(schema) = schema { if !refined_across_methods { - crate::lower::arel::rewrite_arel_in_expr_with_assocs( + rewritten |= crate::lower::arel::rewrite_arel_in_expr_with_assocs( &mut method.body, schema, &classes, assocs, ); - crate::lower::typing::type_method_body(method, &classes, &framework_ivars); } } + if rewritten { + crate::lower::typing::type_method_body(method, &classes, &framework_ivars); + } } methods.extend(collect_attr_accessor_methods(controller)); apply_alias_methods(controller, &mut methods); @@ -607,6 +586,7 @@ pub fn lower_controllers_with_arel_views_assocs_and_routes( // its own `app/models/_params.{rb,ts}` file via the // standard per-LC emit path. out.extend(params_lcs); + }); out } @@ -1041,6 +1021,8 @@ fn build_methods( let shadows = route_helper_shadows(controller, all_controllers); let (publics_all, privs) = split_public_private_actions(controller); + let publics_all: Vec = publics_all.into_iter().cloned().collect(); + let privs: Vec = privs.into_iter().cloned().collect(); // Params helpers resolve through Ruby's MRO: `Rooms::OpensController` // writes `@room.update! room_params`, and `room_params` is defined on // `RoomsController`. The helper→spec map behind @@ -1079,7 +1061,7 @@ fn build_methods( let mut out = privs.clone(); for c in chain.iter().rev() { let (pubs, ancestor_privs) = split_public_private_actions(c); - for a in ancestor_privs.iter().chain(pubs.iter()) { + for a in ancestor_privs.into_iter().chain(pubs) { if !a.name.as_str().ends_with("_params") { continue; } @@ -1117,12 +1099,14 @@ fn build_methods( // filter falls through to the preamble, which emits them in // declaration order. let inlining_ordered = own_filter_inlining_is_ordered(controller, &privs); - let resolve_own = |name: &Symbol| { - privs + let resolve_own = |name: &Symbol, visit: &mut dyn FnMut(&Expr)| { + if let Some(a) = privs .iter() .chain(publics.iter()) .find(|a| &a.name == name) - .map(|a| a.body.clone()) + { + visit(&a.body); + } }; let publics_inlined: Vec = publics .iter() @@ -1422,7 +1406,7 @@ fn inline_before_filters( action: &Action, filters: &[&Filter], privs: &[Action], - resolve: &dyn Fn(&Symbol) -> Option, + resolve: &dyn Fn(&Symbol, &mut dyn FnMut(&Expr)), ) -> Action { let action_name = &action.name; let mut prepended: Vec = Vec::new(); @@ -1517,13 +1501,12 @@ fn build_filter_preamble( // Resolve a filter target's body — self first, then nearest ancestor // (Ruby method resolution order) — so the halting check can be // scoped to filters that can actually render/redirect. - let find_target = |name: &Symbol| -> Option { + let find_target = |name: &Symbol| -> Option<&Action> { let mut scopes: Vec<&Controller> = vec![controller]; scopes.extend(chain.iter().rev().copied()); for c in scopes { - let (pubs, privs) = split_public_private_actions(c); - if let Some(a) = privs.iter().chain(pubs.iter()).find(|a| &a.name == name) { - return Some(a.clone()); + if let Some(a) = c.actions().find(|a| &a.name == name) { + return Some(a); } } None @@ -1585,7 +1568,11 @@ fn build_filter_preamble( // `require_authentication`) still halts the chain. halt_check: can_respond_within( &target.body, - &|name| find_target(name).map(|a| a.body), + &|name, visit| { + if let Some(a) = find_target(name) { + visit(&a.body); + } + }, &mut std::collections::BTreeSet::new(), ), }); @@ -1601,15 +1588,12 @@ fn build_filter_preamble( // bot_key?` sees who signed in); the default then yields to it. // ActionController::API does not include the module. // - // OFF, the `allow_browser` generator form's posture and for its - // reason: the default would put the call into every app, real-blog - // included, and real-blog is emitted for twelve targets of which - // only the ruby family defines `verify_authenticity_token` (the - // strict emitters compiled neither the method nor the dispatcher's - // call to it). An app that WRITES the macro — campfire — reaches - // only the ruby lanes and is protected; one that relies on the - // default is not, which the guide's security posture states. This - // is the switch when the strict runtimes carry the method. + // OFF: a bare `protect_from_forgery` is `:null_session` (lobsters) + // and is not modeled as 422. Implicit `:exception` would turn those + // requests into failures. Apps that write `with: :exception` + // (campfire) get the filter; `verify_authenticity_token` now lives + // on shared Base. Residual vs Rails: an app that relies on the + // implicit default is still CSRF-open until it writes the macro. const IMPLICIT_DEFAULT: bool = false; let root_parent = chain.first().copied().unwrap_or(controller).parent.as_ref(); let redeclared = chain.iter().copied().chain(std::iter::once(controller)).any(|c| { @@ -1884,13 +1868,11 @@ fn inherited_params_spec<'a>( helper: &Symbol, specs: &'a ParamsSpecs, ) -> Option<&'a ParamsSpec> { - let own: Vec = controller.actions().cloned().collect(); - if helper_spec_map(&own, specs).contains_key(helper) { + if helper_spec_map(controller.actions(), specs).contains_key(helper) { return None; } for ancestor in ancestor_chain(controller, all).iter().rev() { - let acts: Vec = ancestor.actions().cloned().collect(); - if let Some(spec) = helper_spec_map(&acts, specs).get(helper) { + if let Some(spec) = helper_spec_map(ancestor.actions(), specs).get(helper) { return Some(*spec); } } @@ -1993,7 +1975,7 @@ fn ancestor_chain<'a>(controller: &Controller, all: &'a [Controller]) -> Vec<&'a /// halting check to filters that need it — pure-assignment filters /// (and every blog controller) add no dispatch noise. fn can_respond(body: &Expr) -> bool { - can_respond_within(body, &|_| None, &mut std::collections::BTreeSet::new()) + can_respond_within(body, &|_, _| {}, &mut std::collections::BTreeSet::new()) } /// Whether this body can render/redirect/head — DIRECTLY, or through a @@ -2012,16 +1994,17 @@ fn can_respond(body: &Expr) -> bool { /// controller (or an ancestor) defines it; `seen` stops a cycle. Only /// receiverless sends are followed: a call on another object is that /// object's business, and Rails' halting is about THIS controller's -/// filter chain. +/// filter chain. Super-chain definitions are visited in MRO order by +/// calling `visit` once per body, matching a concatenated `Seq`. fn can_respond_within( body: &Expr, - resolve: &dyn Fn(&Symbol) -> Option, + resolve: &dyn Fn(&Symbol, &mut dyn FnMut(&Expr)), seen: &mut std::collections::BTreeSet, ) -> bool { fn walk( e: &Expr, found: &mut bool, - resolve: &dyn Fn(&Symbol) -> Option, + resolve: &dyn Fn(&Symbol, &mut dyn FnMut(&Expr)), seen: &mut std::collections::BTreeSet, ) { if *found { @@ -2041,11 +2024,13 @@ fn can_respond_within( Some(r) => matches!(&*r.node, ExprNode::SelfRef), }; if self_call && seen.insert(method.clone()) { - if let Some(callee) = resolve(method) { - walk(&callee, found, resolve, seen); - if *found { - return; + resolve(method, &mut |callee| { + if !*found { + walk(callee, found, resolve, seen); } + }); + if *found { + return; } } } @@ -2301,7 +2286,7 @@ fn rename_local(body: &Expr, from: &Symbol, to: &Symbol) -> Expr { /// the `private` marker. Filters and unknown class-body statements are /// dropped here — filters get re-synthesized into `process_action`, /// unknowns (e.g. `allow_browser`) carry no semantics in spinel. -fn split_public_private_actions(c: &Controller) -> (Vec, Vec) { +fn split_public_private_actions(c: &Controller) -> (Vec<&Action>, Vec<&Action>) { let mut pubs = Vec::new(); let mut privs = Vec::new(); let mut seen_private = false; @@ -2310,9 +2295,9 @@ fn split_public_private_actions(c: &Controller) -> (Vec, Vec) { ControllerBodyItem::PrivateMarker { .. } => seen_private = true, ControllerBodyItem::Action { action, .. } => { if seen_private { - privs.push(action.clone()); + privs.push(action); } else { - pubs.push(action.clone()); + pubs.push(action); } } _ => {} @@ -2410,17 +2395,14 @@ fn action_to_method( // definition that calls `super` brings the next one with it. let can_respond_via_helper = { let chain = ancestor_chain(controller, all_controllers_for_params); - let resolve = |name: &Symbol| -> Option { - let mut bodies = Vec::new(); + let resolve = |name: &Symbol, visit: &mut dyn FnMut(&Expr)| { for c in std::iter::once(controller).chain(chain.iter().rev().copied()) { let Some(m) = c.actions().find(|m| &m.name == name) else { continue }; - bodies.push(m.body.clone()); + visit(&m.body); if !calls_super(&m.body) { break; } } - (!bodies.is_empty()) - .then(|| Expr::new(Span::synthetic(), ExprNode::Seq { exprs: bodies })) }; can_respond_within(&a.body, &resolve, &mut std::collections::BTreeSet::new()) }; diff --git a/src/lower/controller_to_library/params.rs b/src/lower/controller_to_library/params.rs index 1fb3eae2c..5186b1d9e 100644 --- a/src/lower/controller_to_library/params.rs +++ b/src/lower/controller_to_library/params.rs @@ -839,10 +839,13 @@ fn build_params_object( with_ty(Expr::new(span, ExprNode::Seq { exprs: stmts }), owner_ty) } -pub fn helper_spec_map<'a>( - actions: &[crate::dialect::Action], +pub fn helper_spec_map<'a, 'b, I>( + actions: I, specs: &'a ParamsSpecs, -) -> BTreeMap { +) -> BTreeMap +where + I: IntoIterator, +{ let mut out = BTreeMap::new(); for a in actions { if !a.name.as_str().ends_with("_params") { @@ -2049,10 +2052,16 @@ fn build_from_raw_call(class_id: &ClassId, span: Span) -> Expr { /// `permitted[:title]`-shape call sites in test bodies / view /// bodies dispatch through the typed accessor. pub fn rewrite_typed_bracket_to_field(expr: &Expr, specs: &ParamsSpecs) -> Expr { + let permitted_fields = permitted_field_tys(specs); + crate::lower::controller_to_library::util::map_expr(expr, &|e| { + try_rewrite_typed_bracket(e, &permitted_fields) + }) +} + +pub(crate) fn permitted_field_tys( + specs: &ParamsSpecs, +) -> std::collections::HashMap> { use crate::ty::Ty; - // Build a quick `class_id -> permitted-fields-set` lookup so the - // walker can validate the literal key is one of the permitted - // fields before rewriting. let mut permitted_fields: std::collections::HashMap< ClassId, std::collections::HashMap, @@ -2064,43 +2073,54 @@ pub fn rewrite_typed_bracket_to_field(expr: &Expr, specs: &ParamsSpecs) -> Expr } permitted_fields.insert(spec.class_id.clone(), set); } + permitted_fields +} - map_expr(expr, &|e| { - let ExprNode::Send { recv: Some(recv), method, args, .. } = &*e.node else { - return None; - }; - if method.as_str() != "[]" || args.len() != 1 { - return None; - } - let recv_class_id = match recv.ty.as_ref() { - Some(Ty::Class { id, .. }) => id, - _ => return None, - }; - let fields = permitted_fields.get(recv_class_id)?; - let key = match &*args[0].node { - ExprNode::Lit { value: Literal::Sym { value } } => value.as_str().to_string(), - ExprNode::Lit { value: Literal::Str { value } } => value.clone(), - _ => return None, - }; - let slot_ty = fields.get(&key)?; - // Synthesize `recv.` — a zero-arg Send to the typed - // attr_reader. Carries the receiver's type forward and drops - // the bracket-key arg. - Some(Expr { - span: e.span, - node: Box::new(ExprNode::Send { - recv: Some(recv.clone()), - method: Symbol::from(key), - args: Vec::new(), - block: None, - parenthesized: false, - }), - ty: Some(slot_ty.clone()), - effects: e.effects.clone(), - leading_blank_line: e.leading_blank_line, - diagnostic: None, - hint: None, - decisions: 0, - }) +pub(crate) fn rewrite_typed_bracket_to_field_in_place( + expr: &mut Expr, + permitted_fields: &std::collections::HashMap>, +) -> bool { + crate::lower::controller_to_library::util::map_expr_mut(expr, &|e| { + try_rewrite_typed_bracket(e, permitted_fields) + }) +} + +fn try_rewrite_typed_bracket( + e: &Expr, + permitted_fields: &std::collections::HashMap>, +) -> Option { + use crate::ty::Ty; + let ExprNode::Send { recv: Some(recv), method, args, .. } = &*e.node else { + return None; + }; + if method.as_str() != "[]" || args.len() != 1 { + return None; + } + let recv_class_id = match recv.ty.as_ref() { + Some(Ty::Class { id, .. }) => id, + _ => return None, + }; + let fields = permitted_fields.get(recv_class_id)?; + let key = match &*args[0].node { + ExprNode::Lit { value: Literal::Sym { value } } => value.as_str().to_string(), + ExprNode::Lit { value: Literal::Str { value } } => value.clone(), + _ => return None, + }; + let slot_ty = fields.get(&key)?; + Some(Expr { + span: e.span, + node: Box::new(ExprNode::Send { + recv: Some(recv.clone()), + method: Symbol::from(key), + args: Vec::new(), + block: None, + parenthesized: false, + }), + ty: Some(slot_ty.clone()), + effects: e.effects.clone(), + leading_blank_line: e.leading_blank_line, + diagnostic: None, + hint: None, + decisions: 0, }) } diff --git a/src/lower/controller_to_library/rewrites.rs b/src/lower/controller_to_library/rewrites.rs index 14cde764b..ec172d9a9 100644 --- a/src/lower/controller_to_library/rewrites.rs +++ b/src/lower/controller_to_library/rewrites.rs @@ -13,7 +13,7 @@ use crate::span::Span; use crate::ty::Ty; use super::params::{ParamsSpec, ParamsSpecs}; -use super::util::map_expr; +use super::util::{map_expr, map_expr_mut}; // --------------------------------------------------------------------------- // Render-template-as-Views-call rewrite. Spinel doesn't have Rails' @@ -2525,33 +2525,41 @@ fn params_option_value(arg: &Expr) -> Option<&Expr> { /// that types to a class is a record standing where its id belongs. /// Idempotent — a projected argument types `Integer`, not a class. pub fn project_route_helper_ids(expr: &Expr) -> Expr { - map_expr(expr, &|e| { - let ExprNode::Send { recv: Some(r), method, args, block, parenthesized } = &*e.node else { - return None; - }; - if !matches!(&*r.node, ExprNode::Const { path } - if path.len() == 1 && path[0].as_str() == "RouteHelpers") - { - return None; - } - if !(method.as_str().ends_with("_path") || method.as_str().ends_with("_url")) { - return None; - } - if !args.iter().any(arg_carries_a_model) && !args.iter().any(query_carries_an_int) { - return None; - } - let projected: Vec = args.iter().map(project_arg).collect(); - Some(Expr::new( - e.span, - ExprNode::Send { - recv: Some(r.clone()), - method: method.clone(), - args: projected, - block: block.clone(), - parenthesized: *parenthesized, - }, - )) - }) + map_expr(expr, &project_route_helper_ids_node) +} + +/// In-place twin. Returns whether any argument was projected so the +/// test lowerer can skip a follow-up typing pass. +pub fn project_route_helper_ids_in_place(expr: &mut Expr) -> bool { + map_expr_mut(expr, &project_route_helper_ids_node) +} + +fn project_route_helper_ids_node(e: &Expr) -> Option { + let ExprNode::Send { recv: Some(r), method, args, block, parenthesized } = &*e.node else { + return None; + }; + if !matches!(&*r.node, ExprNode::Const { path } + if path.len() == 1 && path[0].as_str() == "RouteHelpers") + { + return None; + } + if !(method.as_str().ends_with("_path") || method.as_str().ends_with("_url")) { + return None; + } + if !args.iter().any(arg_carries_a_model) && !args.iter().any(query_carries_an_int) { + return None; + } + let projected: Vec = args.iter().map(project_arg).collect(); + Some(Expr::new( + e.span, + ExprNode::Send { + recv: Some(r.clone()), + method: method.clone(), + args: projected, + block: block.clone(), + parenthesized: *parenthesized, + }, + )) } /// A route-helper argument with every model instance in it projected diff --git a/src/lower/controller_to_library/util.rs b/src/lower/controller_to_library/util.rs index 3d76ea1e2..f25e7d7fb 100644 --- a/src/lower/controller_to_library/util.rs +++ b/src/lower/controller_to_library/util.rs @@ -386,3 +386,22 @@ where decisions: expr.decisions, } } + +/// In-place twin of [`map_expr`]. Returns whether any node was replaced, +/// so callers can skip a follow-up typing pass when nothing changed. +pub fn map_expr_mut(expr: &mut Expr, f: &F) -> bool +where + F: Fn(&Expr) -> Option, +{ + if let Some(replacement) = f(expr) { + *expr = replacement; + return true; + } + let mut changed = false; + expr.node.for_each_child_mut(&mut |c| { + if map_expr_mut(c, f) { + changed = true; + } + }); + changed +} diff --git a/src/lower/csv_generate.rs b/src/lower/csv_generate.rs index 2b24b7e6c..60610f093 100644 --- a/src/lower/csv_generate.rs +++ b/src/lower/csv_generate.rs @@ -13,6 +13,10 @@ pub fn apply_csv_generate_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, diff --git a/src/lower/destroy_by.rs b/src/lower/destroy_by.rs index 69d8fcf9f..cbece9f1a 100644 --- a/src/lower/destroy_by.rs +++ b/src/lower/destroy_by.rs @@ -38,7 +38,10 @@ pub fn apply_destroy_by_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/dirty_predicate_kwargs.rs b/src/lower/dirty_predicate_kwargs.rs index ddd3b45ed..246f6f87a 100644 --- a/src/lower/dirty_predicate_kwargs.rs +++ b/src/lower/dirty_predicate_kwargs.rs @@ -59,6 +59,10 @@ fn column_of(method: &str) -> Option<&str> { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv, method, args, block: None, .. } = &*expr.node else { return }; if args.len() != 1 { return; diff --git a/src/lower/each_with_index.rs b/src/lower/each_with_index.rs index 088167f32..abe8498c5 100644 --- a/src/lower/each_with_index.rs +++ b/src/lower/each_with_index.rs @@ -31,7 +31,10 @@ pub fn apply_each_with_index_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let span = expr.span; let (collection, offset, mut block) = { let ExprNode::Send { diff --git a/src/lower/enum_symbols.rs b/src/lower/enum_symbols.rs index 3476d0c98..6e95eff07 100644 --- a/src/lower/enum_symbols.rs +++ b/src/lower/enum_symbols.rs @@ -42,7 +42,7 @@ const ATTR_METHODS: &[&str] = &[ "merge", ]; -type EnumMap = std::collections::HashMap>; +pub(crate) type EnumMap = std::collections::HashMap>; pub fn apply_enum_symbol_lowering(app: &mut App) { let map = enum_columns(app); @@ -71,7 +71,7 @@ pub fn apply_enum_symbol_lowering(app: &mut App) { /// the result: the rewrite is keyed by column name, so an inconsistent /// mapping has no single right answer and guessing one would write the /// wrong integer into a real query. -fn enum_columns(app: &App) -> EnumMap { +pub(crate) fn enum_columns(app: &App) -> EnumMap { let mut out: EnumMap = Default::default(); let mut conflicted: std::collections::HashSet = Default::default(); for model in &app.models { @@ -116,6 +116,10 @@ fn literal_eq(a: &Literal, b: &Literal) -> bool { fn rewrite(expr: &mut Expr, map: &EnumMap) { expr.node.for_each_child_mut(&mut |c| rewrite(c, map)); + rewrite_node(expr, map); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, map: &EnumMap) { let ExprNode::Send { method, args, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/enumerable_ext.rs b/src/lower/enumerable_ext.rs index 022e2c125..ef6733882 100644 --- a/src/lower/enumerable_ext.rs +++ b/src/lower/enumerable_ext.rs @@ -55,15 +55,26 @@ pub fn apply_enumerable_ext_grounding(app: &mut App) { /// `<<~HTML.squish` in a test is a String only after that pass, and /// this module's own walk runs before it. Sibling of /// `array_ordinal::rewrite_body`, called from the same place. -pub(crate) fn rewrite_body(expr: &mut Expr) { - rewrite(expr); +/// Returns whether any site was rewritten so the caller can skip a +/// follow-up type. +pub(crate) fn rewrite_body(expr: &mut Expr) -> bool { + let mut changed = false; + expr.node.for_each_child_mut(&mut |c| { + if rewrite_body(c) { + changed = true; + } + }); + rewrite_node(expr) || changed } fn rewrite(expr: &mut Expr) { - expr.node.for_each_child_mut(&mut rewrite); + let _ = rewrite_body(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) -> bool { let span = expr.span; let ExprNode::Send { recv, method, args, block, parenthesized } = &mut *expr.node else { - return; + return false; }; // `first(n)` / `last(n)` on the same Relation-or-Array union `many?` // meets below: campfire's direct-room sidebar goes on to @@ -88,21 +99,21 @@ fn rewrite(expr: &mut Expr) { ); to_a.ty = Some(array_ty); *recv = Some(to_a); + return true; } - return; + return false; } // `index_by` takes the block and `many?` refuses one — the bare // call is the form Rails' counter-and-`any?` body reduces to a // length test, and the block form counts MATCHES instead, which is // a different question no corpus app asks. if method.as_str() == "wrap" { - ground_array_wrap(expr); - return; + return ground_array_wrap(expr); } let wants_block = match method.as_str() { "index_by" => true, "many?" | "to_sentence" | "sole" | "squish" => false, - _ => return, + _ => return false, }; // `to_sentence` takes Rails' three connector options; the module // function takes them positionally, so a keyword Hash of those keys @@ -112,15 +123,15 @@ fn rewrite(expr: &mut Expr) { let connectors = match (method.as_str(), args.len()) { ("to_sentence", 0) => Some(SENTENCE_DEFAULTS.map(String::from)), ("to_sentence", 1) => { - let Some(c) = sentence_connectors(&args[0]) else { return }; + let Some(c) = sentence_connectors(&args[0]) else { return false }; Some(c) } _ => None, }; if (connectors.is_none() && !args.is_empty()) || block.is_some() != wants_block { - return; + return false; } - let Some(receiver) = recv.as_ref() else { return }; + let Some(receiver) = recv.as_ref() else { return false }; // A Relation-or-Array receiver — campfire's direct-room sidebar, // `members = room.users.without(user).presence || [user]`, then // `members.many?`. Neither grounding fits: the Relation half has a @@ -156,10 +167,10 @@ fn rewrite(expr: &mut Expr) { parenthesized: false, }; expr.ty = Some(Ty::Bool); - return; + return true; } if is_relation(receiver.ty.as_ref()) { - return; + return false; } // `many?` names an `Array` parameter, so only an Array receiver // goes. `index_by` keeps the wider gate it has always had (its @@ -170,14 +181,14 @@ fn rewrite(expr: &mut Expr) { && !matches!(receiver.ty.as_ref(), Some(Ty::Array { .. })) && !(method.as_str() == "to_sentence" && is_block_map(receiver)) { - return; + return false; } // `squish` names a `String` parameter, so only a String receiver // goes — and it is the only one of these whose name a model could // plausibly define itself, which is the second reason to gate on // the analyzer's answer rather than on the spelling. if method.as_str() == "squish" && !matches!(receiver.ty.as_ref(), Some(Ty::Str)) { - return; + return false; } let receiver = recv.take().expect("checked above"); *recv = Some(Expr::new( @@ -194,25 +205,26 @@ fn rewrite(expr: &mut Expr) { })); } *parenthesized = true; + true } /// `Array.wrap(value)` → `ActiveSupport.wrap(value)`. A receiverless /// `wrap`, or a wrap on something other than the Array class, stays. -fn ground_array_wrap(expr: &mut Expr) { +fn ground_array_wrap(expr: &mut Expr) -> bool { let span = expr.span; let ExprNode::Send { recv, method, args, block, parenthesized: _ } = &mut *expr.node else { - return; + return false; }; if method.as_str() != "wrap" || block.is_some() || args.len() != 1 { - return; + return false; } - let Some(receiver) = recv.as_ref() else { return }; - let ExprNode::Const { path } = &*receiver.node else { return }; + let Some(receiver) = recv.as_ref() else { return false }; + let ExprNode::Const { path } = &*receiver.node else { return false }; // `Reports::Array.wrap` is not ActiveSupport's method. Only the // top-level constant, written `Array` or `::Array`, is. let names: Vec<&str> = path.iter().map(|name| name.as_str()).collect(); if names != ["Array"] && names != ["::Array"] { - return; + return false; } // Folded here, not hosted as `ActiveSupport.wrap`. That method // reads an untyped parameter, and each read counts against the @@ -224,9 +236,10 @@ fn ground_array_wrap(expr: &mut Expr) { // `to_ary` is dropped, which would wrap the object instead of its // records. let arg = args[0].clone(); - let Some(folded) = fold_array_wrap(span, &arg) else { return }; + let Some(folded) = fold_array_wrap(span, &arg) else { return false }; expr.ty = folded.ty.clone(); *expr.node = *folded.node; + true } fn empty_array(span: crate::span::Span) -> Expr { diff --git a/src/lower/errors_full_messages.rs b/src/lower/errors_full_messages.rs index 691e4cf59..fb3b12a62 100644 --- a/src/lower/errors_full_messages.rs +++ b/src/lower/errors_full_messages.rs @@ -51,7 +51,7 @@ pub fn apply_errors_full_messages_lowering(app: &mut App) { /// Does any app class define its own `full_messages`? Then the name /// doesn't mean Rails' and the pass stands down, rather than folding /// away a real call. Mirrors `exclude_predicate`'s guard. -fn app_defines_full_messages(app: &App) -> bool { +pub(crate) fn app_defines_full_messages(app: &App) -> bool { let is_fm = |n: &Symbol| n.as_str() == "full_messages"; app.models.iter().any(|m| { m.body.iter().any(|item| { @@ -66,6 +66,10 @@ fn app_defines_full_messages(app: &App) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // `.errors.full_messages` — zero-arg, block-free on both hops. // The receiver being an `errors` reader is what makes this our // accumulator rather than some other object's method of the same diff --git a/src/lower/exclude_predicate.rs b/src/lower/exclude_predicate.rs index 8141ae751..fc4183dac 100644 --- a/src/lower/exclude_predicate.rs +++ b/src/lower/exclude_predicate.rs @@ -44,7 +44,7 @@ pub fn apply_exclude_predicate_lowering(app: &mut App) { /// Does any app class define its own `exclude?`? Then the name doesn't /// mean ActiveSupport's and the pass stands down. -fn app_defines_exclude(app: &App) -> bool { +pub(crate) fn app_defines_exclude(app: &App) -> bool { let is_exclude = |n: &Symbol| n.as_str() == "exclude?"; app.models.iter().any(|m| { m.body.iter().any(|item| { @@ -59,6 +59,10 @@ fn app_defines_exclude(app: &App) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(_), method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/exists_conditions.rs b/src/lower/exists_conditions.rs index dda893888..8e24400e2 100644 --- a/src/lower/exists_conditions.rs +++ b/src/lower/exists_conditions.rs @@ -42,7 +42,10 @@ pub fn apply_exists_conditions_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/fused.rs b/src/lower/fused.rs new file mode 100644 index 000000000..07e042c20 --- /dev/null +++ b/src/lower/fused.rs @@ -0,0 +1,536 @@ +//! One post-order walk for independent post-analyze send rewrites. +//! +//! Each of these passes used to call [`super::for_each_hook_body`] (and +//! usually walk views too) on its own. The rewrites do not consume each +//! other's output — they match disjoint method names — so applying them +//! at every node in one walk is equivalent to N sequential walks and +//! drops the quadratic tree traffic on large apps. +//! +//! Surfaces stay the same as the original per-pass walks: every rewrite +//! runs on hook bodies; view/test coverage matches the pass it came from. +//! Most fused rewrites match disjoint method names. The mid-group +//! `route_format_suffix` wrap (`call + ".ext"`) is the exception: +//! position/host follow-ups still run on that inner helper. +//! +//! Six walks, split by ordering constraints: the early group runs +//! before any pass that mutates argument lists; the context group sits +//! after mocha (`try_guard` last, so minted method names are not fed +//! back into this walk); the pre-tag group sits after the STI +//! predecessor cluster and before `tag_builder`; the narrow and mid +//! groups sit after `tag_builder` and before `kwsplat`; the late group +//! runs after `kwsplat` (and after `tag_builder`, which `capture_inline` +//! depends on). + +use crate::app::App; +use crate::expr::{Expr, ExprNode, InterpPart, Literal}; +use crate::ident::Symbol; +use std::collections::{BTreeSet, HashSet}; + +pub fn apply_fused_independent_rewrites(app: &mut App) { + let skip_exclude = super::exclude_predicate::app_defines_exclude(app); + let skip_in = super::in_predicate::app_defines_in(app); + let skip_including = super::including::app_defines_including(app); + let skip_full_messages = super::errors_full_messages::app_defines_full_messages(app); + + super::for_each_hook_body(app, &mut |body| { + walk_postorder(body, &mut |e| { + rewrite_hook_node(e, skip_exclude, skip_in, skip_including, skip_full_messages); + }); + }); + for view in &mut app.views { + walk_postorder(&mut view.body, &mut |e| { + rewrite_view_node(e, skip_exclude, skip_in, skip_including, skip_full_messages); + }); + } + super::for_each_test_body(app, &mut |body| { + walk_postorder(body, &mut |e| rewrite_test_node(e, skip_full_messages)); + }); +} + +/// Independent rewrites that must observe ingested (or already +/// `kwsplat`-expanded) argument lists, and `capture_inline` which +/// must see `tag_builder`'s synthesized `capture` blocks. +pub fn apply_fused_late_rewrites(app: &mut App) { + super::for_each_hook_body(app, &mut |body| { + walk_postorder(body, &mut rewrite_late_hook_node); + }); + for view in &mut app.views { + walk_postorder(&mut view.body, &mut super::attachables_grep::rewrite_node); + } + super::for_each_test_body(app, &mut |body| { + walk_postorder(body, &mut super::attachables_grep::rewrite_node); + }); +} + +/// Context-heavy independent send rewrites that still do a full tree +/// walk each: collect per-pass tables once, then one hook walk, one +/// test walk, and one view walk matching the original surfaces. +/// `try_guard` is last: it mints arbitrary method names from a literal +/// symbol, so a later fused rewrite that keyed on those names would see +/// new children the sequential order never walked. `attribute_aliases` +/// still runs after this walk for that reason. +pub fn apply_fused_context_rewrites(app: &mut App) { + let formats = std::mem::take(&mut app.time_formats); + let mut gid_models: BTreeSet = BTreeSet::new(); + let materialized = super::assoc_pluck::materialized_assoc_names(app); + let try_definers = super::try_guard::collect_definers(app); + let try_parents = super::try_guard::collect_parents(app); + + super::for_each_hook_body(app, &mut |body| { + walk_postorder(body, &mut |e| { + super::time_current::rewrite_node(e, &formats); + super::global_id_locate::rewrite_node(e, &mut gid_models); + super::assoc_pluck::rewrite_node(e, &materialized); + super::try_guard::rewrite_node(e, &try_definers, &try_parents); + }); + }); + super::for_each_test_body(app, &mut |body| { + walk_postorder(body, &mut |e| { + super::time_current::rewrite_node(e, &formats); + super::webmock::rewrite_node(e); + super::try_guard::rewrite_node(e, &try_definers, &try_parents); + }); + }); + // `try_guard` also rewrites test constants and inner-class methods, + // which `for_each_test_body` does not reach. The other context + // rewrites never walked those surfaces. + for tm in &mut app.test_modules { + for (_, value) in &mut tm.constants { + walk_postorder(value, &mut |e| { + super::try_guard::rewrite_node(e, &try_definers, &try_parents); + }); + } + for ic in &mut tm.inner_classes { + for m in &mut ic.methods { + walk_postorder(&mut m.body, &mut |e| { + super::try_guard::rewrite_node(e, &try_definers, &try_parents); + }); + } + } + } + for view in &mut app.views { + walk_postorder(&mut view.body, &mut |e| { + super::global_id_locate::rewrite_node(e, &mut gid_models); + super::try_guard::rewrite_node(e, &try_definers, &try_parents); + }); + } + + app.time_formats = formats; + app.global_id_locate_models.extend(gid_models); +} + +/// Independent rewrites after the STI predecessor cluster and before +/// `tag_builder`. Surfaces match the original per-pass walks: +/// `attribute_aliases` starts with an in-model walk then hooks/tests/views; +/// `sti_is_a` is hooks+views; job/cookie tests are tests only; +/// `controller_class_render` is hooks plus per-test attachment locals; +/// `sum_symbol` is model methods/unknown last so its new `to_a`/`sum` +/// children are not fed to the other fused rewrites. +pub fn apply_fused_pre_tag_rewrites(app: &mut App) { + let models: HashSet = app + .models + .iter() + .map(|m| m.name.0.as_str().to_string()) + .collect(); + let sti_names = super::sti_is_a::subclass_type_names(app); + let contracts = super::controller_class_render::call_contracts(app); + let none = HashSet::new(); + + for model in &mut app.models { + for item in &mut model.body { + use crate::dialect::{Association, ModelBodyItem}; + match item { + ModelBodyItem::Method { method, .. } => { + walk_postorder(&mut method.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, true); + }); + } + ModelBodyItem::Scope { scope, .. } => { + walk_postorder(&mut scope.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, true); + }); + } + ModelBodyItem::Callback { callback, .. } => { + if let Some(cond) = &mut callback.condition { + walk_postorder(cond, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, true); + }); + } + } + ModelBodyItem::Unknown { expr, .. } => { + walk_postorder(expr, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, true); + }); + } + ModelBodyItem::Association { + assoc: Association::HasMany { extension, .. }, + .. + } => { + for m in extension.iter_mut() { + walk_postorder(&mut m.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, true); + }); + } + } + _ => {} + } + } + } + + super::for_each_hook_body(app, &mut |body| { + walk_postorder(body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, false); + super::sti_is_a::rewrite_node(e, &sti_names); + super::controller_class_render::rewrite_node(e, &contracts, &none); + }); + }); + for view in &mut app.views { + walk_postorder(&mut view.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, false); + super::sti_is_a::rewrite_node(e, &sti_names); + }); + } + for tm in &mut app.test_modules { + let builders = super::controller_class_render::attachment_builders(&tm.helpers); + if let Some(setup) = &mut tm.setup { + let locals = super::controller_class_render::attachment_locals(setup, &builders); + walk_postorder(setup, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, false); + super::job_test_only::rewrite_node(e); + super::test_cookie_jar::rewrite_node(e); + super::controller_class_render::rewrite_node(e, &contracts, &locals); + }); + } + for t in &mut tm.tests { + let locals = super::controller_class_render::attachment_locals(&t.body, &builders); + walk_postorder(&mut t.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, false); + super::job_test_only::rewrite_node(e); + super::test_cookie_jar::rewrite_node(e); + super::controller_class_render::rewrite_node(e, &contracts, &locals); + }); + } + for h in &mut tm.helpers { + let locals = super::controller_class_render::attachment_locals(&h.body, &builders); + walk_postorder(&mut h.body, &mut |e| { + super::attribute_aliases::rewrite_node(e, &models, false); + super::job_test_only::rewrite_node(e); + super::test_cookie_jar::rewrite_node(e); + super::controller_class_render::rewrite_node(e, &contracts, &locals); + }); + } + } + + for model in &mut app.models { + for item in &mut model.body { + match item { + crate::dialect::ModelBodyItem::Method { method, .. } => { + walk_postorder(&mut method.body, &mut super::sum_symbol::rewrite_node); + } + crate::dialect::ModelBodyItem::Unknown { expr, .. } => { + walk_postorder(expr, &mut super::sum_symbol::rewrite_node); + } + _ => {} + } + } + } +} + +/// Controller/library/model-only independent rewrites. Surfaces match +/// the original per-pass walks: `request_index` is controllers only; +/// `session_options`/`status_literal` add library methods; +/// `to_param_residue` adds model methods. Not folded into the mid hook +/// walk, which would rewrite `request[]` in helpers and views. +pub fn apply_fused_narrow_rewrites(app: &mut App) { + use crate::dialect::{ControllerBodyItem, ModelBodyItem}; + for controller in &mut app.controllers { + for item in &mut controller.body { + match item { + ControllerBodyItem::Action { action, .. } => { + for (_name, default) in &mut action.opt_params { + walk_postorder(default, &mut |e| { + super::request_index::rewrite_node(e); + super::session_options::rewrite_node(e); + }); + } + walk_postorder(&mut action.body, &mut rewrite_narrow_controller_node); + } + ControllerBodyItem::Unknown { expr, .. } => { + walk_postorder(expr, &mut rewrite_narrow_controller_node); + } + _ => {} + } + } + } + for class in &mut app.library_classes { + for m in &mut class.methods { + walk_postorder(&mut m.body, &mut |e| { + super::session_options::rewrite_node(e); + super::status_literal::rewrite_node(e); + super::to_param_residue::rewrite_node(e); + }); + } + } + for model in &mut app.models { + for item in &mut model.body { + if let ModelBodyItem::Method { method, .. } = item { + walk_postorder(&mut method.body, &mut super::to_param_residue::rewrite_node); + } + } + } +} + +fn rewrite_narrow_controller_node(e: &mut Expr) { + super::request_index::rewrite_node(e); + super::session_options::rewrite_node(e); + super::status_literal::rewrite_node(e); + super::to_param_residue::rewrite_node(e); +} + +/// Independent rewrites after `tag_builder` and before `kwsplat`: route +/// helpers, enum symbols, `has_json` flatten, and assoc `loaded?`/ +/// `.target`. Collect tables once; one owned-hook walk, one view walk, +/// one test walk. Tests skip `position_path_params`, matching the +/// original `route_url_options` surface. +pub fn apply_fused_mid_rewrites(app: &mut App) { + let helpers = super::route_format_suffix::route_helper_names(app); + let path_params = if helpers.is_empty() { + Default::default() + } else { + super::route_url_options::helper_path_params(app) + }; + let enum_map = super::enum_symbols::enum_columns(app); + let json_map = super::has_json::has_json_columns(&app.models); + let by_model = super::assoc_loaded::has_many_by_model(app); + let readers = super::assoc_loaded::association_readers_by_model(app); + let sole_includer = app.sole_includer_of_modules(); + + super::for_each_owned_hook_body(app, &mut |owner, body| { + walk_postorder(body, &mut |e| { + rewrite_mid_node( + e, + owner, + &helpers, + &path_params, + true, + &enum_map, + &json_map, + &sole_includer, + &by_model, + &readers, + ); + }); + }); + for view in &mut app.views { + walk_postorder(&mut view.body, &mut |e| { + rewrite_mid_node( + e, + None, + &helpers, + &path_params, + true, + &enum_map, + &json_map, + &sole_includer, + &by_model, + &readers, + ); + }); + } + super::for_each_test_body(app, &mut |body| { + walk_postorder(body, &mut |e| { + rewrite_mid_node( + e, + None, + &helpers, + &path_params, + false, + &enum_map, + &json_map, + &sole_includer, + &by_model, + &readers, + ); + }); + }); +} + +fn rewrite_mid_node( + e: &mut Expr, + enclosing: Option<&crate::ident::ClassId>, + helpers: &std::collections::HashSet, + path_params: &std::collections::HashMap>, + position_path: bool, + enum_map: &super::enum_symbols::EnumMap, + json_map: &super::has_json::HasJsonColumns, + sole_includer: &std::collections::HashMap, + by_model: &std::collections::HashMap>, + readers: &std::collections::HashMap>, +) { + if !helpers.is_empty() { + super::route_format_suffix::rewrite_node(e, helpers); + // Format suffix can wrap a helper as ` + ".ext"`. Sequential + // walks still visit that inner `recv: None` send; a fused callback + // would otherwise only see `+` and skip position/host rewrites. + if is_format_suffix_wrap(e, helpers) { + if let ExprNode::Send { recv: Some(inner), .. } = &mut *e.node { + apply_route_url_followups(inner, position_path, path_params, helpers); + } + } else { + apply_route_url_followups(e, position_path, path_params, helpers); + } + } + if !enum_map.is_empty() { + super::enum_symbols::rewrite_node(e, enum_map); + } + if !json_map.is_empty() { + super::has_json::rewrite_node(e, json_map); + } + super::assoc_loaded::rewrite_node(e, enclosing, sole_includer, by_model, readers); +} + +fn apply_route_url_followups( + target: &mut Expr, + position_path: bool, + path_params: &std::collections::HashMap>, + helpers: &std::collections::HashSet, +) { + if position_path { + super::route_url_options::rewrite_position_node(target, path_params); + } + super::route_url_options::rewrite_node(target, helpers); +} + +/// True when `route_format_suffix` just replaced a helper with concat. +fn is_format_suffix_wrap(e: &Expr, helpers: &HashSet) -> bool { + let ExprNode::Send { recv: Some(r), method, args, .. } = &*e.node else { + return false; + }; + if method.as_str() != "+" || args.len() != 1 { + return false; + } + let looks_like_ext = match &*args[0].node { + ExprNode::Lit { value: Literal::Str { value } } => value.starts_with('.'), + ExprNode::StringInterp { parts } => { + matches!(parts.first(), Some(InterpPart::Text { value }) if value == ".") + } + _ => false, + }; + looks_like_ext + && matches!( + &*r.node, + ExprNode::Send { recv: None, method: m, .. } if helpers.contains(m.as_str()) + ) +} + +fn rewrite_hook_node( + e: &mut Expr, + skip_exclude: bool, + skip_in: bool, + skip_including: bool, + skip_full_messages: bool, +) { + super::pathname_ctor::rewrite_node(e); + super::array_ordinal::rewrite_node(e); + super::save_without_validation::rewrite_node(e); + super::random_formatter::rewrite_node(e); + super::number_to_fs::rewrite_node(e); + super::string_inflections::rewrite_node(e); + super::to_json::rewrite_node(e); + super::csv_generate::rewrite_node(e); + super::presence_in::rewrite_node(e); + super::enumerable_ext::rewrite_node(e); + super::boolean_cast::rewrite_node(e); + super::values_at_splat::rewrite_node(e); + if !skip_exclude { + super::exclude_predicate::rewrite_node(e); + } + if !skip_in { + super::in_predicate::rewrite_node(e); + } + if !skip_including { + super::including::rewrite_node(e); + } + super::exists_conditions::rewrite_node(e); + super::destroy_by::rewrite_node(e); + super::literal_append::rewrite_node(e); + super::byte_size::rewrite_node(e); + super::dirty_predicate_kwargs::rewrite_node(e); + super::relation_select_block::rewrite_node(e); + super::arel_attribute::rewrite_node(e); + super::attr_or_assign::rewrite_node(e); + super::group_count::rewrite_node(e); + if !skip_full_messages { + super::errors_full_messages::rewrite_node(e); + } + super::each_with_index::rewrite_node(e); + // Last: produces `ActiveSupport.*` / Range nodes no earlier fused + // rewrite keys on. `where_range_split` still runs sequentially + // afterwards and walks those Ranges. + super::time_calendar::rewrite_node(e); +} + +fn rewrite_view_node( + e: &mut Expr, + skip_exclude: bool, + skip_in: bool, + skip_including: bool, + skip_full_messages: bool, +) { + super::pathname_ctor::rewrite_node(e); + super::array_ordinal::rewrite_node(e); + super::random_formatter::rewrite_node(e); + super::number_to_fs::rewrite_node(e); + super::string_inflections::rewrite_node(e); + super::to_json::rewrite_node(e); + super::csv_generate::rewrite_node(e); + super::presence_in::rewrite_node(e); + super::enumerable_ext::rewrite_node(e); + super::boolean_cast::rewrite_node(e); + if !skip_exclude { + super::exclude_predicate::rewrite_node(e); + } + if !skip_in { + super::in_predicate::rewrite_node(e); + } + if !skip_including { + super::including::rewrite_node(e); + } + super::exists_conditions::rewrite_node(e); + super::destroy_by::rewrite_node(e); + super::literal_append::rewrite_node(e); + super::dirty_predicate_kwargs::rewrite_node(e); + super::relation_select_block::rewrite_node(e); + super::arel_attribute::rewrite_node(e); + if !skip_full_messages { + super::errors_full_messages::rewrite_node(e); + } + super::each_with_index::rewrite_node(e); + super::time_calendar::rewrite_node(e); +} + +fn rewrite_test_node(e: &mut Expr, skip_full_messages: bool) { + super::save_without_validation::rewrite_node(e); + super::enumerable_ext::rewrite_node(e); + super::byte_size::rewrite_node(e); + super::dirty_predicate_kwargs::rewrite_node(e); + if !skip_full_messages { + super::errors_full_messages::rewrite_node(e); + } +} + +fn rewrite_late_hook_node(e: &mut Expr) { + super::rails_cache::rewrite_node(e); + super::capture_inline::rewrite_node(e); + super::and_return::rewrite_node(e); + super::case_lambda::rewrite_node(e); + super::system_exception::rewrite_node(e); + super::perform_all_later::rewrite_node(e); + super::attachables_grep::rewrite_node(e); + super::send_file::rewrite_node(e); +} + +fn walk_postorder(expr: &mut Expr, f: &mut impl FnMut(&mut Expr)) { + expr.node.for_each_child_mut(&mut |c| walk_postorder(c, f)); + f(expr); +} diff --git a/src/lower/global_id_locate.rs b/src/lower/global_id_locate.rs index 66b8cef26..ce1361833 100644 --- a/src/lower/global_id_locate.rs +++ b/src/lower/global_id_locate.rs @@ -61,7 +61,10 @@ pub fn apply_global_id_locate_lowering(app: &mut App) { fn rewrite(expr: &mut Expr, models: &mut BTreeSet) { expr.node.for_each_child_mut(&mut |child| rewrite(child, models)); + rewrite_node(expr, models); +} +pub(crate) fn rewrite_node(expr: &mut Expr, models: &mut BTreeSet) { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/group_count.rs b/src/lower/group_count.rs index 3a54fefde..05025c559 100644 --- a/src/lower/group_count.rs +++ b/src/lower/group_count.rs @@ -65,6 +65,10 @@ pub fn grouped_count_parts(expr: &Expr) -> Option> { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // The SOURCE spelling only — `grouped_count_parts` accepts both, so // a second pass over an already-renamed tree is a no-op rather than // a rename of a rename. diff --git a/src/lower/has_json.rs b/src/lower/has_json.rs index 407527b1c..5f409d2de 100644 --- a/src/lower/has_json.rs +++ b/src/lower/has_json.rs @@ -315,6 +315,10 @@ pub fn apply_has_json_lowering( fn rewrite(expr: &mut Expr, map: &HasJsonColumns) { expr.node.for_each_child_mut(&mut |c| rewrite(c, map)); + rewrite_node(expr, map); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, map: &HasJsonColumns) { match &mut *expr.node { // `x.settings.foo`, `x.settings.foo?`, `x.settings.foo = v` — // a plain attribute write ingests as a `foo=` send, so all @@ -335,7 +339,7 @@ fn rewrite(expr: &mut Expr, map: &HasJsonColumns) { } } -type HasJsonColumns = HashMap>; +pub(crate) type HasJsonColumns = HashMap>; /// When `recv` is the `.` hop of a schema key named by /// `method`, collapse `recv` to `` and answer with the flat diff --git a/src/lower/has_one_builder.rs b/src/lower/has_one_builder.rs index 2591f56a2..2c0bac8c6 100644 --- a/src/lower/has_one_builder.rs +++ b/src/lower/has_one_builder.rs @@ -46,8 +46,8 @@ use crate::span::Span; use crate::ty::Ty; use std::collections::HashMap; -/// `has_one` declarations by owner: assoc name → (target, foreign key). -type Builders = HashMap>; +/// `has_one` declarations by owner: assoc name → (target, foreign key, as:). +type Builders = HashMap)>>; pub fn apply_has_one_builder_lowering(app: &mut App) { let mut table: Builders = HashMap::new(); @@ -56,13 +56,16 @@ pub fn apply_has_one_builder_lowering(app: &mut App) { // Polymorphic `has_one ..., as:` needs the interface TYPE // column set too. Left out rather than half-done: writing // only the id column would build a row no reader can find. - if let Association::HasOne { name, target, foreign_key, as_interface: None, .. } = + if let Association::HasOne { name, target, foreign_key, as_interface, .. } = assoc { table .entry(model.name.clone()) .or_default() - .insert(name.clone(), (target.clone(), foreign_key.clone())); + .insert( + name.clone(), + (target.clone(), foreign_key.clone(), as_interface.clone()), + ); } } } @@ -157,7 +160,7 @@ fn rewrite(expr: &mut Expr, table: &Builders, self_owner: Option<&ClassId>) { } else { return; }; - let Some((target, foreign_key)) = by_name.get(&Symbol::from(assoc)) else { return }; + let Some((target, foreign_key, as_interface)) = by_name.get(&Symbol::from(assoc)) else { return }; // Rails allows the no-argument form (`user.create_webhook!`); the // foreign key alone is still a complete row to attempt. @@ -165,6 +168,12 @@ fn rewrite(expr: &mut Expr, table: &Builders, self_owner: Option<&ClassId>) { crate::lower::typing::lit_sym(foreign_key.clone()), owner_id(recv.as_ref()), )]; + if let Some(intf) = as_interface { + entries.push(( + crate::lower::typing::lit_sym(Symbol::from(format!("{}_type", intf.as_str()))), + crate::lower::typing::lit_str(owner.0.as_str().to_string()), + )); + } match args.len() { 0 => {} 1 => { diff --git a/src/lower/helper_kwargs.rs b/src/lower/helper_kwargs.rs index e71f59d31..d6eb2a3d7 100644 --- a/src/lower/helper_kwargs.rs +++ b/src/lower/helper_kwargs.rs @@ -61,8 +61,16 @@ pub fn apply_helper_kwarg_positional_lowering(app: &mut App) { // producers rejoin this pass's established positional normalization. let _ = super::forwarding::apply(app); apply_to_test_modules(app); - apply_to_library_class_calls(app); - apply_to_instance_calls(app); + let class_params = library_class_call_params(app); + let instance_params = instance_call_params(app); + if !class_params.is_empty() || !instance_params.is_empty() { + super::for_each_hook_body(app, &mut |e| { + rewrite_class_and_instance(e, &class_params, &instance_params); + }); + for view in &mut app.views { + rewrite_class_and_instance(&mut view.body, &class_params, &instance_params); + } + } super::kwsplat::restore_kwrest_in_test_helpers(app); let params = helper_param_names(app); if params.is_empty() { @@ -86,7 +94,7 @@ pub fn apply_helper_kwarg_positional_lowering(app: &mut App) { /// `from_keyword` may be named. A class method's genuine optional /// positional (`def f(x, opts = {})`) called with `f(x, opts: 1)` is /// handed the Hash `{opts: 1}` by Ruby, and must keep it. -fn apply_to_library_class_calls(app: &mut App) { +fn library_class_call_params(app: &App) -> HashMap<(String, Symbol), Vec> { let mut params: HashMap<(String, Symbol), Vec> = HashMap::new(); for lc in &app.library_classes { for m in &lc.methods { @@ -108,18 +116,25 @@ fn apply_to_library_class_calls(app: &mut App) { ); } } + params +} + +fn rewrite_class_and_instance( + e: &mut Expr, + class_params: &HashMap<(String, Symbol), Vec>, + instance_params: &HashMap<(String, Symbol), Vec>, +) { + e.node.for_each_child_mut(&mut |c| { + rewrite_class_and_instance(c, class_params, instance_params) + }); + rewrite_class_calls_node(e, class_params); + rewrite_instance_calls_node(e, instance_params); +} + +fn rewrite_class_calls_node(e: &mut Expr, params: &HashMap<(String, Symbol), Vec>) { if params.is_empty() { return; } - let mut rewrite = |e: &mut Expr| rewrite_class_calls(e, ¶ms); - super::for_each_hook_body(app, &mut rewrite); - for view in &mut app.views { - rewrite_class_calls(&mut view.body, ¶ms); - } -} - -fn rewrite_class_calls(e: &mut Expr, params: &HashMap<(String, Symbol), Vec>) { - e.node.for_each_child_mut(&mut |c| rewrite_class_calls(c, params)); let ExprNode::Send { recv: Some(recv), method, args, .. } = &mut *e.node else { return }; let ExprNode::Const { path } = &*recv.node else { return }; let class = path.iter().map(|s| s.as_str()).collect::>().join("::"); @@ -139,7 +154,7 @@ fn rewrite_class_calls(e: &mut Expr, params: &HashMap<(String, Symbol), Vec HashMap<(String, Symbol), Vec> { let mut params: HashMap<(String, Symbol), Vec> = HashMap::new(); for lc in &app.library_classes { for m in &lc.methods { @@ -161,18 +176,13 @@ fn apply_to_instance_calls(app: &mut App) { ); } } + params +} + +fn rewrite_instance_calls_node(e: &mut Expr, params: &HashMap<(String, Symbol), Vec>) { if params.is_empty() { return; } - let mut rewrite = |e: &mut Expr| rewrite_instance_calls(e, ¶ms); - super::for_each_hook_body(app, &mut rewrite); - for view in &mut app.views { - rewrite_instance_calls(&mut view.body, ¶ms); - } -} - -fn rewrite_instance_calls(e: &mut Expr, params: &HashMap<(String, Symbol), Vec>) { - e.node.for_each_child_mut(&mut |c| rewrite_instance_calls(c, params)); let ExprNode::Send { recv: Some(recv), method, args, .. } = &mut *e.node else { return }; let Some(crate::ty::Ty::Class { id, .. }) = &recv.ty else { return }; let Some(slots) = params.get(&(id.0.as_str().to_string(), method.clone())) else { return }; diff --git a/src/lower/in_predicate.rs b/src/lower/in_predicate.rs index 2c9eac993..3e3725242 100644 --- a/src/lower/in_predicate.rs +++ b/src/lower/in_predicate.rs @@ -41,7 +41,7 @@ pub fn apply_in_predicate_lowering(app: &mut App) { /// Does any app class define its own `in?`? Then the name doesn't mean /// ActiveSupport's and the pass stands down. -fn app_defines_in(app: &App) -> bool { +pub(crate) fn app_defines_in(app: &App) -> bool { let is_in = |n: &Symbol| n.as_str() == "in?"; app.models.iter().any(|m| { m.body.iter().any(|item| { @@ -56,6 +56,10 @@ fn app_defines_in(app: &App) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(_), method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/including.rs b/src/lower/including.rs index a62b41c79..36f6e7a80 100644 --- a/src/lower/including.rs +++ b/src/lower/including.rs @@ -42,7 +42,7 @@ pub fn apply_including_lowering(app: &mut App) { /// Does any app class define its own `including`? Then the name doesn't /// mean ActiveSupport's and the pass stands down. -fn app_defines_including(app: &App) -> bool { +pub(crate) fn app_defines_including(app: &App) -> bool { let is_including = |n: &Symbol| n.as_str() == "including"; let in_model = app.models.iter().any(|m| { m.body.iter().any(|item| match item { @@ -66,6 +66,10 @@ fn app_defines_including(app: &App) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/jbuilder_to_library/mod.rs b/src/lower/jbuilder_to_library/mod.rs index 42018b910..9c87c8c8e 100644 --- a/src/lower/jbuilder_to_library/mod.rs +++ b/src/lower/jbuilder_to_library/mod.rs @@ -160,15 +160,44 @@ pub fn lower_jbuilder_to_library_classes( // both arguments reached the helper whole, so every boost's // `url` read `/rooms/#/messages/#`. // Same pass the controller and test bodies run, for the - // same reason and with the same idempotence. - method.body = crate::lower::controller_to_library::rewrites:: - project_route_helper_ids(&method.body); - crate::lower::typing::type_method_body(method, &classes, &empty_ivars); + // same reason and with the same idempotence. In-place + skip + // the follow-up type when the projection is a no-op — the + // cloning entry re-walked every jbuilder body twice. + if crate::lower::controller_to_library::rewrites:: + project_route_helper_ids_in_place(&mut method.body) + { + crate::lower::typing::type_method_body(method, &classes, &empty_ivars); + } } } lcs } +/// Untyped jbuilder LibraryClasses — method signatures only. The +/// controller lowerer registers these so `Views::X._json` +/// resolves; body typing is the jbuilder lowerer's job. +pub fn jbuilder_signature_classes(views: &[View], app: &App) -> Vec { + views + .iter() + .filter(|v| v.jbuilder && !v.analysis_only) + .map(|v| { + let (module_id, method) = jbuilder_signature_method(v, app); + LibraryClass { + name: module_id, + is_module: true, + parent: None, + includes: Vec::new(), + methods: vec![method], + nullable_columns: Vec::new(), + origin: None, + constants: Vec::new(), + unknown_calls: Vec::new(), + class_ivar_initializers: Vec::new(), + } + }) + .collect() +} + /// Single-template entry. Used by tests and the dump_ir binary; the /// production bulk path is `lower_jbuilder_to_library_classes`. pub fn lower_jbuilder_to_library_class(view: &View, app: &App) -> LibraryClass { @@ -179,7 +208,84 @@ fn build_library_class(view: &View, app: &App, type_body: bool) -> LibraryClass let (dir, base) = split_view_name(view.name.as_str()); let stem = base.trim_start_matches('_'); let is_partial = base.starts_with('_'); + let (module_id, mut method, arg_name, _extra_params, known_models) = + jbuilder_method_parts(view, app, dir, stem, is_partial); + // Rewrite `@ivar` → bare `ivar` so the inferred arg / extras + // read as plain locals. Mirrors the ERB lowerer. + let rewritten = rewrite_ivars_to_locals(&view.body); + + let arg_columns = if arg_name.is_empty() { + std::collections::HashMap::new() + } else { + columns_for_arg(&arg_name, dir, is_partial, stem, app) + }; + let ctx = Ctx { + resource_dir: dir.to_string(), + accumulator: "io".to_string(), + arg_name: arg_name.clone(), + arg_columns, + direct_helpers: app + .routes + .direct_helpers + .iter() + .map(|h| h.name.as_str().to_string()) + .collect(), + models: known_models.iter().cloned().collect(), + temps: Default::default(), + }; + + let mut body_stmts: Vec = Vec::new(); + body_stmts.push(assign_accumulator_string_new(&ctx.accumulator)); + body_stmts.extend(walk_template(&rewritten, &ctx)); + let mut result = var_ref(Symbol::from(ctx.accumulator.as_str())); + result.hint = Some(IrHint::StringBuilderResult); + body_stmts.push(result); + + let mut body = seq(body_stmts); + // File-grain catch-all: whatever the walk-level stamps didn't reach + // (`io = String.new`, the `{`/`}` wrappers, the trailing `io`) + // attributes to the template as a whole — same convention as the + // ERB lowerer. + body.inherit_span(view.body.span); + method.body = body; + + if type_body { + type_method_body_solo(&mut method); + } + + LibraryClass { + name: module_id, + is_module: true, + parent: None, + includes: Vec::new(), + methods: vec![method], + nullable_columns: Vec::new(), + origin: None, + constants: Vec::new(), + unknown_calls: Vec::new(), + class_ivar_initializers: Vec::new(), + } +} + +fn jbuilder_signature_method(view: &View, app: &App) -> (ClassId, MethodDef) { + let (dir, base) = split_view_name(view.name.as_str()); + let stem = base.trim_start_matches('_'); + let is_partial = base.starts_with('_'); + let (module_id, method, _, _, _) = jbuilder_method_parts(view, app, dir, stem, is_partial); + (module_id, method) +} + +/// Params + signature for a jbuilder template. The controller lowerer +/// only needs this shape; `build_library_class` then walks the template +/// into `method.body`. +fn jbuilder_method_parts( + view: &View, + app: &App, + dir: &str, + stem: &str, + is_partial: bool, +) -> (ClassId, MethodDef, String, Vec, Vec) { let module_id = view_module_id(dir); let method_name = Symbol::from(format!("{stem}_json")); @@ -190,10 +296,6 @@ fn build_library_class(view: &View, app: &App, type_body: bool) -> LibraryClass .collect(); let arg_name = infer_view_arg(stem, dir, is_partial, &known_models); - // Rewrite `@ivar` → bare `ivar` so the inferred arg / extras - // read as plain locals. Mirrors the ERB lowerer. - let rewritten = rewrite_ivars_to_locals(&view.body); - // The IVARS THIS TEMPLATE READS decide an action view's parameters, // and the NAME CONVENTION is only the fallback. // @@ -259,41 +361,7 @@ fn build_library_class(view: &View, app: &App, type_body: bool) -> LibraryClass crate::lower::view_to_library::build_view_signature_from(&typed, &extra_params) }; - let arg_columns = if arg_name.is_empty() { - std::collections::HashMap::new() - } else { - columns_for_arg(&arg_name, dir, is_partial, stem, app) - }; - let ctx = Ctx { - resource_dir: dir.to_string(), - accumulator: "io".to_string(), - arg_name: arg_name.clone(), - arg_columns, - direct_helpers: app - .routes - .direct_helpers - .iter() - .map(|h| h.name.as_str().to_string()) - .collect(), - models: known_models.iter().cloned().collect(), - temps: Default::default(), - }; - - let mut body_stmts: Vec = Vec::new(); - body_stmts.push(assign_accumulator_string_new(&ctx.accumulator)); - body_stmts.extend(walk_template(&rewritten, &ctx)); - let mut result = var_ref(Symbol::from(ctx.accumulator.as_str())); - result.hint = Some(IrHint::StringBuilderResult); - body_stmts.push(result); - - let mut body = seq(body_stmts); - // File-grain catch-all: whatever the walk-level stamps didn't reach - // (`io = String.new`, the `{`/`}` wrappers, the trailing `io`) - // attributes to the template as a whole — same convention as the - // ERB lowerer. - body.inherit_span(view.body.span); - - let mut method = MethodDef { + let method = MethodDef { visibility: crate::dialect::MethodVisibility::Public, unsupported_formals: None, has_anonymous_block: false, @@ -301,32 +369,16 @@ fn build_library_class(view: &View, app: &App, type_body: bool) -> LibraryClass name: method_name, receiver: MethodReceiver::Class, params, - body, + body: seq(Vec::new()), signature, effects: EffectSet::default(), enclosing_class: Some(module_id.0.clone()), kind: AccessorKind::Method, is_async: false, - mutates_self: false, - block_param: None, + mutates_self: false, + block_param: None, }; - - if type_body { - type_method_body_solo(&mut method); - } - - LibraryClass { - name: module_id, - is_module: true, - parent: None, - includes: Vec::new(), - methods: vec![method], - nullable_columns: Vec::new(), - origin: None, - constants: Vec::new(), - unknown_calls: Vec::new(), - class_ivar_initializers: Vec::new(), - } + (module_id, method, arg_name, extra_params, known_models) } fn type_method_body_solo(method: &mut MethodDef) { diff --git a/src/lower/job_test_only.rs b/src/lower/job_test_only.rs index 584c11157..947d83335 100644 --- a/src/lower/job_test_only.rs +++ b/src/lower/job_test_only.rs @@ -35,6 +35,7 @@ const JOB_FILTER_HELPERS: &[&str] = &[ "assert_enqueued_with", ]; +#[allow(dead_code)] pub fn apply_job_test_only_lowering(app: &mut App) { for tm in &mut app.test_modules { if let Some(setup) = &mut tm.setup { @@ -51,6 +52,10 @@ pub fn apply_job_test_only_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: None, method, args, .. } = &mut *expr.node else { return }; // `assert_enqueued_email_with PasswordsMailer, :reset` — the mailer // is the first POSITIONAL argument, and a name like a job's. diff --git a/src/lower/literal_append.rs b/src/lower/literal_append.rs index b60d57431..503670ba3 100644 --- a/src/lower/literal_append.rs +++ b/src/lower/literal_append.rs @@ -64,6 +64,10 @@ fn is_string_arg(e: &Expr) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/mod.rs b/src/lower/mod.rs index e40c2076f..3b554521a 100644 --- a/src/lower/mod.rs +++ b/src/lower/mod.rs @@ -48,6 +48,7 @@ pub mod create_block; pub mod as_json_poro; pub mod active_model_model; pub mod enumerable_ext; +mod fused; pub mod time_calendar; pub mod boolean_cast; pub mod where_range_split; @@ -298,24 +299,58 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ ("params_residue", &["bool_fold"]), // After the ledger, which reads the calls this rewrites; before the controller lowering turns `params` into `@params`. ("params_permit", &["params_residue"]), + // Independent send rewrites: one fused tree walk in + // `fused::apply_fused_independent_rewrites`. Grouped here so the + // executed-pass assert stays a straight list match. + ("pathname_ctor", &[]), + ("array_ordinal", &[]), + ("save_without_validation", &[]), + ("random_formatter", &[]), + ("number_to_fs", &[]), + ("string_inflections", &[]), + ("to_json", &[]), + ("csv_generate", &[]), + ("presence_in", &[]), + ("enumerable_ext", &[]), + ("boolean_cast", &[]), + ("values_at_splat", &[]), + ("exclude_predicate", &[]), + ("in_predicate", &[]), + ("including", &[]), + ("exists_conditions", &[]), + ("destroy_by", &[]), + ("literal_append", &[]), + ("byte_size", &[]), + ("dirty_predicate_kwargs", &[]), + ("relation_select_block", &[]), + ("arel_attribute", &[]), + ("attr_or_assign", &[]), + ("group_count", &[]), + ("errors_full_messages", &[]), + ("each_with_index", &[]), + // After the other fused send rewrites: produces Range / ActiveSupport + // calls `where_range_split` later consumes. Empty runs_after of its + // own — the dependent is the later pass. + ("time_calendar", &[]), ("blank", &[]), - ("time_current", &[]), ("as_json_super", &[]), // `super` in a model's own `password=` → the `has_secure_password` // writer under its own name. Before `create_block`, which inlines // blocks: the pass leaves a `super` inside a block alone. ("secure_password_super", &[]), ("parameterize", &[]), - // `Pathname(p)` → `Pathname.new(p)`. Rewrites a receiverless call - // no other pass produces or consumes into a Const-receiver send of - // a name no other pass reads, so no ordering constraints. - ("pathname_ctor", &[]), // A class body's bare `new` gets the class as its receiver. Reads a // receiverless send no other pass produces and writes a // Const-receiver one nothing else keys on, so no ordering // constraints. ("class_body_new", &[]), ("mocha", &[]), + // Independent context-heavy send rewrites: one fused tree walk in + // `fused::apply_fused_context_rewrites`. Empty runs_after — they + // match disjoint method names and do not consume each other's + // output. Grouped after mocha so the executed-pass assert stays a + // straight list match. + ("time_current", &[]), // `WebMock.stub_request(v, u).to_return(...)` → `HttpStub.stub(...)`. // Keys on a `WebMock` Const receiver and a `to_return` send, neither // of which any other pass produces or consumes; the `.to_s` it @@ -326,51 +361,19 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // neither of which any other pass produces or consumes, so no // ordering constraints. ("global_id_locate", &[]), - // ActiveSupport `Array#second`..`#fifth` → an index read. Keys on - // a typed-Array receiver, which no other pass produces or - // consumes, so no ordering constraints. - ("array_ordinal", &[]), - // `recv.each.with_index(n?) { }` → `each_with_index` (+ offset bind). - // Keys on an Enumerator chain no other pass produces or consumes, - // so no ordering constraints. Unblocks Spinel AOT on Writebook - // Positionable#move_to_position (keyword call closing over index). - ("each_with_index", &[]), - // `save(validate: false)` → `save_after_validation`. Keys on a - // literal `validate: false` kwarg no other pass produces or - // consumes, so no ordering constraints. - ("save_without_validation", &[]), ("assoc_pluck", &[]), + // Last in the fused context walk. Mints arbitrary method names from + // a literal symbol; those new `If`/`is_a?` children must not be + // walked by a later fused rewrite that keys on the minted name + // (`attribute_aliases` keys on `read_attribute`). Extra test + // constant / inner-class surfaces stay try_guard-only. ("try_guard", &[]), - // `Random.uuid` → `SecureRandom.uuid` — the same `Random::Formatter` - // method under a byte source that exists. Rewrites a receiver - // Const no pass produces and writes a name no pass consumes, so - // no ordering constraints. - ("random_formatter", &[]), - // `number.to_fs(:delimited)` → `ActiveSupport.number_delimited(number)`; - // a rewrite of a name no other pass produces or consumes. - ("number_to_fs", &[]), - // `str.humanize` → `ActiveSupport.humanize(str)`; a rewrite of a name no other pass produces or consumes. - ("string_inflections", &[]), - // `record.read_attribute(:x)` → `record[:x]`; a rename of a name no other pass produces or consumes. - ("attribute_aliases", &[]), - // `hash.to_json` → `JSON.generate(hash)`; a receiver-shape rewrite - // of a name no other pass produces or consumes. - ("to_json", &[]), - // `value.presence_in(list)` → `ActiveSupport.presence_in(value, - // list)`; a receiver-shape rewrite of a name no other pass produces - // or consumes, so no ordering constraints. - // No runs_after: it rewrites a `CSV.generate` call's own arguments and block. - ("csv_generate", &[]), - ("presence_in", &[]), - // `list.index_by { … }` → `ActiveSupport.index_by(list) { … }`. - // Same receiver-shape rewrite, same absence of constraints. - ("enumerable_ext", &[]), - // No runs_after: it reads only analyzer types and produces calls no other pass consumes. - ("time_calendar", &[]), - // After time_calendar: `t.all_month` becomes the Range literal this splits out. + // After time_calendar (fused earlier): `t.all_month` becomes the Range literal this splits out. + // Stays sequential: rewrite plus a diagnostic walk that tracks + // `where`/`find_by` condition position. Fusing the rewrite would + // still leave the ledger walk, and the ledger must not see later + // fused output. ("where_range_split", &["time_calendar"]), - // No runs_after: it rewrites one constant-rooted shape no other pass produces. - ("boolean_cast", &[]), // `Rooms::Open.count` → `Room.where(type: "Rooms::Open").count`. // Produces a `where` at a model Const root, which is vocabulary // every later pass already reads; consumes nothing any pass @@ -386,14 +389,9 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // `defined?(@x)` → `@x_defined`, with the flag set beside every // assignment to `@x`. Reads a `defined?` send no other pass // produces and writes an ivar name no other pass reads, so no - // ordering constraints. + // ordering constraints. Class-level collect-then-rewrite, not a + // simple post-order send rewrite. ("defined_ivar_memo", &[]), - // `room.is_a?(Rooms::Open)` → the inheritance-column read it stands - // for. Beside `sti_scope` because it asks that pass the same - // question (which classes are STI subclasses of which base); it - // reads a name that pass does not produce and writes a `type` - // comparison it does not consume, so no ordering constraint. - ("sti_is_a", &[]), // Folds an STI subclass's callback declarations into hook methods // on that subclass. Reads `unknown_calls` on a library class and // writes methods on the same class; no other pass produces or @@ -401,33 +399,44 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // `sti_scope` because both exist for the same reason — an STI // subclass is not a Model, so the model-keyed machinery skips it. ("sti_subclass_callbacks", &[]), + // Independent rewrites after the STI predecessor cluster, before + // `tag_builder`: one fused walk in `fused::apply_fused_pre_tag_rewrites`. + // `record.read_attribute(:x)` → `record[:x]`; a rename of a name no other pass produces or consumes. + ("attribute_aliases", &[]), + // `room.is_a?(Rooms::Open)` → the inheritance-column read it stands + // for. Beside `sti_scope` because it asks that pass the same + // question (which classes are STI subclasses of which base); it + // reads a name that pass does not produce and writes a `type` + // comparison it does not consume, so no ordering constraint. + ("sti_is_a", &[]), // `only: ` → `only: ["JobClass"]` in test bodies. Reads a // Const literal nothing else produces and writes a String array // nothing else reads, so no ordering constraints. ("job_test_only", &[]), ("test_cookie_jar", &[]), - // `x_previously_changed?(to: V)` → the predicate AND a comparison. - // Reads a kwargs hash on a synthesized predicate name and writes - // reads of the same synthesis; no pass produces or consumes either - // shape, so no ordering constraints. - ("dirty_predicate_kwargs", &[]), // `Controller.render partial:` → the view-module call. Reads a // shape no pass produces and writes a `Views::` call no pass // consumes, so no ordering constraints. ("controller_class_render", &[]), // `sum(:col)` → block form; no ordering constraints (rewrites a // literal-symbol arg shape no other pass produces or consumes). + // Last in the fused pre-tag walk so new `to_a`/`sum` children are + // not fed to the other fused rewrites. ("sum_symbol", &[]), - // `values_at(*keys)` → keys.map block form; same no-constraints - // rationale. - ("values_at_splat", &[]), // `tag.div(…)` → the HTML string it builds. BEFORE `html_safe` // (whose fold would erase the `.html_safe` marker it reads on // content, and which must SEE the marker this pass writes so the // enclosing helper registers as safe) and before `capture_inline` // (which flattens the `capture { … }` this pass synthesizes for the - // block form). + // block form). Stays sequential: the synthesized `capture` / + // `.html_safe` subtrees must be walked by those later passes, and + // fusing with mid would skip mid rewrites on those new children. + // Must not move after `kwsplat` (would change kwsplat's view of + // argument lists). ("tag_builder", &[]), + // Independent rewrites after tag_builder, before kwsplat: one fused + // controller/library/model walk in `fused::apply_fused_narrow_rewrites` + // plus one fused hook+view+test walk in `fused::apply_fused_mid_rewrites`. ("request_index", &[]), // `config.session_options[:key]` → `session_cookie_key`; rewrites a // receiver chain no other pass produces or consumes. @@ -437,22 +446,6 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ ("status_literal", &[]), // `"#{v.to_param}"` on an untyped receiver → `ActiveSupport.to_param(v)`. ("to_param_residue", &[]), - // `x.exclude?(y)` → `!x.include?(y)`; total rewrite, no ordering - // constraints (no other pass produces or consumes `exclude?`). - ("exclude_predicate", &[]), - // `x.in?(xs)` → `xs.include?(x)`; the `exclude_predicate` mirror, - // same total rewrite and same lack of ordering constraints. - ("in_predicate", &[]), - // `xs.including(a)` → `xs.to_a + [a]`; same shape and same lack of - // ordering constraints as `exclude_predicate` above. - ("including", &[]), - // `.select { … }` → `.filter { … }`, so the projection - // `select(*specs)` is the only thing left on the name and answers a - // Relation and nothing else. Reads the receiver's analyzer type - // (Relation, or untyped) and rewrites only the method name, so it - // has no ordering constraint of its own — it just has to run before - // the `relation_residue` ledger reads the final chain shape. - ("relation_select_block", &[]), // `x_path(format: :json)` → `x_path() + ".json"`. Must run before // the route-helper lowering surveys call sites for query keys; the // two do not overlap (`format` is on NON_QUERY_OPTIONS) but the @@ -490,15 +483,6 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // `x.inquiry` / `x.?` → equality against the label; total // rewrite of a name no other pass produces or consumes. ("inquiry", &[]), - // `Model.exists?(col: v)` → `Model.where(col: v).exists?`, so the - // conditions form reaches the Relation instead of the by-id - // primitive. Before the emit-time arel rewrite, which then folds - // the chain when the values are literal. - ("exists_conditions", &[]), - // `Model.destroy_by(col: v)` → `Model.where(col: v).destroy_all`, - // Rails' own definition. Same placement and same reasons as - // `exists_conditions`: before the arel rewrite that folds the chain. - ("destroy_by", &[]), // `owner.create_!(…)` → `Target.create!(fk: owner.id, …)` for // a `has_one`. Runs after `destroy_by` and before the arel rewrite, // like the other call-site rewrites: what it produces is an ordinary @@ -513,26 +497,31 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // Symbols. Runs AFTER `config_reader` because the receiver that // makes it fire — a lifted config group reader — does not exist // until that pass has rewritten the `config` chain and stamped its - // type. + // type. Folded last into that pass's hook+view walk. ("symbolize_keys", &["config_reader"]), // No runs_after: it reads the ingested enum tables and rewrites only the key argument. ("enum_mapping_keys", &[]), - ("arel_attribute", &[]), - // `"lit" << x` → `"lit" + x`; local expression rewrite, no ordering - // constraints. - ("literal_append", &[]), - // `5.megabytes` → `5 * 1048576`; local expression rewrite of a name - // no other pass produces or consumes. - ("byte_size", &[]), // `f(**h)` (erased to `f(h)` at ingest) → `f(k: h[:k], …)` when the // callee declares explicit keywords. Reads the arg count against the // callee's signature, so it must see the argument list as ingested — // before any pass that appends or drops a positional argument. + // Stays sequential: expanding a splat produces new keyword-arg + // children that `send_file` (in the late fused walk) must still + // see. Fusing with late would skip those. Must stay after + // `tag_builder` so it does not observe tag-builder-rewritten args. ("kwsplat", &[]), - // `Rails.cache.fetch(k, expires_in: t) { }` → `fetch_str`; - // must run BEFORE the render lowering, whose rewrite of - // `render_to_string` into a `Views::` call is the tail this gates on. + // Independent late send rewrites: one fused tree walk in + // `fused::apply_fused_late_rewrites`. After `kwsplat` so argument + // lists are still as ingested for `send_file`; after `tag_builder` + // so `capture_inline` sees synthesized `capture` blocks. ("rails_cache", &[]), + ("capture_inline", &["tag_builder"]), + ("and_return", &[]), + ("case_lambda", &[]), + ("system_exception", &[]), + ("perform_all_later", &[]), + ("attachables_grep", &[]), + ("send_file", &[]), // `.html_safe` → ``, recording the producing method on the // App. No ordering constraints among the lowerings; the view // lowerer reads what it records, and that runs later, at emit. @@ -550,15 +539,9 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // `ActiveSupport::Duration`, and `duration` is what grounds one. ("signed_id", &[]), ("partial_qualify", &[]), - ("capture_inline", &["tag_builder"]), - ("and_return", &[]), - ("case_lambda", &[]), ("first_or_create", &[]), - ("attr_or_assign", &[]), - ("system_exception", &[]), ("case_class_narrow", &[]), ("reset_counters", &[]), - ("perform_all_later", &[]), // `Model.authenticate_by(email: …, password: …)` → bind // `find_by()`, then check `authenticate()`; // macro-inline of a Rails 7.1 name no other pass produces or @@ -568,13 +551,8 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // Wraps finder keywords in a model's `normalizes`; authenticate_by // expands into one such `find_by`. ("normalizes", &["authenticate_by"]), - ("group_count", &[]), ("dead_default", &[]), ("errors_add", &[]), - // `errors.full_messages` -> `errors`; total rewrite, no ordering - // constraints (no other pass produces or consumes `full_messages`, - // and the `errors` receiver it matches is left untouched). - ("errors_full_messages", &[]), // `errors[:field]` -> `ActiveSupport.errors_for(errors, "Field ")`. // AFTER `errors_add`, so a hand-written `errors.add(:field, …)` in // the same body has already baked its humanized prefix — the @@ -607,11 +585,6 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // running second leaves exactly the sites it declined, which is the // division of labour intended. ("assoc_attr_key", &["update_kwargs"]), - // `content.attachables.grep(User)` -> `User.where(id: - // content.attachable_ids("User")).to_a`. Writes a `Model.where` - // chain, which the ruby emit's scope lowering then folds like any - // other; consumes a shape no pass produces, so no constraints. - ("attachables_grep", &[]), // `obj.extend Mod` on an instance -> a raise stub with the report. // Consumes a shape no pass produces or reads; no constraints. ("object_extend", &[]), @@ -661,13 +634,6 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[ // String-typed helper gets a String. Matches the reader's NAME, // which no earlier pass rewrites; no constraint. ("attached_url", &[]), - // `send_file path, content_type:` → `send_data File.binread(path), - // type:`. Reads Rails' own keyword spelling, which no earlier pass - // rewrites, and writes a `send_data` the runtime already answers — - // so no ordering constraint. Beside `attach` because it is the same - // move for the same reason: the file read belongs at the call site, - // where the app pays for it, not in a runtime every target shares. - ("send_file", &[]), // Moves a forwarded `**` bundle out of an optional keyword's // flattened slot and into the `**rest` slot it was aimed at. BEFORE // helper_kwargs, which splices literal keywords into those same @@ -788,107 +754,95 @@ pub fn apply_post_analyze_lowerings( ran!("params_residue"); params_permit::apply_params_permit_lowering(app); ran!("params_permit"); - diags.extend(blank::apply_blank_lowering(app)); + crate::timings::phase("post-analyze: fused send rewrites", || { + fused::apply_fused_independent_rewrites(app); + }); + ran!("pathname_ctor"); + ran!("array_ordinal"); + ran!("save_without_validation"); + ran!("random_formatter"); + ran!("number_to_fs"); + ran!("string_inflections"); + ran!("to_json"); + ran!("csv_generate"); + ran!("presence_in"); + ran!("enumerable_ext"); + ran!("boolean_cast"); + ran!("values_at_splat"); + ran!("exclude_predicate"); + ran!("in_predicate"); + ran!("including"); + ran!("exists_conditions"); + ran!("destroy_by"); + ran!("literal_append"); + ran!("byte_size"); + ran!("dirty_predicate_kwargs"); + ran!("relation_select_block"); + ran!("arel_attribute"); + ran!("attr_or_assign"); + ran!("group_count"); + ran!("errors_full_messages"); + ran!("each_with_index"); + ran!("time_calendar"); + diags.extend(crate::timings::phase("post-analyze: blank", || { + blank::apply_blank_lowering(app) + })); ran!("blank"); - time_current::apply_time_current_lowering(app); - ran!("time_current"); as_json_super::apply_as_json_super_grounding(app); ran!("as_json_super"); secure_password::apply_secure_password_super(app); ran!("secure_password_super"); parameterize::apply_parameterize_grounding(app); ran!("parameterize"); - pathname_ctor::apply_pathname_ctor_lowering(app); - ran!("pathname_ctor"); diags.extend(class_body_new::apply_class_body_new_lowering(app)); ran!("class_body_new"); - mocha::apply_mocha_lowering(app); + crate::timings::phase("post-analyze: mocha", || { + mocha::apply_mocha_lowering(app); + }); ran!("mocha"); - webmock::apply_webmock_lowering(app); + crate::timings::phase("post-analyze: fused context rewrites", || { + fused::apply_fused_context_rewrites(app); + }); + ran!("time_current"); ran!("webmock"); - global_id_locate::apply_global_id_locate_lowering(app); ran!("global_id_locate"); - array_ordinal::apply_array_ordinal_lowering(app); - ran!("array_ordinal"); - each_with_index::apply_each_with_index_lowering(app); - ran!("each_with_index"); - save_without_validation::apply_save_without_validation_lowering(app); - ran!("save_without_validation"); - assoc_pluck::apply_assoc_pluck_lowering(app); ran!("assoc_pluck"); - try_guard::apply_try_guard_lowering(app); ran!("try_guard"); - random_formatter::apply_random_formatter_grounding(app); - ran!("random_formatter"); - number_to_fs::apply_number_to_fs_grounding(app); - ran!("number_to_fs"); - string_inflections::apply_string_inflection_grounding(app); - ran!("string_inflections"); - attribute_aliases::apply_attribute_alias_lowering(app); - ran!("attribute_aliases"); - to_json::apply_to_json_lowering(app); - ran!("to_json"); - csv_generate::apply_csv_generate_lowering(app); - ran!("csv_generate"); - presence_in::apply_presence_in_grounding(app); - ran!("presence_in"); - enumerable_ext::apply_enumerable_ext_grounding(app); - ran!("enumerable_ext"); - time_calendar::apply_time_calendar_grounding(app); - ran!("time_calendar"); diags.extend(where_range_split::apply_where_range_split(app)); ran!("where_range_split"); - boolean_cast::apply_boolean_cast_grounding(app); - ran!("boolean_cast"); sti_scope::apply_sti_scope_lowering(app); ran!("sti_scope"); relation_ivar_materialize::apply_relation_ivar_materialize(app); ran!("relation_ivar_materialize"); defined_ivar_memo::apply_defined_ivar_memo_lowering(app); ran!("defined_ivar_memo"); - sti_is_a::apply_sti_is_a_lowering(app); - ran!("sti_is_a"); sti_subclass_callbacks::apply_sti_subclass_callbacks(app); ran!("sti_subclass_callbacks"); - job_test_only::apply_job_test_only_lowering(app); + crate::timings::phase("post-analyze: fused pre-tag rewrites", || { + fused::apply_fused_pre_tag_rewrites(app); + }); + ran!("attribute_aliases"); + ran!("sti_is_a"); ran!("job_test_only"); - test_cookie_jar::apply_test_cookie_jar_lowering(app); ran!("test_cookie_jar"); - dirty_predicate_kwargs::apply_dirty_predicate_kwargs(app); - ran!("dirty_predicate_kwargs"); - controller_class_render::apply_controller_class_render(app); ran!("controller_class_render"); - sum_symbol::apply_sum_symbol_lowering(app); ran!("sum_symbol"); - values_at_splat::apply_values_at_splat_lowering(app); - ran!("values_at_splat"); diags.extend(tag_builder::apply_tag_builder_lowering(app, registry)); ran!("tag_builder"); - request_index::apply_request_index_lowering(app); + crate::timings::phase("post-analyze: fused narrow rewrites", || { + fused::apply_fused_narrow_rewrites(app); + }); ran!("request_index"); - session_options::apply_session_options_lowering(app); ran!("session_options"); - status_literal::apply_status_literal_lowering(app); ran!("status_literal"); - to_param_residue::apply_to_param_residue_lowering(app); ran!("to_param_residue"); - exclude_predicate::apply_exclude_predicate_lowering(app); - ran!("exclude_predicate"); - in_predicate::apply_in_predicate_lowering(app); - ran!("in_predicate"); - including::apply_including_lowering(app); - ran!("including"); - relation_select_block::apply_relation_select_block_lowering(app); - ran!("relation_select_block"); - route_format_suffix::apply_route_format_suffix_lowering(app); + crate::timings::phase("post-analyze: fused mid rewrites", || { + fused::apply_fused_mid_rewrites(app); + }); ran!("route_format_suffix"); - route_url_options::apply_route_url_options_lowering(app); ran!("route_url_options"); - enum_symbols::apply_enum_symbol_lowering(app); ran!("enum_symbols"); - diags.extend(has_json::apply_has_json_lowering(app)); ran!("has_json"); - diags.extend(assoc_loaded::apply_assoc_loaded_lowering(app)); ran!("assoc_loaded"); default_self_recv::apply_default_self_recv(app); ran!("default_self_recv"); @@ -900,28 +854,28 @@ pub fn apply_post_analyze_lowerings( ran!("update_writer_check"); inquiry::apply_inquiry_lowering(app); ran!("inquiry"); - exists_conditions::apply_exists_conditions_lowering(app); - ran!("exists_conditions"); - destroy_by::apply_destroy_by_lowering(app); - ran!("destroy_by"); has_one_builder::apply_has_one_builder_lowering(app); ran!("has_one_builder"); config_reader::apply_config_reader_lowering(app); ran!("config_reader"); - symbolize_keys::apply_symbolize_keys_grounding(app); ran!("symbolize_keys"); enum_mapping_keys::apply_enum_mapping_keys(app); ran!("enum_mapping_keys"); - arel_attribute::apply_arel_attribute_lowering(app); - ran!("arel_attribute"); - literal_append::apply_literal_append_lowering(app); - ran!("literal_append"); - byte_size::apply_byte_size_lowering(app); - ran!("byte_size"); - diags.extend(kwsplat::apply_kwsplat_expansion(app)); + diags.extend(crate::timings::phase("post-analyze: kwsplat", || { + kwsplat::apply_kwsplat_expansion(app) + })); ran!("kwsplat"); - rails_cache::apply_rails_cache_lowering(app); + crate::timings::phase("post-analyze: fused late rewrites", || { + fused::apply_fused_late_rewrites(app); + }); ran!("rails_cache"); + ran!("capture_inline"); + ran!("and_return"); + ran!("case_lambda"); + ran!("system_exception"); + ran!("perform_all_later"); + ran!("attachables_grep"); + ran!("send_file"); html_safe::apply_html_safe_lowering(app); ran!("html_safe"); diags.extend(module_mixins::apply_module_mixins_lowering(app)); @@ -934,38 +888,22 @@ pub fn apply_post_analyze_lowerings( ran!("signed_id"); partial_qualify::apply_partial_qualification(app); ran!("partial_qualify"); - capture_inline::apply_capture_inline(app); - ran!("capture_inline"); - and_return::apply_and_return_lowering(app); - ran!("and_return"); - case_lambda::apply_case_lambda_lowering(app); - ran!("case_lambda"); diags.extend(first_or_create::apply_first_or_create_lowering(app)); ran!("first_or_create"); - attr_or_assign::apply_attr_or_assign_lowering(app); - ran!("attr_or_assign"); - system_exception::apply_system_exception_lowering(app); - ran!("system_exception"); case_class_narrow::apply_case_class_narrowing(app); ran!("case_class_narrow"); reset_counters::apply_reset_counters_lowering(app); ran!("reset_counters"); - perform_all_later::apply_perform_all_later_lowering(app); - ran!("perform_all_later"); diags.extend(authenticate_by::apply_authenticate_by_lowering(app)); ran!("authenticate_by"); // After authenticate_by: its expansion is a `find_by` whose keyword // a `normalizes` declaration applies to. normalizes::apply_normalizes_finder_lowering(app); ran!("normalizes"); - group_count::apply_group_count_lowering(app); - ran!("group_count"); dead_default::apply_dead_default_lowering(app, registry); ran!("dead_default"); diags.extend(errors_add::apply_errors_add_lowering(app)); ran!("errors_add"); - errors_full_messages::apply_errors_full_messages_lowering(app); - ran!("errors_full_messages"); diags.extend(errors_index::apply_errors_index_lowering(app)); ran!("errors_index"); diags.extend(create_block::apply_create_block_inline(app)); @@ -980,8 +918,6 @@ pub fn apply_post_analyze_lowerings( ran!("update_kwargs"); diags.extend(assoc_attr_key::apply_assoc_attr_key_lowering(app)); ran!("assoc_attr_key"); - attachables_grep::apply_attachables_grep_lowering(app); - ran!("attachables_grep"); diags.extend(object_extend::apply_object_extend_stub(app)); ran!("object_extend"); param_rebind::apply_param_rebind_lowering(app); @@ -998,7 +934,9 @@ pub fn apply_post_analyze_lowerings( ran!("mailer_class_side"); diags.extend(job_class_side::apply_job_class_side(app)); ran!("job_class_side"); - diags.extend(send_dispatch::apply_send_static_dispatch(app, registry)); + diags.extend(crate::timings::phase("post-analyze: send_dispatch", || { + send_dispatch::apply_send_static_dispatch(app, registry) + })); ran!("send_static_dispatch"); // AFTER send_dispatch — see POST_ANALYZE_PASS_ORDER (the `duration` // entry's runs_after). An all-duration-unit name set dispatches @@ -1012,8 +950,6 @@ pub fn apply_post_analyze_lowerings( ran!("attach"); attached_url::apply_attached_url_lowering(app); ran!("attached_url"); - send_file::apply_send_file_lowering(app); - ran!("send_file"); diags.extend(kwrest_forward::apply_kwrest_forward_lowering(app)); ran!("kwrest_forward"); helper_kwargs::apply_helper_kwarg_positional_lowering(app); @@ -1022,7 +958,9 @@ pub fn apply_post_analyze_lowerings( ran!("form_wrapper_owners"); broadcast_calls::apply_broadcast_calls_lowering(app); ran!("broadcast_calls"); - diags.extend(relation_residue::apply_relation_residue_ledger(app, registry)); + diags.extend(crate::timings::phase("post-analyze: relation_residue", || { + relation_residue::apply_relation_residue_ledger(app, registry) + })); ran!("relation_residue"); #[cfg(debug_assertions)] debug_assert_eq!( @@ -1527,9 +1465,10 @@ pub use ty_coerce_insertion::{insert_ty_coercions, insert_ty_coercions_with_extr pub use view_to_library::{ ViewLowerCtx, flatten_lcs_to_functions, lower_view_to_library_class, lower_views_to_library_classes, lower_views_to_library_functions, + preliminary_view_classes, type_view_library_classes, }; pub use jbuilder_to_library::{ - lower_jbuilder_to_library_class, lower_jbuilder_to_library_classes, + jbuilder_signature_classes, lower_jbuilder_to_library_class, lower_jbuilder_to_library_classes, }; pub use broadcasts::{ app_broadcasts_live, lower_broadcasts, BroadcastAction, LoweredAssocRef, LoweredBroadcast, diff --git a/src/lower/model_to_library/associations.rs b/src/lower/model_to_library/associations.rs index 6407686fe..93a341a0f 100644 --- a/src/lower/model_to_library/associations.rs +++ b/src/lower/model_to_library/associations.rs @@ -1,6 +1,7 @@ //! Associations: has_many becomes a typed reader returning a where-style -//! query. dependent: :destroy generates a `before_destroy` cascade that -//! iterates and destroys each child. +//! query. `dependent: :destroy` generates a `before_destroy` cascade: +//! has_many iterates each child; has_one destroys the single child when +//! present. use crate::dialect::{ AccessorKind, Association, Dependent, MethodDef, MethodReceiver, Model, Param, @@ -319,13 +320,14 @@ pub(super) fn push_association_methods( methods.push(synth_belongs_to_writer(owner, name, target, foreign_key, sentinel)); } } - Association::HasOne { name, target, foreign_key, as_interface, .. } => { + Association::HasOne { name, target, foreign_key, as_interface, scope, .. } => { methods.push(synth_has_one_reader( owner, name, target, foreign_key, as_interface.as_ref(), + scope.as_ref(), )); } // HABTM lands when a fixture demands it. @@ -778,6 +780,7 @@ fn synth_has_one_reader( target: &ClassId, foreign_key: &Symbol, as_interface: Option<&Symbol>, + scope: Option<&Expr>, ) -> MethodDef { let mut entries = vec![( lit_sym(foreign_key.clone()), @@ -809,6 +812,10 @@ fn synth_has_one_reader( parenthesized: true, }, ); + let query = match scope { + Some(scope_expr) => graft_scope(scope_expr, query), + None => query, + }; let first = Expr::new( Span::synthetic(), ExprNode::Send { @@ -1666,6 +1673,39 @@ pub(super) fn push_dependent_destroy(methods: &mut Vec, model: &Model cascade.inherit_span(span); stmts.push(cascade); } + } else if let Association::HasOne { name, dependent, .. } = assoc { + if matches!(dependent, Dependent::Destroy) { + let reader = Expr::new( + Span::synthetic(), + ExprNode::Send { + recv: None, + method: name.clone(), + args: Vec::new(), + block: None, + parenthesized: false, + }, + ); + let destroy = Expr::new( + Span::synthetic(), + ExprNode::Send { + recv: Some(reader.clone()), + method: Symbol::from("destroy"), + args: Vec::new(), + block: None, + parenthesized: false, + }, + ); + let mut cascade = Expr::new( + Span::synthetic(), + ExprNode::If { + cond: reader, + then_branch: destroy, + else_branch: nil_lit(), + }, + ); + cascade.inherit_span(span); + stmts.push(cascade); + } } } diff --git a/src/lower/number_to_fs.rs b/src/lower/number_to_fs.rs index ace4c6fca..5c9254562 100644 --- a/src/lower/number_to_fs.rs +++ b/src/lower/number_to_fs.rs @@ -13,6 +13,10 @@ pub fn apply_number_to_fs_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, diff --git a/src/lower/pathname_ctor.rs b/src/lower/pathname_ctor.rs index 1cc5d047b..a9f42e033 100644 --- a/src/lower/pathname_ctor.rs +++ b/src/lower/pathname_ctor.rs @@ -38,7 +38,10 @@ pub fn apply_pathname_ctor_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: None, method, args, block: None, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/perform_all_later.rs b/src/lower/perform_all_later.rs index eef37362c..e73d9ebd6 100644 --- a/src/lower/perform_all_later.rs +++ b/src/lower/perform_all_later.rs @@ -24,12 +24,18 @@ use crate::app::App; use crate::expr::{Expr, ExprNode, LValue}; use crate::ident::Symbol; +#[allow(dead_code)] pub fn apply_perform_all_later_lowering(app: &mut App) { super::for_each_hook_body(app, &mut rewrite); } +#[allow(dead_code)] fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // Inline form: `ActiveJob.perform_all_later(xs.map { … })`. if let Some(arg) = perform_all_later_arg(expr) { if let Some(each) = map_to_each(arg) { diff --git a/src/lower/presence_in.rs b/src/lower/presence_in.rs index 336ba3a4f..a550a545a 100644 --- a/src/lower/presence_in.rs +++ b/src/lower/presence_in.rs @@ -33,6 +33,10 @@ pub fn apply_presence_in_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let span = expr.span; if let ExprNode::Send { recv, method, args, block, parenthesized } = &mut *expr.node { if method.as_str() == "presence_in" diff --git a/src/lower/rails_cache.rs b/src/lower/rails_cache.rs index 631c1a065..0e8fb8a1c 100644 --- a/src/lower/rails_cache.rs +++ b/src/lower/rails_cache.rs @@ -37,7 +37,10 @@ pub fn apply_rails_cache_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, args, block: Some(b), .. } = &*expr.node else { return; }; diff --git a/src/lower/random_formatter.rs b/src/lower/random_formatter.rs index e0433bd82..11305caf8 100644 --- a/src/lower/random_formatter.rs +++ b/src/lower/random_formatter.rs @@ -49,7 +49,10 @@ pub fn apply_random_formatter_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, .. } = &mut *expr.node else { return }; if !FORMATTER_ONLY.contains(&method.as_str()) { return; diff --git a/src/lower/relation_select_block.rs b/src/lower/relation_select_block.rs index 814361dca..dd43ed2bc 100644 --- a/src/lower/relation_select_block.rs +++ b/src/lower/relation_select_block.rs @@ -58,6 +58,10 @@ pub fn apply_relation_select_block_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/request_index.rs b/src/lower/request_index.rs index 635929015..1065a0a59 100644 --- a/src/lower/request_index.rs +++ b/src/lower/request_index.rs @@ -64,6 +64,10 @@ fn to_bare_params(e: &mut Expr) { fn rewrite_request_index(expr: &mut Expr) { expr.node .for_each_child_mut(&mut rewrite_request_index); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // Indexed READS are `Send "[]"` (`LValue::Index` is the write side, // and `request[k] = v` isn't a thing). if let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &mut *expr.node { diff --git a/src/lower/route_format_suffix.rs b/src/lower/route_format_suffix.rs index d1074a95b..a15cc020b 100644 --- a/src/lower/route_format_suffix.rs +++ b/src/lower/route_format_suffix.rs @@ -80,6 +80,10 @@ pub(crate) fn route_helper_names(app: &App) -> std::collections::HashSet fn rewrite(expr: &mut Expr, helpers: &std::collections::HashSet) { expr.node.for_each_child_mut(&mut |c| rewrite(c, helpers)); + rewrite_node(expr, helpers); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, helpers: &std::collections::HashSet) { let ExprNode::Send { recv: None, method, args, block: None, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/route_url_options.rs b/src/lower/route_url_options.rs index 6673b1d69..e87d2b0c0 100644 --- a/src/lower/route_url_options.rs +++ b/src/lower/route_url_options.rs @@ -124,7 +124,7 @@ pub fn apply_route_url_options_lowering(app: &mut App) { /// flattened routes share (lobsters' `/s/:id/(:title)` flattens to /// `/s/:id/:title` and `/s/:id`) answers its longest list — the helper /// the generator builds takes every slot. -fn helper_path_params(app: &App) -> std::collections::HashMap> { +pub(crate) fn helper_path_params(app: &App) -> std::collections::HashMap> { let mut out: std::collections::HashMap> = Default::default(); for route in super::routes::flatten_routes(app) { if !route.named { @@ -153,6 +153,13 @@ fn position_path_params( params: &std::collections::HashMap>, ) { expr.node.for_each_child_mut(&mut |c| position_path_params(c, params)); + rewrite_position_node(expr, params); +} + +pub(crate) fn rewrite_position_node( + expr: &mut Expr, + params: &std::collections::HashMap>, +) { let span = expr.span; let ExprNode::Send { recv: None, method, args, block: None, .. } = &mut *expr.node else { return; @@ -236,6 +243,10 @@ fn spread_options_local(arg: &Expr, index: usize, names: &[String]) -> Option) { expr.node.for_each_child_mut(&mut |c| rewrite(c, helpers)); + rewrite_node(expr, helpers); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, helpers: &std::collections::HashSet) { ground_symbol_query_values(expr, helpers); let Some((stem, host, protocol)) = strip_host_options(expr, helpers) else { return; diff --git a/src/lower/save_without_validation.rs b/src/lower/save_without_validation.rs index 45411997d..e02eeb7e4 100644 --- a/src/lower/save_without_validation.rs +++ b/src/lower/save_without_validation.rs @@ -39,6 +39,10 @@ pub fn apply_save_without_validation_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(_), method, args, block: None, .. } = &mut *expr.node else { return; }; diff --git a/src/lower/seeds_to_library/mod.rs b/src/lower/seeds_to_library/mod.rs index 999c51276..79b9fd80e 100644 --- a/src/lower/seeds_to_library/mod.rs +++ b/src/lower/seeds_to_library/mod.rs @@ -83,6 +83,21 @@ pub fn rewrite_assoc_create(expr: &Expr) -> Expr { } pub fn rewrite_assoc_create_with_models(expr: &Expr, models: &[crate::dialect::Model]) -> Expr { + crate::lower::controller_to_library::util::map_expr(expr, &|e| assoc_create_rewrite(e, models)) +} + +/// In-place twin. Returns whether any node changed so the test +/// lowerer can skip a follow-up typing pass. +pub fn rewrite_assoc_create_with_models_in_place( + expr: &mut Expr, + models: &[crate::dialect::Model], +) -> bool { + crate::lower::controller_to_library::util::map_expr_mut(expr, &|e| { + assoc_create_rewrite(e, models) + }) +} + +fn assoc_create_rewrite(e: &Expr, models: &[crate::dialect::Model]) -> Option { let declared_target = |parent: &str, assoc: &str| -> Option { let model = models.iter().find(|m| m.name.0.as_str() == parent)?; model.associations().find_map(|a| match a { @@ -94,100 +109,98 @@ pub fn rewrite_assoc_create_with_models(expr: &Expr, models: &[crate::dialect::M _ => None, }) }; - crate::lower::controller_to_library::util::map_expr(expr, &|e| { - let ExprNode::Send { - recv: Some(outer_recv), - method: outer_method, - args: outer_args, - block: None, - .. - } = &*e.node - else { - return None; - }; - let outer_method_str = outer_method.as_str(); - // `create` / `create!` map to the same-named class methods. - // `build` is Rails-specific (instantiate without saving) — - // map to `new` so Ruby/CRuby callers get `.new(attrs)` - // and the TS emitter renders that as `new (attrs)`. - let target_method = match outer_method_str { - "create" | "create!" => outer_method.clone(), - "build" => Symbol::from("new"), - _ => return None, - }; - let ExprNode::Send { - recv: Some(parent_expr), - method: assoc_method, - args: inner_args, + let ExprNode::Send { + recv: Some(outer_recv), + method: outer_method, + args: outer_args, + block: None, + .. + } = &*e.node + else { + return None; + }; + let outer_method_str = outer_method.as_str(); + // `create` / `create!` map to the same-named class methods. + // `build` is Rails-specific (instantiate without saving) — + // map to `new` so Ruby/CRuby callers get `.new(attrs)` + // and the TS emitter renders that as `new (attrs)`. + let target_method = match outer_method_str { + "create" | "create!" => outer_method.clone(), + "build" => Symbol::from("new"), + _ => return None, + }; + let ExprNode::Send { + recv: Some(parent_expr), + method: assoc_method, + args: inner_args, + block: None, + .. + } = &*outer_recv.node + else { + return None; + }; + if !inner_args.is_empty() { + return None; + } + // Resolve parent class from the typer-set type. Without the + // class type we can't derive the FK name; fall through. + let parent_class = match parent_expr.ty.as_ref() { + Some(Ty::Class { id, .. }) => id.0.as_str().to_string(), + _ => return None, + }; + let assoc_class = declared_target(&parent_class, assoc_method.as_str()) + .unwrap_or_else(|| crate::naming::singularize_camelize(assoc_method.as_str())); + let fk = format!("{}_id", crate::naming::snake_case(&parent_class)); + + // Build the FK-id expr: `.id`. + let parent_id = Expr::new( + parent_expr.span, + ExprNode::Send { + recv: Some(parent_expr.clone()), + method: Symbol::from("id"), + args: vec![], block: None, - .. - } = &*outer_recv.node - else { - return None; - }; - if !inner_args.is_empty() { - return None; + parenthesized: false, + }, + ); + let fk_entry = ( + Expr::new( + e.span, + ExprNode::Lit { value: crate::expr::Literal::Sym { value: Symbol::from(fk) } }, + ), + parent_id, + ); + + // Merge FK entry with original kwargs/hash. Real-blog's + // `article.comments.create!(commenter:, body:)` parses the + // trailing kwargs as a single Hash arg. + let merged_hash = match outer_args.first().map(|a| (&*a.node, a.span)) { + Some((ExprNode::Hash { entries, kwargs }, span)) => { + let mut new_entries = vec![fk_entry]; + new_entries.extend(entries.iter().cloned()); + Expr::new(span, ExprNode::Hash { entries: new_entries, kwargs: *kwargs }) } - // Resolve parent class from the typer-set type. Without the - // class type we can't derive the FK name; fall through. - let parent_class = match parent_expr.ty.as_ref() { - Some(Ty::Class { id, .. }) => id.0.as_str().to_string(), - _ => return None, - }; - let assoc_class = declared_target(&parent_class, assoc_method.as_str()) - .unwrap_or_else(|| crate::naming::singularize_camelize(assoc_method.as_str())); - let fk = format!("{}_id", crate::naming::snake_case(&parent_class)); + _ => Expr::new( + e.span, + ExprNode::Hash { entries: vec![fk_entry], kwargs: true }, + ), + }; - // Build the FK-id expr: `.id`. - let parent_id = Expr::new( - parent_expr.span, - ExprNode::Send { - recv: Some(parent_expr.clone()), - method: Symbol::from("id"), - args: vec![], - block: None, - parenthesized: false, - }, - ); - let fk_entry = ( - Expr::new( + Some(Expr::new( + e.span, + ExprNode::Send { + recv: Some(Expr::new( e.span, - ExprNode::Lit { value: crate::expr::Literal::Sym { value: Symbol::from(fk) } }, - ), - parent_id, - ); - - // Merge FK entry with original kwargs/hash. Real-blog's - // `article.comments.create!(commenter:, body:)` parses the - // trailing kwargs as a single Hash arg. - let merged_hash = match outer_args.first().map(|a| (&*a.node, a.span)) { - Some((ExprNode::Hash { entries, kwargs }, span)) => { - let mut new_entries = vec![fk_entry]; - new_entries.extend(entries.iter().cloned()); - Expr::new(span, ExprNode::Hash { entries: new_entries, kwargs: *kwargs }) - } - _ => Expr::new( - e.span, - ExprNode::Hash { entries: vec![fk_entry], kwargs: true }, - ), - }; - - Some(Expr::new( - e.span, - ExprNode::Send { - recv: Some(Expr::new( - e.span, - ExprNode::Const { - path: assoc_class.split("::").map(Symbol::from).collect(), - }, - )), - method: target_method, - args: vec![merged_hash], - block: None, - parenthesized: true, - }, - )) - }) + ExprNode::Const { + path: assoc_class.split("::").map(Symbol::from).collect(), + }, + )), + method: target_method, + args: vec![merged_hash], + block: None, + parenthesized: true, + }, + )) } // `Span` used in synthesized expressions above. diff --git a/src/lower/send_file.rs b/src/lower/send_file.rs index b91b3d1a4..ad51c3b46 100644 --- a/src/lower/send_file.rs +++ b/src/lower/send_file.rs @@ -45,6 +45,10 @@ const UNMODELED_OPTIONS: &[&str] = &["filename", "status", "url_based_filename", fn rewrite_send_file(e: &mut Expr) { e.node.for_each_child_mut(&mut rewrite_send_file); + rewrite_node(e); +} + +pub(crate) fn rewrite_node(e: &mut Expr) { let span = e.span; let ExprNode::Send { recv, method, args, block, .. } = &*e.node else { return }; if method.as_str() != "send_file" || args.is_empty() || args.len() > 2 || block.is_some() { diff --git a/src/lower/session_options.rs b/src/lower/session_options.rs index da41d26ed..b81dc3e88 100644 --- a/src/lower/session_options.rs +++ b/src/lower/session_options.rs @@ -75,6 +75,10 @@ fn session_options_base(e: &Expr) -> Option<&Expr> { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { // Indexed reads are `Send "[]"` (the write side is `LValue::Index`, // which is why `request.session_options[:skip] = true` can't match). let base = match &*expr.node { diff --git a/src/lower/status_literal.rs b/src/lower/status_literal.rs index ced71e2b2..5ca65315c 100644 --- a/src/lower/status_literal.rs +++ b/src/lower/status_literal.rs @@ -86,6 +86,10 @@ fn int_to_sym(e: &mut Expr) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: None, method, args, .. } = &mut *expr.node else { return }; match method.as_str() { "render" | "redirect_to" | "head" => {} diff --git a/src/lower/sti_is_a.rs b/src/lower/sti_is_a.rs index 373f705d2..09cf3100a 100644 --- a/src/lower/sti_is_a.rs +++ b/src/lower/sti_is_a.rs @@ -53,13 +53,22 @@ use crate::ty::Ty; /// The column Rails writes an STI row's class name into. const INHERITANCE_COLUMN: &str = "type"; +#[allow(dead_code)] pub fn apply_sti_is_a_lowering(app: &mut App) { - let bases = crate::lower::sti_scope::sti_bases(app); - if bases.is_empty() { + let names = subclass_type_names(app); + if names.is_empty() { return; } - // Subclass → every name a row of that class can carry in `type`: - // itself plus anything descending from it. + super::for_each_hook_body(app, &mut |e| rewrite(e, &names)); + for view in &mut app.views { + rewrite(&mut view.body, &names); + } +} + +/// Subclass → every name a row of that class can carry in `type`: +/// itself plus anything descending from it. +pub(crate) fn subclass_type_names(app: &App) -> HashMap> { + let bases = crate::lower::sti_scope::sti_bases(app); let mut names: HashMap> = HashMap::new(); for sub in bases.keys() { let mut set: Vec = vec![sub.0.as_str().to_string()]; @@ -71,10 +80,7 @@ pub fn apply_sti_is_a_lowering(app: &mut App) { set.sort(); names.insert(sub.clone(), set); } - super::for_each_hook_body(app, &mut |e| rewrite(e, &names)); - for view in &mut app.views { - rewrite(&mut view.body, &names); - } + names } /// Does `class_id`'s parent chain reach `ancestor`? @@ -104,6 +110,10 @@ fn descends_from(app: &App, class_id: &ClassId, ancestor: &ClassId) -> bool { fn rewrite(expr: &mut Expr, names: &HashMap>) { expr.node.for_each_child_mut(&mut |c| rewrite(c, names)); + rewrite_node(expr, names); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, names: &HashMap>) { let ExprNode::Send { recv, method, args, .. } = &*expr.node else { return }; if method.as_str() != "is_a?" && method.as_str() != "kind_of?" { return; diff --git a/src/lower/string_inflections.rs b/src/lower/string_inflections.rs index e1e95b6f3..4c654516c 100644 --- a/src/lower/string_inflections.rs +++ b/src/lower/string_inflections.rs @@ -23,6 +23,10 @@ fn is_string(ty: Option<&Ty>) -> bool { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &*expr.node else { return }; if !matches!(method.as_str(), "humanize" | "titleize") || !args.is_empty() || !is_string(r.ty.as_ref()) { return; diff --git a/src/lower/sum_symbol.rs b/src/lower/sum_symbol.rs index 196749d50..e47940851 100644 --- a/src/lower/sum_symbol.rs +++ b/src/lower/sum_symbol.rs @@ -14,6 +14,7 @@ use crate::app::App; use crate::expr::{Expr, ExprNode, Literal}; use crate::ident::Symbol; +#[allow(dead_code)] pub fn apply_sum_symbol_lowering(app: &mut App) { for model in &mut app.models { for item in &mut model.body { @@ -30,6 +31,10 @@ pub fn apply_sum_symbol_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let span = expr.span; let sym = match &*expr.node { ExprNode::Send { recv: Some(_), method, args, block: None, .. } diff --git a/src/lower/symbolize_keys.rs b/src/lower/symbolize_keys.rs index a89295b2e..03703e6ab 100644 --- a/src/lower/symbolize_keys.rs +++ b/src/lower/symbolize_keys.rs @@ -30,6 +30,7 @@ use crate::app::App; use crate::expr::{Expr, ExprNode}; use crate::ty::Ty; +#[allow(dead_code)] pub fn apply_symbolize_keys_grounding(app: &mut App) { super::for_each_hook_body(app, &mut rewrite); for view in &mut app.views { @@ -39,6 +40,10 @@ pub fn apply_symbolize_keys_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let replacement = match &mut *expr.node { ExprNode::Send { recv: Some(r), method, args, block: None, .. } if method.as_str() == "stringify_keys" && args.is_empty() => diff --git a/src/lower/system_exception.rs b/src/lower/system_exception.rs index d4c210303..f2e7c3455 100644 --- a/src/lower/system_exception.rs +++ b/src/lower/system_exception.rs @@ -17,12 +17,18 @@ use crate::app::App; use crate::expr::{BoolOpKind, BoolOpSurface, Expr, ExprNode, InterpPart, Literal}; use crate::ident::Symbol; +#[allow(dead_code)] pub fn apply_system_exception_lowering(app: &mut App) { super::for_each_hook_body(app, &mut rewrite); } +#[allow(dead_code)] fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::Send { recv: None, method, args, block: None, .. } = &*expr.node else { return; }; diff --git a/src/lower/test_cookie_jar.rs b/src/lower/test_cookie_jar.rs index 3864c6750..df8014377 100644 --- a/src/lower/test_cookie_jar.rs +++ b/src/lower/test_cookie_jar.rs @@ -21,6 +21,7 @@ use crate::app::App; use crate::expr::{Expr, ExprNode}; use crate::ident::Symbol; +#[allow(dead_code)] pub fn apply_test_cookie_jar_lowering(app: &mut App) { for tm in &mut app.test_modules { if let Some(setup) = &mut tm.setup { @@ -37,6 +38,10 @@ pub fn apply_test_cookie_jar_lowering(app: &mut App) { fn rewrite(e: &mut Expr) { e.node.for_each_child_mut(&mut rewrite); + rewrite_node(e); +} + +pub(crate) fn rewrite_node(e: &mut Expr) { if is_default_test_request_jar(e) { let jar = Expr::new( e.span, diff --git a/src/lower/test_module_to_library/inline_assertions.rs b/src/lower/test_module_to_library/inline_assertions.rs index 6e8f90874..2e2dca2c5 100644 --- a/src/lower/test_module_to_library/inline_assertions.rs +++ b/src/lower/test_module_to_library/inline_assertions.rs @@ -36,7 +36,7 @@ //! their helper bodies in `runtime/spinel/test/test_helper.rb` raise //! directly rather than delegating to a vacuous `assert`. -use crate::expr::{Expr, ExprNode, InterpPart, LValue, Literal, RescueClause}; +use crate::expr::{Expr, ExprNode, LValue, Literal, RescueClause}; use crate::ident::{Symbol, VarId}; use crate::span::Span; @@ -44,146 +44,57 @@ use crate::span::Span; /// assertion Sends to inline raise statements. Unrecognized Sends /// pass through unchanged. pub fn inline_assertions(body: &Expr) -> Expr { - map_expr(body) + let mut clone = body.clone(); + inline_assertions_in_place(&mut clone); + clone } -fn map_expr(e: &Expr) -> Expr { - // Recurse first (bottom-up). After children are rewritten, give - // the current node a chance to rewrite at this level. For Seq - // nodes, flatten any inlined Seq replacements so downstream - // emitters see a single flat statement list. - let inner = match &*e.node { - ExprNode::Seq { exprs } => { - let mut out: Vec = Vec::with_capacity(exprs.len()); - for child in exprs { - let mapped = map_expr(child); - if let ExprNode::Seq { exprs: nested } = &*mapped.node { - // Splice — a per-statement assertion rewrite that - // produces multiple statements (e.g. assert_difference) - // returns a Seq; flatten into the surrounding Seq so - // emit produces a clean statement list rather than a - // nested begin block. - out.extend(nested.iter().cloned()); - } else { - out.push(mapped); +/// In-place twin. Returns whether any assertion was rewritten (or a +/// nested Seq flattened as a rewrite's result) so the test lowerer +/// can skip a follow-up typing pass. +pub fn inline_assertions_in_place(body: &mut Expr) -> bool { + walk_inline(body) +} + +fn walk_inline(e: &mut Expr) -> bool { + if matches!(&*e.node, ExprNode::Seq { .. }) { + let exprs = match &mut *e.node { + ExprNode::Seq { exprs } => std::mem::take(exprs), + _ => unreachable!(), + }; + let mut out: Vec = Vec::with_capacity(exprs.len()); + let mut changed = false; + for mut child in exprs { + if walk_inline(&mut child) { + changed = true; + } + let node = std::mem::replace(&mut *child.node, ExprNode::SelfRef); + match node { + ExprNode::Seq { exprs: nested } => { + changed = true; + out.extend(nested); + } + other => { + *child.node = other; + out.push(child); } } - return Expr::new(e.span, ExprNode::Seq { exprs: out }); } - ExprNode::If { cond, then_branch, else_branch } => ExprNode::If { - cond: map_expr(cond), - then_branch: map_expr(then_branch), - else_branch: map_expr(else_branch), - }, - ExprNode::Case { scrutinee, arms } => ExprNode::Case { - scrutinee: map_expr(scrutinee), - arms: arms - .iter() - .map(|a| crate::expr::Arm { - pattern: a.pattern.clone(), - guard: a.guard.as_ref().map(map_expr), - body: map_expr(&a.body), - }) - .collect(), - }, - ExprNode::Send { recv, method, args, block, parenthesized } => ExprNode::Send { - recv: recv.as_ref().map(map_expr), - method: method.clone(), - args: args.iter().map(map_expr).collect(), - block: block.as_ref().map(map_expr), - parenthesized: *parenthesized, - }, - ExprNode::Apply { fun, args, block } => ExprNode::Apply { - fun: map_expr(fun), - args: args.iter().map(map_expr).collect(), - block: block.as_ref().map(map_expr), - }, - ExprNode::Lambda { rest_param, params, block_param, body, block_style } => ExprNode::Lambda { rest_param: rest_param.clone(), - params: params.clone(), - block_param: block_param.clone(), - body: map_expr(body), - block_style: *block_style, - }, - ExprNode::Assign { target, value } => ExprNode::Assign { - target: match target { - LValue::Attr { recv, name } => LValue::Attr { - recv: map_expr(recv), - name: name.clone(), - }, - LValue::Index { recv, index } => LValue::Index { - recv: map_expr(recv), - index: map_expr(index), - }, - other => other.clone(), - }, - value: map_expr(value), - }, - ExprNode::Let { id, name, value, body } => ExprNode::Let { - id: *id, - name: name.clone(), - value: map_expr(value), - body: map_expr(body), - }, - ExprNode::BoolOp { op, surface, left, right } => ExprNode::BoolOp { - op: *op, - surface: *surface, - left: map_expr(left), - right: map_expr(right), - }, - ExprNode::Return { value } => ExprNode::Return { value: map_expr(value) }, - ExprNode::Raise { value } => ExprNode::Raise { value: map_expr(value) }, - ExprNode::Yield { args } => ExprNode::Yield { - args: args.iter().map(map_expr).collect(), - }, - ExprNode::BeginRescue { body, rescues, else_branch, ensure, implicit } => { - ExprNode::BeginRescue { - body: map_expr(body), - rescues: rescues - .iter() - .map(|rc| RescueClause { - classes: rc.classes.iter().map(map_expr).collect(), - binding: rc.binding.clone(), - body: map_expr(&rc.body), - }) - .collect(), - else_branch: else_branch.as_ref().map(map_expr), - ensure: ensure.as_ref().map(map_expr), - implicit: *implicit, - } + let ExprNode::Seq { exprs } = &mut *e.node else { unreachable!() }; + *exprs = out; + return changed; + } + let mut changed = false; + e.node.for_each_child_mut(&mut |c| { + if walk_inline(c) { + changed = true; } - ExprNode::Hash { entries, kwargs } => ExprNode::Hash { - entries: entries - .iter() - .map(|(k, v)| (map_expr(k), map_expr(v))) - .collect(), - kwargs: *kwargs, - }, - ExprNode::Array { elements, style } => ExprNode::Array { - elements: elements.iter().map(map_expr).collect(), - style: *style, - }, - ExprNode::StringInterp { parts } => ExprNode::StringInterp { - parts: parts - .iter() - .map(|p| match p { - InterpPart::Text { value } => InterpPart::Text { value: value.clone() }, - InterpPart::Expr { expr } => InterpPart::Expr { expr: map_expr(expr) }, - }) - .collect(), - }, - _ => return rewrite_send(e).unwrap_or_else(|| e.clone()), - }; - let new_e = Expr { - span: e.span, - node: Box::new(inner), - ty: e.ty.clone(), - effects: e.effects.clone(), - leading_blank_line: e.leading_blank_line, - diagnostic: e.diagnostic.clone(), - hint: e.hint, - decisions: e.decisions, - }; - rewrite_send(&new_e).unwrap_or(new_e) + }); + if let Some(replacement) = rewrite_send(e) { + *e = replacement; + return true; + } + changed } /// Rewrite a bare-receiver `assert_*`/`refute_*` Send into an inline diff --git a/src/lower/test_module_to_library/mod.rs b/src/lower/test_module_to_library/mod.rs index 916ff9a0c..a39558541 100644 --- a/src/lower/test_module_to_library/mod.rs +++ b/src/lower/test_module_to_library/mod.rs @@ -241,13 +241,19 @@ pub fn lower_test_modules_with_inner( // return, and BEFORE the test bodies are typed against the // registry. A body the typer cannot name keeps the nil default // rather than gaining `untyped`; a test method is never a helper. + let synthesized_per_module: Vec> = test_modules + .iter() + .map(|tm| { + tm.helpers + .iter() + .filter(|h| h.signature.is_none()) + .map(|h| h.name.clone()) + .collect() + }) + .collect(); + let mut lifted_sigs_per_module = vec![false; all_lcs.len()]; for (idx, lc) in all_lcs.iter_mut().enumerate() { - let synthesized: std::collections::HashSet = test_modules[idx] - .helpers - .iter() - .filter(|h| h.signature.is_none()) - .map(|h| h.name.clone()) - .collect(); + let synthesized = &synthesized_per_module[idx]; if synthesized.is_empty() { continue; } @@ -268,6 +274,7 @@ pub fn lower_test_modules_with_inner( lifted.push((method.name.clone(), sig.clone())); } } + lifted_sigs_per_module[idx] = !lifted.is_empty(); if let Some(info) = classes.get_mut(&lc.name) { for (name, sig) in lifted { info.instance_methods.insert(name, sig); @@ -280,9 +287,27 @@ pub fn lower_test_modules_with_inner( // per-method loop. let blank_defs = crate::lower::blank::AppDefinitions::from_class_registry(&classes); - for (idx, lc) in all_lcs.iter_mut().enumerate() { + for (idx, mut lc) in all_lcs.into_iter().enumerate() { + let synthesized = &synthesized_per_module[idx]; + let lifted_sigs = lifted_sigs_per_module[idx]; for method in &mut lc.methods { - crate::lower::typing::type_method_body(method, &classes, &empty_ivars); + // Helpers with no declared signature were typed in the lift + // pass above, before sibling returns were in the registry. + // Retype them once those signatures exist. Skip the extra + // type only when this module lifted nothing. + if !synthesized.contains(&method.name) || lifted_sigs { + crate::lower::typing::type_method_body(method, &classes, &empty_ivars); + } + // Harvest from this first typed pass, before the rewrites. + // Assoc-create / route-id / assert / blank / header rewrites + // do not introduce ivar assignments, so the map is the same + // as harvesting after them — and one follow-up type then + // covers both rewritten nodes and ivar reads. Without the + // ivar seed, `@messages = ….to_a` binds nothing and + // `@messages.third` is a read off an untyped ivar. + let mut ivars: HashMap = HashMap::new(); + crate::analyze::extract_ivar_assignments(&method.body, &mut ivars); + ivars.retain(|_, ty| !ty.is_unknown()); // Has-many `.create` / `.build` rewrite needs the parent // expression's class type — must run AFTER the typer. // Statement-shape pass (no outer Assign) so it pairs with @@ -291,7 +316,8 @@ pub fn lower_test_modules_with_inner( // freshly-synthesized Sends/Hash entries get a `ty` — // `lowered_real_blog_typing_residual` enforces a // 0-untyped ceiling. - method.body = crate::lower::seeds_to_library::rewrite_assoc_create_with_models(&method.body, models); + let mut rewritten = crate::lower::seeds_to_library:: + rewrite_assoc_create_with_models_in_place(&mut method.body, models); // A record standing where a route helper wants an id. // Type-directed, so it must be here and not back where the // `RouteHelpers.` receiver was added: at THAT point a test @@ -300,8 +326,8 @@ pub fn lower_test_modules_with_inner( // a chain — neither is a shape the receiver-adding pass can // recognize, and both asserted a redirect to // `/rooms/#`. - method.body = crate::lower::controller_to_library::rewrites:: - project_route_helper_ids(&method.body); + rewritten |= crate::lower::controller_to_library::rewrites:: + project_route_helper_ids_in_place(&mut method.body); // Inline assert_*/refute_* sends — replaces vacuous // Minitest dispatch with real `raise` so spinel's // assertion-correctness signal is non-fake. See @@ -310,7 +336,7 @@ pub fn lower_test_modules_with_inner( // ExprNode::Raise as its native halt-with-message (Ruby // `raise`, Crystal `raise`, TS `throw`, …) — see the // issue's "Cross-target benefits" table. - method.body = inline_assertions::inline_assertions(&method.body); + rewritten |= inline_assertions::inline_assertions_in_place(&mut method.body); // Ground `blank?`/`present?`/`presence` by receiver type, // AFTER the assertion inlining that wraps them in a `raise // … if !(…)` and BEFORE the re-type that stamps the result. @@ -319,32 +345,24 @@ pub fn lower_test_modules_with_inner( // controller test files; on a strict target the dynamic // send is `undefined method 'present?' for an instance of // String` and takes every test behind it. - crate::lower::blank::ground_body(&mut method.body, &blank_defs); - lowercase_header_reads(&mut method.body); - crate::lower::typing::type_method_body(method, &classes, &empty_ivars); - // A test's ivars are bound in its own body — the setup is - // inlined ahead of every test — so they can be harvested - // from this one typed pass and the body re-typed with - // them, the way `type_inner_class` does for a stand-in - // class. Without it `@messages = ….to_a` binds nothing and - // `@messages.third` below is a read off an untyped ivar. - let mut ivars: HashMap = HashMap::new(); - crate::analyze::extract_ivar_assignments(&method.body, &mut ivars); - ivars.retain(|_, ty| !ty.is_unknown()); - if !ivars.is_empty() { + rewritten |= crate::lower::blank::ground_body(&mut method.body, &blank_defs); + rewritten |= lowercase_header_reads(&mut method.body); + if rewritten || !ivars.is_empty() { crate::lower::typing::type_method_body(method, &classes, &ivars); } // `second`…`fifth` on a typed Array — type-directed, so // here and not in the pre-typing pass over `app.test_modules`. - crate::lower::array_ordinal::rewrite_body(&mut method.body); + let mut ordinal = crate::lower::array_ordinal::rewrite_body(&mut method.body); // `squish` on a typed String receiver — type-directed for // the same reason, and after the ordinal rewrite for no // reason but that the two are one re-type apart. - crate::lower::enumerable_ext::rewrite_body(&mut method.body); - crate::lower::typing::type_method_body(method, &classes, &ivars); + ordinal |= crate::lower::enumerable_ext::rewrite_body(&mut method.body); + if ordinal { + crate::lower::typing::type_method_body(method, &classes, &ivars); + } } out.push(LoweredTestModule { - test_class: lc.clone(), + test_class: lc, inner_classes: std::mem::take(&mut typed_inner_per_module[idx]), constants: test_modules[idx].constants.clone(), }); @@ -370,11 +388,13 @@ pub fn lower_test_modules_with_inner( /// 2. Harvest ivar types from the typed bodies — direct `@x = v` /// assignments plus `self.x = v` setter calls (the latter carries /// the inherited AR primary-key `id`, set via `self.id = id`). -/// 3. Re-type every body with the harvested ivar bindings (so `@id`/ -/// `@title` reads resolve to Integer/String), then lift the -/// inferred body type into each synthesized signature's return -/// slot. `initialize` is pinned to a nil (void) return rather than -/// the type of its last assignment. +/// 3. If any ivar was harvested, re-type every body with those +/// bindings (so `@id`/`@title` reads resolve to Integer/String). +/// Then lift the inferred body type into each synthesized +/// signature's return slot. `initialize` is pinned to a nil (void) +/// return rather than the type of its last assignment. Skip the +/// retype when harvest found nothing — pass 1 already typed +/// against empty ivars. /// Copy a parent class's instance surface onto `info` for every name /// the subclass doesn't declare itself. Only the names are inherited — /// an override keeps its own entry, which `type_inner_class` then pins @@ -489,9 +509,12 @@ fn type_inner_class(inner: &mut LibraryClass, classes: &HashMap Ty { Ty::Class { id: ClassId(Symbol::from("ActionController::CookieJar")), args: vec![] } } +fn permanent_cookie_jar_ty() -> Ty { + Ty::Class { id: ClassId(Symbol::from("ActionController::PermanentCookieJar")), args: vec![] } +} + fn signed_cookie_jar_ty() -> Ty { Ty::Class { id: ClassId(Symbol::from("ActionController::SignedCookieJar")), args: vec![] } } @@ -916,7 +943,7 @@ fn insert_cookie_jar_baseline(classes: &mut HashMap) { ("raw", fn_sig(vec![key()], Ty::Str)), ("raw_set", fn_sig(vec![key(), value()], Ty::Str)), ("delete", fn_sig(vec![key()], Ty::Str)), - ("permanent", fn_sig(vec![], cookie_jar_ty())), + ("permanent", fn_sig(vec![], permanent_cookie_jar_ty())), ("signed", fn_sig(vec![], signed_cookie_jar_ty())), ("pending", fn_sig(vec![], str_hash.clone())), ("to_h", fn_sig(vec![], str_hash.clone())), @@ -971,6 +998,24 @@ fn insert_cookie_jar_baseline(classes: &mut HashMap) { signed.clone(), ); classes.insert(ClassId(Symbol::from("SignedCookieJar")), signed); + + // `cookies.permanent`: the unsigned jar's surface, writes expiring. + let mut permanent = ClassInfo::default(); + for (name, sig) in [ + ("[]", fn_sig(vec![key()], Ty::Str)), + ("[]=", fn_sig(vec![key(), value()], Ty::Str)), + ("delete", fn_sig(vec![key()], Ty::Str)), + ("signed", fn_sig(vec![], signed_cookie_jar_ty())), + ] { + let sym = Symbol::from(name); + permanent.instance_methods.insert(sym.clone(), sig); + permanent.instance_method_kinds.insert(sym, AccessorKind::Method); + } + classes.insert( + ClassId(Symbol::from("ActionController::PermanentCookieJar")), + permanent.clone(), + ); + classes.insert(ClassId(Symbol::from("PermanentCookieJar")), permanent); } /// Insert a `Minitest::Test` ClassInfo entry — the parent of every @@ -1006,19 +1051,29 @@ fn insert_minitest_test_baseline(classes: &mut HashMap) { /// the lookup Rails performs is the same for every spelling. Reads /// only, and only in test bodies; the controller's own /// `headers["X-Thing"] = …` writes are the app's. -fn lowercase_header_reads(expr: &mut Expr) { - expr.node.for_each_child_mut(&mut lowercase_header_reads); +fn lowercase_header_reads(expr: &mut Expr) -> bool { + let mut changed = false; + expr.node.for_each_child_mut(&mut |c| { + if lowercase_header_reads(c) { + changed = true; + } + }); let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &mut *expr.node else { - return; + return changed; }; if method.as_str() != "[]" || args.len() != 1 { - return; + return changed; } - let ExprNode::Send { method: inner, args: inner_args, .. } = &*recv.node else { return }; + let ExprNode::Send { method: inner, args: inner_args, .. } = &*recv.node else { return changed }; if inner.as_str() != "headers" || !inner_args.is_empty() { - return; + return changed; } if let ExprNode::Lit { value: crate::expr::Literal::Str { value } } = &mut *args[0].node { - *value = value.to_ascii_lowercase(); + let lower = value.to_ascii_lowercase(); + if lower != *value { + *value = lower; + return true; + } } + changed } diff --git a/src/lower/time_calendar.rs b/src/lower/time_calendar.rs index dbd23c8bb..2d7bccd33 100644 --- a/src/lower/time_calendar.rs +++ b/src/lower/time_calendar.rs @@ -14,6 +14,10 @@ pub fn apply_time_calendar_grounding(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { if let ExprNode::Send { recv: Some(r), method, .. } = &mut *expr.node { if is_time_const(r) && method.as_str() == "use_zone" { *r = Expr::new(r.span, ExprNode::Const { path: vec![Symbol::from("ActiveSupport")] }); diff --git a/src/lower/time_current.rs b/src/lower/time_current.rs index ef6d9fe17..a0d8f5565 100644 --- a/src/lower/time_current.rs +++ b/src/lower/time_current.rs @@ -97,6 +97,10 @@ pub fn apply_time_current_lowering(app: &mut App) { pub(crate) fn rewrite_time_current(expr: &mut Expr, formats: &TimeFormats) { expr.node .for_each_child_mut(&mut |c| rewrite_time_current(c, formats)); + rewrite_node(expr, formats); +} + +pub(crate) fn rewrite_node(expr: &mut Expr, formats: &TimeFormats) { let is_target = matches!( &*expr.node, ExprNode::Send { recv: Some(r), method, args, block: None, .. } diff --git a/src/lower/to_json.rs b/src/lower/to_json.rs index befe5be4b..6e05f4045 100644 --- a/src/lower/to_json.rs +++ b/src/lower/to_json.rs @@ -42,6 +42,10 @@ pub fn apply_to_json_lowering(app: &mut App) { fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let span = expr.span; let ExprNode::Send { recv, method, args, block, parenthesized } = &mut *expr.node else { return; diff --git a/src/lower/to_param_residue.rs b/src/lower/to_param_residue.rs index bc76aec55..013165aec 100644 --- a/src/lower/to_param_residue.rs +++ b/src/lower/to_param_residue.rs @@ -57,6 +57,10 @@ fn is_residue(ty: Option<&Ty>) -> bool { fn walk(expr: &mut Expr) { expr.node.for_each_child_mut(&mut walk); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { let ExprNode::StringInterp { parts } = &mut *expr.node else { return }; for part in parts.iter_mut() { let InterpPart::Expr { expr: e } = part else { continue }; diff --git a/src/lower/try_guard.rs b/src/lower/try_guard.rs index 25149511c..d3e3b9901 100644 --- a/src/lower/try_guard.rs +++ b/src/lower/try_guard.rs @@ -64,6 +64,7 @@ use crate::ty::Ty; /// model) collapses to ONE arm through its common base. const MAX_ARMS: usize = 3; +#[allow(dead_code)] pub fn apply_try_guard_lowering(app: &mut App) { let definers = collect_definers(app); let parents = collect_parents(app); @@ -112,7 +113,14 @@ fn rewrite( parents: &HashMap, ) { expr.node.for_each_child_mut(&mut |c| rewrite(c, definers, parents)); + rewrite_node(expr, definers, parents); +} +pub(crate) fn rewrite_node( + expr: &mut Expr, + definers: &HashMap>, + parents: &HashMap, +) { let ExprNode::Send { recv: Some(recv), method, args, block, .. } = &*expr.node else { return; }; @@ -338,7 +346,7 @@ fn is_a(span: crate::span::Span, recv: &Expr, klass: &Symbol) -> Expr { const SYNTHESIZED_ON_EVERY_MODEL: [&str; 1] = ["to_gid_param"]; /// method name -> the app classes defining it as an INSTANCE method. -fn collect_definers(app: &App) -> HashMap> { +pub(crate) fn collect_definers(app: &App) -> HashMap> { let mut out: HashMap> = HashMap::new(); for m in &app.models { let owner = m.name.0.clone(); @@ -365,7 +373,7 @@ fn collect_definers(app: &App) -> HashMap> { } /// class -> superclass, over models and library classes both. -fn collect_parents(app: &App) -> HashMap { +pub(crate) fn collect_parents(app: &App) -> HashMap { let mut out = HashMap::new(); for m in &app.models { if let Some(p) = &m.parent { diff --git a/src/lower/values_at_splat.rs b/src/lower/values_at_splat.rs index 258074b2c..169bd61fd 100644 --- a/src/lower/values_at_splat.rs +++ b/src/lower/values_at_splat.rs @@ -21,6 +21,10 @@ pub fn apply_values_at_splat_lowering(app: &mut App) { fn rewrite(e: &mut Expr) { e.node.for_each_child_mut(&mut rewrite); + rewrite_node(e); +} + +pub(crate) fn rewrite_node(e: &mut Expr) { let ExprNode::Send { recv: Some(recv), method, args, block: None, .. } = &*e.node else { return; }; diff --git a/src/lower/view_to_library/mod.rs b/src/lower/view_to_library/mod.rs index 879f7462a..c67a2aefb 100644 --- a/src/lower/view_to_library/mod.rs +++ b/src/lower/view_to_library/mod.rs @@ -63,20 +63,33 @@ pub fn lower_views_to_library_classes( app: &App, extras: Vec<(ClassId, crate::analyze::ClassInfo)>, ) -> Vec { - // Build LibraryClasses (with method signatures populated) but - // *skip* the per-view internal body-typing pass — we'll do it - // below with the merged registry. - // - // Only ERB (html-format) views go through this path. Jbuilder - // (json-format) views are lowered by `jbuilder_to_library`, - // which produces `_json` methods on the same view module. let vctx = ViewLowerCtx::new(app); - let mut lcs: Vec = views + let mut lcs = preliminary_view_classes(views, &vctx); + type_view_library_classes(&mut lcs, app, extras); + lcs +} + +/// Untyped view classes used only to seed model/controller registries +/// with `Views::*` method signatures. Body typing happens later in +/// [`type_view_library_classes`]. Callers that already built a +/// [`ViewLowerCtx`] should use this instead of a second `ViewLowerCtx::new` +/// plus a typed `lower` walk — signatures do not need typed bodies. +pub fn preliminary_view_classes(views: &[View], vctx: &ViewLowerCtx<'_>) -> Vec { + views .iter() .filter(|v| crate::lower::view::lowers_through_view_path(v)) .map(|v| vctx.lower_untyped(v)) - .collect(); + .collect() +} +/// Type untyped view LibraryClasses against a merged extras registry. +/// Mutates `lcs` in place so a preliminary untyped pass can be reused +/// instead of constructing the classes a second time. +pub fn type_view_library_classes( + lcs: &mut Vec, + app: &App, + extras: Vec<(ClassId, crate::analyze::ClassInfo)>, +) { // Merge: caller extras + framework runtime stubs + view modules // themselves (so cross-view dispatch like Views::Articles.article // resolves from one view to another). @@ -87,7 +100,7 @@ pub fn lower_views_to_library_classes( } insert_framework_stubs(&mut classes); insert_route_helper_stubs(&mut classes, app); - for lc in &lcs { + for lc in lcs.iter() { let info = classes.entry(lc.name.clone()).or_default(); for m in &lc.methods { if let Some(sig) = &m.signature { @@ -127,16 +140,15 @@ pub fn lower_views_to_library_classes( // defines. Here, after every pass that pattern-matches the bare // shape in URL position, is the last moment the two spellings can // be made one. - crate::lower::route_helper_receiver::qualify_lcs(&mut lcs, app); + crate::lower::route_helper_receiver::qualify_lcs(lcs, app); let empty_ivars: std::collections::HashMap = std::collections::HashMap::new(); - for lc in &mut lcs { + for lc in lcs.iter_mut() { for method in &mut lc.methods { crate::lower::typing::type_method_body(method, &classes, &empty_ivars); } } - lcs } /// Migration entry point: lower views to `LibraryFunction`s, the diff --git a/src/lower/webmock.rs b/src/lower/webmock.rs index a8d78821a..8d67bfa12 100644 --- a/src/lower/webmock.rs +++ b/src/lower/webmock.rs @@ -128,6 +128,10 @@ fn http_stub_call(span: crate::span::Span, method: &str, args: Vec) -> Exp /// outermost send and `stub_request` its receiver. fn rewrite(expr: &mut Expr) { expr.node.for_each_child_mut(&mut rewrite); + rewrite_node(expr); +} + +pub(crate) fn rewrite_node(expr: &mut Expr) { if let Some(r) = replacement_for(expr) { *expr = r; } diff --git a/src/project.rs b/src/project.rs index 1f04774cf..fd874e59f 100644 --- a/src/project.rs +++ b/src/project.rs @@ -703,7 +703,29 @@ fn widen_key_contract(app: &App, files: &mut [(String, String)]) -> Result<(), S /// 2. Terminals (`first` / `find_by` / …) returning `Base` / `Base?` — /// callers expect a concrete model (`User*`); Spinel will not convert /// Base → User. -fn spinel_relation_model_handle(files: &mut [(String, String)]) -> Result<(), String> { +/// Widen `Base` on def / ivar lines only. Comments stay. `Base?` before +/// bare `Base` so `-> Base?` becomes `untyped`, not `untyped?`. +fn widen_spinel_base_on_sigs(src: &str) -> String { + let mut widened = String::new(); + for line in src.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("def ") || trimmed.starts_with('@') { + widened.push_str( + &line + .replace("Array[Base]", "Array[untyped]") + .replace("Set[Base]", "Set[untyped]") + .replace("Base?", "untyped") + .replace("Base", "untyped"), + ); + } else { + widened.push_str(line); + } + widened.push('\n'); + } + widened +} + +pub fn spinel_relation_model_handle(files: &mut [(String, String)]) -> Result<(), String> { let idx = files .iter() .position(|(p, _)| { @@ -714,31 +736,17 @@ fn spinel_relation_model_handle(files: &mut [(String, String)]) -> Result<(), St "spinel_relation_model_handle: active_record/relation.rbs not in the tree".to_string() })?; let relation = &mut files[idx].1; + // Exact pairs: find_by conditions widen past `Base`. Spawn no longer + // takes a records param (`take_query_lists` + clone). let replacements = [ ( - " def initialize: (Base model) -> void\n", - " def initialize: (untyped model) -> void\n", - ), - (" def first: () -> Base?\n", " def first: () -> untyped\n"), - (" def take: () -> Base?\n", " def take: () -> untyped\n"), - (" def first!: () -> Base\n", " def first!: () -> untyped\n"), - (" def last: () -> Base?\n", " def last: () -> untyped\n"), - ( - " def find_by: (untyped conditions) -> Base?\n", + " def find_by: (Hash[Symbol, untyped] | String | nil conditions) -> Base?\n", " def find_by: (untyped conditions) -> untyped\n", ), ( - " def find_by!: (untyped conditions) -> Base\n", + " def find_by!: (Hash[Symbol, untyped] | String | nil conditions) -> Base\n", " def find_by!: (untyped conditions) -> untyped\n", ), - ( - " def first_or_initialize: () -> Base\n", - " def first_or_initialize: () -> untyped\n", - ), - ( - " def find_or_create_by: (Hash[Symbol, untyped] conditions) -> Base\n", - " def find_or_create_by: (Hash[Symbol, untyped] conditions) -> untyped\n", - ), ]; for (narrow, wide) in replacements { if !relation.contains(narrow) { @@ -748,6 +756,39 @@ fn spinel_relation_model_handle(files: &mut [(String, String)]) -> Result<(), St } *relation = relation.replace(narrow, wide); } + *relation = widen_spinel_base_on_sigs(relation); + + let conn_idx = files + .iter() + .position(|(p, _)| { + p == "sig/runtime/active_record/connection.rbs" + || p == "runtime/active_record/connection.rbs" + }) + .ok_or_else(|| { + "spinel_relation_model_handle: active_record/connection.rbs not in the tree" + .to_string() + })?; + let conn = &mut files[conn_idx].1; + // Dynamic `Model.first` / `Model.take` live here, not on Relation. + let conn_pairs = [ + ( + " def self.first: () -> Base?\n", + " def self.first: () -> untyped\n", + ), + ( + " def self.take: () -> Base?\n", + " def self.take: () -> untyped\n", + ), + ]; + for (narrow, wide) in conn_pairs { + if !conn.contains(narrow) { + return Err(format!( + "spinel_relation_model_handle: connection.rbs no longer declares {narrow:?}" + )); + } + *conn = conn.replace(narrow, wide); + } + *conn = widen_spinel_base_on_sigs(conn); Ok(()) } @@ -934,8 +975,11 @@ fn reject_unsupported_forwarded_procs(app: &App, target: BuildTarget) -> Result< { // These shapes predate the arbitrary-expression fallback; // their existing target-specific paths remain unchanged. + // The bytes primitive implements literal &nil as no block. Use + // its shared classifier, without accepting other &expr calls. if !matches!(&*block.node, ExprNode::Lambda { .. } | ExprNode::Var { .. } - | ExprNode::MethodRef { .. }) { + | ExprNode::MethodRef { .. }) + && !crate::emit::shared::string_bytes::materializes_array(e) { *found = true; crate::emit::diagnostics::report_unsupported( block.span, target, "forwarded_proc", @@ -1180,7 +1224,7 @@ pub fn target_files( } let files = crate::timings::phase(format_args!("emit {}: assemble", target.as_str()), || match target { BuildTarget::Blog => blog_files(fixture), - BuildTarget::Spinel => spinel_files(app, fixture).and_then(|(mut files, _)| { + BuildTarget::Spinel => spinel_files_with_source_markers(app, fixture).and_then(|(mut files, _)| { spinel_relation_model_handle(&mut files)?; spin_shape(files) }), @@ -3662,7 +3706,7 @@ pub fn spinel_base_files(app: &App, fixture: &Path) -> Result` markers naming the app +/// `.rb`/`.erb` each emitted line came from (spinel#7630), so `#line`, +/// debug stepping and perf report app positions rather than lowered +/// Ruby. Spinel-only: the ruby family shares `spinel_files` and keeps +/// its output unmarked. See `emit::ruby::source_markers`. +fn spinel_files_with_source_markers( + app: &App, + fixture: &Path, +) -> Result<(Vec<(String, String)>, Vec), String> { + let (mut files, stems) = + emit::ruby::source_markers::with_source_markers(app, || spinel_files(app, fixture))?; + for (path, content) in files.iter_mut() { + if path.ends_with(".rb") { + *content = emit::ruby::source_markers::finish(content); + } + } + Ok((files, stems)) +} + fn spinel_files(app: &App, fixture: &Path) -> Result<(Vec<(String, String)>, Vec), String> { let mut files: Vec<(String, String)> = Vec::new(); @@ -4206,6 +4269,7 @@ fn spinel_files(app: &App, fixture: &Path) -> Result<(Vec<(String, String)>, Vec "inflector_ext", "json_builder", "active_support_ext", + "security_utils", "params", "action_text", "active_storage", @@ -8159,6 +8223,55 @@ mod tests { ); } + /// The spinel tree names the app source each emitted line came from + /// (`#`, spinel#7630): a view's `<%= %>` tag reports + /// its ERB line, at column 0 where Spinel matches the marker. The + /// ruby family shares `spinel_files` and stays unmarked. + #[test] + fn spinel_tree_marks_app_source_lines() { + let fixture = Path::new("fixtures/real-blog"); + if !fixture.is_dir() { + eprintln!("skip: fixtures/real-blog absent"); + return; + } + let app = crate::ingest::ingest_app(fixture).expect("ingest real-blog"); + let files = spinel_base_files(&app, fixture).expect("spinel base files"); + let view = files + .iter() + .find(|(p, _)| p == "app/views/articles/_article.rb") + .map(|(_, c)| c.as_str()) + .expect("_article.rb in spinel tree"); + let lines: Vec<&str> = view.lines().collect(); + let title = lines + .iter() + .position(|l| l.contains("article.title")) + .expect("the link_to article.title line"); + assert_eq!( + lines[title - 1], + "#real-blog/app/views/articles/_article.html.erb:4", + "{view}" + ); + // The def line itself is marked: Spinel names a --debug + // backtrace frame by it (spinel#7658), and unmarked it would + // report the previous method's held position. + let def = lines + .iter() + .position(|l| l.trim_start().starts_with("def self.article_into")) + .expect("the article_into def"); + assert_eq!( + lines[def - 1], + "#real-blog/app/views/articles/_article.html.erb:1", + "{view}" + ); + let ruby = ruby_runtime_files(&app, fixture).expect("ruby tree"); + let marked: Vec<&str> = ruby + .iter() + .filter(|(_, c)| c.contains("#")) + .map(|(p, _)| p.as_str()) + .collect(); + assert!(marked.is_empty(), "ruby tree carries spinel markers: {marked:?}"); + } + /// Shared relation.rbs types `initialize` / terminals as `Base` for /// Roundhouse Bar A/B; Spinel emit must rewrite those to `untyped` /// or campfire AOT rejects class handles and User* slots. @@ -8184,10 +8297,59 @@ mod tests { relation.contains("def first: () -> untyped"), "Spinel must not keep first:()->Base?: {relation}" ); + assert!( + relation.contains("def to_a: () -> Array[untyped]"), + "Spinel must not keep to_a:()->Array[Base]: {relation}" + ); + assert!( + relation.contains("def detect: () { (untyped) -> bool } -> untyped"), + "Spinel must not keep detect:()->Base?: {relation}" + ); + assert!( + relation.contains("def to_set: () -> Set[untyped]"), + "Spinel must not keep to_set:()->Set[Base]: {relation}" + ); + assert!( + relation.contains("def each_with_object: (untyped memo) { (untyped, untyped) -> untyped }"), + "Spinel must not keep each_with_object Base block: {relation}" + ); + assert!( + relation.contains("def take_query_lists:"), + "Spinel must keep #462 take_query_lists: {relation}" + ); + let leftover: Vec<&str> = relation + .lines() + .filter(|l| l.trim_start().starts_with("def ") && l.contains("Base")) + .collect(); + assert!( + leftover.is_empty(), + "Spinel def lines still spell Base: {leftover:?}" + ); assert!( relation.contains("def find_by: (untyped conditions) -> untyped"), "Spinel must not keep find_by:()->Base?: {relation}" ); + let connection = files + .iter() + .find(|(p, _)| p.ends_with("active_record/connection.rbs")) + .map(|(_, c)| c.as_str()) + .expect("connection.rbs in spinel tree"); + assert!( + connection.contains("def self.first: () -> untyped"), + "Spinel must not keep Base.first:()->Base?: {connection}" + ); + assert!( + connection.contains("def self.take: () -> untyped"), + "Spinel must not keep Base.take:()->Base?: {connection}" + ); + let conn_leftover: Vec<&str> = connection + .lines() + .filter(|l| l.trim_start().starts_with("def ") && l.contains("Base")) + .collect(); + assert!( + conn_leftover.is_empty(), + "Spinel connection def lines still spell Base: {conn_leftover:?}" + ); // Shared source still spells Base for Roundhouse Bar B. let shared = crate::runtime_files::read_to_string("runtime/ruby/active_record/relation.rbs") .expect("shared relation.rbs"); @@ -8210,5 +8372,14 @@ mod tests { ruby_relation.contains("def first: () -> Base?"), "CRuby relation.rbs must keep first:()->Base?: {ruby_relation}" ); + let ruby_connection = ruby + .iter() + .find(|(p, _)| p.ends_with("active_record/connection.rbs")) + .map(|(_, c)| c.as_str()) + .expect("connection.rbs in ruby tree"); + assert!( + ruby_connection.contains("def self.first: () -> Base?"), + "CRuby connection.rbs must keep Base.first:()->Base?: {ruby_connection}" + ); } } diff --git a/src/runtime_loader.rs b/src/runtime_loader.rs index e375aa443..64f4635f7 100644 --- a/src/runtime_loader.rs +++ b/src/runtime_loader.rs @@ -670,6 +670,7 @@ const RUST_RUNTIME: &[RuntimeEntry] = &[ ("ParamValue", "param_value"), ("raise", "errors_ext"), ("NotImplementedError", "errors_ext"), + ("ArgumentError", "errors_ext"), ], prelude: NO_PRELUDE, extra_roots: NO_EXTRA_ROOTS, @@ -1856,7 +1857,7 @@ const PYTHON_RUNTIME: &[RuntimeEntry] = &[ namespace: "ActionController", out_path: "app/action_controller_base.py", mode: Mode::Library, - imports: &[("Flash", "app.flash"), ("Session", "app.session")], + imports: &[("re", ""), ("Flash", "app.flash"), ("Session", "app.session")], prelude: NO_PRELUDE, extra_roots: NO_EXTRA_ROOTS, }, diff --git a/src/runtime_src.rs b/src/runtime_src.rs index c1face4fc..a5f15b8a8 100644 --- a/src/runtime_src.rs +++ b/src/runtime_src.rs @@ -422,6 +422,12 @@ pub fn parse_library_with_rbs( let mut library_classes = ingest_library_classes(ruby_src, file) .map_err(|e| format!("ingest_library_classes: {e:?}"))?; let sigs_by_class = crate::rbs::parse_app_signatures(rbs_src)?; + // `@ivar: T` decls (e.g. HeaderStore `@keys: Array[String]`). Pass B + // seeds these so empty `[]`/`{}` initializers stamp via + // `propagate_expected_to_empty_container` — without this, Go/Kotlin/ + // C#/Swift emit `[]interface{}` / `MutableList` and fail when + // `[]` returns `String?`. + let rbs_ivars_by_class = crate::rbs::parse_app_ivars(rbs_src)?; // The class-grouped sig parser doesn't carry the `%a{abstract}` // annotation; the flat parser does. Use the flat result purely as @@ -610,6 +616,14 @@ pub fn parse_library_with_rbs( for m in &lc.methods { crate::analyze::extract_ivar_assignments(&m.body, &mut flow_ivars); } + // RBS `@ivar: T` wins over body-inferred `Array[Var]` / + // `Hash` from empty literals. Same contract as the + // getter override below — declared type, not initializer shape. + if let Some(declared) = rbs_ivars_by_class.get(&lc.name) { + for (name, ty) in declared { + flow_ivars.insert(name.clone(), ty.clone()); + } + } // Override flow-inferred ivar types with RBS-declared // attr_accessor getter return types. `@params = {}` infers // `Hash` from the body; the RBS getter says @@ -1908,4 +1922,48 @@ mod tests { // The If as a whole unions its branches (both StringInterp → Str). assert_eq!(m.body.ty.as_ref(), Some(&Ty::Str)); } + + /// RBS `@keys`/`@vals` must stamp empty `[]` initializers so + /// Go/Kotlin/C#/Swift emit typed slices/lists, not `interface{}`/`Any?`. + #[test] + fn rbs_ivar_decls_stamp_empty_array_initializers() { + let ruby = include_bytes!("../runtime/ruby/action_controller/base.rb"); + let rbs = include_str!("../runtime/ruby/action_controller/base.rbs"); + let classes = parse_library_with_rbs(ruby, rbs, "action_controller/base.rb") + .expect("action_controller/base parses and types"); + let hs = classes + .iter() + .find(|c| c.name.0.as_str() == "ActionController::HeaderStore") + .expect("HeaderStore class"); + let init = hs + .methods + .iter() + .find(|m| m.name.as_str() == "initialize") + .expect("initialize"); + fn ivar_assign_ty<'a>(e: &'a crate::expr::Expr, name: &str) -> Option<&'a Ty> { + use crate::expr::{ExprNode, LValue}; + match &*e.node { + ExprNode::Assign { + target: LValue::Ivar { name: n }, + value, + } if n.as_str() == name => value.ty.as_ref(), + ExprNode::Seq { exprs } => exprs.iter().find_map(|x| ivar_assign_ty(x, name)), + _ => None, + } + } + match ivar_assign_ty(&init.body, "keys") { + Some(Ty::Array { elem }) => assert!( + matches!(elem.as_ref(), Ty::Str), + "expected Array[String], got Array[{elem:?}]" + ), + other => panic!("@keys = [] must carry Array[String], got {other:?}"), + } + match ivar_assign_ty(&init.body, "vals") { + Some(Ty::Array { elem }) => assert!( + matches!(elem.as_ref(), Ty::Str), + "expected Array[String], got Array[{elem:?}]" + ), + other => panic!("@vals = [] must carry Array[String], got {other:?}"), + } + } } diff --git a/src/session.rs b/src/session.rs index c545dd577..bc025165e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -43,5 +43,7 @@ use crate::diagnostic::Diagnostic; pub fn analyze_and_lower(app: &mut App) -> Vec { let mut analyzer = crate::analyze::Analyzer::new(app); analyzer.analyze(app); - crate::lower::apply_post_analyze_lowerings(app, analyzer.class_registry()) + crate::timings::phase("post-analyze lowerings", || { + crate::lower::apply_post_analyze_lowerings(app, analyzer.class_registry()) + }) } diff --git a/src/slim.rs b/src/slim.rs index 60e5067a5..5930008e5 100644 --- a/src/slim.rs +++ b/src/slim.rs @@ -142,10 +142,18 @@ fn line_to_ruby( } b if b.is_ascii_alphabetic() => { if let Some(rest) = content.strip_prefix("doctype") { - if rest.trim() != "html" { - gap(format!("slim doctype not supported: {}", rest.trim())); + let xhtml = |kind: &str, dtd: &str| { + format!("\n") + }; + match rest.trim() { + "html" | "5" => c.text("\n"), + "transitional" => c.text(&xhtml("Transitional", "xhtml1-transitional")), + "strict" => c.text(&xhtml("Strict", "xhtml1-strict")), + other => { + gap(format!("slim doctype not supported: {other}")); + c.text("\n"); + } } - c.text("\n"); } else if let Some(name) = embedded_engine(content) { if name == "ruby" { skip_frame(c, indent, Capture::RawRuby); @@ -431,7 +439,9 @@ fn element( c.out.push_str(&ruby_string_literal(rest)); c.seg(c_start, off, off + rest.len()); c.out.push('\n'); - c.text(&format!("")); + // Deeper lines continue the inline text; the frame closes the tag. + c.stack.push(Frame { indent, close: Close::Tag(tag), capture: Capture::None, ruby_block: false }); + *text_block = Some(indent); } } @@ -507,10 +517,19 @@ mod tests { assert!(r.contains("")); } + #[test] + fn inline_text_continues_on_deeper_lines() { + let r = ruby("p one\n two\n three\nspan x\n"); + assert!(r.contains("\"two\\n\"") && r.contains("\"three\\n\""), "got:\n{r}"); + assert!(r.find("

").unwrap() > r.find("three").unwrap(), "got:\n{r}"); + assert!(r.find("

").unwrap() < r.find("").unwrap(), "got:\n{r}"); + } + #[test] fn doctype_and_ruby_engine() { let r = ruby("doctype html\nruby:\n x = 1\n"); assert!(r.contains(""), "got:\n{r}"); + assert!(ruby("doctype transitional\n").contains("XHTML 1.0 Transitional")); assert!(r.contains("x = 1\n") && !r.contains("\"x = 1"), "got:\n{r}"); } diff --git a/src/timings.rs b/src/timings.rs index ce2c68db1..b95cc50c8 100644 --- a/src/timings.rs +++ b/src/timings.rs @@ -36,20 +36,40 @@ fn peak_rss_mb() -> Option { /// Run a phase and print measurements only when requested. pub fn phase(name: impl std::fmt::Display, f: impl FnOnce() -> T) -> T { + let _guard = begin(name); + f() +} + +/// Start a timed span that prints when dropped. `None` when timings are off. +pub fn begin(name: impl std::fmt::Display) -> Option { if !*ENABLED { - return f(); + return None; } - let start = Instant::now(); - let output = f(); - match peak_rss_mb() { - Some(mb) => eprintln!( - "roundhouse-timing: {name}: {:.2}s (peak rss {mb} MB)", - start.elapsed().as_secs_f64(), - ), - None => eprintln!( - "roundhouse-timing: {name}: {:.2}s (peak rss unavailable)", - start.elapsed().as_secs_f64(), - ), + Some(Guard { + name: name.to_string(), + start: Instant::now(), + }) +} + +/// Prints one `roundhouse-timing:` line on drop. +pub struct Guard { + name: String, + start: Instant, +} + +impl Drop for Guard { + fn drop(&mut self) { + match peak_rss_mb() { + Some(mb) => eprintln!( + "roundhouse-timing: {}: {:.2}s (peak rss {mb} MB)", + self.name, + self.start.elapsed().as_secs_f64(), + ), + None => eprintln!( + "roundhouse-timing: {}: {:.2}s (peak rss unavailable)", + self.name, + self.start.elapsed().as_secs_f64(), + ), + } } - output } diff --git a/src/ty.rs b/src/ty.rs index 2ca818167..b9953d221 100644 --- a/src/ty.rs +++ b/src/ty.rs @@ -372,13 +372,136 @@ impl Ty { /// Sort a flattened variant list into the canonical order: `Nil` /// last (so nilable unions keep reading `T | Nil`), everything else - /// by its structural `Debug` rendering — an arbitrary but total and - /// stable key. Two unions built from the same variants in any join - /// order compare equal under derived `==` only because of this; - /// `analyze`'s lattice join (`union_of`) relies on it for fixpoint - /// convergence. + /// by a structural total order. Two unions built from the same + /// variants in any join order compare equal under derived `==` only + /// because of this; `analyze`'s lattice join (`union_of`) relies on + /// it for fixpoint convergence. + /// + /// Allocating a `Debug` string per variant on every join was the + /// previous key; Campfire's analyzer fixpoint does this each round. pub(crate) fn canonicalize_variants(variants: &mut [Ty]) { - variants.sort_by_cached_key(|v| (matches!(v, Ty::Nil), format!("{v:?}"))); + variants.sort_by(cmp_ty_nil_last); + } +} + +fn cmp_ty_nil_last(a: &Ty, b: &Ty) -> std::cmp::Ordering { + match (matches!(a, Ty::Nil), matches!(b, Ty::Nil)) { + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + _ => cmp_ty(a, b), + } +} + +fn ty_tag(ty: &Ty) -> u8 { + match ty { + Ty::Int => 0, + Ty::Float => 1, + Ty::Bool => 2, + Ty::Str => 3, + Ty::Sym => 4, + Ty::Date => 5, + Ty::Time => 6, + Ty::Relation { .. } => 7, + Ty::Array { .. } => 8, + Ty::Hash { .. } => 9, + Ty::Tuple { .. } => 10, + Ty::Record { .. } => 11, + Ty::Union { .. } => 12, + Ty::SelfInstance => 13, + Ty::Class { .. } => 14, + Ty::Fn { .. } => 15, + Ty::Var { .. } => 16, + Ty::Untyped => 17, + Ty::Bottom => 18, + Ty::Nil => 19, + } +} + +fn cmp_ty(a: &Ty, b: &Ty) -> std::cmp::Ordering { + use std::cmp::Ordering; + ty_tag(a).cmp(&ty_tag(b)).then_with(|| match (a, b) { + (Ty::Relation { of: x }, Ty::Relation { of: y }) => x.cmp(y), + (Ty::Array { elem: x }, Ty::Array { elem: y }) => cmp_ty(x, y), + (Ty::Hash { key: kx, value: vx }, Ty::Hash { key: ky, value: vy }) => { + cmp_ty(kx, ky).then_with(|| cmp_ty(vx, vy)) + } + (Ty::Tuple { elems: x }, Ty::Tuple { elems: y }) => cmp_ty_slice(x, y), + (Ty::Record { row: x }, Ty::Record { row: y }) => cmp_row(x, y), + (Ty::Union { variants: x }, Ty::Union { variants: y }) => cmp_ty_slice(x, y), + (Ty::Class { id: ix, args: ax }, Ty::Class { id: iy, args: ay }) => { + ix.cmp(iy).then_with(|| cmp_ty_slice(ax, ay)) + } + ( + Ty::Fn { params: px, block: bx, ret: rx, effects: ex }, + Ty::Fn { params: py, block: by, ret: ry, effects: ey }, + ) => cmp_params(px, py) + .then_with(|| match (bx, by) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(x), Some(y)) => cmp_ty(x, y), + }) + .then_with(|| cmp_ty(rx, ry)) + .then_with(|| ex.effects.cmp(&ey.effects)), + (Ty::Var { var: x }, Ty::Var { var: y }) => x.cmp(y), + _ => Ordering::Equal, + }) +} + +fn cmp_ty_slice(a: &[Ty], b: &[Ty]) -> std::cmp::Ordering { + a.len().cmp(&b.len()).then_with(|| { + a.iter() + .zip(b) + .map(|(x, y)| cmp_ty(x, y)) + .find(|o| *o != std::cmp::Ordering::Equal) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + +fn cmp_row(a: &Row, b: &Row) -> std::cmp::Ordering { + a.fields + .len() + .cmp(&b.fields.len()) + .then_with(|| { + a.fields + .iter() + .zip(b.fields.iter()) + .map(|((ka, va), (kb, vb))| ka.cmp(kb).then_with(|| cmp_ty(va, vb))) + .find(|o| *o != std::cmp::Ordering::Equal) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| a.rest.cmp(&b.rest)) +} + +fn cmp_params(a: &[Param], b: &[Param]) -> std::cmp::Ordering { + a.len().cmp(&b.len()).then_with(|| { + a.iter() + .zip(b) + .map(|(x, y)| { + x.name + .cmp(&y.name) + .then_with(|| cmp_ty(&x.ty, &y.ty)) + .then_with(|| param_kind_tag(&x.kind).cmp(¶m_kind_tag(&y.kind))) + .then_with(|| match (&x.kind, &y.kind) { + (ParamKind::Keyword { required: ra }, ParamKind::Keyword { required: rb }) => { + ra.cmp(rb) + } + _ => std::cmp::Ordering::Equal, + }) + }) + .find(|o| *o != std::cmp::Ordering::Equal) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + +fn param_kind_tag(kind: &ParamKind) -> u8 { + match kind { + ParamKind::Required => 0, + ParamKind::Optional => 1, + ParamKind::Rest => 2, + ParamKind::Keyword { .. } => 3, + ParamKind::KeywordRest => 4, + ParamKind::Block => 5, } } diff --git a/tests/analyze.rs b/tests/analyze.rs index 046096170..50bbf8317 100644 --- a/tests/analyze.rs +++ b/tests/analyze.rs @@ -1589,8 +1589,8 @@ fn index_ivar_ty(app: &roundhouse::App, name: &str) -> Ty { /// /// lobsters' `Search` is the shape: a PORO with `attr_accessor :page`, /// living in `app/models`. Because an instance receiver resolves -/// `class_methods` before `instance_methods`, seeding kaminari's -/// class-side `page` builder onto it made `@search.page` — an Integer +/// `class_methods` before `instance_methods`, seeding the class-side +/// `page` builder onto it made `@search.page` — an Integer /// the object assigns itself in `initialize` — resolve to a relation /// over `Search`. That mistyping was invisible while chain starts were /// `Array`-shaped and became a hard `relation_type` emit error the day @@ -1649,7 +1649,7 @@ end index_ivar_ty(&app, "search"), ); - // The attr_accessor answers, NOT kaminari's class-side `page`. + // The attr_accessor answers, NOT the class-side `page` builder. let page = index_ivar_ty(&app, "page"); assert!( !matches!(page, Ty::Relation { .. } | Ty::Array { .. }), @@ -3712,6 +3712,64 @@ end assert_eq!(res.layout.as_ref().map(|s| s.as_str()), Some("layouts/application")); } +#[test] +fn subclass_filter_reads_parent_target_effects() { + // `before_action :load_room` declared on the subclass, method body + // on the parent. Lookup by included_via/defined_in both names the + // subclass, which never stamped the method. + let app = app_from_files(&[ + ( + "app/controllers/application_controller.rb", + r#"class ApplicationController < ActionController::Base + private + + def load_room + @room = Room.find(1) + end +end +"#, + ), + ( + "app/controllers/rooms_controller.rb", + r#"class RoomsController < ApplicationController + before_action :load_room + + def show + end +end +"#, + ), + ("app/models/room.rb", "class Room < ApplicationRecord\nend\n"), + ("app/views/rooms/show.html.erb", "

<%= @room %>

\n"), + ( + "db/schema.rb", + r#"ActiveRecord::Schema[7.1].define(version: 1) do + create_table "rooms", force: :cascade do |t| + t.string "name" + end +end +"#, + ), + ]); + + let res = app + .controller_resolutions + .get(&ClassId(Symbol::from("RoomsController"))) + .expect("RoomsController resolution"); + let load = res + .filter_chain + .iter() + .find(|rf| rf.filter.target.as_str() == "load_room") + .expect("load_room filter"); + assert_eq!(load.defined_in.0.as_str(), "RoomsController"); + assert_eq!(load.included_via.0.as_str(), "RoomsController"); + assert!( + load.effects.effects.iter().any(|e| matches!(e, Effect::DbRead { .. })), + "parent load_room DbRead must reach the subclass filter hop; got {:?}", + load.effects + ); +} + #[test] fn controller_resolutions_layout_inheritance_and_skip_entries() { let app = app_from_files(&[ diff --git a/tests/ci_apt_install_test.py b/tests/ci_apt_install_test.py new file mode 100644 index 000000000..fe5390d43 --- /dev/null +++ b/tests/ci_apt_install_test.py @@ -0,0 +1,108 @@ +"""ci-apt-install must skip installed packages and only refresh the index on miss.""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/ci-apt-install" + + +def write_executable(path: Path, body: str) -> None: + path.write_text(body) + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +class AptInstallTests(unittest.TestCase): + def harness(self, *, dpkg_body: str, sudo_body: str) -> Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + shutil.copy(SCRIPT, root / "ci-apt-install") + write_executable(root / "ci-apt-bound", "#!/bin/sh\necho bound >> \"$LOG\"\n") + write_executable(root / "dpkg-query", dpkg_body) + write_executable(root / "sudo", sudo_body) + return root + + def run_install(self, root: Path, *packages: str) -> subprocess.CompletedProcess[str]: + log = root / "apt.log" + env = os.environ.copy() + env["PATH"] = f"{root}:{env['PATH']}" + env["LOG"] = str(log) + result = subprocess.run( + ["bash", str(root / "ci-apt-install"), *packages], + check=False, + text=True, + capture_output=True, + env=env, + cwd=ROOT, + ) + result.log = log.read_text() if log.exists() else "" # type: ignore[attr-defined] + return result + + def test_skips_when_every_package_is_already_installed(self): + root = self.harness( + dpkg_body="#!/bin/sh\necho installed\n", + sudo_body="#!/bin/sh\necho unexpected-apt >> \"$LOG\"; exit 99\n", + ) + result = self.run_install(root, "libvips42", "lld") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("already installed", result.stdout) + self.assertIn("nothing to install", result.stdout) + self.assertEqual(getattr(result, "log"), "") + + def test_installs_missing_packages_without_update_when_the_index_has_them(self): + root = self.harness( + dpkg_body="""#!/bin/sh +pkg=$3 +[ "$pkg" = present ] && echo installed && exit 0 +echo not-installed +""", + sudo_body="""#!/bin/sh +printf '%s\\n' "$*" >> "$LOG" +exit 0 +""", + ) + result = self.run_install(root, "present", "missing") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + log = getattr(result, "log") + self.assertIn("bound", log) + self.assertIn("apt-get install", log) + self.assertIn("missing", log) + self.assertNotIn("present", log) + self.assertNotIn("apt-get update", log) + + def test_refreshes_the_index_only_after_the_image_index_misses(self): + root = self.harness( + dpkg_body="#!/bin/sh\necho not-installed\n", + sudo_body="""#!/bin/sh +printf '%s\\n' "$*" >> "$LOG" +case " $* " in + *" apt-get update "*) exit 0 ;; + *" apt-get install "*) + if [ -f "$LOG.once" ]; then + exit 0 + fi + touch "$LOG.once" + exit 100 + ;; +esac +exit 0 +""", + ) + result = self.run_install(root, "libclang-dev") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + log = getattr(result, "log") + self.assertIn("image index missed packages", result.stderr) + self.assertLess(log.find("apt-get install"), log.find("apt-get update")) + self.assertGreater(log.rfind("apt-get install"), log.find("apt-get update")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci_plan_test.py b/tests/ci_plan_test.py index a80279466..2e4173c8b 100644 --- a/tests/ci_plan_test.py +++ b/tests/ci_plan_test.py @@ -207,7 +207,7 @@ def test_full_overrides_spinel_without_enabling_publication(self): self.assertIn("archive-results", plan["required"]) self.assertNotIn("assemble-site", plan["jobs"]) - def test_canonical_main_push_selects_full_without_a_label(self): + def test_canonical_main_push_selects_spinel_lane_without_extra_sdks(self): with tempfile.TemporaryDirectory() as directory: event = Path(directory) / "event.json" event.write_text(json.dumps({"before": "0" * 40})) @@ -218,6 +218,37 @@ def test_canonical_main_push_selects_full_without_a_label(self): "GITHUB_REF": "refs/heads/main", "CI_SPINEL_REVISION": "2" * 40, } + with ( + patch.dict(os.environ, env, clear=True), + patch("sys.argv", ["ci-plan.py", "plan"]), + patch.object( + ci, "changed_inputs", return_value=(["src/emit/go.rs"], None) + ), + patch.object(ci, "write_outputs") as output, + ): + self.assertEqual(ci.main(), 0) + plan = output.call_args.args[0]["plan"] + self.assertEqual(plan["jobs"], ci.SPINEL_LANE) + self.assertEqual(plan["extra_compare"], []) + self.assertEqual(plan["smoke"], []) + self.assertFalse(plan["wasm"]) + self.assertNotIn("compare-extra", plan["jobs"]) + self.assertNotIn("compare-jruby", plan["jobs"]) + self.assertNotIn("assemble-site", plan["jobs"]) + self.assertTrue(set(ci.SPINEL11).issubset(plan["jobs"])) + + def test_full_input_on_main_still_selects_every_sdk(self): + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + event.write_text(json.dumps({"before": "0" * 40})) + env = { + "GITHUB_EVENT_PATH": str(event), + "GITHUB_EVENT_NAME": "push", + "GITHUB_SHA": "1" * 40, + "GITHUB_REF": "refs/heads/main", + "CI_FULL": "true", + "CI_SPINEL_REVISION": "2" * 40, + } with ( patch.dict(os.environ, env, clear=True), patch("sys.argv", ["ci-plan.py", "plan"]), @@ -227,8 +258,37 @@ def test_canonical_main_push_selects_full_without_a_label(self): self.assertEqual(ci.main(), 0) plan = output.call_args.args[0]["plan"] self.assertEqual(plan["smoke"], ci.TARGETS) + self.assertTrue(plan["wasm"]) + self.assertIn("compare-extra", plan["jobs"]) self.assertTrue(set(ci.SPINEL11).issubset(plan["jobs"])) - self.assertNotIn("assemble-site", plan["jobs"]) + + def test_unknown_changed_inputs_keep_ruby_and_spinel(self): + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + event.write_text(json.dumps({"pull_request": {"labels": []}})) + env = { + "GITHUB_EVENT_PATH": str(event), + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_SHA": "1" * 40, + "CI_SPINEL_REVISION": "2" * 40, + } + with ( + patch.dict(os.environ, env, clear=True), + patch("sys.argv", ["ci-plan.py", "plan"]), + patch.object(ci, "changed_inputs", side_effect=ValueError("no tree")), + patch.object(ci, "write_outputs") as output, + ): + self.assertEqual(ci.main(), 0) + plan = output.call_args.args[0]["plan"] + self.assertEqual(plan["jobs"], ci.SPINEL_LANE) + self.assertEqual(plan["extra_compare"], []) + self.assertFalse(plan["wasm"]) + self.assertTrue( + any("Unknown changed inputs" in reason for reason in plan["reasons"]) + ) + self.assertTrue( + any("extra-language SDKs not selected" in reason for reason in plan["reasons"]) + ) def test_draft_and_ready_events_use_the_same_paths_and_labels(self): with tempfile.TemporaryDirectory() as directory: @@ -245,7 +305,7 @@ def test_draft_and_ready_events_use_the_same_paths_and_labels(self): (["src/emit/go.rs"], [], ["go"], False), (["README.md"], ["ci:spinel"], [], True), (["README.md"], ["ci:full", "ci:spinel"], ci.TARGETS, True), - ([".github/workflows/ci.yml"], [], ci.TARGETS, True), + ([".github/workflows/ci.yml"], [], [], False), ] for paths, labels, expected_smoke, expect_spinel in cases: for draft in (False, True): @@ -285,10 +345,9 @@ def test_contract_tests_do_not_expand_the_exercised_workflows(self): with self.subTest(path=path): self.assertEqual(ci.select([path])["jobs"], ci.BASE) self.assertEqual(ci.select(paths)["archives"], []) - # Test-only narrowing cannot hide a changed workflow or real owner. - self.assertEqual( - ci.select(paths + [".github/workflows/ci.yml"])["smoke"], ci.TARGETS - ) + # CI workflow edits stay on the Ruby floor; a real owner still adds its lane. + self.assertEqual(ci.select(paths + [".github/workflows/ci.yml"])["jobs"], ci.BASE) + self.assertEqual(ci.select(paths + [".github/workflows/ci.yml"])["smoke"], []) partial = ci.select(paths + ["src/emit/go.rs"]) self.assertEqual(partial["extra_compare"], ["go"]) self.assertEqual(partial["smoke"], ["go"]) @@ -338,6 +397,7 @@ def test_param_binds_owns_lowering_drivers_and_database_runtime(self): "src/lower/model_to_library/adapter_emit.rs", "tests/param_binds.rs", "tests/param_binds_emit.rb", + "tests/param_binds_raw_where.rb", "tests/param_binds_runtime.rb", "tests/support/emit_and_run.rs", ]: @@ -492,7 +552,7 @@ def test_wasm_changes_have_no_archive_or_spinel_fanout(self): self.assertIn("browser-smoke-ide", plan["required"]) self.assertNotIn("build-site", plan["jobs"]) - def test_packaging_and_unknown_target_changes_expand_to_full(self): + def test_packaging_and_unknown_target_changes_keep_the_ruby_floor(self): for path in [ "src/project.rs", "src/emit/newlang.rs", @@ -504,7 +564,11 @@ def test_packaging_and_unknown_target_changes_expand_to_full(self): ".github/workflows/ci.yml", ]: with self.subTest(path=path): - self.assertEqual(ci.select([path])["smoke"], ci.TARGETS) + plan = ci.select([path]) + self.assertEqual(plan["jobs"], ci.BASE) + self.assertEqual(plan["smoke"], []) + self.assertEqual(plan["extra_compare"], []) + self.assertFalse(plan["wasm"]) def test_proven_project_owners_keep_their_consumers_without_other_targets(self): for scope in ["interpreted", "ruby-family"]: @@ -532,9 +596,13 @@ def test_proven_project_owners_keep_their_consumers_without_other_targets(self): ci.select(paths, project_scope="interpreted", **options)["smoke"], ci.TARGETS, ) + self.assertEqual( + ci.select(["src/project.rs"], project_scope="unknown")["jobs"], + ci.BASE, + ) self.assertEqual( ci.select(["src/project.rs"], project_scope="unknown")["smoke"], - ci.TARGETS, + [], ) def test_cli_binary_does_not_expand_to_full(self): @@ -759,7 +827,7 @@ def test_fixture_ignores_inherited_git_locations(self): **os.environ, "GITHUB_EVENT_PATH": str(event_path), "GITHUB_EVENT_NAME": "push", - # Not inherited: on main's own CI run GITHUB_REF is main, whose pushes select full validation. + # Not inherited: canonical main selects the Spinel lane, not this feature ref. "GITHUB_REF": "refs/heads/feature", "GITHUB_SHA": head, "GITHUB_OUTPUT": os.devnull, @@ -899,7 +967,7 @@ def test_all_builder_owners_and_raw_literal_fallbacks(self): with self.subTest(name=name, raw=raw, first=first): self.assertIsNone(ci.project_change_scope(first, second)) - def test_unknown_shared_signatures_and_ambiguous_shapes_stay_full(self): + def test_unknown_shared_signatures_and_ambiguous_shapes_do_not_narrow(self): for changed in [ self.source.replace("shared();", "new_shared();"), self.source.replace("spinel_files()", "spinel_files(app: &App)"), @@ -923,6 +991,10 @@ def test_unknown_shared_signatures_and_ambiguous_shapes_stay_full(self): ), ) ) + self.assertEqual( + ci.select(["src/project.rs"], project_scope=None)["jobs"], + ci.BASE, + ) def test_builder_text_inside_strings_or_comments_is_not_a_rust_item(self): fake = "fn ruby_runtime_files() {\n cross_target();\n}\n" @@ -998,7 +1070,7 @@ def test_git_uses_whole_event_trees_and_rejects_mode_changes(self): **os.environ, "GITHUB_EVENT_PATH": str(event_path), "GITHUB_EVENT_NAME": "push", - # Not inherited: on main's own CI run GITHUB_REF is main, whose pushes select full validation. + # Not inherited: canonical main selects the Spinel lane, not this feature ref. "GITHUB_REF": "refs/heads/feature", "GITHUB_SHA": native, "GITHUB_OUTPUT": os.devnull, diff --git a/tests/ci_policy_workflow.rs b/tests/ci_policy_workflow.rs index e63ad8ee2..5d60b3390 100644 --- a/tests/ci_policy_workflow.rs +++ b/tests/ci_policy_workflow.rs @@ -37,9 +37,11 @@ fn unit_batches_all_targets_without_reducing_coverage() { .expect("install sqlite3, bcrypt and ruby-vips before the unit batches"); let install = steps[gems]["run"].as_str().unwrap(); assert!( - install.contains("gem install sqlite3") + install.contains("sqlite3") && install.contains("bcrypt") - && install.contains("ruby-vips"), + && install.contains("ruby-vips") + && install.contains("rails-html-sanitizer") + && install.contains("activerecord"), "{install}" ); let vips = steps @@ -48,6 +50,11 @@ fn unit_batches_all_targets_without_reducing_coverage() { step["name"].as_str() == Some("System libvips for the emitted ruby-vips processor") }) .expect("install libvips42 before ruby-vips"); + let vips_run = steps[vips]["run"].as_str().unwrap(); + assert!( + vips_run.contains("ci-apt-install") && vips_run.contains("libvips42"), + "{vips_run}" + ); assert!( vips < gems, "ruby-vips binds the system libvips; the package must be on the box first" @@ -95,7 +102,11 @@ fn unit_batches_all_targets_without_reducing_coverage() { let body = bench["run"].as_str().unwrap(); assert!(body.contains("bash -euo pipefail -c")); assert!(body.contains("typescript crystal rust python elixir go kotlin swift csharp")); - assert!(body.contains("cargo run --quiet --bin emit_preview -- --target")); + assert!(body.contains("target/debug/emit_preview --target")); + assert!( + !body.contains("cargo run"), + "shard 0 already built bins; do not pay cargo startup per lane" + ); let resources = steps .iter() .find(|step| step["with"]["name"].as_str() == Some("unit-resources-${{ matrix.shard }}")) @@ -372,6 +383,7 @@ fn resource_and_harness_helpers_preserve_failures_and_contracts() { for test in [ "tests/ci_resources_test.py", "tests/ci_unit_tests_test.py", + "tests/ci_apt_install_test.py", "tests/ci_campfire_optimization_test.py", "tests/ci_smoke_test.py", ] { diff --git a/tests/ci_toolchain_workflow.rs b/tests/ci_toolchain_workflow.rs index ef8444d98..4f91a0487 100644 --- a/tests/ci_toolchain_workflow.rs +++ b/tests/ci_toolchain_workflow.rs @@ -99,3 +99,106 @@ fn uv_cache_keys_use_the_generated_dependency_source_before_emit() { assert!(uv["with"].get("ignore-nothing-to-cache").is_none()); } } + +/// Swift compare-extra is the hosted outlier (~7–8 billable minutes). The +/// job still installs Swift 6.1 and runs the same framework-tests + +/// `scripts/compare swift` check; it must cache SwiftPM checkouts so the +/// Hummingbird/NIO clone is not paid on every run. Smoke uses the same +/// cache key because its README `swift build` fetches the same pins. +#[test] +fn swift_compare_and_smoke_cache_swiftpm_checkouts() { + let workflow: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + let mut keys = Vec::new(); + for job in ["compare", "compare-extra", "smoke"] { + let steps = workflow["jobs"][job]["steps"].as_sequence().unwrap(); + let setup = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .unwrap_or("") + .starts_with("swift-actions/setup-swift@") + }) + .unwrap_or_else(|| panic!("{job} installs Swift")); + assert_eq!( + setup["with"]["swift-version"].as_str(), + Some("6.1"), + "{job}" + ); + let cache = steps + .iter() + .find(|step| step["name"].as_str() == Some("Cache SwiftPM checkouts")) + .unwrap_or_else(|| panic!("{job} caches SwiftPM")); + assert_eq!(cache["if"].as_str(), Some("matrix.target == 'swift'"), "{job}"); + assert_eq!( + cache["uses"].as_str(), + Some("actions/cache@v6"), + "{job}" + ); + let path = cache["with"]["path"].as_str().unwrap(); + assert!(path.contains("~/.cache/org.swift.swiftpm"), "{job}: {path}"); + let key = cache["with"]["key"].as_str().unwrap(); + assert!( + key.contains("hashFiles('src/emit/swift/package.rs')"), + "{job}: {key}" + ); + keys.push(key.to_string()); + } + assert!(keys.windows(2).all(|pair| pair[0] == pair[1])); +} + +/// Release WMO of the emitted app was ~150s of compare-extra (swift). +/// Debug `swift build` is the same boot-and-DOM-diff check (and matches +/// the published README / `swift_toolchain` compile gate). +#[test] +fn compare_script_builds_swift_debug_not_release() { + let script = fs::read_to_string("scripts/compare").unwrap(); + assert!( + script.contains("swift build -Xswiftc -gnone"), + "compare must use a debug SPM build for the Swift server" + ); + assert!( + !script.contains("--disable-index-store"), + "Linux `swift test`/`swift build` fatalError if the index store path is missing" + ); + assert!( + !script.contains("swift build -c release"), + "release codegen is for scripts/bench, not the DOM-diff job" + ); + assert!( + script.contains("./.build/debug/App"), + "{script}" + ); + assert!( + !script.contains("./.build/release/App"), + "compare must boot the debug binary it just built" + ); + assert!( + script.contains("tools/compare/target/release/roundhouse-compare"), + "non-Swift compare jobs keep the previous release comparator" + ); +} + +#[test] +fn compare_rust_cache_does_not_add_extra_directories() { + let workflow: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + for job in ["compare", "compare-extra"] { + let rust_cache = workflow["jobs"][job]["steps"] + .as_sequence() + .unwrap() + .iter() + .find(|step| { + step["uses"] + .as_str() + .unwrap_or("") + .starts_with("Swatinem/rust-cache@") + }) + .unwrap_or_else(|| panic!("{job} rust-cache")); + assert!( + rust_cache.get("with").is_none(), + "{job} rust-cache must stay the default workspace cache; extra directories change the key for every compare target" + ); + } +} diff --git a/tests/ci_unit_tests_test.py b/tests/ci_unit_tests_test.py index 7db0a8c67..dcf153f78 100644 --- a/tests/ci_unit_tests_test.py +++ b/tests/ci_unit_tests_test.py @@ -240,11 +240,11 @@ def fake_run(args, **_kwargs): ) self.assertEqual(code, 0) self.assertFalse(any("--lib" in call or "--bins" in call for call in calls)) + self.assertTrue(all("--no-run" not in call for call in calls)) tested = [ call[call.index("--test") + 1] for call in calls if "--test" in call ] - self.assertTrue(tested) - self.assertTrue(all(name == "beta" for name in tested)) + self.assertEqual(tested, ["beta"]) self.assertTrue(all("--locked" in call for call in calls)) self.assertFalse(beta.exists()) self.assertTrue(alpha.exists()) diff --git a/tests/class_body_declarations.rs b/tests/class_body_declarations.rs new file mode 100644 index 000000000..f29e71332 --- /dev/null +++ b/tests/class_body_declarations.rs @@ -0,0 +1,686 @@ +//! Compile-time class-body declarations (#30): enum, attribute, +//! literal `define_method`, interpolatable `class_eval` heredocs, and +//! the same expansion when the class-method provider is an +//! `on_load(:active_record)` include rather than an `include` on the +//! model. +//! +//! Forcing coverage is synthetic overlays on the blog fixtures — not a +//! named app macro. Dynamic string eval stays unexpanded. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::dialect::{Association, MethodReceiver, ModelBodyItem}; +use roundhouse::expr::ExprNode; +use roundhouse::ingest::ingest_app_from_tree; + +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +const HEREDOC_MACRO: &str = r#"module TitleMacro + extend ActiveSupport::Concern + class_methods do + def titled(name) + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + @#{name}.to_s + end + def #{name}=(value) + @#{name} = value + end + CODE + end + end +end +"#; + +const CHILD_MACRO: &str = r#"module ChildMacro + extend ActiveSupport::Concern + class_methods do + def child_named(name) + has_one name, class_name: "Comment", foreign_key: :article_id + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name}_present? + !#{name}.nil? + end + CODE + end + end +end +"#; + +fn tree(files: &[(&str, &str)]) -> HashMap> { + files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect() +} + +fn article_with_macro( + concern_path: &str, + concern: &str, + include_name: &str, + model_body: &str, +) -> HashMap> { + tree(&[ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table :articles do |t|\n t.string :title\n end\n create_table :comments do |t|\n t.text :body\n t.bigint :article_id\n end\nend\n", + ), + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n", + ), + (concern_path, concern), + ( + "app/models/article.rb", + &format!( + "class Article < ApplicationRecord\n include {include_name}\n {model_body}\nend\n" + ), + ), + ( + "app/models/comment.rb", + "class Comment < ApplicationRecord\n belongs_to :article\nend\n", + ), + ]) +} + +fn instance_names(app: &roundhouse::App, model: &str) -> Vec { + app.models + .iter() + .find(|m| m.name.0.as_str() == model) + .expect(model) + .methods() + .filter(|m| m.receiver == MethodReceiver::Instance) + .map(|m| m.name.as_str().to_string()) + .collect() +} + +#[test] +fn heredoc_class_eval_expands_interpolated_accessors() { + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/title_macro.rb", + HEREDOC_MACRO, + "TitleMacro", + "titled :headline", + )) + .expect("ingest"); + let names = instance_names(&app, "Article"); + assert!(names.contains(&"headline".to_string()), "{names:?}"); + assert!(names.contains(&"headline=".to_string()), "{names:?}"); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!(!article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "titled") + ))); +} + +#[test] +fn class_eval_macro_also_ingests_substituted_has_one() { + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/child_macro.rb", + CHILD_MACRO, + "ChildMacro", + "child_named :spotlight", + )) + .expect("ingest"); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!(article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Association { + assoc: Association::HasOne { name, .. }, + .. + } if name.as_str() == "spotlight" + ))); + let names = instance_names(&app, "Article"); + assert!( + names.contains(&"spotlight_present?".to_string()), + "{names:?}" + ); +} + +#[test] +fn load_hook_class_methods_expand_without_mixing_in_instance_methods() { + let files = tree(&[ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table :articles do |t|\n t.string :title\n end\nend\n", + ), + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n", + ), + ( + "lib/title_macro.rb", + &format!( + "{HEREDOC_MACRO}\nActiveSupport.on_load :active_record do\n include TitleMacro\nend\n" + ), + ), + ( + "app/models/article.rb", + "class Article < ApplicationRecord\n titled :headline\nend\n", + ), + ]); + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(files).expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let names = instance_names(&app, "Article"); + assert!(names.contains(&"headline".to_string()), "{names:?}"); + assert!(names.contains(&"headline=".to_string()), "{names:?}"); + assert!(!names + .iter() + .any(|n| n.contains("installer") || n == "titled")); +} + +#[test] +fn dynamic_class_eval_string_is_not_expanded() { + let concern = r#"module TitleMacro + extend ActiveSupport::Concern + class_methods do + def titled(name) + template = "def #{name}; 99; end" + class_eval template + end + end +end +"#; + let files = tree(&[ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table :articles do |t|\n t.string :title\n end\nend\n", + ), + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n", + ), + ("app/models/concerns/title_macro.rb", concern), + ( + "app/models/article.rb", + "class Article < ApplicationRecord\n include TitleMacro\n titled :headline\nend\n", + ), + ]); + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(files).expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!(article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "titled") + ))); + assert!(!article + .methods() + .any(|m| m.name.as_str() == "headline" && m.receiver == MethodReceiver::Instance)); +} + +#[test] +fn tiny_blog_enum_and_attribute_ingest() { + let files = tree(&[ + ( + "db/schema.rb", + include_str!("../fixtures/tiny-blog/db/schema.rb"), + ), + ( + "app/models/post.rb", + r#"class Post < ApplicationRecord + has_many :comments + validates :title, presence: true + scope :recent, -> { limit(10) } + scope :published, -> { where(published: true) } + before_save :normalize_title + enum :status, %i[draft published], default: :draft + attribute :flagged, :boolean + + def normalize_title + title.strip + end +end +"#, + ), + ( + "app/models/comment.rb", + include_str!("../fixtures/tiny-blog/app/models/comment.rb"), + ), + ]); + let app = ingest_app_from_tree(files).expect("ingest"); + let names = instance_names(&app, "Post"); + assert!(names.iter().any(|n| n == "draft?"), "{names:?}"); + assert!(names.iter().any(|n| n == "published?"), "{names:?}"); + let post = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Post") + .unwrap(); + let lowered = roundhouse::lower::lower_model_to_library_class(post, &app.schema); + assert!( + lowered.methods.iter().any(|m| m.name.as_str() == "flagged"), + "attribute reader" + ); + assert!( + lowered + .methods + .iter() + .any(|m| m.name.as_str() == "flagged="), + "attribute writer" + ); +} + +#[test] +fn emitted_heredoc_class_eval_accessors_run() { + emit_and_run::real_blog() + .write("app/models/concerns/title_macro.rb", HEREDOC_MACRO) + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n", + "class Article < ApplicationRecord\n include TitleMacro\n titled :headline\n", + ) + .run_ruby( + r#" +a = Article.new +a.headline = "Hello" +raise "writer lost" unless a.headline == "Hello" +puts "class_eval heredoc accessors passed" +"#, + ) + .assert_passes(); +} + +#[test] +fn emitted_load_hook_class_eval_accessors_run_without_mixin() { + emit_and_run::real_blog() + .write( + "lib/title_macro.rb", + r#"module TitleMacro + extend ActiveSupport::Concern + class_methods do + def titled(name) + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + @#{name}.to_s + end + def #{name}=(value) + @#{name} = value + end + CODE + end + end + def installer_marker + 37 + end +end +ActiveSupport.on_load :active_record do + include TitleMacro +end +"#, + ) + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n", + "class Article < ApplicationRecord\n titled :headline\n", + ) + .run_ruby( + r#" +a = Article.new +a.headline = "Hello" +raise "writer lost" unless a.headline == "Hello" +raise "hook mixed in instance methods" if a.respond_to?(:installer_marker, true) +puts "load-hook class_eval accessors passed" +"#, + ) + .assert_passes(); +} + +#[test] +fn emitted_enum_and_boolean_attribute_run() { + emit_and_run::real_blog() + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n", + "class Article < ApplicationRecord\n enum :status, %i[draft published], default: :draft\n attribute :flagged, :boolean\n", + ) + .edit( + "db/schema.rb", + " t.string \"title\"", + " t.string \"title\"\n t.string \"status\"", + ) + .run_ruby( + r#" +a = Article.new(title: "Hello world", body: "abcdefghij") +raise "enum default" unless a.draft? +a.published! +raise "enum bang" unless a.published? +a.flagged = "0" +raise "boolean attribute" unless a.flagged == false +a.flagged = "1" +raise "boolean attribute true" unless a.flagged == true +puts "enum and attribute passed" +"#, + ) + .assert_passes(); +} + +#[test] +fn emitted_literal_define_method_macro_runs() { + emit_and_run::real_blog() + .write( + "app/models/concerns/label_macro.rb", + r#"module LabelMacro + extend ActiveSupport::Concern + class_methods do + def labeled(field) + define_method :label_of do + send(field) + end + end + end +end +"#, + ) + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n", + "class Article < ApplicationRecord\n include LabelMacro\n labeled :title\n", + ) + .run_ruby( + r#" +a = Article.new(title: "Hello world", body: "abcdefghij") +raise "define_method" unless a.label_of == "Hello world" +puts "literal define_method passed" +"#, + ) + .assert_passes(); +} + +#[test] +fn emitted_substituted_has_one_and_class_eval_predicate_run() { + emit_and_run::real_blog() + .write("app/models/concerns/child_macro.rb", CHILD_MACRO) + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n", + "class Article < ApplicationRecord\n include ChildMacro\n child_named :spotlight\n", + ) + .run_ruby( + r#" +a = Article.create!(title: "Hello world", body: "abcdefghij") +raise "empty has_one" unless a.spotlight_present? == false +Comment.create!(article: a, body: "hi", commenter: "x") +raise "has_one reader" unless a.spotlight_present? +raise "wrong child" unless a.spotlight.body == "hi" +puts "substituted has_one passed" +"#, + ) + .assert_passes(); +} + +#[test] +fn missing_required_keyword_does_not_expand() { + let concern = r#"module LabelMacro + extend ActiveSupport::Concern + class_methods do + def labeled(title:) + define_method title do + 1 + end + end + end +end +"#; + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/label_macro.rb", + concern, + "LabelMacro", + "labeled", + )) + .expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!( + article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "labeled") + )), + "required keyword missing must fail closed" + ); + let names = instance_names(&app, "Article"); + assert!( + !names.iter().any(|n| n == "headline"), + "must not invent a helper: {names:?}" + ); +} + +const LEFTOVER_MACRO: &str = r##"module AttachMacro + extend ActiveSupport::Concern + class_methods do + def attached(name, strict_loading: false) + has_one :"#{name}_record", class_name: "Comment", foreign_key: :article_id, strict_loading: strict_loading + scope :"with_#{name}", -> { all } + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + 1 + end + CODE + end + end +end +"##; + +/// Interpolatable class_eval plus leftover interpolated association / +/// scope names must fail closed: do not emit the class_eval methods +/// when the rest of the macro is not ingestible. +#[test] +fn leftover_interpolated_association_does_not_expand() { + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/attach_macro.rb", + LEFTOVER_MACRO, + "AttachMacro", + "attached :spotlight", + )) + .expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!( + article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "attached") + )), + "call must stay unexpanded" + ); + let names = instance_names(&app, "Article"); + assert!( + !names.iter().any(|n| n == "spotlight" || n == "spotlight_record"), + "must not invent readers: {names:?}" + ); +} + +/// Same leftover shape through `on_load(:active_record)` — the Writebook +/// installer path — without claiming a named-app runtime. +#[test] +fn load_hook_leftover_interpolated_association_does_not_expand() { + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(tree(&[ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table :articles do |t|\n t.string :title\n end\n create_table :comments do |t|\n t.text :body\n t.bigint :article_id\n end\nend\n", + ), + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n", + ), + ( + "lib/attach_macro.rb", + &format!( + "{LEFTOVER_MACRO}\nActiveSupport.on_load :active_record do\n include AttachMacro\nend\n" + ), + ), + ( + "app/models/article.rb", + "class Article < ApplicationRecord\n attached :spotlight\nend\n", + ), + ( + "app/models/comment.rb", + "class Comment < ApplicationRecord\n belongs_to :article\nend\n", + ), + ])) + .expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!( + article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "attached") + )), + "load-hook call must stay unexpanded" + ); + let names = instance_names(&app, "Article"); + assert!( + !names.iter().any(|n| n == "spotlight" || n == "spotlight_record"), + "must not invent readers: {names:?}" + ); +} + +/// Optional non-symbol kwargs are omitted from the binding set. A +/// leftover `strict_loading: strict_loading` must decline the whole +/// expansion rather than ingest `has_one` without that option. +#[test] +fn omitted_optional_keyword_read_does_not_expand() { + let concern = r#"module TitleMacro + extend ActiveSupport::Concern + class_methods do + def titled(name, strict_loading: false) + has_one :spotlight, class_name: "Comment", foreign_key: :article_id, strict_loading: strict_loading + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + 1 + end + CODE + end + end +end +"#; + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/title_macro.rb", + concern, + "TitleMacro", + "titled :headline", + )) + .expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!( + article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "titled") + )), + "omitted keyword read must stay unexpanded" + ); + let names = instance_names(&app, "Article"); + assert!( + !names.iter().any(|n| n == "headline" || n == "spotlight"), + "must not invent readers: {names:?}" + ); +} + +/// A slice-local that shadows a macro parameter (`->(name) { … name }`) +/// is not the bound symbol. Decline rather than rewrite Ruby's lambda +/// argument into `:headline`. +#[test] +fn shadowed_macro_parameter_does_not_expand() { + let concern = r#"module TitleMacro + extend ActiveSupport::Concern + class_methods do + def titled(name) + scope :named, ->(name) { where(title: name) } + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + 1 + end + CODE + end + end +end +"#; + roundhouse::ingest::survey::activate(); + let app = ingest_app_from_tree(article_with_macro( + "app/models/concerns/title_macro.rb", + concern, + "TitleMacro", + "titled :headline", + )) + .expect("survey ingest"); + roundhouse::ingest::survey::drain(); + let article = app + .models + .iter() + .find(|m| m.name.0.as_str() == "Article") + .unwrap(); + assert!( + article.body.iter().any(|item| matches!( + item, + ModelBodyItem::Unknown { expr, .. } + if matches!(&*expr.node, ExprNode::Send { method, .. } if method.as_str() == "titled") + )), + "shadowed parameter must stay unexpanded" + ); + let names = instance_names(&app, "Article"); + assert!( + !names.iter().any(|n| n == "headline"), + "must not invent readers: {names:?}" + ); +} + +/// Strict ingest keeps the abort as an error — survey records a gap, +/// emit-and-run would panic, and we must not drop the diagnostic to +/// emit a reader without the association. +#[test] +fn leftover_interpolated_association_stays_an_ingest_error() { + let err = ingest_app_from_tree(article_with_macro( + "app/models/concerns/attach_macro.rb", + LEFTOVER_MACRO, + "AttachMacro", + "attached :spotlight", + )) + .expect_err("strict ingest must fail closed"); + let message = err.to_string(); + assert!( + message.contains("model macro `attached` not expanded"), + "{message}" + ); +} + diff --git a/tests/class_body_macro_expansion.rs b/tests/class_body_macro_expansion.rs index 839ceb346..22761dcca 100644 --- a/tests/class_body_macro_expansion.rs +++ b/tests/class_body_macro_expansion.rs @@ -1201,3 +1201,75 @@ end }).collect::>() ); } + +/// The `setup_mobile!` filters `call` expands to, as their `only` lists. +/// `has_mobile_version(*actions)` peels its options with +/// `extract_options!` and reads `options[:if]`. +fn mobile_version_filters(call: &str) -> Vec> { + let concern = r#" +module MobileableConcern + extend ActiveSupport::Concern + + module ClassMethods + def has_mobile_version(*actions) + options = actions.extract_options! + before_action(:setup_mobile!, if: options[:if], only: actions) + end + end + + private + def setup_mobile! + end +end +"#; + let controller = format!( + "class ThingsController < ApplicationController\n ACTIONS = %i[index show]\n {call}\n def index; end\n def show; end\nend\n" + ); + let tree = vec![ + ("app/controllers/concerns/mobileable_concern.rb", concern.to_string()), + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\n include MobileableConcern\nend\n".to_string(), + ), + ("app/controllers/things_controller.rb", controller), + ] + .into_iter() + .map(|(p, s)| (std::path::PathBuf::from(p), s.into_bytes())) + .collect(); + let app = ingest_app_from_tree(tree).expect("ingest"); + filters(&app) + .into_iter() + .filter(|(kind, target, ..)| *kind == FilterKind::Before && target == "setup_mobile!") + .map(|(_, _, only, _)| only) + .collect() +} + +/// The call's symbols are the filter's `only`; an absent `if:` is nil, +/// no guard. +#[test] +fn rest_actions_macro_with_extract_options_expands_to_a_scoped_filter() { + let scoped = vec![vec!["index".to_string(), "show".to_string()]]; + assert_eq!(mobile_version_filters("has_mobile_version :index, :show"), scoped); + // A literal array splat spreads its elements. + assert_eq!(mobile_version_filters("has_mobile_version *%i[index show]"), scoped); + // A repeated key reads its last value, as Ruby does. + assert_eq!(mobile_version_filters("has_mobile_version :index, :show, if: :x, if: nil"), scoped); +} + +/// What expansion cannot read stays unexpanded rather than becoming a +/// broader or unguarded filter. +#[test] +fn rest_actions_macro_refuses_what_it_cannot_read() { + for call in [ + // Unknown actions: expanding would drop them from `only`. + "has_mobile_version *ACTIONS", + "has_mobile_version *[:index, ACTIONS.first]", + "has_mobile_version :index, ACTIONS.first", + // The last `if:` is a guard this expansion does not carry. + "has_mobile_version :index, if: nil, if: :x", + // A computed key might be `:if`. + "has_mobile_version :index, \"if\".to_sym => :x", + ] { + assert!(mobile_version_filters(call).is_empty(), "{call} expanded"); + } +} diff --git a/tests/emit_and_run.rs b/tests/emit_and_run.rs index 27bd9cc4c..3bb46ea8a 100644 --- a/tests/emit_and_run.rs +++ b/tests/emit_and_run.rs @@ -3939,6 +3939,87 @@ fn trailing_erb_comments_execute_without_swallowing_output_terminators() { .assert_passes(); } +/// `cookies.permanent` in each spelling Rails accepts, over one plain +/// write as the control. The permanent jar was the identity until +/// 2026-10-06, so these went out with no Expires and ended with the +/// browser session: campfire's sign-in did not survive a restart. +fn permanent_cookie_app() -> emit_and_run::Overlay { + emit_and_run::empty_app() + .write( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ) + .write( + "app/controllers/visits_controller.rb", + r#"class VisitsController < ApplicationController + def index + cookies.permanent[:last_room] = 7 + cookies.signed.permanent[:session_token] = { value: "tok", httponly: true, same_site: :lax } + cookies.permanent.signed[:remember] = "me" + cookies[:plain] = "p" + head :no_content + end +end +"#, + ) + .write( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n", + ) + .write( + "config/routes.rb", + "Rails.application.routes.draw do\n resources :visits, only: :index\nend\n", + ) + .write("app/models/visit.rb", "class Visit < ApplicationRecord\nend\n") + .write( + "db/schema.rb", + "ActiveRecord::Schema[8.1].define(version: 2026_01_01_000000) do\n create_table \"visits\", force: :cascade do |t|\n t.string \"room\"\n end\nend\n", + ) +} + +#[test] +fn permanent_cookies_go_out_with_an_expiry() { + permanent_cookie_app() + .run_ruby( + r##"status, headers, = Main.run_rack("REQUEST_METHOD" => "GET", "PATH_INFO" => "/visits", "QUERY_STRING" => "", "rack.input" => StringIO.new("")) +raise "GET /visits answered #{status}" unless status == 204 +lines = headers["set-cookie"] || [] +year = (Time.now.utc.year + 20).to_s +%w[last_room session_token remember].each do |name| + line = lines.find { |l| l.start_with?("#{name}=") } or raise "no Set-Cookie for #{name}: #{lines.inspect}" + raise "#{name} has no twenty-year Expires: #{line}" unless line =~ /; Expires=\w{3}, \d{2} \w{3} #{year} \d{2}:\d{2}:\d{2} GMT/ +end +plain = lines.find { |l| l.start_with?("plain=") } or raise "no Set-Cookie for plain: #{lines.inspect}" +raise "a plain cookie must stay a session cookie: #{plain}" if plain.include?("Expires") +session = lines.find { |l| l.start_with?("session_token=") } +raise "options still apply under permanent: #{session}" unless session.include?("SameSite=Lax") && session.include?("HttpOnly") +puts "permanent cookies passed" +"##, + ) + .assert_passes(); +} + +#[test] +#[ignore = "requires the Spinel toolchain"] +fn permanent_cookies_record_an_expiry_on_spinel() { + permanent_cookie_app() + .run_spinel( + r##"controller = VisitsController.new +controller.process_action(:index) +jar = controller.cookies +year = (Time.now.utc.year + 20).to_s +["last_room", "session_token", "remember"].each do |name| + exp = jar.flag_expires(name) + raise "#{name} has no twenty-year expiry: #{exp.inspect}" unless exp.split(" ")[3] == year && exp.end_with?(" GMT") +end +raise "a plain cookie must stay a session cookie" unless jar.flag_expires("plain") == "" +raise "the signed permanent value must round-trip" unless jar.signed[:session_token] == "tok" +puts "permanent cookies passed" +"##, + ) + .assert_passes(); +} + /// Not the scaffold blog's `app/views.rb`, whose requires name views this tree does not have: an app with no views boots and answers a request (#164). #[test] fn an_app_with_no_views_boots() { @@ -5603,6 +5684,9 @@ end .assert_passes(); } +#[path = "emit_and_run/string_bytes.rs"] +mod string_bytes; + /// A Slim view is ingested rather than skipped, so `check` going quiet on /// it is a claim the emitted page renders. Swap the blog's index for a /// Slim twin that exercises the grammar (shortcuts merging with a @@ -5671,3 +5755,364 @@ fn a_hyphenated_view_directory_renders() { .run_test("test/controllers/articles_controller_test.rb") .assert_passes(); } + +/// Rails' own guards against a request-steered header, ahead of the +/// server's (which drops any header holding a control character): +/// `redirect_to` deletes CR and LF from the location +/// (`_compute_redirect_to_location`, actionpack 8.1), and Active +/// Storage serves only `inline` or `attachment`, whatever disposition a +/// URL asks for (`content_disposition_with`, activestorage 8.1) — the +/// blob redirect route takes it from a query param and signs it into +/// the disk URL whose Content-Disposition it becomes. +fn header_values_app() -> emit_and_run::Overlay { + emit_and_run::empty_app() + .write("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n") + .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") + .write("db/schema.rb", r#"ActiveRecord::Schema.define do + create_table "docs", force: :cascade do |t| + t.string "name" + end + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + end + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + end +end +"#) + .write("app/models/doc.rb", "class Doc < ApplicationRecord\n has_one_attached :file\nend\n") + .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/bounce\", to: \"docs#bounce\"\nend\n") + .write("app/controllers/docs_controller.rb", r#"class DocsController < ApplicationController + def bounce + redirect_to params[:back] + end +end +"#) +} + +#[test] +fn request_steered_header_values_stay_one_line() { + header_values_app() + .run_ruby(r#" +require_relative "app/controllers/docs_controller" +controller = DocsController.new +controller.params = { "back" => "/next\r\nSet-Cookie: pwned=1" } +controller.process_action(:bounce) +location = controller.location.to_s +raise "CR/LF reached the Location: #{location.inspect}" if location.include?("\r") || location.include?("\n") +raise "the rest of the location is kept, as Rails keeps it: #{location.inspect}" unless location == "/nextSet-Cookie: pwned=1" + +asked = ActiveStorage::DiskKey.decode(ActiveStorage::DiskKey.encode("k", "attachment\r\nSet-Cookie: pwned=1")) +raise "an unknown disposition was signed as asked: #{asked.inspect}" unless asked == ["k", "inline"] +kept = ActiveStorage::DiskKey.decode(ActiveStorage::DiskKey.encode("k", "attachment")) +raise "attachment is a disposition: #{kept.inspect}" unless kept == ["k", "attachment"] +puts "header values passed" +"#) + .assert_passes(); +} + +fn query_value_app() -> emit_and_run::Overlay { + emit_and_run::empty_app() + .write("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n") + .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\n protect_from_forgery with: :exception\nend\n") + .write("db/schema.rb", r#"ActiveRecord::Schema.define do + create_table "widgets", force: :cascade do |t| + t.string "name" + end +end +"#) + .write("app/models/widget.rb", "class Widget < ApplicationRecord\nend\n") + .write("config/routes.rb", r#"Rails.application.routes.draw do + get "/limited", to: "widgets#limited" + get "/paged", to: "widgets#paged" + get "/sorted", to: "widgets#sorted" + get "/sorted_by", to: "widgets#sorted_by" + get "/sorted_str", to: "widgets#sorted_str" + get "/bounce", to: "widgets#bounce" + post "/touch", to: "widgets#touch" + get "/headed", to: "widgets#headed" + get "/tails", to: "widgets#tails" +end +"#) + .write("app/controllers/widgets_controller.rb", r#"class WidgetsController < ApplicationController + def limited + render plain: Widget.order(:name).limit(params[:n]).map { |w| w.name }.join(",") + end + + def paged + render plain: Widget.order(:name).offset(params[:skip]).map { |w| w.name }.join(",") + end + + def sorted + render plain: Widget.order(name: params[:dir]).map { |w| w.name }.join(",") + end + + def sorted_by + render plain: Widget.order(params[:sort] => :asc).map { |w| w.name }.join(",") + end + + def sorted_str + render plain: Widget.order(params[:sort]).map { |w| w.name }.join(",") + end + + def bounce + redirect_to params[:back] + end + + def touch + render plain: "ok" + end + + def headed + head :created, location: params[:back] + end + + def tails + render plain: Widget.order(:name).first(1).map { |w| w.name }.join(",") + Widget.order(:name).last(1).map { |w| w.name }.join(",") + end +end +"#) +} + +fn query_value_assertions() -> &'static str { + r#" +require_relative "app/controllers/widgets_controller" +ActionController::Base.allow_forgery_protection = false +Widget.create!(name: "beta") +Widget.create!(name: "alpha") +Widget.create!(name: "gamma") + +def run(action, params) + controller = WidgetsController.new + controller.request_method = "GET" + controller.params = params + controller.process_action(action) + controller.body +end + +def rejected(action, params) + "ran: " + run(action, params) +rescue ArgumentError + "rejected" +end + +got = run(:limited, { "n" => "2" }) +raise "a numeric String limit is Rails' Integer(): #{got}" unless got == "alpha,beta" +got = rejected(:limited, { "n" => "(SELECT COUNT(*) FROM widgets)" }) +raise "LIMIT took SQL: #{got}" unless got == "rejected" + +got = run(:paged, { "skip" => "1" }) +raise "a numeric String offset is Rails' to_i: #{got}" unless got == "beta,gamma" +got = run(:paged, { "skip" => "(SELECT 2)" }) +raise "OFFSET took SQL: #{got}" unless got == "alpha,beta,gamma" +got = run(:paged, { "skip" => "1; SELECT 1" }) +raise "OFFSET to_i prefix: #{got}" unless got == "beta,gamma" + +got = run(:sorted, { "dir" => "desc" }) +raise "a String direction: #{got}" unless got == "gamma,beta,alpha" +got = rejected(:sorted, { "dir" => "asc, (SELECT 1)" }) +raise "ORDER direction took SQL: #{got}" unless got == "rejected" + +got = run(:sorted_by, { "sort" => "name" }) +raise "a String column key: #{got}" unless got == "alpha,beta,gamma" +got = rejected(:sorted_by, { "sort" => "(SELECT 1)" }) +raise "ORDER column took SQL: #{got}" unless got == "rejected" +got = run(:sorted_by, { "sort" => "widgets.name" }) +raise "table.col hash key: #{got}" unless got == "alpha,beta,gamma" + +got = run(:sorted_str, { "sort" => "name desc" }) +raise "string order: #{got}" unless got == "gamma,beta,alpha" +got = rejected(:sorted_str, { "sort" => "id DESC, (SELECT 1)" }) +raise "string ORDER took SQL: #{got}" unless got == "rejected" +got = Widget.all.order("LOWER(name)").map { |w| w.name }.join(",") +raise "LOWER(name) order: #{got}" unless got == "alpha,beta,gamma" +got = Widget.all.order("RANDOM()").map { |w| w.name }.length +raise "RANDOM() order rejected" unless got == 3 +got = rejected(:sorted_str, { "sort" => "SLEEP()" }) +raise "SLEEP() order: #{got}" unless got == "rejected" +got = rejected(:sorted_str, { "sort" => "LOWER(name); SELECT 1" }) +raise "LOWER plus splice: #{got}" unless got == "rejected" + +rel = Widget.all.order(:name) +begin + rel.last_n("(SELECT 1)") + raise "last_n accepted SQL" +rescue ArgumentError + got = rel.order(:name).map { |w| w.name }.join(",") + raise "last_n mutated orders: #{got}" unless got == "alpha,beta,gamma" +end +got = Widget.all.order(:name).first_n("2").map { |w| w.name }.join(",") +raise "first_n string: #{got}" unless got == "alpha,beta" +got = Widget.all.order(:name).limit(2.9).map { |w| w.name }.join(",") +raise "float limit truncate: #{got}" unless got == "alpha,beta" + +puts "query values passed" +"# +} + +#[test] +fn query_params_are_values_not_sql() { + query_value_app() + .run_ruby(query_value_assertions()) + .assert_passes(); +} + +#[test] +#[ignore = "requires the Spinel toolchain"] +fn query_params_are_values_not_sql_on_spinel() { + let script = format!( + "Db.configure(\":memory:\")\nSchema.statements.each {{ |sql| Db.exec(sql) }}\nActiveRecord.adapter = SqliteAdapter\n{}", + query_value_assertions() + ); + query_value_app().run_spinel(&script).assert_passes(); +} + +#[test] +fn request_steered_head_and_headers_stay_one_line() { + query_value_app() + .run_ruby(r#" +require_relative "app/controllers/widgets_controller" +ActionController::Base.allow_forgery_protection = false +controller = WidgetsController.new +controller.params = { "back" => "/next\r\nSet-Cookie: pwned=1" } +controller.process_action(:headed) +location = controller.location.to_s +raise "head location kept CR/LF: #{location.inspect}" if location.include?("\r") || location.include?("\n") + +controller = WidgetsController.new +controller.headers["X-Link"] = "a\r\nSet-Cookie: pwned=1" +raise "CR/LF header was stored" unless controller.headers["X-Link"].nil? + +controller.headers["X-Ok"] = "one-line" +raise "legal header dropped" unless controller.headers["X-Ok"] == "one-line" + +controller.headers["X-Rev"] = nil +raise "nil header write stored a value" unless controller.headers["X-Rev"].nil? +raise "nil header wiped a sibling" unless controller.headers["X-Ok"] == "one-line" +puts "head and headers passed" +"#) + .assert_passes(); +} + +#[test] +fn redirect_to_rejects_an_unvalidated_host() { + query_value_app() + .run_ruby(r#" +require_relative "app/controllers/widgets_controller" +ActionController::Base.allow_forgery_protection = false +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "http://evil.example/" } +begin + controller.process_action(:bounce) + raise "open redirect ran: #{controller.location.inspect}" +rescue ArgumentError +end + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "/home" } +controller.process_action(:bounce) +raise "relative redirect lost: #{controller.location.inspect}" unless controller.location == "/home" + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "http://app.example/ok" } +controller.process_action(:bounce) +raise "same-host absolute refused: #{controller.location.inspect}" unless controller.location == "http://app.example/ok" + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "evil.example") +controller.request_method = "GET" +controller.session[:return_to_after_authenticating] = controller.request.url +controller.params = { "back" => controller.session[:return_to_after_authenticating] } +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +begin + controller.process_action(:bounce) + raise "spoofed request.url honored: #{controller.location.inspect}" +rescue ArgumentError +end + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "/\\evil.example" } +begin + controller.process_action(:bounce) + raise "backslash host honored: #{controller.location.inspect}" +rescue ArgumentError +end + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "///evil.example" } +begin + controller.process_action(:bounce) + raise "triple-slash honored: #{controller.location.inspect}" +rescue ArgumentError +end + +controller = WidgetsController.new +controller.request = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example") +controller.request_method = "GET" +controller.params = { "back" => "/\t/evil.example" } +begin + controller.process_action(:bounce) + raise "tab host honored: #{controller.location.inspect}" +rescue ArgumentError +end +puts "open redirect passed" +"#) + .assert_passes(); +} + +#[test] +fn csrf_rejects_a_post_without_the_session_token() { + query_value_app() + .run_ruby(r#" +require_relative "app/controllers/widgets_controller" +ActionController::Base.allow_forgery_protection = true +controller = WidgetsController.new +ActionController::Current.controller = controller +req = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example", "REQUEST_METHOD" => "POST") +controller.request = req +ActionController::Current.request = req +controller.request_method = "POST" +controller.params = {} +controller.process_action(:touch) +raise "empty CSRF ran: #{controller.status}" unless controller.status == 422 + +controller = WidgetsController.new +ActionController::Current.controller = controller +req = ActionDispatch::TestRequest.create("HTTP_HOST" => "app.example", "REQUEST_METHOD" => "POST") +controller.request = req +ActionController::Current.request = req +controller.request_method = "POST" +controller.session[:_csrf_token] = "tok" +controller.params = { "authenticity_token" => "tok" } +controller.process_action(:touch) +raise "matching CSRF failed: #{controller.status} #{controller.body}" unless controller.status == 200 && controller.body == "ok" + +token = ActionView::ViewHelpers.form_authenticity_token +raise "parked session token not read: #{token.inspect}" unless token == "tok" +puts "csrf passed" +"#) + .assert_passes(); +} + diff --git a/tests/emit_and_run/string_bytes.rs b/tests/emit_and_run/string_bytes.rs new file mode 100644 index 000000000..d08fe05c9 --- /dev/null +++ b/tests/emit_and_run/string_bytes.rs @@ -0,0 +1,138 @@ +//! Native Ruby emission keeps byte values, evaluation count, and block effects. +use super::emit_and_run; + +/// Overlay only the primitive probe; the standard fixture supplies a complete +/// emitted boot so this regression does not depend on handcrafted Rails setup. +fn byte_probe() -> emit_and_run::Overlay { + emit_and_run::real_blog().write( + "app/lib/byte_probe.rb", + r#"class ByteProbe + def initialize + @calls = 0 + end + def receiver + @calls += 1 + "A\0é東🎉" + end + def values + receiver.bytes + end + def nil_values + receiver.bytes(&nil) + end + def calls + @calls + end + def block_value + "é".bytes { |byte| @calls += byte } + end +end +"#, + ) +} + +const ASSERTIONS: &str = r#" +require_relative "app/models/byte_probe" +probe = ByteProbe.new +raise "wrong bytes" unless probe.values == [65, 0, 195, 169, 230, 157, 177, 240, 159, 142, 137] +raise "receiver evaluated more than once" unless probe.calls == 1 +raise "wrong nil-block bytes" unless probe.nil_values == [65, 0, 195, 169, 230, 157, 177, 240, 159, 142, 137] +raise "nil-block receiver evaluated more than once" unless probe.calls == 2 +raise "wrong block return" unless probe.block_value == "é" +raise "block disappeared" unless probe.calls == 366 +puts "String#bytes emitted contract passed" +"#; + +/// Both runtimes execute byte methods natively, but Spinel also consumes the +/// generated signature, so a passing CRuby run alone cannot certify this fix. +fn assert_byte_signatures(path: &std::path::Path) { + let rbs = std::fs::read_to_string(path).unwrap(); + assert!(rbs.contains("def values: () -> Array[Integer]"), "{rbs}"); + assert!( + rbs.contains("def nil_values: () -> Array[Integer]"), + "{rbs}" + ); + assert!(rbs.contains("def block_value: () -> String"), "{rbs}"); +} + +/// Integer sums distinguish executed block effects from both a dropped block +/// and string-valued bytes, while the call counter exposes receiver duplication. +#[test] +fn string_bytes_preserves_values_and_block_effects() { + let run = byte_probe().run_ruby(ASSERTIONS); + run.assert_passes(); + assert_byte_signatures(&run.emitted.join("sig/app/models/byte_probe.rbs")); +} + +/// Compile the same source and consume its inferred byte-array RBS on Spinel. +#[test] +#[ignore = "requires the Spinel toolchain"] +fn string_bytes_preserves_values_and_block_effects_on_spinel() { + use std::process::Command; + let (emitted, errors) = byte_probe().emit(roundhouse::project::BuildTarget::Spinel); + assert!(errors.is_empty(), "{errors:?}"); + assert_byte_signatures(&emitted.join("app/models/byte_probe.rbs")); + std::fs::write( + emitted.join("contract.rb"), + format!("require_relative \"boot\"\n{ASSERTIONS}"), + ) + .unwrap(); + let compiler = std::env::var("SPINEL").unwrap_or_else(|_| "spinel".into()); + let extractor = std::path::Path::new(&compiler).with_file_name("spinel_rbs_extract"); + let seeds = Command::new(extractor) + .arg(".") + .current_dir(&*emitted) + .output() + .expect("extract emitted RBS seeds"); + assert!( + seeds.status.success(), + "{}", + String::from_utf8_lossy(&seeds.stderr) + ); + let seed_text = String::from_utf8_lossy(&seeds.stdout); + let mut in_probe = false; + let mut probe_seeds = Vec::new(); + for line in seed_text.lines() { + if line.starts_with("class ") { + in_probe = line == "class ByteProbe"; + } else if in_probe { + probe_seeds.push(line); + } + } + assert!(!probe_seeds.is_empty(), "{seed_text}"); + for method in ["values", "nil_values"] { + assert!( + probe_seeds + .iter() + .any(|line| line.starts_with(&format!("meth {method} int_array "))), + "{seed_text}" + ); + } + std::fs::write(emitted.join("rbs-seeds.txt"), &seeds.stdout).unwrap(); + let compiled = Command::new(&compiler) + .args(["--rbs", ".", "contract.rb", "-o", "contract"]) + .current_dir(&*emitted) + .output() + .expect("compile with generated RBS"); + std::fs::write(emitted.join("compile.stdout"), &compiled.stdout).unwrap(); + std::fs::write(emitted.join("compile.stderr"), &compiled.stderr).unwrap(); + assert!( + compiled.status.success(), + "{}", + String::from_utf8_lossy(&compiled.stderr) + ); + assert!(!String::from_utf8_lossy(&compiled.stderr).contains("type seeds are unavailable")); + let executed = Command::new(emitted.join("contract")) + .current_dir(&*emitted) + .output() + .expect("run native byte contract"); + assert!( + executed.status.success(), + "{}", + String::from_utf8_lossy(&executed.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&executed.stdout).trim(), + "String#bytes emitted contract passed" + ); +} diff --git a/tests/fixtures/writebook-inventory.json b/tests/fixtures/writebook-inventory.json index 4d75f5cfa..73164ab87 100644 --- a/tests/fixtures/writebook-inventory.json +++ b/tests/fixtures/writebook-inventory.json @@ -835,7 +835,6 @@ "Error|send_dispatch_failed|app/controllers/leafables_controller.rb:52:16|no known method `model_name` on bot": 1, "Error|send_dispatch_failed|app/controllers/leafables_controller.rb:70:7|no known method `broadcast_render_later_to` on Turbo::StreamsChannel": 1, "Error|send_dispatch_failed|app/models/book.rb:17:36|no known method `leafable` on Leaf": 1, - "Error|send_dispatch_failed|app/models/page.rb:21:19|no known method `first` on String": 1, "Error|send_dispatch_failed|app/views/leafables/show.html.erb:15:7|no known method `section?` on Leaf": 1, "Error|send_dispatch_failed|app/views/leafables/show.html.erb:16:50|no known method `leafable` on Leaf": 1, "Error|send_dispatch_failed|app/views/leafables/show.html.erb:17:61|no known method `section` on Leaf": 1, @@ -863,6 +862,10 @@ "Error|send_dispatch_failed|app/views/pages/edits/show.html.erb:65:26|no known method `body` on Relation[Leaf]": 1, "Error|unsupported|app/controllers/action_text/markdown/uploads_controller.rb:48:21|constant not supported (all targets): ActiveStorage::Attachment": 1, "Error|unsupported|app/models/leafable.rb:27:26|constant not supported (all targets): ActiveModel::Name": 1, + "Info|send_dispatch_failed|app/models/page.rb:21:19|no known method `first` on String — likely roundhouse coverage, not an app error (ingest gap in app/models/page.rb: model macro `has_markdown` not expanded: requires distinct parameterless definitions, symbol bindings and named visibili)": 1, + "Info|unresolved_type|app/models/page.rb:25:5|method call `body` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in app/models/page.rb: model macro `has_markdown` not expanded: requires distinct parameterless definitions, symbol bindings and named visibili)": 1, + "Info|unresolved_type|app/models/page.rb:35:7|method call `preview_renderer` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in app/models/page.rb: model macro `has_markdown` not expanded: requires distinct parameterless definitions, symbol bindings and named visibili)": 1, + "Info|unresolved_type|app/models/page.rb:39:7|method call `body` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in app/models/page.rb: model macro `has_markdown` not expanded: requires distinct parameterless definitions, symbol bindings and named visibili)": 1, "Info|unresolved_type|lib/rails_ext/action_text_markdown.rb:30:31|method call `renderer` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in lib/rails_ext/action_text_markdown.rb: `ActiveSupport.on_load)": 1, "Info|unresolved_type|lib/rails_ext/action_text_markdown.rb:30:8|method call `renderer` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in lib/rails_ext/action_text_markdown.rb: `ActiveSupport.on_load)": 1, "Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:22:8|expression resolves to RBS `untyped` (gradual escape)": 1, @@ -964,9 +967,6 @@ "Warning|unresolved_type|app/models/edit.rb:11:52|local read `edit` has unresolved type": 1, "Warning|unresolved_type|app/models/edit.rb:25:7|method call `leafable` has unresolved type": 1, "Warning|unresolved_type|app/models/leaf/searchable.rb:25:40|local read `terms` has unresolved type": 1, - "Warning|unresolved_type|app/models/page.rb:25:5|method call `body` has unresolved type": 1, - "Warning|unresolved_type|app/models/page.rb:35:7|method call `preview_renderer` has unresolved type": 1, - "Warning|unresolved_type|app/models/page.rb:39:7|method call `body` has unresolved type": 1, "Warning|unresolved_type|app/models/session.rb:11:25|local read `user_agent` has unresolved type": 1, "Warning|unresolved_type|app/models/session.rb:11:49|local read `ip_address` has unresolved type": 1, "Warning|unresolved_type|app/models/user/transferable.rb:8:19|local read `id` has unresolved type": 1, @@ -1114,6 +1114,7 @@ "app/helpers/arrangement_helper.rb|anonymous `**` keyword forwarding not yet supported": 1, "app/helpers/books_helper.rb|anonymous `**` keyword forwarding not yet supported": 1, "app/helpers/leaves_helper.rb|anonymous `**` keyword forwarding not yet supported": 3, + "app/models/page.rb|model macro `has_markdown` not expanded: requires distinct parameterless definitions, symbol bindings and named visibility on those definitions": 1, "app/views/books/show.md.erb|view template not ingested: erb (non-html format)": 1, "app/views/leafables/show.md.erb|view template not ingested: erb (non-html format)": 1, "lib/rails_ext/action_text_has_markdown.rb|`ActiveSupport.on_load(:active_record)` contains `include ActionText::HasMarkdown`, which is not carried: load-hook mixin installation is unsupported": 1, diff --git a/tests/forgery_protection_filters.rs b/tests/forgery_protection_filters.rs index 43b6b5643..8b891a38e 100644 --- a/tests/forgery_protection_filters.rs +++ b/tests/forgery_protection_filters.rs @@ -108,13 +108,13 @@ fn skip_forgery_protection_removes_the_check() { } #[test] -fn rails_implicit_default_is_not_applied_yet() { +fn rails_implicit_default_is_not_applied() { // Rails puts `verify_authenticity_token` at the head of every chain - // rooted at ActionController::Base (`default_protect_from_forgery`). - // Gated off in `build_filter_preamble`: an app relying on it — - // real-blog — is emitted for the strict targets too, which have no - // such method. Pinned so turning it on is a decision with a test - // to update, not a side effect. + // rooted at ActionController::Base. Still gated off: bare + // `protect_from_forgery` is `:null_session` and must not become 422. + // Shared Base now defines the method for apps that write + // `with: :exception`. Pinned so turning the default on is a + // decision with a test to update. let files = emit(vec![ ( "app/controllers/application_controller.rb", diff --git a/tests/framework_tests_spinel.rs b/tests/framework_tests_spinel.rs index 530be2023..fccb2b60a 100644 --- a/tests/framework_tests_spinel.rs +++ b/tests/framework_tests_spinel.rs @@ -49,6 +49,25 @@ fn copy_tree(src: &Path, dst: &Path) { } } +/// Same Base→untyped rewrite the shipped Spinel tree applies. This +/// harness copies `runtime/ruby` verbatim, so without it +/// `Relation.new(self)` keeps `initialize:(Base)` and Spinel refuses. +fn apply_spinel_relation_model_handle(scratch: &Path) { + let rel = scratch.join("runtime/active_record/relation.rbs"); + let conn = scratch.join("runtime/active_record/connection.rbs"); + let rel_src = std::fs::read_to_string(&rel).expect("read relation.rbs"); + let conn_src = std::fs::read_to_string(&conn).expect("read connection.rbs"); + let mut files = vec![ + ("runtime/active_record/relation.rbs".to_string(), rel_src), + ("runtime/active_record/connection.rbs".to_string(), conn_src), + ]; + roundhouse::project::spinel_relation_model_handle(&mut files) + .expect("spinel relation Base rewrite"); + for (path, content) in files { + std::fs::write(scratch.join(path), content).expect("write rewritten rbs"); + } +} + /// Move every `/{runtime,test}/**/*.rbs` to /// `/sig/{runtime,test}/.rbs` to match the shipped /// project's sidecar layout. @@ -133,6 +152,7 @@ fn build_and_run(test_file: &Path, tag: &str) { .unwrap_or_else(|_| panic!("copy sidecar for {entry}")); } } + apply_spinel_relation_model_handle(&scratch); // Spinel-specific shims that the framework runtime calls into but // doesn't itself define: Base64 (used by ActionView::ViewHelpers diff --git a/tests/framework_tests_swift.rs b/tests/framework_tests_swift.rs index d93cfd2fd..c0f7cab37 100644 --- a/tests/framework_tests_swift.rs +++ b/tests/framework_tests_swift.rs @@ -113,9 +113,13 @@ fn build_and_run(test_files: &[&str], tag: &str) { std::fs::write(&path, &file.content).expect("write emitted file"); } - // `swift test` builds main + tests and runs XCTest. + // `swift test` builds main + tests and runs XCTest. Skip full + // debuginfo: CI only needs the XCTest result. Do not pass + // `--disable-index-store`: SPM still looks up the index store + // path and `swift test` then fatalErrors on Linux. let output = Command::new("swift") .arg("test") + .args(["-Xswiftc", "-gline-tables-only"]) .current_dir(&scratch) .output() .expect("run swift test"); diff --git a/tests/has_one_dependent_destroy.rs b/tests/has_one_dependent_destroy.rs new file mode 100644 index 000000000..bf138fa49 --- /dev/null +++ b/tests/has_one_dependent_destroy.rs @@ -0,0 +1,40 @@ +//! `has_one …, dependent: :destroy` cascades through the synthesized +//! `before_destroy`, the same hook `has_many` already uses. Autosave +//! and preload stay unclaimed: those need a writer and a cache. + +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +#[test] +fn destroying_the_owner_destroys_the_has_one_child() { + emit_and_run::real_blog() + .edit( + "db/schema.rb", + " create_table \"comments\", force: :cascade do |t|", + " create_table \"profiles\", force: :cascade do |t|\n t.integer \"article_id\"\n t.string \"bio\"\n end\n\n create_table \"comments\", force: :cascade do |t|", + ) + .write( + "app/models/profile.rb", + "class Profile < ApplicationRecord\n belongs_to :article\nend\n", + ) + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n has_many :comments, dependent: :destroy", + "class Article < ApplicationRecord\n has_many :comments, dependent: :destroy\n has_one :profile, dependent: :destroy", + ) + .run_ruby( + r#" +a = Article.create!(title: "Hello world", body: "abcdefghij") +child = Profile.create!(article_id: a.id, bio: "hi") +raise "reader missed child" unless a.profile.bio == "hi" +a.destroy +raise "child survived" unless Profile.find_by(id: child.id).nil? +raise "owner survived" unless Article.find_by(id: a.id).nil? +b = Article.create!(title: "Empty owner", body: "abcdefghij") +b.destroy +raise "nil child raised" unless Article.find_by(id: b.id).nil? +puts "has_one dependent destroy passed" +"#, + ) + .assert_passes(); +} diff --git a/tests/helper_param_inference.rs b/tests/helper_param_inference.rs index a5457c270..3b8ae4e15 100644 --- a/tests/helper_param_inference.rs +++ b/tests/helper_param_inference.rs @@ -78,6 +78,19 @@ fn view_call_sites_unify_helper_params_and_stamp_signature() { assert_eq!(params[0].ty, Ty::Str, "param should unify to Str from the call site"); // Body is `msg` — the param read — so the return follows the seed. assert_eq!(**ret, Ty::Str); + // Views are typed once before that harvest; they must be restamped + // so the template Send is Str, not leftover gradual untyped. + match &*app.views[0].body.node { + ExprNode::Send { method, .. } if method.as_str() == "shout" => { + assert_eq!( + app.views[0].body.ty.as_ref(), + Some(&Ty::Str), + "view helper call should restamp as the harvested return, got {:?}", + app.views[0].body.ty + ); + } + other => panic!("expected shout Send, got {other:?}"), + } } #[test] diff --git a/tests/model_lowerer.rs b/tests/model_lowerer.rs index 431d2ee0f..6c605c0c0 100644 --- a/tests/model_lowerer.rs +++ b/tests/model_lowerer.rs @@ -453,6 +453,63 @@ fn article_lowers_dependent_destroy_to_before_destroy() { assert!(block_present, "each call should carry a block"); } +/// `has_one …, dependent: :destroy` cascades the single child, not +/// a collection `each`. Nil child is the else branch so destroy of an +/// owner with no row does not raise. +#[test] +fn has_one_dependent_destroy_lowers_to_before_destroy() { + use roundhouse::ingest::{ingest_model, ingest_schema}; + + let schema = ingest_schema( + br#" +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "users", force: :cascade do |t| + t.string "name" + end + create_table "profiles", force: :cascade do |t| + t.integer "user_id" + t.string "bio" + end +end +"#, + "db/schema.rb", + ) + .expect("ingest schema"); + let model = ingest_model( + b"class User < ApplicationRecord\n has_one :profile, dependent: :destroy\nend\n", + "app/models/user.rb", + &schema, + &Default::default(), + ) + .expect("ingest") + .expect("model"); + let lc = lower_model_to_library_class(&model, &schema); + let cb = lc + .methods + .iter() + .find(|m| m.name.as_str() == "before_destroy") + .expect("before_destroy method present (has_one dependent: :destroy)"); + assert!(matches!(cb.receiver, MethodReceiver::Instance)); + let first = &body_stmts(cb)[0]; + match &*first.node { + roundhouse::ExprNode::If { cond, then_branch, .. } => { + match &*cond.node { + roundhouse::ExprNode::Send { method, .. } => { + assert_eq!(method.as_str(), "profile"); + } + other => panic!("expected profile reader cond; got {other:?}"), + } + match &*then_branch.node { + roundhouse::ExprNode::Send { method, .. } => { + assert_eq!(method.as_str(), "destroy"); + } + other => panic!("expected destroy in then; got {other:?}"), + } + } + other => panic!("expected If cascade for has_one destroy; got {other:?}"), + } +} + /// Statements of a method body as a list (single stmt = one element). fn body_stmts(m: &roundhouse::dialect::MethodDef) -> Vec { match &*m.body.node { diff --git a/tests/on_load_includes.rs b/tests/on_load_includes.rs index 76c0c4245..204fac0ad 100644 --- a/tests/on_load_includes.rs +++ b/tests/on_load_includes.rs @@ -1,4 +1,5 @@ -//! Load-hook mixin installation is a reported gap, not Markdown support. +//! Load-hook mixin installation is a reported gap. Overlays are +//! abstract stems; Writebook-shaped macros are extra coverage only. use std::collections::HashMap; use std::path::PathBuf; @@ -14,8 +15,14 @@ fn tree(hooks: &str) -> HashMap> { [ ("config/routes.rb", "Rails.application.routes.draw do\nend\n"), ("lib/rails_ext/hooks.rb", hooks), - ("app/models/page.rb", "class Page < ApplicationRecord\n has_markdown :body\nend\n"), - ("db/schema.rb", "ActiveRecord::Schema.define do\n create_table :pages do |t|\n t.string :title\n end\n create_table :action_text_markdowns do |t|\n t.string :record_type\n t.integer :record_id\n t.string :name\n t.text :content\n end\nend\n"), + ( + "app/models/article.rb", + "class Article < ApplicationRecord\n labeled :headline\nend\n", + ), + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table :articles do |t|\n t.string :title\n end\nend\n", + ), ] .into_iter() .map(|(p, s)| (PathBuf::from(p), s.as_bytes().to_vec())) @@ -24,11 +31,9 @@ fn tree(hooks: &str) -> HashMap> { #[test] fn direct_includes_name_the_hook_and_source_without_installing_anything() { - // First hook is Writebook's installer, verbatim. Later hooks prove - // that a literal-name-only recognizer cannot silently skip other shapes. let files = tree( r#"ActiveSupport.on_load :active_record do - include ActionText::HasMarkdown + include LabelMacro end ActiveSupport.on_load(:action_text_markdown) do include First, Second @@ -44,7 +49,7 @@ end let messages: Vec<_> = gaps.iter().map(ToString::to_string).collect(); assert_eq!(messages.len(), 3, "one gap per include: {messages:?}"); for (hook, include) in [ - ("active_record", "include ActionText::HasMarkdown"), + ("active_record", "include LabelMacro"), ("action_text_markdown", "include First, Second"), ( "action_text_markdown", @@ -63,18 +68,14 @@ end ); } assert_eq!(surveyed, strict, "reporting must not change ingested IR"); - let page = surveyed + let article = surveyed .models .iter() - .find(|m| m.name.0.as_str() == "Page") + .find(|m| m.name.0.as_str() == "Article") .unwrap(); - assert!(page.body.iter().any(|item| matches!(item, + assert!(article.body.iter().any(|item| matches!(item, ModelBodyItem::Unknown { expr, .. } if matches!(&*expr.node, - ExprNode::Send { method, .. } if method.as_str() == "has_markdown")))); - assert!(!surveyed.models.iter().any(|m| matches!( - m.name.0.as_str(), - "ActionText::Markdown" | "ActionText::RichText" - ))); + ExprNode::Send { method, .. } if method.as_str() == "labeled")))); let strict_diags = roundhouse::session::analyze_and_lower(&mut strict); let survey_diags = roundhouse::session::analyze_and_lower(&mut surveyed); assert_eq!( @@ -90,31 +91,21 @@ end assert_eq!(survey_files, strict_files, "no emitted behavior changed"); assert_eq!(survey_emit, strict_emit); assert!( - survey_emit - .iter() - .any(|d| d.message.contains("has_markdown")), + survey_emit.iter().any(|d| d.message.contains("labeled")), "declaration warning must remain: {survey_emit:?}" ); - let page = survey_files + let article = survey_files .iter() - .find(|f| f.path.ends_with("page.rb")) + .find(|f| f.path.ends_with("article.rb")) .unwrap(); - for method in [ - "body", - "body?", - "body=", - "markdown_body", - "build_markdown_body", - "with_markdown_body", - "with_markdown_body_and_embeds", - ] { + for method in ["headline", "headline?", "headline=", "build_headline", "with_headline"] { assert!( - !page.content.lines().any(|line| line + !article.content.lines().any(|line| line .trim_start() .starts_with(&format!("def {method}(")) || line.trim() == format!("def {method}")), "not supported: {method}\n{}", - page.content + article.content ); } } @@ -179,49 +170,43 @@ fn binary_encoded_source_uses_original_byte_locations_before_lossy_display() { } #[test] -fn emitted_app_does_not_gain_methods_from_a_dropped_installer_or_string_class_eval() { - // This is a negative execution check. The provider is a minimal - // string-eval macro, not a replacement implementation of Writebook. +fn load_hook_does_not_install_mixin_instance_methods() { + // Class-method macros from the hook may expand (see + // `class_body_declarations`). Instance methods of the included + // module still do not land — mixin installation stays a gap. let run = emit_and_run::real_blog() - .write("lib/rails_ext/action_text_has_markdown.rb", r#"module ActionText::HasMarkdown + .write( + "lib/rails_ext/title_macro.rb", + r#"module TitleMacro extend ActiveSupport::Concern class_methods do - def has_markdown(name) - class_eval "def body; 99; end" + def titled(name) + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + @#{name}.to_s + end + CODE end end - def markdown_installer_marker + def installer_marker 37 end end ActiveSupport.on_load :active_record do - include ActionText::HasMarkdown -end -"#) - .write("app/models/page.rb", "class Page < ApplicationRecord\n has_markdown :body\nend\n") - .edit("db/schema.rb", " add_foreign_key \"comments\", \"articles\"", " create_table :pages do |t|\n t.string :title\n end\n add_foreign_key \"comments\", \"articles\"") - .run_ruby(r#" -page = Page.new -[:body, :body?, :body=, :markdown_body, :build_markdown_body].each do |name| - raise "invented Markdown method #{name}" if page.respond_to?(name, true) + include TitleMacro end -[page, Article.new].each do |owner| - raise "dropped hook installed mixin on #{owner.class}" if owner.respond_to?(:markdown_installer_marker, true) -end -[:with_markdown_body, :with_markdown_body_and_embeds].each do |name| - raise "invented preload scope #{name}" if Page.respond_to?(name, true) -end -begin - Page.new.body - raise "string class_eval was expanded" -rescue NoMethodError => e - raise "wrong missing method" unless e.name == :body -end -puts "unsupported Markdown boundary preserved" -"#); +"#, + ) + .run_ruby( + r#" +a = Article.new +raise "dropped hook installed mixin" if a.respond_to?(:installer_marker, true) +puts "load-hook mixin installation still dropped" +"#, + ); run.assert_passes(); assert!( run.stdout - .contains("unsupported Markdown boundary preserved") + .contains("load-hook mixin installation still dropped") ); } diff --git a/tests/param_binds.rs b/tests/param_binds.rs index 13a2d9e55..10731d74c 100644 --- a/tests/param_binds.rs +++ b/tests/param_binds.rs @@ -372,3 +372,32 @@ fn varying_binds_spinel() { fn bind_runtime_spinel() { runtime(true); } + +fn raw_where_substitution(target: BuildTarget) { + let (dir, errors) = overlay().emit(target); + assert!(errors.is_empty(), "{}", errors.join("\n")); + let script = format!( + r#"require_relative "boot" +require_relative "app/models/item" +SqliteAdapter.configure("file:raw_where_gate?mode=memory&cache=shared") +ActiveRecord.adapter = SqliteAdapter +Schema.statements.each {{ |sql| Db.exec(sql) }} +{} +Db.close +"#, + include_str!("param_binds_raw_where.rb") + ); + run_script(&dir, &script, target == BuildTarget::Spinel); + std::fs::remove_dir_all(dir.parent().unwrap()).expect("remove successful overlay"); +} + +#[test] +fn raw_where_substitution_ruby() { + raw_where_substitution(BuildTarget::Ruby); +} + +#[test] +#[ignore = "requires Spinel (SPINEL=/path/to/spinel)"] +fn raw_where_substitution_spinel() { + raw_where_substitution(BuildTarget::Spinel); +} diff --git a/tests/param_binds_raw_where.rb b/tests/param_binds_raw_where.rb new file mode 100644 index 000000000..547de9168 --- /dev/null +++ b/tests/param_binds_raw_where.rb @@ -0,0 +1,43 @@ +# Execute raw Relation predicates against real rows. Escaped values must +# never become the input to a later placeholder or replacement expansion. +Db.with_connection do + backslashes = %q{path\1\&\`\'} + "雪" + Db.exec("INSERT INTO items (id, parent_id, name) VALUES (1, 1, 'a'), (2, 1, 'what?')") + Db.exec("INSERT INTO items (id, parent_id, name) VALUES (3, 2, " + Db.escape_string(backslashes) + ")") + + scalar = ActiveRecord::Relation.new(Item).where("items.name = ? OR items.id = ?", "what?", 1) + raise "scalar value consumed later placeholder" unless scalar.count == 2 + escaped = ActiveRecord::Relation.new(Item).where("items.name = ? AND items.id = ?", backslashes, 3) + raise "replacement escapes changed scalar value" unless escaped.count == 1 + quoted = ActiveRecord::Relation.new(Item).where("items.name = ? OR items.id = ?", "x' OR ?=1 --", 999) + raise "quoted scalar changed predicate" unless quoted.count == 0 + reversed = ActiveRecord::Relation.new(Item).where("items.id = ? AND items.name = ?", 2, "what?") + raise "last argument changed" unless reversed.count == 1 + negated = ActiveRecord::Relation.new(Item).not("items.name = ? OR items.id = ?", "what?", 1) + raise "negated substitution changed" unless negated.count == 1 + having = ActiveRecord::Relation.new(Item).group(:id).having("items.name = ? AND items.id = ?", "what?", 2) + raise "HAVING substitution changed" unless having.load_records.length == 1 + + # Keep main's adapter treatment of Arrays, not the draft's IN-list expansion. + list = ActiveRecord::Relation.new(Item).where("items.id IN (?) AND items.parent_id = ?", [1, 2], 1) + raise "Array escaping changed" unless list.to_sql.include?("items.id IN ('[1, 2]') AND items.parent_id = 1") + empty = ActiveRecord::Relation.new(Item).where("items.id IN (?)", []) + raise "empty Array escaping changed" unless empty.to_sql.include?("items.id IN ('[]')") + hashed = ActiveRecord::Relation.new(Item).where(id: [1, 3]) + raise "hash IN changed" unless hashed.count == 2 + + # This repair does not add a SQL parser or an arity policy. + literal = ActiveRecord::Relation.new(Item).where("items.id = 1", "ignored?") + raise "literal raw clause changed" unless literal.count == 1 + surplus = ActiveRecord::Relation.new(Item).where("items.name = ?", "what?", "ignored") + raise "surplus argument rewrote substituted data" unless surplus.count == 1 + missing = ActiveRecord::Relation.new(Item).where("items.name = ? AND items.id = ?", "what?") + raise "missing argument changed placeholder" unless missing.to_sql.include?("items.name = 'what?' AND items.id = ?") + none = ActiveRecord::Relation.new(Item).where("items.id = ?") + raise "unfilled trailing placeholder changed" unless none.to_sql.include?("items.id = ?") + adjacent = ActiveRecord::Relation.new(Item).where("??", "a", "b") + raise "adjacent placeholders changed" unless adjacent.to_sql.include?("('a''b')") + blank = ActiveRecord::Relation.new(Item).where("", "ignored") + raise "empty fragment changed" if blank.to_sql.include?("WHERE") +end +puts "raw where: scalar substitution, escaping, negation, HAVING and Array compatibility passed" diff --git a/tests/pattern_matching.rs b/tests/pattern_matching.rs index 802ca4bb1..306ea553c 100644 --- a/tests/pattern_matching.rs +++ b/tests/pattern_matching.rs @@ -21,6 +21,24 @@ fn parse(source: &str) -> Expr { .unwrap() } +/// Analyzer unions are canonicalized by structural `ty_tag` (Nil last), not +/// Debug-string order. Treat variant lists as sets so a sort-key change +/// cannot flake an otherwise equivalent type. +fn same_ty(a: &Ty, b: &Ty) -> bool { + match (a, b) { + (Ty::Union { variants: av }, Ty::Union { variants: bv }) => { + av.len() == bv.len() + && av.iter().all(|v| bv.iter().any(|w| same_ty(v, w))) + && bv.iter().all(|v| av.iter().any(|w| same_ty(v, w))) + } + (Ty::Array { elem: a }, Ty::Array { elem: b }) => same_ty(a, b), + (Ty::Hash { key: ak, value: av }, Ty::Hash { key: bk, value: bv }) => { + same_ty(ak, bk) && same_ty(av, bv) + } + _ => a == b, + } +} + #[test] fn native_matches_preserve_expression_precedence_and_pattern_syntax() { for (source, expected) in [ @@ -110,8 +128,11 @@ fn pattern_locals_survive_guard_failure_predicates_and_required_matches() { ), ( "(7 in n) && n", + // Canonicalizer is structural (`ty_tag`, Nil last), not Debug + // string order — Int before Bool. Compare unions as sets below + // so this does not flake if the sort key changes again. Ty::Union { - variants: vec![Ty::Bool, Ty::Int, Ty::Nil], + variants: vec![Ty::Int, Ty::Bool, Ty::Nil], }, ), ( @@ -122,10 +143,10 @@ fn pattern_locals_survive_guard_failure_predicates_and_required_matches() { ), ] { let mut expr = parse(source); - assert_eq!( - typer.analyze_expr(&mut expr, &Ctx::default()), - expected, - "{source}" + let got = typer.analyze_expr(&mut expr, &Ctx::default()); + assert!( + same_ty(&got, &expected), + "{source}\n left: {got:?}\n right: {expected:?}" ); } } diff --git a/tests/kaminari_pagination.rs b/tests/relation_pagination.rs similarity index 70% rename from tests/kaminari_pagination.rs rename to tests/relation_pagination.rs index 911c2963d..45bc746ad 100644 --- a/tests/kaminari_pagination.rs +++ b/tests/relation_pagination.rs @@ -1,12 +1,10 @@ -//! Kaminari's `page` / `per` and the paginator readers on the runtime -//! Relation (#311). +//! Relation `page` / `per`: LIMIT/OFFSET page arithmetic, plus +//! count-without-limit for the paginator readers (`current_page`, +//! `total_pages`, `total_count`, …). //! -//! The catalog typed `page` / `per` as relation builders, but the runtime -//! Relation defined neither, nor the readers a paged relation answers -//! (`current_page`, `total_pages`, …), so `check` reported every reader -//! as a failed dispatch and the emitted program raised NoMethodError on -//! `page`. The page size comes from the app's `Kaminari.configure` block -//! when it has one, else Kaminari's default of 25. +//! `paginate` is cataloged as the same builder. The default page size +//! is 25 unless ingest lifts a literal from a `Kaminari.configure` +//! block (one input spelling of that default). #[path = "support/emit_and_run.rs"] mod emit_and_run; @@ -24,6 +22,22 @@ const CONTROLLER: &str = r#"class ReportsController < ApplicationController reports = Report.page(params[:page]) render plain: "page=#{reports.current_page} per=#{reports.limit_value} titles=#{reports.map(&:title).join(",")}" end + + def by_paginate + reports = Report.order(:title).paginate(params[:page]).per(params[:per]) + render plain: "page=#{reports.current_page} per=#{reports.limit_value} total=#{reports.total_count} " \ + "pages=#{reports.total_pages} next=#{reports.next_page.inspect} prev=#{reports.prev_page.inspect} " \ + "first=#{reports.first_page?} last=#{reports.last_page?} out=#{reports.out_of_range?} " \ + "titles=#{reports.map(&:title).join(",")}" + end + + def by_paginate_kwargs + reports = Report.order(:title).paginate(page: params[:page], per_page: params[:per]) + render plain: "page=#{reports.current_page} per=#{reports.limit_value} total=#{reports.total_count} " \ + "pages=#{reports.total_pages} next=#{reports.next_page.inspect} prev=#{reports.prev_page.inspect} " \ + "first=#{reports.first_page?} last=#{reports.last_page?} out=#{reports.out_of_range?} " \ + "titles=#{reports.map(&:title).join(",")}" + end end "#; @@ -54,14 +68,14 @@ fn app(initializer: Option<&str>) -> emit_and_run::Overlay { ) .write( "config/routes.rb", - "Rails.application.routes.draw do\n resources :reports, only: :index\n get \"unordered\", to: \"reports#unordered\"\nend\n", + "Rails.application.routes.draw do\n resources :reports, only: :index\n get \"unordered\", to: \"reports#unordered\"\n get \"by_paginate\", to: \"reports#by_paginate\"\n get \"by_paginate_kwargs\", to: \"reports#by_paginate_kwargs\"\nend\n", ) .write( "db/schema.rb", "ActiveRecord::Schema[8.1].define(version: 2026_01_01_000000) do\n create_table \"reports\", force: :cascade do |t|\n t.string \"title\"\n end\nend\n", ); match initializer { - Some(source) => overlay.write("config/initializers/kaminari_config.rb", source), + Some(source) => overlay.write("config/initializers/kaminari.rb", source), None => overlay, } } @@ -79,9 +93,9 @@ def expect(path, query, want) end "#; -/// What Rails with kaminari answers for the same requests and rows. +/// LIMIT/OFFSET windows and the count-without-limit readers. #[test] -fn page_and_per_answer_what_kaminari_does() { +fn page_and_per_answer_limit_offset_windows() { let script = format!( r#"{PRELUDE} expect("/reports", "page=2&per=2", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=false last=false out=false titles=c,d") @@ -91,16 +105,18 @@ expect("/reports", "", "page=1 per=2 total=5 pages=3 next=2 prev=nil first=true expect("/reports", "page=2&per=", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=false last=false out=false titles=c,d") expect("/reports", "page=9", "page=9 per=2 total=5 pages=3 next=nil prev=nil first=false last=false out=true titles=") expect("/unordered", "page=2", "page=2 per=2 titles=c,b") +expect("/by_paginate", "page=2&per=2", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=false last=false out=false titles=c,d") +expect("/by_paginate_kwargs", "page=2&per=2", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=false last=false out=false titles=c,d") "# ); app(Some(INITIALIZER)).run_ruby(&script).assert_passes(); } #[test] -fn without_an_initializer_a_page_is_kaminaris_default_25_rows() { +fn without_configure_a_page_is_25_rows() { let script = format!( r#"{PRELUDE} -raise "default per page is #{{Rails.application.kaminari_default_per_page}}" unless Rails.application.kaminari_default_per_page == 25 +raise "default per page is #{{Rails.application.default_per_page}}" unless Rails.application.default_per_page == 25 expect("/unordered", "", "page=1 per=25 titles=e,d,c,b,a") expect("/reports", "page=2", "page=2 per=25 total=5 pages=1 next=nil prev=nil first=false last=false out=true titles=") "# @@ -110,13 +126,13 @@ expect("/reports", "page=2", "page=2 per=25 total=5 pages=1 next=nil prev=nil fi /// Only the `Kaminari.configure` block's own parameter sets the page /// size: a `config.default_per_page =` in another config block of -/// the same initializer belongs to that block's receiver, not Kaminari. +/// the same initializer belongs to that block's receiver. #[test] -fn only_the_kaminari_configure_block_sets_the_page_size() { +fn only_the_configure_block_sets_the_page_size() { let initializer = "Kaminari.configure { |k| k.default_per_page = 2 }\n\nRails.application.configure do |config|\n config.default_per_page = 50\nend\n"; let script = format!( r#"{PRELUDE} -raise "default per page is #{{Rails.application.kaminari_default_per_page}}" unless Rails.application.kaminari_default_per_page == 2 +raise "default per page is #{{Rails.application.default_per_page}}" unless Rails.application.default_per_page == 2 expect("/reports", "page=2", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=false last=false out=false titles=c,d") "# ); @@ -125,10 +141,10 @@ expect("/reports", "page=2", "page=2 per=2 total=5 pages=3 next=3 prev=1 first=f /// A page size the initializer computes (an ENV read) cannot be carried /// into the emitted app, so survey mode ledgers it instead of the app -/// silently paging at Kaminari's default. +/// silently paging at the baked-in default of 25. #[test] fn a_computed_page_size_is_a_survey_gap() { - use roundhouse::ingest::{IngestError, ingest_app_from_tree, survey}; + use roundhouse::ingest::{ingest_app_from_tree, survey, IngestError}; let tree = [ ( @@ -136,7 +152,7 @@ fn a_computed_page_size_is_a_survey_gap() { "require_relative \"boot\"\nrequire \"rails/all\"\n\nmodule Archive\n class Application < Rails::Application\n end\nend\n", ), ( - "config/initializers/kaminari_config.rb", + "config/initializers/kaminari.rb", "Kaminari.configure do |config|\n config.default_per_page = ENV.fetch(\"PAGE_SIZE\").to_i\nend\n", ), ] @@ -151,7 +167,7 @@ fn a_computed_page_size_is_a_survey_gap() { gaps.iter().any(|gap| matches!( gap, IngestError::Unsupported { file, message } - if file.ends_with("config/initializers/kaminari_config.rb") + if file.ends_with("config/initializers/kaminari.rb") && message.contains("default_per_page") && message.contains("ENV.fetch(\"PAGE_SIZE\").to_i") )), diff --git a/tests/relation_result_set_count.rs b/tests/relation_result_set_count.rs new file mode 100644 index 000000000..09eb583bb --- /dev/null +++ b/tests/relation_result_set_count.rs @@ -0,0 +1,38 @@ +//! Relation `#count` on DISTINCT and GROUP BY queries counts the +//! result-set shape, not the underlying row total (#343). +//! +//! That is ActiveRecord::Relation SQL. A scope is the relation seed +//! (same as other overlays): `select` on a loaded Array is Enumerable. + +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +#[test] +fn distinct_and_grouped_count_use_the_result_set_size() { + emit_and_run::real_blog() + .edit( + "app/models/article.rb", + "class Article < ApplicationRecord\n has_many :comments, dependent: :destroy", + "class Article < ApplicationRecord\n has_many :comments, dependent: :destroy\n scope :named, -> { where.not(title: nil) }", + ) + .run_ruby( + r#" +%w[c c b b a].each { |t| Article.create!(title: t, body: "abcdefghij") } +# Five rows, three distinct titles. Scalar `#count` on a grouped +# relation is group cardinality (`count_sql`), not Rails' Hash of +# group → n. +n = Article.named.select("title").distinct.count +raise "distinct count #{n}" unless n == 3 +g = Article.named.group("title").count +raise "grouped count #{g}" unless g == 3 +sql = Article.named.select("title").distinct.count_sql +raise "distinct count_sql lost DISTINCT: #{sql}" unless sql.include?("DISTINCT") +raise "distinct count_sql lost subquery: #{sql}" unless sql.include?("__rh_count") +gsql = Article.named.group("title").count_sql +raise "grouped count_sql lost GROUP BY: #{gsql}" unless gsql.include?("GROUP BY") +raise "grouped count_sql lost subquery: #{gsql}" unless gsql.include?("__rh_count") +puts "result-set count passed" +"#, + ) + .assert_passes(); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 71c404e35..2ca94c93d 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -145,6 +145,7 @@ fn tiny_blog_round_trips() { view_visible_controller_methods: std::collections::BTreeSet::new(), global_id_locate_models: std::collections::BTreeSet::new(), attachable_unsigned_models: Vec::new(), + load_hook_class_macros: Vec::new(), partial_local_types: std::collections::HashMap::new(), view_ivar_types: std::collections::HashMap::new(), html_safe_methods: std::collections::BTreeSet::new(), diff --git a/tests/route_url_options.rs b/tests/route_url_options.rs index 01aba19a1..dfe18ff5d 100644 --- a/tests/route_url_options.rs +++ b/tests/route_url_options.rs @@ -41,6 +41,23 @@ fn lowered_views(view: &str) -> String { app.views.iter().map(|v| format!("{:?}", v.body)).collect::>().join("\n") } +/// Same view after the emit pipeline's fused post-analyze walks. +fn lowered_views_post_analyze(view: &str) -> String { + let files = vec![ + ("db/schema.rb", SCHEMA), + ("app/models/note.rb", "class Note < ApplicationRecord\nend\n"), + ("config/routes.rb", ROUTES), + ("app/views/notes/index.html.erb", view), + ]; + let tree = files + .into_iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app: App = ingest_app_from_tree(tree).expect("ingest tree"); + roundhouse::session::analyze_and_lower(&mut app); + app.views.iter().map(|v| format!("{:?}", v.body)).collect::>().join("\n") +} + /// The wall: campfire's `host:`. #[test] fn a_host_option_is_stripped_from_the_call() { @@ -191,3 +208,33 @@ fn a_hostless_url_is_untouched() { assert!(!out.contains("\"://\""), "no authority is invented:\n{out}"); assert!(out.contains("Symbol(\"room_id\")"), "the query key stays:\n{out}"); } + +/// A non-literal `format:` wraps the helper as `call + ".ext"`. The fused +/// mid walk must still run host rewrite on that inner `recv: None` send, +/// matching the sequential format-then-host walks. +#[test] +fn a_dynamic_format_does_not_hide_host_from_the_url_rewrite() { + let out = lowered_views_post_analyze( + "<%= link_to \"a\", autocompletable_notes_url(host: \"once.campfire.test\", format: @note.body) %>\n", + ); + assert!( + out.contains("StringInterp"), + "the host rewrite must still rebuild the URL as an interpolation:\n{out}" + ); + assert!( + out.contains("once.campfire.test"), + "the host survives as authority, not a query key:\n{out}" + ); + assert!( + out.contains("autocompletable_notes_path"), + "over the generated _path helper:\n{out}" + ); + assert!( + !out.contains("Symbol(\"host\")"), + "`host:` must not remain a kwargs key after the wrap:\n{out}" + ); + assert!( + !out.contains("Symbol(\"format\")"), + "`format:` is consumed by the suffix pass:\n{out}" + ); +} diff --git a/tests/runtime_src_integration.rs b/tests/runtime_src_integration.rs index 4dfcbcdf7..12d703280 100644 --- a/tests/runtime_src_integration.rs +++ b/tests/runtime_src_integration.rs @@ -793,13 +793,12 @@ fn every_runtime_method_body_concretely_typed() { eprintln!(" {stem}.rb: {count}"); } - // Soft Bar B ratchet: count of exact `Ty::Untyped` sites across - // `runtime/ruby`. Fails only when the residual rises. Tighten the - // ceiling after a measured drop — never raise without a ledgered - // feature. Current residual is dominated by `relation.rb` - // (polymorphic SQL / Enumerable) and `Hash[Symbol, untyped]` helper - // opts; `Relation[T]` is the longer-term fix. - const CEILING: usize = 435; + // Soft Bar B ratchet: fails when residual rises. Tighten after a + // measured drop; never raise without a ledgered feature. Residual + // still dominated by polymorphic SQL / helper-opt hashes; + // `Relation[T]` is the longer-term fix. Merged main's 299; measure + // after the security helpers before changing this number. + const CEILING: usize = 299; assert!( total_gradual <= CEILING, "{total_gradual} Ty::Untyped sites exceeds ceiling of {CEILING}", diff --git a/tests/rust_json_value_coerce.rs b/tests/rust_json_value_coerce.rs new file mode 100644 index 000000000..02f8dbeb8 --- /dev/null +++ b/tests/rust_json_value_coerce.rs @@ -0,0 +1,79 @@ +//! Pin the rust emit of RBS unions that collapse to `serde_json::Value`. +//! +//! `scripts/compare rust` compiles the transpiled `view_helpers.rb`, not +//! the handwritten `runtime/rust/view_helpers.rs`. Heterogeneous RBS +//! unions (`String | Symbol`, `String | Array[untyped]`, `String | +//! Integer | Float | bool | nil`) used to emit `&str` / `Value` / +//! `.is_none()` combinations that rustc rejected (E0308 / E0599). + +use roundhouse::analyze::Analyzer; +use roundhouse::emit::{rust, EmittedFile}; +use roundhouse::ingest::ingest_app; + +fn file(files: &[EmittedFile], suffix: &str) -> String { + files + .iter() + .find(|f| f.path.to_string_lossy().ends_with(suffix)) + .unwrap_or_else(|| panic!("missing {suffix}")) + .content + .clone() +} + +fn method_body<'a>(src: &'a str, name: &str) -> &'a str { + let start = src + .find(&format!("pub fn {name}(")) + .unwrap_or_else(|| panic!("missing fn {name} in:\n{src}")); + let rest = &src[start..]; + let end = rest + .find("\n pub fn ") + .unwrap_or(rest.len()); + &rest[..end] +} + +fn emit_real_blog_rust() -> Vec { + let mut app = ingest_app(roundhouse::fixtures::real_blog()).expect("ingest"); + Analyzer::new(&app).analyze(&mut app); + rust::emit(&app) +} + +#[test] +fn view_helpers_nil_pred_and_value_string_coercions_typecheck() { + let files = emit_real_blog_rust(); + let vh = file(&files, "view_helpers.rs"); + + let optional = method_body(&vh, "optional_value_attr"); + assert!( + optional.contains("is_null()") && !optional.contains("is_none()"), + "optional_value_attr must use Value::is_null, not Option::is_none:\n{optional}" + ); + assert!( + optional.contains("ruby_to_s"), + "optional_value_attr must to_s Value via ruby_to_s (no JSON quotes):\n{optional}" + ); + + let escape = method_body(&vh, "escape_or_empty"); + assert!( + escape.contains("is_null()") && !escape.contains("is_none()"), + "escape_or_empty must use Value::is_null, not Option::is_none:\n{escape}" + ); + + let form_with = method_body(&vh, "form_with"); + assert!( + form_with.contains("method_override_input") && form_with.contains("ruby_to_s"), + "form_with must stringify the Hash fetch before method_override_input:\n{form_with}" + ); + assert!( + !form_with.contains("method_override_input(opts"), + "gradual opts must not cross method_override_input:\n{form_with}" + ); + + let articles = file(&files, "views/articles.rs"); + assert!( + articles.contains("turbo_stream_from(serde_json::Value::from(\"articles\")"), + "string turbo_stream_from sites must box into Value:\n{articles}" + ); + assert!( + articles.contains("turbo_stream_from(serde_json::Value::from(format!("), + "interpolated turbo_stream_from sites must box into Value:\n{articles}" + ); +} diff --git a/tests/security_utils_execution.rs b/tests/security_utils_execution.rs new file mode 100644 index 000000000..6f8149c56 --- /dev/null +++ b/tests/security_utils_execution.rs @@ -0,0 +1,92 @@ +//! The registry accepts SecurityUtils; its emitted runtime must exist too. +#[path = "support/emit_and_run.rs"] +mod emit_and_run; + +fn app() -> emit_and_run::Overlay { + emit_and_run::empty_app() + .write("db/schema.rb", "ActiveRecord::Schema.define do\n create_table :items do |t|\n t.string :name\n end\nend\n") + .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") + .write("config/routes.rb", "Rails.application.routes.draw do\n get \"/comparisons\", to: \"comparisons#index\"\nend\n") + .write("app/services/signatures.rb", r#"class Signatures + def self.match(left, right) + ActiveSupport::SecurityUtils.secure_compare(left, right) + end +end +"#) + .write("app/controllers/comparisons_controller.rb", r#"class ComparisonsController < ApplicationController + def index + render plain: Signatures.match("same", "same").to_s + end +end +"#) +} + +const STRING_COMPARISONS: &str = r#" +require_relative "app/controllers/comparisons_controller" +controller = ComparisonsController.new +controller.process_action(:index) +raise "controller comparison failed" unless controller.body == "true" +cases = [ + ["abc", "abc", true], ["abc", "xbc", false], ["abc", "abd", false], + ["abc", "ab", false], ["ab", "abc", false], ["", "", true], + ["", "a", false], ["é", "é", true], ["é", "ê", false], + ["é", "ab", false], ["a\0b", "a\0b", true], ["a\0b", "a\0c", false], + ["é", "\xc3\xa9".b, true] +] +cases.each do |left, right, expected| + actual = Signatures.match(left, right) + raise "comparison changed: #{[left, right, actual].inspect}" unless actual == expected +end +puts "SecurityUtils comparison passed (13 vectors and controller)" +"#; + +#[test] +fn a_security_utils_comparison_runs_in_the_emitted_app() { + app().run_ruby(STRING_COMPARISONS).assert_passes(); +} + +#[test] +#[ignore = "requires the native Spinel compiler"] +fn a_security_utils_comparison_runs_natively() { + app().run_spinel(STRING_COMPARISONS).assert_passes(); +} + +#[test] +fn secure_compare_rejects_ordinary_non_string_arguments() { + app().run_ruby(r#" +[nil, 1, :abc, [], {}, true].each do |invalid| + [[invalid, "abc"], ["abc", invalid]].each do |left, right| + begin + Signatures.match(left, right) + rescue NoMethodError + next + end + raise "non-string argument was accepted" + end +end +puts "SecurityUtils rejects ordinary non-string arguments (12 cases)" +"#).assert_passes(); +} + +#[test] +fn ruby_family_targets_ship_and_require_security_utils() { + use roundhouse::project::BuildTarget; + for target in [BuildTarget::Ruby, BuildTarget::Jruby, BuildTarget::Spinel] { + let (emitted, errors) = app().emit(target); + assert!(errors.is_empty(), "{target:?}: {errors:?}"); + let signature = if target == BuildTarget::Spinel { + "runtime/security_utils.rbs" + } else { + "sig/runtime/security_utils.rbs" + }; + for path in ["runtime/security_utils.rb", signature] { + assert!(emitted.join(path).is_file(), "{target:?} omitted {path}"); + } + let source = std::fs::read_to_string(emitted.join("app/models/signatures.rb")) + .expect("emitted service"); + assert!( + source.contains("runtime/security_utils"), + "{target:?}: {source}" + ); + } +} diff --git a/tests/spinel_header_injection.rb b/tests/spinel_header_injection.rb new file mode 100644 index 000000000..553c55221 --- /dev/null +++ b/tests/spinel_header_injection.rb @@ -0,0 +1,57 @@ +# Driver for tests/spinel_header_injection.rs — the spinel lane's HTTP +# servers never put a header line on the wire that carries a CR, LF or +# other control character. +# +# Run under plain CRuby against the real servers over a scripted socket +# (tests/tep_server_harness.rb). The app answers with values an attacker +# can steer — a redirect Location built from a param, a Content- +# Disposition built from one, a cookie option — each carrying a CRLF and +# a header of its own. Written as is, the CRLF ends the header and the +# rest becomes a header the app never set (`Set-Cookie: pwned=1`), or a +# blank line and a body of the attacker's choosing. +# +# Puma's rule, which a Rails app sits behind: a header whose key or value +# holds a control character (anything below 0x20 but tab, in a value) is +# DROPPED, and the rest of the response goes out. + +require_relative "tep_server_harness" + +class RecordingApp + def dispatch(req, res) + @bodies << req.raw_body.dup + res.status = 302 + res.headers["X-Ok"] = "fine\tstill fine" + res.headers["Location"] = "/next\r\nSet-Cookie: pwned=1" + res.headers["X-Split"] = "a\nX-Injected: 1" + res.headers["X-Bad\r\nX-Key-Injected"] = "v" + res.headers["X-Nul"] = "a\0b" + res.set_cookie("kept", "1", { "Path" => "/" }) + res.set_cookie("sid", "abc", { "Path" => "/\r\nX-Cookie-Injected: 1" }) + res.body = "ok" + end +end + +GET = "GET /go HTTP/1.1\r\nHost: localhost\r\n\r\n".b + +SERVERS.each_key do |s| + r = serve(s, GET) + status, _recvs, _bodies, raised = r + head = Sock.wire.out.split("\r\n\r\n", 2).first.to_s + lines = head.split("\r\n") + check("#{s}: answers the redirect", raised.nil? && status == 302, describe(*r)) + check("#{s}: no bare LF or NUL inside a header line", + lines.none? { |l| l.include?("\n") || l.include?("\0") }, head.inspect) + injected = lines.grep(/\A(Set-Cookie: pwned|X-Injected|X-Key-Injected|X-Cookie-Injected)/) + check("#{s}: no injected header line", injected.empty?, injected.inspect) + check("#{s}: the poisoned headers are dropped", + lines.none? { |l| l.start_with?("Location:", "X-Split:", "X-Nul:", "X-Bad", "Set-Cookie: sid=") }, + lines.inspect) + check("#{s}: the clean headers still go out (tab allowed)", + lines.include?("X-Ok: fine\tstill fine") && lines.include?("Set-Cookie: kept=1; Path=/"), + lines.inspect) +end + +total = CHECKS.length +passed = CHECKS.count(true) +puts "#{passed}/#{total} checks pass" +puts "done" diff --git a/tests/spinel_header_injection.rs b/tests/spinel_header_injection.rs new file mode 100644 index 000000000..dd69c93c4 --- /dev/null +++ b/tests/spinel_header_injection.rs @@ -0,0 +1,41 @@ +//! The spinel lane's HTTP servers drop a header that carries a control +//! character instead of writing it, so a CRLF in an app-supplied value — +//! a redirect Location built from a param, a Content-Disposition, a +//! cookie option — cannot end the header and start one of its own +//! (`Set-Cookie: pwned=1`) or a body. That is Puma's rule, the server a +//! Rails app sits behind: a key or value holding a control character +//! (tab excepted, in a value) is dropped and the rest of the response +//! goes out. +//! +//! All three servers (threaded — the default —, scheduled, blocking) are +//! checked, under CRuby, by the driver `tests/spinel_header_injection.rb` +//! over the scripted socket in `tests/tep_server_harness.rb`. + +use std::path::Path; +use std::process::Command; + +const CHECKS: usize = 15; + +#[test] +fn a_header_carrying_a_control_character_never_reaches_the_wire() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let out = Command::new("ruby") + .arg(root.join("tests/spinel_header_injection.rb")) + .output() + .expect("ruby is on PATH"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + // `done` is the last line; its absence means the driver died part + // way, which an absence of FAIL lines alone would read as a pass. + assert!( + stdout.lines().any(|l| l == "done"), + "driver did not finish\n=== stdout ===\n{stdout}\n=== stderr ===\n{stderr}" + ); + let failed: Vec<&str> = stdout.lines().filter(|l| l.starts_with("FAIL")).collect(); + assert!(failed.is_empty(), "{}\n=== stdout ===\n{stdout}", failed.join("\n")); + assert!( + stdout.lines().any(|l| l == format!("{CHECKS}/{CHECKS} checks pass")), + "fewer checks ran than the driver makes\n=== stdout ===\n{stdout}" + ); +} diff --git a/tests/string_bytes.rs b/tests/string_bytes.rs new file mode 100644 index 000000000..846fc5890 --- /dev/null +++ b/tests/string_bytes.rs @@ -0,0 +1,557 @@ +//! A String's bytes are unsigned integers, including multi-byte UTF-8. +use roundhouse::analyze::Analyzer; +use roundhouse::diagnostic::Severity; +use roundhouse::emit; +use roundhouse::project::BuildTarget; +use roundhouse::ty::Ty; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Keep inference and the actual backend dispatch on the same ordinary Ruby +/// source; a block consumer must not be mistaken for array materialization. +fn analyzed() -> roundhouse::App { + let source = r#"class ByteProbe + def values + "Aé東🎉".bytes + end + def nil_block + "é".bytes(&nil) + end + def yielded + "é".bytes { |byte| byte + 1 } + end +end +"#; + analyze_source(source) +} + +/// Analyze a standalone support class without bringing Rails behavior into +/// byte-method typing and control-flow tests. +fn analyze_source(source: &str) -> roundhouse::App { + let mut app = roundhouse::ingest::ingest_app_from_tree(HashMap::from([( + PathBuf::from("app/lib/byte_probe.rb"), + source.as_bytes().to_vec(), + )])) + .unwrap(); + Analyzer::new(&app).analyze(&mut app); + app +} + +/// Extract the actual analyzed call, retaining a literal nil block operand so +/// primitive execution tests exercise the same classifier as project emission. +fn analyzed_call(name: &str) -> roundhouse::expr::Expr { + let app = analyzed(); + let class = app + .library_classes + .iter() + .find(|class| class.name.0.as_str() == "ByteProbe") + .unwrap(); + let body = &class + .methods + .iter() + .find(|method| method.name.as_str() == name) + .unwrap() + .body; + if let roundhouse::expr::ExprNode::Seq { exprs } = &*body.node { + exprs.last().unwrap().clone() + } else { + body.clone() + } +} + +/// Pin both the collection element type and the block yield/return contract; +/// an explicit nil block still requests array materialization. +#[test] +fn no_block_returns_integer_array_and_block_returns_string() { + let app = analyzed(); + let class = app + .library_classes + .iter() + .find(|c| c.name.0.as_str() == "ByteProbe") + .unwrap(); + for (name, expected) in [ + ( + "values", + Ty::Array { + elem: Box::new(Ty::Int), + }, + ), + ("yielded", Ty::Str), + ( + "nil_block", + Ty::Array { + elem: Box::new(Ty::Int), + }, + ), + ] { + let method = class + .methods + .iter() + .find(|m| m.name.as_str() == name) + .unwrap(); + assert_eq!( + method.body.ty.as_ref(), + Some(&expected), + "{name}: {:?}", + method.body + ); + } + let mut pending = vec![ + &class + .methods + .iter() + .find(|m| m.name.as_str() == "yielded") + .unwrap() + .body, + ]; + let mut yielded_reads = 0; + while let Some(expr) = pending.pop() { + if matches!(&*expr.node, roundhouse::expr::ExprNode::Var { name, .. } if name.as_str() == "byte") + { + assert_eq!(expr.ty, Some(Ty::Int)); + yielded_reads += 1; + } + expr.node.for_each_child(&mut |child| pending.push(child)); + } + assert!(yielded_reads > 0, "block parameter was not inspected"); + let errors: Vec<_> = roundhouse::analyze::diagnose(&app) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} + +/// Give public diagnostics a normal model-method root; ordinary app/lib +/// bodies are deliberately outside the upstream diagnostic inventory. +fn analyzed_model(body: &str) -> roundhouse::App { + let source = + format!("class ByteProbe < ActiveRecord::Base\n def values\n {body}\n end\nend\n"); + let mut app = roundhouse::ingest::ingest_app_from_tree(HashMap::from([ + (PathBuf::from("app/models/byte_probe.rb"), source.into_bytes()), + (PathBuf::from("db/schema.rb"), b"ActiveRecord::Schema.define do\n create_table :byte_probes do |t|\n t.string :name\n end\nend\n".to_vec()), + ])).unwrap(); + Analyzer::new(&app).analyze(&mut app); + app +} + +/// Exercise the actual backend entry points, including the error ledger for +/// a supplied block rather than a plausible but side-effect-free array. +#[test] +fn every_bridge_dispatches_and_refuses_the_supplied_block() { + let make_app = |body: &str| { + let mut app = analyzed_model(body); + roundhouse::session::analyze_and_lower(&mut app); + app + }; + let app = make_app("\"Aé東🎉\".bytes"); + let nil_app = make_app("\"Aé東🎉\".bytes(&nil)"); + let block_app = make_app("\"é\".bytes { |byte| byte + 1 }"); + let targets = [ + ("rust", BuildTarget::Rust, ".as_bytes().iter().map("), + ( + "typescript", + BuildTarget::Typescript, + "new TextEncoder().encode(", + ), + ( + "crystal", + BuildTarget::Crystal, + ".bytes.map { |byte| byte.to_i64 }", + ), + ("python", BuildTarget::Python, ".encode(\"utf-8\")"), + ( + "kotlin", + BuildTarget::Kotlin, + ".toByteArray(Charsets.UTF_8)", + ), + ("swift", BuildTarget::Swift, ".utf8.map { Int($0) }"), + ( + "csharp", + BuildTarget::CSharp, + "System.Text.Encoding.UTF8.GetBytes(", + ), + ("go", BuildTarget::Go, "out[i] = int64(text[i])"), + // Elixir has no native `While`. Leftover loops after + // `while_to_recursion` hit the expression catch-all + // (`elixir2`) rather than `library.rs` walkers. Whole-app + // `target_files` therefore reports `While not supported` on + // current `main` (runtime loops, not this bytes primitive). + // The shared classifier still covers Elixir in + // `shared_bridge_evaluates_receiver_once_and_rejects_arguments`. + ]; + let library = analyzed(); + let class = library + .library_classes + .iter() + .find(|c| c.name.0.as_str() == "ByteProbe") + .unwrap(); + let output = |app: &roundhouse::App, target, name: &str| { + // Python's model emitter does not carry arbitrary model methods; + // its shared runtime/library expression entry point does. + if target == BuildTarget::Python { + let body = &class + .methods + .iter() + .find(|m| m.name.as_str() == name) + .unwrap() + .body; + vec![("probe.py".into(), emit::python::emit_expr_for_runtime(body))] + } else { + roundhouse::project::target_files(app, std::path::Path::new("."), target).unwrap() + } + }; + for (name, target, bridge) in targets { + let (files, errors) = emit::diagnostics::scope(|| output(&app, target, "values")); + assert!( + files.iter().any(|(_, content)| content.contains(bridge)), + "{name} did not dispatch String#bytes: {errors:?}" + ); + // Exercise project admission even for Python, whose arbitrary model + // methods use the separate library expression path below. + let (result, diagnostics) = emit::diagnostics::scope(|| { + roundhouse::project::target_files(&nil_app, std::path::Path::new("."), target) + }); + assert!(result.is_ok(), "{name} refused literal &nil: {result:?}"); + assert!(diagnostics.is_empty(), "{name}: {diagnostics:?}"); + let (files, diagnostics) = + emit::diagnostics::scope(|| output(&nil_app, target, "nil_block")); + assert!(diagnostics.is_empty(), "{name}: {diagnostics:?}"); + assert!( + files.iter().any(|(_, content)| content.contains(bridge)), + "{name} did not materialize literal &nil" + ); + let (_, diagnostics) = emit::diagnostics::scope(|| output(&block_app, target, "yielded")); + assert!( + diagnostics + .iter() + .any(|d| d.severity == Severity::Error && d.message.contains("String#bytes")), + "{name} discarded the block: {diagnostics:?}" + ); + } +} + +/// Ruby and Spinel share emission, so canonicalize only the typed no-block byte +/// call; unrelated forwarding and effectful block operands must survive. +#[test] +fn ruby_family_canonicalizes_only_literal_nil_bytes() { + let app = analyze_source( + r#"class ByteProbe + def values + "é".bytes(&nil) + end + def unrelated + "é".upcase(&nil) + end + def effectful + "é".bytes(&(puts("side effect"); nil)) + end + def yielded + "é".bytes { |byte| byte + 1 } + end +end +"#, + ); + let class = app + .library_classes + .iter() + .find(|c| c.name.0.as_str() == "ByteProbe") + .unwrap(); + let output = |name: &str| { + emit::ruby::emit_expr( + &class + .methods + .iter() + .find(|m| m.name.as_str() == name) + .unwrap() + .body, + ) + }; + let values = output("values"); + assert!(values.contains(".bytes"), "{values}"); + assert!(!values.contains('&'), "{values}"); + let unrelated = output("unrelated"); + assert!(unrelated.contains("&nil"), "{unrelated}"); + let effectful = output("effectful"); + assert!( + effectful.contains('&') && effectful.contains("side effect"), + "{effectful}" + ); + let yielded = output("yielded"); + assert!( + yielded.contains("|byte|") && yielded.contains("byte + 1"), + "{yielded}" + ); +} + +/// Lowering may represent an unsupported expression as a diagnostic-bearing +/// nil. It must remain a refusal or raise, never become an omitted block. +#[test] +fn diagnostic_nil_block_is_not_discarded() { + use emit::shared::string_bytes::{self, Target}; + use roundhouse::diagnostic::DiagnosticKind; + use roundhouse::expr::ExprNode; + let mut call = analyzed_call("nil_block"); + let ExprNode::Send { + block: Some(block), .. + } = &mut *call.node + else { + panic!("expected the literal-nil byte call"); + }; + block.diagnostic = Some(DiagnosticKind::Unsupported { + target: None, + construct: "forwarded operand".into(), + detail: "preserve this refusal".into(), + }); + assert!(!string_bytes::materializes_array(&call)); + let ruby = emit::ruby::emit_expr(&call); + assert!( + ruby.contains("raise") && ruby.contains("forwarded operand not supported"), + "{ruby}" + ); + let (_, diagnostics) = emit::diagnostics::scope(|| { + string_bytes::emit(&call, Target::Python, |_| panic!("must not materialize")) + }); + assert!( + diagnostics + .iter() + .any(|d| d.severity == Severity::Error && d.message.contains("String#bytes")), + "{diagnostics:?}" + ); +} + +/// The bytes exemption is local to the implemented primitive. It must not +/// admit unrelated forwarding or skip validation within an effectful receiver. +#[test] +fn nil_bytes_exemption_preserves_other_forwarding_boundaries() { + for source in [ + "class ByteProbe < ActiveRecord::Base\n def values\n \"A\".upcase(&nil)\n end\nend\n", + "class ByteProbe < ActiveRecord::Base\n def receiver\n \"A\"\n end\n def values\n receiver(&nil).bytes(&nil)\n end\nend\n", + "class ByteProbe < ActiveRecord::Base\n def values\n \"A\".bytes(&(puts(\"side effect\"); nil))\n end\nend\n", + ] { + let mut app = roundhouse::ingest::ingest_app_from_tree(HashMap::from([ + (PathBuf::from("app/models/byte_probe.rb"), source.as_bytes().to_vec()), + (PathBuf::from("db/schema.rb"), b"ActiveRecord::Schema.define do\n create_table :byte_probes do |t|\n t.string :name\n end\nend\n".to_vec()), + ])).unwrap(); + roundhouse::session::analyze_and_lower(&mut app); + let mut pending: Vec<_> = app + .models + .iter() + .flat_map(|model| model.methods().map(|method| &method.body)) + .collect(); + let mut materializing_call = false; + while let Some(expression) = pending.pop() { + materializing_call |= emit::shared::string_bytes::materializes_array(expression); + expression + .node + .for_each_child(&mut |child| pending.push(child)); + } + assert_eq!( + materializing_call, + source.contains("receiver(&nil).bytes"), + "the nested receiver control must reach the bytes exemption: {source}" + ); + let (result, diagnostics) = emit::diagnostics::scope(|| { + roundhouse::project::target_files(&app, std::path::Path::new("."), BuildTarget::Rust) + }); + assert!( + result.is_err(), + "unrelated forwarding was accepted: {source}" + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| matches!(&diagnostic.kind, + roundhouse::diagnostic::DiagnosticKind::Unsupported { construct, .. } + if construct.as_str() == "forwarded_proc")), + "{source}: {diagnostics:?}" + ); + } +} + +/// The bridge must not duplicate an effectful operand, and malformed calls +/// must fail before being lowered into a supported array operation. +#[test] +fn shared_bridge_evaluates_receiver_once_and_rejects_arguments() { + use emit::shared::string_bytes::{self, Target}; + use roundhouse::expr::ExprNode; + let app = analyzed(); + let class = app + .library_classes + .iter() + .find(|c| c.name.0.as_str() == "ByteProbe") + .unwrap(); + let mut expression = class + .methods + .iter() + .find(|m| m.name.as_str() == "values") + .unwrap() + .body + .clone(); + // Method bodies may contain their single expression in a Seq. + if let ExprNode::Seq { exprs } = &*expression.node { + expression = exprs.last().unwrap().clone(); + } + for target in [ + Target::Rust, + Target::TypeScript, + Target::Crystal, + Target::Python, + Target::Kotlin, + Target::Swift, + Target::CSharp, + Target::Go, + Target::Elixir, + ] { + for method in ["values", "nil_block"] { + let call = analyzed_call(method); + assert!(string_bytes::materializes_array(&call)); + let mut calls = 0; + let output = string_bytes::emit(&call, target, |_| { + calls += 1; + "effectful_receiver()".into() + }) + .unwrap(); + assert_eq!(calls, 1); + assert_eq!( + output.matches("effectful_receiver()").count(), + 1, + "{output}" + ); + } + } + let argument = expression.clone(); + if let ExprNode::Send { args, .. } = &mut *expression.node { + args.push(argument); + } + let (_, diagnostics) = emit::diagnostics::scope(|| { + string_bytes::emit(&expression, Target::Python, |_| { + panic!("invalid call must not materialize bytes") + }) + }); + assert!( + diagnostics + .iter() + .any(|d| d.severity == Severity::Error && d.message.contains("String#bytes")), + "{diagnostics:?}" + ); +} + +/// Execute generated primitive expressions in the installed SDKs. Keeping this +/// separate from shape checks makes unavailable SDK coverage explicit. +#[test] +#[ignore = "requires rustc and python3"] +fn generated_byte_expressions_execute_with_unsigned_values_and_one_receiver_call() { + use emit::shared::string_bytes::{self, Target}; + use std::process::Command; + let dir = std::env::temp_dir().join(format!("roundhouse-string-bytes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rust = string_bytes::render(Target::Rust, "effectful_receiver()"); + let nil_rust = string_bytes::emit(&analyzed_call("nil_block"), Target::Rust, |_| { + "effectful_receiver()".into() + }) + .unwrap(); + let source = format!( + r#" +use std::sync::atomic::{{AtomicUsize, Ordering}}; +static CALLS: AtomicUsize = AtomicUsize::new(0); +/// Count source receiver evaluations for the emitted primitive expressions. +fn effectful_receiver() -> String {{ CALLS.fetch_add(1, Ordering::SeqCst); "A\0é東🎉".to_string() }} +/// Execute both omitted-block and literal-nil forms with identical byte checks. +fn main() {{ + let bytes: Vec = {rust}; + assert_eq!(bytes, vec![65, 0, 195, 169, 230, 157, 177, 240, 159, 142, 137]); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); + let nil_bytes: Vec = {nil_rust}; + assert_eq!(nil_bytes, bytes); + assert_eq!(CALLS.load(Ordering::SeqCst), 2); + let empty: Vec = {empty}; + assert!(empty.is_empty()); +}} +"#, + empty = string_bytes::render(Target::Rust, "\"\"") + ); + std::fs::write(dir.join("probe.rs"), source).unwrap(); + let compiled = Command::new("rustc") + .arg(dir.join("probe.rs")) + .arg("-o") + .arg(dir.join("probe")) + .output() + .expect("rustc"); + assert!( + compiled.status.success(), + "{}", + String::from_utf8_lossy(&compiled.stderr) + ); + assert!(Command::new(dir.join("probe")).status().unwrap().success()); + let python = string_bytes::render(Target::Python, "effectful_receiver()"); + let nil_python = string_bytes::emit(&analyzed_call("nil_block"), Target::Python, |_| { + "effectful_receiver()".into() + }) + .unwrap(); + let source = format!( + r#" +calls = 0 +def effectful_receiver(): + """Count source receiver evaluations for the emitted primitive expressions.""" + global calls + calls += 1 + return "A\0é東🎉" +assert {python} == [65, 0, 195, 169, 230, 157, 177, 240, 159, 142, 137] +assert calls == 1 +assert {nil_python} == [65, 0, 195, 169, 230, 157, 177, 240, 159, 142, 137] +assert calls == 2 +assert {empty} == [] +"#, + empty = string_bytes::render(Target::Python, "\"\"") + ); + std::fs::write(dir.join("probe.py"), source).unwrap(); + let output = Command::new("python3") + .arg(dir.join("probe.py")) + .output() + .expect("python3"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// A break escaping the bytes iterator changes its result independently of +/// the receiver type; it must not receive a clean but false String signature. +#[test] +fn escaping_bytes_block_break_has_an_explicit_diagnostic() { + for body in ["break 7", "break"] { + let app = analyzed_model(&format!("\"x\".bytes {{ {body} }}")); + let errors: Vec<_> = roundhouse::analyze::diagnose(&app) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors + .iter() + .any(|d| d.message.contains("String#bytes") && d.message.contains("break")), + "{body}: {errors:?}" + ); + } +} + +/// A nested loop or iterator consumes its own break; refusing those would +/// reject an ordinary bytes block that still returns its String receiver. +#[test] +fn nested_loop_and_iterator_breaks_do_not_escape_bytes() { + for body in [ + "while byte > 0; break; end", + "[byte].each { |value| break value }", + ] { + let app = analyzed_model(&format!("\"x\".bytes {{ |byte| {body} }}")); + let errors: Vec<_> = roundhouse::analyze::diagnose(&app) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{body}: {errors:?}"); + assert_eq!( + app.models[0].methods().next().unwrap().body.ty, + Some(Ty::Str) + ); + } +} diff --git a/tests/support/emit_and_run.rs b/tests/support/emit_and_run.rs index e7dee334d..9b245294d 100644 --- a/tests/support/emit_and_run.rs +++ b/tests/support/emit_and_run.rs @@ -58,7 +58,7 @@ const SKIP: &[&str] = &["tmp", "log", "storage", "node_modules", ".git"]; /// Start from `fixtures/real-blog`. pub fn real_blog() -> Overlay { - Overlay { base: roundhouse::fixtures::real_blog().to_path_buf(), edits: Vec::new() } + Overlay { base: roundhouse::fixtures::real_blog().to_path_buf(), scratch_base: false, edits: Vec::new() } } /// Start from an empty tree and `write` the app file by file, for a @@ -66,11 +66,13 @@ pub fn real_blog() -> Overlay { pub fn empty_app() -> Overlay { let base = scratch_dir(); std::fs::create_dir_all(&base).expect("mkdir"); - Overlay { base, edits: Vec::new() } + Overlay { base, scratch_base: true, edits: Vec::new() } } pub struct Overlay { base: PathBuf, + /// `base` is this overlay's own scratch tree, removed once copied. + scratch_base: bool, edits: Vec, } @@ -124,7 +126,7 @@ impl Overlay { } fn run_test_with(self, flags: &[&str], test_path: &str) -> Run { - let (emitted, errors) = self.emit(BuildTarget::Ruby); + let (emitted, errors) = self.emit_tree(BuildTarget::Ruby); let output = ruby() .args(flags) .args(["-Itest", "-I."]) @@ -141,7 +143,7 @@ impl Overlay { /// required and the default adapter configured on an in-memory /// database before the script's first line. pub fn run_ruby(self, script: &str) -> Run { - let (emitted, errors) = self.emit(BuildTarget::Ruby); + let (emitted, errors) = self.emit_tree(BuildTarget::Ruby); let script = format!( "require File.expand_path(\"main\", Dir.pwd)\nMain.configure_default_adapter!\n{script}" ); @@ -158,7 +160,7 @@ impl Overlay { /// Compile the unchanged Spinel output and run its native binary. /// The consumer boots libraries, not the HTTP server or a database. pub fn run_spinel(self, script: &str) -> Run { - let (emitted, errors) = self.emit(BuildTarget::Spinel); + let (emitted, errors) = self.emit_tree(BuildTarget::Spinel); std::fs::write(emitted.join("contract.rb"), format!("require_relative \"boot\"\n{script}")) .expect("write native consumer"); let compiler = std::env::var("SPINEL").unwrap_or_else(|_| "spinel".into()); @@ -175,11 +177,20 @@ impl Overlay { } /// Copy the fixture, apply the edits, analyze, and write the Ruby - /// target. Returns the emitted tree and `check`'s error diagnostics. - pub fn emit(self, target: BuildTarget) -> (PathBuf, Vec) { + /// target. Returns the emitted tree, removed with its scratch when + /// dropped (see `Run`'s `Drop`), and `check`'s error diagnostics. + pub fn emit(self, target: BuildTarget) -> (Emitted, Vec) { + let (dir, errors) = self.emit_tree(target); + (Emitted(dir), errors) + } + + fn emit_tree(self, target: BuildTarget) -> (PathBuf, Vec) { let scratch = scratch_dir(); let source = scratch.join("app"); copy_tree(&self.base, &source); + if self.scratch_base { + let _ = std::fs::remove_dir_all(&self.base); + } for edit in &self.edits { match edit { Edit::Write { path, content } => { @@ -271,6 +282,48 @@ impl Run { } } +/// A run takes its scratch tree with it, unless its test is failing (the +/// failure names the tree) or `ROUNDHOUSE_KEEP_EMITTED=1` keeps every one. +/// A run expected to fail is a passing test and is removed too. Left +/// behind, a full suite fills `/tmp`'s inodes. +impl Drop for Run { + fn drop(&mut self) { + remove_scratch(&self.emitted); + } +} + +/// An emitted tree, read as its path; removed like a `Run`'s. +pub struct Emitted(PathBuf); + +impl std::ops::Deref for Emitted { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +// So `Command::current_dir(&emitted)` and the other `AsRef` APIs +// take it as they took the PathBuf it replaced. +impl AsRef for Emitted { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for Emitted { + fn drop(&mut self) { + remove_scratch(&self.0); + } +} + +fn remove_scratch(emitted: &Path) { + if !std::thread::panicking() && std::env::var_os("ROUNDHOUSE_KEEP_EMITTED").is_none() { + if let Some(scratch) = emitted.parent() { + let _ = std::fs::remove_dir_all(scratch); + } + } +} + /// `ruby`, with the prerequisite checked once and named on failure. pub fn ruby() -> Command { static CHECKED: std::sync::Once = std::sync::Once::new(); diff --git a/tests/workflow_yaml_parses.rs b/tests/workflow_yaml_parses.rs index 7f82a55c5..f48bfbc83 100644 --- a/tests/workflow_yaml_parses.rs +++ b/tests/workflow_yaml_parses.rs @@ -1247,7 +1247,7 @@ fn pr_reuse_receipts_are_checked_against_adversarial_inputs() { fn reused_checks_keep_cargo_dependencies_locked_and_upload_only_execution_receipts() { let ci: serde_yaml_ng::Value = serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); - for (name, expected_cargo_commands) in [("store-check", 1), ("writebook-inventory", 2)] { + for (name, expected_cargo_commands) in [("store-check", 1), ("writebook-inventory", 1)] { let steps = ci["jobs"][name]["steps"].as_sequence().unwrap(); let commands: Vec<_> = steps .iter()