tina4ruby 3.10.18 → 3.10.21

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: b0dfa03a5f7986169a14d59895459e7ca50d70f0254789c36dbc29aca80b1b93
4
- data.tar.gz: e248121b6577b8c5d0ec172ab47e2f82235b500d3508f75e8e0fedd3071ac657
3
+ metadata.gz: 29aa756b32ea3d0051d1b5d26cb8b1312bb77c871fec39fea3e4ce2a8d5c5afd
4
+ data.tar.gz: 326553173c9e4753b6b0bf0471729c065d169d62f609c663b0c3400847e889e8
5
5
  SHA512:
6
- metadata.gz: 164505c4f5449c7fbfa15ca1b21fbdffa3beac78a7d84e907407ed11881e3a4335d7df8dd6c46cb4bb4fb11ff3f9b89d2cd4b8cd02a77bdfb72a584b965f942e
7
- data.tar.gz: b8797d3fd1abe7baf178c73a752b47357d8e56753a699d833a100940433884d036211cfc9942fcee481ca56d610efcb50169ec086df04f31e549e73971b7fb65
6
+ metadata.gz: 2f23cc2b84be7fdc7d7f6b9e21ce400a124a95d51b78e5d5b458313516d4aba930e166fc32120df8c1c6f317df78e3d4937b9722d3459bcfbb00cd2cd7c731c0
7
+ data.tar.gz: db8f5ac24ff747c59cb6b93b4877b99ac5790b3234b1f7e360d811fb002bb00618215d32fb2fa1a707108b68257de31dfd937f4c312e41f7b63f8caf65b359e7
@@ -313,14 +313,18 @@ module Tina4
313
313
 
314
314
  # Pre-generate the next available primary key ID using engine-aware strategies.
315
315
  #
316
+ # Race-safe implementation using a `tina4_sequences` table for SQLite/MySQL/MSSQL
317
+ # fallback. Each call atomically increments the stored counter, so concurrent
318
+ # callers never receive the same value.
319
+ #
316
320
  # - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
317
- # - PostgreSQL: tries nextval() on the standard sequence, falls through to MAX+1.
318
- # - SQLite/MySQL/MSSQL: uses MAX(pk) + 1.
321
+ # - PostgreSQL: tries nextval() on the named sequence, auto-creates it if missing.
322
+ # - SQLite/MySQL/MSSQL: atomic UPDATE on `tina4_sequences` table.
319
323
  # - Returns 1 if the table is empty or does not exist.
320
324
  #
321
325
  # @param table [String] Table name
322
326
  # @param pk_column [String] Primary key column name (default: "id")
323
- # @param generator_name [String, nil] Firebird generator name override
327
+ # @param generator_name [String, nil] Override for sequence/generator name
324
328
  # @return [Integer] The next available ID
325
329
  def get_next_id(table, pk_column: "id", generator_name: nil)
326
330
  drv = current_driver
@@ -338,36 +342,101 @@ module Tina4
338
342
 
339
343
  rows = drv.execute_query("SELECT GEN_ID(#{gen_name}, 1) AS NEXT_ID FROM RDB$DATABASE")
340
344
  row = rows.is_a?(Array) ? rows.first : nil
341
- return (row && (row["NEXT_ID"] || row["next_id"]))&.to_i || 1
345
+ val = row_value(row, :NEXT_ID) || row_value(row, :next_id)
346
+ return val&.to_i || 1
342
347
  end
343
348
 
344
- # PostgreSQL — try sequence first, fall through to MAX
349
+ # PostgreSQL — try sequence first, auto-create if missing
345
350
  if @driver_name == "postgres"
346
- seq_name = "#{table.downcase}_#{pk_column.downcase}_seq"
351
+ seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
347
352
  begin
348
353
  rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
349
354
  row = rows.is_a?(Array) ? rows.first : nil
350
- if row && (row["next_id"] || row["nextval"])
351
- return (row["next_id"] || row["nextval"]).to_i
352
- end
355
+ val = row_value(row, :next_id) || row_value(row, :nextval)
356
+ return val.to_i if val
353
357
  rescue
