text-gen 0.11.3 → 0.12.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/text/gen/context.rb +119 -0
  3. data/lib/text/gen/filter/base.rb +16 -13
  4. data/lib/text/gen/filter/capitalize.rb +19 -0
  5. data/lib/text/gen/filter/censor.rb +52 -0
  6. data/lib/text/gen/filter/clear.rb +19 -0
  7. data/lib/text/gen/filter/distinct.rb +19 -0
  8. data/lib/text/gen/filter/downcase.rb +19 -0
  9. data/lib/text/gen/filter/exclude.rb +19 -0
  10. data/lib/text/gen/filter/locale.rb +44 -0
  11. data/lib/text/gen/filter/match.rb +19 -0
  12. data/lib/text/gen/filter/meta.rb +35 -0
  13. data/lib/text/gen/filter/pluralize.rb +96 -0
  14. data/lib/text/gen/filter/reject.rb +24 -0
  15. data/lib/text/gen/filter/remember.rb +18 -0
  16. data/lib/text/gen/filter/replace.rb +15 -0
  17. data/lib/text/gen/filter/select.rb +24 -0
  18. data/lib/text/gen/filter/swap.rb +50 -0
  19. data/lib/text/gen/filter/titleize.rb +29 -0
  20. data/lib/text/gen/filter/upcase.rb +19 -0
  21. data/lib/text/gen/filter.rb +10 -168
  22. data/lib/text/gen/investigator.rb +38 -0
  23. data/lib/text/gen/meta.rb +46 -0
  24. data/lib/text/gen/result.rb +7 -22
  25. data/lib/text/gen/result_accumulator.rb +6 -11
  26. data/lib/text/gen/runner.rb +113 -174
  27. data/lib/text/gen/store.rb +29 -1
  28. data/lib/text/gen/version.rb +1 -1
  29. data/lib/text/gen.rb +4 -0
  30. metadata +21 -17
  31. data/lib/text/gen/filter/item/locale.rb +0 -29
  32. data/lib/text/gen/filter/item/reject.rb +0 -28
  33. data/lib/text/gen/filter/item/select.rb +0 -28
  34. data/lib/text/gen/filter/item_filter.rb +0 -17
  35. data/lib/text/gen/filter/result/capitalize.rb +0 -17
  36. data/lib/text/gen/filter/result/censor.rb +0 -55
  37. data/lib/text/gen/filter/result/clear.rb +0 -19
  38. data/lib/text/gen/filter/result/downcase.rb +0 -17
  39. data/lib/text/gen/filter/result/exclude.rb +0 -22
  40. data/lib/text/gen/filter/result/match.rb +0 -22
  41. data/lib/text/gen/filter/result/meta.rb +0 -31
  42. data/lib/text/gen/filter/result/pluralize.rb +0 -91
  43. data/lib/text/gen/filter/result/swap.rb +0 -48
  44. data/lib/text/gen/filter/result/titleize.rb +0 -28
  45. data/lib/text/gen/filter/result/upcase.rb +0 -17
  46. data/lib/text/gen/filter/result_filter.rb +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 549a452c7b5e0be1d179c8d3cd9a63fd5e3b922d3b5b46aebd589862a57a47c5
4
- data.tar.gz: 1cc91f513982f11d3e2c062da0fb0f968cbde160adc6c8d1d53c3083c523ec35
3
+ metadata.gz: 1f4b6dac1a3fe31b9d98c79834a4fc69af77a06e5206d8051e6e6fe5d6e2742f
4
+ data.tar.gz: bbf696d52a9838d6c9caccb1d8ae2772ed75cde64a82325db1d9a673eb664d38
5
5
  SHA512:
