Skip to content

fix(python): count control flow inside f-string interpolations (#317)#481

Merged
terryyin merged 1 commit into
terryyin:masterfrom
StressTestor:fix/317-fstring-complexity
Jun 11, 2026
Merged

fix(python): count control flow inside f-string interpolations (#317)#481
terryyin merged 1 commit into
terryyin:masterfrom
StressTestor:fix/317-fstring-complexity

Conversation

@StressTestor

Copy link
Copy Markdown
Contributor

Fixes #317.

lizard doesn't count control flow hidden inside f-string interpolations. this function:

def f(items):
    return f"{', '.join([x for x in items])}"

reports CCN 1. drop the f-string wrapper and it's correctly 2 - the comprehension for is a branch either way.

why

the tokenizer treats a whole f-string as one opaque string token, so the for/if/and/or inside {...} never reach the condition counter. same blind spot for a ternary if or a logical operator inside an interpolation.

the fix

generate_tokens now re-tokenizes the interpolations. the tokenizer already emits the f-string prefix (f, rf, ...) as its own token right before the string body, so it watches for a prefix followed by a string, splits the body into literal chunks (kept as string tokens) and interpolation expressions, and tokenizes each expression as the python code it is - so the inner keywords get counted. nested f-strings recurse. the splitting follows what the TypeScript reader already does for template-literal ${...}; this additionally tokenizes the expression so the conditions count.

what it gets right, with tests:

  • {{ and }} stay literal braces, not interpolations
  • a keyword inside a nested string (f"{x or 'skip if empty'}") doesn't count - only the real or does
  • a plain f-string with no control flow stays CCN 1

tests

8 cases in testPython.py covering comprehensions, ternaries, logical operators, nested f-strings, the escape and nested-string guards, and parity with the non-f-string equivalent. full suite green - the one unrelated test_gitignore_filter failure is pre-existing on master.

…yin#317)

An f-string was emitted as a single opaque token, so control-flow
keywords inside its {...} interpolations (a comprehension 'for', a
ternary 'if', 'and'/'or') never reached the condition counter and
cyclomatic complexity was undercounted. f"{[x for x in items]}" scored
1 instead of 2.

The tokenizer emits the f-string prefix ('f', 'rf', ...) as its own
token right before the string body, so generate_tokens now watches for
a prefix followed by a string and re-tokenizes each {...} interpolation
as Python code (recursing for nested f-strings). Literal text and the
'{{'/'}}' escapes stay literal, and keywords inside nested string
literals are not counted because they remain string tokens.
@terryyin terryyin merged commit cf85f5a into terryyin:master Jun 11, 2026
4 checks passed
@terryyin

Copy link
Copy Markdown
Owner

thanks for the PR. I also added a small follow up a46756d

@StressTestor

Copy link
Copy Markdown
Contributor Author

nice, that brace-in-string skip is a good catch - my depth counter would've tripped on a } inside a quoted literal.

and you're right the original #317 (backslash at the end of a # comment) is still live. it's actually worse than the CCN undercount - the comment swallows the next line via the \<newline> continuation, so a function right after such a comment can vanish from the report entirely. it hits ruby/perl/r too, since they share the # pattern in generate_common_tokens. picking it up now, PR incoming.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python - Wrong Cyclomatic Complexity values

2 participants