Lexer.kt
src/jvmMain/kotlin/afield/lang/fieldml/lex/Lexer.kt · 256 lines
package afield.lang.fieldml.lex
import afield.lang.fieldml.diag.Diagnostic
import afield.lang.fieldml.diag.Severity
import afield.lang.fieldml.diag.SourcePos
import afield.lang.fieldml.diag.SourceRange
FieldML's first stage: source text in, a flat list of Tokens out, and a diagnostic for anything
that could not be read.
The scan never stops early. A character the lexer cannot use costs one diagnostic and no token,
and the scan carries on, so a typo halfway down a box does not hide what is below it. Whether the
compile carries on is a separate decision, made by FieldMLCompiler, which does not reach the
parser at all once lexing has reported an error.
Trivia is skipped rather than emitted: // to the end of the line, and (* … *) blocks, which
nest by depth count, so a region commented out this way can contain comments of its own. An
unterminated block is reported at the position it was opened.
The shapes a literal can take are all decided here. A number is digits, and a . opens a
fraction only when a digit follows it, which leaves . free to be the field-access token and
makes 1. an INT and then a DOT; there is no exponent form, and a leading - is always a
token of its own rather than part of the literal. An identifier starts with a letter or _ and
may carry ' after that. A string runs to the next unescaped ", and one that reaches the end
of the source first is reported rather than closed. &, : and ! are errors on their own,
since only &&, :: and != exist, and the first two say so in the message.
data class LexResult
The whole of what one scan produced.
Both lists are complete: tokens ends with EOF even when diagnostics is not empty, because
the lexer reports and continues rather than returning at the first problem.
data class LexResult(
/** The tokens, in source order, ending with `EOF`. Trivia is not among them. */
val tokens: List<Token>,
/** One entry per thing the lexer could not read, in source order. All of them are errors. */
val diagnostics: List<Diagnostic>,
)
class Lexer(private val source: String)
A single-use scanner over one string.
The cursor, the line and the column are instance state and lex does not reset them, so one
instance scans one source once: a second call finds itself already at the end and appends a
second EOF to the same list. Build another instance instead.
class Lexer(private val source: String) {
private var index = 0
private var line = 1
private var column = 1
private val tokens = mutableListOf<Token>()
private val diagnostics = mutableListOf<Diagnostic>()
fun lex(): LexResult
Scan to the end of the source, appending the EOF token. Reports rather than throws.
body — 198 lines, from line 58
fun lex(): LexResult {
while (!atEnd()) {
skipTrivia()
if (!atEnd()) lexToken()
}
val end = pos()
tokens += Token(TokenKind.EOF, "", SourceRange(end, end))
return LexResult(tokens, diagnostics)
}
private fun lexToken() {
val start = pos()
val c = peek()
when {
c.isLetter() || c == '_' -> lexIdentifier(start)
c.isDigit() -> lexNumber(start)
c == '"' -> lexString(start)
else -> when (c) {
'(' -> single(TokenKind.LPAREN, start)
')' -> single(TokenKind.RPAREN, start)
'[' -> single(TokenKind.LBRACKET, start)
']' -> single(TokenKind.RBRACKET, start)
'{' -> single(TokenKind.LBRACE, start)
'}' -> single(TokenKind.RBRACE, start)
',' -> single(TokenKind.COMMA, start)
';' -> single(TokenKind.SEMICOLON, start)
'|' -> {
advance()
when {
match('>') -> emit(TokenKind.PIPE_FORWARD, start)
match('|') -> emit(TokenKind.BARBAR, start)
else -> emit(TokenKind.BAR, start)
}
}
'&' -> {
advance()
if (match('&')) emit(TokenKind.AMPAMP, start) else errorAt(start, "Unexpected character '&' (did you mean '&&'?)")
}
':' -> {
advance()
if (match(':')) emit(TokenKind.COLONCOLON, start) else errorAt(start, "Unexpected character ':' (did you mean '::'?)")
}
'.' -> single(TokenKind.DOT, start)
'+' -> {
advance()
if (match('+')) emit(TokenKind.PLUSPLUS, start) else emit(TokenKind.PLUS, start)
}
'*' -> single(TokenKind.STAR, start)
'/' -> single(TokenKind.SLASH, start)
'-' -> {
advance()
if (match('>')) emit(TokenKind.ARROW, start) else emit(TokenKind.MINUS, start)
}
'=' -> {
advance()
if (match('=')) emit(TokenKind.EQEQ, start) else emit(TokenKind.EQ, start)
}
'!' -> {
advance()
if (match('=')) emit(TokenKind.NEQ, start) else errorAt(start, "Unexpected character '!'")
}
'<' -> {
advance()
if (match('=')) emit(TokenKind.LTE, start) else emit(TokenKind.LT, start)
}
'>' -> {
advance()
if (match('=')) emit(TokenKind.GTE, start) else emit(TokenKind.GT, start)
}
else -> {
advance()
errorAt(start, "Unexpected character '$c'")
}
}
}
}
private fun skipTrivia() {
while (!atEnd()) {
when {
peek().isWhitespace() -> advance()
peek() == '/' && peek(1) == '/' -> {
while (!atEnd() && peek() != '\n') advance()
}
peek() == '(' && peek(1) == '*' -> skipBlockComment()
else -> return
}
}
}
private fun skipBlockComment() {
val start = pos()
advance()
advance()
var depth = 1
while (!atEnd() && depth > 0) {
when {
peek() == '(' && peek(1) == '*' -> {
advance()
advance()
depth++
}
peek() == '*' && peek(1) == ')' -> {
advance()
advance()
depth--
}
else -> advance()
}
}
if (depth != 0) errorAt(start, "Unterminated block comment")
}
private fun lexIdentifier(start: SourcePos) {
while (!atEnd() && (peek().isLetterOrDigit() || peek() == '_' || peek() == '\'')) advance()
val text = source.substring(start.index, index)
val kind = when (text) {
"let" -> TokenKind.LET
"type" -> TokenKind.TYPE
"in" -> TokenKind.IN
"if" -> TokenKind.IF
"then" -> TokenKind.THEN
"else" -> TokenKind.ELSE
"fun" -> TokenKind.FUN
"match" -> TokenKind.MATCH
"with" -> TokenKind.WITH
"true" -> TokenKind.TRUE
"false" -> TokenKind.FALSE
else -> TokenKind.IDENT
}
emit(kind, start)
}
private fun lexNumber(start: SourcePos) {
while (!atEnd() && peek().isDigit()) advance()
var isFloat = false
if (peek() == '.' && peek(1).isDigit()) {
isFloat = true
advance()
while (!atEnd() && peek().isDigit()) advance()
}
emit(if (isFloat) TokenKind.FLOAT else TokenKind.INT, start)
}
private fun lexString(start: SourcePos) {
advance()
while (!atEnd() && peek() != '"') {
if (peek() == '\\') {
advance()
if (!atEnd()) advance()
} else {
advance()
}
}
if (atEnd()) {
errorAt(start, "Unterminated string literal")
return
}
advance()
emit(TokenKind.STRING, start)
}
private fun single(kind: TokenKind, start: SourcePos) {
advance()
emit(kind, start)
}
private fun emit(kind: TokenKind, start: SourcePos) {
val end = pos()
tokens += Token(kind, source.substring(start.index, end.index), SourceRange(start, end))
}
private fun errorAt(start: SourcePos, message: String) {
diagnostics += Diagnostic(Severity.Error, message, SourceRange(start, pos()))
}
private fun match(expected: Char): Boolean {
if (atEnd() || peek() != expected) return false
advance()
return true
}
private fun peek(offset: Int = 0): Char = source.getOrNull(index + offset) ?: '\u0000'
private fun atEnd(): Boolean = index >= source.length
private fun advance(): Char {
val c = source[index++]
if (c == '\n') {
line++
column = 1
} else {
column++
}
return c
}
private fun pos(): SourcePos = SourcePos(index, line, column)
}