ghul.test
8.2.0
Prefix Reserved
dotnet tool install --global ghul.test --version 8.2.0
dotnet new tool-manifest
dotnet tool install --local ghul.test --version 8.2.0
#tool dotnet:?package=ghul.test&version=8.2.0
nuke :add-package ghul.test --version 8.2.0
ghūl compiler integration test runner
This is a very simple snapshot based test runner which is used by the ghūl programming language compiler integration tests. It compares test expectations, in the form of snapshot text files, against the actual outputs of the compiler and test executables and flags any differences.
Test Folder Structure
A test directory must contain at least two things:
- One or more
.ghulsource files – the sources to compile. - A
ghulflagsfile – flags passed directly to the compiler when building the test.
Any directory containing a ghulflags file is treated as a test. A directory without one is not run, so one that looks like a test anyway - it holds a test.ghul, or an *.expected snapshot - is named at the end of the run and fails it, rather than passing unnoticed. Other directories without a ghulflags file, such as a library a test builds against, are ignored.
Optional expectation and configuration files may also be present:
| File | Purpose |
|---|---|
fail.expected |
If present, the build is expected to fail. Its mere presence enables this behaviour; the file contents are ignored. |
err.expected |
Expected compiler error output. Actual errors are extracted from compiler.out, sorted, and diffed against this file. |
warn.expected |
Expected compiler warning output. Warnings undergo the same grep and sort process as errors. |
run.expected |
Expected stdout from running the compiled binary. |
run.in |
Written to the program's standard input, which is closed once it has been sent. Without it the program inherits the runner's own standard input. A program that reads to end of input needs the close, so a test driving an interactive program supplies the whole session here and snapshots the transcript in run.expected. Sent to the formatted binary too, where a format.expected makes one. |
run.args |
The command line the program is run with, one argument a line, so an argument containing spaces is still one argument. Without it the program runs with no arguments. Passed to the formatted binary too, where a format.expected makes one. |
run.exit.expected |
The exit status the program is expected to end with, for a test whose subject is failing. Without it any non-zero status fails the test. The status an unhandled exception produces is the runtime's rather than the program's, so a test that asserts one is asserting what this platform's runtime does with it. |
run.err.expected |
Expected standard error from running the compiled binary. The stream is captured either way, and shown when a run fails; a test asserts it by carrying this file. Distinct from err.expected, which is the compiler's diagnostics rather than the program's. |
run.session |
Drives a program that prompts, the way a person at a terminal does. Each line is sent when the output so far ends part way through a line - the program sitting at a prompt with nothing more to say until it is answered - and is written into run.out at that point, where the terminal would have echoed it. So run.expected holds the session as it would have been seen, answers included, rather than the prompts with the answers missing. A program that reads without prompting first is not driven this way, because nothing marks the point to answer at; use run.in for those. A test carrying both files fails. |
ignore-space |
If present, the program's output - run.out, the formatted build's output and run.err - is compared ignoring changes in the amount of whitespace, as diff -b does, for a program whose spacing genuinely varies. Without it the comparison is exact, so a padding or alignment change fails the test. Carriage returns are stripped either way. |
cultures |
If present, the compiler and the program run with the host's culture. Without it, every process the test runs has DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, as the wasm and llvm targets behave, so string order, casing and number and date formats are the invariant ones whatever the machine. |
run.check |
An executable that judges the run's output, for a program whose output is not the same twice. It is run from the test directory with run.out as its only argument, and passes the test by exiting zero. Its own output is what the failure report shows, since an exit status alone says nothing about which part of the output was wrong. Any interpreter its #! line names will do, #!/usr/bin/env ghul included. A run.expected alongside it is not compared - a program whose output varies has no snapshot to hold it to - but is still what scripts/capture.sh writes and what a consumer of the test can read as a sample of what the program prints. |
<name>.png.expected |
An image the test wrote, asserted against this one. The bytes are compared first, and only when they differ is either file decoded and the pixels compared - what a PNG compresses to is fixed by nothing, so an image that is right does not fail because the compressor changed. Depth 8, colour types grey, truecolour, and either with alpha; indexed and interlaced images are reported rather than compared. scripts/capture.sh writes one for every .png a test produced. |
il.expected |
Expected IL. When present, the assembly the compiler emitted is disassembled into il.out and diffed against this file. A test that expects IL but emits none compares against an empty file, so it fails rather than being skipped. |
il.item |
Optional, and only meaningful alongside il.expected: the one type or member to disassemble, as Namespace.TYPE or Namespace.TYPE::member. Without it the whole assembly is dumped, which is what a test asserting assembly-level shape wants. Naming something the assembly does not contain fails the test, because the disassembler reports no such thing itself. |
format.expected |
Expected result of formatting the test's sources. Its presence also makes the runner build the formatted sources and run what it built, comparing the output against the same run.expected as the original. Not supported in a project build, where the sources come from the project rather than from the runner. |
ghulflags |
Mandatory file containing additional command line flags for the compiler. |
disabled* |
Any file beginning with disabled causes the test to be skipped. |
tags |
Zero or more whitespace-separated tag names (spaces or newlines), used to select a subset of tests with --tag. A test with no tags file has no tags. |
targets |
The targets the test runs under, one or more of dotnet, wasm and llvm separated by spaces or newlines. The test is run once per target, reported as <path>[<target>], with --target <name> added to its ghulflags, which must not name a target itself. Each run happens in its own directory, .targets/<target>/, holding copies of the test's sources, inputs and expectations, so the runs neither collide nor leave artifacts in the test directory; the directory is remade on every run. An expectation file may name a target - run.wasm.expected, err.llvm.expected, fail.wasm.expected - and then stands in for the plain file in that target's run alone, so a test holds one run.expected for every target and a target-specific one only where the result legitimately differs. Under a configuration's build_targets the file names the test's targets in place of that list, and the run says which of the configuration's targets it leaves out. In a msbuild, direct or ghul-cli build the variants are built as build_targets describes. |
A basic “hello world” example can be found in the integration-tests folder of this repository.
A directory whose name starts with a dot is never a test and is not searched for one, which is what keeps a .targets/<target>/ directory from being discovered twice.
Expectation Comparison Workflow
- The runner invokes the compiler using the arguments from
ghulflagsand the test’s.ghulsources. Compiler stdout/stderr is written tocompiler.out. grepextracts error and warning lines fromcompiler.outintoerr.grepandwarn.greprespectively. It also looks for theexception:lines the compiler writes when one of its passes throws, and a test whose compiler output holds one fails on that alone: a pass that throws is a compiler defect whatever the test says about its source, and it is reported as neither an error nor a warning, so nothing a test asserts would otherwise notice it.- These files are sorted with
sort(withLC_COLLATEset toCfor stable output) intoerr.sortandwarn.sort. diffcompareserr.sorttoerr.expectedandwarn.sorttowarn.expected. Whitespace differences are ignored and carriage returns are stripped.- If compilation succeeded,
ghul-runtime.dllis symlinked into the test directory and the binary is executed viadotnet, with the arguments arun.argsholds. A test built for the wasm target - itsghulflagsinclude--target wasm, or, in a project build, its project'sGhulOptionsdo - builds a WebAssembly module and the JavaScript loader the compiler writes beside it, and is run instead withnodeon the loader, so the comparisons below apply to it unchanged; nothing is symlinked for it, and the test fails with a message saying so where Node cannot be started. A configuration that nameswasm_hostruns the module under that program instead of under Node. A test built for the llvm target, with--target llvm, is a native executable and is run directly, with nothing symlinked beside it. Arun.inis written to its standard input and the stream closed; arun.sessionis sent a line at a time as the program prompts for it, and echoed into the captured output. Input and both output streams are carried concurrently, so a program that fills one pipe while still reading another is not deadlocked by the runner. A program that has not exited within five minutes is killed and its test fails. Output is captured inrun.outand compared torun.expectedexactly, spacing and a final newline included, or handed torun.checkwhere the test carries one. - The exit status is compared against
run.exit.expectedwhere the test carries one, and otherwise any non-zero status fails the test. If arun.err.expectedfile exists, what the program wrote to standard error is compared against it, after the same path and stack-trace normalisationrun.outgets, and as exactly: eachat …frame loses itsin <path>:line <N>suffix, so a stack trace is portable. Where a trace still carries something local, a test can assert the exit status alone and leave the stream unasserted. - Every
<name>.png.expectedin the test directory is compared against the<name>.pngthe run wrote, by bytes and then, if those differ, by decoded pixels. - If an
il.expectedfile exists,diffcompares it againstil.out. A compiler that wroteil.outitself has already answered; when it wrote nothing and the build succeeded, the emitted assembly is disassembled withildasmto produce it instead. A test therefore moves from one to the other by dropping whatever made its compiler write the file. Lines that describe the run rather than the assembly are removed first: the disassembler's version banner, the MVID, the partial-disassembly warning, and the image base, which is where the file happened to be mapped and so differs on every run. - If a
format.expectedfile exists, the sources are copied into aformattedsubdirectory and formatted in place. The results are joined intoformat.out- each under a// === <name> ===header when there is more than one - and compared toformat.expected, without ignoring whitespace, since whitespace is what the snapshot is about. The formatted sources are then built into their own binary and, for a test that is not a library, run - with the output compared to the samerun.expectedthe original was. A snapshot on its own would say only that the formatter's output is what it was last time; building and running it is what says the output is still a program that does the same thing.
Any mismatches cause a failure report containing a unified diff of the actual versus expected output.
Disassembler
The IL comparison uses Microsoft's ildasm, which ships with this tool for
linux-x64 and win-x64 and is found beside it. Override with the configuration's ildasm or the GHUL_TEST_ILDASM
environment variable, either of which must name an existing file; on any other platform
one of those is required, and a test wanting IL reports that none was found
rather than passing quietly.
Configurations
A repository states how its suites are run in a ghul-test.json, and a run names one of its configurations:
ghul-test --config ci integration-tests
The file is found in the working directory or the nearest directory above it. A run that names no configuration, and gives none of the options a configuration sets, runs the file's default, if it has one. Comments and trailing commas are allowed.
{
"default": "local",
"configurations": {
"local": {
"compiler": { "publish": "publish" },
"runtime": "beside-compiler",
"targets": {
"wasm": {
"libraries": [
{ "name": "ghul-core", "root": ["libraries/ghul-core", "../ghul-core"], "sources": "src/**/*.ghul" },
{ "name": "ghul-runtime", "root": ["libraries/ghul-runtime", "../ghul-runtime"], "sources": "@wasm-sources.rsp" }
]
}
}
},
"ci": {
"extends": "local",
"compiler": "tool",
"runtime": { "path": "bootstrap-tool/ghul-runtime.dll" }
}
}
}
Paths are relative to the file. A configuration holds:
extends– another configuration of the file, applied first; what this one sets replaces what that one set, andtargetsis replaced target by target.build– how each test is built:compiler, the default: the runner calls the compiler on the test's sources and links the configuration's runtime beside the program.msbuild: each test folder is an MSBuild project (for ghūl, a.ghulproj), built withdotnet build.direct: asmsbuild, but a project is built with the compiler alone, as MSBuild would have called it, where everything in the project and in theDirectory.Build.propsabove it is something the runner can read: anExeonMicrosoft.NET.Sdkfor the runner's target framework, sources*.ghulor**/*.ghulin the test directory itself,GhulOptions, theGhul*properties that become compiler flags, and aPackageReferencetoghul.runtimealone, at a fixed version that is already restored. Its diagnostics are written as MSBuild writes them, so the same snapshots hold either way. Any other project, and one whoseghulflagsis not empty, is built withdotnet build, and the run says how many of each, and why.ghul-cli: a directory holding aghul-project.jsonis a test, and needs noghulflags. It is built withghul build --target <target>in the test folder, and the program ghul-cli writes toout/<target>/is run: undernodefor wasm, directly for llvm, underdotnetfor dotnet. The compiler and libraries are whatever the manifest says.format.expectedandil.expectedare not supported, and a library project is built and not run.
compiler– for acompilerbuild,"tool"for theghul-compilerthe tool manifest pins, or{ "publish": "<dir>" }or{ "path": "<dir>/ghul.dll" }for a published compiler. For amsbuildordirectbuild,{ "path": ... }or{ "command": "<words>" }names the compiler each project is built with, passed to MSBuild as theGhulCompilerproperty, and"project"leaves the choice to each project, unless apublishdirectory at or above the working directory holds a compiler, which is then used. A command with no spaces in it must name an existing file. A project that assignsGhulCompileritself overrides whatever the run chooses. Which compiler a project build uses is reported before the run starts.runtime– theghul-runtime.dlla compiled test runs against:"beside-compiler",{ "path": "<file>" }, or{ "package": "ghul.runtime", "version": "<version>" }from the NuGet package cache, where"pinned_in": "<file>"in place ofversionreads the version an MSBuild file such asDirectory.Packages.propspins. Acompilerbuild using the tool has to name its runtime. A project build takes its runtime from the project's ownPackageReference.targets– for each target a test can be built for, the compilerflagsand thelibrariesa run under it adds, after the test's ownghulflagsand--target <name>. In adirectormsbuildbuild they apply to a variant built for a target other than dotnet. A library is aname, aroot(or a list of candidate roots, the first that exists being used), andsources:<dir>/**/<files>under the root, or@<listing>, a file in the root listing source paths relative to it. The library's version is read fromVERSIONin its root, and each becomes a--library-source. A path under a library's root is written in a test's output as the library's name in angle brackets followed by the path within it, such as<ghul-core>/src/tasks.ghul, so an expectation naming a library source holds whichever root the library resolved to. A library whose root holds the test itself is left out of this. A library that names apackage, such as"package": "ghul.raster", is given only to a project build that references that package, standing in for it under a target other than dotnet.build_targets– the targets each test is built for, such as["dotnet", "llvm"]. Each test is built and run once per target, as a variant reported as<path>[<target>]in its own directory, where an expectation named for the target -run.llvm.expected,fail.wasm.expected- stands in for the plain one. A test whoseghulflagsname a target is built for that one, and a test with atargetsfile for the ones it names. Aghul-cliproject is built for the targets its manifest lists too. Amsbuildordirectproject is built for dotnet as usual, and for any other target with the compiler alone, as adirectbuild is, given the project's options,--targetand the configuration'stargetsentry; a project the runner cannot read, or one that references a package no library stands for, is passed over for that target, and the run says why. A compiler build's variant directory is.targets/<target>/inside the test; a project build's is.targets-<test>-<target>/beside it, at the same depth, so paths a project gives relative to its own directory still resolve. A repository running project builds this way ignores.targets-*/. Left out, each test is built once, for the target it names.run_targets– the targets the run covers, such as["dotnet", "wasm"]. A test built for any other target, or atargetsfile's variant for one, is passed over rather than run, and the run says how many it passed over. A test's target is its variant's, or the--targetitsghulflagsgive, ordotnet. Left out, every target is run.ildasm– the disassembler, in place of the copy that ships beside ghul-test.wasm_host– the program that runs a test built for the wasm target, in place ofnodeon the loader the compiler writes: it is given the module and the arguments fromrun.args. Paths are relative to the file. Without it, wasm tests run undernode.ghulandghul_target– for aghul-clibuild, the command that runs ghul-cli (ghulunless given,dotnet ghulfor a local tool) and the target each project is built for. A project whose manifest does not list the target is passed over; without one, each project is built for the first target its manifest lists.
Command Line Usage
ghul-test [--config <name>] [--compiler <command>] [--tag <name>]... [--not-tag <name>]... [--shard <index>/<count>] <test-folder> [...]
--config <name>– run the named configuration fromghul-test.json; without it, the file'sdefaultruns. A run with noghul-test.jsonat or above the working directory, or with neither, is refused. See Configurations.--compiler <command>– override the configuration's compiler: for acompilerbuild, the published compiler to run, such asdotnet /path/to/ghul.dll; for amsbuildordirectbuild, theGhulCompilereach project is built with.--tag <name>– restrict discovery to tests whosetagsfile contains at least one of the given names. Repeatable; the requested tags are matched as a union (a test runs if it carries any of them), not an intersection. A test with notagsfile is excluded whenever any--tagis given.--not-tag <name>- exclude tests whosetagsfile contains any of the given names. Repeatable, and matched as a union like--tag. A test with notagsfile is never excluded. Exclusion wins over inclusion, so--tag generics --not-tag asyncruns the generics tests that are not also async ones.--shard <index>/<count>- run only one part of the suite, so several processes can divide it between them.--shard 2/6runs the second of six parts. Tests are striped by discovery order rather than cut into contiguous blocks, since discovery walks the tree in sorted order and a block would put a whole directory into one shard. Every discovered test belongs to exactly one shard, so running all<count>of them runs the suite exactly once. The run reports how many of the discovered tests it took, and summing that across the shards shows none was dropped. Applies after--tag, so the two compose.<test-folder>– one or more directories containing tests. Each is recursively searched for subdirectories with aghulflagsfile, or for aghul-clibuild with aghul-project.json.
The options earlier versions took in place of a configuration (--ci, --no-ci, --use-dotnet-build, --direct, --use-ghul-cli, --ghul, --target, --runtime-dll, --ildasm, --target-flags) are refused, naming what the configuration says instead.
Environment variables:
GHUL_TEST_ILDASM– the disassembler, overriding the configuration'sildasm. The path must name an existing file.TEST_PROCESSES– number of worker processes to use. If unset, a value derived from CPU count is used.GHUL_TEST_KEEP_ARTIFACTS– when set, keep every test's build and run artifacts instead of deleting them on success. A passing test normally cleans up after itself, so a green run leaves only the failures behind; set this to inspect what was actually built — to audit every emitted assembly, or to look at a test that passes but is suspected of passing for the wrong reason.
The runner prints progress for each test and a final summary indicating total, enabled, passed and failed counts. The configuration it runs is named on standard error.
Runtime Library Handling
In a compiler build the produced executable expects to find ghul-runtime.dll beside it, so the runner links the configuration's runtime into the test directory, and deletes the link when the test passes. A project build needs no link: its runtime comes from its own PackageReference.
MSBuild Projects
This runner does not execute arbitrary MSBuild projects. It either drives the compiler directly on .ghul source files or, in a msbuild or direct build, assumes the folder already contains a valid MSBuild project (for ghūl projects this means a *.ghulproj file). Only a small set of standard .NET assemblies is referenced so complex projects are out of scope.
Dependencies
The runner relies on several standard Unix utilities being available in the environment: grep, sort, diff and ln. A .NET 10 SDK installation is required; mono is not supported.
Writing New Tests
This repository includes helper scripts under ./scripts:
create.sh– create a new test directory from the built-in template.capture.sh– update expectation files after running a test.
- Run
./scripts/create.shand provide the new test name. - Edit the generated
.ghulsources andghulflagsas required. - Execute
ghul-test <path-to-test>(expect it to fail initially). The runner produces.outfiles with the actual output. - Run
./scripts/capture.sh <path-to-test>to copy the.outfiles over the corresponding*.expectedfiles. - Re-run
ghul-testand verify the test now passes. - Commit the test directory along with the expectations.
Refer to the ghūl compiler integration tests for many real‑world examples of this structure.
Issues
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
| Version | Downloads | Last Updated |
|---|---|---|
| 8.2.0 | 27 | 10/14/2026 |
| 8.1.0 | 1,133 | 10/12/2026 |
| 8.0.0 | 342 | 10/12/2026 |
| 7.0.0 | 941 | 10/11/2026 |
| 6.1.1 | 175 | 10/11/2026 |
| 6.1.0 | 59 | 10/11/2026 |
| 6.0.0 | 1,525 | 10/11/2026 |
| 5.6.1 | 123 | 10/11/2026 |
| 5.6.0 | 215 | 10/11/2026 |
| 5.5.1 | 58 | 10/11/2026 |
| 5.5.0 | 197 | 10/11/2026 |
| 5.4.0 | 142 | 10/11/2026 |
| 5.3.1 | 54 | 10/11/2026 |
| 5.3.0 | 153 | 10/11/2026 |
| 5.2.0 | 57 | 10/11/2026 |
| 5.1.2 | 800 | 10/10/2026 |
| 5.1.1 | 751 | 10/4/2026 |
| 5.1.0 | 334 | 10/4/2026 |
| 5.0.5 | 100 | 10/4/2026 |
| 5.0.4 | 86 | 10/4/2026 |