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