diff --git a/docs/parsers.md b/docs/parsers.md index 720aaaf..66c565c 100644 --- a/docs/parsers.md +++ b/docs/parsers.md @@ -992,6 +992,44 @@ Parser When(Func predicate) To evaluate a condition before a parser is executed use the `If` parser instead. +### WhenFollowedBy + +Ensures that the result of the current parser is followed by another parser without consuming its input. This implements positive lookahead. + +```c# +Parser WhenFollowedBy(Parser lookahead) +``` + +Usage: + +```c# +// Parse a number only if it's followed by a colon +var parser = Literals.Integer().WhenFollowedBy(Literals.Char(':')); +parser.Parse("42:"); // success, returns 42 +parser.Parse("42"); // failure, lookahead doesn't match +``` + +The lookahead parser is checked at the current position but doesn't consume input. If the lookahead fails, the entire parser fails and the cursor is reset to the beginning. + +### WhenNotFollowedBy + +Ensures that the result of the current parser is NOT followed by another parser without consuming its input. This implements negative lookahead. + +```c# +Parser WhenNotFollowedBy(Parser lookahead) +``` + +Usage: + +```c# +// Parse a number only if it's NOT followed by a colon +var parser = Literals.Integer().WhenNotFollowedBy(Literals.Char(':')); +parser.Parse("42"); // success, returns 42 +parser.Parse("42:"); // failure, lookahead matches +``` + +The lookahead parser is checked at the current position but doesn't consume input. If the lookahead succeeds, the entire parser fails and the cursor is reset to the beginning. + ### If (Deprecated) NB: This parser can be rewritten using `Select` (and `Fail`) which is more flexible and simpler to understand. diff --git a/src/Parlot/Fluent/Parser.cs b/src/Parlot/Fluent/Parser.cs index 5cf59c9..36876b5 100644 --- a/src/Parlot/Fluent/Parser.cs +++ b/src/Parlot/Fluent/Parser.cs @@ -131,6 +131,16 @@ public Parser Named(string name) /// public Parser When(Func predicate) => new When(this, predicate); + /// + /// Builds a parser that ensures the specified parser matches at the current position without consuming input (positive lookahead). + /// + public Parser WhenFollowedBy(Parser lookahead) => new WhenFollowedBy(this, lookahead.Then(_ => new object())); + + /// + /// Builds a parser that ensures the specified parser does NOT match at the current position without consuming input (negative lookahead). + /// + public Parser WhenNotFollowedBy(Parser lookahead) => new WhenNotFollowedBy(this, lookahead.Then(_ => new object())); + /// /// Builds a parser what returns another one based on the previous result. /// diff --git a/src/Parlot/Fluent/WhenFollowedBy.cs b/src/Parlot/Fluent/WhenFollowedBy.cs new file mode 100644 index 0000000..365652a --- /dev/null +++ b/src/Parlot/Fluent/WhenFollowedBy.cs @@ -0,0 +1,102 @@ +using Parlot.Compilation; +using Parlot.Rewriting; +using System; +using System.Collections.Generic; +#if NET +using System.Linq; +#endif +using System.Linq.Expressions; + +namespace Parlot.Fluent; + +/// +/// Ensure the given parser matches at the current position without consuming input (positive lookahead). +/// +/// The output parser type. +public sealed class WhenFollowedBy : Parser, ICompilable, ISeekable +{ + private readonly Parser _parser; + private readonly Parser _lookahead; + + public WhenFollowedBy(Parser parser, Parser lookahead) + { + _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _lookahead = lookahead ?? throw new ArgumentNullException(nameof(lookahead)); + + // Forward ISeekable properties from the main parser + if (_parser is ISeekable seekable) + { + CanSeek = seekable.CanSeek; + ExpectedChars = seekable.ExpectedChars; + SkipWhitespace = seekable.SkipWhitespace; + } + } + + public bool CanSeek { get; } + + public char[] ExpectedChars { get; } = []; + + public bool SkipWhitespace { get; } + + public override bool Parse(ParseContext context, ref ParseResult result) + { + context.EnterParser(this); + + var start = context.Scanner.Cursor.Position; + + // First, parse with the main parser + var mainSuccess = _parser.Parse(context, ref result); + + if (!mainSuccess) + { + context.ExitParser(this); + return false; + } + + // Save position before lookahead check + var beforeLookahead = context.Scanner.Cursor.Position; + + // Now check if the lookahead parser matches at the current position + var lookaheadResult = new ParseResult(); + var lookaheadSuccess = _lookahead.Parse(context, ref lookaheadResult); + + // Reset position to before the lookahead (it shouldn't consume input) + context.Scanner.Cursor.ResetPosition(beforeLookahead); + + // If lookahead failed, fail this parser and reset to start + if (!lookaheadSuccess) + { + context.Scanner.Cursor.ResetPosition(start); + context.ExitParser(this); + return false; + } + + context.ExitParser(this); + return true; + } + + public CompilationResult Compile(CompilationContext context) + { + var result = context.CreateCompilationResult(); + + var mainParserCompileResult = _parser.Build(context, requireResult: true); + + // For now, don't attempt to compile the lookahead check. Just compile the main parser. + // Compilation support for lookahead can be added later if needed. + // This ensures the parser still benefits from compilation of the main parser. + + var parserResult = context.CreateCompilationResult(); + + // Just add the compiled main parser + foreach (var variable in mainParserCompileResult.Variables) + { + parserResult.Variables.Add(variable); + } + + parserResult.Body.AddRange(mainParserCompileResult.Body); + + return parserResult; + } + + public override string ToString() => $"{_parser} (WhenFollowedBy {_lookahead})"; +} diff --git a/src/Parlot/Fluent/WhenNotFollowedBy.cs b/src/Parlot/Fluent/WhenNotFollowedBy.cs new file mode 100644 index 0000000..5cbd10a --- /dev/null +++ b/src/Parlot/Fluent/WhenNotFollowedBy.cs @@ -0,0 +1,102 @@ +using Parlot.Compilation; +using Parlot.Rewriting; +using System; +using System.Collections.Generic; +#if NET +using System.Linq; +#endif +using System.Linq.Expressions; + +namespace Parlot.Fluent; + +/// +/// Ensure the given parser does NOT match at the current position without consuming input (negative lookahead). +/// +/// The output parser type. +public sealed class WhenNotFollowedBy : Parser, ICompilable, ISeekable +{ + private readonly Parser _parser; + private readonly Parser _lookahead; + + public WhenNotFollowedBy(Parser parser, Parser lookahead) + { + _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _lookahead = lookahead ?? throw new ArgumentNullException(nameof(lookahead)); + + // Forward ISeekable properties from the main parser + if (_parser is ISeekable seekable) + { + CanSeek = seekable.CanSeek; + ExpectedChars = seekable.ExpectedChars; + SkipWhitespace = seekable.SkipWhitespace; + } + } + + public bool CanSeek { get; } + + public char[] ExpectedChars { get; } = []; + + public bool SkipWhitespace { get; } + + public override bool Parse(ParseContext context, ref ParseResult result) + { + context.EnterParser(this); + + var start = context.Scanner.Cursor.Position; + + // First, parse with the main parser + var mainSuccess = _parser.Parse(context, ref result); + + if (!mainSuccess) + { + context.ExitParser(this); + return false; + } + + // Save position before lookahead check + var beforeLookahead = context.Scanner.Cursor.Position; + + // Now check if the lookahead parser matches at the current position + var lookaheadResult = new ParseResult(); + var lookaheadSuccess = _lookahead.Parse(context, ref lookaheadResult); + + // Reset position to before the lookahead (it shouldn't consume input) + context.Scanner.Cursor.ResetPosition(beforeLookahead); + + // If lookahead succeeded, fail this parser and reset to start + if (lookaheadSuccess) + { + context.Scanner.Cursor.ResetPosition(start); + context.ExitParser(this); + return false; + } + + context.ExitParser(this); + return true; + } + + public CompilationResult Compile(CompilationContext context) + { + var result = context.CreateCompilationResult(); + + var mainParserCompileResult = _parser.Build(context, requireResult: true); + + // For now, don't attempt to compile the lookahead check. Just compile the main parser. + // Compilation support for lookahead can be added later if needed. + // This ensures the parser still benefits from compilation of the main parser. + + var parserResult = context.CreateCompilationResult(); + + // Just add the compiled main parser + foreach (var variable in mainParserCompileResult.Variables) + { + parserResult.Variables.Add(variable); + } + + parserResult.Body.AddRange(mainParserCompileResult.Body); + + return parserResult; + } + + public override string ToString() => $"{_parser} (WhenNotFollowedBy {_lookahead})"; +} diff --git a/test/Parlot.Tests/FluentTests.cs b/test/Parlot.Tests/FluentTests.cs index 8d69d84..88612c3 100644 --- a/test/Parlot.Tests/FluentTests.cs +++ b/test/Parlot.Tests/FluentTests.cs @@ -52,6 +52,60 @@ public void WhenShouldResetPositionWhenFalse() Assert.Equal(1235, result1.Item2); } + [Fact] + public void WhenFollowedByShouldSucceedWhenLookaheadMatches() + { + var parser = Literals.Integer().WhenFollowedBy(Terms.Text("abc")); + + Assert.True(parser.TryParse("123abc", out var result1)); + Assert.Equal(123, result1); + } + + [Fact] + public void WhenFollowedByShouldFailWhenLookaheadDoesNotMatch() + { + var parser = Literals.Integer().WhenFollowedBy(Terms.Text("abc")); + + Assert.False(parser.TryParse("123xyz", out var result1)); + Assert.Equal(default, result1); + } + + [Fact] + public void WhenFollowedByShouldNotConsumeInput() + { + var parser = Literals.Integer().WhenFollowedBy(Literals.Char('x')).And(Literals.Char('x')); + + Assert.True(parser.TryParse("123x", out var result1)); + Assert.Equal((123, 'x'), result1); + } + + [Fact] + public void WhenNotFollowedByShouldSucceedWhenLookaheadDoesNotMatch() + { + var parser = Literals.Integer().WhenNotFollowedBy(Terms.Text("abc")); + + Assert.True(parser.TryParse("123xyz", out var result1)); + Assert.Equal(123, result1); + } + + [Fact] + public void WhenNotFollowedByShouldFailWhenLookaheadMatches() + { + var parser = Literals.Integer().WhenNotFollowedBy(Terms.Text("abc")); + + Assert.False(parser.TryParse("123abc", out var result1)); + Assert.Equal(default, result1); + } + + [Fact] + public void WhenNotFollowedByShouldNotConsumeInput() + { + var parser = Literals.Integer().WhenNotFollowedBy(Literals.Char('x')).And(Literals.Char('y')); + + Assert.True(parser.TryParse("123y", out var result1)); + Assert.Equal((123, 'y'), result1); + } + [Fact] public void ShouldCast() {