132 lines
3.1 KiB
Go
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
|
|
}
|