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