Skip to content

Syntax highlighting silently fails (and Bundle.module crashes) in SwiftPM CLI-built app bundles #95

Description

@sohei56

Summary

When CodeEditLanguages is consumed by an app that is built and packaged
with swift build (plus a hand-rolled bundle-assembly step) rather than
Xcode
, two resource-resolution problems surface:

  1. Distributed app crashes on the first highlight lookup, because the
    SwiftPM-generated Bundle.module accessor cannot locate the resource
    bundle inside a .app and calls fatalError.
  2. Even with the bundle located, every tree-sitter query file fails to
    load
    because CodeLanguage.queryURL(for:) hardcodes a path layout that
    only matches the Xcode-produced bundle. The editor renders as plain gray
    text with no error.

Both stem from the same assumption — that the package is always built and
embedded by Xcode. Dev builds on the build machine work, which is why this is
easy to miss.

Environment

  • Package: CodeEditLanguages 0.1.20 (pulled transitively via
    CodeEditSourceEditor 0.15.2).
  • Build: swift build from the command line. No .xcodeproj. The app is
    assembled into a .app by a custom script that copies the SwiftPM resource
    bundle (CodeEditLanguages_CodeEditLanguages.bundle) into
    MyApp.app/Contents/Resources/.
  • macOS, Apple Silicon, current Swift toolchain.

Defect 1 — Bundle.module fatalErrors in a distributed .app

Root cause

The SwiftPM-generated accessor
(.build/…/CodeEditLanguages.build/DerivedSources/resource_bundle_accessor.swift)
probes exactly two locations:

let mainPath = Bundle.main.bundleURL.appendingPathComponent("CodeEditLanguages_CodeEditLanguages.bundle").path
let buildPath = "/Users/<build-machine>/…/.build/arm64-apple-macosx/debug/CodeEditLanguages_CodeEditLanguages.bundle"

let preferredBundle = Bundle(path: mainPath)
guard let bundle = preferredBundle ?? Bundle(path: buildPath) else {
    Swift.fatalError("could not load resource bundle: from \(mainPath) or \(buildPath)")
}
  • mainPath uses Bundle.main.**bundleURL** — i.e. the .app wrapper root,
    not Contents/Resources. A correctly-formed .app keeps resources in
    Contents/Resources, so this candidate never matches.
  • buildPath is a hardcoded absolute path on the build machine. It
    resolves during local development, but obviously not on any other machine.

Result on an end user's machine: neither candidate exists →
Swift.fatalError → the app crashes the first time a CodeLanguage's
resourceURL / query is accessed.

Bundle.module (CodeLanguage.resourceURL) is used here:

// CodeLanguage.swift
internal var resourceURL: URL? = Bundle.module.resourceURL

Repro

  1. Depend on CodeEditLanguages (directly or via CodeEditSourceEditor).
  2. swift build (no Xcode project).
  3. Assemble a .app and copy CodeEditLanguages_CodeEditLanguages.bundle
    into Contents/Resources/.
  4. Run the .app. First access to a language's queryURL → crash at the
    accessor's fatalError.
  • Expected: the bundle is found under Contents/Resources; no crash.
  • Actual: fatalError("could not load resource bundle …").

Defect 2 — query path assumes the Xcode bundle layout

Root cause

// CodeLanguage.swift
internal func queryURL(for highlights: String = "highlights") -> URL? {
    return resourceURL?
        .appendingPathComponent("Resources/tree-sitter-\(tsName)/\(highlights).scm")
}

The "Resources/" segment is unconditionally prepended. That matches the
directory nesting Xcode produces, but under the SwiftPM command-line build the
grammar files do not live at that relative path from the resolved
resourceURL, so queryURL points at a file that does not exist. The lookup
returns a URL that never resolves, no error is thrown, and highlighting
silently degrades to plain text.

Repro

  1. With Defect 1 worked around (bundle located), open any source file in an
    editor backed by CodeEditSourceEditor.
  2. Observe: no syntax colors; text is uniform gray.
  3. Log CodeLanguage.swift.queryURL and FileManager.fileExists on the
    returned path — the file is not there.
  • Expected: the .scm query loads; tokens are highlighted.
  • Actual: queryURL resolves to a non-existent path; queries silently
    fail.

Proposed fix

Both are small and, importantly, change nothing for Xcode consumers — the
new code paths only engage when the current lookup fails.

  1. Bundle resolution — before falling back to Bundle.module, probe
    Bundle.main.resourceURL (i.e. Contents/Resources), where a
    CLI-assembled app copies the SwiftPM resource bundle:

    private static let queryBundleResourceURL: URL? = {
        if let bundled = Bundle.main.resourceURL?
            .appendingPathComponent("CodeEditLanguages_CodeEditLanguages.bundle"),
           let bundle = Bundle(url: bundled) {
            return bundle.resourceURL
        }
        return Bundle.module.resourceURL
    }()
    
    internal var resourceURL: URL? = CodeLanguage.queryBundleResourceURL
  2. Layout-aware query path — resolve against whichever layout exists on
    disk, probing the nested (Resources/…) layout first so Xcode behavior is
    byte-for-byte unchanged, then the flat layout:

    guard let base = resourceURL else { return nil }
    let relativePath = "tree-sitter-\(tsName)/\(highlights).scm"
    let nested = base.appendingPathComponent("Resources/\(relativePath)")
    if FileManager.default.fileExists(atPath: nested.path) {
        return nested
    }
    return base.appendingPathComponent(relativePath)

Offer to contribute

We are already running these two changes as a local patch against 0.1.20 and
they resolve both issues with no observed regression for Xcode-built consumers.
Per the CodeEdit contribution convention (open an issue first, ask to be
assigned), we'd be glad to submit the PR — could you assign this to us? The
patch passes SwiftLint and keeps the existing code paths as the primary
lookup. Happy to adjust the approach if you'd prefer a different shape.

Thanks for maintaining CodeEditLanguages.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions