├── .gitignore ├── .travis.yml ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── tco_method.rb └── tco_method │ ├── ambiguous_source_error.rb │ ├── block_extractor.rb │ ├── block_with_tco.rb │ ├── method_info.rb │ ├── method_reevaluator.rb │ ├── mixin.rb │ └── version.rb ├── tco_method.gemspec └── test ├── test_helper.rb ├── test_helpers ├── assertions.rb └── fibbers.rb └── unit ├── block_extractor_test.rb ├── block_with_tco_test.rb ├── method_info_test.rb ├── method_reevaluator_test.rb ├── mixin_test.rb └── tco_method_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.0.0 5 | - 2.1.0 6 | - 2.2.0 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :doc do 6 | gem "yard" 7 | end 8 | 9 | group :test do 10 | gem "coveralls", :require => false 11 | gem "guard" 12 | gem "guard-minitest" 13 | gem "minitest", ">= 3.0" 14 | gem "mocha" 15 | gem "simplecov", :require => false 16 | end 17 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard(:minitest, :all_after_pass => false, :all_on_start => false) do 2 | watch(%r{^lib/tco_method/(.+)\.rb$}) { |m| "test/unit/#{m[1]}_test.rb" } 3 | watch(%r{^test/.+_test\.rb$}) 4 | watch(%r{^(?:test/test_helper(.*)|lib/tco_method)\.rb$}) { "test" } 5 | end 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Danny Guinther 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TCOMethod 2 | [![Gem Version](https://badge.fury.io/rb/tco_method.svg)](http://badge.fury.io/rb/tco_method) 3 | [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/gems/tco_method) 4 | [![Build Status](https://travis-ci.org/tdg5/tco_method.svg?branch=master)](https://travis-ci.org/tdg5/tco_method) 5 | [![Coverage Status](https://coveralls.io/repos/tdg5/tco_method/badge.svg)](https://coveralls.io/r/tdg5/tco_method) 6 | [![Code Climate](https://codeclimate.com/github/tdg5/tco_method/badges/gpa.svg)](https://codeclimate.com/github/tdg5/tco_method) 7 | [![Dependency Status](https://gemnasium.com/tdg5/tco_method.svg)](https://gemnasium.com/tdg5/tco_method) 8 | 9 | The `tco_method` gem provides a number of different APIs to facilitate 10 | evaluating code with tail call optimization enabled in MRI Ruby. 11 | 12 | The `TCOMethod.with_tco` method is perhaps the simplest means of evaluating code 13 | with tail call optimization enabled. `TCOMethod.with_tco` takes a block and 14 | compiles all code **in that block** with tail call optimization enabled. 15 | 16 | The `TCOMethod::Mixin` module extends Classes and Modules with helper methods 17 | (kind of like method annotations) to facilitate compiling some types of methods 18 | with tail call optimization enabled. 19 | 20 | The `TCOMethod.tco_eval` method provides a direct means to evaluate code strings 21 | with tail call optimization enabled. This API is the most cumbersome, but it can 22 | be useful for loading full files with tail call optimization enabled (see 23 | examples below). It is also the foundation of all of the other `TCOMethod` APIs. 24 | 25 | Be warned, there are a few gotchas. For example, even when using one of the APIs 26 | provided by the `tco_method` gem, `require`, `load`, and `Kernel#eval` still 27 | won't evaluate code with tail call optimization enabled without changing the 28 | `RubyVM` settings globally. More on the various limitations of the `tco_method` 29 | gem are outlined in the docs in the 30 | [Gotchas](http://www.rubydoc.info/gems/tco_method/file/README.md#Gotchas) 31 | section. 32 | 33 | ## Installation 34 | 35 | Add this line to your application's Gemfile: 36 | 37 | ```ruby 38 | gem "tco_method" 39 | ``` 40 | 41 | And then execute: 42 | 43 | ```bash 44 | $ bundle 45 | ``` 46 | 47 | Or install it yourself as: 48 | 49 | ```bash 50 | $ gem install tco_method 51 | ``` 52 | 53 | ## Usage 54 | 55 | Require the [`TCOMethod`](http://www.rubydoc.info/gems/tco_method/TCOMethod) 56 | library: 57 | 58 | ```ruby 59 | require "tco_method" 60 | ``` 61 | 62 | ### `TCOMethod.with_tco` 63 | 64 | The fastest road to tail call optimized glory is the 65 | [`TCOMethod.with_tco`](http://www.rubydoc.info/gems/tco_method/TCOMethod#with_tco-class_method) 66 | method. Using 67 | [`TCOMethod.with_tco`](http://www.rubydoc.info/gems/tco_method/TCOMethod#with_tco-class_method) 68 | you can evaluate a block of code with tail call optimization enabled liked so: 69 | 70 | ```ruby 71 | TCOMethod.with_tco do 72 | class MyClass 73 | def factorial(n, acc = 1) 74 | n <= 1 ? acc : factorial(n - 1, n * acc) 75 | end 76 | end 77 | end 78 | 79 | puts MyClass.new.factorial(10_000).to_s.length 80 | # => 35660 81 | ``` 82 | 83 | It's worth noting that in the example above the actual optimized tail call 84 | occurs outside of the `TCOMethod.with_tco` block. `TCOMethod.with_tco` is used 85 | to compile code in such a way that tail call optimization is enabled. Once 86 | compiled, the tail call optimized code can be invoked from anywhere in the 87 | program. 88 | 89 | ### `TCOMethod::Mixin` 90 | 91 | Alternatively, you can extend a Class or Module with the 92 | [`TCOMethod::Mixin`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin) 93 | and let the TCO fun begin using helpers that act like method annotations. 94 | 95 | To redefine an instance method with tail call optimization enabled, use 96 | [`tco_method`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin:tco_method): 97 | 98 | ```ruby 99 | class MyClass 100 | extend TCOMethod::Mixin 101 | 102 | def factorial(n, acc = 1) 103 | n <= 1 ? acc : factorial(n - 1, n * acc) 104 | end 105 | tco_method :factorial 106 | end 107 | 108 | puts MyClass.new.factorial(10_000).to_s.length 109 | # => 35660 110 | ``` 111 | 112 | Or alternatively, use [`tco_module_method`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin:tco_module_method) 113 | or [`tco_class_method`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin:tco_module_method) 114 | for a Module or Class method: 115 | 116 | ```ruby 117 | module MyFibonacci 118 | extend TCOMethod::Mixin 119 | 120 | def self.fibonacci(index, back_one = 1, back_two = 0) 121 | index < 1 ? back_two : fibonacci(index - 1, back_one + back_two, back_one) 122 | end 123 | tco_module_method :fibonacci 124 | end 125 | 126 | puts MyFibonacci.fibonacci(10_000).to_s.length 127 | # => 2090 128 | ``` 129 | 130 | ### `TCOMethod.tco_eval` 131 | 132 | Finally, depending on your needs (and your love for stringified code blocks), 133 | you can also use 134 | [`TCOMethod.tco_eval`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin:tco_eval) 135 | directly. 136 | [`TCOMethod.tco_eval`](http://www.rubydoc.info/gems/tco_method/TCOMethod/Mixin:tco_eval) 137 | can be useful in situations where the `method_source` gem is unable to determine 138 | the source of a particular block or for loading entire files with tail call 139 | optimization enabled. 140 | 141 | ```ruby 142 | TCOMethod.tco_eval(<<-CODE) 143 | class MyClass 144 | def factorial(n, acc = 1) 145 | n <= 1 ? acc : factorial(n - 1, n * acc) 146 | end 147 | end 148 | CODE 149 | 150 | MyClass.new.factorial(10_000).to_s.length 151 | # => 35660 152 | ``` 153 | 154 | You can kind of get around the need for stringified code blocks by reading the 155 | code you want to compile with tail call optimization dynamically at runtime, but 156 | this approach also has downsides in that it goes around the standard Ruby 157 | `require`/`load` process. For example, consider the `Fibonacci` example broken across 158 | two scripts, one script serving as a loader and the other script acting as a 159 | more standard library: 160 | 161 | ```ruby 162 | # loader.rb 163 | 164 | require "tco_method" 165 | fibonacci_lib = File.read(File.expand_path("../fibonacci.rb", __FILE__)) 166 | TCOMethod.tco_eval(fibonacci_lib) 167 | 168 | puts MyFibonacci.fibonacci(10_000).to_s.length 169 | # => 2090 170 | 171 | 172 | # fibonacci.rb 173 | 174 | module MyFibonacci 175 | def self.fibonacci(index, back_one = 1, back_two = 0) 176 | index < 1 ? back_two : fibonacci(index - 1, back_one + back_two, back_one) 177 | end 178 | end 179 | ``` 180 | 181 | If you really want to get crazy, you can include the `TCOMethod::Mixin` module 182 | in the `Module` class to add these behaviors to all Modules and Classes. To quote 183 | VIM plugin author extraordinaire, Tim Pope, "I don't like to get crazy." Consider 184 | yourself warned. 185 | 186 | ```ruby 187 | # Don't say I didn't warn you... 188 | 189 | Module.include(TCOMethod::Mixin) 190 | 191 | module MyFibonacci 192 | def self.fibonacci(index, back_one = 1, back_two = 0) 193 | index < 1 ? back_two : fibonacci(index - 1, back_one + back_two, back_one) 194 | end 195 | tco_module_method :fibonacci 196 | end 197 | 198 | puts MyFibonacci.fibonacci(10_000).to_s.length 199 | # => 2090 200 | ``` 201 | 202 | ## Gotchas 203 | **Quirks with the `method_source` gem**: 204 | - Annotations and `TCOMethod.with_tco` use the 205 | [`method_source` gem](https://github.com/banister/method_source) to retrieve 206 | the method source to evaluate. As a result, class annotations and 207 | `TCOMethod.with_tco` can act strangely when used in more dynamic contexts like 208 | `irb` or `pry`. Additionally, if the code to be evaluated is formatted in 209 | unconventional ways, it can make it difficult for `method_source` and/or 210 | `tco_method` to determine the unambiguous source of the method or code block. 211 | Most of these ambiguities can be solved by following standard Ruby formating 212 | conventions. 213 | 214 | **Quirks with `TCOMethod.with_tco`**: 215 | - Because the source code of the specified block is determined using the 216 | `method_source` gem, the given block will be evaluated with a binding 217 | different from the one it was defined in. Attempts have been made to get around 218 | this, but so far, no dice. Seems like a job for a C extension. 219 | - `require`, `load`, and `eval` will still load code **without tail call 220 | optimization enabled** even when called from within a block given to 221 | `TCOMethod.with_tco`. Each of these methods compiles code using the primary 222 | `RubyVM::InstructionSequence` object which honors the configuration specified 223 | by `RubyVM::InstructionSequence.compile_option`. 224 | 225 | **Quirks with Module and Class annotations**: 226 | - Annotations only work with methods defined using the `def` keyword. 227 | - Annotations reopen the Module or Class by name to redefine the given method. 228 | This process will fail for dynamic Modules and Classes that aren't assigned to 229 | constants and, ergo, don't have names that can be used for lookup. 230 | 231 | There are almost certainly more gotchas, so check back for more in the future if 232 | you run into weirdness while using this gem. Issues are welcome. 233 | 234 | ## Contributing 235 | 236 | 1. Fork it ( https://github.com/tdg5/tco_method/fork ) 237 | 2. Create your feature branch (`git checkout -b my-new-feature`) 238 | 3. Commit your changes (`git commit -am 'Add some feature'`) 239 | 4. Push to the branch (`git push origin my-new-feature`) 240 | 5. Create a new Pull Request 241 | 242 | ## Reference 243 | 244 | - Class annotations are based on [Nithin Bekal's blog post *Tail Call 245 | Optimization in Ruby*](http://nithinbekal.com/posts/ruby-tco/) which follows 246 | his efforts to create a method decorator to recompile methods with tail call 247 | optimization. 248 | - For more background on how tail call optimization is implemented in MRI Ruby, 249 | see [Danny Guinther's *Tail Call Optimization in Ruby: Deep Dive*](http://blog.tdg5.com/tail-call-optimization-ruby-deep-dive/). 250 | - For those on flavors of Ruby other than MRI, check out [Magnus Holm's *Tailin' 251 | Ruby*](http://timelessrepo.com/tailin-ruby) for some insight into how else 252 | tail call optimization (or at least tail call optimization like behavior) can 253 | be achieved in Ruby. 254 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.libs << "test" 6 | t.pattern = "test/**/*_test.rb" 7 | end 8 | 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /lib/tco_method.rb: -------------------------------------------------------------------------------- 1 | require "method_source" 2 | require "tco_method/version" 3 | require "tco_method/mixin" 4 | require "tco_method/block_with_tco" 5 | 6 | # The namespace for the TCOMethod gem. Home to private API methods employed by 7 | # the {TCOMethod::Mixin} module to provide tail call optimized behavior to 8 | # extending Classes and Modules. 9 | module TCOMethod 10 | # Options that must be provided to RubyVM::InstructionSequence in order to 11 | # compile code with tail call optimization enabled. Beyond simply enabling the 12 | # `tailcall optimization` option, the `trace_instruction` option must also be 13 | # disabled because the RubyVM doesn't currently support `set_trace_func` for 14 | # code that is compiled with tail call optimization. 15 | ISEQ_OPTIONS = { 16 | tailcall_optimization: true, 17 | trace_instruction: false, 18 | }.freeze 19 | 20 | # Provides a mechanism for evaluating Strings of code with tail call 21 | # optimization enabled. 22 | # 23 | # @param [String] code The code to evaluate with tail call optimization 24 | # enabled. 25 | # @return [Object] Returns the value of the final expression of the provided 26 | # code String. 27 | # @raise [ArgumentError] if the provided code argument is not a String. 28 | def self.tco_eval(code, file = nil, path = nil, line = nil) 29 | raise ArgumentError, "Invalid code string!" unless code.is_a?(String) 30 | RubyVM::InstructionSequence.new(code, file, path, line, ISEQ_OPTIONS).eval 31 | end 32 | 33 | # Allows for executing a block of code with tail call optimization enabled. 34 | # 35 | # All code that is evaluated in the block will be evaluated with tail call 36 | # optimization enabled, however here be dragons, so be warned of a few things: 37 | # 38 | # 1. Though it may not be obvious, any call to `require`, `load`, or similar 39 | # methods from within the block will be evaluated by another part of the VM 40 | # and will not be tail call optimized. This applies for `tco_eval` as well. 41 | # 42 | # 2. The block will be evaluated with a different binding than the binding it 43 | # was defined in. That means that references to variables or other binding 44 | # context will result in method errors. For example: 45 | # 46 | # some_variable = "Hello, World!" 47 | # womp_womp = TCOMethod.with_tco { some_variable } 48 | # # => NameError: Undefined local variable or method 'some_variable' 49 | # 50 | # 3. Though this approach is some what nicer than working with strings of 51 | # code, it comes with the tradeoff that it relies on the the `method_source` 52 | # gem to do the work of finding the source of the block. There are situations 53 | # where `method_source` can't accurately determine the source location of a 54 | # block. That said, if you don't format your code like a maniac, you should be 55 | # fine. 56 | # 57 | # @param [Proc] block The proc to evaluate with tail call optimization 58 | # enabled. 59 | # @return [Object] Returns whatever the result of evaluating the given block. 60 | def self.with_tco(&block) 61 | raise ArgumentError, "Block required" unless block_given? 62 | BlockWithTCO.new(&block).result 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/tco_method/ambiguous_source_error.rb: -------------------------------------------------------------------------------- 1 | module TCOMethod 2 | # Exception raised when it's not possible to reliably determine the source 3 | # code of a block. 4 | class AmbiguousSourceError < StandardError 5 | # Default message template. 6 | MESSAGE = "Could not determine source of block".freeze 7 | 8 | # Returns the exception that this exception was created to wrap if any such 9 | # exception exists. Used only when this exception is created to wrap 10 | # another. 11 | attr_accessor :original_exception 12 | 13 | # Create an exception from a problematic block. 14 | # 15 | # @param [Proc] block The block for which the source is ambiguous. 16 | # @return [AmbiguousBlockError] A new exception instance wrapping the given 17 | # exception. 18 | def self.from_proc(block) 19 | new(MESSAGE + " #{block.inspect}") 20 | end 21 | 22 | # Wrap another exception with an AmbiguousBlockError. Useful for wrapping 23 | # errors raised by MethodSource. 24 | # 25 | # @param [Exception] exception The exception instance that should be 26 | # wrapped. 27 | # @return [AmbiguousBlockError] A new exception instance wrapping the given 28 | # exception. 29 | def self.wrap(exception) 30 | error = new(exception.message) 31 | error.original_exception = exception 32 | error 33 | end 34 | 35 | # Creates a new instance of the exception. 36 | # 37 | # @param [String] message The message to use with the exception. 38 | def initialize(message = MESSAGE) 39 | super 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/tco_method/block_extractor.rb: -------------------------------------------------------------------------------- 1 | require "tco_method/ambiguous_source_error" 2 | require "method_source" 3 | require "ripper" 4 | 5 | module TCOMethod 6 | # Object encapsulating the logic to extract the source code of a given block. 7 | class BlockExtractor 8 | DO_STR = "do".freeze 9 | END_STR = "end".freeze 10 | 11 | attr_reader :source 12 | 13 | def initialize(block) 14 | source = block.source 15 | type = block.lambda? ? :lambda : :proc 16 | start_offset, end_offset = determine_offsets(block, source) 17 | @source = "#{type} #{source[start_offset..end_offset]}" 18 | rescue MethodSource::SourceNotFoundError => ex 19 | raise AmbiguousSourceError.wrap(ex) 20 | end 21 | 22 | private 23 | 24 | # Encapsulates the logic required to determine the offset of the end of the 25 | # block. The end of the block is characterized by a matching curly brace 26 | # (`}`) or the `end` keyword. 27 | def determine_end_offset(block, tokens, source, expected_matcher) 28 | lines = source.lines 29 | last_line_number = lines.length 30 | end_offset = nil 31 | tokens.reverse_each do |token| 32 | # Break once we're through with the last line. 33 | break if token[0][0] != last_line_number 34 | 35 | # Look for expected match to block opener 36 | next if token[1] != expected_matcher 37 | next if token[1] == :on_kw && token[2] != END_STR 38 | 39 | # Raise if we've already found something that looks like a block end. 40 | raise AmbiguousSourceError.from_proc(block) if end_offset 41 | # Ending offset is the position of the ending token, plus the length of 42 | # that token. 43 | end_offset = token[0][1] + token[2].length 44 | end 45 | raise AmbiguousSourceError.from_proc(block) unless end_offset 46 | determine_end_offset_relative_to_source(end_offset, lines.last.length) 47 | end 48 | 49 | # We subract the length of the last line from end offset to determine the 50 | # negative offset into the source string. However we must subtract 1 to 51 | # correct for the negative offset referring to the character after the 52 | # desired terminal character. 53 | def determine_end_offset_relative_to_source(end_offset, last_line_length) 54 | end_offset - last_line_length - 1 55 | end 56 | 57 | # Tokenizes the source of the block as determined by the `method_source` gem 58 | # and determines the beginning and end of the block. 59 | # 60 | # In both cases the entire line is checked to ensure there's no unexpected 61 | # ambiguity as to the start or end of the block. See the test file for this 62 | # class for examples of ambiguous situations. 63 | # 64 | # @param [Proc] block The proc for which the starting offset of its source 65 | # code should be determined. 66 | # @param [String] source The source code of the provided block. 67 | # @raise [AmbiguousSourceError] Raised when the source of the block cannot 68 | # be determined unambiguously. 69 | # @return [Array] The start and end offsets of the block's source 70 | # code as 2-element Array. 71 | def determine_offsets(block, source) 72 | tokens = Ripper.lex(source) 73 | start_offset, start_token = determine_start_offset(block, tokens) 74 | expected_match = start_token == :on_kw ? :on_kw : :on_rbrace 75 | end_offset = determine_end_offset(block, tokens, source, expected_match) 76 | [start_offset, end_offset] 77 | end 78 | 79 | # The logic required to determine the starting offset of the block. The 80 | # start of the block is characterized by the opening left curly brace (`{`) 81 | # of the block or the `do` keyword. Everything prior to the start of the 82 | # block is ignored because we can determine whether the block should be a 83 | # lambda or a proc by asking the block directly, and we may not always have 84 | # such a keyword available to us, e.g. a method that takes a block like 85 | # TCOMethod.with_tco. 86 | def determine_start_offset(block, tokens) 87 | start_offset = start_token = nil 88 | # The start of the block should occur somewhere on line 1. 89 | # Check the whole line to ensure there aren't multiple blocks on the line. 90 | tokens.each do |token| 91 | # Break after line 1. 92 | break if token[0][0] != 1 93 | 94 | # Look for a left brace (`{`) or `do` keyword. 95 | if token[1] == :on_lbrace || (token[1] == :on_kw && token[2] == DO_STR) 96 | # Raise if we've already found something that looks like a block 97 | # start. 98 | raise AmbiguousSourceError.from_proc(block) if start_offset 99 | start_token = token[1] 100 | start_offset = token[0][1] 101 | end 102 | end 103 | raise AmbiguousSourceError.from_proc(block) unless start_offset 104 | [start_offset, start_token] 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/tco_method/block_with_tco.rb: -------------------------------------------------------------------------------- 1 | require "tco_method/block_extractor" 2 | 3 | module TCOMethod 4 | class BlockWithTCO 5 | attr_reader :result 6 | 7 | def initialize(&block) 8 | raise ArgumentError, "Block required" unless block 9 | @result = eval(block) 10 | end 11 | 12 | private 13 | 14 | def extract_source(block) 15 | BlockExtractor.new(block).source 16 | end 17 | 18 | def eval(block) 19 | TCOMethod.tco_eval(extract_source(block)).call 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/tco_method/method_info.rb: -------------------------------------------------------------------------------- 1 | module TCOMethod 2 | # Class encapsulating the behaviors required to extract information about a 3 | # method from the 14-element Array of data representing the instruction 4 | # sequence of that method. 5 | class MethodInfo 6 | # A collection of those classes that will be recognized as methods and can 7 | # be used effectively with this class. 8 | VALID_METHOD_CLASSES = [ 9 | Method, 10 | UnboundMethod, 11 | ].freeze 12 | 13 | # Creates a new MethodInfo instance. 14 | # 15 | # @param [Method] method_obj The Method or UnboundMethod object representing 16 | # the method for which more information should be retrieved. 17 | # @raise [TypeError] Raised if the provided method object is not a Method or 18 | # Unbound method. 19 | # @see VALID_METHOD_CLASSES 20 | def initialize(method_obj) 21 | unless VALID_METHOD_CLASSES.any? { |klass| method_obj.is_a?(klass) } 22 | msg = "Invalid argument! Method or UnboundMethod expected, received #{method_obj.class.name}" 23 | raise TypeError, msg 24 | end 25 | @info = RubyVM::InstructionSequence.of(method_obj).to_a 26 | end 27 | 28 | # Returns the type of the method object as reported by the Array of data 29 | # describing the instruction sequence representing the method. 30 | # 31 | # @return [Symbol] A Symbol identifying the type of the instruction 32 | # sequence. Typical values will be :method or :block, but all of the 33 | # following are valid return values: :top, :method, :block, :class, 34 | # :rescue, :ensure, :eval, :main, and :defined_guard. 35 | def type 36 | @info[9] 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/tco_method/method_reevaluator.rb: -------------------------------------------------------------------------------- 1 | require "tco_method/method_info" 2 | 3 | module TCOMethod 4 | class MethodReevaluator 5 | # Reevaluates a method with tail call optimization enabled. 6 | # 7 | # @note This class is not part of the public API and should not be used 8 | # directly. See {TCOMethod::Mixin} for a module that provides publicly 9 | # supported access to the behaviors provided by this method. 10 | # @param [Class, Module] receiver The Class or Module for which the specified 11 | # module, class, or instance method should be reevaluated with tail call 12 | # optimization enabled. 13 | # @param [String, Symbol] method_name The name of the method that should be 14 | # reevaluated with tail call optimization enabled. 15 | # @param [Symbol] method_owner A symbol representing whether the specified 16 | # method is expected to be owned by a class, module, or instance. 17 | # @raise [ArgumentError] Raised if receiver, method_name, or method_owner 18 | # argument is omitted. 19 | # @raise [TypeError] Raised if the specified method is not a method that can 20 | # be reevaluated with tail call optimization enabled. 21 | def initialize(receiver, method_name, method_owner) 22 | raise ArgumentError, "Receiver required!" unless receiver 23 | raise ArgumentError, "Method name required!" unless method_name 24 | raise ArgumentError, "Method owner required!" unless method_owner 25 | if method_owner == :instance 26 | existing_method = receiver.instance_method(method_name) 27 | elsif method_owner == :class || method_owner== :module 28 | existing_method = receiver.method(method_name) 29 | end 30 | method_info = MethodInfo.new(existing_method) 31 | if method_info.type != :method 32 | raise TypeError, "Invalid method type: #{method_info.type}" 33 | end 34 | receiver_class = receiver.is_a?(Class) ? :class : :module 35 | code = <<-CODE 36 | #{receiver_class} #{receiver.name} 37 | #{existing_method.source} 38 | end 39 | CODE 40 | 41 | file, line = existing_method.source_location 42 | TCOMethod.tco_eval(code, file, File.dirname(file), line - 1) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tco_method/mixin.rb: -------------------------------------------------------------------------------- 1 | require "tco_method/method_reevaluator" 2 | 3 | module TCOMethod 4 | # Mixin providing tail call optimization eval and class annotations. When 5 | # extended by a Class or Module adds methods for evaluating code with tail 6 | # call optimization enabled and re-evaluating existing methods with tail call 7 | # optimization enabled. 8 | module Mixin 9 | # Module or Class annotation causing the class or module method identified 10 | # by the given method name to be reevaluated with tail call optimization 11 | # enabled. Only works for methods defined using the `def` keyword. 12 | # 13 | # @param [String, Symbol] method_name The name of the class or module method 14 | # that should be reeevaluated with tail call optimization enabled. 15 | # @return [Symbol] The symbolized method name. 16 | # @see TCOMethod::MethodReevaluator 17 | def tco_module_method(method_name) 18 | MethodReevaluator.new(self, method_name, :module) 19 | end 20 | alias_method :tco_class_method, :tco_module_method 21 | 22 | # Evaluate the given code String with tail call optimization enabled. 23 | # 24 | # @param [String] code The code to evaluate with tail call optimization 25 | # enabled. 26 | # @return [Object] Returns the value of the final expression of the provided 27 | # code String. 28 | # @raise [ArgumentError] if the provided code argument is not a String. 29 | # @see TCOMethod.tco_eval 30 | def tco_eval(code) 31 | TCOMethod.tco_eval(code) 32 | end 33 | 34 | # Class annotation causing the instance method identified by the given 35 | # method name to be reevaluated with tail call optimization enabled. Only 36 | # works for methods defined using the `def` keyword. 37 | # 38 | # @param [String, Symbol] method_name The name of the instance method that 39 | # should be reeevaluated with tail call optimization enabled. 40 | # @return [Symbol] The symbolized method name. 41 | # @see TCOMethod::MethodReevaluator 42 | def tco_method(method_name) 43 | MethodReevaluator.new(self, method_name, :instance) 44 | end 45 | 46 | # Allows for executing a block of code with tail call optimization enabled. 47 | # 48 | # All code that is evaluated in the block will be evaluated with tail call 49 | # optimization enabled, however here be dragons, so make sure to read the 50 | # docs for {TCOMethod.with_tco} before getting too crazy. 51 | # 52 | # @param [Proc] block The proc to evaluate with tail call optimization 53 | # enabled. 54 | # @return [Object] Returns whatever the result of evaluating the given block. 55 | # @see TCOMethod.with_tco 56 | def with_tco(&block) 57 | TCOMethod.with_tco(&block) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/tco_method/version.rb: -------------------------------------------------------------------------------- 1 | module TCOMethod 2 | # The version of the TCOMethod gem. 3 | VERSION = "0.2.0" 4 | end 5 | -------------------------------------------------------------------------------- /tco_method.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "tco_method/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "tco_method" 8 | spec.version = TCOMethod::VERSION 9 | spec.authors = ["Danny Guinther"] 10 | spec.email = ["dannyguinther@gmail.com"] 11 | spec.summary = %q{Simplifies compiling code with tail call optimization in MRI Ruby.} 12 | spec.description = %q{Simplifies compiling code with tail call optimization in MRI Ruby.} 13 | spec.homepage = "https://github.com/tdg5/tco_method" 14 | spec.license = "MIT" 15 | 16 | spec.required_ruby_version = "~> 2" 17 | 18 | spec.files = `git ls-files -z`.split("\x0") 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.test_files = spec.files.grep(%r{^test/}) 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_dependency "method_source", "~> 0" 24 | 25 | spec.add_development_dependency "bundler", "~> 1.6" 26 | spec.add_development_dependency "rake", "~> 0" 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV["CI"] 2 | require "simplecov" 3 | require "coveralls" 4 | SimpleCov.formatter = Coveralls::SimpleCov::Formatter 5 | SimpleCov.root(File.expand_path("../..", __FILE__)) 6 | SimpleCov.start do 7 | add_filter "test" 8 | end 9 | end 10 | 11 | require "minitest/autorun" 12 | require "mocha/setup" 13 | require "tco_method" 14 | require "test_helpers/assertions" 15 | require "test_helpers/fibbers" 16 | 17 | # Use alternate shoulda-style DSL for tests 18 | class TCOMethod::TestCase < Minitest::Spec 19 | class << self 20 | alias :setup :before 21 | alias :teardown :after 22 | alias :context :describe 23 | alias :should :it 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helpers/assertions.rb: -------------------------------------------------------------------------------- 1 | module TCOMethod 2 | module TestHelpers 3 | module Assertions 4 | def assert_tail_call_optimized(method, *args) 5 | is_tco = tail_call_optimized(method, *args) 6 | msg = "Expected method #{method.name} to be tail call optimized" 7 | assert is_tco, msg 8 | end 9 | 10 | def refute_tail_call_optimized(method, *args) 11 | is_tco = tail_call_optimized(method, *args) 12 | msg = "Expected method #{method.name} not to be tail call optimized" 13 | refute is_tco, msg 14 | end 15 | 16 | def tail_call_optimized?(method, *args) 17 | initial_length = nil 18 | method.call(*args) do 19 | if initial_length.nil? 20 | initial_length = caller.length 21 | else 22 | break initial_length == caller.length 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/test_helpers/fibbers.rb: -------------------------------------------------------------------------------- 1 | # A couple of test classes used for testing tail call optimization in various 2 | # contexts. 3 | module TCOMethod 4 | test_subject_builder = proc do 5 | extend TCOMethod::Mixin 6 | 7 | class << self 8 | define_method(:singleton_block_method) { } 9 | end 10 | 11 | # Equivalent to the below, but provides a target for verifying that 12 | # tco_module_method works on Classes and tco_class_method works on Modules. 13 | def self.module_fib_yielder(index, back_one = 1, back_two = 0, &block) 14 | yield back_two if index > 0 15 | index < 1 ? back_two : module_fib_yielder(index - 1, back_one + back_two, back_one, &block) 16 | end 17 | 18 | # Equivalent to the above, but provides a target for verifying that 19 | # tco_module_method works on Classes and tco_class_method works on Modules. 20 | def self.class_fib_yielder(index, back_one = 1, back_two = 0, &block) 21 | yield back_two if index > 0 22 | index < 1 ? back_two : class_fib_yielder(index - 1, back_one + back_two, back_one, &block) 23 | end 24 | 25 | define_method(:instance_block_method) { } 26 | 27 | # Equivalent to the above, but provides a target for verifying that 28 | # instance methods work for both Classes and Modules 29 | def instance_fib_yielder(index, back_one = 1, back_two = 0, &block) 30 | yield back_two if index > 0 31 | index < 1 ? back_two : instance_fib_yielder(index - 1, back_one + back_two, back_one, &block) 32 | end 33 | end 34 | 35 | TestModule = Module.new(&test_subject_builder) 36 | TestClass = Class.new(&test_subject_builder) 37 | end 38 | -------------------------------------------------------------------------------- /test/unit/block_extractor_test.rb: -------------------------------------------------------------------------------- 1 | require "pry" 2 | require "test_helper" 3 | 4 | module TCOMethod 5 | class BlockExtractorTest < TestCase 6 | Subject = BlockExtractor 7 | subject { Subject } 8 | 9 | blocks = [ 10 | :lambda_brace_inline, 11 | :lambda_brace_multi, 12 | :lambda_do_inline, 13 | :lambda_do_multi, 14 | :method_brace_inline, 15 | :method_brace_multi, 16 | :method_do_inline, 17 | :method_do_multi, 18 | :proc_brace_inline, 19 | :proc_brace_multi, 20 | :proc_do_inline, 21 | :proc_do_multi, 22 | ] 23 | 24 | unsourceable_blocks = [ 25 | :ambiguous_procs, 26 | :a_hash_with_an_ambiguous_proc, 27 | :an_ambiguous_proc_with_hash, 28 | :an_unsourceable_proc, 29 | ] 30 | 31 | context "block extraction" do 32 | blocks.each do |meth| 33 | should "extract block in #{meth} form" do 34 | block = send(meth) 35 | block_source = subject.new(block).source 36 | reblock = eval(block_source) 37 | reblock_result = reblock.call 38 | 39 | # Ensure both blocks return the same result 40 | assert_equal block.call, reblock_result 41 | 42 | # Ensure a lambda is used where appropriate 43 | assert_equal reblock_result == :lambda, reblock.lambda? 44 | end 45 | end 46 | 47 | unsourceable_blocks.each do |meth| 48 | should "raise when given a #{meth}" do 49 | block = send(meth) 50 | assert_raises(AmbiguousSourceError) { subject.new(block).source } 51 | end 52 | end 53 | 54 | should "correctly strip trailing code at the end of the block" do 55 | # The ').source' below should be plenty to test this concern. 56 | block_source = subject.new(lambda do 57 | "Hold on to your butts" 58 | end).source 59 | begin 60 | eval(block_source) 61 | rescue SyntaxError 62 | assert false, "Syntax error in block source" 63 | end 64 | end 65 | end 66 | 67 | # This ambiguity could be handled, but encourages poorly formatted code and 68 | # doesn't seem worth the effort presently. 69 | def a_hash_with_an_ambiguous_proc 70 | {}; proc { :proc } 71 | end 72 | 73 | def ambiguous_procs 74 | proc { :please }; proc { :dont_do_this } 75 | end 76 | 77 | def an_unsourceable_proc 78 | { 79 | :block => proc { :method_source_error } 80 | }[:block] 81 | end 82 | 83 | # This ambiguity could be handled, but encourages poorly formatted code and 84 | # doesn't seem worth the effort presently. 85 | def an_ambiguous_proc_with_hash 86 | block = proc { :proc }; {} 87 | block 88 | end 89 | 90 | def lambda_brace_inline 91 | lambda { :lambda } 92 | end 93 | 94 | def lambda_brace_multi 95 | lambda { 96 | :lambda 97 | } 98 | end 99 | 100 | def lambda_do_inline 101 | lambda do; :lambda; end 102 | end 103 | 104 | def lambda_do_multi 105 | lambda do 106 | :lambda 107 | end 108 | end 109 | 110 | def method_brace_inline 111 | Proc.new { :proc } 112 | end 113 | 114 | def method_brace_multi 115 | Proc.new { 116 | :proc 117 | } 118 | end 119 | 120 | def method_do_inline 121 | Proc.new do; :proc; end 122 | end 123 | 124 | def method_do_multi 125 | Proc.new do 126 | :proc 127 | end 128 | end 129 | 130 | def proc_do_inline 131 | proc do; :proc; end 132 | end 133 | 134 | def proc_do_multi 135 | proc do 136 | :proc 137 | end 138 | end 139 | 140 | def proc_brace_inline 141 | proc { :proc } 142 | end 143 | 144 | def proc_brace_multi 145 | proc { 146 | :proc 147 | } 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/unit/block_with_tco_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module TCOMethod 4 | class BlockWithTCOTest < TestCase 5 | include TCOMethod::TestHelpers::Assertions 6 | 7 | Subject = BlockWithTCO 8 | 9 | context "::with_tco" do 10 | subject { Subject } 11 | 12 | should "raise ArgumentError if a block is not given" do 13 | exception = assert_raises(ArgumentError) { subject.new } 14 | assert_match(/block required/i, exception.message) 15 | end 16 | 17 | # It would be nice if it could evaluate the block with the same binding, but 18 | # I haven't been able to find a way to make that work. 19 | should "evaluate the provided block with a different binding" do 20 | some_variable = "Hello, world!" 21 | assert_raises(NameError) do 22 | subject.new { some_variable } 23 | end 24 | end 25 | 26 | should "work with a proc in block form" do 27 | tco_block = subject.new do 28 | "Hello, world!" 29 | end 30 | assert_equal "Hello, world!", tco_block.result 31 | end 32 | 33 | should "work with a proc in curly-brace form" do 34 | tco_block = subject.new { "Hello, world!" } 35 | assert_equal "Hello, world!", tco_block.result 36 | end 37 | 38 | should "be tail call optimized" do 39 | subject.new do 40 | class ::TCOTester 41 | def fib_yielder(index, back_one = 1, back_two = 0, &block) 42 | index > 0 ? yield(back_two) : (return back_two) 43 | fib_yielder(index - 1, back_one + back_two, back_one, &block) 44 | end 45 | end 46 | end 47 | 48 | meth = ::TCOTester.new.method(:fib_yielder) 49 | assert_equal true, tail_call_optimized?(meth, 5) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/method_info_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MethodInfoTest < TCOMethod::TestCase 4 | Subject = TCOMethod::MethodInfo 5 | 6 | TestClass = Class.new do 7 | class << self; define_method(:class_block_method) { }; end 8 | def self.class_def_method; end 9 | define_method(:instance_block_method) { } 10 | def instance_def_method; end 11 | end 12 | 13 | context "#initialize" do 14 | should "raise TypeError unless given a Method object" do 15 | non_methods = [ 16 | proc { }, 17 | lambda { }, 18 | Proc.new { }, 19 | ] 20 | non_methods.each do |non_method| 21 | assert_raises(TypeError) do 22 | Subject.new(non_method) 23 | end 24 | end 25 | end 26 | 27 | should "accept Method objects defined on a class using def" do 28 | method_obj = TestClass.method(:class_def_method) 29 | assert_kind_of Method, method_obj 30 | assert_kind_of Subject, Subject.new(method_obj) 31 | end 32 | 33 | should "accept UnboundMethod objects defined on an instance using def" do 34 | method_obj = TestClass.instance_method(:instance_def_method) 35 | assert_kind_of UnboundMethod, method_obj 36 | assert_kind_of Subject, Subject.new(method_obj) 37 | end 38 | 39 | should "accept Method objects defined on a class using define_method" do 40 | method_obj = TestClass.method(:class_block_method) 41 | assert_kind_of Method, method_obj 42 | assert_kind_of Subject, Subject.new(method_obj) 43 | end 44 | 45 | should "accept UnboundMethod objects defined on an instance using define_method" do 46 | method_obj = TestClass.instance_method(:instance_block_method) 47 | assert_kind_of UnboundMethod, method_obj 48 | assert_kind_of Subject, Subject.new(method_obj) 49 | end 50 | end 51 | 52 | context "#type" do 53 | subject { TestClass.new } 54 | should "return :method for methods defined using def" do 55 | assert_equal :method, Subject.new(TestClass.method(:class_def_method)).type 56 | assert_equal :method, Subject.new(subject.method(:instance_def_method)).type 57 | end 58 | 59 | should "return :block for methods defined using define_method" do 60 | assert_equal :block, Subject.new(TestClass.method(:class_block_method)).type 61 | assert_equal :block, Subject.new(subject.method(:instance_block_method)).type 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/unit/method_reevaluator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module TCOMethod 4 | class MethodReevaluatorTest < TestCase 5 | include TCOMethod::TestHelpers::Assertions 6 | 7 | Subject = MethodReevaluator 8 | 9 | context "#initialize" do 10 | subject { Subject.method(:new) } 11 | 12 | [TestClass, TestModule].each do |method_owner| 13 | method_owner_class = method_owner.class.name.downcase.to_sym 14 | 15 | context "validation" do 16 | should "raise ArgumentError unless receiver given" do 17 | assert_raises(ArgumentError) do 18 | subject.call(nil, :nil?, :instance) 19 | end 20 | end 21 | 22 | should "raise ArgumentError unless method name given" do 23 | assert_raises(ArgumentError) do 24 | subject.call(method_owner, nil, :instance) 25 | end 26 | end 27 | 28 | should "raise ArgumentError unless method owner given" do 29 | assert_raises(ArgumentError) do 30 | subject.call(method_owner, :class_factorial, nil) 31 | end 32 | end 33 | 34 | should "raise TypeError for block methods" do 35 | assert_raises(TypeError) do 36 | subject.call(method_owner, :singleton_block_method, :class) 37 | end 38 | assert_raises(TypeError) do 39 | subject.call(method_owner, :instance_block_method, :instance) 40 | end 41 | end 42 | end 43 | 44 | context "#{method_owner_class} receiver" do 45 | context "with module method" do 46 | should "raise NameError if no #{method_owner_class} method with given name defined" do 47 | assert_raises(NameError) do 48 | subject.call(method_owner, :marmalade, method_owner_class) 49 | end 50 | end 51 | 52 | should "re-compile the given method with tail call optimization" do 53 | fib_yielder = method_owner.method(:module_fib_yielder) 54 | refute tail_call_optimized?(fib_yielder, 5) 55 | 56 | subject.call(method_owner, :module_fib_yielder, :module) 57 | tco_fib_yielder = method_owner.method(:module_fib_yielder) 58 | assert tail_call_optimized?(tco_fib_yielder, 5) 59 | 60 | assert_equal fib_yielder.source_location, tco_fib_yielder.source_location 61 | end 62 | end 63 | 64 | context "with class method" do 65 | should "raise NameError if no class method with given name defined" do 66 | assert_raises(NameError) do 67 | subject.call(method_owner, :marmalade, :class) 68 | end 69 | end 70 | 71 | should "re-compile the given method with tail call optimization" do 72 | fib_yielder = method_owner.method(:class_fib_yielder) 73 | refute tail_call_optimized?(fib_yielder, 5) 74 | 75 | subject.call(method_owner, :class_fib_yielder, :module) 76 | tco_fib_yielder = method_owner.method(:class_fib_yielder) 77 | assert tail_call_optimized?(tco_fib_yielder, 5) 78 | 79 | assert_equal fib_yielder.source_location, tco_fib_yielder.source_location 80 | end 81 | end 82 | 83 | context "with instance method" do 84 | should "raise NameError if no instance method with given name defined" do 85 | assert_raises(NameError) do 86 | subject.call(method_owner, :marmalade, :instance) 87 | end 88 | end 89 | 90 | should "re-compile the given method with tail call optimization" do 91 | instance_class = instance_class_for_receiver(method_owner) 92 | 93 | fib_yielder = instance_class.new.method(:instance_fib_yielder) 94 | refute tail_call_optimized?(fib_yielder, 5) 95 | 96 | subject.call(method_owner, :instance_fib_yielder, :instance) 97 | tco_fib_yielder = instance_class.new.method(:instance_fib_yielder) 98 | assert tail_call_optimized?(tco_fib_yielder, 5) 99 | 100 | assert_equal fib_yielder.source_location, tco_fib_yielder.source_location 101 | end 102 | end 103 | end 104 | end 105 | end 106 | 107 | def instance_class_for_receiver(receiver) 108 | return receiver if receiver.is_a?(Class) 109 | Class.new { include receiver } 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/unit/mixin_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module TCOMethod 4 | class MixinTest < TCOMethod::TestCase 5 | TestClass = Class.new { extend TCOMethod::Mixin } 6 | TestModule = Module.new { extend TCOMethod::Mixin } 7 | 8 | 9 | context "Module extensions" do 10 | subject { TestModule } 11 | 12 | context "#tco_module_method" do 13 | should "call MethodReevaluator#new with expected arguments" do 14 | method_name = :some_method 15 | args = [subject, method_name, :module] 16 | MethodReevaluator.expects(:new).with(*args) 17 | subject.tco_module_method(method_name) 18 | end 19 | end 20 | 21 | context "#tco_method" do 22 | should "call MethodReevaluator#new with expected arguments" do 23 | method_name = :some_method 24 | args = [subject, method_name, :instance] 25 | MethodReevaluator.expects(:new).with(*args) 26 | subject.tco_method(method_name) 27 | end 28 | end 29 | 30 | context "#tco_eval" do 31 | should "call TCOMethod.tco_eval with expected arguments" do 32 | code = "some_code" 33 | TCOMethod.expects(:tco_eval).with(code) 34 | subject.tco_eval(code) 35 | end 36 | end 37 | 38 | context "#with_tco" do 39 | should "call TCOMethod.with_tco with the given block" do 40 | # Mocha doesn't offer a good way for sensing passed blocks, so run 41 | # through the process twice, once with a stub, once without. 42 | 43 | # Stubbed 44 | TCOMethod.expects(:with_tco).returns(true) 45 | assert_equal true, subject.with_tco { } 46 | 47 | # Now unstubbed to make sure the expected block is invoked. 48 | TCOMethod.unstub(:with_tco) 49 | 50 | # Must use some sort of global for sensing side effects because the 51 | # block given to with_tco is called with a different binding than the 52 | # one used here. 53 | module ::WithTCOSensor 54 | def self.call; @called = true; end 55 | def self.called?; !!@called; end 56 | end 57 | 58 | result = subject.with_tco { ::WithTCOSensor.call; ::WithTCOSensor } 59 | assert_equal true, ::WithTCOSensor.called? 60 | assert_equal ::WithTCOSensor, result 61 | end 62 | end 63 | 64 | context "#with_tco" do 65 | should "call TCOMethod.with_tco with the given block" do 66 | # Must use some sort of global because the block is called with a 67 | # different binding. 68 | $with_tco_called = false 69 | subject.with_tco { $with_tco_called = true } 70 | assert $with_tco_called 71 | end 72 | end 73 | end 74 | 75 | context "Class extensions" do 76 | subject { TestClass } 77 | 78 | context "#tco_class_method" do 79 | should "call MethodReevaluator#new with expected arguments" do 80 | method_name = :some_method 81 | args = [subject, method_name, :module] 82 | MethodReevaluator.expects(:new).with(*args) 83 | subject.tco_class_method(method_name) 84 | end 85 | end 86 | 87 | context "#tco_method" do 88 | should "call MethodReevaluator#new with expected arguments" do 89 | method_name = :some_method 90 | args = [subject, method_name, :instance] 91 | MethodReevaluator.expects(:new).with(*args) 92 | subject.tco_method(method_name) 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/unit/tco_method_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module TCOMethod 4 | class TCOMethodTest < TCOMethod::TestCase 5 | include TCOMethod::TestHelpers::Assertions 6 | 7 | Subject = TCOMethod 8 | 9 | # Grab source before it's recompiled for use later 10 | InstanceFibYielderSource = TestClass.instance_method(:instance_fib_yielder).source 11 | 12 | subject { Subject } 13 | 14 | context Subject.name do 15 | should "be defined" do 16 | assert defined?(subject), "Expected #{subject.name} to be defined!" 17 | end 18 | end 19 | 20 | context "::tco_eval" do 21 | should "raise ArgumentError unless code is a String" do 22 | bad_code = [ 23 | :bad_code, 24 | 5, 25 | proc { puts "hello" }, 26 | ] 27 | bad_code.each do |non_code| 28 | assert_raises(ArgumentError) do 29 | subject.tco_eval(non_code) 30 | end 31 | end 32 | end 33 | 34 | should "compile the given code with tail call optimization" do 35 | EvalDummy = dummy_class = Class.new 36 | subject.tco_eval(<<-CODE) 37 | class #{dummy_class.name} 38 | #{InstanceFibYielderSource} 39 | end 40 | CODE 41 | 42 | fib_yielder = dummy_class.new.method(:instance_fib_yielder) 43 | assert tail_call_optimized?(fib_yielder, 5) 44 | end 45 | end 46 | 47 | context "::with_tco" do 48 | should "raise ArgumentError if a block is not given" do 49 | exception = assert_raises(ArgumentError) { subject.with_tco } 50 | assert_match(/block required/i, exception.message) 51 | end 52 | 53 | should "compile the given code with tail call optimization" do 54 | subject.with_tco do 55 | class WithTCODummy 56 | def countdown(count, &block) 57 | yield count 58 | count.zero? ? 0 : countdown(count - 1, &block) 59 | end 60 | end 61 | end 62 | 63 | meth = WithTCODummy.new.method(:countdown) 64 | assert tail_call_optimized?(meth, 5) 65 | end 66 | end 67 | end 68 | end 69 | --------------------------------------------------------------------------------