- Author: Douglas P. Fields, Jr.
- Copyright 2026 Douglas P. Fields, Jr.
- License: Apache 2.0 - see LICENSE
dotcl-packagegen is a standalone CLI tool (packaged as a dotnet tool) that generates
DotCL Common Lisp packages/bindings for arbitrary .NET
assemblies. It is a hybrid C#/Common Lisp codebase: C# does .NET reflection and hosts the
DotCL Lisp runtime; Lisp does the actual code generation via string templating.
For details on the C# capabilities exposed in the generated Lisp packages, please see FEATURES.
A single invocation generates everything for one or more assemblies in one pass:
-
Metadata reflection (pure C#, no DotCL needed): for each
--assembly,AssemblyToLispy.csreflects over the .NET assembly (plus its sidecar.xmldoc file, if present) and emits a single Lisp-reader-compatible s-expression list — one plist per public type — to a<AssemblyName>.lispy.metadatafile in--out-dir. Seedoc/assembly-to-lispy.mdfor the complete, canonical schema of this format (keys, flags, parameter plists, documentation plists, default-value literal formatting, etc.) -
Package generation (boots DotCL): once every assembly's metadata has been reflected, the metadata plus the requested classes are handed to the code generator (the
apg-*.lispmodules; seeCLAUDE.md's architecture map orFILES.mdfor the per-file breakdown) (run-assembly-package-generator-batch→generate-assembly-packages-batch→generate-class-file), which emits a.lispfile per requested class defining a package with idiomatic Lisp wrapper functions for that C# type's constructors, methods, properties, and fields. The same invocation also writespackages.lisp(every generated package'scl:defpackageform, including a sharedcsharp-assembly-utilssupport package),csharp-assembly-utils.lisp(a small runtime-support condition type generated code depends on), andcsharp-assembly-packages.asd(an ASDF system tying the whole batch together), so the output directory is self-contained and loadable with a single(asdf:load-system "csharp-assembly-packages"). All files generated by one invocation share a single creation timestamp.
An --assembly with no --class options is valid — it emits only that assembly's metadata
file, generating no packages, e.g. to inspect an assembly's reflected metadata without
generating any Lisp packages from it.
The metadata is a single Lisp s-expression.
This package was split out of my DotCL Dungeon Slime MonoGame proof of concept.
dotcl-packagegen --out-dir ./cspackages \
--assembly path/to/Some.Assembly.dll \
--class Some.Namespace.Type1 --constant-properties "*" \
--class Some.Namespace.Type2 --export-parents \
--assembly path/to/Some.Other.Assembly.dll \
--class Some.Other.Namespace.Type3--class attaches to the most recently given --assembly; --constant-properties attaches to
the most recently given --class. --assembly may be repeated to process several assemblies
in one invocation, and a --assembly with no --class options is valid (metadata-only).
--all-classes <namespace>/--all-classes-recursive <namespace> behave like --class but
name a whole C# namespace instead of one class: they expand, against the assembly's own
already-reflected metadata, into every public type whose namespace is exactly <namespace>
(--all-classes) or <namespace> or any sub-namespace (--all-classes-recursive, never a bare
string prefix — Gum.Form does not match Gum.Forms.X), each carrying that group's own
per-class flags/--constant-properties, exactly like an explicit --class:
dotcl-packagegen --out-dir ./cspackages \
--assembly Some.Assembly.dll \
--all-classes 'Some.Namespace' --defgeneric \
--all-classes-recursive 'Some.Other.Namespace'A namespace matching zero types is an error (like an unresolvable --class name), unless
--skip-missing, in which case it's dropped with a warning instead. An explicit --class
always wins over an overlapping namespace expansion, regardless of which comes first on the
command line. Namespace expansion is scoped to its own --assembly group, like every other
per-class option.
--all-classes/--all-classes-recursive do not support wildcards/globs (System.*o*) — only
an exact namespace or its recursive sub-namespaces.
A real invocation for a project with more than a handful of classes gets long fast; put it in
a response file instead and pass --options-file <path>:
dotcl-packagegen --out-dir ./cspackages --options-file dotcl-packagegen-options.txt--options-file's tokens are spliced into the argument list at the exact position
--options-file appears (order matters — several flags above are position-sensitive by
design), so it can be freely mixed with regular command-line arguments, or split across
several files each covering one --assembly group. Format: one or more whitespace-separated
arguments per line; # at the start of a token begins a line comment; a double-quoted token
may contain whitespace (only \" and \\ are recognized escapes) — unlike the shell, a
generic class name's backtick (e.g. System.ValueTuple\2) needs no quoting or escaping at all in a response file. Not recursive: a response file containing its own --options-fileis an error. Seetest-options.txt.in(the templatemake test`'s own smoke-test invocation is
generated from) for a real example.
--constant-properties (comma/semicolon-separated names, or "*" for all) forces static
read-only properties to be memoized — computed once, on first use, then cached for the life
of the program — instead of re-evaluated on every reference; safe only when the property
genuinely never changes at runtime (e.g. Vector2.Zero), since reflection alone can't tell
constants from properties that vary.
Nested C# types (a class/struct/enum declared inside another type) are addressed by their
CIL name, which separates nesting levels with + rather than . — e.g.
--class Microsoft.Xna.Framework.Graphics.SpriteFont+Glyph for the Glyph type nested
inside SpriteFont. The generated Lisp package/file name flattens the + the same way it
flattens namespace dots, so that example generates
microsoft-xna-framework-graphics-sprite-font-glyph, not a name containing a literal +.
Assembly files are validated to exist, and requested classes are validated to exist in their
assembly's metadata, before any output file is written; any error is reported in red to
standard error. --version/--help and --test boot the DotCL host
(DotclHost.Initialize()); the metadata-reflection portion of a --out-dir invocation
intentionally does not, since it's pure reflection and runs before DotCL boots.
A class's package normally contains only the members it declares itself, not anything
inherited. Per-class flags (attach to the most recently given --class, like
--constant-properties) opt a class into also generating packages for, and re-exporting
non-conflicting members from, its ancestors:
--export-parents— also generate a package for, and re-export from, every super-class (excludingSystem.Objectunless--export-objectis also given).--export-interfaces— also generate a package for, and re-export from, every interface the class implements.--export-object— when combined with--export-parents, also includeSystem.Object.
Each of these has a --no- counterpart (--no-export-parents, --no-export-interfaces,
--no-export-object) that turns it back off for just that one class — useful for opting a
single class out when a --export-all-* sticky default (below) is otherwise on.
Sticky defaults (--export-all-parents/--no-export-all-parents, and the -interfaces/
-object equivalents) change the default for the current and every subsequent --class, in
command-line order; a class's own --export-*/--no-export-* flags always override the sticky
default for that one class only.
A re-exported member is skipped (with a comment, not silently dropped) when the class already
declares a member of that name itself (the class's own wins), or when more than one ancestor
exports the same name (ambiguous — a future version may add a renamed re-export for this case;
for now it's a comment only). By default, an ancestor that cannot be found in any provided
assembly's metadata is a hard error before anything is generated; pass --skip-missing to
instead warn and drop it (--no-skip-missing restores the default). See
doc/parents-and-interfaces-plan.md for the full design.
A Makefile is provided with the following targets:
-
make build— Builds the project (dotnet build dotcl-packagegen.csproj -c Debug), compiling both the C# host and the DotCL Common Lisp sources intobin/Debug/net10.0/. -
make test— Builds the project (viabuild), then runs the built executable in--testmode. This runs the generator's own Lisp unit tests (package-generator-tests-*.lisp, registered viagenerator-tests.lisp, including the generatedSystem.TimeSpanoperator-overload checks) followed by theAssemblyToLispymetadata test suite againstSystem.Runtime.dll,System.Console.dll, the syntheticAssemblyToLispyTestTarget.dll, andDotCL.Runtime.dll. It then generates a real batch of packages end-to-end intocspackages-test/, verifies balanced parentheses withcheck_parens.py, and finally read-checks the same output with--read-check(a real Lisp reader read-back, catching invalid-token bugs paren-balance checking alone cannot see). -
make test-runtime— The runtime exercise suite (RuntimeExerciseTest/, seedoc/plan-fable-detail-02.md): generates real packages againstAssemblyToLispyTestTargetfixture classes, BCL classes (System.TimeSpan/DateTime/Text.StringBuilder), and — from the same real-world librariesdotcl-dungeonslimeconsumes — MonoGame (Vector2,Color,Point,Rectangle,MathHelper,GameTime,Input.Keys) and Gum (DimensionUnitType,KeyCombo,TextRuntime) intoRuntimeExerciseTest/gen/, then a sibling C# project cross-compiles and actually calls the generated wrapper functions against live .NET objects — the structural fix for the v48-v50 escape class (omitted-optional-passed-as-nil, Master Wrapper dispatch ordering,Nullable<T>guards), all runtime-dispatch bugs invisible tomake test's string-level (paren-balance/read-back) checks above. The MonoGame/Gum assemblies are staged from the local NuGet cache (versions pinned in theMakefile, matchingRuntimeExerciseTest.csproj'sPackageReferences). Run it before any release, or after touching overload dispatch/codegen. -
make package— Builds Release binaries for every configuredRuntimeIdentifier(linux-x64,linux-arm64,win-x64,osx-x64,osx-arm64,any) and produces the installable NuGet package(s) (dotnet pack -c Release -o nupkg) in thenupkg/directory: one package per RID plus a top-level meta-package that dispatches to them. -
make deploy— Depends onpackage, then installs (or reinstalls, if already present)dotcl-packagegenas a globaldotnet toolfrom the package(s) just built (dotnet tool install --global --add-source nupkg dotcl-packagegen), making thedotcl-packagegencommand available onPATHfrom any directory. -
make clean— Runsdotnet cleanand removes thebin/,obj/,AssemblyToLispyTestTarget/bin/,AssemblyToLispyTestTarget/obj/,RuntimeExerciseTest/bin/,RuntimeExerciseTest/obj/,RuntimeExerciseTest/gen/, andnupkg/directories.
Typical workflow: make build test while developing, make deploy to install
a local build as the system-wide dotcl-packagegen command.
Please see generator-design-notes for some internal details on how the generator works.
Please see PLAN for some of the planned future directions, as well as suggested-improvements.
Please see GEMINI (also linked to AGENTS) and CLAUDE to see some of the directions provided to AI coding agents (e.g., Antigravity CLI, Claude CLI, OpenCode CLI).