@@ -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