Fix extensionAccessControl incorrectly hoisting public onto extensions of nested internal types (#2461)

Co-authored-by: Cal Stephens <cal@calstephens.tech>
This commit is contained in:
Copilot
2026-03-17 07:32:49 -07:00
committed by Cal Stephens
parent 3f4f61ba85
commit 727c044bfa
2 changed files with 75 additions and 12 deletions
+23 -12
View File
@@ -14,6 +14,22 @@ public extension FormatRule {
options: ["extension-acl"]
) { formatter in
let declarations = formatter.parseDeclarations()
// Build a map of fully-qualified type names to their effective visibility.
var typeVisibilityByName = [String: Visibility]()
declarations.forEachRecursiveDeclaration { declaration in
guard declaration.keyword != "extension",
declaration.asTypeDeclaration != nil,
let qualifiedName = declaration.fullyQualifiedName
else { return }
// A type declared inside a `public extension` inherits public visibility.
let insidePublicExtension = declaration.parentDeclarations.contains(where: {
$0.keyword == "extension" && $0.visibility() == .public
})
typeVisibilityByName[qualifiedName] = insidePublicExtension ? .public : (declaration.visibility() ?? .internal)
}
declarations.forEachRecursiveDeclaration { declaration in
guard let extensionDeclaration = declaration.asTypeDeclaration,
extensionDeclaration.keyword == "extension"
@@ -54,18 +70,13 @@ public extension FormatRule {
else { return }
if memberVisibility > extensionVisibility ?? .internal {
// Check type being extended does not have lower visibility
for extendedType in declarations where extendedType.name == extensionDeclaration.name {
guard let type = extendedType.asTypeDeclaration else { continue }
if extendedType.keyword != "extension",
extendedType.visibility() ?? .internal < memberVisibility
{
// Cannot make extension with greater visibility than type being extended
return
}
break
// Check the type being extended does not have lower visibility.
if let extendedTypeName = extensionDeclaration.name,
let typeVisibility = typeVisibilityByName[extendedTypeName],
typeVisibility < memberVisibility
{
// Cannot make extension with greater visibility than type being extended
return
}
}
@@ -450,6 +450,58 @@ final class ExtensionAccessControlTests: XCTestCase {
testFormatting(for: input, rule: .extensionAccessControl, exclude: [.redundantPublic])
}
func testAccessNotHoistedIfNestedTypeVisibilityIsLower() {
// Extension of a dot-separated nested type whose inner type is internal.
// SwiftFormat must not hoist `public` onto the extension because the type is internal.
let input = """
extension CategorySurface {
struct RetailerItemGroup: Hashable {
let collection: String
}
}
extension CategorySurface.RetailerItemGroup {
public static func placeholder(id: String) -> Self {
.init(collection: id)
}
}
"""
testFormatting(for: input, rule: .extensionAccessControl, exclude: [.redundantPublic])
}
func testAccessHoistedForPublicNestedType() {
// Extension of a dot-separated nested type that IS public should still allow hoisting.
let input = """
extension CategorySurface {
public struct RetailerItemGroup: Hashable {
let collection: String
}
}
extension CategorySurface.RetailerItemGroup {
public static func placeholder(id: String) -> Self {
.init(collection: id)
}
}
"""
// `public` is hoisted from `public struct RetailerItemGroup` to `public extension CategorySurface`,
// and from `public static func placeholder` to `public extension CategorySurface.RetailerItemGroup`.
let output = """
public extension CategorySurface {
struct RetailerItemGroup: Hashable {
let collection: String
}
}
public extension CategorySurface.RetailerItemGroup {
static func placeholder(id: String) -> Self {
.init(collection: id)
}
}
"""
testFormatting(for: input, output, rule: .extensionAccessControl)
}
func testExtensionAccessControlRuleTerminatesInFileWithConditionalCompilation() {
let input = """
#if os(Linux)