Files
barkfile-parser/parser.go
Shaun Campbell 34ccc01c26
All checks were successful
Test and Release Module / test (push) Successful in 29s
Test and Release Module / release (push) Successful in 7s
feat!: replace password environment with Docker secret references
2026-09-16 15:01:15 -04:00

391 lines
10 KiB
Go

package barkfile
import (
"fmt"
"os"
"strings"
"time"
"unicode"
)
func ParseFile(path string) (Config, error) {
contents, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read Barkfile: %w", err)
}
return Parse(contents)
}
// Parse parses a Barkfile document using the current Barkfile schema.
func Parse(input []byte) (Config, error) {
tokens, err := lex(string(input))
if err != nil {
return Config{}, err
}
p := parser{tokens: tokens}
return p.parse()
}
type tokenKind uint8
const (
tokenWord tokenKind = iota
tokenOpenBrace
tokenCloseBrace
tokenNewline
tokenEOF
)
type token struct {
kind tokenKind
text string
line int
}
type parser struct {
tokens []token
index int
}
func (p *parser) parse() (Config, error) {
p.skipNewlines()
if err := p.expectWord("pawsql"); err != nil {
return Config{}, err
}
if err := p.expect(tokenOpenBrace, "{"); err != nil {
return Config{}, err
}
var cfg Config
for {
p.skipNewlines()
if p.current().kind == tokenCloseBrace {
p.index++
break
}
if p.current().kind == tokenEOF {
return Config{}, p.errorf("expected } to close pawsql block")
}
if p.current().kind != tokenWord {
return Config{}, p.errorf("expected directive")
}
switch p.current().text {
case "listen":
if cfg.Listen != "" {
return Config{}, p.errorf("listen may only be specified once")
}
p.index++
value, err := p.value("listen address")
if err != nil {
return Config{}, err
}
cfg.Listen = value
if err := p.endLine(); err != nil {
return Config{}, err
}
case "tls":
if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" {
return Config{}, p.errorf("tls may only be specified once")
}
p.index++
tlsConfig, err := p.parseTLS()
if err != nil {
return Config{}, err
}
cfg.TLS = tlsConfig
case "database":
p.index++
name, err := p.value("database name")
if err != nil {
return Config{}, err
}
database, err := p.parseDatabase(name)
if err != nil {
return Config{}, err
}
cfg.Databases = append(cfg.Databases, database)
default:
return Config{}, p.errorf("unknown directive %q", p.current().text)
}
}
p.skipNewlines()
if p.current().kind != tokenEOF {
return Config{}, p.errorf("unexpected content after pawsql block")
}
return cfg, nil
}
func (p *parser) parseTLS() (TLSConfig, error) {
if err := p.expect(tokenOpenBrace, "{"); err != nil {
return TLSConfig{}, err
}
var cfg TLSConfig
for {
p.skipNewlines()
if p.current().kind == tokenCloseBrace {
p.index++
return cfg, nil
}
if p.current().kind == tokenEOF {
return TLSConfig{}, p.errorf("expected } to close tls block")
}
name := p.current()
if name.kind != tokenWord {
return TLSConfig{}, p.errorf("expected tls directive")
}
p.index++
value, err := p.value("tls value")
if err != nil {
return TLSConfig{}, err
}
switch name.text {
case "cert":
if cfg.CertFile != "" {
return TLSConfig{}, fmt.Errorf("line %d: cert may only be specified once", name.line)
}
cfg.CertFile = value
case "key":
if cfg.KeyFile != "" {
return TLSConfig{}, fmt.Errorf("line %d: key may only be specified once", name.line)
}
cfg.KeyFile = value
default:
return TLSConfig{}, fmt.Errorf("line %d: unknown tls directive %q", name.line, name.text)
}
if err := p.endLine(); err != nil {
return TLSConfig{}, err
}
}
}
func (p *parser) parseDatabase(name string) (DatabaseConfig, error) {
if err := p.expect(tokenOpenBrace, "{"); err != nil {
return DatabaseConfig{}, err
}
database := DatabaseConfig{Name: name}
for {
p.skipNewlines()
if p.current().kind == tokenCloseBrace {
p.index++
return database, nil
}
if p.current().kind == tokenEOF {
return DatabaseConfig{}, p.errorf("expected } to close database block")
}
directive := p.current()
if directive.kind != tokenWord {
return DatabaseConfig{}, p.errorf("expected database directive")
}
p.index++
if directive.text == "postgres" {
if database.Postgres != nil {
return DatabaseConfig{}, fmt.Errorf("line %d: postgres may only be specified once", directive.line)
}
postgres, err := p.parsePostgres()
if err != nil {
return DatabaseConfig{}, err
}
database.Postgres = &postgres
continue
}
value, err := p.value("database value")
if err != nil {
return DatabaseConfig{}, err
}
switch directive.text {
case "hostname":
if database.Hostname != "" {
return DatabaseConfig{}, fmt.Errorf("line %d: hostname may only be specified once", directive.line)
}
database.Hostname = value
case "upstream":
if database.Upstream != "" {
return DatabaseConfig{}, fmt.Errorf("line %d: upstream may only be specified once", directive.line)
}
database.Upstream = value
default:
return DatabaseConfig{}, fmt.Errorf("line %d: unknown database directive %q", directive.line, directive.text)
}
if err := p.endLine(); err != nil {
return DatabaseConfig{}, err
}
}
}
func (p *parser) parsePostgres() (PostgresConfig, error) {
if err := p.expect(tokenOpenBrace, "{"); err != nil {
return PostgresConfig{}, err
}
var cfg PostgresConfig
idleTimeoutSet := false
trafficIdleTimeoutSet := false
for {
p.skipNewlines()
if p.current().kind == tokenCloseBrace {
p.index++
return cfg, nil
}
if p.current().kind == tokenEOF {
return PostgresConfig{}, p.errorf("expected } to close postgres block")
}
directive := p.current()
if directive.kind != tokenWord {
return PostgresConfig{}, p.errorf("expected postgres directive")
}
p.index++
value, err := p.value("postgres value")
if err != nil {
return PostgresConfig{}, err
}
switch directive.text {
case "image":
if cfg.Image != "" {
return PostgresConfig{}, fmt.Errorf("line %d: image may only be specified once", directive.line)
}
cfg.Image = value
case "volume":
if cfg.Volume != "" {
return PostgresConfig{}, fmt.Errorf("line %d: volume may only be specified once", directive.line)
}
cfg.Volume = value
case "password_secret":
if cfg.PasswordSecret != "" {
return PostgresConfig{}, fmt.Errorf("line %d: password_secret may only be specified once", directive.line)
}
cfg.PasswordSecret = value
case "idle_timeout":
if idleTimeoutSet {
return PostgresConfig{}, fmt.Errorf("line %d: idle_timeout may only be specified once", directive.line)
}
idleTimeoutSet = true
timeout, err := time.ParseDuration(value)
if err != nil {
return PostgresConfig{}, fmt.Errorf("line %d: invalid idle_timeout %q: %w", directive.line, value, err)
}
cfg.IdleTimeout = timeout
case "traffic_idle_timeout":
if trafficIdleTimeoutSet {
return PostgresConfig{}, fmt.Errorf("line %d: traffic_idle_timeout may only be specified once", directive.line)
}
trafficIdleTimeoutSet = true
timeout, err := time.ParseDuration(value)
if err != nil {
return PostgresConfig{}, fmt.Errorf("line %d: invalid traffic_idle_timeout %q: %w", directive.line, value, err)
}
cfg.TrafficIdleTimeout = timeout
default:
return PostgresConfig{}, fmt.Errorf("line %d: unknown postgres directive %q", directive.line, directive.text)
}
if err := p.endLine(); err != nil {
return PostgresConfig{}, err
}
}
}
func (p *parser) value(description string) (string, error) {
current := p.current()
if current.kind != tokenWord {
return "", p.errorf("expected %s", description)
}
p.index++
return current.text, nil
}
func (p *parser) endLine() error {
if p.current().kind == tokenNewline {
p.skipNewlines()
return nil
}
if p.current().kind == tokenCloseBrace || p.current().kind == tokenEOF {
return nil
}
return p.errorf("expected end of line")
}
func (p *parser) expectWord(word string) error {
if p.current().kind != tokenWord || p.current().text != word {
return p.errorf("expected %q", word)
}
p.index++
return nil
}
func (p *parser) expect(kind tokenKind, name string) error {
if p.current().kind != kind {
return p.errorf("expected %s", name)
}
p.index++
return nil
}
func (p *parser) skipNewlines() {
for p.current().kind == tokenNewline {
p.index++
}
}
func (p *parser) current() token { return p.tokens[p.index] }
func (p *parser) errorf(format string, args ...any) error {
return fmt.Errorf("line %d: %s", p.current().line, fmt.Sprintf(format, args...))
}
func lex(input string) ([]token, error) {
var tokens []token
line := 1
for index := 0; index < len(input); {
ch := input[index]
switch {
case ch == '#':
for index < len(input) && input[index] != '\n' {
index++
}
case ch == '\n':
tokens = append(tokens, token{kind: tokenNewline, line: line})
index++
line++
case unicode.IsSpace(rune(ch)):
index++
case ch == '{':
tokens = append(tokens, token{kind: tokenOpenBrace, text: "{", line: line})
index++
case ch == '}':
tokens = append(tokens, token{kind: tokenCloseBrace, text: "}", line: line})
index++
case ch == '"':
startLine := line
index++
var value strings.Builder
terminated := false
for index < len(input) {
if input[index] == '\n' {
return nil, fmt.Errorf("line %d: unterminated quoted string", startLine)
}
if input[index] == '"' {
index++
terminated = true
break
}
if input[index] == '\\' && index+1 < len(input) {
index++
value.WriteByte(input[index])
index++
continue
}
value.WriteByte(input[index])
index++
}
if !terminated {
return nil, fmt.Errorf("line %d: unterminated quoted string", startLine)
}
tokens = append(tokens, token{kind: tokenWord, text: value.String(), line: startLine})
default:
start := index
for index < len(input) && !unicode.IsSpace(rune(input[index])) && !strings.ContainsRune("{}#\"", rune(input[index])) {
index++
}
if start == index {
return nil, fmt.Errorf("line %d: unexpected character %q", line, input[index])
}
tokens = append(tokens, token{kind: tokenWord, text: input[start:index], line: line})
}
}
tokens = append(tokens, token{kind: tokenEOF, line: line})
return tokens, nil
}