Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ already sitting in the `herb` gem's native C extension the whole time.
every future adapter. Prism-backed rules need real walkable `PrismNode` objects, not JSON,
so `Prism.dump`'s self-describing versioned binary format crosses as a `Uint8Array` via
`MiniRacer::Binary` — verified byte-accurate including non-ASCII source.
- **`prism_program` rules are wired; `prism_nodes`/`prism_nodes_deep` rules are not (yet).**
12 rules in the vendored bundle declare which Prism mode they need via `parserOptions`:
`erb-no-debug-output` and `erb-no-instance-variables-in-partials`
ask for `prism_program` (one whole-document Prism parse, attached to the root
`DocumentNode`); the other 10 (`a11y-no-autofocus-attribute`,
`actionview-no-silent-helper`, `actionview-no-unnecessary-tag-attributes`,
`erb-no-output-in-attribute-position`, `erb-no-silent-statement`, `erb-no-unsafe-raw`,
`erb-no-unsafe-script-interpolation`, `erb-no-unused-expressions`,
`erb-no-unused-literals`, `erb-prefer-direct-output`) ask for `prism_nodes` (a separate
Prism parse per embedded-Ruby node). `ResultEnvelope.parse` implements the `prism_program`
case: it never asks `Herb.parse` itself to embed `prism_node` (that comes back as a raw
ASCII-8BIT String and blows up `.to_json`, the bug `FORWARDABLE_OPTIONS` still guards
against); instead it separately computes `Prism.dump(Herb.extract_ruby(source)).bytes` — a
plain JSON-safe `Integer` array — and injects it onto the root value's `prism_node` key.
`Herb.extract_ruby` blanks non-Ruby content but preserves byte length/position, so the
resulting Prism byte offsets still line up with `DocumentNode#prismNode`'s use of the
original (whole-file) `source`. The `prism_nodes`/`prism_nodes_deep` case — injection onto
individual `ERBContentNode`s rather than the document root — needs its own design pass
(which nodes carry embedded Ruby, how offsets there compose) and remains unimplemented; see
herb-embedded-gu7.
- **Exceptions cross the boundary raw, not as structured errors.** An earlier design assumed
exceptions needed to be caught and translated before crossing. Sixteen probes showed raw
crossing is catchable, non-poisoning, and preserves the original Ruby class in the outward
Expand Down
28 changes: 24 additions & 4 deletions lib/herb/embedded/result_envelope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "herb"
require "json"
require "prism"

module Herb
module Embedded
Expand All @@ -11,9 +12,12 @@ module Embedded
module ResultEnvelope
# Allowlist of Herb::ParserOptions keys safe to forward from
# caller-supplied options. Deliberately excludes prism_nodes,
# prism_nodes_deep, and prism_program: Prism data crosses the
# engine boundary as binary (Uint8Array), and forwarding it here
# raises JSON::GeneratorError on ASCII-8BIT content. Also excludes
# prism_nodes_deep, and prism_program: asking Herb.parse itself to
# embed prism_node populates it with a raw ASCII-8BIT String, and
# forwarding that through raises JSON::GeneratorError. prism_program
# is instead handled below by computing a JSON-safe byte array
# ourselves; prism_nodes/prism_nodes_deep (per-ERBContentNode
# injection) remain unimplemented — see CHARTER.md. Also excludes
# timeout and max_errors (timing/error-cap options, not shape).
FORWARDABLE_OPTIONS = %i[
strict
Expand All @@ -28,10 +32,14 @@ module ResultEnvelope
module_function

def parse(source, options_hash = {})
options_hash = (options_hash || {}).transform_keys(&:to_sym)
result = Herb.parse(source, **forwardable(options_hash))

value_hash = result.value.to_hash
value_hash[:prism_node] = prism_program_bytes(source) if options_hash[:prism_program]

{
value: result.value,
value: value_hash,
source: result.source,
warnings: result.warnings,
errors: result.errors,
Expand All @@ -57,6 +65,18 @@ def forwardable(options_hash)
end
end
private_class_method :forwardable

# @herb-tools/core's DocumentNode#prismNode getter deserializes
# prism_node bytes against the node's own (whole-file) `source`, so
# the bytes must come from parsing something byte-length-identical
# to source with Ruby content at the same offsets — exactly what
# Herb.extract_ruby produces (non-Ruby content blanked, not
# stripped). A plain Array of bytes (not the ASCII-8BIT String
# Prism.dump returns) is what keeps this JSON-safe.
def prism_program_bytes(source)
Prism.dump(Herb.extract_ruby(source)).bytes
end
private_class_method :prism_program_bytes
end
end
end
21 changes: 15 additions & 6 deletions spec/bridge_lint_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,9 @@ class SpyRule {
expect(adapter.call("__spyRuleInstantiationCount")).to eq(2)
end

# check() depends on result.value.prismNode, which is never populated
# under the current architecture (see herb-embedded-gu7) — Task 5's
# ResultEnvelope deliberately never forwards prism_program/prism_nodes,
# so check() silently returns [] regardless of context. isEnabled() only
# reads context.fileName, so it's the only way to prove context
# threading for this rule until gu7 wires live Prism injection.
# isEnabled() only reads context.fileName, so it's a narrower probe than
# check() (see the prism_program-backed diagnostic test below) — kept
# separately since it isolates context threading from prismNode wiring.
it "threads file: into LintContext under both fileName and filename" do
bridge

Expand Down Expand Up @@ -99,6 +96,18 @@ class FakeCrashRule {
expect(bridge.lint(%(<div class="a">x</div>), file: "x.html.erb", rules: ["html-no-space-in-tag"])).to be_empty
end

it "produces a real offense for a prism_program-backed rule, not just isEnabled() firing" do
source = %(<div><%= @foo %></div>\n)

diagnostics = bridge.lint(source, file: "app/views/_form.html.erb", rules: ["erb-no-instance-variables-in-partials"])

expect(diagnostics.size).to eq(1)
expect(diagnostics.first.message).to include("@foo")

non_partial_diagnostics = bridge.lint(source, file: "app/views/form.html.erb", rules: ["erb-no-instance-variables-in-partials"])
expect(non_partial_diagnostics).to be_empty
end

it "excludes a rule not enabled by default when rules: is nil, but includes it when explicitly selected" do
bridge

Expand Down
16 changes: 16 additions & 0 deletions spec/result_envelope_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@
expect { JSON.parse(json) }.not_to raise_error
end

it "injects a JSON-safe prism_node byte array on the root value when prism_program is requested" do
json = described_class.parse("<div><%= @foo %></div>\n", prism_program: true)
envelope = JSON.parse(json)

expect(envelope["value"]["prism_node"]).to be_an(Array)
expect(envelope["value"]["prism_node"]).not_to be_empty
expect(envelope["value"]["prism_node"]).to all(be_an(Integer))
end

it "leaves prism_node nil on the root value when prism_program is not requested" do
json = described_class.parse("<div><%= @foo %></div>\n", {})
envelope = JSON.parse(json)

expect(envelope["value"]["prism_node"]).to be_nil
end

it "forwards track_whitespace and observably changes parse output" do
source = %(<div class="a">x</div>)

Expand Down
Loading