| """Provides MacroCheckerFile, a subclassable type that validates a single file in the spec.""" |
| |
| # Copyright (c) 2018-2019 Collabora, Ltd. |
| # |
| # SPDX-License-Identifier: Apache-2.0 |
| # |
| # Author(s): Ryan Pavlik <ryan.pavlik@collabora.com> |
| |
| import logging |
| import re |
| from collections import OrderedDict, namedtuple |
| from enum import Enum |
| from inspect import currentframe |
| |
| from .shared import (AUTO_FIX_STRING, CATEGORIES_WITH_VALIDITY, |
| EXTENSION_CATEGORY, NON_EXISTENT_MACROS, EntityData, |
| Message, MessageContext, MessageId, MessageType, |
| generateInclude, toNameAndLine) |
| |
| # Code blocks may start and end with any number of ---- |
| CODE_BLOCK_DELIM = '----' |
| |
| # Mostly for ref page blocks, but also used elsewhere? |
| REF_PAGE_LIKE_BLOCK_DELIM = '--' |
| |
| # For insets/blocks like the implicit valid usage |
| # TODO think it must start with this - does it have to be exactly this? |
| BOX_BLOCK_DELIM = '****' |
| |
| |
| INTERNAL_PLACEHOLDER = re.compile( |
| r'(?P<delim>__+)([a-zA-Z]+)(?P=delim)' |
| ) |
| |
| # Matches a generated (api or validity) include line. |
| INCLUDE = re.compile( |
| r'include::(?P<directory_traverse>((../){1,4}|\{(INCS-VAR|generated)\}/)(generated/)?)(?P<generated_type>[\w]+)/(?P<category>\w+)/(?P<entity_name>[^./]+).txt[\[][\]]') |
| |
| # Matches an [[AnchorLikeThis]] |
| ANCHOR = re.compile(r'\[\[(?P<entity_name>[^\]]+)\]\]') |
| |
| # Looks for flink:foo:: or slink::foo:: at the end of string: |
| # used to detect explicit pname context. |
| PRECEDING_MEMBER_REFERENCE = re.compile( |
| r'\b(?P<macro>[fs](text|link)):(?P<entity_name>[\w*]+)::$') |
| |
| # Matches something like slink:foo::pname:bar as well as |
| # the under-marked-up slink:foo::bar. |
| MEMBER_REFERENCE = re.compile( |
| 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' |
| ) |
| |
| # Matches if a string ends while a link is still "open". |
| # (first half of a link being broken across two lines, |
| # or containing our interested area when matched against the text preceding). |
| # Used to skip checking in some places. |
| OPEN_LINK = re.compile( |
| r'.*(?<!`)<<[^>]*$' |
| ) |
| |
| # Matches if a string begins and is followed by a link "close" without a matching open. |
| # (second half of a link being broken across two lines) |
| # Used to skip checking in some places. |
| CLOSE_LINK = re.compile( |
| r'[^<]*>>.*$' |
| ) |
| |
| # Matches if a line should be skipped without further considering. |
| # Matches lines starting with: |
| # - `ifdef:` |
| # - `endif:` |
| # - `todo` (followed by something matching \b, like : or (. capitalization ignored) |
| SKIP_LINE = re.compile( |
| r'^(ifdef:)|(endif:)|([tT][oO][dD][oO]\b).*' |
| ) |
| |
| # Matches the whole inside of a refpage tag. |
| BRACKETS = re.compile(r'\[(?P<tags>.*)\]') |
| |
| # Matches a key='value' pair from a ref page tag. |
| REF_PAGE_ATTRIB = re.compile( |
| r"(?P<key>[a-z]+)='(?P<value>[^'\\]*(?:\\.[^'\\]*)*)'") |
| |
| |
| class Attrib(Enum): |
| """Attributes of a ref page.""" |
| |
| REFPAGE = 'refpage' |
| DESC = 'desc' |
| TYPE = 'type' |
| ALIAS = 'alias' |
| XREFS = 'xrefs' |
| ANCHOR = 'anchor' |
| |
| |
| VALID_REF_PAGE_ATTRIBS = set( |
| (e.value for e in Attrib)) |
| |
| AttribData = namedtuple('AttribData', ['match', 'key', 'value']) |
| |
| |
| def makeAttribFromMatch(match): |
| """Turn a match of REF_PAGE_ATTRIB into an AttribData value.""" |
| return AttribData(match=match, key=match.group( |
| 'key'), value=match.group('value')) |
| |
| |
| def parseRefPageAttribs(line): |
| """Parse a ref page tag into a dictionary of attribute_name: AttribData.""" |
| return {m.group('key'): makeAttribFromMatch(m) |
| for m in REF_PAGE_ATTRIB.finditer(line)} |
| |
| |
| def regenerateIncludeFromMatch(match, generated_type): |
| """Create an include directive from an INCLUDE match and a (new or replacement) generated_type.""" |
| return generateInclude( |
| match.group('directory_traverse'), |
| generated_type, |
| match.group('category'), |
| match.group('entity_name')) |
| |
| |
| BlockEntry = namedtuple( |
| 'BlockEntry', ['delimiter', 'context', 'block_type', 'refpage']) |
| |
| |
| class BlockType(Enum): |
| """Enumeration of the various distinct block types known.""" |
| CODE = 'code' |
| REF_PAGE_LIKE = 'ref-page-like' # with or without a ref page tag before |
| BOX = 'box' |
| |
| @classmethod |
| def lineToBlockType(self, line): |
| """Return a BlockType if the given line is a block delimiter. |
| |
| Returns None otherwise. |
| """ |
| if line == REF_PAGE_LIKE_BLOCK_DELIM: |
| return BlockType.REF_PAGE_LIKE |
| if line.startswith(CODE_BLOCK_DELIM): |
| return BlockType.CODE |
| if line.startswith(BOX_BLOCK_DELIM): |
| return BlockType.BOX |
| |
| return None |
| |
| |
| def _pluralize(word, num): |
| if num == 1: |
| return word |
| if word.endswith('y'): |
| return word[:-1] + 'ies' |
| return word + 's' |
| |
| |
| def _s_suffix(num): |
| """Simplify pluralization.""" |
| if num > 1: |
| return 's' |
| return '' |
| |
| |
| def shouldEntityBeText(entity, subscript): |
| """Determine if an entity name appears to use placeholders, wildcards, etc. and thus merits use of a *text macro. |
| |
| Call with the entity and subscript groups from a match of MacroChecker.macro_re. |
| """ |
| entity_only = entity |
| if subscript: |
| if subscript == '[]' or subscript == '[i]' or subscript.startswith( |
| '[_') or subscript.endswith('_]'): |
| return True |
| entity_only = entity[:-len(subscript)] |
| |
| if ('*' in entity) or entity.startswith('_') or entity_only.endswith('_'): |
| return True |
| |
| if INTERNAL_PLACEHOLDER.search(entity): |
| return True |
| return False |
| |
| |
| class MacroCheckerFile(object): |
| """Object performing processing of a single AsciiDoctor file from a specification. |
| |
| For testing purposes, may also process a string as if it were a file. |
| """ |
| |
| def __init__(self, checker, filename, enabled_messages, stream_maker): |
| """Construct a MacroCheckerFile object. |
| |
| Typically called by MacroChecker.processFile or MacroChecker.processString(). |
| |
| Arguments: |
| checker -- A MacroChecker object. |
| filename -- A string to use in messages to refer to this checker, typically the file name. |
| enabled_messages -- A set() of MessageId values that should be considered "enabled" and thus stored. |
| stream_maker -- An object with a makeStream() method that returns a stream. |
| """ |
| self.checker = checker |
| self.filename = filename |
| self.stream_maker = stream_maker |
| self.enabled_messages = enabled_messages |
| self.missing_validity_suppressions = set( |
| self.getMissingValiditySuppressions()) |
| |
| self.logger = logging.getLogger(__name__) |
| self.logger.addHandler(logging.NullHandler()) |
| |
| self.fixes = set() |
| self.messages = [] |
| |
| self.pname_data = None |
| self.pname_mentions = {} |
| |
| self.refpage_includes = {} |
| |
| self.lines = [] |
| |
| # For both of these: |
| # keys: entity name |
| # values: MessageContext |
| self.fs_api_includes = {} |
| self.validity_includes = {} |
| |
| self.in_code_block = False |
| self.in_ref_page = False |
| self.prev_line_ref_page_tag = None |
| self.current_ref_page = None |
| |
| # Stack of block-starting delimiters. |
| self.block_stack = [] |
| |
| # Regexes that are members because they depend on the name prefix. |
| self.suspected_missing_macro_re = self.checker.suspected_missing_macro_re |
| self.heading_command_re = self.checker.heading_command_re |
| |
| ### |
| # Main process/checking methods, arranged roughly from largest scope to smallest scope. |
| ### |
| |
| def process(self): |
| """Check the stream (file, string) created by the streammaker supplied to the constructor. |
| |
| This is the top-level method for checking a spec file. |
| """ |
| self.logger.info("processing file %s", self.filename) |
| |
| # File content checks - performed line-by-line |
| with self.stream_maker.make_stream() as f: |
| # Iterate through lines, calling processLine on each. |
| for lineIndex, line in enumerate(f): |
| trimmedLine = line.rstrip() |
| self.lines.append(trimmedLine) |
| self.processLine(lineIndex + 1, trimmedLine) |
| |
| # End of file checks follow: |
| |
| # Check "state" at end of file: should have blocks closed. |
| if self.prev_line_ref_page_tag: |
| self.error(MessageId.REFPAGE_BLOCK, |
| "Reference page tag seen, but block not opened before end of file.", |
| context=self.storeMessageContext(match=None)) |
| |
| if self.block_stack: |
| locations = (x.context for x in self.block_stack) |
| formatted_locations = ['{} opened at {}'.format(x.delimiter, self.getBriefLocation(x.context)) |
| for x in self.block_stack] |
| self.logger.warning("Unclosed blocks: %s", |
| ', '.join(formatted_locations)) |
| |
| self.error(MessageId.UNCLOSED_BLOCK, |
| ["Reached end of page, with these unclosed blocks remaining:"] + |
| formatted_locations, |
| context=self.storeMessageContext(match=None), |
| see_also=locations) |
| |
| # Check that every include of an /api/ file in the protos or structs category |
| # had a matching /validity/ include |
| for entity, includeContext in self.fs_api_includes.items(): |
| if not self.checker.entity_db.entityHasValidity(entity): |
| continue |
| |
| if entity in self.missing_validity_suppressions: |
| continue |
| |
| if entity not in self.validity_includes: |
| self.warning(MessageId.MISSING_VALIDITY_INCLUDE, |
| ['Saw /api/ include for {}, but no matching /validity/ include'.format(entity), |
| 'Expected a line with ' + regenerateIncludeFromMatch(includeContext.match, 'validity')], |
| context=includeContext) |
| |
| # Check that we never include a /validity/ file |
| # without a matching /api/ include |
| for entity, includeContext in self.validity_includes.items(): |
| if entity not in self.fs_api_includes: |
| self.error(MessageId.MISSING_API_INCLUDE, |
| ['Saw /validity/ include for {}, but no matching /api/ include'.format(entity), |
| 'Expected a line with ' + regenerateIncludeFromMatch(includeContext.match, 'api')], |
| context=includeContext) |
| |
| if not self.numDiagnostics(): |
| # no problems, exit quietly |
| return |
| |
| print('\nFor file {}:'.format(self.filename)) |
| |
| self.printMessageCounts() |
| numFixes = len(self.fixes) |
| if numFixes > 0: |
| fixes = ', '.join(('{} -> {}'.format(search, replace) |
| for search, replace in self.fixes)) |
| |
| print('{} unique auto-fix {} recorded: {}'.format(numFixes, |
| _pluralize('pattern', numFixes), fixes)) |
| |
| def processLine(self, lineNum, line): |
| """Check the contents of a single line from a file. |
| |
| Eventually populates self.match, self.entity, self.macro, |
| before calling processMatch. |
| """ |
| self.lineNum = lineNum |
| self.line = line |
| self.match = None |
| self.entity = None |
| self.macro = None |
| |
| self.logger.debug("processing line %d", lineNum) |
| |
| if self.processPossibleBlockDelimiter(): |
| # This is a block delimiter - proceed to next line. |
| # Block-type-specific stuff goes in processBlockOpen and processBlockClosed. |
| return |
| |
| if self.in_code_block: |
| # We do no processing in a code block. |
| return |
| |
| ### |
| # Detect if the previous line was [open,...] starting a refpage |
| # but this line isn't -- |
| # If the line is some other block delimiter, |
| # the related code in self.processPossibleBlockDelimiter() |
| # would have handled it. |
| # (because execution would never get to here for that line) |
| if self.prev_line_ref_page_tag: |
| self.handleExpectedRefpageBlock() |
| |
| ### |
| # Detect headings |
| if line.startswith('=='): |
| # Headings cause us to clear our pname_context |
| self.pname_data = None |
| |
| command = self.heading_command_re.match(line) |
| if command: |
| data = self.checker.findEntity(command) |
| if data: |
| self.pname_data = data |
| return |
| |
| ### |
| # Detect [open, lines for manpages |
| if line.startswith('[open,'): |
| self.checkRefPage() |
| return |
| |
| ### |
| # Skip comments |
| if line.lstrip().startswith('//'): |
| return |
| |
| ### |
| # Skip ifdef/endif |
| if SKIP_LINE.match(line): |
| return |
| |
| ### |
| # Detect include:::....[] lines |
| match = INCLUDE.match(line) |
| if match: |
| self.match = match |
| entity = match.group('entity_name') |
| |
| data = self.checker.findEntity(entity) |
| if not data: |
| self.error(MessageId.UNKNOWN_INCLUDE, |
| 'Saw include for {}, but that entity is unknown.'.format(entity)) |
| self.pname_data = None |
| return |
| |
| self.pname_data = data |
| |
| if match.group('generated_type') == 'api': |
| self.recordInclude(self.checker.apiIncludes) |
| |
| # Set mentions to None. The first time we see something like `* pname:paramHere`, |
| # we will set it to an empty set |
| self.pname_mentions[entity] = None |
| |
| if match.group('category') in CATEGORIES_WITH_VALIDITY: |
| self.fs_api_includes[entity] = self.storeMessageContext() |
| |
| if entity in self.validity_includes: |
| name_and_line = toNameAndLine( |
| self.validity_includes[entity], root_path=self.checker.root_path) |
| self.error(MessageId.API_VALIDITY_ORDER, |
| ['/api/ include found for {} after a corresponding /validity/ include'.format(entity), |
| 'Validity include located at {}'.format(name_and_line)]) |
| |
| elif match.group('generated_type') == 'validity': |
| self.recordInclude(self.checker.validityIncludes) |
| self.validity_includes[entity] = self.storeMessageContext() |
| |
| if entity not in self.pname_mentions: |
| self.error(MessageId.API_VALIDITY_ORDER, |
| '/validity/ include found for {} without a preceding /api/ include'.format(entity)) |
| return |
| |
| if self.pname_mentions[entity]: |
| # Got a validity include and we have seen at least one * pname: line |
| # since we got the API include |
| # so we can warn if we haven't seen a reference to every |
| # parameter/member. |
| members = self.checker.getMemberNames(entity) |
| missing = [member for member in members |
| if member not in self.pname_mentions[entity]] |
| if missing: |
| self.error(MessageId.UNDOCUMENTED_MEMBER, |
| ['Validity include found for {}, but not all members/params apparently documented'.format(entity), |
| 'Members/params not mentioned with pname: {}'.format(', '.join(missing))]) |
| |
| # If we found an include line, we're done with this line. |
| return |
| |
| if self.pname_data is not None and '* pname:' in line: |
| context_entity = self.pname_data.entity |
| if self.pname_mentions[context_entity] is None: |
| # First time seeting * pname: after an api include, prepare the set that |
| # tracks |
| self.pname_mentions[context_entity] = set() |
| |
| ### |
| # Detect [[Entity]] anchors |
| for match in ANCHOR.finditer(line): |
| entity = match.group('entity_name') |
| if self.checker.findEntity(entity): |
| # We found an anchor with the same name as an entity: |
| # treat it (mostly) like an API include |
| self.match = match |
| self.recordInclude(self.checker.apiIncludes, |
| generated_type='api (manual anchor)') |
| |
| ### |
| # Detect :: without pname |
| for match in MEMBER_REFERENCE.finditer(line): |
| if not match.group('member_macro'): |
| self.match = match |
| # Got :: but not followed by pname |
| |
| search = match.group() |
| replacement = match.group( |
| 'first_part') + '::pname:' + match.group('second_part') |
| self.error(MessageId.MEMBER_PNAME_MISSING, |
| 'Found a function parameter or struct member reference with :: but missing pname:', |
| group='double_colons', |
| replacement='::pname:', |
| fix=(search, replacement)) |
| |
| # check pname here because it won't come up in normal iteration below |
| # because of the missing macro |
| self.entity = match.group('entity_name') |
| self.checkPname(match.group('scope')) |
| |
| ### |
| # Look for things that seem like a missing macro. |
| for match in self.suspected_missing_macro_re.finditer(line): |
| if OPEN_LINK.match(line, endpos=match.start()): |
| # this is in a link, skip it. |
| continue |
| if CLOSE_LINK.match(line[match.end():]): |
| # this is in a link, skip it. |
| continue |
| |
| entity = match.group('entity_name') |
| self.match = match |
| self.entity = entity |
| data = self.checker.findEntity(entity) |
| if data: |
| |
| if data.category == EXTENSION_CATEGORY: |
| # Ah, this is an extension |
| self.warning(MessageId.EXTENSION, "Seems like this is an extension name that was not linked.", |
| group='entity_name', replacement=self.makeExtensionLink()) |
| else: |
| self.warning(MessageId.MISSING_MACRO, |
| ['Seems like a "{}" macro was omitted for this reference to a known entity in category "{}".'.format(data.macro, data.category), |
| 'Wrap in ` ` to silence this if you do not want a verified macro here.'], |
| group='entity_name', |
| replacement=self.makeMacroMarkup(data.macro)) |
| else: |
| |
| dataArray = self.checker.findEntityCaseInsensitive(entity) |
| # We might have found the goof... |
| |
| if dataArray: |
| if len(dataArray) == 1: |
| # Yep, found the goof: |
| # incorrect macro and entity capitalization |
| data = dataArray[0] |
| if data.category == EXTENSION_CATEGORY: |
| # Ah, this is an extension |
| self.warning(MessageId.EXTENSION, |
| "Seems like this is an extension name that was not linked.", |
| group='entity_name', replacement=self.makeExtensionLink(data.entity)) |
| else: |
| self.warning(MessageId.MISSING_MACRO, |
| 'Seems like a macro was omitted for this reference to a known entity in category "{}", found by searching case-insensitively.'.format( |
| data.category), |
| replacement=self.makeMacroMarkup(data=data)) |
| |
| else: |
| # Ugh, more than one resolution |
| |
| self.warning(MessageId.MISSING_MACRO, |
| ['Seems like a macro was omitted for this reference to a known entity, found by searching case-insensitively.', |
| 'More than one apparent match.'], |
| group='entity_name', see_also=dataArray[:]) |
| |
| ### |
| # Main operations: detect markup macros |
| for match in self.checker.macro_re.finditer(line): |
| self.match = match |
| self.macro = match.group('macro') |
| self.entity = match.group('entity_name') |
| self.subscript = match.group('subscript') |
| self.processMatch() |
| |
| def processPossibleBlockDelimiter(self): |
| """Look at the current line, and if it's a delimiter, update the block stack. |
| |
| Calls self.processBlockDelimiter() as required. |
| |
| Returns True if a delimiter was processed, False otherwise. |
| """ |
| line = self.line |
| new_block_type = BlockType.lineToBlockType(line) |
| if not new_block_type: |
| return False |
| |
| ### |
| # Detect if the previous line was [open,...] starting a refpage |
| # but this line is some block delimiter other than -- |
| # Must do this here because if we get a different block open instead of the one we want, |
| # the order of block opening will be wrong. |
| if new_block_type != BlockType.REF_PAGE_LIKE and self.prev_line_ref_page_tag: |
| self.handleExpectedRefpageBlock() |
| |
| # Delegate to the main process for delimiters. |
| self.processBlockDelimiter(line, new_block_type) |
| |
| return True |
| |
| def processBlockDelimiter(self, line, new_block_type, context=None): |
| """Update the block stack based on the current or supplied line. |
| |
| Calls self.processBlockOpen() or self.processBlockClosed() as required. |
| |
| Called by self.processPossibleBlockDelimiter() both in normal operation, as well as |
| when "faking" a ref page block open. |
| |
| Returns BlockProcessResult. |
| """ |
| if not context: |
| context = self.storeMessageContext() |
| |
| location = self.getBriefLocation(context) |
| |
| top = self.getInnermostBlockEntry() |
| top_delim = self.getInnermostBlockDelimiter() |
| if top_delim == line: |
| self.processBlockClosed() |
| return |
| |
| if top and top.block_type == new_block_type: |
| # Same block type, but not matching - might be an error? |
| # TODO maybe create a diagnostic here? |
| self.logger.warning( |
| "processPossibleBlockDelimiter: %s: Matched delimiter type %s, but did not exactly match current delim %s to top of stack %s, may be a typo?", |
| location, new_block_type, line, top_delim) |
| |
| # Empty stack, or top doesn't match us. |
| self.processBlockOpen(new_block_type, delimiter=line) |
| |
| def processBlockOpen(self, block_type, context=None, delimiter=None): |
| """Do any block-type-specific processing and push the new block. |
| |
| Must call self.pushBlock(). |
| May be overridden (carefully) or extended. |
| |
| Called by self.processBlockDelimiter(). |
| """ |
| if block_type == BlockType.REF_PAGE_LIKE: |
| if self.prev_line_ref_page_tag: |
| if self.current_ref_page: |
| refpage = self.current_ref_page |
| else: |
| refpage = '?refpage-with-invalid-tag?' |
| |
| self.logger.info( |
| 'processBlockOpen: Opening refpage for %s', refpage) |
| # Opening of refpage block "consumes" the preceding ref |
| # page context |
| self.prev_line_ref_page_tag = None |
| self.pushBlock(block_type, refpage=refpage, |
| context=context, delimiter=delimiter) |
| self.in_ref_page = True |
| return |
| |
| if block_type == BlockType.CODE: |
| self.in_code_block = True |
| |
| self.pushBlock(block_type, context=context, delimiter=delimiter) |
| |
| def processBlockClosed(self): |
| """Do any block-type-specific processing and pop the top block. |
| |
| Must call self.popBlock(). |
| May be overridden (carefully) or extended. |
| |
| Called by self.processPossibleBlockDelimiter(). |
| """ |
| old_top = self.popBlock() |
| |
| if old_top.block_type == BlockType.CODE: |
| self.in_code_block = False |
| |
| elif old_top.block_type == BlockType.REF_PAGE_LIKE and old_top.refpage: |
| self.logger.info( |
| 'processBlockClosed: Closing refpage for %s', old_top.refpage) |
| # leaving a ref page so reset associated state. |
| self.current_ref_page = None |
| self.prev_line_ref_page_tag = None |
| self.in_ref_page = False |
| |
| def processMatch(self): |
| """Process a match of the macro:entity regex for correctness.""" |
| match = self.match |
| entity = self.entity |
| macro = self.macro |
| |
| ### |
| # Track entities that we're actually linking to. |
| ### |
| if self.checker.entity_db.isLinkedMacro(macro): |
| self.checker.addLinkToEntity(entity, self.storeMessageContext()) |
| |
| ### |
| # Link everything that should be, and nothing that shouldn't be |
| ### |
| if self.checkRecognizedEntity(): |
| # if this returns true, |
| # then there is no need to do the remaining checks on this match |
| return |
| |
| ### |
| # Non-existent macros |
| if macro in NON_EXISTENT_MACROS: |
| self.error(MessageId.BAD_MACRO, '{} is not a macro provided in the specification, despite resembling other macros.'.format( |
| macro), group='macro') |
| |
| ### |
| # Wildcards (or leading underscore, or square brackets) |
| # if and only if a 'text' macro |
| self.checkText() |
| |
| # Do some validation of pname references. |
| if macro == 'pname': |
| # See if there's an immediately-preceding entity |
| preceding = self.line[:match.start()] |
| scope = PRECEDING_MEMBER_REFERENCE.search(preceding) |
| if scope: |
| # Yes there is, check it out. |
| self.checkPname(scope.group('entity_name')) |
| elif self.current_ref_page is not None: |
| # No, but there is a current ref page: very reliable |
| self.checkPnameImpliedContext(self.current_ref_page) |
| elif self.pname_data is not None: |
| # No, but there is a pname_context - better than nothing. |
| self.checkPnameImpliedContext(self.pname_data) |
| else: |
| # no, and no existing context we can imply: |
| # can't check this. |
| pass |
| |
| def checkRecognizedEntity(self): |
| """Check the current macro:entity match to see if it is recognized. |
| |
| Returns True if there is no need to perform further checks on this match. |
| |
| Helps avoid duplicate warnings/errors: typically each macro should have at most |
| one of this class of errors. |
| """ |
| entity = self.entity |
| macro = self.macro |
| if self.checker.findMacroAndEntity(macro, entity) is not None: |
| # We know this macro-entity combo |
| return True |
| |
| # We don't know this macro-entity combo. |
| possibleCats = self.checker.entity_db.getCategoriesForMacro(macro) |
| if possibleCats is None: |
| possibleCats = ['???'] |
| msg = ['Definition of link target {} with macro {} (used for {} {}) does not exist.'.format( |
| entity, |
| macro, |
| _pluralize('category', len(possibleCats)), |
| ', '.join(possibleCats))] |
| |
| data = self.checker.findEntity(entity) |
| if data: |
| # We found the goof: incorrect macro |
| msg.append('Apparently matching entity in category {} found.'.format( |
| data.category)) |
| self.handleWrongMacro(msg, data) |
| return True |
| |
| see_also = [] |
| dataArray = self.checker.findEntityCaseInsensitive(entity) |
| if dataArray: |
| # We might have found the goof... |
| |
| if len(dataArray) == 1: |
| # Yep, found the goof: |
| # incorrect macro and entity capitalization |
| data = dataArray[0] |
| msg.append('Apparently matching entity in category {} found by searching case-insensitively.'.format( |
| data.category)) |
| self.handleWrongMacro(msg, data) |
| return True |
| else: |
| # Ugh, more than one resolution |
| msg.append( |
| 'More than one apparent match found by searching case-insensitively, cannot auto-fix.') |
| see_also = dataArray[:] |
| |
| # OK, so we don't recognize this entity (and couldn't auto-fix it). |
| |
| if self.checker.entity_db.shouldBeRecognized(macro, entity): |
| # We should know the target - it's a link macro, |
| # or there's some reason the entity DB thinks we should know it. |
| if self.checker.likelyRecognizedEntity(entity): |
| # Should be linked and it matches our pattern, |
| # so probably not wrong macro. |
| # Human brains required. |
| if not self.checkText(): |
| self.error(MessageId.BAD_ENTITY, msg + ['Might be a misspelling, or, less likely, the wrong macro.'], |
| see_also=see_also) |
| else: |
| # Doesn't match our pattern, |
| # so probably should be name instead of link. |
| newMacro = macro[0] + 'name' |
| if self.checker.entity_db.isValidMacro(newMacro): |
| self.error(MessageId.BAD_ENTITY, msg + |
| ['Entity name does not fit the pattern for this API, which would mean it should be a "name" macro instead of a "link" macro'], |
| group='macro', replacement=newMacro, fix=self.makeFix(newMacro=newMacro), see_also=see_also) |
| else: |
| self.error(MessageId.BAD_ENTITY, msg + |
| ['Entity name does not fit the pattern for this API, which would mean it should be a "name" macro instead of a "link" macro', |
| 'However, {} is not a known macro so cannot auto-fix.'.format(newMacro)], see_also=see_also) |
| |
| elif macro == 'ename': |
| # TODO This might be an ambiguity in the style guide - ename might be a known enumerant value, |
| # or it might be an enumerant value in an external library, etc. that we don't know about - so |
| # hard to check this. |
| if self.checker.likelyRecognizedEntity(entity): |
| if not self.checkText(): |
| self.warning(MessageId.BAD_ENUMERANT, msg + |
| ['Unrecognized ename:{} that we would expect to recognize since it fits the pattern for this API.'.format(entity)], see_also=see_also) |
| else: |
| # This is fine: |
| # it doesn't need to be recognized since it's not linked. |
| pass |
| # Don't skip other tests. |
| return False |
| |
| def checkText(self): |
| """Evaluate the usage (or non-usage) of a *text macro. |
| |
| Wildcards (or leading or trailing underscore, or square brackets with |
| nothing or a placeholder) if and only if a 'text' macro. |
| |
| Called by checkRecognizedEntity() when appropriate. |
| """ |
| macro = self.macro |
| entity = self.entity |
| shouldBeText = shouldEntityBeText(entity, self.subscript) |
| if shouldBeText and not self.macro.endswith( |
| 'text') and not self.macro == 'code': |
| newMacro = macro[0] + 'text' |
| if self.checker.entity_db.getCategoriesForMacro(newMacro): |
| self.error(MessageId.MISSING_TEXT, |
| ['Asterisk/leading or trailing underscore/bracket found - macro should end with "text:", probably {}:'.format(newMacro), |
| AUTO_FIX_STRING], |
| group='macro', replacement=newMacro, fix=self.makeFix(newMacro=newMacro)) |
| else: |
| self.error(MessageId.MISSING_TEXT, |
| ['Asterisk/leading or trailing underscore/bracket found, so macro should end with "text:".', |
| 'However {}: is not a known macro so cannot auto-fix.'.format(newMacro)], |
| group='macro') |
| return True |
| elif macro.endswith('text') and not shouldBeText: |
| msg = [ |
| "No asterisk/leading or trailing underscore/bracket in the entity, so this might be a mistaken use of the 'text' macro {}:".format(macro)] |
| data = self.checker.findEntity(entity) |
| if data: |
| # We found the goof: incorrect macro |
| msg.append('Apparently matching entity in category {} found.'.format( |
| data.category)) |
| msg.append(AUTO_FIX_STRING) |
| replacement = self.makeFix(data=data) |
| if data.category == EXTENSION_CATEGORY: |
| self.error(MessageId.EXTENSION, msg, |
| replacement=replacement, fix=replacement) |
| else: |
| self.error(MessageId.WRONG_MACRO, msg, |
| group='macro', replacement=data.macro, fix=replacement) |
| else: |
| if self.checker.likelyRecognizedEntity(entity): |
| # This is a use of *text: for something that fits the pattern but isn't in the spec. |
| # This is OK. |
| return False |
| msg.append('Entity not found in spec, either.') |
| if macro[0] != 'e': |
| # Only suggest a macro if we aren't in elink/ename/etext, |
| # since ename and elink are not related in an equivalent way |
| # to the relationship between flink and fname. |
| newMacro = macro[0] + 'name' |
| if self.checker.entity_db.getCategoriesForMacro(newMacro): |
| msg.append( |
| 'Consider if {}: might be the correct macro to use here.'.format(newMacro)) |
| else: |
| msg.append( |
| 'Cannot suggest a new macro because {}: is not a known macro.'.format(newMacro)) |
| self.warning(MessageId.MISUSED_TEXT, msg) |
| return True |
| return False |
| |
| def checkPnameImpliedContext(self, pname_context): |
| """Handle pname: macros not immediately preceded by something like flink:entity or slink:entity. |
| |
| Also records pname: mentions of members/parameters for completeness checking in doc blocks. |
| |
| Contains call to self.checkPname(). |
| Called by self.processMatch() |
| """ |
| self.checkPname(pname_context.entity) |
| if pname_context.entity in self.pname_mentions and \ |
| self.pname_mentions[pname_context.entity] is not None: |
| # Record this mention, |
| # in case we're in the documentation block. |
| self.pname_mentions[pname_context.entity].add(self.entity) |
| |
| def checkPname(self, pname_context): |
| """Check the current match (as a pname: usage) with the given entity as its 'pname context', if possible. |
| |
| e.g. slink:foo::pname:bar, pname_context would be 'foo', while self.entity would be 'bar', etc. |
| |
| Called by self.processLine(), self.processMatch(), as well as from self.checkPnameImpliedContext(). |
| """ |
| if '*' in pname_context: |
| # This context has a placeholder, can't verify it. |
| return |
| |
| entity = self.entity |
| |
| context_data = self.checker.findEntity(pname_context) |
| members = self.checker.getMemberNames(pname_context) |
| |
| if context_data and not members: |
| # This is a recognized parent entity that doesn't have detectable member names, |
| # skip validation |
| # TODO: Annotate parameters of function pointer types with <name> |
| # and <param>? |
| return |
| if not members: |
| self.warning(MessageId.UNRECOGNIZED_CONTEXT, |
| 'pname context entity was un-recognized {}'.format(pname_context)) |
| return |
| |
| if entity not in members: |
| self.warning(MessageId.UNKNOWN_MEMBER, ["Could not find member/param named '{}' in {}".format(entity, pname_context), |
| 'Known {} mamber/param names are: {}'.format( |
| pname_context, ', '.join(members))], group='entity_name') |
| |
| def checkIncludeRefPageRelation(self, entity, generated_type): |
| """Identify if our current ref page (or lack thereof) is appropriate for an include just recorded. |
| |
| Called by self.recordInclude(). |
| """ |
| if not self.in_ref_page: |
| # Not in a ref page block: This probably means this entity needs a |
| # ref-page block added. |
| self.handleIncludeMissingRefPage(entity, generated_type) |
| return |
| |
| if not isinstance(self.current_ref_page, EntityData): |
| # This isn't a fully-valid ref page, so can't check the includes any better. |
| return |
| |
| ref_page_entity = self.current_ref_page.entity |
| if ref_page_entity not in self.refpage_includes: |
| self.refpage_includes[ref_page_entity] = set() |
| expected_ref_page_entity = self.computeExpectedRefPageFromInclude( |
| entity) |
| self.refpage_includes[ref_page_entity].add((generated_type, entity)) |
| |
| if ref_page_entity == expected_ref_page_entity: |
| # OK, this is a total match. |
| pass |
| elif self.checker.entity_db.areAliases(expected_ref_page_entity, ref_page_entity): |
| # This appears to be a promoted synonym which is OK. |
| pass |
| else: |
| # OK, we are in a ref page block that doesn't match |
| self.handleIncludeMismatchRefPage(entity, generated_type) |
| |
| def checkRefPage(self): |
| """Check if the current line (a refpage tag) meets requirements. |
| |
| Called by self.processLine(). |
| """ |
| line = self.line |
| |
| # Should always be found |
| self.match = BRACKETS.match(line) |
| |
| data = None |
| directory = None |
| if self.in_ref_page: |
| msg = ["Found reference page markup, but we are already in a refpage block.", |
| "The block before the first message of this type is most likely not closed.", ] |
| # Fake-close the previous ref page, if it's trivial to do so. |
| if self.getInnermostBlockEntry().block_type == BlockType.REF_PAGE_LIKE: |
| msg.append( |
| "Pretending that there was a line with `--` immediately above to close that ref page, for more readable messages.") |
| self.processBlockDelimiter( |
| REF_PAGE_LIKE_BLOCK_DELIM, BlockType.REF_PAGE_LIKE) |
| else: |
| msg.append( |
| "Ref page wasn't the last block opened, so not pretending to auto-close it for more readable messages.") |
| |
| self.error(MessageId.REFPAGE_BLOCK, msg) |
| |
| attribs = parseRefPageAttribs(line) |
| |
| unknown_attribs = set(attribs.keys()).difference( |
| VALID_REF_PAGE_ATTRIBS) |
| if unknown_attribs: |
| self.error(MessageId.REFPAGE_UNKNOWN_ATTRIB, |
| "Found unknown attrib(s) in reference page markup: " + ','.join(unknown_attribs)) |
| |
| # Required field: refpage='xrValidEntityHere' |
| if Attrib.REFPAGE.value in attribs: |
| attrib = attribs[Attrib.REFPAGE.value] |
| text = attrib.value |
| self.entity = text |
| |
| context = self.storeMessageContext( |
| group='value', match=attrib.match) |
| if self.checker.seenRefPage(text): |
| self.error(MessageId.REFPAGE_DUPLICATE, |
| ["Found reference page markup when we already saw refpage='{}' elsewhere.".format( |
| text), |
| "This (or the other mention) may be a copy-paste error."], |
| context=context) |
| self.checker.addRefPage(text) |
| |
| # Skip entity check if it's a spir-v built in |
| type = '' |
| if Attrib.TYPE.value in attribs: |
| type = attribs[Attrib.TYPE.value].value |
| |
| if type != 'builtins' and type != 'spirv': |
| data = self.checker.findEntity(text) |
| self.current_ref_page = data |
| if data: |
| # OK, this is a known entity that we're seeing a refpage for. |
| directory = data.directory |
| else: |
| # TODO suggest fixes here if applicable |
| self.error(MessageId.REFPAGE_NAME, |
| [ "Found reference page markup, but refpage='{}' type='{}' does not refer to a recognized entity".format( |
| text, type), |
| 'If this is intentional, add the entity to EXTRA_DEFINES or EXTRA_REFPAGES in check_spec_links.py.' ], |
| context=context) |
| |
| else: |
| self.error(MessageId.REFPAGE_TAG, |
| "Found apparent reference page markup, but missing refpage='...'", |
| group=None) |
| |
| # Required field: desc='preferably non-empty' |
| if Attrib.DESC.value in attribs: |
| attrib = attribs[Attrib.DESC.value] |
| text = attrib.value |
| if not text: |
| context = self.storeMessageContext( |
| group=None, match=attrib.match) |
| self.warning(MessageId.REFPAGE_MISSING_DESC, |
| "Found reference page markup, but desc='' is empty", |
| context=context) |
| else: |
| self.error(MessageId.REFPAGE_TAG, |
| "Found apparent reference page markup, but missing desc='...'", |
| group=None) |
| |
| # Required field: type='protos' for example |
| # (used by genRef.py to compute the macro to use) |
| if Attrib.TYPE.value in attribs: |
| attrib = attribs[Attrib.TYPE.value] |
| text = attrib.value |
| if directory and not text == directory: |
| context = self.storeMessageContext( |
| group='value', match=attrib.match) |
| self.error(MessageId.REFPAGE_TYPE, |
| "Found reference page markup, but type='{}' is not the expected value '{}'".format( |
| text, directory), |
| context=context) |
| else: |
| self.error(MessageId.REFPAGE_TAG, |
| "Found apparent reference page markup, but missing type='...'", |
| group=None) |
| |
| # Optional field: alias='spaceDelimited validEntities' |
| # Currently does nothing. Could modify checkRefPageXrefs to also |
| # check alias= attribute value |
| # if Attrib.ALIAS.value in attribs: |
| # # This field is optional |
| # self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) |
| |
| # Optional field: xrefs='spaceDelimited validEntities' |
| if Attrib.XREFS.value in attribs: |
| # This field is optional |
| self.checkRefPageXrefs(attribs[Attrib.XREFS.value]) |
| self.prev_line_ref_page_tag = self.storeMessageContext() |
| |
| def checkRefPageXrefs(self, xrefs_attrib): |
| """Check all cross-refs indicated in an xrefs attribute for a ref page. |
| |
| Called by self.checkRefPage(). |
| |
| Argument: |
| xrefs_attrib -- A match of REF_PAGE_ATTRIB where the group 'key' is 'xrefs'. |
| """ |
| text = xrefs_attrib.value |
| context = self.storeMessageContext( |
| group='value', match=xrefs_attrib.match) |
| |
| def splitRefs(s): |
| """Split the string on whitespace, into individual references.""" |
| return s.split() # [x for x in s.split() if x] |
| |
| def remakeRefs(refs): |
| """Re-create a xrefs string from something list-shaped.""" |
| return ' '.join(refs) |
| |
| refs = splitRefs(text) |
| |
| # Pre-checking if messages are enabled, so that we can correctly determine |
| # the current string following any auto-fixes: |
| # the fixes for messages directly in this method would interact, |
| # and thus must be in the order specified here. |
| |
| if self.messageEnabled(MessageId.REFPAGE_XREFS_COMMA) and ',' in text: |
| old_text = text |
| # Re-split after replacing commas. |
| refs = splitRefs(text.replace(',', ' ')) |
| # Re-create the space-delimited text. |
| text = remakeRefs(refs) |
| self.error(MessageId.REFPAGE_XREFS_COMMA, |
| "Found reference page markup, with an unexpected comma in the (space-delimited) xrefs attribute", |
| context=context, |
| replacement=text, |
| fix=(old_text, text)) |
| |
| # We could conditionally perform this creation, but the code complexity would increase substantially, |
| # for presumably minimal runtime improvement. |
| unique_refs = OrderedDict.fromkeys(refs) |
| if self.messageEnabled(MessageId.REFPAGE_XREF_DUPE) and len(unique_refs) != len(refs): |
| # TODO is it safe to auto-fix here? |
| old_text = text |
| text = remakeRefs(unique_refs.keys()) |
| self.warning(MessageId.REFPAGE_XREF_DUPE, |
| ["Reference page for {} contains at least one duplicate in its cross-references.".format( |
| self.entity), |
| "Look carefully to see if this is a copy and paste error and should be changed to a different but related entity:", |
| "auto-fix simply removes the duplicate."], |
| context=context, |
| replacement=text, |
| fix=(old_text, text)) |
| |
| if self.messageEnabled(MessageId.REFPAGE_SELF_XREF) and self.entity and self.entity in unique_refs: |
| # Not modifying unique_refs here because that would accidentally affect the whitespace auto-fix. |
| new_text = remakeRefs( |
| [x for x in unique_refs.keys() if x != self.entity]) |
| |
| # DON'T AUTOFIX HERE because these are likely copy-paste between related entities: |
| # e.g. a Create function and the associated CreateInfo struct. |
| self.warning(MessageId.REFPAGE_SELF_XREF, |
| ["Reference page for {} included itself in its cross-references.".format(self.entity), |
| "This is typically a copy and paste error, and the dupe should likely be changed to a different but related entity.", |
| "Not auto-fixing for this reason."], |
| context=context, |
| replacement=new_text,) |
| |
| # We didn't have another reason to replace the whole attribute value, |
| # so let's make sure it doesn't have any extra spaces |
| if self.messageEnabled(MessageId.REFPAGE_WHITESPACE) and xrefs_attrib.value == text: |
| old_text = text |
| text = remakeRefs(unique_refs.keys()) |
| if old_text != text: |
| self.warning(MessageId.REFPAGE_WHITESPACE, |
| ["Cross-references for reference page for {} had non-minimal whitespace,".format(self.entity), |
| "and no other enabled message has re-constructed this value already."], |
| context=context, |
| replacement=text, |
| fix=(old_text, text)) |
| |
| for entity in unique_refs.keys(): |
| self.checkRefPageXref(entity, context) |
| |
| def checkRefPageXref(self, referenced_entity, line_context): |
| """Check a single cross-reference entry for a refpage. |
| |
| Called by self.checkRefPageXrefs(). |
| |
| Arguments: |
| referenced_entity -- The individual entity under consideration from the xrefs='...' string. |
| line_context -- A MessageContext referring to the entire line. |
| """ |
| data = self.checker.findEntity(referenced_entity) |
| if data: |
| # This is OK |
| return |
| context = line_context |
| match = re.search(r'\b{}\b'.format(referenced_entity), self.line) |
| if match: |
| context = self.storeMessageContext( |
| group=None, match=match) |
| msg = ["Found reference page markup, with an unrecognized entity listed: {}".format( |
| referenced_entity)] |
| |
| see_also = None |
| dataArray = self.checker.findEntityCaseInsensitive( |
| referenced_entity) |
| |
| if dataArray: |
| # We might have found the goof... |
| |
| if len(dataArray) == 1: |
| # Yep, found the goof - incorrect entity capitalization |
| data = dataArray[0] |
| new_entity = data.entity |
| self.error(MessageId.REFPAGE_XREFS, msg + [ |
| 'Apparently matching entity in category {} found by searching case-insensitively.'.format( |
| data.category), |
| AUTO_FIX_STRING], |
| replacement=new_entity, |
| fix=(referenced_entity, new_entity), |
| context=context) |
| return |
| |
| # Ugh, more than one resolution |
| msg.append( |
| 'More than one apparent match found by searching case-insensitively, cannot auto-fix.') |
| see_also = dataArray[:] |
| else: |
| # Probably not just a typo |
| msg.append( |
| 'If this is intentional, add the entity to EXTRA_DEFINES or EXTRA_REFPAGES in check_spec_links.py.') |
| |
| # Multiple or no resolutions found |
| self.error(MessageId.REFPAGE_XREFS, |
| msg, |
| see_also=see_also, |
| context=context) |
| |
| ### |
| # Message-related methods. |
| ### |
| |
| def warning(self, message_id, messageLines, context=None, group=None, |
| replacement=None, fix=None, see_also=None, frame=None): |
| """Log a warning for the file, if the message ID is enabled. |
| |
| Wrapper around self.diag() that automatically sets severity as well as frame. |
| |
| Arguments: |
| message_id -- A MessageId value. |
| messageLines -- A string or list of strings containing a human-readable error description. |
| |
| Optional, named arguments: |
| context -- A MessageContext. If None, will be constructed from self.match and group. |
| group -- The name of the regex group in self.match that contains the problem. Only used if context is None. |
| If needed and is None, self.group is used instead. |
| replacement -- The string, if any, that should be suggested as a replacement for the group in question. |
| Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough |
| (or can't easily phrase a regex) to do it automatically. |
| fix -- A (old text, new text) pair if this error is auto-fixable safely. |
| see_also -- An optional array of other MessageContext locations relevant to this message. |
| frame -- The 'inspect' stack frame corresponding to the location that raised this message. |
| If None, will assume it is the direct caller of self.warning(). |
| """ |
| if not frame: |
| frame = currentframe().f_back |
| self.diag(MessageType.WARNING, message_id, messageLines, group=group, |
| replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) |
| |
| def error(self, message_id, messageLines, group=None, replacement=None, |
| context=None, fix=None, see_also=None, frame=None): |
| """Log an error for the file, if the message ID is enabled. |
| |
| Wrapper around self.diag() that automatically sets severity as well as frame. |
| |
| Arguments: |
| message_id -- A MessageId value. |
| messageLines -- A string or list of strings containing a human-readable error description. |
| |
| Optional, named arguments: |
| context -- A MessageContext. If None, will be constructed from self.match and group. |
| group -- The name of the regex group in self.match that contains the problem. Only used if context is None. |
| If needed and is None, self.group is used instead. |
| replacement -- The string, if any, that should be suggested as a replacement for the group in question. |
| Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough |
| (or can't easily phrase a regex) to do it automatically. |
| fix -- A (old text, new text) pair if this error is auto-fixable safely. |
| see_also -- An optional array of other MessageContext locations relevant to this message. |
| frame -- The 'inspect' stack frame corresponding to the location that raised this message. |
| If None, will assume it is the direct caller of self.error(). |
| """ |
| if not frame: |
| frame = currentframe().f_back |
| self.diag(MessageType.ERROR, message_id, messageLines, group=group, |
| replacement=replacement, context=context, fix=fix, see_also=see_also, frame=frame) |
| |
| def diag(self, severity, message_id, messageLines, context=None, group=None, |
| replacement=None, fix=None, see_also=None, frame=None): |
| """Log a diagnostic for the file, if the message ID is enabled. |
| |
| Also records the auto-fix, if applicable. |
| |
| Arguments: |
| severity -- A MessageType value. |
| message_id -- A MessageId value. |
| messageLines -- A string or list of strings containing a human-readable error description. |
| |
| Optional, named arguments: |
| context -- A MessageContext. If None, will be constructed from self.match and group. |
| group -- The name of the regex group in self.match that contains the problem. Only used if context is None. |
| If needed and is None, self.group is used instead. |
| replacement -- The string, if any, that should be suggested as a replacement for the group in question. |
| Does not create an auto-fix: sometimes we want to show a possible fix but aren't confident enough |
| (or can't easily phrase a regex) to do it automatically. |
| fix -- A (old text, new text) pair if this error is auto-fixable safely. |
| see_also -- An optional array of other MessageContext locations relevant to this message. |
| frame -- The 'inspect' stack frame corresponding to the location that raised this message. |
| If None, will assume it is the direct caller of self.diag(). |
| """ |
| if not self.messageEnabled(message_id): |
| self.logger.debug( |
| 'Discarding a %s message because it is disabled.', message_id) |
| return |
| |
| if isinstance(messageLines, str): |
| messageLines = [messageLines] |
| |
| self.logger.info('Recording a %s message: %s', |
| message_id, ' '.join(messageLines)) |
| |
| # Ensure all auto-fixes are marked as such. |
| if fix is not None and AUTO_FIX_STRING not in messageLines: |
| messageLines.append(AUTO_FIX_STRING) |
| |
| if not frame: |
| frame = currentframe().f_back |
| if context is None: |
| message = Message(message_id=message_id, |
| message_type=severity, |
| message=messageLines, |
| context=self.storeMessageContext(group=group), |
| replacement=replacement, |
| see_also=see_also, |
| fix=fix, |
| frame=frame) |
| else: |
| message = Message(message_id=message_id, |
| message_type=severity, |
| message=messageLines, |
| context=context, |
| replacement=replacement, |
| see_also=see_also, |
| fix=fix, |
| frame=frame) |
| if fix is not None: |
| self.fixes.add(fix) |
| self.messages.append(message) |
| |
| def messageEnabled(self, message_id): |
| """Return true if the given message ID is enabled.""" |
| return message_id in self.enabled_messages |
| |
| ### |
| # Accessors for externally-interesting information |
| |
| def numDiagnostics(self): |
| """Count the total number of diagnostics (errors or warnings) for this file.""" |
| return len(self.messages) |
| |
| def numErrors(self): |
| """Count the total number of errors for this file.""" |
| return self.numMessagesOfType(MessageType.ERROR) |
| |
| def numMessagesOfType(self, message_type): |
| """Count the number of messages of a particular type (severity).""" |
| return len( |
| [msg for msg in self.messages if msg.message_type == message_type]) |
| |
| def hasFixes(self): |
| """Return True if any messages included auto-fix patterns.""" |
| return len(self.fixes) > 0 |
| |
| ### |
| # Assorted internal methods. |
| def printMessageCounts(self): |
| """Print a simple count of each MessageType of diagnostics.""" |
| for message_type in [MessageType.ERROR, MessageType.WARNING]: |
| count = self.numMessagesOfType(message_type) |
| if count > 0: |
| print('{num} {mtype}{s} generated.'.format( |
| num=count, mtype=message_type, s=_s_suffix(count))) |
| |
| def dumpInternals(self): |
| """Dump internal variables to screen, for debugging.""" |
| print('self.lineNum: ', self.lineNum) |
| print('self.line:', self.line) |
| print('self.prev_line_ref_page_tag: ', self.prev_line_ref_page_tag) |
| print('self.current_ref_page:', self.current_ref_page) |
| |
| def getMissingValiditySuppressions(self): |
| """Return an enumerable of entity names that we shouldn't warn about missing validity. |
| |
| May override. |
| """ |
| return [] |
| |
| def recordInclude(self, include_dict, generated_type=None): |
| """Store the current line as being the location of an include directive or equivalent. |
| |
| Reports duplicate include errors, as well as include/ref-page mismatch or missing ref-page, |
| by calling self.checkIncludeRefPageRelation() for "actual" includes (where generated_type is None). |
| |
| Arguments: |
| include_dict -- The include dictionary to update: one of self.apiIncludes or self.validityIncludes. |
| generated_type -- The type of include (e.g. 'api', 'valid', etc). By default, extracted from self.match. |
| """ |
| entity = self.match.group('entity_name') |
| if generated_type is None: |
| generated_type = self.match.group('generated_type') |
| |
| # Only checking the ref page relation if it's retrieved from regex. |
| # Otherwise it might be a manual anchor recorded as an include, |
| # etc. |
| self.checkIncludeRefPageRelation(entity, generated_type) |
| |
| if entity in include_dict: |
| self.error(MessageId.DUPLICATE_INCLUDE, |
| "Included {} docs for {} when they were already included.".format(generated_type, |
| entity), see_also=include_dict[entity]) |
| include_dict[entity].append(self.storeMessageContext()) |
| else: |
| include_dict[entity] = [self.storeMessageContext()] |
| |
| def getInnermostBlockEntry(self): |
| """Get the BlockEntry for the top block delim on our stack.""" |
| if not self.block_stack: |
| return None |
| return self.block_stack[-1] |
| |
| def getInnermostBlockDelimiter(self): |
| """Get the delimiter for the top block on our stack.""" |
| top = self.getInnermostBlockEntry() |
| if not top: |
| return None |
| return top.delimiter |
| |
| def pushBlock(self, block_type, refpage=None, context=None, delimiter=None): |
| """Push a new entry on the block stack.""" |
| if not delimiter: |
| self.logger.info("pushBlock: not given delimiter") |
| delimiter = self.line |
| if not context: |
| context = self.storeMessageContext() |
| |
| old_top_delim = self.getInnermostBlockDelimiter() |
| |
| self.block_stack.append(BlockEntry( |
| delimiter=delimiter, |
| context=context, |
| refpage=refpage, |
| block_type=block_type)) |
| |
| location = self.getBriefLocation(context) |
| self.logger.info( |
| "pushBlock: %s: Pushed %s delimiter %s, previous top was %s, now %d elements on the stack", |
| location, block_type.value, delimiter, old_top_delim, len(self.block_stack)) |
| |
| self.dumpBlockStack() |
| |
| def popBlock(self): |
| """Pop and return the top entry from the block stack.""" |
| old_top = self.block_stack.pop() |
| location = self.getBriefLocation(old_top.context) |
| self.logger.info( |
| "popBlock: %s: popping %s delimiter %s, now %d elements on the stack", |
| location, old_top.block_type.value, old_top.delimiter, len(self.block_stack)) |
| |
| self.dumpBlockStack() |
| |
| return old_top |
| |
| def dumpBlockStack(self): |
| self.logger.debug('Block stack, top first:') |
| for distFromTop, x in enumerate(reversed(self.block_stack)): |
| self.logger.debug(' - block_stack[%d]: Line %d: "%s" refpage=%s', |
| -1 - distFromTop, |
| x.context.lineNum, x.delimiter, x.refpage) |
| |
| def getBriefLocation(self, context): |
| """Format a context briefly - omitting the filename if it has newlines in it.""" |
| if '\n' in context.filename: |
| return 'input string line {}'.format(context.lineNum) |
| return '{}:{}'.format( |
| context.filename, context.lineNum) |
| |
| ### |
| # Handlers for a variety of diagnostic-meriting conditions |
| # |
| # Split out for clarity and for allowing fine-grained override on a per-project basis. |
| ### |
| |
| def handleIncludeMissingRefPage(self, entity, generated_type): |
| """Report a message about an include outside of a ref-page block.""" |
| msg = ["Found {} include for {} outside of a reference page block.".format(generated_type, entity), |
| "This is probably a missing reference page block."] |
| refpage = self.computeExpectedRefPageFromInclude(entity) |
| data = self.checker.findEntity(refpage) |
| if data: |
| msg.append('Expected ref page block might start like:') |
| msg.append(self.makeRefPageTag(refpage, data=data)) |
| else: |
| msg.append( |
| "But, expected ref page entity name {} isn't recognized...".format(refpage)) |
| self.warning(MessageId.REFPAGE_MISSING, msg) |
| |
| def handleIncludeMismatchRefPage(self, entity, generated_type): |
| """Report a message about an include not matching its containing ref-page block.""" |
| self.warning(MessageId.REFPAGE_MISMATCH, "Found {} include for {}, inside the reference page block of {}".format( |
| generated_type, entity, self.current_ref_page.entity)) |
| |
| def handleWrongMacro(self, msg, data): |
| """Report an appropriate message when we found that the macro used is incorrect. |
| |
| May be overridden depending on each API's behavior regarding macro misuse: |
| e.g. in some cases, it may be considered a MessageId.LEGACY warning rather than |
| a MessageId.WRONG_MACRO or MessageId.EXTENSION. |
| """ |
| message_type = MessageType.WARNING |
| message_id = MessageId.WRONG_MACRO |
| group = 'macro' |
| |
| if data.category == EXTENSION_CATEGORY: |
| # Ah, this is an extension |
| msg.append( |
| 'This is apparently an extension name, which should be marked up as a link.') |
| message_id = MessageId.EXTENSION |
| group = None # replace the whole thing |
| else: |
| # Non-extension, we found the macro though. |
| message_type = MessageType.ERROR |
| msg.append(AUTO_FIX_STRING) |
| self.diag(message_type, message_id, msg, |
| group=group, replacement=self.makeMacroMarkup(data=data), fix=self.makeFix(data=data)) |
| |
| def handleExpectedRefpageBlock(self): |
| """Handle expecting to see -- to start a refpage block, but not seeing that at all.""" |
| self.error(MessageId.REFPAGE_BLOCK, |
| ["Expected, but did not find, a line containing only -- following a reference page tag,", |
| "Pretending to insert one, for more readable messages."], |
| see_also=[self.prev_line_ref_page_tag]) |
| # Fake "in ref page" regardless, to avoid spurious extra errors. |
| self.processBlockDelimiter('--', BlockType.REF_PAGE_LIKE, |
| context=self.prev_line_ref_page_tag) |
| |
| ### |
| # Construct related values (typically named tuples) based on object state and supplied arguments. |
| # |
| # Results are typically supplied to another method call. |
| ### |
| |
| def storeMessageContext(self, group=None, match=None): |
| """Create message context from corresponding instance variables. |
| |
| Arguments: |
| group -- The regex group name, if any, identifying the part of the match to highlight. |
| match -- The regex match. If None, will use self.match. |
| """ |
| if match is None: |
| match = self.match |
| return MessageContext(filename=self.filename, |
| lineNum=self.lineNum, |
| line=self.line, |
| match=match, |
| group=group) |
| |
| def makeFix(self, newMacro=None, newEntity=None, data=None): |
| """Construct a fix pair for replacing the old macro:entity with new. |
| |
| Wrapper around self.makeSearch() and self.makeMacroMarkup(). |
| """ |
| return (self.makeSearch(), self.makeMacroMarkup( |
| newMacro, newEntity, data)) |
| |
| def makeSearch(self): |
| """Construct the string self.macro:self.entity, for use in the old text part of a fix pair.""" |
| return '{}:{}'.format(self.macro, self.entity) |
| |
| def makeMacroMarkup(self, newMacro=None, newEntity=None, data=None): |
| """Construct appropriate markup for referring to an entity. |
| |
| Typically constructs macro:entity, but can construct `<<EXTENSION_NAME>>` if the supplied |
| entity is identified as an extension. |
| |
| Arguments: |
| newMacro -- The macro to use. Defaults to data.macro (if available), otherwise self.macro. |
| newEntity -- The entity to use. Defaults to data.entity (if available), otherwise self.entity. |
| data -- An EntityData value corresponding to this entity. If not provided, will be looked up by newEntity. |
| """ |
| if not newEntity: |
| if data: |
| newEntity = data.entity |
| else: |
| newEntity = self.entity |
| if not newMacro: |
| if data: |
| newMacro = data.macro |
| else: |
| newMacro = self.macro |
| if not data: |
| data = self.checker.findEntity(newEntity) |
| if data and data.category == EXTENSION_CATEGORY: |
| return self.makeExtensionLink(newEntity) |
| return '{}:{}'.format(newMacro, newEntity) |
| |
| def makeExtensionLink(self, newEntity=None): |
| """Create a correctly-formatted link to an extension. |
| |
| Result takes the form `<<EXTENSION_NAME>>`. |
| |
| Argument: |
| newEntity -- The extension name to link to. Defaults to self.entity. |
| """ |
| if not newEntity: |
| newEntity = self.entity |
| return '`<<{}>>`'.format(newEntity) |
| |
| def computeExpectedRefPageFromInclude(self, entity): |
| """Compute the expected ref page entity based on an include entity name.""" |
| # No-op in general. |
| return entity |
| |
| def makeRefPageTag(self, entity, data=None, |
| ref_type=None, desc='', xrefs=None): |
| """Construct a ref page tag string from attribute values.""" |
| if ref_type is None and data is not None: |
| ref_type = data.directory |
| if ref_type is None: |
| ref_type = "????" |
| return "[open,refpage='{}',type='{}',desc='{}',xrefs='{}']".format( |
| entity, ref_type, desc, ' '.join(xrefs or [])) |