#!/usr/bin/python3 # # Copyright (c) 2018 Collabora, Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Author(s): Ryan Pavlik # # Purpose: This file performs some basic checks of the custom macros # used in the AsciiDoctor source for the spec, especially # related to the validity of the entities linked-to. import argparse import html import json import re from abc import ABC, abstractmethod from collections import namedtuple from enum import Enum from io import StringIO from pathlib import Path from sys import exit, stdout from xml.etree import ElementTree from inspect import currentframe, getframeinfo from reg import Registry ### # "Configuration" constants NAME_PREFIX = 'vk' MISSING_VALIDITY_SUPPRESSIONS = set() ENTITIES_WITHOUT_VALIDITY = set( ['VkBaseOutStructure', 'VkBaseInStructure']) # These are marked with the code: macro SYSTEM_TYPES = set(['void', 'char', 'float', 'size_t', 'uintptr_t', 'int8_t', 'uint8_t', 'int32_t', 'uint32_t', 'int64_t', 'uint64_t', 'ANativeWindow', 'AHardwareBuffer']) PLATFORM_REQUIRES = 'vk_platform' EXTRA_DEFINES = [] # TODO - defines mentioned in spec but not needed in registry ROOT = Path(__file__).resolve().parent.parent REGISTRY_FILE = ROOT / 'xml/vk.xml' ALL_DOCS = [str(fn) for fn in sorted((ROOT / 'chapters/').glob('**/*.txt'))] ALL_DOCS.extend([str(fn) for fn in sorted((ROOT / 'appendices/').glob('**/*.txt'))]) INCLUDE = re.compile( r'include::(?P((../){1,4}|\{INCS-VAR\}/))(?P[\w]+)/(?P\w+)/(?P[^./]+).txt[[][]]') ANCHOR = re.compile(r'\[\[(?P[^\]]+)\]\]') MEMBER_REFERENCE = re.compile( r'\b(?P(?P[fs](text|link)):(?P[\w*]+))(?P::)(?P(?Ppname:?)(?P[\w]+))\b' ) PRECEDING_MEMBER_REFERENCE = re.compile( r'\b(?P[fs](text|link)):(?P[\w*]+)::$') HEADING_COMMAND = re.compile( r'=+ (?P{}[\w]+)'.format(NAME_PREFIX) ) SUSPECTED_MISSING_MACRO = re.compile( r'\b(?[vV][kK][\w*]+)\b(?!>>)' ) OPEN_LINK = re.compile( r'.*(?]*$' ) CLOSE_LINK = re.compile( r'[^<]*>>.*$' ) SKIP_LINE = re.compile( r'^(ifdef:)|(endif:).*' ) INTERNAL_PLACEHOLDER = re.compile( r'(?P__+)([a-zA-Z]+)(?P=delim)' ) CATEGORIES_WITH_VALIDITY = set(['protos', 'structs']) LEADING_SPACES = re.compile(r'^(?P\s*)(?P.*)') CWD = Path('.').resolve() if hasattr(stdout, 'isatty') and stdout.isatty(): try: from termcolor import colored as colored_impl HAVE_COLOR = True except ImportError: HAVE_COLOR = False else: HAVE_COLOR = False try: from tabulate import tabulate_impl HAVE_TABULATE = True except ImportError: HAVE_TABULATE = False AUTO_FIX_STRING = 'Note: Auto-fix available.' EXTENSION_CATEGORY = 'extension' def colored(s, color=None, attrs=None): if HAVE_COLOR: return colored_impl(s, color, attrs=attrs) return s def colWidth(collection, columnNum): MIN_PADDING = 5 return MIN_PADDING + max([len(row[columnNum]) for row in collection]) def alternateTabulate(collection, headers=None): if headers is None: fullTable = collection else: underline = ['-' * len(header) for header in headers] fullTable = [headers, underline] + collection widths = [colWidth(collection, colNum) for colNum in range(len(fullTable[0]))] widths[-1] = None lines = [] for row in fullTable: fields = [] for data, width in zip(row, widths): if width: spaces = ' ' * (width-len(data)) fields.append(data + spaces) else: fields.append(data) lines.append(''.join(fields)) return '\n'.join(lines) def printTabulated(collection, headers=None): if HAVE_TABULATE: print(tabulate_impl(collection, headers=headers)) else: print(alternateTabulate(collection, headers=headers)) def generateInclude(dir_traverse, generated_type, category, entity): return 'include::{directory_traverse}{generated_type}/{category}/{entity_name}.txt[]'.format( directory_traverse=dir_traverse, generated_type=generated_type, category=category, entity_name=entity) def regenerateIncludeFromMatch(match, generated_type): return generateInclude(match.group('directory_traverse'), generated_type, match.group('category'), match.group('entity_name')) def shouldEntityBeText(entity, subscript): 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 EntityData = namedtuple( 'EntityData', ['entity', 'macro', 'elem', 'filename', 'category']) def entityToDict(data): return { 'macro': data.macro, 'filename': data.filename, 'category': data.category } class MessageType(Enum): WARNING = 1 ERROR = 2 NOTE = 3 def __str__(self): return str(self.name).lower() def formattedWithColon(self): if self == MessageType.WARNING: return colored(str(self) + ':', 'magenta', attrs=['bold']) if self == MessageType.ERROR: return colored(str(self) + ':', 'red', attrs=['bold']) return str(self) + ':' MessageContext = namedtuple('MessageContext', ['filename', 'lineNum', 'line', 'match', 'group']) class MessageId(Enum): MISSING_TEXT = 1 LEGACY = 2 WRONG_MACRO = 3 MISSING_MACRO = 4 BAD_ENTITY = 5 BAD_ENUMERANT = 6 BAD_MACRO = 7 UNRECOGNIZED_CONTEXT = 8 UNKNOWN_MEMBER = 9 DUPLICATE_INCLUDE = 10 UNKNOWN_INCLUDE = 11 API_VALIDITY_ORDER = 12 UNDOCUMENTED_MEMBER = 13 MEMBER_PNAME_MISSING = 14 MISSING_VALIDITY_INCLUDE = 15 MISSING_API_INCLUDE = 16 MISUSED_TEXT = 17 EXTENSION = 18 def __str__(self): return self.name.lower() def enable_arg(self): return 'W{}'.format(self.name.lower()) def disable_arg(self): return 'Wno_{}'.format(self.name.lower()) def desc(self): if self == MessageId.MISSING_TEXT: return "a *text: macro is expected but not found" elif self == MessageId.LEGACY: return "legacy usage of *name: macro when *link: is applicable" elif self == MessageId.WRONG_MACRO: return "wrong macro used for an entity" elif self == MessageId.MISSING_MACRO: return "a macro might be missing" elif self == MessageId.BAD_ENTITY: return "entity not recognized, etc." elif self == MessageId.BAD_ENUMERANT: return "unrecognized enumerant value used in ename:" elif self == MessageId.BAD_MACRO: return "unrecognized macro used" elif self == MessageId.UNRECOGNIZED_CONTEXT: return "pname used with an unrecognized context" elif self == MessageId.UNKNOWN_MEMBER: return "pname used but member/argument by that name not found" elif self == MessageId.DUPLICATE_INCLUDE: return "duplicated include line" elif self == MessageId.UNKNOWN_INCLUDE: return "include line specified file we wouldn't expect to exists" elif self == MessageId.API_VALIDITY_ORDER: return "saw API include after validity include" elif self == MessageId.UNDOCUMENTED_MEMBER: return "saw an apparent struct/function documentation, but missing a member" elif self == MessageId.MEMBER_PNAME_MISSING: return "pname: missing from a 'scope' operator" elif self == MessageId.MISSING_VALIDITY_INCLUDE: return "missing validity include" elif self == MessageId.MISSING_API_INCLUDE: return "missing API include" elif self == MessageId.MISUSED_TEXT: return "a *text: macro is found but not expected" elif self == MessageId.EXTENSION: return "an extension name is incorrectly marked" @classmethod def default_set(self): return set([self.WRONG_MACRO, self.BAD_ENTITY, self.BAD_ENUMERANT, self.BAD_MACRO, self.UNKNOWN_MEMBER, self.DUPLICATE_INCLUDE, self.UNKNOWN_INCLUDE, self.API_VALIDITY_ORDER, self.UNDOCUMENTED_MEMBER, self.MEMBER_PNAME_MISSING, self.MISSING_VALIDITY_INCLUDE, self.MISSING_API_INCLUDE ]) def getInterestedRange(message_context): return (message_context.match.start(), message_context.match.end()) def getHighlightedRange(message_context): if message_context.group is not None: return (message_context.match.start(message_context.group), message_context.match.end(message_context.group)) return getInterestedRange(message_context) def getColumn(message_context): if message_context.group is not None: return message_context.match.start(message_context.group) return message_context.match.start() def toNameAndLine(context): """Convert MessageContext into a simple filename:line string""" return '{}:{}'.format(str(Path(context.filename).relative_to(ROOT)), context.lineNum) def likelyRecognizedEntity(entity_name): return entity_name.lower().startswith(NAME_PREFIX) class Message(object): def __init__(self, message_id, message_type, message, context, replacement=None, see_also=None, fix=None, frame=None): self.message_id = message_id self.message_type = message_type if isinstance(message, str): self.message = [message] else: self.message = message self.context = context self.replacement = replacement self.fix = fix if see_also is None: self.see_also = None elif isinstance(see_also, MessageContext): self.see_also = [see_also] else: self.see_also = see_also self.script_location = None if frame: try: frameinfo = getframeinfo(frame) self.script_location = "{}:{}".format( frameinfo.filename, frameinfo.lineno) finally: del frame def s_suffix(num): """Simplify pluralization.""" if num > 1: return 's' return '' def pluralize(word, num): if num == 1: return word if word.endswith('y'): return word[:-1]+'ies' return word + 's' def printLineSubsetWithHighlighting(line, start, end, highlightStart=None, highlightEnd=None, maxLen=120, replacement=None): if highlightStart is None: highlightStart = start if highlightEnd is None: highlightEnd = end start = min(start, highlightStart) end = max(end, highlightEnd) tildeLength = highlightEnd - highlightStart - 1 caretLoc = highlightStart continuation = '[...]' if len(line) > maxLen: # Too long # the max is to handle -1 from .find() (which indicates "not found") followingSpaceIndex = max(end, line.find(' ', min(len(line), end + 1))) # Maximum length has decreased by at least # the length of a single continuation we absolutely need. maxLen -= len(continuation) if followingSpaceIndex <= maxLen: # We can grab the whole beginning of the line and not adjust caretLoc line = line[:maxLen] + continuation elif (len(line) - followingSpaceIndex) < 5: # We need to truncate the beginning, but we're close to the end of line. newBeginning = len(line) - maxLen caretLoc += len(continuation) caretLoc -= newBeginning line = continuation + line[newBeginning:] else: # Need to truncate the beginning of the string too. newEnd = followingSpaceIndex # Now we need two continuations (and to adjust caret to the right accordingly) maxLen -= len(continuation) caretLoc += len(continuation) newBeginning = newEnd - maxLen caretLoc -= newBeginning line = continuation + line[newBeginning:newEnd] + continuation print(line) spaces = ' ' * caretLoc tildes = '~' * tildeLength print(spaces + colored('^' + tildes, 'green')) if replacement is not None: print(spaces + colored(replacement, 'green')) class MacroCheckerFile(object): def __init__(self, checker, filename, enabled_messages, stream_maker): self.checker = checker self.filename = filename self.stream_maker = stream_maker self.enabled_messages = enabled_messages self.fixes = set() self.messages = [] self.pname_data = None self.pname_mentions = {} self.lines = [] # For both of these: # keys: entity name # values: MessageContext self.fs_api_includes = {} self.validity_includes = {} self.in_code_block = False def numDiagnostics(self): return len(self.messages) def numErrors(self): return self.numMessagesOfType(MessageType.ERROR) def numMessagesOfType(self, message_type): return len([msg for msg in self.messages if msg.message_type == message_type]) def hasFixes(self): return len(self.fixes) > 0 def printMessageCounts(self): for message_type in [MessageType.ERROR, MessageType.WARNING]: count = len( [msg for msg in self.messages if msg.message_type == message_type]) if count > 0: print('{num} {mtype}{s} generated.'.format( num=count, mtype=message_type, s=s_suffix(count))) def diag(self, severity, message_id, messageLines, group=None, replacement=None, context=None, fix=None, see_also=None, frame=None): if message_id not in self.enabled_messages: return 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 warning(self, message_id, messageLines, group=None, replacement=None, context=None, fix=None, see_also=None, frame=None): if not frame: frame = currentframe().f_back self.diag(MessageType.WARNING, message_id, messageLines, group, replacement, context, fix, see_also) def error(self, message_id, messageLines, group=None, replacement=None, context=None, fix=None, see_also=None, frame=None): if not frame: frame = currentframe().f_back self.diag(MessageType.ERROR, message_id, messageLines, group, replacement, context, fix, see_also) def storeMessageContext(self, group=None): """Create message context from corresponding instance variables.""" return MessageContext(filename=self.filename, lineNum=self.lineNum, line=self.line, match=self.match, group=group) def makeFix(self, newMacro=None, newEntity=None, data=None): return (self.makeSearch(), self.makeMacroMarkup(newMacro, newEntity, data)) def makeSearch(self): return '{}:{}'.format(self.macro, self.entity) def makeMacroMarkup(self, newMacro=None, newEntity=None, data=None): 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): if not newEntity: newEntity = self.entity return '`<<{}>>`'.format(newEntity) def hasMember(self, elem, memberName): pass def checkRecognizedEntity(self): """Returns true if there is no need to perform further checks on this match. Helps avoid duplicate warnings/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.categoriesByMacro.get(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 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. msg.append('Apparently matching entity in category {} found.'.format( data.category)) if data.macro[0] == macro[0] and data.macro[1:] == 'link' and macro[1:] == 'name': # This is legacy markup msg.append( 'This is legacy markup that has not been updated yet.') message_id = MessageId.LEGACY else: 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)) 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] self.error(MessageId.WRONG_MACRO, msg + ['Apparently matching entity in category {} found by searching case-insensitively.'.format(data.category)], replacement=self.makeMacroMarkup(data=data), fix=self.makeFix(data=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 macro in self.checker.linkedMacros: # This should be linked, which means we should know the target. if not likelyRecognizedEntity(entity): newMacro = macro[0] + 'name' if newMacro in self.checker.categoriesByMacro: self.error(MessageId.BAD_ENTITY, msg + ['Entity name fits the pattern for this API, which would mean it should be a "name" macro instead of "link"'], 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', 'However, {} is not a known macro so cannot auto-fix.'.format(newMacro)], see_also=see_also) else: # no idea why it's missing, it just is. Human brains required. if not self.checkText(): self.error(MessageId.BAD_ENTITY, msg + ['Might be a misspelling or the wrong macro.'], 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 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): ### # Wildcards (or leading or trailing underscore, or square brackets with nothing or a placeholder) if and only if a 'text' macro 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 newMacro in self.checker.categoriesByMacro: 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: 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 newMacro in self.checker.categoriesByMacro: 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 checkPname(self, pname_context): 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 and ? 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 processMatch(self): match = self.match entity = self.entity macro = self.macro ### # Track entities that we're actually linking to. ### if macro in self.checker.linkedMacros: 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 self.checker.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.pname_data is not None: # No, but there is a pname_context self.checkPname(self.pname_data.entity) if self.pname_data.entity in self.pname_mentions and self.pname_mentions[self.pname_data.entity] is not None: # Record this mention, in case we're in the documentation block. self.pname_mentions[self.pname_data.entity].add(entity) else: # no, and no existing pname_context from an include - can't check this. pass def recordInclude(self, include_dict, generated_type=None): entity = self.match.group('entity_name') if generated_type is None: generated_type = self.match.group('generated_type') if entity in include_dict: self.warning(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 processLine(self, lineNum, line): self.lineNum = lineNum self.line = line self.match = None self.entity = None self.macro = None if line.startswith('----'): self.in_code_block = not self.in_code_block return if self.in_code_block: # We do no processing in a code block. return ### # Detect headings if line.startswith('=='): # Headings cause us to clear our pname_context self.pname_data = None command = HEADING_COMMAND.match(line) if command: data = self.checker.findEntity(command) if data: self.pname_data = data return ### # Detect [open, lines for manpages # TODO: Verify these (and use for pname context) instead of skipping them. if line.startswith('[open,refpage='): 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') if not entity in self.checker.byEntity: self.warning(MessageId.UNKNOWN_INCLUDE, ['Saw include for {}, but that entity is unknown.'.format(entity), 'This could be typo, or it could be a bug in check_spec_links.']) self.pname_data = None return self.pname_data = self.checker.byEntity[entity] 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: self.warning(MessageId.API_VALIDITY_ORDER, ['/api/ include found for {} after a corresponding /validity/ include'.format(entity), 'Validity include located at {}'.format(toNameAndLine(self.validity_includes[entity]))]) elif match.group('generated_type') == 'validity': self.recordInclude(self.checker.validityIncludes) self.validity_includes[entity] = self.storeMessageContext() if self.pname_mentions[entity] is not None: # 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 len(missing) > 0: self.warning(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 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 SUSPECTED_MISSING_MACRO.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.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.regex.finditer(line): self.match = match self.macro = match.group('macro') self.entity = match.group('entity_name') self.subscript = match.group('subscript') self.processMatch() def process(self): with self.stream_maker.make_stream() as f: for lineIndex, line in enumerate(f): trimmedLine = line.rstrip() self.lines.append(trimmedLine) self.processLine(lineIndex + 1, trimmedLine) # 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.entityHasValidity(entity): continue if entity in 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.warning(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)) class MacroChecker(object): def __init__(self, enabled_messages): self.enabled_messages = enabled_messages self.files = [] self.byEntity = {} self.byLowercaseEntity = {} self.byMacroAndEntity = {} self.categoriesByMacro = {} # keys: entity names. values: MessageContext self.links = {} self.apiIncludes = {} self.validityIncludes = {} self.headings = {} # Entities that get a generated/api/category/entity.txt file. self.generatingEntities = {} # The rest are added later inside addMacros self.linkedMacros = set(['basetype']) self.registryFile = str(REGISTRY_FILE) self.registry = Registry() self.registry.loadFile(self.registryFile) self.registry.parseTree() self.addMacro('basetype', ['basetypes']) self.addMacro('code', ['code']) self.addMacros('f', ['link', 'name', 'text'], ['protos']) self.addMacros('s', ['link', 'name', 'text'], ['structs', 'handles']) self.addMacros('e', ['link', 'name', 'text'], ['enums']) self.addMacros('p', ['name', 'text'], ['parameter', 'member']) self.addMacros('t', ['link', 'name'], ['funcpointers', 'flags']) self.addMacros('d', ['link', 'name'], ['defines', 'configdefines']) self.NON_EXISTENT_MACROS = set(['plink', 'ttext', 'dtext']) for macro in self.NON_EXISTENT_MACROS: # Still search for them self.addMacro(macro, None) # the "formatting" group is to strip matching */**/_/__ surrounding an entire macro. self.regex = re.compile( r'(?P\**|_*)(?P{}):(?P[\w*]+((?P[\[][^\]]*[\]]))?)(?P=formatting)'.format('|'.join([re.escape(macro) for macro in self.categoriesByMacro]))) for t in SYSTEM_TYPES: self.addEntity(t, 'code', generates=False) for name, info in self.registry.typedict.items(): if name in SYSTEM_TYPES: # We already added these. continue requires = info.elem.get('requires') if requires == PLATFORM_REQUIRES: # Ah, no, don't skip this, it's just in the platform header file. # TODO are these code or basetype? self.addEntity(name, 'code', elem=info.elem) continue if requires and not requires.lower().startswith(NAME_PREFIX): # This is an externally-defined type, will skip it. continue protect = info.elem.get('protect') if protect: self.addEntity(protect, 'dlink', category='configdefines') cat = info.elem.get('category') if cat == 'struct': self.addEntity(name, 'slink', elem=info.elem) elif cat == 'union': # TODO: is this right? self.addEntity(name, 'slink', elem=info.elem) elif cat == 'enum': self.addEntity( name, 'elink', elem=info.elem) elif cat == 'handle': self.addEntity(name, 'slink', elem=info.elem, category='handles') elif cat == 'bitmask': self.addEntity( name, 'tlink', elem=info.elem, category='flags') elif cat == 'basetype': self.addEntity(name, 'basetype', elem=info.elem) elif cat == 'define': self.addEntity(name, 'dlink', elem=info.elem) elif cat == 'funcpointer': self.addEntity(name, 'tlink', elem=info.elem) elif cat == 'include': # skip continue elif cat is None: self.addEntity(name, 'code', elem=info.elem) else: raise RuntimeError('unrecognized category {}'.format(cat)) for name, info in self.registry.enumdict.items(): self.addEntity(name, 'ename', elem=info.elem, category='enumvalues') for name, info in self.registry.cmddict.items(): self.addEntity(name, 'flink', elem=info.elem, category='commands', directory='protos') for name, info in self.registry.extdict.items(): # Only get the protect strings and name from extensions self.addEntity(name, None, category=EXTENSION_CATEGORY, generates=False) protect = info.elem.get('protect') if protect: self.addEntity(protect, 'dlink', category='configdefines') # These are not mentioned in the XML for name in EXTRA_DEFINES: self.addEntity(name, 'dlink', category='configdefines') def addMacro(self, macro, categories): self.categoriesByMacro[macro] = categories def addMacros(self, letter, macroTypes, categories): for macroType in macroTypes: macro = letter + macroType self.addMacro(macro, categories) if macroType == 'link': self.linkedMacros.add(macro) def addEntity(self, entityName, macro, category=None, elem=None, filename=None, directory=None, generates=True): if category is None: category = self.categoriesByMacro.get(macro)[0] if directory is None: directory = category # Don't generate a filename for ename or code, or configdefines because there are no files for those. if filename is None and macro not in ['ename', 'code'] and category != 'configdefines' and generates: filename = '{}/{}.txt'.format(directory, entityName) data = EntityData( entity=entityName, macro=macro, elem=elem, filename=filename, category=category ) if entityName in self.byEntity: # skip return if entityName.lower() not in self.byLowercaseEntity: self.byLowercaseEntity[entityName.lower()] = [] self.byEntity[entityName] = data self.byLowercaseEntity[entityName.lower()].append(data) self.byMacroAndEntity[(macro, entityName)] = data if generates and filename is not None: self.generatingEntities[entityName] = data def haveLinkTarget(self, entity): if not self.findEntity(entity): return None if entity in self.apiIncludes: return True return entity in self.headings def hasFixes(self): for f in self.files: if f.hasFixes(): return True return False def addLinkToEntity(self, entity, context): if entity not in self.links: self.links[entity] = [] self.links[entity].append(context) def findMacroAndEntity(self, macro, entity): return self.byMacroAndEntity.get((macro, entity)) def findEntity(self, entity): return self.byEntity.get(entity) def findEntityCaseInsensitive(self, entity): return self.byLowercaseEntity.get(entity.lower()) def getMemberElems(self, commandOrStruct): data = self.findEntity(commandOrStruct) if not data: return None if data.macro == 'slink': tag = 'member' else: tag = 'param' return data.elem.findall('.//{}'.format(tag)) def getMemberNames(self, commandOrStruct): members = self.getMemberElems(commandOrStruct) if not members: return [] ret = [] for member in members: name_tag = member.find('name') if name_tag: ret.append(name_tag.text) return ret def entityHasValidity(self, entity): # Related to ValidityGenerator.isStructAlwaysValid data = self.findEntity(entity) if not data: return None if entity in ENTITIES_WITHOUT_VALIDITY: return False if data.category == 'protos': # All protos have validity return True if data.category not in CATEGORIES_WITH_VALIDITY: return False # Handle structs here. members = self.getMemberElems(entity) if not members: return None for member in members: if member.find('name').text in ['next', 'type']: return True if member.find('type').text in ['void', 'char']: return True if member.get('noautovalidity'): # Not generating validity for this member, skip it continue if member.get('len'): # Array return True typetail = member.find('type').tail if typetail and '*' in typetail: # Pointer return True if member.get('category') in ['handle', 'enum', 'bitmask'] == 'type': return True if member.get('category') in ['struct', 'union'] and self.entityHasValidity(member.find('type').text): # struct or union member - recurse return True # Got this far - no validity needed return False def processFile(self, filename): class FileStreamMaker(object): def __init__(self, filename): self.filename = filename def make_stream(self): return open(self.filename, 'r', encoding='utf-8') f = MacroCheckerFile(self, filename, self.enabled_messages, FileStreamMaker(filename)) f.process() self.files.append(f) def processString(self, s): """For testing purposes""" filename = "string{}".format(len(self.files)) class StringStreamMaker(object): def __init__(self, string): self.string = string def make_stream(self): return StringIO(self.string) f = MacroCheckerFile(self, filename, self.enabled_messages, StringStreamMaker(s)) f.process() self.files.append(f) return f def numDiagnostics(self): return sum([f.numDiagnostics() for f in self.files]) def numErrors(self): return sum([f.numErrors() for f in self.files]) def getEntityJson(self): """Dump the internal entity dictionary to JSON for debugging""" d = {entity: entityToDict(data) for entity, data in self.byEntity.items()} return json.dumps(d, sort_keys=True, indent=4) def getMissingUnreferencedApiIncludes(self): return [entity for entity, data in self.generatingEntities.items() if (not self.haveLinkTarget(entity)) and entity not in self.links] def getBrokenLinks(self): return {entity: contexts for entity, contexts in self.links.items() if entity in self.generatingEntities and not self.haveLinkTarget(entity)} class BasePrinter(ABC): def close(self): pass def getRelativeFilename(self, fn): try: return str(Path(fn).relative_to(CWD)) except ValueError: return str(Path(fn)) def formatContext(self, context, message_type=None): return self.formatContextBrief(context) def formatContextBrief(self, context, with_color=True): return '{}:{}:{}'.format(self.getRelativeFilename(context.filename), context.lineNum, getColumn(context)) def formatMessageTypeBrief(self, message_type, with_color=True): return '{}:'.format(message_type) def formatEntityBrief(self, entity_data, with_color=True): return '{}:{}'.format(entity_data.macro, entity_data.entity) def formatBrief(self, obj, with_color=True): if isinstance(obj, MessageContext): return self.formatContextBrief(obj, with_color) if isinstance(obj, MessageType): return self.formatMessageTypeBrief(obj, with_color) if isinstance(obj, EntityData): return self.formatEntityBrief(obj, with_color) return str(obj) @abstractmethod def outputMessage(self, msg): raise NotImplementedError @abstractmethod def outputFallback(self, msg): raise NotImplementedError def outputCheckerFile(self, fileChecker): for m in fileChecker.messages: self.output(m) def outputChecker(self, checker): for f in checker.files: self.output(f) def output(self, obj): if isinstance(obj, Message): self.outputMessage(obj) elif isinstance(obj, MacroCheckerFile): self.outputCheckerFile(obj) elif isinstance(obj, MacroChecker): self.outputChecker(obj) else: self.outputFallback(self.formatBrief(obj)) @abstractmethod def outputResults(self, checker, broken_links=True, missing_includes=False): raise NotImplementedError class ConsolePrinter(BasePrinter): def formatFilename(self, fn, with_color=True): return self.getRelativeFilename(fn) def formatMessageTypeBrief(self, message_type, with_color=True): if with_color: return message_type.formattedWithColon() else: return super(ConsolePrinter, self).formatMessageTypeBrief(message_type, with_color) def outputFallback(self, obj): print(obj) def outputMessage(self, msg): highlightStart, highlightEnd = getHighlightedRange(msg.context) fileAndLine = colored('{}:'.format( self.formatBrief(msg.context)), attrs=['bold']) headingSize = len('{context}: {mtype}: '.format( context=self.formatBrief(msg.context), mtype=self.formatBrief(msg.message_type, False))) indent = ' ' * headingSize printedHeading = False lines = msg.message[:] if msg.see_also is not None and len(msg.see_also) != 0: lines.append('See also:') for see in msg.see_also: lines.append(' {}'.format(self.formatBrief(see))) if msg.fix is not None: lines.append('Note: Auto-fix available') for line in msg.message: if not printedHeading: scriptloc = '' if msg.script_location: scriptloc = ', ' + msg.script_location print('{fileLine} {mtype} {msg} (-{arg}{loc})'.format( fileLine=fileAndLine, mtype=msg.message_type.formattedWithColon(), msg=colored(line, attrs=['bold']), arg=msg.message_id.enable_arg(), loc=scriptloc)) printedHeading = True else: print(colored(indent + line, attrs=['bold'])) if len(msg.message) > 1: # extra blank line after multiline message print('') start, end = getInterestedRange(msg.context) printLineSubsetWithHighlighting( msg.context.line, start, end, highlightStart, highlightEnd, replacement=msg.replacement) def outputBrokenLinks(self, checker, broken): print('Missing API includes that are referenced by a linking macro: these result in broken links in the spec!') def makeRowOfBroken(entity, uses): fn = checker.findEntity(entity).filename anchor = '[[{}]]'.format(entity) locations = ', '.join([toNameAndLine(context) for context in uses]) return (fn, anchor, locations) printTabulated(sorted([makeRowOfBroken(entity, uses) for entity, uses in broken.items()]), headers=['Include File', 'Anchor in lieu of include', 'Links to this entity']) def outputMissingIncludes(self, checker, missing): print( 'Missing, but unreferenced, API includes/anchors - potentially not-documented entities:') def makeRowOfMissing(entity): fn = checker.findEntity(entity).filename anchor = '[[{}]]'.format(entity) return (fn, anchor) printTabulated(sorted([makeRowOfMissing(entity) for entity in missing]), headers=['Include File', 'Anchor in lieu of include']) def outputResults(self, checker, broken_links=True, missing_includes=False): self.output(checker) if broken_links: broken = checker.getBrokenLinks() if len(broken) > 0: self.outputBrokenLinks(checker, broken) if missing_includes: missing = checker.getMissingUnreferencedApiIncludes() if len(missing) > 0: self.outputMissingIncludes(checker, missing) MESSAGE_TYPE_STYLES = { MessageType.ERROR: 'danger', MessageType.WARNING: 'warning', MessageType.NOTE: 'secondary' } MESSAGE_TYPE_ICONS = { MessageType.ERROR: '⊗', # makeIcon('times-circle'), MessageType.WARNING: '⚠', # makeIcon('exclamation-triangle'), MessageType.NOTE: 'ℹ' # makeIcon('info-circle') } LINK_ICON = '🔗' # link icon class HTMLPrinter(BasePrinter): def __init__(self, filename): self.f = open(filename, 'w', encoding='utf-8') self.f.write(""" check_spec_links results

