TL;DR — sourcekit-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.
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.
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.indexPrefixMapandindex.updateIndexStoreTimeout— there is noindexStorePath.
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.
brew install xcode-build-server
cd path/to/your/ios # the directory containing MyApp.xcodeproj
xcode-build-server config -project MyApp.xcodeproj -scheme MyAppFor 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")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.
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.)
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.jsonCommitted, 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.
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 -lThen 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.
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 |
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.
- sourcekit-lsp — see
Documentation/Configuration File.mdandDocumentation/Enable Experimental Background Indexing.md - xcode-build-server