skip to content

If you were implementing a BSP server for Gradle, how would you architect it on top of the Tooling API, and what are the main challenges?

level: seniorimportance: nice to knowfreq 12%

answer

  1. lsp4j JSON-RPC + Tooling API adapter
  2. custom BuildAction = one config pass
  3. (subproject x source set) -> build target
  4. BuildLauncher/TestLauncher for exec
  5. warm daemon, cache model, push didChange

basics

~20 s

Run a JSON-RPC server that maps BSP requests to Tooling API calls: a custom model for structure, BuildLauncher for compile/test. Cache the configured model, map source sets to build targets, and stream Gradle progress to BSP notifications.

solid answer

~50 s

A Gradle BSP server is an **adapter**: a long-lived process speaking BSP JSON-RPC on one side and driving the **Tooling API** on the other. - **Transport/RPC**: use a JSON-RPC library (e.g. lsp4j, which also backs BSP) to handle `build/initialize`, `workspace/buildTargets`, `buildTarget/compile`, etc. - **Structure**: implement a **custom Tooling API model** (an `Action` via `BuildController`) so a single configuration pass yields all subprojects, source sets, dependencies, and classpaths; cache that model. Map each **source set of each subproject** to one **build target** with a stable URI id and the right `canCompile/canTest/canRun` flags. - **Execution**: route `buildTarget/compile`/`test` to `BuildLauncher`/`TestLauncher`, mapping the target id back to Gradle task paths. - **Progress/diagnostics**: subscribe to Tooling API progress and compiler output, translating to `build/taskStart`/`taskProgress`/`taskFinish` and `build/publishDiagnostics`. Key challenges: keeping the cached model **fresh** (emit `buildTarget/didChange` on script edits), reusing a warm **Gradle daemon** for latency, mapping Gradle's source-set/variant model onto BSP's flatter target model, and handling configuration failures gracefully so a broken script doesn't kill the session.

code

kotlin · 11 lines
kotlin
// One configuration pass yields all per-project models
val models = connection.action(BuildAction { controller ->
    controller.buildModel.projects.map { p ->
        controller.getModel(p, MyProjectModel::class.java)
    }
}).run()

// Execute a target's tests through the Tooling API
connection.newTestLauncher()
    .withJvmTestClasses("com.example.FooTest")
    .run()  // -> stream events to build/taskProgress, build/publishDiagnostics

go deeper

for a junior

Recognize that such a server translates IDE requests into Gradle calls; deep design isn't expected.

for a middle

Sketch the adapter idea: JSON-RPC in, Tooling API out, source sets become build targets.

for a senior

Design the layers, choose a custom BuildAction for one-pass model loading, map targets, handle execution and progress, and name the latency/freshness/impedance challenges.

for a principal

Weigh build/maintain a server vs adopt an existing one, plan daemon/config-cache strategy and protocol-version support across editors, and govern the model-mapping conventions.

## Goal Expose Gradle's project model and execution to any BSP client. The server is stateless to the client conceptually but stateful internally (it caches the model and keeps a Gradle daemon warm). ## Layered architecture ### 1. RPC/transport layer Use **lsp4j** (the same library underpinning many LSP/BSP servers) to bind JSON-RPC methods to handler functions and manage the request lifecycle. It gives you typed `build/initialize`, capability objects, and notification dispatch for free. ### 2. Model layer (sync) The naive approach calls many Tooling API queries; better is a **custom model** via a `BuildAction`/`BuildController` so **one configuration pass** returns everything: ```java connection.action(new MyModelAction()).run(); // BuildController fetches per-project models ``` From that model you derive **build targets**. The natural mapping is **(subproject x source set) -> build target**: `:app` main, `:app` test, `:lib` main, etc. Each target gets a stable `BuildTargetIdentifier` URI, language ids, dependency edges (from project + external deps), and capability flags. `buildTarget/javacOptions` returns each target's resolved classpath and output dir from the model. ### 3. Execution layer `buildTarget/compile` -> `BuildLauncher.forTasks(...)`; `buildTarget/test` -> `TestLauncher` (which can select specific tests). Map the requested target id back to concrete Gradle task paths (`:app:compileJava`, `:app:test`). Stream stdout/stderr and progress events back as notifications, and surface compiler errors via `build/publishDiagnostics`. ### 4. Freshness / invalidation Watch build scripts and inputs; when they change, either invalidate the cached model and push `buildTarget/didChange`, or recompute lazily on the next `workspace/buildTargets`. This is what makes BSP feel live versus the static idea/eclipse files. ## The hard parts - **Configuration cost & daemon reuse**: configuration can be slow. Keep the **Gradle daemon** warm and cache the model; consider the configuration cache. Cold sync latency is the top UX risk. - **Model impedance mismatch**: Gradle's model is rich — **variants**, source sets, multiple JVM targets, Kotlin/Android. BSP targets are flatter, so you must decide granularity (per source set is the common choice) and encode extras in `data`/`dataKind`. - **Incremental correctness**: emitting `didChange` for exactly the affected targets (not a full reimport) is fiddly but key to responsiveness. - **Robustness**: a broken `build.gradle` must produce a clean error to the client, not crash the server or hang the daemon. - **Capability negotiation**: advertise only what you actually implement (e.g. `testProvider`, `dependencySourcesProvider`) so clients don't issue unsupported requests. ## Trade-off summary | Concern | Choice | |---|---| | One config pass | Custom BuildAction + BuildController | | Target granularity | per subproject source set | | Latency | warm daemon + cached model | | Liveness | didChange on input changes | | Test selection | TestLauncher |

  • Why use a custom BuildAction instead of many separate getModel calls?
    A BuildAction runs inside the build with a BuildController, so a single configuration pass can fetch a model from every project. Separate getModel calls can trigger repeated configuration and are far slower.
  • How would you keep sync latency low?
    Reuse a warm Gradle daemon, cache the configured model between requests, enable the configuration cache, and only recompute/emit didChange for targets whose inputs actually changed.
  • What is the natural mapping from Gradle to a BSP build target?
    Each source set of each subproject becomes a build target (e.g. :app main, :app test), with its resolved classpath, output dir, languages, dependency edges, and capability flags.

saying these in an interview costs you the question

  • Proposing a fresh Gradle invocation per BSP request (kills latency; ignores daemon/model caching).
  • Mapping a whole subproject to a single target and losing the main/test distinction.
  • Crashing the server on a configuration error instead of returning a diagnostic.

context