Skip to content

Commit 1f47fbc

Browse files
committed
Implement TestML for Ruby
This implements all of the tests present in the other languages. It gets a couple of things wrong, that can be addressed in follow-up, specifically: * labels are right for a lot of cases, but there appears to be some kind of substitution that we are currently missing * it's not clear what "Plan" means, so turning it on does not do anything right now
1 parent 00ce1ac commit 1f47fbc

25 files changed

+306
-1085
lines changed

bin/testml-cli.bash

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ TestML supports the following runners:
137137
node-tap NodeJS w/ TAP
138138
perl-tap Perl w/ TAP
139139
python-tap Python (2 or 3) w/ TAP
140-
python-tap Python (2 or 3) w/ unittest
140+
python-unit Python (2 or 3) w/ unittest
141141
raku-tap Raku w/ TAP
142+
ruby-tap Ruby w/ TAP
142143
143144
Aliases:
144145
coffee Alias for coffee-mocha

src/ruby/bin/testml-ruby-tap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ set -e -u -o pipefail
77
testml-run-file() {
88
# set -x
99
RUBYLIB=$TESTML_ROOT_LIB:$TESTML_LIB${RUBYLIB:+:$RUBYLIB} \
10-
${TESTML_LANG} -e "require 'testml/run/tap'; $TESTML_MODULE.run('$1')"
10+
${TESTML_LANG} -rtestml -e "$TESTML_MODULE.run('$1')"
1111
}
1212

1313
[[ ${TESTML_SOURCED-} ]] ||
@@ -17,7 +17,7 @@ source-testml-config
1717

1818
: "${TESTML_BIN:=testml-ruby-tap}"
1919
: "${TESTML_LANG:=ruby}"
20-
: "${TESTML_MODULE:=TestML::Run::TAP}"
20+
: "${TESTML_MODULE:=TestML}"
2121
: "${TESTML_BRIDGE:=testml-bridge}"
2222

2323
[[ $0 != "${BASH_SOURCE[0]}" ]] || testml-run "$@"

