Custom dialects

Experimental. The dialect API, .synq format, codegen output, and shared-library ABI are not yet stable and can change between releases. Pin your syntaqlite version when you ship a dialect.

If you have SQL that extends SQLite — custom statements, extra functions, new clauses — you can package it as a dialect that syntaqlite's parser, formatter, and analyzer will understand. This guide walks an end-to-end dialect from source files to a loaded shared library, using the in-tree Perfetto dialect as a running example.

1. Write the grammar

A dialect is a directory of .synq node definitions plus optional .y grammar action files. .synq is syntaqlite's grammar DSL: each node declares an AST shape with typed fields, optional semantic annotations, and a fmt block for pretty-printing.

Minimal example (from dialects/perfetto/nodes/perfetto.synq):

node CreatePerfettoMacroStmt {
  name: index SqliteIdent
  args: index PerfettoMacroArgList
  returns: inline PerfettoMacroReturns
  body: index Expr

  fmt {
    group {
      "CREATE PERFETTO MACRO" line
      child(name)
      "(" nest { softline child(args) } softline ")"
      line "RETURNS" " " child(returns)
      line "AS" nest { line child(body) }
    }
  }
}

.y action files in a parallel actions/ directory hook the dialect's rules into the base SQLite grammar; simple dialects that only add functions or reserved words may not need any.

2. Generate C sources

syntaqlite dialect generate reads your .synq (and optional .y) files and emits a C amalgamation — the runtime plus your dialect, inlined into a single .h/.c pair:

syntaqlite dialect generate \
  --name perfetto \
  --nodes-dir dialects/perfetto/nodes \
  --actions-dir dialects/perfetto/actions \
  --output-dir out \
  --output-type full

Produces:

out/syntaqlite_perfetto.h
out/syntaqlite_perfetto.c

The --name flag drives all generated symbol names: the dialect exposes syntaqlite_perfetto_dialect_template(), which is what the loader looks for at runtime.

Other --output-type values split the artifacts differently (raw for flat C files per module, runtime-only to skip the dialect body); full is the right default for shipping a self-contained shared library.

3. Compile to a shared library

The generated .c is pure C, no Rust toolchain required. Build it as a shared library for the target platform:

# Linux / macOS
cc -O2 -shared -fPIC -o libperfetto.dylib out/syntaqlite_perfetto.c

On Linux the extension is .so; on Windows, build a .dll with your C compiler's equivalent of -shared.

Confirm the loader symbol is exported:

nm libperfetto.dylib | grep dialect_template
# T _syntaqlite_perfetto_dialect_template

4. Use the dialect at runtime

Pass the library and dialect name to any syntaqlite subcommand:

syntaqlite fmt \
  --dialect ./libperfetto.dylib \
  --dialect-name perfetto \
  -e "create perfetto macro m() returns int as 42"
CREATE PERFETTO MACRO m()
RETURNS int
AS 42;

The --dialect-name matches the --name you passed to dialect generate. Omit it to resolve the default syntaqlite_dialect_template symbol, which is what an unnamed dialect exports.

To bake the choice into a project, set the dialect in syntaqlite.toml; see the config reference.

Alternative: compile the dialect into your binary

Dynamic loading is the fastest path to "works with the stock CLI", but it adds a runtime dependency and a symbol-resolution step. If you're shipping your own binary (for example, a syntaqlite-mydialect wrapper), compile the dialect in directly via the Rust CliApp trait. See syntaqlite-cli/examples/cli_wrapper.rs for a complete example.

Trade-offs:

Dynamic (--dialect)Static (CliApp)
DistributionOne shared library, stock CLISingle binary, your own name
Startup costdlopen on every invocationNone
ABI stabilityNeeded — library and CLI must matchNot needed — compiled together
ToolingWorks with any syntaqlite installRequires the Rust toolchain to build

Next steps