# Tooling

> The linter, the formatter, and the Go API — what you get to check a segment or a workflow before it ever sends an email.

Source: https://www.sendlang.com/docs/tooling
Section: Introduction

Both languages ship as Go modules that only ever do three things: **parse**,
**analyze**, and **print**. They never evaluate. Execution lives in a separate
engine, which is why you can safely embed the parsers in an editor, a CI job, or
a code-generation tool without dragging a database along.

The [playground](/playground) is that embedding made visible: the same parser,
compiled to WebAssembly, checking a segment or a workflow in your browser as you
type. It is the fastest way to feel the two stages below.

## The two stages

Everything downstream depends on understanding that checking is two stages, not
one.

**Parse** is grammar only. It answers *is this a well-formed program?* and knows
nothing about your attributes or templates. It fails with a single positioned
error.

**Analyze** is meaning. It answers *does this program refer to things that exist,
and do the types line up?* It needs a **registry** — the set of attributes,
events, topics, templates and lists that exist in your account — and it returns
a list of diagnostics rather than stopping at the first one.

A query can parse perfectly and still be nonsense:

**Rejected — the parser refuses this:**

```sendql
attr.plna = "pro"
```

That is a grammatically flawless predicate. It fails in the second stage, with
`unknown attribute "plna"` — which is precisely the class of mistake a canvas
would have let you ship.

## sendflow-fmt

The formatter is `gofmt` for workflows: one canonical layout, no options to
argue about.

```
$ sendflow-fmt -w winback.flow      # rewrite the file in place
$ sendflow-fmt -l ./flows           # list files that are not formatted (exit 1 if any)
$ sendflow-fmt -d winback.flow      # show what would change
$ cat winback.flow | sendflow-fmt   # or just pipe it
```

`-l` is the CI gate: it exits non-zero if any file would change, which is all you
need to keep a repository of workflows from drifting into five different styles.

Formatting is a **fixed point**, not a byte-for-byte preservation:
`fmt(fmt(x)) == fmt(x)`. Your comments and your blank-line paragraphs survive;
your bespoke indentation does not. See [Formatting](/docs/sendflow/formatting).

## sendflow-lint

The linter parses, analyzes, and reports.

```
$ sendflow-lint -registry registry.json flows/*.flow
flows/winback.flow:8:3: warning: unreachable: this statement follows an exit
flows/onboarding.flow: ok
```

It exits `0` when clean, `1` on a parse failure or any error-severity
diagnostic, and `2` on a usage or I/O problem. Warnings alone do not fail the
build — which is deliberate, because the most useful lint in the language (the
[absence lint](/docs/sendql/absence)) is a warning you will sometimes correctly
ignore.

Without `-registry` you get **structural lints only** — split weights, empty
blocks, bounded repeats. It cannot tell you that `attr.plna` does not exist,
because you have not told it what does. The registry is a plain JSON file:

```json
{
  "attributes": { "plan": "string", "signup_date": "datetime" },
  "events": { "open": { "template": "string" } },
  "topics": ["onboarding"],
  "templates": ["welcome"],
  "lists": ["engaged"]
}
```

## The Go API

The surface is small enough to fit on one page.

```go
// SendQL: a predicate.
pred, err := sendql.Parse(`attr.plan = "pro" and count(open within 30d) >= 3`)
diags := sendql.Analyze(pred, registry)   // resolves names, checks types
warn  := sendql.CheckAbsence(pred)        // the null-contract lint, separately

// SendFlow: a workflow.
wf, err := sendflow.ParseFile(src)        // ParseFile keeps comments; Parse drops them
diags   := sendflow.Analyze(wf, registry, workflowRegistry)
out     := sendflow.Print(wf)             // canonical text
```

A few things worth knowing:

- **`Analyze` mutates the AST in place**, annotating it with the types it
  resolved. That is what makes the analyzed tree useful to a downstream compiler.
- **`Diagnostics` deliberately does not implement `error`.** Call `.Err()`, which
  returns `nil` when the slice is empty. A nil slice stuffed into an `error`
  interface is not nil, and that trap has been closed on purpose.
- **`CheckAbsence` is separate from `Analyze` in SendQL** but folded into it in
  SendFlow. If you are calling SendQL directly and want the warning, ask for it.
- **`ParseFile` attaches comments; `Parse` does not.** Use `ParseFile` for
  anything that will re-emit source.
- **`GrammarEBNF()`** returns the language's own grammar, generated from the
  parser. Every EBNF block in [the reference](/docs/reference/grammar) is that
  function's output, not a hand transcription.

## Getting the parsers

The parsers are not distributed on their own today — they ship inside
[SendOps](https://sendops.dev), which uses these exact modules to validate every
segment and workflow you save. Writing a segment in the app and writing one in a
`.sendql` file are the same act, checked by the same code.

So the fastest way to run either language against real data is SendOps itself.
Everything on this site describes the languages as SendOps implements them; if
you need the parsers standalone, get in touch.
