xref: /petsc/lib/petsc/bin/maint/petsclinter/petsclinter/classes/docs/_doc_section.py (revision ec42381fdf5bb48a6ea45cf3b5c9e6ed3c5f82db)
1#!/usr/bin/env python3
2"""
3# Created: Thu Nov 17 11:50:52 2022 (-0500)
4# @author: Jacob Faibussowitsch
5"""
6from __future__ import annotations
7
8import re
9import difflib
10import clang.cindex as clx # type: ignore[import]
11
12from ..._typing import *
13
14from .._diag    import DiagnosticManager, Diagnostic
15from .._src_pos import SourceRange
16from .._cursor  import Cursor
17from .._patch   import Patch
18from .._path    import Path
19
20from ._doc_section_base import SectionBase, Synopsis, ParameterList, Prose, VerbatimBlock, InlineList
21
22from ...util._clang import clx_char_type_kinds, clx_function_type_kinds
23
24"""
25==========================================================================================
26Derived Classes
27
28==========================================================================================
29"""
30class DefaultSection(SectionBase):
31  @classmethod
32  def __diagnostic_prefix__(cls, *flags):
33    return DiagnosticManager.flag_prefix(super())('', *flags)
34
35  def __init__(self, *args, **kwargs) -> None:
36    r"""Construct a `DefaultSection`
37
38    Parameters
39    ----------
40    *args :
41      additional positional arguments to `SectionBase.__init__()`
42    **kwargs :
43      additional keyword arguments to `SectionBase.__init__()`
44    """
45    kwargs.setdefault('name', 'UNKNOWN_SECTION')
46    kwargs.setdefault('titles', ('__UNKNOWN_SECTION__',))
47    super().__init__(*args, **kwargs)
48    return
49
50class FunctionSynopsis(Synopsis):
51  SynopsisItemType: TypeAlias = List[Tuple[SourceRange, str]]
52  ItemsType                   = TypedDict(
53    'ItemsType',
54    # We want to extend Synopsis.ItemsType, this is the only way I saw how. I tried doing
55    # {'synopsis' : ..., ***typing.get_type_hints(Synopsis.ItemsType)}
56    #
57    # but mypy barfed:
58    #
59    # error: Invalid TypedDict() field name  [misc]
60    # {'synopsis' : List[Synopsis.ItemsEntryType], **typing.get_type_hints(Synopsis.ItemsType)}
61    {
62      'synopsis' : SynopsisItemType,
63      'name'     : Synopsis.NameItemType,
64      'blurb'    : Synopsis.BlurbItemType
65    }
66  )
67  items: ItemsType
68
69  class Inspector(Synopsis.Inspector):
70    __slots__ = ('synopsis_items', )
71
72    synopsis_items: FunctionSynopsis.SynopsisItemType
73
74    def __init__(self, cursor: Cursor) -> None:
75      r"""Construct an `Inspecto` for a funciton synopsis
76
77      Parameters
78      ----------
79      cursor :
80        the cursor that this docstring belongs to
81      """
82      super().__init__(cursor)
83      self.synopsis_items = []
84      return
85
86    def __call__(self, ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
87      super().__call__(ds, loc, line, verdict)
88      if self.found_synopsis:
89        return
90
91      lstrp = line.strip()
92      if 'synopsis:' in lstrp.casefold():
93        self.capturing = self.CaptureKind.SYNOPSIS
94      if self.capturing == self.CaptureKind.SYNOPSIS:
95        # don't want to accidentally capture the blurb
96        if lstrp:
97          self.synopsis_items.append((ds.make_source_range(lstrp, line, loc.start.line), line))
98        else:
99          # reached the end of the synopsis block
100          self.found_synopsis = True
101          self.capturing      = self.CaptureKind.NONE
102      return
103
104    def get_items(self, ds: PetscDocStringImpl) -> FunctionSynopsis.ItemsType:
105      r"""Get the items from this `Inspector`
106
107      Parameters
108      ----------
109      ds :
110        the docstring (unused)
111
112      Returns
113      -------
114      items :
115        the items
116      """
117      return {
118        'synopsis' : self.synopsis_items,
119        'name'     : self.items['name'],
120        'blurb'    : self.items['blurb']
121      }
122
123  def setup(self, ds: PetscDocStringImpl) -> None:
124    r"""Set up a `FunctionSynopsis`
125
126    Parameters
127    ----------
128    ds :
129      the `PetscDocString` instance for this section
130    """
131    inspector = self.Inspector(ds.cursor)
132    super()._do_setup(ds, inspector)
133    self.items = inspector.get_items(ds)
134    return
135
136  def _check_macro_synopsis(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl, explicit_synopsis: SynopsisItemType) -> bool:
137    r"""Ensure that synopsese of macros exist and have proper prototypes
138
139    Parameters
140    ----------
141    linter :
142      the `Linter` instance to log errors to
143    cursor :
144      the cursor this docstring section belongs to
145    docstring :
146      the docstring that owns this section
147    explicit_synopsis :
148      the list of source-range - text pairs of lines that make up the synopsis section
149
150    Returns
151    -------
152    should_check :
153      True if the section should continue to check that the synopsis name matches the symbol
154
155    Notes
156    -----
157    If the synopsis is a macro type, then the name in the synopsis won't match the actual symbol type,
158    so it is pointless to check it
159    """
160    if not (len(explicit_synopsis) or docstring.Modifier.FLOATING in docstring.type_mod):
161      # we are missing the synopsis section entirely
162      with open(cursor.get_file()) as fh:
163        gen   = (l.strip() for l in fh if l.lstrip().startswith('#') and 'include' in l and '/*' in l)
164        lines = [
165          l.group(2).strip() for l in filter(None, map(self._sowing_include_finder.match, gen))
166        ]
167
168      try:
169        include_header = lines[0]
170      except IndexError:
171        include_header = '"some_header.h"'
172      args        = ', '.join(
173        f'{c.derivedtypename} {c.name}' for c in linter.get_argument_cursors(cursor)
174      )
175      extent      = docstring._attr['sowing_char_range']
176      macro_ident = docstring.make_source_range('M', extent[0], extent.start.line)
177      docstring.add_diagnostic(
178        docstring.make_diagnostic(
179          Diagnostic.Kind.ERROR, self.diags.macro_explicit_synopsis_missing,
180          f'Macro docstring missing an explicit synopsis {Diagnostic.FLAG_SUBST}',
181          self.extent, highlight=False
182        ).add_note(
183          '\n'.join([
184            'Expected:',
185            '',
186            '  Synopsis:',
187            f'  #include {include_header}',
188            f'  {cursor.result_type.spelling} {cursor.name}({args})'
189          ])
190        ).add_note(
191          f'symbol marked as macro here, but it is ambiguous if this symbol is really meant to be a macro or not\n{macro_ident.formatted(num_context=2)}',
192          location=macro_ident.start
193        ),
194        cursor=cursor
195      )
196       # the code should not check the name
197      return False
198
199    # search the explicit docstring for the
200    # #include <header.h>
201    # line
202    header_name = ''
203    header_loc  = None
204    for loc, line in explicit_synopsis:
205      stripped = line.strip()
206      if not stripped or stripped.endswith(':') or stripped.casefold().startswith('synopsis'):
207        continue
208      if found := self._header_include_finder.match(stripped):
209        header_name = found.group(1)
210        header_loc  = loc
211        break
212
213    if not header_name:
214      print(80*'=')
215      docstring.extent.view()
216      print('')
217      print('Dont know how to handle no header name yet')
218      print(80*'=')
219      return False # don't know how to handle this
220
221    assert header_loc is not None
222    # TODO cursor.get_declaration() now appears it might work!
223    # decl = cursor.get_declaration()
224
225    # OK found it, now find the actual file. Clang unfortunately cannot help us here since
226    # it does not pick up header that are in the precompiled header (which chances are,
227    # this one is). So we search for it ourselves
228    def find_header(directory: Path) -> Optional[Path]:
229      path = directory / header_name
230      if path.exists():
231        return path.resolve()
232      return None
233
234    header_path = None
235    for flag_path in (Path(flag[2:]) for flag in linter.flags if flag.startswith('-I')):
236      header_path = find_header(flag_path)
237      if header_path is not None:
238        break
239
240    if header_path is None:
241      header_path = find_header(Path(str(docstring.extent.start.file)).parent)
242
243    assert header_path
244    fn_name = self.items['name'][1]
245    decls   = [line for line in header_path.read_text().splitlines() if fn_name in line]
246    if not decls:
247      # the name was not in the header, so the docstring is wrong
248      mess = f"Macro docstring explicit synopsis appears to have incorrect include line. Could not locate '{fn_name}()' in '{header_name}'. Are you sure that's where it lives?"
249      docstring.add_diagnostic_from_source_range(
250        Diagnostic.Kind.ERROR, self.diags.macro_explicit_synopsis_valid_header, mess, header_loc
251      )
252      return False
253
254    cursor_spelling = cursor.spelling
255    if len(decls) > 1 and len(cursor_spelling) > len(fn_name):
256      decls2 = [
257        d.replace(cursor_spelling, '') for d in decls if fn_name in d.replace(cursor_spelling, '')
258      ]
259      # We removed the longer of the two names, and now don't have any matches. Maybe
260      # that means it's not defined in this header?
261      if not decls2:
262        mess = f'Removing {cursor.spelling} from the decl list:\n{decls}\nhas emptied it. Maybe this means {fn_name} is not defined in {header_path}?'
263        raise RuntimeError(mess)
264      decls = decls2
265    # the only remaining item should be the macro (or maybe function), note this
266    # probably needs a lot more postprocessing
267    # TODO
268    # assert len(decls) == 1
269    return False
270
271  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
272    r"""Perform all checks for this function synopsis
273
274    Parameters
275    ----------
276    linter :
277      the `Linter` instance to log any errors with
278    cursor :
279      the cursor to which the docstring this section belongs to belongs
280    docstring :
281      the docstring to which this section belongs
282    """
283    super().check(linter, cursor, docstring)
284
285    items = self.items
286    if items['name'][0] is None:
287      # missing synopsis entirely
288      docstring.add_diagnostic(
289        docstring.make_diagnostic(
290          Diagnostic.Kind.ERROR, self.diags.missing_description, 'Docstring missing synopsis',
291          self.extent, highlight=False
292        ).add_note(
293          f"Expected '{cursor.name} - a very useful description'"
294        )
295      )
296      return
297
298    if docstring.Modifier.MACRO in docstring.type_mod:
299      # chances are that if it is a macro then the name won't match
300      self._check_macro_synopsis(linter, cursor, docstring, items['synopsis'])
301      return
302
303    self._syn_common_checks(linter, cursor, docstring)
304    return
305
306class EnumSynopsis(Synopsis):
307  ItemsType = TypedDict(
308    'ItemsType',
309    {
310      'enum_params' : ParameterList,
311      'name'        : Synopsis.NameItemType,
312      'blurb'       : Synopsis.BlurbItemType
313    }
314  )
315  items: ItemsType
316
317  class Inspector(Synopsis.Inspector):
318    __slots__ = ('enum_params', )
319
320    enum_params: List[Tuple[SourceRange, str, Verdict]]
321
322    def __init__(self, cursor: Cursor) -> None:
323      super().__init__(cursor)
324      self.enum_params = []
325      return
326
327    def __call__(self, ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
328      super().__call__(ds, loc, line, verdict)
329      lstrp = line.lstrip()
330      # check that '-' is in the line since some people like to use entire blocks of $'s
331      # to describe a single enum value...
332      if lstrp.startswith('$') and '-' in lstrp:
333        from ._doc_str import Verdict # HACK?
334
335        assert self.items['name'][1] # we should have already found the symbol name
336        name = lstrp[1:].split(maxsplit=1)[0].strip()
337        self.enum_params.append(
338          (ds.make_source_range(name, line, loc.start.line), line, Verdict.NOT_HEADING)
339        )
340      return
341
342    # HACK
343    @staticmethod
344    def _check_enum_starts_with_dollar(params: ParameterList, ds: PetscDocStringImpl, items: ParameterList.ItemsType) -> ParameterList.ItemsType:
345      for key, opts in sorted(items.items()):
346        if len(opts) < 1:
347          raise RuntimeError(f'number of options {len(opts)} < 1, key: {key}, items: {items}')
348        for opt in opts:
349          params._check_opt_starts_with(ds, opt, 'Enum', '$')
350      return items
351
352    def get_items(self, ds: PetscDocStringImpl) -> EnumSynopsis.ItemsType:
353      params = ParameterList(name='enum params', prefixes=('$',))
354      assert self.enum_params, 'No parameter lines in enum description!'
355      params.consume(self.enum_params)
356      params.setup(ds, parameter_list_prefix_check=self._check_enum_starts_with_dollar)
357      return {
358        'enum_params' : params,
359        'name'        : self.items['name'],
360        'blurb'       : self.items['blurb']
361      }
362
363  def setup(self, ds: PetscDocStringImpl) -> None:
364    r"""Set up an `EnumSynopsis`
365
366    Parameters
367    ----------
368    ds :
369      the `PetscDocString` instance for this section
370    """
371    inspector = self.Inspector(ds.cursor)
372    super()._do_setup(ds, inspector)
373    self.items = inspector.get_items(ds)
374    return
375
376  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
377    r"""Perform all checks for this enum synopsis
378
379    Parameters
380    ----------
381    linter :
382      the `Linter` instance to log any errors with
383    cursor :
384      the cursor to which the docstring this section belongs to belongs
385    docstring :
386      the docstring to which this section belongs
387    """
388    super().check(linter, cursor, docstring)
389    if self.items['name'][0] is None:
390      # missing synopsis entirely
391      docstring.add_diagnostic(
392        docstring.make_diagnostic(
393          Diagnostic.Kind.ERROR, self.diags.missing_description, 'Docstring missing synopsis',
394          self.extent, highlight=False
395        ).add_note(
396          f"Expected '{cursor.name} - a very useful description'"
397        )
398      )
399    else:
400      self._syn_common_checks(linter, cursor, docstring)
401    return
402
403@DiagnosticManager.register(
404  ('parameter-documentation','Verify that if a, b, c are documented then the function exactly has parameters a, b, and c and vice versa'),
405  ('fortran-interface','Verify that functions needing a custom Fortran interface have the correct sowing indentifiers'),
406)
407class FunctionParameterList(ParameterList):
408  diags: DiagnosticMap # satisfy type checkers
409
410  @classmethod
411  def __diagnostic_prefix__(cls, *flags):
412    return DiagnosticManager.flag_prefix(super())('func', *flags)
413
414  def __init__(self, *args, **kwargs) -> None:
415    r"""Construct a `FunctionParameterList`
416
417    Parameters
418    ----------
419    *args :
420      additional positional arguments to `SectionBase.__init__()`
421    **kwargs :
422      additional keyword arguments to `SectionBase.__init__()`
423    """
424    kwargs.setdefault(
425      'titles', ('Input Parameter', 'Output Parameter', 'Calling sequence', 'Calling Sequence')
426    )
427    kwargs.setdefault('keywords', ('Input', 'Output', 'Calling sequence of', 'Calling Sequence Of'))
428    super().__init__(*args, **kwargs)
429    return
430
431  @staticmethod
432  def _get_deref_pointer_cursor_type(cursor: CursorLike) -> clx.Type:
433    r"""Get the 'bottom' type of a muli-level pointer type, i.e. get double from
434    const double *const ****volatile *const *ptr
435    """
436    canon_type = cursor.type.get_canonical()
437    it         = 0
438    while canon_type.kind == clx.TypeKind.POINTER:
439      if it >= 100:
440        import petsclinter as pl
441        # there is no chance that someone has a variable over 100 pointers deep, so
442        # clearly something is wrong
443        cursorview = '\n'.join(pl.classes._util.view_ast_from_cursor(cursor))
444        emess      = f'Ran for {it} iterations (>= 100) trying to get pointer type for\n{cursor.error_view_from_cursor(cursor)}\n{cursorview}'
445        raise RuntimeError(emess)
446      canon_type = canon_type.get_pointee()
447      it        += 1
448    return canon_type
449
450  def _check_fortran_interface(self, docstring: PetscDocStringImpl, fnargs: tuple[Cursor, ...]) -> None:
451    r"""Ensure that functions which require a custom Fortran interface are correctly tagged with 'C'
452    sowing designator
453
454    Parameters
455    ----------
456    docstring :
457      the docstring this section belongs to
458    fnargs :
459      the set of cursors of the function arguments
460    """
461    requires_c: list[tuple[Cursor, str]] = []
462    for arg in fnargs:
463      kind = self._get_deref_pointer_cursor_type(arg).kind
464
465      #if kind in clx_char_type_kinds:
466      #  requires_c.append((arg, 'char pointer'))
467      if kind in clx_function_type_kinds:
468        requires_c.append((arg, 'function pointer'))
469
470    if len(requires_c):
471      begin_sowing_range = docstring._attr['sowing_char_range']
472      sowing_chars       = begin_sowing_range.raw(tight=True)
473      if docstring.Modifier.C_FUNC not in docstring.type_mod:
474        assert 'C' not in sowing_chars
475        diag = docstring.make_diagnostic(
476          Diagnostic.Kind.ERROR, self.diags.fortran_interface,
477          f"Function requires custom Fortran interface but missing 'C' from docstring header {Diagnostic.FLAG_SUBST}",
478          begin_sowing_range, patch=Patch(begin_sowing_range, sowing_chars + 'C')
479        )
480        for reason_cursor, reason_type in requires_c:
481          diag.add_note(
482            f'due to {reason_type} {reason_cursor.get_formatted_blurb(num_context=1).rstrip()}',
483            location=reason_cursor.extent.start
484          )
485        docstring.add_diagnostic(diag)
486    return
487
488  def _check_no_args_documented(self, docstring: PetscDocStringImpl, arg_cursors: tuple[Cursor, ...]) -> bool:
489    r"""Check if no arguments were documented
490
491    Parameters
492    ----------
493    docstring :
494      the docstring this section belongs to
495    arg_cursors :
496      the set of argument cursors for the function cursor to check
497
498    Returns
499    -------
500    ret :
501      True (and logs the appropriate error) if no arguments were documented, False otherwise
502    """
503    if arg_cursors and not self:
504      # none of the function arguments are documented
505      docstring.add_diagnostic(
506        docstring.make_diagnostic(
507          Diagnostic.Kind.ERROR, self.diags.parameter_documentation,
508          f'Symbol parameters are all undocumented {Diagnostic.FLAG_SUBST}',
509          docstring.extent, highlight=False
510        ).add_note(
511          Diagnostic.make_message_from_formattable(
512            'Parameters defined here',
513            crange=SourceRange.from_locations(arg_cursors[0].extent.start, arg_cursors[-1].extent.end)
514          ),
515          location=arg_cursors[0].extent.start
516        )
517      )
518      return True
519
520    if not arg_cursors and self and len(self.items.values()):
521      # function has no arguments, so check there are no parameter docstrings, if so, we can
522      # delete them
523      doc_cursor = docstring.cursor
524      disp_name  = doc_cursor.displayname
525      docstring.add_diagnostic(
526        docstring.make_diagnostic(
527          Diagnostic.Kind.ERROR, self.diags.parameter_documentation,
528          f'Found parameter docstring(s) but \'{disp_name}\' has no parameters',
529          self.extent,
530          highlight=False, patch=Patch(self.extent, '')
531        ).add_note(
532          # can't use the Diagnostic.make_diagnostic_message() (with doc_cursor.extent),
533          # since that prints the whole function. Cursor.formatted() has special code to
534          # only print the function line for us, so use that instead
535          f'\'{disp_name}\' defined here:\n{doc_cursor.formatted(num_context=2)}',
536          location=doc_cursor.extent.start
537        )
538      )
539      return True
540
541    return False
542
543  class ParamVisitor:
544    def __init__(self, num_groups: int, arg_cursors: Iterable[Cursor]) -> None:
545      r"""Construct a `ParamVisitor`
546
547      Parameters
548      ----------
549      num_groups :
550        the number of argument groups in the `FunctionParameterList` items
551      arg_cursors :
552        the full set of argument cursors (i.e. those retrieved by
553        `FunctionParameterList._get_recursive_cursor_list()`)
554      """
555      self.num_groups = num_groups
556      self.arg_names  = [a.name for a in arg_cursors if a.name]
557      self.arg_seen   = [0] * len(self.arg_names)
558      return
559
560    def mark_as_seen(self, name: str) -> int:
561      r"""Mark an argument name as 'seen' by this visitor
562
563      Parameters
564      ----------
565      name :
566        the name of the argument
567
568      Returns
569      -------
570      arg_idx :
571        the 0-based index of `name` into the argument list, or -1 if `name` was invalid
572
573      Notes
574      -----
575      `name` is considered invalid if:
576      - it does not match any of the argument names
577      - it does match an argument name, but that name has already been seen enough times. For example,
578        if `self.arg_names` contains 3 instances of `name`, the first 3 calls to
579        `self.mark_as_seen(name)` will return the first 3 indices of `name`, while the 4th call will
580        return -1
581      """
582      idx  = 0
583      prev = -1
584      while 1:
585        # in case of multiple arguments of the same name, we need to loop until we
586        # find an index that has not yet been found
587        try:
588          idx = self.arg_names.index(name, idx)
589        except ValueError:
590          idx = prev
591          break
592        count = self.arg_seen[idx]
593        if 0 <= count <= self.num_groups:
594          # arg_seen[idx] = 0 -> argument exists and has not been found yet
595          # arg_seen[idx] <= num_groups -> argument is possibly in-out and is defined in
596          # multiple groups
597          if count == 0:
598            # first time, use this arg
599            break
600          # save this to come back to
601          prev = idx
602        # argument exists but has already been claimed
603        idx += 1
604      if idx >= 0:
605        self.arg_seen[idx] += 1
606      return idx
607
608  def _param_initial_traversal(self, docstring: PetscDocStringImpl, visitor: FunctionParameterList.ParamVisitor) -> list[tuple[str, SourceRange]]:
609    r"""Perform the initial traversal of a parameter list, and return any arguments that were seemingly
610    never found
611
612    Parameters
613    ----------
614    docstring :
615      the docstring this section belongs to
616    visitor :
617      the visitor to call on each argument
618
619    Returns
620    -------
621    not_found :
622      a list of names (and their source ranges) which were not found in the function arguments
623
624    Notes
625    -----
626    The visitor should implement `mark_as_seen(name: str) -> int` which returns the 0-based index of
627    `name` in the list of function arguments if it was found, and `-1` otherwise
628    """
629    not_found           = []
630    solitary_param_diag = self.diags.solitary_parameter
631    for group in self.items.values():
632      remove = set()
633      for i, (loc, descr_item, _) in enumerate(group):
634        arg, sep = descr_item.arg, descr_item.sep
635        if sep == ',' or ',' in arg:
636          sub_args = tuple(map(str.strip, arg.split(',')))
637          if len(sub_args) > 1:
638            diag = docstring.make_diagnostic(
639              Diagnostic.Kind.ERROR, solitary_param_diag,
640              'Each parameter entry must be documented separately on its own line',
641              docstring.make_source_range(arg, descr_item.text, loc.start.line)
642            )
643            if docstring.cursor.is_variadic_function():
644              diag.add_note('variable argument lists should be documented in notes')
645            docstring.add_diagnostic(diag)
646        elif sep == '=':
647          sub_args = tuple(map(str.strip, arg.split(' = ')))
648          if len(sub_args) > 1:
649            sub_args = (sub_args[0],) # case of bad separator, only the first entry is valid
650        else:
651          sub_args = (arg,)
652
653        for sub in sub_args:
654          idx = visitor.mark_as_seen(sub)
655          if idx == -1 and sub == '...' and docstring.cursor.is_variadic_function():
656            idx = 0 # variadic parameters don't get a cursor, so can't be picked up
657          if idx == -1:
658            # argument was not found at all
659            not_found.append((sub, docstring.make_source_range(sub, descr_item.text, loc.start.line)))
660            remove.add(i)
661          else:
662            descr_item.check(docstring, self, loc)
663      self.check_aligned_descriptions(docstring, [g for i, g in enumerate(group) if i not in remove])
664    return not_found
665
666  def _check_docstring_param_is_in_symbol_list(self, docstring: PetscDocStringImpl, arg_cursors: Sequence[Cursor], not_found: list[tuple[str, SourceRange]], args_left: list[str], visitor: FunctionParameterList.ParamVisitor) -> list[str]:
667    r"""Check that all documented parameters are actually in the symbol list.
668
669    Parameters
670    ----------
671    docstring :
672      the docstring to which this section belongs
673    arg_cursors :
674      the set of argument cursors for the function cursor
675    note_found :
676      a list of name - source range pairs of arguments in the docstring which were not found
677    args_left :
678      a list of function argument names which were not seen in the docstring
679    visitor :
680      the visitor to call on each argument
681
682    Returns
683    -------
684    args_left :
685      the pruned args_left, all remaining entries will be undocuments function argument names
686
687    Notes
688    -----
689    This catches items that were documented, but don't actually exist in the argument list
690    """
691    param_doc_diag   = self.diags.parameter_documentation
692    func_ptr_cursors = None
693    for i, (arg, loc) in enumerate(not_found):
694      patch = None
695      try:
696        if (len(args_left) == 1) and (i == len(not_found) - 1):
697          # if we only have 1 arg left and 1 wasn't found, chances are they are meant to
698          # be the same
699          arg_match = args_left[0]
700          if docstring.Modifier.MACRO not in docstring.type_mod:
701            # furthermore, if this is not a macro then we can be certain that this is
702            # indeed an error we can fix
703            patch = Patch(loc, arg_match)
704        else:
705          arg_match = difflib.get_close_matches(arg, args_left, n=1)[0]
706      except IndexError:
707        # the difflib call failed
708        note_loc = docstring.cursor.extent.start
709        note     = Diagnostic.make_message_from_formattable(
710          'Parameter list defined here', crange=docstring.cursor
711        )
712      else:
713        match_cursor = [c for c in arg_cursors if c.name == arg_match][0]
714        note_loc     = match_cursor.extent.start
715        note         = Diagnostic.make_message_from_formattable(
716          f'Maybe you meant {match_cursor.get_formatted_blurb()}'
717        )
718        args_left.remove(arg_match)
719        idx = visitor.mark_as_seen(arg_match)
720        assert idx != -1, f'{arg_match} was not found in arg_names'
721      diag = docstring.make_diagnostic(
722        Diagnostic.Kind.ERROR, param_doc_diag,
723        f"Extra docstring parameter \'{arg}\' not found in symbol parameter list", loc,
724        patch=patch
725      ).add_note(
726        note, location=note_loc
727      )
728      if func_ptr_cursors is None:
729        # have not checked yet
730        func_ptr_cursors = any(
731          c for c in arg_cursors
732          if self._get_deref_pointer_cursor_type(c).kind == clx.TypeKind.FUNCTIONPROTO
733        )
734      if func_ptr_cursors:
735        diag.add_note(
736          '\n'.join((
737            'If you are trying to document a function-pointer parameter, then you must name the function pointer arguments in source and introduce a new section \'Calling Sequence of `<name of function pointer arg>\'. For example:',
738            '',
739            '/*@C',
740            '  ...',
741            '  Input Parameter:',
742            '. func_ptr - A function pointer',
743            '',
744            '  Calling Sequence of `func_ptr`:',
745            '+ foo - a very useful description >-----------------------x Note named parameters!',
746            '- bar - a very useful description >-----------------------|-----------x',
747            '  ...                                                     |           |',
748            '@*/                                                      vvv         vvv',
749            'PetscErrorCode MyFunction(PetscErrorCode (*func_ptr)(int foo, double bar))'
750          ))
751        )
752      docstring.add_diagnostic(diag)
753    return args_left
754
755  def _get_recursive_cursor_list(self, cursor_list: Iterable[CursorLike]) -> list[Cursor]:
756    r"""Traverse an arg list recursively to get all nested arg cursors
757
758    Parameters
759    ----------
760    cursor_list :
761      the initial list of arg cursors
762
763    Returns
764    -------
765    cursor_list :
766      the complete cursor list
767
768    Notes
769    -----
770    This performs a depth-first search to return all cursors. So given a function
771    ```
772    PetscErrorCode Foo(int x, void (*bar)(int y, void (*baz)(double z)), int w)
773    ```
774    This returns in `[x_cursor, bar_cursor, y_cursor, baz_cursor, z_cursor, w_cursor]` in `cursor_list`
775    """
776    new_cursor_list = []
777    PARM_DECL_KIND  = clx.CursorKind.PARM_DECL
778    for cursor in map(Cursor.cast, cursor_list):
779      new_cursor_list.append(cursor)
780      # Special handling of functions taking function pointer arguments. In this case we
781      # should recursively descend and pick up the names of all the function parameters
782      #
783      # note the order, by appending cursor first we effectively do a depth-first search
784      if self._get_deref_pointer_cursor_type(cursor).kind == clx.TypeKind.FUNCTIONPROTO:
785        new_cursor_list.extend(
786          self._get_recursive_cursor_list(c for c in cursor.get_children() if c.kind == PARM_DECL_KIND)
787        )
788    return new_cursor_list
789
790  def _check_valid_param_list_from_cursor(self, docstring: PetscDocStringImpl, arg_cursors: tuple[Cursor, ...]) -> None:
791    r"""Ensure that the parameter list matches the documented values, and that their order is correct
792
793    Parameters
794    ----------
795    docstring :
796      the docstring to which this section belongs
797    arg_cursors :
798      the set of argument cursors for the function cursor
799    """
800    if self._check_no_args_documented(docstring, arg_cursors) or not self:
801      return
802
803    full_arg_cursors = self._get_recursive_cursor_list(arg_cursors)
804    visitor          = self.ParamVisitor(max(self.items.keys(), default=0), full_arg_cursors)
805    not_found        = self._param_initial_traversal(docstring, visitor)
806    args_left        = self._check_docstring_param_is_in_symbol_list(
807      docstring, full_arg_cursors, not_found,
808      [name for seen, name in zip(visitor.arg_seen, visitor.arg_names) if not seen],
809      visitor
810    )
811
812    for arg in args_left:
813      idx = visitor.mark_as_seen(arg)
814      assert idx >= 0
815      if docstring.Modifier.MACRO in docstring.type_mod:
816        # TODO
817        # Blindly assume that macro docstrings are OK for now. Ultimately this function
818        # should check against a parsed synopsis instead of the actual function arguments.
819        continue
820      docstring.add_diagnostic(
821        docstring.make_diagnostic(
822          Diagnostic.Kind.ERROR, self.diags.parameter_documentation,
823          f'Undocumented parameter \'{arg}\' not found in parameter section',
824          self.extent, highlight=False
825        ).add_note(
826          Diagnostic.make_message_from_formattable(
827            f'Parameter \'{arg}\' defined here', crange=full_arg_cursors[idx], num_context=1
828          ),
829          location=full_arg_cursors[idx].extent.start
830        )
831      )
832    return
833
834  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
835    r"""Perform all checks for this function param list
836
837    Parameters
838    ----------
839    linter :
840      the `Linter` instance to log any errors with
841    cursor :
842      the cursor to which the docstring this section belongs to
843    docstring :
844      the docstring to which this section belongs
845    """
846    super().check(linter, cursor, docstring)
847    fnargs = linter.get_argument_cursors(cursor)
848
849    self._check_fortran_interface(docstring, fnargs)
850    self._check_valid_param_list_from_cursor(docstring, fnargs)
851    return
852
853class OptionDatabaseKeys(ParameterList):
854  diags: DiagnosticMap # satisfy type checkers
855
856  @classmethod
857  def __diagnostic_prefix__(cls, *flags):
858    return DiagnosticManager.flag_prefix(super())('option-keys', *flags)
859
860  def __init__(self, *args, **kwargs) -> None:
861    r"""Construct an `OptionsDatabaseKeys`
862
863    Parameters
864    ----------
865    *args :
866      additional positional arguments to `SectionBase.__init__()`
867    **kwargs :
868      additional keyword arguments to `SectionBase.__init__()`
869    """
870    kwargs.setdefault('name', 'options')
871    kwargs.setdefault('titles', ('Options Database',))
872    super().__init__(*args, **kwargs)
873    return
874
875  def _check_option_database_key_alignment(self, docstring: PetscDocStringImpl) -> None:
876    r"""Ensure that option database keys and their descriptions are properly aligned
877
878    Parameters
879    ----------
880    docstring :
881      the docstring to which this section belongs
882    """
883    for _, group in sorted(self.items.items()):
884      self.check_aligned_descriptions(docstring, group)
885    return
886
887  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
888    r"""Perform all checks for this optionsdb list
889
890    Parameters
891    ----------
892    linter :
893      the `Linter` instance to log any errors with
894    cursor :
895      the cursor to which the docstring this section belongs to
896    docstring :
897      the docstring to which this section belongs
898    """
899    super().check(linter, cursor, docstring)
900
901    self._check_option_database_key_alignment(docstring)
902    return
903
904class Notes(Prose):
905  diags: DiagnosticMap # satisfy type checkers
906
907  @classmethod
908  def __diagnostic_prefix__(cls, *flags):
909    return DiagnosticManager.flag_prefix(super())('notes', *flags)
910
911  def __init__(self, *args, **kwargs) -> None:
912    r"""Construct a `Notes`
913
914    Parameters
915    ----------
916    *args :
917      additional positional arguments to `SectionBase.__init__()`
918    **kwargs :
919      additional keyword arguments to `SectionBase.__init__()`
920    """
921    kwargs.setdefault('name', 'notes')
922    kwargs.setdefault('titles', ('Notes', 'Note'))
923    super().__init__(*args, **kwargs)
924    return
925
926class DeveloperNotes(Prose):
927  diags: DiagnosticMap # satisfy type checkers
928
929  @classmethod
930  def __diagnostic_prefix__(cls, *flags):
931    return DiagnosticManager.flag_prefix(super())('dev-notes', *flags)
932
933  def __init__(self, *args, **kwargs) -> None:
934    r"""Construct a `DeveloperNotes`
935
936    Parameters
937    ----------
938    *args :
939      additional positional arguments to `SectionBase.__init__()`
940    **kwargs :
941      additional keyword arguments to `SectionBase.__init__()`
942    """
943    kwargs.setdefault('name', 'developer notes')
944    kwargs.setdefault('titles', ('Developer Notes', 'Developer Note'))
945    super().__init__(*args, **kwargs)
946
947class References(Prose):
948  diags: DiagnosticMap # satisfy type checkers
949
950  @classmethod
951  def __diagnostic_prefix__(cls, *flags):
952    return DiagnosticManager.flag_prefix(super())('references', *flags)
953
954  def __init__(self, *args, **kwargs) -> None:
955    r"""Construct a `References`
956
957    Parameters
958    ----------
959    *args :
960      additional positional arguments to `SectionBase.__init__()`
961    **kwargs :
962      additional keyword arguments to `SectionBase.__init__()`
963    """
964    kwargs.setdefault('name', 'references')
965    kwargs.setdefault('solitary', False)
966    super().__init__(*args, **kwargs)
967    return
968
969class FortranNotes(Prose):
970  diags: DiagnosticMap # satisfy type checkers
971
972  @classmethod
973  def __diagnostic_prefix__(cls, *flags):
974    return DiagnosticManager.flag_prefix(super())('fortran-notes', *flags)
975
976  def __init__(self, *args, **kwargs) -> None:
977    r"""Construct a `FortranNotes`
978
979    Parameters
980    ----------
981    *args :
982      additional positional arguments to `SectionBase.__init__()`
983    **kwargs :
984      additional keyword arguments to `SectionBase.__init__()`
985    """
986    kwargs.setdefault('name', 'fortran notes')
987    kwargs.setdefault('titles', ('Fortran Notes', 'Fortran Note'))
988    kwargs.setdefault('keywords', ('Fortran', ))
989    super().__init__(*args, **kwargs)
990    return
991
992class SourceCode(VerbatimBlock):
993  diags: DiagnosticMap # satisfy type checkers
994
995  @classmethod
996  def __diagnostic_prefix__(cls, *flags):
997    return DiagnosticManager.flag_prefix(super())('source-code', *flags)
998
999  def __init__(self, *args, **kwargs) -> None:
1000    r"""Construct a `SourceCode`
1001
1002    Parameters
1003    ----------
1004    *args :
1005      additional positional arguments to `SectionBase.__init__()`
1006    **kwargs :
1007      additional keyword arguments to `SectionBase.__init__()`
1008    """
1009    kwargs.setdefault('name', 'code')
1010    # kwargs.setdefault('titles', ('Example Usage', 'Example', 'Calling Sequence'))
1011    # kwargs.setdefault('keywords', ('Example', 'Usage', 'Sample Usage', 'Calling
1012    # Sequence'))
1013    kwargs.setdefault('titles', ('Example Usage', 'Example'))
1014    kwargs.setdefault('keywords', ('Example', 'Usage', 'Sample Usage'))
1015    super().__init__(*args, **kwargs)
1016    return
1017
1018@DiagnosticManager.register(
1019  ('casefold', 'Verify that level subheadings are lower-case'),
1020  ('spelling', 'Verify that level subheadings are correctly spelled'),
1021)
1022class Level(InlineList):
1023  __slots__ = ('valid_levels',)
1024
1025  valid_levels: tuple[str, ...]
1026
1027  diags: DiagnosticMap # satisfy type checkers
1028
1029  def __init__(self, *args, **kwargs) -> None:
1030    r"""Construct a `Level`
1031
1032    Parameters
1033    ----------
1034    *args :
1035      additional positional arguments to `SectionBase.__init__()`
1036    **kwargs :
1037      additional keyword arguments to `SectionBase.__init__()`
1038    """
1039    kwargs.setdefault('name', 'level')
1040    kwargs.setdefault('required', True)
1041    super().__init__(*args, **kwargs)
1042    self.valid_levels = ('beginner', 'intermediate', 'advanced', 'developer', 'deprecated')
1043    return
1044
1045  @classmethod
1046  def __diagnostic_prefix__(cls, *flags):
1047    return DiagnosticManager.flag_prefix(super())('level', *flags)
1048
1049  def __do_check_valid_level_spelling(self, docstring: PetscDocStringImpl, loc: SourceRange, level_name: str) -> None:
1050    r"""Do the actual valid level spelling check
1051
1052    Parameters
1053    ----------
1054    docstring :
1055      the docstring to which this section belongs
1056    loc :
1057      the location of the level item, i.e. the location of 'beginner'
1058    level_name :
1059      the string of the location
1060    """
1061    if level_name in self.valid_levels:
1062      return # all good
1063
1064    def make_sub_loc(loc: SourceRange, substr: str) -> SourceRange:
1065      return docstring.make_source_range(substr, loc.raw(), loc.start.line, offset=loc.start.column - 1)
1066
1067    locase  = level_name.casefold()
1068    patch   = None
1069    sub_loc = loc
1070    if locase in self.valid_levels:
1071      diag  = self.diags.casefold
1072      mess  = f"Level subheading must be lowercase, expected '{locase}' found '{level_name}'"
1073      patch = Patch(loc, locase)
1074    else:
1075      diag            = self.diags.spelling
1076      lvl_match_close = difflib.get_close_matches(locase, self.valid_levels, n=1)
1077      if not lvl_match_close:
1078        sub_split = level_name.split(maxsplit=1)[0]
1079        if sub_split != level_name:
1080          sub_loc         = make_sub_loc(loc, sub_split)
1081          lvl_match_close = difflib.get_close_matches(sub_split.casefold(), self.valid_levels, n=1)
1082
1083      if lvl_match_close:
1084        lvl_match = lvl_match_close[0]
1085        if lvl_match == 'deprecated':
1086          if re_match := re.match(
1087              r'(\w+)\s*(\(\s*[sS][iI][nN][cC][eE]\s*\d+\.\d+[\.\d\s]*\))', level_name
1088          ):
1089            # given:
1090            #
1091            # deprecated (since MAJOR.MINOR[.PATCH])
1092            # ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
1093            #     |                     |
1094            # re_match[1]               |
1095            #                   re_match[2]
1096            #
1097            # check that invalid_name_match is properly formatted
1098            invalid_name_match = re_match[1]
1099            return self.__do_check_valid_level_spelling(
1100              docstring, make_sub_loc(loc, invalid_name_match), invalid_name_match
1101            )
1102
1103        mess  = f"Unknown Level subheading '{level_name}', assuming you meant '{lvl_match}'"
1104        patch = Patch(loc, lvl_match)
1105      else:
1106        if 'level' not in loc.raw().casefold():
1107          return # TODO fix this with _check_level_heading_on_same_line()
1108        expected = ', or '.join([', '.join(self.valid_levels[:-1]), self.valid_levels[-1]])
1109        mess     = f"Unknown Level subheading '{level_name}', expected one of {expected}"
1110    docstring.add_diagnostic_from_source_range(Diagnostic.Kind.ERROR, diag, mess, sub_loc, patch=patch)
1111    return
1112
1113  def _check_valid_level_spelling(self, docstring: PetscDocStringImpl) -> None:
1114    r"""Ensure that the level values are both proper and properly spelled
1115
1116    Parameters
1117    ----------
1118    docstring :
1119      the docstring to which this section belongs
1120    """
1121    for line_after_colon, sub_items in self.items:
1122      for loc, level_name in sub_items:
1123        self.__do_check_valid_level_spelling(docstring, loc, level_name)
1124    return
1125
1126  def _check_level_heading_on_same_line(self) -> None:
1127    r"""Ensure that the level heading value is on the same line as Level:
1128
1129    Notes
1130    -----
1131    TODO
1132    """
1133    return
1134    # TODO FIX ME, need to be able to handle the below
1135    # for loc, line, verdict in self.lines():
1136    #   if line and ':' not in line:
1137    #     # if you get a "prevloc" and "prevline" not defined error here this means that we
1138    #     # are erroring out on the first trip round this loop and somehow have a
1139    #     # lone-standing 'beginner' or whatever without an explicit "Level:" line...
1140    #     errorMessage = f"Level values must be on the same line as the 'Level' heading, not on separate line:\n{prevloc.merge_with(loc).formatted(num_context=2, highlight=False)}"
1141    #     # This is a stupid hack to solve a multifaceted issue. Suppose you have
1142    #     # Level:
1143    #     # BLABLABLA
1144    #     # The first fix above does a tolower() transformation
1145    #     # Level:
1146    #     # blabla
1147    #     # while this fix would apply a join transformation
1148    #     # Level: BLABLA
1149    #     # See the issue already? Since we sort the transformations by line the second
1150    #     # transformation would actually end up going *first*, meaning that the lowercase
1151    #     # transformation is no longer valid for patch...
1152
1153    #     # create a range starting at newline of previous line going until the first
1154    #     # non-space character on the next line
1155    #     delrange = SourceRange.from_positions(
1156    #       cursor.translation_unit, prevloc.end.line, -1, loc.start.line, len(line) - len(line.lstrip())
1157    #     )
1158    #     # given '  Level:\n  blabla'
1159    #     #                ^^^
1160    #     #                 |
1161    #     #              delrange
1162    #     # delete delrange from it to get '  Level: blabla'
1163    #     # TODO: make a real diagnostic here
1164    #     diag = Diagnostic(Diagnostic.Kind.ERRROR, spellingDiag, errorMessage, patch=Patch(delrange, ''))
1165    #     linter.add_diagnostic_from_cursor(cursor, diag)
1166    #   prevloc  = loc
1167    #   prevline = line
1168    # return
1169
1170  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
1171    r"""Perform all checks for this level
1172
1173    Parameters
1174    ----------
1175    linter :
1176      the `Linter` instance to log any errors with
1177    cursor :
1178      the cursor to which the docstring this section belongs to
1179    docstring :
1180      the docstring to which this section belongs
1181    """
1182    super().check(linter, cursor, docstring)
1183    self._check_valid_level_spelling(docstring)
1184    self._check_level_heading_on_same_line()
1185    return
1186
1187@DiagnosticManager.register(
1188  ('duplicate','Verify that there are no duplicate entries in seealso lists'),
1189  ('self-reference','Verify that seealso lists don\'t contain the current symbol name'),
1190  ('backticks','Verify that seealso list entries are all enclosed by \'`\''),
1191)
1192class SeeAlso(InlineList):
1193  __slots__ = ('special_chars',)
1194
1195  special_chars: str
1196
1197  diags: DiagnosticMap # satisfy type checkers
1198
1199  def __init__(self, *args, **kwargs) -> None:
1200    r"""Construct a `SeeAlso`
1201
1202    Parameters
1203    ----------
1204    *args :
1205      additional positional arguments to `SectionBase.__init__()`
1206    **kwargs :
1207      additional keyword arguments to `SectionBase.__init__()`
1208    """
1209    kwargs.setdefault('name', 'seealso')
1210    kwargs.setdefault('required', True)
1211    kwargs.setdefault('titles', ('.seealso',))
1212    super().__init__(*args, **kwargs)
1213    self.special_chars = '`'
1214    return
1215
1216  @classmethod
1217  def __diagnostic_prefix__(cls, *flags):
1218    return DiagnosticManager.flag_prefix(super())('seealso', *flags)
1219
1220  @staticmethod
1221  def transform(text: str) -> str:
1222    return text.casefold()
1223
1224  @staticmethod
1225  def __make_deletion_patch(loc: SourceRange, text: str, look_behind: bool) -> Patch:
1226    """Make a cohesive deletion patch
1227
1228    Parameters
1229    ----------
1230    loc :
1231      the source range for the item to delete
1232    text :
1233      the text of the full line
1234    look_behind :
1235      should we remove the comma and space behind the location as well?
1236
1237    Returns
1238    -------
1239    patch :
1240      the patch
1241
1242    Notes
1243    -----
1244    first(),    second(),      third
1245
1246    Extend source range of 'second' so that deleting it yields
1247
1248    first(), third
1249    """
1250    raw = loc.raw().rstrip('\n')
1251    col = loc.start.column - 1
1252    # str.partition won't work here since it returns the first instance of 'sep', which in
1253    # our case might be the first instance of the value rather than the duplicate we just
1254    # found
1255    post = raw[col + len(text):]
1256    # get the number of characters between us and next alphabetical character
1257    cend = len(post) - len(post.lstrip(', '))
1258    if look_behind:
1259      # look to remove comma and space the entry behind us
1260      pre    = raw[:col]
1261      cbegin = len(pre.rstrip(', ')) - len(pre) # note intentionally negative value
1262      assert cbegin < 0
1263    else:
1264      cbegin = 0
1265    return Patch(loc.resized(cbegin=cbegin, cend=cend), '')
1266
1267  def _check_self_referential(self, cursor: Cursor, docstring: PetscDocStringImpl, items: InlineList.ItemsType, last_loc: SourceRange) -> list[tuple[SourceRange, str]]:
1268    r"""Ensure that the seealso list does not contain the name of the cursors symbol, i.e. that the
1269    docstring is not self-referential
1270
1271    Parameters
1272    ----------
1273    cursor :
1274      the cursor to which this docstring belongs
1275    docstring :
1276      the docstring to which this section belongs
1277    items :
1278      the inline list items
1279    last_loc :
1280      the location of the final entry in the list
1281
1282    Returns
1283    -------
1284    item_remain :
1285      the list of items, with self-referential items removed
1286    """
1287    item_remain: list[tuple[SourceRange, str]] = []
1288    symbol_name = Cursor.get_name_from_cursor(cursor)
1289    for line_after_colon, sub_items in items:
1290      for loc, text in sub_items:
1291        if text.replace(self.special_chars, '').rstrip('()') == symbol_name:
1292          mess = f"Found self-referential seealso entry '{text}'; your documentation may be good but it's not *that* good"
1293          docstring.add_diagnostic_from_source_range(
1294            Diagnostic.Kind.ERROR, self.diags.self_reference, mess, loc,
1295            patch=self.__make_deletion_patch(loc, text, loc == last_loc)
1296          )
1297        else:
1298          item_remain.append((loc, text))
1299    return item_remain
1300
1301  def _check_enclosed_by_special_chars(self, docstring: PetscDocStringImpl, item_remain: list[tuple[SourceRange, str]]) -> None:
1302    r"""Ensure that every entry in the seealso list is enclosed in backticks
1303
1304    Parameters
1305    ----------
1306    docstring :
1307      the docstring to which this section belongs
1308    item_remain :
1309      the list of valid items to check
1310    """
1311    def enclosed_by(string: str, char: str) -> bool:
1312      return string.startswith(char) and string.endswith(char)
1313
1314    chars = self.special_chars
1315    for loc, text in item_remain:
1316      if not enclosed_by(text, chars) and not re.search(r'\[.*\]\(\w+\)', text):
1317        docstring.add_diagnostic_from_source_range(
1318          Diagnostic.Kind.ERROR, self.diags.backticks,
1319          f"seealso symbol '{text}' not enclosed with '{chars}'",
1320          loc, patch=Patch(loc, f'{chars}{text.replace(chars, "")}{chars}')
1321        )
1322    return
1323
1324  def _check_duplicate_entries(self, docstring: PetscDocStringImpl, item_remain: list[tuple[SourceRange, str]], last_loc: SourceRange) -> None:
1325    r"""Ensure that the seealso list has no duplicate entries
1326
1327    Parameters
1328    ----------
1329    docstring :
1330      the docstring to which this section belongs
1331    item_remain :
1332      the list of valid items to check
1333    last_loc :
1334      the location of the final entry in the list
1335
1336    Notes
1337    -----
1338    `last_loc` must be the original final location, even if `item_remain` does not contain it (i.e. it
1339    is an invalid entry)!
1340    """
1341    seen: dict[str, SourceRange] = {}
1342    for loc, text in item_remain:
1343      text_no_special = text.replace(self.special_chars, '')
1344      assert text_no_special
1345      if text_no_special in seen:
1346        first_seen = seen[text_no_special]
1347        docstring.add_diagnostic(
1348          docstring.make_diagnostic(
1349            Diagnostic.Kind.ERROR, self.diags.duplicate, f"Seealso entry '{text}' is duplicate", loc,
1350            patch=self.__make_deletion_patch(loc, text, loc == last_loc)
1351          ).add_note(
1352            Diagnostic.make_message_from_formattable(
1353              'first instance found here', crange=first_seen, num_context=1
1354            ),
1355            location=first_seen.start
1356          )
1357        )
1358      else:
1359        seen[text_no_special] = loc
1360    return
1361
1362  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
1363    r"""Perform all checks for this seealso list
1364
1365    Parameters
1366    ----------
1367    linter :
1368      the `Linter` instance to log any errors with
1369    cursor :
1370      the cursor to which the docstring this section belongs to
1371    docstring :
1372      the docstring to which this section belongs
1373    """
1374    super().check(linter, cursor, docstring)
1375
1376    if self.barren() or not self:
1377      return # barren
1378
1379    items       = self.items
1380    last_loc    = items[-1][1][-1][0]
1381    item_remain = self._check_self_referential(cursor, docstring, items, last_loc)
1382    self._check_enclosed_by_special_chars(docstring, item_remain)
1383    self._check_duplicate_entries(docstring, item_remain, last_loc)
1384    return
1385