]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
bpo-44010: IDLE: colorize pattern-matching soft keywords (GH-25851)
authorMiss Islington (bot) <31488909+miss-islington@users.noreply.github.com>
Wed, 19 May 2021 09:44:14 +0000 (02:44 -0700)
committerGitHub <noreply@github.com>
Wed, 19 May 2021 09:44:14 +0000 (02:44 -0700)
(cherry picked from commit 60d343a81679ea90ae0e08fadcd132c16906a51a)

Co-authored-by: Tal Einat <532281+taleinat@users.noreply.github.com>
Doc/library/idle.rst
Doc/whatsnew/3.10.rst
Lib/idlelib/colorizer.py
Lib/idlelib/help.html
Lib/idlelib/idle_test/test_colorizer.py
Misc/NEWS.d/next/IDLE/2021-05-09-09-02-09.bpo-44010.TaLe9x.rst [new file with mode: 0644]

index 3c302115b5f40811b47eb7bb2f0ed8d1853dfc60..faa34e69ff15d7c1bd33273ee8a37f09646c7a6a 100644 (file)
@@ -613,6 +613,12 @@ keywords, builtin class and function names, names following ``class`` and
 ``def``, strings, and comments. For any text window, these are the cursor (when
 present), found text (when possible), and selected text.
 
+IDLE also highlights the :ref:`soft keywords <soft-keywords>` :keyword:`match`,
+:keyword:`case <match>`, and :keyword:`_ <wildcard-patterns>` in
+pattern-matching statements. However, this highlighting is not perfect and
+will be incorrect in some rare cases, including some ``_``-s in ``case``
+patterns.
+
 Text coloring is done in the background, so uncolorized text is occasionally
 visible.  To change the color scheme, use the Configure IDLE dialog
 Highlighting tab.  The marking of debugger breakpoint lines in the editor and
index 926679e6f32dc525bbcbbd13ea280d021a77d7d9..570af7f3b618149d1f38e7ba680aa59ab1283d83 100644 (file)
@@ -1030,6 +1030,12 @@ Terry Jan Reedy in :issue:`37892`.)
 We expect to backport these shell changes to a future 3.9 maintenance
 release.
 
+Highlight the new :ref:`soft keywords <soft-keywords>` :keyword:`match`,
+:keyword:`case <match>`, and :keyword:`_ <wildcard-patterns>` in
+pattern-matching statements. However, this highlighting is not perfect
+and will be incorrect in some rare cases, including some ``_``-s in
+``case`` patterns.  (Contributed by Tal Einat in bpo-44010.)
+
 importlib.metadata
 ------------------
 
index 3c527409731afa63bc7e54bcf9533f4a75dbb73e..e9f19c145c867395ceb622375fe917bb1f736f5e 100644 (file)
@@ -16,6 +16,32 @@ def any(name, alternates):
 
 def make_pat():
     kw = r"\b" + any("KEYWORD", keyword.kwlist) + r"\b"
+    match_softkw = (
+        r"^[ \t]*" +  # at beginning of line + possible indentation
+        r"(?P<MATCH_SOFTKW>match)\b" +
+        r"(?![ \t]*(?:" + "|".join([  # not followed by ...
+            r"[:,;=^&|@~)\]}]",  # a character which means it can't be a
+                                 # pattern-matching statement
+            r"\b(?:" + r"|".join(keyword.kwlist) + r")\b",  # a keyword
+        ]) +
+        r"))"
+    )
+    case_default = (
+        r"^[ \t]*" +  # at beginning of line + possible indentation
+        r"(?P<CASE_SOFTKW>case)" +
+        r"[ \t]+(?P<CASE_DEFAULT_UNDERSCORE>_\b)"
+    )
+    case_softkw_and_pattern = (
+        r"^[ \t]*" +  # at beginning of line + possible indentation
+        r"(?P<CASE_SOFTKW2>case)\b" +
+        r"(?![ \t]*(?:" + "|".join([  # not followed by ...
+            r"_\b",  # a lone underscore
+            r"[:,;=^&|@~)\]}]",  # a character which means it can't be a
+                                 # pattern-matching case
+            r"\b(?:" + r"|".join(keyword.kwlist) + r")\b",  # a keyword
+        ]) +
+        r"))"
+    )
     builtinlist = [str(name) for name in dir(builtins)
                    if not name.startswith('_') and
                    name not in keyword.kwlist]
@@ -27,12 +53,29 @@ def make_pat():
     sq3string = stringprefix + r"'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?"
     dq3string = stringprefix + r'"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?'
     string = any("STRING", [sq3string, dq3string, sqstring, dqstring])
-    return (kw + "|" + builtin + "|" + comment + "|" + string +
-            "|" + any("SYNC", [r"\n"]))
+    prog = re.compile("|".join([
+                                builtin, comment, string, kw,
+                                match_softkw, case_default,
+                                case_softkw_and_pattern,
+                                any("SYNC", [r"\n"]),
+                               ]),
+                      re.DOTALL | re.MULTILINE)
+    return prog
 
 
-prog = re.compile(make_pat(), re.S)
-idprog = re.compile(r"\s+(\w+)", re.S)
+prog = make_pat()
+idprog = re.compile(r"\s+(\w+)")
+prog_group_name_to_tag = {
+    "MATCH_SOFTKW": "KEYWORD",
+    "CASE_SOFTKW": "KEYWORD",
+    "CASE_DEFAULT_UNDERSCORE": "KEYWORD",
+    "CASE_SOFTKW2": "KEYWORD",
+}
+
+
+def matched_named_groups(re_match):
+    "Get only the non-empty named groups from an re.Match object."
+    return ((k, v) for (k, v) in re_match.groupdict().items() if v)
 
 
 def color_config(text):
@@ -231,14 +274,10 @@ class ColorDelegator(Delegator):
     def recolorize_main(self):
         "Evaluate text and apply colorizing tags."
         next = "1.0"
-        while True:
-            item = self.tag_nextrange("TODO", next)
-            if not item:
-                break
-            head, tail = item
-            self.tag_remove("SYNC", head, tail)
-            item = self.tag_prevrange("SYNC", head)
-            head = item[1] if item else "1.0"
+        while todo_tag_range := self.tag_nextrange("TODO", next):
+            self.tag_remove("SYNC", todo_tag_range[0], todo_tag_range[1])
+            sync_tag_range = self.tag_prevrange("SYNC", todo_tag_range[0])
+            head = sync_tag_range[1] if sync_tag_range else "1.0"
 
             chars = ""
             next = head
@@ -256,23 +295,8 @@ class ColorDelegator(Delegator):
                     return
                 for tag in self.tagdefs:
                     self.tag_remove(tag, mark, next)
-                chars = chars + line
-                m = self.prog.search(chars)
-                while m:
-                    for key, value in m.groupdict().items():
-                        if value:
-                            a, b = m.span(key)
-                            self.tag_add(key,
-                                         head + "+%dc" % a,
-                                         head + "+%dc" % b)
-                            if value in ("def", "class"):
-                                m1 = self.idprog.match(chars, b)
-                                if m1:
-                                    a, b = m1.span(1)
-                                    self.tag_add("DEFINITION",
-                                                 head + "+%dc" % a,
-                                                 head + "+%dc" % b)
-                    m = self.prog.search(chars, m.end())
+                chars += line
+                self._add_tags_in_section(chars, head)
                 if "SYNC" in self.tag_names(next + "-1c"):
                     head = next
                     chars = ""
@@ -291,6 +315,40 @@ class ColorDelegator(Delegator):
                     if DEBUG: print("colorizing stopped")
                     return
 
+    def _add_tag(self, start, end, head, matched_group_name):
+        """Add a tag to a given range in the text widget.
+
+        This is a utility function, receiving the range as `start` and
+        `end` positions, each of which is a number of characters
+        relative to the given `head` index in the text widget.
+
+        The tag to add is determined by `matched_group_name`, which is
+        the name of a regular expression "named group" as matched by
+        by the relevant highlighting regexps.
+        """
+        tag = prog_group_name_to_tag.get(matched_group_name,
+                                         matched_group_name)
+        self.tag_add(tag,
+                     f"{head}+{start:d}c",
+                     f"{head}+{end:d}c")
+
+    def _add_tags_in_section(self, chars, head):
+        """Parse and add highlighting tags to a given part of the text.
+
+        `chars` is a string with the text to parse and to which
+        highlighting is to be applied.
+
+            `head` is the index in the text widget where the text is found.
+        """
+        for m in self.prog.finditer(chars):
+            for name, matched_text in matched_named_groups(m):
+                a, b = m.span(name)
+                self._add_tag(a, b, head, name)
+                if matched_text in ("def", "class"):
+                    if m1 := self.idprog.match(chars, b):
+                        a, b = m1.span(1)
+                        self._add_tag(a, b, head, "DEFINITION")
+
     def removecolors(self):
         "Remove all colorizing tags."
         for tag in self.tagdefs:
@@ -299,27 +357,14 @@ class ColorDelegator(Delegator):
 
 def _color_delegator(parent):  # htest #
     from tkinter import Toplevel, Text
+    from idlelib.idle_test.test_colorizer import source
     from idlelib.percolator import Percolator
 
     top = Toplevel(parent)
     top.title("Test ColorDelegator")
     x, y = map(int, parent.geometry().split('+')[1:])
-    top.geometry("700x250+%d+%d" % (x + 20, y + 175))
-    source = (
-        "if True: int ('1') # keyword, builtin, string, comment\n"
-        "elif False: print(0)\n"
-        "else: float(None)\n"
-        "if iF + If + IF: 'keyword matching must respect case'\n"
-        "if'': x or''  # valid keyword-string no-space combinations\n"
-        "async def f(): await g()\n"
-        "# All valid prefixes for unicode and byte strings should be colored.\n"
-        "'x', '''x''', \"x\", \"\"\"x\"\"\"\n"
-        "r'x', u'x', R'x', U'x', f'x', F'x'\n"
-        "fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x'\n"
-        "b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x'\n"
-        "# Invalid combinations of legal characters should be half colored.\n"
-        "ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x'\n"
-        )
+    top.geometry("700x550+%d+%d" % (x + 20, y + 175))
+
     text = Text(top, background="white")
     text.pack(expand=1, fill="both")
     text.insert("insert", source)
index e80384b7775222ce97185cafeb56af2d7dfa8057..19041c6054e4cc9aa5a98ad5438c5c19c49dedce 100644 (file)
@@ -5,7 +5,7 @@
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>IDLE &#8212; Python 3.10.0a6 documentation</title>
+    <title>IDLE &#8212; Python 3.11.0a0 documentation</title>
     <link rel="stylesheet" href="../_static/pydoctheme.css" type="text/css" />
     <link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
 
@@ -18,7 +18,7 @@
     <script src="../_static/sidebar.js"></script>
 
     <link rel="search" type="application/opensearchdescription+xml"
-          title="Search within Python 3.10.0a6 documentation"
+          title="Search within Python 3.11.0a0 documentation"
           href="../_static/opensearch.xml"/>
     <link rel="author" title="About these documents" href="../about.html" />
     <link rel="index" title="Index" href="../genindex.html" />
@@ -71,7 +71,7 @@
 
 
     <li id="cpython-language-and-version">
-      <a href="../index.html">3.10.0a6 Documentation</a> &#187;
+      <a href="../index.html">3.11.0a0 Documentation</a> &#187;
     </li>
 
           <li class="nav-item nav-item-1"><a href="index.html" >The Python Standard Library</a> &#187;</li>
 
   <div class="section" id="idle">
 <span id="id1"></span><h1>IDLE<a class="headerlink" href="#idle" title="Permalink to this headline">¶</a></h1>
-<p><strong>Source code:</strong> <a class="reference external" href="https://github.com/python/cpython/tree/master/Lib/idlelib/">Lib/idlelib/</a></p>
+<p><strong>Source code:</strong> <a class="reference external" href="https://github.com/python/cpython/tree/main/Lib/idlelib/">Lib/idlelib/</a></p>
 <hr class="docutils" id="index-0" />
 <p>IDLE is Python’s Integrated Development and Learning Environment.</p>
 <p>IDLE has the following features:</p>
@@ -581,6 +581,11 @@ user error.  For Python code, at the shell prompt or in an editor, these are
 keywords, builtin class and function names, names following <code class="docutils literal notranslate"><span class="pre">class</span></code> and
 <code class="docutils literal notranslate"><span class="pre">def</span></code>, strings, and comments. For any text window, these are the cursor (when
 present), found text (when possible), and selected text.</p>
+<p>IDLE also highlights the <a class="reference internal" href="../reference/lexical_analysis.html#soft-keywords"><span class="std std-ref">soft keywords</span></a> <a class="reference internal" href="../reference/compound_stmts.html#match"><code class="xref std std-keyword docutils literal notranslate"><span class="pre">match</span></code></a>,
+<a class="reference internal" href="../reference/compound_stmts.html#match"><code class="xref std std-keyword docutils literal notranslate"><span class="pre">case</span></code></a>, and <a class="reference internal" href="../reference/compound_stmts.html#wildcard-patterns"><code class="xref std std-keyword docutils literal notranslate"><span class="pre">_</span></code></a> in
+pattern-matching statements. However, this highlighting is not perfect and
+will be incorrect in some rare cases, including some <code class="docutils literal notranslate"><span class="pre">_</span></code>-s in <code class="docutils literal notranslate"><span class="pre">case</span></code>
+patterns.</p>
 <p>Text coloring is done in the background, so uncolorized text is occasionally
 visible.  To change the color scheme, use the Configure IDLE dialog
 Highlighting tab.  The marking of debugger breakpoint lines in the editor and
@@ -685,7 +690,7 @@ intended to be the same as executing the same code by the default method,
 directly with Python in a text-mode system console or terminal window.
 However, the different interface and operation occasionally affect
 visible results.  For instance, <code class="docutils literal notranslate"><span class="pre">sys.modules</span></code> starts with more entries,
-and <code class="docutils literal notranslate"><span class="pre">threading.activeCount()</span></code> returns 2 instead of 1.</p>
+and <code class="docutils literal notranslate"><span class="pre">threading.active_count()</span></code> returns 2 instead of 1.</p>
 <p>By default, IDLE runs user code in a separate OS process rather than in
 the user interface process that runs the shell and editor.  In the execution
 process, it replaces <code class="docutils literal notranslate"><span class="pre">sys.stdin</span></code>, <code class="docutils literal notranslate"><span class="pre">sys.stdout</span></code>, and <code class="docutils literal notranslate"><span class="pre">sys.stderr</span></code>
@@ -939,7 +944,7 @@ also used for testing.</p>
     <ul class="this-page-menu">
       <li><a href="../bugs.html">Report a Bug</a></li>
       <li>
-        <a href="https://github.com/python/cpython/blob/master/Doc/library/idle.rst"
+        <a href="https://github.com/python/cpython/blob/main/Doc/library/idle.rst"
             rel="nofollow">Show Source
         </a>
       </li>
@@ -971,7 +976,7 @@ also used for testing.</p>
 
 
     <li id="cpython-language-and-version">
-      <a href="../index.html">3.10.0a6 Documentation</a> &#187;
+      <a href="../index.html">3.11.0a0 Documentation</a> &#187;
     </li>
 
           <li class="nav-item nav-item-1"><a href="index.html" >The Python Standard Library</a> &#187;</li>
@@ -997,13 +1002,19 @@ also used for testing.</p>
     <div class="footer">
     &copy; <a href="../copyright.html">Copyright</a> 2001-2021, Python Software Foundation.
     <br />
+    This page is licensed under the Python Software Foundation License Version 2.
+    <br />
+    Examples, recipes, and other code in the documentation are additionally licensed under the Zero Clause BSD License.
+    <br />
+    See <a href="">History and License</a> for more information.
+    <br /><br />
 
     The Python Software Foundation is a non-profit corporation.
 <a href="https://www.python.org/psf/donations/">Please donate.</a>
 <br />
     <br />
 
-    Last updated on Mar 29, 2021.
+    Last updated on May 11, 2021.
     <a href="https://docs.python.org/3/bugs.html">Found a bug</a>?
     <br />
 
index c31c49236ca0b9854d1272ca5e6f59a953a176d4..498480a74e3551ad53e5853ebbf5676d1c7a496f 100644 (file)
@@ -1,11 +1,12 @@
-"Test colorizer, coverage 93%."
-
+"Test colorizer, coverage 99%."
 from idlelib import colorizer
 from test.support import requires
 import unittest
 from unittest import mock
+from .tkinter_testing_utils import run_in_tk_mainloop
 
 from functools import partial
+import textwrap
 from tkinter import Tk, Text
 from idlelib import config
 from idlelib.percolator import Percolator
@@ -19,15 +20,38 @@ testcfg = {
     'extensions': config.IdleUserConfParser(''),
 }
 
-source = (
-    "if True: int ('1') # keyword, builtin, string, comment\n"
-    "elif False: print(0)  # 'string' in comment\n"
-    "else: float(None)  # if in comment\n"
-    "if iF + If + IF: 'keyword matching must respect case'\n"
-    "if'': x or''  # valid string-keyword no-space combinations\n"
-    "async def f(): await g()\n"
-    "'x', '''x''', \"x\", \"\"\"x\"\"\"\n"
-    )
+source = textwrap.dedent("""\
+    if True: int ('1') # keyword, builtin, string, comment
+    elif False: print(0)  # 'string' in comment
+    else: float(None)  # if in comment
+    if iF + If + IF: 'keyword matching must respect case'
+    if'': x or''  # valid keyword-string no-space combinations
+    async def f(): await g()
+    # Strings should be entirely colored, including quotes.
+    'x', '''x''', "x", \"""x\"""
+    'abc\\
+    def'
+    '''abc\\
+    def'''
+    # All valid prefixes for unicode and byte strings should be colored.
+    r'x', u'x', R'x', U'x', f'x', F'x'
+    fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x'
+    b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x'
+    # Invalid combinations of legal characters should be half colored.
+    ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x'
+    match point:
+        case (x, 0) as _:
+            print(f"X={x}")
+        case [_, [_], "_",
+                _]:
+            pass
+        case _ if ("a" if _ else set()): pass
+        case _:
+            raise ValueError("Not a point _")
+    '''
+    case _:'''
+    "match x:"
+    """)
 
 
 def setUpModule():
@@ -107,7 +131,7 @@ class ColorDelegatorInstantiationTest(unittest.TestCase):
         requires('gui')
         root = cls.root = Tk()
         root.withdraw()
-        text = cls.text = Text(root)
+        cls.text = Text(root)
 
     @classmethod
     def tearDownClass(cls):
@@ -152,7 +176,7 @@ class ColorDelegatorTest(unittest.TestCase):
 
     @classmethod
     def tearDownClass(cls):
-        cls.percolator.redir.close()
+        cls.percolator.close()
         del cls.percolator, cls.text
         cls.root.update_idletasks()
         cls.root.destroy()
@@ -364,8 +388,21 @@ class ColorDelegatorTest(unittest.TestCase):
                     ('4.0', ('KEYWORD',)), ('4.3', ()), ('4.6', ()),
                     ('5.2', ('STRING',)), ('5.8', ('KEYWORD',)), ('5.10', ('STRING',)),
                     ('6.0', ('KEYWORD',)), ('6.10', ('DEFINITION',)), ('6.11', ()),
-                    ('7.0', ('STRING',)), ('7.4', ()), ('7.5', ('STRING',)),
-                    ('7.12', ()), ('7.14', ('STRING',)),
+                    ('8.0', ('STRING',)), ('8.4', ()), ('8.5', ('STRING',)),
+                    ('8.12', ()), ('8.14', ('STRING',)),
+                    ('19.0', ('KEYWORD',)),
+                    ('20.4', ('KEYWORD',)), ('20.16', ('KEYWORD',)),# ('20.19', ('KEYWORD',)),
+                    #('22.4', ('KEYWORD',)), ('22.10', ('KEYWORD',)), ('22.14', ('KEYWORD',)), ('22.19', ('STRING',)),
+                    #('23.12', ('KEYWORD',)),
+                    ('24.8', ('KEYWORD',)),
+                    ('25.4', ('KEYWORD',)), ('25.9', ('KEYWORD',)),
+                    ('25.11', ('KEYWORD',)), ('25.15', ('STRING',)),
+                    ('25.19', ('KEYWORD',)), ('25.22', ()),
+                    ('25.24', ('KEYWORD',)), ('25.29', ('BUILTIN',)), ('25.37', ('KEYWORD',)),
+                    ('26.4', ('KEYWORD',)), ('26.9', ('KEYWORD',)),# ('26.11', ('KEYWORD',)), ('26.14', (),),
+                    ('27.25', ('STRING',)), ('27.38', ('STRING',)),
+                    ('29.0', ('STRING',)),
+                    ('30.1', ('STRING',)),
                     # SYNC at the end of every line.
                     ('1.55', ('SYNC',)), ('2.50', ('SYNC',)), ('3.34', ('SYNC',)),
                    )
