tina4ruby 3.13.86 → 3.13.88

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: dae161f7ae0f39d834a01876bde153cd83b4df51ac355225c2c123ce20eec210
4
- data.tar.gz: 1e12103ca5685fc65dcfa7dedd16c38ddf5d4435ccd77ad4f73ad9a987596e2f
3
+ metadata.gz: 8878ff3cfcfe50696d437b251a372f66d99a8098707e7cfd919554ec24d996fb
4
+ data.tar.gz: dbc5ae0b61c125a88fda3310d7dff72e0bb5a5d52d94ae863455ff607c5aab35
5
5
  SHA512:
6
- metadata.gz: 5ea0ee3373aab820be175916dda33ca1e353f8cd9894ab1fa2613af2a85438195b5345a0af2b441ec444c51482e8bb64632f49c9af3e4f6acedbef8c3f66fe45
7
- data.tar.gz: cd556ef4a20f2b6acd245a2926af62e8592ca56a11fb3827ffcb9e8fede128f928b070ab1bfd7acb850a0b4371f8ec6367499d140488216eeb7a0f65939303ab
6
+ metadata.gz: b51a87207d60bff1cd55241e23eb3b7325980752430bfef840ed8fce8e1ba491fbdf4096dd8369548112163ec40147b5e70874b3d6b66c174d82a5161e3f2062
7
+ data.tar.gz: 8b03d0f03526f60931fefafdf78f21e84b3e85dfb040af5106a1a7c26677e110fc21a2274b8d623bbbab60c23ba70661d8612ae02c43f024d29e02a82ca5e167
@@ -213,16 +213,38 @@ module Tina4
213
213
  "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
214
214
  end
215
215
 
216
+ # Transaction handling — mirrors the Python master's connection-level
217
+ # contract (tina4_python firebird.py: start_transaction sets a flag,
218
+ # commit/rollback act on the connection).
219
+ #
220
+ # In the `fb` gem the transaction lives ON the connection:
221
+ # `Fb::Connection#transaction` (no block) STARTS a transaction and returns
222
+ # `true` (a boolean — NOT a transaction object), and
223
+ # `Fb::Connection#commit` / `#rollback` end it. The old code stored that
224
+ # boolean in `@transaction` and called `@transaction&.commit` /
225
+ # `&.rollback`, i.e. `true.commit` / `true.rollback` — a NoMethodError that
226
+ # broke every explicit-transaction commit AND rollback (a rolled-back write
227
+ # was never undone). We now start the transaction on the connection and
228
+ # commit/rollback the CONNECTION, tracking open-ness with an
229
+ # `@in_transaction` boolean (parity with Python's `_in_transaction`).
230
+ #
231
+ # A standalone write auto-commits inside the gem's own `execute`, so the
232
+ # framework's autocommit_standalone_write then calls #commit with no txn
233
+ # open — `Fb::Connection#commit` is a harmless no-op there (returns nil, no
234
+ # raise), so standalone-autocommit is preserved.
216
235
  def begin_transaction
217
- @transaction = @connection.transaction
236
+ @connection.transaction
237
+ @in_transaction = true
218
238
  end
219
239
 
220
240
  def commit
221
- @transaction&.commit
241
+ @connection&.commit
242
+ @in_transaction = false
222
243
  end
223
244
 
224
245
  def rollback
225
- @transaction&.rollback
246
+ @connection&.rollback
247
+ @in_transaction = false
226
248
  end
227
249
 
228
250
  def tables
@@ -263,7 +285,7 @@ module Tina4
263
285
  # connection already gone — nothing to clean up
264
286
  end
265
287
  @connection = nil
266
- @transaction = nil
288
+ @in_transaction = false
267
289
  open_connection
268
290
  end
269
291
 
@@ -273,7 +295,7 @@ module Tina4
273
295
  def with_reconnect
274
296
  yield
275
297
  rescue StandardError => e
276
- raise unless self.class.dead_connection?(e) && @transaction.nil?
298
+ raise unless self.class.dead_connection?(e) && !@in_transaction
277
299
  reconnect!
278
300
  yield
279
301
  end
data/lib/tina4/frond.rb CHANGED
@@ -106,7 +106,11 @@ module Tina4
106
106
  " and ", " or ", " not ",
107
107
  " + ", " - ", " * ", " // ", " / ", " % ", " ** "
108
108
  ].freeze
109
- FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
109
+ # A dot is allowed in the callee so {% import "f" as m %} can register its macros
110
+ # under the literal key "m.greet" and {{ m.greet("Andre") }} resolves as a call.
111
+ # Without the dot the whole expression was not recognised as a function call at
112
+ # all, so an aliased macro rendered as SILENTLY EMPTY.
113
+ FUNC_CALL_RE = /\A([\w.]+)\s*\((.*)\)\z/m
110
114
  FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
111
115
  FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
112
116
  OR_SPLIT_RE = /\s+or\s+/
@@ -136,6 +140,7 @@ module Tina4
136
140
  .gsub("<", "&lt;").gsub(">", "&gt;")
137
141
  end
138
142
  FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
143
+ IMPORT_AS_RE = /\Aimport\s+["'](.+?)["']\s+as\s+(\w+)/
139
144
  CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
140
145
  SPACELESS_RE = />\s+</
141
146
  AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
@@ -147,6 +152,17 @@ module Tina4
147
152
  # Set of common no-arg filter names that can be inlined for speed
148
153
  INLINE_FILTERS = %w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
149
154
 
155
+ # Hard cap on the template caches — @compiled and @compiled_strings
156
+ # (ADR-0004, parity with PHP/Python/Node TEMPLATE_CACHE_MAX).
157
+ #
158
+ # An entry here is a whole token list, so the cap sits well below what a
159
+ # per-expression memo would justify. 256 is far above any real
160
+ # application's template count, so a normal app never evicts. The cap
161
+ # exists for the workload that genuinely grows without limit for the life
162
+ # of a worker: +render_string+ keys on md5(source), so an app that builds
163
+ # template strings dynamically adds an entry per distinct string.
164
+ TEMPLATE_CACHE_MAX = 256
165
+
150
166
  # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
151
167
  class LoopContext
152
168
  def initialize(parent)
@@ -302,6 +318,7 @@ module Tina4
302
318
  source = File.read(path, encoding: "utf-8")
303
319
  mtime = File.mtime(path)
304
320
  tokens = tokenize(source)
321
+ cap_cache(@compiled, TEMPLATE_CACHE_MAX)
305
322
  @compiled[template] = [tokens, mtime, Time.now.to_i]
306
323
  execute_with_tokens(source, tokens, context)
307
324
  end
@@ -318,6 +335,7 @@ module Tina4
318
335
  end
319
336
 
320
337
  tokens = tokenize(source)
338
+ cap_cache(@compiled_strings, TEMPLATE_CACHE_MAX)
321
339
  @compiled_strings[key] = tokens
322
340
  execute_cached(tokens, context)
323
341
  end
@@ -411,8 +429,89 @@ module Tina4
411
429
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
412
430
  end
413
431
 
432
+ # Serializes a value to compact JSON text that is always valid JSON.
433
+ #
434
+ # Never raises and never returns an empty string: a non-finite float becomes
435
+ # null (the JSON spec has no Infinity or NaN) and malformed UTF-8 is scrubbed,
436
+ # so a payload always arrives, in the worst case as null.
437
+ def self.json_text(value)
438
+ JSON.generate(value)
439
+ rescue StandardError
440
+ # Only reached when the happy path raised, so a well-formed payload never
441
+ # pays for the walk.
442
+ begin
443
+ JSON.generate(json_sanitize(value))
444
+ rescue StandardError
445
+ "null"
446
+ end
447
+ end
448
+
449
+ # Replaces anything JSON.generate refuses with a JSON-representable stand-in.
450
+ def self.json_sanitize(value)
451
+ case value
452
+ when Float then value.finite? ? value : nil
453
+ when Hash then value.transform_values { |item| json_sanitize(item) }
454
+ when Array then value.map { |item| json_sanitize(item) }
455
+ when String then value.valid_encoding? ? value : value.scrub
456
+ else value
457
+ end
458
+ end
459
+
460
+ # Serializes to JSON that is valid JSON, valid JavaScript, and safe in HTML.
461
+ #
462
+ # THE cross-framework contract for json_encode / to_json / tojson. Keep the
463
+ # four implementations byte-identical; frond_expression_corpus.txt locks it.
464
+ #
465
+ # Three things this must never do, each of which was a real bug:
466
+ #
467
+ # 1. Never emit a non-finite literal. JSON.generate raises on Infinity, and
468
+ # the old `rescue v.to_s` turned that raise into Ruby inspect output --
469
+ # `{"a" => 1.0}` -- which no JSON.parse will read. Reported as
470
+ # tina4-php#184 by justin-k-bruce, who hit the same class of bug in PHP.
471
+ # 2. Never emit nothing, and never emit something that still parses and means
472
+ # something else. "var ROWS = ;" is at least a loud SyntaxError.
473
+ # 3. Never HTML-escape it. Entity-encoding JSON produces {&quot;a&quot;:1},
474
+ # a SyntaxError inside <script>, which is the filter's whole point. Escape
475
+ # only the dangerous characters, as JSON \uXXXX escapes: the result stays
476
+ # valid JSON AND valid JavaScript, </script> cannot terminate the block,
477
+ # and it is safe inside a single-quoted attribute. This is what Jinja2's
478
+ # tojson does, and it is why the result is a SafeString.
479
+ #
480
+ # U+2028 and U+2029 join that escape set. Both are legal inside a JSON string
481
+ # and both were illegal inside a JavaScript string literal before ES2019.
482
+ def self.json_safe(value)
483
+ Tina4::SafeString.new(json_text(value).gsub(JSON_ESCAPE_RE, JSON_ESCAPE_MAP))
484
+ end
485
+
486
+ JSON_ESCAPE_MAP = {
487
+ "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026", "'" => "\\u0027",
488
+ "\u2028" => "\\u2028", "\u2029" => "\\u2029"
489
+ }.freeze
490
+ JSON_ESCAPE_RE = /[<>&'\u2028\u2029]/
491
+
414
492
  private
415
493
 
494
+ # Keep a memo cache bounded (ADR-0004). Call immediately before inserting
495
+ # a new entry.
496
+ #
497
+ # Eviction is insertion-ordered (oldest first), not true LRU: a Ruby Hash
498
+ # preserves insertion order, so dropping from the front is cheap, whereas
499
+ # refreshing recency on every cache HIT would add writes to the hottest
500
+ # path in a render and cost more than it saves. Half the cache is dropped
501
+ # at once so the sweep amortises to O(1) per insert.
502
+ #
503
+ # Evicting can never change what a render produces: every read site treats
504
+ # a miss as "recompute", so a swept entry is rebuilt on next use.
505
+ #
506
+ # @param cache [Hash] memo cache to bound, mutated in place
507
+ # @param max_entries [Integer] cap for this cache
508
+ # @return [void]
509
+ def cap_cache(cache, max_entries)
510
+ return if cache.size < max_entries
511
+
512
+ cache.keys.first(max_entries / 2).each { |key| cache.delete(key) }
513
+ end
514
+
416
515
  # -----------------------------------------------------------------------
417
516
  # Tokenizer
418
517
  # -----------------------------------------------------------------------
@@ -625,6 +724,9 @@ module Tina4
625
724
  end
626
725
  when "macro"
627
726
  i = handle_macro(tokens, i, context)
727
+ when "import"
728
+ handle_import_as(content, context)
729
+ i += 1
628
730
  when "from"
629
731
  handle_from_import(content, context)
630
732
  i += 1
@@ -1082,44 +1184,111 @@ module Tina4
1082
1184
  # Helpers return :not_matched when the expression doesn't match their
1083
1185
  # type, so the dispatcher falls through to the next handler.
1084
1186
 
1187
+ # Bound for the expression-form cache. Python bounds its expression caches
1188
+ # (lru_cache 1024) and PHP's were unbounded instance arrays until ADR-0004;
1189
+ # a template with generated expression strings must not grow this forever.
1190
+ # FIFO drop-oldest-half, deliberately not LRU (no per-hit bookkeeping on the
1191
+ # hot path).
1192
+ EXPR_FORM_CACHE_MAX = 2048
1193
+
1194
+ # ── Expression evaluation: a cascade, with the branch memoised ──
1195
+ #
1196
+ # eval_expr tries 12 structural forms in Twig precedence order. The catch is
1197
+ # that the LAST one, `resolve`, is by far the most common (a plain `i.name`),
1198
+ # so every ordinary variable used to walk all 11 detectors ahead of it on
1199
+ # EVERY render. Measured: ~6-7 microseconds and ~38 Frond method calls per
1200
+ # expression evaluation, linear in expression count.
1201
+ #
1202
+ # So: on first sight run the real cascade and RECORD which branch fired; on
1203
+ # later renders jump straight to that branch. The cache key is the expression
1204
+ # string, and a form is a pure property of that string.
1205
+ #
1206
+ # Correctness is owned by the existing evaluators, not by a second copy of the
1207
+ # detection logic (which is what would drift). The fast path calls exactly one
1208
+ # evaluator; if it declines -- returns its own :not_* sentinel -- we fall
1209
+ # through to the full cascade and re-record. A wrong cache entry therefore
1210
+ # costs one wasted call, never a wrong render.
1085
1211
  def eval_expr(expr, context)
1086
1212
  expr = expr.strip
1087
1213
  return nil if expr.empty?
1088
1214
 
1215
+ form = (@expr_form ||= {})[expr]
1216
+ if form
1217
+ result = eval_expr_as(form, expr, context)
1218
+ return result unless result == :form_declined
1219
+ end
1220
+
1089
1221
  result = eval_literal(expr)
1090
- return result unless result == :not_literal
1222
+ return remember_form(expr, :literal, result) unless result == :not_literal
1091
1223
 
1092
1224
  result = eval_collection_literal(expr, context)
1093
- return result unless result == :not_collection
1225
+ return remember_form(expr, :collection, result) unless result == :not_collection
1094
1226
 
1095
- return eval_expr(expr[1..-2], context) if matched_parens?(expr)
1227
+ if matched_parens?(expr)
1228
+ remember_form(expr, :parens, nil)
1229
+ return eval_expr(expr[1..-2], context)
1230
+ end
1096
1231
 
1097
1232
  result = eval_ternary(expr, context)
1098
- return result unless result == :not_ternary
1233
+ return remember_form(expr, :ternary, result) unless result == :not_ternary
1099
1234
 
1100
1235
  result = eval_inline_if(expr, context)
1101
- return result unless result == :not_inline_if
1236
+ return remember_form(expr, :inline_if, result) unless result == :not_inline_if
1102
1237
 
1103
1238
  result = eval_null_coalesce(expr, context)
1104
- return result unless result == :not_coalesce
1239
+ return remember_form(expr, :coalesce, result) unless result == :not_coalesce
1105
1240
 
1106
1241
  result = eval_concat(expr, context)
1107
- return result unless result == :not_concat
1242
+ return remember_form(expr, :concat, result) unless result == :not_concat
1108
1243
 
1109
- return eval_comparison(expr, context) if has_comparison?(expr)
1244
+ if has_comparison?(expr)
1245
+ remember_form(expr, :comparison, nil)
1246
+ return eval_comparison(expr, context)
1247
+ end
1110
1248
 
1111
1249
  result = eval_arithmetic(expr, context)
1112
- return result unless result == :not_arithmetic
1250
+ return remember_form(expr, :arithmetic, result) unless result == :not_arithmetic
1113
1251
 
1114
1252
  result = eval_filter_pipe(expr, context)
1115
- return result unless result == :not_filter_pipe
1253
+ return remember_form(expr, :pipe, result) unless result == :not_filter_pipe
1116
1254
 
1117
1255
  result = eval_function_call(expr, context)
1118
- return result unless result == :not_function
1256
+ return remember_form(expr, :function, result) unless result == :not_function
1119
1257
 
1258
+ remember_form(expr, :resolve, nil)
1120
1259
  resolve(expr, context)
1121
1260
  end
1122
1261
 
1262
+ # Record the branch an expression took, then hand back its value unchanged.
1263
+ def remember_form(expr, form, result)
1264
+ cache = (@expr_form ||= {})
1265
+ if cache.length >= EXPR_FORM_CACHE_MAX
1266
+ cache.keys.first(EXPR_FORM_CACHE_MAX / 2).each { |k| cache.delete(k) }
1267
+ end
1268
+ cache[expr] = form
1269
+ result
1270
+ end
1271
+
1272
+ # Evaluate via the one remembered branch. Returns :form_declined when that
1273
+ # branch no longer applies, so the caller re-runs the full cascade.
1274
+ def eval_expr_as(form, expr, context)
1275
+ case form
1276
+ when :resolve then resolve(expr, context)
1277
+ when :pipe then (r = eval_filter_pipe(expr, context)) == :not_filter_pipe ? :form_declined : r
1278
+ when :literal then (r = eval_literal(expr)) == :not_literal ? :form_declined : r
1279
+ when :function then (r = eval_function_call(expr, context)) == :not_function ? :form_declined : r
1280
+ when :concat then (r = eval_concat(expr, context)) == :not_concat ? :form_declined : r
1281
+ when :comparison then has_comparison?(expr) ? eval_comparison(expr, context) : :form_declined
1282
+ when :arithmetic then (r = eval_arithmetic(expr, context)) == :not_arithmetic ? :form_declined : r
1283
+ when :ternary then (r = eval_ternary(expr, context)) == :not_ternary ? :form_declined : r
1284
+ when :inline_if then (r = eval_inline_if(expr, context)) == :not_inline_if ? :form_declined : r
1285
+ when :coalesce then (r = eval_null_coalesce(expr, context)) == :not_coalesce ? :form_declined : r
1286
+ when :collection then (r = eval_collection_literal(expr, context)) == :not_collection ? :form_declined : r
1287
+ when :parens then matched_parens?(expr) ? eval_expr(expr[1..-2], context) : :form_declined
1288
+ else :form_declined
1289
+ end
1290
+ end
1291
+
1123
1292
  # ── Filter pipe: value|filter(args) ──
1124
1293
  # Reached only after every looser-binding operator (concat ~, comparison,
1125
1294
  # arithmetic, ternary, ...) has been ruled out at this level, so the pipe
@@ -1139,6 +1308,10 @@ module Tina4
1139
1308
  # looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
1140
1309
  # operator-like text inside a string literal or filter args never matches.
1141
1310
  def has_looser_than_pipe_operator?(expr)
1311
+ # Leading unary `not` -- see has_comparison? for why a space-delimited
1312
+ # match cannot see it. `not x|upper` must be `not (x|upper)`.
1313
+ return true if expr.start_with?("not ")
1314
+
1142
1315
  LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
1143
1316
  end
1144
1317
 
@@ -1254,7 +1427,12 @@ module Tina4
1254
1427
  def eval_concat(expr, context)
1255
1428
  return :not_concat unless expr.include?("~")
1256
1429
  parts = expr.split("~")
1257
- parts.map { |p| (eval_expr(p.strip, context) || "").to_s }.join
1430
+ # `|| ""` would swallow a legitimate `false`: in Ruby only nil and false are
1431
+ # falsy, so `false || ""` is "" and a boolean rendered as EMPTY. Guard on nil
1432
+ # only -- false must render as "false", matching the other three frameworks
1433
+ # (and line 635, which already got this right, which is why a comparison
1434
+ # rendered "false" while a bare false variable rendered "").
1435
+ parts.map { |p| v = eval_expr(p.strip, context); v.nil? ? "" : v.to_s }.join
1258
1436
  end
1259
1437
 
1260
1438
  # ── Arithmetic: +, -, *, //, /, %, ** ──
@@ -1282,7 +1460,21 @@ module Tina4
1282
1460
  fn.call(*args)
1283
1461
  end
1284
1462
 
1463
+ # True when the expression carries a comparison/logical operator, so it is
1464
+ # routed to eval_comparison -- the SAME evaluator {% if %} uses. A condition
1465
+ # therefore means the same thing in a condition and in an output expression.
1466
+ #
1467
+ # The LEADING unary `not` needs its own check: every operator in the list is
1468
+ # matched WITH surrounding spaces, so `not x` (nothing to its left) matched
1469
+ # none of them and fell through to the variable-resolution tail, which
1470
+ # looked up a variable literally named "not x", found nothing, and rendered
1471
+ # EMPTY. `{% if not x %}` and `x and not y` always worked; only the
1472
+ # standalone `{{ not x }}` was dropped, and before booleans rendered
1473
+ # lowercase a dropped expression and `false -> ''` looked identical.
1474
+ # Fixed in 3.13.87 alongside the boolean contract.
1285
1475
  def has_comparison?(expr)
1476
+ return true if expr.start_with?("not ")
1477
+
1286
1478
  [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
1287
1479
  " and ", " or ", " not "].any? { |op| expr.include?(op) }
1288
1480
  end
@@ -1448,7 +1640,19 @@ module Tina4
1448
1640
  parts.each do |part|
1449
1641
  part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1450
1642
  if value.is_a?(Hash) || value.is_a?(LoopContext)
1451
- value = value[part] || value[part.to_sym]
1643
+ # `a[k] || a[k.to_sym]` LOSES a stored `false`: only nil and false are
1644
+ # falsy in Ruby, so a legitimate false fell through to the symbol lookup,
1645
+ # missed, and became nil -- rendering as EMPTY. That is why
1646
+ # `{{ flag }}` printed nothing while `{{ n < 3 }}` printed "false".
1647
+ # Probe the string key by presence, not truthiness.
1648
+ value = if value.is_a?(Hash) && value.key?(part)
1649
+ value[part]
1650
+ elsif value.is_a?(Hash) && value.key?(part.to_sym)
1651
+ value[part.to_sym]
1652
+ else
1653
+ sv = value[part]
1654
+ sv.nil? ? value[part.to_sym] : sv
1655
+ end
1452
1656
  elsif value.is_a?(Array)
1453
1657
  # Slice syntax: value[1:5], value[:10], value[start:end]
1454
1658
  if part.include?(":") && !(part.start_with?('"') || part.start_with?("'"))
@@ -1750,7 +1954,7 @@ module Tina4
1750
1954
  end
1751
1955
 
1752
1956
  macro_name = m[1]
1753
- param_names = m[2].split(",").map(&:strip).reject(&:empty?)
1957
+ params = parse_macro_params(m[2])
1754
1958
 
1755
1959
  body_tokens = []
1756
1960
  i = start + 1
@@ -1769,8 +1973,8 @@ module Tina4
1769
1973
 
1770
1974
  context[macro_name] = lambda { |*args|
1771
1975
  macro_ctx = captured_context.dup
1772
- param_names.each_with_index do |pname, pi|
1773
- macro_ctx[pname] = pi < args.length ? args[pi] : nil
1976
+ params.each_with_index do |(pname, pdefault), pi|
1977
+ macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
1774
1978
  end
1775
1979
  Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
1776
1980
  }
@@ -1778,6 +1982,54 @@ module Tina4
1778
1982
  i
1779
1983
  end
1780
1984
 
1985
+ # {% import "file" as alias %} — load EVERY macro in a file under one namespace.
1986
+ #
1987
+ # Macros are registered in the context under the dotted key "alias.name", so
1988
+ # {{ alias.greet("Andre") }} resolves through the ordinary function-call path
1989
+ # (FUNC_CALL_RE now admits a dot) and reuses _make_macro_fn — identical argument
1990
+ # binding, default handling and SafeString output as every other macro. Both
1991
+ # import forms therefore render identically, the contract Python locks too.
1992
+ def handle_import_as(content, context)
1993
+ m = content.match(IMPORT_AS_RE)
1994
+ return unless m
1995
+
1996
+ filename = m[1]
1997
+ alias_name = m[2]
1998
+ source = load_template(filename)
1999
+ tokens = tokenize(source)
2000
+
2001
+ i = 0
2002
+ while i < tokens.length
2003
+ ttype, raw = tokens[i]
2004
+ if ttype == BLOCK
2005
+ tag_content, = strip_tag(raw)
2006
+ if tag_content.split(/\s+/).first == "macro"
2007
+ macro_m = tag_content.match(MACRO_RE)
2008
+ if macro_m
2009
+ macro_name = macro_m[1]
2010
+ params = parse_macro_params(macro_m[2])
2011
+
2012
+ body_tokens = []
2013
+ i += 1
2014
+ while i < tokens.length
2015
+ if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
2016
+ i += 1
2017
+ break
2018
+ end
2019
+ body_tokens << tokens[i]
2020
+ i += 1
2021
+ end
2022
+
2023
+ context["#{alias_name}.#{macro_name}"] =
2024
+ _make_macro_fn(body_tokens.dup, params.dup, context.dup)
2025
+ next
2026
+ end
2027
+ end
2028
+ end
2029
+ i += 1
2030
+ end
2031
+ end
2032
+
1781
2033
  # {% from "file" import macro1, macro2 %}
1782
2034
  def handle_from_import(content, context)
1783
2035
  m = content.match(FROM_IMPORT_RE)
@@ -1799,7 +2051,7 @@ module Tina4
1799
2051
  macro_m = tag_content.match(MACRO_RE)
1800
2052
  if macro_m && names.include?(macro_m[1])
1801
2053
  macro_name = macro_m[1]
1802
- param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
2054
+ param_names = parse_macro_params(macro_m[2])
1803
2055
 
1804
2056
  body_tokens = []
1805
2057
  i += 1
@@ -1821,13 +2073,34 @@ module Tina4
1821
2073
  end
1822
2074
  end
1823
2075
 
2076
+ # Parse a macro parameter list into [name, default] pairs.
2077
+ #
2078
+ # Handles: name, name="default", name='default'. Splitting on "," alone left
2079
+ # a defaulted parameter NAMED "greeting='Hello'", so the body's {{ greeting }}
2080
+ # matched nothing (rendered empty) AND the caller's positional argument was
2081
+ # stored under that junk key and lost. Mirrors the Python master's
2082
+ # _parse_macro_params. `default` is nil when none is declared.
2083
+ def parse_macro_params(raw_params)
2084
+ raw_params.split(",").map(&:strip).reject(&:empty?).map do |p|
2085
+ name, default = p.split("=", 2)
2086
+ default = default.strip if default
2087
+ if default && default.length >= 2 &&
2088
+ ((default.start_with?('"') && default.end_with?('"')) ||
2089
+ (default.start_with?("'") && default.end_with?("'")))
2090
+ default = default[1..-2]
2091
+ end
2092
+ [name.strip, default]
2093
+ end
2094
+ end
2095
+
1824
2096
  # Build an isolated lambda for a macro — avoids closure-in-loop variable sharing.
1825
- def _make_macro_fn(body_tokens, param_names, ctx)
2097
+ # `params` is the [name, default] list from parse_macro_params.
2098
+ def _make_macro_fn(body_tokens, params, ctx)
1826
2099
  engine = self
1827
2100
  lambda { |*args|
1828
2101
  macro_ctx = ctx.dup
1829
- param_names.each_with_index do |pname, pi|
1830
- macro_ctx[pname] = pi < args.length ? args[pi] : nil
2102
+ params.each_with_index do |(pname, pdefault), pi|
2103
+ macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
1831
2104
  end
1832
2105
  Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
1833
2106
  }
@@ -2193,7 +2466,10 @@ module Tina4
2193
2466
  "e" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
2194
2467
  "raw" => ->(v, *_a) { v },
2195
2468
  "safe" => ->(v, *_a) { v },
2196
- "json_encode" => ->(v, *_a) { JSON.generate(v) rescue v.to_s },
2469
+ # Frond.json_safe, never a bare JSON.generate: generate RAISES on an
2470
+ # Infinity/NaN float, and the old `rescue v.to_s` answered that raise with
2471
+ # Ruby inspect output that no JSON.parse will read. See Frond.json_safe.
2472
+ "json_encode" => ->(v, *_a) { Frond.json_safe(v) },
2197
2473
  "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
2198
2474
  "base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2199
2475
  "base64encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
@@ -2212,17 +2488,12 @@ module Tina4
2212
2488
  "url_encode" => ->(v, *_a) { ERB::Util.url_encode(v.to_s) },
2213
2489
 
2214
2490
  # -- JSON / JS --
2215
- "to_json" => ->(v, *a) {
2216
- indent = a[0] ? a[0].to_i : nil
2217
- json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
2218
- # Escape <, >, & for safe HTML embedding
2219
- Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
2220
- },
2221
- "tojson" => ->(v, *a) {
2222
- indent = a[0] ? a[0].to_i : nil
2223
- json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
2224
- Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
2225
- },
2491
+ # Same serializer as json_encode -- the three names are one behaviour.
2492
+ # The old indent argument is gone: PHP cannot honour an arbitrary indent
2493
+ # (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
2494
+ # broke byte-parity for the one filter whose whole job is a wire format.
2495
+ "to_json" => ->(v, *_a) { Frond.json_safe(v) },
2496
+ "tojson" => ->(v, *_a) { Frond.json_safe(v) },
2226
2497
  "js_escape" => ->(v, *_a) {
2227
2498
  Tina4::SafeString.new(
2228
2499
  v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"')
data/lib/tina4/metrics.rb CHANGED
@@ -338,7 +338,14 @@ module Tina4
338
338
  "total_functions" => all_functions.length,
339
339
  "avg_complexity" => avg_cc.round(2),
340
340
  "avg_maintainability" => avg_mi.round(1),
341
+ # Display-only: the top-15 for the "most complex functions" report.
342
+ # Do NOT source offenders / --fail-on from this — capping here silently
343
+ # hides the 16th+ over-threshold function from the gate. offenders()
344
+ # reads "all_functions" (below) instead.
341
345
  "most_complex_functions" => all_functions.first(15),
346
+ # Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
347
+ # so no function over the complexity threshold ever escapes the gate.
348
+ "all_functions" => all_functions,
342
349
  "file_metrics" => file_metrics,
343
350
  "violations" => violations,
344
351
  "dependency_graph" => import_graph,
@@ -386,8 +393,10 @@ module Tina4
386
393
 
387
394
  items = []
388
395
 
389
- # Function-level: cyclomatic complexity.
390
- (analysis["most_complex_functions"] || []).each do |fn|
396
+ # Function-level: cyclomatic complexity. Use the FULL function list (not the
397
+ # display-capped most_complex_functions[:15]) so a 16th+ over-threshold
398
+ # function is never silently dropped from the offenders list or --fail-on.
399
+ (analysis["all_functions"] || analysis["most_complex_functions"] || []).each do |fn|
391
400
  cc = fn["complexity"]
392
401
  next unless cc > 10
393
402
  items << {
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.13.86"
4
+ VERSION = "3.13.88"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.86
4
+ version: 3.13.88
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-25 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack