tina4ruby 3.13.71 → 3.13.72

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: 9f7c750568ef4a0318f99b67d4d92a5a9ce7b8ba4acbb6ac44be312713f8bced
4
- data.tar.gz: 0a206bb1d8e813073e5d2914d5f90f450ab5bb52f74fd60fe5cdc27f11ee1733
3
+ metadata.gz: 3ed4c522a29f92b73683272345e23d289b81698615cc9d006c72a09d19dca1a2
4
+ data.tar.gz: 77e3fb90d8ce8c4a7609655c512e2794fead60a70ec03c728968cea1f9f5a122
5
5
  SHA512:
6
- metadata.gz: a188e4d9926366ddc6152711722f9bfaddba8740353abb1b616e931c4ebec67ef26a798b5cb54248f95ea9987510bae5b931627da5b7e9aa6ca58967528bb6df
7
- data.tar.gz: a554393a76905b3bd88304c7d70e76b59e29c678d7248ff880d0754ec2261e23c7699536b8a29916c9855f21973de43e60fe044fa428c0f4107048a972f810b7
6
+ metadata.gz: 0dfd6b813d86acf8b6977e0132d60dc0ab735d2f312197e8bef771f20a74c607d31fb720b9496227430fb205d40ec02f185d8b7b2e459f15ee594779e53df381
7
+ data.tar.gz: e112213e75cbd5ffadbaca25f4a910bd409542c40bdde4f194bfc93d591e9d780b628615f6f7b3831191113d60722f087fb93c7c3c091c7876aa1316649b07f8
@@ -488,11 +488,14 @@ module Tina4
488
488
  begin
489
489
  if defined?(Tina4::Queue)
490
490
  queue = Tina4::Queue.new(backend: :file, topic: topic)
491
+ # Queue#size is keyword-only (def size(status:)). Calling it
492
+ # positionally raises ArgumentError, which the rescue below
493
+ # swallows — silently zeroing every stat. Use keyword form.
491
494
  stats = {
492
- pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
493
- completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
494
- failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
495
- reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
495
+ pending: queue.respond_to?(:size) ? queue.size(status: "pending") : 0,
496
+ completed: queue.respond_to?(:size) ? queue.size(status: "completed") : 0,
497
+ failed: queue.respond_to?(:size) ? queue.size(status: "failed") : 0,
498
+ reserved: queue.respond_to?(:size) ? queue.size(status: "reserved") : 0,
496
499
  }
497
500
  jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
498
501
  jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
@@ -519,11 +522,10 @@ module Tina4
519
522
  error_tracker.clear_all
520
523
  json_response({ cleared: true })
521
524
  when ["GET", "/__dev/api/websockets"]
522
- json_response({ connections: [], count: 0 })
525
+ json_response(websockets_payload)
523
526
  when ["POST", "/__dev/api/websockets/disconnect"]
524
- body = read_json_body(env)
525
- # TODO: disconnect WS connection by id from body["id"]
526
- json_response({ disconnected: true })
527
+ body = read_json_body(env) || {}
528
+ json_response(websockets_disconnect(body))
527
529
  when ["GET", "/__dev/api/mailbox/read"]
528
530
  message_id = query_param(env, "id")
529
531
  message = mailbox.read(message_id)
@@ -547,16 +549,14 @@ module Tina4
547
549
  filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
548
550
  json_response({ messages: filtered, count: filtered.size, keyword: keyword })
549
551
  when ["POST", "/__dev/api/queue/retry"]
550
- body = read_json_body(env)
551
- # TODO: retry failed jobs by id from body["id"]
552
- json_response({ retried: true })
552
+ body = read_json_body(env) || {}
553
+ json_response(queue_retry(body))
553
554
  when ["POST", "/__dev/api/queue/purge"]
554
- # TODO: purge completed jobs
555
- json_response({ purged: true })
555
+ body = read_json_body(env) || {}
556
+ json_response(queue_purge(body))
556
557
  when ["POST", "/__dev/api/queue/replay"]
