Skip to content

Instantly share code, notes, and snippets.

@senagbe
Created August 10, 2026 14:30
Show Gist options
  • Select an option

  • Save senagbe/e3cf0436115a08d4f8afadf49ba2dfc2 to your computer and use it in GitHub Desktop.

Select an option

Save senagbe/e3cf0436115a08d4f8afadf49ba2dfc2 to your computer and use it in GitHub Desktop.
sourcekit-lsp + Xcode projects: how buildServer.json works, why per-worktree isolation is automatic, and the one file you must never share

sourcekit-lsp with Xcode projects — and how it behaves across git worktrees

TL;DRsourcekit-lsp cannot index an .xcodeproj by itself. It can reuse the index Xcode already built, but only through a Build Server Protocol bridge. Set that up with two commands per checkout. The resulting buildServer.json must be gitignored, and must never be copied between worktrees — it hard-codes absolute paths to one checkout's index.

Verified on macOS with Xcode 26.x / Swift toolchain 2026-08-10, xcode-build-server 1.3.0.


The symptom that sends you here

documentSymbol works. findReferences returns zero results — and not an error:

textDocument/references on MyType.myMethod  →  0 locations
grep for the same name                      →  10 hits across 2 files

That silence is the dangerous part. The server advertises referencesProvider: true in its initialize response and then returns an empty list, so "LSP found no callers" reads like a fact rather than a missing configuration. If you are using LSP to decide whether something is safe to delete or rename, an unconfigured server will happily tell you it has no callers.

Treat an empty Swift reference result as unproven until you've done the setup below.


Why it happens

documentSymbol is syntactic — it parses the open file, so it needs no build context and works immediately. Everything cross-file (references, definition, implementation, call hierarchy) needs the compiler index, and sourcekit-lsp has to be told where that is.

It discovers build context exactly three ways:

Mechanism Applies to
Package.swift SwiftPM packages
compile_commands.json CMake and friends
buildServer.json anything with a Build Server Protocol server — this is the Xcode route

Xcode writes none of them. Xcode's own editor reaches SourceKit through private build-system knowledge; it has no reason to emit a public description of itself. So a plain .xcodeproj gives sourcekit-lsp nothing to work with.

Two things people reasonably try, and why neither helps:

  • Background indexing. Upstream is explicit: "Background Indexing is only supported for SwiftPM projects." sourcekit-lsp will not build its own index for an Xcode project.
  • Pointing it at Xcode's index directly. There is no such option. The index-related settings in sourcekit-lsp's configuration file are index.indexPrefixMap and index.updateIndexStoreTimeout — there is no indexStorePath.

Meanwhile the index you want already exists. Xcode maintains one per project at:

~/Library/Developer/Xcode/DerivedData/<Project>-<hash>/Index.noindex/DataStore

In a mid-sized app that's a real database — the one measured for this write-up was 208 MB across 21,060 files. Nothing is missing from your toolchain. There is simply no path from sourcekit-lsp to it, and the bridge is what you install.


Setup

brew install xcode-build-server

cd path/to/your/ios          # the directory containing MyApp.xcodeproj
xcode-build-server config -project MyApp.xcodeproj -scheme MyApp

For a workspace, use -workspace MyApp.xcworkspace -scheme MyApp.

That writes buildServer.json next to the project:

{
  "name": "xcode build server",
  "version": "1.3.0",
  "bspVersion": "2.2.0",
  "languages": ["c", "cpp", "objective-c", "objective-cpp", "swift"],
  "argv": ["/opt/homebrew/bin/xcode-build-server"],
  "workspace":  "/Users/you/code/myapp/ios/MyApp.xcodeproj/project.xcworkspace",
  "build_root": "/Users/you/Library/Developer/Xcode/DerivedData/MyApp-abc123def456",
  "scheme": "MyApp",
  "kind": "xcode"
}

build_root is the whole game. For kind: xcode, the server derives the index location from it:

# xcode-build-server, server.py
if self.config.kind == "xcode":
    return os.path.join(root, "Index.noindex/DataStore")

It needs a build first

config succeeds on a never-built checkout — it computes the DerivedData path from xcodebuild -showBuildSettings rather than finding an existing directory. But Xcode only writes the index during a build. So:

config, no build yet   →  references returns empty (not an error — same silent failure)
config + one build     →  references works

Build the scheme once in Xcode or via xcodebuild, then you're done.


Worktrees: the good news, then the trap

Isolation is automatic

Xcode derives the <Project>-<hash> directory name deterministically from the project's path on disk. Every git worktree is a different path, so every worktree already gets its own DerivedData directory and its own index — with no configuration on your part.

And xcode-build-server config reads the current checkout's xcodebuild -showBuildSettings, so running it inside a worktree can only ever resolve to that worktree's own index.

Observed, running the same command in two worktrees of one repo:

worktree A  →  build_root: .../DerivedData/MyApp-cxfjypfrxmcwjgaoxxjdfloozhkq
worktree B  →  build_root: .../DerivedData/MyApp-dacyoognpulijjamsqfqnojxefpz

Two checkouts, two indexes, no cross-talk. You do not have to manage this.

(A side effect worth knowing: DerivedData accumulates one directory per project path forever. Delete a worktree and its DerivedData stays behind. On one machine, 37 of 43 MyApp-* directories belonged to worktrees that no longer existed. At a couple of hundred MB each that adds up — worth an occasional sweep, and harmless to delete since they rebuild.)

⛔ The trap: never share buildServer.json

