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 +4 -4
- data/lib/tina4/drivers/firebird_driver.rb +27 -5
- data/lib/tina4/frond.rb +304 -33
- data/lib/tina4/metrics.rb +11 -2
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8878ff3cfcfe50696d437b251a372f66d99a8098707e7cfd919554ec24d996fb
|
|
4
|
+
data.tar.gz: dbc5ae0b61c125a88fda3310d7dff72e0bb5a5d52d94ae863455ff607c5aab35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
@
|
|
236
|
+
@connection.transaction
|
|
237
|
+
@in_transaction = true
|
|
218
238
|
end
|
|
219
239
|
|
|
220
240
|
def commit
|
|
221
|
-
@
|
|
241
|
+
@connection&.commit
|
|
242
|
+
@in_transaction = false
|
|
222
243
|
end
|
|
223
244
|
|
|
224
245
|
def rollback
|
|
225
|
-
@
|
|
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
|
-
@
|
|
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) &&
|
|
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
|
-
|
|
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("<", "<").gsub(">", ">")
|
|
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 {"a":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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1773
|
-
macro_ctx[pname] = pi < args.length ? args[pi] :
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
1830
|
-
macro_ctx[pname] = pi < args.length ? args[pi] :
|
|
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
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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
|
-
|
|
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
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.
|
|
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-
|
|
11
|
+
date: 2026-07-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|