Repository: pylint-dev/pylint

Issue:
W0511: Doesn't detect TODO in docstrings
### Bug description

```python
# TODO: Write good doc strings.

def foobar():
    """
    TODO: Implement
    """
```


### Configuration

_No response_

### Command used

```shell
pylint todo.py
```


### Pylint output

```python
************* Module todo
todo.py:1:1: W0511: TODO: Write good doc strings. (fixme)
todo.py:1:0: C0114: Missing module docstring (missing-module-docstring)

------------------------------------------------------------------
Your code has been rated at 0.00/10 (previous run: 0.00/10, +0.00)
```


### Expected behavior

I expect the `TODO: Implement` to lead to an error reported by pylint.

### Pylint version

```shell
pylint 3.0.2
astroid 3.0.1
Python 3.11.2 (main, Mar 13 2023, 12:18:29) [GCC 12.2.0]
```


### OS / Environment

Debian 12 in WSL 2

### Additional dependencies

_No response_


Discussion:
I'd like to work on this issue
Thank you @shenjunjie2003, I assigned you to it :)
When I am using dev pylint, the fixme words in comments are not being detected. But they are indeed being detected in the version 3.0.2 which I am currently using.
Might be due to pylint's pylintrc influencing what you do. I would start by creating a functional test in tests/functional with an associated conf file enabling fixme.
Yep! I found it. Thanks!



Patch:
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": "<y or n>",
+                "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<msg>({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<msg>({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<msg>({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:


Test patch:
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