Icecream / .NET 8
Implementation reference
The current Icecream C# implementation takes precedence. This reference records its syntax, rendering rules and limitations.
Scope and terminology
This is a descriptive reference for the current .NET 8 implementation, not a promise of a broader v1 grammar. Where older website wording differs, the implementation takes precedence.
- Icedoc
- An Icecream template document, conventionally stored as a
.icefile and passed to the renderer as a string. - Projection
- The output string.
- ProjectionEngine
- The C# parser/renderer, distinct from a host that executes an action described by the output.
- EvalContext
- The model stack, shared variables and resolver used while rendering.
- Resolver
- An implementation of IIceDocResolver that supplies named template text.
Design intent
Keep documents readable, composition explicit and formatting separate from downstream execution. Predictability is the design goal. The actual permissive/error cases are listed below; do not infer validation or safety guarantees from the word “strict”.
Input and output
Render(string icedoc, object model) creates a fresh context. Render(string template, EvalContext context) reuses the supplied context and replaces its resolver with the engine’s resolver. Every call parses the template again; there is no compiled-template cache or asynchronous render API. The parser treats a null template as empty.
File extensions and encodings are host conventions, not enforced by the string API. Output is text regardless of whether it resembles JSON, HTML or a command.
Lexical forms
Ordinary lines are scanned for <{ and the next unquoted }> on that same line. Expression contents are trimmed. An unclosed token is copied as literal text, including the original newline. Backslash escapes render delimiters literally; see comments and literal notation.
A trimmed line starting with <{#} is parsed as a directive; a trimmed line equal to {#}> closes a block. Structural lines are consumed, including their line endings. Put no output text on those lines. Directive matching uses case-insensitive prefixes in several places, so unsupported suffixes and trailing text are not consistently rejected for block directives. Standalone includes validate the invocation and require exactly one closing marker on the same line. Use the exact spellings and forms shown here.
Strict missing-value checks
The default remains permissive. Enable RenderOptions.StrictMissingValues per render, or construct an EvalContext with those options. The setting is inherited by included documents and does not mutate a shared ProjectionEngine. The playground exposes the same option as a checkbox.
var options = new RenderOptions { StrictMissingValues = true };
var output = engine.Render(icedoc, model, options, "invoice.ice");
// Or: var context = new EvalContext(model, options);
// engine.Render(icedoc, context, "invoice.ice");
Absent variables and properties raise MissingValueException, whose Path identifies the requested path. A present null value remains valid: Note | default('None') works if Note exists and is null. A missing Note still raises an error before default runs. Traversing through null, such as Note.Name, also fails. Explicit null literals and an absent parent scope at the top level remain valid null values.
Strict lookup tries variables, current-model properties, then root properties only when the current property is absent. A present null property shadows the root. The permissive mode retains its existing null-to-root fallback. Strict model scope handles are case-insensitive; lowercase is recommended for compatibility with permissive mode. Checks apply when expressions are evaluated, including conditions, loops and includes; unselected branches are not evaluated. This option is not schema validation or a complete syntax-validation pass.
Model scopes and binding
#root: the first model on the stack.#this: the current model on top of the stack.#parent: the previous model, or null when there is none.
Only includes push a model during normal rendering; loops bind variables without changing the stack. Use lowercase scope handles: recognition starts case-insensitively, but the final selector is case-sensitive, so #ROOT.Name currently resolves to null.
Property lookup supports public instance properties via reflection, non-generic IDictionary, Newtonsoft JObject/JToken, System.Text.Json JsonObject/JsonNode and object-valued JsonElement. Keys/properties are matched case-insensitively. Public fields, index expressions, ExpandoObject property bags and dictionaries implementing only generic interfaces are not supported by this lookup path. JSON scalars are unwrapped when encountered as property values; the new collection filters and loops also unwrap JSON scalar elements into CLR values.
Variables and resolution order
Single- or double-quoted strings, null, booleans and invariant decimal numeric literals are resolved before property lookup. Otherwise the expression is split on dots, trimming segments and removing empty segments. Explicit scope handles select a model. For ordinary paths, the first segment checks a case-insensitive variable dictionary, then the current model, then the root if the current lookup returned null. A variable present with a null value still shadows model properties. Remaining segments follow properties without further root fallback.
set requires a leading #; loop item names do not. Captures live in the shared context until overwritten or removed. Variable “push” overwrites a dictionary entry and “pop” removes it; neither public operation restores a previous value. Loops separately save and restore their item binding and metadata, including on exceptions.
Lowercase #root, #this and #parent are rejected by set. The reserved-name check is case-sensitive; other casing variants are not consistently protected or usable. Avoid every casing variant. #loop is reserved case-insensitively for loop metadata and cannot be assigned by set or used as a loop item name.
Directive semantics
set(#name)
Render the body into a temporary string, normalize CRLF and CR to LF, and store it in the shared variable dictionary. Emit no text directly. Leading/trailing body whitespace is retained.
<{#} set(#message)
Hello <{Name}>.
{#}>forEach(collection:item)
Evaluate the collection path and iterate non-generic IEnumerable or a JsonElement array. Bind each element to the item name and render the body. Order follows the collection. A null, missing or non-enumerable value produces no output and no error. Strings iterate Unicode text elements (graphemes); JsonArray, JArray and JsonElement arrays are supported. Scalar JSON elements are unwrapped. Collection expressions here do not accept filter pipelines.
#loop.index is zero-based; #loop.first and #loop.last are booleans. Nested loops expose the enclosing metadata through #loop.parent. Includes can read the surrounding metadata. After a loop, its previous item binding and metadata are restored, even after an exception. Missing prior bindings are removed. Enumeration uses one-item lookahead to determine last, without buffering or enumerating twice; it disposes enumerators on success and failure.
<{#} forEach(Items:i)
<{i.Name}>
{#}>if / elseIf / else
Choose the first true branch, or else. Nesting is supported. There is one closing marker for the whole chain. else() is also accepted.
<{#} if(Enabled == true)
Enabled
<{#} elseIf(Status != 'Closed')
Open
<{#} else
Closed
{#}>include(templateName, modelExpression)
Evaluate the second argument; null raises an error before resolver lookup. Pass the trimmed first argument literally to the resolver, push the model, recursively parse/render the included template in the same context, append its output, then restore the model stack in a finally block on both success and failure. Quotes in the name are retained. Exactly one same-line closing marker is required. It completes the include, not a surrounding block. Missing markers, malformed or empty arguments, duplicate markers and trailing text are parse errors, including inside an unselected conditional branch.
<{#} include(QuoteLine, i) {#}>Include is a standalone directive, not a block. Its closing marker must be on the invocation line. A following standalone closing line closes its surrounding block, if one exists; otherwise it is an orphan-end error. Model-stack cleanup is guaranteed for normal managed exceptions. There is no include recursion/cycle limit or resolver cache.
Expressions and conditions
Values support property paths, variables, lowercase scope handles, single- or double-quoted strings, booleans, null, and signed decimal numbers with optional exponents within the .NET decimal range. Numeric literals use a dot regardless of culture. Indexing, general method calls, OR and null-coalescing are not implemented. Basic arithmetic and aggregate filters are supported; see arithmetic and numeric summaries. Bare if(Enabled) remains unsupported; write if(Enabled == true).
Conditions support ==, !=, >, >=, < and <=. Both operands may use filter pipelines: if(Items | count > 0). Ordering requires numeric values, not numeric strings or null. Two numeric operands compare numerically; integer and decimal comparisons preserve decimal precision. Comparisons involving float/double use double precision; non-finite numbers raise an error. Other equality comparisons retain ordinal case-insensitive text comparison. Two nulls compare equal; null differs from an empty string.
Combine checks with &&, evaluated left to right with short-circuiting. Separators and operators inside quotes are literal text. Empty conjunction segments, missing operands and chained comparisons raise errors. An empty whole condition remains false.
Emptiness predicates
empty(value) and notEmpty(value) accept one value expression, including a filter pipeline. Null, an empty string, an empty enumerable collection, and an empty JSON object are empty. Whitespace, false, zero and ordinary non-enumerable objects are not empty. Predicates may stand alone, combine with &&, or compare to a boolean. Equivalent empty and notEmpty filters return booleans.
<{#} if(notEmpty(Items) && Total >= 100)
Ready
<{#} elseIf(empty(Items))
No items
{#}>Arithmetic and numeric summaries
Use +, -, * and / in substitutions and condition operands. Parentheses group calculations; unary plus and minus are supported. Multiplication and division bind before addition and subtraction; operators at the same level evaluate left to right. Division is fractional: 1 / 2 gives 0.5. Addition is numeric, not string concatenation.
<{Quantity * UnitPrice | number('0.00')}>
<{(Completed / Total * 100) | number('0.0')}>%
<{#loop.index + 1}>
<{(Items | sum('Amount')) - Paid | number('0.00')}>
Arithmetic runs before the surrounding filters. To use a filtered result in another calculation, surround its pipeline with parentheses: (Items | sum('Amount')) - Paid. Filter arguments remain literal strings; sum('Quantity * Price') is not an expression selector. Arithmetic is available in inline expressions and condition operands; forEach collection paths and include model arguments retain their existing value lookup.
sum, avg, min and max
Each filter accepts zero arguments for numeric items, or one item-relative dotted property path, such as sum('Amount') or avg('Price.Net'). Property names are case-insensitive. Selectors never fall back to root properties or loop variables. They accept identifier segments containing letters, digits or underscores, beginning with a letter or underscore.
CLR collections, JsonArray, JArray and JsonElement arrays are supported. Null collections are treated as empty. An empty sum is decimal zero; empty avg/min/max return null, so Scores | avg | default('N/A') is useful. Null items, missing selected properties, strings (including numeric strings), booleans and nonnumeric items raise an error. No items are silently skipped. A string or scalar is not an aggregate collection, even if the string is empty.
avg is the ordinary arithmetic mean, not a weighted average. Aggregates enumerate once and dispose the enumerator, including on failure; they do not buffer the collection. Repeated aggregate expressions need a repeatable data source.
Precision and errors
Integer and decimal arithmetic produces decimal results. An operation involving float/double produces double results. Aggregates accumulate in decimal until a float/double is encountered, then use double; min/max follow the same type promotion. Numeric literals use invariant decimal notation. Operations use .NET numeric precision and rounding; no currency rounding is applied automatically. Apply number or currency after calculating. Currency formatting requires a decimal result; double results can use number.
Division by zero, decimal overflow, non-finite inputs and infinite floating-point results raise errors. An average sums first, so its intermediate sum can overflow even if the mathematical average would fit. Floating-point calculations can round or underflow according to .NET double rules. Parenthesized expression nesting is limited to 128 levels. The playground displays arithmetic failures as render errors.
For migration: unquoted +, -, *, / and parentheses now carry expression meaning. A property key such as unit-price can no longer be used as a plain inline path; expose an identifier such as UnitPrice instead. Keep literal text quoted. Out-of-range numeric literals in expressions now raise overflow errors. Newtonsoft numeric properties retain their underlying numeric type, including decimal precision.
Filter reference
Filters apply left to right. Names are case-insensitive. Pipes and argument commas split only outside quotes and parentheses. Arguments are literal strings, not evaluated model expressions; unquoted arguments such as 10 are also strings. Use single or double quotes for commas, pipes, parentheses or empty strings. Quoted strings support backslash, quote, newline, carriage-return and tab escapes: \\, \', \", \n, \r, \t. Unknown escape sequences retain their backslash. Malformed quoting, parentheses, empty pipeline stages and unquoted empty arguments raise errors. New filters validate argument counts; older filters retain their existing permissive arity. The registry remains a fixed switch.
| Filter | Current behaviour |
|---|---|
upper / lower | Invariant uppercase / lowercase of the value’s text. |
title | Invariant TextInfo title casing; existing uppercase words may stay uppercase. |
trim | Remove leading and trailing whitespace. |
nospaces | Remove ASCII space characters only; tabs and newlines remain. |
pascal / camel | Split words using separators, camel/Pascal and acronym boundaries, normalize case, and join. HTTPServer_name becomes HttpServerName / httpServerName. Unicode letters and digits are retained. |
kebab | Use the same word boundaries as camel/pascal; join lowercase words with hyphens. HTTPServer_name becomes http-server-name. |
snake | Use the same word boundaries as camel/pascal; join lowercase words with underscores. HTTPServer_name becomes http_server_name. |
plural | Use a finite irregular dictionary (person/people, child/children, mouse/mice, analysis/analyses and others), invariant words such as sheep/equipment, then simple es/ies/s suffix rules. Preserve uppercase and title case, and mixed-case regular identifiers. Recognized irregular plurals stay unchanged. This is not a complete English inflector; unknown already-plural words can receive another suffix. |
default('fallback') | Return the first argument when the input text is null or empty; otherwise return that text. Whitespace is not empty. |
date('yyyy-MM-dd') | Format DateTime input using the first argument; default yyyy-MM-dd. Uses current culture. Other types pass through. |
number('0.00') | Call IFormattable.ToString with the first argument and invariant culture. Other types pass through. |
currency('en-AU') | Format decimal input with currency format C and the named culture; default en-AU. Other numeric types pass through. |
json / jsonc | Serialize with System.Text.Json, indented / compact. JsonNode is serialized directly; JSON-looking strings pass through unchanged; null becomes null. |
jsonString / jsonStringc | Escape string contents without quotes. For JsonNode or other non-string values, serialize indented / compact first. String input is not serialized first. Null becomes the text null. |
replace('old', 'new') | Exactly two arguments. Ordinal, case-sensitive replacement; an empty search string is rejected. Null becomes empty text. |
join(', ') | Exactly one separator. Join enumerable items using invariant scalar formatting; null items become empty strings. Strings are sequences of Unicode text elements. |
count | No arguments. Count collection items or Unicode text elements in a string. Null counts as zero. |
first / last | No arguments. Return the first/last collection item or string text element, preserving its value type for the next filter. Empty or null input returns null. |
empty / notEmpty | No arguments. Return a boolean using the emptiness rules above; these are also available as condition predicates. |
html | No arguments. HTML-encode text, including ampersands, angle brackets and quotes. Null becomes empty. Apply explicitly when output is used as HTML text or a quoted attribute; this does not validate URLs or encode JavaScript/CSS. |
urlEncode | No arguments. Encode one URL component using Uri.EscapeDataString; spaces become %20. Do not apply to an entire URL. Null becomes empty. |
truncate(40, '…') | One length and optional suffix; default suffix is … . The non-negative integer limit includes the suffix. Count Unicode text elements, preserving emoji and combining sequences. Text within the limit is unchanged; zero returns empty; an oversized suffix is clipped to fit. An empty suffix is allowed. |
sum / sum('Amount') | Sum numeric items or a selected property. Empty or null collections give decimal zero. |
avg / avg('Amount') | Arithmetic mean of numeric items or a selected property. Empty or null collections give null. |
min / min('Amount') | Lowest numeric value or selected property. Empty or null collections give null. |
max / max('Amount') | Highest numeric value or selected property. Empty or null collections give null. |
Collection filters support CLR enumerables, JsonArray, JArray and JsonElement arrays, and unwrap scalar JSON items. Null is an empty sequence. Non-enumerable scalar input raises an error for count/join/first/last. Enumerable operations can consume lazy sources: use a repeatable collection if an icedoc reads it more than once.
JSON string escaping handles backslashes, quotes, LF, tabs, backspace, form-feed and control characters below U+0020. CRLF/CR normalize to LF before escaping. Other characters pass through. jsonStringc does not compact an ordinary string.
json/jsonc pass a string through if its first non-whitespace character is { or [, even when it is invalid JSON. Newtonsoft tokens have no dedicated serialization path here; use System.Text.Json nodes or CLR data when JSON serialization fidelity matters.
Whitespace and conversion to text
Literal content keeps its original LF/CRLF/CR line endings. Directive-only lines disappear. Captures and JSON string escaping normalize line endings to LF. Included output is appended without added indentation, separators or newlines.
An unfiltered null appends nothing. Other values are appended through their string representation; booleans may render as True/False and CLR numeric/date formatting can depend on current culture. Use JSON filters for JSON values, and explicit formatting filters where applicable. There is no automatic HTML, SQL, shell or URL escaping.
Errors and permissive cases
Errors you can expect
- Unknown filters when evaluated.
- Unknown directives in the general block parser.
- Missing block endings and orphan closing/conditional lines.
- Invalid set names and lowercase reserved names.
- Missing same-line include markers, malformed include arguments and trailing include text.
- Some invalid block-directive argument forms.
- Bare conditions such as if(Enabled).
- Null include models; resolver errors propagate.
- Reflection, serialization and formatting failures.
- Invalid arithmetic operands, division by zero, overflow, and invalid aggregate items/selectors.
Cases that can be silent
- Missing properties render empty in the default permissive mode; strict mode raises an error.
- Non-enumerable collections skip the loop.
- Unclosed inline tokens remain literal text.
- Unknown directive lines inside conditional bodies may be ignored.
- Block-directive prefix matching and trailing text are not fully validated.
- Unknown filters in a branch that never renders are not evaluated.
Parser errors and failures during engine rendering report the document name and one-based line/column. Positions identify the failing token or directive start, not necessarily the individual operator. Columns count UTF-16 code units; LF, CRLF and CR are handled without altering source locations. Included documents report their resolver key as the document name; the default top-level name is <icedoc>, or supply your own name to Render.
try { engine.Render(icedoc, model, options, "invoice.ice"); }
catch (Exception error)
{
SourceLocation? location = IcecreamDiagnostics.GetLocation(error);
Console.WriteLine(error.Message);
}Common language exception types are retained, with the original failure in InnerException. Unexpected host getter/resolver failures are wrapped in IcecreamRenderException. Cancellation and out-of-memory exceptions propagate without annotation. GetLocation returns null for an unlocated exception. Nodes rendered manually through Render instead of the engine do not automatically add location information.
Include model-stack cleanup and loop binding cleanup run in finally blocks, even after parsing, rendering or resolver failures. This restores scope, not the whole render transaction: earlier captures and host side effects are not rolled back. Prefer a fresh context after a failed render if partial captures matter. A passing render does not imply a valid target document.
Host responsibilities
The language has no network, filesystem, process-launch or AI-call directives. Rendering still invokes model getters, collection enumeration, serialization and the host’s resolver. Those are application code, not a sandbox. Expose only intended model data and trusted accessors; bound resolver access and resource use. See security and integration guidance.
Executable examples
The example catalogue supplies complete inputs and uses this website’s referenced Icecream library to render outputs. Its expected outputs are checked by the accompanying verification project. The playground uses the same library with JSON input and a bundled QuoteLine include.
Compatibility and current limits
Standalone include syntax now requires <{#} include(Name, Model) {#}>. This is a breaking syntax change for unclosed includes: add the closing marker on the same line and retain any surrounding block endings. Includes are currently the only standalone directive. Block syntax is unchanged.
The filter expansion changes acronym/separator normalization, irregular plurals and numeric equality. Numeric, null and double-quoted literals are now recognized before property names. Strings in loops now iterate Unicode text elements rather than UTF-16 code units. Nested loops restore shadowed item bindings. Malformed quoted arguments and empty conjunction segments now raise errors. Review existing icedocs that depended on those earlier behaviours.
This reference documents observed behaviour. It does not promote accidental permissiveness into a recommended syntax or guarantee identical behaviour in another implementation. Use canonical examples, regression-test your own icedocs, and check changes to the library before upgrading.
There is no built-in schema validation, execution policy, permission system, maximum output size, cancellation, recursion limit, thread-safe shared context or automatic encoding for target languages. Portability is a design aim; .NET type and culture behaviour still matter today.
