Add AST caching for subject matcher
At least this dramatically speeds up unit tests. And this is a good thing.
This commit is contained in:
parent
8696a796de
commit
49133680ee
23 changed files with 180 additions and 148 deletions
|
@ -22,6 +22,7 @@ require 'concord'
|
|||
module Mutant
|
||||
end
|
||||
|
||||
require 'mutant/cache'
|
||||
require 'mutant/node_helpers'
|
||||
require 'mutant/singleton_methods'
|
||||
require 'mutant/constants'
|
||||
|
@ -77,6 +78,7 @@ require 'mutant/subject/method'
|
|||
require 'mutant/matcher'
|
||||
require 'mutant/matcher/chain'
|
||||
require 'mutant/matcher/method'
|
||||
require 'mutant/matcher/method/finder'
|
||||
require 'mutant/matcher/method/singleton'
|
||||
require 'mutant/matcher/method/instance'
|
||||
require 'mutant/matcher/methods'
|
||||
|
|
30
lib/mutant/cache.rb
Normal file
30
lib/mutant/cache.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
module Mutant
|
||||
# An AST cache
|
||||
class Cache
|
||||
# This is explicitly empty! Ask me if you are interested in reasons :D
|
||||
include Equalizer.new
|
||||
|
||||
# Initialize object
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def initialize
|
||||
@cache = {}
|
||||
end
|
||||
|
||||
# Return node for file
|
||||
#
|
||||
# @return [AST::Node]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def parse(path)
|
||||
@cache.fetch(path) do
|
||||
@cache[path] = Parser::CurrentRuby.parse(File.read(path))
|
||||
end
|
||||
end
|
||||
|
||||
end # Cache
|
||||
end # Mutant
|
|
@ -41,6 +41,8 @@ module Mutant
|
|||
def initialize(arguments=[])
|
||||
@filters, @matchers = [], []
|
||||
|
||||
@cache = Mutant::Cache.new
|
||||
|
||||
parse(arguments)
|
||||
strategy
|
||||
matcher
|
||||
|
@ -54,6 +56,7 @@ module Mutant
|
|||
#
|
||||
def config
|
||||
Config.new(
|
||||
:cache => @cache,
|
||||
:debug => debug?,
|
||||
:matcher => matcher,
|
||||
:filter => filter,
|
||||
|
@ -212,7 +215,7 @@ module Mutant
|
|||
#
|
||||
def parse_matchers(patterns)
|
||||
patterns.each do |pattern|
|
||||
matcher = Classifier.build(pattern)
|
||||
matcher = Classifier.build(@cache, pattern)
|
||||
@matchers << matcher if matcher
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,7 +2,9 @@ module Mutant
|
|||
class CLI
|
||||
# A classifier for input strings
|
||||
class Classifier < Matcher
|
||||
include AbstractType, Adamantium::Flat, Equalizer.new(:identification)
|
||||
include AbstractType, Adamantium::Flat, Concord.new(:cache, :match)
|
||||
|
||||
include Equalizer.new(:identifier)
|
||||
|
||||
SCOPE_NAME_PATTERN = /[A-Za-z][A-Za-z_0-9]*/.freeze
|
||||
METHOD_NAME_PATTERN = /[_A-Za-z][A-Za-z0-9_]*[!?=]?/.freeze
|
||||
|
@ -39,8 +41,6 @@ module Mutant
|
|||
|
||||
# Return matchers for input
|
||||
#
|
||||
# @param [String] input
|
||||
#
|
||||
# @return [Classifier]
|
||||
# if a classifier handles the input
|
||||
#
|
||||
|
@ -49,9 +49,9 @@ module Mutant
|
|||
#
|
||||
# @api private
|
||||
#
|
||||
def self.build(input)
|
||||
def self.build(*arguments)
|
||||
classifiers = REGISTRY.map do |descendant|
|
||||
descendant.run(input)
|
||||
descendant.run(*arguments)
|
||||
end.compact
|
||||
|
||||
raise if classifiers.length > 1
|
||||
|
@ -61,6 +61,10 @@ module Mutant
|
|||
|
||||
# Run classifier
|
||||
#
|
||||
# @param [Cache] cache
|
||||
#
|
||||
# @param [String] input
|
||||
#
|
||||
# @return [Classifier]
|
||||
# if input is handled by classifier
|
||||
#
|
||||
|
@ -69,11 +73,11 @@ module Mutant
|
|||
#
|
||||
# @api private
|
||||
#
|
||||
def self.run(input)
|
||||
def self.run(cache, input)
|
||||
match = self::REGEXP.match(input)
|
||||
return unless match
|
||||
|
||||
new(match)
|
||||
new(cache, match)
|
||||
end
|
||||
|
||||
# No protected_class_method in ruby :(
|
||||
|
@ -95,38 +99,16 @@ module Mutant
|
|||
self
|
||||
end
|
||||
|
||||
# Return identification
|
||||
# Return identifier
|
||||
#
|
||||
# @return [String]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def identification
|
||||
def identifier
|
||||
match.to_s
|
||||
end
|
||||
memoize :identification
|
||||
|
||||
private
|
||||
|
||||
# Initialize object
|
||||
#
|
||||
# @param [MatchData] match
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def initialize(match)
|
||||
@match = match
|
||||
end
|
||||
|
||||
# Return match
|
||||
#
|
||||
# @return [MatchData]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
attr_reader :match
|
||||
memoize :identifier
|
||||
|
||||
# Return matcher
|
||||
#
|
||||
|
|
|
@ -24,7 +24,7 @@ module Mutant
|
|||
# @api private
|
||||
#
|
||||
def matcher
|
||||
methods_matcher.matcher.new(scope, method)
|
||||
methods_matcher.matcher.new(cache, scope, method)
|
||||
end
|
||||
memoize :matcher
|
||||
|
||||
|
@ -90,7 +90,7 @@ module Mutant
|
|||
# @api private
|
||||
#
|
||||
def methods_matcher
|
||||
TABLE.fetch(scope_symbol).new(scope)
|
||||
TABLE.fetch(scope_symbol).new(cache, scope)
|
||||
end
|
||||
memoize :methods_matcher
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ module Mutant
|
|||
# @api private
|
||||
#
|
||||
def matcher
|
||||
self.class::MATCHER.new(namespace)
|
||||
self.class::MATCHER.new(cache, namespace)
|
||||
end
|
||||
|
||||
# Return namespace
|
||||
|
|
|
@ -2,7 +2,7 @@ module Mutant
|
|||
# The configuration of a mutator run
|
||||
class Config
|
||||
include Adamantium::Flat, Anima.new(
|
||||
:debug, :strategy, :matcher, :filter, :reporter
|
||||
:cache, :debug, :strategy, :matcher, :filter, :reporter
|
||||
)
|
||||
|
||||
# Enumerate subjects
|
||||
|
|
|
@ -15,10 +15,10 @@ module Mutant
|
|||
#
|
||||
# @api private
|
||||
#
|
||||
def self.each(input, &block)
|
||||
return to_enum(__method__, input) unless block_given?
|
||||
def self.each(cache, input, &block)
|
||||
return to_enum(__method__, cache, input) unless block_given?
|
||||
|
||||
new(input).each(&block)
|
||||
new(cache, input).each(&block)
|
||||
|
||||
self
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@ module Mutant
|
|||
class Matcher
|
||||
# Matcher for subjects that are a specific method
|
||||
class Method < self
|
||||
include Adamantium::Flat, Concord::Public.new(:scope, :method)
|
||||
include Adamantium::Flat, Concord::Public.new(:cache, :scope, :method)
|
||||
|
||||
# Methods within rbx kernel directory are precompiled and their source
|
||||
# cannot be accessed via reading source location
|
||||
|
@ -18,13 +18,12 @@ module Mutant
|
|||
#
|
||||
# @api private
|
||||
#
|
||||
def each(&block)
|
||||
def each
|
||||
return to_enum unless block_given?
|
||||
|
||||
return self if skip?
|
||||
|
||||
util = subject
|
||||
yield util if util
|
||||
unless skip?
|
||||
yield subject if subject
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
@ -78,7 +77,7 @@ module Mutant
|
|||
# @api private
|
||||
#
|
||||
def ast
|
||||
Parser::CurrentRuby.parse(File.read(source_path))
|
||||
cache.parse(source_path)
|
||||
end
|
||||
|
||||
# Return path to source
|
||||
|
@ -128,72 +127,6 @@ module Mutant
|
|||
end
|
||||
memoize :subject
|
||||
|
||||
# Visitor to find last match inside AST
|
||||
class Finder
|
||||
|
||||
# Run finder
|
||||
#
|
||||
# @param [Parser::AST::Node]
|
||||
#
|
||||
# @return [Parser::AST::Node]
|
||||
# if found
|
||||
#
|
||||
# @return [nil]
|
||||
# otherwise
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
#
|
||||
def self.run(root, &predicate)
|
||||
new(root, predicate).match
|
||||
end
|
||||
|
||||
private_class_method :new
|
||||
|
||||
# Return match
|
||||
#
|
||||
# @return [Parser::AST::Node]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
attr_reader :match
|
||||
|
||||
private
|
||||
|
||||
# Initialize object
|
||||
#
|
||||
# @param [Parer::AST::Node]
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
#
|
||||
def initialize(root, predicate)
|
||||
@root, @predicate = root, predicate
|
||||
visit(root)
|
||||
end
|
||||
|
||||
# Visit node
|
||||
#
|
||||
# @param [Parser::AST::Node] node
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def visit(node)
|
||||
if @predicate.call(node)
|
||||
@match = node
|
||||
end
|
||||
|
||||
node.children.each do |child|
|
||||
visit(child) if child.kind_of?(Parser::AST::Node)
|
||||
end
|
||||
end
|
||||
|
||||
end # Finder
|
||||
|
||||
# Return matched node
|
||||
#
|
||||
# @return [Parser::AST::Node]
|
||||
|
|
72
lib/mutant/matcher/method/finder.rb
Normal file
72
lib/mutant/matcher/method/finder.rb
Normal file
|
@ -0,0 +1,72 @@
|
|||
module Mutant
|
||||
class Matcher
|
||||
class Method
|
||||
|
||||
# Visitor to find last match inside AST
|
||||
class Finder
|
||||
|
||||
# Run finder
|
||||
#
|
||||
# @param [Parser::AST::Node]
|
||||
#
|
||||
# @return [Parser::AST::Node]
|
||||
# if found
|
||||
#
|
||||
# @return [nil]
|
||||
# otherwise
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
#
|
||||
def self.run(root, &predicate)
|
||||
new(root, predicate).match
|
||||
end
|
||||
|
||||
private_class_method :new
|
||||
|
||||
# Return match
|
||||
#
|
||||
# @return [Parser::AST::Node]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
attr_reader :match
|
||||
|
||||
private
|
||||
|
||||
# Initialize object
|
||||
#
|
||||
# @param [Parer::AST::Node]
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
#
|
||||
def initialize(root, predicate)
|
||||
@root, @predicate = root, predicate
|
||||
visit(root)
|
||||
end
|
||||
|
||||
# Visit node
|
||||
#
|
||||
# @param [Parser::AST::Node] node
|
||||
#
|
||||
# @return [undefined]
|
||||
#
|
||||
# @api private
|
||||
#
|
||||
def visit(node)
|
||||
if @predicate.call(node)
|
||||
@match = node
|
||||
end
|
||||
|
||||
node.children.each do |child|
|
||||
visit(child) if child.kind_of?(Parser::AST::Node)
|
||||
end
|
||||
end
|
||||
|
||||
end # Finder
|
||||
end # Method
|
||||
end # Matcher
|
||||
end # Mutant
|
|
@ -16,8 +16,8 @@ module Mutant
|
|||
end
|
||||
memoize :identification
|
||||
|
||||
RECEIVER_INDEX = 0
|
||||
NAME_INDEX = 1
|
||||
RECEIVER_INDEX = 0
|
||||
NAME_INDEX = 1
|
||||
CONST_NAME_INDEX = 1
|
||||
|
||||
private
|
||||
|
|
|
@ -2,7 +2,7 @@ module Mutant
|
|||
class Matcher
|
||||
# Abstract base class for matcher that returns method subjects extracted from scope
|
||||
class Methods < self
|
||||
include AbstractType, Concord::Public.new(:scope)
|
||||
include AbstractType, Concord::Public.new(:cache, :scope)
|
||||
|
||||
# Enumerate subjects
|
||||
#
|
||||
|
@ -59,7 +59,7 @@ module Mutant
|
|||
# @api private
|
||||
#
|
||||
def emit_matches(method)
|
||||
matcher.new(scope, method).each do |subject|
|
||||
matcher.new(cache, scope, method).each do |subject|
|
||||
yield subject
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@ module Mutant
|
|||
|
||||
# Matcher for specific namespace
|
||||
class Namespace < self
|
||||
include Concord::Public.new(:namespace)
|
||||
include Concord::Public.new(:cache, :namespace)
|
||||
|
||||
# Enumerate subjects
|
||||
#
|
||||
|
@ -19,7 +19,7 @@ module Mutant
|
|||
return to_enum unless block_given?
|
||||
|
||||
scopes.each do |scope|
|
||||
Scope.each(scope, &block)
|
||||
Scope.each(cache, scope, &block)
|
||||
end
|
||||
|
||||
self
|
||||
|
|
|
@ -2,7 +2,7 @@ module Mutant
|
|||
class Matcher
|
||||
# Matcher for specific namespace
|
||||
class Scope < self
|
||||
include Concord::Public.new(:scope)
|
||||
include Concord::Public.new(:cache, :scope)
|
||||
|
||||
MATCHERS = [
|
||||
Matcher::Methods::Singleton,
|
||||
|
@ -23,7 +23,7 @@ module Mutant
|
|||
return to_enum unless block_given?
|
||||
|
||||
MATCHERS.each do |matcher|
|
||||
matcher.each(scope, &block)
|
||||
matcher.each(cache, scope, &block)
|
||||
end
|
||||
|
||||
self
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
shared_examples_for 'a method matcher' do
|
||||
before do
|
||||
subject
|
||||
end
|
||||
|
||||
before { subject }
|
||||
|
||||
let(:node) { mutation_subject.node }
|
||||
let(:context) { mutation_subject.context }
|
||||
|
|
|
@ -6,6 +6,10 @@ $: << File.join(TestApp.root,'lib')
|
|||
|
||||
require 'test_app'
|
||||
|
||||
module Fixtures
|
||||
AST_CACHE = Mutant::Cache.new
|
||||
end
|
||||
|
||||
module ParserHelper
|
||||
def generate(node)
|
||||
Unparser.unparse(node)
|
||||
|
|
|
@ -70,15 +70,15 @@ describe Mutant::CLI, '.new' do
|
|||
end
|
||||
|
||||
context 'with explicit method matcher' do
|
||||
let(:arguments) { %w(--rspec-unit TestApp::Literal#float) }
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Method.new('TestApp::Literal#float') }
|
||||
let(:arguments) { %w(--rspec-unit TestApp::Literal#float) }
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Method.new(Mutant::Cache.new, 'TestApp::Literal#float') }
|
||||
|
||||
it_should_behave_like 'a cli parser'
|
||||
end
|
||||
|
||||
context 'with namespace matcher' do
|
||||
let(:arguments) { %w(--rspec-unit ::TestApp*) }
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Namespace::Recursive.new('::TestApp*') }
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Namespace::Recursive.new(Mutant::Cache.new, '::TestApp*') }
|
||||
|
||||
it_should_behave_like 'a cli parser'
|
||||
end
|
||||
|
@ -93,7 +93,7 @@ describe Mutant::CLI, '.new' do
|
|||
]
|
||||
end
|
||||
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Method.new('TestApp::Literal#float') }
|
||||
let(:expected_matcher) { Mutant::CLI::Classifier::Method.new(Mutant::Cache.new, 'TestApp::Literal#float') }
|
||||
let(:expected_filter) { Mutant::Mutation::Filter::Whitelist.new(filters) }
|
||||
|
||||
it_should_behave_like 'a cli parser'
|
||||
|
|
|
@ -1,20 +1,23 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Mutant::CLI::Classifier, '.build' do
|
||||
subject { described_class.build(input) }
|
||||
subject { described_class.build(cache, input) }
|
||||
|
||||
let(:cache) { mock('Cache') }
|
||||
|
||||
this_spec = 'Mutant::CLI::Classifier.build'
|
||||
|
||||
shared_examples_for this_spec do
|
||||
it 'shoud return expected instance' do
|
||||
should eql(expected_class.new(expected_class::REGEXP.match(input)))
|
||||
should eql(expected_class.new(cache, expected_class::REGEXP.match(input)))
|
||||
end
|
||||
|
||||
let(:expected_class) { Mutant::CLI::Classifier::Method }
|
||||
end
|
||||
|
||||
context 'with explicit toplevel scope' do
|
||||
|
||||
let(:input) { '::TestApp::Literal#string' }
|
||||
let(:expected_class) { Mutant::CLI::Classifier::Method }
|
||||
|
||||
it_should_behave_like this_spec
|
||||
end
|
||||
|
@ -22,14 +25,12 @@ describe Mutant::CLI::Classifier, '.build' do
|
|||
context 'with instance method notation' do
|
||||
|
||||
let(:input) { 'TestApp::Literal#string' }
|
||||
let(:expected_class) { Mutant::CLI::Classifier::Method }
|
||||
|
||||
it_should_behave_like this_spec
|
||||
end
|
||||
|
||||
context 'with singleton method notation' do
|
||||
let(:input) { 'TestApp::Literal.string' }
|
||||
let(:expected_class) { Mutant::CLI::Classifier::Method }
|
||||
|
||||
it_should_behave_like this_spec
|
||||
end
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Mutant::Matcher::Method::Instance, '#each' do
|
||||
let(:object) { described_class.new(scope, method) }
|
||||
let(:method) { scope.instance_method(method_name) }
|
||||
let(:cache) { Fixtures::AST_CACHE }
|
||||
let(:object) { described_class.new(cache, scope, method) }
|
||||
let(:method) { scope.instance_method(method_name) }
|
||||
|
||||
let(:yields) { [] }
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Mutant::Matcher::Method::Singleton, '#each' do
|
||||
let(:object) { described_class.new(scope, method) }
|
||||
let(:method) { scope.method(method_name) }
|
||||
let(:object) { described_class.new(cache, scope, method) }
|
||||
let(:method) { scope.method(method_name) }
|
||||
let(:cache) { Fixtures::AST_CACHE }
|
||||
|
||||
let(:yields) { [] }
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Mutant::Matcher::Methods::Instance, '#each' do
|
||||
let(:object) { described_class.new(Foo) }
|
||||
let(:object) { described_class.new(cache, Foo) }
|
||||
let(:cache) { Mutant::Cache.new }
|
||||
|
||||
subject { object.each { |matcher| yields << matcher } }
|
||||
|
||||
|
@ -45,9 +46,9 @@ describe Mutant::Matcher::Methods::Instance, '#each' do
|
|||
|
||||
before do
|
||||
matcher = Mutant::Matcher::Method::Instance
|
||||
matcher.stub(:new).with(Foo, Foo.instance_method(:method_a)).and_return([subject_a])
|
||||
matcher.stub(:new).with(Foo, Foo.instance_method(:method_b)).and_return([subject_b])
|
||||
matcher.stub(:new).with(Foo, Foo.instance_method(:method_c)).and_return([subject_c])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.instance_method(:method_a)).and_return([subject_a])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.instance_method(:method_b)).and_return([subject_b])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.instance_method(:method_c)).and_return([subject_c])
|
||||
end
|
||||
|
||||
it 'should yield expected subjects' do
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Mutant::Matcher::Methods::Singleton, '#each' do
|
||||
let(:object) { described_class.new(Foo) }
|
||||
let(:object) { described_class.new(cache, Foo) }
|
||||
let(:cache) { Mutant::Cache.new }
|
||||
|
||||
subject { object.each { |matcher| yields << matcher } }
|
||||
|
||||
|
@ -39,9 +40,9 @@ describe Mutant::Matcher::Methods::Singleton, '#each' do
|
|||
|
||||
before do
|
||||
matcher = Mutant::Matcher::Method::Singleton
|
||||
matcher.stub(:new).with(Foo, Foo.method(:method_a)).and_return([subject_a])
|
||||
matcher.stub(:new).with(Foo, Foo.method(:method_b)).and_return([subject_b])
|
||||
matcher.stub(:new).with(Foo, Foo.method(:method_c)).and_return([subject_c])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.method(:method_a)).and_return([subject_a])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.method(:method_b)).and_return([subject_b])
|
||||
matcher.stub(:new).with(cache, Foo, Foo.method(:method_c)).and_return([subject_c])
|
||||
end
|
||||
|
||||
it 'should yield expected subjects' do
|
||||
|
|
|
@ -4,7 +4,9 @@ describe Mutant::Matcher::Namespace, '#each' do
|
|||
subject { object.each { |item| yields << item } }
|
||||
|
||||
let(:yields) { [] }
|
||||
let(:object) { described_class.new(TestApp::Literal) }
|
||||
let(:object) { described_class.new(cache, TestApp::Literal) }
|
||||
|
||||
let(:cache) { Mutant::Cache.new }
|
||||
|
||||
let(:singleton_a) { mock('SingletonA', :name => 'TestApp::Literal') }
|
||||
let(:singleton_b) { mock('SingletonB', :name => 'TestApp::Foo') }
|
||||
|
@ -12,8 +14,8 @@ describe Mutant::Matcher::Namespace, '#each' do
|
|||
let(:subject_b) { mock('SubjectB') }
|
||||
|
||||
before do
|
||||
Mutant::Matcher::Methods::Singleton.stub(:each).with(singleton_a).and_yield(subject_a)
|
||||
Mutant::Matcher::Methods::Instance.stub(:each).with(singleton_a).and_yield(subject_b)
|
||||
Mutant::Matcher::Methods::Singleton.stub(:each).with(cache, singleton_a).and_yield(subject_a)
|
||||
Mutant::Matcher::Methods::Instance.stub(:each).with(cache, singleton_a).and_yield(subject_b)
|
||||
ObjectSpace.stub(:each_object => [singleton_a, singleton_b])
|
||||
end
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue