rhales 0.3.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.
@@ -0,0 +1,304 @@
1
+ # lib/rhales/rue_document.rb
2
+
3
+ require_relative 'parsers/rue_format_parser'
4
+
5
+ module Rhales
6
+ # High-level interface for parsed .rue files
7
+ #
8
+ # This class provides a convenient interface to .rue files parsed by RueFormatParser.
9
+ # It uses RueFormatParser internally for low-level parsing and provides high-level
10
+ # methods for accessing sections, attributes, and extracted data.
11
+ #
12
+ # Features:
13
+ # - High-level interface to RueFormatParser AST
14
+ # - Accurate error reporting with line/column information
15
+ # - Convenient section access methods
16
+ # - Section validation and attribute extraction
17
+ # - Variable and partial dependency analysis
18
+ # - AST-to-string conversion when needed
19
+ #
20
+ # Note: This class represents a parsed .rue file document, similar to how
21
+ # HTML::Document represents a parsed HTML document.
22
+ #
23
+ # Usage:
24
+ # document = RueDocument.new(rue_content)
25
+ # document.parse!
26
+ # template_section = document.section('template')
27
+ # variables = document.template_variables
28
+ class RueDocument
29
+ class ParseError < ::Rhales::ValidationError; end
30
+ class SectionMissingError < ParseError; end
31
+ class SectionDuplicateError < ParseError; end
32
+ class InvalidSyntaxError < ParseError; end
33
+
34
+ # At least one of these sections must be present
35
+ REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
36
+ KNOWN_SECTIONS = %w[data template logic].freeze
37
+ ALL_SECTIONS = KNOWN_SECTIONS.freeze
38
+
39
+ # Known data section attributes
40
+ KNOWN_DATA_ATTRIBUTES = %w[window merge layout].freeze
41
+
42
+ attr_reader :content, :file_path, :grammar, :ast
43
+
44
+ def initialize(content, file_path = nil)
45
+ @content = content
46
+ @file_path = file_path
47
+ @grammar = RueFormatParser.new(content, file_path)
48
+ @ast = nil
49
+ end
50
+
51
+ def parse!
52
+ @grammar.parse!
53
+ @ast = @grammar.ast
54
+ parse_data_attributes!
55
+ self
56
+ rescue RueFormatParser::ParseError => ex
57
+ raise ParseError, "Parser error: #{ex.message}"
58
+ end
59
+
60
+ def sections
61
+ return {} unless @ast
62
+
63
+ @grammar.sections.transform_values do |section_node|
64
+ convert_nodes_to_string(section_node.value[:content])
65
+ end
66
+ end
67
+
68
+ def convert_nodes_to_string(nodes)
69
+ nodes.map { |node| convert_node_to_string(node) }.join
70
+ end
71
+
72
+ def convert_node_to_string(node)
73
+ case node.type
74
+ when :text
75
+ node.value
76
+ when :variable_expression
77
+ name = node.value[:name]
78
+ raw = node.value[:raw]
79
+ raw ? "{{{#{name}}}}" : "{{#{name}}}"
80
+ when :partial_expression
81
+ "{{> #{node.value[:name]}}}"
82
+ when :if_block
83
+ condition = node.value[:condition]
84
+ if_content = convert_nodes_to_string(node.value[:if_content])
85
+ else_content = convert_nodes_to_string(node.value[:else_content])
86
+ if else_content.empty?
87
+ "{{#if #{condition}}}#{if_content}{{/if}}"
88
+ else
89
+ "{{#if #{condition}}}#{if_content}{{else}}#{else_content}{{/if}}"
90
+ end
91
+ when :unless_block
92
+ condition = node.value[:condition]
93
+ content = convert_nodes_to_string(node.value[:content])
94
+ "{{#unless #{condition}}}#{content}{{/unless}}"
95
+ when :each_block
96
+ items = node.value[:items]
97
+ content = convert_nodes_to_string(node.value[:content])
98
+ "{{#each #{items}}}#{content}{{/each}}"
99
+ when :handlebars_expression
100
+ # Handle legacy format for data sections
101
+ if node.value[:raw]
102
+ "{{{#{node.value[:content]}}}"
103
+ else
104
+ "{{#{node.value[:content]}}}"
105
+ end
106
+ else
107
+ ''
108
+ end
109
+ end
110
+
111
+ def section(name)
112
+ sections[name]
113
+ end
114
+
115
+ def data_attributes
116
+ @data_attributes ||= {}
117
+ end
118
+
119
+ def window_attribute
120
+ data_attributes['window'] || 'data'
121
+ end
122
+
123
+ def schema_path
124
+ data_attributes['schema']
125
+ end
126
+
127
+ def merge_strategy
128
+ data_attributes['merge']
129
+ end
130
+
131
+ def layout
132
+ data_attributes['layout']
133
+ end
134
+
135
+ def section?(name)
136
+ @grammar.sections.key?(name)
137
+ end
138
+
139
+ # Get the raw section node with location information
140
+ def section_node(name)
141
+ @grammar.sections[name]
142
+ end
143
+
144
+ def partials
145
+ return [] unless @ast
146
+
147
+ partials = []
148
+ extract_partials_from_node(@ast, partials)
149
+ partials.uniq
150
+ end
151
+
152
+ def template_variables
153
+ extract_variables_from_section('template', exclude_partials: true)
154
+ end
155
+
156
+ def data_variables
157
+ extract_variables_from_section('data')
158
+ end
159
+
160
+ def all_variables
161
+ (template_variables + data_variables).uniq
162
+ end
163
+
164
+ private
165
+
166
+ def extract_partials_from_node(node, partials)
167
+ return unless @ast
168
+
169
+ # Extract from all sections
170
+ @grammar.sections.each do |section_name, section_node|
171
+ content_nodes = section_node.value[:content]
172
+ next unless content_nodes.is_a?(Array)
173
+
174
+ extract_partials_from_content_nodes(content_nodes, partials)
175
+ end
176
+ end
177
+
178
+ def extract_partials_from_content_nodes(content_nodes, partials)
179
+ content_nodes.each do |content_node|
180
+ case content_node.type
181
+ when :partial_expression
182
+ partials << content_node.value[:name]
183
+ when :if_block
184
+ extract_partials_from_content_nodes(content_node.value[:if_content], partials)
185
+ extract_partials_from_content_nodes(content_node.value[:else_content], partials)
186
+ when :unless_block, :each_block
187
+ extract_partials_from_content_nodes(content_node.value[:content], partials)
188
+ when :handlebars_expression
189
+ # Handle old format for data sections
190
+ content = content_node.value[:content]
191
+ if content.start_with?('>')
192
+ partials << content[1..].strip
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ def extract_variables_from_section(section_name, exclude_partials: false)
199
+ section_node = @grammar.sections[section_name]
200
+ return [] unless section_node
201
+
202
+ variables = []
203
+ content_nodes = section_node.value[:content]
204
+ extract_variables_from_content(content_nodes, variables, exclude_partials: exclude_partials)
205
+ variables.uniq
206
+ end
207
+
208
+ def extract_variables_from_content(content_nodes, variables, exclude_partials: false)
209
+ return unless content_nodes.is_a?(Array)
210
+
211
+ content_nodes.each do |node|
212
+ case node.type
213
+ when :variable_expression
214
+ variables << node.value[:name]
215
+ when :if_block
216
+ variables << node.value[:condition]
217
+ extract_variables_from_content(node.value[:if_content], variables, exclude_partials: exclude_partials)
218
+ extract_variables_from_content(node.value[:else_content], variables, exclude_partials: exclude_partials)
219
+ when :unless_block
220
+ variables << node.value[:condition]
221
+ extract_variables_from_content(node.value[:content], variables, exclude_partials: exclude_partials)
222
+ when :each_block
223
+ variables << node.value[:items]
224
+ extract_variables_from_content(node.value[:content], variables, exclude_partials: exclude_partials)
225
+ when :partial_expression
226
+ # Skip partials if requested
227
+ next if exclude_partials
228
+ when :text
229
+ # Extract handlebars expressions from text content (for data sections)
230
+ extract_variables_from_text(node.value, variables, exclude_partials: exclude_partials)
231
+ when :handlebars_expression
232
+ # Handle old format for data sections
233
+ content = node.value[:content]
234
+
235
+ # Skip partials if requested
236
+ next if exclude_partials && content.start_with?('>')
237
+
238
+ # Skip block helpers
239
+ next if content.match?(%r{^(#|/)(if|unless|each|with)\s})
240
+
241
+ variables << content.strip
242
+ end
243
+ end
244
+ end
245
+
246
+ private
247
+
248
+ def extract_variables_from_text(text, variables, exclude_partials: false)
249
+ # Find all handlebars expressions in text content
250
+ text.scan(/\{\{(.+?)\}\}/) do |match|
251
+ content = match[0].strip
252
+
253
+ # Skip partials if requested
254
+ next if exclude_partials && content.start_with?('>')
255
+
256
+ # Skip block helpers
257
+ next if content.match?(%r{^(#|/)(if|unless|each|with)\s})
258
+
259
+ variables << content
260
+ end
261
+ end
262
+
263
+ def parse_data_attributes!
264
+ data_section = @grammar.sections['data']
265
+ @data_attributes = {}
266
+
267
+ if data_section
268
+ @data_attributes = data_section.value[:attributes].dup
269
+
270
+ # Validate attributes and warn about unknown ones
271
+ validate_data_attributes!
272
+ end
273
+
274
+ # Set default window attribute
275
+ @data_attributes['window'] ||= 'data'
276
+ end
277
+
278
+ def validate_data_attributes!
279
+ unknown_attributes = @data_attributes.keys - KNOWN_DATA_ATTRIBUTES
280
+
281
+ unknown_attributes.each do |attr|
282
+ warn_unknown_attribute(attr)
283
+ end
284
+ end
285
+
286
+ def warn_unknown_attribute(attribute)
287
+ file_info = @file_path ? " in #{@file_path}" : ""
288
+ warn "Warning: data section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
289
+ end
290
+
291
+ class << self
292
+ def parse_file(file_path)
293
+ raise ArgumentError, 'Not a .rue file' unless rue_file?(file_path)
294
+
295
+ file_content = File.read(file_path)
296
+ new(file_content, file_path).parse!
297
+ end
298
+
299
+ def rue_file?(file_path)
300
+ File.extname(file_path) == '.rue'
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,353 @@
1
+ # lib/rhales/template_engine.rb
2
+
3
+ require 'erb'
4
+ require_relative 'parsers/rue_format_parser'
5
+ require_relative 'parsers/handlebars_parser'
6
+ require_relative 'rue_document'
7
+
8
+ module Rhales
9
+ # Rhales - Ruby Handlebars-style template engine
10
+ #
11
+ # Modern AST-based template engine that supports both simple template strings
12
+ # and full .rue files. Uses RueFormatParser for parsing with proper
13
+ # nested structure handling and block statement support.
14
+ #
15
+ # Features:
16
+ # - Dual-mode operation: simple templates and .rue files
17
+ # - Full AST parsing eliminates regex-based vulnerabilities
18
+ # - Proper nested block handling with accurate error reporting
19
+ # - XSS protection through HTML escaping by default
20
+ # - Handlebars-compatible syntax with Ruby idioms
21
+ #
22
+ # Supported syntax:
23
+ # - {{variable}} - Variable interpolation with HTML escaping
24
+ # - {{{variable}}} - Raw variable interpolation (no escaping)
25
+ # - {{#if condition}} ... {{else}} ... {{/if}} - Conditionals with else
26
+ # - {{#unless condition}} ... {{/unless}} - Negated conditionals
27
+ # - {{#each items}} ... {{/each}} - Iteration with context
28
+ # - {{> partial_name}} - Partial inclusion
29
+ class TemplateEngine
30
+ class RenderError < ::Rhales::RenderError; end
31
+ class PartialNotFoundError < RenderError; end
32
+ class UndefinedVariableError < RenderError; end
33
+ class BlockNotFoundError < RenderError; end
34
+
35
+ attr_reader :template_content, :context, :partial_resolver, :parser
36
+
37
+ def initialize(template_content, context, partial_resolver: nil)
38
+ @template_content = template_content
39
+ @context = context
40
+ @partial_resolver = partial_resolver
41
+ @parser = nil
42
+ end
43
+
44
+ def render
45
+ # Check if this is a simple template or a full .rue file
46
+ if simple_template?
47
+ # Use HandlebarsParser for simple templates
48
+ parser = HandlebarsParser.new(@template_content)
49
+ parser.parse!
50
+ render_content_nodes(parser.ast.children)
51
+ else
52
+ # Use RueDocument for .rue files
53
+ @parser = RueDocument.new(@template_content)
54
+ @parser.parse!
55
+
56
+ # Get template section via RueDocument
57
+ template_content = @parser.section('template')
58
+ raise RenderError, 'Missing template section' unless template_content
59
+
60
+ # Render the template section as a simple template
61
+ render_template_string(template_content)
62
+ end
63
+ rescue ::Rhales::ParseError => ex
64
+ # Parse errors already have good error messages with location
65
+ raise RenderError, "Template parsing failed: #{ex.message}"
66
+ rescue ::Rhales::ValidationError => ex
67
+ # Validation errors from RueDocument
68
+ raise RenderError, "Template validation failed: #{ex.message}"
69
+ rescue StandardError => ex
70
+ raise RenderError, "Template rendering failed: #{ex.message}"
71
+ end
72
+
73
+ # Access window attribute from parsed .rue file
74
+ def window_attribute
75
+ @parser&.window_attribute
76
+ end
77
+
78
+ # Access schema path from parsed .rue file
79
+ def schema_path
80
+ @parser&.schema_path
81
+ end
82
+
83
+ # Access all data attributes from parsed .rue file
84
+ def data_attributes
85
+ @parser&.data_attributes || {}
86
+ end
87
+
88
+ # Get template variables used in the template
89
+ def template_variables
90
+ @parser&.template_variables || []
91
+ end
92
+
93
+ # Get all partials used in the template
94
+ def partials
95
+ @parser&.partials || []
96
+ end
97
+
98
+ private
99
+
100
+ def simple_template?
101
+ !@template_content.match?(/^<(data|template|logic)\b/)
102
+ end
103
+
104
+ def render_template_string(template_string)
105
+ # Parse the template string as a simple Handlebars template
106
+ parser = HandlebarsParser.new(template_string)
107
+ parser.parse!
108
+ render_content_nodes(parser.ast.children)
109
+ end
110
+
111
+ # Render array of AST content nodes with proper block handling
112
+ # Processes text nodes and AST block nodes directly
113
+ def render_content_nodes(content_nodes)
114
+ return '' unless content_nodes.is_a?(Array)
115
+
116
+ result = ''
117
+
118
+ content_nodes.each do |node|
119
+ case node.type
120
+ when :text
121
+ result += node.value
122
+ when :variable_expression
123
+ result += render_variable_expression(node)
124
+ when :partial_expression
125
+ result += render_partial_expression(node)
126
+ when :if_block
127
+ result += render_if_block(node)
128
+ when :unless_block
129
+ result += render_unless_block(node)
130
+ when :each_block
131
+ result += render_each_block(node)
132
+ when :handlebars_expression
133
+ # Handle old format for data sections
134
+ result += render_handlebars_expression(node)
135
+ end
136
+ end
137
+
138
+ result
139
+ end
140
+
141
+ def render_variable_expression(node)
142
+ name = node.value[:name]
143
+ raw = node.value[:raw]
144
+
145
+ value = get_variable_value(name)
146
+ raw ? value.to_s : escape_html(value.to_s)
147
+ end
148
+
149
+ def render_partial_expression(node)
150
+ partial_name = node.value[:name]
151
+ render_partial(partial_name)
152
+ end
153
+
154
+ def render_if_block(node)
155
+ condition = node.value[:condition]
156
+ if_content = node.value[:if_content]
157
+ else_content = node.value[:else_content]
158
+
159
+ if evaluate_condition(condition)
160
+ render_content_nodes(if_content)
161
+ else
162
+ render_content_nodes(else_content)
163
+ end
164
+ end
165
+
166
+ def render_unless_block(node)
167
+ condition = node.value[:condition]
168
+ content = node.value[:content]
169
+
170
+ if evaluate_condition(condition)
171
+ ''
172
+ else
173
+ render_content_nodes(content)
174
+ end
175
+ end
176
+
177
+ def render_each_block(node)
178
+ items_var = node.value[:items]
179
+ block_content = node.value[:content]
180
+
181
+ items = get_variable_value(items_var)
182
+
183
+ if items.respond_to?(:each)
184
+ items.map.with_index do |item, index|
185
+ # Create context for each iteration
186
+ item_context = create_each_context(item, index, items_var)
187
+ engine = self.class.new('', item_context, partial_resolver: @partial_resolver)
188
+ engine.send(:render_content_nodes, block_content)
189
+ end.join
190
+ else
191
+ ''
192
+ end
193
+ end
194
+
195
+ def render_handlebars_expression(node)
196
+ content = node.value[:content]
197
+ raw = node.value[:raw]
198
+
199
+ # Handle different expression types
200
+ case content
201
+ when /^>\s*(\w+)/ # Partials
202
+ render_partial(Regexp.last_match(1))
203
+ when %r{^(#|/)(if|unless|each)} # Block statements (should be handled by render_content_nodes)
204
+ ''
205
+ else # Variables
206
+ value = get_variable_value(content)
207
+ raw ? value.to_s : escape_html(value.to_s)
208
+ end
209
+ end
210
+
211
+ def render_partial(partial_name)
212
+ return "{{> #{partial_name}}}" unless @partial_resolver
213
+
214
+ partial_content = @partial_resolver.call(partial_name)
215
+ raise PartialNotFoundError, "Partial '#{partial_name}' not found" unless partial_content
216
+
217
+ # Recursively render the partial content
218
+ engine = self.class.new(partial_content, @context, partial_resolver: @partial_resolver)
219
+ engine.render
220
+ end
221
+
222
+ # Get variable value from context
223
+ def get_variable_value(variable_name)
224
+ # Handle special variables
225
+ case variable_name
226
+ when 'this', '.'
227
+ return @context.respond_to?(:current_item) ? @context.current_item : nil
228
+ when '@index'
229
+ return @context.respond_to?(:current_index) ? @context.current_index : nil
230
+ end
231
+
232
+ # Get from context
233
+ if @context.respond_to?(:get)
234
+ @context.get(variable_name)
235
+ elsif @context.respond_to?(:[])
236
+ @context[variable_name] || @context[variable_name.to_sym]
237
+ else
238
+ nil
239
+ end
240
+ end
241
+
242
+ # Evaluate condition for if/unless blocks
243
+ def evaluate_condition(condition)
244
+ value = get_variable_value(condition)
245
+
246
+ # Handle truthy/falsy evaluation
247
+ case value
248
+ when nil, false
249
+ false
250
+ when ''
251
+ false
252
+ when 'false', 'False', 'FALSE'
253
+ false
254
+ when Array
255
+ !value.empty?
256
+ when Hash
257
+ !value.empty?
258
+ when 0
259
+ false
260
+ else
261
+ true
262
+ end
263
+ end
264
+
265
+ # Create context for each iteration
266
+ def create_each_context(item, index, items_var)
267
+ EachContext.new(@context, item, index, items_var)
268
+ end
269
+
270
+ # HTML escape for XSS protection
271
+ def escape_html(string)
272
+ ERB::Util.html_escape(string)
273
+ end
274
+
275
+ # Context wrapper for {{#each}} iterations
276
+ class EachContext
277
+ attr_reader :parent_context, :current_item, :current_index, :items_var
278
+
279
+ def initialize(parent_context, current_item, current_index, items_var)
280
+ @parent_context = parent_context
281
+ @current_item = current_item
282
+ @current_index = current_index
283
+ @items_var = items_var
284
+ end
285
+
286
+ def get(variable_name)
287
+ # Handle special each variables
288
+ case variable_name
289
+ when 'this', '.'
290
+ return @current_item
291
+ when '@index'
292
+ return @current_index
293
+ when '@first'
294
+ return @current_index == 0
295
+ when '@last'
296
+ # We'd need to know the total length for this
297
+ return false
298
+ end
299
+
300
+ # Check if it's a property of the current item
301
+ if @current_item.respond_to?(:[])
302
+ item_value = @current_item[variable_name] || @current_item[variable_name.to_sym]
303
+ return item_value unless item_value.nil?
304
+ end
305
+
306
+ if @current_item.respond_to?(variable_name)
307
+ return @current_item.public_send(variable_name)
308
+ end
309
+
310
+ # Fall back to parent context
311
+ @parent_context.get(variable_name) if @parent_context.respond_to?(:get)
312
+ end
313
+
314
+ def respond_to?(method_name)
315
+ super || @parent_context.respond_to?(method_name)
316
+ end
317
+
318
+ def method_missing(method_name, *)
319
+ if @parent_context.respond_to?(method_name)
320
+ @parent_context.public_send(method_name, *)
321
+ else
322
+ super
323
+ end
324
+ end
325
+
326
+ def respond_to_missing?(method_name, include_private = false)
327
+ super || @parent_context.respond_to?(method_name, include_private)
328
+ end
329
+ end
330
+
331
+ class << self
332
+ # Render template with context and optional partial resolver
333
+ def render(template_content, context, partial_resolver: nil)
334
+ new(template_content, context, partial_resolver: partial_resolver).render
335
+ end
336
+
337
+ # Create partial resolver that loads .rue files from a directory
338
+ def file_partial_resolver(templates_dir)
339
+ proc do |partial_name|
340
+ partial_path = File.join(templates_dir, "#{partial_name}.rue")
341
+
342
+ if File.exist?(partial_path)
343
+ # Load and parse the partial .rue file
344
+ document = RueDocument.parse_file(partial_path)
345
+ document.section('template')
346
+ else
347
+ nil
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end