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