557
- body = read_json_body(env)
558
- # TODO: replay a specific job by id from body["id"]
559
- json_response({ replayed: true })
558
+ body = read_json_body(env) || {}
559
+ json_response(queue_replay(body))
560
560
  when ["GET", "/__dev/api/table"]
561
561
  table_name = query_param(env, "name")
562
562
  json_response(table_detail_payload(table_name))
@@ -573,6 +573,23 @@ module Tina4
573
573
  body = read_json_body(env)
574
574
  tool = (body && body["tool"]) || ""
575
575
  json_response(run_tool(tool))
576
+ # Dedicated run-chip routes (parity with the SPA's migrate/test/seed
577
+ # chips + Python's dev-admin tools). Not MCP-gated — same as the
578
+ # sibling /tool, /seed and /query operational routes, which are only
579
+ # reachable at all under TINA4_DEBUG (DevAdmin.enabled?).
580
+ when ["POST", "/__dev/api/migrate"]
581
+ json_response(run_migrations_payload)
582
+ when ["POST", "/__dev/api/test"]
583
+ json_response(run_tests_payload)
584
+ when ["POST", "/__dev/api/seed/run"]
585
+ json_response(run_seeds_payload)
586
+ # Grounding panel — configure the tina4-coder MCP token used for
587
+ # live-docs grounding. Self-contained (.env read/write); no proxy.
588
+ when ["GET", "/__dev/api/grounding/status"]
589
+ json_response(grounding_status_payload)
590
+ when ["POST", "/__dev/api/grounding/token"]
591
+ body = read_json_body(env) || {}
592
+ json_response(grounding_token_save(body))
576
593
  when ["POST", "/__dev/api/chat"]
577
594
  # Proxy dev-admin chat to the Rust agent's /chat endpoint.
578
595
  # The SPA POSTs {message, thread_id?, active_file?, settings?}
@@ -878,21 +895,281 @@ module Tina4
878
895
  }
879
896
  end
880
897
 
898
+ # Run a named developer tool. `test`/`migrate`/`seed` reuse the same
899
+ # real code paths as the dedicated run-chip routes above (no stubs), so
900
+ # the legacy POST /tool surface and the new chips stay in lockstep.
881
901
  def run_tool(tool)
882
- output = case tool
883
- when "routes"
884
- routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
885
- JSON.pretty_generate(routes)
886
- when "test"
887
- "Test runner not yet configured. Run: bundle exec rspec"
888
- when "migrate"
889
- "Migration runner not yet configured. Run: tina4ruby migrate"
890
- when "seed"
891
- "Seeder not yet configured. Run: tina4ruby seed"
902
+ case tool
903
+ when "routes"
904
+ routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
905
+ { tool: tool, output: JSON.pretty_generate(routes) }
906
+ when "test"
907
+ r = run_tests_payload
908
+ { tool: tool, output: r[:output].to_s, exit_code: r[:code], ok: r[:ok] }
909
+ when "migrate"
910
+ r = run_migrations_payload
911
+ lines = []
912
+ lines << "Applied: #{Array(r[:applied]).join(', ')}" unless Array(r[:applied]).empty?
913
+ lines << "Skipped: #{Array(r[:skipped]).join(', ')}" unless Array(r[:skipped]).empty?
914
+ failed_names = Array(r[:failed]).map { |f| f.is_a?(Hash) ? f[:name] : f }
915
+ lines << "Failed: #{failed_names.join(', ')}" unless failed_names.empty?
916
+ lines << "No pending migrations" if lines.empty? && r[:error].nil?
917
+ lines << "Error: #{r[:error]}" if r[:error]
918
+ { tool: tool, output: lines.join("\n"), result: r }
919
+ when "seed"
920
+ r = run_seeds_payload
921
+ { tool: tool, output: "Seeded #{r[:seeded]} file(s), #{r[:failed]} failed", result: r }
922
+ else
923
+ { tool: tool, output: "Unknown tool: #{tool}" }
924
+ end
925
+ end
926
+
927
+ # ── Run-chip implementations (Tier 1) ──────────────────────────
928
+
929
+ # POST /__dev/api/migrate — run pending migrations via the REAL
930
+ # Tina4::Migration runner (the same path `tina4ruby migrate` uses).
931
+ # `skipped` are pending files that never ran because an earlier
932
+ # migration in the batch failed (the runner stops on first failure).
933
+ def run_migrations_payload
934
+ db = Tina4.database
935
+ return { applied: [], skipped: [], failed: [], error: "No database configured" } unless db
936
+
937
+ require_relative "migration"
938
+ migration = Tina4::Migration.new(db)
939
+ pending_before = (migration.status[:pending] rescue [])
940
+ results = Array(migration.migrate)
941
+
942
+ applied = results.select { |r| r[:status] == "success" }.map { |r| r[:name] }
943
+ failed = results.select { |r| r[:status] == "failed" }
944
+ .map { |r| { name: r[:name], error: r[:error] } }
945
+ handled = results.map { |r| r[:name] }
946
+ skipped = pending_before - handled
947
+ { applied: applied, skipped: skipped, failed: failed }
948
+ rescue => e
949
+ { applied: [], skipped: [], failed: [], error: e.message }
950
+ end
951
+
952
+ # POST /__dev/api/test — run the project RSpec suite. Prefers
953
+ # `bundle exec rspec` when a Gemfile + bundler are present.
954
+ def run_tests_payload
955
+ root = Dir.pwd
956
+ runner = if File.exist?(File.join(root, "Gemfile")) &&
957
+ system("command -v bundle > /dev/null 2>&1")
958
+ "bundle exec rspec --no-color"
892
959
  else