354
- # No sequencefall through to MAX
358
+ # Sequence does not exist auto-create it seeded from MAX
359
+ begin
360
+ max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
361
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
362
+ max_val = row_value(max_row, :max_id)
363
+ start_val = max_val ? max_val.to_i + 1 : 1
364
+ drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
365
+ drv.commit rescue nil
366
+ rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
367
+ row = rows.is_a?(Array) ? rows.first : nil
368
+ val = row_value(row, :next_id) || row_value(row, :nextval)
369
+ return val&.to_i || start_val
370
+ rescue
371
+ # Fall through to sequence table fallback
372
+ end
355
373
  end
356
374
  end
357
375
 
358
- # SQLite / MySQL / MSSQL / PostgreSQL fallback — MAX + 1
359
- begin
360
- rows = drv.execute_query("SELECT MAX(#{pk_column}) + 1 AS next_id FROM #{table}")
361
- row = rows.is_a?(Array) ? rows.first : nil
362
- next_id = row && (row["next_id"] || row["max"])
363
- return next_id ? next_id.to_i : 1
364
- rescue
365
- return 1
366
- end
376
+ # SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
377
+ seq_key = generator_name || "#{table}.#{pk_column}"
378
+ sequence_next(seq_key, table: table, pk_column: pk_column)
367
379
  end
368
380
 
369
381
  private
370
382
 
