diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/Thymeleaf.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/Thymeleaf.java index 24c9607c1..f7f389618 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/Thymeleaf.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/Thymeleaf.java @@ -18,6 +18,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; +import javax.annotation.Nullable; +import org.sonar.plugins.html.node.Attribute; import org.sonar.plugins.html.node.TagNode; /** @@ -32,9 +36,29 @@ */ public final class Thymeleaf { + /** + * Attributes that ask Thymeleaf to render an external fragment in place of (or inside) the + * current element. Statically, the resulting markup is opaque — the rendered fragment can + * supply text, controls, or both — so checks should treat these as "do not flag". + */ + public static final Set FRAGMENT_INSERTION_ATTRIBUTES = Set.of("th:insert", "th:include", "th:replace"); + private Thymeleaf() { } + /** + * Returns whether the node carries any Thymeleaf fragment-insertion attribute + * ({@code th:insert}, {@code th:include}, {@code th:replace}). + */ + public static boolean hasFragmentInsertion(TagNode node) { + for (Attribute attribute : node.getAttributes()) { + if (FRAGMENT_INSERTION_ATTRIBUTES.contains(attribute.getName().toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + /** * Returns whether {@code th:attr} on the node assigns a value to the given attribute. * Presence check only — the assigned value may itself be empty. @@ -84,6 +108,33 @@ public static boolean isEmptyAssignmentValue(String value) { return false; } + /** + * Returns whether the raw attribute value is absent, blank, or a quoted literal that resolves + * to whitespace. Combines a null/blank check with {@link #isEmptyAssignmentValue(String)} so + * callers do not have to trim and null-check separately. + */ + public static boolean isEmptyValue(@Nullable String value) { + if (value == null) { + return true; + } + String trimmed = value.trim(); + return trimmed.isEmpty() || isEmptyAssignmentValue(trimmed); + } + + /** + * Returns whether the node sets {@code attributeName} via either the literal {@code th:NAME} + * attribute or a {@code th:attr=NAME=...} assignment, with a non-empty value. This is the + * Thymeleaf-only counterpart used to enrich attribute-presence checks; combine it with a + * plain property lookup at the call site when both forms should be accepted. + */ + public static boolean hasNonEmptyThymeleafAttribute(TagNode node, String attributeName) { + String literalValue = node.getAttribute("th:" + attributeName.toLowerCase(Locale.ROOT)); + if (!isEmptyValue(literalValue)) { + return true; + } + return !isEmptyValue(getAttrAssignmentValue(node, attributeName)); + } + private static List splitAssignments(String thAttrValue) { List assignments = new ArrayList<>(); StringBuilder currentAssignment = new StringBuilder(); diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/AccessibilityUtils.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/AccessibilityUtils.java index dd13a4563..d5b3cd89c 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/AccessibilityUtils.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/AccessibilityUtils.java @@ -16,16 +16,40 @@ */ package org.sonar.plugins.html.api.accessibility; +import java.util.Set; +import org.sonar.plugins.html.api.Thymeleaf; import org.sonar.plugins.html.node.TagNode; import static org.sonar.plugins.html.api.HtmlConstants.isInteractiveElement; public class AccessibilityUtils { + /** + * Attributes that template engines use to inject text content into an element at render time: + * Thymeleaf {@code th:text}/{@code th:utext} and Vue {@code v-text}/{@code v-html}. Centralized + * here so accessibility checks that ask "does this element get its text from a template?" all + * see the same definition. + */ + public static final Set TEMPLATE_TEXT_ATTRIBUTES = Set.of("th:text", "th:utext", "v-text", "v-html"); + private AccessibilityUtils() { // utility class } + /** + * Returns whether {@code element} carries any template-text attribute with a usable value, + * including {@code th:attr} assignments to {@code text} or {@code utext}. + */ + public static boolean hasNonEmptyTemplateTextAttribute(TagNode element) { + for (String attributeName : TEMPLATE_TEXT_ATTRIBUTES) { + if (!Thymeleaf.isEmptyValue(element.getAttribute(attributeName))) { + return true; + } + } + return Thymeleaf.hasNonEmptyThymeleafAttribute(element, "text") + || Thymeleaf.hasNonEmptyThymeleafAttribute(element, "utext"); + } + public static boolean isHiddenFromScreenReader(TagNode element) { return ( ( diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java index 0ca75e810..ae96cdeeb 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/AnchorsHaveContentCheck.java @@ -26,6 +26,7 @@ import org.sonar.plugins.html.node.TagNode; import org.sonar.plugins.html.node.TextNode; +import static org.sonar.plugins.html.api.accessibility.AccessibilityUtils.hasNonEmptyTemplateTextAttribute; import static org.sonar.plugins.html.api.accessibility.AccessibilityUtils.isHiddenFromScreenReader; @Rule(key = "S6827") @@ -113,7 +114,7 @@ private static boolean hasContent(TagNode element) { return true; } } - return element.hasProperty("title") || element.hasProperty("aria-label") || element.hasAttribute("th:text") - || element.hasAttribute("th:utext") || element.hasAttribute("v-text") || element.hasAttribute("v-html"); + return element.hasProperty("title") || element.hasProperty("aria-label") + || hasNonEmptyTemplateTextAttribute(element); } } diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/HeadingHasAccessibleContentCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/HeadingHasAccessibleContentCheck.java index 2c9c32337..ac651e3ae 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/HeadingHasAccessibleContentCheck.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/HeadingHasAccessibleContentCheck.java @@ -19,6 +19,7 @@ import org.sonar.check.Rule; import org.sonar.plugins.html.api.Helpers; import org.sonar.plugins.html.api.BufferStack; +import org.sonar.plugins.html.api.accessibility.AccessibilityUtils; import org.sonar.plugins.html.checks.AbstractPageCheck; import org.sonar.plugins.html.node.DirectiveNode; import org.sonar.plugins.html.node.ExpressionNode; @@ -38,11 +39,6 @@ public class HeadingHasAccessibleContentCheck extends AbstractPageCheck { "aria-hidden" ); - private final List vueJsContentLikeAttributes = List.of( - "v-html", - "v-text" - ); - private final BufferStack bufferStack = new BufferStack(); private final Deque openingHeadingTags = new ArrayDeque<>(); @@ -65,8 +61,8 @@ public void startElement(TagNode node) { } } - // vueJS attributes that maps to content are considered as content - vueJsContentLikeAttributes.forEach(attributeName -> { + // template-text attributes (Thymeleaf th:text/th:utext, Vue v-text/v-html) are content + AccessibilityUtils.TEMPLATE_TEXT_ATTRIBUTES.forEach(attributeName -> { String nodeAttribute = node.getAttribute(attributeName); if (nodeAttribute != null && !nodeAttribute.isBlank() && bufferStack.getLevel() > 0) { diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheck.java index 844f63076..21ae0363d 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheck.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheck.java @@ -21,8 +21,11 @@ import java.util.Set; import java.util.regex.Pattern; import org.sonar.check.Rule; +import org.sonar.plugins.html.api.Helpers; +import org.sonar.plugins.html.api.Thymeleaf; +import org.sonar.plugins.html.api.accessibility.AccessibilityUtils; import org.sonar.plugins.html.checks.AbstractPageCheck; -import org.sonar.plugins.html.node.CommentNode; +import org.sonar.plugins.html.node.Attribute; import org.sonar.plugins.html.node.DirectiveNode; import org.sonar.plugins.html.node.ExpressionNode; import org.sonar.plugins.html.node.Node; @@ -59,9 +62,14 @@ public void startDocument(List nodes) { public void startElement(TagNode node) { if (isLabel(node)) { label = node; - foundControl = hasForAttribute(label); - foundAccessibleLabel = false; + foundControl = hasControlAssociationHint(label); + foundAccessibleLabel = hasAccessibleTextHint(label); foundLabelBodyContent = false; + // A fragment-rendered label is opaque to static analysis — accept both axes. + if (Thymeleaf.hasFragmentInsertion(label)) { + foundControl = true; + foundAccessibleLabel = true; + } } else { if (label != null) { foundLabelBodyContent = true; @@ -69,32 +77,53 @@ public void startElement(TagNode node) { if (isControl(node)) { foundControl = true; } - } - if (hasAccessibleLabel(node)) { - foundAccessibleLabel = true; + if (label != null && hasAccessibleTextHint(node)) { + foundAccessibleLabel = true; + } + // Razor view-component or child can supply both text and control. + if (label != null && Helpers.isRazorFragmentTagHelper(node)) { + foundControl = true; + foundAccessibleLabel = true; + } } } - private static boolean hasForAttribute(TagNode label) { - return label.hasProperty("for") || - label.hasProperty("htmlFor") || - // Angular binding - label.hasProperty("[for]") || - // Vue shorthand binding - label.hasProperty(":for") || - // Vue full binding syntax - label.hasProperty("v-bind:for") || - // ASP.NET Core Tag Helper - label.hasProperty("asp-for"); + private static boolean hasControlAssociationHint(TagNode label) { + return hasPropertyHint(label, "for") + || hasPropertyHint(label, "htmlFor") + || hasAttributeHint(label, "asp-for") + || Thymeleaf.hasNonEmptyThymeleafAttribute(label, "for"); } - private static boolean hasAccessibleLabel(TagNode node) { - return - node.hasProperty("alt") || - node.hasProperty("aria-labelledby") || - node.hasProperty("aria-label") || + private static boolean hasAccessibleTextHint(TagNode node) { + return hasPropertyHint(node, "alt") + || hasPropertyHint(node, "aria-labelledby") + || hasPropertyHint(node, "aria-label") + // Angular [innerText]/[innerHTML]/[textContent] write text content at runtime. + || hasPropertyHint(node, "innerText") + || hasPropertyHint(node, "innerHTML") + || hasPropertyHint(node, "textContent") + // Thymeleaf th:aria-label / th:attr="aria-label=..." (and aria-labelledby/alt variants). + || Thymeleaf.hasNonEmptyThymeleafAttribute(node, "aria-label") + || Thymeleaf.hasNonEmptyThymeleafAttribute(node, "aria-labelledby") + || Thymeleaf.hasNonEmptyThymeleafAttribute(node, "alt") + || AccessibilityUtils.hasNonEmptyTemplateTextAttribute(node) // see https://sonarsource.github.io/rspec/#/rspec/S1926 - "FMT:MESSAGE".equalsIgnoreCase(node.getNodeName()); + || "FMT:MESSAGE".equalsIgnoreCase(node.getNodeName()); + } + + // Property lookup that accepts Angular/Vue binding forms even with empty value — the binding name itself is the hint. + private static boolean hasPropertyHint(TagNode node, String propertyName) { + Attribute property = node.getProperty(propertyName); + return property != null && (isBindingForm(property, propertyName) || !Thymeleaf.isEmptyValue(property.getValue())); + } + + private static boolean hasAttributeHint(TagNode node, String attributeName) { + return !Thymeleaf.isEmptyValue(node.getAttribute(attributeName)); + } + + private static boolean isBindingForm(Attribute attribute, String canonicalName) { + return !canonicalName.equalsIgnoreCase(attribute.getName()); } private static boolean isLabel(TagNode node) { @@ -116,13 +145,11 @@ public void characters(TextNode textNode) { if (RAZOR_CONTROL_PATTERN.matcher(textNode.getCode()).find()) { foundControl = true; } - } - } - - @Override - public void comment(CommentNode node) { - if (label != null) { - foundLabelBodyContent = true; + // Razor fragment rendering (@Html.PartialAsync, @RenderBody, ...) is opaque. + if (Helpers.isRazorFile(getHtmlSourceCode()) && Helpers.containsRazorFragmentRendering(textNode.getCode())) { + foundControl = true; + foundAccessibleLabel = true; + } } } @@ -146,7 +173,7 @@ public void expression(ExpressionNode node) { @Override public void endElement(TagNode node) { if (isLabel(node)) { - if (label != null && label.hasProperty("asp-for") && !foundLabelBodyContent) { + if (label != null && hasAttributeHint(label, "asp-for") && !foundLabelBodyContent) { foundAccessibleLabel = true; } if ((!foundAccessibleLabel || !foundControl) && label != null) { diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/ImgWithoutAltCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/ImgWithoutAltCheck.java index e2ad9c762..d0f95484a 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/ImgWithoutAltCheck.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/ImgWithoutAltCheck.java @@ -16,7 +16,6 @@ */ package org.sonar.plugins.html.checks.sonar; -import java.util.Locale; import org.sonar.check.Rule; import org.sonar.plugins.html.api.Thymeleaf; import org.sonar.plugins.html.checks.AbstractPageCheck; @@ -99,14 +98,7 @@ private static boolean hasNonEmptyAttribute(TagNode node, String attributeName) if (value != null && !value.trim().isEmpty()) { return true; } - - String thymeleafValue = node.getAttribute("th:" + attributeName.toLowerCase(Locale.ROOT)); - if (thymeleafValue != null) { - return !Thymeleaf.isEmptyAssignmentValue(thymeleafValue.trim()); - } - - String thymeleafAssignedValue = Thymeleaf.getAttrAssignmentValue(node, attributeName); - return thymeleafAssignedValue != null && !Thymeleaf.isEmptyAssignmentValue(thymeleafAssignedValue); + return Thymeleaf.hasNonEmptyThymeleafAttribute(node, attributeName); } /** diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/TableWithoutHeaderCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/TableWithoutHeaderCheck.java index 84e46cee6..dff3d2fbc 100644 --- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/TableWithoutHeaderCheck.java +++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/sonar/TableWithoutHeaderCheck.java @@ -22,6 +22,7 @@ import javax.annotation.Nullable; import org.sonar.check.Rule; import org.sonar.plugins.html.api.Helpers; +import org.sonar.plugins.html.api.Thymeleaf; import org.sonar.plugins.html.checks.AbstractPageCheck; import org.sonar.plugins.html.node.Attribute; import org.sonar.plugins.html.node.Node; @@ -32,8 +33,6 @@ @Rule(key = "S5256") public class TableWithoutHeaderCheck extends AbstractPageCheck { - private static final Set THYMELEAF_FRAGMENT_INSERTION_KEYWORDS = Set.of("th:insert", "th:include", "th:replace"); - private final Set tablesWithRazorFragmentRendering = new HashSet<>(); @Override @@ -127,7 +126,7 @@ private static boolean hasThymeleafFragmentInsertionFromTableAttribute(List nodes) { for (TagNode node : nodes) { - if (node.getAttributes().stream().map(Attribute::getName).anyMatch(THYMELEAF_FRAGMENT_INSERTION_KEYWORDS::contains) + if (node.getAttributes().stream().map(Attribute::getName).anyMatch(Thymeleaf.FRAGMENT_INSERTION_ATTRIBUTES::contains) || hasThymeleafFragmentInsertionFromTableChildren(node.getChildren())) { return true; } diff --git a/sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/ThymeleafTest.java b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/ThymeleafTest.java index 20d0bf201..cdaf3df6d 100644 --- a/sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/ThymeleafTest.java +++ b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/api/ThymeleafTest.java @@ -106,6 +106,55 @@ void isEmptyAssignmentValue_nonEmptyValues() { assertThat(Thymeleaf.isEmptyAssignmentValue("foo")).isFalse(); } + @Test + void isEmptyValue_nullOrBlank() { + assertThat(Thymeleaf.isEmptyValue(null)).isTrue(); + assertThat(Thymeleaf.isEmptyValue("")).isTrue(); + assertThat(Thymeleaf.isEmptyValue(" ")).isTrue(); + } + + @Test + void isEmptyValue_emptyQuotedLiteralWithPadding() { + assertThat(Thymeleaf.isEmptyValue(" '' ")).isTrue(); + assertThat(Thymeleaf.isEmptyValue("\" \"")).isTrue(); + } + + @Test + void isEmptyValue_nonEmptyValues() { + assertThat(Thymeleaf.isEmptyValue("foo")).isFalse(); + assertThat(Thymeleaf.isEmptyValue("'foo'")).isFalse(); + assertThat(Thymeleaf.isEmptyValue("#{logo}")).isFalse(); + } + + @Test + void hasNonEmptyThymeleafAttribute_returnsFalse_whenNeitherFormIsPresent() { + assertThat(Thymeleaf.hasNonEmptyThymeleafAttribute(tagWithoutThAttr(), "alt")).isFalse(); + } + + @Test + void hasNonEmptyThymeleafAttribute_returnsTrue_whenLiteralIsSet() { + TagNode node = new TagNode(); + node.getAttributes().add(new Attribute("th:alt", "#{logo}")); + assertThat(Thymeleaf.hasNonEmptyThymeleafAttribute(node, "alt")).isTrue(); + } + + @Test + void hasNonEmptyThymeleafAttribute_returnsFalse_whenLiteralIsEmpty() { + TagNode node = new TagNode(); + node.getAttributes().add(new Attribute("th:alt", "''")); + assertThat(Thymeleaf.hasNonEmptyThymeleafAttribute(node, "alt")).isFalse(); + } + + @Test + void hasNonEmptyThymeleafAttribute_returnsTrue_whenAssignedViaThAttr() { + assertThat(Thymeleaf.hasNonEmptyThymeleafAttribute(tagWithThAttr("alt=#{logo}"), "alt")).isTrue(); + } + + @Test + void hasNonEmptyThymeleafAttribute_returnsFalse_whenAssignmentIsEmpty() { + assertThat(Thymeleaf.hasNonEmptyThymeleafAttribute(tagWithThAttr("alt=''"), "alt")).isFalse(); + } + private static TagNode tagWithThAttr(String thAttrValue) { TagNode node = new TagNode(); node.getAttributes().add(new Attribute("th:attr", thAttrValue)); diff --git a/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheckTest.java b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheckTest.java index 419a9258b..c004ae0bd 100644 --- a/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheckTest.java +++ b/sonar-html-plugin/src/test/java/org/sonar/plugins/html/checks/accessibility/LabelHasAssociatedControlCheckTest.java @@ -40,6 +40,7 @@ void nesting() { .next().atLine(9) .next().atLine(11) .next().atLine(16) + .next().atLine(30) .noMore(); } @@ -57,6 +58,8 @@ void forAttribute() { .next().atLine(10) .next().atLine(12) .next().atLine(14) + .next().atLine(15) + .next().atLine(16) .noMore(); } @@ -111,7 +114,20 @@ void aspFor() { checkMessagesVerifier.verify(sourceCode.getIssues()) .next().atLine(11).withMessage("A form label must be associated with a control and have accessible text.") .next().atLine(12) - .next().atLine(13) + .noMore(); + } + + @Test + void templateGeneratedLabels() { + HtmlSourceCode sourceCode = TestHelper.scan( + new File("src/test/resources/checks/LabelHasAssociatedControlCheck/templateGenerated.html"), + new LabelHasAssociatedControlCheck()); + checkMessagesVerifier.verify(sourceCode.getIssues()) + .next().atLine(18).withMessage("A form label must be associated with a control and have accessible text.") + .next().atLine(21) + .next().atLine(22) + .next().atLine(25) + .next().atLine(26) .noMore(); } diff --git a/sonar-html-plugin/src/test/resources/checks/HeadingHasAccessibleContentCheck/file.html b/sonar-html-plugin/src/test/resources/checks/HeadingHasAccessibleContentCheck/file.html index ae0eb5d8e..194f37250 100644 --- a/sonar-html-plugin/src/test/resources/checks/HeadingHasAccessibleContentCheck/file.html +++ b/sonar-html-plugin/src/test/resources/checks/HeadingHasAccessibleContentCheck/file.html @@ -89,4 +89,7 @@

- \ No newline at end of file + + +

+

\ No newline at end of file diff --git a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/aspFor.cshtml b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/aspFor.cshtml index 5f99950f2..e92d7db47 100644 --- a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/aspFor.cshtml +++ b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/aspFor.cshtml @@ -10,4 +10,6 @@ + + diff --git a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/binding.html b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/binding.html index 35e07165c..6c97075f5 100644 --- a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/binding.html +++ b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/binding.html @@ -14,3 +14,8 @@ + + + + + diff --git a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/for.html b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/for.html index d29c17231..d0f80e1e0 100644 --- a/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/for.html +++ b/sonar-html-plugin/src/test/resources/checks/LabelHasAssociatedControlCheck/for.html @@ -12,3 +12,5 @@
+