check_spec_links.py Scan Results

""") # self.filenameTransformer = re.compile(r'[^\w]+') self.fileRange = {} self.fileLines = {} self.backLink = namedtuple( 'BackLink', ['lineNum', 'col', 'end_col', 'target', 'tooltip', 'message_type']) self.fileBackLinks = {} self.nextAnchor = 0 def close(self): self.f.write("""
""") self.f.close() def recordUsage(self, context, linkBackTooltip=None, linkBackTarget=None, linkBackType=MessageType.NOTE): BEFORE_CONTEXT = 6 AFTER_CONTEXT = 3 # Clamp because we need accurate start line number to make line number display right start = max(1, context.lineNum - BEFORE_CONTEXT) stop = context.lineNum + AFTER_CONTEXT + 1 if context.filename not in self.fileRange: self.fileRange[context.filename] = range(start, stop) self.fileBackLinks[context.filename] = [] else: oldRange = self.fileRange[context.filename] self.fileRange[context.filename] = range( min(start, oldRange.start), max(stop, oldRange.stop)) if linkBackTarget is not None: start_col, end_col = getHighlightedRange(context) self.fileBackLinks[context.filename].append(self.backLink( lineNum=context.lineNum, col=start_col, end_col=end_col, target=linkBackTarget, tooltip=linkBackTooltip, message_type=linkBackType)) def formatFilename(self, fn): return '{relative}'.format( href=fn, relative=self.getRelativeFilename(fn)) def formatContext(self, context, message_type=None): if message_type is None: icon = LINK_ICON else: icon = MESSAGE_TYPE_ICONS[message_type] return 'In context: {icon}{relative}:{lineNum}:{col}'.format( href=self.getAnchorLinkForContext(context), icon=icon, id=self.makeIdentifierFromFilename(context.filename), relative=self.getRelativeFilename(context.filename), lineNum=context.lineNum, col=getColumn(context)) def makeIdentifierFromFilename(self, fn): return self.filenameTransformer.sub('_', self.getRelativeFilename(fn)) def getAnchorLinkForContext(self, context): return '#excerpt-{}.{}'.format(self.makeIdentifierFromFilename(context.filename), context.lineNum) def outputFallback(self, obj): self.f.write(obj) def getUniqueAnchor(self): """Create and return a new unique string usable as a link anchor""" anchor = 'anchor-{}'.format(self.nextAnchor) self.nextAnchor += 1 return anchor def outputMessage(self, msg): anchor = self.getUniqueAnchor() self.recordUsage(msg.context, linkBackTarget=anchor, linkBackTooltip='{}: {} [...]'.format( msg.message_type, msg.message[0]), linkBackType=msg.message_type) self.f.write("""
{icon} {t} Line {lineNum}, Column {col} (-{arg})

