1# Copyright 2016-2023 The Khronos Group Inc. 2# 3# SPDX-License-Identifier: Apache-2.0 4 5require 'asciidoctor/extensions' unless RUBY_ENGINE == 'opal' 6 7include ::Asciidoctor 8 9module Asciidoctor 10 11# Duplicate of "AnyListRx" defined by asciidoctor 12# Detects the start of any list item. 13# 14# NOTE we only have to check as far as the blank character because we know it means non-whitespace follows. 15HighlighterAnyListRx = /^(?:#{CG_BLANK}*(?:-|([*.\u2022])\1{0,4}|\d+\.|[a-zA-Z]\.|[IVXivx]+\))#{CG_BLANK}|#{CG_BLANK}*.*?(?::{2,4}|;;)(?:$|#{CG_BLANK})|<?\d+>#{CG_BLANK})/ 16 17class ExtensionHighlighterPreprocessorReader < PreprocessorReader 18 def initialize document, diff_extensions, data = nil, cursor = nil 19 super(document, data, cursor) 20 @status_stack = [] 21 @diff_extensions = diff_extensions 22 @tracking_target = nil 23 end 24 25 # This overrides the default preprocessor reader conditional logic such 26 # that any extensions which need highlighting and are enabled have their 27 # ifdefs left intact. 28 def preprocess_conditional_directive directive, target, delimiter, text 29 # If we are tracking a target for highlighting already, we do not need to do 30 # additional processing unless we hit the end of that conditional 31 # section 32 # NOTE: This will break if for some absurd reason someone nests the same 33 # conditional inside itself. 34 if @tracking_target != nil && directive == 'endif' && @tracking_target == target.downcase 35 @tracking_target = nil 36 elsif @tracking_target 37 return super(directive, target, delimiter, text) 38 end 39 40 # If it is an ifdef or ifndef, push the directive onto a stack 41 # If it is an endif, pop the last one off. 42 # This is done to apply the next bit of logic to both the start and end 43 # of an conditional block correctly 44 status = directive 45 if directive == 'endif' 46 status = @status_stack.pop 47 else 48 @status_stack.push status 49 end 50 51 # If the status is negative, we need to still include the conditional 52 # text for the highlighter, so we replace the requirement for the 53 # extension attribute in question to be not defined with an 54 # always-undefined attribute, so that it evaluates to true when it needs 55 # to. 56 # Undefined attribute is currently just the extension with "_undefined" 57 # appended to it. 58 modified_target = target.downcase 59 if status == 'ifndef' 60 @diff_extensions.each do | extension | 61 modified_target.gsub!(extension, extension + '_undefined') 62 end 63 end 64 65 # Call the original preprocessor 66 result = super(directive, modified_target, delimiter, text) 67 68 # If any of the extensions are in the target, and the conditional text 69 # is not flagged to be skipped, return false to prevent the preprocessor 70 # from removing the line from the processed source. 71 unless @skipping 72 @diff_extensions.each do | extension | 73 if target.downcase.include?(extension) 74 if directive != 'endif' 75 @tracking_target = target.downcase 76 end 77 return false 78 end 79 end 80 end 81 return result 82 end 83 84 # Identical to preprocess_conditional_directive, but older versions of 85 # Asciidoctor used a different name, so this is there to override the same 86 # method in older versions. 87 # This is a pure c+p job for awkward inheritance reasons (see use of 88 # the super() keyword :|) 89 # At some point, will rewrite to avoid this mess, but this fixes things 90 # for now without breaking things for anyone. 91 def preprocess_conditional_inclusion directive, target, delimiter, text 92 # If we are tracking a target for highlighting already, do not need to do 93 # additional processing unless we hit the end of that conditional 94 # section 95 # NOTE: This will break if for some absurd reason someone nests the same 96 # conditional inside itself. 97 if @tracking_target != nil && directive == 'endif' && @tracking_target == target.downcase 98 @tracking_target = nil 99 elsif @tracking_target 100 return super(directive, target, delimiter, text) 101 end 102 103 # If it is an ifdef or ifndef, push the directive onto a stack 104 # If it is an endif, pop the last one off. 105 # This is done to apply the next bit of logic to both the start and end 106 # of an conditional block correctly 107 status = directive 108 if directive == 'endif' 109 status = @status_stack.pop 110 else 111 @status_stack.push status 112 end 113 114 # If the status is negative, we need to still include the conditional 115 # text for the highlighter, so we replace the requirement for the 116 # extension attribute in question to be not defined with an 117 # always-undefined attribute, so that it evaluates to true when it needs 118 # to. 119 # Undefined attribute is currently just the extension with "_undefined" 120 # appended to it. 121 modified_target = target.downcase 122 if status == 'ifndef' 123 @diff_extensions.each do | extension | 124 modified_target.gsub!(extension, extension + '_undefined') 125 end 126 end 127 128 # Call the original preprocessor 129 result = super(directive, modified_target, delimiter, text) 130 131 # If any of the extensions are in the target, and the conditional text 132 # is not flagged to be skipped, return false to prevent the preprocessor 133 # from removing the line from the processed source. 134 unless @skipping 135 @diff_extensions.each do | extension | 136 if target.downcase.include?(extension) 137 if directive != 'endif' 138 @tracking_target = target.downcase 139 end 140 return false 141 end 142 end 143 end 144 return result 145 end 146end 147 148class Highlighter 149 def initialize 150 @delimiter_stack = [] 151 @current_anchor = 1 152 end 153 154 def highlight_marks line, previous_line, next_line 155 if !(line.start_with? 'endif') 156 # Any intact "ifdefs" are sections added by an extension, and 157 # "ifndefs" are sections removed. 158 # Currently do not track *which* extension(s) is/are responsible for 159 # the addition or removal - though it would be possible to add it. 160 if line.start_with? 'ifdef' 161 role = 'added' 162 else # if line.start_with? 'ifndef' 163 role = 'removed' 164 end 165 166 # Create an anchor with the current anchor number 167 anchor = '[[difference' + @current_anchor.to_s + ']]' 168 169 # Figure out which markup to use based on the surrounding text 170 # This is robust enough as far as I can tell, though we may want to do 171 # something more generic later since currently it relies on the fact 172 # that if you start inside a list or paragraph, you will end in the same 173 # list or paragraph and not cross to other blocks. 174 # In practice it *might just work* but it also might not. 175 # May need to consider what to do about this in future - maybe just 176 # use open blocks for everything? 177 highlight_delimiter = :inline 178 if (HighlighterAnyListRx.match(next_line) != nil) 179 # NOTE: There is a corner case here that should never be hit (famous last words) 180 # If a line in the middle of a paragraph begins with an asterisk and 181 # then whitespace, this will think it is a list item and use the 182 # wrong delimiter. 183 # That should not be a problem in practice though, it just might look 184 # a little weird. 185 highlight_delimiter = :list 186 elsif previous_line.strip.empty? 187 highlight_delimiter = :block 188 end 189 190 # Add the delimiter to the stack for the matching 'endif' to consume 191 @delimiter_stack.push highlight_delimiter 192 193 # Add an appropriate method of delimiting the highlighted areas based 194 # on the surrounding text determined above. 195 if highlight_delimiter == :block 196 return ['', anchor, ":role: #{role}", ''] 197 elsif highlight_delimiter == :list 198 return ['', anchor, "[.#{role}]", '~~~~~~~~~~~~~~~~~~~~', ''] 199 else #if highlight_delimiter == :inline 200 return [anchor + ' [.' + role + ']##'] 201 end 202 else # if !(line.start_with? 'endif') 203 # Increment the anchor when we see a matching endif, and generate a 204 # link to the next diff section 205 @current_anchor = @current_anchor + 1 206 anchor_link = '<<difference' + @current_anchor.to_s + ', =>>>' 207 208 # Close the delimited area according to the previously determined 209 # delimiter 210 highlight_delimiter = @delimiter_stack.pop 211 if highlight_delimiter == :block 212 return [anchor_link, '', ':role:', ''] 213 elsif highlight_delimiter == :list 214 return [anchor_link, '~~~~~~~~~~~~~~~~~~~~', ''] 215 else #if highlight_delimiter == :inline 216 return [anchor_link + '##'] 217 end 218 end 219 end 220end 221 222# Preprocessor hook to iterate over ifdefs to prevent them from affecting asciidoctor's processing. 223class ExtensionHighlighterPreprocessor < Extensions::Preprocessor 224 def process document, reader 225 226 # Only attempt to highlight extensions that are also enabled - if one 227 # is not, warn about it and skip highlighting that extension. 228 diff_extensions = document.attributes['diff_extensions'].downcase.split(' ') 229 actual_diff_extensions = [] 230 diff_extensions.each do | extension | 231 if document.attributes.has_key?(extension) 232 actual_diff_extensions << extension 233 else 234 puts 'The ' + extension + ' extension is not enabled - changes will not be highlighted.' 235 end 236 end 237 238 # Create a new reader to return, which leaves extension ifdefs that need highlighting intact beyond the preprocess step. 239 extension_preprocessor_reader = ExtensionHighlighterPreprocessorReader.new(document, actual_diff_extensions, reader.lines) 240 241 highlighter = Highlighter.new 242 new_lines = [] 243 244 # Store the old lines so we can reference them in a non-trivial fashion 245 old_lines = extension_preprocessor_reader.read_lines() 246 old_lines.each_index do | index | 247 248 # Grab the previously processed line 249 # This is used by the highlighter to figure out if the highlight will 250 # be inline, or part of a block. 251 if index > 0 252 previous_line = old_lines[index - 1] 253 else 254 previous_line = '' 255 end 256 257 # Current line to process 258 line = old_lines[index] 259 260 # Grab the next line to process 261 # This is used by the highlighter to figure out if the highlight is 262 # between list elements or not - which need special handling. 263 if index < (old_lines.length - 1) 264 next_line = old_lines[index + 1] 265 else 266 next_line = '' 267 end 268 269 # Highlight any preprocessor directives that were left intact by the 270 # custom preprocessor reader. 271 if line.start_with?( 'ifdef::', 'ifndef::', 'endif::') 272 new_lines += highlighter.highlight_marks(line, previous_line, next_line) 273 else 274 new_lines << line 275 end 276 end 277 278 # Return a new reader after preprocessing - this takes care of creating 279 # the AST from the new source. 280 Reader.new(new_lines) 281 end 282end 283 284class AddHighlighterCSS < Extensions::Postprocessor 285 HighlighterStyleCSS = [ 286 '.added {', 287 ' background-color: lime;', 288 ' border-color: green;', 289 ' padding:1px;', 290 '}', 291 '.removed {', 292 ' background-color: pink;', 293 ' border-color: red;', 294 ' padding:1px;', 295 '}', 296 '</style>'] 297 298 def process document, output 299 output.sub! '</style>', HighlighterStyleCSS.join("\n") 300 end 301end 302 303end 304