@@ -391,11 +428,173 @@ class ColorDelegatorTest(unittest.TestCase):
         eq(text.tag_nextrange('COMMENT', '2.0'), ('2.22', '2.43'))
         eq(text.tag_nextrange('SYNC', '2.0'), ('2.43', '3.0'))
         eq(text.tag_nextrange('STRING', '2.0'), ('4.17', '4.53'))
-        eq(text.tag_nextrange('STRING', '7.0'), ('7.0', '7.3'))
-        eq(text.tag_nextrange('STRING', '7.3'), ('7.5', '7.12'))
-        eq(text.tag_nextrange('STRING', '7.12'), ('7.14', '7.17'))
-        eq(text.tag_nextrange('STRING', '7.17'), ('7.19', '7.26'))
-        eq(text.tag_nextrange('SYNC', '7.0'), ('7.26', '9.0'))
+        eq(text.tag_nextrange('STRING', '8.0'), ('8.0', '8.3'))
+        eq(text.tag_nextrange('STRING', '8.3'), ('8.5', '8.12'))
+        eq(text.tag_nextrange('STRING', '8.12'), ('8.14', '8.17'))
+        eq(text.tag_nextrange('STRING', '8.17'), ('8.19', '8.26'))
+        eq(text.tag_nextrange('SYNC', '8.0'), ('8.26', '9.0'))
+        eq(text.tag_nextrange('SYNC', '30.0'), ('30.10', '32.0'))
+
+    def _assert_highlighting(self, source, tag_ranges):
+        """Check highlighting of a given piece of code.
+
+        This inserts just this code into the Text widget. It will then
+        check that the resulting highlighting tag ranges exactly match
+        those described in the given `tag_ranges` dict.
+
+        Note that the irrelevant tags 'sel', 'TODO' and 'SYNC' are
+        ignored.
+        """
+        text = self.text
+
+        with mock.patch.object(colorizer.ColorDelegator, 'notify_range'):
+            text.delete('1.0', 'end-1c')
+            text.insert('insert', source)
+            text.tag_add('TODO', '1.0', 'end-1c')
+            self.color.recolorize_main()
+
+        # Make a dict with highlighting tag ranges in the Text widget.
+        text_tag_ranges = {}
+        for tag in set(text.tag_names()) - {'sel', 'TODO', 'SYNC'}:
+            indexes = [rng.string for rng in text.tag_ranges(tag)]
+            for index_pair in zip(indexes[::2], indexes[1::2]):
+                text_tag_ranges.setdefault(tag, []).append(index_pair)
+
+        self.assertEqual(text_tag_ranges, tag_ranges)
+
+        with mock.patch.object(colorizer.ColorDelegator, 'notify_range'):
+            text.delete('1.0', 'end-1c')
+
+    def test_def_statement(self):
+        # empty def
+        self._assert_highlighting('def', {'KEYWORD': [('1.0', '1.3')]})
+
+        # def followed by identifier
+        self._assert_highlighting('def foo:', {'KEYWORD': [('1.0', '1.3')],
+                                               'DEFINITION': [('1.4', '1.7')]})
+
+        # def followed by partial identifier
+        self._assert_highlighting('def fo', {'KEYWORD': [('1.0', '1.3')],
+                                             'DEFINITION': [('1.4', '1.6')]})
+
+        # def followed by non-keyword
+        self._assert_highlighting('def ++', {'KEYWORD': [('1.0', '1.3')]})
+
+    def test_match_soft_keyword(self):
+        # empty match
+        self._assert_highlighting('match', {'KEYWORD': [('1.0', '1.5')]})
+
+        # match followed by partial identifier
+        self._assert_highlighting('match fo', {'KEYWORD': [('1.0', '1.5')]})
+
+        # match followed by identifier and colon
+        self._assert_highlighting('match foo:', {'KEYWORD': [('1.0', '1.5')]})
+
+        # match followed by keyword
+        self._assert_highlighting('match and', {'KEYWORD': [('1.6', '1.9')]})
+
+        # match followed by builtin with keyword prefix
+        self._assert_highlighting('match int:', {'KEYWORD': [('1.0', '1.5')],
+                                                 'BUILTIN': [('1.6', '1.9')]})
+
+        # match followed by non-text operator
+        self._assert_highlighting('match^', {})
+        self._assert_highlighting('match @', {})
+
+        # match followed by colon
+        self._assert_highlighting('match :', {})
+
+        # match followed by comma
+        self._assert_highlighting('match\t,', {})
+
+        # match followed by a lone underscore
+        self._assert_highlighting('match _:', {'KEYWORD': [('1.0', '1.5')]})
+
+    def test_case_soft_keyword(self):
+        # empty case
+        self._assert_highlighting('case', {'KEYWORD': [('1.0', '1.4')]})
+
+        # case followed by partial identifier
+        self._assert_highlighting('case fo', {'KEYWORD': [('1.0', '1.4')]})
+
+        # case followed by identifier and colon
+        self._assert_highlighting('case foo:', {'KEYWORD': [('1.0', '1.4')]})
+
+        # case followed by keyword
+        self._assert_highlighting('case and', {'KEYWORD': [('1.5', '1.8')]})
+
+        # case followed by builtin with keyword prefix
+        self._assert_highlighting('case int:', {'KEYWORD': [('1.0', '1.4')],
+                                                'BUILTIN': [('1.5', '1.8')]})
+
+        # case followed by non-text operator
+        self._assert_highlighting('case^', {})
+        self._assert_highlighting('case @', {})
+
+        # case followed by colon
+        self._assert_highlighting('case :', {})
+
+        # case followed by comma
+        self._assert_highlighting('case\t,', {})
+
+        # case followed by a lone underscore
+        self._assert_highlighting('case _:', {'KEYWORD': [('1.0', '1.4'),
+                                                          ('1.5', '1.6')]})
+
+    def test_long_multiline_string(self):
+        source = textwrap.dedent('''\
+            """a
+            b
+            c
+            d
+            e"""
+            ''')
+        self._assert_highlighting(source, {'STRING': [('1.0', '5.4')]})
+
+    @run_in_tk_mainloop
+    def test_incremental_editing(self):
+        text = self.text
+        eq = self.assertEqual
+
+        # Simulate typing 'inte'. During this, the highlighting should
+        # change from normal to keyword to builtin to normal.
+        text.insert('insert', 'i')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ())
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ())
+
+        text.insert('insert', 'n')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ())
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2'))
+
+        text.insert('insert', 't')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3'))
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ())
+
+        text.insert('insert', 'e')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ())
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ())
+
+        # Simulate deleting three characters from the end of 'inte'.
+        # During this, the highlighting should change from normal to
+        # builtin to keyword to normal.
+        text.delete('insert-1c', 'insert')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3'))
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ())
+
+        text.delete('insert-1c', 'insert')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ())
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2'))
+
+        text.delete('insert-1c', 'insert')
+        yield
+        eq(text.tag_nextrange('BUILTIN', '1.0'), ())
+        eq(text.tag_nextrange('KEYWORD', '1.0'), ())
 
     @mock.patch.object(colorizer.ColorDelegator, 'recolorize')
     @mock.patch.object(colorizer.ColorDelegator, 'notify_range')
diff --git a/Misc/NEWS.d/next/IDLE/2021-05-09-09-02-09.bpo-44010.TaLe9x.rst b/Misc/NEWS.d/next/IDLE/2021-05-09-09-02-09.bpo-44010.TaLe9x.rst
new file mode 100644 (file)
index 0000000..becd331
--- /dev/null
@@ -0,0 +1,5 @@
+Highlight the new :ref:`match <match>` statement's
+:ref:`soft keywords <soft-keywords>`: :keyword:`match`,
+:keyword:`case <match>`, and :keyword:`_ <wildcard-patterns>`.
+However, this highlighting is not perfect and will be incorrect in some
+rare cases, including some ``_``-s in ``case`` patterns.