Skip to content

Commit 82c8e7a

Browse files
authored
Merge pull request #477 from ixnes/fix/python_match_case
Add cyclomatic complexity support for Python `match`/`case` (PEP 634, Python 3.10+)
2 parents bd261a1 + 0d22cc7 commit 82c8e7a

4 files changed

Lines changed: 519 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
### New Features
6+
- **Python** — structural pattern matching (`match`/`case`, PEP 634, Python 3.10+) is now counted correctly:
7+
- Each `case` arm adds +1 to cyclomatic complexity (like an `if`/`elif` branch)
8+
- `case` guards (`case x if cond:`) count the `if` as a normal condition
9+
- `case` and `match` used as plain variable names (assignments, attribute access, function calls, subscripts, tuple unpacking, annotated assignments) are not counted — soft-keyword disambiguation via lookahead in `preprocess`
10+
- `--modified`: an entire `match`/`case` block counts as 1, consistent with `switch`/`case` in C-family languages
11+
512
## 1.22.2
613

714
### Bug Fixes

lizard_ext/lizardmodified.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,36 @@
77

88
class LizardExtension(object): # pylint: disable=R0903
99
"""
10-
Modified CCN extension: counts entire switch/case as 1 complexity.
11-
Adds +1 for 'switch', subtracts -1 for each 'case'.
12-
Works with switch/case keywords (conceptually from reader.case_keywords).
10+
Modified CCN extension: counts an entire switch/case (or Python match/case)
11+
as 1 complexity point instead of 1 per arm.
12+
Adds +1 for 'switch'/'match' (the block opener), subtracts -1 for each arm.
1313
"""
1414

1515
def __call__(self, tokens, reader):
1616
for token in tokens:
17-
if token == 'switch': # Add complexity for switch statement
17+
if token in ('switch', 'match') and self._is_block_keyword(token, reader):
1818
reader.context.add_condition()
1919
if hasattr(reader.context, "add_nd_condition"):
2020
reader.context.add_nd_condition()
21-
elif token == 'case': # Subtract complexity for each case
21+
elif token == 'case' and self._is_case_keyword(reader):
2222
reader.context.add_condition(-1)
2323
if hasattr(reader.context, "add_nd_condition"):
2424
reader.context.add_nd_condition(-1)
2525
yield token
26+
27+
@staticmethod
28+
def _is_block_keyword(token, reader):
29+
"""True when this token opens a switch-like block that counts as 1."""
30+
if token == 'switch':
31+
return True
32+
# 'match' is a soft keyword in Python; the reader sets _keyword_match.
33+
return getattr(reader, '_keyword_match', False)
34+
35+
@staticmethod
36+
def _is_case_keyword(reader):
37+
"""True when condition_counter or the reader has already counted this 'case'."""
38+
# Formal keyword languages (C, C++, Java, C#): condition_counter added +1.
39+
if 'case' in reader.conditions:
40+
return True
41+
# Python soft keyword: process_token will add +1 via _keyword_case.
42+
return getattr(reader, '_keyword_case', False)

lizard_languages/python.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,28 @@ class PythonReader(CodeReader, ScriptLanguageMixIn):
3636
_case_keywords = set() # Python uses if/elif, not case
3737
_ternary_operators = set() # Python uses 'x if c else y' syntax, not ?
3838

39+
# Tokens that, when immediately following 'case' or 'match' at line start,
40+
# indicate a variable rather than a soft keyword.
41+
# '.' → attribute access (case.value, match.group)
42+
# '=' → assignment (case = 5)
43+
# ':' → annotated assignment (case: int = 5)
44+
# ',' → tuple unpacking (case, other = 1, 2)
45+
# compound assignments (case += 1, etc.)
46+
# '(' and '[' require deeper lookahead (see _soft_keyword_lookahead) to
47+
# distinguish pattern starters ('case (x, y):') from function calls
48+
# ('case(x)') and subscripts ('case[0]').
49+
_SOFT_KW_VARIABLE_NEXT = frozenset((
50+
'=', '.', ':', ',',
51+
'+=', '-=', '*=', '/=', '%=',
52+
'//=', '**=', '&=', '|=', '^=', '<<=', '>>=', ':=',
53+
))
54+
3955
def __init__(self, context):
4056
super(PythonReader, self).__init__(context)
4157
self.parallel_states = [PythonStates(context, self)]
4258
self._last_meaningful_token = None # Track the last meaningful token
59+
self._keyword_case = False # set by _soft_keyword_lookahead: True when 'case' is a soft keyword
60+
self._keyword_match = False # set by _soft_keyword_lookahead: True when 'match' is a soft keyword
4361

4462
@staticmethod
4563
def generate_tokens(source_code, addition='', token_class=None):
@@ -50,16 +68,28 @@ def generate_tokens(source_code, addition='', token_class=None):
5068
token_class)
5169

