Skip to content

Add project-aware symbol indexing for cross-file formatting - #2722

Merged
nicklockwood merged 10 commits into
developfrom
project-symbol-index
Oct 5, 2026
Merged

nicklockwood merged 10 commits into
developfrom
project-symbol-index

Conversation

@nicklockwood

Copy link
Copy Markdown
Owner

Several of SwiftFormat's rules are currently hampered or made unsafe by the limitation that only the contents of the current file can be taken into account.

For rules that do things like symbol renaming, typo correction, self insertion or removal, dead code elimination, etc. this severely limits their usefulness, and often means that they can't be used with 100% reliability due to the possibility of introducing conflicts with another file.

This PR implements the first steps in a feature I've wanted to add for a long time. It creates a project-wide symbol index, similar to the file-level index that several rules already implement. This index is relatively quick to calculate compared with the formatting pass, and the results are cached so that they don't need to be recomputed for files that haven't changed.

This symbol index then allows file-level formatting rules to query the type or visibility of symbols that are declared outside of that file, without increasing the formatting cost or preventing parallel formatting.

Computing the index adds a roughly 5% increase to formatting times on average. The index is only calculated if at least one enabled rule needs it, so it doesn't impact the performance of unit tests, etc.

For now I've used the index to improve the redundantPublic and redundantSelf rules, but there is potential for much bigger impact in future.

@nicklockwood
nicklockwood changed the base branch from main to develop October 1, 2026 20:18
@nicklockwood
nicklockwood requested a review from calda October 1, 2026 20:19
@nicklockwood
nicklockwood force-pushed the project-symbol-index branch 3 times, most recently from 48df8bf to 891e9af Compare October 1, 2026 21:52
}

