xref: /petsc/lib/petsc/bin/maint/petsclinter/petsclinter/classes/docs/_doc_section_base.py (revision ec42381fdf5bb48a6ea45cf3b5c9e6ed3c5f82db)
1#!/usr/bin/env python3
2"""
3# Created: Sun Nov 20 12:27:36 2022 (-0500)
4# @author: Jacob Faibussowitsch
5"""
6from __future__ import annotations
7
8import re
9import enum
10import difflib
11import textwrap
12import itertools
13import collections
14
15from ..._typing import *
16from ..._error  import ParsingError
17
18from .._diag    import DiagnosticManager, Diagnostic
19from .._src_pos import SourceRange
20from .._patch   import Patch
21
22"""
23==========================================================================================
24Base Classes
25==========================================================================================
26"""
27class DescribableItem:
28  __slots__ = 'text', 'prefix', 'arg', 'description', 'sep', 'expected_sep'
29
30  text: str
31  prefix: str
32  arg: str
33  description: str
34  sep: str
35  expected_sep: str
36
37  def __init__(self, raw: str, prefixes: Optional[Sequence[str]] = None, expected_sep: str = '-') -> None:
38    r"""Construct a `DescribableItem`
39
40    Parameters
41    ----------
42    raw :
43      the raw line
44    prefixes : optional
45      the set of possible item prefixes
46    expected_sep : optional
47      the expected separator char between the arg and description
48    """
49    if prefixes is None:
50      prefixes = tuple()
51    text = raw.strip()
52    sep  = expected_sep
53
54    prefix, arg, descr = self.split_param(text, prefixes, sep)
55    if not descr:
56      found = False
57      for sep in (',', '='):
58        _, arg, descr = self.split_param(text, prefixes, sep)
59        if descr:
60          found = True
61          break
62      if not found:
63        sep = ' '
64        if prefix:
65          arg = text.split(prefix, maxsplit=1)[1].strip()
66        else:
67          arg, *rest = text.split(maxsplit=1)
68          if isinstance(rest, (list, tuple)):
69            descr = rest[0] if len(rest) else ''
70          assert isinstance(descr, str)
71    self.text         = raw
72    self.prefix       = prefix
73    self.arg          = arg
74    self.description  = descr
75    self.sep          = sep
76    self.expected_sep = expected_sep
77    return
78
79  @staticmethod
80  def split_param(text: str, prefixes: Sequence[str], sep: str) -> tuple[str, str, str]:
81    r"""Retrieve groups '([\.+-$])\s*([A-z,-]+) - (.*)'
82
83    Parameters
84    ----------
85    text :
86      the raw text line
87    prefixes :
88      the set of possible line prefixes to look for, empty set for no prexies
89    sep :
90      the separator char between the argument and its description
91
92    Returns
93    -------
94    prefix :
95      the detected prefix
96    arg :
97      the detected argument
98    descr :
99      the detected deescription
100
101    Notes
102    -----
103    Any one of the returned values may be the empty string, which indicates that value was not detected.
104    """
105    stripped = text.strip()
106    if not prefixes:
107      prefix = ''
108      rest   = stripped
109    else:
110      try:
111        prefix = next(filter(stripped.startswith, prefixes))
112      except StopIteration:
113        prefix = ''
114        rest   = stripped
115      else:
116        rest = stripped.split(prefix, maxsplit=1)[1].strip()
117      assert len(prefix) >= 1
118      assert rest
119    arg, part_sep, descr = rest.partition(sep.join((' ', ' ')))
120    if not part_sep:
121      if rest.endswith(sep):
122        arg = rest[:-1]
123      elif sep + ' ' in rest:
124        arg, _, descr = rest.partition(sep + ' ')
125      # if we hit neither then there is no '-' in text, possible case of '[prefix] foo'?
126    return prefix, arg.strip(), descr.lstrip()
127
128  def arglen(self) -> int:
129    r"""Return the argument length
130
131    Returns
132    -------
133    alen :
134      the length l such that self.text[:l] returns all text up until the end of the arg name
135    """
136    arg = self.arg
137    return self.text.find(arg) + len(arg)
138
139  def check(self, docstring: PetscDocStringImpl, section: SectionImpl, loc: SourceRange) -> None:
140    r"""Check a `DescribableItem` for errors
141
142    Parameters
143    ----------
144    docstring :
145      the owning `PetscDocString` instance
146    section :
147      the owning section instance
148    loc :
149      the source range of the argument
150    """
151    name = section.transform(section.name)
152    if self.sep != self.expected_sep:
153      diag  = section.diags.wrong_description_separator
154      mess  = f"{name} seems to be missing a description separator; I suspect you may be using '{self.sep}' as a separator instead of '{self.expected_sep}'. Expected '{self.arg} {self.expected_sep} {self.description}'"
155    elif not self.description:
156      diag = section.diags.missing_description
157      mess = f"{name} missing a description. Expected '{self.arg} {self.expected_sep} a very useful description'"
158    else:
159      return # ok?
160    docstring.add_diagnostic_from_source_range(Diagnostic.Kind.ERROR, diag, mess, loc)
161    return
162
163class DocBase:
164  __slots__: tuple[str, ...] = tuple()
165
166  @classmethod
167  def __diagnostic_prefix__(cls, *flags: str) -> collections.deque[str]:
168    return cls.diagnostic_flag('-'.join(flags))
169
170  @classmethod
171  def diagnostic_flag(cls, text: Union[str, collections.deque[str]], *, prefix: str = 'doc') -> collections.deque[str]:
172    r"""Construct the diagnostic flag components
173
174    Parameters
175    ----------
176    text :
177      the base flag or collections.deque of flags
178    prefix : optional
179      the flag prefix
180
181    Returns
182    -------
183    A collection.deque of flag components
184    """
185    if isinstance(text, str):
186      ret = collections.deque((prefix, text))
187      if prefix != 'doc':
188        ret.appendleft('doc')
189      return ret
190    assert isinstance(text, collections.deque)
191    if not text[0].startswith(prefix):
192      text.appendleft(prefix)
193    return text
194
195@DiagnosticManager.register(
196  ('section-header-missing', 'Verify that required sections exist in the docstring'),
197  ('section-header-unique', 'Verify that appropriate sections are unique per docstring'),
198  ('section-barren', 'Verify there are no sections containing a title and nothing else'),
199  ('section-header-solitary', 'Verify that qualifying section headers are alone on their line'),
200  ('section-header-spelling', 'Verify section headers are correctly spelled'),
201  ('section-header-unknown', 'Verify that section header is known'),
202)
203class SectionBase(DocBase):
204  """
205  Container for a single section of the docstring
206  """
207  __slots__ = (
208    'name', 'required', 'titles', 'keywords', 'raw', 'extent', '_lines', 'items', 'seen_headers',
209    'solitary'
210  )
211
212  name: str
213  required: bool
214  titles: tuple[str, ...]
215  keywords: tuple[str, ...]
216  extent: SourceRange
217  _lines: list[tuple[SourceRange, str, Verdict]]
218  items: Any
219  seen_headers: dict[str, list[SourceRange]]
220  solitary: bool
221
222  # to pacify type checkers...
223  diags: DiagnosticMap
224
225  LineInspector: TypeAlias = collections.abc.Callable[['PetscDocStringImpl', SourceRange, str, 'Verdict'], None]
226
227  def __init__(self, name: str, required: bool = False, keywords: Optional[tuple[str, ...]] = None, titles: Optional[tuple[str, ...]] = None, solitary: bool = True) -> None:
228    r"""Construct a `SectionBase`
229
230    Parameters
231    ----------
232    name :
233      the name of this section
234    required : optional
235      is this section required in the docstring
236    keywords : optional
237      keywords to help match an unknown title to a section
238    titles :
239      header-titles, i.e. "Input Parameter", or "Level", must be spelled correctly
240    solitary : optional
241      should the heading be alone on the line it sits on
242
243    Notes
244    -----
245    In addition it has the following additional members
246    raw :
247      the raw text in the section
248    extent :
249      the SourceRange for the whole section
250    _lines :
251      a tuple of each line of text and its SourceRange in the section
252    items :
253      a container of extracted tokens of interest, e.g. the level value, options parameters,
254      function parameters, etc
255    """
256    assert isinstance(name, str)
257    titlename = name.title()
258    if titles is None:
259      titles = (titlename,)
260    else:
261      titles = tuple(titles)
262    if keywords is None:
263      keywords = (titlename,)
264    else:
265      keywords = tuple(keywords)
266
267    self.name     = name
268    self.required = required
269    self.titles   = titles
270    self.keywords = tuple(set(keywords + self.titles))
271    self.solitary = solitary
272    self.clear()
273    return
274
275  def __str__(self) -> str:
276    return '\n'.join([
277      f'Type:   {type(self)}',
278      f'Name:   {self.name}',
279      f'Extent: {self.extent}'
280    ])
281
282  def __bool__(self) -> bool:
283    return bool(self.lines())
284
285  def clear(self) -> None:
286    r"""Clear a `SectionBase`
287
288    Notes
289    -----
290    Resets the section to its default state
291    """
292    self.raw          = ''
293    self.extent       = None # type: ignore[assignment]
294    self._lines       = []
295    self.items        = None
296    self.seen_headers = {}
297    return
298
299  def lines(self, headings_only: bool = False) -> list[tuple[SourceRange, str, Verdict]]:
300    r"""Retrieve the lines for this section
301
302    Parameters
303    ----------
304    headings_only : optional
305      retrieve only lines which are definitely headings
306
307    Returns
308    -------
309    lines :
310      the iterable of lines
311    """
312    if headings_only:
313      return [(loc, line, verdict) for loc, line, verdict in self._lines if verdict > 0]
314    return self._lines
315
316  def consume(self, data: Collection[tuple[SourceRange, str, Verdict]]) -> list[tuple[SourceRange, str, Verdict]]:
317    r"""Consume raw data and add it to the section
318
319    Parameters
320    ----------
321    data :
322      the container of raw data to consume
323
324    Returns
325    -------
326    data :
327      the consumed (and now empty) container
328    """
329    if data:
330      self.lines().extend(data)
331      self.raw    = '\n'.join(s for _, s, _ in self.lines())
332      self.extent = SourceRange.from_locations(self.lines()[0][0].start, self.lines()[-1][0].end)
333    return []
334
335  def _do_setup(self, docstring: PetscDocStringImpl, inspect_line: LineInspector[PetscDocStringImpl]) -> None:
336    r"""Do the actual setting up
337
338    Parameters
339    ----------
340    docstring :
341      the `PetscDocString` instance to use to log any errors
342    inspect_line
343      a callback to inspect each line
344
345    Notes
346    -----
347    This is intended to be called by derived classes that wish to set a custom line inspector
348    """
349    seen = collections.defaultdict(list)
350    for loc, line, verdict in self.lines():
351      if verdict > 0:
352        possible_header = line.split(':' if ':' in line else None, maxsplit=1)[0].strip()
353        seen[possible_header.casefold()].append(
354          docstring.make_source_range(possible_header, line, loc.start.line)
355        )
356      # let each section type determine if this line is useful
357      inspect_line(docstring, loc, line, verdict)
358
359    self.seen_headers = dict(seen)
360    return
361
362  def setup(self, docstring: PetscDocStringImpl) -> None:
363    r"""Set up a section
364
365    Parameters
366    ----------
367    docstring :
368      the `PetscDocString` instance to use to log any errors
369
370    Notes
371    -----
372    This routine is used to populate `self.items` and any other metadata before checking. As a rule,
373    subclasses should do minimal error handling or checking here, gathering only the necessary
374    statistics and data.
375    """
376    self._do_setup(docstring, lambda ds, loc, line, verdict: None)
377    return
378
379  def barren(self) -> bool:
380    r"""Is this section empty?
381
382    Returns
383    -------
384    ret :
385      True if the sectino is empty, False otherwise
386    """
387    lines = self.lines()
388    return not self.items and sum(not line.strip() for _, line, _ in lines) == len(lines) - 1
389
390  @staticmethod
391  def transform(text: str) -> str:
392    r"""Transform a text into the expected title form
393
394    Parameters
395    ----------
396    text :
397      the string to transform
398
399    Returns
400    -------
401    text :
402      the transformed string
403
404    Notes
405    -----
406    This is used for the equality check:
407    ```
408    if self.transform(text) in self.titles:
409      # text could be a title if transformed
410    else:
411      # text needs further work
412    ```
413    """
414    return text.title()
415
416  def check_indent_allowed(self) -> bool:
417    r"""Whether this section should check for indentation
418
419    Returns
420    -------
421    ret :
422      True if the linter should check indentation, False otherwise
423
424    Notes
425    -----
426    This is used to disable indentation checking in e.g. source code blocks, but the implementation
427    is very incomplete and likely needs a lot more work...
428    """
429    return True
430
431  def _check_required_section_found(self, docstring: PetscDocStringImpl) -> None:
432    r"""Check a required section does in fact exist
433
434    Parameters
435    ----------
436    docstring :
437      the `PetscDocString` owning the section
438    """
439    if not self and self.required:
440      diag = self.diags.section_header_missing
441      mess = f'Required section \'{self.titles[0]}\' not found'
442      docstring.add_diagnostic_from_source_range(
443        Diagnostic.Kind.ERROR, diag, mess, docstring.extent, highlight=False
444      )
445    return
446
447  def _check_section_is_not_barren(self, docstring: PetscDocStringImpl) -> None:
448    r"""Check that a section isn't just a solitary header out on its own
449
450    Parameters
451    ----------
452    docstring :
453      the `PetscDocString` owning the section
454    """
455    if self and self.barren():
456      diag      = self.diags.section_barren
457      highlight = len(self.lines()) == 1
458      mess      = 'Section appears to be empty; while I\'m all for a good mystery, you should probably elaborate here'
459      docstring.add_diagnostic_from_source_range(
460        Diagnostic.Kind.ERROR, diag, mess, self.extent, highlight=highlight
461      )
462    return
463
464  def _check_section_header_spelling(self, linter: Linter, docstring: PetscDocStringImpl, headings: Optional[Sequence[tuple[SourceRange, str, Verdict]]] = None, transform: Optional[Callable[[str], str]] = None) -> None:
465    r"""Check that a section header is correctly spelled and formatted.
466
467    Parameters
468    ----------
469    linter :
470      the `Linter` instance to log any errors with
471    docstring :
472      the `PetscDocString` that owns this section
473    headings : optional
474      a set of heading lines
475    transform : optional
476      the text transformation function to transform a line into a heading
477
478    Notes
479    -----
480    Sections may be found through fuzzy matching so this check asserts that a particular heading is
481    actually correct.
482    """
483    if headings is None:
484      headings = self.lines(headings_only=True)
485
486    if transform is None:
487      transform = self.transform
488
489    diag = self.diags.section_header_spelling
490    for loc, text, verdict in headings:
491      before, sep, _ = text.partition(':')
492      if not sep:
493        # missing colon, but if we are at this point then we are pretty sure it is a
494        # header, so we assume the first word is the header
495        before, _, _ = docstring.guess_heading(text)
496
497      heading = before.strip()
498      if any(t in heading for t in self.titles):
499        continue
500
501      heading_loc = docstring.make_source_range(heading, text, loc.start.line)
502      correct     = transform(heading)
503      if heading != correct and any(t in correct for t in self.titles):
504        docstring.add_diagnostic_from_source_range(
505          Diagnostic.Kind.ERROR, diag,
506          f'Invalid header spelling. Expected \'{correct}\' found \'{heading}\'',
507          heading_loc, patch=Patch(heading_loc, correct)
508        )
509        continue
510
511      try:
512        matchname = difflib.get_close_matches(correct, self.titles, n=1)[0]
513      except IndexError:
514        warn_diag = docstring.make_diagnostic(
515          Diagnostic.Kind.WARNING, self.diags.section_header_unknown,
516          f'Unknown section \'{heading}\'', heading_loc
517        )
518        prevline = docstring.extent.start.line - 1
519        loc      = SourceRange.from_positions(
520          docstring.cursor.translation_unit, prevline, 1, prevline, -1
521        )
522        warn_diag.add_note(
523          f'If this is indeed a valid heading, you can locally silence this diagnostic by adding \'// PetscClangLinter pragma disable: {DiagnosticManager.make_command_line_flag(warn_diag.flag)}\' on its own line before the docstring'
524        ).add_note(
525          Diagnostic.make_message_from_formattable(
526            'add it here', crange=loc, highlight=False
527          ),
528          location=loc.start
529        )
530        docstring.add_diagnostic(warn_diag)
531      else:
532        docstring.add_diagnostic_from_source_range(
533          Diagnostic.Kind.ERROR, diag,
534          f'Unknown section header \'{heading}\', assuming you meant \'{matchname}\'',
535          heading_loc, patch=Patch(heading_loc, matchname)
536        )
537    return
538
539  def _check_duplicate_headers(self, docstring: PetscDocStringImpl) -> None:
540    r"""Check that a particular heading is not repeated within the docstring
541
542    Parameters
543    ----------
544    docstring :
545      the `PetscDocString` owning the section
546    """
547    for heading, where in self.seen_headers.items():
548      if len(where) <= 1:
549        continue
550
551      lasti           = len(where) - 1
552      src_list        = []
553      nbefore         = 2
554      nafter          = 0
555      prev_line_begin = 0
556      for i, loc in enumerate(where):
557        startline = loc.start.line
558        if i:
559          nbefore = startline - prev_line_begin - 1
560          if i == lasti:
561            nafter = 2
562        src_list.append(loc.formatted(num_before_context=nbefore, num_after_context=nafter, trim=False))
563        prev_line_begin = startline
564      mess = "Multiple '{}' subheadings. Much like Highlanders, there can only be one:\n{}".format(
565        self.transform(self.name), '\n'.join(src_list)
566      )
567      docstring.add_diagnostic(
568        Diagnostic(Diagnostic.Kind.ERROR, self.diags.section_header_unique, mess, self.extent.start)
569      )
570    return
571
572  def _check_section_header_solitary(self, docstring: PetscDocStringImpl, headings: Optional[Sequence[tuple[SourceRange, str, Verdict]]] = None) -> None:
573    r"""Check that a section appears solitarily on its line, i.e. that there is no other text after ':'
574
575    Parameters
576    ----------
577    docstring :
578      the `PetscDocString` owning the section
579    headings : optional
580      a set of heading lines
581    """
582    if not self.solitary:
583      return
584
585    if headings is None:
586      headings = self.lines(headings_only=True)
587
588    for loc, text, verdict in headings:
589      _, sep, after = text.partition(':')
590      if not sep:
591        head, _, _    = docstring.guess_heading(text)
592        _, sep, after = text.partition(head)
593        assert sep
594      if after.strip():
595        diag = self.diags.section_header_solitary
596        mess = 'Heading must appear alone on a line, any content must be on the next line'
597        docstring.add_diagnostic_from_source_range(
598          Diagnostic.Kind.ERROR, diag, mess, docstring.make_source_range(after, text, loc.start.line)
599        )
600      break
601    return
602
603  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
604    r"""Perform a set of base checks for this instance
605
606    Parameters
607    ----------
608    linter :
609      the `Linter` instance to log any errors with
610    cursor :
611      the cursor to which the docstring this section belongs to
612    docstring :
613      the docstring to which this section belongs
614    """
615    self._check_required_section_found(docstring)
616    self._check_section_header_spelling(linter, docstring)
617    self._check_section_is_not_barren(docstring)
618    self._check_duplicate_headers(docstring)
619    self._check_section_header_solitary(docstring)
620    return
621
622@DiagnosticManager.register(
623  ('matching-symbol-name','Verify that description matches the symbol name'),
624  ('missing-description','Verify that a synopsis has a description'),
625  ('wrong-description-separator','Verify that synopsis uses the right description separator'),
626  ('verbose-description','Verify that synopsis descriptions don\'t drone on and on'),
627  ('macro-explicit-synopsis-missing','Verify that macro docstrings have an explicit synopsis section'),
628  ('macro-explicit-synopsis-valid-header','Verify that macro docstrings with explicit synopses have the right header include')
629)
630class Synopsis(SectionBase):
631  _header_include_finder = re.compile(r'\s*#\s*include\s*[<"](.*)[>"]')
632  _sowing_include_finder = re.compile(
633    _header_include_finder.pattern + r'\s*/\*\s*I\s*(["<].*[>"])\s*I\s*\*/.*'
634  )
635
636  NameItemType: TypeAlias  = Tuple[Optional[SourceRange], str]
637  BlurbItemType: TypeAlias = List[Tuple[SourceRange, str]]
638  ItemsType                = TypedDict(
639    'ItemsType',
640    {
641      'name'  : NameItemType,
642      'blurb' : BlurbItemType,
643    }
644  )
645  items: ItemsType
646
647  diags: DiagnosticMap # satisfy type checkers
648
649  class Inspector:
650    __slots__ = 'cursor_name', 'lo_name', 'found_description', 'found_synopsis', 'capturing', 'items'
651
652    class CaptureKind(enum.Enum):
653      NONE        = enum.auto()
654      DESCRIPTION = enum.auto()
655      SYNOPSIS    = enum.auto()
656
657    cursor_name: str
658    lo_name: str
659    found_description: bool
660    found_synopsis: bool
661    capturing: CaptureKind
662    items: Synopsis.ItemsType
663
664    def __init__(self, cursor: Cursor) -> None:
665      self.cursor_name       = cursor.name
666      self.lo_name           = self.cursor_name.casefold()
667      self.found_description = False
668      self.found_synopsis    = False
669      self.capturing         = self.CaptureKind.NONE
670      self.items             = {
671        'name'  : (None, ''),
672        'blurb' : []
673      }
674      return
675
676    def __call__(self, ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
677      r"""Look for the '<NAME> - description' block in a synopsis"""
678      if self.found_description:
679        return
680
681      startline = loc.start.line
682      if self.capturing == self.CaptureKind.NONE:
683        pre, dash, rest = line.partition('-')
684        if dash:
685          rest = rest.strip()
686        elif self.lo_name in line.casefold():
687          pre  = self.cursor_name
688          rest = line.split(self.cursor_name, maxsplit=1)[1].strip()
689        else:
690          return
691        item = pre.strip()
692        self.items['name'] = (ds.make_source_range(item, line, startline), item)
693        self.items['blurb'].append((ds.make_source_range(rest, line, startline), rest))
694        self.capturing = self.CaptureKind.DESCRIPTION # now capture the rest of the blurb
695      else:
696        assert self.capturing == self.CaptureKind.DESCRIPTION, 'Mixing blurb and synopsis capture?'
697        if item := line.strip():
698          self.items['blurb'].append((ds.make_source_range(item, line, startline), item))
699        else:
700          self.capturing         = self.CaptureKind.NONE
701          self.found_description = True
702      return
703
704  @classmethod
705  def __diagnostic_prefix__(cls, *flags):
706    return DiagnosticManager.flag_prefix(super())('synopsis', *flags)
707
708  def __init__(self, *args, **kwargs) -> None:
709    r"""Construct a `Synopsis`
710
711    Parameters
712    ----------
713    *args :
714      additional positional arguments to `SectionBase.__init__()`
715    **kwargs :
716      additional keyword arguments to `SectionBase.__init__()`
717    """
718    kwargs.setdefault('name', 'synopsis')
719    kwargs.setdefault('required', True)
720    kwargs.setdefault('keywords', ('Synopsis', 'Not Collective'))
721    super().__init__(*args, **kwargs)
722    return
723
724  def barren(self) -> bool:
725    return False # synoposis is never barren
726
727  def _check_symbol_matches_synopsis_name(self: SynopsisImpl, docstring: PetscDocStringImpl, cursor: Cursor, loc: SourceRange, symbol: str) -> None:
728    r"""Ensure that the name of the symbol matches that of the name in the custom synopsis (if provided)
729
730    Parameters
731    ----------
732    docstring :
733      the `PetscDocString` this section belongs to
734    cursor :
735      the cursor this docstring belongs to
736    loc :
737      the source range for symbol
738    symbol :
739      the name of the symbol in the docstring description
740
741    Notes
742    -----
743    Checks:
744
745    /*@
746      FooBar - ....
747      ^^^^^^------------------x-- Checks that these match
748      ...             ________|
749    @*/            vvvvvv
750    PetscErrorCode FooBar(...)
751    """
752    if symbol != cursor.name:
753      if len(difflib.get_close_matches(symbol, [cursor.name], n=1)):
754        mess  = f"Docstring name '{symbol}' does not match symbol. Assuming you meant '{cursor.name}'"
755        patch = Patch(loc, cursor.name)
756      else:
757        mess  = f"Docstring name '{symbol}' does not match symbol name '{cursor.name}'"
758        patch = None
759      docstring.add_diagnostic_from_source_range(
760        Diagnostic.Kind.ERROR, self.diags.matching_symbol_name, mess, loc, patch=patch
761      )
762    return
763
764  def _check_synopsis_description_separator(self: SynopsisImpl, docstring: PetscDocStringImpl, start_line: int) -> None:
765    r"""Ensure that the synopsis uses the proper separator
766
767    Parameters
768    ----------
769    docstring :
770      the docstring this section belongs to
771    start_line :
772      the line number of the description
773    """
774    for sloc, sline, _ in self.lines():
775      if sloc.start.line == start_line:
776        DescribableItem(sline, expected_sep='-').check(docstring, self, sloc)
777        break
778    return
779
780  def _check_blurb_length(self: SynopsisImpl, docstring: PetscDocStringImpl, cursor: Cursor, blurb_items: Synopsis.BlurbItemType) -> None:
781    r"""Ensure the blurb is not too wordy
782
783    Parameters
784    ----------
785    docstring :
786      the docstring this section belongs to
787    cursor :
788      the cursor this docstring belongs to
789    items :
790      the synopsis items
791    """
792    total_blurb = [line for _, line in blurb_items]
793    word_count  = sum(len(l.split()) for l in total_blurb)
794    char_count  = sum(map(len, total_blurb))
795
796    max_char_count = 250
797    max_word_count = 40
798    if char_count > max_char_count and word_count > max_word_count:
799      mess = f"Synopsis for '{cursor.name}' is too long (must be at most {max_char_count} characters or {max_word_count} words), consider moving it to Notes. If you can't explain it simply, then you don't understand it well enough!"
800      docstring.add_diagnostic_from_source_range(
801        Diagnostic.Kind.ERROR, self.diags.verbose_description, mess, self.extent, highlight=False
802      )
803    return
804
805  def _syn_common_checks(self: SynopsisImpl, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
806    r"""Perform the common set of checks for all synopses
807
808    Parameters
809    ----------
810    linter :
811      the `Linter` instance to log any errors with
812    cursor :
813      the cursor to which the docstring this section belongs to
814    docstring :
815      the docstring to which this section belongs
816
817    Notes
818    -----
819    Does not call `super().check()`! Therefore this should be used as the epilogue to your synopsis
820    checks, after any potential early returns
821    """
822    items                 = self.items
823    name_loc, symbol_name = items['name']
824    assert name_loc is not None # pacify type checkers
825    self._check_symbol_matches_synopsis_name(docstring, cursor, name_loc, symbol_name)
826    self._check_synopsis_description_separator(docstring, name_loc.start.line)
827    self._check_blurb_length(docstring, cursor, items['blurb'])
828    return
829
830@DiagnosticManager.register(
831  ('alignment', 'Verify that parameter list entries are correctly white-space aligned'),
832  ('prefix', 'Verify that parameter list entries begin with the correct prefix'),
833  ('missing-description', 'Verify that parameter list entries have a description'),
834  ('wrong-description-separator', 'Verify that parameter list entries use the right description separator'),
835  ('solitary-parameter', 'Verify that each parameter has its own entry'),
836)
837class ParameterList(SectionBase):
838  __slots__ = ('prefixes', )
839
840  prefixes: Tuple[str, ...]
841
842  ItemsType: TypeAlias = Dict[int, List[Tuple[SourceRange, DescribableItem, int]]]
843  items: ItemsType
844
845  @classmethod
846  def __diagnostic_prefix__(cls, *flags):
847    return DiagnosticManager.flag_prefix(super())('param-list', *flags)
848
849  def __init__(self, *args, prefixes: Optional[tuple[str, ...]] = None, **kwargs) -> None:
850    r"""Construct a `ParameterList`
851
852    Parameters
853    ----------
854    prefixes : optional
855      a set of prefixes which the parameter list starts with
856    """
857    if prefixes is None:
858      prefixes = ('+', '.', '-')
859
860    self.prefixes = prefixes
861    kwargs.setdefault('name', 'parameters')
862    super().__init__(*args, **kwargs)
863    return
864
865  def check_indent_allowed(self) -> bool:
866    r"""Whether `ParameterList`s should check for indentation
867
868    Returns
869    -------
870    ret :
871      Always True
872    """
873    return False
874
875  def check_aligned_descriptions(self, ds: PetscDocStringImpl, group: Sequence[tuple[SourceRange, DescribableItem, int]]) -> None:
876    r"""Verify that the position of the '-' before the description for each argument is aligned
877
878    Parameters
879    ----------
880    ds :
881      the `PetscDocString` instance which owns this section
882    group :
883      the item group to check, each entry is a tuple of src_range for the item, the `DescribableItem`
884      instance, and the arg len for that item
885    """
886    align_diag  = self.diags.alignment
887    group_args  = [item.arg for _, item, _ in group]
888    lens        = list(map(len, group_args))
889    max_arg_len = max(lens, default=0)
890    longest_arg = group_args[lens.index(max_arg_len)] if lens else 'NO ARGS'
891
892    for loc, item, _ in group:
893      pre   = item.prefix
894      arg   = item.arg
895      descr = item.description
896      text  = item.text
897      fixed = f'{pre} {arg:{max_arg_len}} - {descr}'
898      try:
899        diff_index = next(
900          i for i, (a1, a2) in enumerate(itertools.zip_longest(text, fixed)) if a1 != a2
901        )
902      except StopIteration:
903        assert text == fixed # equal
904        continue
905
906      if diff_index <= text.find(pre):
907        mess = f'Prefix \'{pre}\' must be indented to column (1)'
908      elif diff_index <= text.find(arg):
909        mess = f'Argument \'{arg}\' must be 1 space from prefix \'{pre}\''
910      else:
911        mess = f'Description \'{textwrap.shorten(descr, width=35)}\' must be aligned to 1 space from longest (valid) argument \'{longest_arg}\''
912
913      eloc = ds.make_source_range(text[diff_index:], text, loc.end.line)
914      ds.add_diagnostic_from_source_range(
915        Diagnostic.Kind.ERROR, align_diag, mess, eloc, patch=Patch(eloc, fixed[diff_index:])
916      )
917    return
918
919  def setup(self, ds: PetscDocStringImpl, parameter_list_prefix_check: Optional[Callable[[ParameterList, PetscDocString, ItemsType], ItemsType]] = None) -> None:
920    r"""Set up a `ParmeterList`
921
922    Parameters
923    ----------
924    ds :
925      the `PetscDocString` instance for this section
926    parameters_list_prefix_check : optional
927      a callable to check the prefixes of each item
928    """
929    groups: collections.defaultdict[
930      int,
931      list[tuple[SourceRange, DescribableItem, int]]
932    ]          = collections.defaultdict(list)
933    subheading = 0
934
935    def inspector(ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
936      if not line or line.isspace():
937        return
938
939      if verdict > 0 and len(groups.keys()):
940        nonlocal subheading
941        subheading += 1
942      lstp = line.lstrip()
943      # .ve and .vb might trip up the prefix detection since they start with '.'
944      if lstp.startswith(self.prefixes) and not lstp.startswith(('.vb', '.ve')):
945        item = DescribableItem(line, prefixes=self.prefixes)
946        groups[subheading].append((loc, item, item.arglen()))
947      return
948
949    super()._do_setup(ds, inspector)
950    items = dict(groups)
951    if parameter_list_prefix_check is not None:
952      assert callable(parameter_list_prefix_check)
953      items = parameter_list_prefix_check(self, ds, items)
954    self.items = items
955    return
956
957  def _check_opt_starts_with(self, docstring: PetscDocStringImpl, item: tuple[SourceRange, DescribableItem, int], entity_name: str, char: str) -> None:
958    r"""Check an option starts with the given prefix
959
960    Parameters
961    ----------
962    docstring :
963      the `PetscDocString` that owns this section
964    item :
965      the `SourceRange`, `DescribableItem`, arg len triple for the line
966    entity_name :
967      the name of the entity to which the param list belongs, e.g. 'function' or 'enum'
968    char :
969      the prefix character
970    """
971    loc, descr_item, _ = item
972    pre                = descr_item.prefix
973    if pre != char:
974      eloc = docstring.make_source_range(pre, descr_item.text, loc.start.line)
975      mess = f'{entity_name} parameter list entry must start with \'{char}\''
976      docstring.add_diagnostic_from_source_range(
977        Diagnostic.Kind.ERROR, self.diags.prefix, mess, eloc, patch=Patch(eloc, char)
978      )
979    return
980
981  def _check_prefixes(self, docstring: PetscDocStringImpl) -> None:
982    r"""Check all prefixes in the section for validity
983
984    Parameters
985    ----------
986    docstring :
987      the `PetscDocString` instance owning this section
988    """
989    for key, opts in sorted(self.items.items()):
990      lopts = len(opts)
991      assert lopts >= 1, f'number of options {lopts} < 1, key: {key}, items: {self.items}'
992
993      if lopts == 1:
994        # only 1 option, should start with '.'
995        self._check_opt_starts_with(docstring, opts[0], 'Solitary', '.')
996      else:
997        # more than 1, should be '+', then however many '.', then last is '-'
998        self._check_opt_starts_with(docstring, opts[0], 'First multi', '+')
999        for opt in opts[1:-1]:
1000          self._check_opt_starts_with(docstring, opt, 'Multi', '.')
1001        self._check_opt_starts_with(docstring, opts[-1], 'Last multi', '-')
1002    return
1003
1004  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
1005    r"""Perform all checks for this param list
1006
1007    Parameters
1008    ----------
1009    linter :
1010      the `Linter` instance to log any errors with
1011    cursor :
1012      the cursor to which the docstring this section belongs to
1013    docstring :
1014      the docstring to which this section belongs
1015    """
1016    super().check(linter, cursor, docstring)
1017    self._check_prefixes(docstring)
1018    return
1019
1020class Prose(SectionBase):
1021  ItemsType: TypeAlias = Dict[int, Tuple[Tuple[SourceRange, str], List[Tuple[SourceRange, str]]]]
1022  items: ItemsType
1023
1024  @classmethod
1025  def __diagnostic_prefix__(cls, *flags):
1026    return DiagnosticManager.flag_prefix(super())('prose', *flags)
1027
1028  def setup(self, ds: PetscDocStringImpl) -> None:
1029    r"""Set up a `Prose`
1030
1031    Parameters
1032    ----------
1033    ds :
1034      the `PetscDocString` instance for this section
1035
1036    Raises
1037    ------
1038    ParsingError
1039      if a subheading does not exist yet??
1040    """
1041    subheading = 0
1042    self.items = {}
1043
1044    def inspector(ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
1045      if verdict > 0:
1046        head, _, rest = line.partition(':')
1047        head          = head.strip()
1048        assert head, f'No heading in PROSE section?\n\n{loc.formatted(num_context=5)}'
1049        if self.items.keys():
1050          nonlocal subheading
1051          subheading += 1
1052        start_line = loc.start.line
1053        self.items[subheading] = (
1054          (ds.make_source_range(head, line, start_line), head),
1055          [(ds.make_source_range(rest, line, start_line), rest)] if rest else []
1056        )
1057      elif line.strip():
1058        try:
1059          self.items[subheading][1].append((loc, line))
1060        except KeyError as ke:
1061          raise ParsingError from ke
1062      return
1063
1064    super()._do_setup(ds, inspector)
1065    return
1066
1067class VerbatimBlock(SectionBase):
1068  ItemsType: TypeAlias = Dict[int, List[int]]
1069  items: ItemsType
1070
1071  def setup(self, ds: PetscDocStringImpl) -> None:
1072    r"""Set up a `VerbatimBlock`
1073
1074    Parameters
1075    ----------
1076    ds :
1077      the `PetscDocString` instance for this section
1078    """
1079    items = {}
1080
1081    class Inspector:
1082      __slots__ = 'codeblocks', 'startline'
1083
1084      codeblocks: int
1085      startline: int
1086
1087      def __init__(self, startline: int) -> None:
1088        self.codeblocks = 0
1089        self.startline  = startline
1090        return
1091
1092      def __call__(self, ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
1093        sub   = self.codeblocks
1094        lstrp = line.lstrip()
1095        if lstrp.startswith('.vb'):
1096          items[sub] = [loc.start.line - self.startline]
1097        elif lstrp.startswith('.ve'):
1098          assert len(items[sub]) == 1
1099          items[sub].append(loc.start.line - self.startline + 1)
1100          self.codeblocks += 1
1101        return
1102
1103    super()._do_setup(ds, Inspector(self.extent.start.line if self else 0))
1104    self.items = items
1105    return
1106
1107@DiagnosticManager.register(
1108  ('formatting', 'Verify that inline lists are correctly white-space formatted')
1109)
1110class InlineList(SectionBase):
1111  ItemsEntry: TypeAlias = Tuple[Tuple[str, str], List[Tuple[SourceRange, str]]]
1112  ItemsType: TypeAlias  = Tuple[ItemsEntry, ...]
1113  items: ItemsType
1114
1115  def __init__(self, *args, **kwargs) -> None:
1116    r"""Construct an `InlineList`
1117
1118    Parameters
1119    ----------
1120    *args :
1121      additional positional parameters to `SectionBase.__init__()`
1122    **kwargs :
1123      additional keywords parameters to `SectionBase.__init__()`
1124    """
1125    kwargs.setdefault('solitary', False)
1126    super().__init__(*args, **kwargs)
1127    return
1128
1129  @classmethod
1130  def __diagnostic_prefix__(cls, *flags):
1131    return DiagnosticManager.flag_prefix(super())('inline-list', *flags)
1132
1133  def check_indent_allowed(self) -> bool:
1134    r"""Whether this section should check for indentation
1135
1136    Returns
1137    -------
1138    ret :
1139      always False
1140    """
1141    return False
1142
1143  def setup(self, ds: PetscDocStringImpl) -> None:
1144    r"""Set up an `InlineList`
1145
1146    Parameters
1147    ----------
1148    ds :
1149      the `PetscDocString` instance for this section
1150    """
1151    items: list[InlineList.ItemsEntry] = []
1152    titles                             = set(map(str.casefold, self.titles))
1153
1154    def inspector(ds: PetscDocStringImpl, loc: SourceRange, line: str, verdict: Verdict) -> None:
1155      rest = (line.split(':', maxsplit=2)[1] if ':' in line else line).strip()
1156      if not rest:
1157        return
1158
1159      if ':' not in rest:
1160        # try and see if this is one of the bad-egg lines where the heading is missing
1161        # the colon
1162        bad_title = next(filter(lambda t: t.casefold() in titles, rest.split()), None)
1163        if bad_title:
1164          # kind of a hack, we just erase the bad heading with whitespace so it isnt
1165          # picked up below in the item detection
1166          rest = rest.replace(bad_title, ' ' * len(bad_title))
1167
1168      start_line = loc.start.line
1169      offset     = 0
1170      sub_items  = []
1171      for sub in filter(bool, map(str.strip, rest.split(','))):
1172        subloc = ds.make_source_range(sub, line, start_line, offset=offset)
1173        offset = subloc.end.column - 1
1174        sub_items.append((subloc, sub))
1175      if sub_items:
1176        items.append(((line, rest), sub_items))
1177      return
1178
1179    super()._do_setup(ds, inspector)
1180    self.items = tuple(items)
1181    return
1182
1183  def _check_whitespace_formatting(self, docstring: PetscDocStringImpl) -> None:
1184    r"""Ensure that inline list ensures are on the same line and 1 space away from the title
1185
1186    Parameters
1187    ----------
1188    docstring :
1189      the `PetscDocString` which owns this section
1190    """
1191    format_diag = self.diags.formatting
1192    base_mess   = f'{self.transform(self.name)} values must be (1) space away from colon not ({{}})'
1193    for (line, line_after_colon), sub_items in self.items:
1194      colon_idx = line.find(':')
1195      if colon_idx < 0:
1196        continue
1197
1198      correct_offset = colon_idx + 2
1199      rest_idx       = line.find(line_after_colon)
1200      if rest_idx == correct_offset:
1201        continue
1202
1203      nspaces = rest_idx - correct_offset
1204      if rest_idx > correct_offset:
1205        sub    = ' ' * nspaces
1206        offset = correct_offset
1207        fix    = ''
1208      else:
1209        sub    = ':'
1210        offset = colon_idx
1211        fix    = ': '
1212      floc = docstring.make_source_range(sub, line, sub_items[0][0].start.line, offset=offset)
1213      docstring.add_diagnostic_from_source_range(
1214        Diagnostic.Kind.ERROR, format_diag, base_mess.format(nspaces + 1), floc, patch=Patch(floc, fix)
1215      )
1216    return
1217
1218  def check(self, linter: Linter, cursor: Cursor, docstring: PetscDocStringImpl) -> None:
1219    r"""Perform all checks for this inline list
1220
1221    Parameters
1222    ----------
1223    linter :
1224      the `Linter` instance to log any errors with
1225    cursor :
1226      the cursor to which the docstring this section belongs to
1227    docstring :
1228      the docstring to which this section belongs
1229    """
1230    super().check(linter, cursor, docstring)
1231    self._check_whitespace_formatting(docstring)
1232    return
1233