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