""".format( anchor=anchor, icon=MESSAGE_TYPE_ICONS[msg.message_type], style=MESSAGE_TYPE_STYLES[msg.message_type], t=self.formatBrief(msg.message_type), lineNum=msg.context.lineNum, col=getColumn(msg.context), arg=msg.message_id.enable_arg())) self.f.write(self.formatContext(msg.context)) self.f.write('
') for line in msg.message: self.f.write(html.escape(line)) self.f.write('
\n') self.f.write('

\n') if msg.see_also is not None and len(msg.see_also) != 0: self.f.write('

See also:

    \n') for see in msg.see_also: if isinstance(see, MessageContext): self.f.write( '
  • {}
  • \n'.format(self.formatContext(see))) self.recordUsage(see, linkBackTarget=anchor, linkBackType=MessageType.NOTE, linkBackTooltip='see-also associated with {} at {}'.format(msg.message_type, self.formatContextBrief(see))) else: self.f.write('
  • {}
  • \n'.format(self.formatBrief(see))) self.f.write('
') if msg.replacement is not None: self.f.write( '
Hover the highlight text to view suggested replacement.
') if msg.fix is not None: self.f.write( '
Note: Auto-fix available.
') if msg.script_location: self.f.write( '

Message originated at {}

'.format(msg.script_location)) self.f.write('
'.format(
            msg.context.lineNum))
        highlightStart, highlightEnd = getHighlightedRange(msg.context)
        self.f.write(html.escape(msg.context.line[:highlightStart]))
        self.f.write(
            '')
        self.f.write(html.escape(
            msg.context.line[highlightStart:highlightEnd]))
        self.f.write('')
        self.f.write(html.escape(msg.context.line[highlightEnd:]))
        self.f.write('
') def outputCheckerFile(self, fileChecker): # Save lines for later self.fileLines[fileChecker.filename] = fileChecker.lines if not fileChecker.numDiagnostics(): return self.f.write("""
""".format(id=self.makeIdentifierFromFilename(fileChecker.filename), relativefn=html.escape(self.getRelativeFilename(fileChecker.filename)))) self.f.write('
') warnings = fileChecker.numMessagesOfType(MessageType.WARNING) if warnings > 0: self.f.write(""" {icon} {num} warnings""".format(num=warnings, icon=MESSAGE_TYPE_ICONS[MessageType.WARNING])) self.f.write('
\n
') errors = fileChecker.numMessagesOfType(MessageType.ERROR) if errors > 0: self.f.write(""" {icon} {num} errors""".format(num=errors, icon=MESSAGE_TYPE_ICONS[MessageType.ERROR])) self.f.write("""
""".format( linkicon=LINK_ICON, id=self.makeIdentifierFromFilename(fileChecker.filename), fn=html.escape(fileChecker.filename))) super(HTMLPrinter, self).outputCheckerFile(fileChecker) self.f.write("""
""".format(id=self.makeIdentifierFromFilename(fileChecker.filename))) def outputChecker(self, checker): self.f.write( '

Per-File Warnings and Errors

\n') self.f.write('
\n') super(HTMLPrinter, self).outputChecker(checker) self.f.write("""
\n""") def outputBrokenLinks(self, checker, broken): self.f.write("""