src/ruby/lib/testml.rb

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
5+
module TestML
6+
def self.run(filepath)
7+
json = JSON.parse(File.read(filepath))
8+
raise "Unsupported TestML version: #{json["testml"]}" if json["testml"] != "0.3.0"
9+
10+
index = Index.new
11+
runner = Runner.new(index)
12+
13+
runner.run(json["code"], json["data"])
14+
puts "1..#{index}"
15+
end
16+
17+
class Index
18+
def initialize = @value = 0
19+
def succ! = @value = @value.succ
20+
def to_s = "#{@value}"
21+
end
22+
23+
Mine = Class.new
24+
NULL = Object.new
25+
NONE = Object.new
26+
FALSY = [nil, false, NONE, NULL].freeze
27+
28+
class Bridge
29+
def add(left, right)
30+
left + right
31+
end
32+
33+
def cat(*values)
34+
values.join
35+
end
36+
37+
def get_env(key)
38+
ENV.fetch(key) { NONE }
39+
end
40+
41+
def mine
42+
Mine.new
43+
end
44+
45+
def sub(left, right)
46+
left - right
47+
end
48+
end
49+
50+
class Runner
51+
def initialize(index)
52+
@index = index
53+
@vars = {}
54+
@bridge = Bridge.new
55+
56+
@label = nil
57+
@diff = false
58+
@plan = false
59+
end
60+
61+
def run(exprs, env)
62+
exprs.each { |expr| compile(expr, env) }
63+
end
64+
65+
private
66+
67+
def join_label(parent, child)
68+
child.gsub(/(\A\+|\+\z|\{\+\})/, parent)
69+
end
70+
71+
def assert(positive, got, operator, want, label, env)
72+
if env.is_a?(Hash)
73+
label ||= env["Label"]
74+
label = join_label(label, @label) if @label
75+
end
76+
77+
label ||= ""
78+
label = label.gsub(/\{(\*?.+?)\}/) do
79+
substitution =
80+
case (value = $1)
81+
when "Got" then got
82+
when "Want" then want
83+
when /^\*(.+)$/ then env.fetch($1)
84+
else @vars.fetch(value)
85+
end
86+
87+
case substitution
88+
when String
89+
substitution.gsub("\n", "␤")
90+
when Array
91+
"[#{substitution.map(&:inspect).join(",")}]"
92+
else
93+
substitution.inspect
94+
end
95+
end
96+
97+
@index.succ!
98+
99+
result = got.public_send(operator, want)
100+
result = !result unless positive
101+
102+
if result
103+
puts "ok #{@index} - #{label}"
104+
else
105+
puts "not ok #{@index} - #{label}"
106+
107+
if @diff
108+
puts " got: '#{got.inspect}'"
109+
puts " expected: '#{want.inspect}'"
110+
end
111+
end
112+
end
113+
114+
def filters?(filters, env)
115+
filters.all? do |filter|
116+
case filter
117+
in /^\*(.+)$/ then env.key?($1)
118+
in /^\!\*(.+)$/ then !env.key?($1)
119+
end
120+
end
121+
end
122+
123+
def compile(expr, env)
124+
case expr
125+
126+
# functions
127+
in ["ArgV"] then ARGV
128+
in ["Bool", value] then !FALSY.include?(compile(value, env))
129+
in ["Block"] then env
130+
in ["Block", label] then env.find { |env| env["Label"] == label }
131+
in ["Blocks"] then env
132+
in ["Cat", *values] then values.map { |value| compile(value, env) }.join
133+
in ["Env"] then ENV
134+
in ["Error"] then StandardError.new
135+
in ["Error", message] then StandardError.new(message)
136+
in ["False"] then false
137+
in ["None"] then NONE
138+
in ["Null"] then NULL
139+
in ["Sum", *values] then values.sum { |value| compile(value, env) }
140+
in ["Throw", message] then raise StandardError, message
141+
in ["True"] then true
142+
143+
# assignments
144+
in ["=", "Label", value]
145+
@label = value
146+
in ["=", "Diff", value]
147+
@diff = compile(value, env)
148+
in ["=", "Plan", value]
149+
@plan = compile(value, env)
150+
in ["=", name, value]
151+
@vars[name] = compile(value, env)
152+
in ["||=", name, value]
153+
@vars[name] = compile(value, env) if FALSY.include?(@vars[name])
154+
155+
# statements
156+
in ["*", name]
157+
compile(env.fetch(name), env)
158+
in ["%<>", filters, ["=>", *] => function]
159+
env.each do |child_env|
160+
compile(function, child_env).call([], child_env) if filters?(filters, child_env)
161+
end
162+
in ["%<>", filters, expr]
163+
env.each do |child_env|
164+
if expr.length == 4
165+
child_env = { **child_env }
166+
child_env["Label"] = join_label(child_env["Label"], expr[3])
167+
end
168+
169+
compile(expr, child_env) if filters?(filters, child_env)
170+
end
171+
in ["<>", filters, ["=>", *] => function]
172+
compile(function, env).call([], env) if filters?(filters, env)
173+
in ["<>", filters, expr]
174+
compile(expr, env) if filters?(filters, env)
175+
in ["<>", filters, expr, label]
176+
compile(expr, env.merge("Label" => join_label(env["Label"], label))) if filters?(filters, env)
177+
in [".", receiver, *calls]
178+
compile_calls(receiver, calls, env)
179+
in ["%", inputs, function]
180+
callable = compile(function, env)
181+
compile(inputs, env).each { |input| callable.call([[input]], {}) }
182+
in ["&", expr]
183+
compile(expr, env).call([], env)
184+
in ["[]", receiver, index]
185+
compile(receiver, env).fetch(compile(index, env)) { NONE }
186+
in [":" | /\Ahash-lookup\z/i, receiver, key]
187+
compile(receiver, env).fetch(compile(key, env)) { NONE }
188+
189+
# assertions
190+
in [("==" | "!==") => operator, left, right, *labels]
191+
assert(operator == "==", compile(left, env), :==, compile(right, env), labels[0], env)
192+
in [("=~" | "!=~") => operator, left, right, *labels]
193+
left = compile(left, env)
194+
right = compile(right, env)
195+
196+
Array(right).each do |right|
197+
Array(left).each do |left|
198+
assert(operator == "=~", left, :match?, right, labels[0], env)
199+
end
200+
end
201+
in [("~~" | "!~~") => operator, left, right, *labels]
202+
left = compile(left, env)
203+
right = compile(right, env)
204+
205+
Array(right).each do |substring|
206+
assert(operator == "~~", left, :include?, substring, labels[0], env)
207+
end
208+
209+
# types
210+
in [Array => values] then values.map { |value| compile(value, env) }
211+
in [Hash => values] then values.transform_values { |value| compile(value, env) }
212+
in String then expr
213+
in Integer then expr
214+
in ["/", pattern] then Regexp.new(pattern)
215+
in ["\"", value] then value.gsub(/\{(.+?)\}/) { @vars.fetch($1) }
216+
in ["_"] then env["inputs"][0][0]
217+
in ["=>", params, exprs]
218+
runner = Runner.new(@index)
219+
exprs = compile_params(params).concat(exprs)
220+
->(inputs, env) { runner.run(exprs, env.merge("inputs" => inputs)) }
221+
in [String => name] if @vars.key?(name)
222+
@vars.fetch(name)
223+
in [String => name, *arguments] if @vars.key?(name)
224+
@vars.fetch(name).call([arguments.map { |argument| compile(argument, env) }], {})
225+
in [/\A[a-z]/ => name, *arguments]
226+
@bridge.public_send(name.tr("-", "_"), *arguments.map { |argument| compile(argument, env) })
227+
end
228+
end
229+
230+
def compile_params(params)
231+
params.map.with_index { |param, index| ["=", param, ["[]", ["*", "inputs"], index]] }
232+
end
233+
234+
def compile_calls(callee, calls, env)
235+
calls.inject(-> { compile(callee, env) }) do |callee, call|
236+
-> {
237+
case call
238+
in ["Catch"]
239+
begin callee.call; rescue => error then error end
240+
in ["Count"]
241+
callee.call.count
242+
in ["Join", delimiter]
243+
callee.call.join(delimiter)
244+
in ["Lines"]
245+
callee.call.split("\n")
246+
in ["Msg"]
247+
callee.call.message
248+
in ["Split", delimiter]
249+
callee.call.split(delimiter)
250+
in ["Text"]
251+
callee.call.map { |line| "#{line}\n" }.join
252+
in ["Type"]
253+
compile_type(callee.call)
254+
in ["=>", params, exprs]
255+
Runner.new(@index).run(compile_params(params).concat(exprs), { "inputs" => [callee.call] })
256+
in [String => name, *arguments] if @vars.key?(name)
257+
@vars.fetch(name).call([[callee.call, *arguments]], {})
258+
in [/\A[a-z]/ => name, *arguments]
259+
@bridge.public_send(name.tr("-", "_"), callee.call, *arguments.map { |argument| compile(argument, env) })
260+
else
261+
begin callee.call; rescue => error then raise end
262+
raise StandardError, "Unknown call: #{call.inspect}"
263+
end
264+
}
265+
end.call
266+
end
267+
268+
def compile_type(object)
269+
case object
270+
in Array then "list"
271+
in Hash then "hash"
272+
in Integer then "num"
273+
in Mine then "native"
274+
in NONE then "none"
275+
in NULL then "null"
276+
in Proc then "func"
277+
in Regexp then "regex"
278+
in StandardError then "error"
279+
in String then "str"
280+
in TrueClass | FalseClass then "bool"
281+
end
282+
end
283+
end
284+
end

src/ruby/lib/testml/bridge.rb

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)