Reexport @_spi declarations

I wonder is it possible to reexport a module preserving declarations marked with @_spi?
Suppose we have the following module structure:

ModuleA:
@_spi(_Foo) func foo() {}

ModuleB:
@_exported import ModuleA

ModuleC:
@_spi(_Foo) import ModuleB
foo() // error: 'foo' is inaccessible due to '@_spi' protection level
2 Likes

Currently SPIs are only reexported when there's also an -export-as declaration from the underlying module. This was the middle ground chosen to avoid unintentionally reexporting SPIs. Be careful as -export-as has a wider effect.

I'd be curious to know what is your use case as we could make something more specific to this need.

See: [Sema] Restrict reexported SPIs to modules with an `export_as` relationship by xymus · Pull Request #62102 · apple/swift · GitHub

I'm fighting the viral effect of extensions. This is one of approaches I think about.
Consider a big library that consists of ModuleA, ModuleB, UmbrellaModule. And a consumer module App.

// ModuleA:
extension Array where Element == Double {
  public func sum() -> Double {
    reduce(0, +)
  }
}
public func someUsefulFunction1(_ x: [Double]) -> Double {
  return x.sum()
}

// ModuleB:
import ModuleA // danger point
public func someUsefulFunction2(_ x: [Double]) -> Double {
  return x.sum() / x.count // (or whatever what depends on ModuleA)
}

// UmbrellaModule:
@_exported import ModuleA
@_exported import ModuleB

// App:
import UmbrellaModule
[1.0].sum() // ok, but it shouldn't be ok.

As we imported UmbrellaModule in App we got visible:

  • Free functions someUsefulFunction1 and someUsefulFunction2.
  • Method sum on Array<Double>.

Free functions are no problem. In case of name clash with functions defined in other module we can easily workaround it by calling them by FQN: UmbrellaModule.someUsefulFunction1().
But extensions are viral: once an extension is visible in the import tree it will be visible up to the root.

There is another option how to prevent pollution from extensions. I could split ModuleA into ModuleA and ModuleAExtensions, and then carefully import ModuleAExtensions as _implementationOnly. But that's much more fragile approach - one missed attribute and we got a pollution.

@xymus I was able to make it work via export_as in the underlying module's modulemap, but not with -exported-as. It feels like the parity is broken here lib/AST/Module.cpp

bool ModuleDecl::isExportedAs(const ModuleDecl *other) const {
  auto clangModule = findUnderlyingClangModule();
  if (!clangModule)
    return false;

  return other->getRealName().str() == clangModule->ExportAsModule;
}

I think it should take into account ExportAsName field of ModuleDecl as well.

Issue

@xymus Did you consider to reexport SPIs when they're explicitly imported? I.e. you express your intention to reexport by marking them with @_exported @_spi(x) in sources, rather than a modulemap or compiler flag?

ModuleA:
@_spi(_Foo) func foo() {}

ModuleB:
@_exported @_spi(_Foo) import ModuleA
// or @_exported @_exported_spi(_Foo) import ModuleA

ModuleC:
@_spi(_Foo) import ModuleB
foo()