Fix: generated type headers self-import, breaking clang module / Swift interop builds - #150
Conversation
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.
There was a problem hiding this comment.
🎉 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!
| 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 |
There was a problem hiding this comment.
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:
| 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.
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.
Problem
Any Swift code that consumes a
single_file_codegenmodule's generatedObj-C bindings fails to import them. When a
swift_library(or any clangmodule build) tries to import the generated Types module, compilation fails
with:
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 modulehas an import prefix (or the quoted
"MyModuleTypes.h"form without one).Under
single_file_codegen,CombineNativeSourcesProcessor.mergeAnySourcesthen merges all per-type headers into that very file — so the merged
MyModuleTypes.hends 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#importisinclude-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 helperfilterSelfImports(from:outputFilename:)that drops lines equal (afterwhitespace trimming) to any of the three forms the emission sites can produce
for the merged output file
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 theheader-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_codegenmodule implementedagainst its generated bindings
failed in clang module construction on the self-import line;
getId()(returnsidentifierForVendor), returning alive UUID rendered by the app UI — proving the generated Obj-C module was
imported and called through the Swift interop path.
Additionally:
swift testincompiler/compiler/Compiler—
CombineNativeSourcesProcessorTests, 4 tests: all three self-import formsdropped, 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).
bazel test //valdi/testdata/resources/modules/test:test_testandbazel build //valdi/testdata/resources/modules/test:test_cppboth pass.Out of scope (deliberately not changed)
ensureTypeAvailability,IOSType.importHeaderStatement,NativeSourceheader prepending) — they arecorrect for non-merged output; only the merged file must not import itself.
.mmerging (mergeObjcSources), Kotlin, C++, or otherprocessors — the fix is isolated to the
.hmerge path.