cadishfile

package
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

Documentation

Overview

Package cadishfile implements a lexer, parser, AST, and formatter for the Cadishfile: cadish's flat, Caddy-style configuration format.

The package is purely STRUCTURAL. It faithfully represents a Cadishfile as an abstract syntax tree (AST) but knows nothing about the semantics of individual directives or matchers — that validation belongs to later milestones (the pipeline compiler and `cadish check`). Concretely, the parser will happily accept a directive named "frobnicate" with arbitrary arguments; it is not this package's job to decide whether that directive exists or what it means.

The grammar implemented here is "form A" from the cadish design document: a sequence of site blocks, each holding a flat list of matcher definitions (@name ...) and directives (name ... { ... }). See docs/cadishfile-grammar.md for the full grammar and the directive/matcher catalog.

The package is stdlib-only (zero external dependencies).

Index

Constants

This section is empty.

Variables

View Source
var DefaultDirectives = []string{
	"tls",
	"cache",
	"upstream",
	"cluster",
	"origin",
	"pass",

	"upgrade",
	"cache_key",
	"cache_ttl",

	"cache_unsafe",

	"cache_credentialed",

	"client_cache_control",
	"storage",
	"lb",
	"sticky",
	"host_header",

	"sni",
	"http_reuse",

	"tls_insecure",
	"ca_file",
	"alpn",

	"resolve",
	"header",
	"strip_cookies",

	"cookie_allow",
	"route",
	"rewrite",
	"respond",
	"redirect",
	"purge",
	"cors",
	"import",
	"device_detect",
	"geo",

	"trust_proxy",
	"normalize",
	"tenant",
	"replace",
	"encode",
	"classify",
	"edge",

	"access_log",

	"strict_host",

	"admin",

	"proxy_protocol",

	"security",

	"server",

	"allow",
	"deny",
	"block",
	"monitor",

	"rate_limit",
}

DefaultDirectives is the catalog of directive keywords implemented for the v1 (form A) Cadishfile, taken from the cadish design document §4 and the canonical example configs. It is provided as a convenience seed for DirectiveRegistry; the parser does not use it. Keeping it here (rather than hard-coded in the pipeline) gives tooling one authoritative list.

View Source
var DefaultMatcherTypes = []string{
	"path",
	"path_regex",
	"host",
	"host_regex",
	"header",

	"header_present",

	"header_regex",
	"method",
	"upstream",
	"content_type",

	"resp_header",
	"cookie",

	"cookie_json",
	"header_json",
	"set_cookie",
	"classify",
	"geo",
	"query_present",

	"query",
	"ip",

	"all",

	"upstream_healthy",
}

DefaultMatcherTypes is the catalog of matcher type keywords implemented for v1 (form A). Like DefaultDirectives it is informational; the parser accepts any matcher type. Provided so `cadish check` can warn on unknown matcher types.

View Source
var GlobalBlockDirectives = map[string]bool{
	"server":         true,
	"admin":          true,
	"access_log":     true,
	"strict_host":    true,
	"security":       true,
	"proxy_protocol": true,
}

GlobalBlockDirectives is the AUTHORITATIVE allowlist of directive keywords that a consumer reads out of the leading global-options block ("{ … }" at the very top of a Cadishfile). Each entry corresponds to exactly ONE config-layer consumer that scans f.Global.Body for its own directive:

server         → config.serverConfigFromFile   (internal/config/server.go)
admin          → config.adminFromFile          (internal/config/admin.go)
access_log     → config.accessLogOffFromFile   (internal/config/admin.go)
strict_host    → config.strictHostFromFile     (internal/config/admin.go)
security       → config.securityFromFile       (internal/config/security.go)
proxy_protocol → config.proxyProtocolFromFile  (internal/config/proxyprotocol.go)

A TOP-LEVEL directive in the global block whose keyword is NOT in this set is read by no consumer and is SILENTLY IGNORED at runtime — the CAD-62 trap: a production config carried `trust_proxy <CF ranges>` in the global block for weeks; it parsed clean, checked clean, and did nothing (trust_proxy is site-level only), so real-IP restoration behind Cloudflare never happened. `cadish check` and `cadish run` both surface such a directive via UnconsumedGlobalDirectives.

SINGLE SOURCE OF TRUTH: when you add a new consumer of f.Global.Body, add its directive keyword HERE. Two guards keep it honest:

  • a config-package test (global_consumer_guard_test.go) scans every f.Global.Body consumer in internal/config and FAILS if one reads a directive missing from this map;
  • pipeline.Compile derives its global-only site-body rejection from this map (IsGlobalBlockDirective), so the "reject a global block misplaced in a site body" set can never drift from the "allow it in the global block" set.

