Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 0 additions & 39 deletions benchmarks/TextTemplate.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -1,46 +1,7 @@
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using TextTemplate;
using HandlebarsDotNet;
using Scriban;
using DotLiquid;
using ScribanTemplateClass = Scriban.Template;
using DotLiquidTemplateClass = DotLiquid.Template;
using Hbs = HandlebarsDotNet.Handlebars;

public class TemplateBenchmarks
{
private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}";
private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}";
private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}";
private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}";

private Dictionary<string, object> _model = null!;

[GlobalSetup]
public void Setup()
{
_model = new Dictionary<string, object>
{
["Name"] = "Bob",
["Items"] = new List<string> { "one", "two", "three", "four", "five" }
};
DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention();
}

[Benchmark]
public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model);

[Benchmark]
public string Handlebars() => Hbs.Compile(HBTemplate)(_model);

[Benchmark]
public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model);

[Benchmark]
public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model));
}

public class Program
{
Expand Down
39 changes: 39 additions & 0 deletions benchmarks/TextTemplate.Benchmarks/TemplateBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using BenchmarkDotNet.Attributes;
using TextTemplate;
using DotLiquid;
using ScribanTemplateClass = Scriban.Template;
using DotLiquidTemplateClass = DotLiquid.Template;
using Hbs = HandlebarsDotNet.Handlebars;

public class TemplateBenchmarks
{
private const string TTTemplate = "Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}";
private const string HBTemplate = "Hello {{Name}}! {{#each Items}}{{this}} {{/each}}";
private const string ScribanTmpl = "Hello {{name}}! {{ for item in items }}{{item}} {{end}}";
private const string DotLiquidTmpl = "Hello {{Name}}! {% for item in Items %}{{item}} {% endfor %}";

private Dictionary<string, object> _model = null!;

[GlobalSetup]
public void Setup()
{
_model = new Dictionary<string, object>
{
["Name"] = "Bob",
["Items"] = new List<string> { "one", "two", "three", "four", "five" }
};
DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention();
}

[Benchmark]
public string GoTextTemplate() => TemplateEngine.Process(TTTemplate, _model);

[Benchmark]
public string Handlebars() => Hbs.Compile(HBTemplate)(_model);

[Benchmark]
public string Scriban() => ScribanTemplateClass.Parse(ScribanTmpl).Render(_model);

[Benchmark]
public string DotLiquid() => DotLiquidTemplateClass.Parse(DotLiquidTmpl).Render(Hash.FromDictionary(_model));
}
69 changes: 37 additions & 32 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@

[![NuGet](https://img.shields.io/nuget/v/go-text-template.svg)](https://www.nuget.org/packages/go-text-template/)

This project is a C# implementation of Go's template engine using ANTLR for parsing. It began as an experiment to see whether OpenAI Codex could port the Go implementation to .NET. Claude.AI helped with explanations and refinements along the way.
The source code in this repository was largely produced by Codex with input
from Claude.AI, and this README itself was also authored using Codex.
text/template is a C# implementation of Go's template engine using ANTLR for parsing. It began as an experiment to see whether OpenAI Codex could port the Go implementation to .NET. Claude AI helped with explanations and refinements along the way, but the prompts were from my own knowledge of Antlr and grammars, and obviously C#.

It ported the Go code to begin with, but then I realised Antlr would probably be a safer and more readable alternative than a straight Go ---> C# port.

This README itself was also largely authored using Codex. Internally the engine uses an ANTLR-generated lexer and parser.

The original Go package can be found here:

- https://pkg.go.dev/text/template#pkg-overview
- https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/text/template/template.go

This library now contains virtually all functionality from the original Go text/template package. Parse templates with `Template.New("name").Parse(text)` and execute them with `Execute` to perform variable substitution, loops and conditionals. Internally the engine uses an ANTLR-generated lexer and parser.
This library now contains virtually all functionality from the original Go text/template package.

## Usage

```csharp
var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!");
var result = tmpl.Execute(new { Name = "World" });
Console.WriteLine(result); // Hello World!
```


## Features

Expand Down Expand Up @@ -123,17 +134,8 @@ var result = Template.New("calc").Parse(template).Execute(new {});

## Not Implemented Yet

- Custom functions beyond basic comparisons and boolean operators.
- Custom delimiter support.

## Usage

```csharp
var tmpl = Template.New("hello").Parse("Hello {{ .Name }}!");
var result = tmpl.Execute(new { Name = "World" });
Console.WriteLine(result); // Hello World!
```

### Example Template

```csharp
Expand Down Expand Up @@ -197,21 +199,25 @@ See the unit tests for more examples covering loops, conditionals and range expr

## Benchmark Results

The following microbenchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0. Each benchmark renders the same short template:
The following benchmarks were run using [BenchmarkDotNet](https://benchmarkdotnet.org/) on .NET 9.0, `cpu: AMD Ryzen 7 5700X 8-Core Processor`. Each benchmark renders the same short template:

```text
Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}
```

```bash
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "TemplateBenchmarks*"
```

The model contains five strings in the `Items` list so every engine performs a small loop. BenchmarkDotNet ran each test using its default configuration which executes a warm‑up phase followed by enough iterations (13–96 in our runs) to collect roughly one second of timing data. The Go implementation was benchmarked with `go test -bench .` using the equivalent template and data.

| Method | Mean | Error | StdDev |
|-------|------:|------:|------:|
| GoTextTemplate (.NET) | 14.52 us | 0.18 us | 0.15 us |
| Handlebars.Net | 1,857 us | 32 us | 29 us |
| Scriban | 14.62 us | 0.29 us | 0.81 us |
| DotLiquid | 13.79 us | 0.27 us | 0.28 us |
| Go text/template | 1.69 us | 0.00 us | 0.00 us |
| Method | Mean | Error | StdDev |
|--------------- |------------:|----------:|----------:|
| GoTextTemplate | 15.28 us | 0.238 us | 0.222 us |
| Handlebars.net | 1,721.10 us | 29.683 us | 26.313 us |
| Scriban | 15.36 us | 0.304 us | 0.482 us |
| DotLiquid | 12.98 us | 0.162 us | 0.151 us |
| Go text/template | 2,167 ns | (505,638 iterations ) |

### Advanced Scenario Benchmarks

Expand All @@ -220,28 +226,27 @@ loads the Kubernetes-style YAML templates found under `tests/TestData` and
executes them as a single nested template. Run the .NET benchmarks with:

```bash
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "*"
dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "ComplexNestedTemplateBenchmarks*"
```

BenchmarkDotNet will then execute both the basic and advanced scenarios. The Go
implementation can be benchmarked separately with:
The Go implementation can be benchmarked separately with:

```bash
go test -bench BenchmarkGoComplexTemplate ./benchmarks/go -benchmem
```

Example results on a small container:

| Method | Mean | Error | StdDev |
|-------|------:|------:|------:|
| GoTextTemplate_NET | 477.1 us | 371.94 us | 20.39 us |
| Handlebars | 47,455.5 us | 79,242.45 us | 4,343.55 us |
| Scriban | 202.1 us | 426.47 us | 23.38 us |
| DotLiquid | 467.4 us | 86.21 us | 4.73 us |
| Go text/template | 0.79 us | 0.00 us | 0.00 us |
| Method | Mean | Error | StdDev |
|------------------- |------------:|----------:|----------:|
| GoTextTemplate_NET | 172.0 us | 3.19 us | 2.98 us |
| Handlebars.net | 47,411.7 us | 679.98 us | 602.78 us |
| Scriban | 195.4 us | 3.07 us | 3.01 us |
| DotLiquid | 417.5 us | 6.07 us | 5.38 us |
| Go text/template | 2,167 ns | (1,218,702 iterations ) |

## Claude's suggestions
https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js
https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf

## License

Expand Down
12 changes: 8 additions & 4 deletions src/TextTemplate/Template.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace TextTemplate;
public class Template
{
private string _templateString = string.Empty;
private GoTextTemplateParser.TemplateContext? _parseTree;

/// <summary>
/// The template name.
Expand All @@ -34,8 +35,7 @@ private Template(string name)
public Template Parse(string templateString)
{
if (templateString == null) throw new ArgumentNullException(nameof(templateString));
// Validate using the TemplateEngine so parsing rules remain consistent.
TemplateEngine.Validate(templateString);
_parseTree = TemplateEngine.Parse(templateString);
_templateString = templateString;
return this;
}
Expand All @@ -59,15 +59,19 @@ public Template ParseFiles(params string[] filenames)
/// </summary>
public string Execute(IDictionary<string, object> data)
{
return TemplateEngine.Process(_templateString, data);
if (_parseTree == null)
_parseTree = TemplateEngine.Parse(_templateString);
return TemplateEngine.Process(_parseTree, data);
}

/// <summary>
/// Executes the template using the public properties of <typeparamref name="T"/>.
/// </summary>
public string Execute<T>(T model)
{
return TemplateEngine.Process(_templateString, model);
if (_parseTree == null)
_parseTree = TemplateEngine.Parse(_templateString);
return TemplateEngine.Process(_parseTree, model);
}

/// <summary>
Expand Down
57 changes: 44 additions & 13 deletions src/TextTemplate/TemplateEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
using System.Text.Encodings.Web;
using System.Net;
using Antlr4.Runtime;
using Antlr4.Runtime.Atn;
using Antlr4.Runtime.Tree;
using Antlr4.Runtime.Misc;

namespace TextTemplate;

Expand All @@ -22,17 +24,8 @@
/// </summary>
public static string Process(string templateString, IDictionary<string, object> data)
{
templateString = PreprocessWhitespace(templateString);
templateString = PreprocessComments(templateString);
var inputStream = new AntlrInputStream(templateString);
var lexer = new GoTextTemplateLexer(inputStream);
var tokens = new CommonTokenStream(lexer);
var parser = new GoTextTemplateParser(tokens);
IParseTree tree = parser.template();

var templates = new Dictionary<string, GoTextTemplateParser.ContentContext>();
var visitor = new ReplacementVisitor(data, templates);
return visitor.Visit(tree);
var tree = Parse(templateString);
return Process(tree, data);
}

/// <summary>
Expand All @@ -41,13 +34,44 @@
public static void Validate(string templateString)
{
if (templateString == null) throw new ArgumentNullException(nameof(templateString));
Parse(templateString);
}

internal static GoTextTemplateParser.TemplateContext Parse(string templateString)
{
templateString = PreprocessWhitespace(templateString);
templateString = PreprocessComments(templateString);
var inputStream = new AntlrInputStream(templateString);
var lexer = new GoTextTemplateLexer(inputStream);
var tokens = new CommonTokenStream(lexer);
var parser = new GoTextTemplateParser(tokens);
parser.template();
var parser = new GoTextTemplateParser(tokens)
{
ErrorHandler = new BailErrorStrategy()
};
parser.Interpreter.PredictionMode = PredictionMode.SLL;
try
{
return parser.template();
}
catch (ParseCanceledException)
{
inputStream = new AntlrInputStream(templateString);
lexer = new GoTextTemplateLexer(inputStream);
tokens = new CommonTokenStream(lexer);
parser = new GoTextTemplateParser(tokens)
{
ErrorHandler = new DefaultErrorStrategy()
};
parser.Interpreter.PredictionMode = PredictionMode.LL;
return parser.template();
}
}

internal static string Process(GoTextTemplateParser.TemplateContext tree, IDictionary<string, object> data)
{
var templates = new Dictionary<string, GoTextTemplateParser.ContentContext>();
var visitor = new ReplacementVisitor(data, templates);
return visitor.Visit(tree);
}

/// <summary>
Expand All @@ -61,6 +85,13 @@
return Process(templateString, dict);
}

internal static string Process<T>(GoTextTemplateParser.TemplateContext tree, T model)
{
IDictionary<string, object> dict = model as IDictionary<string, object> ??
ToDictionary(model!);
return Process(tree, dict);
}

private static string PreprocessWhitespace(string template)
{
var sb = new StringBuilder();
Expand Down Expand Up @@ -157,7 +188,7 @@
if (args.Length == 0) return string.Empty;
string fmt = args[0]?.ToString() ?? string.Empty;
var rest = args.Skip(1).ToArray();
return SprintfFormatter.Format(fmt, rest);

Check warning on line 191 in src/TextTemplate/TemplateEngine.cs

View workflow job for this annotation

GitHub Actions / build

Argument of type 'object?[]' cannot be used for parameter 'args' of type 'object[]' in 'string SprintfFormatter.Format(string format, params object[] args)' due to differences in the nullability of reference types.
},
["html"] = args => System.Net.WebUtility.HtmlEncode(string.Concat(args.Select(a => a?.ToString()))),
["js"] = args => System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(string.Concat(args.Select(a => a?.ToString()))),
Expand Down Expand Up @@ -196,7 +227,7 @@
}
if (current is IDictionary dict)
{
current = dict[key];

Check warning on line 230 in src/TextTemplate/TemplateEngine.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'key' in 'object? IDictionary.this[object key]'.
continue;
}
current = null;
Expand Down
Loading