Parlot .NET Parser Combinator Library

repository·main·Indexed 20 days ago

https://github.com/sebastienros/parlot

A fast, lightweight .NET parser combinator library featuring a fluent API for runtime grammar building and a source-generation mode using C# interceptors for high-performance, AOT-friendly, compile-time parser construction. It provides tools for handling recursive grammars via Deferred<T>, operator overloading for composition, and specialized parsers for terms, literals, keywords, and numbers.

Tokens
12.1K
Snippets
32
Records
37
Agent score
68%

What's inside Parlot

  1. Difference between Terms and Literals

    main

    Parlot distinguishes between Terms and Literals based on how they handle whitespace:

    • Literals: These are low-level elements (like specific strings or characters) that match the input exactly as specified.
    • Terms: These return a parser that automatically accepts blank spaces before the element.

    For example, Literals.Text("hello") will only match the exact string "hello", whereas Terms.Text("hello") can match " hello".

  2. Leverage covariance with IParser<out T> and OneOf<T>

    main

    Parlot supports covariance through the IParser<out T> interface. This allows you to use a parser of a derived type (e.g., IParser<Dog>) in a context that expects a parser of a base type (e.g., IParser<Animal>).

    Instead of using .Then<TBase>(x => x) to manually cast parsers—which creates unnecessary wrapper objects and impacts performance—you can use the OneOf<T> method. OneOf<T> accepts params IParser<T>[], allowing it to directly consume covariant parsers.

    class Animal { public string Name { get; set; } }
    class Dog : Animal { public string Breed { get; set; } }
    class Cat : Animal { public string Color { get; set; } }
    
    var dogParser = Terms.Text("dog").Then(_ => new Dog { Name = "Buddy", Breed = "Golden Retriever" });
    var catParser = Terms.Text("cat").Then(_ => new Cat { Name = "Whiskers", Color = "Orange" });
    
    // Use OneOf<T> directly with covariant parsers to avoid wrapper overhead
    var animalParser = OneOf<Animal>(dogParser, catParser);
  3. Configure parser dependencies with Helper Attributes

    main

    You can use helper attributes to control the environment of the generated code. These can be applied to individual methods or at the class level to affect all parsers within that class.

    IncludeFiles

    Include additional source files that your parser depends on. Paths are relative to the source file containing the [GenerateParser] method. Supports glob patterns:

    • *: matches any characters except path separator
    • **: matches recursively
    • ?: matches single character

    IncludeUsings

    Add specific using directives to the generated code.

    IncludeGenerators

    Specify other source generators (e.g., PolySharp) that should run before the Parlot parser generation.

    Class-level Application

    Applying these attributes to a partial class applies them to all [GenerateParser] methods within that class. Method-level attributes are additive to class-level attributes.

    // Method-level usage
    [GenerateParser]
    [IncludeFiles("Ast.cs", "Tokens.cs")]
    [IncludeUsings("System.Collections.Generic", "MyProject.Models")]
    [IncludeGenerators("PolySharp")]
    public static Parser<Expression> CreateParser() => ...;
    
    // Class-level usage
    [IncludeFiles("Ast.cs")]
    [IncludeUsings("MyProject.Models")]
    public static partial class MyParsers
    {
        [GenerateParser]
        public static Parser<Expression> ExprParser() => ...;
    
        [GenerateParser]
        [IncludeFiles("Extra.cs")]  // Combined with class-level
        public static Parser<Statement> StmtParser() => ...;
    }
  4. Optimize parsers using Lookup Tables and ISeekable

    main

    The OneOf parser (created via .Or(a, b)) can optimize parsing by using lookup tables to make quick decisions based on the next character in the input. This avoids unnecessary parser invocations.

    To enable this optimization, a parser type can implement the ISeekable interface. Even if the parser cannot provide a full list of expected characters, implementing ISeekable and setting the CanSeek property allows the engine to utilize the optimization.

    Example of creating an optimized Or parser:

    var integer = Terms.Integer();
    var hello = Terms.Text("Hello", caseInsensitive: true);
    var intOrHello = integer.Or(hello);

    Note on Terms.Text:

    • When caseInsensitive: true is used, Text("Hello") returns the canonical requested text ("Hello") by default to avoid allocations.
    • If you require the actual matched input text (e.g., "HELLO"), set returnMatchedText: true.
  5. Handle failures with Else and ThenElse

    main

    Else

    Returns a specific value if the previous parser fails. This makes the parser always succeed. Use Else when you only care about having a value; use Optional() if you need to know if the original parser actually succeeded.

    ThenElse

    Combines Then and Else. It converts the result if successful, or returns a fallback value if the parser fails. This parser always succeeds.

    Overloads for ThenElse:

    • ThenElse<U>(Func<T, U> conversion, U elseValue)
    • ThenElse<U>(Func<ParseContext, T, U> conversion, U elseValue)
    • ThenElse<U>(U value, U elseValue)
    var parser = Terms.Integer().Else<string>(0).And(Terms.Text("years"));
    parser.Parse("years");    // Result: (0, "years")
    parser.Parse("123 years"); // Result: (123, "years")
    
    // Using ThenElse
    var parser = Terms.Integer().ThenElse<long?>(x => x, null);
    parser.Parse("abc"); // Result: (long?)null
  6. How to use [GenerateParser] for optimized parsers

    main

    Annotate a static method with the [GenerateParser] attribute to trigger source generation. This replaces the runtime construction of a parser graph with optimized C# code, improving startup time and parsing performance.

    Requirements for annotated methods:

    • Must be static.
    • Must be parameterless.
    • Must return Parlot.Fluent.Parser<T>.
    • It is recommended to use a partial class.

    Example usage:

    using Parlot.SourceGenerator;
    using Parlot.Fluent;
    using static Parlot.Fluent.Parsers;
    
    public static partial class MyGrammar
    {
        [GenerateParser]
        public static Parser<string> HelloParser() => Terms.Text("hello");
    }
    
    // Usage:
    var parser = MyGrammar.HelloParser(); // This call is intercepted and uses generated code
    var result = parser.Parse("hello world");
    using Parlot.SourceGenerator;
    using Parlot.Fluent;
    using static Parlot.Fluent.Parsers;
    
    public static partial class MyGrammar
    {
        [GenerateParser]
        public static Parser<string> HelloParser() => Terms.Text("hello");
    }
  7. Inspect generated source files

    main

    If you need to debug or verify the code produced by the source generator, you can instruct the compiler to emit these files to your output directory by adding the following to your project file:

    <PropertyGroup>
      <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
      <CompilerGeneratedFilesOutputPath>obj\$(Configuration)\$(TargetFramework)</CompilerGeneratedFilesOutputPath>
    </PropertyGroup>
  8. Setup Parlot Fluent API

    main

    To use the parser combinators in Parlot, you must include the following import statements. If your project has ImplicitUsings enabled, the static import is included automatically.

    using Parlot.Fluent;
    using static Parlot.Fluent.Parsers;

    The using static statement allows you to access Terms, Literals, and other combinators (like ZeroOrOne, Between, etc.) directly without prefixing them with the class name.

  9. Manage cursor position with ResetPosition

    main

    When implementing a custom parser, you must ensure that if a parser fails after having advanced the cursor, the cursor is reset to its initial position. This prevents subsequent parsers from starting at the wrong offset.

    Best Practices:

    • Always return from a non-successful parser with the cursor position it had when the parser was first invoked.
    • If a parser succeeds and then a subsequent parser in a sequence fails, you must call context.Scanner.Cursor.ResetPosition(start) where start is the position captured before the first parser was called.
    • If a parser is designed to be atomic and handles its own cleanup, it is assumed to reset the cursor on failure.
    • Unit Testing: Always create a unit test to verify that your parser correctly resets the cursor position upon failure.
    // Example of resetting position in a Sequence parser
    public override bool Parse(ParseContext context, ref ParseResult<ValueTuple<T1, T2>> result)
    {
        context.EnterParser(this);
    
        var parseResult1 = new ParseResult<T1>();
        var start = context.Scanner.Cursor.Position; // Capture start position
    
        if (_parser1.Parse(context, ref parseResult1))
        {
            var parseResult2 = new ParseResult<T2>();
    
            if (_parser2.Parse(context, ref parseResult2))
            {
                result.Set(parseResult1.Start, parseResult2.End, new ValueTuple<T1, T2>(parseResult1.Value, parseResult2.Value));
                context.ExitParser(this);
                return true;
            }
    
            // Reset position because parser1 succeeded but parser2 failed
            context.Scanner.Cursor.ResetPosition(start);
        }
    
        context.ExitParser(this);
        return false;
    }
  10. Setup the Parlot Fluent API

    main

    To use the Fluent API for defining grammars, you must import the Parlot.Fluent namespace and the static Parsers class. The static import is essential to access combinators like Terms, Literals, ZeroOrOne, and Between directly.

    If your project has ImplicitUsings (Global Usings) enabled, this import may be included automatically.

    using Parlot.Fluent;
    using static Parlot.Fluent.Parsers;
  11. Debug generated parser code

    main

    To inspect the optimized code produced by the source generator, configure your project file (.csproj) to emit compiler-generated files to a specific output directory.

    <PropertyGroup>
      <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
      <CompilerGeneratedFilesOutputPath>obj\$(Configuration)\$(TargetFramework)</CompilerGeneratedFilesOutputPath>
    </PropertyGroup>
  12. Use Parser Factories and Selectors instead of dynamic creation

    main

    Parser constructors can be expensive (for example, OneOf builds lookup tables). To maintain performance, avoid creating new parser instances dynamically during the parsing process (e.g., inside a lambda).

    Instead of returning a new parser from a selector, use a selector that returns an index into a pre-defined list of fixed parsers.

    // var p = Select(c => c.OptionA ? Terms.Text("a") : Terms.Text("b"));
    
    // DO this (index into fixed list):
    var a = Terms.Text("a");
    var b = Terms.Text("b");
    var p = Select(c => c.OptionA ? 0 : 1, a, b);