Missing Referenced API Includes

Items here have been referenced by a linking macro, so these are all broken links in the spec!

""") for entity in sorted(broken): uses = broken[entity] category = checker.findEntity(entity).category anchor = self.getUniqueAnchor() asciidocAnchor = '[[{}]]'.format(entity) include = generateInclude(dir_traverse='../../generated/', generated_type='api', category=category, entity=entity) self.f.write(""" """) self.f.write("""
Add line to include this file or add this macro instead Links to this entity
{} {}
    """.format(anchor, include, asciidocAnchor)) for context in uses: self.f.write( '
  • {}
  • '.format(self.formatContext(context, MessageType.NOTE))) self.recordUsage(context, linkBackTooltip='Link broken in spec: {} not included'.format( fn), linkBackTarget=anchor, linkBackType=MessageType.NOTE) self.f.write("""
""") def outputMissingIncludes(self, checker, missing): self.f.write("""

Missing Unreferenced API Includes

These items are expected to be generated in the spec build process, but aren't included. However, as they also are not referenced by any linking macros, they aren't broken links - at worst they are undocumented entities, at best they are errors in check_spec_links.py logic computing which entities get generated files.

""") for entity in sorted(missing): fn = checker.findEntity(entity).filename anchor = '[[{}]]'.format(entity) self.f.write(""" """.format(filename=fn, anchor=anchor)) self.f.write("""
Add line to include this file or add this macro instead
{filename} {anchor}
""") def outputFileExcerpt(self, filename): self.f.write("""
""".format(id=self.makeIdentifierFromFilename(filename), fn=self.getRelativeFilename(filename))) lines = self.fileLines[filename] r = self.fileRange[filename] self.f.write("""
""".format(
            id=self.makeIdentifierFromFilename(filename),
            start=r.start))
        for lineNum, line in enumerate(lines[(r.start - 1):(r.stop - 1)], r.start):
            # self.f.write(line)
            lineLinks = [x for x in self.fileBackLinks[filename]
                         if x.lineNum == lineNum]
            # opened_links = 0
            for col, char in enumerate(line):
                colLinks = [x for x in lineLinks if x.col == col]
                # opened_links += len(colLinks)
                for link in colLinks:
                    # TODO right now the syntax highlighting is interfering with the link! so the link-generation is commented out,
                    # only generating the emoji icon.

                    # self.f.write('{icon}'.format(
                    #     target=link.target, title=html.escape(link.tooltip), icon=MESSAGE_TYPE_ICONS[link.message_type]))
                    self.f.write(MESSAGE_TYPE_ICONS[link.message_type])
                    self.f.write('Cross reference: {t} {title}'.format(
                        title=html.escape(link.tooltip, False), t=link.message_type))

                    # If you uncomment the following line, don't uncomment anything related to opened_links
                    # self.f.write('')

                # Write the actual character
                self.f.write(html.escape(char))
                # Close any applicable links
                # links_to_close = len([x for x in lineLinks if x.end_col == col])
                # self.f.write('' * links_to_close)
                # opened_links -= links_to_close

            # Close any leftovers
            # self.f.write('' * opened_links)
            self.f.write('\n')

        self.f.write('
') self.f.write('
\n') self.f.write('
\n') def outputResults(self, checker, broken_links=True, missing_includes=False): self.output(checker) if broken_links: broken = checker.getBrokenLinks() if len(broken) > 0: self.outputBrokenLinks(checker, broken) if missing_includes: missing = checker.getMissingUnreferencedApiIncludes() if len(missing) > 0: self.outputMissingIncludes(checker, missing) self.f.write("""

