Skip to content

Fix: generated type headers self-import, breaking clang module / Swift interop builds - #150

Open
scott-thornton wants to merge 2 commits into
Snapchat:mainfrom
scott-thornton:fix/self-import-in-merged-type-headers
Open

Fix: generated type headers self-import, breaking clang module / Swift interop builds#150
scott-thornton wants to merge 2 commits into
Snapchat:mainfrom
scott-thornton:fix/self-import-in-merged-type-headers

Conversation

@scott-thornton

Copy link
Copy Markdown

Problem

Any Swift code that consumes a single_file_codegen module's generated
Obj-C bindings fails to import them. When a swift_library (or any clang
module build) tries to import the generated Types module, compilation fails
with:

error: 'deviceTypes/deviceTypes.h' file not found
error: could not build Objective-C module 'deviceTypes'

while the exact same generated sources compile fine as plain Obj-C. This makes
the generated modules unusable from Swift entirely — there is no workaround
from the consumer side, because the offending import is inside the generated
header itself. In valdi's own testdata (//valdi/testdata/resources/modules/test),
the merged output contains imports of itself, e.g. in SCCTestTypes.h:
#import <SCCTestTypes/SCCTestTypes.h> (3x) and #import "SCCTestTypes.h",
and in SCCTest.h: #import <SCCTest/SCCTest.h> (3x).

Root cause

Two emission sites import "the Types header" by name when a type is referenced:

  • ObjCCodeGenerator.ensureTypeAvailability (Generation/ObjC/ObjCCodeGenerator.swift:233)
    adds type.importHeaderStatement(kind:) to the api and regular headers;
  • NativeSource.iosNativeSourcesFromGeneratedCode (Models/NativeSource.swift:48–52)
    prepends the same statement to each generated per-type header.

IOSType.importHeaderStatement(kind:) (Parser/Models/ValdiRawDocument.swift:315)
renders that as the canonical <MyModuleTypes/MyModuleTypes.h> when the module
has an import prefix (or the quoted "MyModuleTypes.h" form without one).

Under single_file_codegen, CombineNativeSourcesProcessor.mergeAnySources
then merges all per-type headers into that very file — so the merged
MyModuleTypes.h ends up containing #import <MyModuleTypes/MyModuleTypes.h>,
an import of itself (once per merged input).

Regular Obj-C compilation never notices: per-target header maps resolve
<MyModuleTypes/MyModuleTypes.h> to the file being compiled, and #import is
include-once, so the self-import is a no-op. Clang module builds — which is
what Swift interop uses to import the generated module — do not receive header
maps, so the angled self-import cannot be resolved and module construction
fails at exactly that line.

The fix

Filter self-referential import lines while merging, in
CombineNativeSourcesProcessor.mergeAnySources, via a new static helper
filterSelfImports(from:outputFilename:) that drops lines equal (after
whitespace trimming) to any of the three forms the emission sites can produce
for the merged output file MyModuleTypes.h:

let selfImports: Set<String> = [
    "#import <\(outputFilename)>",          // #import <MyModuleTypes.h>
    "#import \"\(outputFilename)\"",        // #import "MyModuleTypes.h"
    "#import <\(stem)/\(outputFilename)>",  // #import <MyModuleTypes/MyModuleTypes.h>
]

The comparison is on exact statement equality against the output filename, so
legitimate imports are untouched: <Foundation/Foundation.h>,
<valdi_core/...>, the main header inside the Types file (SCCTest.h
SCCTestTypes.h), and vice versa. Removing a provable no-op cannot regress the
header-map-based ObjC path that works today.

Evidence

End-to-end, as an out-of-tree consumer of generated modules (any Swift interop
consumer is affected the same way): with only this patch applied to the
compiler, a project using a generated single_file_codegen module implemented
against its generated bindings

  1. built green through bazel (128 actions), where the same project previously
    failed in clang module construction on the self-import line;
  2. produced a linked binary containing the module's generated symbols;
  3. installed and launched on an iOS simulator; and
  4. executed the module's getId() (returns identifierForVendor), returning a
    live UUID rendered by the app UI — proving the generated Obj-C module was
    imported and called through the Swift interop path.

Additionally:

  • New unit tests cover the filter (swift test in compiler/compiler/Compiler
    CombineNativeSourcesProcessorTests, 4 tests: all three self-import forms
    dropped, other-header imports kept, indented lines matched, and content
    without self-imports preserved byte-for-byte). Full suite: 20/20 pass.
    The tests caught a real defect during development (the quoted form was
    initially mis-escaped and not actually filtered).
  • Regression checks with the patch applied (prebuilt-compiler mode):
    bazel test //valdi/testdata/resources/modules/test:test_test and
    bazel build //valdi/testdata/resources/modules/test:test_cpp both pass.

Out of scope (deliberately not changed)

  • The emission sites themselves (ensureTypeAvailability,
    IOSType.importHeaderStatement, NativeSource header prepending) — they are
    correct for non-merged output; only the merged file must not import itself.
  • Header-map behavior and modulemap generation.
  • Any change to .m merging (mergeObjcSources), Kotlin, C++, or other
    processors — the fix is isolated to the .h merge path.

Generated type headers (e.g. XTypes.h) can end up containing an import of
themselves (#import <XTypes/XTypes.h>), emitted by type-availability
annotation. Header-map builds tolerate this, but clang module builds —
including the Swift interop path — fail with duplicate definition errors,
so Swift code cannot import generated module types at all.

Filter exact self-import statements during mergeAnySources, keeping imports
of every other header intact.
@github-actions github-actions Bot added the area/compiler Valdi compiler label Aug 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎉 Thanks for your first contribution to Valdi!

A maintainer will review your PR soon. Here are a few things to check while you wait:

  • ✅ All tests pass (bazel test //...)
  • ✅ Your changes follow our coding standards
  • ✅ You've added tests for your changes (if applicable)
  • ✅ You've updated documentation (if needed)

Ask in GitHub Discussions if you have questions!

Comment on lines +84 to +92
var data = ""

for line in content.split(separator: "\n", omittingEmptySubsequences: false) {
if selfImports.contains(line.trimmingCharacters(in: .whitespaces)) { continue }
data += line
data += "\n"
}

return data

@scottthompsonsc scottthompsonsc Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit, non-blocking: for newline-terminated input, split(omittingEmptySubsequences: false) produces a trailing empty element, so the loop appends one extra \n and each merged section grows by a blank line relative to the original content. Purely cosmetic (these are build-time artifacts), but a filter + join keeps the content byte-for-byte:

Suggested change
var data = ""
for line in content.split(separator: "\n", omittingEmptySubsequences: false) {
if selfImports.contains(line.trimmingCharacters(in: .whitespaces)) { continue }
data += line
data += "\n"
}
return data
return content
.split(separator: "\n", omittingEmptySubsequences: false)
.filter { !selfImports.contains($0.trimmingCharacters(in: .whitespaces)) }
.joined(separator: "\n")

If you take this, testFilterSelfImportsMatchesIndentedLines and testFilterSelfImportsPreservesContentWithoutSelfImports will need their expected strings updated, since they currently encode the extra newline.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, applied.

Applied the suggested filter+join form — the line-by-line rebuild appended
a trailing newline for the empty element split produces on newline-
terminated input, growing each merged section by a blank line. The two
tests that encoded the extra newline updated to the byte-exact strings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compiler Valdi compiler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants