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.
- checksums.yaml +7 -0
- data/CLAUDE.locale.txt +7 -0
- data/CLAUDE.md +90 -0
- data/LICENSE.txt +21 -0
- data/README.md +881 -0
- data/lib/rhales/adapters/base_auth.rb +106 -0
- data/lib/rhales/adapters/base_request.rb +97 -0
- data/lib/rhales/adapters/base_session.rb +93 -0
- data/lib/rhales/configuration.rb +156 -0
- data/lib/rhales/context.rb +240 -0
- data/lib/rhales/csp.rb +94 -0
- data/lib/rhales/errors/hydration_collision_error.rb +85 -0
- data/lib/rhales/errors.rb +36 -0
- data/lib/rhales/hydration_data_aggregator.rb +220 -0
- data/lib/rhales/hydration_registry.rb +58 -0
- data/lib/rhales/hydrator.rb +141 -0
- data/lib/rhales/parsers/handlebars-grammar-review.txt +39 -0
- data/lib/rhales/parsers/handlebars_parser.rb +727 -0
- data/lib/rhales/parsers/rue_format_parser.rb +385 -0
- data/lib/rhales/refinements/require_refinements.rb +236 -0
- data/lib/rhales/rue_document.rb +304 -0
- data/lib/rhales/template_engine.rb +353 -0
- data/lib/rhales/tilt.rb +214 -0
- data/lib/rhales/version.rb +6 -0
- data/lib/rhales/view.rb +412 -0
- data/lib/rhales/view_composition.rb +165 -0
- data/lib/rhales.rb +57 -0
- data/rhales.gemspec +46 -0
- metadata +78 -0
@@ -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
|