6
- metadata.gz: e83e321c33340d6e1962fc1c4f58668bd17778c215efda2218b5c1ee3c900c7dd9e2086d0dc46ba8b12551704554e54e6adb30ee82b48fc8f6f8d298aef39fe6
7
- data.tar.gz: 9f7908767de4101d65bdf2a65640d2c6c431d940db45637383122112e7932cd285ea1aeff92f10cde24583f842417b404000f125a255b43e378ab993e01a0cec
6
+ metadata.gz: 6f5bfbc0ad2b052980ad177dea9f0f9eb688447defd40cc0f352025e881b57a408510e50f5788bfd17a45b2996cd597e06d05d56c50a9d28b8444a1b54ae9ee1
7
+ data.tar.gz: '089919fc1c912b1ec0ddb13a25c6ca32e55d6a64d05afe368c5b93b97b6c6377603b255ce0c74f5f650fc88943c4b74f3e1b8cd448690427fdbff39c78279540'
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ # Keep the context for one run.
6
+ class Context
7
+ attr_reader :store, :depth
8
+
9
+ def initialize(store:, filters: nil, meta: nil, max_recursion: 10)
10
+ @depth = 0
11
+ @store = store
12
+
13
+ # Request filters are added to the top level builder
14
+ @filters = (filters || []).map { |hsh| Filter.build(hsh, 1) }.compact
15
+ @meta = meta
16
+ @keys = []
17
+ @strategy_stack = []
18
+ @remember = Hash.new { |h, k| h[k] = [] }
19
+ @max_recursion = max_recursion
20
+ end
21
+
22
+ def descend!(builder)
23
+ @depth += 1
24
+ @keys << builder["key"]
25
+ @strategy_stack << (builder["strategy"] || "random").split(":", 2)
26
+ raise MaxRecursionError if @depth > @max_recursion
27
+ end
28
+
29
+ def ascend!
30
+ @depth -= 1
31
+ @keys.pop
32
+ @strategy_stack.pop
33
+ end
34
+
35
+ def with_filters(filters, offset: 0)
36
+ count = 0
37
+ (filters || []).each do |hsh|
38
+ filter = Filter.build(hsh, depth + offset)
39
+ next unless filter
40
+
41
+ count += 1
42
+ @filters << filter
43
+ end
44
+
45
+ result = yield
46
+
47
+ @filters.pop(count)
48
+ result
49
+ end
50
+
51
+ def current_key
52
+ @keys.last
53
+ end
54
+
55
+ def current_strategy
56
+ @strategy_stack.last&.first || "random"
57
+ end
58
+
59
+ def current_modifier
60
+ @strategy_stack.last&.[](1)
61
+ end
62
+
63
+ def remember(result)
64
+ @remember[current_key] << result
65
+ result
66
+ end
67
+
68
+ def remembered(key = nil)
69
+ @remember[key || current_key]
70
+ end
71
+
72
+ def apply_builder_filters(builder)
73
+ @filters.each do |filter|
74
+ builder = filter.builder(self, builder) if filter.respond_to?(:builder)
75
+ return unless builder
76
+ return builder if builder.is_a?(Text::Gen::Result)
77
+ end
78
+
79
+ builder
80
+ end
81
+
82
+ def apply_item_list_filters(items)
83
+ @filters.each do |filter|
84
+ items = filter.items(self, items) if filter.respond_to?(:items)
85
+ return [] if items.nil? || items.empty?
86
+ end
87
+
88
+ items
89
+ end
90
+
91
+ def apply_item_filters(item)
92
+ @filters.each do |filter|
93
+ item = filter.item(self, item) if filter.respond_to?(:item)
94
+ return unless item
95
+ end
96
+
97
+ item
98
+ end
99
+
100
+ def apply_segment_filters(segment)
101
+ @filters.each do |filter|
102
+ segment = filter.segment(self, segment) if filter.respond_to?(:segment)
103
+ return if segment.nil? || segment.empty?
104
+ end
105
+
106
+ [segment]
107
+ end
108
+
109
+ def apply_result_filters(result)
110
+ @filters.each do |filter|
111
+ result = filter.result(self, result) if filter.respond_to?(:result)
112
+ return unless result
113
+ end
114
+
115
+ result
116
+ end
117
+ end
118
+ end
119
+ end
@@ -6,33 +6,36 @@ module Text
6
6
  # Base class for all filters
