modules/template/sluz.zzm

template-sluz-0.0.1 source code

Package

Name
template-sluz
Version
0.0.1
Uploaded
2026-07-09 22:36:42
Dependencies
Metadata
zuzu-distribution.json
Archive
Download .tar.gz
=encoding utf8

=head1 NAME

template/sluz - Smarty-like templates for ZuzuScript.

=head1 SYNOPSIS

  from template/sluz import Sluz;

  const s := new Sluz();
  s.assign("name", "Ada");
  s.assign_all({ items: [ "one", "two" ] });
  s.add_modifiers(trim: function (v) { return trim("" _ v); });

  say( s.parse_string("Hello {$name|escape}") );

  const t := s.compile_string("Hello {$name|escape}");
  say( t.render({ name: "Ada" }) );
  say( t.render({ name: "Bob" }) );

=head1 DESCRIPTION

C<template/sluz> is a ZuzuScript port of Scott Baker's
C<Template::Sluz> template language. It keeps the familiar Sluz syntax
for variables, modifiers, conditionals, loops, includes, literals, and
comments, but uses explicit lexical modifier registration instead of
looking up Perl subs in C<main::>.

=head1 DEFAULT MODIFIERS

Each new C<Sluz> instance registers these modifiers by default:

=over

=item C<escape>

Escape scalar output. The default mode is C<html>, which escapes
ampersands, angle brackets, and quotes. C<escape:"xml"> uses XML entity
names, C<escape:"url"> percent-encodes the value for a URL component,
and C<escape:"js"> emits a quoted JavaScript string literal.

=item C<noescape>

Return the value unchanged and mark the expression as explicitly not
requiring automatic escaping.

=item C<raw>

Alias for C<noescape>.

=item C<default>

Return a fallback value when the input is C<null> or an empty C<String>.

=item C<count>

Return the size of an C<Array>, C<Bag>, C<Set>, C<Dict>, or C<PairList>,
or a scalar count for other values.

=item C<join>

Join an C<Array>, C<Bag>, or C<Set> with a separator, defaulting to
C<", ">. C<Array> order is preserved. C<Bag> and C<Set> output order is
not guaranteed. Other values are stringified.

=item C<uc>

Uppercase the value. Alias: C<upper>.

=item C<lc>

Lowercase the value. Alias: C<lower>.

=item C<upper>

Uppercase the value.

=item C<lower>

Lowercase the value.

=item C<ucfirst>

Uppercase the first character of the value.

=item C<length>

Return the string length of the value.

=item C<substr>

Return a substring using C<substr(value, start, length?)> semantics.

=item C<replace>

Replace all occurrences of a string.

=item C<first>

Return the first element of an C<Array>, or the first character of a
scalar value.

=item C<last>

Return the last element of an C<Array>, or the last character of a
scalar value.

=item C<trim>

Trim leading and trailing whitespace.

=item C<normalize>

Trim leading and trailing whitespace, then replace each internal run of
whitespace with a single space character.

=back

C<s.add_modifiers> can override any of these. C<s.unset_modifiers> removes
ordinary modifiers, but C<escape>, C<noescape>, and C<raw> are reset to
identity functions instead of becoming undefined.

=head1 EXPORTS

=head2 Classes

=over

=item C<< Sluz({ auto_escape?: Boolean|String, base_dir?: Path|Array }) >>

Constructs a renderer. C<auto_escape> defaults to false. If true, it uses
HTML escaping. It may also be C<html>, C<xml>, C<url>, or C<js>. C<base_dir>
controls relative template and include lookup. It may be a single
C<std/io::Path> or an ordered C<Array> of C<Path> objects; the first
existing file wins.

=over

=item C<< s.assign(String name, value) >>

Assign one template variable.

=item C<< s.assign_all(Dict|PairList values) >>

Assign several template variables.

=item C<< s.add_modifiers(... PairList modifiers) >>

Register named modifiers or callable functions. Registered modifiers
override built-ins, including C<escape>.

=item C<< s.unset_modifiers(String name, ...) >>

Remove modifiers. Removing C<escape>, C<noescape>, or C<raw> replaces
that modifier with an identity function so explicit and automatic escaping
hooks remain defined.

=item C<< s.compile(Path|String file) >>

Load and parse a template file immediately, returning a C<CompiledTemplate>.
The root file is not re-read by later C<render> calls. C<parent_tpl> is a
C<parse> feature and is not applied by C<compile>.

=item C<< s.compile_string(String template) >>

Parse a template string immediately, returning a C<CompiledTemplate>.

=item C<< s.parse_string(String template) >>

Render a template string.

=item C<< s.parse(String file, String parent?) >>

Render a template file. If C<parent> or C<s.parent_tpl()> is set, the
parent template is rendered with C<__CHILD_TPL> bound to the child file.

=item C<< s.fetch(...) >>

Alias for C<parse>.

=item C<< s.display(...) >>

Prints the result of C<parse>.

=item C<< s.display_string(String template) >>

Prints the result of C<parse_string>.

=item C<< s.parent_tpl() >>, C<< s.parent_tpl(value) >>

Get or set the default parent template.

=item C<< s.set_delimiters(String open, String close) >>

Set one-character template delimiters.

=back

=item C<< CompiledTemplate >>

A parsed template returned by C<Sluz.compile> or C<Sluz.compile_string>.

=over

=item C<< t.render(Dict|PairList values?) >>

Render the compiled template. Values passed to C<render> temporarily overlay
the variables assigned on the owning C<Sluz> instance; missing variables fall
back to values set with C<assign> and C<assign_all>. If the same key appears
more than once in a C<PairList>, the later value wins.

Compiled templates use the owning C<Sluz> instance's current modifiers,
C<auto_escape>, C<base_dir>, and fallback assignments at render time. The
template delimiters are frozen at compile time and are used for the root
template and any compiled includes.

Includes are resolved at render time, so dynamic include expressions may
select different files on different renders. Each resolved include file is
parsed on first use and cached by path for that compiled template; later
renders reuse the cached parsed include and do not observe changes to that
included file.

=back

=back

=head1 ERRORS

C<template/sluz> throws structured exceptions. All module exceptions inherit
from C<SluzException>, which inherits from C<Exception>. C<SluzException>
provides C<get_code()> and C<get_details()> accessors; C<get_code()> returns
the stored numeric legacy Sluz error code when the exception represents a
template-language error, and C<null> otherwise. C<get_details()> currently
returns an empty C<Dict>. The raw C<message> slot stores only the bare error
message; C<to_String> adds the C<template/sluz> prefix and optional code.

The hierarchy is:

=over

=item C<SluzException>

Base class for all exceptions thrown by this module.

=over

=item C<SluzUsageException>

Errors caused by invalid API usage or renderer configuration.

=over

=item C<SluzArgumentException>

Invalid argument shapes, such as passing something other than C<Dict> or
C<PairList> to C<assign_all> or C<CompiledTemplate.render>.

=item C<SluzConfigurationException>

Invalid renderer options, such as C<auto_escape> or C<base_dir> values.

=over

=item C<SluzDelimiterException>

Invalid delimiter configuration.

=back

=item C<SluzLoadException>

Template path lookup and loading errors.

=back

=item C<SluzTemplateException>

Errors found while parsing or rendering Sluz template content.

=over

=item C<SluzSyntaxException>

Template syntax and block structure errors.

=item C<SluzExpressionException>

Template expression parsing and evaluation errors.

=item C<SluzIncludeException>

Invalid include tags.

=item C<SluzModifierException>

