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': 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 context=context) 998 999 else: 1000 self.error(MessageId.REFPAGE_TAG, 1001 "Found apparent reference page markup, but missing refpage='...'", 1002 group=None) 1003 1004 # Required field: desc='preferably non-empty' 1005 if Attrib.DESC.value in attribs: 1006 attrib = attribs[Attrib.DESC.value] 1007 text = attrib.value 1008 if not text: 1009 context = self.storeMessageContext( 1010 group=None, match=attrib.match) 1011 self.warning(MessageId.REFPAGE_MISSING_DESC, 1012 "Found reference page markup, but desc='' is empty", 1013 context=context) 1014 else: 1015 self.error(MessageId.REFPAGE_TAG, 1016 "Found apparent reference page markup, but missing desc='...'", 1017 group=None) 1018 1019 # Required field: type='protos' for example 1020 # (used by genRef.py to compute the macro to use) 1021 if Attrib.TYPE.value in attribs: 1022 attrib = attribs[Attrib.TYPE.value] 1023 text = attrib.value 1024 if directory and not text == directory: 1025 context = self.storeMessageContext( 1026 group='value', match=attrib.match) 1027 self.error(MessageId.REFPAGE_TYPE, 1028 "Found reference page markup, but type='{}' is not the expected value '{}'".format( 1029 text, directory), 1030 context=context) 1031 else: 1032 self.error(MessageId.REFPAGE_TAG, 1033 "Found apparent reference page markup, but missing type='...'", 1034 group=None) 1035 1036 # Optional field: alias='spaceDelimited validEntities' 1037 # Currently does nothing. Could modify checkRefPageXrefs to also 1038 # check alias= attribute value 1039 # if Attrib.ALIAS.value in attribs: 1040 # # This field is optional 1041 # self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) 1042 1043 # Optional field: xrefs='spaceDelimited validEntities' 1044 if Attrib.XREFS.value in attribs: 1045 # This field is optional 1046 self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) 1047 self.prev_line_ref_page_tag = self.storeMessageContext() 1048 1049 def checkRefPageXrefs(self, xrefs_attrib): 1050 """Check all cross-refs indicated in an xrefs attribute for a ref page. 1051 1052 Called by self.checkRefPage(). 1053 1054 Argument: 1055 xrefs_attrib -- A match of REF_PAGE_ATTRIB where the group 'key' is 'xrefs'. 1056 """ 1057 text = xrefs_attrib.value 1058 context = self.storeMessageContext( 1059 group='value', match=xrefs_attrib.match) 1060 1061 def splitRefs(s): 1062 """Split the string on whitespace, into individual references.""" 1063 return s.split() # [x for x in s.split() if x] 1064 1065 def remakeRefs(refs): 1066 """Re-create a xrefs string from something list-shaped.""" 1067 return ' '.join(refs) 1068 1069 refs = splitRefs(text) 1070 1071 # Pre-checking if messages are enabled, so that we can correctly determine 1072 # the current string following any auto-fixes: 1073 # the fixes for messages directly in this method would interact, 1074 # and thus must be in the order specified here. 1075 1076 if self.messageEnabled(MessageId.REFPAGE_XREFS_COMMA) and ',' in text: 1077 old_text = text 1078 # Re-split after replacing commas. 1079 refs = splitRefs(text.replace(',', ' ')) 1080 # Re-create the space-delimited text. 1081 text = remakeRefs(refs) 1082 self.error(MessageId.REFPAGE_XREFS_COMMA, 1083 "Found reference page markup, with an unexpected comma in the (space-delimited) xrefs attribute", 1084 context=context, 1085 replacement=text, 1086 fix=(old_text, text)) 1087 1088 # We could conditionally perform this creation, but the code complexity would increase substantially, 1089 # for presumably minimal runtime improvement. 1090 unique_refs = OrderedDict.fromkeys(refs) 1091 if self.messageEnabled(MessageId.REFPAGE_XREF_DUPE) and len(unique_refs) != len(refs): 1092 # TODO is it safe to auto-fix here? 1093 old_text = text 1094 text = remakeRefs(unique_refs.keys()) 1095 self.warning(MessageId.REFPAGE_XREF_DUPE, 1096 ["Reference page for {} contains at least one duplicate in its cross-references.".format( 1097 self.entity), 1098 "Look carefully to see if this is a copy and paste error and should be changed to a different but related entity:", 1099 "auto-fix simply removes the duplicate."], 1100 context=context, 1101 replacement=text, 1102 fix=(old_text, text)) 1103 1104 if self.messageEnabled(MessageId.REFPAGE_SELF_XREF) and self.entity and self.entity in unique_refs: 1105 # Not modifying unique_refs here because that would accidentally affect the whitespace auto-fix. 1106 new_text = remakeRefs( 1107 [x for x in unique_refs.keys() if x != self.entity]) 1108 1109 # DON'T AUTOFIX HERE because these are likely copy-paste between related entities: 1110 # e.g. a Create function and the associated CreateInfo struct. 1111 self.warning(MessageId.REFPAGE_SELF_XREF, 1112 ["Reference page for {} included itself in its cross-references.".format(self.entity), 1113 "This is typically a copy and paste error, and the dupe should likely be changed to a different but related entity.", 1114 "Not auto-fixing for this reason."], 1115 context=context, 1116 replacement=new_text,) 1117 1118 # We didn't have another reason to replace the whole attribute value, 1119 # so let's make sure it doesn't have any extra spaces 1120 if self.messageEnabled(MessageId.REFPAGE_WHITESPACE) and xrefs_attrib.value == text: 1121 old_text = text 1122 text = remakeRefs(unique_refs.keys()) 1123 if old_text != text: 1124 self.warning(MessageId.REFPAGE_WHITESPACE, 1125 ["Cross-references for reference page for {} had non-minimal whitespace,".format(self.entity), 1126 "and no other enabled message has re-constructed this value already."], 1127 context=context, 1128 replacement=text, 1129 fix=(old_text, text)) 1130 1131 for entity in unique_refs.keys(): 1132 self.checkRefPageXref(entity, context) 1133 1134 def checkRefPageXref(self, referenced_entity, line_context): 1135 """Check a single cross-reference entry for a refpage. 1136 1137 Called by self.checkRefPageXrefs(). 1138 1139 Arguments: 1140 referenced_entity -- The individual entity under consideration from the xrefs='...' string. 1141 line_context -- A MessageContext referring to the entire line. 1142 """ 1143 data = self.checker.findEntity(referenced_entity) 1144 if data: 1145 # This is OK 1146 return 1147 context = line_context 1148 match = re.search(r'\b{}\b'.format(referenced_entity), self.line) 1149 if match: 1150 context = self.storeMessageContext( 1151 group=None, match=match) 1152 msg = ["Found reference page markup, with an unrecognized entity listed: {}".format( 1153 referenced_entity)] 1154 1155 see_also = None 1156 dataArray = self.checker.findEntityCaseInsensitive( 1157 referenced_entity) 1158 1159 if dataArray: 1160 # We might have found the goof... 1161 1162 if len(dataArray) == 1: 1163 # Yep, found the goof - incorrect entity capitalization 1164 data = dataArray[0] 1165 new_entity = data.entity 1166 self.error(MessageId.REFPAGE_XREFS, msg + [ 1167 'Apparently matching entity in category {} found by searching case-insensitively.'.format( 1168 data.category), 1169 AUTO_FIX_STRING], 1170 replacement=new_entity, 1171 fix=(referenced_entity, new_entity), 1172 context=context) 1173 return 1174 1175 # Ugh, more than one resolution 1176 msg.append( 1177 'More than one apparent match found by searching case-insensitively, cannot auto-fix.') 1178 see_also = dataArray[:] 1179 1180 # Multiple or no resolutions found 1181 self.error(MessageId.REFPAGE_XREFS, 1182 msg, 1183 see_also=see_also, 1184 context=context) 1185 1186 ### 1187 # Message-related methods. 1188 ### 1189 1190 def warning(self, message_id, messageLines, context=None, group=None, 1191 replacement=None, fix=None, see_also=None, frame=None): 1192 """Log a warning for the file, if the message ID is enabled. 1193 1194 Wrapper around self.diag() that automatically sets severity as well as frame. 1195 1196 Arguments: 1197 message_id -- A MessageId value. 1198 messageLines -- A string or list of strings containing a human-readable error description. 1199 1200 Optional, named arguments: 1201 context -- A MessageContext. If None, will be constructed from self.match and group. 1202 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1203 If needed and is None, self.group is used instead. 1204 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1205 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1206 (or can't easily phrase a regex) to do it automatically. 1207 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1208 see_also -- An optional array of other MessageContext locations relevant to this message. 1209 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1210 If None, will assume it is the direct caller of self.warning(). 1211 """ 1212 if not frame: 1213 frame = currentframe().f_back 1214 self.diag(MessageType.WARNING, message_id, messageLines, group=group, 1215 replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) 1216 1217 def error(self, message_id, messageLines, group=None, replacement=None, 1218 context=None, fix=None, see_also=None, frame=None): 1219 """Log an error for the file, if the message ID is enabled. 1220 1221 Wrapper around self.diag() that automatically sets severity as well as frame. 1222 1223 Arguments: 1224 message_id -- A MessageId value. 1225 messageLines -- A string or list of strings containing a human-readable error description. 1226 1227 Optional, named arguments: 1228 context -- A MessageContext. If None, will be constructed from self.match and group. 1229 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1230 If needed and is None, self.group is used instead. 1231 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1232 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1233 (or can't easily phrase a regex) to do it automatically. 1234 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1235 see_also -- An optional array of other MessageContext locations relevant to this message. 1236 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1237 If None, will assume it is the direct caller of self.error(). 1238 """ 1239 if not frame: 1240 frame = currentframe().f_back 1241 self.diag(MessageType.ERROR, message_id, messageLines, group=group, 1242 replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) 1243 1244 def diag(self, severity, message_id, messageLines, context=None, group=None, 1245 replacement=None, fix=None, see_also=None, frame=None): 1246 """Log a diagnostic for the file, if the message ID is enabled. 1247 1248 Also records the auto-fix, if applicable. 1249 1250 Arguments: 1251 severity -- A MessageType value. 1252 message_id -- A MessageId value. 1253 messageLines -- A string or list of strings containing a human-readable error description. 1254 1255 Optional, named arguments: 1256 context -- A MessageContext. If None, will be constructed from self.match and group. 1257 group -- The name of the regex group in self.match that contains the problem. Only used if context is None. 1258 If needed and is None, self.group is used instead. 1259 replacement -- The string, if any, that should be suggested as a replacement for the group in question. 1260 Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough 1261 (or can't easily phrase a regex) to do it automatically. 1262 fix -- A (old text, new text) pair if this error is auto-fixable safely. 1263 see_also -- An optional array of other MessageContext locations relevant to this message. 1264 frame -- The 'inspect' stack frame corresponding to the location that raised this message. 1265 If None, will assume it is the direct caller of self.diag(). 1266 """ 1267 if not self.messageEnabled(message_id): 1268 self.logger.debug( 1269 'Discarding a %s message because it is disabled.', message_id) 1270 return 1271 1272 if isinstance(messageLines, str): 1273 messageLines = [messageLines] 1274 1275 self.logger.info('Recording a %s message: %s', 1276 message_id, ' '.join(messageLines)) 1277 1278 # Ensure all auto-fixes are marked as such. 1279 if fix is not None and AUTO_FIX_STRING not in messageLines: 1280 messageLines.append(AUTO_FIX_STRING) 1281 1282 if not frame: 1283 frame = currentframe().f_back 1284 if context is None: 1285 message = Message(message_id=message_id, 1286 message_type=severity, 1287 message=messageLines, 1288 context=self.storeMessageContext(group=group), 1289 replacement=replacement, 1290 see_also=see_also, 1291 fix=fix, 1292 frame=frame) 1293 else: 1294 message = Message(message_id=message_id, 1295 message_type=severity, 1296 message=messageLines, 1297 context=context, 1298 replacement=replacement, 1299 see_also=see_also, 1300 fix=fix, 1301 frame=frame) 1302 if fix is not None: 1303 self.fixes.add(fix) 1304 self.messages.append(message) 1305 1306 def messageEnabled(self, message_id): 1307 """Return true if the given message ID is enabled.""" 1308 return message_id in self.enabled_messages 1309 1310 ### 1311 # Accessors for externally-interesting information 1312 1313 def numDiagnostics(self): 1314 """Count the total number of diagnostics (errors or warnings) for this file.""" 1315 return len(self.messages) 1316 1317 def numErrors(self): 1318 """Count the total number of errors for this file.""" 1319 return self.numMessagesOfType(MessageType.ERROR) 1320 1321 def numMessagesOfType(self, message_type): 1322 """Count the number of messages of a particular type (severity).""" 1323 return len( 1324 [msg for msg in self.messages if msg.message_type == message_type]) 1325 1326 def hasFixes(self): 1327 """Return True if any messages included auto-fix patterns.""" 1328 return len(self.fixes) > 0 1329 1330 ### 1331 # Assorted internal methods. 1332 def printMessageCounts(self): 1333 """Print a simple count of each MessageType of diagnostics.""" 1334 for message_type in [MessageType.ERROR, MessageType.WARNING]: 1335 count = self.numMessagesOfType(message_type) 1336 if count > 0: 1337 print('{num} {mtype}{s} generated.'.format( 1338 num=count, mtype=message_type, s=_s_suffix(count))) 1339 1340 def dumpInternals(self): 1341 """Dump internal variables to screen, for debugging.""" 1342 print('self.lineNum: ', self.lineNum) 1343 print('self.line:', self.line) 1344 print('self.prev_line_ref_page_tag: ', self.prev_line_ref_page_tag) 1345 print('self.current_ref_page:', self.current_ref_page) 1346 1347 def getMissingValiditySuppressions(self): 1348 """Return an enumerable of entity names that we shouldn't warn about missing validity. 1349 1350 May override. 1351 """ 1352 return [] 1353 1354 def recordInclude(self, include_dict, generated_type=None): 1355 """Store the current line as being the location of an include directive or equivalent. 1356 1357 Reports duplicate include errors, as well as include/ref-page mismatch or missing ref-page, 1358 by calling self.checkIncludeRefPageRelation() for "actual" includes (where generated_type is None). 1359 1360 Arguments: 1361 include_dict -- The include dictionary to update: one of self.apiIncludes or self.validityIncludes. 1362 generated_type -- The type of include (e.g. 'api', 'valid', etc). By default, extracted from self.match. 1363 """ 1364 entity = self.match.group('entity_name') 1365 if generated_type is None: 1366 generated_type = self.match.group('generated_type') 1367 1368 # Only checking the ref page relation if it's retrieved from regex. 1369 # Otherwise it might be a manual anchor recorded as an include, 1370 # etc. 1371 self.checkIncludeRefPageRelation(entity, generated_type) 1372 1373 if entity in include_dict: 1374 self.error(MessageId.DUPLICATE_INCLUDE, 1375 "Included {} docs for {} when they were already included.".format(generated_type, 1376 entity), see_also=include_dict[entity]) 1377 include_dict[entity].append(self.storeMessageContext()) 1378 else: 1379 include_dict[entity] = [self.storeMessageContext()] 1380 1381 def getInnermostBlockEntry(self): 1382 """Get the BlockEntry for the top block delim on our stack.""" 1383 if not self.block_stack: 1384 return None 1385 return self.block_stack[-1] 1386 1387 def getInnermostBlockDelimiter(self): 1388 """Get the delimiter for the top block on our stack.""" 1389 top = self.getInnermostBlockEntry() 1390 if not top: 1391 return None 1392 return top.delimiter 1393 1394 def pushBlock(self, block_type, refpage=None, context=None, delimiter=None): 1395 """Push a new entry on the block stack.""" 1396 if not delimiter: 1397 self.logger.info("pushBlock: not given delimiter") 1398 delimiter = self.line 1399 if not context: 1400 context = self.storeMessageContext() 1401 1402 old_top_delim = self.getInnermostBlockDelimiter() 1403 1404 self.block_stack.append(BlockEntry( 1405 delimiter=delimiter, 1406 context=context, 1407 refpage=refpage, 1408 block_type=block_type)) 1409 1410 location = self.getBriefLocation(context) 1411 self.logger.info( 1412 "pushBlock: %s: Pushed %s delimiter %s, previous top was %s, now %d elements on the stack", 1413 location, block_type.value, delimiter, old_top_delim, len(self.block_stack)) 1414 1415 self.dumpBlockStack() 1416 1417 def popBlock(self): 1418 """Pop and return the top entry from the block stack.""" 1419 old_top = self.block_stack.pop() 1420 location = self.getBriefLocation(old_top.context) 1421 self.logger.info( 1422 "popBlock: %s: popping %s delimiter %s, now %d elements on the stack", 1423 location, old_top.block_type.value, old_top.delimiter, len(self.block_stack)) 1424 1425 self.dumpBlockStack() 1426 1427 return old_top 1428 1429 def dumpBlockStack(self): 1430 self.logger.debug('Block stack, top first:') 1431 for distFromTop, x in enumerate(reversed(self.block_stack)): 1432 self.logger.debug(' - block_stack[%d]: Line %d: "%s" refpage=%s', 1433 -1 - distFromTop, 1434 x.context.lineNum, x.delimiter, x.refpage) 1435 1436 def getBriefLocation(self, context): 1437 """Format a context briefly - omitting the filename if it has newlines in it.""" 1438 if '\n' in context.filename: 1439 return 'input string line {}'.format(context.lineNum) 1440 return '{}:{}'.format( 1441 context.filename, context.lineNum) 1442 1443 ### 1444 # Handlers for a variety of diagnostic-meriting conditions 1445 # 1446 # Split out for clarity and for allowing fine-grained override on a per-project basis. 1447 ### 1448 1449 def handleIncludeMissingRefPage(self, entity, generated_type): 1450 """Report a message about an include outside of a ref-page block.""" 1451 msg = ["Found {} include for {} outside of a reference page block.".format(generated_type, entity), 1452 "This is probably a missing reference page block."] 1453 refpage = self.computeExpectedRefPageFromInclude(entity) 1454 data = self.checker.findEntity(refpage) 1455 if data: 1456 msg.append('Expected ref page block might start like:') 1457 msg.append(self.makeRefPageTag(refpage, data=data)) 1458 else: 1459 msg.append( 1460 "But, expected ref page entity name {} isn't recognized...".format(refpage)) 1461 self.warning(MessageId.REFPAGE_MISSING, msg) 1462 1463 def handleIncludeMismatchRefPage(self, entity, generated_type): 1464 """Report a message about an include not matching its containing ref-page block.""" 1465 self.warning(MessageId.REFPAGE_MISMATCH, "Found {} include for {}, inside the reference page block of {}".format( 1466 generated_type, entity, self.current_ref_page.entity)) 1467 1468 def handleWrongMacro(self, msg, data): 1469 """Report an appropriate message when we found that the macro used is incorrect. 1470 1471 May be overridden depending on each API's behavior regarding macro misuse: 1472 e.g. in some cases, it may be considered a MessageId.LEGACY warning rather than 1473 a MessageId.WRONG_MACRO or MessageId.EXTENSION. 1474 """ 1475 message_type = MessageType.WARNING 1476 message_id = MessageId.WRONG_MACRO 1477 group = 'macro' 1478 1479 if data.category == EXTENSION_CATEGORY: 1480 # Ah, this is an extension 1481 msg.append( 1482 'This is apparently an extension name, which should be marked up as a link.') 1483 message_id = MessageId.EXTENSION 1484 group = None # replace the whole thing 1485 else: 1486 # Non-extension, we found the macro though. 1487 message_type = MessageType.ERROR 1488 msg.append(AUTO_FIX_STRING) 1489 self.diag(message_type, message_id, msg, 1490 group=group, replacement=self.makeMacroMarkup(data=data), fix=self.makeFix(data=data)) 1491 1492 def handleExpectedRefpageBlock(self): 1493 """Handle expecting to see -- to start a refpage block, but not seeing that at all.""" 1494 self.error(MessageId.REFPAGE_BLOCK, 1495 ["Expected, but did not find, a line containing only -- following a reference page tag,", 1496 "Pretending to insert one, for more readable messages."], 1497 see_also=[self.prev_line_ref_page_tag]) 1498 # Fake "in ref page" regardless, to avoid spurious extra errors. 1499 self.processBlockDelimiter('--', BlockType.REF_PAGE_LIKE, 1500 context=self.prev_line_ref_page_tag) 1501 1502 ### 1503 # Construct related values (typically named tuples) based on object state and supplied arguments. 1504 # 1505 # Results are typically supplied to another method call. 1506 ### 1507 1508 def storeMessageContext(self, group=None, match=None): 1509 """Create message context from corresponding instance variables. 1510 1511 Arguments: 1512 group -- The regex group name, if any, identifying the part of the match to highlight. 1513 match -- The regex match. If None, will use self.match. 1514 """ 1515 if match is None: 1516 match = self.match 1517 return MessageContext(filename=self.filename, 1518 lineNum=self.lineNum, 1519 line=self.line, 1520 match=match, 1521 group=group) 1522 1523 def makeFix(self, newMacro=None, newEntity=None, data=None): 1524 """Construct a fix pair for replacing the old macro:entity with new. 1525 1526 Wrapper around self.makeSearch() and self.makeMacroMarkup(). 1527 """ 1528 return (self.makeSearch(), self.makeMacroMarkup( 1529 newMacro, newEntity, data)) 1530 1531 def makeSearch(self): 1532 """Construct the string self.macro:self.entity, for use in the old text part of a fix pair.""" 1533 return '{}:{}'.format(self.macro, self.entity) 1534 1535 def makeMacroMarkup(self, newMacro=None, newEntity=None, data=None): 1536 """Construct appropriate markup for referring to an entity. 1537 1538 Typically constructs macro:entity, but can construct `<<EXTENSION_NAME>>` if the supplied 1539 entity is identified as an extension. 1540 1541 Arguments: 1542 newMacro -- The macro to use. Defaults to data.macro (if available), otherwise self.macro. 1543 newEntity -- The entity to use. Defaults to data.entity (if available), otherwise self.entity. 1544 data -- An EntityData value corresponding to this entity. If not provided, will be looked up by newEntity. 1545 """ 1546 if not newEntity: 1547 if data: 1548 newEntity = data.entity 1549 else: 1550 newEntity = self.entity 1551 if not newMacro: 1552 if data: 1553 newMacro = data.macro 1554 else: 1555 newMacro = self.macro 1556 if not data: 1557 data = self.checker.findEntity(newEntity) 1558 if data and data.category == EXTENSION_CATEGORY: 1559 return self.makeExtensionLink(newEntity) 1560 return '{}:{}'.format(newMacro, newEntity) 1561 1562 def makeExtensionLink(self, newEntity=None): 1563 """Create a correctly-formatted link to an extension. 1564 1565 Result takes the form `<<EXTENSION_NAME>>`. 1566 1567 Argument: 1568 newEntity -- The extension name to link to. Defaults to self.entity. 1569 """ 1570 if not newEntity: 1571 newEntity = self.entity 1572 return '`<<{}>>`'.format(newEntity) 1573 1574 def computeExpectedRefPageFromInclude(self, entity): 1575 """Compute the expected ref page entity based on an include entity name.""" 1576 # No-op in general. 1577 return entity 1578 1579 def makeRefPageTag(self, entity, data=None, 1580 ref_type=None, desc='', xrefs=None): 1581 """Construct a ref page tag string from attribute values.""" 1582 if ref_type is None and data is not None: 1583 ref_type = data.directory 1584 if ref_type is None: 1585 ref_type = "????" 1586 return "[open,refpage='{}',type='{}',desc='{}',xrefs='{}']".format( 1587 entity, ref_type, desc, ' '.join(xrefs or [])) 1588