kanna

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM, i18n. Each
works on its own and emits plain Go with no runtime reflection.
Your structs are the source of truth. Point a generator at a package and it writes the code you would otherwise write by
hand, so the output stays readable, debuggable, and free of anything to learn at runtime.
Status
kanna is being assembled from generators that already exist as standalone tools. Each is ported onto a shared scanning
layer as it lands.
| Generator |
What it does |
Status |
kanna-di |
dependency-injection constructors |
available |
kanna-fixture |
test fixtures from model structs |
available |
kanna-mapper |
struct-to-struct mapping |
available |
kanna-orm |
query helpers from model structs |
available |
kanna-i18n |
typed message constructors from locale files |
available |
Nothing is released yet, so import paths and flags may still move.
kanna-di
Wires a container struct from the providers it can find, and writes a plain constructor.
Install
go get -tool github.com/go-kanna/kanna/cmd/kanna-di
Use
A provider is a top-level function whose first result is a named type, a pointer to one, or an interface, optionally
followed by an error. Nothing needs to be registered — kanna-di finds them by scanning the packages you point it at.
A container is a struct whose fields carry a di tag.
package app
//go:generate go tool kanna-di ./...
type DB struct{}
func NewDB() *DB { return &DB{} }
type User struct{ db *DB }
func NewUser(db *DB) User { return User{db: db} }
type Container struct {
User User `di:""`
}
go generate ./...
writes di_gen.go next to it:
// Code generated by kanna. DO NOT EDIT.
package app
// NewContainer initializes dependencies and constructs Container.
func NewContainer() *Container {
db := NewDB()
user := NewUser(db)
return &Container{
User: user,
}
}
When any provider in the chain returns an error, the constructor returns one too and propagates it.
A field may be named — the resolved value is stored in it — or blank (_), which declares something about the container
without keeping a value.
| Tag |
On a named field |
On a blank field |
di:"" |
resolve from whichever provider returns the field's type |
— |
di:"with=<ref>" |
resolve from the named provider |
pick that provider for the type everywhere in this container |
di:"arg" |
take it as a constructor parameter, named after its type, and store it |
take it as a parameter only |
di:"arg=<name>" |
same, with the parameter name spelled out |
same, with the parameter name spelled out |
di:"returns" |
store it and declare its type as the constructor's return type |
declare the return type only |
di:"embed" |
— |
take a struct as a parameter and offer its exported fields as resolution sources |
<ref> may be a bare function name (NewWriter), a package-qualified one (config.NewWriter), or fully qualified (
github.com/me/config.NewWriter).
Directives
A //kanna:container comment above the struct adjusts what gets generated. It is optional — one di tag is enough to
make a struct a container.
| Directive |
Effect |
//kanna:container name=<ident> |
name the constructor (default: New + struct name) |
//kanna:container returns=<type> |
declare the return type (default: pointer to the struct) |
//kanna:container must |
also emit MustNew*, which panics instead of returning the error |
Write the tag against the comment marker. Go only treats //kanna:… as a directive when the two are adjacent; with a
space, // kanna:container stays part of the doc comment and shows up in go doc and on pkg.go.dev. kanna does not
honor that form either, and points out the spelling it expected.
returns= takes the container's own type to construct it by value, or an interface the container satisfies to hide the
concrete type.
Flags
| Flag |
Meaning |
--must |
emit MustNew* for every container |
--tags <list> |
comma-separated build tags |
-check |
verify generated files are up to date instead of writing them |
-v, --verbose |
verbose output |
Example
examples/di wires a small application covering every directive and every tag except di:"arg=<name>",
which needs a name collision before it is worth showing. CI regenerates it and fails if the output would change, so what
you read there is what the generator currently produces.
kanna-fixture
Writes one constructor per struct in a package, with every field already filled, so a test states only what it cares
about.
Install
go get -tool github.com/go-kanna/kanna/cmd/kanna-fixture
Use
Point it at the package holding your models and at the directory the fixtures should live in. Every exported struct
there gets a function — there is nothing to opt into, which is what keeps fixtures from falling behind the model.
package model
//go:generate go tool kanna-fixture -source ./model -destination ./fixture
type User struct {
ID int64
Name string
Email string
Age int `fake:"{number:18,65}"`
}
go generate ./...
writes fixture/fixture_gen.go:
// Code generated by kanna. DO NOT EDIT.
package fixture
import (
"github.com/brianvoe/gofakeit/v7"
"example.com/app/model"
)
func User(setters ...func(m *model.User)) model.User {
m := model.User{
ID: gofakeit.Int64(),
Name: gofakeit.Name(),
Email: gofakeit.Email(),
Age: gofakeit.Number(18, 65),
}
for _, s := range setters {
s(&m)
}
return m
}
u := fixture.User(func(m *model.User) { m.Email = "known@example.com" })
The generated code calls gofakeit, plus whatever else the values it builds
need — github.com/google/uuid for a uuid.UUID field, for instance. When the destination module does not require one
of them yet, the generator says so.
Generation is deterministic; the values are not. Seed the faker when a test needs the same data twice:
func TestMain(m *testing.M) {
if err := gofakeit.Seed(1); err != nil {
panic(err)
}
os.Exit(m.Run())
}
Inference
Each field takes the first rule that matches.
| Rule |
Applies when |
Result |
fake tag |
the field carries one |
see below |
| field name |
the name is known and the type agrees |
Email string → Email() |
| field type |
the type has a faker |
bool → Bool() |
| a struct generated in this run |
the field is a value of that type |
Author User → User() |
| otherwise |
— |
zero value |
Names matched on a string field: Email, Name, FirstName, LastName, Phone, URL, UUID, Address, City,
Country. Any field whose name ends in At and whose type is time.Time gets a date.
Types matched: every string, bool, int, uint, and float kind, plus time.Time and github.com/google/uuid's
UUID.
Left at the zero value: pointers, slices, maps, interfaces, channels, funcs, named basic types without a tag (the valid
values are not knowable — type Status string could be anything), and any reference that would recurse, including a
struct that points back at its own type.
Unexported fields are skipped, since the generated package cannot set them.
The fake tag follows gofakeit's own template syntax, so there is nothing new to learn.
| Tag |
Effect |
fake:"{email}" |
use that generator — also {firstname}, {name}, {phone}, {url}, and others |
fake:"{number:18,65}" |
a parameterized generator — also {intrange:…}, {uintrange:…}, {price:…} |
fake:"???-####" |
any other template, resolved at run time through gofakeit.Generate |
fake:"skip" or "-" |
leave the field at its zero value |
A tag also opts a named basic type back in: a Status field tagged fake:"{word}" emits
model.Status(gofakeit.Word()).
A template that cannot produce the field's type is refused rather than forced, and the field stays zero. That covers a
range too wide for the field ({number:1,300} on an int8) and a mismatched kind ({email} on an int), so the
generated file always compiles.
Directives
| Directive |
Effect |
//kanna:ignore |
do not generate a fixture for this struct |
Write it against the comment marker. // kanna:ignore is not a directive to Go, so kanna does not treat it as one
either, and says so.
Flags
| Flag |
Meaning |
-source <pkg> |
package to scan (relative path or import path) |
-destination <dir> |
directory to write fixture_gen.go into |
-package <name> |
package name for the generated file (default: what the destination declares) |
-exclude <names> |
comma-separated type names to skip |
-check |
verify the output is up to date instead of writing it |
The destination has to be a different package from the source; a fixture that imported its own package would not
compile.
Example
examples/fixture generates fixtures for a model covering each inference rule, then builds values
from them. CI regenerates it and fails if the output would change.
kanna-mapper
Writes the mapping functions between your domain types and the wire types you do not control, calling the converters you
registered for the fields Go cannot convert on its own.
Install
go get -tool github.com/go-kanna/kanna/cmd/kanna-mapper
go get github.com/go-kanna/kanna/mapper
Two lines, because this generator has a runtime half. The package declaring your converters imports
github.com/go-kanna/kanna/mapper; the generated code does not.
Use
Register the conversions Go cannot do by itself, once, anywhere:
package converters
import "github.com/go-kanna/kanna/mapper"
func init() {
mapper.Register(UUIDToString) // uuid.UUID → string
mapper.RegisterE(uuid.Parse) // string → uuid.UUID, and this one can fail
}
Then name the pairs to map:
//go:generate go tool kanna-mapper -types=model.Employee:*employeev1.Employee -converter-pkg=../lib/converters
package mapper
import (
_ "example.com/app/gen/employeev1"
_ "example.com/app/model"
)
The blank imports are what make model and employeev1 resolvable as selectors. If the package already imports those
types for real use, the directive stands on its own; full import paths work too.
go generate ./... writes the functions:
// EmployeeToEmployeev1 maps model.Employee to *employeev1.Employee.
func EmployeeToEmployeev1(src model.Employee) *employeev1.Employee {
return &employeev1.Employee{
Id: converters.UUIDToString(src.ID),
Name: src.Name,
Address: AddressToEmployeev1(src.Address),
}
}
// EmployeeFromEmployeev1 maps *employeev1.Employee to model.Employee.
func EmployeeFromEmployeev1(src *employeev1.Employee) (model.Employee, error) {
if src == nil {
return model.Employee{}, nil
}
v1, err0 := uuid.Parse(src.GetId())
if err0 != nil {
return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err0)
}
// ...
}
The direction that can fail returns an error naming the field that produced it. The direction that cannot, does not.
Nil-safe getters are used when the wire type has them.
mapper.Register is never executed by the generator: the calls are read statically, and the functions they name are
called directly. The registry also works at run time through mapper.Convert if you want it.
How fields are matched
Each destination field takes the first rule that matches.
| Rule |
Example |
| the destination field's own tag |
a destination tagged map:"EmployeeName" reads that field |
| a source field tagged with the destination's name |
a source tagged map:"Name" fills Name |
| the same name |
Name → Name |
| the same name, any case |
ID → Id |
| a promoted field |
an embedded struct's field, by exact name |
A destination field with no source is an error, not a silent zero value. Exclude it with map:"-" on the source, or
-ignore TYPE.FIELD when the type is not yours to tag.
How values are converted
| Case |
Result |
| identical types |
assigned as-is |
| a registered converter exists |
that function is called |
the pair is declared in -types |
the generated function for it is called |
| source is a pointer |
dereferenced; nil leaves the destination zero |
| destination is a pointer |
the value's address is taken |
| both are slices |
converted element-wise; nil maps to nil |
| a lossless Go conversion exists |
dst(v) |
"Lossless" is meant strictly: int32 → int64 converts, int64 → int32 does not. Neither does int → int32
(int is 64 bits on some platforms), int → uint (sign), int64 → float64 (precision), or anything → string.
Everything else needs a converter, and the error message shows the mapper.Register line that would satisfy it.
Flags
| Flag |
Meaning |
-types <SRC:DST> |
pairs to map, comma-separated; repeatable. * marks a pointer |
-converters <pkg> |
package holding the mapper.Register calls; repeatable |
-exclude <TYPE.FIELD> |
destination fields to exclude; repeatable |
-output <path> |
output directory, or a file path ending in .go |
-direction <dir> |
both (default), to, or from |
-package <name> |
output package name (default: $GOPACKAGE) |
-check |
verify the output is up to date instead of writing it |
Example
examples/mapper maps a domain aggregate onto protobuf-shaped wire types and back, covering each way
a field can be handled: renamed with map:"Name", excluded from the domain side with map:"-", excluded from the wire
side with -exclude where there is no tag to write, converted through a registered function, and converted through one
that can fail. CI regenerates it and fails if the output would change.
kanna-orm
Generates type-safe query code from annotated model structs: a factory returning orm.Query[T] per table, row scanning, relations with eager loading, and automatic timestamps. The generated code is plain Go on top of the orm/ runtime, which brings the query builder, MySQL/PostgreSQL dialects, and transactions.
Install
go get -tool github.com/go-kanna/kanna/cmd/kanna-orm
Annotate
//kanna:table opts a struct in; everything else is inferred from the fields and overridden with orm tags where the inference is not what the schema says.
package model
import "time"
//kanna:table
type User struct {
ID int // primary key by name
Name string // column "name"
Email string `orm:"email_address"` // explicit column name
CreatedAt time.Time // set automatically on create
Posts []Post `orm:"has_many,foreign_key:user_id"`
}
//kanna:table
type Post struct {
ID int
UserID int
Title string
User *User `orm:"belongs_to,foreign_key:user_id"`
}
Generate
//go:generate go tool kanna-orm -source ./model -destination ./query
The destination must be a package of its own — generating into the model package would leave it uncompilable whenever the output goes stale.
Use
db := orm.New(sqlDB, orm.MySQL) // or orm.PostgreSQL
users, err := query.Users(db).Where("name LIKE ?", "A%").OrderBy("id").All(ctx)
posts, err := query.Posts(db).Preload("User").All(ctx)
err = db.Transaction(ctx, func(tx orm.Querier) error {
return query.Users(tx).Create(ctx, &model.User{Name: "Alice"})
})
The first element of an orm tag is either a relation kind or a column name; everything after it is an option.
| Tag |
Meaning |
| (no tag) |
column inferred from the field name (CreatedAt → created_at) |
orm:"col_name" |
explicit column name |
orm:"-" |
not a column |
orm:",primary_key" |
primary key (default: the field named ID) |
orm:",created_at" / orm:",updated_at" |
timestamp managed on create/update (default: fields named CreatedAt/UpdatedAt) |
orm:"has_many,foreign_key:user_id" |
one-to-many; the field is a slice |
orm:"has_one,foreign_key:user_id" |
one-to-one; the target holds the key |
orm:"belongs_to,foreign_key:user_id" |
the owning side; this struct holds the key |
orm:"many_to_many,join_table:user_tags,foreign_key:user_id,references:tag_id" |
via a join table |
Table names are pluralized snake_case (UserProfile → user_profiles); //kanna:table name=people overrides one. Name inference is mechanical — there is deliberately no acronym dictionary, so a mixed-case name like OAuthToken takes its column from the tag. Anything malformed — an unknown option, a relation whose target generates no queries, a missing foreign key column — is a positioned error, not a silent skip.
Flags
| Flag |
Description |
-source <pkg> |
source package to scan |
-destination <dir> |
output directory for orm_gen.go |
-package <name> |
generated package name (defaults to what the destination declares) |
-check |
verify the output is up to date instead of writing it |
Example
examples/orm drives the generated queries end to end against real MySQL and PostgreSQL: scopes, joins, the three preload kinds, transactions, batch inserts, and upserts. CI regenerates it, runs it against both databases, and fails if the output would change.
kanna-i18n
Generates typed message constructors from a directory of locale files — and compiles the translations themselves into
the output, so nothing is parsed or read at run time.
Install
go get -tool github.com/go-kanna/kanna/cmd/kanna-i18n
go get github.com/go-kanna/kanna/i18n
Two lines, because this generator has a runtime half: both the generated code and the code calling Localize import
github.com/go-kanna/kanna/i18n, which carries the CLDR plural rules, locale-aware number formatting, and language
fallback that depend on run-time values.
Use
One file per language, named by its BCP 47 tag:
# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
plural:
one: "You have {count} item."
other: "You have {count} items."
total_price: "Total: {price:number}"
user:
not_found: "User not found."
//go:generate go tool kanna-i18n
With the defaults, that one line reads locales/ and writes messages/i18n_gen.go: a constructor per message, a
Localizer accessor, and the translations of every language as an embedded bundle.
Discovery is flat: one file defines one language, and subdirectories are never scanned. Nothing locale-shaped is
skipped in silence — a subdirectory or unreadable file named like a locale (locales/en/, en.json) fails the run,
and other non-locale files are skipped with a warning.
// Hello returns the "hello" message.
func Hello(name string) i18n.Message {
return i18n.Message{Key: "hello", Args: []i18n.Arg{
{Name: "name", Value: name},
}}
}
// Localizer renders this package's messages in the compiled locale best
// matching tag.
func Localizer(tag language.Tag) i18n.Localizer {
return bundle.Localizer(tag)
}
// bundle holds every locale this package was generated from.
var bundle = i18n.NewBundle("en",
i18n.Catalog{Lang: "en", Entries: map[string]i18n.Entry{
"hello": {Single: i18n.Template{{Text: "Hello, "}, {Param: "name"}, {Text: "!"}}},
// ...
}},
)
Calling it takes no setup at all:
en := messages.Localizer(language.English)
fmt.Println(en.Localize(messages.Hello("World")))
The default language (-default, en unless said otherwise) defines the constructor signatures; every other locale is
validated against it at generation time. At run time, a message missing from the requested language falls back through
the language's parents (en-GB falls back to en) and finally to the default language, and requesting a language that
was never compiled in gets the default outright.
Locale files
The locale directory is flat — one file per language, no recursion — and the filename stem is the language: en.yaml,
pt-BR.yaml, ja.toml. YAML (.yaml, .yml) and TOML (.toml) both work. Files whose stem is not a language tag are
skipped with a warning, so a stray config.yaml does not fail the run.
Nested mappings become dot-joined keys, and keys become constructor names: user.not_found generates UserNotFound().
Keys and parameter names match [a-z][a-z0-9_]* per segment.
A message declares its plural forms under an explicit plural mapping, keyed by CLDR category (zero, one, two,
few, many, other) and always defining other. The marker is explicit so intent is never guessed from shape: keys
that merely happen to be named one or other are ordinary nesting, and the only name a locale file cannot use freely
is a mapping-valued plural. The generated constructor takes count int first, and the count picks the variant under
the rendering language's own plural rules — Japanese has no one form, and that is fine.
A plural group that skips forms its language does use gets a warning, because those counts silently render with
other: Russian providing only other is missing one, few, and many, while Japanese providing only other is
complete.
Placeholders
| Form |
Meaning |
{name} |
a string parameter |
{name:int} |
a plain integer, rendered as-is — counts, IDs |
{name:number} |
a float64, rendered with the locale's conventions: 1,234.56 in en, 1.234,56 in de |
{{ and }} |
literal braces |
A bare placeholder inherits an explicit kind annotated elsewhere in the same message; conflicting annotations are an
error.
Validation
Errors fail the generation: a key a translation has but the default language does not, a plural group where the default
has a plain message (or the reverse), a parameter the default language never mentions, and conflicting kind annotations
across plural variants.
Missing translations are warnings, not errors, because the runtime falls back to the default language. The generated
code never breaks when a locale lags behind; it renders the default until the translation lands.
Flags
| Flag |
Meaning |
-locales <dir> |
directory containing locale files (default: locales) |
-default <lang> |
default language defining the generated signatures (default: en) |
-destination <dir> |
output directory for the generated file (default: messages) |
-package <name> |
package name of the generated file (default: base of the output directory) |
-check |
verify the output is up to date instead of writing it |
Example
examples/i18n renders the same messages in English and Japanese — plurals, locale-formatted numbers,
and a fallback for a missing translation — with zero run-time setup. CI regenerates it and fails if the output would
change.
Development
make test # unit tests
make lint # golangci-lint
make examples # regenerate every example, then build and run it
The repository is a Go workspace: go.work points the examples' tool directive at this checkout, so go generate
inside an example runs the generator you have locally rather than a published version.
License
MIT