Files
danbooru/app/logical/string_parser.rb
evazion bbe748bd2b posts: factor out post edit logic.
Factor out most of the tag edit logic from the Post class to a new
PostEdit class. The PostEdit class contains the logic for parsing tags
and metatags from the tag edit string, and for determining which tags
were added or removed by the edit.

Fixes various bugs caused by not calculating the set of added or removed
tags correctly, for example when tag category prefixes were used (e.g.
`copy:touhou`) or when the same tag was added and removed in the same
edit (e.g. `touhou -touhou`).

Fixes #5123: Tag categorization prefixes bypass deprecation check
Fixes #5126: Negating a deprecated tag will still cause the warning to show
Fixes #3477: Remove tag validator triggering on tag category changes
Fixes #4848: newpool: metatag doesn't parse correctly
2022-04-29 17:13:33 -05:00

110 lines
2.9 KiB
Ruby

# 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