Look at that file again — workspace and build_root are absolute paths into one specific checkout. So:

1. Gitignore it. Do not commit it.

# sourcekit-lsp build-server config — contains absolute, machine- and worktree-specific paths
buildServer.json

Committed, every clone and every worktree inherits one machine's paths. On a colleague's machine those paths don't exist; on yours, they point at the wrong worktree's index — so LSP answers questions about a branch you aren't looking at, confidently and silently.

2. If you have a mechanism that copies gitignored dev files into new worktrees — exclude this one.

This is the part that bites, because it's a reasonable mistake. Many teams keep a manifest of gitignored-but-required files (.env.local, signing configs, secrets) that gets copied into each new worktree. buildServer.json is gitignored and required, so it looks like it belongs in that list. It does not. Copying it defeats the per-worktree isolation you get for free and pins every worktree to the source worktree's index.

Put the warning where someone will actually meet it — in the manifest itself, not only in a README:

# ⛔ DO NOT ADD buildServer.json HERE.
# It is gitignored, so it looks like it belongs — it does not. It holds absolute paths to the
# SOURCE worktree's DerivedData. Copying it makes new worktrees resolve symbols against another
# tree's index. Each worktree must generate its own:
#     cd ios && xcode-build-server config -project MyApp.xcodeproj -scheme MyApp

3. Re-run config when the build setup changes — a different scheme, or after deleting DerivedData. A stale config fails the same silent way: empty results, no error.


Verify it, don't assume it

Because the failure mode is an empty result rather than an error, config-inspection proves nothing. Ask the server a question you already know the answer to.

Pick a symbol with known cross-file callers, get grep's count first, then compare:

grep -rn 'myMethod' --include='*.swift' . | wc -l

Then drive the LSP directly (no editor involved):

#!/usr/bin/env python3
"""Ask sourcekit-lsp for references and print where they landed."""
import json, os, shutil, subprocess, threading, time

ROOT = os.path.abspath("ios")                     # dir containing buildServer.json
FILE = os.path.abspath("ios/MyApp/MyType.swift")  # file declaring the symbol
LINE, SYMBOL = 161, "myMethod"                    # 1-based line, as shown in your editor

char = open(FILE).read().split("\n")[LINE - 1].index(SYMBOL)
p = subprocess.Popen([shutil.which("sourcekit-lsp")],
                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)

def send(msg):
    b = json.dumps(msg).encode()
    p.stdin.write(b"Content-Length: %d\r\n\r\n" % len(b) + b)
    p.stdin.flush()

got = {}
def read(want, timeout):
    def loop():
        while True:
            hdr = b""
            while b"\r\n\r\n" not in hdr:
                c = p.stdout.read(1)
                if not c:
                    return
                hdr += c
            n = int([l for l in hdr.decode().split("\r\n")
                     if l.lower().startswith("content-length")][0].split(":")[1])
            msg = json.loads(p.stdout.read(n).decode())
            if msg.get("id") == want:      # skip notifications; wait for OUR reply
                got[want] = msg
                return
    t = threading.Thread(target=loop, daemon=True)
    t.start()
    t.join(timeout)

send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
    "processId": None, "rootUri": "file://" + ROOT, "capabilities": {},
    "workspaceFolders": [{"uri": "file://" + ROOT, "name": "app"}]}})
read(1, 90)
send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
send({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {"textDocument": {
    "uri": "file://" + FILE, "languageId": "swift", "version": 1, "text": open(FILE).read()}}})
time.sleep(6)   # let the build server hand over settings

send({"jsonrpc": "2.0", "id": 2, "method": "textDocument/references", "params": {
    "textDocument": {"uri": "file://" + FILE},
    "position": {"line": LINE - 1, "character": char},
    "context": {"includeDeclaration": True}}})
t0 = time.time()
read(2, 120)

locs = (got.get(2) or {}).get("result") or []
print(f"{len(locs)} location(s) in {time.time() - t0:.1f}s")
by_file = {}
for l in locs:
    by_file[l["uri"].replace("file://" + ROOT + "/", "")] = \
        by_file.get(l["uri"].replace("file://" + ROOT + "/", ""), 0) + 1
for f, n in sorted(by_file.items()):
    print(f"  {n:>2}x  {f}")
print("cross-file OK" if len(by_file) > 1 else "single-file only — index not in use")
p.kill()

Measured before and after on a real project:

before config:  0 locations
after config:  10 locations across 2 files, 2.0s
                1x  MyApp/Features/.../MyTypeView.swift
                9x  MyAppTests/.../MyTypeTests.swift

One gotcha if you write your own probe: the server sends window/logMessage notifications before it answers. Read the first message off the wire and you'll mistake a log line for the initialize result and conclude it worked when it hasn't. Loop until you see your request's id — that's what the read(want, …) helper above is doing.


Summary

sourcekit-lsp indexing an .xcodeproj itself ❌ not supported — SwiftPM only
Pointing it at Xcode's index directly ❌ no such option
Reusing Xcode's index via BSP xcode-build-server
Per-worktree index isolation ✅ automatic — hash is derived from project path
buildServer.json in git ⛔ never — absolute paths
buildServer.json copied into new worktrees ⛔ never — generate per worktree
Works before a build ❌ config succeeds, references return empty
Failure mode ⚠️ silent — empty results, not errors

The one thing to carry away: an empty Swift reference result is not evidence of no callers. Until you've run the setup and built once, it means nothing at all — and it looks exactly like an answer.


References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment