dynamic_locals 0.1.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 +7 -0
- data/.github/workflows/test.yml +19 -0
- data/.gitignore +8 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +67 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/dynamic_locals.gemspec +32 -0
- data/lib/dynamic_locals/ast_rewriter.rb +116 -0
- data/lib/dynamic_locals/base_translator.rb +28 -0
- data/lib/dynamic_locals/eval_translator.rb +22 -0
- data/lib/dynamic_locals/rewrite_translator.rb +512 -0
- data/lib/dynamic_locals/version.rb +3 -0
- data/lib/dynamic_locals.rb +20 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 82329848153ed44bdf77018d27125b15b1b3b8bb039867ba5aeac718c13f9bcc
|
|
4
|
+
data.tar.gz: 422065d9beca6fad9aa6447c361352d25b860efeb0c5f7477b4c02b7df809084
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28b72b64c991993cac07d046e9090f7b9c0b6c969e4c0e62a8e72f5bc4948795da721ac5588cfc6f7deec21e72725515a35215ccb154ea3286a34e27a65054a2
|
|
7
|
+
data.tar.gz: 0a44008c2ea2c798c38b46352e92e596c1c943e704fde57cd7764cc4ba010878762c9b25957e0631b9c81baca36e1329b39edf41d8029cb3dd11ca2b643a4986
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
fail-fast: false
|
|
10
|
+
matrix:
|
|
11
|
+
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: ruby/setup-ruby@v1
|
|
16
|
+
with:
|
|
17
|
+
ruby-version: ${{ matrix.ruby }}
|
|
18
|
+
bundler-cache: true
|
|
19
|
+
- run: bundle exec rake
|
data/.gitignore
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
|
10
|
+
orientation.
|
|
11
|
+
|
|
12
|
+
## Our Standards
|
|
13
|
+
|
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
|
15
|
+
include:
|
|
16
|
+
|
|
17
|
+
* Using welcoming and inclusive language
|
|
18
|
+
* Being respectful of differing viewpoints and experiences
|
|
19
|
+
* Gracefully accepting constructive criticism
|
|
20
|
+
* Focusing on what is best for the community
|
|
21
|
+
* Showing empathy towards other community members
|
|
22
|
+
|
|
23
|
+
Examples of unacceptable behavior by participants include:
|
|
24
|
+
|
|
25
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or
|
|
26
|
+
advances
|
|
27
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
|
28
|
+
* Public or private harassment
|
|
29
|
+
* Publishing others' private information, such as a physical or electronic
|
|
30
|
+
address, without explicit permission
|
|
31
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
32
|
+
professional setting
|
|
33
|
+
|
|
34
|
+
## Our Responsibilities
|
|
35
|
+
|
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
|
38
|
+
response to any instances of unacceptable behavior.
|
|
39
|
+
|
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
|
44
|
+
threatening, offensive, or harmful.
|
|
45
|
+
|
|
46
|
+
## Scope
|
|
47
|
+
|
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
|
49
|
+
when an individual is representing the project or its community. Examples of
|
|
50
|
+
representing a project or community include using an official project e-mail
|
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
|
53
|
+
further defined and clarified by project maintainers.
|
|
54
|
+
|
|
55
|
+
## Enforcement
|
|
56
|
+
|
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
58
|
+
reported by contacting the project team at john@hawthorn.email. All
|
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
|
63
|
+
|
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
|
66
|
+
members of the project's leadership.
|
|
67
|
+
|
|
68
|
+
## Attribution
|
|
69
|
+
|
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
|
71
|
+
available at [http://contributor-covenant.org/version/1/4][version]
|
|
72
|
+
|
|
73
|
+
[homepage]: http://contributor-covenant.org
|
|
74
|
+
[version]: http://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
dynamic_locals (0.1.0)
|
|
5
|
+
prism (>= 1.0)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
minitest (5.27.0)
|
|
11
|
+
prism (1.9.0)
|
|
12
|
+
rake (13.4.2)
|
|
13
|
+
|
|
14
|
+
PLATFORMS
|
|
15
|
+
ruby
|
|
16
|
+
|
|
17
|
+
DEPENDENCIES
|
|
18
|
+
bundler (>= 2.0)
|
|
19
|
+
dynamic_locals!
|
|
20
|
+
minitest (~> 5.0)
|
|
21
|
+
rake (~> 13.0)
|
|
22
|
+
|
|
23
|
+
BUNDLED WITH
|
|
24
|
+
4.0.10
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 John Hawthorn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# DynamicLocals
|
|
2
|
+
|
|
3
|
+
This is an experimental Ruby to Ruby transpiler which swaps out the functionality of local variables, allowing them to be dynamically defined.
|
|
4
|
+
|
|
5
|
+
This is a proof of concept and shouldn't be used yet.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
``` ruby
|
|
10
|
+
c = Class.new do
|
|
11
|
+
def foo
|
|
12
|
+
"from method"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
src = DynamicLocals.translate("foo")
|
|
17
|
+
# => "locals.fetch(:foo) { foo() }"
|
|
18
|
+
|
|
19
|
+
c.class_eval("def run(locals); #{src}; end")
|
|
20
|
+
|
|
21
|
+
c.new.run({})
|
|
22
|
+
#=> "from method"
|
|
23
|
+
c.new.run({ foo: "from local" })
|
|
24
|
+
#=> "from local"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why?
|
|
28
|
+
|
|
29
|
+
ActionView has the interesting quality that the local variables defined within a template are defined by the caller.
|
|
30
|
+
|
|
31
|
+
``` ruby
|
|
32
|
+
<%= render partial: "some_template", locals: { foo: "bar" } %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This is tricky, because local variables are determined by Ruby at compile time.
|
|
36
|
+
Because method calls with no arguments look the same as local variable accesses, it's ambiguous which they are. Ruby determines between calls and locals by checking if the local is being assigned to at
|
|
37
|
+
|
|
38
|
+
ActionView currently solves this by compiling a separate template for each set of local variable names passed in. This wastes memory by duplicating the template for every set of locals being passed in and also prevents templates from being compiled at boot, since we don't know what locals they will be given.
|
|
39
|
+
|
|
40
|
+
## How?
|
|
41
|
+
|
|
42
|
+
The basic mechanism is to replace all ambiguous access a hash fetch falling back to an unambiguous method call:
|
|
43
|
+
|
|
44
|
+
``` ruby
|
|
45
|
+
locals.fetch(:foobar) { foobar() }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This introduces a number of corner cases (see tests for many examples), which this also tries to work around.
|
|
49
|
+
The goal of this is for Ruby's behaviour to be unchanged and not to leak this "optimization".
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
56
|
+
|
|
57
|
+
## Contributing
|
|
58
|
+
|
|
59
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/jhawthorn/dynamic_locals. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
64
|
+
|
|
65
|
+
## Code of Conduct
|
|
66
|
+
|
|
67
|
+
Everyone interacting in the DynamicLocals project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jhawthorn/dynamic_locals/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "dynamic_locals"
|
|
5
|
+
|
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
8
|
+
|
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
10
|
+
# require "pry"
|
|
11
|
+
# Pry.start
|
|
12
|
+
|
|
13
|
+
require "irb"
|
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
|
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
+
require "dynamic_locals/version"
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = "dynamic_locals"
|
|
8
|
+
spec.version = DynamicLocals::VERSION
|
|
9
|
+
spec.authors = ["John Hawthorn"]
|
|
10
|
+
spec.email = ["john@hawthorn.email"]
|
|
11
|
+
|
|
12
|
+
spec.summary = %q{Makes it possible to define locals dynamically}
|
|
13
|
+
spec.description = %q{Parses ruby to rewrite how locals work}
|
|
14
|
+
spec.homepage = "https://github.com/jhawthorn/dynamic_locals"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
spec.required_ruby_version = ">= 3.2"
|
|
17
|
+
|
|
18
|
+
# Specify which files should be added to the gem when it is released.
|
|
19
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
20
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
22
|
+
end
|
|
23
|
+
spec.bindir = "exe"
|
|
24
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
|
|
27
|
+
spec.add_dependency "prism", ">= 1.0"
|
|
28
|
+
|
|
29
|
+
spec.add_development_dependency "bundler", ">= 2.0"
|
|
30
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
31
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
32
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module DynamicLocals
|
|
4
|
+
class ASTRewriter
|
|
5
|
+
# The source we translate is a *method body*: it may legally use constructs
|
|
6
|
+
# that are only valid inside a method -- `yield`, a top-level `return`, and
|
|
7
|
+
# an implicit begin/rescue (`raise; rescue; ...`). Parsing it as a standalone
|
|
8
|
+
# program rejects those, so we parse it wrapped in a method definition and
|
|
9
|
+
# then map node offsets back onto the original (unwrapped) source.
|
|
10
|
+
WRAPPER_NAME = "__dynamic_locals_parse_wrapper__"
|
|
11
|
+
WRAPPER_PREFIX = "def #{WRAPPER_NAME}\n"
|
|
12
|
+
WRAPPER_SUFFIX = "\nend"
|
|
13
|
+
|
|
14
|
+
def initialize(source)
|
|
15
|
+
@original_src = source.dup.freeze
|
|
16
|
+
|
|
17
|
+
result, prefix = parse_with_locals([])
|
|
18
|
+
errors = result.errors.reject { |error| error.type == :no_local_variable }
|
|
19
|
+
raise SyntaxError, errors.map(&:message).join("\n") if errors.any?
|
|
20
|
+
|
|
21
|
+
# A pin such as `^foo` reports :no_local_variable when `foo` will only be
|
|
22
|
+
# supplied dynamically. Reparse with those names declared on the wrapper
|
|
23
|
+
# method; pins in blocks then resolve naturally, while a pin beyond a real
|
|
24
|
+
# scope boundary (a nested def/class/module body) remains a syntax error.
|
|
25
|
+
implicit_locals = result.errors.filter_map do |error|
|
|
26
|
+
error.location.slice.to_sym if error.type == :no_local_variable
|
|
27
|
+
end.uniq
|
|
28
|
+
result, prefix = parse_with_locals(implicit_locals) if implicit_locals.any?
|
|
29
|
+
raise SyntaxError, result.errors.map(&:message).join("\n") unless result.success?
|
|
30
|
+
|
|
31
|
+
@offset = prefix.bytesize
|
|
32
|
+
@def_node = result.value.statements.body.first
|
|
33
|
+
@replacements = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Replacement = Struct.new(:range, :src)
|
|
37
|
+
|
|
38
|
+
attr_reader :original_src
|
|
39
|
+
|
|
40
|
+
# The method body scope: a StatementsNode, a BeginNode (when the body has a
|
|
41
|
+
# rescue/else/ensure), or nil when the body is empty.
|
|
42
|
+
def ast
|
|
43
|
+
@def_node.body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The method-level local table Ruby derives from the body.
|
|
47
|
+
def locals
|
|
48
|
+
@def_node.locals
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def modified_src
|
|
52
|
+
# Apply right-to-left so earlier ranges stay valid. Ties are broken so
|
|
53
|
+
# that a zero-width insertion at an offset is applied before a
|
|
54
|
+
# replacement ending there (keeping the insertion outside the replaced
|
|
55
|
+
# text), and same-position insertions land in registration order.
|
|
56
|
+
rewrites =
|
|
57
|
+
@replacements.each_with_index.sort_by do |replacement, index|
|
|
58
|
+
[replacement.range.end, replacement.range.begin, index]
|
|
59
|
+
end.reverse
|
|
60
|
+
|
|
61
|
+
src = original_src.b
|
|
62
|
+
rewrites.each do |replacement, _|
|
|
63
|
+
src[replacement.range] = replacement.src.b
|
|
64
|
+
end
|
|
65
|
+
src.force_encoding(original_src.encoding)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def insert_before(node, src)
|
|
69
|
+
raise TypeError unless Prism::Node === node
|
|
70
|
+
|
|
71
|
+
insert_at(node.location.start_offset, src)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def insert_after(node, src)
|
|
75
|
+
raise TypeError unless Prism::Node === node
|
|
76
|
+
|
|
77
|
+
insert_at(node.location.end_offset, src)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Offsets are relative to the wrapped source, as in Prism locations.
|
|
81
|
+
def insert_at(wrapped_offset, src)
|
|
82
|
+
pos = wrapped_offset - @offset
|
|
83
|
+
@replacements << Replacement.new(pos...pos, src)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def replace_offsets(wrapped_start, wrapped_end, src)
|
|
87
|
+
@replacements << Replacement.new((wrapped_start - @offset)...(wrapped_end - @offset), src)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replace(node, src)
|
|
91
|
+
raise TypeError unless Prism::Node === node
|
|
92
|
+
|
|
93
|
+
range = range_from(node)
|
|
94
|
+
@replacements << Replacement.new(range, src)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def parse_with_locals(locals)
|
|
100
|
+
prefix =
|
|
101
|
+
if locals.empty?
|
|
102
|
+
WRAPPER_PREFIX
|
|
103
|
+
else
|
|
104
|
+
parameters = locals.map { |local| "#{local}: nil" }.join(", ")
|
|
105
|
+
"def #{WRAPPER_NAME}(#{parameters})\n"
|
|
106
|
+
end
|
|
107
|
+
[Prism.parse("#{prefix}#{@original_src}#{WRAPPER_SUFFIX}"), prefix]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Node offsets are relative to the wrapped source; shift them back onto the
|
|
111
|
+
# original source by subtracting the wrapper prefix length.
|
|
112
|
+
def range_from(node)
|
|
113
|
+
(node.location.start_offset - @offset)...(node.location.end_offset - @offset)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module DynamicLocals
|
|
2
|
+
class BaseTranslator
|
|
3
|
+
attr_reader :original_src, :locals_hash, :lookup_strategy, :known_locals
|
|
4
|
+
|
|
5
|
+
def initialize(src, locals_hash: :locals, lookup_strategy: :hash, known_locals: [])
|
|
6
|
+
@original_src = src
|
|
7
|
+
@locals_hash = locals_hash
|
|
8
|
+
@lookup_strategy = lookup_strategy
|
|
9
|
+
@known_locals = Array(known_locals).map(&:to_sym)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def translate
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The parameter list that pairs with the translated body. The default shape
|
|
17
|
+
# takes the locals as a single hash argument; strategies that use real
|
|
18
|
+
# parameters (see RewriteTranslator's keyword strategy) override this.
|
|
19
|
+
def parameters
|
|
20
|
+
"#{locals_hash} = {}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Wrap the translated body in a method definition.
|
|
24
|
+
def to_s(name = "__dynamic_locals__")
|
|
25
|
+
"def #{name}(#{parameters})\n#{translate}\nend"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "dynamic_locals/base_translator"
|
|
2
|
+
|
|
3
|
+
module DynamicLocals
|
|
4
|
+
# This is (probably) the simplest possible implementation of the behaviour we
|
|
5
|
+
# want. It builds a new string of Ruby code with assignments to the locals
|
|
6
|
+
# each time it is called and evals that string.
|
|
7
|
+
#
|
|
8
|
+
# This is not intended to be used in production code, but for debugging and
|
|
9
|
+
# as a reference implementation.
|
|
10
|
+
class EvalTranslator < BaseTranslator
|
|
11
|
+
def translate
|
|
12
|
+
# `eval` compiles its argument as a standalone program, which -- unlike a
|
|
13
|
+
# method body -- has no implicit `begin`. Wrap the source in begin/end so a
|
|
14
|
+
# method-body-style implicit rescue parses.
|
|
15
|
+
body = "begin\n#{original_src}\nend"
|
|
16
|
+
# The file/line arguments make errors report the line the source would
|
|
17
|
+
# occupy had it been spliced in directly: the `begin` line lands one
|
|
18
|
+
# line above the eval call, so source line N reports as call line + N.
|
|
19
|
+
%{eval(#{locals_hash}.keys.map { |k| "\#{k} = #{locals_hash}[\#{k.inspect}];" }.join + #{body.inspect}, binding, __FILE__, __LINE__ - 1)}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
require "dynamic_locals/base_translator"
|
|
2
|
+
require "dynamic_locals/ast_rewriter"
|
|
3
|
+
|
|
4
|
+
module DynamicLocals
|
|
5
|
+
class RewriteTranslator < BaseTranslator
|
|
6
|
+
def initialize(...)
|
|
7
|
+
super
|
|
8
|
+
|
|
9
|
+
unless [:hash, :keywords].include?(lookup_strategy)
|
|
10
|
+
raise ArgumentError, "unknown lookup strategy: #{lookup_strategy.inspect}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@rewriter = ASTRewriter.new(original_src)
|
|
14
|
+
|
|
15
|
+
root = @rewriter.ast
|
|
16
|
+
# Names assigned anywhere in this method body. Earlier occurrences of
|
|
17
|
+
# these names can still be CallNodes, e.g. `foo; foo = 123`.
|
|
18
|
+
@local_table = @rewriter.locals
|
|
19
|
+
@dynamic_locals = @local_table.dup
|
|
20
|
+
@keyword_unset_flags = {}
|
|
21
|
+
@shadowed_locals = []
|
|
22
|
+
@shadow_names = {}
|
|
23
|
+
@generated_names = []
|
|
24
|
+
@scope_parameter_names = {}
|
|
25
|
+
collect_dynamic_locals(root)
|
|
26
|
+
# Known locals are real variables that already exist (e.g. method
|
|
27
|
+
# parameters); they are never looked up dynamically, made into kwargs,
|
|
28
|
+
# or initialized from the locals hash.
|
|
29
|
+
@dynamic_locals = @dynamic_locals.sort.uniq - known_locals
|
|
30
|
+
dynamic_locals.each { |local| keyword_unset_flag(local) } if keywords?
|
|
31
|
+
find_replacements(root, [:root])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :dynamic_locals
|
|
35
|
+
|
|
36
|
+
def keyword_parameters(rest: nil)
|
|
37
|
+
return "#{locals_hash} = {}" unless keywords?
|
|
38
|
+
params = dynamic_locals.map do |local|
|
|
39
|
+
"#{local}: (#{keyword_unset_flag(local)} = true; nil)"
|
|
40
|
+
end
|
|
41
|
+
params << "**#{rest}" if rest
|
|
42
|
+
params.join(", ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def translate
|
|
46
|
+
src = @rewriter.modified_src
|
|
47
|
+
|
|
48
|
+
initialize = initialize_local_table
|
|
49
|
+
|
|
50
|
+
"#{initialize}#{src}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# In the keyword strategy the locals are real parameters; absorb any
|
|
54
|
+
# unsupplied-but-passed keys so callers can splat an arbitrary hash.
|
|
55
|
+
def parameters
|
|
56
|
+
keyword_parameters(rest: :__dynamic_locals_unused_keywords)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def collect_dynamic_locals(node)
|
|
62
|
+
return unless Prism::Node === node
|
|
63
|
+
|
|
64
|
+
if nested_scope?(node)
|
|
65
|
+
nested_local_table(node).each do |local|
|
|
66
|
+
@local_table << local
|
|
67
|
+
@dynamic_locals << local
|
|
68
|
+
# Pre-declaring a dynamic local as a method-level variable changes
|
|
69
|
+
# the scoping of assignments inside blocks: plain Ruby would have
|
|
70
|
+
# created a fresh block-local per invocation. These names get a
|
|
71
|
+
# "shadow" variable whose assignments are co-located with the
|
|
72
|
+
# original ones, so its scope structure matches plain Ruby's.
|
|
73
|
+
@shadowed_locals << local unless known_locals.include?(local)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
child_nodes_for_method_body(node).each { |child| collect_dynamic_locals(child) }
|
|
77
|
+
elsif defined_variable_call?(node)
|
|
78
|
+
@dynamic_locals << node.value.name
|
|
79
|
+
elsif variable_call?(node)
|
|
80
|
+
@dynamic_locals << node.name
|
|
81
|
+
else
|
|
82
|
+
child_nodes_for_method_body(node).each { |child| collect_dynamic_locals(child) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Leaf branches replace a node (or deliberately leave it alone) and return;
|
|
87
|
+
# write branches recurse only into their value; everything else falls
|
|
88
|
+
# through to the generic recursion at the bottom.
|
|
89
|
+
def find_replacements(node, scopes)
|
|
90
|
+
return unless Prism::Node === node
|
|
91
|
+
|
|
92
|
+
if nested_scope?(node)
|
|
93
|
+
scopes += [node]
|
|
94
|
+
elsif defined_variable_call?(node)
|
|
95
|
+
# A `defined?` spanning newlines must not pull later lines up;
|
|
96
|
+
# re-emit its newlines inside the parenthesized replacement.
|
|
97
|
+
newlines = "\n" * node.slice.count("\n")
|
|
98
|
+
@rewriter.replace node, "(#{defined_lookup_src(node.value.name)}#{newlines})"
|
|
99
|
+
return
|
|
100
|
+
elsif Prism::DefinedNode === node && Prism::LocalVariableReadNode === node.value
|
|
101
|
+
# `defined?` of a real local is 'local-variable' whether the name was
|
|
102
|
+
# supplied or shadow-assigned, so leave it alone. Rewriting the read
|
|
103
|
+
# into a guarded expression would change the answer to 'expression'.
|
|
104
|
+
return
|
|
105
|
+
elsif variable_call?(node)
|
|
106
|
+
name = node.name
|
|
107
|
+
fallback = @local_table.include?(name) ? "#{name}()" : name.to_s
|
|
108
|
+
@rewriter.replace node, local_lookup_src(name, fallback)
|
|
109
|
+
return
|
|
110
|
+
elsif Prism::LocalVariableReadNode === node
|
|
111
|
+
if shadow_binding?(node.name, node.depth, scopes)
|
|
112
|
+
@rewriter.replace node, shadow_read_src(node.name)
|
|
113
|
+
end
|
|
114
|
+
return
|
|
115
|
+
elsif Prism::LocalVariableWriteNode === node
|
|
116
|
+
if shadow_binding?(node.name, node.depth, scopes)
|
|
117
|
+
@rewriter.insert_before(node, "#{shadow_name(node.name)} = ")
|
|
118
|
+
end
|
|
119
|
+
return find_replacements(node.value, scopes)
|
|
120
|
+
elsif local_operator_write?(node)
|
|
121
|
+
expand_operator_write(node, scopes) if shadow_binding?(node.name, node.depth, scopes)
|
|
122
|
+
return find_replacements(node.value, scopes)
|
|
123
|
+
elsif Prism::MatchWriteNode === node
|
|
124
|
+
shadow_match_write(node, scopes)
|
|
125
|
+
return find_replacements(node.call, scopes)
|
|
126
|
+
elsif Prism::MultiWriteNode === node
|
|
127
|
+
shadow_multi_write(node, scopes)
|
|
128
|
+
elsif Prism::RescueNode === node
|
|
129
|
+
shadow_rescue_reference(node, scopes)
|
|
130
|
+
elsif Prism::ForNode === node
|
|
131
|
+
shadow_for_index(node, scopes)
|
|
132
|
+
elsif Prism::MatchRequiredNode === node
|
|
133
|
+
shadow_required_pattern(node, scopes)
|
|
134
|
+
elsif Prism::MatchPredicateNode === node
|
|
135
|
+
shadow_predicate_pattern(node, scopes)
|
|
136
|
+
elsif Prism::InNode === node
|
|
137
|
+
# This branch owns its recursion: guard syncs must be registered
|
|
138
|
+
# around the predicate's own rewrites (see shadow_in_clause).
|
|
139
|
+
return shadow_in_clause(node, scopes) do
|
|
140
|
+
child_nodes_for_method_body(node).each { |child| find_replacements(child, scopes) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
child_nodes_for_method_body(node).each { |child| find_replacements(child, scopes) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# True when a read/write of +name+ resolves to a binding that was declared
|
|
148
|
+
# by assignment rather than by a parameter or explicit block-local -- those
|
|
149
|
+
# are the bindings whose scoping our pre-declaration changed, so they must
|
|
150
|
+
# go through the shadow variable. Parameter bindings shadow the dynamic
|
|
151
|
+
# local in plain Ruby too and stay untouched.
|
|
152
|
+
def shadow_binding?(name, depth, scopes)
|
|
153
|
+
return false unless @shadowed_locals.include?(name)
|
|
154
|
+
scope = scopes[scopes.length - 1 - depth]
|
|
155
|
+
return true if scope == :root
|
|
156
|
+
!scope_parameter_names(scope).include?(name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def scope_parameter_names(scope)
|
|
160
|
+
@scope_parameter_names[scope] ||= parameter_names(scope.parameters)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `x = v` => `__dynamic_locals_shadow_x = x = v`
|
|
164
|
+
# The kwarg keeps its real name (callers supply it), and the shadow gets a
|
|
165
|
+
# fresh block-local binding exactly where plain `x` would have. The leading
|
|
166
|
+
# underscore exempts write-only shadows from unused-variable warnings.
|
|
167
|
+
def shadow_name(name)
|
|
168
|
+
@shadow_names[name] ||= generate_hygienic_name("__dynamic_locals_shadow_#{name}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def discard_name
|
|
172
|
+
@discard_name ||= generate_hygienic_name("__dynamic_locals_discard")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def generate_hygienic_name(base)
|
|
176
|
+
candidate = base.to_sym
|
|
177
|
+
index = 0
|
|
178
|
+
while reserved_name?(candidate)
|
|
179
|
+
index += 1
|
|
180
|
+
candidate = :"#{base}#{index}"
|
|
181
|
+
end
|
|
182
|
+
@generated_names << candidate
|
|
183
|
+
candidate
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def reserved_name?(name)
|
|
187
|
+
# The regex alone can't cover dynamic locals: \b fails around non-word
|
|
188
|
+
# names like emoji identifiers.
|
|
189
|
+
known_locals.include?(name) ||
|
|
190
|
+
@generated_names.include?(name) ||
|
|
191
|
+
@dynamic_locals.include?(name) ||
|
|
192
|
+
original_src.match?(/\b#{Regexp.escape(name.to_s)}\b/)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Selects the plain variable when the local was supplied, the shadow
|
|
196
|
+
# otherwise. Needed even for method-level bindings: when the local is
|
|
197
|
+
# unset, the double-write leaves garbage in the kwarg.
|
|
198
|
+
def shadow_read_src(name)
|
|
199
|
+
if keywords?
|
|
200
|
+
"(#{keyword_unset_flag(name)} ? #{shadow_name(name)} : #{name})"
|
|
201
|
+
else
|
|
202
|
+
"(#{locals_hash}.key?(#{name.inspect}) ? #{name} : #{shadow_name(name)})"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# `x op= v` => `__dynamic_locals_shadow_x = x = <guarded read> op (v)`
|
|
207
|
+
# The parens around the value preserve associativity (`x -= a - b`); any
|
|
208
|
+
# newlines between the operator and its value re-emit after the opening
|
|
209
|
+
# paren so the value keeps its line.
|
|
210
|
+
def expand_operator_write(node, scopes)
|
|
211
|
+
name = node.name
|
|
212
|
+
op =
|
|
213
|
+
case node
|
|
214
|
+
when Prism::LocalVariableOrWriteNode then "||"
|
|
215
|
+
when Prism::LocalVariableAndWriteNode then "&&"
|
|
216
|
+
else node.binary_operator.to_s
|
|
217
|
+
end
|
|
218
|
+
value_offset = node.value.location.start_offset
|
|
219
|
+
newlines = "\n" * node.slice.byteslice(0, value_offset - node.location.start_offset).count("\n")
|
|
220
|
+
prefix = "#{shadow_name(name)} = #{name} = #{shadow_read_src(name)} #{op} (#{newlines}"
|
|
221
|
+
@rewriter.replace_offsets(node.location.start_offset, value_offset, prefix)
|
|
222
|
+
@rewriter.insert_after(node.value, ")")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# `a, b = v` =>
|
|
226
|
+
# `__dynamic_locals_shadow_a, __dynamic_locals_shadow_b = (a, b = v)`
|
|
227
|
+
# A masgn evaluates to its right-hand side, so a structurally identical
|
|
228
|
+
# outer masgn assigns the same values to the shadows. Targets that don't
|
|
229
|
+
# need a shadow become a discard variable (duplicates are legal).
|
|
230
|
+
def shadow_multi_write(node, scopes)
|
|
231
|
+
copied, any = copy_masgn_targets(node, scopes)
|
|
232
|
+
return unless any
|
|
233
|
+
@rewriter.insert_before(node, "#{copied} = (")
|
|
234
|
+
@rewriter.insert_after(node, ")")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def copy_masgn_targets(node, scopes)
|
|
238
|
+
any = false
|
|
239
|
+
items = []
|
|
240
|
+
node.lefts.each do |target|
|
|
241
|
+
src, hit = copy_masgn_target(target, scopes)
|
|
242
|
+
any ||= hit
|
|
243
|
+
items << src
|
|
244
|
+
end
|
|
245
|
+
case node.rest
|
|
246
|
+
when Prism::SplatNode
|
|
247
|
+
if (expression = node.rest.expression)
|
|
248
|
+
src, hit = copy_masgn_target(expression, scopes)
|
|
249
|
+
any ||= hit
|
|
250
|
+
items << "*#{src}"
|
|
251
|
+
else
|
|
252
|
+
items << "*"
|
|
253
|
+
end
|
|
254
|
+
when Prism::ImplicitRestNode
|
|
255
|
+
items << ""
|
|
256
|
+
end
|
|
257
|
+
node.rights.each do |target|
|
|
258
|
+
src, hit = copy_masgn_target(target, scopes)
|
|
259
|
+
any ||= hit
|
|
260
|
+
items << src
|
|
261
|
+
end
|
|
262
|
+
[items.join(", "), any]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def copy_masgn_target(node, scopes)
|
|
266
|
+
case node
|
|
267
|
+
when Prism::LocalVariableTargetNode
|
|
268
|
+
if shadow_binding?(node.name, node.depth, scopes)
|
|
269
|
+
[shadow_name(node.name).to_s, true]
|
|
270
|
+
else
|
|
271
|
+
[discard_name.to_s, false]
|
|
272
|
+
end
|
|
273
|
+
when Prism::MultiTargetNode
|
|
274
|
+
src, any = copy_masgn_targets(node, scopes)
|
|
275
|
+
["(#{src})", any]
|
|
276
|
+
else
|
|
277
|
+
# Attribute/index/constant/etc. targets: assign to a discard variable
|
|
278
|
+
# instead so the side effect isn't repeated.
|
|
279
|
+
[discard_name.to_s, false]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# `/(?<word>...)/ =~ str` assigns its named captures as locals. It's an
|
|
284
|
+
# expression (often a condition), so sync the shadows inside a wrapper that
|
|
285
|
+
# preserves the match result:
|
|
286
|
+
# `(tmp = (re =~ str); __dynamic_locals_shadow_word = word; tmp)`
|
|
287
|
+
def shadow_match_write(node, scopes)
|
|
288
|
+
names = node.targets.filter_map do |target|
|
|
289
|
+
next unless Prism::LocalVariableTargetNode === target
|
|
290
|
+
target.name if shadow_binding?(target.name, target.depth, scopes)
|
|
291
|
+
end
|
|
292
|
+
return if names.empty?
|
|
293
|
+
|
|
294
|
+
temp = match_temp_name
|
|
295
|
+
syncs = names.map { |name| "#{shadow_name(name)} = #{name}" }.join("; ")
|
|
296
|
+
@rewriter.insert_before(node, "(#{temp} = (")
|
|
297
|
+
@rewriter.insert_after(node, "); #{syncs}; #{temp})")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def match_temp_name
|
|
301
|
+
@match_temp_name ||= generate_hygienic_name("__dynamic_locals_match")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# `rescue => e` has no expression form to wrap, so sync the shadow as the
|
|
305
|
+
# first statement of the rescue body.
|
|
306
|
+
def shadow_rescue_reference(node, scopes)
|
|
307
|
+
reference = node.reference
|
|
308
|
+
return unless Prism::LocalVariableTargetNode === reference
|
|
309
|
+
return unless shadow_binding?(reference.name, reference.depth, scopes)
|
|
310
|
+
|
|
311
|
+
sync = "#{shadow_name(reference.name)} = #{reference.name}"
|
|
312
|
+
insert_at_body_start(node, node.then_keyword_loc, sync) do
|
|
313
|
+
@rewriter.insert_after(reference, "; #{sync}")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# `for x in list` assigns its index in the enclosing scope; sync the shadow
|
|
318
|
+
# at the top of the loop body.
|
|
319
|
+
def shadow_for_index(node, scopes)
|
|
320
|
+
names = []
|
|
321
|
+
each_local_target(node.index) do |target|
|
|
322
|
+
names << target.name if shadow_binding?(target.name, target.depth, scopes)
|
|
323
|
+
end
|
|
324
|
+
return if names.empty?
|
|
325
|
+
|
|
326
|
+
sync = names.map { |name| "#{shadow_name(name)} = #{name}" }.join("; ")
|
|
327
|
+
insert_at_body_start(node, node.do_keyword_loc, sync) do
|
|
328
|
+
@rewriter.insert_at(node.collection.location.end_offset, " do #{sync}")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# `expr => pattern` raises on mismatch, so on any fall-through the targets
|
|
333
|
+
# were assigned; sync unconditionally, preserving the expression's value:
|
|
334
|
+
# `begin tmp = (expr); tmp => pattern; __dynamic_locals_shadow_x = x; tmp end`
|
|
335
|
+
def shadow_required_pattern(node, scopes)
|
|
336
|
+
names = shadow_pattern_names(node.pattern, scopes)
|
|
337
|
+
return if names.empty?
|
|
338
|
+
|
|
339
|
+
result = generate_hygienic_name("__dynamic_locals_pattern_result")
|
|
340
|
+
@rewriter.insert_before(node, "begin #{result} = (")
|
|
341
|
+
@rewriter.insert_after(node.value, "); #{result}")
|
|
342
|
+
@rewriter.insert_after(node, "; #{shadow_sync_src(names)}; #{result} end")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# `expr in pattern` is a boolean; sync only when it matched:
|
|
346
|
+
# `(begin tmp = (expr in pattern); __dynamic_locals_shadow_x = x if tmp; tmp end)`
|
|
347
|
+
def shadow_predicate_pattern(node, scopes)
|
|
348
|
+
names = shadow_pattern_names(node.pattern, scopes)
|
|
349
|
+
return if names.empty?
|
|
350
|
+
|
|
351
|
+
result = generate_hygienic_name("__dynamic_locals_pattern_result")
|
|
352
|
+
@rewriter.insert_before(node, "(begin #{result} = (")
|
|
353
|
+
@rewriter.insert_after(
|
|
354
|
+
node,
|
|
355
|
+
"); #{shadow_sync_src(names)} if #{result}; #{result} end)",
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# A `case/in` clause's targets are assigned once its pattern matches, even
|
|
360
|
+
# if a guard then rejects it, so sync at the start of the clause body -- or
|
|
361
|
+
# inside the guard itself, which can already read the captures.
|
|
362
|
+
def shadow_in_clause(node, scopes)
|
|
363
|
+
guard = node.pattern if Prism::IfNode === node.pattern || Prism::UnlessNode === node.pattern
|
|
364
|
+
pattern = guard ? guard.statements : node.pattern
|
|
365
|
+
names = shadow_pattern_names(pattern, scopes)
|
|
366
|
+
return yield if names.empty?
|
|
367
|
+
|
|
368
|
+
sync = shadow_sync_src(names)
|
|
369
|
+
if guard
|
|
370
|
+
predicate = guard.predicate
|
|
371
|
+
# Register the closing insertion after rewriting the predicate so a
|
|
372
|
+
# nested pattern wrapper closes before this guard wrapper.
|
|
373
|
+
@rewriter.insert_before(predicate, "(begin #{sync}; ")
|
|
374
|
+
yield
|
|
375
|
+
@rewriter.insert_after(predicate, " end)")
|
|
376
|
+
else
|
|
377
|
+
insert_at_body_start(node, node.then_loc, sync) do
|
|
378
|
+
@rewriter.insert_after(node.pattern, "; #{sync};")
|
|
379
|
+
end
|
|
380
|
+
yield
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def shadow_pattern_names(pattern, scopes)
|
|
385
|
+
names = []
|
|
386
|
+
each_local_target(pattern) do |target|
|
|
387
|
+
names << target.name if shadow_binding?(target.name, target.depth, scopes)
|
|
388
|
+
end
|
|
389
|
+
names.uniq
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def shadow_sync_src(names)
|
|
393
|
+
names.map { |name| "#{shadow_name(name)} = #{name}" }.join("; ")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Insert +src+ as the first statement of +node+'s body. An empty body has
|
|
397
|
+
# no statement to insert before, so it goes after +keyword_loc+ (the
|
|
398
|
+
# node's `then`/`do`) instead; when that is also absent, yields to let the
|
|
399
|
+
# caller pick a spot.
|
|
400
|
+
def insert_at_body_start(node, keyword_loc, src)
|
|
401
|
+
if (first = node.statements&.body&.first)
|
|
402
|
+
@rewriter.insert_before(first, "#{src}; ")
|
|
403
|
+
elsif keyword_loc
|
|
404
|
+
@rewriter.insert_at(keyword_loc.end_offset, " #{src};")
|
|
405
|
+
else
|
|
406
|
+
yield
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def each_local_target(node, &block)
|
|
411
|
+
return unless Prism::Node === node
|
|
412
|
+
|
|
413
|
+
if Prism::LocalVariableTargetNode === node
|
|
414
|
+
yield node
|
|
415
|
+
else
|
|
416
|
+
node.child_nodes.each { |child| each_local_target(child, &block) }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def local_operator_write?(node)
|
|
421
|
+
Prism::LocalVariableOperatorWriteNode === node ||
|
|
422
|
+
Prism::LocalVariableOrWriteNode === node ||
|
|
423
|
+
Prism::LocalVariableAndWriteNode === node
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def initialize_local_table
|
|
427
|
+
return "" if keywords?
|
|
428
|
+
(@local_table.sort.uniq - known_locals).map do |local|
|
|
429
|
+
"#{local} = #{locals_hash}[#{local.inspect}];"
|
|
430
|
+
end.join
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def local_lookup_src(name, fallback)
|
|
434
|
+
if keywords?
|
|
435
|
+
"(#{keyword_unset_flag(name)} ? #{name}() : #{name})"
|
|
436
|
+
elsif @local_table.include?(name)
|
|
437
|
+
"(#{locals_hash}.key?(#{name.inspect}) ? #{name} : #{fallback})"
|
|
438
|
+
else
|
|
439
|
+
"#{locals_hash}.fetch(#{name.inspect}){ #{fallback} }"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def defined_lookup_src(name)
|
|
444
|
+
if keywords?
|
|
445
|
+
"(#{keyword_unset_flag(name)} ? defined?(#{name}()) : 'local-variable'.freeze)"
|
|
446
|
+
else
|
|
447
|
+
"(#{locals_hash}.key?(#{name.inspect}) ? 'local-variable'.freeze : defined?(#{name}()))"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def keyword_unset_flag(name)
|
|
452
|
+
@keyword_unset_flags[name] ||=
|
|
453
|
+
generate_hygienic_name("__dynamic_locals_unset_#{@keyword_unset_flags.length}")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def keywords?
|
|
457
|
+
lookup_strategy == :keywords
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def nested_local_table(node)
|
|
461
|
+
node.locals - parameter_names(node.parameters)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def parameter_names(node, names = [])
|
|
465
|
+
return names unless Prism::Node === node
|
|
466
|
+
|
|
467
|
+
if Prism::NumberedParametersNode === node
|
|
468
|
+
node.maximum.times { |i| names << :"_#{i + 1}" }
|
|
469
|
+
elsif parameter_node?(node) && node.respond_to?(:name) && node.name
|
|
470
|
+
names << node.name
|
|
471
|
+
elsif Prism::BlockLocalVariableNode === node
|
|
472
|
+
names << node.name
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
node.child_nodes.each { |child| parameter_names(child, names) }
|
|
476
|
+
names
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def parameter_node?(node)
|
|
480
|
+
node.class.name.start_with?("Prism::") && node.class.name.end_with?("ParameterNode")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def nested_scope?(node)
|
|
484
|
+
Prism::BlockNode === node || Prism::LambdaNode === node
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Scopes that don't share our local table: recurse only into the parts
|
|
488
|
+
# still evaluated in the enclosing scope.
|
|
489
|
+
def child_nodes_for_method_body(node)
|
|
490
|
+
case node
|
|
491
|
+
when Prism::DefNode
|
|
492
|
+
[node.receiver]
|
|
493
|
+
when Prism::ClassNode
|
|
494
|
+
[node.superclass]
|
|
495
|
+
when Prism::ModuleNode
|
|
496
|
+
[]
|
|
497
|
+
when Prism::SingletonClassNode
|
|
498
|
+
[node.expression]
|
|
499
|
+
else
|
|
500
|
+
node.child_nodes
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def variable_call?(node)
|
|
505
|
+
Prism::CallNode === node && node.variable_call? && !known_locals.include?(node.name)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def defined_variable_call?(node)
|
|
509
|
+
Prism::DefinedNode === node && variable_call?(node.value)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "dynamic_locals/version"
|
|
2
|
+
require "dynamic_locals/base_translator"
|
|
3
|
+
require "dynamic_locals/eval_translator"
|
|
4
|
+
require "dynamic_locals/rewrite_translator"
|
|
5
|
+
|
|
6
|
+
module DynamicLocals
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
DefaultImplementation = RewriteTranslator
|
|
10
|
+
|
|
11
|
+
def self.translate(...)
|
|
12
|
+
DefaultImplementation.new(...).translate
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# This is intended for convenience debugging purposes
|
|
16
|
+
def self.compile(...)
|
|
17
|
+
translator = DefaultImplementation.new(...)
|
|
18
|
+
eval("-> (#{translator.parameters}) { #{translator.translate} }")
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dynamic_locals
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- John Hawthorn
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: bundler
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.0'
|
|
68
|
+
description: Parses ruby to rewrite how locals work
|
|
69
|
+
email:
|
|
70
|
+
- john@hawthorn.email
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- ".github/workflows/test.yml"
|
|
76
|
+
- ".gitignore"
|
|
77
|
+
- CODE_OF_CONDUCT.md
|
|
78
|
+
- Gemfile
|
|
79
|
+
- Gemfile.lock
|
|
80
|
+
- LICENSE.txt
|
|
81
|
+
- README.md
|
|
82
|
+
- Rakefile
|
|
83
|
+
- bin/console
|
|
84
|
+
- bin/setup
|
|
85
|
+
- dynamic_locals.gemspec
|
|
86
|
+
- lib/dynamic_locals.rb
|
|
87
|
+
- lib/dynamic_locals/ast_rewriter.rb
|
|
88
|
+
- lib/dynamic_locals/base_translator.rb
|
|
89
|
+
- lib/dynamic_locals/eval_translator.rb
|
|
90
|
+
- lib/dynamic_locals/rewrite_translator.rb
|
|
91
|
+
- lib/dynamic_locals/version.rb
|
|
92
|
+
homepage: https://github.com/jhawthorn/dynamic_locals
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata: {}
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- lib
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.2'
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 4.0.10
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: Makes it possible to define locals dynamically
|
|
113
|
+
test_files: []
|