prompter-ruby 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62d43dac53307b79d18f84fcf1c40fd88dbf19a5c337b9e914c13fa010b73dbe
4
- data.tar.gz: 7e2c23ca40c7178b0821bf105bd4389a33b2f293ca4ae76551b762262b17e9d2
3
+ metadata.gz: c21c7bea53b34f25eed8b9b186c5212d23033bd2600110125c85210b91052e85
4
+ data.tar.gz: f0f14bfcf7d30ad14d02d93e475d1a1d002aa548df5328002579fba950043089
5
5
  SHA512:
6
- metadata.gz: 630c8c261d232440ac6335e0d479a8933b9e2c8f57379d63a5a94da0af59aabc9cabd1c32e549b07663c14902d8f70dcf3d9cc211832900a6b6f81b61f4e3ff6
7
- data.tar.gz: 170c646d22cd2e94a1f63772f1772660acd4f33e9cc0215ba5432d29766df0560c9be3114b904cdf1b3c8116fe30226adacb7318a8f95cf749b4265a7da71f89
6
+ metadata.gz: 3ac3b64c564a9aafe1859ad333147318f46d6d9e0194760bcd78528fe0e737fe78bc45c3b2a917d54131f24d256bc6a9d619631d0a2c58304de6d6871577cb72
7
+ data.tar.gz: c0e98a881337ab38b3806ffe199a9be57d7632d6bb1a0f1dc85dbbcc3685f8d74ba5e5ce1b51e09fdd2c2d960e476f51218f1182852b2f29114320c636704d6c
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # prompter-ruby
2
2
 
3
- Prompt template engine for Ruby with variables, conditionals, loops, and versioning.
3
+ Prompt template engine for Ruby. Render dynamic prompts with variables, conditionals, and loops.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```ruby
8
- gem "prompter-ruby", "~> 0.1"
8
+ gem "prompter-ruby"
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -13,25 +13,39 @@ gem "prompter-ruby", "~> 0.1"
13
13
  ```ruby
14
14
  require "prompter_ruby"
15
15
 
16
- result = PrompterRuby.render("Hello {{name}}!", name: "World")
16
+ # Basic variable interpolation
17
+ template = PrompterRuby::Template.new("Hello, {{name}}!")
18
+ template.render(name: "World") # => "Hello, World!"
17
19
 
18
- template = PrompterRuby::Template.new(<<~PROMPT)
19
- You are {{bot_name}}.
20
- {% if context %}Context: {{context}}{% endif %}
21
- {% for rule in rules %}- {{rule}}
20
+ # Conditionals and loops
21
+ source = <<~TMPL
22
+ You are a {{role}} assistant.
23
+ {% if context %}Use this context: {{context}}{% endif %}
24
+ {% for item in items %}
25
+ - {{item}}
22
26
  {% endfor %}
23
- PROMPT
27
+ TMPL
28
+ template = PrompterRuby::Template.new(source)
29
+ template.render(role: "helpful", items: ["A", "B", "C"])
24
30
 
25
- result = template.render(
26
- bot_name: "Max",
27
- context: "Some info",
28
- rules: ["Be concise", "Be helpful"]
29
- )
31
+ # Strict mode (raise on undefined variables)
32
+ template.render({ name: "World" }, strict: true)
30
33
 
31
- library = PrompterRuby::Library.new("./prompts/")
32
- template = library.get("rag_answer", version: "v2")
34
+ # Validation
35
+ template.valid? # => true/false
36
+ template.validate! # raises ParseError if invalid
33
37
  ```
34
38
 
39
+ ## Features
40
+
41
+ - `{{variable}}` interpolation with nested key access
42
+ - `{% if %}` / `{% else %}` / `{% endif %}` conditionals
43
+ - `{% for x in items %}` / `{% endfor %}` loops
44
+ - Strict mode raises `UndefinedVariableError` for missing variables
45
+ - ParseError with line numbers for unclosed blocks
46
+ - Template validation (`valid?`, `validate!`)
47
+ - Whitespace trimming for tag-only lines
48
+
35
49
  ## License
36
50
 
37
51
  MIT
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PrompterRuby
6
+ module Filters
7
+ REGISTRY = {
8
+ "upcase" => ->(v, _) { v.to_s.upcase },
9
+ "downcase" => ->(v, _) { v.to_s.downcase },
10
+ "capitalize" => ->(v, _) { v.to_s.capitalize },
11
+ "strip" => ->(v, _) { v.to_s.strip },
12
+ "reverse" => ->(v, _) { v.respond_to?(:reverse) ? v.reverse : v.to_s.reverse },
13
+ "size" => ->(v, _) { v.respond_to?(:size) ? v.size : v.to_s.size },
14
+ "first" => ->(v, _) { v.respond_to?(:first) ? v.first : v },
15
+ "last" => ->(v, _) { v.respond_to?(:last) ? v.last : v },
16
+ "json" => ->(v, _) { JSON.generate(v) },
17
+ "escape" => ->(v, _) { escape_html(v.to_s) },
18
+ "truncate" => ->(v, arg) { n = (arg || 100).to_i; v.to_s.length > n ? v.to_s[0...n] + "..." : v.to_s },
19
+ "join" => ->(v, arg) { v.respond_to?(:join) ? v.join(arg || ", ") : v.to_s },
20
+ "default" => ->(v, arg) { (v.nil? || (v.respond_to?(:empty?) && v.empty?)) ? arg : v },
21
+ "replace" => ->(v, arg) { parts = arg.to_s.split(",", 2).map(&:strip); v.to_s.gsub(parts[0].to_s, parts[1].to_s) }
22
+ }.freeze
23
+
24
+ def self.apply(value, filter_chain)
25
+ filter_chain.each do |name, arg|
26
+ filter = REGISTRY[name]
27
+ raise RenderError, "Unknown filter: '#{name}'" unless filter
28
+ value = filter.call(value, arg)
29
+ end
30
+ value
31
+ end
32
+
33
+ def self.parse(expression)
34
+ parts = expression.split("|")
35
+ var_name = parts.shift.strip
36
+ filters = parts.map do |f|
37
+ f = f.strip
38
+ if f.include?(":")
39
+ name, arg = f.split(":", 2)
40
+ [name.strip, arg.strip.delete("'\"")]
41
+ else
42
+ [f, nil]
43
+ end
44
+ end
45
+ [var_name, filters]
46
+ end
47
+
48
+ def self.escape_html(str)
49
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&#39;")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrompterRuby
4
+ module Nodes
5
+ class CommentNode
6
+ def initialize(text)
7
+ @text = text
8
+ end
9
+
10
+ def render(_context)
11
+ ""
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,36 +3,91 @@
3
3
  module PrompterRuby
4
4
  module Nodes
5
5
  class IfNode
6
- def initialize(condition, true_nodes, false_nodes = [])
6
+ COMPARISON_OPS = %w[== != >= <= > <].freeze
7
+
8
+ def initialize(condition, true_nodes, false_nodes = [], elsif_branches: [])
7
9
  @condition = condition.strip
8
10
  @true_nodes = true_nodes
9
11
  @false_nodes = false_nodes
12
+ @elsif_branches = elsif_branches # Array of [condition, nodes]
10
13
  end
11
14
 
12
15
  def render(context)
13
- if evaluate_condition(context)
16
+ if evaluate_condition(@condition, context)
14
17
  @true_nodes.map { |n| n.render(context) }.join
15
18
  else
19
+ @elsif_branches.each do |cond, nodes|
20
+ if evaluate_condition(cond, context)
21
+ return nodes.map { |n| n.render(context) }.join
22
+ end
23
+ end
16
24
  @false_nodes.map { |n| n.render(context) }.join
17
25
  end
18
26
  end
19
27
 
20
28
  private
21
29
 
22
- def evaluate_condition(context)
23
- negate = false
24
- condition = @condition
30
+ def evaluate_condition(condition, context)
31
+ # Boolean operators (and/or)
32
+ if condition.include?(" and ")
33
+ parts = condition.split(" and ", 2)
34
+ return evaluate_condition(parts[0].strip, context) && evaluate_condition(parts[1].strip, context)
35
+ end
36
+ if condition.include?(" or ")
37
+ parts = condition.split(" or ", 2)
38
+ return evaluate_condition(parts[0].strip, context) || evaluate_condition(parts[1].strip, context)
39
+ end
25
40
 
41
+ negate = false
26
42
  if condition.start_with?("not ")
27
43
  negate = true
28
44
  condition = condition.sub("not ", "").strip
29
45
  end
30
46
 
47
+ # Comparison operators
48
+ COMPARISON_OPS.each do |op|
49
+ if condition.include?(" #{op} ")
50
+ left, right = condition.split(" #{op} ", 2).map(&:strip)
51
+ left_val = resolve_or_literal(left, context)
52
+ right_val = resolve_or_literal(right, context)
53
+ result = compare(left_val, right_val, op)
54
+ return negate ? !result : result
55
+ end
56
+ end
57
+
31
58
  value = resolve_value(condition, context)
32
59
  result = truthy?(value)
33
60
  negate ? !result : result
34
61
  end
35
62
 
63
+ def compare(a, b, op)
64
+ case op
65
+ when "==" then a == b
66
+ when "!=" then a != b
67
+ when ">" then a.to_f > b.to_f
68
+ when ">=" then a.to_f >= b.to_f
69
+ when "<" then a.to_f < b.to_f
70
+ when "<=" then a.to_f <= b.to_f
71
+ else false
72
+ end
73
+ end
74
+
75
+ def resolve_or_literal(expr, context)
76
+ # String literal
77
+ if expr.match?(/\A['"](.*)['"]/)
78
+ return expr[1..-2]
79
+ end
80
+ # Numeric literal
81
+ if expr.match?(/\A-?\d+(\.\d+)?\z/)
82
+ return expr.include?(".") ? expr.to_f : expr.to_i
83
+ end
84
+ # Boolean
85
+ return true if expr == "true"
86
+ return false if expr == "false"
87
+ # Variable
88
+ resolve_value(expr, context)
89
+ end
90
+
36
91
  def resolve_value(name, context)
37
92
  parts = name.split(".")
38
93
  value = context
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrompterRuby
4
+ module Nodes
5
+ class RawNode
6
+ def initialize(text)
7
+ @text = text
8
+ end
9
+
10
+ def render(_context)
11
+ @text
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrompterRuby
4
+ module Nodes
5
+ class SetNode
6
+ def initialize(name, value_expr)
7
+ @name = name.strip
8
+ @value_expr = value_expr.strip
9
+ end
10
+
11
+ def render(context)
12
+ value = evaluate(@value_expr, context)
13
+ context[@name.to_sym] = value
14
+ ""
15
+ end
16
+
17
+ private
18
+
19
+ def evaluate(expr, context)
20
+ # String literal
21
+ if expr.match?(/\A["'](.*)["']\z/)
22
+ return expr[1..-2]
23
+ end
24
+
25
+ # Numeric
26
+ if expr.match?(/\A-?\d+(\.\d+)?\z/)
27
+ return expr.include?(".") ? expr.to_f : expr.to_i
28
+ end
29
+
30
+ # Boolean
31
+ return true if expr == "true"
32
+ return false if expr == "false"
33
+
34
+ # Variable reference
35
+ resolve(expr, context)
36
+ end
37
+
38
+ def resolve(name, context)
39
+ parts = name.split(".")
40
+ value = context
41
+ parts.each do |part|
42
+ if value.is_a?(Hash)
43
+ key = value.key?(part.to_sym) ? part.to_sym : part
44
+ value = value[key]
45
+ else
46
+ return nil
47
+ end
48
+ end
49
+ value
50
+ end
51
+ end
52
+ end
53
+ end
@@ -6,23 +6,28 @@ module PrompterRuby
6
6
  attr_reader :name
7
7
 
8
8
  def initialize(name)
9
- @name = name.strip
9
+ @raw = name.strip
10
+ @var_name, @filters = Filters.parse(@raw)
10
11
  end
11
12
 
12
13
  def render(context)
13
14
  value = resolve(context)
14
15
 
15
16
  if value.nil? && context[:__strict__]
16
- raise UndefinedVariableError, "Undefined variable: '#{@name}'"
17
+ raise UndefinedVariableError, "Undefined variable: '#{@var_name}'"
17
18
  end
18
19
 
19
- value.to_s
20
+ if @filters.any?
21
+ Filters.apply(value, @filters).to_s
22
+ else
23
+ value.to_s
24
+ end
20
25
  end
21
26
 
22
27
  private
23
28
 
24
29
  def resolve(context)
25
- parts = @name.split(".")
30
+ parts = @var_name.split(".")
26
31
  value = context
27
32
 
28
33
  parts.each do |part|
@@ -4,6 +4,7 @@ module PrompterRuby
4
4
  class Parser
5
5
  VARIABLE_PATTERN = /\{\{(.*?)\}\}/
6
6
  TAG_PATTERN = /\{%(.*?)%\}/
7
+ COMMENT_PATTERN = /\{#(.*?)#\}/m
7
8
 
8
9
  def parse(source)
9
10
  tokens = tokenize(source)
@@ -21,6 +22,7 @@ module PrompterRuby
21
22
  while scanner.length > 0
22
23
  var_match = scanner.match(VARIABLE_PATTERN)
23
24
  tag_match = scanner.match(TAG_PATTERN)
25
+ comment_match = scanner.match(COMMENT_PATTERN)
24
26
 
25
27
  next_match = nil
26
28
  next_pos = scanner.length
@@ -35,6 +37,11 @@ module PrompterRuby
35
37
  next_pos = tag_match.begin(0)
36
38
  end
37
39
 
40
+ if comment_match && comment_match.begin(0) < next_pos
41
+ next_match = :comment
42
+ next_pos = comment_match.begin(0)
43
+ end
44
+
38
45
  if next_pos > 0
39
46
  tokens << [:text, scanner[0...next_pos], line_number(source, offset)]
40
47
  end
@@ -48,6 +55,10 @@ module PrompterRuby
48
55
  tokens << [:tag, tag_match[1].strip, line_number(source, offset + tag_match.begin(0))]
49
56
  offset += tag_match.end(0)
50
57
  scanner = scanner[tag_match.end(0)..]
58
+ when :comment
59
+ tokens << [:comment, comment_match[1].strip, line_number(source, offset + comment_match.begin(0))]
60
+ offset += comment_match.end(0)
61
+ scanner = scanner[comment_match.end(0)..]
51
62
  else
52
63
  break
53
64
  end
@@ -144,6 +155,9 @@ module PrompterRuby
144
155
  when :text
145
156
  nodes << Nodes::TextNode.new(value)
146
157
  index += 1
158
+ when :comment
159
+ nodes << Nodes::CommentNode.new(value)
160
+ index += 1
147
161
  when :variable
148
162
  nodes << Nodes::VariableNode.new(value)
149
163
  index += 1
@@ -164,7 +178,23 @@ module PrompterRuby
164
178
 
165
179
  index = index + 1 + end_index
166
180
 
181
+ # Handle elsif/else chains
182
+ elsif_branches = []
167
183
  false_nodes = []
184
+
185
+ while tokens[index] && tokens[index][1]&.start_with?("elsif ")
186
+ elsif_cond = tokens[index][1].sub("elsif ", "").strip
187
+ elsif_line = tokens[index][2]
188
+ result = parse_nodes(tokens[(index + 1)..], stop_tags: ["elsif", "else", "endif"], open_tag: "elsif", open_line: elsif_line)
189
+ if result.is_a?(Array) && result.length == 2
190
+ elsif_nodes, end_index = result
191
+ else
192
+ raise ParseError, "Unclosed 'elsif' block starting at line #{elsif_line}"
193
+ end
194
+ elsif_branches << [elsif_cond, elsif_nodes]
195
+ index = index + 1 + end_index
196
+ end
197
+
168
198
  if tokens[index] && tokens[index][1]&.start_with?("else")
169
199
  else_line = tokens[index][2]
170
200
  result = parse_nodes(tokens[(index + 1)..], stop_tags: ["endif"], open_tag: "else", open_line: else_line)
@@ -185,7 +215,7 @@ module PrompterRuby
185
215
 
186
216
  index += 1
187
217
 
188
- nodes << Nodes::IfNode.new(condition, true_nodes, false_nodes)
218
+ nodes << Nodes::IfNode.new(condition, true_nodes, false_nodes, elsif_branches: elsif_branches)
189
219
  elsif value.start_with?("for ")
190
220
  match = value.match(/for\s+(\w+)\s+in\s+(\S+)/)
191
221
  raise ParseError, "Invalid for syntax: #{value}" unless match
@@ -209,6 +239,30 @@ module PrompterRuby
209
239
  index = index + 1 + end_index + 1
210
240
 
211
241
  nodes << Nodes::ForNode.new(item_name, collection_name, body_nodes)
242
+ elsif value == "raw"
243
+ # Collect raw tokens until endraw, reconstructing original syntax
244
+ raw_text = ""
245
+ index += 1
246
+ while index < tokens.length
247
+ type_r, value_r, = tokens[index]
248
+ if type_r == :tag && value_r == "endraw"
249
+ index += 1
250
+ break
251
+ end
252
+ case type_r
253
+ when :text, :text_skip then raw_text += value_r
254
+ when :variable then raw_text += "{{#{value_r}}}"
255
+ when :tag then raw_text += "{%#{value_r}%}"
256
+ when :comment then raw_text += "{##{value_r}#}"
257
+ end
258
+ index += 1
259
+ end
260
+ nodes << Nodes::RawNode.new(raw_text)
261
+ elsif value.start_with?("set ")
262
+ match = value.match(/set\s+(\w+)\s*=\s*(.+)/)
263
+ raise ParseError, "Invalid set syntax: #{value}" unless match
264
+ nodes << Nodes::SetNode.new(match[1], match[2])
265
+ index += 1
212
266
  else
213
267
  nodes << Nodes::TextNode.new("{%#{value}%}")
214
268
  index += 1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrompterRuby
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/prompter_ruby.rb CHANGED
@@ -2,10 +2,14 @@
2
2
 
3
3
  require_relative "prompter_ruby/version"
4
4
  require_relative "prompter_ruby/errors"
5
+ require_relative "prompter_ruby/filters"
5
6
  require_relative "prompter_ruby/nodes/text_node"
6
7
  require_relative "prompter_ruby/nodes/variable_node"
7
8
  require_relative "prompter_ruby/nodes/if_node"
8
9
  require_relative "prompter_ruby/nodes/for_node"
10
+ require_relative "prompter_ruby/nodes/comment_node"
11
+ require_relative "prompter_ruby/nodes/raw_node"
12
+ require_relative "prompter_ruby/nodes/set_node"
9
13
  require_relative "prompter_ruby/parser"
10
14
  require_relative "prompter_ruby/template"
11
15
  require_relative "prompter_ruby/library"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompter-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -51,9 +51,13 @@ files:
51
51
  - Rakefile
52
52
  - lib/prompter_ruby.rb
53
53
  - lib/prompter_ruby/errors.rb
54
+ - lib/prompter_ruby/filters.rb
54
55
  - lib/prompter_ruby/library.rb
56
+ - lib/prompter_ruby/nodes/comment_node.rb
55
57
  - lib/prompter_ruby/nodes/for_node.rb
56
58
  - lib/prompter_ruby/nodes/if_node.rb
59
+ - lib/prompter_ruby/nodes/raw_node.rb
60
+ - lib/prompter_ruby/nodes/set_node.rb
57
61
  - lib/prompter_ruby/nodes/text_node.rb
58
62
  - lib/prompter_ruby/nodes/variable_node.rb
59
63
  - lib/prompter_ruby/parser.rb