ast_transform 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3322dbf3c31a96df5ddea9892b60c40b785842500c13f178d1a9b11b5842bf12
4
- data.tar.gz: aef4ed8d812275368ec5f22c61d9b3d049ecc42490c406d08e8afd4e34e589e7
3
+ metadata.gz: d42a8d0999b373707aeb231d0c8bcf50ef7011ef8fd2bc2e9de9e7fda131550b
4
+ data.tar.gz: e7dccb5d7359e7fd974b5497ffbdf433458141ea278e25e937b3dda1c574dc2b
5
5
  SHA512:
6
- metadata.gz: bfd3828a33d2668a854ed4d5b1c299615ca47e92232ccf6705b008437cdb43e6497b9ef517e2664f5cd68b9197f9cc78ef2bd50090152f7889490a18e28cf72a
7
- data.tar.gz: 6f9647eaad1208d3cacb9ba038f8e63ff213d1ae7030bb96ec0a28386125096526f64bffa93100795427d67c2671b2638711c9ca891222beac01b0c046a07afc
6
+ metadata.gz: ad852344a9213e684c9e8f05e98d7d0292d654ee2ca2a4af14aaa230428ecdb165579bf914fc0e751259e22f0712553e4a8c7b4ae65d776a946baa055a67cd50
7
+ data.tar.gz: 5e20310267e2f0b233eb790cc10808436dfdbf5f6c7d8700935982ffefdeae1660a8fac0df1f4e933ed3704c96079dbea2526109ec3d8eb4e60f5054313793ea
@@ -0,0 +1 @@
1
+ * @JPDuchesne
@@ -7,12 +7,22 @@ on:
7
7
  branches: [main, master]
8
8
 
9
9
  jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: ruby/setup-ruby@v1
15
+ with:
16
+ ruby-version: '4.0'
17
+ bundler-cache: true
18
+ - run: bundle exec rubocop
19
+
10
20
  test:
11
21
  runs-on: ubuntu-latest
12
22
  strategy:
13
23
  fail-fast: false
14
24
  matrix:
15
- ruby: ['3.2', '3.3', '4.0']
25
+ ruby: ['3.3', '3.4', '4.0']
16
26
  steps:
17
27
  - uses: actions/checkout@v4
18
28
  - uses: ruby/setup-ruby@v1
data/.gitignore CHANGED
@@ -50,4 +50,7 @@ build-iPhoneSimulator/
50
50
  .rvmrc
51
51
 
52
52
  # RubyMine
