Files
zzg b9bd39689e
ci / test (push) Canceled after 0s
initial commit
2026-09-25 21:40:55 -07:00

261 lines
5.5 KiB
Go

// Package lexer turns source text into a stream of tokens.
package lexer
import (
"strings"
"github.com/zg/interp/token"
)
// Lexer scans source text. It tracks line and column so that parse and
// runtime errors can point at the offending source rather than just failing.
type Lexer struct {
input []rune // runes, not bytes, so multi-byte source is handled
pos int // current position
readPos int // next position
ch rune
line int
column int
}
func New(input string) *Lexer {
l := &Lexer{input: []rune(input), line: 1, column: 0}
l.readChar()
return l
}
func (l *Lexer) readChar() {
if l.readPos >= len(l.input) {
l.ch = 0
} else {
l.ch = l.input[l.readPos]
}
l.pos = l.readPos
l.readPos++
if l.ch == '\n' {
l.line++
l.column = 0
} else {
l.column++
}
}
func (l *Lexer) peekChar() rune {
if l.readPos >= len(l.input) {
return 0
}
return l.input[l.readPos]
}
// Next returns the next token.
func (l *Lexer) Next() token.Token {
l.skipWhitespaceAndComments()
line, col := l.line, l.column
make := func(t token.Type, lit string) token.Token {
return token.Token{Type: t, Literal: lit, Line: line, Column: col}
}
// two builds a two-character token, consuming the second character.
two := func(t token.Type, lit string) token.Token {
l.readChar()
return make(t, lit)
}
var tok token.Token
switch l.ch {
case '=':
if l.peekChar() == '=' {
tok = two(token.EQ, "==")
} else {
tok = make(token.ASSIGN, "=")
}
case '!':
if l.peekChar() == '=' {
tok = two(token.NOT_EQ, "!=")
} else {
tok = make(token.BANG, "!")
}
case '<':
if l.peekChar() == '=' {
tok = two(token.LTE, "<=")
} else {
tok = make(token.LT, "<")
}
case '>':
if l.peekChar() == '=' {
tok = two(token.GTE, ">=")
} else {
tok = make(token.GT, ">")
}
case '&':
if l.peekChar() == '&' {
tok = two(token.AND, "&&")
} else {
tok = make(token.ILLEGAL, string(l.ch))
}
case '|':
if l.peekChar() == '|' {
tok = two(token.OR, "||")
} else {
tok = make(token.ILLEGAL, string(l.ch))
}
case '+':
tok = make(token.PLUS, "+")
case '-':
tok = make(token.MINUS, "-")
case '*':
tok = make(token.ASTERISK, "*")
case '/':
tok = make(token.SLASH, "/")
case '%':
tok = make(token.PERCENT, "%")
case ',':
tok = make(token.COMMA, ",")
case ';':
tok = make(token.SEMICOLON, ";")
case ':':
tok = make(token.COLON, ":")
case '(':
tok = make(token.LPAREN, "(")
case ')':
tok = make(token.RPAREN, ")")
case '{':
tok = make(token.LBRACE, "{")
case '}':
tok = make(token.RBRACE, "}")
case '[':
tok = make(token.LBRACKET, "[")
case ']':
tok = make(token.RBRACKET, "]")
case '"':
lit, ok := l.readString()
if !ok {
return token.Token{Type: token.ILLEGAL, Literal: "unterminated string", Line: line, Column: col}
}
tok = make(token.STRING, lit)
case 0:
tok = make(token.EOF, "")
default:
switch {
case isLetter(l.ch):
// Identifiers consume their own characters, so return early
// rather than falling through to the readChar below.
lit := l.readIdentifier()
return token.Token{Type: token.LookupIdent(lit), Literal: lit, Line: line, Column: col}
case isDigit(l.ch):
lit, isFloat := l.readNumber()
t := token.INT
if isFloat {
t = token.FLOAT
}
return token.Token{Type: token.Type(t), Literal: lit, Line: line, Column: col}
default:
tok = make(token.ILLEGAL, string(l.ch))
}
}
l.readChar()
return tok
}
func (l *Lexer) skipWhitespaceAndComments() {
for {
switch {
case l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r':
l.readChar()
case l.ch == '/' && l.peekChar() == '/':
for l.ch != '\n' && l.ch != 0 {
l.readChar()
}
case l.ch == '/' && l.peekChar() == '*':
l.readChar()
l.readChar()
for !(l.ch == '*' && l.peekChar() == '/') && l.ch != 0 {
l.readChar()
}
if l.ch != 0 {
l.readChar() // *
l.readChar() // /
}
default:
return
}
}
}
func (l *Lexer) readIdentifier() string {
start := l.pos
for isLetter(l.ch) || isDigit(l.ch) {
l.readChar()
}
return string(l.input[start:l.pos])
}
// readNumber reads an integer or float, reporting which it found. A trailing
// dot not followed by a digit (as in "1..2" or a method-style "1.") is left
// for the next token rather than swallowed.
func (l *Lexer) readNumber() (string, bool) {
start := l.pos
for isDigit(l.ch) {
l.readChar()
}
isFloat := false
if l.ch == '.' && isDigit(l.peekChar()) {
isFloat = true
l.readChar()
for isDigit(l.ch) {
l.readChar()
}
}
return string(l.input[start:l.pos]), isFloat
}
// readString reads a double-quoted string with backslash escapes. It
// reports false when the string is never closed, which must be an error
// rather than a silent EOF.
func (l *Lexer) readString() (string, bool) {
var sb strings.Builder
for {
l.readChar()
switch l.ch {
case '"':
return sb.String(), true
case 0:
return "", false
case '\\':
l.readChar()
switch l.ch {
case 'n':
sb.WriteRune('\n')
case 't':
sb.WriteRune('\t')
case 'r':
sb.WriteRune('\r')
case '"':
sb.WriteRune('"')
case '\\':
sb.WriteRune('\\')
case 0:
return "", false
default:
// Unknown escape: keep both characters rather than
// silently dropping the backslash.
sb.WriteRune('\\')
sb.WriteRune(l.ch)
}
default:
sb.WriteRune(l.ch)
}
}
}
func isLetter(ch rune) bool {
return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'
}
func isDigit(ch rune) bool { return '0' <= ch && ch <= '9' }