# frozen_string_literal: true require "strscan" # A StringParser is a wrapper around StringScanner that adds extra # helper methods for writing parser-combinator style parsers. # # @see StringScanner # @see https://hmac.dev/posts/2019-05-19-ruby-parser-combinators.html class StringParser class Error < StandardError; end attr_reader :input attr_accessor :state private attr_reader :scanner delegate :rest, :eos?, to: :scanner # @param input [String] The string to parse. # @param state [Object] An arbitrary piece of user-defined state. Will be # rolled back when the parser backtracks or is reset. def initialize(input, state: nil) @input = input.to_s.clone.freeze @state = state @scanner = StringScanner.new(@input) end # Try to match `pattern`, returning the string if it matched or nil if it didn't. # # @param pattern [Regexp, String] The pattern to match. # @return [String, nil] The matched string, or nil def accept(pattern) scanner.scan(pattern) end # Skip over `pattern`, returning true if it was skipped or false if it wasn't. # # @param pattern [Regexp, String] The pattern to match. # @return [Boolean] True if the pattern was skipped, false otherwise. def skip(pattern) scanner.scan(pattern) != nil end # Try to match `pattern`, returning the string if it matched or raising an Error if it didn't. # # @param pattern [Regexp, String] The pattern to match. # @return [String] The matched string # @raise [Error] If the pattern didn't match def expect(pattern) str = scanner.scan(pattern) error("Expected '#{pattern}'; got '#{str}'") if str.nil? str end # Move the scan pointer back N characters (default: 1) # # @param n [Integer] The number of characters to move back (default: 1). def rewind(n = 1) scanner.pos -= n end # Raise a parse error. # # @param message [String] The parse error message. # @raise [Error] def error(message) raise Error, message end # Try to parse the given block, backtracking to the previous state if the parse failed. def backtrack(&block) saved_pos = scanner.pos saved_state = state.deep_dup error("Unexpected EOS") if scanner.eos? yield rescue Error scanner.pos = saved_pos self.state = saved_state raise end # Parse the block zero or more times, returning an array of parse results. def zero_or_more(&block) matches = [] loop do matches << backtrack { yield } end rescue Error matches end # Parse the block one or more times, returning an array of parse results. def one_or_more(&block) first = yield rest = zero_or_more(&block) [first, *rest] end # Given a list of parsers, try each in sequence and return the first one that succeeds. def one_of(parsers) parsers.each do |parser| return backtrack { parser.call } rescue Error next end error("expected one of: #{parsers}") end end