Skip to content

2.2.0

Latest

Choose a tag to compare

@dfed dfed released this 01 Nov 00:29
· 170 commits to main since this release

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.
  • SendableLazilyInstantiated creates its instance exactly once while holding a lock, so it can be shared across concurrency domains. It requires a Sendable instance and macOS 15, iOS 18, tvOS 18, watchOS 11, or visionOS 2. Like a SendableInstantiator, it is built in a @Sendable function: what its instance's tree receives from outside it must be Sendable, and every initializer in that tree must be callable outside any actor, so a type whose initializer is isolated to @MainActor needs a nonisolated 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 SendableLazilyInstantiated when a type beneath a SendableInstantiator, SendableErasedInstantiator, or SendableLazilyInstantiated receives it. Those are built in a @Sendable function, which cannot capture a LazilyInstantiated; 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.
  • generate warns when a LazilyInstantiated is 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 LazilyInstantiated with @Received(fulfilledByDependencyNamed:ofType:), with or without onlyIfAvailable: true. The rename reads the instance whenever the type is created, so generate warns 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 LazilyInstantiated of its label that a promoted dependency receives, as in production: when the mocked type instantiates analytics: Analytics and a dependency the mock promotes receives analytics: LazilyInstantiated<Analytics>, the mock builds the mocked type's own analytics as that wrapper, passes the wrapper to its receivers, and passes the mocked type its instance (#480). A mock whose tree requires x: X? and also receives x as a LazilyInstantiated, optional or not, builds one wrapper and passes its instance to the receivers of x: X? (#484).
  • Do not read instance from 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 .safedi module info files as modules, beside or instead of --include directories, so a project whose build already writes them (the Bazel rule, the Tuist plugin, or a script that runs generate --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 .safedi and without the _safedi the Bazel macro appends. A missing file, or one written by another version of SafeDI, is reported with the explanation generate gives 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 formatVersion is 0 until 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 @Instantiable type named without its enclosing type now resolves inside a wrapper's generic arguments, in fulfilledByType:, and in ofType:. Inside Feature, @Instantiated let serviceBuilder: Instantiator<Service> builds Feature.Service. It used to fail with "No @Instantiable-decorated type or extension found" (#401).
  • Generated mocks call a nested type's hand-written mock that 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 SafeDITool for 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), build SafeDITool from source (#502). See Breaking changes.
  • To always build SafeDITool from source under Bazel, set --@safedi//bazel:prefer_prebuilt_safeditool=false, for example as common --@safedi//bazel:prefer_prebuilt_safeditool=false in .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 the SafeDITool your build runs, prebuilt or built from source, for query needs, dump, or --help (#502).
  • A Bazel-built SafeDITool and the SwiftPM-built or prebuilt SafeDITool of the same version write .safedi files that each can read, so query needs --module-info and dump work on a Bazel build's outputs with any SafeDITool of 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, and SendableLazilyInstantiated are in one section, "Sendable wrappers", and the query needs report 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 SafeDITool and where its build writes .safedi files, 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 .safedi files are written in a compact binary format. .safedi files are 89% smaller and scan caches 59% smaller. Summed generate wall time falls 36% as the SwiftPM and Xcode plugin runs it, and 9% when generate runs one module at a time reading .safedi files, as the Bazel rule and the Tuist script do (#364).
  • scan searches each file's bytes for SafeDI's markers before deciding whether to parse it. scan wall time falls 38%, and about 32% on a large production app (#420).
  • Each SafeDIMockConfiguration struct is generated once instead of once per mock root, and sibling dependencies are looked up instead of compared pairwise: about 3% fewer instructions in generate (#395).
  • generate no 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).
  • scan and generate together 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: scan 31% less, generate 39% 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 SafeDIOverrides entries for the dependencies it builds, which of two mutually onlyIfAvailable dependencies is passed nil, 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). Flat mock() parameters, forwarded parameters, and optional onlyIfAvailable overrides are still listed by label, then form, then type. In a cycle of Instantiators, which builder has no SafeDIOverrides field 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: X and another x: Instantiator<X>, the mock builds both, as the entries x_X and x_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 required mock() parameter. A tree requiring x: any Service and x: (any Service)? compiles; the mock used to read x before declaring it (#470).
  • A mock whose onlyIfAvailable receive shares a label with a dependency of another type (a forwarded parameter, another onlyIfAvailable receive, or a promoted dependency) compiles. Such an override is named by label and type, such as user_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 as user_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 @Instantiable declaration 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 produce invalid redeclaration, and an enclosing builder that declared the label hid the parameter (#446).
  • A mock that promotes a received optional Instantiator passes nil to an onlyIfAvailable renamed dependency of it inside the tree it builds, instead of reading it before it is declared (#448).
  • A type whose mockOnly hand-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 SafeDIMockConfiguration takes its default from that mock (#461). See Breaking changes.
  • A mock no longer builds what a @Forwarded property'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 @Forwarded property comes from outside the tree, so it is not the instance of a LazilyInstantiated of its label. A dependency the mock promotes under the label of a forwarded property or of a default-valued mock() parameter is named apart from it, such as session_Instantiator__Session beside a forwarded session: Session, user_Draft beside a forwarded user: User, or session_Session beside session: Int = 0. It used to hide the parameter, so the mock did not compile or ignored the caller's argument. A LazilyInstantiated or SendableLazilyInstantiated of that label that the mock does not build takes an entry of its own, by value: an override, nil unless set, when it is received only if available, or a mock() 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 to nil; it is now passed the instance, and so is a dependency the mock builds beside the mocked type that receives the LazilyInstantiated of 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 such LazilyInstantiated or SendableLazilyInstantiated as 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, or SendableLazilyInstantiated has a labeled default-valued initializer parameter of a non-Sendable type, or instantiates a dependency the mock cannot build (a fulfilledByType: type the module cannot see, a type with no @Instantiable, or an ErasedInstantiator of one) whose type is not Sendable. The mock now takes each as a @Sendable autoclosure and creates the value inside the function, once per construction, as production does (#507). See Breaking changes.

Generated code

  • A lazy Instantiator cycle 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 Instantiator through a Sendable builder 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 Instantiator it instantiates itself, and rename that rename. An onlyIfAvailable receive or onlyIfAvailable renamed dependency of the property still being built is passed nil, whether that property is an Instantiator, a constant, or a LazilyInstantiated, 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 @Forwarded property or a mock() 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 of onlyIfAvailable renamed 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> builds Feature.Service rather than a top-level Service of the same name (#401). An onlyIfAvailable receive of such a type, and fulfilledByType: written there, change too; see below.
  • any P and P are one type when SafeDI matches dependencies, at any depth: (any P)?, [any P], any A & B, LazilyInstantiated<any P>, and in renames and fulfillingAdditionalTypes too. A receive spelled one way of a dependency declared the other way used to be rejected, and mocks built the dependency twice. some P and (any P).Type stay distinct (#458). For an onlyIfAvailable receive, see the next entry; for mocks, see Breaking changes.
  • In generated dependency-tree code, an @Received(onlyIfAvailable: true) property that 2.1.1 passed nil is 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 as let service: Service? inside Feature when the tree has a Feature.Service (#401); or it spells a protocol any P where the provider wrote P, or the reverse (#458). Both versions compile, so a running app sees a value where it saw nil. Also inside an enclosing type, fulfilledByType: "Service" now builds the nested Feature.Service instead of a top-level Service when both are @Instantiable, as let service: Service written 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 additionalMocksToGenerate type named like a file's mock, used to share a file, and one's code replaced the other's. A generateMock: true type that is also listed in additionalMocksToGenerate no 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, so A/Root.swift and B/root.swift get their own files, A_Root+SafeDI.swift and B_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 in additionalMocksToGenerate yields to a source file's mock file name. Names never depend on input order (#494).
  • A type that requires an onlyIfAvailable renamed 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 onlyIfAvailable renaming of its own onlyIfAvailable renamed 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 onlyIfAvailable renaming of its own onlyIfAvailable renamed dependency is passed nil for 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 onlyIfAvailable renamed 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 when ofType: named the non-optional type (#472).
  • A label written in backticks is the same name as without them, so let `leaf` matches init(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 as default 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 additionalDirectoriesToInclude directory that is also an input is no longer declared twice, generate uses scan'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-root no longer mixes spellings in the build inputs it lists (#394, #405).
  • An additionalDirectoriesToInclude entry naming a .swift file that does not exist no longer fails scan with a raw Foundation error. It gets the warning below (#454).
  • generate rejects 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). scan lists a type that a configuration's additionalMocksToGenerate repeats once (#437).
  • When a source file has a syntax error, generate writes its #error stub to SafeDIMockConfiguration.swift as 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 #SafeDIConfiguration in 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 generate reports, 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 @Forwarded properties, 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. generate used 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 @Instantiated type has @Forwarded properties, the suggested instantiator follows the property: SendableInstantiator for a SendableLazilyInstantiated, and ErasedInstantiator or SendableErasedInstantiator for a property with fulfilledByType. The forwarded properties are spelled out, such as ErasedInstantiator<String, AnyChild>, instead of Child.ForwardedProperties, which the declaring module may not be able to see (#423).
  • generate warns when an additionalDirectoriesToInclude entry names nothing that exists, or names a file that is not a Swift file (#454).
  • Diagnostics, DOT output, and query needs spell 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 @Instantiable macro warns when a dependency's type names a type nested in the decorated declaration without qualifying it, or through Self, such as Config declared in Child. 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 own mock() 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 onlyIfAvailable renamed 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 the Instantiator being built (#404).
  • A required receive of an onlyIfAvailable renamed dependency whose fulfilling dependency can only be built after the receiver. The error suggests receiving it with @Received(onlyIfAvailable: true), which is passed nil there (#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 a SafeDIMockConfiguration (each takes an @Instantiated dependency or a default-valued parameter). Both share one SafeDIMockConfiguration, so one of the two mocks did not compile, and which one depended on root names. The same check applies to instantiate(…); 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 @Instantiated dependencies and no default-valued parameters (#440).
  • In a mock that promotes a received Instantiator, a type in that Instantiator's own tree that requires an onlyIfAvailable renamed dependency of it, which can only be nil there. Production already reports this (#451).
  • An ErasedInstantiator whose 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 @Sendable builder (SendableInstantiator, SendableErasedInstantiator, SendableLazilyInstantiated) whose tree receives an Instantiator, ErasedInstantiator, or LazilyInstantiated, or a LazilyInstantiated's instance, declared outside it. The error names the declaration to change to its Sendable twin (#441). See Breaking changes.
  • A type whose mock must build an Instantiator<X> and the X that contains a type requiring that Instantiator. The graph was accepted when X's name sorted before Instantiator<, with the Instantiator taken as a mock() parameter, and rejected otherwise (#470). See Breaking changes.
  • A required receive of an onlyIfAvailable renamed 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 onlyIfAvailable renaming of an onlyIfAvailable renamed dependency, on the same type or on a type beneath it, or a required renaming of one, whether ofType: names the optional or the non-optional type (#481).
  • A required receive of an onlyIfAvailable renamed 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 onlyIfAvailable renaming of an onlyIfAvailable renamed 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 @Forwarded property of the mocked type, when the promoted dependency receives the same label in another form the mock builds or takes as a parameter: an Instantiator, a LazilyInstantiated, or a dependency of another type, such as user: Draft beside a forwarded user: 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 onlyIfAvailable renamed dependency of x: X? while x is still being built: inside the tree of a LazilyInstantiated<X> the mock promotes or a promoted dependency instantiates, inside the tree of a constant x: X a promoted dependency instantiates, or in a sibling that a promoted LazilyInstantiated<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: Dashboard instantiates userService: UserService, which nothing fulfills, and receives a Garage that instantiates userService: Profile and a Car that receives userService: 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 @Instantiated property whose type is optional: X?, X!, Optional<X>, an optional Instantiator, ErasedInstantiator, or LazilyInstantiated, or a wrapper that builds an optional type, such as Instantiator<X?>. The @Instantiated macro reports it, with a fix-it that removes the optionality. SafeDI always creates an @Instantiated property, 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, when ofType: 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. Write ofType: with the property's type, or, for a LazilyInstantiated property, 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

  1. Reserved names. The @Instantiable macro now reports an error for a dependency name, fulfilledByDependencyNamed: name, or default-valued parameter label that begins with __safeDI_ or is exactly safeDIOverrides, safeDIBuilder, Instantiator, SendableInstantiator, ErasedInstantiator, SendableErasedInstantiator, LazilyInstantiated, or SendableLazilyInstantiated. 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 @Instantiable declaration, including where they compile today, such as safeDIOverrides on a type that is never mocked or a dependency named safeDIBuilder on a mock root; rename them (#410).

  2. Default-valued instantiate(…) parameters. A default-valued parameter of an extension's static func instantiate(…) is no longer a dependency, as for init. SafeDI calls instantiate(…) 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, and mock() sorted it among the others by label. So instantiate(alpha: Alpha, title: String = "production", zeta: Zeta) changes Service.mock(alpha:title:zeta:) to Service.mock(title:alpha:zeta:), and instantiate(title: String = …, count: Int = …) changes mock(count:title:) to mock(title:count:). A caller must pass these arguments first, in declaration order. A parent mock's required parameter becomes a field of the type's SafeDIMockConfiguration. An unlabeled default-valued parameter, such as _ note: String = "", is no longer offered: mock() drops it, and a parent mock's SafeDIOverrides closure for the type no longer takes it, so a caller that passed it must stop (#452).

    The @Instantiable macro 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 in instantiate(_ enabled: Bool = false, _ leaf: Leaf): add a label or move it last. A default that refers to Self, 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 in mock(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 from instantiate(…) clears all three (#452).

    An extension whose hand-written mock returns one of its fulfillingAdditionalTypes and has default-valued parameters other than instantiate(…)'s is now rejected when generated mocks build it both ways, because instantiate(…)'s default-valued parameters are now fields of the type's one SafeDIMockConfiguration (see #440 under Errors SafeDI now reports). These mocks compiled with 2.1.1. Give the mock exactly instantiate(…)'s default-valued parameters, in order; make it return the extended type; give it no default-valued parameters; or remove the default from instantiate(…) (#440, #452).

  3. .safedi files are binary. scan's cache and .safedi module 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).

  4. Unknown additionalMocksToGenerate entries. An entry that names no @Instantiable type 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 #if clause 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 as Box<Int>, that an @Instantiable extension declares only in an inactive clause. Guard that entry with the extension's condition (#492).

  5. Non-Sendable wrappers in @Sendable builders. In the Swift 5 language mode, a @Sendable builder whose tree receives a non-Sendable wrapper 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 for Instantiator: a type beneath a SendableInstantiator that receives an Instantiator declared above it. Declare the named dependency as SendableInstantiator, SendableErasedInstantiator, or SendableLazilyInstantiated (#441).

  6. Generated mock() changes.

    • A mock builds the dependencies it promotes, and orders their SafeDIOverrides initializer 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 other onlyIfAvailable, the one received later is passed nil, as under a root (#469).
    • A mock builds every form in which its tree receives one label (x: X and x: Instantiator<X> become the entries x_X and x_Instantiator__X), so a mock() 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, then Instantiator, ErasedInstantiator, SendableInstantiator, SendableErasedInstantiator, LazilyInstantiated, SendableLazilyInstantiated), which can reorder two parameters or two overrides that share a label: items_Value: Value? now comes before items_Instantiator__Value: Instantiator<Value> (#470).
    • A graph in which the mock must build both an Instantiator<X> and an X whose tree requires that Instantiator is rejected as a dependency cycle. It was accepted, with the Instantiator as a mock() parameter, when X's name sorted before Instantiator< (#470).
    • When a type has a hand-written mock, every default-valued field of its SafeDIMockConfiguration takes 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 or SafeDIOverrides entry whose name matched a label that a type in the mock's tree declares is renamed, such as session_GuestSession_. Only a name colliding with a label you wrote changes (#443, #446).
    • A type whose mockOnly hand-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 its SafeDIOverrides entry goes too, and when nothing fulfills it, the required mock() 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: ConcreteService passed to a required service: ServiceProtocol; that mock now takes service_ServiceProtocol:. Pass the dependency the mock should use (#412).
    • A dependency spelled any P in one place and P in another is one dependency: a mock that took two parameters for it takes one (#458). An onlyIfAvailable receive that 2.1.1 passed nil now 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 mock instead 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 @Forwarded property's type receives, so that dependency's SafeDIOverrides entry 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 its SafeDIOverrides argument: for example, logger, analytics, cache, uploader becomes analytics, logger, cache, uploader (#475).
    • A dependency the mock builds and promotes under the label of a @Forwarded property or of a default-valued mock() parameter is named apart from it. Beside a default-valued title: Title = Title(), a promoted @Instantiable Title's SafeDIOverrides entry title becomes title_Title, and beside a forwarded user: UserProtocol, a promoted @Instantiable Draft that conforms to UserProtocol has its entry user become user_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 a Draft that does not conform to UserProtocol, it did not compile. A dependency the mock cannot build was already a mock() parameter named by label and type, such as user_Draft:, and is unchanged (#480).
    • A mock whose tree receives, beneath the type that instantiates it, an @Instantiated dependency the mock cannot build no longer takes a second required parameter for that receiver: mock(userService_AnyUserService:userService:) becomes mock(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 @Instantiable declaration 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 build AnyUserService(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 SafeDIMockConfiguration of a type reached through a SendableInstantiator, SendableErasedInstantiator, or SendableLazilyInstantiated now stores each labeled default-valued parameter as a @Sendable closure, let flag: @Sendable () -> Bool, behind an @autoclosure initializer parameter: .init(flag: true) still compiles, while code that read the field as a value must call it. A mock() 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-Sendable value held in a let outside the call does not. Both mocks failed to compile in 2.1.1 when the value's type was not Sendable; the parameter's form also changes for a Sendable type (#507).
  7. SafeDI recognizes its wrapper types by name, so a project type named LazilyInstantiated or SendableLazilyInstantiated, like one named Instantiator, is treated as SafeDI's wrapper and now also clashes with SafeDI's public type. Do not declare types with these names (#363, #428).

  8. onlyIfAvailable renamings of a renamed dependency that is not created. Where a type is passed nil for an onlyIfAvailable renamed dependency that is not created, it is now also passed nil for an onlyIfAvailable renaming 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).

  9. Bazel downloads the prebuilt SafeDITool. Bazel builds now run the release's prebuilt SafeDITool instead 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 .safedi file 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 rerun generate when 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 SafeDIOverrides arguments or mock() 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.