53
- /.idea
53
+ /.idea
54
+
55
+ # Generated per-machine by `dev up` (d3mlabs dev tool); never committed
56
+ /.shadowenv.d/
data/.rubocop.yml ADDED
@@ -0,0 +1,5 @@
1
+ inherit_gem:
2
+ rubocop-shopify: rubocop.yml
3
+
4
+ AllCops:
5
+ SuggestExtensions: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.0.0] - 2026-07-24
8
+ ### Added
9
+ - Line-aligned emission: transformed code is emitted with every loc-carrying statement on its original source line, making backtraces, breakpoints, and debugger display correct by construction (`LineAlignedEmitter`).
10
+ - Authoring toolkit in `TransformationHelper`: `s_at` (loc-anchored node construction), `thunk` (a single invariant-checked `Thunk` node spliced at the execution point; the lowering derives the hidden proc's textual placement from the body's source locations), and `run_after` (sequence-level execution reordering that preserves textual/source order). Thunks lower to a non-lambda proc, so `return` still returns from the enclosing method, and locals assigned by thunked statements are pre-declared to stay method-scope. Reusing one thunk node executes its body from several points.
11
+ - `ASTTransform::Node.register`: type-routed construction of custom IR node classes through `s`, with an emitter postcondition (`LineAlignedEmitter::UnloweredNodeTypeError`) rejecting custom types that were not lowered before emission.
12
+ - `ast_transform/testing/assertions` (test-only): `assert_line_aligned` and `assert_backtrace_lines` for transform authors' suites.
13
+ - Error types, each owned by its producer: `TransformationHelper::MissingLocationError`, `ThunkLowering::PlacementError`, `LineAlignedEmitter::UnloweredNodeTypeError`. Thunk construction invariants raise plain `ArgumentError`.
14
+
15
+ ### Removed
16
+ - **Breaking:** `ASTTransform::SourceMap` and source-map registration. Line-aligned emission makes raw VM line numbers the source line numbers, so there is nothing left to map at display time.
17
+
18
+ ### Changed
19
+ - **Breaking:** `Transformer#transform` and `#transform_file_source` emit line-aligned output (source-anchored layout, always newline-terminated) instead of Unparser's re-normalized formatting.
20
+ - **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`.
21
+ - Dependency floors now reflect reality: `unparser >= 0.8` (the emitter uses `static_local_variables:`, a 0.7 interface, and 0.8's prism-based round-trip verification is required for Ruby >= 3.4 syntax) and `parser >= 3.3` (unparser's own floor; the declared `>= 3.0` could never resolve lower).
22
+
7
23
  ## [0.1.4] 2019-06-20
8
24
  ### Fixed
9
25
  - Source mapping for transformations wrapping source nodes into virtual nodes now work.
data/Gemfile CHANGED
@@ -1,6 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source "https://rubygems.org"
2
4
 
3
- git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
6
 
5
7
  # Specify your gem's dependencies in ast_transform.gemspec
6
8
  gemspec
9
+
10
+ # Development dependencies
11
+ gem "bundler", ">= 2.1"
12
+ gem "minitest", "~> 5.14"
13
+ gem "minitest-reporters", "~> 1.4"
14
+ gem "pry", ">= 0.14"
15
+ gem "rake", "~> 13.0"
16
+ gem "rubocop-shopify", "~> 3.0", require: false
17
+ gem "simplecov", "~> 0.22"
data/Gemfile.lock CHANGED
@@ -1,29 +1,33 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ast_transform (2.1.2)
5
- parser (>= 3.0)
4
+ ast_transform (3.0.0)
5
+ parser (>= 3.3)
6
6
  prism (>= 1.5)
7
- unparser (>= 0.6)
7
+ unparser (>= 0.8)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- ansi (1.5.0)
12
+ ansi (1.6.0)
13
13
  ast (2.4.3)
14
14
  builder (3.3.0)
15
15
  coderay (1.1.3)
16
- diff-lcs (1.6.2)
16
+ diff-lcs (2.0.0)
17
17
  docile (1.4.1)
18
18
  io-console (0.8.2)
19
+ json (2.21.1)
20
+ language_server-protocol (3.17.0.6)
21
+ lint_roller (1.1.0)
19
22
  method_source (1.1.0)
20
23
  minitest (5.27.0)
21
- minitest-reporters (1.7.1)
24
+ minitest-reporters (1.8.0)
22
25
  ansi
23
26
  builder
24
- minitest (>= 5.0)
27
+ minitest (>= 5.0, < 7)
25
28
  ruby-progressbar
26
- parser (3.3.10.2)
29
+ parallel (2.1.0)
30
+ parser (3.3.12.0)
27
31
  ast (~> 2.4.1)
28
32
  racc
29
33
  prism (1.9.0)
@@ -32,9 +36,28 @@ GEM
32
36
  method_source (~> 1.0)
33
37
  reline (>= 0.6.0)
34
38
  racc (1.8.1)
35
- rake (13.3.1)
39
+ rainbow (3.1.1)
40
+ rake (13.4.2)
41
+ regexp_parser (2.12.0)
36
42
  reline (0.6.3)
37
43
  io-console (~> 0.5)
44
+ rubocop (1.88.2)
45
+ json (~> 2.3)
46
+ language_server-protocol (~> 3.17.0.2)
47
+ lint_roller (~> 1.1.0)
48
+ parallel (>= 1.10)
49
+ parser (>= 3.3.0.2)
50
+ rainbow (>= 2.2.2, < 4.0)
51
+ regexp_parser (>= 2.9.3, < 3.0)
52
+ rubocop-ast (>= 1.49.0, < 2.0)
53
+ ruby-progressbar (~> 1.7)
54
+ unicode-display_width (>= 2.4.0, < 4.0)
55
+ rubocop-ast (1.50.0)
56
+ parser (>= 3.3.7.2)
57
+ prism (~> 1.7)
58
+ rubocop-shopify (3.0.1)
59
+ lint_roller
60
+ rubocop (~> 1.72, >= 1.72.1)
38
61
  ruby-progressbar (1.13.0)
39
62
  simplecov (0.22.0)
40
63
  docile (~> 1.1)
@@ -42,8 +65,11 @@ GEM
42
65
  simplecov_json_formatter (~> 0.1)
43
66
  simplecov-html (0.13.2)
44
67
  simplecov_json_formatter (0.1.4)
45
- unparser (0.8.1)
46
- diff-lcs (~> 1.6)
68
+ unicode-display_width (3.2.0)
69
+ unicode-emoji (~> 4.1)
70
+ unicode-emoji (4.2.0)
71
+ unparser (0.9.0)
72
+ diff-lcs (>= 1.6, < 3)
47
73
  parser (>= 3.3.0)
48
74
  prism (>= 1.5.1)
49
75
 
@@ -58,6 +84,7 @@ DEPENDENCIES
58
84
  minitest-reporters (~> 1.4)
59
85
  pry (>= 0.14)
60
86
  rake (~> 13.0)
87
+ rubocop-shopify (~> 3.0)
61
88
  simplecov (~> 0.22)
62
89
 
63
90
  BUNDLED WITH
data/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  ASTTransform is an Abstract Syntax Tree (AST) transformation framework. It hooks into the compilation process and allows to perform AST transformations using an annotation: `transform!`.
7
7
 
8
+ Transformed code is emitted **line-aligned**: every statement carrying a source location is placed on its original source line. Backtraces, failure messages, `break file:line` breakpoints, and debugger display are therefore correct by construction — no source maps, no backtrace filtering, no debugger integration required.
9
+
8
10
  ## Installation
9
11
 
10
12
  Add this line to your application's Gemfile:
@@ -160,6 +162,54 @@ In the above, `node#updated` allows updating the node, either its type or its ch
160
162
 
