Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves #4246, use recursive descent parser to implement ifeval #4247

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/asciidoctor.rb
Expand Up @@ -570,6 +570,7 @@ def self.const_missing name
require_relative 'asciidoctor/stylesheets'
require_relative 'asciidoctor/table'
require_relative 'asciidoctor/writer'
require_relative 'asciidoctor/ifeval_parser'

# main API entry points
require_relative 'asciidoctor/load'
Expand Down
197 changes: 197 additions & 0 deletions lib/asciidoctor/ifeval_parser.rb
@@ -0,0 +1,197 @@
module Asciidoctor


# The recursive descent parser for asciidoctor-pdf's ifeval
#
# EBNF expression LL(*):
# expr := disjunction
# disjunction := conjunction ('or' conjunction)*
# conjunction := inversion ('and' inversion)*
# inversion := 'not' inversion
# | comparison
#
# comparison := term "==" term
# | term "!=" term
# | term ">" term
# | term "<" term
# | term "<=" term
# | term ">=" term
# term := literal
# | '(' expr ')'
#
# reference:
# https://docs.python.org/3/reference/grammar.html

class IfevalParser
include Logging

@str = ""
@pos = 0
@next_pos = 0
@document

def initialize doc
@str = ""
@pos = 0
@next_pos = 0
@document = doc
end

def peek_token
@next_pos = @pos
str = @str[@pos..-1]
if str =~ /^\s*\d+/ || # for number
str =~ /^\s*[+\-*\/()]/ || # for operator
str =~ /^\s*(==|!=|>=|<=|>|<)/ || # for equivalent
str =~ /^\s*"\S*?"/ || # for string
str =~ /^\s*{\S*?}/ || # for attribute
str =~ /^\s*(not|and|or|true|false)/ # for binary operator
token = $~[0]
@next_pos += token.length
# puts @str[@next_pos..-1]
return token.gsub(/\s*/, "")
end

nil
end

def commit_token
@pos = @next_pos
end

def solve str
initialize @document
@str = str
next_expr
end

def next_expr
next_disjunction
end

def next_disjunction
a = next_conjunction
token = peek_token
while token == "or"
commit_token
b = next_conjunction
token = peek_token
a = a | b
end
return a
end

def next_conjunction
a = next_inversion
token = peek_token
while token == "and"
commit_token
b = next_inversion
token = peek_token
a = a & b
end
return a
end

def next_inversion
token = peek_token
if token == "not"
commit_token
ret = next_inversion
return !ret
end

return next_comparison
end

def next_comparison
a = next_term
token = peek_token
if token == "=="
commit_token
b = next_term
return a == b
elsif token == "!="
commit_token
b = next_term
return a != b
elsif token == ">="
commit_token
b = next_term
return a >= b
elsif token == "<="
commit_token
b = next_term
return a <= b
elsif token == ">"
commit_token
b = next_term
return a > b
elsif token == "<"
commit_token
b = next_term
return a < b
else
return a
end
end

def next_term
token = peek_token
commit_token
if token == "("
a = next_expr
token = peek_token
commit_token
if token != ")"
raise ArgumentError, "Expect ')', but is #{token}"
end
token = a
end

# literal
resolve_expr_val token
# if token.to_s =~ /(true|false)/
# return token.to_s == "true"
# elsif token.to_s =~ /"(\S+)"/
# return $1
# else
# return token
# end
end

def resolve_expr_val val
if ((val.start_with? '"') && (val.end_with? '"')) ||
((val.start_with? '\'') && (val.end_with? '\''))
quoted = true
val = val.gsub(/"(.+)"/, '\1')
val = val.gsub(/'(.+)'/, '\1')
else
quoted = false
end

# QUESTION should we substitute first?
# QUESTION should we also require string to be single quoted (like block attribute values?)
val = @document.sub_attributes val, attribute_missing: 'drop' if val.include? ATTR_REF_HEAD

if quoted
val = val.gsub(/"(.+)"/, '\1')
val
elsif val.empty?
nil
elsif val == 'true'
true
elsif val == 'false'
false
elsif val.rstrip.empty?
' '
elsif val.include? '.'
val.to_f
else
# fallback to coercing to integer, since we
# require string values to be explicitly quoted
val.to_i
end
end
end
end
28 changes: 15 additions & 13 deletions lib/asciidoctor/reader.rb
Expand Up @@ -935,18 +935,7 @@ def preprocess_conditional_directive keyword, target, delimiter, text
end
when 'ifeval'
if no_target
# the text in brackets must match a conditional expression
if text && EvalExpressionRx =~ text.strip
# NOTE assignments must happen before call to resolve_expr_val for compatibility with Opal
lhs = $1
# regex enforces a restricted set of math-related operations (==, !=, <=, >=, <, >)
op = $2
rhs = $3
skip = ((resolve_expr_val lhs).send op, (resolve_expr_val rhs)) ? false : true rescue true
else
logger.error message_with_context %(malformed preprocessor directive - #{text ? 'invalid expression' : 'missing expression'}: ifeval::[#{text}]), source_location: cursor
return true
end
skip = !(conditional_ifeval text)
else
logger.error message_with_context %(malformed preprocessor directive - target not permitted: ifeval::#{target}[#{text}]), source_location: cursor
return true
Expand All @@ -973,6 +962,17 @@ def preprocess_conditional_directive keyword, target, delimiter, text
true
end

def conditional_ifeval text
# the text in brackets must match a conditional expression
if text && EvalExpressionRx =~ text.strip
parser = IfevalParser.new @document
return parser.solve text
else
logger.error message_with_context %(malformed preprocessor directive - #{text ? 'invalid expression' : 'missing expression'}: ifeval::[#{text}]), source_location: cursor
return true
end
end

# Internal: Preprocess the directive to include lines from another document.
#
# Preprocess the directive to include the target document. The scenarios
Expand Down Expand Up @@ -1325,7 +1325,8 @@ def resolve_expr_val val
if ((val.start_with? '"') && (val.end_with? '"')) ||
((val.start_with? '\'') && (val.end_with? '\''))
quoted = true
val = val.slice 1, (val.length - 1)
val = val.gsub(/"(.+)"/, '\1')
val = val.gsub(/'(.+)'/, '\1')
else
quoted = false
end
Expand All @@ -1335,6 +1336,7 @@ def resolve_expr_val val
val = @document.sub_attributes val, attribute_missing: 'drop' if val.include? ATTR_REF_HEAD

if quoted
val = val.gsub(/"(.+)"/, '\1')
val
elsif val.empty?
nil
Expand Down