feat: initial plan

This commit is contained in:
Hein
2026-06-23 16:59:50 +02:00
parent 90fe1a448b
commit 625ddc79a1
23 changed files with 3390 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
package lexer
import "strings"
// opChars are the characters PostgreSQL allows in operator names.
const opChars = "+-*/<>=~!@#%^&|`?"
// opSpecial is the subset whose presence lets an operator end in + or -.
const opSpecial = "~!@#%^&|`?"
// Lex tokenizes src into a lossless token stream. The concatenation of every
// returned token's Text equals src. The final token is always EOF (empty Text).
func Lex(src string) []Token {
l := &lexer{src: src, line: 1, col: 1}
return l.run()
}
type lexer struct {
src string
pos int
line int
col int
out []Token
// position of the token currently being scanned, captured each iteration
tokOff, tokLine, tokCol int
}
func (l *lexer) run() []Token {
n := len(l.src)
for l.pos < n {
l.tokOff, l.tokLine, l.tokCol = l.pos, l.line, l.col
start := l.pos
c := l.src[l.pos]
switch {
case isSpace(c):
l.scanWhile(isSpace)
l.emit(Whitespace, start)
case c == '-' && l.peek(1) == '-':
l.scanLineComment()
l.emit(LineComment, start)
case c == '/' && l.peek(1) == '*':
l.scanBlockComment()
l.emit(BlockComment, start)
case c == '\'':
l.scanString('\'', false)
l.emit(String, start)
case c == '"':
l.scanString('"', false)
l.emit(QuotedIdent, start)
case c == '$':
l.scanDollar(start)
case c == '(':
l.advance(1)
l.emit(LParen, start)
case c == ')':
l.advance(1)
l.emit(RParen, start)
case c == '[':
l.advance(1)
l.emit(LBracket, start)
case c == ']':
l.advance(1)
l.emit(RBracket, start)
case c == ',':
l.advance(1)
l.emit(Comma, start)
case c == ';':
l.advance(1)
l.emit(Semicolon, start)
case c == ':':
// :: (cast), := (assign), or bare : (slice).
if l.peek(1) == ':' || l.peek(1) == '=' {
l.advance(2)
} else {
l.advance(1)
}
l.emit(Operator, start)
case c == '.':
if isDigit(l.peek(1)) {
l.scanNumber()
l.emit(Number, start)
} else {
l.advance(1)
l.emit(Dot, start)
}
case isDigit(c):
l.scanNumber()
l.emit(Number, start)
case isIdentStart(c):
l.scanWord(start)
case strings.IndexByte(opChars, c) >= 0:
l.scanOperator()
l.emit(Operator, start)
default:
l.advance(1)
l.emit(Unknown, start)
}
}
l.out = append(l.out, Token{Kind: EOF, Off: l.pos, Line: l.line, Col: l.col})
return l.out
}
// scanWord handles identifiers and the typed-string prefixes E' B' X' U&' / U&".
func (l *lexer) scanWord(start int) {
c := l.src[l.pos]
switch c {
case 'E', 'e':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', true)
l.emit(EscapeString, start)
return
}
case 'B', 'b':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', false)
l.emit(BitString, start)
return
}
case 'X', 'x':
if l.peek(1) == '\'' {
l.advance(1)
l.scanString('\'', false)
l.emit(HexString, start)
return
}
case 'U', 'u':
if l.peek(1) == '&' && (l.peek(2) == '\'' || l.peek(2) == '"') {
q := l.peek(2)
l.advance(2)
l.scanString(q, false)
l.emit(UnicodeString, start)
return
}
}
l.scanWhile(isIdentCont)
l.emit(Ident, start)
}
// scanDollar handles dollar-quoted strings ($tag$...$tag$), positional
// parameters ($1), and a lone $.
func (l *lexer) scanDollar(start int) {
if tag, ok := l.dollarTag(); ok {
// Opening delimiter is "$tag$"; find the matching close.
open := "$" + tag + "$"
l.advance(len(open))
if idx := strings.Index(l.src[l.pos:], open); idx >= 0 {
l.advance(idx + len(open))
} else {
l.advance(len(l.src) - l.pos) // unterminated: consume to EOF
}
l.emit(DollarString, start)
return
}
if isDigit(l.peek(1)) {
l.advance(1)
l.scanWhile(isDigit)
l.emit(Param, start)
return
}
l.advance(1)
l.emit(Unknown, start)
}
// dollarTag checks whether the current $ begins a dollar-quote opening
// delimiter and, if so, returns the (possibly empty) tag.
func (l *lexer) dollarTag() (string, bool) {
// src[pos] == '$'
j := l.pos + 1
n := len(l.src)
if j < n && l.src[j] == '$' {
return "", true // "$$"
}
k := j
if k < n && isIdentStart(l.src[k]) {
k++
// Tag chars follow identifier rules but exclude '$' (which closes the tag).
for k < n && isIdentCont(l.src[k]) && l.src[k] != '$' {
k++
}
if k < n && l.src[k] == '$' {
return l.src[j:k], true
}
}
return "", false
}
func (l *lexer) scanLineComment() {
// Consume until newline (exclusive); the newline is whitespace.
for l.pos < len(l.src) && l.src[l.pos] != '\n' {
l.advance(1)
}
}
func (l *lexer) scanBlockComment() {
l.advance(2) // /*
depth := 1
for l.pos < len(l.src) && depth > 0 {
if l.src[l.pos] == '/' && l.peek(1) == '*' {
l.advance(2)
depth++
} else if l.src[l.pos] == '*' && l.peek(1) == '/' {
l.advance(2)
depth--
} else {
l.advance(1)
}
}
}
// scanString consumes a quoted run beginning at the current quote character.
// A doubled quote (”) is an embedded quote; with backslash=true a backslash
// escapes the next byte (escape-string syntax).
func (l *lexer) scanString(quote byte, backslash bool) {
l.advance(1) // opening quote
for l.pos < len(l.src) {
c := l.src[l.pos]
if backslash && c == '\\' && l.pos+1 < len(l.src) {
l.advance(2)
continue
}
if c == quote {
if l.peek(1) == quote {
l.advance(2) // doubled quote
continue
}
l.advance(1) // closing quote
return
}
l.advance(1)
}
}
func (l *lexer) scanNumber() {
// Non-decimal integer literals: 0x.., 0o.., 0b..
if l.src[l.pos] == '0' {
switch l.peek(1) {
case 'x', 'X', 'o', 'O', 'b', 'B':
l.advance(2)
l.scanWhile(isHexOrSep)
return
}
}
l.scanWhile(isDigitOrSep)
if l.pos < len(l.src) && l.src[l.pos] == '.' {
l.advance(1)
l.scanWhile(isDigitOrSep)
}
if c := l.cur(); c == 'e' || c == 'E' {
if p := l.peek(1); isDigit(p) || ((p == '+' || p == '-') && isDigit(l.peek(2))) {
l.advance(1)
if l.cur() == '+' || l.cur() == '-' {
l.advance(1)
}
l.scanWhile(isDigit)
}
}
}
// scanOperator consumes a run of operator characters, applying PostgreSQL's
// rule that a multi-character operator may not end in + or - unless it also
// contains one of ~ ! @ # % ^ & | ` ?.
func (l *lexer) scanOperator() {
start := l.pos
hasSpecial := false
for l.pos < len(l.src) {
c := l.src[l.pos]
if strings.IndexByte(opChars, c) < 0 {
break
}
if c == '-' && l.peek(1) == '-' {
break // comment start
}
if c == '/' && l.peek(1) == '*' {
break // comment start
}
if strings.IndexByte(opSpecial, c) >= 0 {
hasSpecial = true
}
l.advance(1)
}
if !hasSpecial {
for l.pos-1 > start {
last := l.src[l.pos-1]
if last != '+' && last != '-' {
break
}
l.backup()
}
}
}
// --- low-level helpers ---
func (l *lexer) cur() byte {
if l.pos < len(l.src) {
return l.src[l.pos]
}
return 0
}
func (l *lexer) peek(k int) byte {
if l.pos+k < len(l.src) {
return l.src[l.pos+k]
}
return 0
}
func (l *lexer) scanWhile(pred func(byte) bool) {
for l.pos < len(l.src) && pred(l.src[l.pos]) {
l.advance(1)
}
}
// advance moves forward k bytes, maintaining line/col.
func (l *lexer) advance(k int) {
for i := 0; i < k && l.pos < len(l.src); i++ {
if l.src[l.pos] == '\n' {
l.line++
l.col = 1
} else {
l.col++
}
l.pos++
}
}
// backup moves back one byte. Only used within scanOperator, which never
// spans a newline, so column bookkeeping is safe.
func (l *lexer) backup() {
l.pos--
l.col--
}
// emit appends a token covering src[start:pos], using the start position
// captured at the top of the scan loop.
func (l *lexer) emit(kind Kind, start int) {
l.out = append(l.out, Token{
Kind: kind,
Text: l.src[start:l.pos],
Off: l.tokOff,
Line: l.tokLine,
Col: l.tokCol,
})
}
func isSpace(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '\v', '\f':
return true
}
return false
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func isDigitOrSep(c byte) bool { return isDigit(c) || c == '_' }
func isHexOrSep(c byte) bool {
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == '_'
}
func isIdentStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80
}
func isIdentCont(c byte) bool {
return isIdentStart(c) || isDigit(c) || c == '$'
}
+146
View File
@@ -0,0 +1,146 @@
package lexer
import (
"os"
"path/filepath"
"strings"
"testing"
)
// emit reconstructs the source from a token stream.
func emit(toks []Token) string {
var b strings.Builder
for _, t := range toks {
b.WriteString(t.Text)
}
return b.String()
}
func TestRoundTripSmall(t *testing.T) {
cases := []string{
"",
"SELECT 1;",
"select * from t where a = b;",
"-- a comment\nSELECT 1",
"/* block /* nested */ still */ SELECT 1",
"SELECT 'it''s', E'a\\nb', $$dollar$$, $tag$x$tag$, $1;",
"a->>'b'::text",
"x := y + 1;",
"a=-b",
"SELECT 1.5, .5, 1e10, 0xFF, 1_000;",
"arr[1:2]",
"\"Quoted Ident\".col",
"U&'d\\0061t'",
}
for _, src := range cases {
got := emit(Lex(src))
if got != src {
t.Errorf("round-trip mismatch\n in: %q\nout: %q", src, got)
}
}
}
func TestKinds(t *testing.T) {
toks := nonEOF(Lex("a->>'b'::text"))
want := []Kind{Ident, Operator, String, Operator, Ident}
if len(toks) != len(want) {
t.Fatalf("got %d tokens, want %d: %v", len(toks), len(want), toks)
}
for i, k := range want {
if toks[i].Kind != k {
t.Errorf("token %d: got %s, want %s (text %q)", i, toks[i].Kind, k, toks[i].Text)
}
}
}
func TestOperatorTrailingRule(t *testing.T) {
// "=-" must split into "=" and "-" (no special char, can't end in -).
toks := nonEOF(Lex("a=-b"))
if len(toks) != 4 || toks[1].Text != "=" || toks[2].Text != "-" {
t.Fatalf("a=-b mis-lexed: %v", toks)
}
// "@-" keeps trailing - because @ is special.
toks = nonEOF(Lex("a@-b"))
if toks[1].Text != "@-" {
t.Fatalf("@- should stay one operator, got %q", toks[1].Text)
}
}
func TestDollarQuote(t *testing.T) {
toks := nonEOF(Lex("$func$ body $$ inner $func$"))
if len(toks) != 1 || toks[0].Kind != DollarString {
t.Fatalf("dollar quote not single token: %v", toks)
}
}
func TestLineColumns(t *testing.T) {
toks := nonEOF(Lex("ab\n cd"))
// ab(1,1) ws cd(2,3)
if toks[0].Line != 1 || toks[0].Col != 1 {
t.Errorf("ab at %d:%d, want 1:1", toks[0].Line, toks[0].Col)
}
cd := toks[len(toks)-1]
if cd.Text != "cd" || cd.Line != 2 || cd.Col != 3 {
t.Errorf("cd at %d:%d, want 2:3", cd.Line, cd.Col)
}
}
// TestCorpusRoundTrip is the core lossless invariant: lexing then re-emitting
// every real-world .pgsql fixture must reproduce it byte-for-byte.
func TestCorpusRoundTrip(t *testing.T) {
dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir)
if err != nil {
t.Skipf("no corpus dir: %v", err)
}
var seen int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") {
continue
}
seen++
path := filepath.Join(dir, e.Name())
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
src := string(data)
if got := emit(Lex(src)); got != src {
t.Errorf("%s: round-trip mismatch (len in=%d out=%d)", e.Name(), len(src), len(got))
reportFirstDiff(t, e.Name(), src, got)
}
}
if seen == 0 {
t.Skip("corpus dir has no .pgsql files")
}
t.Logf("round-tripped %d corpus files", seen)
}
func reportFirstDiff(t *testing.T, name, a, b string) {
t.Helper()
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
lo := i - 20
if lo < 0 {
lo = 0
}
t.Logf("%s: first diff at byte %d\n in: %q\n out: %q", name, i, a[lo:min(i+20, len(a))], b[lo:min(i+20, len(b))])
return
}
}
}
func nonEOF(toks []Token) []Token {
var out []Token
for _, t := range toks {
if t.Kind == EOF {
continue
}
out = append(out, t)
}
return out
}
+102
View File
@@ -0,0 +1,102 @@
// Package lexer implements a lossless lexer for PostgreSQL SQL and PL/pgSQL.
//
// "Lossless" means every byte of the input is represented by exactly one token,
// including whitespace and comments. Concatenating the Text of all tokens in
// order reproduces the original source byte-for-byte:
//
// emit(Lex(src)) == src
//
// This property is the foundation of the formatter: comments and whitespace are
// first-class tokens (trivia) so the parser/printer can preserve them.
package lexer
// Kind classifies a token.
type Kind int
const (
EOF Kind = iota
// Trivia — insignificant to the grammar but preserved losslessly.
Whitespace
LineComment // -- ... (up to, not including, the newline)
BlockComment // /* ... */ (nestable)
// Words.
Ident // unquoted identifier or keyword (keyword-ness resolved later)
QuotedIdent // "..."
// Literals.
String // '...'
EscapeString // E'...'
BitString // B'...'
HexString // X'...'
UnicodeString // U&'...' or U&"..."
DollarString // $tag$...$tag$
Number // 123, 1.5, .5, 1e10, 0xff, 1_000
Param // $1, $2
// Operators (incl. ::, :=, :, ->, ->>, and op-char runs).
Operator
// Structural punctuation.
LParen
RParen
LBracket
RBracket
Comma
Semicolon
Dot
Unknown // a byte that fits no other category
)
var kindNames = map[Kind]string{
EOF: "EOF",
Whitespace: "Whitespace",
LineComment: "LineComment",
BlockComment: "BlockComment",
Ident: "Ident",
QuotedIdent: "QuotedIdent",
String: "String",
EscapeString: "EscapeString",
BitString: "BitString",
HexString: "HexString",
UnicodeString: "UnicodeString",
DollarString: "DollarString",
Number: "Number",
Param: "Param",
Operator: "Operator",
LParen: "LParen",
RParen: "RParen",
LBracket: "LBracket",
RBracket: "RBracket",
Comma: "Comma",
Semicolon: "Semicolon",
Dot: "Dot",
Unknown: "Unknown",
}
func (k Kind) String() string {
if s, ok := kindNames[k]; ok {
return s
}
return "Kind(?)"
}
// Token is a single lexical unit covering Text == src[Off:Off+len(Text)].
type Token struct {
Kind Kind
Text string
Off int // byte offset of the first byte
Line int // 1-based line of the first byte
Col int // 1-based byte column of the first byte
}
// IsTrivia reports whether the token is whitespace or a comment.
func (t Token) IsTrivia() bool {
switch t.Kind {
case Whitespace, LineComment, BlockComment:
return true
}
return false
}