Excerpts of referenced files

""") for fn in self.fileRange: self.outputFileExcerpt(fn) self.f.write('
\n') if __name__ == '__main__': enabled_messages = MessageId.default_set() disable_args = [] enable_args = [] parser = argparse.ArgumentParser() parser.add_argument("-Werror", "--warning_error", help="Make warnings act as errors, exiting with non-zero error code", action="store_true") parser.add_argument("--include_warn", help="List all expected but unseen include files, not just those that are referenced.", action='store_true') parser.add_argument("--include_error", help="Make expected but unseen include files cause exiting with non-zero error code", action='store_true') parser.add_argument("--broken_error", help="Make missing include/anchor for linked-to entities cause exiting with non-zero error code. Weaker version of --include_error.", action='store_true') parser.add_argument("--dump_entities", help="Just dump the parsed entity data to entities.json and exit.", action='store_true') parser.add_argument("--html", help="Output messages to the named HTML file instead of stdout.") parser.add_argument("file", help="Only check the indicated file(s). By default, all chapters and extensions are checked.", nargs="*") parser.add_argument("--ignore_count", type=int, help="Ignore up to the given number of errors without exiting with a non-zero error code.") parser.add_argument("-Wall", help="Enable all warning categories.", action='store_true') for message_id in MessageId: enable_arg = message_id.enable_arg() enable_args.append((message_id, enable_arg)) disable_arg = message_id.disable_arg() disable_args.append((message_id, disable_arg)) if message_id in enabled_messages: parser.add_argument('-' + enable_arg, action="store_true", help="Disable message category {}: {}".format(str(message_id), message_id.desc())) # Don't show the disable flag in help since it's disabled by default parser.add_argument('-' + disable_arg, action="store_true", help=argparse.SUPPRESS) else: parser.add_argument('-' + disable_arg, action="store_true", help="Enable message category {}: {}".format(str(message_id), message_id.desc())) # Don't show the enable flag in help since it's enabled by default parser.add_argument('-' + enable_arg, action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() arg_dict = vars(args) for message_id, arg in enable_args: if args.Wall or (arg in arg_dict and arg_dict[arg]): enabled_messages.add(message_id) for message_id, arg in disable_args: if arg in arg_dict and arg_dict[arg]: enabled_messages.discard(message_id) checker = MacroChecker(enabled_messages) if args.dump_entities: with open('entities.json', 'w', encoding='utf-8') as f: f.write(checker.getEntityJson()) exit(0) if args.file: files = [str(Path(f).resolve()) for f in args.file] else: files = ALL_DOCS for fn in files: checker.processFile(fn) if args.html: printer = HTMLPrinter(args.html) else: printer = ConsolePrinter() if args.file: printer.output("Only checked specified files.") for f in args.file: printer.output(f) else: printer.output("Checked all chapters and extensions.") if args.warning_error: numErrors = checker.numDiagnostics() else: numErrors = checker.numErrors() check_includes = args.include_warn check_broken = True if args.file and args.include_warn: print('Note: forcing --include_warn off because only checking supplied files.') printer.outputResults(checker, broken_links=(not args.file), missing_includes=(args.include_warn and not args.file)) if args.include_error and not args.file: numErrors += len(checker.getMissingUnreferencedApiIncludes()) printer.close() if args.broken_error and not args.file: numErrors += len(checker.getBrokenLinks()) if checker.hasFixes(): fixFn = 'applyfixes.sh' print('Saving shell script to apply fixes as {}'.format(fixFn)) with open(fixFn, 'w', encoding='utf-8') as f: f.write('#!/bin/sh -e\n') for fileChecker in checker.files: wroteComment = False for msg in fileChecker.messages: if msg.fix is not None: if not wroteComment: f.write('\n# {}\n'.format(fileChecker.filename)) wroteComment = True search, replace = msg.fix f.write( r"sed -i -r 's~\b{}\b~{}~g' {}".format(re.escape(search), replace, fileChecker.filename)) f.write('\n') print('Total number of errors with this run: {}'.format(numErrors)) if args.ignore_count: if numErrors > args.ignore_count: # Exit with non-zero error code so that we "fail" CI, etc. print('Exceeded specified limit of {}, so exiting with error'.format( args.ignore_count)) exit(1) else: print('At or below specified limit of {}, so exiting with success'.format( args.ignore_count)) exit(0) if numErrors: # Exit with non-zero error code so that we "fail" CI, etc. print('Exiting with error') exit(1) else: print('Exiting with success') exit(0)