Icecream.ink

Icecream / .NET 8

Icecream documentation

A practical guide to the current C# implementation: supply data, compose an icedoc, and render text.

Render your first icedoc

Icecream is a .NET 8 class library. Add a project reference to Icecream.csproj from your application; these instructions do not assume a published NuGet package. An icedoc is an Icecream template document, conventionally stored in a UTF-8 .ice file. The renderer receives its contents as a string. The host reads the file and supplies the model.

dotnet add YourApp.csproj reference path/to/Icecream.csproj
using Icecream;

var resolver = new TestIceDocResolver();
var engine = new ProjectionEngine(resolver);
var result = engine.Render("Hello <{Name | upper}>!", new { Name = "Ada" });
// Hello ADA!

TestIceDocResolver is the included in-memory resolver. You can use it for examples or implement IIceDocResolver for your own document store. A resolver is passed to the constructor even when a template has no includes.

Text, tokens and directive lines

Write text normally. Use <{ expression }> for a substitution on the same line. Directives start with <{#} at the beginning of a trimmed line; indentation before them is allowed. Put block opening, branch and closing directives on separate lines. A standalone directive, currently include, opens and closes on one line: <{#} include(QuoteLine, i) {#}>. Directive lines and their newlines do not appear in output.

Hello <{Customer.Name}>.
<{#} if(Enabled == true)
Your account is enabled.
{#}>

Filters use | and run left to right. Values and filter arguments are deliberately simple: use single- or double-quoted strings, property paths, numeric literals, null, and true/false. Quoted strings can contain commas and pipes. Use basic arithmetic directly in substitutions and comparisons; keep business policy and complex calculations in your model.

Comments, escaping and checks

Use <{! author note !}> for a same-line comment. A comment-only line disappears. For longer notes, use a comment block; its body is ignored until the first standalone closing marker.

<{#} comment
Explain the document here without printing these notes.
{#}>
Hello <{Name}>! <{! A greeting for the recipient. !}>

Write \<{Name}> to print <{Name}> literally. Escape directive openers and closing lines the same way. \\ emits one backslash. Comments do not nest; quoted strings use their existing escape rules.

Opt into strict missing-value checks when typos should fail. Explicit null values remain valid and can use default; missing properties fail even with default.

var options = new RenderOptions { StrictMissingValues = true };
var output = engine.Render(icedoc, model, options, "invoice.ice");

Errors include a document name, line and column identifying the failing token or directive. Included documents use their resolver name. Read structured positions with IcecreamDiagnostics.GetLocation(error). Failed includes restore the model stack, including nested includes; existing captures are not rolled back.

Try the strict-mode example and remove a property from its JSON to see the diagnostic. See the reference for escaping, strict lookup and cleanup details.

Models and lookup

Plain and anonymous C# objects expose public instance properties. Ordinary dictionaries implementing IDictionary, Newtonsoft JObject, System.Text.Json JsonObject, and object-valued JsonElement support property lookup. Property and variable names are case-insensitive; write reserved scope handles in lowercase.

  1. Reserved handles #root, #this and #parent select an explicit model.
  2. Otherwise the first path segment checks variables, then the current model, then the root if the current property is null or missing.
  3. Further dotted segments follow that result without another root fallback.

#root is the original model. #this is the current include model. #parent is the immediately preceding include model, or null at the top level. Loops do not change these handles. Access a loop item through its named variable, such as i.Name.

For JSON input, use JsonNode.Parse with an object root. JsonArray, JArray and JsonElement arrays support loops and collection filters. ExpandoObject and generic-only dictionary interfaces are not handled as property bags.

Conditions, loops and captures

Conditions

Use == or != for equality and >, >=, <, <= for numeric ordering. Combine checks with &&. Use empty(Items), notEmpty(Items) or Items | count > 0 to inspect collections. Bare if(Enabled) and OR are unsupported. Arithmetic can be used on either side of a comparison. String equality ignores case; ordering requires numeric values. The first matching branch wins, and one closing line terminates the entire chain.

<{#} if(Status == 'Approved' && Enabled == true)
Approved
<{#} elseIf(Status == 'Pending')
Pending
<{#} else
Other
{#}>

Loops

<{#} forEach(Items:i)
- <{i.Name}>
{#}>

The item name need not start with #. A missing, null or non-enumerable collection emits nothing. Use #loop.index (zero-based), #loop.first and #loop.last inside the body. Nested loops expose the outer metadata as #loop.parent and restore the outer item binding when finished, even when the same item name is reused. The #loop name is reserved.

Captures

<{#} set(#greeting)
Hello <{Name}>.
{#}>
<{#greeting | trim}>

set renders the body into a string and stores it without output. The name must start with #. Avoid all casing variants of the reserved scope handles. Captures are shared across the render context, including includes, and can be overwritten. Line endings normalize to LF; a final body newline is retained. Add trim when you want it removed.

Include a named document

The first argument is a literal resolver key, written without quotes. The second is a model expression that must resolve to a non-null value. Include is a standalone directive: its opening and closing markers must be on the same line.

resolver.Add("Greeting", "Hello <{Name}> from <{#root.Company}>.");
var text = engine.Render(
    "<{#} include(Greeting, Customer) {#}>",
    new { Company = "Example Co", Customer = new { Name = "Ada" } });
// Hello Ada from Example Co.

Each include pushes the passed model, renders the resolved document in the shared context, and restores the model stack on both success and failure. #parent refers to the caller’s current model; includes do not automatically make a surrounding loop item the parent.

The closing {#}> is required, not optional trailing text. Missing markers, duplicate markers and text after the completed directive raise parse errors. A closing marker on the next line cannot complete an include; after a complete include it belongs to a surrounding block or raises an orphan-end error. Quotes in a template name are passed through to the resolver; they are not stripped.

Migration: older documents using <{#} include(Name, Model) must add {#}> on that line. Keep any closing line needed by a surrounding loop, capture or conditional.

Format a value

Common forms include upper, lower, trim, nospaces, camel, pascal, kebab, snake and plural. Formatting filters include date, number and currency. Use replace and truncate for text, join, count, first, last, empty and notEmpty for collections, and explicit html or urlEncode for output encoding. Filter names ignore case; unknown names throw.

<{Title | trim | upper}>
<{Title | default('Untitled')}>
<{Total | number('0.00')}>
<{Total | currency('en-AU')}>
<{Tags | join(', ') | html}>
<{Summary | truncate(80) | html}>
<{Name | replace(',', ' | ')}>

default checks null or empty text, not whitespace. date requires a DateTime; currency requires a decimal. JSON strings are not automatically parsed as dates. Arguments are literal strings, not evaluated model expressions. See the complete filter reference for type and formatting details.

Calculate and summarise

Basic arithmetic supports +, -, *, /, parentheses and unary signs, with standard precedence. Filters apply after the calculation. Use parentheses around a filtered value when it participates in arithmetic.

Line total: <{i.Quantity * i.UnitPrice | currency('en-AU')}>
Total hours: <{Hours | sum}>
Average score: <{Scores | avg | number('0.0') | default('N/A')}>
Lowest: <{Scores | min | default('N/A')}>
Highest: <{Scores | max | default('N/A')}>
Balance: <{(Items | sum('Amount')) - Paid | number('0.00')}>

Aggregates accept numeric items or one property path such as 'Amount'. An empty sum is zero; empty avg/min/max return null. Missing properties, null items and nonnumeric values raise errors. Average means the ordinary arithmetic mean.

Integer/decimal inputs use decimal arithmetic; float/double inputs use double precision. Division by zero and overflow raise errors. Formatting happens afterwards; the engine does not apply financial rounding rules. See the numeric reference and the invoice playground example.

Build JSON deliberately

json serializes a value with indentation; jsonc uses compact output. A string whose first non-whitespace character is { or [ is passed through unchanged by either filter, without validation. For predictable JSON model behaviour, prefer System.Text.Json nodes.

jsonString escapes string contents without surrounding quotes; jsonStringc uses compact serialization before escaping non-string input. Both leave ordinary string spacing alone and normalize line endings to LF.

<{#} set(#system)
Explain clearly.
Return a JSON object.
{#}>
{"system":"<{#system | jsonString}>"}

A null input to jsonString produces the text null; inside quotes that becomes the JSON string "null". For a JSON null value use an unquoted json expression. Validate the completed output with a JSON parser and your application schema.

Use the projection

Generate reports, quote text, HTML source, configuration, or structured instructions. Keys such as gpt.prompt, sys.call, and capability.spec are application conventions. Icecream does not interpret them, call AI services or enforce their constraints.

The host may load new icedocs at runtime to change the projected content without rebuilding its renderer. Storage, approvals, output encoding, execution and audit belong to that host. Browse complete runnable examples.

Behaviour to account for

  • Use lowercase model scope handles. Loop metadata lives in the reserved #loop variable.
  • Keep tokens on one line and directives on separate lines.
  • Quote filter arguments containing commas or pipes; escape embedded matching quotes with a backslash. Use an explicitly quoted empty string for empty arguments.
  • Indexing, general method calls and general truthiness are not implemented. Emptiness differs from truthiness: zero, false and whitespace are not empty.
  • Missing values become empty text by default; strict mode reports absent properties. Not every malformed construct produces an error.
  • The library does not validate JSON/HTML or escape HTML, SQL, shell syntax or URLs automatically.
  • Failed includes restore model scopes, but earlier captures are not rolled back; use a fresh context when partial state matters. Do not share a mutable EvalContext between concurrent renders.

The reference describes current permissive cases. These are implementation limits, not features to build new templates around.