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 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
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
@@ -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
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in dynamic_locals.gemspec
6
+ gemspec
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
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,3 @@
1
+ module DynamicLocals
2
+ VERSION = "0.1.0"
3
+ 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: []