[Python-checkins] bpo-44010: IDLE: colorize pattern-matching soft keywords (GH-25851)

miss-islington webhook-mailer at python.org
Wed May 19 05:44:18 EDT 2021


https://github.com/python/cpython/commit/3357604db966693b752cbd9ffc3ad0f40b970d31
commit: 3357604db966693b752cbd9ffc3ad0f40b970d31
branch: 3.10
author: Miss Islington (bot) <31488909+miss-islington at users.noreply.github.com>
committer: miss-islington <31488909+miss-islington at users.noreply.github.com>
date: 2021-05-19T02:44:14-07:00
summary:

bpo-44010: IDLE: colorize pattern-matching soft keywords (GH-25851)

(cherry picked from commit 60d343a81679ea90ae0e08fadcd132c16906a51a)

Co-authored-by: Tal Einat <532281+taleinat at users.noreply.github.com>

files:
A Misc/NEWS.d/next/IDLE/2021-05-09-09-02-09.bpo-44010.TaLe9x.rst
M Doc/library/idle.rst
M Doc/whatsnew/3.10.rst
M Lib/idlelib/colorizer.py
M Lib/idlelib/help.html
M Lib/idlelib/idle_test/test_colorizer.py

diff --git a/Doc/library/idle.rst b/Doc/library/idle.rst
index 3c302115b5f40..faa34e69ff15d 100644
--- a/Doc/library/idle.rst
+++ b/Doc/library/idle.rst
@@ -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
diff --git a/Doc/whatsnew/3.10.rst b/Doc/whatsnew/3.10.rst
index 926679e6f32dc..570af7f3b6181 100644
--- a/Doc/whatsnew/3.10.rst
+++ b/Doc/whatsnew/3.10.rst
@@ -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
 ------------------
 
diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py
index 3c527409731af..e9f19c145c867 100644
--- a/Lib/idlelib/colorizer.py
+++ b/Lib/idlelib/colorizer.py
@@ -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 @@ def recolorize(self):
     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 @@ def recolorize_main(self):
                     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 @@ def recolorize_main(self):
                     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 @@ def removecolors(self):
 
 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)
diff --git a/Lib/idlelib/help.html b/Lib/idlelib/help.html
index e80384b777522..19041c6054e4c 100644
--- a/Lib/idlelib/help.html
+++ b/Lib/idlelib/help.html
@@ -5,7 +5,7 @@
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>IDLE — Python 3.10.0a6 documentation</title>
+    <title>IDLE — 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 @@ <h3>Navigation</h3>
 
 
     <li id="cpython-language-and-version">
-      <a href="../index.html">3.10.0a6 Documentation</a> »
+      <a href="../index.html">3.11.0a0 Documentation</a> »
     </li>
 
           <li class="nav-item nav-item-1"><a href="index.html" >The Python Standard Library</a> »</li>
@@ -102,7 +102,7 @@ <h3>Navigation</h3>
 
   <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 @@ <h3>Text colors<a class="headerlink" href="#text-colors" title="Permalink to thi
 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 @@ <h3>Running user code<a class="headerlink" href="#running-user-code" title="Perm
 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 @@ <h3>This Page</h3>
     <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 @@ <h3>Navigation</h3>
 
 
     <li id="cpython-language-and-version">
-      <a href="../index.html">3.10.0a6 Documentation</a> »
+      <a href="../index.html">3.11.0a0 Documentation</a> »
     </li>
 
           <li class="nav-item nav-item-1"><a href="index.html" >The Python Standard Library</a> »</li>
@@ -997,13 +1002,19 @@ <h3>Navigation</h3>
     <div class="footer">
     © <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 />
 
diff --git a/Lib/idlelib/idle_test/test_colorizer.py b/Lib/idlelib/idle_test/test_colorizer.py
index c31c49236ca0b..498480a74e355 100644
--- a/Lib/idlelib/idle_test/test_colorizer.py
+++ b/Lib/idlelib/idle_test/test_colorizer.py
@@ -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 @@
     '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 @@ def setUpClass(cls):
         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 @@ def setUpClass(cls):
 
     @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 @@ def test_recolorize_main(self, mock_notify):
                     ('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 @@ def test_recolorize_main(self, mock_notify):
         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
index 0000000000000..becd331f6d789
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2021-05-09-09-02-09.bpo-44010.TaLe9x.rst
@@ -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.



More information about the Python-checkins mailing list