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,385 @@
1
+ # lib/rhales/parsers/rue_format_parser.rb
2
+
3
+ require 'strscan'
4
+ require_relative 'handlebars_parser'
5
+
6
+ module Rhales
7
+ # Hand-rolled recursive descent parser for .rue files
8
+ #
9
+ # This parser implements .rue file parsing rules in Ruby code and produces
10
+ # an Abstract Syntax Tree (AST) for .rue file processing. It handles:
11
+ #
12
+ # - Section-based parsing: <data>, <template>, <logic>
13
+ # - Attribute extraction from section tags
14
+ # - Delegation to HandlebarsParser for template content
15
+ # - Validation of required sections
16
+ #
17
+ # Note: This class is a parser implementation, not a formal grammar definition.
18
+ # A formal grammar would be written in BNF/EBNF notation, while this class
19
+ # contains the actual parsing logic written in Ruby.
20
+ #
21
+ # File format structure:
22
+ # rue_file := section+
23
+ # section := '<' tag_name attributes? '>' content '</' tag_name '>'
24
+ # tag_name := 'data' | 'template' | 'logic'
25
+ # attributes := attribute+
26
+ # attribute := key '=' quoted_value
27
+ # content := (text | handlebars_expression)*
28
+ # handlebars_expression := '{{' expression '}}'
29
+ class RueFormatParser
30
+ # At least one of these sections must be present
31
+ REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
32
+ KNOWN_SECTIONS = %w[data template logic].freeze
33
+ ALL_SECTIONS = KNOWN_SECTIONS.freeze
34
+
35
+ # Regular expression to match HTML/XML comments outside of sections
36
+ COMMENT_REGEX = /<!--.*?-->/m
37
+
38
+ class ParseError < ::Rhales::ParseError
39
+ def initialize(message, line: nil, column: nil, offset: nil)
40
+ super(message, line: line, column: column, offset: offset, source_type: :rue)
41
+ end
42
+ end
43
+
44
+ class Node
45
+ attr_reader :type, :location, :children, :value
46
+
47
+ def initialize(type, location, value: nil, children: [])
48
+ @type = type
49
+ @location = location
50
+ @value = value
51
+ @children = children
52
+ end
53
+ end
54
+
55
+ class Location
56
+ attr_reader :start_line, :start_column, :end_line, :end_column, :start_offset, :end_offset
57
+
58
+ def initialize(start_line:, start_column:, end_line:, end_column:, start_offset:, end_offset:)
59
+ @start_line = start_line
60
+ @start_column = start_column
61
+ @end_line = end_line
62
+ @end_column = end_column
63
+ @start_offset = start_offset
64
+ @end_offset = end_offset
65
+ end
66
+ end
67
+
68
+ def initialize(content, file_path = nil)
69
+ @content = preprocess_content(content)
70
+ @file_path = file_path
71
+ @position = 0
72
+ @line = 1
73
+ @column = 1
74
+ @ast = nil
75
+ end
76
+
77
+ def parse!
78
+ @ast = parse_rue_file
79
+ validate_ast!
80
+ self
81
+ end
82
+
83
+ attr_reader :ast
84
+
85
+ def sections
86
+ return {} unless @ast
87
+
88
+ @ast.children.each_with_object({}) do |section_node, sections|
89
+ sections[section_node.value[:tag]] = section_node
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def parse_rue_file
96
+ sections = []
97
+
98
+ until at_end?
99
+ skip_whitespace
100
+ break if at_end?
101
+
102
+ sections << parse_section
103
+ end
104
+
105
+ if sections.empty?
106
+ raise ParseError.new('Empty .rue file', line: @line, column: @column, offset: @position)
107
+ end
108
+
109
+ Node.new(:rue_file, current_location, children: sections)
110
+ end
111
+
112
+ def parse_section
113
+ start_pos = current_position
114
+
115
+ # Parse opening tag
116
+ consume('<') || parse_error("Expected '<' to start section")
117
+ tag_name = parse_tag_name
118
+ attributes = parse_attributes
119
+ consume('>') || parse_error("Expected '>' to close opening tag")
120
+
121
+ # Parse content
122
+ content = parse_section_content(tag_name)
123
+
124
+ # Parse closing tag
125
+ consume("</#{tag_name}>") || parse_error("Expected '</#{tag_name}>' to close section")
126
+
127
+ end_pos = current_position
128
+ location = Location.new(
129
+ start_line: start_pos[:line],
130
+ start_column: start_pos[:column],
131
+ end_line: end_pos[:line],
132
+ end_column: end_pos[:column],
133
+ start_offset: start_pos[:offset],
134
+ end_offset: end_pos[:offset],
135
+ )
136
+
137
+ Node.new(:section, location, value: {
138
+ tag: tag_name,
139
+ attributes: attributes,
140
+ content: content,
141
+ }
142
+ )
143
+ end
144
+
145
+ def parse_tag_name
146
+ start_pos = @position
147
+
148
+ advance while !at_end? && current_char.match?(/[a-zA-Z]/)
149
+
150
+ if start_pos == @position
151
+ parse_error('Expected tag name')
152
+ end
153
+
154
+ @content[start_pos...@position]
155
+ end
156
+
157
+ def parse_attributes
158
+ attributes = {}
159
+
160
+ while !at_end? && current_char != '>'
161
+ skip_whitespace
162
+ break if current_char == '>'
163
+
164
+ # Parse attribute name
165
+ attr_name = parse_identifier
166
+ skip_whitespace
167
+
168
+ consume('=') || parse_error("Expected '=' after attribute name")
169
+ skip_whitespace
170
+
171
+ # Parse attribute value
172
+ attr_value = parse_quoted_string
173
+ attributes[attr_name] = attr_value
174
+
175
+ skip_whitespace
176
+ end
177
+
178
+ attributes
179
+ end
180
+
181
+ def parse_section_content(tag_name)
182
+ start_pos = @position
183
+ content_start = @position
184
+
185
+ # Extract the raw content between section tags
186
+ raw_content = ''
187
+ while !at_end? && !peek_closing_tag?(tag_name)
188
+ raw_content << current_char
189
+ advance
190
+ end
191
+
192
+ # For template sections, use HandlebarsParser to parse the content
193
+ if tag_name == 'template'
194
+ handlebars_parser = HandlebarsParser.new(raw_content)
195
+ handlebars_parser.parse!
196
+ handlebars_parser.ast.children
197
+ else
198
+ # For data and logic sections, keep as simple text
199
+ return [Node.new(:text, current_location, value: raw_content)] unless raw_content.empty?
200
+
201
+ []
202
+ end
203
+ end
204
+
205
+ def parse_quoted_string
206
+ quote_char = current_char
207
+ unless ['"', "'"].include?(quote_char)
208
+ parse_error('Expected quoted string')
209
+ end
210
+
211
+ advance # Skip opening quote
212
+ value = ''
213
+
214
+ while !at_end? && current_char != quote_char
215
+ value << current_char
216
+ advance
217
+ end
218
+
219
+ consume(quote_char) || parse_error('Unterminated quoted string')
220
+ value
221
+ end
222
+
223
+ def parse_identifier
224
+ start_pos = @position
225
+
226
+ advance while !at_end? && current_char.match?(/[a-zA-Z0-9_]/)
227
+
228
+ if start_pos == @position
229
+ parse_error('Expected identifier')
230
+ end
231
+
232
+ @content[start_pos...@position]
233
+ end
234
+
235
+ def peek_closing_tag?(tag_name)
236
+ peek_string?("</#{tag_name}>")
237
+ end
238
+
239
+ def peek_string?(string)
240
+ @content[@position, string.length] == string
241
+ end
242
+
243
+ def consume(expected)
244
+ if peek_string?(expected)
245
+ expected.length.times { advance }
246
+ true
247
+ else
248
+ false
249
+ end
250
+ end
251
+
252
+ def current_char
253
+ return "\0" if at_end?
254
+
255
+ @content[@position]
256
+ end
257
+
258
+ def peek_char
259
+ return "\0" if @position + 1 >= @content.length
260
+
261
+ @content[@position + 1]
262
+ end
263
+
264
+ def advance
265
+ if current_char == "\n"
266
+ @line += 1
267
+ @column = 1
268
+ else
269
+ @column += 1
270
+ end
271
+ @position += 1
272
+ end
273
+
274
+ def at_end?
275
+ @position >= @content.length
276
+ end
277
+
278
+ def skip_whitespace
279
+ advance while !at_end? && current_char.match?(/\s/)
280
+ end
281
+
282
+ def current_position
283
+ { line: @line, column: @column, offset: @position }
284
+ end
285
+
286
+ def current_location
287
+ pos = current_position
288
+ Location.new(
289
+ start_line: pos[:line],
290
+ start_column: pos[:column],
291
+ end_line: pos[:line],
292
+ end_column: pos[:column],
293
+ start_offset: pos[:offset],
294
+ end_offset: pos[:offset],
295
+ )
296
+ end
297
+
298
+ def validate_ast!
299
+ sections = @ast.children.map { |node| node.value[:tag] }
300
+
301
+ # Check that at least one required section is present
302
+ required_present = REQUIRES_ONE_OF_SECTIONS & sections
303
+ if required_present.empty?
304
+ raise ParseError.new("Must have at least one of: #{REQUIRES_ONE_OF_SECTIONS.join(', ')}", line: 1, column: 1)
305
+ end
306
+
307
+ # Check for duplicates
308
+ duplicates = sections.select { |tag| sections.count(tag) > 1 }.uniq
309
+ if duplicates.any?
310
+ raise ParseError.new("Duplicate sections: #{duplicates.join(', ')}", line: 1, column: 1)
311
+ end
312
+
313
+ # Check for unknown sections
314
+ unknown = sections - KNOWN_SECTIONS
315
+ if unknown.any?
316
+ raise ParseError.new("Unknown sections: #{unknown.join(', ')}", line: 1, column: 1)
317
+ end
318
+ end
319
+
320
+ def parse_error(message)
321
+ raise ParseError.new(message, line: @line, column: @column, offset: @position)
322
+ end
323
+
324
+ # Preprocess content to strip XML/HTML comments outside of sections
325
+ # Uses Ruby 3.4+ pattern matching for robust, secure parsing
326
+ def preprocess_content(content)
327
+ tokens = tokenize_content(content)
328
+
329
+ # Use pattern matching to filter out comments outside sections
330
+ result_parts = []
331
+ in_section = false
332
+
333
+ tokens.each do |token|
334
+ case token
335
+ in { type: :comment } unless in_section
336
+ # Skip comments outside sections
337
+ next
338
+ in { type: :section_start }
339
+ in_section = true
340
+ result_parts << token[:content]
341
+ in { type: :section_end }
342
+ in_section = false
343
+ result_parts << token[:content]
344
+ in { type: :comment | :text, content: content }
345
+ # Include comments inside sections and all text
346
+ result_parts << content
347
+ end
348
+ end
349
+
350
+ result_parts.join
351
+ end
352
+
353
+ private
354
+
355
+ # Tokenize content into structured tokens for pattern matching
356
+ # Uses StringScanner for better performance and cleaner code
357
+ def tokenize_content(content)
358
+ scanner = StringScanner.new(content)
359
+ tokens = []
360
+
361
+ until scanner.eos?
362
+ case
363
+ when scanner.scan(/<!--.*?-->/m)
364
+ # Comment token - non-greedy match for complete comments
365
+ tokens << { type: :comment, content: scanner.matched }
366
+ when scanner.scan(/<(data|template|logic)(\s[^>]*)?>/m)
367
+ # Section start token - matches opening tags with optional attributes
368
+ tokens << { type: :section_start, content: scanner.matched }
369
+ when scanner.scan(/<\/(data|template|logic)>/m)
370
+ # Section end token - matches closing tags
371
+ tokens << { type: :section_end, content: scanner.matched }
372
+ when scanner.scan(/[^<]+/)
373
+ # Text token - consolidates runs of non-< characters for efficiency
374
+ tokens << { type: :text, content: scanner.matched }
375
+ else
376
+ # Fallback for single characters (< that don't match patterns)
377
+ # This maintains compatibility with the original character-by-character behavior
378
+ tokens << { type: :text, content: scanner.getch }
379
+ end
380
+ end
381
+
382
+ tokens
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,236 @@
1
+ # lib/rhales/refinements/require_refinements.rb
2
+
3
+ require_relative '../rue_document'
4
+
5
+ module Rhales
6
+ module Ruequire
7
+ # Thread-safe cache for parsed RSFC templates
8
+ @rsfc_cache = {}
9
+ @file_watchers = {}
10
+ @cache_mutex = Mutex.new
11
+ @watchers_mutex = Mutex.new
12
+
13
+ class << self
14
+ # Thread-safe access to cache
15
+ def rsfc_cache
16
+ @cache_mutex.synchronize { @rsfc_cache.dup }
17
+ end
18
+
19
+ # Thread-safe access to file watchers
20
+ def file_watchers
21
+ @watchers_mutex.synchronize { @file_watchers.dup }
22
+ end
23
+
24
+ # Clear cache (useful for development and testing)
25
+ def clear_cache!
26
+ @cache_mutex.synchronize { @rsfc_cache.clear }
27
+ cleanup_file_watchers!
28
+ end
29
+
30
+ # Stop all file watchers and clean up resources
31
+ def cleanup_file_watchers!
32
+ @watchers_mutex.synchronize do
33
+ @file_watchers.each_value do |watcher_thread|
34
+ next unless watcher_thread.is_a?(Thread) && watcher_thread.alive?
35
+
36
+ watcher_thread.kill
37
+ begin
38
+ watcher_thread.join(1) # Wait up to 1 second for clean shutdown
39
+ rescue StandardError
40
+ # Thread might already be dead, ignore errors
41
+ end
42
+ end
43
+ @file_watchers.clear
44
+ end
45
+ end
46
+
47
+ # Stop watching a specific file
48
+ def stop_watching_file!(full_path)
49
+ @watchers_mutex.synchronize do
50
+ watcher_thread = @file_watchers.delete(full_path)
51
+ if watcher_thread.is_a?(Thread) && watcher_thread.alive?
52
+ watcher_thread.kill
53
+ begin
54
+ watcher_thread.join(1)
55
+ rescue StandardError
56
+ # Thread cleanup error, ignore
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # Enable development mode file watching
63
+ def enable_file_watching!
64
+ @file_watching_enabled = true
65
+ end
66
+
67
+ # Disable file watching
68
+ def disable_file_watching!
69
+ @file_watching_enabled = false
70
+ end
71
+
72
+ def file_watching_enabled?
73
+ @file_watching_enabled ||= false
74
+ end
75
+ end
76
+
77
+ # Ensure cleanup on program exit
78
+ at_exit do
79
+ if defined?(Rhales::Ruequire)
80
+ Rhales::Ruequire.cleanup_file_watchers!
81
+ end
82
+ end
83
+
84
+ refine Kernel do
85
+ def require(path)
86
+ return process_rue(path) if path.end_with?('.rue')
87
+
88
+ super
89
+ end
90
+
91
+ def process_rue(path)
92
+ # Resolve full path
93
+ full_path = resolve_rue_path(path)
94
+
95
+ unless File.exist?(full_path)
96
+ raise LoadError, "cannot load such file -- #{path} (resolved to #{full_path})"
97
+ end
98
+
99
+ # Check cache first
100
+ cached_parser = get_cached_parser(full_path)
101
+ return cached_parser if cached_parser
102
+
103
+ # Parse the .rue file
104
+ parser = Rhales::RueDocument.parse_file(full_path)
105
+
106
+ # Cache the parsed result
107
+ cache_parser(full_path, parser)
108
+
109
+ # Set up file watching in development mode
110
+ setup_file_watching(full_path) if Rhales::Ruequire.file_watching_enabled?
111
+
112
+ parser
113
+ rescue StandardError => ex
114
+ if defined?(OT) && OT.respond_to?(:le)
115
+ OT.le "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
116
+ else
117
+ puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
118
+ end
119
+ raise
120
+ end
121
+
122
+ private
123
+
124
+ # Resolve .rue file path
125
+ def resolve_rue_path(path)
126
+ # If path is absolute and exists, use it
127
+ return path if path.start_with?('/') && File.exist?(path)
128
+
129
+ # If path is relative and exists in current directory
130
+ return File.expand_path(path) if File.exist?(path)
131
+
132
+ # Search in templates directory
133
+ boot_root = defined?(OT) && OT.respond_to?(:boot_root) ? OT.boot_root : File.expand_path('../../..', __dir__)
134
+ templates_path = File.join(boot_root, 'templates', path)
135
+ return templates_path if File.exist?(templates_path)
136
+
137
+ # Search in templates/web directory
138
+ web_templates_path = File.join(boot_root, 'templates', 'web', path)
139
+ return web_templates_path if File.exist?(web_templates_path)
140
+
141
+ # If path doesn't have .rue extension, add it and try again
142
+ unless path.end_with?('.rue')
143
+ return resolve_rue_path("#{path}.rue")
144
+ end
145
+
146
+ # Return original path (will cause file not found error)
147
+ path
148
+ end
149
+
150
+ # Get parser from cache if available and not stale
151
+ def get_cached_parser(full_path)
152
+ Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
153
+ cache_entry = Rhales::Ruequire.instance_variable_get(:@rsfc_cache)[full_path]
154
+ return nil unless cache_entry
155
+
156
+ # Check if file has been modified
157
+ if File.mtime(full_path) > cache_entry[:mtime]
158
+ # File modified, remove from cache
159
+ Rhales::Ruequire.instance_variable_get(:@rsfc_cache).delete(full_path)
160
+ return nil
161
+ end
162
+
163
+ cache_entry[:parser]
164
+ end
165
+ end
166
+
167
+ # Cache parsed parser with modification time
168
+ def cache_parser(full_path, parser)
169
+ Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
170
+ Rhales::Ruequire.instance_variable_get(:@rsfc_cache)[full_path] = {
171
+ parser: parser,
172
+ mtime: File.mtime(full_path),
173
+ }
174
+ end
175
+ end
176
+
177
+ # Set up file watching for development mode
178
+ def setup_file_watching(full_path)
179
+ # Check if already watching in a thread-safe way
180
+ watchers_mutex = Rhales::Ruequire.instance_variable_get(:@watchers_mutex)
181
+ is_already_watching = watchers_mutex.synchronize do
182
+ Rhales::Ruequire.instance_variable_get(:@file_watchers)[full_path]
183
+ end
184
+
185
+ return if is_already_watching
186
+
187
+ # Simple polling-based file watching
188
+ # In a production system, you might want to use a more sophisticated
189
+ # file watching library like Listen or rb-inotify
190
+ watcher_thread = Thread.new do
191
+ last_mtime = File.mtime(full_path)
192
+
193
+ loop do
194
+ sleep 1 # Check every second
195
+
196
+ begin
197
+ current_mtime = File.mtime(full_path)
198
+
199
+ if current_mtime > last_mtime
200
+ if defined?(OT) && OT.respond_to?(:ld)
201
+ OT.ld "[RSFC] File changed, clearing cache: #{full_path}"
202
+ end
203
+
204
+ # Thread-safe cache removal
205
+ Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
206
+ Rhales::Ruequire.instance_variable_get(:@rsfc_cache).delete(full_path)
207
+ end
208
+
209
+ last_mtime = current_mtime
210
+ end
211
+ rescue StandardError => ex
212
+ # File might have been deleted
213
+ if defined?(OT) && OT.respond_to?(:ld)
214
+ OT.ld "[RSFC] File watcher error for #{full_path}: #{ex.message}"
215
+ end
216
+
217
+ # Clean up watcher entry on error
218
+ watchers_mutex.synchronize do
219
+ Rhales::Ruequire.instance_variable_get(:@file_watchers).delete(full_path)
220
+ end
221
+ break
222
+ end
223
+ end
224
+ end
225
+
226
+ # Set thread as daemon so it doesn't prevent program exit
227
+ watcher_thread.thread_variable_set(:daemon, true)
228
+
229
+ # Mark as being watched
230
+ watchers_mutex.synchronize do
231
+ Rhales::Ruequire.instance_variable_get(:@file_watchers)[full_path] = watcher_thread
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end