"Fossies" - the Fresh Open Source Software Archive

Member "asciidoctor-2.0.10/lib/asciidoctor/substitutors.rb" (1 Jun 2019, 57444 Bytes) of package /linux/www/asciidoctor-2.0.10.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Ruby source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. See also the last Fossies "Diffs" side-by-side code changes report for "substitutors.rb": 2.0.8_vs_2.0.9.

    1 # frozen_string_literal: true
    2 module Asciidoctor
    3 # Public: Methods to perform substitutions on lines of AsciiDoc text. This module
    4 # is intented to be mixed-in to Section and Block to provide operations for performing
    5 # the necessary substitutions.
    6 module Substitutors
    7   SpecialCharsRx = /[<&>]/
    8   SpecialCharsTr = { '>' => '&gt;', '<' => '&lt;', '&' => '&amp;' }
    9 
   10   # Detects if text is a possible candidate for the quotes substitution.
   11   QuotedTextSniffRx = { false => /[*_`#^~]/, true => /[*'_+#^~]/ }
   12 
   13   (BASIC_SUBS = [:specialcharacters]).freeze
   14   (HEADER_SUBS = [:specialcharacters, :attributes]).freeze
   15   (NO_SUBS = []).freeze
   16   (NORMAL_SUBS = [:specialcharacters, :quotes, :attributes, :replacements, :macros, :post_replacements]).freeze
   17   (REFTEXT_SUBS = [:specialcharacters, :quotes, :replacements]).freeze
   18   (VERBATIM_SUBS = [:specialcharacters, :callouts]).freeze
   19 
   20   SUB_GROUPS = {
   21     none: NO_SUBS,
   22     normal: NORMAL_SUBS,
   23     verbatim: VERBATIM_SUBS,
   24     specialchars: BASIC_SUBS,
   25   }
   26 
   27   SUB_HINTS = {
   28     a: :attributes,
   29     m: :macros,
   30     n: :normal,
   31     p: :post_replacements,
   32     q: :quotes,
   33     r: :replacements,
   34     c: :specialcharacters,
   35     v: :verbatim,
   36   }
   37 
   38   SUB_OPTIONS = {
   39     block:  SUB_GROUPS.keys + NORMAL_SUBS + [:callouts],
   40     inline: SUB_GROUPS.keys + NORMAL_SUBS,
   41   }
   42 
   43   CAN = ?\u0018
   44   DEL = ?\u007f
   45 
   46   # Delimiters and matchers for the passthrough placeholder
   47   # See http://www.aivosto.com/vbtips/control-characters.html#listabout for characters to use
   48 
   49   # SPA, start of guarded protected area (\u0096)
   50   PASS_START = ?\u0096
   51 
   52   # EPA, end of guarded protected area (\u0097)
   53   PASS_END = ?\u0097
   54 
   55   # match passthrough slot
   56   PassSlotRx = /#{PASS_START}(\d+)#{PASS_END}/
   57 
   58   # fix passthrough slot after syntax highlighting
   59   HighlightedPassSlotRx = %r(<span\b[^>]*>#{PASS_START}</span>[^\d]*(\d+)[^\d]*<span\b[^>]*>#{PASS_END}</span>)
   60 
   61   RS = '\\'
   62 
   63   R_SB = ']'
   64 
   65   ESC_R_SB = '\]'
   66 
   67   PLUS = '+'
   68 
   69   # Public: Apply the specified substitutions to the text.
   70   #
   71   # text  - The String or String Array of text to process; must not be nil.
   72   # subs  - The substitutions to perform; must be a Symbol Array or nil (default: NORMAL_SUBS).
   73   #
   74   # Returns a String or String Array to match the type of the text argument with substitutions applied.
   75   def apply_subs text, subs = NORMAL_SUBS
   76     return text if text.empty? || !subs
   77 
   78     if (is_multiline = ::Array === text)
   79       text = text[1] ? (text.join LF) : text[0]
   80     end
   81 
   82     if subs.include? :macros
   83       text = extract_passthroughs text
   84       unless @passthroughs.empty?
   85         passthrus = @passthroughs
   86         # NOTE placeholders can move around, so we can only clear in the outermost substitution call
   87         @passthroughs_locked ||= (clear_passthrus = true)
   88       end
   89     end
   90 
   91     subs.each do |type|
   92       case type
   93       when :specialcharacters
   94         text = sub_specialchars text
   95       when :quotes
   96         text = sub_quotes text
   97       when :attributes
   98         text = sub_attributes text if text.include? ATTR_REF_HEAD
   99       when :replacements
  100         text = sub_replacements text
  101       when :macros
  102         text = sub_macros text
  103       when :highlight
  104         text = highlight_source text, (subs.include? :callouts)
  105       when :callouts
  106         text = sub_callouts text unless subs.include? :highlight
  107       when :post_replacements
  108         text = sub_post_replacements text
  109       else
  110         logger.warn %(unknown substitution type #{type})
  111       end
  112     end
  113 
  114     if passthrus
  115       text = restore_passthroughs text
  116       if clear_passthrus
  117         passthrus.clear
  118         @passthroughs_locked = nil
  119       end
  120     end
  121 
  122     is_multiline ? (text.split LF, -1) : text
  123   end
  124 
  125   # Public: Apply normal substitutions.
  126   #
  127   # An alias for apply_subs with default remaining arguments.
  128   #
  129   # text  - The String text to which to apply normal substitutions
  130   #
  131   # Returns the String with normal substitutions applied.
  132   def apply_normal_subs text
  133     apply_subs text, NORMAL_SUBS
  134   end
  135 
  136   # Public: Apply substitutions for header metadata and attribute assignments
  137   #
  138   # text    - String containing the text process
  139   #
  140   # Returns A String with header substitutions performed
  141   def apply_header_subs text
  142     apply_subs text, HEADER_SUBS
  143   end
  144 
  145   # Public: Apply substitutions for titles.
  146   #
  147   # title  - The String title to process
  148   #
  149   # Returns A String with title substitutions performed
  150   alias apply_title_subs apply_subs
  151 
  152   # Public: Apply substitutions for reftext.
  153   #
  154   # text - The String to process
  155   #
  156   # Returns a String with all substitutions from the reftext substitution group applied
  157   def apply_reftext_subs text
  158     apply_subs text, REFTEXT_SUBS
  159   end
  160 
  161   # Public: Substitute special characters (i.e., encode XML)
  162   #
  163   # The special characters <, &, and > get replaced with &lt;, &amp;, and &gt;, respectively.
  164   #
  165   # text - The String text to process.
  166   #
  167   # Returns The String text with special characters replaced.
  168   if RUBY_ENGINE == 'opal'
  169     def sub_specialchars text
  170       (text.include? ?>) || (text.include? ?&) || (text.include? ?<) ? (text.gsub SpecialCharsRx, SpecialCharsTr) : text
  171     end
  172   else
  173     CGI = ::CGI
  174     def sub_specialchars text
  175       if (text.include? ?>) || (text.include? ?&) || (text.include? ?<)
  176         (text.include? ?') || (text.include? ?") ? (text.gsub SpecialCharsRx, SpecialCharsTr) : (CGI.escape_html text)
  177       else
  178         text
  179       end
  180     end
  181   end
  182   alias sub_specialcharacters sub_specialchars
  183 
  184   # Public: Substitute quoted text (includes emphasis, strong, monospaced, etc.)
  185   #
  186   # text - The String text to process
  187   #
  188   # returns The converted [String] text
  189   def sub_quotes text
  190     if QuotedTextSniffRx[compat = @document.compat_mode].match? text
  191       QUOTE_SUBS[compat].each do |type, scope, pattern|
  192         text = text.gsub(pattern) { convert_quoted_text $~, type, scope }
  193       end
  194     end
  195     text
  196   end
  197 
  198   # Public: Substitutes attribute references in the specified text
  199   #
  200   # Attribute references are in the format +{name}+.
  201   #
  202   # If an attribute referenced in the line is missing or undefined, the line may be dropped
  203   # based on the attribute-missing or attribute-undefined setting, respectively.
  204   #
  205   # text - The String text to process
  206   # opts - A Hash of options to control processing: (default: {})
  207   #        * :attribute_missing controls how to handle a missing attribute (see Compliance.attribute_missing for values)
  208   #        * :drop_line_severity the severity level at which to log a dropped line (:info or :ignore)
  209   #
  210   # Returns the [String] text with the attribute references replaced with resolved values
  211   def sub_attributes text, opts = {}
  212     doc_attrs = @document.attributes
  213     drop = drop_line = drop_line_severity = drop_empty_line = attribute_undefined = attribute_missing = nil
  214     text = text.gsub AttributeReferenceRx do
  215       # escaped attribute, return unescaped
  216       if $1 == RS || $4 == RS
  217         %({#{$2}})
  218       elsif $3
  219         case (args = $2.split ':', 3).shift
  220         when 'set'
  221           _, value = Parser.store_attribute args[0], args[1] || '', @document
  222           # NOTE since this is an assignment, only drop-line applies here (skip and drop imply the same result)
  223           if value || (attribute_undefined ||= (doc_attrs['attribute-undefined'] || Compliance.attribute_undefined)) != 'drop-line'
  224             drop = drop_empty_line = DEL
  225           else
  226             drop = drop_line = CAN
  227           end
  228         when 'counter2'
  229           @document.counter(*args)
  230           drop = drop_empty_line = DEL
  231         else # 'counter'
  232           @document.counter(*args)
  233         end
  234       elsif doc_attrs.key?(key = $2.downcase)
  235         doc_attrs[key]
  236       elsif (value = INTRINSIC_ATTRIBUTES[key])
  237         value
  238       else
  239         case (attribute_missing ||= (opts[:attribute_missing] || doc_attrs['attribute-missing'] || Compliance.attribute_missing))
  240         when 'drop'
  241           drop = drop_empty_line = DEL
  242         when 'drop-line'
  243           if (drop_line_severity ||= (opts[:drop_line_severity] || :info)) == :info
  244             logger.info { %(dropping line containing reference to missing attribute: #{key}) }
  245           #elsif drop_line_severity == :warn
  246           #  logger.warn %(dropping line containing reference to missing attribute: #{key})
  247           end
  248           drop = drop_line = CAN
  249         when 'warn'
  250           logger.warn %(skipping reference to missing attribute: #{key})
  251           $&
  252         else # 'skip'
  253           $&
  254         end
  255       end
  256     end
  257 
  258     if drop
  259       # drop lines from text
  260       if drop_empty_line
  261         lines = (text.squeeze DEL).split LF, -1
  262         if drop_line
  263           (lines.reject {|line| line == DEL || line == CAN || (line.start_with? CAN) || (line.include? CAN) }.join LF).delete DEL
  264         else
  265           (lines.reject {|line| line == DEL }.join LF).delete DEL
  266         end
  267       elsif text.include? LF
  268         (text.split LF, -1).reject {|line| line == CAN || (line.start_with? CAN) || (line.include? CAN) }.join LF
  269       else
  270         ''
  271       end
  272     else
  273       text
  274     end
  275   end
  276 
  277   # Public: Substitute replacement characters (e.g., copyright, trademark, etc.)
  278   #
  279   # text - The String text to process
  280   #
  281   # returns The [String] text with the replacement characters substituted
  282   def sub_replacements text
  283     REPLACEMENTS.each do |pattern, replacement, restore|
  284       text = text.gsub(pattern) { do_replacement $~, replacement, restore }
  285     end if ReplaceableTextRx.match? text
  286     text
  287   end
  288 
  289   # Public: Substitute inline macros (e.g., links, images, etc)
  290   #
  291   # Replace inline macros, which may span multiple lines, in the provided text
  292   #
  293   # source - The String text to process
  294   #
  295   # returns The converted String text
  296   def sub_macros text
  297     #return text if text.nil_or_empty?
  298     # some look ahead assertions to cut unnecessary regex calls
  299     found_square_bracket = text.include? '['
  300     found_colon = text.include? ':'
  301     found_macroish = found_square_bracket && found_colon
  302     found_macroish_short = found_macroish && (text.include? ':[')
  303     doc_attrs = (doc = @document).attributes
  304 
  305     # TODO allow position of substitution to be controlled (before or after other macros)
  306     # TODO this handling needs some cleanup
  307     if (extensions = doc.extensions) && extensions.inline_macros? # && found_macroish
  308       extensions.inline_macros.each do |extension|
  309         text = text.gsub extension.instance.regexp do
  310           # honor the escape
  311           next $&.slice 1, $&.length if (match = $&).start_with? RS
  312           if $~.names.empty?
  313             target, content = $1, $2
  314           else
  315             target, content = ($~[:target] rescue nil), ($~[:content] rescue nil)
  316           end
  317           attributes = (default_attrs = (ext_config = extension.config)[:default_attrs]) ? default_attrs.merge : {}
  318           if content
  319             if content.empty?
  320               attributes['text'] = content unless ext_config[:content_model] == :attributes
  321             else
  322               content = normalize_text content, true, true
  323               # QUESTION should we store the unparsed attrlist in the attrlist key?
  324               if ext_config[:content_model] == :attributes
  325                 parse_attributes content, ext_config[:positional_attrs] || ext_config[:pos_attrs] || [], into: attributes
  326               else
  327                 attributes['text'] = content
  328               end
  329             end
  330             # NOTE for convenience, map content (unparsed attrlist) to target when format is short
  331             target ||= ext_config[:format] == :short ? content : target
  332           end
  333           if (Inline === (replacement = extension.process_method[self, target, attributes]))
  334             if (inline_subs = replacement.attributes.delete 'subs')
  335               replacement.text = apply_subs replacement.text, (expand_subs inline_subs)
  336             end
  337             replacement.convert
  338           elsif replacement
  339             logger.info %(expected substitution value for custom inline macro to be of type Inline; got #{replacement.class}: #{match})
  340             replacement
  341           else
  342             ''
  343           end
  344         end
  345       end
  346     end
  347 
  348     if doc_attrs.key? 'experimental'
  349       if found_macroish_short && ((text.include? 'kbd:') || (text.include? 'btn:'))
  350         text = text.gsub InlineKbdBtnMacroRx do
  351           # honor the escape
  352           if $1
  353             $&.slice 1, $&.length
  354           elsif $2 == 'kbd'
  355             if (keys = $3.strip).include? R_SB
  356               keys = keys.gsub ESC_R_SB, R_SB
  357             end
  358             if keys.length > 1 && (delim_idx = (delim_idx = keys.index ',', 1) ?
  359                 [delim_idx, (keys.index '+', 1)].compact.min : (keys.index '+', 1))
  360               delim = keys.slice delim_idx, 1
  361               # NOTE handle special case where keys ends with delimiter (e.g., Ctrl++ or Ctrl,,)
  362               if keys.end_with? delim
  363                 keys = (keys.chop.split delim, -1).map {|key| key.strip }
  364                 keys[-1] += delim
  365               else
  366                 keys = keys.split(delim).map {|key| key.strip }
  367               end
  368             else
  369               keys = [keys]
  370             end
  371             (Inline.new self, :kbd, nil, attributes: { 'keys' => keys }).convert
  372           else # $2 == 'btn'
  373             (Inline.new self, :button, (normalize_text $3, true, true)).convert
  374           end
  375         end
  376       end
  377 
  378       if found_macroish && (text.include? 'menu:')
  379         text = text.gsub InlineMenuMacroRx do
  380           # honor the escape
  381           next $&.slice 1, $&.length if $&.start_with? RS
  382 
  383           menu = $1
  384           if (items = $2)
  385             items = items.gsub ESC_R_SB, R_SB if items.include? R_SB
  386             if (delim = items.include?('&gt;') ? '&gt;' : (items.include?(',') ? ',' : nil))
  387               submenus = items.split(delim).map {|it| it.strip }
  388               menuitem = submenus.pop
  389             else
  390               submenus, menuitem = [], items.rstrip
  391             end
  392           else
  393             submenus, menuitem = [], nil
  394           end
  395 
  396           Inline.new(self, :menu, nil, attributes: { 'menu' => menu, 'submenus' => submenus, 'menuitem' => menuitem }).convert
  397         end
  398       end
  399 
  400       if (text.include? '"') && (text.include? '&gt;')
  401         text = text.gsub InlineMenuRx do
  402           # honor the escape
  403           next $&.slice 1, $&.length if $&.start_with? RS
  404 
  405           menu, *submenus = $1.split('&gt;').map {|it| it.strip }
  406           menuitem = submenus.pop
  407           Inline.new(self, :menu, nil, attributes: { 'menu' => menu, 'submenus' => submenus, 'menuitem' => menuitem }).convert
  408         end
  409       end
  410     end
  411 
  412     if found_macroish && ((text.include? 'image:') || (text.include? 'icon:'))
  413       # image:filename.png[Alt Text]
  414       text = text.gsub InlineImageMacroRx do
  415         # honor the escape
  416         if $&.start_with? RS
  417           next $&.slice 1, $&.length
  418         elsif $&.start_with? 'icon:'
  419           type, posattrs = 'icon', ['size']
  420         else
  421           type, posattrs = 'image', ['alt', 'width', 'height']
  422         end
  423         target = $1
  424         attrs = parse_attributes $2, posattrs, unescape_input: true
  425         unless type == 'icon'
  426           doc.register :images, target
  427           attrs['imagesdir'] = doc_attrs['imagesdir']
  428         end
  429         attrs['alt'] ||= (attrs['default-alt'] = Helpers.basename(target, true).tr('_-', ' '))
  430         Inline.new(self, :image, nil, type: type, target: target, attributes: attrs).convert
  431       end
  432     end
  433 
  434     if ((text.include? '((') && (text.include? '))')) || (found_macroish_short && (text.include? 'dexterm'))
  435       # (((Tigers,Big cats)))
  436       # indexterm:[Tigers,Big cats]
  437       # ((Tigers))
  438       # indexterm2:[Tigers]
  439       text = text.gsub InlineIndextermMacroRx do
  440         case $1
  441         when 'indexterm'
  442           # honor the escape
  443           next $&.slice 1, $&.length if $&.start_with? RS
  444 
  445           # indexterm:[Tigers,Big cats]
  446           if (attrlist = normalize_text $2, true, true).include? '='
  447             if (primary = (attrs = (AttributeList.new attrlist, self).parse)[1])
  448               attrs['terms'] = terms = [primary]
  449               if (secondary = attrs[2])
  450                 terms << secondary
  451                 if (tertiary = attrs[3])
  452                   terms << tertiary
  453                 end
  454               end
  455               if (see_also = attrs['see-also'])
  456                 attrs['see-also'] = (see_also.include? ',') ? (see_also.split ',').map {|it| it.lstrip } : [see_also]
  457               end
  458             else
  459               attrs = { 'terms' => (terms = attrlist) }
  460             end
  461           else
  462             attrs = { 'terms' => (terms = split_simple_csv attrlist) }
  463           end
  464           #doc.register :indexterms, terms
  465           (Inline.new self, :indexterm, nil, attributes: attrs).convert
  466         when 'indexterm2'
  467           # honor the escape
  468           next $&.slice 1, $&.length if $&.start_with? RS
  469 
  470           # indexterm2:[Tigers]
  471           if (term = normalize_text $2, true, true).include? '='
  472             term = (attrs = (AttributeList.new term, self).parse)[1] || (attrs = nil) || term
  473             if attrs && (see_also = attrs['see-also'])
  474               attrs['see-also'] = (see_also.include? ',') ? (see_also.split ',').map {|it| it.lstrip } : [see_also]
  475             end
  476           end
  477           #doc.register :indexterms, [term]
  478           (Inline.new self, :indexterm, term, attributes: attrs, type: :visible).convert
  479         else
  480           text = $3
  481           # honor the escape
  482           if $&.start_with? RS
  483             # escape concealed index term, but process nested flow index term
  484             if (text.start_with? '(') && (text.end_with? ')')
  485               text = text.slice 1, text.length - 2
  486               visible, before, after = true, '(', ')'
  487             else
  488               next $&.slice 1, $&.length
  489             end
  490           else
  491             visible = true
  492             if text.start_with? '('
  493               if text.end_with? ')'
  494                 text, visible = (text.slice 1, text.length - 2), false
  495               else
  496                 text, before, after = (text.slice 1, text.length), '(', ''
  497               end
  498             elsif text.end_with? ')'
  499               text, before, after = text.chop, '', ')'
  500             end
  501           end
  502           if visible
  503             # ((Tigers))
  504             if (term = normalize_text text, true).include? ';&'
  505               if term.include? ' &gt;&gt; '
  506                 term, _, see = term.partition ' &gt;&gt; '
  507                 attrs = { 'see' => see }
  508               elsif term.include? ' &amp;&gt; '
  509                 term, *see_also = term.split ' &amp;&gt; '
  510                 attrs = { 'see-also' => see_also }
  511               end
  512             end
  513             #doc.register :indexterms, [term]
  514             subbed_term = (Inline.new self, :indexterm, term, attributes: attrs, type: :visible).convert
  515           else
  516             # (((Tigers,Big cats)))
  517             attrs = {}
  518             if (terms = normalize_text text, true).include? ';&'
  519               if terms.include? ' &gt;&gt; '
  520                 terms, _, see = terms.partition ' &gt;&gt; '
  521                 attrs['see'] = see
  522               elsif terms.include? ' &amp;&gt; '
  523                 terms, *see_also = terms.split ' &amp;&gt; '
  524                 attrs['see-also'] = see_also
  525               end
  526             end
  527             attrs['terms'] = terms = split_simple_csv terms
  528             #doc.register :indexterms, terms
  529             subbed_term = (Inline.new self, :indexterm, nil, attributes: attrs).convert
  530           end
  531           before ? %(#{before}#{subbed_term}#{after}) : subbed_term
  532         end
  533       end
  534     end
  535 
  536     if found_colon && (text.include? '://')
  537       # inline urls, target[text] (optionally prefixed with link: and optionally surrounded by <>)
  538       text = text.gsub InlineLinkRx do
  539         if (target = $2).start_with? RS
  540           # honor the escape
  541           next %(#{$1}#{target.slice 1, target.length}#{$4})
  542         end
  543 
  544         prefix, suffix = $1, ''
  545         # NOTE if $4 is set, then we're looking at a formal macro
  546         if $4
  547           prefix = '' if prefix == 'link:'
  548           text = $4
  549         else
  550           # invalid macro syntax (link: prefix w/o trailing square brackets)
  551           # FIXME we probably shouldn't even get here...our regex is doing too much
  552           next $& if prefix == 'link:'
  553           text = ''
  554           case $3
  555           when ')'
  556             # move trailing ) out of URL
  557             target = target.chop
  558             suffix = ')'
  559             # NOTE handle case when modified target is a URI scheme (e.g., http://)
  560             next $& if target.end_with? '://'
  561           when ';'
  562             if (prefix.start_with? '&lt;') && (target.end_with? '&gt;')
  563               # move surrounding <> out of URL
  564               prefix = prefix.slice 4, prefix.length
  565               target = target.slice 0, target.length - 4
  566             elsif (target = target.chop).end_with? ')'
  567               # move trailing ); out of URL
  568               target = target.chop
  569               suffix = ');'
  570             else
  571               # move trailing ; out of URL
  572               suffix = ';'
  573             end
  574             # NOTE handle case when modified target is a URI scheme (e.g., http://)
  575             next $& if target.end_with? '://'
  576           when ':'
  577             if (target = target.chop).end_with? ')'
  578               # move trailing ): out of URL
  579               target = target.chop
  580               suffix = '):'
  581             else
  582               # move trailing : out of URL
  583               suffix = ':'
  584             end
  585             # NOTE handle case when modified target is a URI scheme (e.g., http://)
  586             next $& if target.end_with? '://'
  587           end
  588         end
  589 
  590         attrs, link_opts = nil, { type: :link }
  591         unless text.empty?
  592           text = text.gsub ESC_R_SB, R_SB if text.include? R_SB
  593           if !doc.compat_mode && (text.include? '=')
  594             text = (attrs = (AttributeList.new text, self).parse)[1] || ''
  595             link_opts[:id] = attrs['id']
  596           end
  597 
  598           if text.end_with? '^'
  599             text = text.chop
  600             if attrs
  601               attrs['window'] ||= '_blank'
  602             else
  603               attrs = { 'window' => '_blank' }
  604             end
  605           end
  606         end
  607 
  608         if text.empty?
  609           # NOTE it's not possible for the URI scheme to be bare in this case
  610           text = (doc_attrs.key? 'hide-uri-scheme') ? (target.sub UriSniffRx, '') : target
  611           if attrs
  612             attrs['role'] = (attrs.key? 'role') ? %(bare #{attrs['role']}) : 'bare'
  613           else
  614             attrs = { 'role' => 'bare' }
  615           end
  616         end
  617 
  618         doc.register :links, (link_opts[:target] = target)
  619         link_opts[:attributes] = attrs if attrs
  620         %(#{prefix}#{(Inline.new self, :anchor, text, link_opts).convert}#{suffix})
  621       end
  622     end
  623 
  624     if found_macroish && ((text.include? 'link:') || (text.include? 'ilto:'))
  625       # inline link macros, link:target[text]
  626       text = text.gsub InlineLinkMacroRx do
  627         # honor the escape
  628         if $&.start_with? RS
  629           next $&.slice 1, $&.length
  630         elsif (mailto = $1)
  631           target = 'mailto:' + (mailto_text = $2)
  632         else
  633           target = $2
  634         end
  635         attrs, link_opts = nil, { type: :link }
  636         unless (text = $3).empty?
  637           text = text.gsub ESC_R_SB, R_SB if text.include? R_SB
  638           if mailto
  639             if !doc.compat_mode && (text.include? ',')
  640               text = (attrs = (AttributeList.new text, self).parse)[1] || ''
  641               link_opts[:id] = attrs['id']
  642               if attrs.key? 2
  643                 if attrs.key? 3
  644                   target = %(#{target}?subject=#{Helpers.encode_uri_component attrs[2]}&amp;body=#{Helpers.encode_uri_component attrs[3]})
  645                 else
  646                   target = %(#{target}?subject=#{Helpers.encode_uri_component attrs[2]})
  647                 end
  648               end
  649             end
  650           elsif !doc.compat_mode && (text.include? '=')
  651             text = (attrs = (AttributeList.new text, self).parse)[1] || ''
  652             link_opts[:id] = attrs['id']
  653           end
  654 
  655           if text.end_with? '^'
  656             text = text.chop
  657             if attrs
  658               attrs['window'] ||= '_blank'
  659             else
  660               attrs = { 'window' => '_blank' }
  661             end
  662           end
  663         end
  664 
  665         if text.empty?
  666           # mailto is a special case, already processed
  667           if mailto
  668             text = mailto_text
  669           else
  670             if doc_attrs.key? 'hide-uri-scheme'
  671               if (text = target.sub UriSniffRx, '').empty?
  672                 text = target
  673               end
  674             else
  675               text = target
  676             end
  677             if attrs
  678               attrs['role'] = (attrs.key? 'role') ? %(bare #{attrs['role']}) : 'bare'
  679             else
  680               attrs = { 'role' => 'bare' }
  681             end
  682           end
  683         end
  684 
  685         # QUESTION should a mailto be registered as an e-mail address?
  686         doc.register :links, (link_opts[:target] = target)
  687         link_opts[:attributes] = attrs if attrs
  688         Inline.new(self, :anchor, text, link_opts).convert
  689       end
  690     end
  691 
  692     if text.include? '@'
  693       text = text.gsub InlineEmailRx do
  694         # honor the escape
  695         next $1 == RS ? ($&.slice 1, $&.length) : $& if $1
  696 
  697         target = 'mailto:' + (address = $&)
  698         # QUESTION should this be registered as an e-mail address?
  699         doc.register(:links, target)
  700 
  701         Inline.new(self, :anchor, address, type: :link, target: target).convert
  702       end
  703     end
  704 
  705     if found_square_bracket && @context == :list_item && @parent.style == 'bibliography'
  706       text = text.sub(InlineBiblioAnchorRx) { (Inline.new self, :anchor, $2, type: :bibref, id: $1).convert }
  707     end
  708 
  709     if (found_square_bracket && text.include?('[[')) || (found_macroish && text.include?('or:'))
  710       text = text.gsub InlineAnchorRx do
  711         # honor the escape
  712         next $&.slice 1, $&.length if $1
  713 
  714         # NOTE reftext is only relevant for DocBook output; used as value of xreflabel attribute
  715         if (id = $2)
  716           reftext = $3
  717         else
  718           id = $4
  719           if (reftext = $5) && (reftext.include? R_SB)
  720             reftext = reftext.gsub ESC_R_SB, R_SB
  721           end
  722         end
  723         Inline.new(self, :anchor, reftext, type: :ref, id: id).convert
  724       end
  725     end
  726 
  727     #if (text.include? ';&l') || (found_macroish && (text.include? 'xref:'))
  728     if ((text.include? '&') && (text.include? ';&l')) || (found_macroish && (text.include? 'xref:'))
  729       text = text.gsub InlineXrefMacroRx do
  730         # honor the escape
  731         next $&.slice 1, $&.length if $&.start_with? RS
  732 
  733         attrs = {}
  734         if (refid = $1)
  735           refid, text = refid.split ',', 2
  736           text = text.lstrip if text
  737         else
  738           macro = true
  739           refid = $2
  740           if (text = $3)
  741             text = text.gsub ESC_R_SB, R_SB if text.include? R_SB
  742             # NOTE if an equal sign (=) is present, parse text as attributes
  743             text = ((AttributeList.new text, self).parse_into attrs)[1] if !doc.compat_mode && (text.include? '=')
  744           end
  745         end
  746 
  747         if doc.compat_mode
  748           fragment = refid
  749         elsif (hash_idx = refid.index '#')
  750           if hash_idx > 0
  751             if (fragment_len = refid.length - 1 - hash_idx) > 0
  752               path, fragment = (refid.slice 0, hash_idx), (refid.slice hash_idx + 1, fragment_len)
  753             else
  754               path = refid.chop
  755             end
  756             if macro
  757               if path.end_with? '.adoc'
  758                 src2src = path = path.slice 0, path.length - 5
  759               elsif !(Helpers.extname? path)
  760                 src2src = path
  761               end
  762             elsif path.end_with?(*ASCIIDOC_EXTENSIONS.keys)
  763               src2src = path = path.slice 0, (path.rindex '.')
  764             else
  765               src2src = path
  766             end
  767           else
  768             target, fragment = refid, (refid.slice 1, refid.length)
  769           end
  770         elsif macro
  771           if refid.end_with? '.adoc'
  772             src2src = path = refid.slice 0, refid.length - 5
  773           elsif Helpers.extname? refid
  774             path = refid
  775           else
  776             fragment = refid
  777           end
  778         else
  779           fragment = refid
  780         end
  781 
  782         # handles: #id
  783         if target
  784           refid = fragment
  785           logger.info %(possible invalid reference: #{refid}) if logger.info? && !doc.catalog[:refs][refid]
  786         elsif path
  787           # handles: path#, path#id, path.adoc#, path.adoc#id, or path.adoc (xref macro only)
  788           # the referenced path is the current document, or its contents have been included in the current document
  789           if src2src && (doc.attributes['docname'] == path || doc.catalog[:includes][path])
  790             if fragment
  791               refid, path, target = fragment, nil, %(##{fragment})
  792               logger.info %(possible invalid reference: #{refid}) if logger.info? && !doc.catalog[:refs][refid]
  793             else
  794               refid, path, target = nil, nil, '#'
  795             end
  796           else
  797             refid, path = path, %(#{doc.attributes['relfileprefix']}#{path}#{src2src ? (doc.attributes.fetch 'relfilesuffix', doc.outfilesuffix) : ''})
  798             if fragment
  799               refid, target = %(#{refid}##{fragment}), %(#{path}##{fragment})
  800             else
  801               target = path
  802             end
  803           end
  804         # handles: id (in compat mode or when natural xrefs are disabled)
  805         elsif doc.compat_mode || !Compliance.natural_xrefs
  806           refid, target = fragment, %(##{fragment})
  807           logger.info %(possible invalid reference: #{refid}) if logger.info? && doc.catalog[:refs][refid]
  808         # handles: id
  809         elsif doc.catalog[:refs][fragment]
  810           refid, target = fragment, %(##{fragment})
  811         # handles: Node Title or Reference Text
  812         # do reverse lookup on fragment if not a known ID and resembles reftext (contains a space or uppercase char)
  813         elsif ((fragment.include? ' ') || fragment.downcase != fragment) && (refid = doc.resolve_id fragment)
  814           fragment, target = refid, %(##{refid})
  815         else
  816           refid, target = fragment, %(##{fragment})
  817           logger.info %(possible invalid reference: #{refid}) if logger.info?
  818         end
  819         attrs['path'] = path
  820         attrs['fragment'] = fragment
  821         attrs['refid'] = refid
  822         Inline.new(self, :anchor, text, type: :xref, target: target, attributes: attrs).convert
  823       end
  824     end
  825 
  826     if found_macroish && (text.include? 'tnote')
  827       text = text.gsub InlineFootnoteMacroRx do
  828         # honor the escape
  829         next $&.slice 1, $&.length if $&.start_with? RS
  830 
  831         # footnoteref
  832         if $1
  833           if $3
  834             id, text = $3.split ',', 2
  835             logger.warn %(found deprecated footnoteref macro: #{$&}; use footnote macro with target instead) unless doc.compat_mode
  836           else
  837             next $&
  838           end
  839         # footnote
  840         else
  841           id = $2
  842           text = $3
  843         end
  844 
  845         if id
  846           if text
  847             text = restore_passthroughs(normalize_text text, true, true)
  848             index = doc.counter('footnote-number')
  849             doc.register(:footnotes, Document::Footnote.new(index, id, text))
  850             type, target = :ref, nil
  851           else
  852             if (footnote = doc.footnotes.find {|candidate| candidate.id == id })
  853               index, text = footnote.index, footnote.text
  854             else
  855               logger.warn %(invalid footnote reference: #{id})
  856               index, text = nil, id
  857             end
  858             type, target, id = :xref, id, nil
  859           end
  860         elsif text
  861           text = restore_passthroughs(normalize_text text, true, true)
  862           index = doc.counter('footnote-number')
  863           doc.register(:footnotes, Document::Footnote.new(index, id, text))
  864           type = target = nil
  865         else
  866           next $&
  867         end
  868         Inline.new(self, :footnote, text, attributes: { 'index' => index }, id: id, target: target, type: type).convert
  869       end
  870     end
  871 
  872     text
  873   end
  874 
  875   # Public: Substitute post replacements
  876   #
  877   # text - The String text to process
  878   #
  879   # Returns the converted String text
  880   def sub_post_replacements text
  881     #if attr? 'hardbreaks-option', nil, true
  882     if @attributes['hardbreaks-option'] || @document.attributes['hardbreaks-option']
  883       lines = text.split LF, -1
  884       return text if lines.size < 2
  885       last = lines.pop
  886       (lines.map do |line|
  887         Inline.new(self, :break, (line.end_with? HARD_LINE_BREAK) ? (line.slice 0, line.length - 2) : line, type: :line).convert
  888       end << last).join LF
  889     elsif (text.include? PLUS) && (text.include? HARD_LINE_BREAK)
  890       text.gsub(HardLineBreakRx) { Inline.new(self, :break, $1, type: :line).convert }
  891     else
  892       text
  893     end
  894   end
  895 
  896   # Public: Apply verbatim substitutions on source (for use when highlighting is disabled).
  897   #
  898   # source - the source code String on which to apply verbatim substitutions
  899   # process_callouts - a Boolean flag indicating whether callout marks should be substituted
  900   #
  901   # Returns the substituted source
  902   def sub_source source, process_callouts
  903     process_callouts ? sub_callouts(sub_specialchars source) : (sub_specialchars source)
  904   end
  905 
  906   # Public: Substitute callout source references
  907   #
  908   # text - The String text to process
  909   #
  910   # Returns the converted String text
  911   def sub_callouts text
  912     callout_rx = (attr? 'line-comment') ? CalloutSourceRxMap[attr 'line-comment'] : CalloutSourceRx
  913     autonum = 0
  914     text.gsub callout_rx do
  915       # honor the escape
  916       if $2
  917         # use sub since it might be behind a line comment
  918         $&.sub RS, ''
  919       else
  920         Inline.new(self, :callout, $4 == '.' ? (autonum += 1).to_s : $4, id: @document.callouts.read_next_id, attributes: { 'guard' => $1 }).convert
  921       end
  922     end
  923   end
  924 
  925   # Public: Highlight (i.e., colorize) the source code during conversion using a syntax highlighter, if activated by the
  926   # source-highlighter document attribute. Otherwise return the text with verbatim substitutions applied.
  927   #
  928   # If the process_callouts argument is true, this method will extract the callout marks from the source before passing
  929   # it to the syntax highlighter, then subsequently restore those callout marks to the highlighted source so the callout
  930   # marks don't confuse the syntax highlighter.
  931   #
  932   # source - the source code String to syntax highlight
  933   # process_callouts - a Boolean flag indicating whether callout marks should be located and substituted
  934   #
  935   # Returns the highlighted source code, if a syntax highlighter is defined on the document, otherwise the source with
  936   # verbatim substituions applied
  937   def highlight_source source, process_callouts
  938     # NOTE the call to highlight? is a defensive check since, normally, we wouldn't arrive here unless it returns true
  939     return sub_source source, process_callouts unless (syntax_hl = @document.syntax_highlighter) && syntax_hl.highlight?
  940 
  941     source, callout_marks = extract_callouts source if process_callouts
  942 
  943     doc_attrs = @document.attributes
  944     syntax_hl_name = syntax_hl.name
  945     if (linenums_mode = (attr? 'linenums') ? (doc_attrs[%(#{syntax_hl_name}-linenums-mode)] || :table).to_sym : nil)
  946       start_line_number = 1 if (start_line_number = (attr 'start', 1).to_i) < 1
  947     end
  948     highlight_lines = resolve_lines_to_highlight source, (attr 'highlight') if attr? 'highlight'
  949 
  950     highlighted, source_offset = syntax_hl.highlight self, source, (attr 'language'),
  951       callouts: callout_marks,
  952       css_mode: (doc_attrs[%(#{syntax_hl_name}-css)] || :class).to_sym,
  953       highlight_lines: highlight_lines,
  954       number_lines: linenums_mode,
  955       start_line_number: start_line_number,
  956       style: doc_attrs[%(#{syntax_hl_name}-style)]
  957 
  958     # fix passthrough placeholders that got caught up in syntax highlighting
  959     highlighted = highlighted.gsub HighlightedPassSlotRx, %(#{PASS_START}\\1#{PASS_END}) unless @passthroughs.empty?
  960 
  961     # NOTE highlight method may have depleted callouts
  962     callout_marks.nil_or_empty? ? highlighted : (restore_callouts highlighted, callout_marks, source_offset)
  963   end
  964 
  965   # Public: Resolve the line numbers in the specified source to highlight from the provided spec.
  966   #
  967   # e.g., highlight="1-5, !2, 10" or highlight=1-5;!2,10
  968   #
  969   # source - The String source.
  970   # spec   - The lines specifier (e.g., "1-5, !2, 10" or "1..5;!2;10")
  971   #
  972   # Returns an [Array] of unique, sorted line numbers.
  973   def resolve_lines_to_highlight source, spec
  974     lines = []
  975     spec = spec.delete ' ' if spec.include? ' '
  976     ((spec.include? ',') ? (spec.split ',') : (spec.split ';')).map do |entry|
  977       if entry.start_with? '!'
  978         entry = entry.slice 1, entry.length
  979         negate = true
  980       end
  981       if (delim = (entry.include? '..') ? '..' : ((entry.include? '-') ? '-' : nil))
  982         from, delim, to = entry.partition delim
  983         to = (source.count LF) + 1 if to.empty? || (to = to.to_i) < 0
  984         line_nums = (from.to_i..to).to_a
  985         if negate
  986           lines -= line_nums
  987         else
  988           lines.concat line_nums
  989         end
  990       else
  991         if negate
  992           lines.delete entry.to_i
  993         else
  994           lines << entry.to_i
  995         end
  996       end
  997     end
  998     lines.sort.uniq
  999   end
 1000 
 1001   # Public: Extract the passthrough text from the document for reinsertion after processing.
 1002   #
 1003   # text - The String from which to extract passthrough fragements
 1004   #
 1005   # Returns the String text with passthrough regions substituted with placeholders
 1006   def extract_passthroughs text
 1007     compat_mode = @document.compat_mode
 1008     passthrus = @passthroughs
 1009     text = text.gsub InlinePassMacroRx do
 1010       if (boundary = $4) # $$, ++, or +++
 1011         # skip ++ in compat mode, handled as normal quoted text
 1012         next %(#{$2 ? "#{$1}[#{$2}]#{$3}" : "#{$1}#{$3}"}++#{extract_passthroughs $5}++) if compat_mode && boundary == '++'
 1013 
 1014         if (attrlist = $2)
 1015           if (escape_count = $3.length) > 0
 1016             # NOTE we don't look for nested unconstrained pass macros
 1017             next %(#{$1}[#{attrlist}]#{RS * (escape_count - 1)}#{boundary}#{$5}#{boundary})
 1018           elsif $1 == RS
 1019             preceding = %([#{attrlist}])
 1020           else
 1021             if boundary == '++' && (attrlist.end_with? 'x-')
 1022               old_behavior = true
 1023               attrlist = attrlist.slice 0, attrlist.length - 2
 1024             end
 1025             attributes = parse_quoted_text_attributes attrlist
 1026           end
 1027         elsif (escape_count = $3.length) > 0
 1028           # NOTE we don't look for nested unconstrained pass macros
 1029           next %(#{RS * (escape_count - 1)}#{boundary}#{$5}#{boundary})
 1030         end
 1031         subs = (boundary == '+++' ? [] : BASIC_SUBS)
 1032 
 1033         if attributes
 1034           if old_behavior
 1035             passthrus[passthru_key = passthrus.size] = { text: $5, subs: NORMAL_SUBS, type: :monospaced, attributes: attributes }
 1036           else
 1037             passthrus[passthru_key = passthrus.size] = { text: $5, subs: subs, type: :unquoted, attributes: attributes }
 1038           end
 1039         else
 1040           passthrus[passthru_key = passthrus.size] = { text: $5, subs: subs }
 1041         end
 1042       else # pass:[]
 1043         # NOTE we don't look for nested pass:[] macros
 1044         # honor the escape
 1045         next $&.slice 1, $&.length if $6 == RS
 1046         if (subs = $7)
 1047           passthrus[passthru_key = passthrus.size] = { text: (normalize_text $8, nil, true), subs: (resolve_pass_subs subs) }
 1048         else
 1049           passthrus[passthru_key = passthrus.size] = { text: (normalize_text $8, nil, true) }
 1050         end
 1051       end
 1052 
 1053       %(#{preceding || ''}#{PASS_START}#{passthru_key}#{PASS_END})
 1054     end if (text.include? '++') || (text.include? '$$') || (text.include? 'ss:')
 1055 
 1056     pass_inline_char1, pass_inline_char2, pass_inline_rx = InlinePassRx[compat_mode]
 1057     text = text.gsub pass_inline_rx do
 1058       preceding = $1
 1059       attrlist = $2
 1060       escape_mark = RS if (quoted_text = $3).start_with? RS
 1061       format_mark = $4
 1062       content = $5
 1063 
 1064       if compat_mode
 1065         old_behavior = true
 1066       elsif (old_behavior = attrlist && (attrlist.end_with? 'x-'))
 1067         attrlist = attrlist.slice 0, attrlist.length - 2
 1068       end
 1069 
 1070       if attrlist
 1071         if format_mark == '`' && !old_behavior
 1072           next extract_inner_passthrough content, %(#{preceding}[#{attrlist}]#{escape_mark})
 1073         elsif escape_mark
 1074           # honor the escape of the formatting mark
 1075           next %(#{preceding}[#{attrlist}]#{quoted_text.slice 1, quoted_text.length})
 1076         elsif preceding == RS
 1077           # honor the escape of the attributes
 1078           preceding = %([#{attrlist}])
 1079         else
 1080           attributes = parse_quoted_text_attributes attrlist
 1081         end
 1082       elsif format_mark == '`' && !old_behavior
 1083         next extract_inner_passthrough content, %(#{preceding}#{escape_mark})
 1084       elsif escape_mark
 1085         # honor the escape of the formatting mark
 1086         next %(#{preceding}#{quoted_text.slice 1, quoted_text.length})
 1087       end
 1088 
 1089       if compat_mode
 1090         passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS, attributes: attributes, type: :monospaced }
 1091       elsif attributes
 1092         if old_behavior
 1093           subs = (format_mark == '`' ? BASIC_SUBS : NORMAL_SUBS)
 1094           passthrus[passthru_key = passthrus.size] = { text: content, subs: subs, attributes: attributes, type: :monospaced }
 1095         else
 1096           passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS, attributes: attributes, type: :unquoted }
 1097         end
 1098       else
 1099         passthrus[passthru_key = passthrus.size] = { text: content, subs: BASIC_SUBS }
 1100       end
 1101 
 1102       %(#{preceding}#{PASS_START}#{passthru_key}#{PASS_END})
 1103     end if (text.include? pass_inline_char1) || (pass_inline_char2 && (text.include? pass_inline_char2))
 1104 
 1105     # NOTE we need to do the stem in a subsequent step to allow it to be escaped by the former
 1106     text = text.gsub InlineStemMacroRx do
 1107       # honor the escape
 1108       next $&.slice 1, $&.length if $&.start_with? RS
 1109 
 1110       if (type = $1.to_sym) == :stem
 1111         type = STEM_TYPE_ALIASES[@document.attributes['stem']].to_sym
 1112       end
 1113       subs = $2
 1114       content = normalize_text $3, nil, true
 1115       # NOTE drop enclosing $ signs around latexmath for backwards compatibility with AsciiDoc Python
 1116       content = content.slice 1, content.length - 2 if type == :latexmath && (content.start_with? '$') && (content.end_with? '$')
 1117       subs = subs ? (resolve_pass_subs subs) : ((@document.basebackend? 'html') ? BASIC_SUBS : nil)
 1118       passthrus[passthru_key = passthrus.size] = { text: content, subs: subs, type: type }
 1119       %(#{PASS_START}#{passthru_key}#{PASS_END})
 1120     end if (text.include? ':') && ((text.include? 'stem:') || (text.include? 'math:'))
 1121 
 1122     text
 1123   end
 1124 
 1125   # Public: Restore the passthrough text by reinserting into the placeholder positions
 1126   #
 1127   # text  - The String text into which to restore the passthrough text
 1128   #
 1129   # returns The String text with the passthrough text restored
 1130   def restore_passthroughs text
 1131     passthrus = @passthroughs
 1132     text.gsub PassSlotRx do
 1133       if (pass = passthrus[$1.to_i])
 1134         subbed_text = apply_subs(pass[:text], pass[:subs])
 1135         if (type = pass[:type])
 1136           if (attributes = pass[:attributes])
 1137             id = attributes['id']
 1138           end
 1139           subbed_text = Inline.new(self, :quoted, subbed_text, type: type, id: id, attributes: attributes).convert
 1140         end
 1141         subbed_text.include?(PASS_START) ? restore_passthroughs(subbed_text) : subbed_text
 1142       else
 1143         logger.error %(unresolved passthrough detected: #{text})
 1144         '??pass??'
 1145       end
 1146     end
 1147   end
 1148 
 1149   # Public: Resolve the list of comma-delimited subs against the possible options.
 1150   #
 1151   # subs     - The comma-delimited String of substitution names or aliases.
 1152   # type     - A Symbol representing the context for which the subs are being resolved (default: :block).
 1153   # defaults - An Array of substitutions to start with when computing incremental substitutions (default: nil).
 1154   # subject  - The String to use in log messages to communicate the subject for which subs are being resolved (default: nil)
 1155   #
 1156   # Returns An Array of Symbols representing the substitution operation or nothing if no subs are found.
 1157   def resolve_subs subs, type = :block, defaults = nil, subject = nil
 1158     return if subs.nil_or_empty?
 1159     # QUESTION should we store candidates as a Set instead of an Array?
 1160     candidates = nil
 1161     subs = subs.delete ' ' if subs.include? ' '
 1162     modifiers_present = SubModifierSniffRx.match? subs
 1163     subs.split(',').each do |key|
 1164       modifier_operation = nil
 1165       if modifiers_present
 1166         if (first = key.chr) == '+'
 1167           modifier_operation = :append
 1168           key = key.slice 1, key.length
 1169         elsif first == '-'
 1170           modifier_operation = :remove
 1171           key = key.slice 1, key.length
 1172         elsif key.end_with? '+'
 1173           modifier_operation = :prepend
 1174           key = key.chop
 1175         end
 1176       end
 1177       key = key.to_sym
 1178       # special case to disable callouts for inline subs
 1179       if type == :inline && (key == :verbatim || key == :v)
 1180         resolved_keys = BASIC_SUBS
 1181       elsif SUB_GROUPS.key? key
 1182         resolved_keys = SUB_GROUPS[key]
 1183       elsif type == :inline && key.length == 1 && (SUB_HINTS.key? key)
 1184         resolved_key = SUB_HINTS[key]
 1185         if (candidate = SUB_GROUPS[resolved_key])
 1186           resolved_keys = candidate
 1187         else
 1188           resolved_keys = [resolved_key]
 1189         end
 1190       else
 1191         resolved_keys = [key]
 1192       end
 1193 
 1194       if modifier_operation
 1195         candidates ||= (defaults ? (defaults.drop 0) : [])
 1196         case modifier_operation
 1197         when :append
 1198           candidates += resolved_keys
 1199         when :prepend
 1200           candidates = resolved_keys + candidates
 1201         when :remove
 1202           candidates -= resolved_keys
 1203         end
 1204       else
 1205         candidates ||= []
 1206         candidates += resolved_keys
 1207       end
 1208     end
 1209     return unless candidates
 1210     # weed out invalid options and remove duplicates (order is preserved; first occurence wins)
 1211     resolved = candidates & SUB_OPTIONS[type]
 1212     unless (candidates - resolved).empty?
 1213       invalid = candidates - resolved
 1214       logger.warn %(invalid substitution type#{invalid.size > 1 ? 's' : ''}#{subject ? ' for ' : ''}#{subject}: #{invalid.join ', '})
 1215     end
 1216     resolved
 1217   end
 1218 
 1219   # Public: Call resolve_subs for the :block type.
 1220   def resolve_block_subs subs, defaults, subject
 1221     resolve_subs subs, :block, defaults, subject
 1222   end
 1223 
 1224   # Public: Call resolve_subs for the :inline type with the subject set as passthrough macro.
 1225   def resolve_pass_subs subs
 1226     resolve_subs subs, :inline, nil, 'passthrough macro'
 1227   end
 1228 
 1229   # Public: Expand all groups in the subs list and return. If no subs are resolve, return nil.
 1230   #
 1231   # subs - The substitutions to expand; can be a Symbol, Symbol Array or nil
 1232   #
 1233   # Returns a Symbol Array of substitutions to pass to apply_subs or nil if no substitutions were resolved.
 1234   def expand_subs subs
 1235     if ::Symbol === subs
 1236       unless subs == :none
 1237         SUB_GROUPS[subs] || [subs]
 1238       end
 1239     else
 1240       expanded_subs = []
 1241       subs.each do |key|
 1242         unless key == :none
 1243           if (sub_group = SUB_GROUPS[key])
 1244             expanded_subs += sub_group
 1245           else
 1246             expanded_subs << key
 1247           end
 1248         end
 1249       end
 1250 
 1251       expanded_subs.empty? ? nil : expanded_subs
 1252     end
 1253   end
 1254 
 1255   # Internal: Commit the requested substitutions to this block.
 1256   #
 1257   # Looks for an attribute named "subs". If present, resolves substitutions
 1258   # from the value of that attribute and assigns them to the subs property on
 1259   # this block. Otherwise, uses the substitutions assigned to the default_subs
 1260   # property, if specified, or selects a default set of substitutions based on
 1261   # the content model of the block.
 1262   #
 1263   # Returns nothing
 1264   def commit_subs
 1265     unless (default_subs = @default_subs)
 1266       case @content_model
 1267       when :simple
 1268         default_subs = NORMAL_SUBS
 1269       when :verbatim
 1270         # NOTE :literal with listparagraph-option gets folded into text of list item later
 1271         default_subs = @context == :verse ? NORMAL_SUBS : VERBATIM_SUBS
 1272       when :raw
 1273         # TODO make pass subs a compliance setting; AsciiDoc Python performs :attributes and :macros on a pass block
 1274         default_subs = @context == :stem ? BASIC_SUBS : NO_SUBS
 1275       else
 1276         return @subs
 1277       end
 1278     end
 1279 
 1280     if (custom_subs = @attributes['subs'])
 1281       @subs = (resolve_block_subs custom_subs, default_subs, @context) || []
 1282     else
 1283       @subs = default_subs.drop 0
 1284     end
 1285 
 1286     # QUESION delegate this logic to a method?
 1287     if @context == :listing && @style == 'source' && (syntax_hl = @document.syntax_highlighter) &&
 1288         syntax_hl.highlight? && (idx = @subs.index :specialcharacters)
 1289       @subs[idx] = :highlight
 1290     end
 1291 
 1292     nil
 1293   end
 1294 
 1295   # Internal: Parse attributes in name or name=value format from a comma-separated String
 1296   #
 1297   # attrlist - A comma-separated String list of attributes in name or name=value format.
 1298   # posattrs - An Array of positional attribute names (default: []).
 1299   # opts     - A Hash of options to control how the string is parsed (default: {}):
 1300   #            :into           - The Hash to parse the attributes into (optional, default: false).
 1301   #            :sub_input      - A Boolean that indicates whether to substitute attributes prior to
 1302   #                              parsing (optional, default: false).
 1303   #            :sub_result     - A Boolean that indicates whether to apply substitutions
 1304   #                              single-quoted attribute values (optional, default: true).
 1305   #            :unescape_input - A Boolean that indicates whether to unescape square brackets prior
 1306   #                              to parsing (optional, default: false).
 1307   #
 1308   # Returns an empty Hash if attrlist is nil or empty, otherwise a Hash of parsed attributes.
 1309   def parse_attributes attrlist, posattrs = [], opts = {}
 1310     return {} if attrlist ? attrlist.empty? : true
 1311     attrlist = normalize_text attrlist, true, true if opts[:unescape_input]
 1312     attrlist = @document.sub_attributes attrlist if opts[:sub_input] && (attrlist.include? ATTR_REF_HEAD)
 1313     # substitutions are only performed on attribute values if block is not nil
 1314     block = self if opts[:sub_result]
 1315     if (into = opts[:into])
 1316       AttributeList.new(attrlist, block).parse_into(into, posattrs)
 1317     else
 1318       AttributeList.new(attrlist, block).parse(posattrs)
 1319     end
 1320   end
 1321 
 1322   private
 1323 
 1324   # Internal: Extract the callout numbers from the source to prepare it for syntax highlighting.
 1325   def extract_callouts source
 1326     callout_marks = {}
 1327     lineno = 0
 1328     last_lineno = nil
 1329     callout_rx = (attr? 'line-comment') ? CalloutExtractRxMap[attr 'line-comment'] : CalloutExtractRx
 1330     # extract callout marks, indexed by line number
 1331     source = (source.split LF, -1).map do |line|
 1332       lineno += 1
 1333       line.gsub callout_rx do
 1334         # honor the escape
 1335         if $2
 1336           # use sub since it might be behind a line comment
 1337           $&.sub RS, ''
 1338         else
 1339           (callout_marks[lineno] ||= []) << [$1, $4]
 1340           last_lineno = lineno
 1341           ''
 1342         end
 1343       end
 1344     end.join LF
 1345     if last_lineno
 1346       source = %(#{source}#{LF}) if last_lineno == lineno
 1347     else
 1348       callout_marks = nil
 1349     end
 1350     [source, callout_marks]
 1351   end
 1352 
 1353   # Internal: Restore the callout numbers to the highlighted source.
 1354   def restore_callouts source, callout_marks, source_offset = nil
 1355     if source_offset
 1356       preamble = source.slice 0, source_offset
 1357       source = source.slice source_offset, source.length
 1358     else
 1359       preamble = ''
 1360     end
 1361     autonum = lineno = 0
 1362     preamble + ((source.split LF, -1).map do |line|
 1363       if (conums = callout_marks.delete lineno += 1)
 1364         if conums.size == 1
 1365           guard, conum = conums[0]
 1366           %(#{line}#{Inline.new(self, :callout, conum == '.' ? (autonum += 1).to_s : conum, id: @document.callouts.read_next_id, attributes: { 'guard' => guard }).convert})
 1367         else
 1368           %(#{line}#{conums.map do |guard_it, conum_it|
 1369             Inline.new(self, :callout, conum_it == '.' ? (autonum += 1).to_s : conum_it, id: @document.callouts.read_next_id, attributes: { 'guard' => guard_it }).convert
 1370           end.join ' '})
 1371         end
 1372       else
 1373         line
 1374       end
 1375     end.join LF)
 1376   end
 1377 
 1378   # Internal: Extract nested single-plus passthrough; otherwise return unprocessed
 1379   def extract_inner_passthrough text, pre
 1380     if (text.end_with? '+') && (text.start_with? '+', '\+') && SinglePlusInlinePassRx =~ text
 1381       if $1
 1382         %(#{pre}`+#{$2}+`)
 1383       else
 1384         @passthroughs[passthru_key = @passthroughs.size] = { text: $2, subs: BASIC_SUBS }
 1385         %(#{pre}`#{PASS_START}#{passthru_key}#{PASS_END}`)
 1386       end
 1387     else
 1388       %(#{pre}`#{text}`)
 1389     end
 1390   end
 1391 
 1392   # Internal: Convert a quoted text region
 1393   #
 1394   # match  - The MatchData for the quoted text region
 1395   # type   - The quoting type (single, double, strong, emphasis, monospaced, etc)
 1396   # scope  - The scope of the quoting (constrained or unconstrained)
 1397   #
 1398   # Returns The converted String text for the quoted text region
 1399   def convert_quoted_text match, type, scope
 1400     if match[0].start_with? RS
 1401       if scope == :constrained && (attrs = match[2])
 1402         unescaped_attrs = %([#{attrs}])
 1403       else
 1404         return match[0].slice 1, match[0].length
 1405       end
 1406     end
 1407 
 1408     if scope == :constrained
 1409       if unescaped_attrs
 1410         %(#{unescaped_attrs}#{Inline.new(self, :quoted, match[3], type: type).convert})
 1411       else
 1412         if (attrlist = match[2])
 1413           id = (attributes = parse_quoted_text_attributes attrlist)['id']
 1414           type = :unquoted if type == :mark
 1415         end
 1416         %(#{match[1]}#{Inline.new(self, :quoted, match[3], type: type, id: id, attributes: attributes).convert})
 1417       end
 1418     else
 1419       if (attrlist = match[1])
 1420         id = (attributes = parse_quoted_text_attributes attrlist)['id']
 1421         type = :unquoted if type == :mark
 1422       end
 1423       Inline.new(self, :quoted, match[2], type: type, id: id, attributes: attributes).convert
 1424     end
 1425   end
 1426 
 1427   # Internal: Substitute replacement text for matched location
 1428   #
 1429   # returns The String text with the replacement characters substituted
 1430   def do_replacement m, replacement, restore
 1431     if (captured = m[0]).include? RS
 1432       # we have to use sub since we aren't sure it's the first char
 1433       captured.sub RS, ''
 1434     else
 1435       case restore
 1436       when :none
 1437         replacement
 1438       when :bounding
 1439         m[1] + replacement + m[2]
 1440       else # :leading
 1441         m[1] + replacement
 1442       end
 1443     end
 1444   end
 1445 
 1446   # Internal: Inserts text into a formatted text enclosure; used by xreftext
 1447   alias sub_placeholder sprintf unless RUBY_ENGINE == 'opal'
 1448 
 1449   # Internal: Parse the attributes that are defined on quoted (aka formatted) text
 1450   #
 1451   # str - A non-nil String of unprocessed attributes;
 1452   #       space-separated roles (e.g., role1 role2) or the id/role shorthand syntax (e.g., #idname.role)
 1453   #
 1454   # Returns a Hash of attributes (role and id only)
 1455   def parse_quoted_text_attributes str
 1456     return {} if (str = str.rstrip).empty?
 1457     # NOTE attributes are typically resolved after quoted text, so substitute eagerly
 1458     str = sub_attributes str if str.include? ATTR_REF_HEAD
 1459     # for compliance, only consider first positional attribute (very unlikely)
 1460     str = str.slice 0, (str.index ',') if str.include? ','
 1461 
 1462     if (str.start_with? '.', '#') && Compliance.shorthand_property_syntax
 1463       segments = str.split '#', 2
 1464 
 1465       if segments.size > 1
 1466         id, *more_roles = segments[1].split('.')
 1467       else
 1468         more_roles = []
 1469       end
 1470 
 1471       roles = segments[0].empty? ? [] : segments[0].split('.')
 1472       if roles.size > 1
 1473         roles.shift
 1474       end
 1475 
 1476       if more_roles.size > 0
 1477         roles.concat more_roles
 1478       end
 1479 
 1480       attrs = {}
 1481       attrs['id'] = id if id
 1482       attrs['role'] = roles.join ' ' unless roles.empty?
 1483       attrs
 1484     else
 1485       { 'role' => str }
 1486     end
 1487   end
 1488 
 1489   # Internal: Normalize text to prepare it for parsing.
 1490   #
 1491   # If normalize_whitespace is true, strip surrounding whitespace and fold newlines. If unescape_closing_square_bracket
 1492   # is set, unescape any escaped closing square brackets.
 1493   #
 1494   # Returns the normalized text String
 1495   def normalize_text text, normalize_whitespace = nil, unescape_closing_square_brackets = nil
 1496     unless text.empty?
 1497       text = text.strip.tr LF, ' ' if normalize_whitespace
 1498       text = text.gsub ESC_R_SB, R_SB if unescape_closing_square_brackets && (text.include? R_SB)
 1499     end
 1500     text
 1501   end
 1502 
 1503   # Internal: Split text formatted as CSV with support
 1504   # for double-quoted values (in which commas are ignored)
 1505   def split_simple_csv str
 1506     if str.empty?
 1507       []
 1508     elsif str.include? '"'
 1509       values = []
 1510       accum = ''
 1511       quote_open = nil
 1512       str.each_char do |c|
 1513         case c
 1514         when ','
 1515           if quote_open
 1516             accum = accum + c
 1517           else
 1518             values << accum.strip
 1519             accum = ''
 1520           end
 1521         when '"'
 1522           quote_open = !quote_open
 1523         else
 1524           accum = accum + c
 1525         end
 1526       end
 1527       values << accum.strip
 1528     else
 1529       str.split(',').map {|it| it.strip }
 1530     end
 1531   end
 1532 end
 1533 end