Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Compiler'lar ve Programlama Dilleri — #02

Lexer Yazmak: Karakterlerden Token'lara, C# ile Bookstore Filtre Dili

Mert Özen 15 Kas 2026 14 dk 5 görüntülenme
Lexer Yazmak: Karakterlerden Token'lara, C# ile Bookstore Filtre Dili

Lexer, karakter dizisini token'lara bölen ve her token'ın satırını ve sütununu bilen bir state machine'dir. Bookstore filtre dili için ReadOnlySpan<char> üzerinde tek geçişte çalışan bir C# lexer'ı yazıyoruz: maximal munch, Türkçe culture'da bozulmayan keyword eşleşmesi, InvariantCulture ile sayı okuma, surrogate pair'e dayanıklı hata kurtarma ve anlaşılır hata mesajları. Regex ile lexer'ın nerede kırıldığını ve Roslyn'in boşlukları neden trivia olarak sakladığını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ile derlenip xUnit v3 4.0.3 testleriyle doğrulanmıştır; Roslyn örneği Microsoft.CodeAnalysis.CSharp 5.12.0 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yazdığımız filtre dilinin ilk aşamasını kuruyoruz. İki soru. Birincisi: price<=300 metninde aralarında hiç boşluk yok. Lexer bunu kaç token'a böler; < ve = ayrı mı gelir, yoksa tek bir <= mi?

İkincisi: keyword'lerde büyük/küçük harf fark etmesin diye lexer kelimeyi ToLower() ile küçültüp "contains" ile karşılaştırıyor. Uygulama Türkçe Windows'ta, tr-TR culture ile çalışıyor. Kullanıcı TITLE CONTAINS "Dune" yazınca CONTAINS keyword olarak tanınır mı?

Token, Lexeme ve Token Türleri

Lexer'ın işi, karakter dizisini anlamlı parçalara bölmek. Her parçanın iki yüzü var. Lexeme, kaynak metinde yazıldığı hâliyle karakterler: "Jane Austen", tırnaklarıyla birlikte. Token ise o lexeme'in türü ve değeri: String türünde, değeri Jane Austen olan bir token. Parser lexeme'lerle değil token türleriyle çalışır; 300 ile 49.90 parser için aynı şey, ikisi de Number.

