tina4ruby 3.13.86 → 3.13.87
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 +234 -21
- 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: 7743996e98fff70baf3c82a0f2a26dfafb24cb0c9ddc46cfc6a3be5a1df4c92f
|
|
4
|
+
data.tar.gz: 2b747045e71df0e69e7d6bdd3cb2b19309fcea16617770982b5743a4c6db9edc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f94e1f6feeda985b8c5091c0235181bb34165f4ee42120fa808b5b8a39cd8deaabc8ae9fe68a9c05d33598d184680d3b49c40f9791b1f0d2fb57689a87b73054
|
|
7
|
+
data.tar.gz: 6d31b432dddc04f237bddd28e6580d294c9427a22b0fda2947a9e2d9e3aba469b2fd06cdb2e0560d8edb60f1383c6916ba407a7efb257202d7cb95e381514083
|
|
@@ -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
|
|
@@ -413,6 +431,27 @@ module Tina4
|
|
|
413
431
|
|
|
414
432
|
private
|
|
415
433
|
|
|
434
|
+
# Keep a memo cache bounded (ADR-0004). Call immediately before inserting
|
|
435
|
+
# a new entry.
|
|
436
|
+
#
|
|
437
|
+
# Eviction is insertion-ordered (oldest first), not true LRU: a Ruby Hash
|
|
438
|
+
# preserves insertion order, so dropping from the front is cheap, whereas
|
|
439
|
+
# refreshing recency on every cache HIT would add writes to the hottest
|
|
440
|
+
# path in a render and cost more than it saves. Half the cache is dropped
|
|
441
|
+
# at once so the sweep amortises to O(1) per insert.
|
|
442
|
+
#
|
|
443
|
+
# Evicting can never change what a render produces: every read site treats
|
|
444
|
+
# a miss as "recompute", so a swept entry is rebuilt on next use.
|
|
445
|
+
#
|
|
446
|
+
# @param cache [Hash] memo cache to bound, mutated in place
|
|
447
|
+
# @param max_entries [Integer] cap for this cache
|
|
448
|
+
# @return [void]
|
|
449
|
+
def cap_cache(cache, max_entries)
|
|
450
|
+
return if cache.size < max_entries
|
|
451
|
+
|
|
452
|
+
cache.keys.first(max_entries / 2).each { |key| cache.delete(key) }
|
|
453
|
+
end
|
|
454
|
+
|
|
416
455
|
# -----------------------------------------------------------------------
|
|
417
456
|
# Tokenizer
|
|
418
457
|
# -----------------------------------------------------------------------
|
|
@@ -625,6 +664,9 @@ module Tina4
|
|
|
625
664
|
end
|
|
626
665
|
when "macro"
|
|
627
666
|
i = handle_macro(tokens, i, context)
|
|
667
|
+
when "import"
|
|
668
|
+
handle_import_as(content, context)
|
|
669
|
+
i += 1
|
|
628
670
|
when "from"
|
|
629
671
|
handle_from_import(content, context)
|
|
630
672
|
i += 1
|
|
@@ -1082,44 +1124,111 @@ module Tina4
|
|
|
1082
1124
|
# Helpers return :not_matched when the expression doesn't match their
|
|
1083
1125
|
# type, so the dispatcher falls through to the next handler.
|
|
1084
1126
|
|
|
1127
|
+
# Bound for the expression-form cache. Python bounds its expression caches
|
|
1128
|
+
# (lru_cache 1024) and PHP's were unbounded instance arrays until ADR-0004;
|
|
1129
|
+
# a template with generated expression strings must not grow this forever.
|
|
1130
|
+
# FIFO drop-oldest-half, deliberately not LRU (no per-hit bookkeeping on the
|
|
1131
|
+
# hot path).
|
|
1132
|
+
EXPR_FORM_CACHE_MAX = 2048
|
|
1133
|
+
|
|
1134
|
+
# ── Expression evaluation: a cascade, with the branch memoised ──
|
|
1135
|
+
#
|
|
1136
|
+
# eval_expr tries 12 structural forms in Twig precedence order. The catch is
|
|
1137
|
+
# that the LAST one, `resolve`, is by far the most common (a plain `i.name`),
|
|
1138
|
+
# so every ordinary variable used to walk all 11 detectors ahead of it on
|
|
1139
|
+
# EVERY render. Measured: ~6-7 microseconds and ~38 Frond method calls per
|
|
1140
|
+
# expression evaluation, linear in expression count.
|
|
1141
|
+
#
|
|
1142
|
+
# So: on first sight run the real cascade and RECORD which branch fired; on
|
|
1143
|
+
# later renders jump straight to that branch. The cache key is the expression
|
|
1144
|
+
# string, and a form is a pure property of that string.
|
|
1145
|
+
#
|
|
1146
|
+
# Correctness is owned by the existing evaluators, not by a second copy of the
|
|
1147
|
+
# detection logic (which is what would drift). The fast path calls exactly one
|
|
1148
|
+
# evaluator; if it declines -- returns its own :not_* sentinel -- we fall
|
|
1149
|
+
# through to the full cascade and re-record. A wrong cache entry therefore
|
|
1150
|
+
# costs one wasted call, never a wrong render.
|
|
1085
1151
|
def eval_expr(expr, context)
|
|
1086
1152
|
expr = expr.strip
|
|
1087
1153
|
return nil if expr.empty?
|
|
1088
1154
|
|
|
1155
|
+
form = (@expr_form ||= {})[expr]
|
|
1156
|
+
if form
|
|
1157
|
+
result = eval_expr_as(form, expr, context)
|
|
1158
|
+
return result unless result == :form_declined
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1089
1161
|
result = eval_literal(expr)
|
|
1090
|
-
return result unless result == :not_literal
|
|
1162
|
+
return remember_form(expr, :literal, result) unless result == :not_literal
|
|
1091
1163
|
|
|
1092
1164
|
result = eval_collection_literal(expr, context)
|
|
1093
|
-
return result unless result == :not_collection
|
|
1165
|
+
return remember_form(expr, :collection, result) unless result == :not_collection
|
|
1094
1166
|
|
|
1095
|
-
|
|
1167
|
+
if matched_parens?(expr)
|
|
1168
|
+
remember_form(expr, :parens, nil)
|
|
1169
|
+
return eval_expr(expr[1..-2], context)
|
|
1170
|
+
end
|
|
1096
1171
|
|
|
1097
1172
|
result = eval_ternary(expr, context)
|
|
1098
|
-
return result unless result == :not_ternary
|
|
1173
|
+
return remember_form(expr, :ternary, result) unless result == :not_ternary
|
|
1099
1174
|
|
|
1100
1175
|
result = eval_inline_if(expr, context)
|
|
1101
|
-
return result unless result == :not_inline_if
|
|
1176
|
+
return remember_form(expr, :inline_if, result) unless result == :not_inline_if
|
|
1102
1177
|
|
|
1103
1178
|
result = eval_null_coalesce(expr, context)
|
|
1104
|
-
return result unless result == :not_coalesce
|
|
1179
|
+
return remember_form(expr, :coalesce, result) unless result == :not_coalesce
|
|
1105
1180
|
|
|
1106
1181
|
result = eval_concat(expr, context)
|
|
1107
|
-
return result unless result == :not_concat
|
|
1182
|
+
return remember_form(expr, :concat, result) unless result == :not_concat
|
|
1108
1183
|
|
|
1109
|
-
|
|
1184
|
+
if has_comparison?(expr)
|
|
1185
|
+
remember_form(expr, :comparison, nil)
|
|
1186
|
+
return eval_comparison(expr, context)
|
|
1187
|
+
end
|
|
1110
1188
|
|
|
1111
1189
|
result = eval_arithmetic(expr, context)
|
|
1112
|
-
return result unless result == :not_arithmetic
|
|
1190
|
+
return remember_form(expr, :arithmetic, result) unless result == :not_arithmetic
|
|
1113
1191
|
|
|
1114
1192
|
result = eval_filter_pipe(expr, context)
|
|
1115
|
-
return result unless result == :not_filter_pipe
|
|
1193
|
+
return remember_form(expr, :pipe, result) unless result == :not_filter_pipe
|
|
1116
1194
|
|
|
1117
1195
|
result = eval_function_call(expr, context)
|
|
1118
|
-
return result unless result == :not_function
|
|
1196
|
+
return remember_form(expr, :function, result) unless result == :not_function
|
|
1119
1197
|
|
|
1198
|
+
remember_form(expr, :resolve, nil)
|
|
1120
1199
|
resolve(expr, context)
|
|
1121
1200
|
end
|
|
1122
1201
|
|
|
1202
|
+
# Record the branch an expression took, then hand back its value unchanged.
|
|
1203
|
+
def remember_form(expr, form, result)
|
|
1204
|
+
cache = (@expr_form ||= {})
|
|
1205
|
+
if cache.length >= EXPR_FORM_CACHE_MAX
|
|
1206
|
+
cache.keys.first(EXPR_FORM_CACHE_MAX / 2).each { |k| cache.delete(k) }
|
|
1207
|
+
end
|
|
1208
|
+
cache[expr] = form
|
|
1209
|
+
result
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
# Evaluate via the one remembered branch. Returns :form_declined when that
|
|
1213
|
+
# branch no longer applies, so the caller re-runs the full cascade.
|
|
1214
|
+
def eval_expr_as(form, expr, context)
|
|
1215
|
+
case form
|
|
1216
|
+
when :resolve then resolve(expr, context)
|
|
1217
|
+
when :pipe then (r = eval_filter_pipe(expr, context)) == :not_filter_pipe ? :form_declined : r
|
|
1218
|
+
when :literal then (r = eval_literal(expr)) == :not_literal ? :form_declined : r
|
|
1219
|
+
when :function then (r = eval_function_call(expr, context)) == :not_function ? :form_declined : r
|
|
1220
|
+
when :concat then (r = eval_concat(expr, context)) == :not_concat ? :form_declined : r
|
|
1221
|
+
when :comparison then has_comparison?(expr) ? eval_comparison(expr, context) : :form_declined
|
|
1222
|
+
when :arithmetic then (r = eval_arithmetic(expr, context)) == :not_arithmetic ? :form_declined : r
|
|
1223
|
+
when :ternary then (r = eval_ternary(expr, context)) == :not_ternary ? :form_declined : r
|
|
1224
|
+
when :inline_if then (r = eval_inline_if(expr, context)) == :not_inline_if ? :form_declined : r
|
|
1225
|
+
when :coalesce then (r = eval_null_coalesce(expr, context)) == :not_coalesce ? :form_declined : r
|
|
1226
|
+
when :collection then (r = eval_collection_literal(expr, context)) == :not_collection ? :form_declined : r
|
|
1227
|
+
when :parens then matched_parens?(expr) ? eval_expr(expr[1..-2], context) : :form_declined
|
|
1228
|
+
else :form_declined
|
|
1229
|
+
end
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1123
1232
|
# ── Filter pipe: value|filter(args) ──
|
|
1124
1233
|
# Reached only after every looser-binding operator (concat ~, comparison,
|
|
1125
1234
|
# arithmetic, ternary, ...) has been ruled out at this level, so the pipe
|
|
@@ -1139,6 +1248,10 @@ module Tina4
|
|
|
1139
1248
|
# looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
|
|
1140
1249
|
# operator-like text inside a string literal or filter args never matches.
|
|
1141
1250
|
def has_looser_than_pipe_operator?(expr)
|
|
1251
|
+
# Leading unary `not` -- see has_comparison? for why a space-delimited
|
|
1252
|
+
# match cannot see it. `not x|upper` must be `not (x|upper)`.
|
|
1253
|
+
return true if expr.start_with?("not ")
|
|
1254
|
+
|
|
1142
1255
|
LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
|
|
1143
1256
|
end
|
|
1144
1257
|
|
|
@@ -1254,7 +1367,12 @@ module Tina4
|
|
|
1254
1367
|
def eval_concat(expr, context)
|
|
1255
1368
|
return :not_concat unless expr.include?("~")
|
|
1256
1369
|
parts = expr.split("~")
|
|
1257
|
-
|
|
1370
|
+
# `|| ""` would swallow a legitimate `false`: in Ruby only nil and false are
|
|
1371
|
+
# falsy, so `false || ""` is "" and a boolean rendered as EMPTY. Guard on nil
|
|
1372
|
+
# only -- false must render as "false", matching the other three frameworks
|
|
1373
|
+
# (and line 635, which already got this right, which is why a comparison
|
|
1374
|
+
# rendered "false" while a bare false variable rendered "").
|
|
1375
|
+
parts.map { |p| v = eval_expr(p.strip, context); v.nil? ? "" : v.to_s }.join
|
|
1258
1376
|
end
|
|
1259
1377
|
|
|
1260
1378
|
# ── Arithmetic: +, -, *, //, /, %, ** ──
|
|
@@ -1282,7 +1400,21 @@ module Tina4
|
|
|
1282
1400
|
fn.call(*args)
|
|
1283
1401
|
end
|
|
1284
1402
|
|
|
1403
|
+
# True when the expression carries a comparison/logical operator, so it is
|
|
1404
|
+
# routed to eval_comparison -- the SAME evaluator {% if %} uses. A condition
|
|
1405
|
+
# therefore means the same thing in a condition and in an output expression.
|
|
1406
|
+
#
|
|
1407
|
+
# The LEADING unary `not` needs its own check: every operator in the list is
|
|
1408
|
+
# matched WITH surrounding spaces, so `not x` (nothing to its left) matched
|
|
1409
|
+
# none of them and fell through to the variable-resolution tail, which
|
|
1410
|
+
# looked up a variable literally named "not x", found nothing, and rendered
|
|
1411
|
+
# EMPTY. `{% if not x %}` and `x and not y` always worked; only the
|
|
1412
|
+
# standalone `{{ not x }}` was dropped, and before booleans rendered
|
|
1413
|
+
# lowercase a dropped expression and `false -> ''` looked identical.
|
|
1414
|
+
# Fixed in 3.13.87 alongside the boolean contract.
|
|
1285
1415
|
def has_comparison?(expr)
|
|
1416
|
+
return true if expr.start_with?("not ")
|
|
1417
|
+
|
|
1286
1418
|
[" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
|
|
1287
1419
|
" and ", " or ", " not "].any? { |op| expr.include?(op) }
|
|
1288
1420
|
end
|
|
@@ -1448,7 +1580,19 @@ module Tina4
|
|
|
1448
1580
|
parts.each do |part|
|
|
1449
1581
|
part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
|
|
1450
1582
|
if value.is_a?(Hash) || value.is_a?(LoopContext)
|
|
1451
|
-
|
|
1583
|
+
# `a[k] || a[k.to_sym]` LOSES a stored `false`: only nil and false are
|
|
1584
|
+
# falsy in Ruby, so a legitimate false fell through to the symbol lookup,
|
|
1585
|
+
# missed, and became nil -- rendering as EMPTY. That is why
|
|
1586
|
+
# `{{ flag }}` printed nothing while `{{ n < 3 }}` printed "false".
|
|
1587
|
+
# Probe the string key by presence, not truthiness.
|
|
1588
|
+
value = if value.is_a?(Hash) && value.key?(part)
|
|
1589
|
+
value[part]
|
|
1590
|
+
elsif value.is_a?(Hash) && value.key?(part.to_sym)
|
|
1591
|
+
value[part.to_sym]
|
|
1592
|
+
else
|
|
1593
|
+
sv = value[part]
|
|
1594
|
+
sv.nil? ? value[part.to_sym] : sv
|
|
1595
|
+
end
|
|
1452
1596
|
elsif value.is_a?(Array)
|
|
1453
1597
|
# Slice syntax: value[1:5], value[:10], value[start:end]
|
|
1454
1598
|
if part.include?(":") && !(part.start_with?('"') || part.start_with?("'"))
|
|
@@ -1750,7 +1894,7 @@ module Tina4
|
|
|
1750
1894
|
end
|
|
1751
1895
|
|
|
1752
1896
|
macro_name = m[1]
|
|
1753
|
-
|
|
1897
|
+
params = parse_macro_params(m[2])
|
|
1754
1898
|
|
|
1755
1899
|
body_tokens = []
|
|
1756
1900
|
i = start + 1
|
|
@@ -1769,8 +1913,8 @@ module Tina4
|
|
|
1769
1913
|
|
|
1770
1914
|
context[macro_name] = lambda { |*args|
|
|
1771
1915
|
macro_ctx = captured_context.dup
|
|
1772
|
-
|
|
1773
|
-
macro_ctx[pname] = pi < args.length ? args[pi] :
|
|
1916
|
+
params.each_with_index do |(pname, pdefault), pi|
|
|
1917
|
+
macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
|
|
1774
1918
|
end
|
|
1775
1919
|
Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
|
|
1776
1920
|
}
|
|
@@ -1778,6 +1922,54 @@ module Tina4
|
|
|
1778
1922
|
i
|
|
1779
1923
|
end
|
|
1780
1924
|
|
|
1925
|
+
# {% import "file" as alias %} — load EVERY macro in a file under one namespace.
|
|
1926
|
+
#
|
|
1927
|
+
# Macros are registered in the context under the dotted key "alias.name", so
|
|
1928
|
+
# {{ alias.greet("Andre") }} resolves through the ordinary function-call path
|
|
1929
|
+
# (FUNC_CALL_RE now admits a dot) and reuses _make_macro_fn — identical argument
|
|
1930
|
+
# binding, default handling and SafeString output as every other macro. Both
|
|
1931
|
+
# import forms therefore render identically, the contract Python locks too.
|
|
1932
|
+
def handle_import_as(content, context)
|
|
1933
|
+
m = content.match(IMPORT_AS_RE)
|
|
1934
|
+
return unless m
|
|
1935
|
+
|
|
1936
|
+
filename = m[1]
|
|
1937
|
+
alias_name = m[2]
|
|
1938
|
+
source = load_template(filename)
|
|
1939
|
+
tokens = tokenize(source)
|
|
1940
|
+
|
|
1941
|
+
i = 0
|
|
1942
|
+
while i < tokens.length
|
|
1943
|
+
ttype, raw = tokens[i]
|
|
1944
|
+
if ttype == BLOCK
|
|
1945
|
+
tag_content, = strip_tag(raw)
|
|
1946
|
+
if tag_content.split(/\s+/).first == "macro"
|
|
1947
|
+
macro_m = tag_content.match(MACRO_RE)
|
|
1948
|
+
if macro_m
|
|
1949
|
+
macro_name = macro_m[1]
|
|
1950
|
+
params = parse_macro_params(macro_m[2])
|
|
1951
|
+
|
|
1952
|
+
body_tokens = []
|
|
1953
|
+
i += 1
|
|
1954
|
+
while i < tokens.length
|
|
1955
|
+
if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
|
|
1956
|
+
i += 1
|
|
1957
|
+
break
|
|
1958
|
+
end
|
|
1959
|
+
body_tokens << tokens[i]
|
|
1960
|
+
i += 1
|
|
1961
|
+
end
|
|
1962
|
+
|
|
1963
|
+
context["#{alias_name}.#{macro_name}"] =
|
|
1964
|
+
_make_macro_fn(body_tokens.dup, params.dup, context.dup)
|
|
1965
|
+
next
|
|
1966
|
+
end
|
|
1967
|
+
end
|
|
1968
|
+
end
|
|
1969
|
+
i += 1
|
|
1970
|
+
end
|
|
1971
|
+
end
|
|
1972
|
+
|
|
1781
1973
|
# {% from "file" import macro1, macro2 %}
|
|
1782
1974
|
def handle_from_import(content, context)
|
|
1783
1975
|
m = content.match(FROM_IMPORT_RE)
|
|
@@ -1799,7 +1991,7 @@ module Tina4
|
|
|
1799
1991
|
macro_m = tag_content.match(MACRO_RE)
|
|
1800
1992
|
if macro_m && names.include?(macro_m[1])
|
|
1801
1993
|
macro_name = macro_m[1]
|
|
1802
|
-
param_names = macro_m[2]
|
|
1994
|
+
param_names = parse_macro_params(macro_m[2])
|
|
1803
1995
|
|
|
1804
1996
|
body_tokens = []
|
|
1805
1997
|
i += 1
|
|
@@ -1821,13 +2013,34 @@ module Tina4
|
|
|
1821
2013
|
end
|
|
1822
2014
|
end
|
|
1823
2015
|
|
|
2016
|
+
# Parse a macro parameter list into [name, default] pairs.
|
|
2017
|
+
#
|
|
2018
|
+
# Handles: name, name="default", name='default'. Splitting on "," alone left
|
|
2019
|
+
# a defaulted parameter NAMED "greeting='Hello'", so the body's {{ greeting }}
|
|
2020
|
+
# matched nothing (rendered empty) AND the caller's positional argument was
|
|
2021
|
+
# stored under that junk key and lost. Mirrors the Python master's
|
|
2022
|
+
# _parse_macro_params. `default` is nil when none is declared.
|
|
2023
|
+
def parse_macro_params(raw_params)
|
|
2024
|
+
raw_params.split(",").map(&:strip).reject(&:empty?).map do |p|
|
|
2025
|
+
name, default = p.split("=", 2)
|
|
2026
|
+
default = default.strip if default
|
|
2027
|
+
if default && default.length >= 2 &&
|
|
2028
|
+
((default.start_with?('"') && default.end_with?('"')) ||
|
|
2029
|
+
(default.start_with?("'") && default.end_with?("'")))
|
|
2030
|
+
default = default[1..-2]
|
|
2031
|
+
end
|
|
2032
|
+
[name.strip, default]
|
|
2033
|
+
end
|
|
2034
|
+
end
|
|
2035
|
+
|
|
1824
2036
|
# Build an isolated lambda for a macro — avoids closure-in-loop variable sharing.
|
|
1825
|
-
|
|
2037
|
+
# `params` is the [name, default] list from parse_macro_params.
|
|
2038
|
+
def _make_macro_fn(body_tokens, params, ctx)
|
|
1826
2039
|
engine = self
|
|
1827
2040
|
lambda { |*args|
|
|
1828
2041
|
macro_ctx = ctx.dup
|
|
1829
|
-
|
|
1830
|
-
macro_ctx[pname] = pi < args.length ? args[pi] :
|
|
2042
|
+
params.each_with_index do |(pname, pdefault), pi|
|
|
2043
|
+
macro_ctx[pname] = pi < args.length ? args[pi] : pdefault
|
|
1831
2044
|
end
|
|
1832
2045
|
Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
|
|
1833
2046
|
}
|
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.87
|
|
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
|