Skip to content

SONARHTML-472 Handle plain C# conditionals in Razor code blocks - #807

Open
victor-peixoto-sonarsource wants to merge 19 commits into
masterfrom
victormsp/SONARHTML-472
Open

SONARHTML-472 Handle plain C# conditionals in Razor code blocks#807
victor-peixoto-sonarsource wants to merge 19 commits into
masterfrom
victormsp/SONARHTML-472

Conversation

@victor-peixoto-sonarsource

@victor-peixoto-sonarsource victor-peixoto-sonarsource commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Part of SONARHTML-472

Summary

  • recognize plain C# if / else if / else and switch branches inside Razor code blocks
  • preserve branch scope across nested braces, strings, line comments, block comments, and Razor comments
  • ignore HTML-like tags in non-rendered Razor/C# content while keeping the behavior limited to Razor files
  • add regression coverage for nested and chained conditionals, switch branches, comments, and genuine duplicates

Testing

  • mvn verify (689 tests)

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Aug 28, 2026

Copy link
Copy Markdown

SONARHTML-472

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Ruling Report

No changes to ruling expected issues in this PR

@datadog-sonarsource

This comment has been minimized.

@victor-peixoto-sonarsource
victor-peixoto-sonarsource marked this pull request as ready for review August 28, 2026 15:13
escapedCSharpStringCharacter = false;
} else if (!verbatimCSharpString && current == '\\') {
escapedCSharpStringCharacter = true;
} else if (current == csharpStringDelimiter) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes the C# string on any ", but an interpolated string can contain nested string literals inside its {...} holes. For valid Razor like var marker = $"{(true ? "}" : "{")}";, the " before } is read as ending the string, so the following } is counted as a real brace and closes the @{ } block early. The if/else after it then looks unconditional and S7930 reports a false duplicate id. A test with $"..." containing inner quotes would cover this.

if (pendingRenderedClosingBraceDepth < 0) {
return;
}
if (currentElementDepth() >= pendingRenderedClosingBraceDepth && !conditionalBraces.isEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: a literal } in markup text closes the conditional scope

When a lone } appears in rendered markup text and is followed by a child element (no end tag in between), this guard pops the conditional brace and closes the surrounding @if/else scope early. Both branches then look unconditional and S7930 fires a false-positive duplicate id.

Example (differs only by the } in the text):

@{
    @if (Model.ShowPrimary)
    {
        <div>Total is 100} percent
            <span id="choice">First</span>
        </div>
    }
    else
    {
        <span id="choice">Second</span>
    }
}

Result: S7930 line 10: Duplicate id "choice" found. First occurrence was on line 5. The same file without the } is clean. Reproduced against the compiled scanner.

Why: the literal } sets pendingRenderedClosingBraceDepth at the <div> depth. The wrapper's end tag would normally discard it, but the child <span> arrives first, so currentElementDepth() >= pending holds and the conditional is popped even though the } was just text.

Suggestion: only resolve a pending brace when it actually balances an opening markup brace, and add a test with a literal } in branch markup text followed by a child element.

@gitar-bot

gitar-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 13 resolved / 13 findings

Adds C# conditional branch tracking to the Razor lexer to recognize plain if/else if/else and switch statements within Razor code blocks, preserving scope across nested braces, strings, and comments. Resolves 13 edge cases including elementDepth desync, unbalanced braces, verbatim string detection gaps, raw string overflow, nested string context loss, and Razor block state management. All 689 tests pass with new regression coverage for nested conditionals, chained branches, and comment handling.

✅ 13 resolved
Bug: elementDepth desync silently disables C# conditional tracking

📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:113-127 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:129-143 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:300 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:319-321
isInRazorCodeContext() requires elementDepth == razorCodeBlocks.peek().elementDepth() exactly, but elementDepth is not a reliable depth counter: HtmlAstScanner.scanElementTag only calls endElement for end tags and tags whose code ends with />, so every HTML void element written without a slash (<input>, <img>, <br>, <hr>, <meta>, <link>) increments elementDepth and never decrements it. Trigger: @{ <input id="filter" type="text"> if (Model.A) { <div id="dup">A</div> } else { <div id="dup">B</div> } } — after the <input> the depth is one too high, so consumeCSharpConditionalStart never matches the if (, both divs are registered as unconditional and the rule reports a false-positive duplicate id; C# strings and // / /* comments also stop being protected for the rest of the block. The same desync happens in the other direction when a Razor/C# comment contains only one half of a tag pair (e.g. // <div id="x"> followed by a real </div>), because startElement returns early on isInNonRenderedRazorContent() while the later endElement still decrements.

Bug: Unbalanced brace in markup leaves Razor code block open forever

📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:292-303 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:547-559 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:580-588 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:155-157 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/coding/NoDuplicateIDCheck.java:75-78
closeRazorCodeBrace() pops the block only when openingBraceDepth() == razorCodeBraceDepth, and consumeOpeningBrace counts every literal { in rendered text once a block is open. A stray unmatched { inside the block's markup (e.g. @{ if (a) { <code>if (x) {</code> } }) permanently offsets razorCodeBraceDepth, so the block's real closing } no longer matches and razorCodeBlocks never empties. From then on isInRazorCodeContext() stays true at that element depth, so the first ', ", // or /* in ordinary markup text opens a persistent C# string/comment, isInNonRenderedRazorContent() returns true, and NoDuplicateIDCheck.handleIdAttribute silently drops every remaining id in the file. Pop the block whenever the depth falls to or below its opening depth so the state cannot leak past the block.

Edge Case: Verbatim C# string detection misses @$"..." and fragment starts

📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:281-288 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:234-248
verbatimCSharpString is set only when the character immediately before the quote in the same fragment is @, so @$"..." (the C# order that puts @ first is $@", but @$" is equally legal since C# 8) and any @" whose quote lands at index 0 of a split fragment are treated as regular strings. In that state "" is read as end-of-string rather than an escaped quote, so @{ var s = @$"He said ""hi"" }"; } leaves the scanner outside the string at the literal }, which closes the Razor code block early and drops the surrounding conditional scope. Recognise $ as an additional verbatim prefix and remember a trailing @/$ seen at the end of the previous fragment.

Bug: Balanced braces in rendered Razor markup now close the branch early

📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:604-616 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:624-635 📄 sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/TemplateConditionalScopeTrackerTest.java:190-204
consumeOpeningBrace no longer increments nestedTextBlockDepth when the brace sits in rendered markup deeper than the active @{ ... } block (line 608), but consumeClosingBrace still consumes } unconditionally, so the two are no longer symmetric. Trigger: a balanced brace pair inside markup rendered from a Razor code block, e.g. @{ if (Model.ShowPrimary) { <script>function f() { return 1; }</script> <div id="choice">First</div> } else { <div id="choice">Second</div> } } — the { is skipped, then the } falls through to deferBranchContinuation/closeBraceBasedConditional, which drops braceBasedTextConditionalDepth/textConditionalDepth to 0 before the <div> is visited. Both branch divs are then seen as non-conditional and NoDuplicateIDCheck raises a false positive; before this commit the { bumped nestedTextBlockDepth and the } merely decremented it. Note the fix cannot simply ignore all markup braces: tracks_nested_razor_conditionals_in_rendered_markup needs a markup } to close an @if opened in markup, so markup braces must be balanced against each other separately.

Quality: Cross-fragment verbatim-prefix carry is unreachable via the lexer

📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:115 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:134 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:191 📄 sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/TemplateConditionalScopeTracker.java:352-366 📄 sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/TemplateConditionalScopeTrackerTest.java:247-255
TextTokenizer emits one contiguous TextNode up to the next <-token, so two text fragments are always separated by another node; when that node is a tag, startElement/endElement clear trailingCSharpVerbatimPrefix (lines 115, 134) before the next fragment is scanned, and a directive re-computes it. The carry therefore only survives across a comment/expression node (not forwarded to the tracker), so recognizes_verbatim_string_prefix_across_fragments passes only because it calls visitText twice directly, bypassing the lexer — it gives no coverage of a reachable @$ split. Either add a lexer-driven case that actually splits @$ from its quote (e.g. an intervening <!-- -->) or drop the cross-fragment carry and keep the same-fragment @$" handling, which is the part that real Razor input exercises.

...and 8 more resolved from earlier reviews

Implementation Status ✅ 5 of 5 objectives covered
SONARHTML-472 - 5 of 5 objectives covered

This PR implements handling of plain C# conditionals and comments inside Razor blocks, adds appropriate regression fixtures, and ensures genuine duplicates remain reported.

✅ 5 covered here
  • ✅ Verify genuine duplicate ids outside mutually exclusive branches remain reported
  • ✅ Recognize plain C# if, else if, else, and switch branches inside Razor @{...} code blocks
  • ✅ Handle nested braces, strings, line comments, block comments, and Razor comments inside Razor code blocks
  • ✅ Add anonymized regression fixtures for nested and chained plain C# conditionals inside @{...}
  • ✅ Add coverage for HTML-like tags inside Razor and C# comments
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

Copy link
Copy Markdown

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.

2 participants