Bizim dilde token türleri dört grupta toplanıyor: identifier (alan adları), literal (sayı, string, true/false), keyword (and, or, not, contains) ve operatör ya da noktalama (<=, (). Bunlara iki özel tür ekliyoruz: girdinin bittiğini söyleyen EndOfInput ve tanınamayan karakterler için Bad.

namespace Bookstore.Filter.Syntax;

public enum TokenKind
{
    EndOfInput,
    Bad,

    Identifier,
    Number,
    String,

    // keyword'ler
    And,
    Or,
    Not,
    Contains,
    True,
    False,

    // operatörler ve noktalama
    Equal,
    NotEqual,
    Less,
    LessOrEqual,
    Greater,
    GreaterOrEqual,
    OpenParen,
    CloseParen,
}
namespace Bookstore.Filter.Syntax;

public readonly record struct SourcePosition(int Line, int Column)
{
    public override string ToString() => $"{Line}:{Column}";
}

// Text, lexeme'in yazıldığı hâli; Value, parse edilmiş literal (decimal, string, bool) ya da null.
public readonly record struct Token(TokenKind Kind, string Text, object? Value, SourcePosition Position);

public sealed record Diagnostic(SourcePosition Position, string Message)
{
    public override string ToString() => $"error at {Position}: {Message}";
}

Her token konumunu (satır ve sütun) taşıyor. Bunun bedeli küçük, getirisi büyük: parser'ın, type checker'ın ve hatta SQL üretirken çıkan bir sorunun bile kullanıcıya "şu sütunda" diyebilmesi bu bilgiye bağlı. Hataları exception olarak fırlatmak yerine bir Diagnostic listesinde topluyoruz; böylece tek geçişte birden çok hata raporlanabiliyor.

Lexer Bir State Machine'dir

Lexer'ı karakter karakter ilerleyen bir state machine olarak düşünmek işini kolaylaştırır. Başlangıç state'inde bir sonraki karaktere bakıyorsun: harf geldiyse "identifier okuyorum" state'ine, rakam geldiyse "sayı okuyorum" state'ine, tırnak geldiyse "string okuyorum" state'ine geçiyorsun. String state'inin içinde ters bölü bir alt state açıyor (escape). O state'ten çıkmayı sağlayan karakter gelince token bitiyor ve başlangıca dönüyorsun. Elle yazılan bir lexer'da bu state'ler ayrı method'lar oluyor:

public ref struct Lexer
{
    private static readonly FrozenDictionary<string, TokenKind> Keywords =
        new Dictionary<string, TokenKind>
        {
            ["and"] = TokenKind.And,
            ["or"] = TokenKind.Or,
            ["not"] = TokenKind.Not,
            ["contains"] = TokenKind.Contains,
            ["true"] = TokenKind.True,
            ["false"] = TokenKind.False,
        }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

    private static readonly FrozenDictionary<string, TokenKind>.AlternateLookup<ReadOnlySpan<char>> KeywordLookup =
        Keywords.GetAlternateLookup<ReadOnlySpan<char>>();

    private readonly ReadOnlySpan<char> _text;
    private readonly List<Diagnostic> _diagnostics;
    private int _pos;
    private int _line = 1;
    private int _lineStart;

    public Lexer(ReadOnlySpan<char> text, List<Diagnostic> diagnostics)
    {
        _text = text;
        _diagnostics = diagnostics;
    }

    public Token Next()
    {
        SkipWhitespaceAndComments();
        int start = _pos;
        SourcePosition position = Position(start);
        if (_pos >= _text.Length)
            return new Token(TokenKind.EndOfInput, "", null, position);

        char c = _text[_pos];
        switch (c)
        {
            case '(': return Take(1, TokenKind.OpenParen, start, position);
            case ')': return Take(1, TokenKind.CloseParen, start, position);
            case '=': return Take(1, TokenKind.Equal, start, position);
            // maximal munch: önce uzun operatör denenir
            case '<' when Peek(1) == '=': return Take(2, TokenKind.LessOrEqual, start, position);
            case '<': return Take(1, TokenKind.Less, start, position);
            case '>' when Peek(1) == '=': return Take(2, TokenKind.GreaterOrEqual, start, position);
            case '>': return Take(1, TokenKind.Greater, start, position);
            case '!' when Peek(1) == '=': return Take(2, TokenKind.NotEqual, start, position);
            case '"': return ReadString(start, position);
        }

        if (char.IsAsciiDigit(c))
            return ReadNumber(start, position);
        Rune.DecodeFromUtf16(_text[_pos..], out Rune rune, out int consumed);
        if (Rune.IsLetter(rune) || c == '_')
            return ReadIdentifierOrKeyword(start, position);

        // tanınmayan karakter: raporla ve bütün code point'i atla, böylece hata kurtarma hep ilerler
        _pos += Math.Max(consumed, 1);
        string hint = c == '!' ? " (did you mean '!='?)" : "";
        Error(position, $"unexpected character '{rune}' (U+{rune.Value:X4}){hint}");
        return Make(TokenKind.Bad, start, position);
    }
}

Lexer bir ref struct ve metni ReadOnlySpan<char> olarak tutuyor. Tek geçişte soldan sağa ilerliyor, geri dönmüyor ve bir karaktere en fazla bir kez ileriye bakıyor (Peek(1)). Kaynak metnin parçalarını ayrı string'lere kopyalamadan slice ile okuyor; string'e yalnız token'ın Text'i için bir kez çeviriyor. Keyword'ü bulmak için bile string oluşturmuyoruz: .NET 9 ile gelen GetAlternateLookup<ReadOnlySpan<char>>(), FrozenDictionary'de doğrudan span ile arama yapıyor.

İlk tahminin cevabı switch'in içinde. < görünce önce bir sonraki karaktere bakıyoruz; = ise iki karakteri birden alıp LessOrEqual üretiyoruz. Bu kuralın adı maximal munch: lexer her zaman eşleşebilen en uzun token'ı alır. price<=300 üç token, < ve = ayrı değil. Aynı kural identifier'larda da geçerli: android kelimesi and keyword'ü ile başlıyor ama lexer kelimenin sonuna kadar okuduğu için tek bir identifier çıkıyor.

Identifier, Keyword ve Unicode

private Token ReadIdentifierOrKeyword(int start, SourcePosition position)
{
    bool asciiOnly = true;
    while (_pos < _text.Length)
    {
        // bütün code point'i çöz: BMP dışındaki bir harf iki char'dır (surrogate pair)
        Rune.DecodeFromUtf16(_text[_pos..], out Rune rune, out int consumed);
        if (!Rune.IsLetterOrDigit(rune) && rune.Value != '_')
            break;
        asciiOnly &= rune.IsAscii;
        _pos += consumed;
    }

    ReadOnlySpan<char> word = _text[start.._pos];
    if (!asciiOnly)
    {
        Error(position, $"field name '{word}' must use ASCII letters only");
        return Make(TokenKind.Bad, start, position);
    }
    if (!KeywordLookup.TryGetValue(word, out TokenKind kind))
        return Make(TokenKind.Identifier, start, position);

    object? value = kind switch
    {
        TokenKind.True => true,
        TokenKind.False => false,
        _ => null,
    };
    return new Token(kind, word.ToString(), value, position);
}

Alan adlarımız İngilizce ve ASCII (İngiliz alfabesi, rakamlar ve temel işaretlerden oluşan 128 karakterlik set). Peki kullanıcı başlık yazarsa ne olmalı? Lexer'ın ilk sürümü yalnız ASCII harfleri identifier sayıyordu ve şunu üretiyordu:

$ dotnet run --project src/Bookstore.Filter.Cli -- tokens 'başlık = "Dune'
1:1    Identifier      ba
1:3    Bad             ş
1:4    Identifier      l
1:5    Bad             ı
1:6    Identifier      k
1:8    Equal           =
1:10   String          "Dune            "Dune"
1:15   EndOfInput
error at 1:3: unexpected character 'ş' (U+015F)
error at 1:5: unexpected character 'ı' (U+0131)
error at 1:10: unterminated string literal

Teknik olarak doğru, kullanıcı için anlamsız: tek bir kelime beş token'a ve iki hataya dönüştü. Yukarıdaki sürüm kelimeyi Unicode harf kurallarıyla sonuna kadar okuyor ve tek, anlaşılır bir hata veriyor:

$ dotnet run --project src/Bookstore.Filter.Cli -- tokens 'başlık = "Dune'
1:1    Bad             başlık
1:8    Equal           =
1:10   String          "Dune            "Dune"
1:15   EndOfInput
error at 1:1: field name 'başlık' must use ASCII letters only
error at 1:10: unterminated string literal

Karakterleri char yerine Rune olarak okumamızın sebebi .NET string'lerinin kullandığı Unicode kodlaması (UTF-16). Bir char 16 bit; matematik harfleri ya da emoji gibi bazı karakterler iki char'lık bir surrogate pair ile tutuluyor. Pair'in yarısını tek başına char.IsLetter'a verirsen false alırsın ve hata kurtarma bir karakterin ortasından devam eder. Unicode ve surrogate pair'ler Number System'ler serisinin "Unicode ve UTF-8/UTF-16" yazısında ayrıntılı.

Keyword karşılaştırması StringComparer.OrdinalIgnoreCase ile yapılıyor; ikinci tahminin cevabı da burada. ToLower() current culture'ı kullanır ve Türkçede büyük I'nın küçüğü noktasız ı'dır:

CultureInfo turkish = new("tr-TR");
Console.WriteLine("CONTAINS".ToLower(turkish));
Console.WriteLine("CONTAINS".ToLower(turkish) == "contains");
contaıns
False

ToLower() ile yazılmış bir lexer Türkçe Windows'ta CONTAINS'i tanımaz, İngilizce makinede tanır. Böyle bir bug geliştiricinin makinesinde hiç görünmez. Ordinal karşılaştırma culture'dan bağımsız; testlerde de CurrentCulture'ı bilerek tr-TR yapıp keyword'lerin yine tanındığını doğruluyoruz.

Sayılar, String'ler ve Yorumlar

private Token ReadNumber(int start, SourcePosition position)
{
    while (_pos < _text.Length && char.IsAsciiDigit(_text[_pos]))
        _pos++;

    if (Peek(0) == '.')
    {
        _pos++;
        if (!char.IsAsciiDigit(Peek(0)))
        {
            Error(Position(_pos), "expected a digit after '.'");
            return Make(TokenKind.Bad, start, position);
        }
        while (_pos < _text.Length && char.IsAsciiDigit(_text[_pos]))
            _pos++;
    }

    ReadOnlySpan<char> lexeme = _text[start.._pos];
    // dilde ondalık ayırıcı her zaman '.', current culture ne olursa olsun
    if (!decimal.TryParse(lexeme, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal value))
    {
        Error(position, $"number '{lexeme}' is out of range");
        return Make(TokenKind.Bad, start, position);
    }
    return new Token(TokenKind.Number, lexeme.ToString(), value, position);
}

InvariantCulture burada süs değil. tr-TR'de nokta binlik ayırıcı; decimal.Parse("49.90", new CultureInfo("tr-TR")) hata vermeden 4990 döner. Filtre price < 49.90 derken yüz kat büyük bir sayıyla karşılaştırma yapar ve kimse fark etmez. Number System'ler serisinin "Floating Point Nasıl Çalışır" yazısı sayıların temsilini anlatıyor; buradaki kural ise basit: dilin syntax'ı makinenin dil ayarına bağlı olamaz.

private Token ReadString(int start, SourcePosition position)
{
    StringBuilder value = new();
    _pos++; // açılış tırnağı
    while (true)
    {
        if (_pos >= _text.Length || _text[_pos] == '\n')
        {
            Error(position, "unterminated string literal");
            return new Token(TokenKind.String, _text[start.._pos].ToString(), value.ToString(), position);
        }

        char c = _text[_pos];
        if (c == '"')
        {
            _pos++;
            return new Token(TokenKind.String, _text[start.._pos].ToString(), value.ToString(), position);
        }

        if (c == '\\')
        {
            char next = Peek(1);
            if (next is '"' or '\\')
            {
                value.Append(next);
                _pos += 2;
                continue;
            }
            if (next is '\0' or '\n')
            {
                _pos++; // bir sonraki tur kapanmamış string'i raporlar
                continue;
            }
            Error(Position(_pos), $"unknown escape sequence '\\{next}'");
            _pos += 2;
            continue;
        }

        value.Append(c);
        _pos++;
    }
}

String okurken iki karar verdik. Birincisi, satır sonu string'i bitiriyor: kapanmamış bir tırnak dosyanın geri kalanını yutmasın, hata o satırda kalsın. İkincisi, kapanmamış string'de bile bir String token'ı dönüyoruz. Parser böylece "değer bekliyordum" diye ikinci bir hata üretmiyor; kullanıcı tek, doğru yeri gösteren bir mesaj görüyor. Hata konumu da açılış tırnağının konumu, çünkü kullanıcının düzelteceği yer orası.

Boşluklar ve # ile başlayan yorumlar token üretmiyor. SkipWhitespaceAndComments onları atlarken satır sayacını da güncelliyor; konum bilgisi de buradan geliyor:

private void SkipWhitespaceAndComments()
{
    while (_pos < _text.Length)
    {
        char c = _text[_pos];
        if (c == '\n')
        {
            _pos++;
            _line++;
            _lineStart = _pos;
        }
        else if (char.IsWhiteSpace(c))
        {
            _pos++;
        }
        else if (c == '#')
        {
            // yorum satır sonuna kadar sürer
            while (_pos < _text.Length && _text[_pos] != '\n')
                _pos++;
        }
        else
        {
            return;
        }
    }
}

private readonly SourcePosition Position(int index) => new(_line, index - _lineStart + 1);

Çalıştırıp Test Etmek

$ dotnet run --project src/Bookstore.Filter.Cli -- tokens 'price <= 300 and author = "Jane Austen"'
1:1    Identifier      price
1:7    LessOrEqual     <=
1:10   Number          300              300
1:14   And             and
1:18   Identifier      author
1:25   Equal           =
1:27   String          "Jane Austen"    "Jane Austen"
1:40   EndOfInput                       

Lexer'ın doğruluğunu xUnit testleriyle sabitliyoruz. Testlerden ikisi lexer'a özgü riskleri hedefliyor: keyword'lerin Türkçe culture'da da tanınması ve hata kurtarmanın hiçbir girdide sonsuz döngüye girmemesi. İkincisi, rastgele üretilmiş 1.000 girdiyle lexer'ın her seferinde EndOfInput'a ulaştığını ve hiçbir zaman karakter sayısından fazla token üretmediğini kontrol ediyor:

[Fact]
public void Tokenize_Keywords_AreCaseInsensitive_EvenUnderTurkishCulture()
{
    CultureInfo previous = CultureInfo.CurrentCulture;
    try
    {
        CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
        Assert.Equal([TokenKind.Identifier, TokenKind.Contains, TokenKind.String, TokenKind.EndOfInput],
            Kinds("TITLE CONTAINS \"Dune\""));
    }
    finally
    {
        CultureInfo.CurrentCulture = previous;
    }
}

[Fact]
public void Tokenize_Garbage_AlwaysTerminates()
{
    Random random = new(42);
    for (int i = 0; i < 1_000; i++)
    {
        char[] chars = [.. Enumerable.Range(0, 50).Select(_ => (char)random.Next(0, 0xD7FF))];
        List<Token> tokens = Lexer.Tokenize(chars).Tokens;
        Assert.Equal(TokenKind.EndOfInput, tokens[^1].Kind);
        Assert.True(tokens.Count <= chars.Length + 1);
    }
}
$ dotnet test --project tests/Bookstore.Filter.Tests
Test run summary: Passed!
  total: 17
  failed: 0
  succeeded: 17
  skipped: 0
  duration: 335ms

Solution'daki test projesi xUnit v3'ün Microsoft Testing Platform ile çalışan paketini kullanıyor; global.json'daki "test": { "runner": "Microsoft.Testing.Platform" } satırı dotnet test --project komutunu bu runner'a yönlendiriyor.

Regex ile Lexer: Ne Zaman Yeter?

"Bunu bir regex ile yazamaz mıydık?" sorusu haklı. Basit diller için çoğu zaman yazabilirsin. Her token türü için bir named group, aralarında alternation:

static partial class NaiveLexer
{
    [GeneratedRegex("""(?<ws>\s+)|(?<number>\d+(\.\d+)?)|(?<string>"[^"]*")|(?<op><=|>=|!=|[<>=()])|(?<word>[A-Za-z_]\w*)""")]
    public static partial Regex Token();
}
$ dotnet run --project samples/RegexLexer
price <= 300 and author = "Jane Austen"    => word:price  op:<=  number:300  word:and  word:author  op:=  string:"Jane Austen"
title = "The \"Hobbit\""                   => word:title  op:=  string:"The \"  word:Hobbit  string:""
title = "Dune                              => word:title  op:=  word:Dune

İlk satır kusursuz; alternation'da <='yi [<>=()]'den önce yazdığımız için maximal munch bile doğru. İkinci satırda escape'li tırnak string'i ikiye böldü ve ortaya anlamsız token'lar çıktı. Üçüncü satır daha kötü: Matches eşleşmeyen karakterleri sessizce atladığı için kapanmamış tırnak hiçbir hata üretmeden kayboldu ve Dune bir alan adına dönüştü. Bunların hepsi düzeltilebilir: escape için daha karmaşık bir pattern, eşleşmeyen aralıkları bulmak için match'ler arası boşluk kontrolü, konum için satır sonlarını sayan ek kod. Bunları eklediğinde elinde, okuması elle yazılmış lexer'dan zor bir regex ve onun etrafında bir lexer oluyor.

Pratik kural şu: Girdi güvenilir, token'lar düzenli ve hata mesajı önemsizse (bir log satırını parçalamak gibi) regex yeter. Kullanıcı yazıyorsa, hata konumu ve anlaşılır mesaj gerekiyorsa ya da string escape ve iç içe yorum gibi bağlam isteyen kurallar varsa elle yazılmış lexer daha kısa sürede doğru çalışır. Regex'in nasıl eşleştirdiği ve backtracking maliyeti Regular Expressions serisinde anlatılıyor.

Roslyn'in Lexer'ı Boşlukları Neden Atmıyor?

Bizim lexer boşlukları ve yorumları atıyor; filtreyi çalıştırmak için onlara ihtiyacımız yok. C# compiler'ı Roslyn ise hiçbir karakteri atmıyor. Boşluk, satır sonu ve yorumları trivia adıyla token'lara bağlıyor: bir token'ın önündekiler leading, arkasındakiler trailing trivia. Bunu Microsoft.CodeAnalysis.CSharp paketiyle görmek kolay:

string code = """
    // cheap books only
    var cheap=books.Where( b=>b.Price<300m );   /* TRY */

    """;

foreach (SyntaxToken token in SyntaxFactory.ParseTokens(code))
{
    string leading = string.Join(",", token.LeadingTrivia.Select(t => t.Kind()));
    string trailing = string.Join(",", token.TrailingTrivia.Select(t => t.Kind()));
    Console.WriteLine($"{token.Kind(),-22} {token.Text,-8} leading=[{leading}] trailing=[{trailing}]");
}

SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
SyntaxNode root = tree.GetRoot();
Console.WriteLine($"round-trip equal: {root.ToFullString() == code}");
Console.WriteLine("normalized:");
Console.WriteLine(root.NormalizeWhitespace().ToFullString());
$ dotnet run --project samples/RoslynTrivia
IdentifierToken        var      leading=[SingleLineCommentTrivia,EndOfLineTrivia] trailing=[WhitespaceTrivia]
IdentifierToken        cheap    leading=[] trailing=[]
EqualsToken            =        leading=[] trailing=[]
IdentifierToken        books    leading=[] trailing=[]
DotToken               .        leading=[] trailing=[]
IdentifierToken        Where    leading=[] trailing=[]
OpenParenToken         (        leading=[] trailing=[WhitespaceTrivia]
IdentifierToken        b        leading=[] trailing=[]
EqualsGreaterThanToken =>       leading=[] trailing=[]
IdentifierToken        b        leading=[] trailing=[]
DotToken               .        leading=[] trailing=[]
IdentifierToken        Price    leading=[] trailing=[]
LessThanToken          <        leading=[] trailing=[]
NumericLiteralToken    300m     leading=[] trailing=[WhitespaceTrivia]
CloseParenToken        )        leading=[] trailing=[]
SemicolonToken         ;        leading=[] trailing=[WhitespaceTrivia,MultiLineCommentTrivia,EndOfLineTrivia]
EndOfFileToken                  leading=[] trailing=[]
round-trip equal: True
normalized:
// cheap books only
var cheap = books.Where(b => b.Price < 300m); /* TRY */

Çıktıda üç şey var. Birincisi, ağaç kaynak metni karakteri karakterine geri verebiliyor (round-trip equal: True). Formatter'lar, refactoring'ler ve code fix'ler bu yüzden Roslyn ağacı üzerinde çalışabiliyor: bir method'un adını değiştiren refactoring dosyanın geri kalanındaki yorumlara ve hizalamaya dokunmamalı. NormalizeWhitespace() da yalnız trivia'yı yeniden yazarak kodu biçimlendiriyor. İkincisi, yorum bir token'ın parçası; // cheap books only, var'ın leading trivia'sı. Üçüncüsü, var bir IdentifierToken. C#'ta var contextual keyword; onun bir tip adı mı yoksa "tipi çıkar" anlamında mı kullanıldığına lexer değil, sonraki aşamalar karar veriyor. Lexer kelimeye bakıyor, anlamı bilmiyor.

Üretimde Dikkat

Lexer kullanıcı girdisinin ilk dokunduğu kod; girdi uzunluğuna bir üst sınır koy. Bizim lexer doğrusal çalışıyor ama ürettiği token listesi ve sonraki aşamaların işi girdiyle büyüyor. Sınırı 04. yazıda, bütün pipeline'ın önüne koyacağız.

Hata mesajları bir API sözleşmesi. Kullanıcıya gösterilen error at 1:9: unterminated string literal metni zamanla dokümanlara, ekran görüntülerine ve hatta istemci tarafındaki kodlara giriyor. Mesaj formatını testlerle sabitle ve değiştirirken bunu breaking change olarak ele al.

Konum bilgisini satır ve sütun olarak verirken sütunun neyi saydığını belirle. Bizim lexer UTF-16 code unit sayıyor; tab bir sütun, emoji iki sütun. Editörler genelde farklı sayar. Hata gösterilen yer ile kullanıcının gördüğü yer ayrışıyorsa ilk bakacağın yer burası.

Sık Düşülen Tuzaklar

Maximal munch'ı unutmak. Önce kısa operatörü kontrol eden bir switch <='yi < ve = olarak böler, parser da "beklenmeyen =" diye anlamsız bir hata verir. Çok karakterli operatörlerde her zaman uzun olanı önce dene.

Keyword'leri ToLower() ya da ToUpper() ile karşılaştırmak. Türkçe culture'da I ve i dönüşümleri farklı. Ordinal ya da OrdinalIgnoreCase kullan.

Unicode'u char sanmak. Surrogate pair'leri tek tek değerlendirmek hata kurtarmayı bir karakterin ortasına düşürür. Kararı Rune düzeyinde ver.

İlerlemeyen hata kurtarma. Tanınmayan bir karakterde hata ekleyip _pos'u artırmayı unutan bir lexer aynı karakterde sonsuza kadar döner. Kural basit: Next()'in her yolu ya en az bir karakter tüketmeli ya da EndOfInput dönmeli. Rastgele girdiyle yazılan test bu kuralı korur.

Küçük Bir Deneme

★☆☆ Lexer'a !='nin yanında <> desteği ekle (SQL'deki gibi). Maximal munch'a göre < case'lerinin sırası ne olmalı? price<>300 için bir test yaz.

★★☆ String'lere \n ve \u00E7 biçiminde escape desteği ekle. Geçersiz bir \u dizisinde hata konumu nerede olmalı? Hata sonrası lexer'ın hâlâ doğru konumdan devam ettiğini bir testle göster.

★★★ /* ... */ biçiminde iç içe yorum desteği ekle (/* a /* b */ c */ tek bir yorum olsun). Bunu neden düz bir regex ile yapamadığını açıkla; lexer'ına bir sayaç ekleyip kapanmamış yorumun açılış konumunu raporla.

Token'lar hazır; ama price < 300 and author = "Jane Austen" hâlâ düz bir liste. Bir sonraki yazıda grammar'dan recursive descent parser'ı türetecek, operatör önceliğini Pratt parsing ile bir tabloya taşıyacak, AST'yi (abstract syntax tree; kodun anlamlı yapısını gösteren ağaç) C# record'larıyla kuracak ve onu doğrudan çalıştıran bir tree-walking interpreter yazacağız.