module

Krikri::VariableSubstitutor::Rerender

Constants

BARE_INDIRECTION_RE = /\A\{\{\s*([A-Za-z_]\w*)\s*\}\}\z/

The pure, single-level bare indirection (x: "{{ y }}" - no filters, no dotted/bracket access, no surrounding text) whose referenced variable's type is still recoverable without a render-then-reparse round trip. The capture group holds the referenced name.

MAX_RETEMPLATING_DEPTH = 50
RECURSION_ERROR_MARK = "Recursive loop detected in template"
RECURSION_ERROR_MESSAGE = "Recursive loop detected in template: mutually-templated variable values (e.g. a: \"{{ b }}\" / b: \"{{ a }}\") never converge"

The message ansible-core's Templar raises for the same shape (TemplateRecursionError, "recursive loop detected in template string"). Shared with the re-raise helpers below so a krikri-jinja TemplateError wrapping this error (the engine wraps every resolver exception) is recognized by its message and unwrapped back to the real class at the engine boundary.

Class methods

bare_indirection_value(vars : Hash(String, JSON::Any), raw : String, depth : Int32 = 0) : JSON::Any | Nil

Resolves a whole-value bare indirection straight to the referenced variable's own value, preserving its TYPE the way ansible-core 2.19's native typing does: an indirected string stays a string (buluma.bind's bind_python_version: "{{ bind_default_python_ version }}" against bind_default_python_version: "3" must compare equal to the STRING '3'), and an indirected number stays that number (robertdebock.java's java_version == 8). The render-then-reparse detour both callers used cannot tell the two apart - the rendered text "3" always re-typed as Int64, so the strict == this branch introduced turned the bind case False. Returns nil whenever the fast path does not apply - not a pure indirection, referenced variable missing, a non-scalar target, or a target that is itself a template this helper cannot resolve structurally - and every caller then falls back to its existing render path, so behavior elsewhere is unchanged.

Source
enter_retemplating

Manual enter/exit pair for call sites whose body can't be a block (VarSubstitutor#substitute_impl wraps a long multi-return body in a method-level ensure instead).

The increment happens BEFORE the cap check, not after: the old raise-then-never-increment order let every caller's matching exit_retemplating (with_depth_guard's ensure, substitute_impl's own ensure) decrement a level that was never entered - so at the cap the counter leaked DOWNWARD one per failed entry and the mutual-cycle descent oscillated just under it forever, each level re-running the whole cycle instead of ever surfacing the error (the round-5460170 hang). Increment-then-check keeps enter/exit strictly paired, so the depth really accumulates through the nested re-passes and the cap raises exactly once, at the top.

Source
exit_retemplating
Source
if_templated(vars : Hash(String, JSON::Any) | Nil, value : JSON::Any | Nil, source_expr : String | Nil = nil) : JSON::Any | Nil

source_expr: the expression the value was resolved FROM (its leading identifier decides the unsafe gate) - nil keeps the old always-render behavior for call sites with no expression in hand (legacy/edge callers), but every evaluation-path caller threads it.

Source
re_raise_recursion(error : Exception) : Nil

The fallback-rescue convention across both evaluators and the lenient render path (evaluate_expr_dotted's rescue -> plain lookup, whole_span_structured's rescue -> nil, JinjaRenderer# render's rescue -> original text) exists for UNKNOWN constructs, and a TemplateRecursionError is not one: swallowing it made every fallback re-attempt the same cycle (the round-5460170 hang - the guard tripped at the cap over a thousand times in five seconds without the error ever surfacing), and the lenient render's give-back-the-original-text behavior fed the still-templated text back to the outer re-pass loop, which re-rendered it forever. Call this first in any rescue that sits on a re-render path: it re-raises the error (unwrapping the engine's "line N: " prefix when krikri-jinja wrapped it) so the cycle fails the task the way ansible-core does.

Source
recursion_error?(error : Exception) : Bool
Source
render_raw(vars : Hash(String, JSON::Any), raw : String, source_expr : String | Nil = nil) : String

Renders raw (known to contain {{) back to its real value: one whole-string span -> ExpressionEvaluator (preserves result types); anything else -> full substitution. source_expr gates unsafe roots the same way #if_templated does, and selects the other host's scope for a hostvars-rooted expression (see #if_templated).

Source
render_raw_vars(render_vars : Hash(String, JSON::Any), raw : String) : String

The scope-agnostic half of #render_raw: render_vars is already the scope to render with (the reading host's, or the other host's merged scope when #if_templated/#render_raw detected a hostvars origin - detection must not re-run here, the merged scope would rebuild itself needlessly).

Source
whole_span_structured(vars : Hash(String, JSON::Any), raw : String) : JSON::Any | Nil

ansible-core 2.19 native typing for a WHOLE-single-span {{ expr }} value (the template's whole AST is one output node wrapping one expression): the expression's own result TYPE is the value's type, so {{ 42 }} is the int 42, {{ '42' }} the str "42", {{ none }} None - not something re-inferred from the rendered TEXT. The string detour every re-render path used (render to text, then re-type by shape) cannot tell a string expression's output from a number's ("42" either way), so vars like b: "{{ 42 }}" stayed strings here while Ansible keeps them ints (live-verified vs ansible-playbook 2.19.11, with the same matrix for list/dict/bool/None). Evaluating the span STRUCTURALLY once (the engine's real typed evaluation) recovers the type at the one point it is still known.

Returns nil whenever this path does NOT apply or does not succeed - not a whole single span (multi-span/mixed text, or block tags, which Ansible renders to plain text), the expression genuinely resolves to nothing (Undefined), or the engine cannot evaluate it. A None result comes back as a JSON null JSON::Any, not Crystal nil. Every caller falls back to its pre-existing render-then-retype path on nil, which keeps those shapes' behavior byte-identical: the engine evaluation only ran for a value that then resolves to nothing (no side effects to double), and a strict-undefined failure still fires from the fallback's own strict substitute.

Source
with_depth_guard

Runs block under the re-templating depth guard. Every entry point that re-renders a variable's own unrendered {{ }} value must go through this - a cycle can re-enter through any of them (Rerender.if_templated, VariableLookup#rerender_if_templated, VarSubstitutor#substitute), so the counter has to be shared.

Source