7
7
  # Provides common utilities and template method pattern
8
8
  class Base
9
- attr_reader :filter, :lookup
10
-
11
- def initialize(filter = {}, lookup: nil)
9
+ def initialize(filter, depth)
12
10
  @filter = filter
13
- @lookup = lookup
14
- end
15
-
16
- # Template method - must be implemented by subclasses
17
- def apply(input)
18
- raise NotImplementedError, "Subclasses must implement #apply"
11
+ @depth = depth
19
12
  end
20
13
 
21
14
  def component_key
22
- "function:#{ type }"
15
+ "function:#{type}"
23
16
  end
24
17
 
25
18
  # Shared utilities
26
19
  def key
27
- filter["key"]
20
+ return @key if defined?(@key)
21
+
22
+ @key = @filter["key"].nil? || @filter["key"].empty? ? nil : @filter["key"]
28
23
  end
29
24
 
30
25
  def value
31
- filter["value"]
26
+ return @value if defined?(@value)
27
+
28
+ @value = @filter["value"].nil? || @filter["value"].empty? ? nil : @filter["value"]
32
29
  end
33
30
 
34
31
  def type
35
- filter["type"]
32
+ @type ||= @filter["type"]
33
+ end
34
+
35
+ class << self
36
+ def filter_name
37
+ @filter_name ||= name.split("::").last.downcase
38
+ end
36
39
  end
37
40
  end