893
- "Unknown tool: #{tool}"
960
+ "rspec --no-color"
894
961
  end
895
- { tool: tool, output: output }
962
+ output = `cd #{Shellwords.escape(root)} && #{runner} 2>&1`
963
+ code = $?.exitstatus.to_i
964
+ { ok: code.zero?, code: code, output: output.strip }
965
+ rescue => e
966
+ { ok: false, code: -1, output: e.message }
967
+ end
968
+
969
+ # POST /__dev/api/seed/run — run every seed file in seeds/ for real
970
+ # (each file `load`ed in the project context, exactly like `tina4ruby
971
+ # seed`). Counts files that ran vs. files that raised.
972
+ def run_seeds_payload
973
+ seed_folder = File.join(Dir.pwd, "seeds")
974
+ return { seeded: 0, failed: 0, error: "No seeds folder" } unless Dir.exist?(seed_folder)
975
+
976
+ files = Dir.glob(File.join(seed_folder, "*.rb")).sort
977
+ .reject { |f| File.basename(f).start_with?("_") }
978
+ seeded = 0
979
+ failed = 0
980
+ files.each do |filepath|
981
+ begin
982
+ load filepath
983
+ seeded += 1
984
+ rescue StandardError => e
985
+ failed += 1
986
+ Tina4::Log.error("Seed failed: #{File.basename(filepath)} - #{e.message}") if defined?(Tina4::Log)
987
+ end
988
+ end
989
+ { seeded: seeded, failed: failed }
990
+ rescue => e
991
+ { seeded: 0, failed: 0, error: e.message }
992
+ end
993
+
994
+ # ── Grounding panel (Tier 2) ───────────────────────────────────
995
+
996
+ # Resolve an env value: live process ENV first (a just-saved token is
997
+ # reflected there), then the project .env. Returns nil when unset.
998
+ def grounding_env_value(key)
999
+ live = ENV[key]
1000
+ return live unless live.nil? || live.empty?
1001
+
1002
+ env_path = File.join(Dir.pwd, ".env")
1003
+ return nil unless File.file?(env_path)
1004
+
1005
+ File.readlines(env_path).each do |line|
1006
+ line = line.strip
1007
+ next if line.empty? || line.start_with?("#") || !line.include?("=")
1008
+ k, v = line.split("=", 2)
1009
+ next unless k.strip == key
1010
+ return (v || "").strip.gsub(/\A["']|["']\z/, "")
1011
+ end
1012
+ nil
1013
+ end
1014
+
1015
+ # GET /__dev/api/grounding/status
1016
+ def grounding_status_payload
1017
+ token = grounding_env_value("TINA4_MCP_TOKEN").to_s
1018
+ url = grounding_env_value("TINA4_MCP_URL").to_s
1019
+ url = "https://mcp.tina4.com" if url.empty?
1020
+ configured = !token.empty?
1021
+ { configured: configured, last4: configured ? token[-4..].to_s : "", url: url }
1022
+ end
1023
+
1024
+ # POST /__dev/api/grounding/token — upsert TINA4_MCP_TOKEN into the
1025
+ # project .env (empty token clears it). Reflects into the live process
1026
+ # ENV so a subsequent status read returns the new state immediately.
1027
+ def grounding_token_save(body)
1028
+ token = (body && body["token"]).to_s.strip
1029
+ env_path = File.join(Dir.pwd, ".env")
1030
+ lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
1031
+ found = false
1032
+ new_lines = lines.map do |line|
1033
+ stripped = line.strip
1034
+ if !stripped.empty? && !stripped.start_with?("#") && stripped.include?("=") &&
1035
+ stripped.split("=", 2).first.strip == "TINA4_MCP_TOKEN"
1036
+ found = true
1037
+ "TINA4_MCP_TOKEN=#{token}"
1038
+ else
1039
+ line
1040
+ end
1041
+ end
1042
+ new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
1043
+ File.write(env_path, new_lines.join("\n") + "\n")
1044
+
1045
+ if token.empty?
1046
+ ENV.delete("TINA4_MCP_TOKEN")
1047
+ else
1048
+ ENV["TINA4_MCP_TOKEN"] = token
1049
+ end
1050
+ configured = !token.empty?
1051
+ { ok: true, configured: configured, last4: configured ? token[-4..].to_s : "" }
1052
+ rescue => e
1053
+ { ok: false, error: e.message }
1054
+ end
1055
+
1056
+ # ── Queue run-chips (Tier 3) ───────────────────────────────────
1057
+
1058
+ # A file-backed dev Queue for +topic+ (matches the GET /queue handler).
1059
+ def dev_queue(topic)
1060
+ require_relative "queue" unless defined?(Tina4::Queue)
1061
+ Tina4::Queue.new(backend: :file, topic: topic)
1062
+ end
1063
+
1064
+ # POST /__dev/api/queue/retry — revive failed/dead-letter jobs back to
1065
+ # pending (real mutation via Queue#retry_failed).
1066
+ def queue_retry(body)
1067
+ topic = (body["topic"] || "default").to_s
1068
+ queue = dev_queue(topic)
1069
+ retried = queue.respond_to?(:retry_failed) ? queue.retry_failed : 0
1070
+ { retried: retried, topic: topic }
1071
+ rescue => e
1072
+ { retried: 0, error: e.message }
1073
+ end
1074
+
1075
+ # POST /__dev/api/queue/purge — delete jobs by status (default
1076
+ # "completed"). Pass {"status": "pending"} to drop queued jobs.
1077
+ # Real mutation via Queue#purge; returns the number removed.
1078
+ def queue_purge(body)
1079
+ topic = (body["topic"] || "default").to_s
1080
+ status = (body["status"] || "completed").to_s
1081
+ queue = dev_queue(topic)
1082
+ removed = queue.respond_to?(:purge) ? queue.purge(status) : 0
1083
+ { purged: true, removed: removed, status: status, topic: topic }
1084
+ rescue => e
1085
+ { purged: false, error: e.message }
1086
+ end
1087
+
1088
+ # POST /__dev/api/queue/replay — re-queue a specific job by id from the
1089
+ # dead-letter/failed store (real mutation via Queue#retry(job_id)).
1090
+ def queue_replay(body)
1091
+ topic = (body["topic"] || "default").to_s
1092
+ job_id = body["id"]
1093
+ return { replayed: false, error: "Missing job id" } if job_id.nil? || job_id.to_s.empty?
1094
+
1095
+ delay = (body["delay"] || 0).to_i
1096
+ queue = dev_queue(topic)
1097
+ result = queue.respond_to?(:retry) ? queue.retry(job_id, delay_seconds: delay) : false
1098
+ { replayed: result, id: job_id, topic: topic }
1099
+ rescue => e
1100
+ { replayed: false, error: e.message }
1101
+ end
1102
+
1103
+ # ── WebSocket introspection (Tier 3) ───────────────────────────
1104
+
1105
+ # The live WebSocket managers to introspect. In tina4-ruby app routes
1106
+ # share the process-wide engine (Tina4::WebSocket.current) while the
1107
+ # dev-reload channel keeps its own manager (Tina4::DevReload) — both
1108
+ # hold real, live connections, so the panel enumerates both.
1109
+ def ws_managers
1110
+ managers = []
1111
+ if defined?(Tina4::WebSocket) && Tina4::WebSocket.current
1112
+ managers << Tina4::WebSocket.current
1113
+ end
1114
+ managers << Tina4::DevReload.manager if defined?(Tina4::DevReload)
1115
+ managers.compact
1116
+ end
1117
+
1118
+ # GET /__dev/api/websockets — enumerate real live connections.
1119
+ def websockets_payload
1120
+ seen = {}
1121
+ ws_managers.each do |mgr|
1122
+ conns = mgr.respond_to?(:connections) ? mgr.connections : {}
1123
+ conns.each do |id, conn|
1124
+ next if seen.key?(id)
1125
+ seen[id] = {
1126
+ id: id.to_s,
1127
+ path: (conn.respond_to?(:path) ? conn.path : "/").to_s,
1128
+ ip: ws_conn_ip(conn),
1129
+ rooms: (conn.respond_to?(:rooms) ? conn.rooms.to_a : []),
1130
+ last_activity: (conn.respond_to?(:last_activity) ? conn.last_activity : nil),
1131
+ closed: (conn.respond_to?(:closed?) ? conn.closed? : false),
1132
+ }
1133
+ end
1134
+ end
1135
+ list = seen.values
1136
+ { connections: list, count: list.size }
1137
+ rescue => e
1138
+ { connections: [], count: 0, error: e.message }
1139
+ end
1140
+
1141
+ # Best-effort peer IP off the connection params (nil-safe).
1142
+ def ws_conn_ip(conn)
1143
+ return "" unless conn.respond_to?(:params) && conn.params
1144
+ (conn.params["ip"] || conn.params[:ip] || "").to_s
1145
+ end
1146
+
1147
+ # POST /__dev/api/websockets/disconnect — actually close + unregister
1148
+ # the given connection id across the live managers.
1149
+ def websockets_disconnect(body)
1150
+ id = body["id"]
1151
+ return { disconnected: false, error: "id required" } if id.nil? || id.to_s.empty?
1152
+
1153
+ disconnected = false
1154
+ ws_managers.each do |mgr|
1155
+ conns = mgr.respond_to?(:connections) ? mgr.connections : {}
1156
+ next unless conns.key?(id)
1157
+
1158
+ begin
1159
+ mgr.close(id) if mgr.respond_to?(:close)
1160
+ rescue StandardError
1161
+ # closing a half-dead socket must not abort the unregister below
1162
+ end
1163
+ if mgr.respond_to?(:unregister_connection)
1164
+ mgr.unregister_connection(id)
1165
+ else
1166
+ conns.delete(id)
1167
+ end
1168
+ disconnected = true
1169
+ end
1170
+ { disconnected: disconnected, id: id }
1171
+ rescue => e
1172
+ { disconnected: false, error: e.message }
896
1173
  end
897
1174
 
898
1175
  def run_query(sql)
@@ -1590,14 +1867,29 @@ module Tina4
1590
1867
  def deps_install(body)
1591
1868
  name = body["name"].to_s.strip
1592
1869
  return { ok: false, error: "name required" } if name.empty?
1593
- # Append to Gemfile if not present — do NOT actually bundle install.
1870
+ version = body["version"].to_s.strip
1871
+ dev = [true, "true", 1, "1"].include?(body["dev"])
1594
1872
  gemfile = File.join(Dir.pwd, "Gemfile")
1595
1873
  return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1874
+
1875
+ # Prefer `bundle add` — it updates the Gemfile AND installs the gem, so a
1876
+ # dev dep is deployed (not just recorded). Fall back to appending to the
1877
+ # Gemfile when bundler isn't available.
1878
+ if system("command -v bundle > /dev/null 2>&1")
1879
+ cmd = ["bundle", "add", name]
1880
+ cmd += ["--version", version] unless version.empty?
1881
+ cmd += ["--group", "development"] if dev
1882
+ out = `#{cmd.map { |c| Shellwords.escape(c) }.join(' ')} 2>&1`
1883
+ return { ok: true, gem: name, output: out.strip } if $?.success?
1884
+ return { ok: false, error: out.strip }
1885
+ end
1886
+
1596
1887
  content = File.read(gemfile)
1597
1888
  if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1598
1889
  return { ok: true, gem: name, note: "already in Gemfile" }
1599
1890
  end
1600
- File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1891
+ entry = dev ? "\ngroup :development do\n gem \"#{name}\"\nend\n" : "\ngem \"#{name}\"\n"
1892
+ File.open(gemfile, "a") { |f| f.write(entry) }
1601
1893
  { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1602
1894
  end
1603
1895
 
data/lib/tina4/frond.rb CHANGED
@@ -93,6 +93,19 @@ module Tina4
93
93
  HASH_PAIR_RE = /\A\s*(?:["']([^"']+)["']|(\w+))\s*:\s*(.+)\z/
94
94
  RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
95
95
  ARITHMETIC_OPS = [" + ", " - ", " * ", " // ", " / ", " % ", " ** "].freeze
96
+ # Operators Twig binds LOOSER than the filter pipe `|`. When one of these
97
+ # sits at the top level alongside a pipe (e.g. `amount|number_format(2) ~
98
+ # ' EUR'`), the whole expression must go through eval_expr (which resolves
99
+ # the pipe at its correct, tighter precedence) instead of being split on
100
+ # the pipe as a plain filter chain. Detection is quote/paren-aware
101
+ # (find_outside_quotes) so operator-like text inside a string or filter
102
+ # args never false-triggers.
103
+ LOOSER_THAN_PIPE_OPS = [
104
+ "~", "??", " if ",
105
+ " not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
106
+ " and ", " or ", " not ",
107
+ " + ", " - ", " * ", " // ", " / ", " % ", " ** "
108
+ ].freeze
96
109
  FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
97
110
  FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
98
111
  FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
@@ -673,6 +686,12 @@ module Tina4
673
686
  filters.each do |fname, args|
674
687
  next if fname == "raw" || fname == "safe"
675
688
 
689
+ # Sandbox: a blocked filter is silently skipped (value passes through
690
+ # unchanged) — same gate as eval_var_inner. Without this, a filter pipe
691
+ # reached via eval_filter_pipe (`x|f ~ y`, #171) or a ternary condition
692
+ # (`x|f ? a : b`) would run a non-allow-listed filter in sandbox mode.
693
+ next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
694
+
676
695
  # Filter + property-access chain: `first.groupSummary` — apply
677
696
  # the filter, then traverse the path on the result using a
678
697
  # synthetic context so eval_expr's dotted resolution does the
@@ -723,6 +742,20 @@ module Tina4
723
742
  end
724
743
 
725
744
  def eval_var_inner(expr, context)
745
+ # Twig binds the filter pipe `|` TIGHTER than ~, comparison, and
746
+ # arithmetic. When a looser operator sits at the top level alongside a
747
+ # pipe (e.g. `amount|number_format(2) ~ ' EUR'`), splitting on the pipe
748
+ # here would swallow the tail (`~ ' EUR'`) into the filter args and drop
749
+ # it. Hand the whole expression to eval_expr — which resolves the pipe at
750
+ # the correct precedence (eval_filter_pipe) — then apply output escaping.
751
+ if find_outside_quotes(expr, "|") >= 0 && has_looser_than_pipe_operator?(expr)
752
+ value = eval_expr(expr, context)
753
+ if @auto_escape && value.is_a?(String) && !value.is_a?(SafeString)
754
+ value = Frond.escape_html(value)
755
+ end
756
+ return value
757
+ end
758
+
726
759
  var_name, filters = parse_filter_chain(expr)
727
760
 
728
761
  # Sandbox: check variable access
@@ -1068,12 +1101,37 @@ module Tina4
1068
1101
  result = eval_arithmetic(expr, context)
1069
1102
  return result unless result == :not_arithmetic
1070
1103
 
1104
+ result = eval_filter_pipe(expr, context)
1105
+ return result unless result == :not_filter_pipe
1106
+
1071
1107
  result = eval_function_call(expr, context)
1072
1108
  return result unless result == :not_function
1073
1109
 
1074
1110
  resolve(expr, context)
1075
1111
  end
1076
1112
 
1113
+ # ── Filter pipe: value|filter(args) ──
1114
+ # Reached only after every looser-binding operator (concat ~, comparison,
1115
+ # arithmetic, ternary, ...) has been ruled out at this level, so the pipe
1116
+ # binds tighter than all of them — matching Twig. This is what makes
1117
+ # `amount|number_format(2) ~ ' EUR'` resolve as `(amount|number_format(2))
1118
+ # ~ ' EUR'`, and it lets a pipe inside parens / a sub-expression resolve
1119
+ # (previously such a pipe fell through to `resolve` and returned empty).
1120
+ # Delegates to eval_var_raw, which applies the filter chain WITHOUT output
1121
+ # escaping (escaping happens once, at the output layer, on the final value).
1122
+ def eval_filter_pipe(expr, context)
1123
+ return :not_filter_pipe unless find_outside_quotes(expr, "|") >= 0
1124
+
1125
+ eval_var_raw(expr, context)
1126
+ end
1127
+
1128
+ # True when the expression carries a top-level operator that Twig binds
1129
+ # looser than the filter pipe. Quote/paren-aware via find_outside_quotes so
1130
+ # operator-like text inside a string literal or filter args never matches.
1131
+ def has_looser_than_pipe_operator?(expr)
1132
+ LOOSER_THAN_PIPE_OPS.any? { |op| find_outside_quotes(expr, op) >= 0 }
1133
+ end
1134
+
1077
1135
  # ── Literal values: strings, numbers, booleans, null ──
1078
1136
 
1079
1137
  def eval_literal(expr)
@@ -2172,12 +2230,17 @@ module Tina4
2172
2230
  "int" => ->(v, *_a) { v.to_i },
2173
2231
  "float" => ->(v, *_a) { v.to_f },
2174
2232
  "number_format" => ->(v, *a) {
2175
- decimals = a[0] ? a[0].to_i : 0
2233
+ # Twig signature: number_format(decimals, decimalPoint, thousandsSep).
2234
+ # The defaults reproduce the historical output ("1,234.50") so a
2235
+ # 1-arg call is unchanged; args 2 and 3 apply the localized
2236
+ # separators (e.g. number_format(2, ',', '.') -> "1.234,50").
2237
+ decimals = a[0] ? a[0].to_i : 0
2238
+ decimal_point = a[1].nil? ? "." : a[1].to_s
2239
+ thousands_sep = a[2].nil? ? "," : a[2].to_s
2176
2240
  formatted = format("%.#{decimals}f", v.to_f)
2177
- # Add comma thousands separator
2178
- parts = formatted.split(".")
2179
- parts[0] = parts[0].gsub(THOUSANDS_RE, '\\1,')
2180
- parts.join(".")
2241
+ int_part, frac_part = formatted.split(".")
2242
+ int_part = int_part.gsub(THOUSANDS_RE) { |digit| "#{digit}#{thousands_sep}" }
2243
+ frac_part ? "#{int_part}#{decimal_point}#{frac_part}" : int_part
2181
2244
  },
2182
2245
 
2183
2246
  # -- Date --