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