5270
def process_token(self, token):
53-
"""Process triple-quoted strings used as comments.
71+
"""Process triple-quoted strings used as comments, and Python soft keywords.
5472
5573
Triple-quoted strings that are not docstrings (i.e., not immediately
5674
after function definitions) should be treated like comments and not
5775
counted in NLOC, but only if they appear to be standalone statements
5876
rather than part of assignments or other expressions.
5977
78+
'case' used as a soft keyword adds +1 to cyclomatic complexity.
79+
The keyword-vs-variable distinction is made in _soft_keyword_lookahead
80+
(called from preprocess) and stored in self._keyword_case before this
81+
method is invoked. 'match' itself adds no complexity for regular CCN;
82+
the --modified extension adds +1 for the block via self._keyword_match.
83+
6084
Returns:
6185
bool: True if the token was handled specially, False otherwise
6286
"""
87+
# --- Soft keyword: case ---
88+
# _keyword_case is pre-set by _soft_keyword_lookahead in preprocess().
89+
if token == 'case' and self._keyword_case:
90+
self.context.add_condition()
91+
92+
# --- Triple-quoted string comment suppression ---
6393
if (token.startswith('"""') or token.startswith("'''")) and len(token) >= 6:
6494
# Check if this is likely a standalone comment (not a docstring)
6595
# Docstrings are handled separately in _state_first_line
@@ -84,11 +114,90 @@ def process_token(self, token):
84114

85115
return False # Continue with normal processing
86116

117+
def _soft_keyword_lookahead(self, tokens):
118+
"""Wrap the token stream to pre-detect Python soft keywords.
119+
120+
Yields tokens in the original order. Before yielding 'case' or
121+
'match' at the start of a statement, reads ahead to the next
122+
non-whitespace token and sets self._keyword_case / self._keyword_match
123+
so that downstream consumers (extensions and process_token) can read
124+
the flag when they receive that token.
125+
126+
Detection rules (applied in order):
127+
- If the next non-whitespace token is in _SOFT_KW_VARIABLE_NEXT
128+
('=', '.', ':', ',', compound assignments) → variable.
129+
- If the next non-whitespace token is '(' or '[', scan forward
130+
tracking bracket depth across all bracket types; if a ':' at
131+
depth 0 is found before end-of-statement → keyword (case pattern
132+
or match subject with optional guard), otherwise → variable
133+
(function call or subscript).
134+
- Any other following token → keyword.
135+
"""
136+
at_line_start = True
137+
tokens_iter = iter(tokens)
138+
for token in tokens_iter:
139+
if token == '\n':
140+
at_line_start = True
141+
yield token
142+
elif token.isspace():
143+
yield token
144+
elif at_line_start and token in ('case', 'match'):
145+
# Buffer this token; peek at next non-whitespace.
146+
lookahead = []
147+
next_real = None
148+
for t in tokens_iter:
149+
lookahead.append(t)
150+
if t != '\n' and not t.isspace():
151+
next_real = t
152+
break
153+
if next_real in self._SOFT_KW_VARIABLE_NEXT:
154+
is_keyword = False
155+
elif next_real in ('(', '['):
156+
# '(' and '[' are valid pattern starters in match/case
157+
# ('case (x, y):' / 'case [0]:') but also appear in
158+
# function calls ('case(x)') and subscripts ('case[0]').
159+
# Disambiguate by scanning past the balanced bracket group
160+
# and checking whether a ':' at depth 0 follows — that
161+
# colon is the one that opens the case suite. Guards
162+
# ('case (x, y) if cond:') are handled naturally because
163+
# the scan continues until it finds the ':'.
164+
is_keyword = False
165+
depth = 1 # the opening bracket is already in lookahead
166+
for t in tokens_iter:
167+
lookahead.append(t)
168+
if t in ('(', '[', '{'):
169+
depth += 1
170+
elif t in (')', ']', '}'):
171+
depth -= 1
172+
elif t == ':' and depth == 0:
173+
is_keyword = True
174+
break
175+
elif t == '\n' and depth == 0:
176+
break # end of statement — no pattern colon found
177+
else:
178+
is_keyword = True
179+
if token == 'case':
180+
self._keyword_case = is_keyword
181+
else:
182+
self._keyword_match = is_keyword
183+
at_line_start = False
184+
yield token
185+
for t in lookahead:
186+
yield t
187+
else:
188+
# Not a soft keyword at line start — clear stale flags.
189+
if token == 'case':
190+
self._keyword_case = False
191+
elif token == 'match':
192+
self._keyword_match = False
193+
at_line_start = False
194+
yield token
195+
87196
def preprocess(self, tokens):
88197
indents = PythonIndents(self.context)
89198
current_leading_spaces = 0
90199
reading_leading_space = True
91-
for token in tokens:
200+
for token in self._soft_keyword_lookahead(tokens):
92201
if token != '\n':
93202
if reading_leading_space:
94203
if token.isspace():

0 commit comments

Comments
 (0)