tracery 0.7.0
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.
- checksums.yaml +7 -0
- data/lib/mods-eng-basic.rb +88 -0
- data/lib/tracery.rb +637 -0
- data/test/tracery_test.rb +155 -0
- metadata +51 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1ec31ad1ba40cf47f031700475119b6a474300f0
|
4
|
+
data.tar.gz: 5efe8a2fbe3751bbbdbef4545336b86b84063ae3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4e9f09c43cbb638172af396452399c04cadb338a2414ea25625b995ddace2ceef805fce1a5595b56d13f2f19855850face9f0eef00ad8775adcf73e081a21e5b
|
7
|
+
data.tar.gz: be63c6d1c29154a101cd3a91cc838be9b9f4fbfd6031a0489ffd474851a39ff71b7eedbace080dc017a826ffa564e14d7f9c99b7597a331188de459a72210fa9
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Modifiers
|
2
|
+
def self.isVowel(c)
|
3
|
+
return ['a', 'e', 'i', 'o', 'u'].member?(c.downcase)
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.pluralize(s)
|
7
|
+
case(s[-1])
|
8
|
+
when 's' then
|
9
|
+
return s + "es"
|
10
|
+
when 'h' then
|
11
|
+
return s + "es"
|
12
|
+
when 'x' then
|
13
|
+
return s + "es"
|
14
|
+
when 'y' then
|
15
|
+
if(!isVowel(s[-2])) then
|
16
|
+
return s[0...-1] + "ies"
|
17
|
+
else
|
18
|
+
return s + "s"
|
19
|
+
end
|
20
|
+
else
|
21
|
+
return s + "s"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.baseEngModifiers
|
26
|
+
{
|
27
|
+
"replace" => lambda do |s, parameters|
|
28
|
+
return s.gsub(/#{Regexp.quote(parameters[0])}/, parameters[1])
|
29
|
+
end,
|
30
|
+
|
31
|
+
"capitalizeAll" => lambda do |s, parameters|
|
32
|
+
return s.gsub(/\w+/) {|word| word.capitalize}
|
33
|
+
end,
|
34
|
+
|
35
|
+
"capitalize" => lambda do |s, parameters|
|
36
|
+
return s.capitalize
|
37
|
+
end,
|
38
|
+
|
39
|
+
"a" => lambda do |s, parameters|
|
40
|
+
if(s.length > 0) then
|
41
|
+
if(s =~ /^u((\wi)|(\W))/) then
|
42
|
+
#catches "university" and "u-boat"
|
43
|
+
return "a #{s}"
|
44
|
+
end
|
45
|
+
|
46
|
+
if(isVowel(s[0])) then
|
47
|
+
return "an #{s}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
return "a #{s}"
|
52
|
+
end,
|
53
|
+
|
54
|
+
"firstS" => lambda do |s, parameters|
|
55
|
+
words = s.split(" ")
|
56
|
+
if(words.length > 0) then
|
57
|
+
words[0] = pluralize words[0]
|
58
|
+
end
|
59
|
+
return words.join " "
|
60
|
+
end,
|
61
|
+
|
62
|
+
"s" => lambda do |s, parameters|
|
63
|
+
return pluralize(s)
|
64
|
+
end,
|
65
|
+
|
66
|
+
"ed" => lambda do |s, parameters|
|
67
|
+
case(s[-1])
|
68
|
+
when 's' then
|
69
|
+
return s + "ed"
|
70
|
+
when 'e' then
|
71
|
+
return s + "d"
|
72
|
+
when 'h' then
|
73
|
+
return s + "ed"
|
74
|
+
when 'x' then
|
75
|
+
return s + "ed"
|
76
|
+
when 'y' then
|
77
|
+
if(!isVowel(s[-2])) then
|
78
|
+
return s[0...-1] + "ied"
|
79
|
+
else
|
80
|
+
return s + "d"
|
81
|
+
end
|
82
|
+
else
|
83
|
+
return s + "ed"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
data/lib/tracery.rb
ADDED
@@ -0,0 +1,637 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
|
4
|
+
module Tracery
|
5
|
+
# Parses a plaintext rule in the tracery syntax
|
6
|
+
def createGrammar(raw)
|
7
|
+
return Grammar.new(raw)
|
8
|
+
end
|
9
|
+
|
10
|
+
def parseTag(tagContents)
|
11
|
+
parsed = {
|
12
|
+
symbol: nil,
|
13
|
+
preactions: [],
|
14
|
+
postactions: [],
|
15
|
+
modifiers: []
|
16
|
+
}
|
17
|
+
|
18
|
+
sections = parse(tagContents)[:sections]
|
19
|
+
symbolSection = nil;
|
20
|
+
sections.each do |section|
|
21
|
+
if(section[:type] == 0) then
|
22
|
+
if(symbolSection.nil?) then
|
23
|
+
symbolSection = section[:raw]
|
24
|
+
else
|
25
|
+
raise "multiple main sections in #{tagContents}"
|
26
|
+
end
|
27
|
+
else
|
28
|
+
parsed[:preactions].push(section)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
if(symbolSection.nil?) then
|
33
|
+
# raise "no main section in #{tagContents}"
|
34
|
+
else
|
35
|
+
|
36
|
+
components = symbolSection.split(".");
|
37
|
+
parsed[:symbol] = components.first
|
38
|
+
parsed[:modifiers] = components.drop(1)
|
39
|
+
end
|
40
|
+
|
41
|
+
return parsed
|
42
|
+
end
|
43
|
+
|
44
|
+
#TODO_: needs heavy refactoring -- no nesting in ruby (ie. move entire parser to another class w/ shared state)
|
45
|
+
def createSection(start, finish, type, results, lastEscapedChar, escapedSubstring, rule, errors)
|
46
|
+
if(finish - start < 1) then
|
47
|
+
if(type == 1) then
|
48
|
+
errors << "#{start}: empty tag"
|
49
|
+
else
|
50
|
+
if(type == 2) then
|
51
|
+
errors << "#{start}: empty action"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
rawSubstring = ""
|
56
|
+
if(!lastEscapedChar.nil?) then
|
57
|
+
rawSubstring = escapedSubstring + "\\" + rule[(lastEscapedChar+1)..-1]
|
58
|
+
else
|
59
|
+
rawSubstring = rule[start...finish]
|
60
|
+
end
|
61
|
+
|
62
|
+
results[:sections] << {
|
63
|
+
type: type,
|
64
|
+
raw: rawSubstring
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
def parse(rule)
|
69
|
+
depth = 0
|
70
|
+
inTag = false
|
71
|
+
results = {errors: [], sections: []}
|
72
|
+
escaped = false
|
73
|
+
|
74
|
+
errors = []
|
75
|
+
start = 0
|
76
|
+
|
77
|
+
escapedSubstring = ""
|
78
|
+
lastEscapedChar = nil
|
79
|
+
|
80
|
+
if(rule.nil?) then
|
81
|
+
sections = {errors: errors, sections: []}
|
82
|
+
return sections
|
83
|
+
end
|
84
|
+
|
85
|
+
rule.each_char.with_index do |c, i|
|
86
|
+
if(!escaped) then
|
87
|
+
case(c)
|
88
|
+
when '[' then
|
89
|
+
# Enter a deeper bracketed section
|
90
|
+
if(depth == 0 && !inTag) then
|
91
|
+
if(start < i) then
|
92
|
+
createSection(start, i, 0, results, lastEscapedChar, escapedSubstring, rule, errors)
|
93
|
+
lastEscapedChar = nil
|
94
|
+
escapedSubstring = ""
|
95
|
+
end
|
96
|
+
start = i + 1
|
97
|
+
end
|
98
|
+
depth += 1
|
99
|
+
when ']' then
|
100
|
+
depth -= 1
|
101
|
+
# End a bracketed section
|
102
|
+
if(depth == 0 && !inTag) then
|
103
|
+
createSection(start, i, 2, results, lastEscapedChar, escapedSubstring, rule, errors)
|
104
|
+
lastEscapedChar = nil
|
105
|
+
escapedSubstring = ""
|
106
|
+
start = i + 1
|
107
|
+
end
|
108
|
+
when '#' then
|
109
|
+
# Hashtag
|
110
|
+
# ignore if not at depth 0, that means we are in a bracket
|
111
|
+
if(depth == 0) then
|
112
|
+
if(inTag) then
|
113
|
+
createSection(start, i, 1, results, lastEscapedChar, escapedSubstring, rule, errors)
|
114
|
+
lastEscapedChar = nil
|
115
|
+
escapedSubstring = ""
|
116
|
+
start = i + 1
|
117
|
+
else
|
118
|
+
if(start < i) then
|
119
|
+
createSection(start, i, 0, results, lastEscapedChar, escapedSubstring, rule, errors)
|
120
|
+
lastEscapedChar = nil
|
121
|
+
escapedSubstring = ""
|
122
|
+
end
|
123
|
+
start = i + 1
|
124
|
+
end
|
125
|
+
inTag = !inTag
|
126
|
+
end
|
127
|
+
when '\\' then
|
128
|
+
escaped = true;
|
129
|
+
escapedSubstring = escapedSubstring + rule[start...i];
|
130
|
+
start = i + 1;
|
131
|
+
lastEscapedChar = i;
|
132
|
+
end
|
133
|
+
else
|
134
|
+
escaped = false
|
135
|
+
end
|
136
|
+
end #each character in rule
|
137
|
+
|
138
|
+
if(start < rule.length) then
|
139
|
+
createSection(start, rule.length, 0, results, lastEscapedChar, escapedSubstring, rule, errors)
|
140
|
+
lastEscapedChar = nil
|
141
|
+
escapedSubstring = ""
|
142
|
+
end
|
143
|
+
|
144
|
+
errors << ("Unclosed tag") if inTag
|
145
|
+
errors << ("Too many [") if depth > 0
|
146
|
+
errors << ("Too many ]") if depth < 0
|
147
|
+
|
148
|
+
# Strip out empty plaintext sections
|
149
|
+
results[:sections].select! {|section|
|
150
|
+
if(section[:type] == 0 && section[:raw].empty?) then
|
151
|
+
false
|
152
|
+
else
|
153
|
+
true
|
154
|
+
end
|
155
|
+
}
|
156
|
+
results[:errors] = errors;
|
157
|
+
return results
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
class TraceryNode
|
162
|
+
attr_accessor :grammar, :depth, :finishedText, :children, :errors
|
163
|
+
|
164
|
+
include Tracery
|
165
|
+
|
166
|
+
def initialize(parent, childIndex, settings)
|
167
|
+
@errors = []
|
168
|
+
@children = []
|
169
|
+
|
170
|
+
if(settings[:raw].nil?) then
|
171
|
+
@errors << "Empty input for node"
|
172
|
+
settings[:raw] = ""
|
173
|
+
end
|
174
|
+
|
175
|
+
# If the root node of an expansion, it will have the grammar passed as the 'parent'
|
176
|
+
# set the grammar from the 'parent', and set all other values for a root node
|
177
|
+
if(parent.is_a? Grammar)
|
178
|
+
@grammar = parent
|
179
|
+
@parent = nil
|
180
|
+
@depth = 0
|
181
|
+
@childIndex = 0
|
182
|
+
else
|
183
|
+
@grammar = parent.grammar
|
184
|
+
@parent = parent
|
185
|
+
@depth = parent.depth + 1
|
186
|
+
@childIndex = childIndex
|
187
|
+
end
|
188
|
+
|
189
|
+
@raw = settings[:raw]
|
190
|
+
@type = settings[:type]
|
191
|
+
@isExpanded = false
|
192
|
+
|
193
|
+
@errors << "No grammar specified for this node #{self}" if (@grammar.nil?)
|
194
|
+
end
|
195
|
+
|
196
|
+
def to_s
|
197
|
+
"Node('#{@raw}' #{@type} d:#{@depth})"
|
198
|
+
end
|
199
|
+
|
200
|
+
def expandChildren(childRule, preventRecursion)
|
201
|
+
@finishedText = ""
|
202
|
+
@childRule = childRule
|
203
|
+
|
204
|
+
if(!@childRule.nil?)
|
205
|
+
parsed = parse(childRule)
|
206
|
+
sections = parsed[:sections]
|
207
|
+
|
208
|
+
@errors.concat(parsed[:errors])
|
209
|
+
|
210
|
+
sections.each_with_index do |section, i|
|
211
|
+
child = TraceryNode.new(self, i, section)
|
212
|
+
if(!preventRecursion)
|
213
|
+
child.expand(preventRecursion)
|
214
|
+
end
|
215
|
+
@finishedText += child.finishedText
|
216
|
+
@children << child
|
217
|
+
end
|
218
|
+
else
|
219
|
+
# In normal operation, this shouldn't ever happen
|
220
|
+
@errors << "No child rule provided, can't expand children"
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Expand this rule (possibly creating children)
|
225
|
+
def expand(preventRecursion = false)
|
226
|
+
if(@isExpanded) then
|
227
|
+
@errors << "Already expanded #{self}"
|
228
|
+
return
|
229
|
+
end
|
230
|
+
|
231
|
+
@isExpanded = true
|
232
|
+
#this is no longer used
|
233
|
+
@expansionErrors = []
|
234
|
+
|
235
|
+
# Types of nodes
|
236
|
+
# -1: raw, needs parsing
|
237
|
+
# 0: Plaintext
|
238
|
+
# 1: Tag ("#symbol.mod.mod2.mod3#" or "#[pushTarget:pushRule]symbol.mod#")
|
239
|
+
# 2: Action ("[pushTarget:pushRule], [pushTarget:POP]", more in the future)
|
240
|
+
|
241
|
+
case(@type)
|
242
|
+
when -1 then
|
243
|
+
#raw rule
|
244
|
+
expandChildren(@raw, preventRecursion)
|
245
|
+
when 0 then
|
246
|
+
#plaintext, do nothing but copy text into finished text
|
247
|
+
@finishedText = @raw
|
248
|
+
when 1 then
|
249
|
+
#tag - Parse to find any actions, and figure out what the symbol is
|
250
|
+
@preactions = []
|
251
|
+
@postactions = []
|
252
|
+
parsed = parseTag(@raw)
|
253
|
+
@symbol = parsed[:symbol]
|
254
|
+
@modifiers = parsed[:modifiers]
|
255
|
+
|
256
|
+
# Create all the preactions from the raw syntax
|
257
|
+
@preactions = parsed[:preactions].map{|preaction|
|
258
|
+
NodeAction.new(self, preaction[:raw])
|
259
|
+
}
|
260
|
+
|
261
|
+
# @postactions = parsed[:preactions].map{|postaction|
|
262
|
+
# NodeAction.new(self, postaction.raw)
|
263
|
+
# }
|
264
|
+
|
265
|
+
# Make undo actions for all preactions (pops for each push)
|
266
|
+
@postactions = @preactions.
|
267
|
+
select{|preaction| preaction.type == 0 }.
|
268
|
+
map{|preaction| preaction.createUndo() }
|
269
|
+
|
270
|
+
@preactions.each { |preaction| preaction.activate }
|
271
|
+
|
272
|
+
@finishedText = @raw
|
273
|
+
|
274
|
+
# Expand (passing the node, this allows tracking of recursion depth)
|
275
|
+
selectedRule = @grammar.selectRule(@symbol, self, @errors)
|
276
|
+
|
277
|
+
expandChildren(selectedRule, preventRecursion)
|
278
|
+
|
279
|
+
# Apply modifiers
|
280
|
+
# TODO: Update parse function to not trigger on hashtags within parenthesis within tags,
|
281
|
+
# so that modifier parameters can contain tags "#story.replace(#protagonist#, #newCharacter#)#"
|
282
|
+
@modifiers.each{|modName|
|
283
|
+
modParams = [];
|
284
|
+
if (modName.include?("(")) then
|
285
|
+
#match something like `modifier(param, param)`, capture name and params separately
|
286
|
+
match = /([^\(]+)\(([^)]+)\)/.match(modName)
|
287
|
+
if(!match.nil?) then
|
288
|
+
modParams = match.captures[1].split(",")
|
289
|
+
modName = match.captures[0]
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
mod = @grammar.modifiers[modName]
|
294
|
+
|
295
|
+
# Missing modifier?
|
296
|
+
if(mod.nil?)
|
297
|
+
@errors << "Missing modifier #{modName}"
|
298
|
+
@finishedText += "((.#{modName}))"
|
299
|
+
else
|
300
|
+
@finishedText = mod.call(@finishedText, modParams)
|
301
|
+
end
|
302
|
+
}
|
303
|
+
# perform post-actions
|
304
|
+
@postactions.each{|postaction| postaction.activate()}
|
305
|
+
when 2 then
|
306
|
+
# Just a bare action? Expand it!
|
307
|
+
@action = NodeAction.new(self, @raw)
|
308
|
+
@action.activate()
|
309
|
+
|
310
|
+
# No visible text for an action
|
311
|
+
# TODO: some visible text for if there is a failure to perform the action?
|
312
|
+
@finishedText = ""
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def allErrors
|
317
|
+
child_errors = @children.inject([]){|all, child| all.concat(child.allErrors)}
|
318
|
+
return child_errors.concat(@errors)
|
319
|
+
end
|
320
|
+
|
321
|
+
def clearEscapeCharacters
|
322
|
+
@finishedText = @finishedText.gsub(/\\\\/, "DOUBLEBACKSLASH").gsub(/\\/, "").gsub(/DOUBLEBACKSLASH/, "\\")
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# Types of actions:
|
327
|
+
# 0 Push: [key:rule]
|
328
|
+
# 1 Pop: [key:POP]
|
329
|
+
# 2 function: [functionName(param0,param1)] (TODO!)
|
330
|
+
class NodeAction
|
331
|
+
attr_accessor :node, :target, :type, :ruleNode
|
332
|
+
def initialize(node, raw)
|
333
|
+
# puts("No node for NodeAction") if(node.nil?)
|
334
|
+
# puts("No raw commands for NodeAction") if(raw.empty?)
|
335
|
+
|
336
|
+
@node = node
|
337
|
+
|
338
|
+
sections = raw.split(":")
|
339
|
+
@target = sections.first
|
340
|
+
if(sections.size == 1) then
|
341
|
+
# No colon? A function!
|
342
|
+
@type = 2
|
343
|
+
else
|
344
|
+
# Colon? It's either a push or a pop
|
345
|
+
@rule = sections[1] || ""
|
346
|
+
if(@rule == "POP")
|
347
|
+
@type = 1;
|
348
|
+
else
|
349
|
+
@type = 0;
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
def activate
|
355
|
+
grammar = @node.grammar
|
356
|
+
case(@type)
|
357
|
+
when 0 then
|
358
|
+
# split into sections (the way to denote an array of rules)
|
359
|
+
ruleSections = @rule.split(",")
|
360
|
+
finishedRules = ruleSections.map{|ruleSection|
|
361
|
+
n = TraceryNode.new(grammar, 0, {
|
362
|
+
type: -1,
|
363
|
+
raw: ruleSection
|
364
|
+
})
|
365
|
+
n.expand()
|
366
|
+
n.finishedText
|
367
|
+
}
|
368
|
+
|
369
|
+
# TODO: escape commas properly
|
370
|
+
grammar.pushRules(@target, finishedRules, self)
|
371
|
+
# puts("Push rules: #{@target} #{@ruleText}")
|
372
|
+
when 1 then
|
373
|
+
grammar.popRules(@target);
|
374
|
+
when 2 then
|
375
|
+
grammar.flatten(@target, true);
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
def createUndo
|
380
|
+
if(@type == 0) then
|
381
|
+
return NodeAction.new(@node, "#{@target}:POP")
|
382
|
+
end
|
383
|
+
# TODO Not sure how to make Undo actions for functions or POPs
|
384
|
+
return nil
|
385
|
+
end
|
386
|
+
|
387
|
+
def toText
|
388
|
+
case(@type)
|
389
|
+
when 0 then
|
390
|
+
return "#{@target}:#{@rule}"
|
391
|
+
when 1 then
|
392
|
+
return "#{@target}:POP"
|
393
|
+
when 2 then
|
394
|
+
return "((some function))"
|
395
|
+
else
|
396
|
+
return "((Unknown Action))"
|
397
|
+
end
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
# Sets of rules
|
402
|
+
# (Can also contain conditional or fallback sets of rulesets)
|
403
|
+
class RuleSet
|
404
|
+
def initialize(grammar, raw)
|
405
|
+
@raw = raw
|
406
|
+
@grammar = grammar
|
407
|
+
@falloff = 1
|
408
|
+
@random = Random.new
|
409
|
+
|
410
|
+
@defaultUses = {}
|
411
|
+
|
412
|
+
if(raw.is_a? Array) then
|
413
|
+
@defaultRules = raw
|
414
|
+
else
|
415
|
+
if(raw.is_a? String) then
|
416
|
+
@defaultRules = [raw];
|
417
|
+
else
|
418
|
+
# TODO: support for conditional and hierarchical rule sets
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
def selectRule
|
424
|
+
# puts "Get rule #{@raw}"
|
425
|
+
|
426
|
+
#TODO_ : RuleSet.getRule @ conditionalRule
|
427
|
+
#TODO_ : RuleSet.getRule @ ranking
|
428
|
+
|
429
|
+
if(!@defaultRules.nil?) then
|
430
|
+
index = 0
|
431
|
+
# Select from this basic array of rules
|
432
|
+
# Get the distribution from the grammar if there is no other
|
433
|
+
distribution = @distribution || @grammar.distribution
|
434
|
+
case(distribution)
|
435
|
+
when "shuffle" then
|
436
|
+
#create a shuffled deck
|
437
|
+
if(@shuffledDeck.nil? || @shuffledDeck.empty?)
|
438
|
+
#TODO_ - use fyshuffle and falloff
|
439
|
+
@shuffledDeck = (0...@defaultRules.size).to_a.shuffle
|
440
|
+
end
|
441
|
+
index = @shuffledDeck.pop
|
442
|
+
when "weighted" then
|
443
|
+
@errors << "Weighted distribution not yet implemented"
|
444
|
+
when "falloff" then
|
445
|
+
@errors << "Falloff distribution not yet implemented"
|
446
|
+
else
|
447
|
+
index = ((@random.rand ** @falloff) * @defaultRules.size).floor
|
448
|
+
end
|
449
|
+
|
450
|
+
@defaultUses[index] = (@defaultUses[index] || 0) + 1
|
451
|
+
return @defaultRules[index]
|
452
|
+
end
|
453
|
+
|
454
|
+
@errors << "No default rules defined for #{self}"
|
455
|
+
return nil
|
456
|
+
end
|
457
|
+
|
458
|
+
def clearState
|
459
|
+
@defaultUses = {}
|
460
|
+
#TODO_ should clear shuffled deck too?
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
class TracerySymbol
|
465
|
+
attr_accessor :isDynamic
|
466
|
+
|
467
|
+
def initialize(grammar, key, rawRules)
|
468
|
+
# Symbols can be made with a single value, and array, or array of objects of (conditions/values)
|
469
|
+
@key = key
|
470
|
+
@grammar = grammar
|
471
|
+
@rawRules = rawRules
|
472
|
+
|
473
|
+
@baseRules = RuleSet.new(@grammar, @rawRules)
|
474
|
+
clearState
|
475
|
+
end
|
476
|
+
|
477
|
+
def clearState
|
478
|
+
# Clear the stack and clear all ruleset usages
|
479
|
+
@stack = [@baseRules]
|
480
|
+
@uses = []
|
481
|
+
@baseRules.clearState
|
482
|
+
end
|
483
|
+
|
484
|
+
def pushRules(rawRules)
|
485
|
+
rules = RuleSet.new(@grammar, rawRules)
|
486
|
+
@stack.push rules
|
487
|
+
end
|
488
|
+
|
489
|
+
def popRules
|
490
|
+
@stack.pop
|
491
|
+
end
|
492
|
+
|
493
|
+
def selectRule(node, errors)
|
494
|
+
@uses.push({ node: node })
|
495
|
+
if(@stack.empty?) then
|
496
|
+
errors << "The rule stack for '#{@key}' is empty, too many pops?"
|
497
|
+
return "((#{@key}))"
|
498
|
+
end
|
499
|
+
return @stack.last.selectRule
|
500
|
+
end
|
501
|
+
|
502
|
+
def getActiveRules
|
503
|
+
return nil if @stack.empty?
|
504
|
+
return @stack.last.selectRule
|
505
|
+
end
|
506
|
+
|
507
|
+
def rulesToJSON
|
508
|
+
return @rawRules.to_json
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
class Grammar
|
513
|
+
attr_accessor :distribution, :modifiers
|
514
|
+
|
515
|
+
def initialize(raw) #, settings
|
516
|
+
@modifiers = {}
|
517
|
+
loadFromRawObj(raw)
|
518
|
+
end
|
519
|
+
|
520
|
+
def clearState
|
521
|
+
@symbols.each{|k,v| v.clearState} # TODO_ check for nil keys
|
522
|
+
end
|
523
|
+
|
524
|
+
def addModifiers(mods)
|
525
|
+
# copy over the base modifiers
|
526
|
+
mods.each{|k,v| @modifiers[k] = v}
|
527
|
+
end
|
528
|
+
|
529
|
+
def loadFromRawObj(raw)
|
530
|
+
@raw = raw
|
531
|
+
@symbols = {}
|
532
|
+
@subgrammars = []
|
533
|
+
return if(@raw.nil?)
|
534
|
+
@raw.each{|k,v|
|
535
|
+
@symbols[k] = TracerySymbol.new(self, k, v)
|
536
|
+
}
|
537
|
+
end
|
538
|
+
|
539
|
+
def createRoot(rule)
|
540
|
+
# Create a node and subnodes
|
541
|
+
root = TraceryNode.new(self, 0, {
|
542
|
+
type: -1,
|
543
|
+
raw: rule
|
544
|
+
})
|
545
|
+
return root
|
546
|
+
end
|
547
|
+
|
548
|
+
def expand(rule, allowEscapeChars = false)
|
549
|
+
root = createRoot(rule)
|
550
|
+
root.expand
|
551
|
+
root.clearEscapeCharacters if(!allowEscapeChars)
|
552
|
+
return root
|
553
|
+
end
|
554
|
+
|
555
|
+
def flatten(rule, allowEscapeChars = false)
|
556
|
+
return expand(rule, allowEscapeChars).finishedText
|
557
|
+
end
|
558
|
+
|
559
|
+
def pushRules(key, rawRules, sourceAction)
|
560
|
+
# Create or push rules
|
561
|
+
if(@symbols[key].nil?) then
|
562
|
+
@symbols[key] = TracerySymbol.new(self, key, rawRules)
|
563
|
+
@symbols[key].isDynamic = true if(sourceAction)
|
564
|
+
else
|
565
|
+
@symbols[key].pushRules(rawRules)
|
566
|
+
end
|
567
|
+
end
|
568
|
+
|
569
|
+
def popRules(key)
|
570
|
+
errors << "No symbol for key #{key}" if(@symbols[key].nil?)
|
571
|
+
@symbols[key].popRules
|
572
|
+
end
|
573
|
+
|
574
|
+
def selectRule(key, node, errors)
|
575
|
+
if(@symbols.has_key? key) then
|
576
|
+
return @symbols[key].selectRule(node, errors)
|
577
|
+
end
|
578
|
+
|
579
|
+
# Failover to alternative subgrammars
|
580
|
+
@subgrammars.each do |subgrammar|
|
581
|
+
if(subgrammar.symbols.has_key? key) then
|
582
|
+
return subgrammar.symbols[key].selectRule
|
583
|
+
end
|
584
|
+
end
|
585
|
+
|
586
|
+
# No symbol?
|
587
|
+
errors << "No symbol for '#{key}'"
|
588
|
+
return "((#{key}))"
|
589
|
+
end
|
590
|
+
|
591
|
+
def toJSON
|
592
|
+
symbols = @symbols.each.collect{|symkey, symval| "\"#{symkey}\": #{symval.rulesToJSON}"}
|
593
|
+
return "{\n#{symbols.join("\n")}\n}"
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
class TraceryTests
|
598
|
+
include Tracery
|
599
|
+
require 'pp'
|
600
|
+
|
601
|
+
def test
|
602
|
+
tests = {
|
603
|
+
basic: ["", "a", "tracery"],
|
604
|
+
hashtag: ["#a#", "a#b#", "aaa#b##cccc#dd#eee##f#"],
|
605
|
+
hashtagWrong: ["##", "#", "a#a", "#aa#aa###"],
|
606
|
+
escape: ["\\#test\\#", "\\[#test#\\]"],
|
607
|
+
}
|
608
|
+
|
609
|
+
tests.each do |key, testSet|
|
610
|
+
puts "For #{key}:"
|
611
|
+
testSet.each do |t|
|
612
|
+
result = parse(t)
|
613
|
+
puts "\tTesting \"#{t}\": #{result}"
|
614
|
+
end
|
615
|
+
end
|
616
|
+
|
617
|
+
testGrammar = createGrammar({
|
618
|
+
"animal" => ["capybara", "unicorn", "university", "umbrella", "u-boat", "boa", "ocelot", "zebu", "finch", "fox", "hare", "fly"],
|
619
|
+
"color" => ["yellow", "maroon", "indigo", "ivory", "obsidian"],
|
620
|
+
"mood" => ["elated", "irritable", "morose", "enthusiastic"],
|
621
|
+
"story" => ["[mc:#animal#]Once there was #mc.a#, a very #mood# #mc#. In a pack of #color.ed# #mc.s#!"]
|
622
|
+
});
|
623
|
+
|
624
|
+
require "./mods-eng-basic"
|
625
|
+
testGrammar.addModifiers(Modifiers.baseEngModifiers);
|
626
|
+
puts testGrammar.flatten("#story#")
|
627
|
+
|
628
|
+
grammar = createGrammar({"origin" => "foo"});
|
629
|
+
grammar.addModifiers(Modifiers.baseEngModifiers);
|
630
|
+
puts grammar.flatten("#origin#")
|
631
|
+
end
|
632
|
+
end
|
633
|
+
|
634
|
+
if($PROGRAM_NAME == __FILE__) then
|
635
|
+
tests = TraceryTests.new
|
636
|
+
tests.test
|
637
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../lib/')
|
2
|
+
|
3
|
+
require 'test/unit'
|
4
|
+
require 'tracery'
|
5
|
+
require "mods-eng-basic"
|
6
|
+
|
7
|
+
class TraceryTest < Test::Unit::TestCase
|
8
|
+
include Tracery
|
9
|
+
def setup
|
10
|
+
@grammar = createGrammar({
|
11
|
+
"deepHash" => ["\\#00FF00", "\\#FF00FF"],
|
12
|
+
"deeperHash" => ["#deepHash#"],
|
13
|
+
"animal" => ["bear", "cat", "dog", "fox", "giraffe", "hippopotamus"],
|
14
|
+
"mood" => ["quiet", "morose", "gleeful", "happy", "bemused", "clever", "jovial", "vexatious", "curious", "anxious", "obtuse", "serene", "demure"],
|
15
|
+
|
16
|
+
"nonrecursiveStory" => ["The #pet# went to the beach."],
|
17
|
+
# story : ["#recursiveStory#", "#recursiveStory#", "#nonrecursiveStory#"],
|
18
|
+
"recursiveStory" => ["The #pet# opened a book about[pet:#mood# #animal#] #pet.a#. #[#setPronouns#]story#[pet:POP] The #pet# closed the book."],
|
19
|
+
|
20
|
+
"svgColor" => ["rgb(120,180,120)", "rgb(240,140,40)", "rgb(150,45,55)", "rgb(150,145,125)", "rgb(220,215,195)", "rgb(120,250,190)"],
|
21
|
+
"svgStyle" => ['style="fill:#svgColor#;stroke-width:3;stroke:#svgColor#"'],
|
22
|
+
|
23
|
+
"name" => ["Cheri", "Fox", "Morgana", "Jedoo", "Brick", "Shadow", "Krox", "Urga", "Zelph"],
|
24
|
+
"story" => ["#hero.capitalize# was a great #occupation#, and this song tells of #heroTheir# adventure. #hero.capitalize# #didStuff#, then #heroThey# #didStuff#, then #heroThey# went home to read a book."],
|
25
|
+
"monster" => ["dragon", "ogre", "witch", "wizard", "goblin", "golem", "giant", "sphinx", "warlord"],
|
26
|
+
"setPronouns" => ["[heroThey:they][heroThem:them][heroTheir:their][heroTheirs:theirs]", "[heroThey:she][heroThem:her][heroTheir:her][heroTheirs:hers]", "[heroThey:he][heroThem:him][heroTheir:his][heroTheirs:his]"],
|
27
|
+
"setOccupation" => ["[occupation:baker][didStuff:baked bread,decorated cupcakes,folded dough,made croissants,iced a cake]", "[occupation:warrior][didStuff:fought #monster.a#,saved a village from #monster.a#,battled #monster.a#,defeated #monster.a#]"],
|
28
|
+
"origin" => ["#[#setPronouns#][#setOccupation#][hero:#name#]story#"]
|
29
|
+
})
|
30
|
+
@grammar.addModifiers(Modifiers.baseEngModifiers)
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_main_expansion_tests
|
34
|
+
tests = {
|
35
|
+
plaintextShort: {
|
36
|
+
src: "a"
|
37
|
+
},
|
38
|
+
plaintextLong: {
|
39
|
+
src: "Emma Woodhouse, handsome, clever, and rich, with a comfortable home and happy disposition, seemed to unite some of the best blessings of existence; and had lived nearly twenty-one years in the world with very little to distress or vex her."
|
40
|
+
},
|
41
|
+
|
42
|
+
# Escape chars
|
43
|
+
escapeCharacter: {
|
44
|
+
src: "\\#escape hash\\# and escape slash\\\\"
|
45
|
+
},
|
46
|
+
|
47
|
+
escapeDeep: {
|
48
|
+
src: "#deepHash# [myColor:#deeperHash#] #myColor#"
|
49
|
+
},
|
50
|
+
|
51
|
+
escapeQuotes: {
|
52
|
+
src: "\"test\" and \'test\'"
|
53
|
+
},
|
54
|
+
escapeBrackets: {
|
55
|
+
src: "\\[\\]"
|
56
|
+
},
|
57
|
+
escapeHash: {
|
58
|
+
src: "\\#"
|
59
|
+
},
|
60
|
+
unescapeCharSlash: {
|
61
|
+
src: "\\\\"
|
62
|
+
},
|
63
|
+
escapeMelange1: {
|
64
|
+
src: "An action can have inner tags: \[key:\#rule\#\]"
|
65
|
+
},
|
66
|
+
escapeMelange2: {
|
67
|
+
src: "A tag can have inner actions: \"\\#\\[myName:\\#name\\#\\]story\\[myName:POP\\]\\#\""
|
68
|
+
},
|
69
|
+
emoji: {
|
70
|
+
src: "💻🐋🌙🏄🍻"
|
71
|
+
},
|
72
|
+
|
73
|
+
unicode: {
|
74
|
+
src: "&\\#x2665; &\\#x2614; &\\#9749; &\\#x2665;"
|
75
|
+
},
|
76
|
+
|
77
|
+
svg: {
|
78
|
+
src: '<svg width="100" height="70"><rect x="0" y="0" width="100" height="100" #svgStyle#/> <rect x="20" y="10" width="40" height="30" #svgStyle#/></svg>'
|
79
|
+
},
|
80
|
+
|
81
|
+
pushBasic: {
|
82
|
+
src: "[pet:#animal#]You have a #pet#. Your #pet# is #mood#."
|
83
|
+
},
|
84
|
+
|
85
|
+
pushPop: {
|
86
|
+
src: "[pet:#animal#]You have a #pet#. [pet:#animal#]Pet:#pet# [pet:POP]Pet:#pet#"
|
87
|
+
},
|
88
|
+
|
89
|
+
tagAction: {
|
90
|
+
src: "#[pet:#animal#]nonrecursiveStory# post:#pet#"
|
91
|
+
},
|
92
|
+
|
93
|
+
testComplexGrammar: {
|
94
|
+
src: "#origin#"
|
95
|
+
},
|
96
|
+
|
97
|
+
modifierWithParams: {
|
98
|
+
src: "[pet:#animal#]#nonrecursiveStory# -> #nonrecursiveStory.replace(beach,mall)#"
|
99
|
+
},
|
100
|
+
|
101
|
+
recursivePush: {
|
102
|
+
src: "[pet:#animal#]#recursiveStory#"
|
103
|
+
},
|
104
|
+
|
105
|
+
missingModifier: {
|
106
|
+
src: "#animal.foo#",
|
107
|
+
error: true
|
108
|
+
},
|
109
|
+
|
110
|
+
unmatchedHash: {
|
111
|
+
src: "#unmatched",
|
112
|
+
error: true
|
113
|
+
},
|
114
|
+
missingSymbol: {
|
115
|
+
src: "#unicorns#",
|
116
|
+
error: true
|
117
|
+
},
|
118
|
+
missingRightBracket: {
|
119
|
+
src: "[pet:unicorn",
|
120
|
+
error: true
|
121
|
+
},
|
122
|
+
missingLeftBracket: {
|
123
|
+
src: "pet:unicorn]",
|
124
|
+
error: true
|
125
|
+
},
|
126
|
+
justALotOfBrackets: {
|
127
|
+
src: "[][]][][][[[]]][[]]]]",
|
128
|
+
error: true
|
129
|
+
},
|
130
|
+
bracketTagMelange: {
|
131
|
+
src: "[][#]][][##][[[##]]][#[]]]]",
|
132
|
+
error: true
|
133
|
+
}
|
134
|
+
}
|
135
|
+
|
136
|
+
puts
|
137
|
+
tests.each { |k,v|
|
138
|
+
puts "#{k}: "
|
139
|
+
@grammar.clearState
|
140
|
+
source = v[:src]
|
141
|
+
is_error = v[:error].nil? ? false : v[:error]
|
142
|
+
root = @grammar.expand(source)
|
143
|
+
all_errors = root.allErrors
|
144
|
+
puts "\t#{root.finishedText}"
|
145
|
+
if(is_error) then
|
146
|
+
puts "\tErrors: #{all_errors}"
|
147
|
+
refute(all_errors.empty?, "Expected errors.")
|
148
|
+
else
|
149
|
+
assert(all_errors.empty?, "Expected no errors.")
|
150
|
+
refute(root.finishedText.empty?, "Expected non-empty output.")
|
151
|
+
end
|
152
|
+
}
|
153
|
+
end
|
154
|
+
|
155
|
+
end
|
metadata
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tracery
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.7.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kate Compton
|
8
|
+
- Eli Brody
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2016-02-27 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description: |
|
15
|
+
Tracery is a library for text generation.
|
16
|
+
The text is expanded by traversing a grammar.
|
17
|
+
See the main github repo for examples and documentation.
|
18
|
+
email: brodyeli@gmail.com
|
19
|
+
executables: []
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files: []
|
22
|
+
files:
|
23
|
+
- lib/mods-eng-basic.rb
|
24
|
+
- lib/tracery.rb
|
25
|
+
- test/tracery_test.rb
|
26
|
+
homepage: https://github.com/galaxykate/tracery
|
27
|
+
licenses:
|
28
|
+
- Apache-2.0
|
29
|
+
metadata: {}
|
30
|
+
post_install_message:
|
31
|
+
rdoc_options: []
|
32
|
+
require_paths:
|
33
|
+
- lib
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - '>='
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
requirements: []
|
45
|
+
rubyforge_project:
|
46
|
+
rubygems_version: 2.0.14
|
47
|
+
signing_key:
|
48
|
+
specification_version: 4
|
49
|
+
summary: A text expansion library
|
50
|
+
test_files:
|
51
|
+
- test/tracery_test.rb
|