Files
gomail/internal/sieve/lexer.go
T
2026-08-09 18:03:09 +01:00

132 lines
3.1 KiB
Go

// Package sieve implements a Sieve (RFC 5228) interpreter covering the
// common mail-filtering subset: header tests (:contains, :is), if/elsif/else,
// and the fileinto/discard/keep/stop actions. Not full RFC 5228 — no
// extensions (vacation, reject, notify), no envelope/size/address tests,
// no allof/anyof boolean combinators. This covers what real users actually
// write for "move mail matching X to folder Y" / "discard mail from Z",
// which is the overwhelming majority of real-world Sieve scripts; broader
// grammar support is a natural follow-up once client compatibility testing
// calls for it.
package sieve
import (
"fmt"
"strings"
"unicode"
)
type tokenKind int
const (
tokIdent tokenKind = iota
tokString
tokTag // :contains, :is, etc.
tokSemicolon
tokLBrace
tokRBrace
tokEOF
)
type token struct {
kind tokenKind
value string
}
type lexer struct {
input []rune
pos int
}
func newLexer(script string) *lexer {
return &lexer{input: []rune(script)}
}
func (l *lexer) next() (token, error) {
l.skipWhitespaceAndComments()
if l.pos >= len(l.input) {
return token{kind: tokEOF}, nil
}
c := l.input[l.pos]
switch {
case c == ';':
l.pos++
return token{kind: tokSemicolon, value: ";"}, nil
case c == '{':
l.pos++
return token{kind: tokLBrace, value: "{"}, nil
case c == '}':
l.pos++
return token{kind: tokRBrace, value: "}"}, nil
case c == '"':
return l.readString()
case c == ':':
return l.readTag()
case unicode.IsLetter(c):
return l.readIdent()
default:
return token{}, fmt.Errorf("unexpected character %q at position %d", c, l.pos)
}
}
func (l *lexer) skipWhitespaceAndComments() {
for l.pos < len(l.input) {
c := l.input[l.pos]
if unicode.IsSpace(c) {
l.pos++
continue
}
// Single-line comment: # ... end of line
if c == '#' {
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
l.pos++
}
continue
}
// Bracketed comment: /* ... */
if c == '/' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '*' {
l.pos += 2
for l.pos+1 < len(l.input) && !(l.input[l.pos] == '*' && l.input[l.pos+1] == '/') {
l.pos++
}
l.pos += 2
continue
}
break
}
}
func (l *lexer) readString() (token, error) {
l.pos++ // skip opening quote
var sb strings.Builder
for l.pos < len(l.input) && l.input[l.pos] != '"' {
if l.input[l.pos] == '\\' && l.pos+1 < len(l.input) {
l.pos++
}
sb.WriteRune(l.input[l.pos])
l.pos++
}
if l.pos >= len(l.input) {
return token{}, fmt.Errorf("unterminated string literal")
}
l.pos++ // skip closing quote
return token{kind: tokString, value: sb.String()}, nil
}
func (l *lexer) readTag() (token, error) {
start := l.pos
l.pos++ // skip ':'
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || l.input[l.pos] == '-') {
l.pos++
}
return token{kind: tokTag, value: string(l.input[start:l.pos])}, nil
}
func (l *lexer) readIdent() (token, error) {
start := l.pos
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
l.pos++
}
return token{kind: tokIdent, value: string(l.input[start:l.pos])}, nil
}