diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst index 568cdde85c..58f3a04b7c 100644 --- a/doc/user_guide/configuration/all-options.rst +++ b/doc/user_guide/configuration/all-options.rst @@ -1160,6 +1160,13 @@ Standard Checkers ``Miscellaneous`` **Checker** ----------------------------- +--check-fixme-in-docstring +"""""""""""""""""""""""""" +*Whether or not to search for fixme's in docstrings.* + +**Default:** ``False`` + + --notes """"""" *List of note tags to take in consideration, separated by a comma.* @@ -1185,6 +1192,8 @@ Standard Checkers .. code-block:: toml [tool.pylint.miscellaneous] + check-fixme-in-docstring = false + notes = ["FIXME", "XXX", "TODO"] notes-rgx = "" diff --git a/doc/whatsnew/fragments/9255.feature b/doc/whatsnew/fragments/9255.feature new file mode 100644 index 0000000000..bc6b1033da --- /dev/null +++ b/doc/whatsnew/fragments/9255.feature @@ -0,0 +1,4 @@ +The `fixme` check can now search through docstrings as well as comments, by using +``check-fixme-in-docstring = true`` in the ``[tool.pylint.miscellaneous]`` section. + +Closes #9255 diff --git a/pylint/checkers/misc.py b/pylint/checkers/misc.py index 78c21d0c5e..ea2d9e324f 100644 --- a/pylint/checkers/misc.py +++ b/pylint/checkers/misc.py @@ -51,7 +51,7 @@ def process_module(self, node: nodes.Module) -> None: class EncodingChecker(BaseTokenChecker, BaseRawFileChecker): - """BaseChecker for encoding issues. + """BaseChecker for encoding issues and fixme notes. Checks for: * warning notes in the code like FIXME, XXX @@ -90,6 +90,15 @@ class EncodingChecker(BaseTokenChecker, BaseRawFileChecker): "default": "", }, ), + ( + "check-fixme-in-docstring", + { + "type": "yn", + "metavar": "", + "default": False, + "help": "Whether or not to search for fixme's in docstrings.", + }, + ), ) def open(self) -> None: @@ -97,11 +106,21 @@ def open(self) -> None: notes = "|".join(re.escape(note) for note in self.linter.config.notes) if self.linter.config.notes_rgx: - regex_string = rf"#\s*({notes}|{self.linter.config.notes_rgx})(?=(:|\s|\Z))" - else: - regex_string = rf"#\s*({notes})(?=(:|\s|\Z))" + notes += f"|{self.linter.config.notes_rgx}" - self._fixme_pattern = re.compile(regex_string, re.I) + comment_regex = rf"#\s*(?P({notes})(?=(:|\s|\Z)).*?$)" + self._comment_fixme_pattern = re.compile(comment_regex, re.I) + + # single line docstring like '''this''' or """this""" + docstring_regex = rf"((\"\"\")|(\'\'\'))\s*(?P({notes})(?=(:|\s|\Z)).*?)((\"\"\")|(\'\'\'))" + self._docstring_fixme_pattern = re.compile(docstring_regex, re.I) + + # multiline docstrings which will be split into newlines + # so we do not need to look for quotes/double-quotes + multiline_docstring_regex = rf"^\s*(?P({notes})(?=(:|\s|\Z)).*$)" + self._multiline_docstring_fixme_pattern = re.compile( + multiline_docstring_regex, re.I + ) def _check_encoding( self, lineno: int, line: bytes, file_encoding: str @@ -133,16 +152,39 @@ def process_tokens(self, tokens: list[tokenize.TokenInfo]) -> None: if not self.linter.config.notes: return for token_info in tokens: - if token_info.type != tokenize.COMMENT: - continue - comment_text = token_info.string[1:].lstrip() # trim '#' and white-spaces - if self._fixme_pattern.search("#" + comment_text.lower()): - self.add_message( - "fixme", - col_offset=token_info.start[1] + 1, - args=comment_text, - line=token_info.start[0], - ) + if token_info.type == tokenize.COMMENT: + if match := self._comment_fixme_pattern.match(token_info.string): + self.add_message( + "fixme", + col_offset=token_info.start[1] + 1, + args=match.group("msg"), + line=token_info.start[0], + ) + elif self.linter.config.check_fixme_in_docstring: + if self._is_multiline_docstring(token_info): + docstring_lines = token_info.string.split("\n") + for line_no, line in enumerate(docstring_lines): + if match := self._multiline_docstring_fixme_pattern.match(line): + self.add_message( + "fixme", + col_offset=token_info.start[1] + 1, + args=match.group("msg"), + line=token_info.start[0] + line_no, + ) + elif match := self._docstring_fixme_pattern.match(token_info.string): + self.add_message( + "fixme", + col_offset=token_info.start[1] + 1, + args=match.group("msg"), + line=token_info.start[0], + ) + + def _is_multiline_docstring(self, token_info: tokenize.TokenInfo) -> bool: + return ( + token_info.type == tokenize.STRING + and (token_info.line.lstrip().startswith(('"""', "'''"))) + and "\n" in token_info.line.rstrip() + ) def register(linter: PyLinter) -> None: diff --git a/tests/functional/f/fixme.py b/tests/functional/f/fixme.py index e3d420f8ef..7ff749d94c 100644 --- a/tests/functional/f/fixme.py +++ b/tests/functional/f/fixme.py @@ -1,9 +1,16 @@ """Tests for fixme and its disabling and enabling.""" -# pylint: disable=missing-function-docstring, unused-variable +# pylint: disable=missing-function-docstring, unused-variable, pointless-string-statement # +1: [fixme] # FIXME: beep +# +1: [fixme] + # TODO: don't forget indented ones should trigger +# +1: [fixme] +# TODO: that precedes another TODO: is treated as one and the message starts after the first +# +1: [fixme] +# TODO: large indentations after hash are okay +# but things cannot precede the TODO: do this def function(): variable = "FIXME: Ignore me!" @@ -35,6 +42,8 @@ def function(): # pylint: disable-next=fixme # FIXME: Don't raise when the message is disabled +"""TODO: Don't raise when docstring fixmes are disabled""" + # This line needs to be at the end of the file to make sure it doesn't end with a comment # Pragma's compare against the 'lineno' attribute of the respective nodes which # would stop too soon otherwise. diff --git a/tests/functional/f/fixme.txt b/tests/functional/f/fixme.txt index 53b6680202..e70b4d7461 100644 --- a/tests/functional/f/fixme.txt +++ b/tests/functional/f/fixme.txt @@ -1,9 +1,12 @@ fixme:5:1:None:None::"FIXME: beep":UNDEFINED -fixme:11:20:None:None::"FIXME: Valid test":UNDEFINED -fixme:14:5:None:None::"TODO: Do something with the variables":UNDEFINED -fixme:16:18:None:None::"XXX: Fix this later":UNDEFINED -fixme:18:5:None:None::"FIXME: no space after hash":UNDEFINED -fixme:20:5:None:None::"todo: no space after hash":UNDEFINED -fixme:23:2:None:None::"FIXME: this is broken":UNDEFINED -fixme:25:5:None:None::"./TODO: find with notes":UNDEFINED -fixme:27:5:None:None::"TO make something DO: find with regex":UNDEFINED +fixme:7:5:None:None::"TODO: don't forget indented ones should trigger":UNDEFINED +fixme:9:1:None:None::"TODO: that precedes another TODO: is treated as one and the message starts after the first":UNDEFINED +fixme:11:1:None:None::"TODO: large indentations after hash are okay":UNDEFINED +fixme:18:20:None:None::"FIXME: Valid test":UNDEFINED +fixme:21:5:None:None::"TODO: Do something with the variables":UNDEFINED +fixme:23:18:None:None::"XXX: Fix this later":UNDEFINED +fixme:25:5:None:None::"FIXME: no space after hash":UNDEFINED +fixme:27:5:None:None::"todo: no space after hash":UNDEFINED +fixme:30:2:None:None::"FIXME: this is broken":UNDEFINED +fixme:32:5:None:None::"./TODO: find with notes":UNDEFINED +fixme:34:5:None:None::"TO make something DO: find with regex":UNDEFINED diff --git a/tests/functional/f/fixme_docstring.py b/tests/functional/f/fixme_docstring.py new file mode 100644 index 0000000000..918ec00b07 --- /dev/null +++ b/tests/functional/f/fixme_docstring.py @@ -0,0 +1,56 @@ +"""Tests for fixme in docstrings""" +# pylint: disable=missing-function-docstring, pointless-string-statement + +# +1: [fixme] +"""TODO resolve this""" +# +1: [fixme] +""" TODO: indentations are permitted """ +# +1: [fixme] +''' TODO: indentations are permitted ''' +# +1: [fixme] +""" TODO: indentations are permitted""" + +""" preceding text TODO: is not permitted""" + +""" +FIXME don't forget this # [fixme] +XXX also remember this # [fixme] +FIXME: and this line, but treat it as one FIXME TODO # [fixme] +text cannot precede the TODO: it must be at the start + XXX indentations are okay # [fixme] +??? the codetag must be recognized +""" + +# +1: [fixme] +# FIXME should still work + +# +1: [fixme] +# TODO """ should work + +# """ TODO will not work +"""# TODO will not work""" + +"""TODOist API should not result in a message""" + +# +2: [fixme] +""" +TO make something DO: look a regex +""" + +# pylint: disable-next=fixme +"""TODO won't work anymore""" + +# +2: [fixme] +def function(): + """./TODO implement this""" + + +''' + XXX single quotes should be no different # [fixme] +''' +def function2(): + ''' + ./TODO implement this # [fixme] + FIXME and this # [fixme] + ''' + '''FIXME one more for good measure''' # [fixme] diff --git a/tests/functional/f/fixme_docstring.rc b/tests/functional/f/fixme_docstring.rc new file mode 100644 index 0000000000..de8a8941f9 --- /dev/null +++ b/tests/functional/f/fixme_docstring.rc @@ -0,0 +1,7 @@ +[MISCELLANEOUS] +# List of note tags to take in consideration, separated by a comma. +notes=XXX,TODO,./TODO +# Regular expression of note tags to take in consideration. +notes-rgx=FIXME(?!.*ISSUE-\d+)|TO.*DO +# enable checking for fixme's in docstrings +check-fixme-in-docstring=yes diff --git a/tests/functional/f/fixme_docstring.txt b/tests/functional/f/fixme_docstring.txt new file mode 100644 index 0000000000..b0b19a9ebb --- /dev/null +++ b/tests/functional/f/fixme_docstring.txt @@ -0,0 +1,16 @@ +fixme:5:1:None:None::TODO resolve this:UNDEFINED +fixme:7:1:None:None::"TODO: indentations are permitted ":UNDEFINED +fixme:9:1:None:None::"TODO: indentations are permitted ":UNDEFINED +fixme:11:1:None:None::"TODO: indentations are permitted":UNDEFINED +fixme:16:1:None:None::FIXME don't forget this # [fixme]:UNDEFINED +fixme:17:1:None:None::XXX also remember this # [fixme]:UNDEFINED +fixme:18:1:None:None::"FIXME: and this line, but treat it as one FIXME TODO # [fixme]":UNDEFINED +fixme:20:1:None:None::XXX indentations are okay # [fixme]:UNDEFINED +fixme:25:1:None:None::FIXME should still work:UNDEFINED +fixme:28:1:None:None::"TODO """""" should work":UNDEFINED +fixme:37:1:None:None::"TO make something DO: look a regex":UNDEFINED +fixme:45:5:None:None::./TODO implement this:UNDEFINED +fixme:49:1:None:None::XXX single quotes should be no different # [fixme]:UNDEFINED +fixme:53:5:None:None::./TODO implement this # [fixme]:UNDEFINED +fixme:54:5:None:None::FIXME and this # [fixme]:UNDEFINED +fixme:56:5:None:None::FIXME one more for good measure:UNDEFINED