rivescript 0.1.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.
@@ -0,0 +1,581 @@
1
+ # RiveScript Ruby port, https://jvmlab.org/, MIT License
2
+
3
+ require_relative "utils"
4
+
5
+ class RiveScript
6
+ # The version of the RiveScript language we support.
7
+ RS_VERSION = "2.0"
8
+
9
+ # Parser for RiveScript syntax.
10
+ class Parser
11
+ CONCAT_MODES = {
12
+ "none" => "",
13
+ "newline" => "\n",
14
+ "space" => " "
15
+ }.freeze
16
+
17
+ def initialize(master)
18
+ @master = master
19
+ @strict = master._strict
20
+ @utf8 = master._utf8
21
+ end
22
+
23
+ # Proxy functions
24
+ def say(message)
25
+ @master.say(message)
26
+ end
27
+
28
+ def warn(message, filename = nil, lineno = nil)
29
+ @master.warn(message, filename, lineno)
30
+ end
31
+
32
+ # Read and parse a RiveScript document.
33
+ def parse(filename, code, on_error = nil)
34
+ on_error ||= lambda { |err, fname, lineno| warn(err, fname, lineno) }
35
+
36
+ ast = {
37
+ "begin" => {
38
+ "global" => {},
39
+ "var" => {},
40
+ "sub" => {},
41
+ "person" => {},
42
+ "array" => {}
43
+ },
44
+ "topics" => {},
45
+ "objects" => []
46
+ }
47
+
48
+ topic = "random"
49
+ comment = false
50
+ inobj = false
51
+ obj_name = ""
52
+ obj_lang = ""
53
+ obj_buf = []
54
+ cur_trig = nil
55
+ is_that = nil
56
+
57
+ local_options = {
58
+ "concat" => @master._concat.nil? ? "none" : @master._concat
59
+ }
60
+
61
+ lines = code.split("\n")
62
+ lines.each_with_index do |raw_line, lp|
63
+ line = Utils.strip(raw_line)
64
+ lineno = lp + 1
65
+
66
+ next if line.empty?
67
+
68
+ if inobj
69
+ if line.include?("< object") || line.include?("<object")
70
+ if !obj_name.empty?
71
+ ast["objects"] << {
72
+ "name" => obj_name,
73
+ "language" => obj_lang,
74
+ "code" => obj_buf
75
+ }
76
+ end
77
+ obj_name = ""
78
+ obj_lang = ""
79
+ obj_buf = []
80
+ inobj = false
81
+ else
82
+ obj_buf << line
83
+ end
84
+ next
85
+ end
86
+
87
+ if line.start_with?("//")
88
+ next
89
+ elsif line.start_with?("#")
90
+ warn("Using the # symbol for comments is deprecated", filename, lineno)
91
+ next
92
+ elsif line.start_with?("/*")
93
+ if line.include?("*/")
94
+ next
95
+ end
96
+
97
+ comment = true
98
+ next
99
+ elsif line.include?("*/")
100
+ comment = false
101
+ next
102
+ end
103
+ next if comment
104
+
105
+ if line.length < 2
106
+ warn("Weird single-character line '#{line}' found (in topic #{topic})", filename, lineno)
107
+ next
108
+ end
109
+
110
+ cmd = line[0]
111
+ line = Utils.strip(line[1..])
112
+
113
+ if line.include?(" //")
114
+ line = Utils.strip(line.split(" //", 2)[0])
115
+ end
116
+
117
+ if cmd == "?"
118
+ variants = [
119
+ line,
120
+ "[*]#{line}[*]",
121
+ "*#{line}*",
122
+ "[*]#{line}*",
123
+ "*#{line}[*]",
124
+ "#{line}*",
125
+ "*#{line}"
126
+ ]
127
+ cmd = "+"
128
+ line = "(#{variants.join('|')})"
129
+ say("Rewrote ?Keyword as +Trigger: #{line}")
130
+ end
131
+
132
+ if @master._forceCase == true && cmd == "+"
133
+ line = line.downcase
134
+ end
135
+
136
+ syntax_error = check_syntax(cmd, line)
137
+ unless syntax_error.empty?
138
+ if @strict
139
+ on_error.call("Syntax error: #{syntax_error} at #{filename} line #{lineno} near #{cmd} #{line}", filename, lineno)
140
+ else
141
+ warn("Syntax error: #{syntax_error} at #{filename} line #{lineno} near #{cmd} #{line} (in topic #{topic})", filename, lineno)
142
+ end
143
+ end
144
+
145
+ is_that = nil if cmd == "+"
146
+
147
+ say("Cmd: #{cmd}; line: #{line}")
148
+
149
+ ((lp + 1)...lines.length).each do |li|
150
+ lookahead = Utils.strip(lines[li])
151
+ next if lookahead.length < 2
152
+
153
+ look_cmd = lookahead[0]
154
+ lookahead = Utils.strip(lookahead[1..])
155
+
156
+ break unless ["%", "^"].include?(look_cmd)
157
+ break if lookahead.empty?
158
+
159
+ say("\tLookahead #{li}: #{look_cmd} #{lookahead}")
160
+
161
+ if cmd == "+"
162
+ if look_cmd == "%"
163
+ is_that = lookahead
164
+ break
165
+ else
166
+ is_that = nil
167
+ end
168
+ end
169
+
170
+ if cmd == "!"
171
+ if look_cmd == "^"
172
+ line += "<crlf>#{lookahead}"
173
+ end
174
+ next
175
+ end
176
+
177
+ if cmd != "^" && look_cmd != "%"
178
+ if look_cmd == "^"
179
+ if CONCAT_MODES.key?(local_options["concat"])
180
+ line += CONCAT_MODES[local_options["concat"]] + lookahead
181
+ else
182
+ line += lookahead
183
+ end
184
+ else
185
+ break
186
+ end
187
+ end
188
+ end
189
+
190
+ type = ""
191
+ name = ""
192
+
193
+ case cmd
194
+ when "!"
195
+ halves = line.split("=", 2)
196
+ left = Utils.strip(halves[0]).split(" ")
197
+ value = ""
198
+ name = ""
199
+ type = ""
200
+ value = Utils.strip(halves[1]) if halves.length == 2
201
+
202
+ if left.length >= 1
203
+ type = Utils.strip(left[0])
204
+ if left.length >= 2
205
+ left.shift
206
+ name = Utils.strip(left.join(" "))
207
+ end
208
+ end
209
+
210
+ value = value.gsub("<crlf>", "") unless type == "array"
211
+
212
+ if type == "version"
213
+ if value.to_f > RS_VERSION.to_f
214
+ on_error.call("Unsupported RiveScript version. We only support #{RS_VERSION} at #{filename} line #{lineno}", filename, lineno)
215
+ return ast
216
+ end
217
+ next
218
+ end
219
+
220
+ if name.empty?
221
+ warn("Undefined variable name", filename, lineno)
222
+ next
223
+ end
224
+ if value.empty?
225
+ warn("Undefined variable value", filename, lineno)
226
+ next
227
+ end
228
+
229
+ case type
230
+ when "local"
231
+ say("\tSet local parser option #{name} = #{value}")
232
+ local_options[name] = value
233
+ when "global"
234
+ say("\tSet global #{name} = #{value}")
235
+ ast["begin"]["global"][name] = value
236
+ when "var"
237
+ say("\tSet bot variable #{name} = #{value}")
238
+ ast["begin"]["var"][name] = value
239
+ when "array"
240
+ if value == "<undef>"
241
+ ast["begin"]["array"][name] = "<undef>"
242
+ next
243
+ end
244
+
245
+ parts = value.split("<crlf>")
246
+ fields = []
247
+ parts.each do |val|
248
+ if val.include?("|")
249
+ fields.concat(val.split("|"))
250
+ else
251
+ fields.concat(val.split(" "))
252
+ end
253
+ end
254
+
255
+ fields.map! { |field| field.gsub(/\\s/i, " ") }
256
+ fields.reject!(&:empty?)
257
+
258
+ say("\tSet array #{name} = #{fields.inspect}")
259
+ ast["begin"]["array"][name] = fields
260
+ when "sub"
261
+ say("\tSet substitution #{name} = #{value}")
262
+ ast["begin"]["sub"][name] = value
263
+ when "person"
264
+ say("\tSet person substitution #{name} = #{value}")
265
+ ast["begin"]["person"][name] = value
266
+ else
267
+ warn("Unknown definition type #{type}", filename, lineno)
268
+ end
269
+ when ">"
270
+ temp = Utils.strip(line).split(" ")
271
+ type = temp.shift
272
+ name = ""
273
+ fields = []
274
+ name = temp.shift if temp.length > 0
275
+ fields = temp if temp.length > 0
276
+
277
+ case type
278
+ when "begin", "topic"
279
+ if type == "begin"
280
+ say("Found the BEGIN block.")
281
+ type = "topic"
282
+ name = "__begin__"
283
+ end
284
+
285
+ name = name.downcase if @master._forceCase == true
286
+
287
+ say("Set topic to #{name}")
288
+ cur_trig = nil
289
+ topic = name
290
+
291
+ init_topic(ast["topics"], topic)
292
+
293
+ mode = ""
294
+ if fields.length >= 2
295
+ fields.each do |field|
296
+ if ["includes", "inherits"].include?(field)
297
+ mode = field
298
+ elsif !mode.empty?
299
+ ast["topics"][topic][mode][field] = 1
300
+ end
301
+ end
302
+ end
303
+ when "object"
304
+ lang = ""
305
+ lang = fields[0].downcase if fields.length > 0
306
+
307
+ if lang.empty?
308
+ warn("Trying to parse unknown programming language", filename, lineno)
309
+ lang = "ruby"
310
+ end
311
+
312
+ obj_name = name
313
+ obj_lang = lang
314
+ obj_buf = []
315
+ inobj = true
316
+ else
317
+ warn("Unknown label type #{type}", filename, lineno)
318
+ end
319
+ when "<"
320
+ type = line
321
+ if ["begin", "topic"].include?(type)
322
+ say("\tEnd the topic label.")
323
+ topic = "random"
324
+ elsif type == "object"
325
+ say("\tEnd the object label.")
326
+ inobj = false
327
+ end
328
+ when "+"
329
+ say("\tTrigger pattern: #{line}")
330
+
331
+ init_topic(ast["topics"], topic)
332
+ cur_trig = {
333
+ "trigger" => line,
334
+ "reply" => [],
335
+ "condition" => [],
336
+ "redirect" => nil,
337
+ "previous" => is_that
338
+ }
339
+ ast["topics"][topic]["triggers"] << cur_trig
340
+ when "-"
341
+ if cur_trig.nil?
342
+ warn("Response found before trigger", filename, lineno)
343
+ next
344
+ end
345
+
346
+ if !cur_trig["redirect"].nil?
347
+ warn("You can't mix @Redirects with -Replies", filename, lineno)
348
+ end
349
+
350
+ say("\tResponse: #{line}")
351
+ cur_trig["reply"] << line
352
+ when "*"
353
+ if cur_trig.nil?
354
+ warn("Condition found before trigger", filename, lineno)
355
+ next
356
+ end
357
+
358
+ if !cur_trig["redirect"].nil?
359
+ warn("You can't mix @Redirects with *Conditions", filename, lineno)
360
+ end
361
+
362
+ say("\tCondition: #{line}")
363
+ cur_trig["condition"] << line
364
+ when "%", "^"
365
+ next
366
+ when "@"
367
+ if cur_trig["reply"].length > 0 || cur_trig["condition"].length > 0
368
+ warn("You can't mix @Redirects with -Replies or *Conditions", filename, lineno)
369
+ end
370
+ say("\tRedirect response to: #{line}")
371
+ cur_trig["redirect"] = Utils.strip(line)
372
+ else
373
+ warn("Unknown command '#{cmd}' (in topic #{topic})", filename, lineno)
374
+ end
375
+ end
376
+
377
+ ast
378
+ end
379
+
380
+ # Translate deparsed data into the source code of a RiveScript document.
381
+ def stringify(deparsed = nil)
382
+ deparsed = @master.deparse if deparsed.nil?
383
+
384
+ write_triggers = lambda do |triggers, indent|
385
+ id = indent ? "\t" : ""
386
+ output = []
387
+ triggers.each do |t|
388
+ output << "#{id}+ #{t['trigger']}"
389
+ output << "#{id}% #{t['previous']}" if t["previous"]
390
+ t["condition"]&.each do |c|
391
+ output << "#{id}* #{c.gsub("\n", "\\n")}"
392
+ end
393
+ output << "#{id}@ #{t['redirect']}" if t["redirect"]
394
+ t["reply"]&.each do |r|
395
+ output << "#{id}- #{r.gsub("\n", "\\n")}" if r
396
+ end
397
+ output << ""
398
+ end
399
+ output
400
+ end
401
+
402
+ source = ["! version = 2.0", "! local concat = none", ""]
403
+ ref = ["global", "var", "sub", "person", "array"]
404
+
405
+ ref.each do |begin_type|
406
+ next if deparsed["begin"][begin_type].nil? || deparsed["begin"][begin_type].empty?
407
+
408
+ deparsed["begin"][begin_type].each do |key, value|
409
+ if begin_type != "array"
410
+ source << "! #{begin_type} #{key} = #{value}"
411
+ else
412
+ pipes = " "
413
+ value.each do |test|
414
+ if test.match?(/\s+/)
415
+ pipes = "|"
416
+ break
417
+ end
418
+ end
419
+ source << "! #{begin_type} #{key} = #{value.join(pipes)}"
420
+ end
421
+ end
422
+ source << ""
423
+ end
424
+
425
+ if deparsed["objects"]
426
+ deparsed["objects"].each do |lang, lang_objects|
427
+ next unless lang_objects && lang_objects["_objects"]
428
+
429
+ sources = lang_objects["_sources"] || {}
430
+ lang_objects["_objects"].each do |func, code|
431
+ source << "> object #{func} #{lang}"
432
+ if sources[func]
433
+ source << sources[func].to_s.split("\n").map { |ln| "\t#{ln}" }.join("\n")
434
+ elsif code.is_a?(String)
435
+ body = code.to_s.match(/function[^{]+\{\n*([\s\S]*)\};?\s*$/m)
436
+ source << body[1].strip.split("\n").map { |ln| "\t#{ln}" }.join("\n") if body
437
+ elsif code.respond_to?(:source)
438
+ # no-op for procs without source
439
+ end
440
+ source << "< object\n"
441
+ end
442
+ end
443
+ end
444
+
445
+ if deparsed["begin"]["triggers"] && deparsed["begin"]["triggers"].length > 0
446
+ source << "> begin\n"
447
+ source.concat(write_triggers.call(deparsed["begin"]["triggers"], "indent"))
448
+ source << "< begin\n"
449
+ end
450
+
451
+ topics = deparsed["topics"].keys.sort
452
+ topics.unshift("random")
453
+ done_random = false
454
+
455
+ topics.each do |topic_name|
456
+ next unless deparsed["topics"].key?(topic_name)
457
+ next if topic_name == "random" && done_random
458
+
459
+ done_random = true if topic_name == "random"
460
+
461
+ tagged = false
462
+ tagline = []
463
+ if topic_name != "random" ||
464
+ (!(deparsed["inherits"][topic_name] || {}).empty? || !(deparsed["includes"][topic_name] || {}).empty?)
465
+ tagged = true if topic_name != "random"
466
+
467
+ inherits = (deparsed["inherits"][topic_name] || {}).keys
468
+ includes = (deparsed["includes"][topic_name] || {}).keys
469
+
470
+ if includes.length > 0
471
+ tagline.concat(["includes"] + includes)
472
+ tagged = true
473
+ end
474
+ if inherits.length > 0
475
+ tagline.concat(["inherits"] + inherits)
476
+ tagged = true
477
+ end
478
+ end
479
+
480
+ if tagged
481
+ source << ("> topic #{topic_name} " + tagline.join(" ")).strip + "\n"
482
+ end
483
+
484
+ source.concat(write_triggers.call(deparsed["topics"][topic_name], tagged))
485
+
486
+ source << "< topic\n" if tagged
487
+ end
488
+
489
+ source.join("\n")
490
+ end
491
+
492
+ # Check the syntax of a RiveScript command.
493
+ def check_syntax(cmd, line)
494
+ case cmd
495
+ when "!"
496
+ unless line.match?(/\A.+(?:\s+.+|)\s*=\s*.+?\z/)
497
+ return "Invalid format for !Definition line: must be '! type name = value' OR '! type = value'"
498
+ end
499
+
500
+ if line.match?(/^array/)
501
+ if line.match?(/=\s?\||\|\s?$/)
502
+ return "Piped arrays can't begin or end with a |"
503
+ elsif line.match?(/\|\|/)
504
+ return "Piped arrays can't include blank entries"
505
+ end
506
+ end
507
+ when ">"
508
+ parts = line.split(/\s+/)
509
+ if parts[0] == "begin" && parts.length > 1
510
+ return "The 'begin' label takes no additional arguments"
511
+ elsif parts[0] == "topic"
512
+ if !@master._forceCase && line.match?(/[^a-z0-9_\-\s]/)
513
+ return "Topics should be lowercased and contain only letters and numbers"
514
+ elsif line.match?(/[^A-Za-z0-9_\-\s]/)
515
+ return "Topics should contain only letters and numbers in forceCase mode"
516
+ end
517
+ elsif parts[0] == "object"
518
+ if line.match?(/[^A-Za-z0-9_\-\s]/)
519
+ return "Objects can only contain numbers and letters"
520
+ end
521
+ end
522
+ when "+", "%", "@"
523
+ parens = 0
524
+ square = 0
525
+ curly = 0
526
+ angle = 0
527
+
528
+ if @utf8
529
+ if line.match?(/[A-Z\\.]/)
530
+ return "Triggers can't contain uppercase letters, backslashes or dots in UTF-8 mode"
531
+ end
532
+ elsif line.match?(/[^a-z0-9(|)\[\]*_#@{}<>=\/\s]/)
533
+ return "Triggers may only contain lowercase letters, numbers, and these symbols: ( | ) [ ] * _ # { } < > = /"
534
+ elsif line.match?(/\(\||\|\)/)
535
+ return "Piped alternations can't begin or end with a |"
536
+ elsif line.match?(/\([^\)].+\|\|.+\)/)
537
+ return "Piped alternations can't include blank entries"
538
+ elsif line.match?(/\[\||\|\]/)
539
+ return "Piped optionals can't begin or end with a |"
540
+ elsif line.match?(/\[[^\]].+\|\|.+\]/)
541
+ return "Piped optionals can't include blank entries"
542
+ end
543
+
544
+ line.each_char do |char|
545
+ case char
546
+ when "(" then parens += 1
547
+ when ")" then parens -= 1
548
+ when "[" then square += 1
549
+ when "]" then square -= 1
550
+ when "{" then curly += 1
551
+ when "}" then curly -= 1
552
+ when "<" then angle += 1
553
+ when ">" then angle -= 1
554
+ end
555
+ end
556
+
557
+ return "Unmatched parenthesis brackets" if parens != 0
558
+ return "Unmatched square brackets" if square != 0
559
+ return "Unmatched curly brackets" if curly != 0
560
+ return "Unmatched angle brackets" if angle != 0
561
+ when "*"
562
+ unless line.match?(/\A.+?\s*(?:==|eq|!=|ne|<>|<|<=|>|>=)\s*.+?=>.+?\z/)
563
+ return "Invalid format for !Condition: should be like '* value symbol value => response'"
564
+ end
565
+ end
566
+
567
+ ""
568
+ end
569
+
570
+ # Initialize the topic tree for the parsing phase.
571
+ def init_topic(topics, name)
572
+ return unless topics[name].nil?
573
+
574
+ topics[name] = {
575
+ "includes" => {},
576
+ "inherits" => {},
577
+ "triggers" => []
578
+ }
579
+ end
580
+ end
581
+ end