38
41
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Capitalize < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+
10
+ text = result.text
11
+ text = key == "force" ? text.capitalize : text.sub(/\S/, &:upcase)
12
+ return result if text == result.text
13
+
14
+ Result.from(result, text:, type: component_key)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Censor < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+ return result if key.nil?
10
+
11
+ builder = context.store.fetch(key)
12
+ return result unless builder
13
+
14
+ # Build a list of censor texts
15
+ runner = Text::Gen::Runner.new(key:, lookup: nil)
16
+ censor = Context.new(context.store)
17
+ list = builder["items"].map do |item|
18
+ runner.run_item(censor, item)&.text
19
+ end.compact.uniq
20
+
21
+ # Get the text to compare
22
+ compare_text = apply_function(result.text)
23
+
24
+ # Return nil if result text matches any censor text
25
+ return if list.any? { |s| apply_function(s) == compare_text }
26
+
27
+ result
28
+ end
29
+
30
+ private
31
+
32
+ def apply_function(text)
33
+ return nil unless text
34
+
35
+ # default is case insensitve comparison
36
+ return text.downcase if value.nil? || value.empty?
37
+
38
+ case value
39
+ when "downcase"
40
+ text.downcase
41
+ when "upcase"
42
+ text.upcase
43
+ when "capitalize"
44
+ text.capitalize
45
+ else
46
+ text
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ # Clears result meta
7
+ class Clear < Base
8
+ def result(context, result)
9
+ return result if @depth && context.depth != @depth
10
+
11
+ new_meta = Text::Gen::Meta.clear_kv(result.meta, key, value)
12
+ return result if new_meta == result.meta
13
+
14
+ Result.from(result, type: component_key, meta: new_meta)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ # Return nil if the text value has already been generated
7
+ class Distinct < Base
8
+ def result(context, result)
9
+ return result if key != context.current_key
10
+
11
+ previous = context.remembered(key)
12
+ return nil if previous.any? { |r| r.text.strip.downcase == result.text.strip.downcase }
13
+
14
+ result
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Downcase < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+
10
+ text = result.text
11
+ text = text.downcase
12
+ return result if text == result.text
13
+
14
+ Result.from(result, text:, type: component_key)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Exclude < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+ return nil if result.nil?
10
+ return nil if value == "*" && result.meta.key?(key)
11
+ return nil if key == "*" && result.meta.values.any? { |arr| arr.include?(value) }
12
+ return nil if result.meta[key]&.include?(value)
13
+
14
+ result
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ # Locale if the key on the filter matches a meta key, then
7
+ # the value of the key becomes a replacement item
8
+ #
9
+ # For example if a filter with the type and key of "locale:en"
10
+ # were run against an item with segments of `["gracias"]` but with a meta value
11
+ # of `{"en": ["thanks", "thank you"]}` then a new item would replace
12
+ # the existing item with a randomly selected segment from the meta.
13
+ class Locale < Base
14
+ def items(_context, items)
15
+ items.map { |item| locale_item(item) || item }
16
+ end
17
+
18
+ private
19
+
20
+ def locale_item(item)
21
+ meta = item["meta"]
22
+ return if meta.nil? || meta.empty?
23
+
24
+ locale_text = meta[key.downcase]&.sample
25
+ return unless locale_text
26
+
27
+ constant_item(locale_text, item)
28
+ end
29
+
30
+ def constant_item(text, item)
31
+ segments = Segment::Parser.parse(text)
32
+ citem = { "segments" => segments }
33
+ if item
34
+ citem["value"] = item["value"] if item["value"]
35
+ citem["multiplier"] = item["multiplier"] if item["multiplier"]
36
+ citem["filters"] = item["filters"] if item["filters"]
37
+ citem["meta"] = item["meta"] if item["meta"]
38
+ end
39
+ citem
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Match < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+ return nil if result.nil?
10
+ return result if value == "*" && result.meta.key?(key)
11
+ return result if key == "*" && result.meta.values.any? { |arr| arr.include?(value) }
12
+ return result if result.meta[key]&.include?(value)
13
+
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Meta < Base
7
+ def result(context, result)
8
+ return result if @depth && context.depth != @depth
9
+
10
+ new_meta = Text::Gen::Meta.append_kv(result.meta.dup, key, meta_value(result))
11
+ return result if new_meta == result.meta
12
+
13
+ Result.from(result, type: component_key, meta: new_meta)
14
+ end
15
+
16
+ def meta_value(result)
17
+ case value
18
+ when "_text_"
19
+ result.text
20
+ when "_value_"
21
+ result.value.to_s
22
+ when "_multiplier_"
23
+ result.multiplier.to_s
24
+ when "_words_"
25
+ result.text.split(/\s+/).length.to_s
26
+ when "_length_"
27
+ result.text.length.to_s
28
+ else
29
+ value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Pluralize < Base
7
+ EXCEPTIONS = {
8
+ "foot" => "feet",
9
+ "axis" => "axes",
10
+ "child" => "children",
11
+ "codex" => "codices",
12
+ "die" => "dice",
13
+ "dwarf" => "dwarves",
14
+ "goose" => "geese",
15
+ "elf" => "elves",
16
+ "man" => "men",
17
+ "ox" => "oxen",
18
+ "cactus" => "cacti",
19
+ "thesis" => "theses",
20
+ "criterion" => "criteria",
21
+ "thief" => "thieves",
22
+ "tooth" => "teeth",
23
+ "wolf" => "wolves",
24
+ "woman" => "women"
25
+ }.freeze
26
+
27
+ SINGLE = Set.new(%w[a an the this his her its my your our that their])
28
+
29
+ def result(context, result)
30
+ return result if @depth && context.depth != @depth
31
+
32
+ text = result.text
33
+ text = pluralize(text, result.multiplier)
34
+ return result if text == result.text
35
+
36
+ Result.from(result, text:, type: component_key)
37
+ end
38
+
39
+ private
40
+
41
+ def pluralize(str, multiplier = 1)
42
+ return str if str.empty?
43
+
44
+ arr = str.split(/\s+/)
45
+ return str if arr.length < 2
46
+
47
+ idx = arr.rindex { |s| to_num(s) }
48
+
49
+ # Use the multiplier if available, otherwise parse from text
50
+ num = if multiplier > 1
51
+ multiplier
52
+ else
53
+ (idx ? to_num(arr[idx]) : nil)
54
+ end
55
+
56
+ return str if num.nil? || num <= 1
57
+ return str if idx && idx >= arr.length - 1
58
+
59
+ dc = arr[-1].downcase
60
+ arr[-1] = exceptions(dc) || single_letter(dc) || others(dc) || ends_with_y(dc) || simple(dc)
61
+ arr.join(" ")
62
+ end
63
+
64
+ def to_num(str)
65
+ return 1 if SINGLE.include?(str.downcase)
66
+
67
+ str =~ /\d+/ ? str.to_i : nil
68
+ end
69
+
70
+ def exceptions(str)
71
+ EXCEPTIONS[str]
72
+ end
73
+
74
+ def single_letter(str)
75
+ return unless str =~ /^[a-zA-Z0-9]$/
76
+
77
+ "#{str}'s"
78
+ end
79
+
80
+ def others(str)
81
+ if str.end_with?("s") || str.end_with?("x") || str.end_with?("z") || str.end_with?("ch") || str.end_with?("sh")
82
+ "#{str}es"
83
+ end
84
+ end
85
+
86
+ def ends_with_y(str)
87
+ "#{str[0..-2]}ies" if str.end_with?("y")
88
+ end
89
+
90
+ def simple(str)
91
+ "#{str}s"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Reject < Base
7
+ def items(_context, items)
8
+ items.select do |item|
9
+ pass_reject?(item["meta"])
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def pass_reject?(meta)
16
+ return false if value == "*" && meta.key?(key)
17
+ return false if key == "*" && meta.values.any? { |arr| arr.include?(value) }
18
+
19
+ !meta[key]&.include?(value)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Remember < Base
7
+ def result(context, result)
8
+ return result if key != context.current_key
9
+
10
+ previous = context.remembered(key)
11
+ return previous.sample unless previous.nil? || previous.empty?
12
+
13
+ result
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Replace < Base
7
+ def builder(context, builder)
8
+ return builder unless builder["key"] == key
9
+
10
+ Result.new(text: value, type: "replace")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Select < Base
7
+ def apply(items)
8
+ items.select do |item|
9
+ pass_select?(item["meta"])
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def pass_select?(meta)
16
+ return true if value == "*" && meta.key?(key)
17
+ return true if key == "*" && meta.values.any? { |arr| arr.include?(value) }
18
+
19
+ meta[key]&.include?(value)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Text
4
+ module Gen
5
+ module Filter
6
+ class Swap < Base
7
+ DIGIT_WORDS = {
8
+ "1" => "one", "2" => "two", "3" => "three",
9
+ "4" => "four", "5" => "five", "6" => "six",
10
+ "7" => "seven", "8" => "eight", "9" => "nine"
11
+ }.freeze
12
+
13
+ def result(context, result)
14
+ return result if @depth && context.depth != @depth
15
+
16
+ text = result.text
17
+ text = swap(text, key, value)
18
+ return result if text == result.text
19
+
20
+ Result.from(result, text:, type: component_key)
21
+ end
22
+
23
+ private
24
+
25
+ def swap(str, key, val)
26
+ return str if key.nil? || key.empty?
27
+
28
+ arr = str.split(/\s+/)
29
+ arr = arr.map do |s|
30
+ case key
31
+ when "_digit_"
32
+ swap_digit(s)
33
+ else
34
+ swap_any(s, key, val.to_s)
35
+ end
36
+ end
37
+ arr.join(" ")
38
+ end
39
+
40
+ def swap_any(str, key, val)
41
+ str == key ? val : str
42
+ end
43
+
44
+ def swap_digit(str)
45
+ DIGIT_WORDS.fetch(str, str)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end