1"""Provides MacroCheckerFile, a subclassable type that validates a single file in the spec.""" 2 3# Copyright (c) 2018-2019 Collabora, Ltd. 4# 5# SPDX-License-Identifier: Apache-2.0 6# 7# Author(s): Ryan Pavlik <ryan.pavlik@collabora.com> 8 9import logging 10import re 11from collections import OrderedDict, namedtuple 12from enum import Enum 13from inspect import currentframe 14 15from .shared import (AUTO_FIX_STRING, CATEGORIES_WITH_VALIDITY, 16 EXTENSION_CATEGORY, NON_EXISTENT_MACROS, EntityData, 17 Message, MessageContext, MessageId, MessageType, 18 generateInclude, toNameAndLine) 19 20# Code blocks may start and end with any number of ---- 21CODE_BLOCK_DELIM = '----' 22 23# Mostly for ref page blocks, but also used elsewhere? 24REF_PAGE_LIKE_BLOCK_DELIM = '--' 25 26# For insets/blocks like the implicit valid usage 27# TODO think it must start with this - does it have to be exactly this? 28BOX_BLOCK_DELIM = '****' 29 30 31INTERNAL_PLACEHOLDER = re.compile( 32 r'(?P<delim>__+)([a-zA-Z]+)(?P=delim)' 33) 34 35# Matches a generated (api or validity) include line. 36INCLUDE = re.compile( 37 r'include::(?P<directory_traverse>((../){1,4}|\{(INCS-VAR|generated)\}/)(generated/)?)(?P<generated_type>[\w]+)/(?P<category>\w+)/(?P<entity_name>[^./]+).txt[\[][\]]') 38 39# Matches an [[AnchorLikeThis]] 40ANCHOR = re.compile(r'\[\[(?P<entity_name>[^\]]+)\]\]') 41 42# Looks for flink:foo:: or slink::foo:: at the end of string: 43# used to detect explicit pname context. 44PRECEDING_MEMBER_REFERENCE = re.compile( 45 r'\b(?P<macro>[fs](text|link)):(?P<entity_name>[\w*]+)::$') 46 47# Matches something like slink:foo::pname:bar as well as 48# the under-marked-up slink:foo::bar. 49MEMBER_REFERENCE = re.compile( 50 r'\b(?P<first_part>(?P<scope_macro>[fs](text|link)):(?P<scope>[\w*]+))(?P<double_colons>::)(?P<second_part>(?P<member_macro>pname:?)(?P<entity_name>[\w]+))\b' 51) 52 53# Matches if a string ends while a link is still "open". 54# (first half of a link being broken across two lines, 55# or containing our interested area when matched against the text preceding). 56# Used to skip checking in some places. 57OPEN_LINK = re.compile( 58 r'.*(?<!`)<<[^>]*$' 59) 60 61# Matches if a string begins and is followed by a link "close" without a matching open. 62# (second half of a link being broken across two lines) 63# Used to skip checking in some places. 64CLOSE_LINK = re.compile( 65 r'[^<]*>>.*$' 66) 67 68# Matches if a line should be skipped without further considering. 69# Matches lines starting with: 70# - `ifdef:` 71# - `endif:` 72# - `todo` (followed by something matching \b, like : or (. capitalization ignored) 73SKIP_LINE = re.compile( 74 r'^(ifdef:)|(endif:)|([tT][oO][dD][oO]\b).*' 75) 76 77# Matches the whole inside of a refpage tag. 78BRACKETS = re.compile(r'\[(?P<tags>.*)\]') 79 80# Matches a key='value' pair from a ref page tag. 81REF_PAGE_ATTRIB = re.compile( 82 r"(?P<key>[a-z]+)='(?P<value>[^'\\]*(?:\\.[^'\\]*)*)'") 83 84 85class Attrib(Enum): 86 """Attributes of a ref page.""" 87 88 REFPAGE = 'refpage' 89 DESC = 'desc' 90 TYPE = 'type' 91 ALIAS = 'alias' 92 XREFS = 'xrefs' 93 ANCHOR = 'anchor' 94 95 96VALID_REF_PAGE_ATTRIBS = set( 97 (e.value for e in Attrib)) 98 99AttribData = namedtuple('AttribData', ['match', 'key', 'value']) 100 101 102def makeAttribFromMatch(match): 103 """Turn a match of REF_PAGE_ATTRIB into an AttribData value.""" 104 return AttribData(match=match, key=match.group( 105 'key'), value=match.group('value')) 106 107 108def parseRefPageAttribs(line): 109 """Parse a ref page tag into a dictionary of attribute_name: AttribData.""" 110 return {m.group('key'): makeAttribFromMatch(m) 111 for m in REF_PAGE_ATTRIB.finditer(line)} 112 113 114def regenerateIncludeFromMatch(match, generated_type): 115 """Create an include directive from an INCLUDE match and a (new or replacement) generated_type.""" 116 return generateInclude( 117 match.group('directory_traverse'), 118 generated_type, 119 match.group('category'), 120 match.group('entity_name')) 121 122 123BlockEntry = namedtuple( 124 'BlockEntry', ['delimiter', 'context', 'block_type', 'refpage']) 125 126 127class BlockType(Enum): 128 """Enumeration of the various distinct block types known.""" 129 CODE = 'code' 130 REF_PAGE_LIKE = 'ref-page-like' # with or without a ref page tag before 131 BOX = 'box' 132 133 @classmethod 134 def lineToBlockType(self, line): 135 """Return a BlockType if the given line is a block delimiter. 136 137 Returns None otherwise. 138 """ 139 if line == REF_PAGE_LIKE_BLOCK_DELIM: 140 return BlockType.REF_PAGE_LIKE 141 if line.startswith(CODE_BLOCK_DELIM): 142 return BlockType.CODE 143 if line.startswith(BOX_BLOCK_DELIM): 144 return BlockType.BOX 145 146 return None 147 148 149def _pluralize(word, num): 150 if num == 1: 151 return word 152 if word.endswith('y'): 153 return word[:-1] + 'ies' 154 return word + 's' 155 156 157def _s_suffix(num): 158 """Simplify pluralization.""" 159 if num > 1: 160 return 's' 161 return '' 162 163 164def shouldEntityBeText(entity, subscript): 165 """Determine if an entity name appears to use placeholders, wildcards, etc. and thus merits use of a *text macro. 166 167 Call with the entity and subscript groups from a match of MacroChecker.macro_re. 168 """ 169 entity_only = entity 170 if subscript: 171 if subscript == '[]' or subscript == '[i]' or subscript.startswith( 172 '[_') or subscript.endswith('_]'): 173 return True 174 entity_only = entity[:-len(subscript)] 175 176 if ('*' in entity) or entity.startswith('_') or entity_only.endswith('_'): 177 return True 178 179 if INTERNAL_PLACEHOLDER.search(entity): 180 return True 181 return False 182 183 184class MacroCheckerFile(object): 185 """Object performing processing of a single AsciiDoctor file from a specification. 186 187 For testing purposes, may also process a string as if it were a file. 188 """ 189 190 def __init__(self, checker, filename, enabled_messages, stream_maker): 191 """Construct a MacroCheckerFile object. 192 193 Typically called by MacroChecker.processFile or MacroChecker.processString(). 194 195 Arguments: 196 checker -- A MacroChecker object. 197 filename -- A string to use in messages to refer to this checker, typically the file name. 198 enabled_messages -- A set() of MessageId values that should be considered "enabled" and thus stored. 199 stream_maker -- An object with a makeStream() method that returns a stream. 200 """ 201 self.checker = checker 202 self.filename = filename 203 self.stream_maker = stream_maker 204 self.enabled_messages = enabled_messages 205 self.missing_validity_suppressions = set( 206 self.getMissingValiditySuppressions()) 207 208 self.logger = logging.getLogger(__name__) 209 self.logger.addHandler(logging.NullHandler()) 210 211 self.fixes = set() 212 self.messages = [] 213 214 self.pname_data = None 215 self.pname_mentions = {} 216 217 self.refpage_includes = {} 218 219 self.lines = [] 220 221 # For both of these: 222 # keys: entity name 223 # values: MessageContext 224 self.fs_api_includes = {} 225 self.validity_includes = {} 226 227 self.in_code_block = False 228 self.in_ref_page = False 229 self.prev_line_ref_page_tag = None 230 self.current_ref_page = None 231 232 # Stack of block-starting delimiters. 233 self.block_stack = [] 234 235 # Regexes that are members because they depend on the name prefix. 236 self.suspected_missing_macro_re = self.checker.suspected_missing_macro_re 237 self.heading_command_re = self.checker.heading_command_re 238 239 ### 240 # Main process/checking methods, arranged roughly from largest scope to smallest scope. 241 ### 242 243 def process(self): 244 """Check the stream (file, string) created by the streammaker supplied to the constructor. 245 246 This is the top-level method for checking a spec file. 247 """ 248 self.logger.info("processing file %s", self.filename) 249 250 # File content checks - performed line-by-line 251 with self.stream_maker.make_stream() as f: 252 # Iterate through lines, calling processLine on each. 253 for lineIndex, line in enumerate(f): 254 trimmedLine = line.rstrip() 255 self.lines.append(trimmedLine) 256 self.processLine(lineIndex + 1, trimmedLine) 257 258 # End of file checks follow: 259 260 # Check "state" at end of file: should have blocks closed. 261 if self.prev_line_ref_page_tag: 262 self.error(MessageId.REFPAGE_BLOCK, 263 "Reference page tag seen, but block not opened before end of file.", 264 context=self.storeMessageContext(match=None)) 265 266 if self.block_stack: 267 locations = (x.context for x in self.block_stack) 268 formatted_locations = ['{} opened at {}'.format(x.delimiter, self.getBriefLocation(x.context)) 269 for x in self.block_stack] 270 self.logger.warning("Unclosed blocks: %s", 271 ', '.join(formatted_locations)) 272 273 self.error(MessageId.UNCLOSED_BLOCK, 274 ["Reached end of page, with these unclosed blocks remaining:"] + 275 formatted_locations, 276 context=self.storeMessageContext(match=None), 277 see_also=locations) 278 279 # Check that every include of an /api/ file in the protos or structs category 280 # had a matching /validity/ include 281 for entity, includeContext in self.fs_api_includes.items(): 282 if not self.checker.entity_db.entityHasValidity(entity): 283 continue 284 285 if entity in self.missing_validity_suppressions: 286 continue 287 288 if entity not in self.validity_includes: 289 self.warning(MessageId.MISSING_VALIDITY_INCLUDE, 290 ['Saw /api/ include for {}, but no matching /validity/ include'.format(entity), 291 'Expected a line with ' + regenerateIncludeFromMatch(includeContext.match, 'validity')], 292 context=includeContext) 293 294 # Check that we never include a /validity/ file 295 # without a matching /api/ include 296 for entity, includeContext in self.validity_includes.items(): 297 if entity not in self.fs_api_includes: 298 self.error(MessageId.MISSING_API_INCLUDE, 299 ['Saw /validity/ include for {}, but no matching /api/ include'.format(entity), 300 'Expected a line with ' + regenerateIncludeFromMatch(includeContext.match, 'api')], 301 context=includeContext) 302 303 if not self.numDiagnostics(): 304 # no problems, exit quietly 305 return 306 307 print('\nFor file {}:'.format(self.filename)) 308 309 self.printMessageCounts() 310 numFixes = len(self.fixes) 311 if numFixes > 0: 312 fixes = ', '.join(('{} -> {}'.format(search, replace) 313 for search, replace in self.fixes)) 314 315 print('{} unique auto-fix {} recorded: {}'.format(numFixes, 316 _pluralize('pattern', numFixes), fixes)) 317 318 def processLine(self, lineNum, line): 319 """Check the contents of a single line from a file. 320 321 Eventually populates self.match, self.entity, self.macro, 322 before calling processMatch. 323 """ 324 self.lineNum = lineNum 325 self.line = line 326 self.match = None 327 self.entity = None 328 self.macro = None 329 330 self.logger.debug("processing line %d", lineNum) 331 332 if self.processPossibleBlockDelimiter(): 333 # This is a block delimiter - proceed to next line. 334 # Block-type-specific stuff goes in processBlockOpen and processBlockClosed. 335 return 336 337 if self.in_code_block: 338 # We do no processing in a code block. 339 return 340 341 ### 342 # Detect if the previous line was [open,...] starting a refpage 343 # but this line isn't -- 344 # If the line is some other block delimiter, 345 # the related code in self.processPossibleBlockDelimiter() 346 # would have handled it. 347 # (because execution would never get to here for that line) 348 if self.prev_line_ref_page_tag: 349 self.handleExpectedRefpageBlock() 350 351 ### 352 # Detect headings 353 if line.startswith('=='): 354 # Headings cause us to clear our pname_context 355 self.pname_data = None 356 357 command = self.heading_command_re.match(line) 358 if command: 359 data = self.checker.findEntity(command) 360 if data: 361 self.pname_data = data 362 return 363 364 ### 365 # Detect [open, lines for manpages 366 if line.startswith('[open,'): 367 self.checkRefPage() 368 return 369 370 ### 371 # Skip comments 372 if line.lstrip().startswith('//'): 373 return 374 375 ### 376 # Skip ifdef/endif 377 if SKIP_LINE.match(line): 378 return 379 380 ### 381 # Detect include:::....[] lines 382 match = INCLUDE.match(line) 383 if match: 384 self.match = match 385 entity = match.group('entity_name') 386 387 data = self.checker.findEntity(entity) 388 if not data: 389 self.error(MessageId.UNKNOWN_INCLUDE, 390 'Saw include for {}, but that entity is unknown.'.format(entity)) 391 self.pname_data = None 392 return 393 394 self.pname_data = data 395 396 if match.group('generated_type') == 'api': 397 self.recordInclude(self.checker.apiIncludes) 398 399 # Set mentions to None. The first time we see something like `* pname:paramHere`, 400 # we will set it to an empty set 401 self.pname_mentions[entity] = None 402 403 if match.group('category') in CATEGORIES_WITH_VALIDITY: 404 self.fs_api_includes[entity] = self.storeMessageContext() 405 406 if entity in self.validity_includes: 407 name_and_line = toNameAndLine( 408 self.validity_includes[entity], root_path=self.checker.root_path) 409 self.error(MessageId.API_VALIDITY_ORDER, 410 ['/api/ include found for {} after a corresponding /validity/ include'.format(entity), 411 'Validity include located at {}'.format(name_and_line)]) 412 413 elif match.group('generated_type') == 'validity': 414 self.recordInclude(self.checker.validityIncludes) 415 self.validity_includes[entity] = self.storeMessageContext() 416 417 if entity not in self.pname_mentions: 418 self.error(MessageId.API_VALIDITY_ORDER, 419 '/validity/ include found for {} without a preceding /api/ include'.format(entity)) 420 return 421 422 if self.pname_mentions[entity]: 423 # Got a validity include and we have seen at least one * pname: line 424 # since we got the API include 425 # so we can warn if we haven't seen a reference to every 426 # parameter/member. 427 members = self.checker.getMemberNames(entity) 428 missing = [member for member in members 429 if member not in self.pname_mentions[entity]] 430 if missing: 431 self.error(MessageId.UNDOCUMENTED_MEMBER, 432 ['Validity include found for {}, but not all members/params apparently documented'.format(entity), 433 'Members/params not mentioned with pname: {}'.format(', '.join(missing))]) 434 435 # If we found an include line, we're done with this line. 436 return 437 438 if self.pname_data is not None and '* pname:' in line: 439 context_entity = self.pname_data.entity 440 if self.pname_mentions[context_entity] is None: 441 # First time seeting * pname: after an api include, prepare the set that 442 # tracks 443 self.pname_mentions[context_entity] = set() 444 445 ### 446 # Detect [[Entity]] anchors 447 for match in ANCHOR.finditer(line): 448 entity = match.group('entity_name') 449 if self.checker.findEntity(entity): 450 # We found an anchor with the same name as an entity: 451 # treat it (mostly) like an API include 452 self.match = match 453 self.recordInclude(self.checker.apiIncludes, 454 generated_type='api (manual anchor)') 455 456 ### 457 # Detect :: without pname 458 for match in MEMBER_REFERENCE.finditer(line): 459 if not match.group('member_macro'): 460 self.match = match 461 # Got :: but not followed by pname 462 463 search = match.group() 464 replacement = match.group( 465 'first_part') + '::pname:' + match.group('second_part') 466 self.error(MessageId.MEMBER_PNAME_MISSING, 467 'Found a function parameter or struct member reference with :: but missing pname:', 468 group='double_colons', 469 replacement='::pname:', 470 fix=(search, replacement)) 471 472 # check pname here because it won't come up in normal iteration below 473 # because of the missing macro 474 self.entity = match.group('entity_name') 475 self.checkPname(match.group('scope')) 476 477 ### 478 # Look for things that seem like a missing macro. 479 for match in self.suspected_missing_macro_re.finditer(line): 480 if OPEN_LINK.match(line, endpos=match.start()): 481 # this is in a link, skip it. 482 continue 483 if CLOSE_LINK.match(line[match.end():]): 484 # this is in a link, skip it. 485 continue 486 487 entity = match.group('entity_name') 488 self.match = match 489 self.entity = entity 490 data = self.checker.findEntity(entity) 491 if data: 492 493 if data.category == EXTENSION_CATEGORY: 494 # Ah, this is an extension 495 self.warning(MessageId.EXTENSION, "Seems like this is an extension name that was not linked.", 496 group='entity_name', replacement=self.makeExtensionLink()) 497 else: 498 self.warning(MessageId.MISSING_MACRO, 499 ['Seems like a "{}" macro was omitted for this reference to a known entity in category "{}".'.format(data.macro, data.category), 500 'Wrap in ` ` to silence this if you do not want a verified macro here.'], 501 group='entity_name', 502 replacement=self.makeMacroMarkup(data.macro)) 503 else: 504 505 dataArray = self.checker.findEntityCaseInsensitive(entity) 506 # We might have found the goof... 507 508 if dataArray: 509 if len(dataArray) == 1: 510 # Yep, found the goof: 511 # incorrect macro and entity capitalization 512 data = dataArray[0] 513 if data.category == EXTENSION_CATEGORY: 514 # Ah, this is an extension 515 self.warning(MessageId.EXTENSION, 516 "Seems like this is an extension name that was not linked.", 517 group='entity_name', replacement=self.makeExtensionLink(data.entity)) 518 else: 519 self.warning(MessageId.MISSING_MACRO, 520 'Seems like a macro was omitted for this reference to a known entity in category "{}", found by searching case-insensitively.'.format( 521 data.category), 522 replacement=self.makeMacroMarkup(data=data)) 523 524 else: 525 # Ugh, more than one resolution 526 527 self.warning(MessageId.MISSING_MACRO, 528 ['Seems like a macro was omitted for this reference to a known entity, found by searching case-insensitively.', 529 'More than one apparent match.'], 530 group='entity_name', see_also=dataArray[:]) 531 532 ### 533 # Main operations: detect markup macros 534 for match in self.checker.macro_re.finditer(line): 535 self.match = match 536 self.macro = match.group('macro') 537 self.entity = match.group('entity_name') 538 self.subscript = match.group('subscript') 539 self.processMatch() 540 541 def processPossibleBlockDelimiter(self): 542 """Look at the current line, and if it's a delimiter, update the block stack. 543 544 Calls self.processBlockDelimiter() as required. 545 546 Returns True if a delimiter was processed, False otherwise. 547 """ 548 line = self.line 549 new_block_type = BlockType.lineToBlockType(line) 550 if not new_block_type: 551 return False 552 553 ### 554 # Detect if the previous line was [open,...] starting a refpage 555 # but this line is some block delimiter other than -- 556 # Must do this here because if we get a different block open instead of the one we want, 557 # the order of block opening will be wrong. 558 if new_block_type != BlockType.REF_PAGE_LIKE and self.prev_line_ref_page_tag: 559 self.handleExpectedRefpageBlock() 560 561 # Delegate to the main process for delimiters. 562 self.processBlockDelimiter(line, new_block_type) 563 564 return True 565 566 def processBlockDelimiter(self, line, new_block_type, context=None): 567 """Update the block stack based on the current or supplied line. 568 569 Calls self.processBlockOpen() or self.processBlockClosed() as required. 570 571 Called by self.processPossibleBlockDelimiter() both in normal operation, as well as 572 when "faking" a ref page block open. 573 574 Returns BlockProcessResult. 575 """ 576 if not context: 577 context = self.storeMessageContext() 578 579 location = self.getBriefLocation(context) 580 581 top = self.getInnermostBlockEntry() 582 top_delim = self.getInnermostBlockDelimiter() 583 if top_delim == line: 584 self.processBlockClosed() 585 return 586 587 if top and top.block_type == new_block_type: 588 # Same block type, but not matching - might be an error? 589 # TODO maybe create a diagnostic here? 590 self.logger.warning( 591 "processPossibleBlockDelimiter: %s: Matched delimiter type %s, but did not exactly match current delim %s to top of stack %s, may be a typo?", 592 location, new_block_type, line, top_delim) 593 594 # Empty stack, or top doesn't match us. 595 self.processBlockOpen(new_block_type, delimiter=line) 596 597 def processBlockOpen(self, block_type, context=None, delimiter=None): 598 """Do any block-type-specific processing and push the new block. 599 600 Must call self.pushBlock(). 601 May be overridden (carefully) or extended. 602 603 Called by self.processBlockDelimiter(). 604 """ 605 if block_type == BlockType.REF_PAGE_LIKE: 606 if self.prev_line_ref_page_tag: 607 if self.current_ref_page: 608 refpage = self.current_ref_page 609 else: 610 refpage = '?refpage-with-invalid-tag?' 611 612 self.logger.info( 613 'processBlockOpen: Opening refpage for %s', refpage) 614 # Opening of refpage block "consumes" the preceding ref 615 # page context 616 self.prev_line_ref_page_tag = None 617 self.pushBlock(block_type, refpage=refpage, 618 context=context, delimiter=delimiter) 619 self.in_ref_page = True 620 return 621 622 if block_type == BlockType.CODE: 623 self.in_code_block = True 624 625 self.pushBlock(block_type, context=context, delimiter=delimiter) 626 627 def processBlockClosed(self): 628 """Do any block-type-specific processing and pop the top block. 629 630 Must call self.popBlock(). 631 May be overridden (carefully) or extended. 632 633 Called by self.processPossibleBlockDelimiter(). 634 """ 635 old_top = self.popBlock() 636 637 if old_top.block_type == BlockType.CODE: 638 self.in_code_block = False 639 640 elif old_top.block_type == BlockType.REF_PAGE_LIKE and old_top.refpage: 641 self.logger.info( 642 'processBlockClosed: Closing refpage for %s', old_top.refpage) 643 # leaving a ref page so reset associated state. 644 self.current_ref_page = None 645 self.prev_line_ref_page_tag = None 646 self.in_ref_page = False 647 648 def processMatch(self): 649 """Process a match of the macro:entity regex for correctness.""" 650 match = self.match 651 entity = self.entity 652 macro = self.macro 653 654 ### 655 # Track entities that we're actually linking to. 656 ### 657 if self.checker.entity_db.isLinkedMacro(macro): 658 self.checker.addLinkToEntity(entity, self.storeMessageContext()) 659 660 ### 661 # Link everything that should be, and nothing that shouldn't be 662 ### 663 if self.checkRecognizedEntity(): 664 # if this returns true, 665 # then there is no need to do the remaining checks on this match 666 return 667 668 ### 669 # Non-existent macros 670 if macro in NON_EXISTENT_MACROS: 671 self.error(MessageId.BAD_MACRO, '{} is not a macro provided in the specification, despite resembling other macros.'.format( 672 macro), group='macro') 673 674 ### 675 # Wildcards (or leading underscore, or square brackets) 676 # if and only if a 'text' macro 677 self.checkText() 678 679 # Do some validation of pname references. 680 if macro == 'pname': 681 # See if there's an immediately-preceding entity 682 preceding = self.line[:match.start()] 683 scope = PRECEDING_MEMBER_REFERENCE.search(preceding) 684 if scope: 685 # Yes there is, check it out. 686 self.checkPname(scope.group('entity_name')) 687 elif self.current_ref_page is not None: 688 # No, but there is a current ref page: very reliable 689 self.checkPnameImpliedContext(self.current_ref_page) 690 elif self.pname_data is not None: 691 # No, but there is a pname_context - better than nothing. 692 self.checkPnameImpliedContext(self.pname_data) 693 else: 694 # no, and no existing context we can imply: 695 # can't check this. 696 pass 697 698 def checkRecognizedEntity(self): 699 """Check the current macro:entity match to see if it is recognized. 700 701 Returns True if there is no need to perform further checks on this match. 702 703 Helps avoid duplicate warnings/errors: typically each macro should have at most 704 one of this class of errors. 705 """ 706 entity = self.entity 707 macro = self.macro 708 if self.checker.findMacroAndEntity(macro, entity) is not None: 709 # We know this macro-entity combo 710 return True 711 712 # We don't know this macro-entity combo. 713 possibleCats = self.checker.entity_db.getCategoriesForMacro(macro) 714 if possibleCats is None: 715 possibleCats = ['???'] 716 msg = ['Definition of link target {} with macro {} (used for {} {}) does not exist.'.format( 717 entity, 718 macro, 719 _pluralize('category', len(possibleCats)), 720 ', '.join(possibleCats))] 721 722 data = self.checker.findEntity(entity) 723 if data: 724 # We found the goof: incorrect macro 725 msg.append('Apparently matching entity in category {} found.'.format( 726 data.category)) 727 self.handleWrongMacro(msg, data) 728 return True 729 730 see_also = [] 731 dataArray = self.checker.findEntityCaseInsensitive(entity) 732 if dataArray: 733 # We might have found the goof... 734 735 if len(dataArray) == 1: 736 # Yep, found the goof: 737 # incorrect macro and entity capitalization 738 data = dataArray[0] 739 msg.append('Apparently matching entity in category {} found by searching case-insensitively.'.format( 740 data.category)) 741 self.handleWrongMacro(msg, data) 742 return True 743 else: 744 # Ugh, more than one resolution 745 msg.append( 746 'More than one apparent match found by searching case-insensitively, cannot auto-fix.') 747 see_also = dataArray[:] 748 749 # OK, so we don't recognize this entity (and couldn't auto-fix it). 750 751 if self.checker.entity_db.shouldBeRecognized(macro, entity): 752 # We should know the target - it's a link macro, 753 # or there's some reason the entity DB thinks we should know it. 754 if self.checker.likelyRecognizedEntity(entity): 755 # Should be linked and it matches our pattern, 756 # so probably not wrong macro. 757 # Human brains required. 758 if not self.checkText(): 759 self.error(MessageId.BAD_ENTITY, msg + ['Might be a misspelling, or, less likely, the wrong macro.'], 760 see_also=see_also) 761 else: 762 # Doesn't match our pattern, 763 # so probably should be name instead of link. 764 newMacro = macro[0] + 'name' 765 if self.checker.entity_db.isValidMacro(newMacro): 766 self.error(MessageId.BAD_ENTITY, msg + 767 ['Entity name does not fit the pattern for this API, which would mean it should be a "name" macro instead of a "link" macro'], 768 group='macro', replacement=newMacro, fix=self.makeFix(newMacro=newMacro), see_also=see_also) 769 else: 770 self.error(MessageId.BAD_ENTITY, msg + 771 ['Entity name does not fit the pattern for this API, which would mean it should be a "name" macro instead of a "link" macro', 772 'However, {} is not a known macro so cannot auto-fix.'.format(newMacro)], see_also=see_also) 773 774 elif macro == 'ename': 775 # TODO This might be an ambiguity in the style guide - ename might be a known enumerant value, 776 # or it might be an enumerant value in an external library, etc. that we don't know about - so 777 # hard to check this. 778 if self.checker.likelyRecognizedEntity(entity): 779 if not self.checkText(): 780 self.warning(MessageId.BAD_ENUMERANT, msg + 781 ['Unrecognized ename:{} that we would expect to recognize since it fits the pattern for this API.'.format(entity)], see_also=see_also) 782 else: 783 # This is fine: 784 # it doesn't need to be recognized since it's not linked. 785 pass 786 # Don't skip other tests. 787 return False 788 789 def checkText(self): 790 """Evaluate the usage (or non-usage) of a *text macro. 791 792 Wildcards (or leading or trailing underscore, or square brackets with 793 nothing or a placeholder) if and only if a 'text' macro. 794 795 Called by checkRecognizedEntity() when appropriate. 796 """ 797 macro = self.macro 798 entity = self.entity 799 shouldBeText = shouldEntityBeText(entity, self.subscript) 800 if shouldBeText and not self.macro.endswith( 801 'text') and not self.macro == 'code': 802 newMacro = macro[0] + 'text' 803 if self.checker.entity_db.getCategoriesForMacro(newMacro): 804 self.error(MessageId.MISSING_TEXT, 805 ['Asterisk/leading or trailing underscore/bracket found - macro should end with "text:", probably {}:'.format(newMacro), 806 AUTO_FIX_STRING], 807 group='macro', replacement=newMacro, fix=self.makeFix(newMacro=newMacro)) 808 else: 809 self.error(MessageId.MISSING_TEXT, 810 ['Asterisk/leading or trailing underscore/bracket found, so macro should end with "text:".', 811 'However {}: is not a known macro so cannot auto-fix.'.format(newMacro)], 812 group='macro') 813 return True 814 elif macro.endswith('text') and not shouldBeText: 815 msg = [ 816 "No asterisk/leading or trailing underscore/bracket in the entity, so this might be a mistaken use of the 'text' macro {}:".format(macro)] 817 data = self.checker.findEntity(entity) 818 if data: 819 # We found the goof: incorrect macro 820 msg.append('Apparently matching entity in category {} found.'.format( 821 data.category)) 822 msg.append(AUTO_FIX_STRING) 823 replacement = self.makeFix(data=data) 824 if data.category == EXTENSION_CATEGORY: 825 self.error(MessageId.EXTENSION, msg, 826 replacement=replacement, fix=replacement) 827 else: 828 self.error(MessageId.WRONG_MACRO, msg, 829 group='macro', replacement=data.macro, fix=replacement) 830 else: 831 if self.checker.likelyRecognizedEntity(entity): 832 # This is a use of *text: for something that fits the pattern but isn't in the spec. 833 # This is OK. 834 return False 835 msg.append('Entity not found in spec, either.') 836 if macro[0] != 'e': 837 # Only suggest a macro if we aren't in elink/ename/etext, 838 # since ename and elink are not related in an equivalent way 839 # to the relationship between flink and fname. 840 newMacro = macro[0] + 'name' 841 if self.checker.entity_db.getCategoriesForMacro(newMacro): 842 msg.append( 843 'Consider if {}: might be the correct macro to use here.'.format(newMacro)) 844 else: 845 msg.append( 846 'Cannot suggest a new macro because {}: is not a known macro.'.format(newMacro)) 847 self.warning(MessageId.MISUSED_TEXT, msg) 848 return True 849 return False 850 851 def checkPnameImpliedContext(self, pname_context): 852 """Handle pname: macros not immediately preceded by something like flink:entity or slink:entity. 853 854 Also records pname: mentions of members/parameters for completeness checking in doc blocks. 855 856 Contains call to self.checkPname(). 857 Called by self.processMatch() 858 """ 859 self.checkPname(pname_context.entity) 860 if pname_context.entity in self.pname_mentions and \ 861 self.pname_mentions[pname_context.entity] is not None: 862 # Record this mention, 863 # in case we're in the documentation block. 864 self.pname_mentions[pname_context.entity].add(self.entity) 865 866 def checkPname(self, pname_context): 867 """Check the current match (as a pname: usage) with the given entity as its 'pname context', if possible. 868 869 e.g. slink:foo::pname:bar, pname_context would be 'foo', while self.entity would be 'bar', etc. 870 871 Called by self.processLine(), self.processMatch(), as well as from self.checkPnameImpliedContext(). 872 """ 873 if '*' in pname_context: 874 # This context has a placeholder, can't verify it. 875 return 876 877 entity = self.entity 878 879 context_data = self.checker.findEntity(pname_context) 880 members = self.checker.getMemberNames(pname_context) 881 882 if context_data and not members: 883 # This is a recognized parent entity that doesn't have detectable member names, 884 # skip validation 885 # TODO: Annotate parameters of function pointer types with <name> 886 # and <param>? 887 return 888 if not members: 889 self.warning(MessageId.UNRECOGNIZED_CONTEXT, 890 'pname context entity was un-recognized {}'.format(pname_context)) 891 return 892 893 if entity not in members: 894 self.warning(MessageId.UNKNOWN_MEMBER, ["Could not find member/param named '{}' in {}".format(entity, pname_context), 895 'Known {} mamber/param names are: {}'.format( 896 pname_context, ', '.join(members))], group='entity_name') 897 898 def checkIncludeRefPageRelation(self, entity, generated_type): 899 """Identify if our current ref page (or lack thereof) is appropriate for an include just recorded. 900 901 Called by self.recordInclude(). 902 """ 903 if not self.in_ref_page: 904 # Not in a ref page block: This probably means this entity needs a 905 # ref-page block added. 906 self.handleIncludeMissingRefPage(entity, generated_type) 907 return 908 909 if not isinstance(self.current_ref_page, EntityData): 910 # This isn't a fully-valid ref page, so can't check the includes any better. 911 return 912 913 ref_page_entity = self.current_ref_page.entity 914 if ref_page_entity not in self.refpage_includes: 915 self.refpage_includes[ref_page_entity] = set() 916 expected_ref_page_entity = self.computeExpectedRefPageFromInclude( 917 entity) 918 self.refpage_includes[ref_page_entity].add((generated_type, entity)) 919 920 if ref_page_entity == expected_ref_page_entity: 921 # OK, this is a total match. 922 pass 923 elif self.checker.entity_db.areAliases(expected_ref_page_entity, ref_page_entity): 924 # This appears to be a promoted synonym which is OK. 925 pass 926 else: 927 # OK, we are in a ref page block that doesn't match 928 self.handleIncludeMismatchRefPage(entity, generated_type) 929 930 def checkRefPage(self): 931 """Check if the current line (a refpage tag) meets requirements. 932 933 Called by self.processLine(). 934 """ 935 line = self.line 936 937 # Should always be found 938 self.match = BRACKETS.match(line) 939 940 data = None 941 directory = None 942 if self.in_ref_page: 943 msg = ["Found reference page markup, but we are already in a refpage block.", 944 "The block before the first message of this type is most likely not closed.", ] 945 # Fake-close the previous ref page, if it's trivial to do so. 946 if self.getInnermostBlockEntry().block_type == BlockType.REF_PAGE_LIKE: 947 msg.append( 948 "Pretending that there was a line with `--` immediately above to close that ref page, for more readable messages.") 949 self.processBlockDelimiter( 950 REF_PAGE_LIKE_BLOCK_DELIM, BlockType.REF_PAGE_LIKE) 951 else: 952 msg.append( 953 "Ref page wasn't the last block opened, so not pretending to auto-close it for more readable messages.") 954 955 self.error(MessageId.REFPAGE_BLOCK, msg) 956 957 attribs = parseRefPageAttribs(line) 958 959 unknown_attribs = set(attribs.keys()).difference( 960 VALID_REF_PAGE_ATTRIBS) 961 if unknown_attribs: 962 self.error(MessageId.REFPAGE_UNKNOWN_ATTRIB, 963 "Found unknown attrib(s) in reference page markup: " + ','.join(unknown_attribs)) 964 965 # Required field: refpage='xrValidEntityHere' 966 if Attrib.REFPAGE.value in attribs: 967 attrib = attribs[Attrib.REFPAGE.value] 968 text = attrib.value 969 self.entity = text 970 971 context = self.storeMessageContext( 972 group='value', match=attrib.match) 973 if self.checker.seenRefPage(text): 974 self.error(MessageId.REFPAGE_DUPLICATE, 975 ["Found reference page markup when we already saw refpage='{}' elsewhere.".format( 976 text), 977 "This (or the other mention) may be a copy-paste error."], 978 context=context) 979 self.checker.addRefPage(text) 980 981 # Skip entity check if it's a spir-v built in 982 type = '' 983 if Attrib.TYPE.value in attribs: 984 type = attribs[Attrib.TYPE.value].value 985 986 if type != 'builtins' and type != 'spirv': 987 data = self.checker.findEntity(text) 988 self.current_ref_page = data 989 if data: 990 # OK, this is a known entity that we're seeing a refpage for. 991 directory = data.directory 992 else: 993 # TODO suggest fixes here if applicable 994 self.error(MessageId.REFPAGE_NAME, 995 [ "Found reference page markup, but refpage='{}' type='{}' does not refer to a recognized entity".format( 996 text, type), 997 'If this is intentional, add the entity to EXTRA_DEFINES or EXTRA_REFPAGES in check_spec_links.py.' ], 998 context=context) 999 1000 else: 1001 self.error(MessageId.REFPAGE_TAG, 1002 "Found apparent reference page markup, but missing refpage='...'", 1003 group=None) 1004 1005 # Required field: desc='preferably non-empty' 1006 if Attrib.DESC.value in attribs: 1007 attrib = attribs[Attrib.DESC.value] 1008 text = attrib.value 1009 if not text: 1010 context = self.storeMessageContext( 1011 group=None, match=attrib.match) 1012 self.warning(MessageId.REFPAGE_MISSING_DESC, 1013 "Found reference page markup, but desc='' is empty", 1014 context=context) 1015 else: 1016 self.error(MessageId.REFPAGE_TAG, 1017 "Found apparent reference page markup, but missing desc='...'", 1018 group=None) 1019 1020 # Required field: type='protos' for example 1021 # (used by genRef.py to compute the macro to use) 1022 if Attrib.TYPE.value in attribs: 1023 attrib = attribs[Attrib.TYPE.value] 1024 text = attrib.value 1025 if directory and not text == directory: 1026 context = self.storeMessageContext( 1027 group='value', match=attrib.match) 1028 self.error(MessageId.REFPAGE_TYPE, 1029 "Found reference page markup, but type='{}' is not the expected value '{}'".format( 1030 text, directory), 1031 context=context) 1032 else: 1033 self.error(MessageId.REFPAGE_TAG, 1034 "Found apparent reference page markup, but missing type='...'", 1035 group=None) 1036 1037 # Optional field: alias='spaceDelimited validEntities' 1038 # Currently does nothing. Could modify checkRefPageXrefs to also 1039 # check alias= attribute value 1040 # if Attrib.ALIAS.value in attribs: 1041 # # This field is optional 1042 # self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) 1043 1044 # Optional field: xrefs='spaceDelimited validEntities' 1045 if Attrib.XREFS.value in attribs: 1046 # This field is optional 1047 self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) 1048 self.prev_line_ref_page_tag = self.storeMessageContext() 1049 1050 def checkRefPageXrefs(self, xrefs_attrib): 1051 """Check all cross-refs indicated in an xrefs attribute for a ref page. 1052 1053 Called by self.checkRefPage(). 1054 1055 Argument: 1056 xrefs_attrib -- A match of REF_PAGE_ATTRIB where the group 'key' is 'xrefs'. 1057 """ 1058 text = xrefs_attrib.value 1059 context = self.storeMessageContext( 1060 group='value', match=xrefs_attrib.match) 1061 1062 def splitRefs(s): 1063 """Split the string on whitespace, into individual references.""" 1064 return s.split() # [x for x in s.split() if x] 1065 1066 def remakeRefs(refs): 1067 """Re-create a xrefs string from something list-shaped.""" 1068 return ' '.join(refs) 1069 1070 refs = splitRefs(text) 1071 1072 # Pre-checking if messages are enabled, so that we can correctly determine 1073 # the current string following any auto-fixes: 1074 # the fixes for messages directly in this method would interact, 1075 # and thus must be in the order specified here. 1076 1077 if self.messageEnabled(MessageId.REFPAGE_XREFS_COMMA) and ',' in text: 1078 old_text = text 1079 # Re-split after replacing commas. 1080 refs = splitRefs(text.replace(',', ' ')) 1081 # Re-create the space-delimited text. 1082 text = remakeRefs(refs) 1083 self.error(MessageId.REFPAGE_XREFS_COMMA, 1084 "Found reference page markup, with an unexpected comma in the (space-delimited) xrefs attribute", 1085 context=context, 1086 replacement=text, 1087 fix=(old_text, text)) 1088 1089 # We could conditionally perform this creation, but the code complexity would increase substantially, 1090 # for presumably minimal runtime improvement. 1091 unique_refs = OrderedDict.fromkeys(refs) 1092 if self.messageEnabled(MessageId.REFPAGE_XREF_DUPE) and len(unique_refs) != len(refs): 1093 # TODO is it safe to auto-fix here? 1094 old_text = text 1095 text = remakeRefs(unique_refs.keys()) 1096 self.warning(MessageId.REFPAGE_XREF_DUPE, 1097 ["Reference page for {} contains at least one duplicate in its cross-references.".format( 1098 self.entity), 1099 "Look carefully to see if this is a copy and paste error and should be changed to a different but related entity:", 1100 "auto-fix simply removes the duplicate."], 1101 context=context, 1102 replacement=text, 1103 fix=(old_text, text)) 1104 1105 if self.messageEnabled(MessageId.REFPAGE_SELF_XREF) and self.entity and self.entity in unique_refs: 1106 # Not modifying unique_refs here because that would accidentally affect the whitespace auto-fix. 1107 new_text = remakeRefs( 1108 [x for x in unique_refs.keys() if x != self.entity]) 1109 1110 # DON'T AUTOFIX HERE because these are likely copy-paste between related entities: 1111 # e.g. a Create function and the associated CreateInfo struct. 1112 self.warning(MessageId.REFPAGE_SELF_XREF, 1113 ["Reference page for {} included itself in its cross-references.".format(self.entity), 1114 "This is typically a copy and paste error, and the dupe should likely be changed to a different but related entity.", 1115 "Not auto-fixing for this reason."], 1116 context=context, 1117 replacement=new_text,) 1118 1119 # We didn't have another reason to replace the whole attribute value, 1120 # so let's make sure it doesn't have any extra spaces 1121 if self.messageEnabled(MessageId.REFPAGE_WHITESPACE) and xrefs_attrib.value == text: 1122 old_text = text 1123 text = remakeRefs(unique_refs.keys()) 1124 if old_text != text: 1125 self.warning(MessageId.REFPAGE_WHITESPACE, 1126 ["Cross-references for reference page for {} had non-minimal whitespace,".format(self.entity), 1127 "and no other enabled message has re-constructed this value already."], 1128 context=context, 1129 replacement=text, 1130 fix=(old_text, text)) 1131 1132 for entity in unique_refs.keys(): 1133 self.checkRefPageXref(entity, context) 1134 1135 def checkRefPageXref(self, referenced_entity, line_context): 1136 """Check a single cross-reference entry for a refpage. 1137 1138 Called by self.checkRefPageXrefs(). 1139 1140 Arguments: 1141 referenced_entity -- The individual entity under consideration from the xrefs='...' string. 1142 line_context -- A MessageContext referring to the entire line. 1143 """ 1144 data = self.checker.findEntity(referenced_entity) 1145 if data: 1146 # This is OK 1147 return 1148 context = line_context 1149 match = re.search(r'\b{}\b'.format(referenced_entity), self.line) 1150 if match: 1151 context = self.storeMessageContext( 1152 group=None, match=match) 1153 msg = ["Found reference page markup, with an unrecognized entity listed: {}".format( 1154 referenced_entity)] 1155 1156 see_also = None 1157 dataArray = self.checker.findEntityCaseInsensitive( 1158 referenced_entity) 1159 1160 if dataArray: 1161 # We might have found the goof... 1162 1163 if len(dataArray) == 1: 1164 # Yep, found the goof - incorrect entity capitalization 1165 data = dataArray[0] 1166 new_entity = data.entity 1167 self.error(MessageId.REFPAGE_XREFS, msg + [ 1168 'Apparently matching entity in category {} found by searching case-insensitively.'.format( 1169 data.category), 1170 AUTO_FIX_STRING], 1171 replacement=new_entity, 1172 fix=(referenced_entity, new_entity), 1173 context=context) 1174 return 1175 1176 # Ugh, more than one resolution 1177 msg.append( 1178 'More than one apparent match found by searching case-insensitively, cannot auto-fix.') 1179 see_also = dataArray[:] 1180 else: 1181 # Probably not just a typo 1182 msg.append( 1183 'If this is intentional, add the entity to EXTRA_DEFINES or EXTRA_REFPAGES in check_spec_links.py.') 1184 1185 # Multiple or no resolutions found 1186 self.error(MessageId.REFPAGE_XREFS, 1187 msg, 1188 see_also=see_also, 1189 context=context) 1190 1191 ### 1192 # Message-related methods. 1193 ### 1194 1195 def warning(self, message_id, messageLines, context=None, group=None, 1196 replacement=None, fix=None, see_also=None, frame=None): 1197 """Log a warning for the file, if the message ID is enabled. 1198 1199 Wrapper around self.diag() that automatically sets severity as well as frame. 1200 1201 Arguments: 1202 message_id -- A MessageId value. 1203 messageLines -- A string or list of strings containing a human-readable error description. 1204 1205 Optional, named arguments: 1206 context -- A MessageContext. If None, will be constructed from self.match and group. 1207 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1208 If needed and is None, self.group is used instead. 1209 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1210 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1211 (or can't easily phrase a regex) to do it automatically. 1212 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1213 see_also -- An optional array of other MessageContext locations relevant to this message. 1214 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1215 If None, will assume it is the direct caller of self.warning(). 1216 """ 1217 if not frame: 1218 frame = currentframe().f_back 1219 self.diag(MessageType.WARNING, message_id, messageLines, group=group, 1220 replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) 1221 1222 def error(self, message_id, messageLines, group=None, replacement=None, 1223 context=None, fix=None, see_also=None, frame=None): 1224 """Log an error for the file, if the message ID is enabled. 1225 1226 Wrapper around self.diag() that automatically sets severity as well as frame. 1227 1228 Arguments: 1229 message_id -- A MessageId value. 1230 messageLines -- A string or list of strings containing a human-readable error description. 1231 1232 Optional, named arguments: 1233 context -- A MessageContext. If None, will be constructed from self.match and group. 1234 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1235 If needed and is None, self.group is used instead. 1236 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1237 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1238 (or can't easily phrase a regex) to do it automatically. 1239 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1240 see_also -- An optional array of other MessageContext locations relevant to this message. 1241 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1242 If None, will assume it is the direct caller of self.error(). 1243 """ 1244 if not frame: 1245 frame = currentframe().f_back 1246 self.diag(MessageType.ERROR, message_id, messageLines, group=group, 1247 replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) 1248 1249 def diag(self, severity, message_id, messageLines, context=None, group=None, 1250 replacement=None, fix=None, see_also=None, frame=None): 1251 """Log a diagnostic for the file, if the message ID is enabled. 1252 1253 Also records the auto-fix, if applicable. 1254 1255 Arguments: 1256 severity -- A MessageType value. 1257 message_id -- A MessageId value. 1258 messageLines -- A string or list of strings containing a human-readable error description. 1259 1260 Optional, named arguments: 1261 context -- A MessageContext. If None, will be constructed from self.match and group. 1262 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1263 If needed and is None, self.group is used instead. 1264 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1265 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1266 (or can't easily phrase a regex) to do it automatically. 1267 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1268 see_also -- An optional array of other MessageContext locations relevant to this message. 1269 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1270 If None, will assume it is the direct caller of self.diag(). 1271 """ 1272 if not self.messageEnabled(message_id): 1273 self.logger.debug( 1274 'Discarding a %s message because it is disabled.', message_id) 1275 return 1276 1277 if isinstance(messageLines, str): 1278 messageLines = [messageLines] 1279 1280 self.logger.info('Recording a %s message: %s', 1281 message_id, ' '.join(messageLines)) 1282 1283 # Ensure all auto-fixes are marked as such. 1284 if fix is not None and AUTO_FIX_STRING not in messageLines: 1285 messageLines.append(AUTO_FIX_STRING) 1286 1287 if not frame: 1288 frame = currentframe().f_back 1289 if context is None: 1290 message = Message(message_id=message_id, 1291 message_type=severity, 1292 message=messageLines, 1293 context=self.storeMessageContext(group=group), 1294 replacement=replacement, 1295 see_also=see_also, 1296 fix=fix, 1297 frame=frame) 1298 else: 1299 message = Message(message_id=message_id, 1300 message_type=severity, 1301 message=messageLines, 1302 context=context, 1303 replacement=replacement, 1304 see_also=see_also, 1305 fix=fix, 1306 frame=frame) 1307 if fix is not None: 1308 self.fixes.add(fix) 1309 self.messages.append(message) 1310 1311 def messageEnabled(self, message_id): 1312 """Return true if the given message ID is enabled.""" 1313 return message_id in self.enabled_messages 1314 1315 ### 1316 # Accessors for externally-interesting information 1317 1318 def numDiagnostics(self): 1319 """Count the total number of diagnostics (errors or warnings) for this file.""" 1320 return len(self.messages) 1321 1322 def numErrors(self): 1323 """Count the total number of errors for this file.""" 1324 return self.numMessagesOfType(MessageType.ERROR) 1325 1326 def numMessagesOfType(self, message_type): 1327 """Count the number of messages of a particular type (severity).""" 1328 return len( 1329 [msg for msg in self.messages if msg.message_type == message_type]) 1330 1331 def hasFixes(self): 1332 """Return True if any messages included auto-fix patterns.""" 1333 return len(self.fixes) > 0 1334 1335 ### 1336 # Assorted internal methods. 1337 def printMessageCounts(self): 1338 """Print a simple count of each MessageType of diagnostics.""" 1339 for message_type in [MessageType.ERROR, MessageType.WARNING]: 1340 count = self.numMessagesOfType(message_type) 1341 if count > 0: 1342 print('{num} {mtype}{s} generated.'.format( 1343 num=count, mtype=message_type, s=_s_suffix(count))) 1344 1345 def dumpInternals(self): 1346 """Dump internal variables to screen, for debugging.""" 1347 print('self.lineNum: ', self.lineNum) 1348 print('self.line:', self.line) 1349 print('self.prev_line_ref_page_tag: ', self.prev_line_ref_page_tag) 1350 print('self.current_ref_page:', self.current_ref_page) 1351 1352 def getMissingValiditySuppressions(self): 1353 """Return an enumerable of entity names that we shouldn't warn about missing validity. 1354 1355 May override. 1356 """ 1357 return [] 1358 1359 def recordInclude(self, include_dict, generated_type=None): 1360 """Store the current line as being the location of an include directive or equivalent. 1361 1362 Reports duplicate include errors, as well as include/ref-page mismatch or missing ref-page, 1363 by calling self.checkIncludeRefPageRelation() for "actual" includes (where generated_type is None). 1364 1365 Arguments: 1366 include_dict -- The include dictionary to update: one of self.apiIncludes or self.validityIncludes. 1367 generated_type -- The type of include (e.g. 'api', 'valid', etc). By default, extracted from self.match. 1368 """ 1369 entity = self.match.group('entity_name') 1370 if generated_type is None: 1371 generated_type = self.match.group('generated_type') 1372 1373 # Only checking the ref page relation if it's retrieved from regex. 1374 # Otherwise it might be a manual anchor recorded as an include, 1375 # etc. 1376 self.checkIncludeRefPageRelation(entity, generated_type) 1377 1378 if entity in include_dict: 1379 self.error(MessageId.DUPLICATE_INCLUDE, 1380 "Included {} docs for {} when they were already included.".format(generated_type, 1381 entity), see_also=include_dict[entity]) 1382 include_dict[entity].append(self.storeMessageContext()) 1383 else: 1384 include_dict[entity] = [self.storeMessageContext()] 1385 1386 def getInnermostBlockEntry(self): 1387 """Get the BlockEntry for the top block delim on our stack.""" 1388 if not self.block_stack: 1389 return None 1390 return self.block_stack[-1] 1391 1392 def getInnermostBlockDelimiter(self): 1393 """Get the delimiter for the top block on our stack.""" 1394 top = self.getInnermostBlockEntry() 1395 if not top: 1396 return None 1397 return top.delimiter 1398 1399 def pushBlock(self, block_type, refpage=None, context=None, delimiter=None): 1400 """Push a new entry on the block stack.""" 1401 if not delimiter: 1402 self.logger.info("pushBlock: not given delimiter") 1403 delimiter = self.line 1404 if not context: 1405 context = self.storeMessageContext() 1406 1407 old_top_delim = self.getInnermostBlockDelimiter() 1408 1409 self.block_stack.append(BlockEntry( 1410 delimiter=delimiter, 1411 context=context, 1412 refpage=refpage, 1413 block_type=block_type)) 1414 1415 location = self.getBriefLocation(context) 1416 self.logger.info( 1417 "pushBlock: %s: Pushed %s delimiter %s, previous top was %s, now %d elements on the stack", 1418 location, block_type.value, delimiter, old_top_delim, len(self.block_stack)) 1419 1420 self.dumpBlockStack() 1421 1422 def popBlock(self): 1423 """Pop and return the top entry from the block stack.""" 1424 old_top = self.block_stack.pop() 1425 location = self.getBriefLocation(old_top.context) 1426 self.logger.info( 1427 "popBlock: %s: popping %s delimiter %s, now %d elements on the stack", 1428 location, old_top.block_type.value, old_top.delimiter, len(self.block_stack)) 1429 1430 self.dumpBlockStack() 1431 1432 return old_top 1433 1434 def dumpBlockStack(self): 1435 self.logger.debug('Block stack, top first:') 1436 for distFromTop, x in enumerate(reversed(self.block_stack)): 1437 self.logger.debug(' - block_stack[%d]: Line %d: "%s" refpage=%s', 1438 -1 - distFromTop, 1439 x.context.lineNum, x.delimiter, x.refpage) 1440 1441 def getBriefLocation(self, context): 1442 """Format a context briefly - omitting the filename if it has newlines in it.""" 1443 if '\n' in context.filename: 1444 return 'input string line {}'.format(context.lineNum) 1445 return '{}:{}'.format( 1446 context.filename, context.lineNum) 1447 1448 ### 1449 # Handlers for a variety of diagnostic-meriting conditions 1450 # 1451 # Split out for clarity and for allowing fine-grained override on a per-project basis. 1452 ### 1453 1454 def handleIncludeMissingRefPage(self, entity, generated_type): 1455 """Report a message about an include outside of a ref-page block.""" 1456 msg = ["Found {} include for {} outside of a reference page block.".format(generated_type, entity), 1457 "This is probably a missing reference page block."] 1458 refpage = self.computeExpectedRefPageFromInclude(entity) 1459 data = self.checker.findEntity(refpage) 1460 if data: 1461 msg.append('Expected ref page block might start like:') 1462 msg.append(self.makeRefPageTag(refpage, data=data)) 1463 else: 1464 msg.append( 1465 "But, expected ref page entity name {} isn't recognized...".format(refpage)) 1466 self.warning(MessageId.REFPAGE_MISSING, msg) 1467 1468 def handleIncludeMismatchRefPage(self, entity, generated_type): 1469 """Report a message about an include not matching its containing ref-page block.""" 1470 self.warning(MessageId.REFPAGE_MISMATCH, "Found {} include for {}, inside the reference page block of {}".format( 1471 generated_type, entity, self.current_ref_page.entity)) 1472 1473 def handleWrongMacro(self, msg, data): 1474 """Report an appropriate message when we found that the macro used is incorrect. 1475 1476 May be overridden depending on each API's behavior regarding macro misuse: 1477 e.g. in some cases, it may be considered a MessageId.LEGACY warning rather than 1478 a MessageId.WRONG_MACRO or MessageId.EXTENSION. 1479 """ 1480 message_type = MessageType.WARNING 1481 message_id = MessageId.WRONG_MACRO 1482 group = 'macro' 1483 1484 if data.category == EXTENSION_CATEGORY: 1485 # Ah, this is an extension 1486 msg.append( 1487 'This is apparently an extension name, which should be marked up as a link.') 1488 message_id = MessageId.EXTENSION 1489 group = None # replace the whole thing 1490 else: 1491 # Non-extension, we found the macro though. 1492 message_type = MessageType.ERROR 1493 msg.append(AUTO_FIX_STRING) 1494 self.diag(message_type, message_id, msg, 1495 group=group, replacement=self.makeMacroMarkup(data=data), fix=self.makeFix(data=data)) 1496 1497 def handleExpectedRefpageBlock(self): 1498 """Handle expecting to see -- to start a refpage block, but not seeing that at all.""" 1499 self.error(MessageId.REFPAGE_BLOCK, 1500 ["Expected, but did not find, a line containing only -- following a reference page tag,", 1501 "Pretending to insert one, for more readable messages."], 1502 see_also=[self.prev_line_ref_page_tag]) 1503 # Fake "in ref page" regardless, to avoid spurious extra errors. 1504 self.processBlockDelimiter('--', BlockType.REF_PAGE_LIKE, 1505 context=self.prev_line_ref_page_tag) 1506 1507 ### 1508 # Construct related values (typically named tuples) based on object state and supplied arguments. 1509 # 1510 # Results are typically supplied to another method call. 1511 ### 1512 1513 def storeMessageContext(self, group=None, match=None): 1514 """Create message context from corresponding instance variables. 1515 1516 Arguments: 1517 group -- The regex group name, if any, identifying the part of the match to highlight. 1518 match -- The regex match. If None, will use self.match. 1519 """ 1520 if match is None: 1521 match = self.match 1522 return MessageContext(filename=self.filename, 1523 lineNum=self.lineNum, 1524 line=self.line, 1525 match=match, 1526 group=group) 1527 1528 def makeFix(self, newMacro=None, newEntity=None, data=None): 1529 """Construct a fix pair for replacing the old macro:entity with new. 1530 1531 Wrapper around self.makeSearch() and self.makeMacroMarkup(). 1532 """ 1533 return (self.makeSearch(), self.makeMacroMarkup( 1534 newMacro, newEntity, data)) 1535 1536 def makeSearch(self): 1537 """Construct the string self.macro:self.entity, for use in the old text part of a fix pair.""" 1538 return '{}:{}'.format(self.macro, self.entity) 1539 1540 def makeMacroMarkup(self, newMacro=None, newEntity=None, data=None): 1541 """Construct appropriate markup for referring to an entity. 1542 1543 Typically constructs macro:entity, but can construct `<<EXTENSION_NAME>>` if the supplied 1544 entity is identified as an extension. 1545 1546 Arguments: 1547 newMacro -- The macro to use. Defaults to data.macro (if available), otherwise self.macro. 1548 newEntity -- The entity to use. Defaults to data.entity (if available), otherwise self.entity. 1549 data -- An EntityData value corresponding to this entity. If not provided, will be looked up by newEntity. 1550 """ 1551 if not newEntity: 1552 if data: 1553 newEntity = data.entity 1554 else: 1555 newEntity = self.entity 1556 if not newMacro: 1557 if data: 1558 newMacro = data.macro 1559 else: 1560 newMacro = self.macro 1561 if not data: 1562 data = self.checker.findEntity(newEntity) 1563 if data and data.category == EXTENSION_CATEGORY: 1564 return self.makeExtensionLink(newEntity) 1565 return '{}:{}'.format(newMacro, newEntity) 1566 1567 def makeExtensionLink(self, newEntity=None): 1568 """Create a correctly-formatted link to an extension. 1569 1570 Result takes the form `<<EXTENSION_NAME>>`. 1571 1572 Argument: 1573 newEntity -- The extension name to link to. Defaults to self.entity. 1574 """ 1575 if not newEntity: 1576 newEntity = self.entity 1577 return '`<<{}>>`'.format(newEntity) 1578 1579 def computeExpectedRefPageFromInclude(self, entity): 1580 """Compute the expected ref page entity based on an include entity name.""" 1581 # No-op in general. 1582 return entity 1583 1584 def makeRefPageTag(self, entity, data=None, 1585 ref_type=None, desc='', xrefs=None): 1586 """Construct a ref page tag string from attribute values.""" 1587 if ref_type is None and data is not None: 1588 ref_type = data.directory 1589 if ref_type is None: 1590 ref_type = "????" 1591 return "[open,refpage='{}',type='{}',desc='{}',xrefs='{}']".format( 1592 entity, ref_type, desc, ' '.join(xrefs or [])) 1593