mbt-gen 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/progress.rb ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/ruby
2
+
3
+ class Progress
4
+
5
+ def initialize()
6
+ @last_line = ""
7
+ end
8
+
9
+ def update_last_line(last_line)
10
+ @last_line = last_line
11
+ print "\033[M#{last_line}"
12
+ $stdout.flush()
13
+ end
14
+
15
+ def print_line(line)
16
+ print "\033[M#{line}\n#{@last_line}"
17
+ $stdout.flush()
18
+ end
19
+ end
20
+
21
+ class ProgressBar < Progress
22
+ def initialize(max)
23
+ @max = max.to_f
24
+ end
25
+
26
+ def render_bar(current, length)
27
+ bars = (current.to_f * length.to_f).floor
28
+ "=" * bars + ">" + " " * (length - bars)
29
+ end
30
+
31
+ def report_progress(progress)
32
+ if progress then
33
+ current = (progress.to_f / @max)
34
+ update_last_line("[#{render_bar(current, 40)}] combinations covered: #{progress}/#{@max} (#{format("%.8f", current * 100.0)}%)")
35
+ else
36
+ update_last_line("[... ? ... ? ... ? ...] combinations covered: ?/∞ (?%)")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/ruby
2
+
3
+ class Regexp_to_SMTLIB
4
+
5
+ def self.translate_regexp_to_SMTLIB(regex_str)
6
+ if regex_str =~ /^\^(.*)\$$/ then
7
+ return translate_anchored_regexp_to_SMTLIB($1)
8
+ elsif regex_str =~ /^\^(.*)$/ then
9
+ return "(re.++ #{translate_anchored_regexp_to_SMTLIB($1)} (re.* re.allchar))"
10
+ elsif regex_str =~ /^(.*)\$$/ then
11
+ return "(re.++ (re.* re.allchar) #{translate_anchored_regexp_to_SMTLIB($1)})"
12
+ else
13
+ return "(re.++ (re.* re.allchar) #{translate_anchored_regexp_to_SMTLIB($1)} (re.* re.allchar))"
14
+ end
15
+ end
16
+
17
+ def self.translate_anchored_regexp_to_SMTLIB(regex_str)
18
+ begin
19
+ rest = regex_str
20
+ res = []
21
+ found = true
22
+ endindex = nil
23
+ while found do
24
+ found = false
25
+ if rest =~ /^\[([^\]]*)\](\*|\+|\?|\{(\d+),(\d+)\})?/ then
26
+ inner = $1
27
+ multiplicator = $2
28
+ endindex = $~.end(0)
29
+ range = translate_regexp_range_to_SMTLIB(inner)
30
+ if multiplicator then
31
+ res << translate_multiplicator_for_re_to_SMTLIB(multiplicator, range)
32
+ found = true
33
+ else
34
+ res << range
35
+ found = true
36
+ end
37
+ elsif rest =~ /^([^\[]+)(\*|\+|\?|\{(\d+),(\d+)\})?/
38
+ string = $1
39
+ multiplicator = $2
40
+ endindex = $~.end(0)
41
+
42
+ inner = "(str.to.re #{string.inspect})"
43
+ if multiplicator then
44
+ res << translate_multiplicator_for_re_to_SMTLIB(multiplicator, inner)
45
+ found = true
46
+ else
47
+ res << inner
48
+ found = true
49
+ end
50
+ end
51
+ rest = rest.slice(endindex, rest.length) if rest
52
+ end
53
+ if res.size > 1 then
54
+ return "(re.++ #{res.join(" ")})"
55
+ else
56
+ if res.empty? then
57
+ return "re.nostr"
58
+ else
59
+ return res.first
60
+ end
61
+ end
62
+ rescue RuntimeError => rte
63
+ raise RuntimeError.new("cannot parse regex: #{regex_str.inspect}: #{rte.message}")
64
+ end
65
+
66
+ # TODO
67
+ # - . => re.allchar
68
+ # * abc => re.++
69
+ # - string => str.to.re
70
+ # * [A-Z] => re.range
71
+ # * [abc] => re.union
72
+ # * * => re.*
73
+ # * + => re.+
74
+ # * ? => re.opt
75
+ # * {a,b} => re.loop
76
+ end
77
+
78
+ def self.translate_multiplicator_for_re_to_SMTLIB(multiplicator, expr)
79
+ if multiplicator == "*" then
80
+ return "(re.* #{expr})"
81
+ elsif multiplicator == "+" then
82
+ return "(re.+ #{expr})"
83
+ elsif multiplicator == "?" then
84
+ return "(re.opt #{expr})"
85
+ elsif multiplicator =~ /\{(\d+),(\d+)\}/ then
86
+ begin
87
+ lower = Integer($1)
88
+ upper = Integer($2)
89
+ rescue TypeError => e
90
+ raise RuntimeError.new("cannot parse regexp (#{$1} or #{$2} is no Integer)")
91
+ end
92
+ return "((_ re.loop #{lower} #{upper}) #{expr})"
93
+ else
94
+ raise RuntimeError.new("invalid multiplicator: #{multiplicator}")
95
+ end
96
+ end
97
+
98
+ # TODO: range complement with ^
99
+ def self.translate_regexp_range_to_SMTLIB(range)
100
+ rest = range
101
+ res = []
102
+ while rest =~ /^(.)\-(.)/ do
103
+ ch1 = $1
104
+ ch2 = $2
105
+ res << "(re.range \"#{ch1}\" \"#{ch2}\")"
106
+ rest = rest.slice($~.end(0), rest.length)
107
+ end
108
+ if res.size > 1 then
109
+ return "(re.union #{res.join(" ")})"
110
+ else
111
+ if res.empty? then
112
+ return "re.nostr"
113
+ else
114
+ return res.first
115
+ end
116
+ end
117
+ end
118
+
119
+
120
+ end
data/lib/solver-lib.rb ADDED
@@ -0,0 +1,374 @@
1
+ require 'open3'
2
+
3
+ FTYPE_MANDATORY = 0
4
+ FTYPE_OPTIONAL = 1
5
+
6
+ SUBF_AND = 0
7
+ SUBF_OR = 1
8
+ SUBF_XOR = 2
9
+
10
+ LOG_DIR = 'logs'
11
+
12
+
13
+
14
+ class SolverSession
15
+
16
+ def initialize(z3in, z3outAndErr, logFileName)
17
+ @z3in = z3in
18
+ @z3outAndErr = z3outAndErr
19
+ @protocol_filename = logFileName
20
+ @protocol = File.open(@protocol_filename, "w")
21
+ @assertCounter = -1
22
+ @associations = {}
23
+ @consts = {}
24
+ end
25
+
26
+ def emptyReadBuffer(progress)
27
+ empty = false
28
+ while !empty do
29
+ begin
30
+ c = ""
31
+ @z3outAndErr.read_nonblock(1, c)
32
+ line = c + @z3outAndErr.gets
33
+ checkLineForSolverError(line, progress)
34
+ @protocol.puts("; Solver Response: #{line}")
35
+ rescue Errno::EWOULDBLOCK
36
+ # there is nothing to read -> just continue
37
+ empty = true
38
+ rescue Errno::EAGAIN
39
+ # there is nothing to read -> just continue
40
+ empty = true
41
+ rescue EOFError
42
+ # there is nothing to read -> just continue
43
+ empty = true
44
+ end
45
+ end
46
+ end
47
+
48
+ def declare_const(progress, name, type, info)
49
+ info[:type] = type
50
+ @consts[name] = info
51
+ to_solver(progress, "(declare-const #{name} #{type})")
52
+ end
53
+
54
+ def to_solver(progress, line)
55
+ emptyReadBuffer(progress);
56
+ @protocol.puts(line)
57
+ begin
58
+ @z3in.puts(line)
59
+ rescue Errno::EINVAL
60
+ progress.print_line "ERROR: could not write the line #{line.inspect} to the solver."
61
+ writeProtocolToFile("failure.smt2")
62
+ rescue Errno::EPIPE
63
+ writeProtocolToFile("failure.smt2")
64
+ throw RuntimeError.new("Broken Pipe")
65
+ end
66
+ end
67
+
68
+ def checkLineForSolverError(line, progress)
69
+ if line =~ /\(error \"(.*)\"\)/ then
70
+ progress.print_line("WARN: Solver reported ERROR: #{$1}")
71
+ end
72
+ end
73
+
74
+
75
+ def from_solver(progress)
76
+ result = @z3outAndErr.gets
77
+ checkLineForSolverError(result, progress)
78
+ @protocol.puts("; Solver Response: #{result}")
79
+ return result
80
+ end
81
+
82
+ def replace_strings(line)
83
+ line.gsub(/"([^"\\]|\\.)*"/, "")
84
+ end
85
+
86
+ def read_multiline_from_solver()
87
+ result = ""
88
+ for_counting = ""
89
+ @protocol.puts
90
+ @protocol.puts("; BEGIN Multiline Solver Response:")
91
+ line = @z3outAndErr.gets
92
+ result += line
93
+ for_counting += replace_strings(line)
94
+ @protocol.puts("; #{line}")
95
+
96
+ num_opening = for_counting.count("(")
97
+ num_closing = for_counting.count(")")
98
+ while num_opening > num_closing do
99
+ line = @z3outAndErr.gets
100
+ result += line
101
+ for_counting += replace_strings(line)
102
+ @protocol.puts("; #{line}")
103
+
104
+ num_opening = for_counting.count("(")
105
+ num_closing = for_counting.count(")")
106
+ end
107
+ @protocol.puts("; END Multiline Solver Response")
108
+ return result
109
+ end
110
+
111
+ def writeProtocolToFile(progress, filename)
112
+ dir = File.dirname(filename)
113
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
114
+ FileUtils.cp(@protocol_filename, filename) unless @protocol_filename == filename
115
+ progress.print_line "Solver protocol written to file #{filename}"
116
+ end
117
+
118
+ def generateNextAssertionIDAndAssociateWith(hash)
119
+ nextAssertionID = "a#{@assertCounter += 1}"
120
+ @associations[nextAssertionID] = hash
121
+ return nextAssertionID
122
+ end
123
+
124
+ def getAssociationForAssertionID(assertionID)
125
+ return @associations[assertionID]
126
+ end
127
+
128
+ def for_each_const(&block)
129
+ @consts.each do |key, value|
130
+ block.call(key, value)
131
+ end
132
+ end
133
+
134
+ def get_const_info(name)
135
+ @consts[name]
136
+ end
137
+ end
138
+
139
+ class Z3Solver
140
+
141
+ def initialize(progress, z3path)
142
+ z3version = `#{z3path} --version`.chomp()
143
+ if $?.exitstatus == 0 then
144
+ progress.print_line "INFO: Using #{z3version} found in z3path=#{z3path}"
145
+ @z3in, @z3outAndErr, @z3thr = Open3.popen2e(z3path, '-in', '-smt2')
146
+ Process.detach(@z3thr.pid)
147
+ else
148
+ progress.print_line "WARN: could not find z3 in z3path=#{z3path.inspect}"
149
+ `which z3`
150
+ if $?.exitstatus == 0 then
151
+ z3version = `z3 --version`.chomp()
152
+ progress.print_line "However, #{z3version} is availlable on the PATH. Using this one."
153
+ @z3in, @z3outAndErr, @z3thr = Open3.popen2e('z3', '-in', '-smt2')
154
+ Process.detach(@z3thr.pid)
155
+ else
156
+ raise RuntimeError.new("ERROR: unable to find Z3. Exiting! (Please provide its location in the config file using the key :z3path)")
157
+ end
158
+ end
159
+ @z3in.sync = true
160
+
161
+ @sessionIdCounter = 0
162
+ end
163
+
164
+ def close()
165
+ @z3in.close()
166
+ @z3outAndErr.close()
167
+ Thread.kill(@z3thr)
168
+ end
169
+
170
+ def query(&block)
171
+ solver = SolverSession.new(@z3in, @z3outAndErr)
172
+
173
+ block.call(solver)
174
+
175
+ solver.toSolver "(check-sat)"
176
+ result = solver.fromSolver()
177
+ if result.chomp == "sat"
178
+ # model is not contradictory -> continue
179
+ elsif result.chomp == "unsat"
180
+ # model is contradictory -> add a message
181
+
182
+ solver.toSolver("(get-unsat-core)")
183
+ unsat_core = solver.fromSolver()
184
+ explanation = build_explanation_based_on_unsat_core(solver, sid, unsat_core)
185
+ messages << {:type => :issue, :text => "Model is contradictory", :explanation => explanation}
186
+ solver.writeProtocolToFile("contradictions.smt2")
187
+ else
188
+ handleSolverError(solver, result)
189
+ end
190
+
191
+ messages = []
192
+ solver.toSolver "(reset)"
193
+ solver.toSolver "(set-logic ALL)"
194
+ solver.toSolver "(set-option :produce-unsat-cores true)"
195
+ return messages
196
+ end
197
+
198
+ # main entry point!
199
+ def query_model(progress, options, &block)
200
+ unless options[:solverLog]
201
+ raise RuntimeError.new("No solverLog specified, but the option :solverLog is required!")
202
+ end
203
+ progress.print_line("calling solver (solver-log is written to #{options[:solverLog]})")
204
+ solver = SolverSession.new(@z3in, @z3outAndErr, options[:solverLog])
205
+
206
+ block.call(solver)
207
+
208
+ solver.to_solver(progress, "(check-sat)")
209
+ result = solver.from_solver(progress)
210
+ if result.chomp == "sat"
211
+ # model is not contradictory -> extract model
212
+ const_list = []
213
+ const_info = {}
214
+ solver.for_each_const do |name, info|
215
+ const_list << name
216
+ const_info[name] = info
217
+ end
218
+ solver.to_solver(progress, "(get-value (#{const_list.join(" ")}))")
219
+ model_str = solver.read_multiline_from_solver()
220
+ model = {}
221
+ foreach_field_in_model_str(model_str, solver, const_info) do |varname, value, info|
222
+ unless info then
223
+ progress.print_line "WARN: no info availlable for varname=#{varname.inspect}, value=#{value.inspect}"
224
+ end
225
+ xpath = info[:xpath]
226
+ if model.has_key?(xpath) then
227
+ if varname.end_with?("-filled") then
228
+ unless model[xpath][:type] == :field then
229
+ raise RuntimeError.new("inconsistent model: xpath #{xpath} should be a field, not a #{model[xpath][:type]}.")
230
+ end
231
+ unless value == "true" or value == "false" then
232
+ raise RuntimeError.new("inconsistent model value filled-value of #{xpath} should be a Bool, but was #{value.inspect}.")
233
+ end
234
+ if value == "true" then
235
+ model[xpath][:filled] = true
236
+ else
237
+ model[xpath][:filled] = false
238
+ end
239
+ elsif varname.end_with?("-value") then
240
+ unless model[xpath][:type] == :field || model[xpath][:type] == :list then
241
+ raise RuntimeError.new("inconsistent model: xpath #{xpath} should be a field or a list, not a #{model[xpath][:type]}.")
242
+ end
243
+ if model[xpath][:type] == :field then
244
+ model[xpath][:value] = convert_solver_value(info[:type], info[:datatype], value, xpath)
245
+ elsif model[xpath][:type] == :list then
246
+ unless varname =~ /-index-(\d+)-value$/
247
+ raise RuntimeError.new("inconsistent model: xpath #{xpath} is a list value and hence its varname should end like -index-X-value, not #{varname.inspect}.")
248
+ end
249
+ index = Integer($1)
250
+ model[xpath][:xpath_element] = info[:xpath_element]
251
+ if model[xpath].has_key?(:value) then
252
+ val = convert_solver_value(info[:type], info[:datatype], value, xpath)
253
+ model[xpath][:value].insert(index, val)
254
+ else
255
+ val = convert_solver_value(info[:type], info[:datatype], value, xpath)
256
+ model[xpath][:value] = [val]
257
+ end
258
+ end
259
+ elsif varname.end_with?("-size") then
260
+ unless model[xpath][:type] == :list then
261
+ raise RuntimeError.new("inconsistent model: xpath #{xpath} should be a list, not a #{model[xpath][:type]}.")
262
+ end
263
+ model[xpath][:value] = value
264
+ elsif varname.start_with?("struct-") && varname.end_with?("-exists") then
265
+ unless model[xpath][:type] == :struct then
266
+ raise RuntimeError.new("inconsistent model: xpath #{xpath} should be a struct, not a #{model[xpath][:type]}.")
267
+ end
268
+ model[xpath][:exists] = value
269
+ end
270
+ else
271
+ if varname.end_with?("-filled") then
272
+ unless value == "true" or value == "false" then
273
+ raise RuntimeError.new("inconsistent model value filled-value of #{xpath} should be a Bool, but was #{value.inspect}.")
274
+ end
275
+ if value == "true" then
276
+ model[xpath] = { :type => :field, :filled => true }
277
+ else
278
+ model[xpath] = { :type => :field, :filled => false }
279
+ end
280
+ elsif varname.end_with?("-value") then
281
+ check_type(info[:type], value, xpath)
282
+ model[xpath] = { :type => :field, :value => value }
283
+ elsif varname.start_with?("struct-") && varname.end_with?("-exists") then
284
+ unless value == "true" or value == "false" then
285
+ raise RuntimeError.new("inconsistent model value exists-value of struct #{xpath} should be a Bool, but was #{value.inspect}.")
286
+ end
287
+ model[xpath] = { :type => :struct, :exists => value }
288
+ elsif varname.end_with?("-size") then
289
+ model[xpath] = { :type => :list, :size => value }
290
+ end
291
+ end
292
+ end
293
+ return model
294
+ elsif result.chomp == "unsat"
295
+ # model is contradictory -> add a message
296
+
297
+ solver.to_solver(progress, "(get-unsat-core)")
298
+ unsat_core = solver.from_solver(progress)
299
+ solver.writeProtocolToFile(progress, options[:solverLog])
300
+ progress.print_line "Solver reported a contradiction! please see #{options[:solverLog]} for further information."
301
+ return nil
302
+ else
303
+ handleSolverError(progress, solver, options, result)
304
+ end
305
+
306
+ messages = []
307
+ solver.to_solver "(reset)"
308
+ solver.to_solver "(set-logic ALL)"
309
+ solver.to_solver "(set-option :produce-unsat-cores true)"
310
+
311
+ return messages
312
+ end
313
+
314
+ def check_type(type, value, xpath)
315
+ if type == "String" then
316
+ unless value =~ /\".*\"/ then
317
+ raise RuntimeError.new("inconsistent model value: model value for #{xpath} should be a String, but was #{value}.")
318
+ end
319
+ elsif type == "Bool"
320
+ unless value == "true" or value == "false" then
321
+ raise RuntimeError.new("inconsistent model value for #{xpath} should be a Bool, but was #{value.inspect}.")
322
+ end
323
+ elsif type == "Int"
324
+ unless value =~ /\d+/ then
325
+ raise RuntimeError.new("inconsistent model value: model value for #{xpath} should be a Int, but was #{value}.")
326
+ end
327
+ else
328
+ raise RuntimeError.new("encountered unknown solver type: type of solver-var for #{xpath} is #{info[:type]}.")
329
+ end
330
+ end
331
+
332
+ def convert_solver_value(type, datatype, value, xpath)
333
+ check_type(type, value, xpath)
334
+ if datatype == :date then
335
+ return (Time.at(0).to_date + Integer(value)).to_s
336
+ elsif datatype == :timestamp then
337
+ return Time.at(Integer(value)).to_s
338
+ else
339
+ return value
340
+ end
341
+ end
342
+
343
+ def foreach_field_in_model_str(str, solver, const_info, &block)
344
+ unless str =~ /^\((.*)\)$/m
345
+ raise RuntimeError.new("could not parse model: #{str.inspect}")
346
+ end
347
+ str = $1
348
+
349
+ str.split("\n").each do |line|
350
+ unless line =~ /\(([^\ ]*)\ (.*)\)/
351
+ raise RuntimeError.new("could not parse model line: #{line.inspect}")
352
+ end
353
+ varname = $1
354
+ value = $2
355
+ if value =~ /\(-\ (\d+)\)/ then
356
+ simplified = "-#{$1}"
357
+ value = simplified
358
+ end
359
+ info = const_info[varname]
360
+
361
+ block.call(varname, value, info)
362
+ end
363
+
364
+ return nil
365
+ end
366
+
367
+ def handleSolverError(progress, solver, options, result)
368
+ solver.writeProtocolToFile(progress, options[:solverLog])
369
+ raise RuntimeError.new("Solver Error: Solver returned '#{result}', for more information see #{options[:solverLog]}")
370
+ end
371
+
372
+ end
373
+
374
+
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mbt-gen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - FM-enthusiast
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.18'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.18'
27
+ description: |
28
+ DSL for modelling XML structure along with logic-based validation rules / constraints.
29
+ Supports SMT-solver based generation of sample valid XML documents.
30
+ email: nf4eai9s@anonaddy.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - lib/mbt-gen.rb
36
+ - lib/progress.rb
37
+ - lib/regexp-to-smtlib.rb
38
+ - lib/solver-lib.rb
39
+ homepage: https://github.com/FM-enthusiast/MBT-gen
40
+ licenses:
41
+ - GPL-3.0-only
42
+ metadata:
43
+ bug_tracker_uri: https://github.com/FM-enthusiast/MBT-gen/issues
44
+ source_code_uri: https://github.com/FM-enthusiast/MBT-gen
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.0.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements:
60
+ - an SMT-solver. Z3 is recommended, it can be obtained from https://github.com/Z3Prover/z3
61
+ rubygems_version: 3.5.22
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: test data generator for Model-based testing
65
+ test_files: []