Repository navigation
What's new
LazilyInstantiated and SendableLazilyInstantiated
A dependency used to be either a constant, created along with its owner, or an Instantiator, which creates a new instance every time it is called. @Instantiated let analytics: LazilyInstantiated<Analytics> is created the first time it is read, and every later read returns the same instance (#363).
- A type further down the tree receives the wrapper,
@Received let analytics: LazilyInstantiated<Analytics>, and creating that type instantiates nothing. Or it receives the instance,@Received let analytics: Analytics, written exactly as today, and the dependency is created when the first such receiver is. SendableLazilyInstantiatedcreates its instance exactly once while holding a lock, so it can be shared across concurrency domains. It requires aSendableinstance and macOS 15, iOS 18, tvOS 18, watchOS 11, or visionOS 2. Like aSendableInstantiator, it is built in a@Sendablefunction: what its instance's tree receives from outside it must beSendable, and every initializer in that tree must be callable outside any actor, so a type whose initializer is isolated to@MainActorneeds anonisolated init(#363, #441, #490). The Manual's "Sendable wrappers" section says what else such a builder requires (#490). Generated code is not marked@available, so use it only in a module whose deployment targets meet those versions.- Declare a dependency as
SendableLazilyInstantiatedwhen a type beneath aSendableInstantiator,SendableErasedInstantiator, orSendableLazilyInstantiatedreceives it. Those are built in a@Sendablefunction, which cannot capture aLazilyInstantiated; SafeDI now reports that at the declaration (#428, #441). See Breaking changes. - A lazily instantiated dependency is created at most once, so it cannot break a dependency cycle. SafeDI validates it as the constant it stands for, and the cycle error says so.
generatewarns when aLazilyInstantiatedis read whenever its owner is created, and names the receiver to change to receive the wrapper instead.- A type, a root included, may rename the instance of its own
LazilyInstantiatedwith@Received(fulfilledByDependencyNamed:ofType:), with or withoutonlyIfAvailable: true. The rename reads the instance whenever the type is created, sogeneratewarns that the dependency is not lazy (#491). - A generated
mock()has the same parameters whether a dependency is a constant or lazily instantiated (#363, #417, #427, #432). - In a generated mock, a property the mocked type creates is the instance of a
LazilyInstantiatedof its label that a promoted dependency receives, as in production: when the mocked type instantiatesanalytics: Analyticsand a dependency the mock promotes receivesanalytics: LazilyInstantiated<Analytics>, the mock builds the mocked type's ownanalyticsas that wrapper, passes the wrapper to its receivers, and passes the mocked type its instance (#480). A mock whose tree requiresx: X?and also receivesxas aLazilyInstantiated, optional or not, builds one wrapper and passes its instance to the receivers ofx: X?(#484). - Do not read
instancefrom an initializer that runs while that instance is being created, directly or through a callback. The documentation of both types states what each guarantees (#428).
SafeDITool query needs
SafeDITool query needs <Type> --include <sources>… reports what a type needs: the modules and declarations its dependency closure is built from, its @Forwarded parameters, the dependencies nothing fulfills and why, and what its mock needs, whether generated, hand-written, or the one a #SafeDIConfiguration listing would generate. --json prints a versioned report for scripts; Documentation/QueryNeeds.md documents the format (#459, #506).
--module-info <file>…reads.safedimodule info files as modules, beside or instead of--includedirectories, so a project whose build already writes them (the Bazel rule, the Tuist plugin, or a script that runsgenerate --module-info-output) can query without parsing its sources again. Each file is a module holding the declarations its build read, under that build's compilation conditions, and is named after the file without.safediand without the_safedithe Bazel macro appends. A missing file, or one written by another version of SafeDI, is reported with the explanationgenerategives for a dependent module's file (#471).- The command is in beta: its options, text, and JSON may change in any release, and the JSON report's
formatVersionis0until the format is stable (#471).
Received and forwarded erased instantiators
@Received and @Forwarded properties can be ErasedInstantiator or SendableErasedInstantiator. A receiver is passed the ancestor's erased instantiator of the same label and full type. Only an @Instantiated one names fulfilledByType. The macro used to reject both, with advice they could not follow (#455).
Nested types
- A nested
@Instantiabletype named without its enclosing type now resolves inside a wrapper's generic arguments, infulfilledByType:, and inofType:. InsideFeature,@Instantiated let serviceBuilder: Instantiator<Service>buildsFeature.Service. It used to fail with "No@Instantiable-decorated type or extension found" (#401). - Generated mocks call a nested type's hand-written
mockthat returns the type's unqualified name, which is the spelling the macro requires. That mock used to be ignored, and the type was built with its initializer (#409). See Breaking changes.
Bazel runs the prebuilt SafeDITool
- Bazel builds run the SafeDI release's prebuilt
SafeDIToolfor the execution platform (macOS arm64 and x86_64, Linux x86_64 and arm64), the same binary the SwiftPM and Tuist integrations run. It is downloaded from the release and checked against a sha256 that the release's source archive records. Other platforms, and builds of a SafeDI checkout (local_path_override,git_override,archive_override), buildSafeDIToolfrom source (#502). See Breaking changes. - To always build
SafeDIToolfrom source under Bazel, set--@safedi//bazel:prefer_prebuilt_safeditool=false, for example ascommon --@safedi//bazel:prefer_prebuilt_safeditool=falsein.bazelrc. The Bazel Central Registry's presubmit tests the module both ways. 2.2.0's source archive on the Bazel Central Registry records the binaries' hashes (#502). bazel run @safedi//bazel:safeditool -- <arguments>runs theSafeDIToolyour build runs, prebuilt or built from source, forquery needs,dump, or--help(#502).- A Bazel-built
SafeDITooland the SwiftPM-built or prebuiltSafeDIToolof the same version write.safedifiles that each can read, soquery needs --module-infoanddumpwork on a Bazel build's outputs with anySafeDIToolof that version (#497).
Documentation
- The Manual describes how to use SafeDI and the rule behind each feature, and leaves edge cases to the diagnostics, which state them at your declaration. The rules for
SendableInstantiator,SendableErasedInstantiator, andSendableLazilyInstantiatedare in one section, "Sendable wrappers", and thequery needsreport and its JSON format are on their own page,Documentation/QueryNeeds.md(#506). - The Manual's new "Running SafeDITool yourself" section names each integration's own
SafeDITooland where its build writes.safedifiles, and the Manual's instructions for--help,query needs,dump, and DOT output point to it (#496, #502).
Performance
Measured on a synthetic 15-module, 3,100-file project shaped like a production app, unless noted:
scan's cache and.safedifiles are written in a compact binary format..safedifiles are 89% smaller and scan caches 59% smaller. Summedgeneratewall time falls 36% as the SwiftPM and Xcode plugin runs it, and 9% whengenerateruns one module at a time reading.safedifiles, as the Bazel rule and the Tuist script do (#364).scansearches each file's bytes for SafeDI's markers before deciding whether to parse it.scanwall time falls 38%, and about 32% on a large production app (#420).- Each
SafeDIMockConfigurationstruct is generated once instead of once per mock root, and sibling dependencies are looked up instead of compared pairwise: about 3% fewer instructions ingenerate(#395). generateno longer crashes with a stack overflow on deep dependency trees. A chain of about 50 features, each instantiating the next, crashed a release build. Every shape now generates at a depth of 1,000 (#467).scanandgeneratetogether take about a third less time than in 2.1.1 on the 15-module, 3,100-file fixture shaped after a production app (release builds, 11 rounds:scan31% less,generate39% less, 42% fewer instructions); a single measurement on that app showed 43% less.
Fixes
Generated mocks
- Generated mocks depend less on how your types are named. Which dependencies a mock builds, the order of the
SafeDIOverridesentries for the dependencies it builds, which of two mutuallyonlyIfAvailabledependencies is passednil, and whether a graph is accepted used to follow the alphabetical order of type names or labels, so renaming a type could change a mock's signature or turn an accepted graph into a rejected one. They now follow your declarations. The forms of one label that a mock takes rather than builds are listed by form (#469, #470). Flatmock()parameters, forwarded parameters, and optionalonlyIfAvailableoverrides are still listed by label, then form, then type. In a cycle ofInstantiators, which builder has noSafeDIOverridesfield still depends on type names. See Breaking changes. - A mock builds every form in which its tree receives one label. When one type requires
x: Xand anotherx: Instantiator<X>, the mock builds both, as the entriesx_Xandx_Instantiator__X, and takes neither as a parameter. It used to build whichever form's type name sorted first and take the other as a requiredmock()parameter. A tree requiringx: any Serviceandx: (any Service)?compiles; the mock used to readxbefore declaring it (#470). - A mock whose
onlyIfAvailablereceive shares a label with a dependency of another type (a forwarded parameter, anotheronlyIfAvailablereceive, or a promoted dependency) compiles. Such an override is named by label and type, such asuser_Draft. A mock without such a clash is unchanged (#408). - A required receive that shares a label with a promoted dependency of another type is taken as a
mock()parameter named by label and type, such asuser_Draft:. The mock used to pass the promoted dependency instead (#412). See Breaking changes. - A mock no longer reads a promoted dependency before declaring it (
use of local variable 'x_A' before its declaration) when another promoted dependency receives the mocked type's label with a different type (#435). - Names a mock generates for its own bindings (a renamed parameter or override such as
session_GuestSession, a promoted dependency named apart, a renamed dependency, or a lazily instantiated wrapper) are chosen apart from the labels the types in its tree declare. A nested declaration with that label used to hide the mock's binding, so the mock did not compile or passed the wrong value (#443). See Breaking changes. - A
mock()parameter for a dependency the mock cannot build (its@Instantiabledeclaration is in a module this one does not import) is named by label and type when another dependency, parameter, or binding uses its label. Two such dependencies with one label used to produceinvalid redeclaration, and an enclosing builder that declared the label hid the parameter (#446). - A mock that promotes a received optional
Instantiatorpassesnilto anonlyIfAvailablerenamed dependency of it inside the tree it builds, instead of reading it before it is declared (#448). - A type whose
mockOnlyhand-written mock has no parameter for one of the type's dependencies no longer has that dependency built in generated mocks. The unused value drew a compiler warning, which fails a build that treats warnings as errors (#419). See Breaking changes. - A type built with its hand-written mock for one property and with its initializer for another compiles when the mock takes no parameters (#419).
- When a type has a hand-written mock, every default-valued field of its
SafeDIMockConfigurationtakes its default from that mock (#461). See Breaking changes. - A mock no longer builds what a
@Forwardedproperty's type receives. When a dependency the mock promotes receives one of the mocked type's forwarded properties, the mock used to build, and offer an override for, the dependencies of the forwarded property's type, which nothing used. The unused value drew a compiler warning, which fails a build that treats warnings as errors (#475). See Breaking changes. - A mock never makes another form of a forwarded label from the forwarded value: a
@Forwardedproperty comes from outside the tree, so it is not the instance of aLazilyInstantiatedof its label. A dependency the mock promotes under the label of a forwarded property or of a default-valuedmock()parameter is named apart from it, such assession_Instantiator__Sessionbeside a forwardedsession: Session,user_Draftbeside a forwardeduser: User, orsession_Sessionbesidesession: Int = 0. It used to hide the parameter, so the mock did not compile or ignored the caller's argument. ALazilyInstantiatedorSendableLazilyInstantiatedof that label that the mock does not build takes an entry of its own, by value: an override,nilunless set, when it is received only if available, or amock()parameter when the mock cannot build it (#480). See Breaking changes. - A dependency the mock cannot build, which
mock()takes as a parameter, is passed to the receivers beneath the type that instantiates it, as in production. A receiver of it with@Received(onlyIfAvailable: true)used to get an override that silently defaulted tonil; it is now passed the instance, and so is a dependency the mock builds beside the mocked type that receives theLazilyInstantiatedof such a dependency only if available, which gets the instance wrapped. A mock whose builder function around the instantiating type declared the dependency's label did not compile. When a dependency the mock builds beside the mocked type receives the mocked type's own suchLazilyInstantiatedorSendableLazilyInstantiatedas the same wrapper kind, it gets that one wrapper, and the mocked type's own child receives its instance (#503). See Breaking changes. - A generated mock no longer fails to compile with "capture of … with non-Sendable type … in a '@sendable' local function" when a type built through a
SendableInstantiator,SendableErasedInstantiator, orSendableLazilyInstantiatedhas a labeled default-valued initializer parameter of a non-Sendabletype, or instantiates a dependency the mock cannot build (afulfilledByType:type the module cannot see, a type with no@Instantiable, or anErasedInstantiatorof one) whose type is notSendable. The mock now takes each as a@Sendableautoclosure and creates the value inside the function, once per construction, as production does (#507). See Breaking changes.
Generated code
- A lazy
Instantiatorcycle that closes past a builder for another property with the same label compiles. The closing builder referred to the wrong function by name (#407). - A lazy cycle that runs from an
Instantiatorthrough aSendablebuilder and back compiles. The enclosing builder function is now@Sendable(#447). - SafeDI accepts graphs it rejected although their generated code compiles. A rename refers to its own type's property of that name before one further up the tree, so in a fully lazy cycle, a type may rename an
Instantiatorit instantiates itself, and rename that rename. AnonlyIfAvailablereceive oronlyIfAvailablerenamed dependency of the property still being built is passednil, whether that property is anInstantiator, a constant, or aLazilyInstantiated, optional or not. A required receive of the property being built is still an error (#439, #450, #462). - Cycle validation stops at a value the tree is handed, a
@Forwardedproperty or amock()parameter for a dependency the mock cannot build, when no type instantiates that property or renames another to it, in production and in mocks alike. A cycle through that value's own dependencies is reported where a tree builds the type. Graphs rejected only by following those dependencies where the value is handed in are accepted, and their generated code compiles. A mock is generated even when no caller could build a value it takes. Whether a graph with two receivers ofonlyIfAvailablerenamed dependencies of one property is accepted no longer depends on which is declared first. Some rejected graphs report a different first error, such as an unfulfillable dependency instead of a cycle (#482). - Inside an enclosing type,
Instantiator<Service>buildsFeature.Servicerather than a top-levelServiceof the same name (#401). AnonlyIfAvailablereceive of such a type, andfulfilledByType:written there, change too; see below. any PandPare one type when SafeDI matches dependencies, at any depth:(any P)?,[any P],any A & B,LazilyInstantiated<any P>, and in renames andfulfillingAdditionalTypestoo. A receive spelled one way of a dependency declared the other way used to be rejected, and mocks built the dependency twice.some Pand(any P).Typestay distinct (#458). For anonlyIfAvailablereceive, see the next entry; for mocks, see Breaking changes.- In generated dependency-tree code, an
@Received(onlyIfAvailable: true)property that 2.1.1 passednilis now passed the instance an enclosing type instantiates or forwards in two cases: inside an enclosing type, its type names a nested type without the enclosing type, such aslet service: Service?insideFeaturewhen the tree has aFeature.Service(#401); or it spells a protocolany Pwhere the provider wroteP, or the reverse (#458). Both versions compile, so a running app sees a value where it sawnil. Also inside an enclosing type,fulfilledByType: "Service"now builds the nestedFeature.Serviceinstead of a top-levelServicewhen both are@Instantiable, aslet service: Servicewritten there does (#401). - Every root and mock gets its own output file. A root whose directory-prefixed file name matched another file's name, or an
additionalMocksToGeneratetype named like a file's mock, used to share a file, and one's code replaced the other's. AgenerateMock: truetype that is also listed inadditionalMocksToGenerateno longer gets its mock twice (invalid redeclaration of 'mock()') (#426). Names that differ only in letter case or Unicode normalization count as one name, on every platform, soA/Root.swiftandB/root.swiftget their own files,A_Root+SafeDI.swiftandB_root+SafeDI.swift, also on a volume that ignores letter case, as macOS's does by default. A name that still clashes takes_2,_3, …, and the file whose path sorts first keeps the name. A type listed inadditionalMocksToGenerateyields to a source file's mock file name. Names never depend on input order (#494). - A type that requires an
onlyIfAvailablerenamed dependency whose label an enclosing type also declares is handed that enclosing property where the renamed dependency cannot be created, so the enclosing property is now built before the receiver. It used to be built where it was declared, and when that was after the receiver, the generated code did not compile (closure captures 'y' before it is declared) (#481). - A type that declares an
onlyIfAvailablerenaming of its ownonlyIfAvailablerenamed dependency, when a type beneath it requires the second, now has the first's fulfilling dependency built before it, so both are created. The generated code used to name a renamed dependency it had not created (cannot find 'z' in scope) (#481). - A type that declares an
onlyIfAvailablerenaming of its ownonlyIfAvailablerenamed dependency is passednilfor both when neither is created, in production code and in mocks. The generated code used to name the renaming that was not created (cannot find 'z' in scope) (#486). See Breaking changes. - When a type forwards a property with the label of an
onlyIfAvailablerenamed dependency in its tree, the renamed dependency's receivers are handed the forwarded property, so nothing requires the renamed dependency's fulfilling dependency to be built first. Such a graph used to be rejected as a dependency cycle whenofType:named the non-optional type (#472). - A label written in backticks is the same name as without them, so
let `leaf`matchesinit(leaf:). Generated code no longer carries the backticks into the names it builds from a label, which did not compile (func __safeDI_`default`()), and a generated call no longer escapes a keyword argument label, which drew a warning (keyword 'default' does not need to be escaped in argument list) that fails a build that treats warnings as errors. A default-valued parameter with a keyword label, such asdefault value: Int = 1, compiles in a mock (#495).
Build integration
- When a script or custom integration reports the project root through a symbolic link, a file in an
additionalDirectoriesToIncludedirectory that is also an input is no longer declared twice,generateusesscan's cache instead of parsing afresh on every build, and diagnostics name files as the build system spells them, so the IDE can open them. A relative or trailing-slash--project-rootno longer mixes spellings in the build inputs it lists (#394, #405). - An
additionalDirectoriesToIncludeentry naming a.swiftfile that does not exist no longer failsscanwith a raw Foundation error. It gets the warning below (#454). generaterejects a hand-written manifest in which two outputs name one file, which let one output's code replace the other's, whether the two paths are spelled the same, reach the file through a symbolic link to its directory, or differ only in letter case. A custom integration whose manifest did this used to pass (#437, #494).scanlists a type that a configuration'sadditionalMocksToGeneraterepeats once (#437).- When a source file has a syntax error,
generatewrites its#errorstub toSafeDIMockConfiguration.swiftas well as to every other output. A clean build used to lack that file, and an incremental build kept an earlier build's code in it (#494).
Diagnostics
- Error and warning messages say what is wrong and what to do next. Every cycle error starts with
Dependency cycle:and the path. More than one#SafeDIConfigurationin scope is reported at each declaration. The plugin's warning when it falls back to its simpler scanner says what that means, and in Xcode how to install the tool (#453). - Which errors
generatereports, and where, no longer depends on how types, properties, or files are named. SafeDI validates in stages. Within a stage, every root and every mock reports the first error its tree has, and all are reported together in source order, including every property whose type has@Forwardedproperties, every type nothing fulfills, and every type declared more than once. A later stage runs only once the earlier ones pass, so fixing errors can reveal more, and errors in mocks appear once the production dependency tree is valid.generateused to report only the error whose root, type, or file name sorted first, so renaming a type could move an error or swap it for another (#465). - When an
@Instantiatedtype has@Forwardedproperties, the suggested instantiator follows the property:SendableInstantiatorfor aSendableLazilyInstantiated, andErasedInstantiatororSendableErasedInstantiatorfor a property withfulfilledByType. The forwarded properties are spelled out, such asErasedInstantiator<String, AnyChild>, instead ofChild.ForwardedProperties, which the declaring module may not be able to see (#423). generatewarns when anadditionalDirectoriesToIncludeentry names nothing that exists, or names a file that is not a Swift file (#454).- Diagnostics, DOT output, and
query needsspell a label as the generated code declares it: a keyword in backticks, also where the initializer wrote it bare, and any other name without them, also where the declaration escaped it (#495). - The
@Instantiablemacro warns when a dependency's type names a type nested in the decorated declaration without qualifying it, or throughSelf, such asConfigdeclared inChild. The generated code in another type's extension cannot resolve that name. A fix-it writes the name with its enclosing types,Child.Config. It is a warning because such a declaration compiles while only the type's ownmock()names the nested type, so a build that treats warnings as errors fails there until the name is qualified (#498). - A dependency cycle that closes through renamed dependencies is reported at the receive that closes it, with a hint for each
onlyIfAvailablerenamed dependency it passes through, and, where an enclosing type declares the renamed dependency's label, a hint that names that property (#472, #481, #487).
Errors SafeDI now reports
Each of these graphs could never be built. They used to pass SafeDI and fail in the compiler, inside generated code:
- A mock cycle closed by a renamed dependency (
@Received(fulfilledByDependencyNamed:ofType:)) of theInstantiatorbeing built (#404). - A required receive of an
onlyIfAvailablerenamed dependency whose fulfilling dependency can only be built after the receiver. The error suggests receiving it with@Received(onlyIfAvailable: true), which is passednilthere (#436, #453). - A type that generated mocks build both with its hand-written mock (for a property typed by one of its
fulfillingAdditionalTypes) and with its initializer, when the two take different parameters and both need aSafeDIMockConfiguration(each takes an@Instantiateddependency or a default-valued parameter). Both share oneSafeDIMockConfiguration, so one of the two mocks did not compile, and which one depended on root names. The same check applies toinstantiate(…); that case compiled in 2.1.1 and is new because of Breaking changes item 2. Make the hand-written mock return the concrete type, give it the initializer's parameters in the same order, or have it take no@Instantiateddependencies and no default-valued parameters (#440). - In a mock that promotes a received
Instantiator, a type in thatInstantiator's own tree that requires anonlyIfAvailablerenamed dependency of it, which can only benilthere. Production already reports this (#451). - An
ErasedInstantiatorwhose first generic is not the built type's forwarded properties, in a tree only a mock builds. It is reported at its declaration, as production reports it (#455). - A
@Sendablebuilder (SendableInstantiator,SendableErasedInstantiator,SendableLazilyInstantiated) whose tree receives anInstantiator,ErasedInstantiator, orLazilyInstantiated, or aLazilyInstantiated's instance, declared outside it. The error names the declaration to change to itsSendabletwin (#441). See Breaking changes. - A type whose mock must build an
Instantiator<X>and theXthat contains a type requiring thatInstantiator. The graph was accepted whenX's name sorted beforeInstantiator<, with theInstantiatortaken as amock()parameter, and rejected otherwise (#470). See Breaking changes. - A required receive of an
onlyIfAvailablerenamed dependency of a property's optional form (ofType: X?.self), where that property is a sibling of one of the receiver's ancestors and its type receives that ancestor. The property must be built before the ancestor for the renamed dependency to be created, and after it because it receives it (#472). - The same cycle through a chain of renamed dependencies: an
onlyIfAvailablerenaming of anonlyIfAvailablerenamed dependency, on the same type or on a type beneath it, or a required renaming of one, whetherofType:names the optional or the non-optional type (#481). - A required receive of an
onlyIfAvailablerenamed dependency whose label an enclosing type also declares, when that enclosing property cannot be built before the receiver, for example because its type receives the receiver's ancestor (#481). - A required receive, or a required renaming, of an
onlyIfAvailablerenaming of anonlyIfAvailablerenamed dependency of the property being built, on one type or across types. The error is a dependency cycle with a hint for each renaming (#487). - In a mock, a type built inside a promoted dependency that receives a
@Forwardedproperty of the mocked type, when the promoted dependency receives the same label in another form the mock builds or takes as a parameter: anInstantiator, aLazilyInstantiated, or a dependency of another type, such asuser: Draftbeside a forwardeduser: User. The error says the receive is hidden, with a note at the receive that hides it. Production rejects every such pair (#480). - In a mock, a type that requires an
onlyIfAvailablerenamed dependency ofx: X?whilexis still being built: inside the tree of aLazilyInstantiated<X>the mock promotes or a promoted dependency instantiates, inside the tree of a constantx: Xa promoted dependency instantiates, or in a sibling that a promotedLazilyInstantiated<X>'s tree receives. Production already reports these (#484). - In a mock, a receive beneath a dependency the mock builds beside the mocked type, when a declaration in that dependency's tree has the receive's label and another type:
DashboardinstantiatesuserService: UserService, which nothing fulfills, and receives aGaragethat instantiatesuserService: Profileand aCarthat receivesuserService: UserService. The error says the receive is hidden in chain, with a note at the declaration that hides it; production rejects it under any root. Give one of the two a different label (#503). - An
@Instantiatedproperty whose type is optional:X?,X!,Optional<X>, an optionalInstantiator,ErasedInstantiator, orLazilyInstantiated, or a wrapper that builds an optional type, such asInstantiator<X?>. The@Instantiatedmacro reports it, with a fix-it that removes the optionality. SafeDI always creates an@Instantiatedproperty, so declare it non-optional, or receive it with@Received(onlyIfAvailable: true)where it may be absent. Most of these declarations compiled before; see the closing sentence of Breaking changes (#499). - A
@Received(fulfilledByDependencyNamed:ofType:)rename whose label its own type instantiates, forwards, or renames to, whenofType:names a type other than that property's type, including its optional or non-optional form. The rename refers to the type's own property, so SafeDI used to assign it that property or one further up the tree depending on declaration order, and a mock could bind the other one; where the types did not convert, the generated code did not compile. WriteofType:with the property's type, or, for aLazilyInstantiatedproperty, its instance type, or give the type's own property another label. Some of these renames compiled before; see the closing sentence of Breaking changes (#501).
Breaking changes
-
Reserved names. The
@Instantiablemacro now reports an error for a dependency name,fulfilledByDependencyNamed:name, or default-valued parameter label that begins with__safeDI_or is exactlysafeDIOverrides,safeDIBuilder,Instantiator,SendableInstantiator,ErasedInstantiator,SendableErasedInstantiator,LazilyInstantiated, orSendableLazilyInstantiated. These are names SafeDI's generated code uses, and a name spelled the same could fail to compile inside generated code. They are rejected on every@Instantiabledeclaration, including where they compile today, such assafeDIOverrideson a type that is never mocked or a dependency namedsafeDIBuilderon a mock root; rename them (#410). -
Default-valued
instantiate(…)parameters. A default-valued parameter of an extension'sstatic func instantiate(…)is no longer a dependency, as forinit. SafeDI callsinstantiate(…)without it, so its default applies, even when an ancestor instantiates or forwards a property with the same label and type. To keep receiving the ancestor's value, remove the default.On the extension's own
mock(), a labeled default-valued parameter becomes an optional parameter that defaults to the declared value. These parameters now come first, in declaration order, before the parameters for dependencies, where an initializer's default-valued parameters go; in 2.1.1, such a parameter was a dependency, andmock()sorted it among the others by label. Soinstantiate(alpha: Alpha, title: String = "production", zeta: Zeta)changesService.mock(alpha:title:zeta:)toService.mock(title:alpha:zeta:), andinstantiate(title: String = …, count: Int = …)changesmock(count:title:)tomock(title:count:). A caller must pass these arguments first, in declaration order. A parent mock's required parameter becomes a field of the type'sSafeDIMockConfiguration. An unlabeled default-valued parameter, such as_ note: String = "", is no longer offered:mock()drops it, and a parent mock'sSafeDIOverridesclosure for the type no longer takes it, so a caller that passed it must stop (#452).The
@Instantiablemacro now checks these parameters as it checks an initializer's default-valued parameters, so three declarations that compiled with 2.1.1 are errors. An unlabeled default-valued parameter followed by an unlabeled parameter without a default, as ininstantiate(_ enabled: Bool = false, _ leaf: Leaf): add a label or move it last. A default that refers toSelf, when the extension has no hand-written mock: write the type's name. A hand-written mock that takes such a parameter without a default, as inmock(title: String), the form 2.1.1 required: give it a default, which also compiles with 2.1.1, or leave it out. Removing the default frominstantiate(…)clears all three (#452).An extension whose hand-written mock returns one of its
fulfillingAdditionalTypesand has default-valued parameters other thaninstantiate(…)'s is now rejected when generated mocks build it both ways, becauseinstantiate(…)'s default-valued parameters are now fields of the type's oneSafeDIMockConfiguration(see #440 under Errors SafeDI now reports). These mocks compiled with 2.1.1. Give the mock exactlyinstantiate(…)'s default-valued parameters, in order; make it return the extended type; give it no default-valued parameters; or remove the default frominstantiate(…)(#440, #452). -
.safedifiles are binary.scan's cache and.safedimodule info files are no longer JSON. Regenerate them after upgrading (see Upgrading).SafeDITool dump <file>prints either kind as JSON, and explains a file it cannot read (#364, #425). -
Unknown
additionalMocksToGenerateentries. An entry that names no@Instantiabletype is now an error at the#SafeDIConfiguration, which suggests similarly named types. It used to produce a mock file with no code (#454). An entry that names a type declared only in an#ifclause the build leaves inactive is not an error: its mock file is written without code, as 2.1.1 wrote it, and the suggestions for a misspelled entry include such types. One such entry is rejected: a specialization of a generic type, such asBox<Int>, that an@Instantiableextension declares only in an inactive clause. Guard that entry with the extension's condition (#492). -
Non-
Sendablewrappers in@Sendablebuilders. In the Swift 5 language mode, a@Sendablebuilder whose tree receives a non-Sendablewrapper from outside it compiled with a warning and read the wrapper across concurrency domains without synchronization. It is now an error. This shape existed before 2.2.0 forInstantiator: a type beneath aSendableInstantiatorthat receives anInstantiatordeclared above it. Declare the named dependency asSendableInstantiator,SendableErasedInstantiator, orSendableLazilyInstantiated(#441). -
Generated
mock()changes.- A mock builds the dependencies it promotes, and orders their
SafeDIOverridesinitializer arguments, in the order its tree first receives them instead of by label. A caller that passes several of them may need to reorder its arguments. Of two promoted dependencies that receive each otheronlyIfAvailable, the one received later is passednil, as under a root (#469). - A mock builds every form in which its tree receives one label (
x: Xandx: Instantiator<X>become the entriesx_Xandx_Instantiator__X), so amock()that took one of the forms as a required parameter no longer takes it, and a caller that passed it must stop. The forms a mock takes rather than builds — required forms it cannot build, and forms received only if available — are listed by form (the dependency, thenInstantiator,ErasedInstantiator,SendableInstantiator,SendableErasedInstantiator,LazilyInstantiated,SendableLazilyInstantiated), which can reorder two parameters or two overrides that share a label:items_Value: Value?now comes beforeitems_Instantiator__Value: Instantiator<Value>(#470). - A graph in which the mock must build both an
Instantiator<X>and anXwhose tree requires thatInstantiatoris rejected as a dependency cycle. It was accepted, with theInstantiatoras amock()parameter, whenX's name sorted beforeInstantiator<(#470). - When a type has a hand-written mock, every default-valued field of its
SafeDIMockConfigurationtakes its default from the hand-written mock, including for a property built with the initializer, and for one built with the initializer beneath a lazy cycle's pruned edge. It used to take the default of whichever property came first by root name (#461). - A
mock()parameter orSafeDIOverridesentry whose name matched a label that a type in the mock's tree declares is renamed, such assession_GuestSession_. Only a name colliding with a label you wrote changes (#443, #446). - A type whose
mockOnlyhand-written mock has no parameter for one of its dependencies loses that dependency's override entry, which had no effect. A received dependency only that type needed is no longer built, so itsSafeDIOverridesentry goes too, and when nothing fulfills it, the requiredmock()parameter nobody read is removed. A caller that passes it must stop (#419). - A mock gains a required parameter, named by label and type, when the mocked type requires a dependency whose label a promoted dependency of another type shares. This compiled before only when the promoted type converted to the required type, as with a promoted
service: ConcreteServicepassed to a requiredservice: ServiceProtocol; that mock now takesservice_ServiceProtocol:. Pass the dependency the mock should use (#412). - A dependency spelled
any Pin one place andPin another is one dependency: a mock that took two parameters for it takes one (#458). AnonlyIfAvailablereceive that 2.1.1 passednilnow gets the instance in production code; that is a fix, under Fixes > Generated code. - A generated mock now builds a nested type with its hand-written
mockinstead of its initializer, so a mock that compiled before can build that type differently, with the hand-written mock's parameters and defaults (#409). - A mock no longer builds a dependency that only the tree of a
@Forwardedproperty's type receives, so that dependency'sSafeDIOverridesentry is gone, and a caller that passes it must stop. A dependency that such a tree and a tree the mock builds both receive is now ordered where the built tree first receives it, which can move itsSafeDIOverridesargument: for example,logger, analytics, cache, uploaderbecomesanalytics, logger, cache, uploader(#475). - A dependency the mock builds and promotes under the label of a
@Forwardedproperty or of a default-valuedmock()parameter is named apart from it. Beside a default-valuedtitle: Title = Title(), a promoted@InstantiableTitle'sSafeDIOverridesentrytitlebecomestitle_Title, and beside a forwardeduser: UserProtocol, a promoted@InstantiableDraftthat conforms toUserProtocolhas its entryuserbecomeuser_Draft. A caller that set the entry sets the new name. In 2.1.1, the mock bound the promoted value under the parameter's label, which hid the parameter: when that value converted to the parameter's type, as in both examples, the mock compiled and silently ignored the caller's argument, and otherwise, as with aDraftthat does not conform toUserProtocol, it did not compile. A dependency the mock cannot build was already amock()parameter named by label and type, such asuser_Draft:, and is unchanged (#480). - A mock whose tree receives, beneath the type that instantiates it, an
@Instantiateddependency the mock cannot build no longer takes a second required parameter for that receiver:mock(userService_AnyUserService:userService:)becomesmock(userService:). Drop the removed argument at call sites (#503). - In one rare shape a required parameter is added instead. The mocked type instantiates a dependency without
fulfilledByType:, such as@Instantiated let userService: AnyUserService, of a type no@Instantiabledeclaration it can see fulfills; another declaration erases one concrete type to that type,@Instantiated(fulfilledByType: "DefaultUserService", erasedToConcreteExistential: true) let userService: AnyUserService; and a type beneath the mocked type receives the dependency. The mock used to buildAnyUserService(DefaultUserService())only because of that receiver.mock()now takes it as a parameter,mock(userService:), as it already did when nothing beneath received it, and passes it to that receiver. Pass the instance at call sites (#503). - A
SafeDIMockConfigurationof a type reached through aSendableInstantiator,SendableErasedInstantiator, orSendableLazilyInstantiatednow stores each labeled default-valued parameter as a@Sendableclosure,let flag: @Sendable () -> Bool, behind an@autoclosureinitializer parameter:.init(flag: true)still compiles, while code that read the field as a value must call it. Amock()parameter for a dependency the mock cannot build, when a type built inside one of those wrappers instantiates it, is now@autoclosure @escaping @Sendable () -> T:Root.mock(service: MockService())still compiles, while passing a non-Sendablevalue held in aletoutside the call does not. Both mocks failed to compile in 2.1.1 when the value's type was notSendable; the parameter's form also changes for aSendabletype (#507).
- A mock builds the dependencies it promotes, and orders their
-
SafeDI recognizes its wrapper types by name, so a project type named
LazilyInstantiatedorSendableLazilyInstantiated, like one namedInstantiator, is treated as SafeDI's wrapper and now also clashes with SafeDI's public type. Do not declare types with these names (#363, #428). -
onlyIfAvailablerenamings of a renamed dependency that is not created. Where a type is passednilfor anonlyIfAvailablerenamed dependency that is not created, it is now also passednilfor anonlyIfAvailablerenaming of it that the type declares. When an enclosing type forwarded a property with that renaming's label, the type used to be passed the forwarded property; types beneath it still are. This applies to production code as well as mocks (#486). -
Bazel downloads the prebuilt SafeDITool. Bazel builds now run the release's prebuilt
SafeDIToolinstead of building it from source, so the first build needs network access to GitHub, or a repository cache, to fetch the binary. A build without network access, or one that must build the tool from source, sets--@safedi//bazel:prefer_prebuilt_safeditool=false(#502).
A graph SafeDI now rejects did not compile before, except names reserved in item 1, the macro checks and the extension shape in item 2, the configuration entries in item 4, the Swift 5 shape in item 5, the one-name ordering of item 6's cycle, optional @Instantiated properties (#499), and renames whose ofType: disagrees with their own type's property of that label (#501).
Upgrading
From 2.1.1
Most projects need no changes beyond the items above. On a production app, and on the 15-module, 3,100-file fixture shaped after it, every production dependency-tree file generate writes is byte-identical to 2.1.1's. Every generated mock file and SafeDIMockConfiguration.swift that differs (169 of 253 on the app, 197 of 259 on the fixture) holds the same lines as its 2.1.1 counterpart in a different order, apart from a trailing comma that moves where another parameter now ends a list: the reordering of SafeDIOverrides entries and mock() parameters in item 6. So the compiler points at each call site that passes them in the old order, and nothing else in the generated code changed.
- Clean once after upgrading. A module that reads a
.safedifile written by 2.1.1 stops with an error that names the file and says to clean and build again. An integration that does not rerungeneratewhen SafeDITool changes, such as the Tuist script, can leave such a file behind. In Xcode, choose Product > Clean Build Folder (#364, #425). - Diagnostic text changed. A script that matches SafeDI's error or warning text needs updating (#453).
- Mock call sites. Where a mock's
SafeDIOverridesarguments ormock()parameters moved or went away (items 2 and 6), the compiler points at each call site; item 6 states the new order.
From 2.0.x
Read Upgrading from 2.0.0 in the 2.1.0 release notes as well. Since 2.1.0, SafeDITool scans an #if block around a production declaration according to the conditions your build reports, and the generated code asserts that choice. Xcode builds and swift build on the Swift Build backend need nothing; with swift build on SwiftPM's native backend, add .define("DEBUG", .when(configuration: .debug)) to the target's swiftSettings to guard a production declaration with DEBUG. The 2.1.0 Manual's section is at Upgrading to 2.1.0 or later; the 2.2.0 Manual no longer has it.