Skip to content
Back to posts

Building a Mini Linker for SwiftUI Previews

8 min read

If you haven’t read the original post on teaching AI to see SwiftUI previews, start there. This post assumes you know the basic idea: build a minimal app that renders a single #Preview, capture a screenshot, hand it to AI.

Why Rewrite

The first version of Claude-XcodePreviews was shell scripts calling a Ruby script calling xcodebuild. It worked, but every GitHub issue was the same: “I can’t install the xcodeproj gem.” System Ruby conflicts, Bundler version mismatches, gem install permission errors. The tool’s actual job (capturing a SwiftUI preview) was the easy part. The hard part was getting Ruby to cooperate on someone else’s machine.

I also had a correctness problem. v1 extracted #Preview { ... } blocks by counting braces. This is fine until someone writes a preview containing a string literal with a } in it, or a comment with unbalanced braces, or a nested closure that confuses the counter. I kept patching edge cases and the regex kept finding new ways to be wrong.

So I rewrote the whole thing as a Swift CLI. Two dependencies: SwiftSyntax for parsing Swift, XcodeProj for manipulating .xcodeproj files. No Ruby. No shell pipeline. One binary that does everything.

That part was straightforward. The interesting part was what happened when I tried to make it fast.

The App Target Problem

Claude-XcodePreviews works by injecting a temporary PreviewHost target into your Xcode project, building it, and capturing a screenshot. If the view you’re previewing lives in a framework module (say, DesignSystem or ProfileFeature), the tool just links that framework and everything resolves. Fast. Clean.

But if the view lives in the app target itself, you have a problem. The app target might have hundreds of source files. You can’t compile them all into the PreviewHost because one of them has @main, and now you’ve got two entry points and a linker error. You can’t just include the one file with your preview because it references types defined in other files. And you can’t include the whole module minus @main because that’s still hundreds of files and a 45-second build for a tool that’s supposed to feel interactive.

I needed something in between: include exactly the declarations that the preview transitively depends on, and nothing else. A mini linker.

Treating Declarations as a Graph

The idea is to model every top-level Swift declaration (each struct, class, enum, extension, top-level function) as a node in a dependency graph. The edges are type references: if ProfileView mentions AvatarView in its body, there’s an edge from the ProfileView declaration to the AvatarView declaration. Start from the preview’s referenced types and walk the graph. Whatever you reach, you compile. Whatever you don’t, you skip.

This required two pieces: a way to analyze what types each declaration uses, and a way to traverse those references across files.

The DependencyVisitor

SwiftSyntax lets you write visitor classes that walk the AST and react to specific node types. The DependencyVisitor is ~80 lines. It does two things:

  1. When it visits a struct/class/enum/protocol/actor/typealias declaration, it records the type name in declaredTypes.
  2. When it visits any IdentifierTypeSyntax node (every place a type name appears in a type annotation, inheritance clause, or generic constraint), it records that name in referencedTypes.
override public func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
  declaredTypes.insert(node.name.text)
  collectInheritance(node.inheritanceClause)
  checkForMainAttribute(node.attributes)
  return .visitChildren
}

override public func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind {
  let name = node.name.text
  if isPascalCase(name) {
    referencedTypes.insert(name)
  }
  return .visitChildren
}

The isPascalCase check is doing a lot of work here. It’s how the visitor distinguishes type references from value references without doing full type resolution. In Swift, types are PascalCase and values are camelCase by convention. So AvatarView gets tracked as a dependency; avatarSize doesn’t.

This is obviously imperfect. It’ll pick up SomeEnumCase as a type reference when it’s actually a static member. It’ll miss a type named urlSession if someone violates naming conventions. But over-inclusion just means compiling a few extra declarations (slightly slower); under-inclusion means a build error (immediately visible, easy to debug). I’ll take that tradeoff.

The BFS

The resolver parses every .swift file in the module’s source directory, runs DependencyVisitor on each top-level declaration, and builds two lookup maps: type name to declaration indices, and extended type name to extension indices. Then it does a breadth-first search.

The queue is seeded from two sources: all declarations in the preview’s source file (minus any @main entry point), and all declarations that define types mentioned in the #Preview body. From there, for each resolved declaration, follow its referenced types to their declarations and extensions. Also pull in extensions of any type declared in a resolved declaration, because an extension might add protocol conformance that the preview depends on.

