Skip to content

Commit

Permalink
Prevent concatenation of SwiftUI text elements (#3)
Browse files Browse the repository at this point in the history
We want to prevent developers from doing things that break translations
such as concatenating two `Text` elements in order to apply different
styling to each. The proper way to do it is to use `AttributedString`
and apply different styling that way.

This adds a rule to look for binary `+` operators with `Text.init` calls
on both sides.
  • Loading branch information
Killectro authored Apr 8, 2024
1 parent 4feb359 commit 6700615
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 0 deletions.
1 change: 1 addition & 0 deletions Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ public let builtInRules: [any Rule.Type] = [
SwitchCaseOnNewlineRule.self,
SyntacticSugarRule.self,
TestCaseAccessibilityRule.self,
TextConcatenationRule.self,
TextLocalizationRule.self,
TodoRule.self,
ToggleBoolRule.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import SwiftLintCore
import SwiftSyntax

@SwiftSyntaxRule(foldExpressions: true)
struct TextConcatenationRule: Rule {
var configuration = SeverityConfiguration<Self>(.warning)

static let description = RuleDescription(
identifier: "text_concatenation",
name: "SwiftUI.Text Concatenation",
description: "Avoid concatenating SwiftUI.Text instances",
kind: .lint,
nonTriggeringExamples: [
Example("""
Text(string)
"""),
Example(#"Text("wow \(wowee)")"#),
Example("""
HStack {
Text("foo")
Text("bar")
}
""")
],
triggeringExamples: [
Example("""
Text("bar") ↓+ Text("foo")
"""),
Example("""
Text("wow")
.foregroundColor(.blue)
.font(.heavy)
↓+
Text("wow2")
.foregroundColor(.black)
""")
]
)
}

private extension TextConcatenationRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
override func visitPost(_ node: InfixOperatorExprSyntax) {
guard node.operator.as(BinaryOperatorExprSyntax.self)?.operator.text == "+" else { return }

if recursivelySearchForTextInitializerCall(node.leftOperand) != nil,
recursivelySearchForTextInitializerCall(node.rightOperand) != nil {
violations.append(reason(position: node.operator.positionAfterSkippingLeadingTrivia))
}
}

func recursivelySearchForTextInitializerCall(_ node: any ExprSyntaxProtocol) -> FunctionCallExprSyntax? {
if let funcCall = node.as(FunctionCallExprSyntax.self) {
let isTextInit = funcCall.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "Text"

if isTextInit {
return funcCall
} else {
return recursivelySearchForTextInitializerCall(funcCall.calledExpression)
}
} else if let memberAccess = node.as(MemberAccessExprSyntax.self), let base = memberAccess.base {
return recursivelySearchForTextInitializerCall(base)
}

return nil
}

func reason(position: AbsolutePosition) -> ReasonedRuleViolation {
.init(
position: position,
reason: """
Avoid concatenating Swift.Text elements with '+' because it breaks translations. \
Use AttributedString.init if you need to apply multiple styles inside a single string
""",
severity: .warning
)
}
}
}
6 changes: 6 additions & 0 deletions Tests/GeneratedTests/GeneratedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,12 @@ class TestCaseAccessibilityRuleGeneratedTests: SwiftLintTestCase {
}
}

class TextConcatenationRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(TextConcatenationRule.description)
}
}

class TextLocalizationRuleGeneratedTests: SwiftLintTestCase {
func testWithDefaultConfiguration() {
verifyRule(TextLocalizationRule.description)
Expand Down

0 comments on commit 6700615

Please sign in to comment.