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
+194
View File
@@ -0,0 +1,194 @@
package grammar
import (
"fmt"
"slices"
"strconv"
"unicode"
)
// CalcNode is a parsed calc expression node.
type CalcNode interface{ calcNode() }
type CalcNum float64 // a number literal
// CalcVar is an operand, a sibling field or a name. At is its position among the
// distinct operands in the order the expression first names each, which CalcVars lists.
type CalcVar struct {
Name string
At int
}
type CalcNeg struct{ X CalcNode }
type CalcBin struct { // L + - * / R
Operator byte
L, R CalcNode
}
func (CalcNum) calcNode() {}
func (CalcVar) calcNode() {}
func (CalcNeg) calcNode() {}
func (CalcBin) calcNode() {}
// CalcVars lists the distinct operands an expression reads, each at its At.
func CalcVars(n CalcNode) []string {
var out []string
var walk func(CalcNode)
walk = func(n CalcNode) {
switch n := n.(type) {
case CalcVar:
if n.At == len(out) {
out = append(out, n.Name)
}
case CalcNeg:
walk(n.X)
case CalcBin:
walk(n.L)
walk(n.R)
}
}
walk(n)
return out
}
// CalcText spells an expression node the way an author would read it.
func CalcText(n CalcNode) string {
switch n := n.(type) {
case CalcNum:
return strconv.FormatFloat(float64(n), 'f', -1, 64)
case CalcVar:
return n.Name
case CalcNeg:
return "-" + CalcText(n.X)
case CalcBin:
return "(" + CalcText(n.L) + " " + string(n.Operator) + " " + CalcText(n.R) + ")"
}
return "?"
}
// calcParser is a recursive-descent parser over the expression runes, threading
// expr -> term -> factor for the standard * / before + - precedence.
type calcParser struct {
rs []rune
pos int
vars map[string]int
}
// ParseCalc parses a whole expression, requiring it to consume all input.
func ParseCalc(expr string) (CalcNode, error) {
p := &calcParser{rs: []rune(expr), vars: map[string]int{}}
if p.space(); p.pos >= len(p.rs) {
return nil, fmt.Errorf("empty expression")
}
n, err := p.expr()
if err != nil {
return nil, err
}
if p.space(); p.pos != len(p.rs) {
return nil, fmt.Errorf("unexpected %q", string(p.rs[p.pos:]))
}
return n, nil
}
func (p *calcParser) space() {
for p.pos < len(p.rs) && unicode.IsSpace(p.rs[p.pos]) {
p.pos++
}
}
func (p *calcParser) expr() (CalcNode, error) { return p.binary(p.term, '+', '-') }
func (p *calcParser) term() (CalcNode, error) { return p.binary(p.factor, '*', '/') }
// binary parses a left-associative run of next() operands joined by the given
// operators, the one shape expr and term share.
func (p *calcParser) binary(next func() (CalcNode, error), ops ...byte) (CalcNode, error) {
n, err := next()
if err != nil {
return nil, err
}
for {
p.space()
if p.pos >= len(p.rs) || !slices.Contains(ops, byte(p.rs[p.pos])) {
return n, nil
}
op := byte(p.rs[p.pos])
p.pos++
r, err := next()
if err != nil {
return nil, err
}
n = CalcBin{op, n, r}
}
}
func (p *calcParser) factor() (CalcNode, error) {
p.space()
if p.pos >= len(p.rs) {
return nil, fmt.Errorf("unexpected end of expression")
}
switch c := p.rs[p.pos]; {
case c == '-':
p.pos++
x, err := p.factor()
if err != nil {
return nil, err
}
return CalcNeg{x}, nil
case c == '(':
p.pos++
n, err := p.expr()
if err != nil {
return nil, err
}
if p.space(); p.pos >= len(p.rs) || p.rs[p.pos] != ')' {
return nil, fmt.Errorf("missing ')'")
}
p.pos++
return n, nil
case c == '.' || c >= '0' && c <= '9':
return p.number()
case c == '_' || unicode.IsLetter(c):
return p.ident()
default:
return nil, fmt.Errorf("unexpected %q", string(c))
}
}
func (p *calcParser) number() (CalcNode, error) {
start, dot := p.pos, false
for p.pos < len(p.rs) {
if c := p.rs[p.pos]; c >= '0' && c <= '9' {
p.pos++
} else if c == '.' && !dot {
dot, p.pos = true, p.pos+1
} else {
break
}
}
v, err := strconv.ParseFloat(string(p.rs[start:p.pos]), 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", string(p.rs[start:p.pos]))
}
return CalcNum(v), nil
}
// ident reads a field or name: a letter or '_', then letters, digits or '_'. A '-'
// is always the minus operator, so a hyphenated one can't be an operand.
func (p *calcParser) ident() (CalcNode, error) {
start := p.pos
for p.pos < len(p.rs) {
if c := p.rs[p.pos]; c == '_' || unicode.IsLetter(c) || unicode.IsDigit(c) {
p.pos++
} else {
break
}
}
name := string(p.rs[start:p.pos])
at, seen := p.vars[name]
if !seen {
at = len(p.vars)
p.vars[name] = at
}
return CalcVar{name, at}, nil
}
+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:]))
}
+50
View File
@@ -0,0 +1,50 @@
package grammar
import (
"fmt"
"strings"
)
// reservedInName is what a category, folder or field name may not contain: a dot
// separates the segments of a path, '|' the arms of a token, '(' opens a function
// call, braces delimit the token, '/' starts a reference, and brackets and a quote
// open a JSON value. A name carrying one is rejected where it is authored rather
// than where it would be unreachable.
const reservedInName = ".|({}/[]\""
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// CheckName rejects a name the dot path, {token} and JSON grammars cannot spell, or a struct
// tag cannot read.
// Both a category or folder and a field go through it, so there is one answer to
// what a name may contain.
func CheckName(name string) error {
if name == "" {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
}
if name == "-" {
return fmt.Errorf(`%q is reserved: the struct tag fake:"-" leaves a field unfilled, so no tag could read it; rename it`, name)
}
if strings.Contains(name, AsWord) {
return fmt.Errorf("%q contains %q, which a token reads as binding a name; rename it", name, AsWord)
}
if i := strings.IndexAny(name, reservedInName); i >= 0 {
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve",
name, name[i:i+1], reservedList)
}
return nil
}
// CheckPathNames rejects a dotted path with a segment no name may be.
func CheckPathNames(path string) error {
segs, err := SplitPath(path)
if err != nil {
return err
}
for _, seg := range NameSegments(segs) {
if err := CheckName(seg); err != nil {
return fmt.Errorf("path %w", err)
}
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package grammar
import (
"fmt"
"strings"
)
// SplitPath splits a dotted path into segments, a selector — [key or name] after a
// table's name — becoming a segment of its own, brackets kept, and so does each "..". A
// dot inside a selector is part of the key or name.
func SplitPath(path string) ([]string, error) {
segs := make([]string, 0, strings.Count(path, ".")+2*strings.Count(path, "[")+1)
start, open := 0, -1
for i := 0; i < len(path); i++ {
switch path[i] {
case '[':
if err := checkOpen(path, i, start, open); err != nil {
return nil, err
}
segs = append(segs, path[start:i])
start, open = i, i
case ']':
if err := checkClose(path, i, open); err != nil {
return nil, err
}
segs = append(segs, path[start:i+1])
open = -1
segs, i = stepUpAt(segs, path, i+1)
start = i + 1
case '.':
if open < 0 {
segs = append(segs, path[start:i])
segs, i = stepUpAt(segs, path, i)
start = i + 1
}
}
}
if open >= 0 {
return nil, fmt.Errorf(`%q opens a selector with "[" and never closes it with "]"`, path)
}
if start < len(path) || start == len(path) && (len(segs) == 0 || segs[len(segs)-1] != "..") {
segs = append(segs, path[start:])
}
return segs, nil
}
// stepUpAt appends a ".." segment where the dot at i is the first of two, returning the
// index of the last dot it consumed.
func stepUpAt(segs []string, path string, i int) ([]string, int) {
if i+1 < len(path) && path[i+1] == '.' {
return append(segs, ".."), i + 1
}
return segs, i
}
// checkOpen refuses a "[" at i that opens no selector: one inside a selector, or one
// following no name.
func checkOpen(path string, i, start, open int) error {
switch {
case open >= 0:
return fmt.Errorf(`%q holds a "[" inside a selector, which no key or name may`, path)
case i == 0:
return fmt.Errorf(`%q starts with "["; a path starts with a name, and a selector follows a table's name`, path)
case i == start:
return fmt.Errorf(`%q: a selector follows its table's name; write %s`, path, path[:i-1]+path[i:])
}
return nil
}
// checkClose refuses a "]" at i that closes no selector, closes an empty one, or is
// followed by anything but a dot or the end.
func checkClose(path string, i, open int) error {
switch {
case open < 0:
return fmt.Errorf(`%q holds a "]" that closes no "["`, path)
case i == open+1:
return fmt.Errorf("%q holds an empty selector; a selector names a row by key or name", path)
case i+1 < len(path) && path[i+1] != '.':
return fmt.Errorf(`%q: a "]" ends its selector, so a dot or the end must follow it`, path)
}
return nil
}
// IndexOutside is the first c in s outside a [selector], or -1.
func IndexOutside(s string, c byte) int {
depth := 0
for i := 0; i < len(s); i++ {
switch {
case s[i] == '[':
depth++
case s[i] == ']' && depth > 0:
depth--
case s[i] == c && depth == 0:
return i
}
}
return -1
}
// CutOutside cuts s around the first sep outside a [selector].
func CutOutside(s, sep string) (before, after string, found bool) {
depth := 0
for i := 0; i < len(s); i++ {
switch {
case s[i] == '[':
depth++
case s[i] == ']' && depth > 0:
depth--
case depth == 0 && strings.HasPrefix(s[i:], sep):
return s[:i], s[i+len(sep):], true
}
}
return s, "", false
}
func SplitOutside(s string, c byte) []string {
var parts []string
for {
i := IndexOutside(s, c)
if i < 0 {
return append(parts, s)
}
parts, s = append(parts, s[:i]), s[i+1:]
}
}
func IsSelector(seg string) bool { return strings.HasPrefix(seg, "[") }
func HasSelector(segs []string) bool {
for _, s := range segs {
if IsSelector(s) {
return true
}
}
return false
}
func SelectorOf(seg string) string { return seg[1 : len(seg)-1] }
// NameSegments is the segments of a path that are names, its selectors and ".." left out.
func NameSegments(segs []string) []string {
out := segs[:0:0]
for _, s := range segs {
if !IsSelector(s) && s != ".." {
out = append(out, s)
}
}
return out
}
// JoinSegments spells segments as a path: a selector attaches to the name before it, and
// so do a ".." and the name after it.
func JoinSegments(segs []string) string {
var b strings.Builder
for i, s := range segs {
if b.Len() > 0 && !IsSelector(s) && s != ".." && segs[i-1] != ".." {
b.WriteByte('.')
}
b.WriteString(s)
}
return b.String()
}
+41
View File
@@ -0,0 +1,41 @@
package grammar
import (
"fmt"
"strings"
)
// A reference names a node by path rather than as a sibling field: {/a.b} from the
// data root, {.a} from the folder this file sits in, {..a} from the folder above.
func IsRef(name string) bool { return strings.HasPrefix(name, ".") || strings.HasPrefix(name, "/") }
// RefShape splits a reference into its sigil and the dotted path after it.
func RefShape(name string) (sigil, rest string, err error) {
switch {
case strings.HasPrefix(name, "/"):
sigil, rest = "/", name[1:]
case strings.HasPrefix(name, ".."):
sigil, rest = "..", name[2:]
default:
sigil, rest = ".", name[1:]
}
if rest == "" {
return "", "", fmt.Errorf("reference has no path")
}
if strings.HasPrefix(rest, "/") {
return "", "", fmt.Errorf("the path after %s starts at a name, not a /; write {%s%s}", sigil, sigil, rest[1:])
}
if strings.HasPrefix(rest, ".") {
return "", "", fmt.Errorf("a reference starts with / (the root), . (this folder) or .. (the folder above)")
}
segs, err := SplitPath(rest)
if err != nil {
return "", "", err
}
for _, seg := range segs {
if seg == "" {
return "", "", fmt.Errorf("path has an empty segment")
}
}
return sigil, rest, nil
}
+80
View File
@@ -0,0 +1,80 @@
package grammar
import (
"encoding/json"
"fmt"
"strings"
)
// IsTemplate reports whether arg is an inline template rather than a path, by its shape;
// the root's IsTemplate documents the rule.
func IsTemplate(arg string) (bool, error) {
if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) {
if path, lone := loneReference(arg); lone {
return false, pathAdvice(path, fmt.Sprintf("%s is the path %s written as a template", arg, path))
}
return true, nil
}
if i := strings.IndexAny(arg, `}"`); i >= 0 {
return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1])
}
if strings.HasPrefix(strings.TrimSpace(arg), "[") {
return false, fmt.Errorf(`%q starts with "[" and is not valid JSON, so it names no template; a path starts with a name`, arg)
}
if _, err := SplitPath(arg); err != nil {
return false, err
}
if path := strings.TrimLeft(arg, "/"); path != arg && path != "" {
return false, pathAdvice(path, fmt.Sprintf("path %s starts with /, and every path starts at the root already", arg))
}
return false, nil
}
func isJSONStart(arg string) bool {
return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`)
}
// pathAdvice refuses a spelling of path, naming path to write, or why no name can spell it.
func pathAdvice(path, refusal string) error {
if err := CheckPathNames(path); err != nil {
return err
}
return fmt.Errorf("%s; write %s", refusal, path)
}
// loneReference is the path a template spells when the value it holds — a format string, a
// JSON string, or an object holding only a format — is one reference token and nothing else.
func loneReference(arg string) (string, bool) {
var raw any
if json.Unmarshal([]byte(arg), &raw) != nil {
raw = arg
}
if m, isObject := raw.(map[string]any); isObject && len(m) == 1 {
raw = m["format"]
}
format, isString := raw.(string)
if !isString {
return "", false
}
toks, err := ParseFormat(format, nil)
if err != nil {
return "", false
}
body, lone := loneRef(toks)
if !lone {
return "", false
}
if strings.HasPrefix(body, "/") {
body = "/" + strings.TrimLeft(body, "/")
}
_, path, err := RefShape(body)
return path, err == nil
}
// loneRef is the reference a format of one reference token and nothing else reads.
func loneRef(toks []Token) (string, bool) {
if len(toks) != 1 || toks[0].Kind != NameRead || len(toks[0].Names) != 1 || !IsRef(toks[0].Names[0]) {
return "", false
}
return toks[0].Names[0], true
}