tina4ruby 3.13.85 → 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/database/sqlite3_adapter.rb +24 -4
- data/lib/tina4/database.rb +22 -8
- data/lib/tina4/drivers/firebird_driver.rb +27 -5
- data/lib/tina4/drivers/odbc_driver.rb +3 -5
- data/lib/tina4/drivers/postgres_driver.rb +31 -3
- data/lib/tina4/drivers/sqlite_driver.rb +42 -4
- data/lib/tina4/frond.rb +245 -22
- data/lib/tina4/metrics.rb +11 -2
- data/lib/tina4/orm.rb +3 -3
- data/lib/tina4/public/js/tina4-dev-admin.min.js +90 -87
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +20 -3
- metadata +2 -2
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
|
|
@@ -837,12 +879,22 @@ module Tina4
|
|
|
837
879
|
# Find the first occurrence of +needle+ that is not inside quotes or
|
|
838
880
|
# parentheses. Returns the index, or -1 if not found.
|
|
839
881
|
def find_outside_quotes(expr, needle)
|
|
882
|
+
# Fast path. This is the hottest method in a render -- profiling the Python
|
|
883
|
+
# twin showed 415,200 calls and 53% of render time for a single 20-row loop
|
|
884
|
+
# template, and the overwhelming majority return -1 because the needle
|
|
885
|
+
# simply is not in the expression. include? is a C-level scan, so bailing
|
|
886
|
+
# here skips the whole Ruby character loop. Exact, not a heuristic: a
|
|
887
|
+
# needle absent from the string cannot be present outside quotes either.
|
|
888
|
+
return -1 unless expr.include?(needle)
|
|
889
|
+
|
|
840
890
|
in_q = nil
|
|
841
891
|
depth = 0
|
|
842
892
|
bracket_depth = 0
|
|
843
893
|
i = 0
|
|
844
894
|
nlen = needle.length
|
|
845
|
-
|
|
895
|
+
# Hoisted: expr.length was recomputed on every iteration of the condition.
|
|
896
|
+
last_start = expr.length - nlen
|
|
897
|
+
while i <= last_start
|
|
846
898
|
ch = expr[i]
|
|
847
899
|
if (ch == '"' || ch == "'") && depth == 0
|
|
848
900
|
if in_q.nil?
|
|
@@ -1072,44 +1124,111 @@ module Tina4
|
|
|
1072
1124
|
# Helpers return :not_matched when the expression doesn't match their
|
|
1073
1125
|
# type, so the dispatcher falls through to the next handler.
|
|
1074
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.
|
|
1075
1151
|
def eval_expr(expr, context)
|
|
1076
1152
|
expr = expr.strip
|
|
1077
1153
|
return nil if expr.empty?
|
|
1078
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
|
+
|
|
1079
1161
|
result = eval_literal(expr)
|
|
1080
|
-
return result unless result == :not_literal
|
|
1162
|
+
return remember_form(expr, :literal, result) unless result == :not_literal
|
|
1081
1163
|
|
|
1082
1164
|
result = eval_collection_literal(expr, context)
|
|
1083
|
-
return result unless result == :not_collection
|
|
1165
|
+
return remember_form(expr, :collection, result) unless result == :not_collection
|
|
1084
1166
|
|
|
1085
|
-
|
|
1167
|
+
if matched_parens?(expr)
|
|
1168
|
+
remember_form(expr, :parens, nil)
|
|
1169
|
+
return eval_expr(expr[1..-2], context)
|
|
1170
|
+
end
|
|
1086
1171
|
|
|
1087
1172
|
result = eval_ternary(expr, context)
|
|
1088
|
-
return result unless result == :not_ternary
|
|
1173
|
+
return remember_form(expr, :ternary, result) unless result == :not_ternary
|
|
1089
1174
|
|
|
1090
1175
|
result = eval_inline_if(expr, context)
|
|
1091
|
-
return result unless result == :not_inline_if
|
|
1176
|
+
return remember_form(expr, :inline_if, result) unless result == :not_inline_if
|
|
1092
1177
|
|
|
1093
1178
|
result = eval_null_coalesce(expr, context)
|
|
1094
|
-
return result unless result == :not_coalesce
|
|
1179
|
+
return remember_form(expr, :coalesce, result) unless result == :not_coalesce
|
|
1095
1180
|
|
|
1096
1181
|
result = eval_concat(expr, context)
|
|
1097
|
-
return result unless result == :not_concat
|
|
1182
|
+
return remember_form(expr, :concat, result) unless result == :not_concat
|
|
1098
1183
|
|
|
1099
|
-
|
|
1184
|
+
if has_comparison?(expr)
|
|
1185
|
+
remember_form(expr, :comparison, nil)
|
|
1186
|
+
return eval_comparison(expr, context)
|
|
1187
|
+
end
|
|
1100
1188
|
|
|
1101
1189
|
result = eval_arithmetic(expr, context)
|
|
1102
|
-
return result unless result == :not_arithmetic
|
|
1190
|
+
return remember_form(expr, :arithmetic, result) unless result == :not_arithmetic
|
|
1103
1191
|
|
|
1104
1192
|
result = eval_filter_pipe(expr, context)
|
|
1105
|
-
return result unless result == :not_filter_pipe
|
|
1193
|
+
return remember_form(expr, :pipe, result) unless result == :not_filter_pipe
|
|
1106
1194
|
|
|
1107
1195
|
result = eval_function_call(expr, context)
|
|
1108
|
-
return result unless result == :not_function
|
|
1196
|
+
return remember_form(expr, :function, result) unless result == :not_function
|
|
1109
1197
|
|
|
1198
|
+
remember_form(expr, :resolve, nil)
|
|
1110
1199
|
resolve(expr, context)
|
|
1111
1200
|
end
|
|
1112
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
|
+
|
|
1113
1232
|
# ── Filter pipe: value|filter(args) ──
|
|
1114
1233
|
# Reached only after every looser-binding operator (concat ~, comparison,
|
|
1115
1234
|
# arithmetic, ternary, ...) has been ruled out at this level, so the pipe
|
|
@@ -1129,6 +1248,10 @@ module Tina4
|
|
|
1129
1248
|
# looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
|
|
1130
1249
|
# operator-like text inside a string literal or filter args never matches.
|
|
1131
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
|
+
|
|
1132
1255
|
LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
|
|
1133
1256
|
end
|
|
1134
1257
|
|
|
@@ -1244,7 +1367,12 @@ module Tina4
|
|
|
1244
1367
|
def eval_concat(expr, context)
|
|
1245
1368
|
return :not_concat unless expr.include?("~")
|
|
1246
1369
|
parts = expr.split("~")
|
|
1247
|
-
|
|
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
|
|
1248
1376
|
end
|
|
1249
1377
|
|
|
1250
1378
|
# ── Arithmetic: +, -, *, //, /, %, ** ──
|
|
@@ -1272,7 +1400,21 @@ module Tina4
|
|
|
1272
1400
|
fn.call(*args)
|
|
1273
1401
|
end
|
|
1274
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.
|
|
1275
1415
|
def has_comparison?(expr)
|
|
1416
|
+
return true if expr.start_with?("not ")
|
|
1417
|
+
|
|
1276
1418
|
[" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
|
|
1277
1419
|
" and ", " or ", " not "].any? { |op| expr.include?(op) }
|
|
1278
1420
|
end
|
|
@@ -1438,7 +1580,19 @@ module Tina4
|
|
|
1438
1580
|
parts.each do |part|
|
|
1439
1581
|
part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
|
|
1440
1582
|
if value.is_a?(Hash) || value.is_a?(LoopContext)
|
|
1441
|
-
|
|
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
|
|
1442
1596
|
elsif value.is_a?(Array)
|
|
1443
1597
|
# Slice syntax: value[1:5], value[:10], value[start:end]
|
|
1444
1598
|
if part.include?(":") && !(part.start_with?('"') || part.start_with?("'"))
|
|
@@ -1740,7 +1894,7 @@ module Tina4
|
|
|
1740
1894
|
end
|
|
1741
1895
|
|
|
1742
1896
|
macro_name = m[1]
|
|
1743
|
-
|
|
1897
|
+
params = parse_macro_params(m[2])
|
|
1744
1898
|
|
|
1745
1899
|
body_tokens = []
|
|
1746
1900
|
i = start + 1
|
|
@@ -1759,8 +1913,8 @@ module Tina4
|
|
|
1759
1913
|
|
|
1760
1914
|
context[macro_name] = lambda { |*args|
|
|
1761
1915
|
macro_ctx = captured_context.dup
|
|
1762
|
-
|
|
1763
|
-
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
|
|
1764
1918
|
end
|
|
1765
1919
|
Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
|
|
1766
1920
|
}
|
|
@@ -1768,6 +1922,54 @@ module Tina4
|
|
|
1768
1922
|
i
|
|
1769
1923
|
end
|
|
1770
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
|
+
|
|
1771
1973
|
# {% from "file" import macro1, macro2 %}
|
|
1772
1974
|
def handle_from_import(content, context)
|
|
1773
1975
|
m = content.match(FROM_IMPORT_RE)
|
|
@@ -1789,7 +1991,7 @@ module Tina4
|
|
|
1789
1991
|
macro_m = tag_content.match(MACRO_RE)
|
|
1790
1992
|
if macro_m && names.include?(macro_m[1])
|
|
1791
1993
|
macro_name = macro_m[1]
|
|
1792
|
-
param_names = macro_m[2]
|
|
1994
|
+
param_names = parse_macro_params(macro_m[2])
|
|
1793
1995
|
|
|
1794
1996
|
body_tokens = []
|
|
1795
1997
|
i += 1
|
|
@@ -1811,13 +2013,34 @@ module Tina4
|
|
|
1811
2013
|
end
|
|
1812
2014
|
end
|
|
1813
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
|
+
|
|
1814
2036
|
# Build an isolated lambda for a macro — avoids closure-in-loop variable sharing.
|
|
1815
|
-
|
|
2037
|
+
# `params` is the [name, default] list from parse_macro_params.
|
|
2038
|
+
def _make_macro_fn(body_tokens, params, ctx)
|
|
1816
2039
|
engine = self
|
|
1817
2040
|
lambda { |*args|
|
|
1818
2041
|
macro_ctx = ctx.dup
|
|
1819
|
-
|
|
1820
|
-
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
|
|
1821
2044
|
end
|
|
1822
2045
|
Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
|
|
1823
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/orm.rb
CHANGED
|
@@ -785,9 +785,9 @@ module Tina4
|
|
|
785
785
|
# Only adopt the engine-assigned id for an auto-increment PK. A
|
|
786
786
|
# natural-key PK was set by the caller; don't overwrite it with the
|
|
787
787
|
# driver's last_insert_id (which may be a sequence value that
|
|
788
|
-
# doesn't apply here).
|
|
789
|
-
if auto_increment && result
|
|
790
|
-
__send__("#{pk}=", result
|
|
788
|
+
# doesn't apply here). db.insert returns a DatabaseResult (.last_id).
|
|
789
|
+
if auto_increment && result.last_id && respond_to?("#{pk}=")
|
|
790
|
+
__send__("#{pk}=", result.last_id)
|
|
791
791
|
end
|
|
792
792
|
end
|
|
793
793
|
end
|