SynKit Syntax Toolkit

repository·master·Indexed 41 days ago

https://github.com/languagedev/yoakke

A high-performance C# syntax toolkit for lexing, parsing, AST management, and error reporting. SynKit utilizes source generators to create lexers and parsers at compile time via attribute-driven configuration, avoiding runtime reflection. It features declarative lexer definitions using DFAs, recursive descent parsing with BNF-like notation, and a fluent API for formatted, colorized error reporting with immutable ParseError objects.

Tokens
11.6K
Snippets
28
Records
45
Agent score
100%

What's inside SynKit

  1. Overview of SynKit components

    master

    SynKit is a collection of syntax-related modules within the Yoakke ecosystem designed for grammar analysis, lexer/parser generation, and syntax tooling development. It consists of four primary components:

    • Lexing: Declarative lexer definition using regular expression annotations over token types. These are compiled into Deterministic Finite Automata (DFAs) for high performance.
    • Parsing: Recursive descent parsing using BNF-like notation via method annotations. It supports automatic transformation of direct left-recursion and precedence tables.
    • Syntax trees: Tools for managing Abstract Syntax Trees (ASTs), including automated generation of equality, hash implementations, and visitor patterns for AST nodes.
    • Error reporting: A fluent API for generating formatted, colorized, and aligned error messages for console output, including support for custom syntax-highlighters for source lines.
  2. How the Parser library is structured

    master

    The parser library is split into two distinct components:

    1. Yoakke.Parser: Contains the basic structures and types required for parsing.
    2. Yoakke.Parser.Generator: A source generator that automatically creates a parser implementation based on your method annotations.

    Parsing results are modeled using the ParseResult<T> type. Because parsing errors are expected behavior, they are not treated as exceptions. A result will be either:

    • ParseOk<T>: Contains the successfully parsed value of type T.
    • ParseError: Contains details about why the parsing failed.
  3. Optimize Error Handling Performance

    master

    To minimize the performance impact of error handling:

    • Reuse Error Objects: For common errors (like a missing semicolon), use static readonly error instances instead of allocating new ones.
    • Lazy Formatting: Defer the construction of complex error strings until they are actually needed for display.
    • Lightweight Contexts: Use constant strings for context identifiers (e.g., private const string ExpressionContext = "expression") instead of dynamic string generation.
  4. Use Punctuated<TValue, TPunct> for sequences

    master

    When parsing sequences with separators (like comma-separated lists), the raw tuple types generated by the parser can be complex and difficult to work with.

    The library provides the Punctuated<TValue, TPunct> type to simplify this. It is implicitly convertible from the complex tuples generated by common patterns like:

    • (expr (',' expr)*)? (0 or more, no trailing separator)
    • expr (',' expr)* (1 or more, no trailing separator)
    • (initializer (',' initializer)* ','?)? (0 or more, optional trailing separator)
    • (initializer (',' initializer)* ','?) (1 or more, optional trailing separator)

    Note: For 1-or-more patterns, the result is a non-nullable tuple. If the pattern is at the top level of a rule, the library unwraps it into the parameter list, which prevents conversion to Punctuated. To avoid this, wrap the construct in parentheses (...) within your rule string.

    [Rule("name_list : Lparen (Identifier (',' Identifier)*)? Rparen")]
    private static List<string> NameList(
        IToken<TokenType> _lp,
        Punctuated<IToken<TokenType>, IToken<TokenType>> elements,
        IToken<TokenType> _rp) => elements.Values.Select(t => t.Text).ToList();
  5. Merge ParseErrors using the | operator

    master

    You can combine ParseError instances using the | operator. The behavior depends on the position and context:

    • Same Position: Errors at the same position are merged into a single context with multiple expected elements.
    • Different Contexts: Errors from different contexts are both preserved.
    • Position Priority: If errors occur at different positions, the error at the later position takes precedence and replaces the earlier one.
    var error1 = new ParseError("^", null, 12, "expression");
    var error2 = new ParseError("|", null, 12, "expression");
    var merged = error1 | error2;
    // Result: Expected "^" or "|" in expression context
  6. Configure Operator Precedence and Associativity

    master

    Instead of encoding precedence into the grammar structure, use precedence attributes on your binary operator rules. Precedence is determined by the order of attribute declaration (lower precedence to higher precedence).

    Associativity Types

    • [Left(...)]: Left associative (e.g., a + b + c is (a + b) + c).
    • [Right(...)]: Right associative (e.g., a ^ b ^ c is a ^ (b ^ c)).
    • [Precedence(...)]: Non-associative.

    Example Implementation

    [Left("||")]                    // Lowest precedence
    [Left("&&")]  
    [Left("==", "!=")]
    [Left("<", ">", "<=", ">=")]
    [Left("+", "-")]
    [Left("*", "/", "% ")]
    [Right("^")]                    // Highest precedence
    [Rule("expression")]
    public static int BinaryOp(int left, IToken op, int right)
    {
        return op.Text switch
        {
            "+" => left + right,
            "-" => left - right,
            "*" => left * right,
            "/" => left / right,
            "^" => (int)Math.Pow(left, right),
            _ => throw new NotSupportedException($"Operator {op.Text}")
        };
    }
    [Left("+", "-")]          // Lower precedence, left associative
    [Left("*", "/")]          // Higher precedence, left associative  
    [Right("^")]              // Right associative exponentiation
    [Rule("expression")]      // Inferred rule for binary operators
    public static int BinaryOp(int left, IToken op, int right)
    {
        return op.Text switch
        {
            "+" => left + right,
            "-" => left - right,
            "*" => left * right,
            "/" => left / right,
            "^" => (int)Math.Pow(left, right),
            _ => throw new NotSupportedException($"Operator {op.Text}")
        };
    }
  7. Use EBNF-style syntax for Optional and Repetition rules

    master

    The parser supports standard EBNF-style syntax within the [Rule] attribute to handle optional elements and repetitions.

    Repetition

    Use * for zero or more occurrences. When using repetition, the method signature should accept an IReadOnlyList<T> of the repeated items.

    [Rule("modifier_list: modifier*")]
    public static List<Modifier> ModifierList(IReadOnlyList<Modifier> modifiers) 
        => modifiers.ToList();

    Optional and Complex Sequences

    You can combine elements to define complex sequences, such as a comma-separated list where the first element is required and subsequent elements are optional.

    [Rule("parameter_list: parameter (',' parameter)*")]
    public static List<Parameter> ParameterList(Parameter first, IReadOnlyList<(IToken, Parameter)> rest)
    {
        var result = new List<Parameter> { first };
        result.AddRange(rest.Select(x => x.Item2));
        return result;
    }
  8. How SynKit's core design principles work

    master

    SynKit is built around three primary architectural pillars designed for high performance and developer ergonomics:

    1. Source Generation Over Reflection: Instead of using runtime reflection, SynKit uses C# source generators to create lexers and parsers at compile time. This ensures zero runtime reflection overhead, compile-time validation of grammar rules, and the ability to step through generated code during debugging.
    2. Attribute-Driven Configuration: You define your grammar and tokens directly on your code using attributes:
      • [Token]: Defines literal tokens.
      • [Regex]: Defines pattern-based tokens.
      • [Rule]: Defines parser production rules.
      • [Left], [Right]: Specifies operator precedence and associativity.
    3. Immutable Error Handling: Errors are treated as immutable objects. You can combine and enrich ParseError objects using the | operator. Errors include position information, expected elements, and semantic context.
  9. Understand the core lexing interfaces in Yoakke.Lexer

    master

    The Yoakke.Lexer library provides the fundamental building blocks for lexing. Regardless of whether you use the source generator or write a manual lexer, you will interact with these interfaces:

    • IToken: Represents a kind-less token (contains only a text range and the text value).
    • IToken<TKind>: Represents a token with a specific kind tag.
    • Token<TKind>: A standard implementation of IToken<TKind> where TKind is your token enumeration.
    • ILexer: The base interface for any lexer producing ITokens.
    • ILexer<TToken>: An interface for a lexer producing specific token types (e.g., ILexer<Token<TokenType>>).
  10. Understand the SynKit component architecture and data flow

    master

    SynKit operates through a layered architecture that transforms raw text into structured data:

    Component Layers

    • Text Layer: Provides the foundation with SourceFile, Position, Range, and Location.
    • Lexer Layer: Uses ILexer<T> to transform text into a TokenStream of Token<T> objects.
    • Parser Layer: Uses IParser to consume the TokenStream and produce a ParseResult<T>.
    • Reporting Layer: Handles Diagnostics, Severity, Presenter, and Highlighter for error reporting.

    Data Flow

    1. Lexical Analysis: Source Text $\rightarrow$ ILexer<TToken> $\rightarrow$ Token Stream (containing IToken<TToken>).
    2. Syntactic Analysis: Token Stream $\rightarrow$ IParser $\rightarrow$ ParseResult<T> (which is either an Ok<T> or an Error).
  11. Understand the SynKit example structure

    master

    Each example in the repository follows a consistent structure to help you understand how components are organized:

    • README.md: Overview and usage instructions.
    • Program.cs: The main entry point for the application.
    • TokenType.cs: Contains the token definitions (used when implementing lexers).
    • Parser.cs: Contains the parser implementation (used when implementing grammars).
    • sample-input.txt: Example input files used to test the implementation.