class

Krikri::VariableSubstitutor::CrinjaRenderer

Inherits Reference < Object

CrinjaRenderer - Handles full Jinja2 template rendering using Crinja This includes {% if %}, {% for %}, {% set %}, etc.

Constants

MAX_PREPARE_CRINJA_VARS_DEPTH = 3
UPDATE_THEN_REREAD_RE = /\A\{\{\s*([A-Za-z_]\w*)\.update\(\s*([A-Za-z_]\w*)\s*\)\s*\}\}\{\{\s*\1\s*\}\}\z/

Real bug found benchmarking geerlingguy.postgresql: its own pg_hba.conf.j2 iterates postgresql_hba_entries (a list of dicts) via {% for client in ... %} ... {{ client.auth_method }} ...{% endfor %}, where each entry's auth_method: field is itself "{{ postgresql_auth_method }}" - a role default computed from ANOTHER default, the same recursive-re-templating shape this codebase has already fixed a dozen-odd times over for plain scalar variable values. This is a distinct sub-case none of those fixes covered: only a top-level String value used to get re-rendered - postgresql_hba_entries itself is an Array, so it never even reached the raw.is_a?(String) check at all, and the literal unrendered {{ postgresql_auth_method }} text landed straight into the rendered config file (PostgreSQL then refused to start: "invalid authentication method '{{'"). Real Ansible's own recursive re-templating applies at every level of a nested structure, not just the outermost value - walks Array/Hash values recursively, re-rendering every String leaf that still contains "{{".

Exposed as a class method for the same reason #json_any_to_crinja_value is: TemplateActionPlugin has its own separate prepare_*_vars (a genuinely separate Crinja environment - see that method's own comment) that needs this identical recursive-re-render fix, not just this class's. Narrow special case for one specific idiom (found round 755/753, jtyr.nsswitch/jtyr.motd): some_var: "{{ some_dict.update( other_dict) }}{{ some_dict }}" - call .update() purely for its mutating side effect, discard its None return, then render the now-merged dict. Real Ansible's templar preserves the result as a genuine dict (_AnsibleLazyTemplateDict, private ansible-core internals - see KNOWN_MISSING.md's own writeup); replicating that faithfully (deferred evaluation + type preservation through the whole vars pipeline) is a major architectural undertaking, not attempted here. This instead special-cases exactly the documented shape - both operands bare variable names, no arbitrary Jinja expression inside .update(...) - by reading both from the vars store directly, merging (matching Python dict.update's own shallow-merge, top-level-key-overrides semantics), and persisting the merge back onto the target variable (matching Python's real in-place mutation, visible to any LATER reference of it too - not just this one). Falls through to the general string-rendering path below for anything that doesn't match this exact shape.

Constructors

Class methods

convert_hostvars(raw_value : JSON::Any, substitutor : VarSubstitutor) : Crinja::Value

Converts the hostvars magic variable with each host's vars dict wrapped in Krikri::HostVarsVarsDict (see that class's own comment). Shared by BOTH Crinja context builds that can carry hostvars - LazyCrinjaContext#convert (the {% %}/{{ }} evaluator's lazy context, via #convert_var) and the template module's eager env (template_action_plugin.cr) - so a raising attribute miss behaves identically in a .j2 file and a module-arg render.

Source
convert_var(raw_value : JSON::Any, substitutor : VarSubstitutor, name : String = "") : Crinja::Value

Converts one @vars entry to its final Crinja::Value, applying the same recursive re-templating #rerender_nested_templates always did, bounded by the depth guard above. Called from LazyCrinjaContext#convert - kept here (not on that class) because it needs @@prepare_crinja_vars_depth, a CrinjaRenderer class variable shared across every renderer/context in the process, matching the guard's own "process-wide, not per-instance" reasoning (see VarSubstitutor's identical @@block_tag_escalation_depth comment).

Source
crinja_value_to_json_any(value : Crinja::Value) : JSON::Any

Convert Crinja::Value to JSON::Any - the reverse direction of #json_any_to_crinja_value below. Exposed as a class method for the same reason that one is (shareable with any other Crinja environment this codebase spins up).

Source
elide_omitted(value : JSON::Any) : JSON::Any

Real Ansible's omit inside a CONTAINER removes that entry rather than leaving a placeholder in it - verified against ansible-core 2.19.4: {{ [1, v_omit, 3] }} renders [1, 3] and {{ {'a': 1, 'b': v_omit} }} renders {"a": 1}. Crinja builds such a literal itself (this is the raw-value path every bracket/ dict expression takes), so it sees omit as the ordinary string this engine represents it with, and kept it - the literal sentinel text then landed in whatever the list/dict fed.

ExpressionEvaluator's own literal-array/dict builders need the same treatment separately: the two evaluators share no implementation, so this bug class has to be fixed once in each (see CLAUDE.md). Only containers are touched here - a bare scalar omit must survive intact this far, since that is what tells the caller to drop a whole parameter.

Source
ensure_python_filter?(name : String, vars : Hash(String, JSON::Any), env : Crinja = shared_environment) : Bool

If name is exposed by a role-local (or playbook-adjacent) filter_plugins/*.py for the role context in vars, register a dynamic Crinja filter dispatching to the controller's python3 (see PythonFilterRunner) into the shared environment's filter library and return true - so both this render path and #known_filter? (ConditionalEvaluator's compile-time pre-pass) resolve it from here on. False when no plugin source defines the name (or the mechanism is unavailable), leaving the caller to raise the plain unknown-filter error.

Source
json_any_to_crinja_value(json : JSON::Any) : Crinja::Value

Convert JSON::Any to Crinja::Value.

Exposed as a class method because TemplateActionPlugin needs the exact same coercion and used to carry a verbatim copy of it. (Only the converter is shared: that plugin's Crinja environment genuinely must stay separate, since its trim_blocks/lstrip_blocks come from the task's own template: params and therefore vary per task - unlike this class's, whose config is invariant and so can be one process-wide instance.)

Source
known_filter?(name : String) : Bool

True if name resolves in the shared environment's filter library - a registered filter or a registered alias for one (FeatureLibrary#[] downcases lookups and resolves aliases the same way, so this mirrors exactly what a render would find).

Source
known_test?(name : String) : Bool

True if name resolves in the shared environment's TEST library - the test-side twin of #known_filter? above, for ConditionalEvaluator's compile-time test-name pre-pass (an unknown is <name> in a when: must hard-fail even when and/or short-circuiting never reaches that clause).

Source
register_python_filter_instance(name : String, env : Crinja = shared_environment) : Nil

Registers the dynamic dispatching filter under name into env (the shared {{ }}-path environment by default, or a real .j2 template's own standalone Crinja.new - see TemplateActionPlugin#render_template, which builds a fresh environment per render and never shares this class's own, so the shared-environment registration alone never reaches it). The plugin sources are re-resolved from the RENDERING environment's own context at each call (env.context's role_path/playbook_dir magic vars), not captured at registration time - a shared environment outlives any single role, so a stale capture could dispatch a later role's filter to the wrong (already-finished) role's plugin file.

Source
rerender_nested_templates(value : JSON::Any, substitutor : VarSubstitutor) : JSON::Any
Source
shared_environment

Class-level twin of #shared_env so callers without a renderer instance can consult this environment's own feature libraries - ConditionalEvaluator's compile-time filter-name pre-pass asks it whether a | name in a when: is one Crinja itself implements (including aliases), since FilterEngine.apply is only ever the fallback path behind Crinja-native filters.

Source
unknown_feature(e : Crinja::FeatureLibrary::UnknownFeatureError) : Tuple(String, String) | Nil

Parses Crinja's own unknown-feature error wording ("no filter/ test with name ... registered") into {kind, name} - shared by #render's rescue and #evaluate_value!'s rescue (the two Crinja entry points that can surface an unregistered filter/test at evaluation time), so both react to the same feature kinds the same way.

Source

Instance methods

evaluate_value!(expr : String) : JSON::Any | Nil

Evaluates expr (bare Jinja expression text, no surrounding {{ }}) and returns its RAW structured result as JSON::Any (nil for a genuinely undefined result - the same nilable convention VariableLookup#resolve/#resolve_simple/etc. already use) instead of #render!'s always-a-String output.

#render! goes through Template#render, which always produces a String via Crinja::Finalizer#stringify - fine for a FINAL {{ }} substitution, but wrong for a caller (like ExpressionEvaluator's Crinja-delegation branches) that needs to hand the result to something else expecting structured data (another filter, a .get() call, a nested nested expression) or that wants to format an Array/Hash result through THIS codebase's own VariableLookup#format_value (its JSON-compact style, not Crinja's Python-repr Finalizer style) so the existing internal "render sub-expression to a String, JSON. parse it back into structured data" round trip used throughout expression_evaluator.cr/filter_engine.cr/comparison_ evaluator.cr/variable_lookup.cr keeps working unchanged - the general filter-chain dispatch investigation found this was necessary (a naive format_value Python-repr rewrite broke that round trip outright since Python-repr text isn't valid JSON).

Parses via Crinja::Parser::ExpressionLexer/ExpressionParser directly (bypassing Template/from_string entirely - there is no template TAG here, just a bare expression) and Crinja::Environment#evaluate(ast_node, bindings) : Value (lib/crinja/src/environment.cr:106-108), the overload that returns the raw Value rather than a stringified result - mirrors the fork's own spec_helper.cr#evaluate_expression_raw test helper, which uses the identical parse-then-evaluate sequence for the same reason (getting at the raw value, not Crinja's own stringified rendering of it).

Source
evaluate_value_once!(expr : String) : JSON::Any | Nil
Source
render(text : String) : String

Render a template containing Jinja2 control structures

Source
render!(text : String) : String

Render a template containing Jinja2 control structures, raising on any failure instead of swallowing it - for a caller (like ExpressionEvaluator's own Crinja-delegation branches) that wants to fall back to a DIFFERENT rendering strategy on failure, rather than #render's own "give back the original unrendered text" behavior, which would be actively wrong for a caller expecting a real evaluated value.

Source