383
+ # Ensure the tina4_sequences table exists for race-safe ID generation.
384
+ def ensure_sequence_table
385
+ return if table_exists?("tina4_sequences")
386
+
387
+ drv = current_driver
388
+ if @driver_name == "mssql"
389
+ drv.execute("CREATE TABLE tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
390
+ else
391
+ drv.execute("CREATE TABLE IF NOT EXISTS tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
392
+ end
393
+ drv.commit rescue nil
394
+ end
395
+
396
+ # Atomically increment and return the next value for a named sequence.
397
+ # Seeds from MAX(pk_column) on first use so existing data is respected.
398
+ def sequence_next(seq_name, table: nil, pk_column: "id")
399
+ ensure_sequence_table
400
+ drv = current_driver
401
+
402
+ # Check if the sequence key already exists
403
+ rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
404
+ row = rows.is_a?(Array) ? rows.first : nil
405
+
406
+ if row.nil?
407
+ # Seed from MAX(pk_column) if table data exists
408
+ seed_value = 0
409
+ if table
410
+ begin
411
+ max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
412
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
413
+ val = row_value(max_row, :max_id)
414
+ seed_value = val.to_i if val
415
+ rescue
416
+ # Table may not exist yet — start from 0
417
+ end
418
+ end
419
+ drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
420
+ drv.commit rescue nil
421
+ end
422
+
423
+ # Atomic increment
424
+ drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
425
+ drv.commit rescue nil
426
+
427
+ # Read back the incremented value
428
+ rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
429
+ row = rows.is_a?(Array) ? rows.first : nil
430
+ val = row_value(row, :current_value)
431
+ val ? val.to_i : 1
432
+ end
433
+
434
+ # Safely extract a value from a driver result row, trying both symbol and string keys.
435
+ def row_value(row, key)
436
+ return nil unless row
437
+ row[key.to_sym] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_s.downcase]
438
+ end
439
+
371
440
  def truthy?(val)
372
441
  %w[true 1 yes on].include?((val || "").to_s.strip.downcase)
373
442
  end
data/lib/tina4/frond.rb CHANGED
@@ -34,6 +34,121 @@ module Tina4
34
34
  '"' => """, "'" => "'" }.freeze
35
35
  HTML_ESCAPE_RE = /[&<>"']/
36
36
 
37
+ # -- Compiled regex constants (optimization: avoid re-compiling in methods) --
38
+ EXTENDS_RE = /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
39
+ BLOCK_RE = /\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m
40
+ STRING_LIT_RE = /\A["'](.*)["']\z/
41
+ INTEGER_RE = /\A-?\d+\z/
42
+ FLOAT_RE = /\A-?\d+\.\d+\z/
43
+ ARRAY_LIT_RE = /\A\[(.+)\]\z/m
44
+ HASH_LIT_RE = /\A\{(.+)\}\z/m
45
+ HASH_PAIR_RE = /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
46
+ RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
47
+ ARITHMETIC_RE = /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
48
+ FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
49
+ FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
50
+ FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
51
+ OR_SPLIT_RE = /\s+or\s+/
52
+ AND_SPLIT_RE = /\s+and\s+/
53
+ IS_NOT_RE = /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
54
+ IS_RE = /\A(.+?)\s+is\s+(\w+)(.*)\z/
55
+ NOT_IN_RE = /\A(.+?)\s+not\s+in\s+(.+)\z/
56
+ IN_RE = /\A(.+?)\s+in\s+(.+)\z/
57
+ DIVISIBLE_BY_RE = /\s*by\s*\(\s*(\d+)\s*\)/
58
+ RESOLVE_SPLIT_RE = /\.|\[([^\]]+)\]/
59
+ RESOLVE_STRIP_RE = /\A["']|["']\z/
60
+ DIGIT_RE = /\A\d+\z/
61
+ FOR_RE = /\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/
62
+ SET_RE = /\Aset\s+(\w+)\s*=\s*(.+)\z/m
63
+ INCLUDE_RE = /\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/
64
+ MACRO_RE = /\Amacro\s+(\w+)\s*\(([^)]*)\)/
65
+ FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
66
+ CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
67
+ SPACELESS_RE = />\s+</
68
+ AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
69
+ STRIPTAGS_RE = /<[^>]+>/
70
+ THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
71
+ SLUG_CLEAN_RE = /[^a-z0-9]+/
72
+ SLUG_TRIM_RE = /\A-|-\z/
73
+
74
+ # Set of common no-arg filter names that can be inlined for speed
75
+ INLINE_FILTERS = %w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
76
+
77
+ # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
78
+ class LoopContext
79
+ def initialize(parent)
80
+ @parent = parent
81
+ @local = {}
82
+ end
83
+
84
+ def [](key)
85
+ @local.key?(key) ? @local[key] : @parent[key]
86
+ end
87
+
88
+ def []=(key, value)
89
+ @local[key] = value
90
+ end
91
+
92
+ def key?(key)
93
+ @local.key?(key) || @parent.key?(key)
94
+ end
95
+ alias include? key?
96
+ alias has_key? key?
97
+
98
+ def fetch(key, *args, &block)
99
+ if @local.key?(key)
100
+ @local[key]
101
+ elsif @parent.key?(key)
102
+ @parent[key]
103
+ elsif block
104
+ yield key
105
+ elsif !args.empty?
106
+ args[0]
107
+ else
108
+ raise KeyError, "key not found: #{key.inspect}"
109
+ end
110
+ end
111
+
112
+ def merge(other)
113
+ dup_hash = to_h
114
+ dup_hash.merge!(other)
115
+ dup_hash
116
+ end
117
+
118
+ def merge!(other)
119
+ other.each { |k, v| @local[k] = v }
120
+ self
121
+ end
122
+
123
+ def dup
124
+ copy = LoopContext.new(@parent)
125
+ @local.each { |k, v| copy[k] = v }
126
+ copy
127
+ end
128
+
129
+ def to_h
130
+ h = @parent.is_a?(LoopContext) ? @parent.to_h : @parent.dup
131
+ @local.each { |k, v| h[k] = v }
132
+ h
133
+ end
134
+
135
+ def each(&block)
136
+ to_h.each(&block)
137
+ end
138
+
139
+ def respond_to_missing?(name, include_private = false)
140
+ @parent.respond_to?(name, include_private) || super
141
+ end
142
+
143
+ def is_a?(klass)
144
+ klass == Hash || super
145
+ end
146
+
147
+ def keys
148
+ (@parent.is_a?(LoopContext) ? @parent.keys : @parent.keys) | @local.keys
149
+ end
150
+ end
151
+
37
152
  # -----------------------------------------------------------------------
38
153
  # Public API
39
154
  # -----------------------------------------------------------------------
@@ -60,6 +175,15 @@ module Tina4
60
175
  @compiled = {} # {template_name => [tokens, mtime]}
61
176
  @compiled_strings = {} # {md5_hash => tokens}
62
177
 
178
+ # Parsed filter chain cache: expr_string => [variable, filters]
179
+ @filter_chain_cache = {}
180
+
181
+ # Resolved dotted-path split cache: expr_string => parts_array
182
+ @resolve_cache = {}
183
+
184
+ # Sandbox root-var split cache: var_name => root_var_string
185
+ @dotted_split_cache = {}
186
+
63
187
  # Built-in global functions
64
188
  register_builtin_globals
65
189
  end
@@ -115,6 +239,9 @@ module Tina4
115
239
  def clear_cache
116
240
  @compiled.clear
117
241
  @compiled_strings.clear
242
+ @filter_chain_cache.clear
243
+ @resolve_cache.clear
244
+ @dotted_split_cache.clear
118
245
  end
119
246
 
120
247
  # Register a custom filter.
@@ -261,7 +388,7 @@ module Tina4
261
388
 
262
389
  def execute_with_tokens(source, tokens, context)
263
390
  # Handle extends first
264
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
391
+ if source =~ EXTENDS_RE
265
392
  parent_name = Regexp.last_match(1)
266
393
  parent_source = load_template(parent_name)
267
394
  child_blocks = extract_blocks(source)
@@ -273,7 +400,7 @@ module Tina4
273
400
 
274
401
  def execute(source, context)
275
402
  # Handle extends first
276
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
403
+ if source =~ EXTENDS_RE
277
404
  parent_name = Regexp.last_match(1)
278
405
  parent_source = load_template(parent_name)
279
406
  child_blocks = extract_blocks(source)
@@ -285,14 +412,14 @@ module Tina4
285
412
 
286
413
  def extract_blocks(source)
287
414
  blocks = {}
288
- source.scan(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
415
+ source.scan(BLOCK_RE) do
289
416
  blocks[Regexp.last_match(1)] = Regexp.last_match(2)
290
417
  end
291
418
  blocks
292
419
  end
293
420
 
294
421
  def render_with_blocks(parent_source, context, child_blocks)
295
- result = parent_source.gsub(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
422
+ result = parent_source.gsub(BLOCK_RE) do
296
423
  name = Regexp.last_match(1)
297
424
  default_content = Regexp.last_match(2)
298
425
  block_source = child_blocks.fetch(name, default_content)
@@ -422,7 +549,7 @@ module Tina4
422
549
  # The filter name may include a trailing comparison operator,
423
550
  # e.g. "length != 1". Extract the real filter name and the
424
551
  # comparison suffix, apply the filter, then evaluate the comparison.
425
- m = fname.match(/\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/)
552
+ m = fname.match(FILTER_CMP_RE)
426
553
  if m
427
554
  real_filter = m[1]
428
555
  op = m[2]
@@ -455,7 +582,11 @@ module Tina4
455
582
 
456
583
  # Sandbox: check variable access
457
584
  if @sandbox && @allowed_vars
458
- root_var = var_name.split(".")[0].split("[")[0].strip
585
+ root_var = @dotted_split_cache[var_name]
586
+ unless root_var
587
+ root_var = var_name.split(".")[0].split("[")[0].strip
588
+ @dotted_split_cache[var_name] = root_var
589
+ end
459
590
  return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
460
591
  end
461
592
 
@@ -473,6 +604,23 @@ module Tina4
473
604
  next
474
605
  end
475
606
 
607
+ # Inline common no-arg filters for speed (skip generic dispatch)
608
+ if args.empty? && INLINE_FILTERS.include?(fname)
609
+ value = case fname
610
+ when "upper" then value.to_s.upcase
611
+ when "lower" then value.to_s.downcase
612
+ when "length" then value.respond_to?(:length) ? value.length : value.to_s.length
613
+ when "trim" then value.to_s.strip
614
+ when "capitalize" then value.to_s.capitalize
615
+ when "title" then value.to_s.split.map(&:capitalize).join(" ")
616
+ when "string" then value.to_s
617
+ when "int" then value.to_i
618
+ when "escape", "e" then Frond.escape_html(value.to_s)
619
+ else value
620
+ end
621
+ next
622
+ end
623
+
476
624
  fn = @filters[fname]
477
625
  if fn
478
626
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -489,9 +637,9 @@ module Tina4
489
637
  end
490
638
 
491
639
  def eval_filter_arg(arg, context)
492
- return Regexp.last_match(1) if arg =~ /\A["'](.*)["']\z/
493
- return arg.to_i if arg =~ /\A-?\d+\z/
494
- return arg.to_f if arg =~ /\A-?\d+\.\d+\z/
640
+ return Regexp.last_match(1) if arg =~ STRING_LIT_RE
641
+ return arg.to_i if arg =~ INTEGER_RE
642
+ return arg.to_f if arg =~ FLOAT_RE
495
643
  eval_expr(arg, context)
496
644
  end
497
645
 
@@ -597,13 +745,16 @@ module Tina4
597
745
  # -----------------------------------------------------------------------
598
746
 
599
747
  def parse_filter_chain(expr)
748
+ cached = @filter_chain_cache[expr]
749
+ return cached if cached
750
+
600
751
  parts = split_on_pipe(expr)
601
752
  variable = parts[0].strip
602
753
  filters = []
603
754
 
604
755
  parts[1..].each do |f|
605
756
  f = f.strip
606
- if f =~ /\A(\w+)\s*\((.*)\)\z/m
757
+ if f =~ FILTER_WITH_ARGS_RE
607
758
  name = Regexp.last_match(1)
608
759
  raw_args = Regexp.last_match(2).strip
609
760
  args = raw_args.empty? ? [] : parse_args(raw_args)
@@ -613,7 +764,9 @@ module Tina4
613
764
  end
614
765
  end
615
766
 
616
- [variable, filters]
767
+ result = [variable, filters].freeze
768
+ @filter_chain_cache[expr] = result
769
+ result
617
770
  end
618
771
 
619
772
  # Split expression on | but not inside quotes or parens.
@@ -694,8 +847,8 @@ module Tina4
694
847
  end
695
848
 
696
849
  # Numeric
697
- return expr.to_i if expr =~ /\A-?\d+\z/
698
- return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
850
+ return expr.to_i if expr =~ INTEGER_RE
851
+ return expr.to_f if expr =~ FLOAT_RE
699
852
 
700
853
  # Boolean/null
701
854
  return true if expr == "true"
@@ -703,17 +856,17 @@ module Tina4
703
856
  return nil if expr == "null" || expr == "none" || expr == "nil"
704
857
 
705
858
  # Array literal [a, b, c]
706
- if expr =~ /\A\[(.+)\]\z/m
859
+ if expr =~ ARRAY_LIT_RE
707
860
  inner = Regexp.last_match(1)
708
861
  return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
709
862
  end
710
863
 
711
864
  # Hash literal { key: value, ... }
712
- if expr =~ /\A\{(.+)\}\z/m
865
+ if expr =~ HASH_LIT_RE
713
866
  inner = Regexp.last_match(1)
714
867
  hash = {}
715
868
  split_args_toplevel(inner).each do |pair|
716
- if pair =~ /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
869
+ if pair =~ HASH_PAIR_RE
717
870
  hash[Regexp.last_match(1)] = eval_expr(Regexp.last_match(2).strip, context)
718
871
  end
719
872
  end
@@ -721,7 +874,7 @@ module Tina4
721
874
  end
722
875
 
723
876
  # Range literal: 1..5
724
- if expr =~ /\A(\d+)\.\.(\d+)\z/
877
+ if expr =~ RANGE_LIT_RE
725
878
  return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
726
879
  end
727
880
 
@@ -786,7 +939,7 @@ module Tina4
786
939
  end
787
940
 
788
941
  # Arithmetic: +, -, *, /, %
789
- if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
942
+ if expr =~ ARITHMETIC_RE
790
943
  left = eval_expr(Regexp.last_match(1), context)
791
944
  op = Regexp.last_match(2)
792
945
  right = eval_expr(Regexp.last_match(3), context)
@@ -794,7 +947,7 @@ module Tina4
794
947
  end
795
948
 
796
949
  # Function call: name(arg1, arg2)
797
- if expr =~ /\A(\w+)\s*\((.*)\)\z/m
950
+ if expr =~ FUNC_CALL_RE
798
951
  fn_name = Regexp.last_match(1)
799
952
  raw_args = Regexp.last_match(2).strip
800
953
  fn = context[fn_name]
@@ -851,49 +1004,50 @@ module Tina4
851
1004
  # Comparison / logical evaluator
852
1005
  # -----------------------------------------------------------------------
853
1006
 
854
- def eval_comparison(expr, context)
1007
+ def eval_comparison(expr, context, eval_fn = nil)
1008
+ eval_fn ||= method(:eval_expr)
855
1009
  expr = expr.strip
856
1010
 
857
1011
  # Handle 'not' prefix
858
1012
  if expr.start_with?("not ")
859
- return !eval_comparison(expr[4..], context)
1013
+ return !eval_comparison(expr[4..], context, eval_fn)
860
1014
  end
861
1015
 
862
1016
  # 'or' (lowest precedence)
863
- or_parts = expr.split(/\s+or\s+/)
1017
+ or_parts = expr.split(OR_SPLIT_RE)
864
1018
  if or_parts.length > 1
865
- return or_parts.any? { |p| eval_comparison(p, context) }
1019
+ return or_parts.any? { |p| eval_comparison(p, context, eval_fn) }
866
1020
  end
867
1021
 
868
1022
  # 'and'
869
- and_parts = expr.split(/\s+and\s+/)
1023
+ and_parts = expr.split(AND_SPLIT_RE)
870
1024
  if and_parts.length > 1
871
- return and_parts.all? { |p| eval_comparison(p, context) }
1025
+ return and_parts.all? { |p| eval_comparison(p, context, eval_fn) }
872
1026
  end
873
1027
 
874
1028
  # 'is not' test
875
- if expr =~ /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
1029
+ if expr =~ IS_NOT_RE
876
1030
  return !eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
877
- Regexp.last_match(3).strip, context)
1031
+ Regexp.last_match(3).strip, context, eval_fn)
878
1032
  end
879
1033
 
880
1034
  # 'is' test
881
- if expr =~ /\A(.+?)\s+is\s+(\w+)(.*)\z/
1035
+ if expr =~ IS_RE
882
1036
  return eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
883
- Regexp.last_match(3).strip, context)
1037
+ Regexp.last_match(3).strip, context, eval_fn)
884
1038
  end
885
1039
 
886
1040
  # 'not in'
887
- if expr =~ /\A(.+?)\s+not\s+in\s+(.+)\z/
888
- val = eval_expr(Regexp.last_match(1).strip, context)
889
- collection = eval_expr(Regexp.last_match(2).strip, context)
1041
+ if expr =~ NOT_IN_RE
1042
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1043
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
890
1044
  return !(collection.respond_to?(:include?) && collection.include?(val))
891
1045
  end
892
1046
 
893
1047
  # 'in'
894
- if expr =~ /\A(.+?)\s+in\s+(.+)\z/
895
- val = eval_expr(Regexp.last_match(1).strip, context)
896
- collection = eval_expr(Regexp.last_match(2).strip, context)
1048
+ if expr =~ IN_RE
1049
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1050
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
897
1051
  return collection.respond_to?(:include?) ? collection.include?(val) : false
898
1052
  end
899
1053
 
@@ -906,8 +1060,8 @@ module Tina4
906
1060
  ["<", ->(a, b) { a.to_f < b.to_f }]].each do |op, fn|
907
1061
  if expr.include?(op)
908
1062
  left, _, right = expr.partition(op)
909
- l = eval_expr(left.strip, context)
910
- r = eval_expr(right.strip, context)
1063
+ l = eval_fn.call(left.strip, context)
1064
+ r = eval_fn.call(right.strip, context)
911
1065
  begin
912
1066
  return fn.call(l, r)
913
1067
  rescue
@@ -917,7 +1071,7 @@ module Tina4
917
1071
  end
918
1072
 
919
1073
  # Fall through to simple eval
920
- val = eval_expr(expr, context)
1074
+ val = eval_fn.call(expr, context)
921
1075
  truthy?(val)
922
1076
  end
923
1077
 
@@ -925,12 +1079,13 @@ module Tina4
925
1079
  # Tests ('is' expressions)
926
1080
  # -----------------------------------------------------------------------
927
1081
 
928
- def eval_test(value_expr, test_name, args_str, context)
929
- val = eval_expr(value_expr, context)
1082
+ def eval_test(value_expr, test_name, args_str, context, eval_fn = nil)
1083
+ eval_fn ||= method(:eval_expr)
1084
+ val = eval_fn.call(value_expr, context)
930
1085
 
931
1086
  # 'divisible by(n)'
932
1087
  if test_name == "divisible"
933
- if args_str =~ /\s*by\s*\(\s*(\d+)\s*\)/
1088
+ if args_str =~ DIVISIBLE_BY_RE
934
1089
  n = Regexp.last_match(1).to_i
935
1090
  return val.is_a?(Integer) && (val % n).zero?
936
1091
  end
@@ -964,14 +1119,19 @@ module Tina4
964
1119
  # -----------------------------------------------------------------------
965
1120
 
966
1121
  def resolve(expr, context)
967
- parts = expr.split(/\.|\[([^\]]+)\]/).reject(&:empty?)
1122
+ parts = @resolve_cache[expr]
1123
+ unless parts
1124
+ parts = expr.split(RESOLVE_SPLIT_RE).reject(&:empty?)
1125
+ @resolve_cache[expr] = parts
1126
+ end
1127
+
968
1128
  value = context
969
1129
 
970
1130
  parts.each do |part|
971
- part = part.strip.gsub(/\A["']|["']\z/, "") # strip quotes from bracket access
972
- if value.is_a?(Hash)
1131
+ part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1132
+ if value.is_a?(Hash) || value.is_a?(LoopContext)
973
1133
  value = value[part] || value[part.to_sym]
974
- elsif value.is_a?(Array) && part =~ /\A\d+\z/
1134
+ elsif value.is_a?(Array) && part =~ DIGIT_RE
975
1135
  value = value[part.to_i]
976
1136
  elsif value.respond_to?(part.to_sym)
977
1137
  value = value.send(part.to_sym)
@@ -1073,7 +1233,7 @@ module Tina4
1073
1233
  end
1074
1234
 
1075
1235
  branches.each do |cond, branch_tokens|
1076
- if cond.nil? || eval_comparison(cond, context)
1236
+ if cond.nil? || eval_comparison(cond, context, method(:eval_var_raw))
1077
1237
  return [render_tokens(branch_tokens.dup, context), i]
1078
1238
  end
1079
1239
  end
@@ -1084,7 +1244,7 @@ module Tina4
1084
1244
  # {% for item in items %}...{% else %}...{% endfor %}
1085
1245
  def handle_for(tokens, start, context)
1086
1246
  content, _, strip_a_open = strip_tag(tokens[start][1])
1087
- m = content.match(/\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/)
1247
+ m = content.match(FOR_RE)
1088
1248
  return ["", start + 1] unless m
1089
1249
 
1090
1250
  var1 = m[1]
@@ -1158,7 +1318,7 @@ module Tina4
1158
1318
  total = items.length
1159
1319
 
1160
1320
  items.each_with_index do |item, idx|
1161
- loop_ctx = context.dup
1321
+ loop_ctx = LoopContext.new(context)
1162
1322
  loop_ctx["loop"] = {
1163
1323
  "index" => idx + 1,
1164
1324
  "index0" => idx,
@@ -1196,7 +1356,7 @@ module Tina4
1196
1356
 
1197
1357
  # {% set name = expr %}
1198
1358
  def handle_set(content, context)
1199
- if content =~ /\Aset\s+(\w+)\s*=\s*(.+)\z/m
1359
+ if content =~ SET_RE
1200
1360
  name = Regexp.last_match(1)
1201
1361
  expr = Regexp.last_match(2).strip
1202
1362
  context[name] = eval_expr(expr, context)
@@ -1208,7 +1368,7 @@ module Tina4
1208
1368
  ignore_missing = content.include?("ignore missing")
1209
1369
  content = content.gsub("ignore missing", "").strip
1210
1370
 
1211
- m = content.match(/\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/)
1371
+ m = content.match(INCLUDE_RE)
1212
1372
  return "" unless m
1213
1373
 
1214
1374
  filename = m[1]
@@ -1233,7 +1393,7 @@ module Tina4
1233
1393
  # {% macro name(args) %}...{% endmacro %}
1234
1394
  def handle_macro(tokens, start, context)
1235
1395
  content, _, _ = strip_tag(tokens[start][1])
1236
- m = content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1396
+ m = content.match(MACRO_RE)
1237
1397
  unless m
1238
1398
  i = start + 1
1239
1399
  while i < tokens.length
@@ -1276,7 +1436,7 @@ module Tina4
1276
1436
 
1277
1437
  # {% from "file" import macro1, macro2 %}
1278
1438
  def handle_from_import(content, context)
1279
- m = content.match(/\Afrom\s+["'](.+?)["']\s+import\s+(.+)/)
1439
+ m = content.match(FROM_IMPORT_RE)
1280
1440
  return unless m
1281
1441
 
1282
1442
  filename = m[1]
@@ -1292,7 +1452,7 @@ module Tina4
1292
1452
  tag_content, _, _ = strip_tag(raw)
1293
1453
  tag = (tag_content.split[0] || "")
1294
1454
  if tag == "macro"
1295
- macro_m = tag_content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1455
+ macro_m = tag_content.match(MACRO_RE)
1296
1456
  if macro_m && names.include?(macro_m[1])
1297
1457
  macro_name = macro_m[1]
1298
1458
  param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
@@ -1332,7 +1492,7 @@ module Tina4
1332
1492
  # {% cache "key" ttl %}...{% endcache %}
1333
1493
  def handle_cache(tokens, start, context)
1334
1494
  content, _, _ = strip_tag(tokens[start][1])
1335
- m = content.match(/\Acache\s+["'](.+?)["']\s*(\d+)?/)
1495
+ m = content.match(CACHE_RE)
1336
1496
  cache_key = m ? m[1] : "default"
1337
1497
  ttl = m && m[2] ? m[2].to_i : 60
1338
1498
 
@@ -1420,13 +1580,13 @@ module Tina4
1420
1580
  end
1421
1581
 
1422
1582
  rendered = render_tokens(body_tokens.dup, context)
1423
- rendered = rendered.gsub(/>\s+</, "><")
1583
+ rendered = rendered.gsub(SPACELESS_RE, "><")
1424
1584
  [rendered, i]
1425
1585
  end
1426
1586
 
1427
1587
  def handle_autoescape(tokens, start, context)
1428
1588
  content, _, _ = strip_tag(tokens[start][1])
1429
- mode_match = content.match(/\Aautoescape\s+(false|true)/)
1589
+ mode_match = content.match(AUTOESCAPE_RE)
1430
1590
  auto_escape_on = !(mode_match && mode_match[1] == "false")
1431
1591
 
1432
1592
  body_tokens = []
@@ -1497,7 +1657,7 @@ module Tina4
1497
1657
  "ltrim" => ->(v, *_a) { v.to_s.lstrip },
1498
1658
  "rtrim" => ->(v, *_a) { v.to_s.rstrip },
1499
1659
  "replace" => ->(v, *a) { a.length >= 2 ? v.to_s.gsub(a[0].to_s, a[1].to_s) : v.to_s },
1500
- "striptags" => ->(v, *_a) { v.to_s.gsub(/<[^>]+>/, "") },
1660
+ "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
1501
1661
 
1502
1662
  # -- Encoding --
1503
1663
  "escape" => ->(v, *_a) { Frond.escape_html(v.to_s) },
@@ -1555,7 +1715,7 @@ module Tina4
1555
1715
  formatted = format("%.#{decimals}f", v.to_f)
1556
1716
  # Add comma thousands separator
1557
1717
  parts = formatted.split(".")
1558
- parts[0] = parts[0].gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
1718
+ parts[0] = parts[0].gsub(THOUSANDS_RE, '\\1,')
1559
1719
  parts.join(".")
1560
1720
  },
1561
1721
 
@@ -1660,7 +1820,7 @@ module Tina4
1660
1820
  lines << current unless current.empty?
1661
1821
  lines.join("\n")
1662
1822
  },
1663
- "slug" => ->(v, *_a) { v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") },
1823
+ "slug" => ->(v, *_a) { v.to_s.downcase.gsub(SLUG_CLEAN_RE, "-").gsub(SLUG_TRIM_RE, "") },
1664
1824
  "nl2br" => ->(v, *_a) { v.to_s.gsub("\n", "<br>\n") },
1665
1825
  "format" => ->(v, *a) {
1666
1826
  if a.any?
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.18"
4
+ VERSION = "3.10.21"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.18
4
+ version: 3.10.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rack
@@ -400,7 +399,6 @@ licenses:
400
399
  - MIT
401
400
  metadata:
402
401
  homepage_uri: https://tina4.com
403
- post_install_message:
404
402
  rdoc_options: []
405
403
  require_paths:
406
404
  - lib
@@ -415,8 +413,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
415
413
  - !ruby/object:Gem::Version
416
414
  version: '0'
417
415
  requirements: []
418
- rubygems_version: 3.4.19
419
- signing_key:
416
+ rubygems_version: 4.0.3
420
417
  specification_version: 4
421
418
  summary: Simple. Fast. Human. This is not a framework.
422
419
  test_files: []