/// Provides a conservative module identity from conventional project layout.
func moduleIdentifier(for fileURL: URL, in root: ProjectRoot) -> String? {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Understanding what module code belongs to will be important for rules like redundantPublic. Maybe we add a --module-roots-glob option that lets you specify globs for where module roots are defined in your project?

For example, at Airbnb, our modules all have the form:

  • ios/features/MyFeature
  • ios/services/MyFeatureService
  • ios/foundation/MyFeatureFoundation
  • ios/core_ui/MyFeatureCoreUI
  • etc etc etc

We could have a list of globs like ios/features/*, ios/services/* etc for paths that represent module roots.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

I do intend to try to improve the module recognition, but the idea was to start with something conservative. E.g. if the symbol is found anywhere in the project we'll assume for now that the public is not redundant.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Makes sense to me

return nil
}
return "\(rootPath):\(components[0]):\(components[1])"
case .xcodeProject:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder how hard it would be to ingest the new JSON-based Xcode project file format. Makes sense to not try to ingest the legacy plist format.

@calda

calda commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

This is exciting! I don't think we'll be able to use this at Airbnb since we have a custom lint:swift --diff setup that only formats modified files, and keeping the index up to date would be really difficult otherwise. It will work great for smaller projects where always formatting the entire project still makes sense.

@nicklockwood

Copy link
Copy Markdown
Owner Author

we have a custom lint:swift --diff setup that only formats modified files, and keeping the index up to date would be really difficult otherwise

I'm not sure I understand why this is. The index is generated by finding the root of the project and indexing the whole thing, so it shouldn't matter that you are only formatting files that were touched in the diff - unless the way you are doing that prevents swiftformat from seeing the file paths?

@calda

calda commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

I think indexing the entire codebase (60,000 files) on every lint:swift --diff run (which takes well under half a second) is probably too expensive.

@calda

calda commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

I checked out this branch and ran it locally.

Take this example SwiftFormat invocation that happens under the hood when I run our bear lint:swift --diff wrapper (--diff is the default):

Details

This command invocation only formats the files that are modified on my branch:

swiftformat /Users/cal_stephens/Documents/apps/ios/Macros/DLSPrimitivesCoreUI/Sources/DesignSystemVersionMacro.swift /Users/cal_stephens/Documents/apps/ios/XCUITests/E2ETests/HostCalendar/HostCalendarScheduleTests.swift /Users/cal_stephens/Documents/apps/ios/XCUITests/ScreenObject/Host/Calendar/ServiceCalendarScreen.swift /Users/cal_stephens/Documents/apps/ios/core_ui/DLSCompositionsCoreUI/Sources/NavigationConfigured/NavigationConfigurationPassthrough.swift /Users/cal_stephens/Documents/apps/ios/core_ui/DLSLegacyCompositionsCoreUI/Sources/AlertBar/AlertBar.swift /Users/cal_stephens/Documents/apps/ios/core_ui/DLSLegacyNavigationCoreUI/Sources/DLSLegacy16/ActionFooters/DualActionFooter.swift /Users/cal_stephens/Documents/apps/ios/core_ui/ElementsCoreUI/Sources/Labels/ExpandableLabel/ExpandableLabel.swift /Users/cal_stephens/Documents/apps/ios/core_ui/GuestPlatformCoreUI/Sources/EpoxyModel+MarqueePreference.swift /Users/cal_stephens/Documents/apps/ios/core_ui/MagicTransitionsCoreUI/Sources/SwiftUI/SharedElement.swift /Users/cal_stephens/Documents/apps/ios/core_ui/MapCoreUI/Sources/Public/Content/MapPolyline.swift /Users/cal_stephens/Documents/apps/ios/core_ui/MediaPlayerCoreUI/Sources/MediaPlayer/MediaPlayer.swift /Users/cal_stephens/Documents/apps/ios/demos/FloorplansDemo/Sources/Samples/RoomPlanMultiscanMerging/RoomTableViewController.swift /Users/cal_stephens/Documents/apps/ios/demos/GiftCardsFlowsDemo/Sources/GiftCards/RedeemFlow/RedeemContentView.swift /Users/cal_stephens/Documents/apps/ios/feature_interfaces/EnforcementFrameworkFeatureInterface/Sources/EnforcementFrameworkFriction.swift /Users/cal_stephens/Documents/apps/ios/feature_interfaces/ServerDrivenFeatureInterface/Sources/TestDoubles/MockLocalAlertContext.swift /Users/cal_stephens/Documents/apps/ios/features/AddressVerificationFeature/Sources/Network/AddressVerificationNetworkProvider.swift /Users/cal_stephens/Documents/apps/ios/features/ArkoseFrictionFeature/Sources/ArkoseFrictionService.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarSettingsFeature/Sources/CalendarSettings/PricingGuidance/CalendarSettingsState+Compsets.swift /Users/cal_stephens/Documents/apps/ios/features/EnforcementFrameworkFeature/Sources/EnforcementFrameworkFlowNavigationController.swift /Users/cal_stephens/Documents/apps/ios/features/EnforcementFrameworkFeature/Tests/Service/DefaultEnforcementFrameworkServiceTests.swift /Users/cal_stephens/Documents/apps/ios/features/GuestTabFeature/Sources/GuestTabCoordinator.swift /Users/cal_stephens/Documents/apps/ios/features/HostPerformanceFeature/Sources/HostPerformance/Landing/HostPerformanceActionHandler.swift /Users/cal_stephens/Documents/apps/ios/features/HostPriceCalculatorFeature/Sources/GuestTotalPriceCalculator/GuestTotalPriceCalculatorStore.swift /Users/cal_stephens/Documents/apps/ios/features/HostingDetailsFeature/Tests/HostingDetailsFeatureTests.swift /Users/cal_stephens/Documents/apps/ios/features/IdentityVerificationFeature/Sources/ReimagineIdentity/FOV/FOVFormScreen/FOVFormScreenActionHandler.swift /Users/cal_stephens/Documents/apps/ios/features/IdentityVerificationFeature/Sources/ReimagineIdentity/FOV/FOVSSNEntryV2/FOVSSNEntryV2State.swift /Users/cal_stephens/Documents/apps/ios/features/IdentityVerificationFeature/Sources/ReimagineIdentity/ReinhardtRequester.swift /Users/cal_stephens/Documents/apps/ios/features/IdentityVerificationFeature/Tests/FOV/FOVFormScreen/FOVFormScreenActionHandlerTests.swift /Users/cal_stephens/Documents/apps/ios/features/LegacyAirlocksFeature/Sources/Airlock/AirlockPresenters/AirlockNativePresenter.swift /Users/cal_stephens/Documents/apps/ios/features/MSGInboxFeature/Tests/InboxSettings/InboxSettingsActionHandlerTests.swift /Users/cal_stephens/Documents/apps/ios/features/NezhaFeature/Sources/ProductionTestDoubles/Archive/Archive.swift /Users/cal_stephens/Documents/apps/ios/features/NezhaFeature/Tests/NezhaTests/JSBridgeTests.swift /Users/cal_stephens/Documents/apps/ios/features/PaymentOptionsFeature/Sources/AddCreditCard/CardValidationHelper.swift /Users/cal_stephens/Documents/apps/ios/features/PrivacySettingsFeature/Tests/UserConsent/UserConsentActionHandlerTests.swift /Users/cal_stephens/Documents/apps/ios/features/RootInteractiveFeature/Sources/AppCoordinator/AppCoordinator.swift /Users/cal_stephens/Documents/apps/ios/features/SearchInputFlowFeature/Tests/MicroFlexRangeProviderTests.swift /Users/cal_stephens/Documents/apps/ios/features/ServerDrivenFeature/Sources/Form/ClientValidation/ClientValidator.swift /Users/cal_stephens/Documents/apps/ios/features/UserFlagsFeature/Sources/UserFlagManager.swift /Users/cal_stephens/Documents/apps/ios/features/UserProfileFeature/Sources/Profile/ProfileCard/Views/View+fresnelEffect.swift /Users/cal_stephens/Documents/apps/ios/foundations/FlexFormFoundation/Tests/SharedTests/SharedClientTests.swift /Users/cal_stephens/Documents/apps/ios/foundations/FlexFormFoundation/Tests/SharedTests/SharedServerTests.swift /Users/cal_stephens/Documents/apps/ios/foundations/NiobeFoundation/Sources/Decoding/KeyedDecodingContainer+Optional.swift /Users/cal_stephens/Documents/apps/ios/foundations/NiobeFoundation/Sources/Decoding/TypeConversion.swift /Users/cal_stephens/Documents/apps/ios/foundations/SharingFoundation/Sources/SharingContent.swift /Users/cal_stephens/Documents/apps/ios/foundations/StringFoundation/Tests/String+EmojiTests.swift /Users/cal_stephens/Documents/apps/ios/service_interfaces/BugsnagPublishingServiceInterface/Sources/BugnagBouncerService.swift /Users/cal_stephens/Documents/apps/ios/service_interfaces/KeyValueStoreServiceInterface/Sources/SandboxedKeyValueStore.swift /Users/cal_stephens/Documents/apps/ios/service_interfaces/NetworkingServiceInterface/Sources/PagedQuery+DayRange.swift /Users/cal_stephens/Documents/apps/ios/service_interfaces/PrivacySettingsServiceInterface/Sources/PrivacySettingsVendor.swift /Users/cal_stephens/Documents/apps/ios/services/CobrowseService/Sources/Cobrowse/CobrowseServiceManager.swift /Users/cal_stephens/Documents/apps/ios/services/ImageFetchingService/Sources/ImageFetcherV2/ReplaySubject.swift /Users/cal_stephens/Documents/apps/ios/services/JitneyContextBuilderService/Sources/JSON+Merging.swift /Users/cal_stephens/Documents/apps/ios/services/JitneyPublishingService/Sources/DefaultJitneyPublisher.swift /Users/cal_stephens/Documents/apps/ios/services/NiobeService/Tests/Serialization/SerializationParsingTests.swift /Users/cal_stephens/Documents/apps/ios/services/ServerDrivenComponentLocalStateService/Sources/SDCPaginationStateServiceProviding.swift /Users/cal_stephens/Documents/apps/ios/shared_ui/HomesHostSharedUI/Sources/BadgedImageUploadingView.swift /Users/cal_stephens/Documents/apps/ios/shared_ui/TrustSharedUI/Sources/CodeInputViewControllerV2.swift /Users/cal_stephens/Documents/apps/tools/ios/DLSAdoptionMetrics/Sources/DLSAdoptionMetricsCore/InitializerDeclIndexer.swift /Users/cal_stephens/Documents/apps/tools/ios/DLSAdoptionMetrics/Tests/DLSAdoptionMetricsCoreTests/TestEpoxyModelIndexer.swift /Users/cal_stephens/Documents/apps/ios/applications/AirbnbApp/Sources/Application/AppScope/AppScope.swift /Users/cal_stephens/Documents/apps/ios/feature_interfaces/CalendarAvailabilityTripLengthFeatureInterface/Sources/CustomizedByDayRoute.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/AccessibilityTests/CalendarAvailabilityTripLengthFeatureScreenAccessibilityTests.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDay+TextStyles.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDayActionHandler.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDayBuilder.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDayScreen.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDayState+Logging.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Sources/CustomizeByDay/CustomizeByDayState.swift /Users/cal_stephens/Documents/apps/ios/features/CalendarAvailabilityTripLengthFeature/Tests/CustomizeByDayTests.swift /Users/cal_stephens/Documents/apps/ios/features/ValueEditorFeature/Sources/ValueEditor/ValueEditorActionHandler.swift --verbose --strict --cache ignore --config /Users/cal_stephens/Documents/apps/ios/airbnb.swiftformat

Without the project index, the command takes only takes 3.5 seconds (and branches that modify fewer files are even faster).

With the project index, the command takes 15 seconds. Adding an extra 10 seconds of overhead to our local lint command (ran in our git pre-commit hook, etc) probably isn't going to fly.

Formatting the entire codebase takes 1m 7s with the project index enabled, and 48s with the project index disabled.

@nicklockwood

Copy link
Copy Markdown
Owner Author

@calda is that with caching enabled? or do you disable the cache (or get no benefit from it due to using fresh VM instances on each run or something)?

@calda

calda commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

We have the cache disabled because we were occasionally hitting edge cases (every month or so) where the SwiftFormat command would succeed locally for somebody but fail in CI, and then they would get confused. The performance penalty from disabling the cache was pretty small given our --diff command only formats the subset of files you have modified.

How does the cache interact with the project index? Do we cache the project index between runs? I could see caching the project index between runs making it so that it's fast enough for us to use it in lint:swift --diff, but would need to benchmark it.

@nicklockwood

Copy link
Copy Markdown
Owner Author

The project index is stored in the cache, so once it's been computed once it shouldn't need to be recomputed. If you plan to disable the formatting cache but still keep the index cached then we might need to decouple them in some way

@calda

calda commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

I'll benchmark it again with the cache enabled on Monday

@calda

calda commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

With the cache and project index enabled, it still takes 10-11 seconds to run the command from before, compared to <2 seconds with the project index disabled. The cache saves about 5-6 seconds compared to not using the cache, but I think the extra 10 second hit is still probably too much.

Of course I do support shipping this, it will be awesome in smaller projects.

@nicklockwood

Copy link
Copy Markdown
Owner Author

Of course I do support shipping this, it will be awesome in smaller projects.

@calda presumably we need to add a --index disabled option or something for your use-case? Otherwise how will you avoid paying this cost?

@calda

calda commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

The --project-index disabled included in this PR works for me

@nicklockwood

Copy link
Copy Markdown
Owner Author

The --project-index disabled included in this PR works for me

Oh haha, I forgot I already added it 😅

@codecov

codecov Bot commented Oct 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.75810% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.62%. Comparing base (2caa115) to head (57300be).

Files with missing lines Patch % Lines
Sources/ProjectIndex.swift 95.93% 8 Missing ⚠️
Sources/CommandLine.swift 95.93% 5 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #2722      +/-   ##
===========================================
+ Coverage    95.59%   95.62%   +0.02%     
===========================================
  Files          190      191       +1     
  Lines        29560    29921     +361     
===========================================
+ Hits         28258    28611     +353     
- Misses        1302     1310       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nicklockwood
nicklockwood merged commit 39ebeba into develop Oct 5, 2026
16 checks passed
@nicklockwood
nicklockwood deleted the project-symbol-index branch October 5, 2026 22:51
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