161
163
  The [ast gem](https://github.com/whitequark/ast) uses a pattern in which a Transformation may implement a method matching a node type, i.e. `on_class`, `on_send`, `on_lvar`, etc... This is very useful when transformations should process all nodes of this type.
162
164
 
165
+ ### Line-aligned emission and the authoring contract
166
+
167
+ ASTTransform owns text and lines; transform authors own semantics and execution order. The contract:
168
+
169
+ * A node **with** a source location is emitted at that location's line (the emitter pads with blank lines to reach it, and packs with `;` when a line is already occupied).
170
+ * A node **without** a source location is synthetic: it packs onto the current line and inherits its neighbors' line number.
171
+ * Textual order is source order. If your transform needs code to *execute* in a different order than it *appears*, use a thunk (below) instead of moving nodes.
172
+
173
+ `ASTTransform::TransformationHelper` (included by `AbstractTransformation`) provides the authoring toolkit:
174
+
175
+ * `s(type, *children)` — builds a loc-less (synthetic) node. Registered custom types (see below) construct their registered class.
176
+ * `s_at(anchor, type, *children)` — builds a node anchored at `anchor`'s source location, so it is emitted at `anchor`'s line. Raises `TransformationHelper::MissingLocationError` if the anchor has no location.
177
+ * `thunk(*statements)` — wraps statements in a single `Thunk` node: splice it wherever the statements must *run*, in statement position or composed inside an expression (e.g. an `assert_raises` block body). The wrapped statements keep their own locations, and the lowering derives the hidden proc's textual placement from them — the body still emits on its source lines even though execution waits. Reuse the same node to execute one body from several points. Thunk construction is invariant-checked (`ArgumentError` on a missing id or empty body); a body whose source lines fall after its execution point fails lowering with `ThunkLowering::PlacementError` (a thunk can only delay execution, never text).
178
+ * `run_after(statements, run:, after:)` — the paved road over `thunk`: returns a reordered copy of `statements` where the contiguous `run` executes after `after`, while remaining at its source position textually. Elements are matched by object identity.
179
+
180
+ Thunked statements keep their original meaning as far as Ruby's closure semantics allow:
181
+
182
+ * `return` still returns from the enclosing method — the hidden closure is a non-lambda proc, and the proc and its call always share one method activation (placements never cross a `def` boundary).
183
+ * Locals assigned by the thunked statements stay method-scope: the lowering pre-declares each one (`result = result`) before the proc, so code after the execution point can read them. Before the thunk runs they are `nil` — exactly what an unexecuted assignment yields.
184
+ * Jump keywords whose owner lies *outside* the thunked statements keep Ruby's native behavior: `break`/`retry` raise `LocalJumpError` at the jump's own source line, while `next`/`redo` silently end or restart the thunk body. ASTTransform does not validate this — what a transform surface allows users to thunk is the transform author's call.
185
+
186
+ #### Gotcha: location-only rewrites are silently dropped
187
+
188
+ `Parser::AST::Node#updated` returns `self` when the new children compare `==` to the old ones — and `AST::Node#==` ignores source locations. A `Processor` pass that replaces a node with an equal-valued one (e.g. the same call rebuilt loc-less, hoping to change its emitted line) is a no-op: every `node.updated(nil, process_all(node))` up the tree discards the replacement. Location is part of a node's *emission*, not its *value* — to change where a node emits, change what it is (`s_at` an anchored rebuild with different children), or restructure the parent explicitly rather than relying on `updated`.
189
+
190
+ #### Custom node types (intermediate representation)
191
+
192
+ Transformations that parse a DSL can build their own IR by registering node classes:
193
+
194
+ ```ruby
195
+ class InteractionNode < ASTTransform::Node
196
+ register :my_interaction
197
+
198
+ def cardinality = children[0]
199
+ end
200
+
201
+ s(:my_interaction, ...) # => InteractionNode, with domain accessors
202
+ ```
203
+
204
+ Custom node types are IR **between stages that understand them** — the stage that owns a type must lower it to plain Ruby nodes before emission. The emitter enforces this: any registered or `ast_`-prefixed type reaching emission raises `LineAlignedEmitter::UnloweredNodeTypeError`.
205
+
206
+ #### Testing your transformation
207
+
208
+ `require 'ast_transform/testing/assertions'` (test-only) provides `ASTTransform::Testing::Assertions`, a Minitest-flavored module to include in your test class:
209
+
210
+ * `assert_line_aligned(source, *transformations)` — transforms `source` through the real pipeline and asserts every surviving statement is emitted at its source line.
211
+ * `assert_backtrace_lines(source, path:, raise_at:)` — compiles and executes `source`, asserting the raw first backtrace frame cites `path:raise_at` with no filtering.
212
+
163
213
  ### Parameterizable transformations
164
214
 
165
215
  If you want your transformation to be customizable, accept the parameters in the constructor. The annotation can the be changed accordingly:
@@ -183,6 +233,8 @@ end
183
233
 
184
234
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
185
235
 
236
+ If you use the [d3mlabs dev tool](https://github.com/d3mlabs/dev), `dev up` provisions the pinned Ruby (see `.ruby-version`) with a per-project shadowenv, and `dev test` runs the suite — plain Bundler as above works just as well.
237
+
186
238
  To install this gem onto your local machine, run `bundle exec rake install`.
187
239
 
188
240
  ## Releasing a New Version
@@ -191,7 +243,7 @@ There are two ways to create a release. Both require that `version.rb` has alrea
191
243
 
192
244
  ### Via GitHub UI
193
245
 
194
- 1. Update `VERSION` in `lib/ast_transform/version.rb`, commit, open a PR, and merge to main
246
+ 1. Update `VERSION` in `lib/ast_transform/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
195
247
  2. Go to the repo on GitHub → **Releases** → **Draft a new release**
196
248
  3. Enter a new tag (e.g. `v2.0.0`), select `main` as the target branch
197
249
  4. Add a title and release notes (GitHub can auto-generate these from merged PRs)
@@ -199,7 +251,7 @@ There are two ways to create a release. Both require that `version.rb` has alrea
199
251
 
200
252
  ### Via CLI
201
253
 
202
- 1. Update `VERSION` in `lib/ast_transform/version.rb`, commit, open a PR, and merge to main
254
+ 1. Update `VERSION` in `lib/ast_transform/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
203
255
  2. Tag and push:
204
256
  ```
205
257
  git checkout main && git pull
data/Rakefile CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
- require 'bundler/gem_tasks'
3
- require 'rake/testtask'
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
4
5
 
5
6
  Rake::TestTask.new(:test) do |t|
6
7
  # Ensure we load test_loader first for ASTTransform.install
7
- filepath = File.expand_path('test/test_loader.rb', __dir__)
8
+ filepath = File.expand_path("test/test_loader.rb", __dir__)
8
9
  t.ruby_opts << "-r #{filepath}"
9
10
  t.warning = false
10
11
 
11
- t.libs << 'test'
12
- t.libs << 'lib'
13
- t.test_files = FileList['test/**/*_test.rb']
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
14
15
  end
15
16
 
16
- task :default => :test
17
+ task default: :test
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  lib = File.expand_path("../lib", __FILE__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
@@ -9,28 +10,26 @@ Gem::Specification.new do |spec|
9
10
  spec.authors = ["Jean-Philippe Duchesne"]
10
11
  spec.email = ["jpduchesne89@gmail.com"]
11
12
 
12
- spec.summary = 'An AST transformation framework.'
13
+ spec.summary = "An AST transformation framework."
13
14
  spec.description = spec.summary
14
15
  spec.homepage = "https://github.com/rspockframework/ast-transform"
15
16
  spec.license = "MIT"
16
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ spec.files = %x(git ls-files -z).split("\x0").reject do |f|
17
18
  f.match(%r{^(test|spec|features)/})
18
19
  end
19
20
  spec.bindir = "exe"
20
21
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
22
  spec.require_paths = ["lib"]
22
- spec.required_ruby_version = '>= 3.2'
23
+ spec.required_ruby_version = '>= 3.3'
23
24
 
24
- # Development dependencies
25
- spec.add_development_dependency "bundler", ">= 2.1"
26
- spec.add_development_dependency "rake", "~> 13.0"
27
- spec.add_development_dependency "minitest", "~> 5.14"
28
- spec.add_development_dependency "minitest-reporters", "~> 1.4"
29
- spec.add_development_dependency "pry", ">= 0.14"
30
- spec.add_development_dependency "simplecov", "~> 0.22"
25
+ # Development dependencies live in the Gemfile (Gemspec/DevelopmentDependencies).
31
26
 
32
27
  # Runtime dependencies
33
- spec.add_runtime_dependency "parser", ">= 3.0"
28
+ # parser provides the runtime AST vocabulary (Parser::AST::Node/Processor,
29
+ # Source::Buffer/Map); parsing itself goes through prism's translation layer.
30
+ spec.add_runtime_dependency "parser", ">= 3.3"
34
31
  spec.add_runtime_dependency "prism", ">= 1.5"
35
- spec.add_runtime_dependency "unparser", ">= 0.6"
32
+ # unparser >= 0.8: static_local_variables: (0.7 interface) + the prism-based
33
+ # round-trip verification parser required for Ruby >= 3.4 syntax.
34
+ spec.add_runtime_dependency "unparser", ">= 0.8"
36
35
  end
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require "ast_transform"
data/dependencies.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Toolchain-only manifest for d3mlabs' dev tool: it provisions this exact
4
+ # Ruby (rbenv + shadowenv) for `dev` commands. Gems stay bundler-managed
5
+ # through the hand-written gemspec/Gemfile; contributors without dev can
6
+ # ignore this file and use .ruby-version.
7
+ require "dev/deps"
8
+
9
+ Dev::Deps.define do
10
+ ruby "4.0.6"
11
+ end
data/dev.yml ADDED
@@ -0,0 +1,11 @@
1
+ name: ast_transform
2
+ commands:
3
+ up:
4
+ desc: Install gems
5
+ run: bundle install
6
+ test:
7
+ desc: Run this repo's tests
8
+ run: bundle exec rake test
9
+ style:
10
+ desc: Run RuboCop
11
+ run: bundle exec rubocop
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
- require 'ast_transform/transformation_helper'
2
+
3
+ require "ast_transform/transformation_helper"
3
4
 
4
5
  module ASTTransform
5
6
  class AbstractTransformation < Parser::AST::Processor
@@ -22,6 +23,14 @@ module ASTTransform
22
23
  process_node(node)
23
24
  end
24
25
 
26
+ # Thunks are framework-owned IR: descend into the body so passes that don't know about thunks still process the
27
+ # wrapped statements. Without this, Processor's handler_missing default would pass the node through opaquely,
28
+ # hiding the body from every later transformation. The token (first child) is not a node and passes through
29
+ # untouched.
30
+ def on_ast_thunk(node)
31
+ node.updated(nil, [node.children[0], *process_all(node.children.drop(1))])
32
+ end
33
+
25
34
  private
26
35
 
27
36
  # Processes the given +node+.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ast_transform/transformer'
4
- require 'ast_transform/instruction_sequence/mixin_utils'
5
- require 'pathname'
3
+ require "ast_transform/transformer"
4
+ require "ast_transform/instruction_sequence/mixin_utils"
5
+ require "pathname"
6
6
 
7
7
  module ASTTransform
8
8
  module InstructionSequence
@@ -14,7 +14,7 @@ module ASTTransform
14
14
  iseq = ASTTransform::InstructionSequence.source_to_transformed_iseq(source, source_path)
15
15
  iseq.to_binary
16
16
  rescue SyntaxError
17
- raise ::Bootsnap::CompileCache::Uncompilable, 'syntax error'
17
+ raise ::Bootsnap::CompileCache::Uncompilable, "syntax error"
18
18
  end
19
19
  end
20
20
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require 'pathname'
3
- require 'ast_transform/transformer'
4
- require 'ast_transform/transformation'
5
- require 'ast_transform/instruction_sequence/mixin_utils'
2
+
3
+ require "pathname"
4
+ require "ast_transform/transformer"
5
+ require "ast_transform/transformation"
6
+ require "ast_transform/instruction_sequence/mixin_utils"
6
7
 
7
8
  module ASTTransform
8
9
  module InstructionSequence
@@ -10,9 +11,12 @@ module ASTTransform
10
11
  def load_iseq(source_path)
11
12
  return ASTTransform::MixinUtils.try_super(self, :load_iseq, source_path) if source_path == __FILE__
12
13
 
13
- source = File.read(source_path)
14
+ # Binary read avoids encoding errors during the fast-path check below.
15
+ # Downstream (Prism, RubyVM::InstructionSequence) handle encoding natively
16
+ # via magic comments, so we never need to set it ourselves.
17
+ source = File.binread(source_path)
14
18
 
15
- return ASTTransform::MixinUtils.try_super(self, :load_iseq, source_path) unless source =~ /transform!/
19
+ return ASTTransform::MixinUtils.try_super(self, :load_iseq, source_path) unless source.include?("transform!".b)
16
20
 
17
21
  ASTTransform::InstructionSequence.source_to_transformed_iseq(source, source_path)
18
22
  end
@@ -5,7 +5,7 @@ module ASTTransform
5
5
  class << self
6
6
  def try_super(target, method_sym, *args, &block)
7
7
  super_method = target.method(method_sym).super_method
8
- super_method ? super_method.call(*args, &block) : nil
8
+ super_method&.call(*args, &block)
9
9
  end
10
10
  end
11
11
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module ASTTransform
3
4
  module InstructionSequence
4
5
  class << self
@@ -16,18 +17,18 @@ module ASTTransform
16
17
  rewritten_source = transformer.transform_file_source(source, source_path, rewritten_file_pathname.to_s)
17
18
  write(rewritten_source, rewritten_file_pathname)
18
19
 
19
- RubyVM::InstructionSequence.compile(rewritten_source, rewritten_file_pathname.to_s, rewritten_file_pathname.to_s)
20
+ RubyVM::InstructionSequence.compile(rewritten_source, source_path, source_path)
20
21
  end
21
22
 
22
23
  def write_pathname(file_path)
23
24
  project_path = File.expand_path("")
24
25
  relative_source_file_pathname = Pathname.new(file_path).relative_path_from(Pathname.new(project_path))
25
- Pathname.new("").join(project_path, 'tmp', 'ast_transform', relative_source_file_pathname)
26
+ Pathname.new("").join(project_path, "tmp", "ast_transform", relative_source_file_pathname)
26
27
  end
27
28
 
28
29
  def write(string, pathname)
29
30
  FileUtils.mkdir_p(pathname.dirname)
30
- File.open(pathname, 'w') do |file|
31
+ File.open(pathname, "w") do |file|
31
32
  file.write(string)
32
33
  end
33
34
  end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'prism/translation/parser'
3
+ require "prism/translation/parser"
4
4
 
5
5
  module ASTTransform
6
- # Extends the default Prism parser builder to distinguish keyword arguments
7
- # from hash literals in the AST.
6
+ # Extends the default Prism parser builder to distinguish keyword arguments from hash literals in the AST.
8
7
  #
9
- # The upstream builder always emits :hash nodes for both `foo(bar: 1)` and
10
- # `foo({ bar: 1 })`. Unparser uses the node type to decide whether to emit
11
- # braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+ treats these as
12
- # semantically different (strict keyword/positional separation), we need the
13
- # AST to preserve the distinction.
8
+ # The upstream builder always emits :hash nodes for both `foo(bar: 1)` and `foo({ bar: 1 })`. Unparser uses the
9
+ # node type to decide whether to emit braces: :hash gets `{}`, :kwargs does not. Since Ruby 3.0+ treats these as
10
+ # semantically different (strict keyword/positional separation), we need the AST to preserve the distinction.
11
+ #
12
+ # NOTE: parsed nodes deliberately stay plain Parser::AST::Node. Custom node classes exist only for registered
13
+ # custom types (see ASTTransform::Node), which are IR and never reach Unparser: AST::Node#eql? compares class, and
14
+ # Unparser verifies dynamic-string emission by re-parsing and comparing eql? against the freshly parsed
15
+ # (plain-class) node — custom-class nodes of standard types would fail that verification.
14
16
  class KwargsBuilder < Prism::Translation::Parser::Builder
15
17
  def associate(begin_t, pairs, end_t)
16
18
  node = super
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ASTTransform
4
+ # Line-addressed output: text is placed at absolute line numbers, top to bottom, and the cursor never rewinds.
5
+ # When a placement's target line is already behind the cursor, the text is packed (`; `) onto the current line
6
+ # instead — Ruby lets statements share a physical line, so alignment degrades locally and the next placement
7
+ # whose target is still ahead re-anchors. Knows nothing about Ruby structure or ASTs; callers decide WHAT goes
8
+ # on WHICH line, the layout owns the pad-or-pack mechanics.
9
+ class Layout
10
+ def initialize
11
+ @lines = []
12
+ end
13
+
14
+ # The line number currently being written; the next fresh line would be +cursor + 1+.
15
+ def cursor
16
+ @lines.size
17
+ end
18
+
19
+ # Places +text+ at +target_line+ when the cursor hasn't passed it; otherwise packs onto the current line.
20
+ # Multi-line text advances the cursor by its height. When opening a fresh line, the first line is indented to
21
+ # +column+ — cosmetic only (leading whitespace is never significant in emitted code), but it keeps the artifact
22
+ # visually close to the source. Packed text ignores the column, as do continuation lines (they keep their own
23
+ # relative indentation).
24
+ def place(target_line, text, column: nil)
25
+ first, *rest = text.split("\n")
26
+
27
+ if target_line && target_line > @lines.size
28
+ @lines << '' while @lines.size < target_line
29
+ @lines[-1] = indented(first, column)
30
+ else
31
+ pack(first)
32
+ end
33
+
34
+ @lines.concat(rest)
35
+ end
36
+
37
+ # Appends +text+ on a new line unconditionally — for text that must never be `;`-packed after a statement
38
+ # (e.g. keywords).
39
+ def place_on_fresh_line(text)
40
+ @lines << text
41
+ end
42
+
43
+ # Appends +text+ to the current line with a `; ` separator. The last line is never blank here: padding blanks
44
+ # are only created inside +place+, which immediately overwrites the padded line.
45
+ def pack(text)
46
+ if @lines.empty?
47
+ @lines << text
48
+ else
49
+ @lines[-1] = "#{@lines.last}; #{text}"
50
+ end
51
+ end
52
+
53
+ # @return [String] the laid-out text, with a trailing newline.
54
+ def to_source
55
+ "#{@lines.join("\n")}\n"
56
+ end
57
+
58
+ private
59
+
60
+ def indented(text, column)
61
+ column && column.positive? ? "#{' ' * column}#{text}" : text
62
+ end
63
+ end
64
+ end