CodeAssist

Architecture

An extensible, on-device IDE framework for Android/Java development. The framework models projects abstractly enough to support multiple build systems and multiple language backends behind stable interfaces, owns its own project structure, and runs entirely on device — where a full Gradle runtime is impractical.

Design goals

Layering

Modules have one-directional dependencies. Platform modules expose stable API plus minimal implementation; domain-specific behavior ships as plugins against published extension points — including the bundled Android support and build systems.

            UI layer (editors, project view, build console)
                              ▲
            Plugins: android-support, language backends, build systems
                              ▲  (implement extension points)
            Platform SPI: project-model, build-api, language-api, deps-api,
                          vfs-api, extension framework, analysis-api
                              ▲
            Platform core: extension registry, VFS, model store,
                           task scheduler, read/write lock, message bus

See modules.md for the concrete module map and responsibilities.

The project model

The project model is the spine of the framework: the build system, language backends, indexing, and navigation all read from it. It is abstract, extensible via facets, observable through a message bus, and mutated only through transactions.

Containment hierarchy

Workspace                         the set of open projects; orchestrates linked builds
 └── Project                      a buildable unit bound to ONE build system
      ├── buildSystem: BuildSystemId   (native | gradle-compat | …)
      ├── Module                  unit of compilation (a "subproject")
      │    ├── type: ModuleType   (android-app | android-lib | java-lib | java-cli | …)
      │    ├── Facet              domain config (AndroidFacet, JavaFacet, …)
      │    ├── SourceSet          (main, test, debug, release, …)
      │    │    └── ContentRoot   (dir + roles: source/resource/android-res/aidl/assets/…)
      │    ├── dependencies: OrderEntry   (module | library | sdk)
      │    └── languageLevel, outputs, …
      └── variants, settings
 ├── LibraryTable (workspace-scoped, interned shared libraries)
 └── SdkTable     (installed Android/Java SDKs)

ModuleType is itself an extension point, so new module types (for example, a Kotlin library) are registered by a plugin rather than added to the core.

Dependencies and classpath assembly

Dependencies are an ordered list of order entries per module, each carrying a DependencyScope that mirrors Gradle/Maven (API, IMPLEMENTATION, COMPILE_ONLY, RUNTIME_ONLY, TEST_IMPLEMENTATION). An exported flag reproduces Gradle’s api vs implementation distinction: an exported entry is visible to downstream modules’ compile classpaths; a non-exported one is not.

Module.classpath(scope, transitive) walks the order entries and produces a deduplicated, ordered, content-hashed ClasspathSnapshot:

  1. Start with the module’s own direct entries matching the requested scope.
  2. For each module dependency, include its outputs and recurse into its exported entries only.
  3. A COMPILE_ONLY SDK dependency (such as android.jar) is placed on the compile classpath and filtered out of the packaged/runtime classpath.
  4. Resolve version conflicts across the assembled set: because each module is resolved independently and a library dependency carries its whole transitive closure, two modules can contribute different versions of the same Maven artifact. The snapshot keeps the newest version of each artifact (Gradle’s “newest wins”), keyed off the resolver’s Maven-layout cache path. This prevents two copies of a class on the compile path and, critically, two dex archives of the same type colliding in the Android dex merge.

Because classpath correctness lives in the model, build systems and language backends simply consume a ClasspathSnapshot — they never re-derive dependency semantics. The snapshot’s content hash is a build-cache key and a language-backend cache key, so a classpath change invalidates both compilation and editor analysis.

Mutation

Readers see an immutable, consistent snapshot. Structural changes go through a modifiable model: stage edits, then commit atomically under the write lock.

val tx = project.beginModification()           // requires write lock
val mod = tx.addModule("feature-login", AndroidLibModuleType)
mod.addDependency(ModuleDependency("core", scope = API, exported = true))
tx.commit()                                     // atomic; publishes a new snapshot + events

On commit, the store swaps in a new snapshot and publishes typed events (ModuleAdded, DependenciesChanged, …) on the message bus. A build that started before the commit keeps using its captured snapshot until it finishes.

On-disk format (native mode)

In native mode the IDE owns the project structure, so it persists a declarative manifest per module (module.toml) plus workspace-level tables. The format is human-diffable, fast to parse, and not an executable script — there is no script interpreter on the sync path. Derived state (build cache, indices, resolved dependencies) lives under a disposable caches/ directory. Writes go to a temp file and are atomically renamed, so a model file is never left half-written.

Loading is fault-isolating rather than all-or-nothing, because the files can be damaged from outside the IDE: on-device projects live in browsable external app storage, so a module directory can be deleted or moved while workspace.json still lists it. A module whose module.toml is missing or unparsable is left out of the model and reported, and so is an unreadable project entry; the derived workspace tables (libraries.json, sdks.json) fall back to empty and are rebuilt. Nothing damaged is rewritten, so a file that failed to parse stays on disk exactly as it is and can be repaired by hand. Only a missing or unparsable workspace.json, or a schema version from a newer build, fails the open outright.

In gradle-compat mode the truth lives in the Gradle files; the framework holds only a derived model snapshot and caches.

The two graphs

Two dependency graphs are kept distinct so that linked projects with their own build systems can be built in parallel or in sequence.

A workspace build request is planned in nested layers: resolve the target to the set of projects needed, order the project graph into levels, build each level’s projects (each delegating to its own build system), and within a project run the task graph over the module graph. Parallelism happens at both levels, bounded by device-aware limits. A CoordinationPolicy (PARALLEL_BY_LEVEL / STRICTLY_SEQUENTIAL / MANUAL_ORDER) selects the strategy.

The build-system abstraction

The design separates three concerns that a typical build tool bundles together:

The task engine is the durable idea: a build is a DAG of tasks with typed inputs/outputs, up-to-date checking by fingerprint, and a persistent cache. Editing one file re-runs only the affected subgraph. See build-system.md for the engine and the native pipelines.

Concurrency model

A single process edits files, parses them, mutates the model, and builds — concurrently — so a threading discipline keeps the model and ASTs consistent:

The ordering rule that ties it together: mutate under the write lock, publish an event, and let consumers invalidate or recompute under a read action on a background activity.

Extension points

Everything domain-specific plugs in through a minimal extension-point registry. Module types, build systems, language backends, analyzers, quick-fixes, file icons, project templates, and block mappings are all contributed this way. See extension-points.md.