// 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 }