Back to royalty.blog
July 28, 2026Royalty8 min read

Building a Toy Language Processor | Part 2: The Lexer

In this article, we'll build the part of our compiler that transforms our source code to tokens.

compilerlexerparser
Building a Toy Language Processor | Part 2: The Lexer

In the previous chapter, we covered the high-level architecture of a compiler, mapping out the standard phases from lexical analysis down to target code generation.

Before we write our lexer, let's clarify a quick design choice regarding our backend: we are building a source-to-source compiler (a transpiler).

Instead of generating raw machine instructions or bytecode in our final phase, our compiler will output Go source code.

STANDARD Source Code ──> [ Lexer ──> Parser ──> AST ] ──> Code Generator ──> Go Source Code
└──────── Frontend ────────┘     └─ Backend ─┘

Compiling high-level STANDARD code into Go lets us focus on building a real compiler frontend without needing to write a custom execution engine. And because lexical analysis and parsing are backend-agnostic, our lexer and parser remain 100% identical to those in a traditional compiler.

Now, let's build the first phase: Lexical Analysis.

Lexical analysis is the transformation of source code into tokens. This is done by a lexer (also called scanner or tokenizer).

Blog Post Image

The source code is just a string, and we need to represent it in other forms that are easier to work with. This is the essence of the lexer.

The lexer transforms the source code into tokens. Tokens are small data structures that model the structure of the source code. They are fed to the parser, which transforms them to an Abstract Syntax Tree (AST).

AST is a tree representation of the source code of a computer program that conveys the structure of the source code.

Building the Lexer

To start building the lexer, first, we need to define a token.

// ...token/token.go
type TokenType string

type Token struct {
    Type TokenType
    Literal string
}

I'll do a manual transformation of our source code to tokens, it'll be self-explanatory why we represent the Token this way.

Take a look at this source code:

LET name

The output tokens:

[
    Token{Type: token.LET, Literal: "LET"},
    Token{Type: token.IDENT, Literal: "name"}
]

The Token retains its type, and its literal. Next, we're going to be crafting our lexer. We'll need a data structure to hold the source code and other metadata for manipulating the source code.

type Lexer struct {
    source string
}

We'll add Lexer.position and Lexer.readPos to the Lexer structure. The diagram below helps understand the usefulness of this structure.

Blog Post Image

The updated source code will be:

type Lexer struct {
    source string
    position int
    readPos int
    ch byte
}

The ch field stores the current position character of the source. This is useful for preview during tokenisation. The next step will be converting the source into tokens. But firstly, let's define some token type.

type TokenType string

const (
    EOF = "EOF" //end-of-file
    ILLEGAL = "ILLEGAL" // the token is unknown

    // keywords
    PRINT = "PRINT"
    LET      = "LET"
	WHILE    = "WHILE"
	REPEAT   = "REPEAT"
	ENDWHILE = "ENDWHILE"


    IDENT  = "IDENTIFIER"

    // operators
    ASSIGN   = "="
	PLUS     = "+"
	MINUS    = "-"
	BANG     = "!"
	ASTERISK = "*"
	SLASH    = "/"

)

With all these in place, we'll create some functions for processing the next token in the source. The function consumes the next token within the source code, and advances the navigation state.


func (l *Lexer) readChar() {
    if l.readPos >= len(l.source) {
        l.ch = 0 // NUL
    } else {
        l.ch = l.source[l.readPos]
    }

    l.position = l.readPos
    l.readPos += 1
}

func (l *Lexer) NextToken() token.Token {
    var tok token.Token
    switch lexer.ch {
	case '=':
		tok = newToken(token.ASSIGN, lexer.ch)
	case '+':
		tok = newToken(token.PLUS, lexer.ch)
    case '-':
    	tok = newToken(token.MINUS, lexer.ch)
    case '!':
    	tok = newToken(token.BANG, lexer.ch)
	case '*':
		tok = newToken(token.ASTERISK, lexer.ch)
	case '/':
	    tok = newToken(token.SLASH, lexer.ch)
    case 0:
    	tok = newToken(token.ILLEGAL, "")
    }
}

I won't be showing code snippets as frequently as I've done earlier, I'd rather be giving an overview of what I did; Readers can find the code in my GitHub repo.

Our lexer can now consume operators. Next, we need to consume keywords and identifiers. We'll store a map of keywords to token type, after reading an identifier, if it's present in the map, we'll replace it with the paired token type.

//[switch]
    default:
    if isLetter(lexer.ch) {
        tok.Literal = lexer.readIdentifier()
        tok.Type = token.LookupIdent(tok.Literal)
        return tok;
    }
//...

LookupIdent is a helper function that checks if the identifier is an existing keyword. Here's the implementation of the readIdentifier method.

func (l *Lexer) readIdentifier() string {
    pos := l.position

    for isLetter(l.ch) {
        l.readChar()
    }

    return l.source[pos:l.position]
}

The function keeps advancing the position pointer till it encounters a character that is not a valid letter. We compare the identifier with our keywords map, if there's no match, we'll assign token.IDENT.

Handling Indentations

Most programming languages use explicit characters like curly braces ({}) or keywords to mark where a block begins or ends. In STANDARD, we rely on significant tabs and line break just like Python.

This introduces a new challenge for our lexer: how do we tell our parser that a block of code has started or ended when there are no physical characters in the text?

Virtual Tokens and the Indentation Stack

The answer lies in virtual tokens. Tokens that don't directly correspond to a printed keyword or operator in the source code, but are synthesized by the lexer based on layout:

  • INDENT: Synthesised when stepping deeper into a tabbed block.
  • DEDENT: Synthesised when exiting one or more tabbed blocks.
  • NEWLINE: Synthesised at the end of a non-empty statement line.

Because a single line change can drop out of multiple nested blocks at once (for example, moving from 3 tabs directly back to 0 tabs), our lexer might need to emit multiple DEDENT tokens from a single character inspection.

To manage this, we add three new fields to our Lexer struct:

type Lexer struct {
    source string
    position int
    readPos int
    ch byte

    indentStack []int
    tokenQueue []token.Token
    atLineStart bool
}

When atLineStart is true, before parsing standard characters like numbers or identifiers, we invoke a function which does this:

  1. Count Tabs: We measure the number leading \t bytes on new line.
  2. Ignore Empty Lines: If the line contains only newlines or whitespce, we ignore it and avoid altering our indentation state.
  3. Compare Depth: We compare the count of leading tabs against the top of indentStack:
    • tabs > top: We push tabs onto indentStack and push an INDENT token into the tokenQueue.
    • tabs == top: The block depth has changed.
    • tabs < top: We pop values off the indentStack until we match tabs. For every value popped, we push a new DEDENT token into the tokenQueue.

Here is the implementation in Go:

func (l *Lexer) consumeIndentation() {
    for l.ch == '\n' {
        l.readChar()
    }

    tabs := 0
    for l.ch == '\t' {
        tabs++
        l.readChar()
    }

    if l.ch == '\n' || l.ch == 0 {
        return
    }

    currentIndent := l.indentStack[len(l.indentStack) - 1]

    if tabs > currentIndent {
        l.indentStack = append(l.indentStack, tabs)
        l.tokenQueue = append(l.tokenQueue, token.Token{Type: token.INDENT, Literal: '\t'})
    } else if tabs < currentIndent {
        for len(l.indentStack) > 0 && l.indentStack[len(l.indentStack) - 1] > tabs {
            l.indentStack = l.indentStack[:len(l.indentStack)-1]
            l.tokenQueue = append(l.tokenQueue, token.Token{Type: token.DEDENT, Literal: ""})
        }
    }

    if l.indentStack[len(l.indentStack) - 1] != tabs {
            l.tokenQueue = append(l.tokenQueue, token.Token{Type: token.ILLEGAL, Literal: "Indentation Error"})

    }
}

Integrating with NextToken()

Because consumeIndentation() can generate several DEDENT tokens at once into the token queue, NextToken() must check drain this queue before reading new characters from source:

func (l *Lexer) NextToken() token.Token {
    if len(l.tokenQueue) > 0 {
        tok := l.tokenQueue[0]
        l.tokenQueue = l.tokenQueue[1:]
    }

    if l.atLineStart {
        l.atLineStart = false
        l.consumeIndentation()
        if len(l.tokenQueue) > 0 {
            tok := l.tokenQueue[0]
            l.tokenQueue = l.tokenQueue[1:]

            return tok
        }
    }

    l.skipWhitespace()

    var tok token.Token

    switch l.ch {
    case '\n':
        tok = newToken(token.NEWLINE, l.ch)
        l.atLineStart = true
        l.readChar()
        return tok

    case 0:
        // Flush remaining open blocks at EOF
        for len(l.indentStack) > 1 {
            l.indentStack = l.indentStack[:len(l.indentStack)-1]
            l.tokenQueue = append(l.tokenQueue, token.Token{Type: token.DEDENT, Literal: ""})
        }
        if len(l.tokenQueue) > 0 {
            tok = l.tokenQueue[0]
            l.tokenQueue = l.tokenQueue[1:]
            return tok
        }
        tok = token.Token{Type: token.EOF, Literal: ""}
        // ... handle operators, identifiers, and numbers
    }

    l.readChar()
    return tok;
}

To support multi-character operators like ==, !=, <=, and >=, we implement peekChar(). It inspects the character at readPos without advancing the lexer's position pointers.

//[switch...]
case '=':
		{
			if lexer.peekChar() == '=' {
				ch := lexer.ch
				lexer.readChar()
				literal := string(ch) + string(lexer.ch)
				tok = token.Token{Type: token.EQ, Literal: literal}
			} else {
				tok = newToken(token.ASSIGN, lexer.ch)
			}
		}
	case '!':
		{
			if lexer.peekChar() == '=' {
				ch := lexer.ch
				lexer.readChar()
				literal := string(ch) + string(lexer.ch)
				tok = token.Token{Type: token.NOT_EQ, Literal: literal}
			} else {
				tok = newToken(token.BANG, lexer.ch)
			}
		}

What's Next?

With our lexer complete, we can transform raw STANDARD code into a clean stream of tokens.

Next, we will tackle the Parser, building an Abstract Syntax Tree (AST) to structure these tokens for code generation.

Find the full project source code here . Stay tuned for Part 3!