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