Unknown or invalid modifiers.

=over

=item C<SluzEscapeException>

Invalid escaping modes.

=back

=item C<SluzInternalException>

Unexpected internal renderer states.

=back

=back

=back

C<to_String> returns the same text as stringifying the exception, using
module-prefixed strings such as C<template/sluz: ...> or
C<template/sluz error #18933: ...>.

=head1 COPYRIGHT AND LICENCE

This port is copyright Toby Inkster.

It is based on Template::Sluz by Scott Baker and is free software under
the terms of the GNU General Public License version 3 or later.

=cut

from std/io import Path;
from std/string import contains, index, join, ord, replace, split,
	starts_with, substr, trim;

const _INLINE_TEMPLATE := "INLINE_TEMPLATE";

function _sluz_bare_exception_message ( message ) {
	const text := "" _ message;
	const usage_prefix := "template/sluz: ";
	if ( starts_with( text, usage_prefix ) ) {
		return substr( text, #usage_prefix );
	}
	const template_prefix := "template/sluz error #";
	if ( starts_with( text, template_prefix ) ) {
		const colon := index( text, ": " );
		return substr( text, colon + 2 ) if colon >= 0;
	}
	return text;
}

class SluzException extends Exception {
	let code := null;
	let details := {};

	method __build__ () {
		self{message} := _sluz_bare_exception_message( self{message} );
	}

	method get_code () {
		return code;
	}

	method get_details () {
		return details == null ? {} : details;
	}

	method to_String () {
		const text := "" _ self{message};
		return `template/sluz error #${code}: ${text}` if code != null;
		return "template/sluz" if text eq "";
		return "template/sluz: " _ text;
	}
}

class SluzUsageException extends SluzException;
class SluzArgumentException extends SluzUsageException;
class SluzConfigurationException extends SluzUsageException;
class SluzDelimiterException extends SluzConfigurationException;
class SluzLoadException extends SluzUsageException;
class SluzTemplateException extends SluzException;
class SluzSyntaxException extends SluzTemplateException;
class SluzExpressionException extends SluzTemplateException;
class SluzIncludeException extends SluzTemplateException;
class SluzModifierException extends SluzTemplateException;
class SluzEscapeException extends SluzModifierException;
class SluzInternalException extends SluzTemplateException;

function _sluz_starts_at ( String text, Number pos, String needle ) {
	return substr( text, pos, #needle ) eq needle;
}

function _sluz_ch ( String text, Number pos ) {
	return substr( text, pos, 1 );
}

function _sluz_ltrim_one ( String text, String ch ) {
	if ( #text > 0 and substr( text, 0, 1 ) eq ch ) {
		return substr( text, 1 );
	}
	return text;
}

function _sluz_is_space ( String ch ) {
	return ch eq " " or ch eq "\t" or ch eq "\n" or ch eq "\r";
}

function _sluz_trim ( String text ) {
	return trim(text);
}

function _sluz_is_name_start ( String ch ) {
	return ch ~ /^[A-Za-z_]$/;
}

function _sluz_is_name_char ( String ch ) {
	return ch ~ /^[A-Za-z0-9_]$/;
}

function _sluz_is_numeric_text ( value ) {
	return false if value == null;
	return true if value instanceof Number;
	return true if value instanceof Boolean;
	return ( "" _ value ) ~ /^-?[0-9]+(?:\.[0-9]+)?$/;
}

function _sluz_to_number ( value ) {
	return 0 if value == null;
	return value if value instanceof Number;
	return value ? 1 : 0 if value instanceof Boolean;
	const text := "" _ value;
	return 0 if text eq "";
	return +text if text ~ /^-?[0-9]+(?:\.[0-9]+)?$/;
	return 0;
}

function _sluz_truthy ( value ) {
	return false if value == null;
	return value if value instanceof Boolean;
	return value != 0 if value instanceof Number;
	return value ne "" if value instanceof String;
	return #value > 0 if value instanceof Array;
	return #value > 0 if value instanceof Dict;
	return #value > 0 if value instanceof PairList;
	return true;
}

function _sluz_value_string ( value ) {
	return "" if value == null;
	return value ? "1" : "" if value instanceof Boolean;
	if ( value instanceof Number ) {
		const rounded := round( value * 1000000000000 ) / 1000000000000;
		return "" _ rounded;
	}
	return "ARRAY" if value instanceof Array;
	return "HASH" if value instanceof Dict or value instanceof PairList;
	return "" _ value;
}

function _sluz_html_escape ( value ) {
	let text := _sluz_value_string(value);
	text := replace( text, /&/, "&amp;", "g" );
	text := replace( text, /</, "&lt;", "g" );
	text := replace( text, />/, "&gt;", "g" );
	text := replace( text, /"/, "&quot;", "g" );
	text := replace( text, /'/, "&#x27;", "g" );
	return text;
}

function _sluz_xml_escape ( value ) {
	let text := _sluz_value_string(value);
	text := replace( text, /&/, "&amp;", "g" );
	text := replace( text, /</, "&lt;", "g" );
	text := replace( text, />/, "&gt;", "g" );
	text := replace( text, /"/, "&quot;", "g" );
	text := replace( text, /'/, "&apos;", "g" );
	return text;
}

const _SLUZ_HEX := "0123456789ABCDEF";

function _sluz_hex_byte ( Number byte ) {
	const n := int(byte);
	return substr( _SLUZ_HEX, int( n / 16 ) mod 16, 1 )
		_ substr( _SLUZ_HEX, n mod 16, 1 );
}

function _sluz_hex4 ( Number code ) {
	const n := int(code);
	return _sluz_hex_byte( int( n / 256 ) mod 256 )
		_ _sluz_hex_byte( n mod 256 );
}

function _sluz_percent_byte ( Number byte ) {
	return "%" _ _sluz_hex_byte(byte);
}

function _sluz_percent_codepoint ( Number code ) {
	if ( code < 128 ) {
		return _sluz_percent_byte(code);
	}
	if ( code < 2048 ) {
		return _sluz_percent_byte( 192 + int( code / 64 ) )
			_ _sluz_percent_byte( 128 + ( code mod 64 ) );
	}
	if ( code < 65536 ) {
		return _sluz_percent_byte( 224 + int( code / 4096 ) )
			_ _sluz_percent_byte( 128 + ( int( code / 64 ) mod 64 ) )
			_ _sluz_percent_byte( 128 + ( code mod 64 ) );
	}
	return _sluz_percent_byte( 240 + int( code / 262144 ) )
		_ _sluz_percent_byte( 128 + ( int( code / 4096 ) mod 64 ) )
		_ _sluz_percent_byte( 128 + ( int( code / 64 ) mod 64 ) )
		_ _sluz_percent_byte( 128 + ( code mod 64 ) );
}

function _sluz_url_escape ( value ) {
	const text := _sluz_value_string(value);
	let out := "";
	let i := 0;
	while ( i < #text ) {
		const ch := substr( text, i, 1 );
		if ( ch ~ /^[A-Za-z0-9._~-]$/ ) {
			out _= ch;
		}
		else {
			out _= _sluz_percent_codepoint( ord( text, i ) );
		}
		i++;
	}
	return out;
}

function _sluz_js_escape_codepoint ( Number code ) {
	if ( code < 65536 ) {
		return "\\u" _ _sluz_hex4(code);
	}
	const offset := code - 65536;
	const high := 55296 + int( offset / 1024 );
	const low := 56320 + ( offset mod 1024 );
	return "\\u" _ _sluz_hex4(high) _ "\\u" _ _sluz_hex4(low);
}

function _sluz_js_escape ( value ) {
	const text := _sluz_value_string(value);
	let out := "\"";
	let i := 0;
	while ( i < #text ) {
		const ch := substr( text, i, 1 );
		const code := ord( text, i );
		if ( ch eq "\"" ) {
			out _= "\\u0022";
		}
		else if ( ch eq "'" ) {
			out _= "\\u0027";
		}
		else if ( ch eq "<" ) {
			out _= "\\u003C";
		}
		else if ( ch eq ">" ) {
			out _= "\\u003E";
		}
		else if ( ch eq "&" ) {
			out _= "\\u0026";
		}
		else if ( ch eq "\\" ) {
			out _= "\\\\";
		}
		else if ( ch eq "/" ) {
			out _= "\\/";
		}
		else if ( code == 8 ) {
			out _= "\\b";
		}
		else if ( code == 9 ) {
			out _= "\\t";
		}
		else if ( code == 10 ) {
			out _= "\\n";
		}
		else if ( code == 12 ) {
			out _= "\\f";
		}
		else if ( code == 13 ) {
			out _= "\\r";
		}
		else if ( code < 32 or code > 126 ) {
			out _= _sluz_js_escape_codepoint(code);
		}
		else {
			out _= ch;
		}
		i++;
	}
	return out _ "\"";
}

function _sluz_escape ( value, mode := "html" ) {
	const m := lc( "" _ mode );
	return _sluz_html_escape(value) if m eq "html";
	return _sluz_xml_escape(value) if m eq "xml";
	return _sluz_url_escape(value) if m eq "url";
	return _sluz_js_escape(value) if m eq "js";
	throw new SluzEscapeException( message: `unknown escape mode '${mode}'` );
}

function _sluz_auto_escape_mode ( value ) {
	return null if value == null;
	if ( value instanceof Boolean ) {
		return value ? "html" : null;
	}
	if ( value instanceof String ) {
		const mode := lc(value);
		return mode if mode eq "html" or mode eq "xml"
			or mode eq "url" or mode eq "js";
	}
	throw new SluzConfigurationException(
		message: "auto_escape expects Boolean, html, xml, url, or js",
	);
}

function _sluz_identity ( value, ... Array ignored ) {
	return value;
}

function _sluz_count_value ( value ) {
	return 0 if value == null;
	return #value if value instanceof Array;
	return #value if value instanceof Bag;
	return #value if value instanceof Set;
	return #value if value instanceof Dict;
	return #value if value instanceof PairList;
	return 1;
}

function _sluz_split_top ( String text, String sep ) {
	const out := [];
	let start := 0;
	let i := 0;
	let quote := "";
	let escape := false;
	let depth := 0;
	while ( i < #text ) {
		const ch := _sluz_ch( text, i );
		if ( escape ) {
			escape := false;
			i++;
			next;
		}
		if ( ch eq "\\" ) {
			escape := true;
			i++;
			next;
		}
		if ( quote ne "" ) {
			if ( ch eq quote ) {
				quote := "";
			}
			i++;
			next;
		}
		if ( ch eq "\"" or ch eq "'" ) {
			quote := ch;
			i++;
			next;
		}
		if ( ch eq "(" or ch eq "[" ) {
			depth++;
			i++;
			next;
		}
		if ( ch eq ")" or ch eq "]" ) {
			depth-- if depth > 0;
			i++;
			next;
		}
		if ( depth == 0 and _sluz_starts_at( text, i, sep ) ) {
			out.push( substr( text, start, i - start ) );
			i += #sep;
			start := i;
			next;
		}
		i++;
	}
	out.push( substr( text, start ) );
	return out;
}

function _sluz_find_top ( String text, String needle ) {
	let i := 0;
	let quote := "";
	let escape := false;
	let depth := 0;
	while ( i < #text ) {
		const ch := _sluz_ch( text, i );
		if ( escape ) {
			escape := false;
			i++;
			next;
		}
		if ( ch eq "\\" ) {
			escape := true;
			i++;
			next;
		}
		if ( quote ne "" ) {
			if ( ch eq quote ) {
				quote := "";
			}
			i++;
			next;
		}
		if ( ch eq "\"" or ch eq "'" ) {
			quote := ch;
			i++;
			next;
		}
		if ( ch eq "(" or ch eq "[" ) {
			depth++;
			i++;
			next;
		}
		if ( ch eq ")" or ch eq "]" ) {
			depth-- if depth > 0;
			i++;
			next;
		}
		return i if depth == 0 and _sluz_starts_at( text, i, needle );
		i++;
	}
	return -1;
}

function _sluz_unquote ( String text ) {
	const t := _sluz_trim(text);
	return t if #t < 2;
	const first := substr( t, 0, 1 );
	const final := substr( t, #t - 1, 1 );
	return t unless ( first eq "\"" and final eq "\"" ) or ( first eq "'" and final eq "'" );
	const raw := substr( t, 1, #t - 2 );
	let out := "";
	let i := 0;
	while ( i < #raw ) {
		const ch := _sluz_ch( raw, i );
		if ( ch eq "\\" and i + 1 < #raw ) {
			const escaped := _sluz_ch( raw, i + 1 );
			if ( escaped eq "n" ) {
				out _= "\n";
			}
			else if ( escaped eq "t" ) {
				out _= "\t";
			}
			else if ( escaped eq "r" ) {
				out _= "\r";
			}
			else {
				out _= escaped;
			}
			i += 2;
			next;
		}
		out _= ch;
		i++;
	}
	return out;
}

function _sluz_map_has ( map, String key ) {
	return map.exists(key) if map instanceof Dict;
	return map.has(key) if map instanceof PairList;
	return false;
}

function _sluz_map_get ( map, String key, fallback := null ) {
	return map.get(key) if map instanceof Dict and map.exists(key);
	return map.get(key) if map instanceof PairList and map.has(key);
	return fallback;
}

class _SluzParser {
	const Object owner;
	const String source;
	const String open;
	const String close;

	method parse () {
		return self._parse_nodes( 0, [] ){nodes};
	}

	method _tag_at ( Number pos ) {
		return null unless _sluz_starts_at( source, pos, open );
		const end := self._find_tag_close(pos + #open);
		return null if end < 0;
		const inner := substr( source, pos + #open, end - pos - #open );
		return {
			inner: _sluz_trim(inner),
			raw_inner: inner,
			open_pos: pos,
			close_pos: end,
			after: end + #close,
		};
	}

	method _find_tag_close ( Number pos ) {
		let cursor := pos;
		let quote := "";
		let escape := false;
		while ( cursor < #source ) {
			const ch := _sluz_ch( source, cursor );
			if ( escape ) {
				escape := false;
				cursor++;
				next;
			}
			if ( ch eq "\\" ) {
				escape := true;
				cursor++;
				next;
			}
			if ( quote ne "" ) {
				if ( ch eq quote ) {
					quote := "";
				}
				cursor++;
				next;
			}
			if ( ch eq "\"" or ch eq "'" ) {
				quote := ch;
				cursor++;
				next;
			}
			if ( _sluz_starts_at( source, cursor, close ) ) {
				if ( close eq ">" and cursor > 0 and substr( source, cursor - 1, 1 ) eq "=" ) {
					cursor += #close;
					next;
				}
				return cursor;
			}
			cursor++;
		}
		return -1;
	}

	method _is_stop ( String inner, Array stops ) {
		for ( const stop in stops ) {
			if ( stop eq "elseif" and starts_with( inner, "elseif " ) ) {
				return true;
			}
			return true if inner eq stop;
		}
		return false;
	}

	method _find_literal_close ( Number pos ) {
		const open_tag := open _ "literal" _ close;
		const close_tag := open _ "/literal" _ close;
		let cursor := pos;
		let depth := 1;
		while ( cursor < #source ) {
			const next_open := index( source, open_tag, cursor );
			const next_close := index( source, close_tag, cursor );
			owner._syntax_error( "Missing closing literal tag", 45821 ) if next_close < 0;
			if ( next_open >= 0 and next_open < next_close ) {
				depth++;
				cursor := next_open + #open_tag;
				next;
			}
			depth--;
			return next_close if depth == 0;
			cursor := next_close + #close_tag;
		}
		owner._syntax_error( "Missing closing literal tag", 45821 );
	}

	method _parse_nodes ( Number pos, Array stops ) {
		const nodes := [];
		let cursor := pos;
		while ( cursor < #source ) {
			const next_open := index( source, open, cursor );
			if ( next_open < 0 ) {
				nodes.push( { type: "text", text: substr( source, cursor ) } )
					if cursor < #source;
				return { nodes: nodes, pos: #source, stop: null };
			}

			const tag := self._tag_at(next_open);
			if ( tag == null or tag{inner} eq "" ) {
				if ( tag == null and next_open + #open < #source ) {
					const after_open := substr( source, next_open + #open, 1 );
					owner._syntax_error( "Unclosed block", 45821 )
						unless _sluz_is_space(after_open);
				}
				nodes.push( { type: "text", text: substr( source, cursor, next_open - cursor + #open ) } );
				cursor := next_open + #open;
				next;
			}

			if ( self._is_stop( tag{inner}, stops ) ) {
				nodes.push( { type: "text", text: substr( source, cursor, next_open - cursor ) } )
					if next_open > cursor;
				return { nodes: nodes, pos: next_open, stop: tag{inner} };
			}

			nodes.push( { type: "text", text: substr( source, cursor, next_open - cursor ) } )
				if next_open > cursor;

			const inner := tag{inner};
			if ( inner eq "literal" ) {
				const lit_end := self._find_literal_close(tag{after});
				nodes.push( {
					type: "text",
					text: substr( source, tag{after}, lit_end - tag{after} ),
				} );
				cursor := lit_end + #(open _ "/literal" _ close);
				next;
			}

			if ( starts_with( inner, "if " ) ) {
				const parsed_if := self._parse_if( tag{after}, _sluz_trim( substr( inner, 3 ) ) );
				nodes.push(parsed_if{node});
				cursor := parsed_if{pos};
				next;
			}

			if ( starts_with( inner, "foreach " ) ) {
				const parsed_foreach := self._parse_foreach(
					tag{after},
					_sluz_trim( substr( inner, 8 ) ),
				);
				nodes.push(parsed_foreach{node});
				cursor := parsed_foreach{pos};
				next;
			}

			if ( starts_with( inner, "include" ) ) {
				nodes.push( {
					type: "include",
					spec: _sluz_trim( substr( inner, 7 ) ),
				} );
				cursor := tag{after};
				next;
			}

			if ( inner eq "/if" or inner eq "/foreach" or inner eq "else"
				or starts_with( inner, "elseif " ) ) {
				owner._syntax_error( "Unexpected closing tag", 73467 );
			}

			nodes.push( { type: "expr", expr: inner } );
			cursor := tag{after};
		}
		return { nodes: nodes, pos: cursor, stop: null };
	}

	method _parse_if ( Number pos, String first_cond ) {
		const branches := [];
		let cond := first_cond;
		let cursor := pos;
		while ( true ) {
			const parsed := self._parse_nodes( cursor, [ "elseif", "else", "/if" ] );
			const branch_nodes := parsed{nodes};
			if ( #branch_nodes > 0 and branch_nodes[0]{type} eq "text" ) {
				branch_nodes[0]{text} := _sluz_ltrim_one( branch_nodes[0]{text}, "\n" );
			}
			branches.push( { condition: cond, nodes: branch_nodes } );

			owner._syntax_error( "Missing closing if tag", 45821 ) if parsed{stop} == null;
			const tag := self._tag_at(parsed{pos});
			if ( parsed{stop} eq "/if" ) {
				return {
					node: { type: "if", branches: branches },
					pos: tag{after},
				};
			}
			if ( parsed{stop} eq "else" ) {
				const else_parsed := self._parse_nodes( tag{after}, [ "/if" ] );
				const else_nodes := else_parsed{nodes};
				if ( #else_nodes > 0 and else_nodes[0]{type} eq "text" ) {
					else_nodes[0]{text} := _sluz_ltrim_one( else_nodes[0]{text}, "\n" );
				}
				branches.push( { condition: null, nodes: else_nodes } );
				owner._syntax_error( "Missing closing if tag", 45821 ) if else_parsed{stop} == null;
				const end_tag := self._tag_at(else_parsed{pos});
				return {
					node: { type: "if", branches: branches },
					pos: end_tag{after},
				};
			}
			cond := _sluz_trim( substr( parsed{stop}, 7 ) );
			cursor := tag{after};
		}
	}

	method _parse_foreach ( Number pos, String spec ) {
		const parsed := self._parse_nodes( pos, [ "/foreach" ] );
		owner._syntax_error( "Missing closing foreach tag", 45821 ) if parsed{stop} == null;
		const end_tag := self._tag_at(parsed{pos});
		const body := parsed{nodes};
		if ( #body > 0 and body[0]{type} eq "text" ) {
			body[0]{text} := _sluz_ltrim_one( body[0]{text}, "\n" );
		}
		return {
			node: {
				type: "foreach",
				spec: spec,
				nodes: body,
			},
			pos: end_tag{after},
		};
	}
}

class _SluzExpr {
	const Object owner;
	const String source;
	const Array tokens := [];
	let Number pos := 0;

	method __build__ () {
		for ( const token in self._tokenize(source) ) {
			tokens.push(token);
		}
	}

	method parse () {
		const value := self._parse_or();
		self._expect_end();
		return value;
	}

	method _tokenize ( String text ) {
		const out := [];
		let i := 0;
		while ( i < #text ) {
			const ch := _sluz_ch( text, i );
			if ( _sluz_is_space(ch) ) {
				i++;
				next;
			}

			if ( ch eq "\"" or ch eq "'" ) {
				const quote := ch;
				const start := i;
				i++;
				let escape := false;
				while ( i < #text ) {
					const c := _sluz_ch( text, i );
					if ( escape ) {
						escape := false;
						i++;
						next;
					}
					if ( c eq "\\" ) {
						escape := true;
						i++;
						next;
					}
					if ( c eq quote ) {
						i++;
						last;
					}
					i++;
				}
				out.push( { kind: "string", value: _sluz_unquote( substr( text, start, i - start ) ) } );
				next;
			}

			if ( ch ~ /^[0-9]$/ or ( ch eq "." and i + 1 < #text and _sluz_ch( text, i + 1 ) ~ /^[0-9]$/ ) ) {
				const start := i;
				i++;
				while ( i < #text and _sluz_ch( text, i ) ~ /^[0-9.]$/ ) {
					i++;
				}
				out.push( { kind: "number", value: +substr( text, start, i - start ) } );
				next;
			}

			if ( ch eq "$" ) {
				const start := i + 1;
				i++;
				owner._expression_error( "Invalid variable expression", 18933 )
					if i >= #text or not _sluz_is_name_start( _sluz_ch( text, i ) );
				while ( i < #text ) {
					const c := _sluz_ch( text, i );
					if ( _sluz_is_name_char(c) or c eq "." ) {
						i++;
						next;
					}
					if ( c eq "[" ) {
						const close := index( text, "]", i + 1 );
						owner._expression_error( "Invalid bracket lookup", 18933 ) if close < 0;
						i := close + 1;
						next;
					}
					last;
				}
				out.push( { kind: "var", value: substr( text, start, i - start ) } );
				next;
			}

			if ( _sluz_is_name_start(ch) ) {
				const start := i;
				i++;
				while ( i < #text and _sluz_is_name_char( _sluz_ch( text, i ) ) ) {
					i++;
				}
				out.push( { kind: "ident", value: substr( text, start, i - start ) } );
				next;
			}

			const two := i + 1 < #text ? substr( text, i, 2 ) : "";
			if ( [ "&&", "||", "==", "!=", "<=", ">=" ].contains(two) ) {
				out.push( { kind: "op", value: two } );
				i += 2;
				next;
			}

			if ( [ "!", "<", ">", "+", "-", "*", "/", "%", "(", ")", "," ].contains(ch) ) {
				out.push( { kind: "op", value: ch } );
				i++;
				next;
			}

			owner._expression_error( `Unexpected expression token '${ch}'`, 18933 );
		}
		return out;
	}

	method _peek () {
		return pos < #tokens ? tokens[pos] : null;
	}

	method _take () {
		const t := self._peek();
		pos++;
		return t;
	}

	method _take_op ( String op ) {
		const t := self._peek();
		if ( t != null and t{kind} eq "op" and t{value} eq op ) {
			pos++;
			return true;
		}
		return false;
	}

	method _take_ident ( String ident ) {
		const t := self._peek();
		if ( t != null and t{kind} eq "ident" and t{value} eq ident ) {
			pos++;
			return true;
		}
		return false;
	}

	method _expect_op ( String op ) {
		owner._expression_error( `Expected '${op}'`, 18933 ) unless self._take_op(op);
	}

	method _expect_end () {
		owner._expression_error( "Unexpected trailing expression tokens", 18933 )
			if self._peek() != null;
	}

	method _parse_or () {
		let left := self._parse_and();
		while ( true ) {
			if ( self._take_op("||") or self._take_ident("or") ) {
				const right := self._parse_and();
				left := _sluz_truthy(left) or _sluz_truthy(right);
				next;
			}
			return left;
		}
	}

	method _parse_and () {
		let left := self._parse_eq();
		while ( true ) {
			if ( self._take_op("&&") or self._take_ident("and") ) {
				const right := self._parse_eq();
				left := _sluz_truthy(left) and _sluz_truthy(right);
				next;
			}
			return left;
		}
	}

	method _parse_eq () {
		let left := self._parse_cmp();
		while ( true ) {
			if ( self._take_op("==") ) {
				const right := self._parse_cmp();
				left := _sluz_to_number(left) == _sluz_to_number(right);
				next;
			}
			if ( self._take_op("!=") ) {
				const right := self._parse_cmp();
				left := _sluz_to_number(left) != _sluz_to_number(right);
				next;
			}
			if ( self._take_ident("eq") ) {
				const right := self._parse_cmp();
				left := _sluz_value_string(left) eq _sluz_value_string(right);
				next;
			}
			if ( self._take_ident("ne") ) {
				const right := self._parse_cmp();
				left := _sluz_value_string(left) ne _sluz_value_string(right);
				next;
			}
			return left;
		}
	}

	method _parse_cmp () {
		let left := self._parse_add();
		while ( true ) {
			if ( self._take_op(">") ) {
				const right := self._parse_add();
				left := _sluz_to_number(left) > _sluz_to_number(right);
				next;
			}
			if ( self._take_op("<") ) {
				const right := self._parse_add();
				left := _sluz_to_number(left) < _sluz_to_number(right);
				next;
			}
			if ( self._take_op(">=") ) {
				const right := self._parse_add();
				left := _sluz_to_number(left) >= _sluz_to_number(right);
				next;
			}
			if ( self._take_op("<=") ) {
				const right := self._parse_add();
				left := _sluz_to_number(left) <= _sluz_to_number(right);
				next;
			}
			return left;
		}
	}

	method _parse_add () {
		let left := self._parse_mul();
		while ( true ) {
			if ( self._take_op("+") ) {
				left := _sluz_to_number(left) + _sluz_to_number( self._parse_mul() );
				next;
			}
			if ( self._take_op("-") ) {
				left := _sluz_to_number(left) - _sluz_to_number( self._parse_mul() );
				next;
			}
			return left;
		}
	}

	method _parse_mul () {
		let left := self._parse_unary();
		while ( true ) {
			if ( self._take_op("*") ) {
				left := _sluz_to_number(left) * _sluz_to_number( self._parse_unary() );
				next;
			}
			if ( self._take_op("/") ) {
				left := _sluz_to_number(left) / _sluz_to_number( self._parse_unary() );
				next;
			}
			if ( self._take_op("%") ) {
				left := _sluz_to_number(left) mod _sluz_to_number( self._parse_unary() );
				next;
			}
			return left;
		}
	}

	method _parse_unary () {
		if ( self._take_op("!") ) {
			return not _sluz_truthy( self._parse_unary() );
		}
		if ( self._take_op("-") ) {
			return -_sluz_to_number( self._parse_unary() );
		}
		return self._parse_primary();
	}

	method _parse_primary () {
		const t := self._take();
		owner._expression_error( "Missing expression", 18933 ) if t == null;
		if ( t{kind} eq "number" or t{kind} eq "string" ) {
			return t{value};
		}
		if ( t{kind} eq "var" ) {
			return owner._lookup( t{value} );
		}
		if ( t{kind} eq "op" and t{value} eq "(" ) {
			const value := self._parse_or();
			self._expect_op(")");
			return value;
		}
		if ( t{kind} eq "ident" ) {
			const name := t{value};
			return true if name eq "true";
			return false if name eq "false";
			return null if name eq "null";
			if ( self._take_op("(") ) {
				const args := [];
				if ( not self._take_op(")") ) {
					while ( true ) {
						args.push( self._parse_or() );
						if ( self._take_op(")") ) {
							last;
						}
						self._expect_op(",");
					}
				}
				return owner._call_modifier( name, args, true );
			}
			owner._expression_error( `Unknown bare word '${name}'`, 73467 );
		}
		owner._expression_error( "Invalid expression", 18933 );
	}
}

class CompiledTemplate {
	const Object owner;
	const Array nodes := [];
	const source_dir := null;
	const String open_delim := "{";
	const String close_delim := "}";
	const include_cache := {};

	method render ( values := null ) {
		return owner._render_compiled( self, values );
	}

	method _render_include ( raw_file ) {
		const path := owner._resolve_path(raw_file);
		const key := owner._cache_path_key(path);
		if ( not include_cache.exists(key) ) {
			include_cache.set(
				key,
				new CompiledTemplate(
					owner: owner,
					nodes: owner._parse_template_nodes(
						path.slurp_utf8(),
						open_delim,
						close_delim,
					),
					source_dir: path.parent(),
					open_delim: open_delim,
					close_delim: close_delim,
					include_cache: include_cache,
				),
			);
		}
		return owner._render_compiled_template( include_cache.get(key) );
	}
}

class Sluz {
	const auto_escape := false;
	const base_dir := null;
	let String open_delim := "{";
	let String close_delim := "}";
	const tpl_vars := {};
	const modifiers := {};
	let _parent_tpl := null;
	let _render_dir := null;

	method __build__ () {
		self._register_builtin_modifiers();
		_sluz_auto_escape_mode(auto_escape);
	}

	method _register_builtin_modifiers () {
		modifiers.set( "escape", function (value, mode := "html") {
			return _sluz_escape( value, mode );
		} );
		modifiers.set( "noescape", _sluz_identity );
		modifiers.set( "raw", _sluz_identity );
		modifiers.set( "default", function (value, fallback := "") {
			return fallback if value == null;
			return fallback if value instanceof String and value eq "";
			return value;
		} );
		modifiers.set( "count", function (value) {
			return null if value == null;
			return _sluz_count_value(value);
		} );
		modifiers.set( "join", function (value, glue := ", ") {
			return null if value == null;
			return join( "" _ glue, value ) if value instanceof Array;
			return join( "" _ glue, value.to_Array() )
				if value instanceof Bag or value instanceof Set;
			return _sluz_value_string(value);
		} );
		const upper := function (value) {
			return null if value == null;
			return uc( _sluz_value_string(value) );
		};
		const lower := function (value) {
			return null if value == null;
			return lc( _sluz_value_string(value) );
		};
		modifiers.set( "uc", upper );
		modifiers.set( "upper", upper );
		modifiers.set( "lc", lower );
		modifiers.set( "lower", lower );
		modifiers.set( "ucfirst", function (value) {
			return null if value == null;
			const text := _sluz_value_string(value);
			return text if text eq "";
			return uc( substr( text, 0, 1 ) ) _ substr( text, 1 );
		} );
		modifiers.set( "length", function (value) {
			return null if value == null;
			return #(_sluz_value_string(value));
		} );
		modifiers.set( "substr", function (value, start, len? ) {
			return null if value == null;
			const text := _sluz_value_string(value);
			return substr( text, _sluz_to_number(start) ) if len == null;
			return substr( text, _sluz_to_number(start), _sluz_to_number(len) );
		} );
		modifiers.set( "replace", function (value, search, replacement := "") {
			return null if value == null;
			return replace(
				_sluz_value_string(value),
				"" _ search,
				"" _ replacement,
				"g",
			);
		} );
		modifiers.set( "first", function (value) {
			return null if value == null;
			return null if value instanceof Array and #value == 0;
			return value[0] if value instanceof Array;
			const text := _sluz_value_string(value);
			return "" if text eq "";
			return substr( text, 0, 1 );
		} );
		modifiers.set( "last", function (value) {
			return null if value == null;
			return null if value instanceof Array and #value == 0;
			return value[#value - 1] if value instanceof Array;
			const text := _sluz_value_string(value);
			return "" if text eq "";
			return substr( text, #text - 1 );
		} );
		modifiers.set( "trim", function (value) {
			return null if value == null;
			return trim( _sluz_value_string(value) );
		} );
		modifiers.set( "normalize", function (value) {
			return null if value == null;
			return replace( trim( _sluz_value_string(value) ), /\s+/, " ", "g" );
		} );
	}

	method assign ( String key, value ) {
		tpl_vars.set( key, value );
		return self;
	}

	method assign_all ( values ) {
		if ( values instanceof Dict ) {
			for ( const key in values.keys() ) {
				self.assign( key, values.get(key) );
			}
			return self;
		}
		if ( values instanceof PairList ) {
			for ( const pair in values.to_Array() ) {
				self.assign( pair.key, pair.value );
			}
			return self;
		}
		throw new SluzArgumentException(
			message: "assign_all expects Dict or PairList",
		);
	}

	method add_modifiers ( ... PairList funcs ) {
		for ( const pair in funcs.to_Array() ) {
			throw new SluzModifierException(
				message: `modifier '${pair.key}' expects Function`,
			)
				unless pair.value instanceof Function;
			modifiers.set( pair.key, pair.value );
		}
		return self;
	}

	method unset_modifiers ( ... Array names ) {
		for ( const name in names ) {
			throw new SluzModifierException(
				message: "unset_modifiers expects String names",
			)
				unless name instanceof String;
			if ( name eq "escape" or name eq "noescape" or name eq "raw" ) {
				modifiers.set( name, _sluz_identity );
			}
			else {
				modifiers.remove(name) if modifiers.exists(name);
			}
		}
		return self;
	}

	method parent_tpl ( ... Array args ) {
		if ( #args > 0 ) {
			_parent_tpl := args[0];
		}
		return _parent_tpl;
	}

	method set_delimiters ( String open, String close ) {
		throw new SluzDelimiterException(
			message: "set_delimiters requires one-character delimiters",
		)
			unless #open == 1 and #close == 1;
		throw new SluzDelimiterException(
			message: "delimiters must be different",
		) if open eq close;
		open_delim := open;
		close_delim := close;
		return self;
	}

	method parse_string ( String text := "" ) {
		return self._render_nodes(
			self._parse_template_nodes( text, open_delim, close_delim ),
			null,
		);
	}

	method compile ( path ) {
		const resolved := self._resolve_path(path);
		return new CompiledTemplate(
			owner: self,
			nodes: self._parse_template_nodes(
				resolved.slurp_utf8(),
				open_delim,
				close_delim,
			),
			source_dir: resolved.parent(),
			open_delim: open_delim,
			close_delim: close_delim,
			include_cache: {},
		);
	}

	method compile_string ( String text := "" ) {
		return new CompiledTemplate(
			owner: self,
			nodes: self._parse_template_nodes( text, open_delim, close_delim ),
			source_dir: null,
			open_delim: open_delim,
			close_delim: close_delim,
			include_cache: {},
		);
	}

	method parse ( ... Array args ) {
		const tpl_file := #args > 0 ? args[0] : _INLINE_TEMPLATE;
		const parent := #args > 1 ? args[1] : _parent_tpl;
		if ( parent != null and parent ne "" ) {
			return self._with_values(
				{ __CHILD_TPL: self._resolve_path(tpl_file).absolute().to_String() },
				function () {
					return self._render_file(parent);
				},
			);
		}
		return self._render_file(tpl_file);
	}

	method fetch ( ... Array args ) {
		return self.parse(...args);
	}

	method display ( ... Array args ) {
		print self.parse(...args);
		return null;
	}

	method display_string ( String text := "" ) {
		print self.parse_string(text);
		return null;
	}

	method _render_file ( raw_file ) {
		const path := self._resolve_path(raw_file);
		const open := open_delim;
		const close := close_delim;
		return self._with_render_dir( path.parent(), function () {
			return self._render_nodes(
				self._parse_template_nodes(
					path.slurp_utf8(),
					open,
					close,
				),
				null,
			);
		} );
	}

	method _render_compiled ( compiled, values := null ) {
		throw new SluzArgumentException(
			message: "render expects Dict or PairList values",
		)
			unless values == null or values instanceof Dict or values instanceof PairList;
		if ( values == null ) {
			return self._render_compiled_template(compiled);
		}
		return self._with_values( values, function () {
			return self._render_compiled_template(compiled);
		} );
	}

	method _render_compiled_template ( compiled ) {
		return self._with_render_dir( compiled{source_dir}, function () {
			return self._render_nodes( compiled{nodes}, compiled );
		} );
	}

	method _with_render_dir ( dir, Function body ) {
		const old_dir := _render_dir;
		let result := null;
		let thrown := null;
		let failed := false;
		try {
			_render_dir := dir;
			result := body();
		}
		catch ( Any e ) {
			thrown := e;
			failed := true;
		}
		_render_dir := old_dir;
		throw thrown if failed;
		return result;
	}

	method _parse_template_nodes ( String text, String open, String close ) {
		const parser := new _SluzParser(
			owner: self,
			source: self._remove_comments_with_delimiters( text, open, close ),
			open: open,
			close: close,
		);
		return parser.parse();
	}

	method _cache_path_key ( path ) {
		let canonical := path.realpath();
		canonical := path.absolute() if canonical == null;
		return canonical instanceof Path ? canonical.to_String() : "" _ canonical;
	}

	method _base_paths () {
		if ( base_dir == null ) {
			return [ Path.cwd() ];
		}
		if ( base_dir instanceof Path ) {
			return [ base_dir ];
		}
		if ( base_dir instanceof Array ) {
			const out := [];
			for ( const item in base_dir ) {
				throw new SluzConfigurationException(
					message: "base_dir array expects Path values",
				)
					unless item instanceof Path;
				out.push(item);
			}
			return out;
		}
		throw new SluzConfigurationException(
			message: "base_dir expects Path or Array of Path",
		);
	}

	method _resolve_path ( raw_file ) {
		const file := "" _ raw_file;
		throw new SluzLoadException(
			message: "template file name is empty",
		) if file eq "";
		throw new SluzLoadException(
			message: "inline DATA templates are not supported",
		)
			if file eq _INLINE_TEMPLATE;
		const path := new Path(file);
		if ( path.is_absolute() ) {
			throw new SluzLoadException(
				message: `unable to load template file ${path}`,
			)
				unless path.exists() and path.is_file();
			return path;
		}

		const search := [];
		if ( _render_dir != null ) {
			search.push(_render_dir);
		}
		for ( const base in self._base_paths() ) {
			search.push(base);
		}

		for ( const base in search ) {
			const candidate := base.child(file);
			return candidate if candidate.exists() and candidate.is_file();
		}
		throw new SluzLoadException(
			message: `unable to load template file ${file}`,
		);
	}

	method _remove_comments ( String text ) {
		return self._remove_comments_with_delimiters(
			text,
			open_delim,
			close_delim,
		);
	}

	method _remove_comments_with_delimiters ( String text, String open, String close ) {
		let out := "";
		let pos := 0;
		const comment_open := open _ "*";
		const comment_close := "*" _ close;
		while ( pos < #text ) {
			const start := index( text, comment_open, pos );
			if ( start < 0 ) {
				out _= substr( text, pos );
				last;
			}
			const before := substr( text, pos, start - pos );
			const after_start := start + #comment_open;
			let depth := 1;
			let i := after_start;
			while ( i < #text and depth > 0 ) {
				if ( _sluz_starts_at( text, i, comment_open ) ) {
					depth++;
					i += #comment_open;
					next;
				}
				if ( _sluz_starts_at( text, i, comment_close ) ) {
					depth--;
					i += #comment_close;
					next;
				}
				i++;
			}
			self._syntax_error( "Missing closing comment tag", 48724 ) if depth > 0;
			let after := i;
			const own_line := ( start == 0 or substr( text, start - 1, 1 ) eq "\n" )
				and ( after >= #text or substr( text, after, 1 ) eq "\n" );
			out _= before;
			if ( own_line and after < #text ) {
				after++;
			}
			pos := after;
		}
		return out;
	}

	method _render_nodes ( Array nodes, compiled := null ) {
		let out := "";
		for ( const node in nodes ) {
			switch ( node{type} : eq ) {
				case "text":
					out _= node{text};
				case "expr":
					out _= self._render_expr( node{expr} );
				case "if":
					out _= self._render_if( node, compiled );
				case "foreach":
					out _= self._render_foreach( node, compiled );
				case "include":
					out _= self._render_include( node, compiled );
				default:
					self._internal_error( "Unknown parse node", 18933 );
			}
		}
		return out;
	}

	method _render_if ( node, compiled := null ) {
		for ( const branch in node{branches} ) {
			if ( branch{condition} == null or _sluz_truthy( self._eval_expr( branch{condition} ) ) ) {
				return self._render_nodes( branch{nodes}, compiled );
			}
		}
		return "";
	}

	method _parse_foreach_spec ( String spec ) {
		const parts := split( spec, " as " );
		self._syntax_error( "Invalid foreach block", 73467 ) if #parts != 2;
		const expr := _sluz_trim(parts[0]);
		const vars := _sluz_trim(parts[1]);
		let key_name := null;
		let value_name := null;
		if ( contains( vars, "=>" ) ) {
			const kv := split( vars, "=>" );
			self._syntax_error( "Invalid foreach variables", 73467 ) if #kv != 2;
			key_name := replace( _sluz_trim(kv[0]), /^\$/, "" );
			value_name := replace( _sluz_trim(kv[1]), /^\$/, "" );
		}
		else {
			key_name := replace( vars, /^\$/, "" );
		}
		self._syntax_error( "Invalid foreach source", 73467 ) unless starts_with( expr, "$" );
		self._syntax_error( "Invalid foreach variable", 73467 )
			unless key_name ~ /^[A-Za-z_][A-Za-z0-9_]*$/;
		self._syntax_error( "Invalid foreach variable", 73467 )
			if value_name != null and not ( value_name ~ /^[A-Za-z_][A-Za-z0-9_]*$/ );
		return {
			expr: expr,
			key_name: key_name,
			value_name: value_name,
		};
	}

	method _render_foreach ( node, compiled := null ) {
		const spec := self._parse_foreach_spec( node{spec} );
		const src := self._eval_expr( spec{expr} );
		return "" if src == null;

		const keys := [];
		const vals := [];
		if ( src instanceof Array ) {
			let i := 0;
			for ( const item in src ) {
				keys.push(i);
				vals.push(item);
				i++;
			}
		}
		else if ( src instanceof Dict ) {
			for ( const key in src.keys().sort( fn ( a, b ) -> a cmp b ) ) {
				keys.push(key);
				vals.push( src.get(key) );
			}
		}
		else if ( src instanceof PairList ) {
			for ( const key in src.keys().sort( fn ( a, b ) -> a cmp b ) ) {
				keys.push(key);
				vals.push( src.get(key) );
			}
		}
		else {
			keys.push(0);
			vals.push(src);
		}

		return "" if #vals == 0;

		const names := [
			spec{key_name},
			spec{value_name},
			"__FOREACH_FIRST",
			"__FOREACH_LAST",
			"__FOREACH_INDEX",
		];
		return self._with_saved_vars( names, function () {
			let out := "";
			const final := #vals - 1;
			let i := 0;
			while ( i < #vals ) {
				self.assign( "__FOREACH_FIRST", i == 0 );
				self.assign( "__FOREACH_LAST", i == final );
				self.assign( "__FOREACH_INDEX", i );
				if ( spec{value_name} == null ) {
					self.assign( spec{key_name}, vals[i] );
				}
				else {
					self.assign( spec{key_name}, keys[i] );
					self.assign( spec{value_name}, vals[i] );
				}
				out _= self._render_nodes( node{nodes}, compiled );
				i++;
			}
			return out;
		} );
	}

	method _include_parts ( String spec ) {
		const parts := _sluz_split_top( spec, " " );
		let file_expr := null;
		const assigns := {};
		for ( const raw in parts ) {
			const part := _sluz_trim(raw);
			next if part eq "";
			const eq_pos := _sluz_find_top( part, "=" );
			if ( eq_pos < 0 ) {
				file_expr := part if file_expr == null;
				next;
			}
			const key := _sluz_trim( substr( part, 0, eq_pos ) );
			const val := _sluz_trim( substr( part, eq_pos + 1 ) );
			if ( key eq "file" ) {
				file_expr := val;
			}
			else {
				assigns.set( key, val );
			}
		}
		self._include_error( "Unable to find a file in include block", 68493 )
			if file_expr == null;
		return { file: file_expr, assigns: assigns };
	}

	method _eval_include_value ( String expr ) {
		const t := _sluz_trim(expr);
		if ( ( starts_with( t, "\"" ) or starts_with( t, "'" ) ) and #t >= 2 ) {
			const inner := _sluz_unquote(t);
			if ( starts_with( inner, "$" ) and inner ~ /^\$[A-Za-z_][A-Za-z0-9_.\[\]'""]*$/ ) {
				return self._eval_expr(inner);
			}
			return inner;
		}
		return self._eval_expr(t);
	}

	method _render_include ( node, compiled := null ) {
		const spec := self._include_parts( node{spec} );
		const file := self._eval_include_value( spec{file} );
		const values := {};
		for ( const name in spec{assigns}.keys() ) {
			values.set( name, self._eval_include_value( spec{assigns}.get(name) ) );
		}
		return self._with_values( values, function () {
			return compiled == null
				? self._render_file(file)
				: compiled._render_include(file);
		} );
	}

	method _with_values ( values, Function body ) {
		throw new SluzArgumentException(
			message: "values must be a Dict or PairList",
		)
			unless values instanceof Dict or values instanceof PairList;
		const saved := self._save_vars( self._value_names(values) );
		let result := null;
		let thrown := null;
		let failed := false;
		try {
			self._assign_temp_values(values);
			result := body();
		}
		catch ( Any e ) {
			thrown := e;
			failed := true;
		}
		self._restore_vars(saved);
		throw thrown if failed;
		return result;
	}

	method _with_saved_vars ( Array names, Function body ) {
		const saved := self._save_vars(names);
		let result := null;
		let thrown := null;
		let failed := false;
		try {
			result := body();
		}
		catch ( Any e ) {
			thrown := e;
			failed := true;
		}
		self._restore_vars(saved);
		throw thrown if failed;
		return result;
	}

	method _value_names ( values ) {
		const names := [];
		if ( values instanceof Dict ) {
			for ( const key in values.keys() ) {
				names.push(key);
			}
		}
		else {
			for ( const pair in values.to_Array() ) {
				names.push(pair.key);
			}
		}
		return names;
	}

	method _assign_temp_values ( values ) {
		if ( values instanceof Dict ) {
			for ( const key in values.keys() ) {
				self.assign( key, values.get(key) );
			}
		}
		else {
			for ( const pair in values.to_Array() ) {
				self.assign( pair.key, pair.value );
			}
		}
	}

	method _save_vars ( Array names ) {
		const saved := {};
		for ( const name in names ) {
			next if name == null;
			next if saved.exists(name);
			saved.set( name, {
				had: tpl_vars.exists(name),
				value: tpl_vars.exists(name) ? tpl_vars.get(name) : null,
			} );
		}
		return saved;
	}

	method _restore_vars ( saved ) {
		for ( const name in saved.keys() ) {
			const state := saved.get(name);
			if ( state{had} ) {
				tpl_vars.set( name, state{value} );
			}
			else {
				tpl_vars.remove(name);
			}
		}
	}

	method _render_expr ( String expr ) {
		const pieces := _sluz_split_top( expr, "|" );
		let value := self._eval_expr( pieces[0] );
		let bypass_auto := false;
		let i := 1;
		while ( i < #pieces ) {
			const modspec := _sluz_trim(pieces[i]);
			const colon := _sluz_find_top( modspec, ":" );
			const name := colon < 0 ? modspec : _sluz_trim( substr( modspec, 0, colon ) );
			const args := [];
			if ( colon >= 0 ) {
				for ( const arg in _sluz_split_top( substr( modspec, colon + 1 ), "," ) ) {
					args.push( self._eval_expr( _sluz_trim(arg) ) );
				}
			}
			bypass_auto := true
				if name eq "escape" or name eq "noescape" or name eq "raw";
			const call_args := [ value ];
			for ( const arg in args ) {
				call_args.push(arg);
			}
			value := self._call_modifier( name, call_args, false );
			i++;
		}
		const auto_mode := _sluz_auto_escape_mode(auto_escape);
		if ( auto_mode != null and not bypass_auto ) {
			value := auto_mode eq "html"
				? self._call_modifier( "escape", [ value ], false )
				: self._call_modifier( "escape", [ value, auto_mode ], false );
		}
		return _sluz_value_string(value);
	}

	method _eval_expr ( String expr ) {
		const parser := new _SluzExpr( owner: self, source: _sluz_trim(expr) );
		return parser.parse();
	}

	method _call_modifier ( String name, Array args, Boolean from_call ) {
		if ( name eq "count" and from_call ) {
			return _sluz_count_value( #args > 0 ? args[0] : null );
		}
		self._modifier_error( `Unknown modifier '${name}'`, 18933 ) unless modifiers.exists(name);
		const func := modifiers.get(name);
		return func(...args);
	}

	method _lookup ( String path ) {
		const parts := self._lookup_parts(path);
		return null if #parts == 0;
		let value := tpl_vars.exists(parts[0]) ? tpl_vars.get(parts[0]) : null;
		let i := 1;
		while ( i < #parts ) {
			return null if value == null;
			const key := parts[i];
			if ( value instanceof Array ) {
				return null unless ( "" _ key ) ~ /^-?[0-9]+$/;
				let idx := int(key);
				idx := #value + idx if idx < 0;
				return null if idx < 0 or idx >= #value;
				value := value[idx];
			}
			else if ( value instanceof Dict ) {
				return null unless value.exists("" _ key);
				value := value.get("" _ key);
			}
			else if ( value instanceof PairList ) {
				return null unless value.has("" _ key);
				value := value.get("" _ key);
			}
			else {
				return null;
			}
			i++;
		}
		return value;
	}

	method _lookup_parts ( String raw ) {
		const parts := [];
		let i := 0;
		let start := 0;
		while ( i < #raw ) {
			const ch := _sluz_ch( raw, i );
			if ( ch eq "." ) {
				parts.push( substr( raw, start, i - start ) );
				i++;
				start := i;
				next;
			}
			if ( ch eq "[" ) {
				if ( i > start ) {
					parts.push( substr( raw, start, i - start ) );
				}
				const close := index( raw, "]", i + 1 );
				self._expression_error( "Invalid bracket lookup", 18933 ) if close < 0;
				const inner := _sluz_trim( substr( raw, i + 1, close - i - 1 ) );
				parts.push( _sluz_unquote(inner) );
				i := close + 1;
				start := i;
				next;
			}
			i++;
		}
		if ( start < #raw ) {
			parts.push( substr( raw, start ) );
		}
		return parts;
	}

	method _syntax_error ( String message, Number code ) {
		throw new SluzSyntaxException( message: message, code: code );
	}

	method _expression_error ( String message, Number code ) {
		throw new SluzExpressionException( message: message, code: code );
	}

	method _include_error ( String message, Number code ) {
		throw new SluzIncludeException( message: message, code: code );
	}

	method _modifier_error ( String message, Number code ) {
		throw new SluzModifierException( message: message, code: code );
	}

	method _internal_error ( String message, Number code ) {
		throw new SluzInternalException( message: message, code: code );
	}
}