var queueIndex = 0
while queueIndex < queue.count {
  let index = queue[queueIndex]
  queueIndex += 1
  guard !resolved.contains(index) else { continue }

  let decl = allDeclarations[index]
  if decl.hasEntryPoint { continue }
  resolved.insert(index)

  for ref in decl.referencedTypes {
    guard !frameworkTypes.contains(ref) else { continue }
    if let indices = typeToIndices[ref] {
      for i in indices where !resolved.contains(i) {
        queue.append(i)
      }
    }
  }
}

The queueIndex trick is worth calling out. Instead of queue.removeFirst() (which is O(n) on an Array because it shifts every element), I just increment an index into the growing array. The resolved Set<Int> handles deduplication. For a module with a few hundred declarations, the whole thing finishes in under a millisecond.

The 293-Line Hack

That frameworkTypes guard is hiding something. The BFS needs to know which types are “external” (provided by Apple frameworks, don’t need to be resolved from source) and which are “internal” (defined somewhere in your module). Without this distinction, the visitor would see Text in your view body, try to resolve it, fail to find a declaration, and either error out or pull in nothing.

My solution: a hardcoded Set<String> of known framework types. It’s 293 lines long. String, Int, Bool, View, Text, VStack, Color, ObservableObject, Published, Binding, NSObject, UIView, CGFloat, URL, Date, UUID, and every other type from Swift stdlib, SwiftUI, UIKit, Foundation, Combine, and Observation that I could think of.

This is, frankly, a hack. Any type not in that set gets treated as a project-internal type and triggers resolution. If Apple adds a new SwiftUI view in the next SDK, it’ll get flagged as an unresolved reference. The current list works well enough that I haven’t hit a false positive in real usage, but I’m not proud of it. The proper fix would be to query the SDK’s .swiftinterface files or use SourceKit for module-aware type resolution. That’s a much bigger project.

The Safety Net

There’s one more piece I want to mention because it’s the kind of thing that only matters when it matters. After BFS completes, the resolver does a second pass: for every file that contributed at least one resolved declaration, include any top-level non-type declarations from that file too. Global functions, computed properties, constants.

These can’t be reached through type references (nothing in the dependency graph points to let primaryColor = Color.blue), but if they’re in a file that your view’s code lives in, there’s a decent chance they’re used. The alternative is missing them and getting a build error, which defeats the purpose of an automated tool.

What Actually Changed

The rest of v2 is less interesting but worth summarizing. The Ruby xcodeproj gem was replaced by the Swift XcodeProj library, so project injection (adding the PreviewHost target, wiring up dependencies, creating a scheme, cleaning up afterward) all happens in the same process as everything else. Preview extraction uses a proper SwiftSyntax visitor instead of regex; about 20 lines of code that handle every edge case the brace counter couldn’t. SPM package previews generate a temporary Xcode project per-process in /tmp/preview-spm-<pid>/, so parallel agents in separate worktrees can’t collide.

The elimination of Ruby dropped the subprocess overhead from ~1.5-2.5 seconds per capture to effectively nothing. xcodebuild still takes however long it takes (3-4 seconds cached), but the tooling around it is no longer the bottleneck.

212 tests (156 unit, 56 e2e) cover the parsers, resolver, and project injector. The e2e tests exercise the full pipeline up to the point of calling xcodebuild (actually building requires a CI machine with Xcode and a simulator, which is its own headache).

What I’d Do Differently

The PascalCase heuristic works but it’s a blunt instrument. If I were starting over, I’d explore using SourceKit’s cursorInfo request to get actual type information for each reference. That would eliminate false positives from enum cases and false negatives from unconventionally named types. But SourceKit requires a built module to query against, which creates a chicken-and-egg problem: you need to know the dependencies to build, and you need to build to know the dependencies. There might be a way to use the partial module from an incremental build, but I haven’t explored it.

The frameworkTypes list should probably be auto-generated from SDK .swiftinterface files. Right now it’s hand-maintained and will silently go stale as Apple evolves their frameworks. I just haven’t been bitten by this yet, so it stays on the “someday” list.

Using It

Installation is through the Claude Code plugin marketplace:

/plugin marketplace add Iron-Ham/XcodePreviews
/plugin install preview-build@XcodePreviews

The /preview command works the same as before. Ruby is no longer a prerequisite.

The tool is open source on GitHub. If the declaration resolver chokes on your project’s type graph, I want to hear about it.