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