Functions

func ContainsEnvPlaceholder added in v0.2.1

func ContainsEnvPlaceholder(s string) bool

ContainsEnvPlaceholder reports whether s contains an UNESCAPED "{$" env-expansion span — the trigger for SubstituteEnv (including the R07 quoted-literal expansion). A backslash-escaped "\{" is not a placeholder and is skipped. It is the canonical guard for any externally-influenced value (k8s Ingress/Gateway tenant match names, header/ query values, methods, paths) concatenated into GENERATED Cadishfile text: such a value must never be allowed to expand against the controller pod's environment when the combined config is loaded (config.loadFromSource -> SubstituteEnv(os.LookupEnv)), which would leak the admin token / any secret. QuoteArg quotes but does not neutralize "{$" (and there is no value-preserving escape — "\{$" survives lexing as a literal backslash), so generation code must REJECT a tenant token for which this returns true rather than emit it.

func Format

func Format(src []byte) ([]byte, error)

Format rewrites a Cadishfile into canonical form: the Cadishfile equivalent of gofmt. It guarantees:

  • 4-space indentation, one level per nested block;
  • exactly one statement per line (statements separated by ";" in the input are split onto their own lines);
  • opening braces stay on the statement line ("name {"), closing braces on their own line at the parent indentation;
  • line-continuation backslashes are removed (continued arguments are joined onto one line);
  • runs of blank lines are collapsed to at most one;
  • comments are preserved: a full-line comment stays on its own line at the current indentation, and a trailing comment ("... # note") stays at the end of its statement line;
  • the output ends with exactly one trailing newline.

Format requires the input to PARSE: it lexes and parses the source first (so it reports the same lexical AND syntactic errors as the parser, e.g. an unterminated quoted string or an unterminated block) and refuses to emit a partial/truncated result for a config that does not parse. This prevents `cadish fmt -w` from corrupting a file by writing an unclosed block. Once the input is known to parse, Format does its work at the token level (so comments and blank-line spacing are preserved verbatim).

Format is idempotent: Format(Format(x)) == Format(x).

func IsGlobalBlockDirective added in v0.2.3

func IsGlobalBlockDirective(name string) bool

IsGlobalBlockDirective reports whether name is a directive that a consumer reads from the leading global-options block (see GlobalBlockDirectives).

func IsGroup

func IsGroup(s *Site) bool

IsGroup reports whether a site is a `group { … }` block.

func QuoteArg added in v0.2.1

func QuoteArg(text string) string

QuoteArg renders an arbitrary string as a SINGLE Cadishfile word token, double- quoting and escaping it whenever a bare rendering would not survive a re-lex as one word. It is the canonical quoter for any externally-influenced value concatenated into GENERATED Cadishfile text (k8s Ingress / Gateway match names, header/query values, methods, paths): a hostile value must never be able to break out of its directive or its enclosing block.

Beyond the round-trip cases needsQuoting already covers (empty, leading '#', trailing '\', whitespace, '"', ';'), QuoteArg ALSO force-quotes the block-structural braces '{' and '}'. The lexer never emits a brace as part of a bare word — a '}' (or a stray, non-placeholder '{') terminates the current word and is re-read as a structural token — so an unquoted value containing a brace would silently close/open a block. needsQuoting deliberately ignores braces (real source never needs them quoted for a round trip); generated text built from untrusted input does, so QuoteArg adds them.

func SubstituteEnv

func SubstituteEnv(f *File, lookup func(name string) (string, bool))

SubstituteEnv expands environment-variable placeholders of the form "{$VAR}" in every argument of the AST, in place, using the provided lookup function.

This is a separate, opt-in pass rather than something baked into the lexer or parser: `cadish check` and `cadish fmt` must be able to operate on a config without a populated environment (and without leaking host environment values into formatted output). Pass os.LookupEnv (wrapped) for the real environment, or a test stub.

Only "{$VAR}" spans are substituted. Generic placeholders like "{device}" or "{http.X-Foo}" are left untouched — those are runtime placeholders resolved by the pipeline, not environment variables. If a referenced variable is not found by lookup, the placeholder is replaced with the empty string (matching Caddy's behavior). After substitution an argument's Kind is recomputed: a token that no longer contains any "{" placeholder is reclassified from ArgPlaceholder to ArgLiteral (or ArgMatcherRef if it begins with "@").

lookup receives the bare variable name (without "$" or braces) and returns its value and whether it was set.

Types

type Arg

type Arg struct {
	Raw    string
	Quoted bool
	Kind   ArgKind
	Pos    Pos
}

Arg is a single argument token of a matcher definition or directive. It carries the raw (unquoted) token text, whether it was quoted in source, its syntactic Kind, and its source position.

Raw is the token text as the lexer produced it: for a quoted argument the surrounding quotes are stripped and escapes resolved, but for a placeholder the "{...}" is preserved verbatim so it can be substituted later (see SubstituteEnv). Env substitution is deliberately NOT applied at parse time.

func (Arg) String

func (a Arg) String() string

String returns the argument's raw text (useful in tests and debugging).

type ArgKind

type ArgKind int

ArgKind classifies an argument token by the structural shape of its text. This is a syntactic classification only; it does not imply the reference or placeholder actually resolves to anything.

const (
	// ArgLiteral is a plain literal argument: a bare word or quoted string that
	// is neither a matcher reference nor a placeholder.
	ArgLiteral ArgKind = iota
	// ArgMatcherRef is an argument that references a named matcher: an unquoted
	// token beginning with "@" (e.g. "@images"). A quoted "@x" is ArgLiteral.
	ArgMatcherRef
	// ArgPlaceholder is an argument that contains a placeholder, i.e. a "{...}"
	// span — either an environment placeholder "{$VAR}" or a generic
	// placeholder such as "{device}" or "{http.X-Foo}". The whole token is
	// classified as a placeholder if it contains any unescaped "{".
	ArgPlaceholder
)

func (ArgKind) String

func (k ArgKind) String() string

String returns a human-readable name for the argument kind.

type Directive

type Directive struct {
	Name     string
	Args     []Arg
	Block    []Node
	HasBlock bool
	Pos      Pos
}

Directive is a directive statement: "name arg..." optionally followed by a nested "{ ... }" block of further statements.

Examples:

tls { acme x }            => Directive{Name:"tls", Block:[Directive{acme x}]}
cache_key url host        => Directive{Name:"cache_key", Args:[url, host]}
pass @ajax                => Directive{Name:"pass", Args:[@ajax (matcher-ref)]}

Name is the directive keyword (not validated). Args are its arguments. Block is the nested statement list; it is nil when the directive has no block. Note that an empty but present block ("name { }") yields a non-nil, zero-length Block, distinguishing it from no block at all.

func (*Directive) Position

func (d *Directive) Position() Pos

type DirectiveRegistry

type DirectiveRegistry struct {
	// contains filtered or unexported fields
}

DirectiveRegistry is a set of known directive names. It is a hook for later milestones (the pipeline compiler and `cadish check`) to warn about unknown directives. The parser itself stays semantics-free and never consults a registry — this type exists purely so downstream code has a single, shared, well-documented place to record which directive keywords are recognized.

The zero value is not usable; construct one with NewDirectiveRegistry.

func NewDefaultDirectiveRegistry

func NewDefaultDirectiveRegistry() *DirectiveRegistry

NewDefaultDirectiveRegistry returns a DirectiveRegistry seeded with DefaultDirectives.

func NewDirectiveRegistry

func NewDirectiveRegistry(names ...string) *DirectiveRegistry

NewDirectiveRegistry returns a registry seeded with the given directive names.

func (*DirectiveRegistry) Add

func (r *DirectiveRegistry) Add(names ...string)

Add records one or more directive names as known. It is idempotent.

func (*DirectiveRegistry) Has

func (r *DirectiveRegistry) Has(name string) bool

Has reports whether name is a known directive.

func (*DirectiveRegistry) Names

func (r *DirectiveRegistry) Names() []string

Names returns the known directive names (order is unspecified).

type File

type File struct {
	// Global is the optional leading global-options block ("{ ... }" at the very
	// top of the file). It is nil when absent. It is intentionally a thin stub:
	// this milestone does not interpret global options, it only records that one
	// was present and keeps its raw statements.
	Global *Options
	// Sites is the ordered list of site blocks in the file.
	Sites []*Site
	// Body holds top-level statements that are not wrapped in a site block.
	// A complete config is normally all Sites, but an importable sub-config
	// (e.g. nocache.cadish, brought in via `import`) is a bare list of matcher
	// definitions and directives with no site wrapper; those land here. Body and
	// Sites may both be populated, though mixing them in one file is unusual.
	Body []Node
}

File is the root of a parsed Cadishfile. It is a sequence of site blocks, optionally preceded by a single global options block.

The parser is semantics-free: Sites and their bodies are represented faithfully but never interpreted. Comments are not part of the AST (they are preserved by the token-level formatter, not here).

func Parse

func Parse(filename string, src []byte) (*File, error)

Parse parses src (the contents of a Cadishfile) and returns its AST. The filename is used only for position reporting in errors and AST positions; it need not exist on disk. On a lexical or syntax error it returns a *ParseError.

Parse does NOT perform environment-variable substitution; call SubstituteEnv on the result if you want "{$VAR}" placeholders expanded. This separation lets `cadish check` and `cadish fmt` operate without a populated environment.

func ParseFile

func ParseFile(path string) (*File, error)

ParseFile reads the file at path and parses it. The path is used as the filename for positions.

type GlobalDirectiveIssue added in v0.2.3

type GlobalDirectiveIssue struct {
	// Name is the offending directive keyword.
	Name string
	// Pos is its source position (for a file:line:col diagnostic).
	Pos Pos
	// SiteLevel is true when Name is a KNOWN site-level directive misplaced into the
	// global block (e.g. trust_proxy, cache_ttl) — the hint then says "move it into
	// each site block". False for an unrecognized keyword (a typo / stray option).
	SiteLevel bool
}

GlobalDirectiveIssue describes one TOP-LEVEL directive in the leading global-options block that no consumer reads — dead config that parses and checks clean but never takes effect at runtime.

func UnconsumedGlobalDirectives added in v0.2.3

func UnconsumedGlobalDirectives(f *File) []GlobalDirectiveIssue

UnconsumedGlobalDirectives returns every TOP-LEVEL directive of the leading global-options block whose keyword is not in GlobalBlockDirectives — i.e. dead config that no consumer reads. It is the SHARED detector behind both `cadish check` (a warning; an error under -strict) and `cadish run` (a load-time log warning), so the two can never disagree (the check≡run invariant).

Only TOP-LEVEL directives of the global block are inspected: the INNER directives of a CONSUMED block (e.g. `audit_log` inside `security { … }`, `listen`/`auth_token` inside `admin { … }`, `trust` inside `proxy_protocol { … }`) are that consumer's own vocabulary and are never flagged here. A non-directive node (a matcher def) and a bare `import` (a documented no-op that other passes handle) are skipped.

func (GlobalDirectiveIssue) Message added in v0.2.3

func (i GlobalDirectiveIssue) Message() string

Message renders the operator-facing warning for the issue. A misplaced site-level directive gets the specific "move it into each site block" remedy (the CAD-62 case); an unrecognized keyword gets the generic "no consumer reads it" wording.

type GroupError

type GroupError struct {
	Pos Pos
	Msg string
}

GroupError is a positioned error from group expansion.

func (*GroupError) Error

func (e *GroupError) Error() string

type MatcherDef

type MatcherDef struct {
	Name string
	Type string
	Args []Arg
	Pos  Pos
}

MatcherDef is a matcher definition statement: "@name type arg...".

Example: "@nocache path /a/* /b/*" parses to MatcherDef{Name:"nocache", Type:"path", Args:[/a/*, /b/*]}.

The leading "@" is stripped from Name. Type is the matcher type keyword (e.g. "path", "host_regex", "header"); it is NOT validated. A matcher definition has no nested block.

func (*MatcherDef) Position

func (m *MatcherDef) Position() Pos

type Node

type Node interface {
	// Position returns the node's source position (the first token).
	Position() Pos
	// contains filtered or unexported methods
}

Node is a single statement inside a site body or a nested directive block. It is implemented by *MatcherDef and *Directive. The unexported node() method keeps the interface closed to this package's types.

func ParseFragment added in v0.2.1

func ParseFragment(filename string, src []byte) ([]Node, error)

ParseFragment parses src as an imported fragment body: the SAME grammar as a site body — a flat list of statements where each statement is a matcher definition ("@name type arg…") or a directive that MAY carry a trailing "{ … }" block. It returns the statement nodes, ready to be spliced in place of an `import` directive at its site-body position.

Unlike Parse — which at the top level reads "addrs… { … }" as a SITE block (addresses + body) — ParseFragment routes the whole token stream through the block-body grammar. A brace-bodied directive in a fragment (classify {…}, upstream {…}, tls {…}, cache {…}, geo {…}, device_detect {…}, …) therefore associates its body into a Directive.Block exactly as it would inline at the splice point, instead of being mis-read as a site header and flattened into orphaned top-level statements. A malformed/unclosed block, or a stray "}", is a positioned *ParseError. The filename is used only for error/AST positions; the AST stays semantics-free (no directive is interpreted).

func ParseFragmentFile added in v0.2.1

func ParseFragmentFile(path string) ([]Node, error)

ParseFragmentFile reads the file at path and parses it as an imported fragment body (see ParseFragment). The path is used as the filename for positions.

type Options

type Options struct {
	Body []Node
	Pos  Pos
}

Options is the global options block. It is a stub for this milestone: the body is parsed as an ordinary statement list (the same Node types as a site body) but no global-specific semantics are applied. Later milestones may replace Body with structured fields.

type ParseError

type ParseError struct {
	File string
	Line int
	Col  int
	Msg  string
}

ParseError is the error type returned by all parsing and lexing failures. It formats as "file:line:col: message", the conventional compiler diagnostic shape, so callers (e.g. `cadish fmt`) can print actionable errors.

func (*ParseError) Error

func (e *ParseError) Error() string

Error implements the error interface, rendering "file:line:col: message".

type Pos

type Pos struct {
	File string
	Line int
	Col  int
}

Pos is a source position attached to AST nodes for error reporting and tooling. It is 1-based; a zero Pos means "unknown / synthesized".

func (Pos) String

func (p Pos) String() string

String renders the position as "file:line:col" (the conventional compiler format). A missing file is rendered as "<input>".

type Site

type Site struct {
	// Addresses are the site header tokens, in source order, with any
	// separating commas removed (a trailing comma on a token is stripped).
	Addresses []string
	// Body is the ordered statement list: MatcherDef and Directive nodes.
	Body []Node
	Pos  Pos
}

Site is one site block: one or more comma-separated address tokens followed by a "{ ... }" body of statements.

Addresses are kept as raw token strings and are NOT validated or normalized (e.g. "example.com", "*.example.com"); semantic interpretation of addresses belongs to a later milestone.

func ExpandGroups

func ExpandGroups(sites []*Site) ([]*Site, error)

ExpandGroups replaces every `group` site in sites with one ordinary site per tenant (base ⊕ tenant overrides). Non-group sites pass through unchanged. It returns a *GroupError on a malformed group.

type Token

type Token struct {
	Kind TokenKind
	// Text is the token's value. For TokenWord it is the unquoted text (the
	// enclosing double quotes, if any, are stripped and escapes resolved). For
	// TokenComment it is the comment text including the leading '#'. For
	// punctuation and newline/EOF it is the literal symbol or empty.
	Text string
	File string
	Line int
	Col  int

	// Quoted reports whether a TokenWord was written as a double-quoted string
	// in the source. The formatter uses this (and whether the text needs
	// quoting) to decide how to re-emit the token; the parser uses it so that a
	// quoted "@x" is treated as a literal, not a matcher reference.
	Quoted bool

	// BlankBefore is the number of blank source lines immediately preceding
	// this token's line. It is only meaningful on the first token of a line.
	// The formatter clamps it to at most one blank line.
	BlankBefore int
}

Token is a single lexical unit carrying its source position. Positions are 1-based; Col is a rune (not byte) column so multi-byte UTF-8 input reports sensible columns.

func (Token) String

func (t Token) String() string

String renders a token compactly for debugging and test output.

type TokenKind

type TokenKind int

TokenKind classifies a lexical token.

const (
	// TokenWord is a bare word, quoted string, placeholder, or matcher
	// reference — anything that is a single "argument-like" unit. The lexer
	// does not distinguish placeholders or matcher refs from plain words; that
	// classification happens in the parser (see Arg.Kind), because it is a
	// structural property of the token text, not of how it was lexed.
	TokenWord TokenKind = iota
	// TokenOpenBrace is "{" used to open a block. Note: a "{" that is the start
	// of a placeholder such as "{$VAR}" or "{device}" is NOT this kind; the
	// lexer keeps placeholders together as a single TokenWord.
	TokenOpenBrace
	// TokenCloseBrace is "}" used to close a block.
	TokenCloseBrace
	// TokenSemicolon is ";", an explicit statement separator.
	TokenSemicolon
	// TokenNewline marks the end of a logical line. Consecutive newlines are
	// coalesced into a single TokenNewline by the lexer, but the original blank
	// lines are preserved via the BlankBefore count so the formatter can keep
	// (normalized) paragraph spacing.
	TokenNewline
	// TokenComment is a "# ..." comment running to end of line. Comments are
	// emitted as tokens (rather than discarded) so the formatter can preserve
	// them. The parser ignores them for AST construction.
	TokenComment
	// TokenEOF marks end of input.
	TokenEOF
)

func (TokenKind) String

func (k TokenKind) String() string

String returns a human-readable name for the token kind, used in error messages and tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL