Move how the template language is written into internal/grammar

This commit is contained in:
2026-10-05 12:11:36 +02:00
committed by Lillem4n
parent 41292f9d89
commit c2f2e86735
21 changed files with 903 additions and 853 deletions
+159
View File
@@ -0,0 +1,159 @@
// Package grammar is how the template language is written: format tokens, paths and
// their selectors, reference sigils, names, and calc expressions. It reads strings only.
package grammar
import (
"fmt"
"strings"
)
// scanUnit is one unit of a scanned format string: a literal rune or the body of a
// {…} token.
type scanUnit struct {
isToken bool // a {…} body, else a literal rune
char rune
body string
}
// eachScanUnit scans a format string once and calls fn for each unit, the single
// source of truth for how braces are read: "{{" and "}}" are literal braces, a "{"
// opens a token that must reach its "}", and a lone "}" is an error.
func eachScanUnit(format string, fn func(scanUnit) error) error {
rs := []rune(format)
for i := 0; i < len(rs); i++ {
var u scanUnit
switch c := rs[i]; c {
case '{':
if i+1 < len(rs) && rs[i+1] == '{' {
u.char = '{'
i++
break
}
end := i + 1
for end < len(rs) && rs[end] != '}' {
if rs[end] == '{' {
return fmt.Errorf("'{' inside a token in %q; a literal brace is written {{", format)
}
end++
}
if end >= len(rs) {
return fmt.Errorf("unterminated '{' in %q", format)
}
u.isToken, u.body = true, string(rs[i+1:end])
i = end
case '}':
if i+1 < len(rs) && rs[i+1] == '}' {
u.char = '}'
i++
break
}
return fmt.Errorf("lone '}' in %q; a literal brace is written }}", format)
default:
u.char = c
}
if err := fn(u); err != nil {
return err
}
}
return nil
}
// Token is one parsed unit of a format.
type Token struct {
Kind TokenKind
Lit string // LiteralRun
Body string // the braces' content, as written
Fn string // BuiltinCall
Args []string // BuiltinCall
Names []string // NameRead: the '|' arms; BuiltinCall: the operands its builtin reads
// NameBind: the reference it binds, and the name.
BoundRef, Bound string
}
type TokenKind uint8
const (
BuiltinCall TokenKind = iota + 1
NameRead
LiteralRun
NameBind
)
const AsWord = " as "
// ParseFormat is the one reading of a format's tokens: a '(' outside a selector makes
// a token a call. operands lists the operands a call reads; nil reads none.
func ParseFormat(format string, operands func(fn string, args []string) []string) ([]Token, error) {
var toks []Token
var lit strings.Builder
flush := func() {
if lit.Len() > 0 {
toks = append(toks, Token{Kind: LiteralRun, Lit: lit.String()})
lit.Reset()
}
}
err := eachScanUnit(format, func(u scanUnit) error {
if !u.isToken {
lit.WriteRune(u.char)
return nil
}
flush()
if ref, name, binds := CutOutside(u.body, AsWord); binds && IndexOutside(u.body, '(') < 0 {
toks = append(toks, Token{Kind: NameBind, Body: u.body, BoundRef: ref, Bound: name})
return nil
}
if IndexOutside(u.body, '(') < 0 {
toks = append(toks, Token{Kind: NameRead, Body: u.body, Names: SplitOutside(u.body, '|')})
return nil
}
name, args, ok := FuncCall(u.body)
if !ok {
return fmt.Errorf("malformed function token {%s}", u.body)
}
tok := Token{Kind: BuiltinCall, Body: u.body, Fn: name, Args: args}
if operands != nil {
tok.Names = operands(name, args)
}
toks = append(toks, tok)
return nil
})
if err != nil {
return nil, err
}
flush()
return toks, nil
}
// FuncCall splits a "{token}" body shaped name(args) into its parts; ok is false
// for a name-read body. A '(' without a trailing ')' yields ok=false.
func FuncCall(body string) (name string, args []string, ok bool) {
lp := IndexOutside(body, '(')
if lp < 0 || !strings.HasSuffix(body, ")") {
return "", nil, false
}
return body[:lp], SplitArgs(body[lp+1 : len(body)-1]), true
}
// SplitArgs parses a function arg list: comma-separated outside a selector or a
// quoted layout, trimmed; empty -> none.
func SplitArgs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
var args []string
depth, quoted, start := 0, false, 0
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c == '\'' && depth == 0:
quoted = !quoted
case quoted:
case c == '[':
depth++
case c == ']' && depth > 0:
depth--
case c == ',' && depth == 0:
args, start = append(args, strings.TrimSpace(s[start:i])), i+1
}
}
return append(args, strings.TrimSpace(s[start:]))
}