1#!/usr/bin/python3 2# 3# Copyright 2016-2024 The Khronos Group Inc. 4# 5# SPDX-License-Identifier: Apache-2.0 6 7"""Used for automatic reflow of spec sources to satisfy the agreed layout to 8minimize git churn. Also used to insert identifying tags on explicit Valid 9Usage statements. 10 11Usage: `reflow.py [-noflow] [-tagvu] [-nextvu #] [-overwrite] [-out dir] [-suffix str] files` 12 13- `-noflow` acts as a passthrough, instead of reflowing text. Other 14 processing may occur. 15- `-tagvu` generates explicit VUID tag for Valid Usage statements which 16 do not already have them. 17- `-nextvu #` starts VUID tag generation at the specified # instead of 18 the value wired into the `reflow.py` script. 19- `-overwrite` updates in place (can be risky, make sure there are backups) 20- `-check FAIL|WARN` runs some consistency checks on markup. If the checks 21 fail and the WARN option is given, the script will simply print a warning 22 message. If the checks fail and the FAIL option is given, the script will 23 exit with an error code. FAIL is for use with continuous integration 24 scripts enforcing the checks. 25- `-out` specifies directory to create output file in, default 'out' 26- `-suffix` specifies suffix to add to output files, default '' 27- `files` are asciidoc source files from the spec to reflow. 28""" 29# For error and file-loading interfaces only 30import argparse 31import os 32import re 33import sys 34from reflib import loadFile, logDiag, logWarn, logErr, setLogFile, getBranch 35from pathlib import Path 36import doctransformer 37 38# Vulkan-specific - will consolidate into scripts/ like OpenXR soon 39sys.path.insert(0, 'xml') 40 41from apiconventions import APIConventions 42conventions = APIConventions() 43 44# Patterns used to recognize interesting lines in an asciidoc source file. 45# These patterns are only compiled once. 46 47# Find the pname: or code: patterns in a Valid Usage statement 48pnamePat = re.compile(r'pname:(?P<param>\{?\w+\}?)') 49codePat = re.compile(r'code:(?P<param>\w+)') 50 51# Text that (may) not end sentences 52 53# A single letter followed by a period, typically a middle initial. 54endInitial = re.compile(r'^[A-Z]\.$') 55# An abbreviation, which does not (usually) end a line. 56endAbbrev = re.compile(r'(e\.g|i\.e|c\.f|vs)\.$', re.IGNORECASE) 57 58# Explicit Valid Usage list item with one or more leading asterisks 59# The re.DOTALL is needed to prevent vuPat.search() from stripping 60# the trailing newline. 61vuPat = re.compile(r'^(?P<head> [*]+)( *)(?P<tail>.*)', re.DOTALL) 62 63# VUID with the numeric portion captured in the match object 64vuidPat = re.compile(r'VUID-[^-]+-[^-]+-(?P<vuid>[0-9]+)') 65 66# Pattern matching leading nested bullet points 67global nestedVuPat 68nestedVuPat = re.compile(r'^ \*\*') 69 70class ReflowCallbacks: 71 """State and arguments for reflowing. 72 73 Used with DocTransformer to reflow a file.""" 74 def __init__(self, 75 filename, 76 vuidDict, 77 margin = 76, 78 breakPeriod = True, 79 reflow = True, 80 nextvu = None, 81 maxvu = None, 82 check = True): 83 84 self.filename = filename 85 """base name of file being read from.""" 86 87 self.check = check 88 """Whether consistency checks must be performed.""" 89 90 self.margin = margin 91 """margin to reflow text to.""" 92 93 self.breakPeriod = breakPeriod 94 """True if justification should break to a new line after the end of a 95 sentence.""" 96 97 self.breakInitial = True 98 """True if justification should break to a new line after something 99 that appears to be an initial in someone's name. **TBD**""" 100 101 self.reflow = reflow 102 """True if text should be reflowed, False to pass through unchanged.""" 103 104 self.vuPrefix = 'VUID' 105 """Prefix of generated Valid Usage tags""" 106 107 self.vuFormat = '{0}-{1}-{2}-{3:0>5d}' 108 """Format string for generating Valid Usage tags. 109 First argument is vuPrefix, second is command/struct name, third is 110 parameter name, fourth is the tag number.""" 111 112 self.nextvu = nextvu 113 """Integer to start tagging un-numbered Valid Usage statements with, 114 or None if no tagging should be done.""" 115 116 self.maxvu = maxvu 117 """Maximum tag to use for Valid Usage statements, or None if no 118 tagging should be done.""" 119 120 self.vuidDict = vuidDict 121 """Dictionary of VUID numbers found, containing a list of (file, VUID) 122 on which that number was found. This is used to warn on duplicate 123 VUIDs.""" 124 125 self.warnCount = 0 126 """Count of markup check warnings encountered.""" 127 128 def endSentence(self, word): 129 """Return True if word ends with a sentence-period, False otherwise. 130 131 Allows for contraction cases which will not end a line: 132 133 - A single letter (if breakInitial is True) 134 - Abbreviations: 'c.f.', 'e.g.', 'i.e.' (or mixed-case versions)""" 135 if (word[-1:] != '.' or 136 endAbbrev.search(word) or 137 (self.breakInitial and endInitial.match(word))): 138 return False 139 140 return True 141 142 def vuidAnchor(self, word): 143 """Return True if word is a Valid Usage ID Tag anchor.""" 144 return (word[0:7] == '[[VUID-') 145 146 def visitVUID(self, vuid, line): 147 if vuid not in self.vuidDict: 148 self.vuidDict[vuid] = [] 149 self.vuidDict[vuid].append([self.filename, line]) 150 151 def gatherVUIDs(self, para): 152 """Gather VUID tags and add them to vuidDict. Used to verify no-duplicate VUIDs""" 153 for line in para: 154 line = line.rstrip() 155 156 matches = vuidPat.search(line) 157 if matches is not None: 158 vuid = matches.group('vuid') 159 self.visitVUID(vuid, line) 160 161 def addVUID(self, para, state): 162 hangIndent = state.hangIndent 163 164 """Generate and add VUID if necessary.""" 165 if not state.isVU or self.nextvu is None: 166 return para, hangIndent 167 168 # If: 169 # - this paragraph is in a Valid Usage block, 170 # - VUID tags are being assigned, 171 # Try to assign VUIDs 172 173 if nestedVuPat.search(para[0]): 174 # Do not assign VUIDs to nested bullet points. 175 # These are now allowed VU markup syntax, but will never 176 # themselves be VUs, just subsidiary points. 177 return para, hangIndent 178 179 # Skip if there is already a VUID assigned 180 if self.vuPrefix in para[0]: 181 return para, hangIndent 182 183 # If: 184 # - a tag is not already present, and 185 # - the paragraph is a properly marked-up list item 186 # Then add a VUID tag starting with the next free ID. 187 188 # Split the first line after the bullet point 189 matches = vuPat.search(para[0]) 190 if matches is None: 191 # There are only a few cases of this, and they are all 192 # legitimate. Leave detecting this case to another tool 193 # or hand inspection. 194 # logWarn(self.filename + ': Unexpected non-bullet item in VU block (harmless if following an ifdef):', 195 # para[0]) 196 return para, hangIndent 197 198 outPara = para 199 200 logDiag('addVUID: Matched vuPat on line:', para[0], end='') 201 head = matches.group('head') 202 tail = matches.group('tail') 203 204 # Find pname: or code: tags in the paragraph for the purposes of VUID 205 # tag generation. pname:{attribute}s are prioritized to make sure 206 # commonvalidity VUIDs end up being unique. Otherwise, the first pname: 207 # or code: tag in the paragraph is used, which may not always be 208 # correct, but should be highly reliable. 209 pnameMatches = re.findall(pnamePat, ' '.join(para)) 210 codeMatches = re.findall(codePat, ' '.join(para)) 211 212 # Prioritize {attribute}s, but not the ones in the exception list 213 # below. These have complex expressions including ., ->, or [index] 214 # which makes them unsuitable for VUID tags. Ideally these would be 215 # automatically discovered. 216 attributeExceptionList = ['maxinstancecheck', 'regionsparam', 217 'rayGenShaderBindingTableAddress', 218 'rayGenShaderBindingTableStride', 219 'missShaderBindingTableAddress', 220 'missShaderBindingTableStride', 221 'hitShaderBindingTableAddress', 222 'hitShaderBindingTableStride', 223 'callableShaderBindingTableAddress', 224 'callableShaderBindingTableStride', 225 ] 226 attributeMatches = [match for match in pnameMatches if 227 match[0] == '{' and 228 match[1:-1] not in attributeExceptionList] 229 nonattributeMatches = [match for match in pnameMatches if 230 match[0] != '{'] 231 232 if len(attributeMatches) > 0: 233 paramName = attributeMatches[0] 234 elif len(nonattributeMatches) > 0: 235 paramName = nonattributeMatches[0] 236 elif len(codeMatches) > 0: 237 paramName = codeMatches[0] 238 else: 239 paramName = 'None' 240 logWarn(self.filename, 241 'No param name found for VUID tag on line:', 242 para[0]) 243 244 # Transform: 245 # 246 # * VU first line 247 # 248 # To: 249 # 250 # * [[VUID]] 251 # VU first line 252 # 253 tagLine = (head + ' [[' + 254 self.vuFormat.format(self.vuPrefix, 255 state.apiName, 256 paramName, 257 self.nextvu) + ']]\n') 258 self.visitVUID(str(self.nextvu), tagLine) 259 260 newLines = [tagLine] 261 if tail.strip() != '': 262 logDiag('transformParagraph first line matches bullet point -' 263 'single line, assuming hangIndent @ input line', 264 state.lineNumber) 265 hangIndent = len(head) + 1 266 newLines.append(''.ljust(hangIndent) + tail) 267 268 logDiag('Assigning', self.vuPrefix, state.apiName, self.nextvu, 269 ' on line:\n' + para[0], '->\n' + newLines[0] + 'END', '\n' + newLines[1] if len(newLines) > 1 else '') 270 271 # Do not actually assign the VUID unless it is in the reserved range 272 if self.nextvu <= self.maxvu: 273 if self.nextvu == self.maxvu: 274 logWarn('Skipping VUID assignment, no more VUIDs available') 275 outPara = newLines + para[1:] 276 self.nextvu = self.nextvu + 1 277 278 return outPara, hangIndent 279 280 def transformParagraph(self, para, state): 281 """Reflow a given paragraph, respecting the paragraph lead and 282 hanging indentation levels. 283 284 The algorithm also respects trailing '+' signs that indicate embedded newlines, 285 and will not reflow a very long word immediately after a bullet point. 286 287 Just return the paragraph unchanged if the -noflow argument was 288 given.""" 289 290 self.gatherVUIDs(para) 291 292 # If this is a VU that is missing a VUID, add it to the paragraph now. 293 para, hangIndent = self.addVUID(para, state) 294 295 if not self.reflow: 296 return para 297 298 logDiag('transformParagraph lead indent = ', state.leadIndent, 299 'hangIndent =', state.hangIndent, 300 'para:', para[0], end='') 301 302 # Total words processed (we care about the *first* word vs. others) 303 wordCount = 0 304 305 # Tracks the *previous* word processed. It must not be empty. 306 prevWord = ' ' 307 308 # Track the previous line and paragraph being indented, if any 309 outLine = None 310 outPara = [] 311 312 for line in para: 313 line = line.rstrip() 314 words = line.split() 315 316 # logDiag('transformParagraph: input line =', line) 317 numWords = len(words) - 1 318 319 for i in range(0, numWords + 1): 320 word = words[i] 321 wordLen = len(word) 322 wordCount += 1 323 324 endEscape = False 325 if i == numWords and word in ('+', '-'): 326 # Trailing ' +' or ' -' must stay on the same line 327 endEscape = word 328 # logDiag('transformParagraph last word of line =', word, 329 # 'prevWord =', prevWord, 'endEscape =', endEscape) 330 else: 331 # logDiag('transformParagraph wordCount =', wordCount, 332 # 'word =', word, 'prevWord =', prevWord) 333 pass 334 335 if wordCount == 1: 336 # The first word of the paragraph is treated specially. 337 # The loop logic becomes trickier if all this code is 338 # done prior to looping over lines and words, so all the 339 # setup logic is done here. 340 341 outLine = ''.ljust(state.leadIndent) + word 342 outLineLen = state.leadIndent + wordLen 343 344 # If the paragraph begins with a bullet point, generate 345 # a hanging indent level if there is not one already. 346 if doctransformer.beginBullet.match(para[0]): 347 bulletPoint = True 348 if len(para) > 1: 349 logDiag('transformParagraph first line matches bullet point', 350 'but indent already hanging @ input line', 351 state.lineNumber) 352 else: 353 logDiag('transformParagraph first line matches bullet point -' 354 'single line, assuming hangIndent @ input line', 355 state.lineNumber) 356 hangIndent = outLineLen + 1 357 else: 358 bulletPoint = False 359 else: 360 # Possible actions to take with this word 361 # 362 # addWord - add word to current line 363 # closeLine - append line and start a new (null) one 364 # startLine - add word to a new line 365 366 # Default behavior if all the tests below fail is to add 367 # this word to the current line, and keep accumulating 368 # that line. 369 (addWord, closeLine, startLine) = (True, False, False) 370 371 # How long would this line be if the word were added? 372 newLen = outLineLen + 1 + wordLen 373 374 # Are we on the first word following a bullet point? 375 firstBullet = (wordCount == 2 and bulletPoint) 376 377 if endEscape: 378 # If the new word ends the input line with ' +', 379 # add it to the current line. 380 381 (addWord, closeLine, startLine) = (True, True, False) 382 elif self.vuidAnchor(word): 383 # If the new word is a Valid Usage anchor, break the 384 # line afterwards. Note that this should only happen 385 # immediately after a bullet point, but we do not 386 # currently check for this. 387 (addWord, closeLine, startLine) = (True, True, False) 388 389 elif newLen > self.margin: 390 if firstBullet: 391 # If the word follows a bullet point, add it to 392 # the current line no matter its length. 393 394 (addWord, closeLine, startLine) = (True, True, False) 395 elif doctransformer.beginBullet.match(word + ' '): 396 # If the word *is* a bullet point, add it to 397 # the current line no matter its length. 398 # This avoids an innocent inline '-' or '*' 399 # turning into a bogus bullet point. 400 401 (addWord, closeLine, startLine) = (True, True, False) 402 else: 403 # The word overflows, so add it to a new line. 404 405 (addWord, closeLine, startLine) = (False, True, True) 406 elif (self.breakPeriod and 407 (wordCount > 2 or not firstBullet) and 408 self.endSentence(prevWord)): 409 # If the previous word ends a sentence and 410 # breakPeriod is set, start a new line. 411 # The complicated logic allows for leading bullet 412 # points which are periods (implicitly numbered lists). 413 # @@@ But not yet for explicitly numbered lists. 414 415 (addWord, closeLine, startLine) = (False, True, True) 416 417 # Add a word to the current line 418 if addWord: 419 if outLine: 420 outLine += ' ' + word 421 outLineLen = newLen 422 else: 423 # Fall through to startLine case if there is no 424 # current line yet. 425 startLine = True 426 427 # Add current line to the output paragraph. Force 428 # starting a new line, although we do not yet know if it 429 # will ever have contents. 430 if closeLine: 431 if outLine: 432 outPara.append(outLine + '\n') 433 outLine = None 434 435 # Start a new line and add a word to it 436 if startLine: 437 outLine = ''.ljust(hangIndent) + word 438 outLineLen = hangIndent + wordLen 439 440 # Track the previous word, for use in breaking at end of 441 # a sentence 442 prevWord = word 443 444 # Add last line to the output paragraph. 445 if outLine: 446 outPara.append(outLine + '\n') 447 448 return outPara 449 450 def onEmbeddedVUConditional(self, state): 451 if self.check: 452 logWarn('Detected embedded Valid Usage conditional: {}:{}'.format( 453 self.filename, state.lineNumber - 1)) 454 # Keep track of warning check count 455 self.warnCount = self.warnCount + 1 456 457def reflowFile(filename, args): 458 logDiag('reflow: filename', filename) 459 460 # Output file handle and reflow object for this file. There are no race 461 # conditions on overwriting the input, but it is not recommended unless 462 # you have backing store such as git. 463 464 lines, newline_string = loadFile(filename) 465 if lines is None: 466 return 467 468 if args.overwrite: 469 outFilename = filename 470 else: 471 outDir = Path(args.outDir).resolve() 472 outDir.mkdir(parents=True, exist_ok=True) 473 474 outFilename = str(outDir / (os.path.basename(filename) + args.suffix)) 475 476 if args.nowrite: 477 fp = None 478 else: 479 try: 480 fp = open(outFilename, 'w', encoding='utf8', newline=newline_string) 481 except: 482 logWarn('Cannot open output file', outFilename, ':', sys.exc_info()[0]) 483 return 484 485 callback = ReflowCallbacks(filename, 486 args.vuidDict, 487 margin = args.margin, 488 reflow = not args.noflow, 489 nextvu = args.nextvu, 490 maxvu = args.maxvu, 491 check = args.check) 492 493 transformer = doctransformer.DocTransformer(filename, 494 outfile = fp, 495 callback = callback) 496 497 transformer.transformFile(lines) 498 499 if fp is not None: 500 fp.close() 501 502 # Update the 'nextvu' value 503 if args.nextvu != callback.nextvu: 504 logWarn('Updated nextvu to', callback.nextvu, 'after file', filename) 505 args.nextvu = callback.nextvu 506 507 args.warnCount += callback.warnCount 508 509def reflowAllAdocFiles(folder_to_reflow, args): 510 for root, subdirs, files in os.walk(folder_to_reflow): 511 for file in files: 512 if file.endswith(conventions.file_suffix): 513 file_path = os.path.join(root, file) 514 reflowFile(file_path, args) 515 for subdir in subdirs: 516 sub_folder = os.path.join(root, subdir) 517 print('Sub-folder = %s' % sub_folder) 518 if subdir.lower() not in conventions.spec_no_reflow_dirs: 519 print(' Parsing = %s' % sub_folder) 520 reflowAllAdocFiles(sub_folder, args) 521 else: 522 print(' Skipping = %s' % sub_folder) 523 524if __name__ == '__main__': 525 parser = argparse.ArgumentParser() 526 527 parser.add_argument('-diag', action='store', dest='diagFile', 528 help='Set the diagnostic file') 529 parser.add_argument('-warn', action='store', dest='warnFile', 530 help='Set the warning file') 531 parser.add_argument('-log', action='store', dest='logFile', 532 help='Set the log file for both diagnostics and warnings') 533 parser.add_argument('-overwrite', action='store_true', 534 help='Overwrite input filenames instead of writing different output filenames') 535 parser.add_argument('-out', action='store', dest='outDir', 536 default='out', 537 help='Set the output directory in which updated files are generated (default: out)') 538 parser.add_argument('-nowrite', action='store_true', 539 help='Do not write output files, for use with -check') 540 parser.add_argument('-check', action='store', dest='check', 541 help='Run markup checks and warn if WARN option is given, error exit if FAIL option is given') 542 parser.add_argument('-checkVUID', action='store', dest='checkVUID', 543 help='Detect duplicated VUID numbers and warn if WARN option is given, error exit if FAIL option is given') 544 parser.add_argument('-tagvu', action='store_true', 545 help='Tag un-tagged Valid Usage statements starting at the value wired into reflow.py') 546 parser.add_argument('-nextvu', action='store', dest='nextvu', type=int, 547 default=None, 548 help='Tag un-tagged Valid Usage statements starting at the specified base VUID instead of the value wired into reflow.py') 549 parser.add_argument('-maxvu', action='store', dest='maxvu', type=int, 550 default=None, 551 help='Specify maximum VUID instead of the value wired into vuidCounts.py') 552 parser.add_argument('-branch', action='store', dest='branch', 553 help='Specify branch to assign VUIDs for') 554 parser.add_argument('-noflow', action='store_true', dest='noflow', 555 help='Do not reflow text. Other actions may apply') 556 parser.add_argument('-margin', action='store', type=int, dest='margin', 557 default='76', 558 help='Width to reflow text, defaults to 76 characters') 559 parser.add_argument('-suffix', action='store', dest='suffix', 560 default='', 561 help='Set the suffix added to updated file names (default: none)') 562 parser.add_argument('files', metavar='filename', nargs='*', 563 help='a filename to reflow text in') 564 parser.add_argument('--version', action='version', version='%(prog)s 1.0') 565 566 args = parser.parse_args() 567 568 setLogFile(True, True, args.logFile) 569 setLogFile(True, False, args.diagFile) 570 setLogFile(False, True, args.warnFile) 571 572 if args.overwrite: 573 logWarn("reflow.py: will overwrite all input files") 574 575 errors = '' 576 if args.branch is None: 577 (args.branch, errors) = getBranch() 578 if args.branch is None: 579 # This is not fatal unless VUID assignment is required 580 if args.tagvu: 581 logErr('Cannot determine current git branch, so cannot assign VUIDs:', errors) 582 583 if args.tagvu and args.nextvu is None: 584 # Moved here since vuidCounts is only needed in the internal 585 # repository 586 from vuidCounts import vuidCounts 587 588 if args.branch not in vuidCounts: 589 logErr('Branch', args.branch, 'not in vuidCounts, cannot continue') 590 maxVUID = vuidCounts[args.branch][1] 591 startVUID = vuidCounts[args.branch][2] 592 args.nextvu = startVUID 593 args.maxvu = maxVUID 594 595 if args.nextvu is not None: 596 logWarn('Tagging untagged Valid Usage statements starting at', args.nextvu) 597 598 # Count of markup check warnings encountered 599 # This is added to the argparse structure 600 args.warnCount = 0 601 602 # Dictionary of VUID numbers found, containing a list of (file, VUID) on 603 # which that number was found 604 # This is added to the argparse structure 605 args.vuidDict = {} 606 607 # If no files are specified, reflow the entire specification chapters folder 608 if not args.files: 609 folder_to_reflow = conventions.spec_reflow_path 610 logWarn('Reflowing all asciidoc files under', folder_to_reflow) 611 reflowAllAdocFiles(folder_to_reflow, args) 612 else: 613 for file in args.files: 614 reflowFile(file, args) 615 616 if args.warnCount > 0: 617 if args.check == 'FAIL': 618 logErr('Failed with', args.warnCount, 'markup errors detected.\n' + 619 'To fix these, you can take actions such as:\n' + 620 ' * Moving conditionals outside VU start / end without changing VU meaning\n' + 621 ' * Refactor conditional text using terminology defined conditionally outside the VU itself\n' + 622 ' * Remove the conditional (allowable when this just affects command / structure / enum names)\n') 623 else: 624 logWarn('Total warning count for markup issues is', args.warnCount) 625 626 # Look for duplicated VUID numbers 627 if args.checkVUID: 628 dupVUIDs = 0 629 for vuid in sorted(args.vuidDict): 630 found = args.vuidDict[vuid] 631 if len(found) > 1: 632 logWarn('Duplicate VUID number {} found in files:'.format(vuid)) 633 for (file, vuidLine) in found: 634 logWarn(' {}: {}'.format(file, vuidLine)) 635 dupVUIDs = dupVUIDs + 1 636 637 if dupVUIDs > 0: 638 if args.checkVUID == 'FAIL': 639 logErr('Failed with', dupVUIDs, 'duplicated VUID numbers found.\n' + 640 'To fix this, either convert these to commonvalidity VUs if possible, or strip\n' + 641 'the VUIDs from all but one of the duplicates and regenerate new ones.') 642 else: 643 logWarn('Total number of duplicated VUID numbers is', dupVUIDs) 644 645 if args.nextvu is not None and args.nextvu != startVUID: 646 # Update next free VUID to assign 647 vuidCounts[args.branch][2] = args.nextvu 648 try: 649 reflow_count_file_path = os.path.dirname(os.path.realpath(__file__)) 650 reflow_count_file_path += '/vuidCounts.py' 651 reflow_count_file = open(reflow_count_file_path, 'w', encoding='utf8') 652 print('# Do not edit this file, unless reserving a new VUID range', file=reflow_count_file) 653 print('# VUID ranges reserved for branches', file=reflow_count_file) 654 print('# Key is branch name, value is [ start, end, nextfree ]', file=reflow_count_file) 655 print('# New reservations must be made by MR to main branch', file=reflow_count_file) 656 print('vuidCounts = {', file=reflow_count_file) 657 for key in sorted(vuidCounts.keys(), key=lambda k: vuidCounts[k][0]): 658 counts = vuidCounts[key] 659 print(f" '{key}': [ {counts[0]}, {counts[1]}, {counts[2]} ],", 660 file=reflow_count_file) 661 print('}', file=reflow_count_file) 662 reflow_count_file.close() 663 except: 664 logWarn('Cannot open output count file vuidCounts.py', ':', sys.exc_info()[0]) 665