Fleet logo

Fleet

More Than a Code Editor

Custom Fleet Plugins for Your Kotlin Codebase

Fleet is a code editor designed from scratch to be an extendable platform. Many pieces of Fleet’s functionality are implemented as plugins. While the Fleet team is working toward the Fleet Plugins Public Preview, we decided to share some ideas, details, and examples of why and how external developers are supposed to develop their own plugins for Fleet. This blog post is based on the material from the lightning talk at KotlinConf 2024.

Fleet and Kotlin: Available functionality and ideas for customization

Fleet can be used to work with codebases in Kotlin right from the outset. In Smart Mode, you can employ the power of the IntelliJ backend. On top of Fleet, you can rely on the built-in Kotlin Multiplatform tooling or build your projects with Amper. Although all this functionality is already available out of the box, some customizations might also be needed. 

For example, your project might employ custom resources. A custom view for those resources might be a helpful feature. If your project relies on external tools, it might be tempting to integrate them into Fleet. If you care about codebase quality, you might enjoy having quick access to source code metrics such as the total size of your codebase, cyclomatic complexity, metrics regarding code abstraction complexity, or relevance of code comments to the code itself. Anything like that might be implemented as a custom plugin for Fleet if unavailable elsewhere.

In this blog post, we’ll use a running example of a plugin that counts all the top-level functions declared in a source file and reports them via a custom notification:

Note that changes in the source file are propagated to the notification description immediately. Despite being relatively simple (its implementation takes only a hundred lines of code), this plugin helps us see several crucial ideas behind the Fleet platform. 

Fleet architecture for plugin developers

To support remote development and collaboration, Fleet has a distributed architecture, clearly separating such components as:

  • Frontends – the user-facing parts of any Fleet application.
  • Workspace – the part responsible for registering participating components and maintaining a shared state between all of them.
  • Backends – headless services responsible for implementing Smart Mode features (such as static analysis, advanced search, code navigation, and more). 

The same structure is reflected in Fleet plugins:

Fleet plugins typically implement frontend and workspace parts, communicating with backend components. The backend’s implementation depends on the chosen service. For example, when working with Kotlin, the backend part of a Fleet plugin should be an IntelliJ Platform plugin.

Fleet itself is implemented in Kotlin, and plugin developers should write their plugin code in Kotlin. Coding for Fleet therefore requires a good working knowledge of Kotlin and its more advanced concepts, such as DSL (domain-specific language) development or structured concurrency with coroutines. 

To write efficient code for Fleet, developers have to keep the following two key principles in mind:

1: Fleet is a transactional distributed database with reactive queries (see the other blog post for more internal details).

2: Fleet embraces coroutines and structured concurrency.

Every change in the UI is technically a transaction over the Fleet state database. Every coroutine launched by a plugin is controlled by Fleet and canceled automatically if the plugin is unloaded. Plugins themselves are also a part of Fleet’s state and are managed according to the same rules as everything else in Fleet. Both loading and unloading a plugin are transactions. Let’s see how these principles affect plugins’ source code.

Implementing a custom plugin: Counting functions in a Kotlin file

The Count Functions plugin defines only the frontend part: It contributes an action that displays a notification with the number of top-level functions in a Kotlin source file. The full source code of the plugin is available on GitHub.

All the plugin’s code comes from a single Kotlin file and has the following structure:

We provide the main plugin’s class FunCounter, which is loaded via the JVM’s ServiceLoader, as well as several functions that perform our desired task.

Gradle configuration

Fleet plugins are developed using Gradle. The main Gradle configuration file specifies the plugin’s most important details:

version = "0.1.0"

fleetPlugin {
    id = "pro.bravit.fleetPlugin.funCounter"
    metadata {
        readableName = "Count Functions"
        description = "This plugin contributes an action..."
    }
   fleetRuntime {
       version = "1.35.115"
   }
   pluginDependencies {
       plugin("fleet.kotlin")
   }
}

While we’re targeting a specific Fleet runtime, it’s also possible to set a range of supported versions. Additionally, we’re declaring dependencies on other Fleet plugins. The fleet.kotlin plugin gives us access to packages providing classes and methods to work with an abstract syntax tree (AST) of a Kotlin source file.

A plugin class

The FunCounter class is an entry point to a frontend part of the plugin. It declares several components required for Fleet plugin bookkeeping and also loads all the contributed functionality.

typealias FunCounterAPI = Unit
class FunCounter : Plugin<FunCounterAPI> {

   companion object : Plugin.Key<FunCounterAPI>
   override val key: Plugin.Key<FunCounterAPI> = FunCounter

   override fun ContributionScope.load(pluginScope: PluginScope) {
       notificationCategory(countFunctionsNotification)
       actions {
           setupCountFunctionsAction(pluginScope)
       }
   }
}

In this example, we’re contributing a notification category and an action. Our plugin doesn’t provide any APIs, so we’ll use Unit as a generic parameter for the Plugin interface. This class is mentioned in the module-info.java as the one providing a plugin implementation:

module pro.bravit.fleetPlugin.funCounter {
   // module requirements
   // exports
   provides fleet.kernel.plugins.Plugin with pro.bravit.fleetPlugin.funCounter.FunCounter;
}

Note the pluginScope argument of the load method: It’s a coroutine scope used by Fleet to control all the coroutines launched by a plugin. If a plugin is unloaded, then all of its coroutines are canceled.

In the rest of this article, we’ll look at the implementation of the contributed functionality. 

Managing notifications

Managing notifications in Fleet usually involves the following two components:

  • We define a notification category so that Fleet can manage all the notifications coming from a plugin.
  • We make Fleet show a notification whenever needed.

Apart from that, we’ll also make it update a notification whenever new information becomes available. Although notifications are not the best place to display information that changes with time, these ones are simple enough to serve as an example here.

The notification category is a simple data class value:

val countFunctionsNotification = NotificationCategory(
   id = NotificationCategoryId("CountFunctions"),
   readableName = "Count Functions"
)

Creating a notification is a bit more complicated: 

private suspend fun createCountFunctionsNotification(editor: EditorEntity): NotificationEntity {
   val fileName = editor.layout?.ownerTab()?.displayName() ?: "Unknown file"
   val title = "Function counter ($fileName)"
   val description = "Number of top-level functions: ?"
   return change {
       val notification = showNotification(
           countFunctionsNotification,
           title, NotificationIcon.Info, description,
           isSticky = true
       )
       cascadeDelete(editor, notification)
       notification
   }
}

The code above demonstrates several important features of Fleet’s plugin machinery:

  • The Entity suffix found in EditorEntity and NotificationEntity reminds us that we’re working with database entities. These entities are represented by Kotlin values and are managed by Fleet. They can be created, looked up, and updated as needed. Entities from Fleet itself provide us with information about what’s going on in the Fleet instance (for example, we get the name of the loaded file via editor.layout?.ownerTab()?.displayName())
  • Changes in the database are executed in transactions. We introduce transactions with change-blocks.
  • Note the cascadeDelete call: With it, we can establish relations between entities in the database. In this case, we ask Fleet to delete a freshly created notification when the corresponding editor is deleted.
  • The showNotification function comes from the Fleet Notification API. It creates and displays a notification and then returns a created notification entity so that we can manage it later.

In many cases, Fleet plugin code follows the same pattern, manipulating database entities. Entities are created and updated (in transactions), or we just use the information we’ve extracted from them.  

To update the notification, we need to execute another transaction:

private suspend fun updateCountFunctionsNotification(
					notification: NotificationEntity, 
					numberOfFunctions: Int) {
   val description = "Number of top-level functions: $numberOfFunctions"
   change {
       notification.description = description
   }
}

Note that both functions above are suspend functions. We have to run them from Kotlin coroutines. We’ll get back to this shortly.

Declaring actions

The main functionality this plugin provides is the Count Functions action. Fleet actions have the following life cycle:

  • They are registered in the ContributionScope.load plugin’s method.
  • Fleet shows available actions in the Actions list. Depending on the way an action is defined, it can be missing from that list (if the action’s static prerequisites are not met) or it can be grayed out (if the action’s dynamic prerequisites are not met).
  • Fleet executes an action whenever the corresponding action is triggered by a user. In most cases, actions are executed by launching a Kotlin coroutine that then provides the required functionality. 

The Fleet Action API provides a way to declare actions. It starts with the actions block containing action definitions. Now, let’s look at setting up the Count Functions action:

private fun ActionRegistrar.setupCountFunctionsAction(pluginScope: PluginScope) {
   action(id = "Count-Functions", name = "Count Functions") {
       val requiredEditor = required(FleetDataKeys.LastEditorEntity)
       dynamic {
           val editor = requiredEditor.value
           if (editor.document.mediaType == MediaType("text", "kotlin")) {
               callback {
                   pluginScope.launch {
                       performCountFunctionsAction(editor)
                   }
               }
           }
       }
   }
}

The static requirement for this action is the availability of an editor: If there’s no focused editor, the Count Functions action makes no sense. We specify this requirement by requesting FleetDataKeys.LastEditorEntity in the first line of the action block. Then, we use the dynamic block to specify the dynamic requirement. The document loaded in the editor must contain Kotlin source code. If this requirement is met, we provide a callback block to define code that is executed whenever the action is triggered. As explained above, the execution of the action starts with launching a coroutine in the plugin’s coroutine scope. 

The Fleet Action API and many other Fleet APIs apply Kotlin DSL builders to describe functionality contributed to Fleet. In this example, we’ve used several Action DSL components, including action, dynamic, and callback, to introduce an action definition, specify its dynamic requirements, and provide an implementation, respectively. 

Performing actions with reactive queries

The central piece of the Count Functions plugin implementation is propagating changes in the source code’s AST to the notification’s description:

The corresponding code comes in the performCountFunctionsAction function:

private suspend fun performCountFunctionsAction(editor: EditorEntity) {
   withEntities(editor) {
       val notification = createCountFunctionsNotification(editor)
       withEntities(notification) {
           query {
               editor.document.syntaxes
                  ?.firstNotNullOfOrNull(ASTContainer::getDataAsync)
           }.collectLatest { ast ->
               val numberOfFunctions = countFunctions(ast?.await())
               updateCountFunctionsNotification(notification, numberOfFunctions)
           }
       }
   }
}

Note the following important parts of this function:

  • We use the withEntities function to introduce suspend blocks that depend on the existence of the referenced entities. If the corresponding entity does not exist anymore, the coroutine is canceled. This approach greatly simplifies implementation, as we have a guarantee that these entities exist in these blocks so that we can safely work with them.
  • The query {} block specifies a subscription request to the Fleet database regarding all the changes occurring to the mentioned entities. These requests are the cornerstone of the Fleet Query API: We can subscribe to changes in the database in order to react to them in a timely manner.
  • Queries give us something that closely resembles Kotlin’s asynchronous flows. We collect the flow’s elements and process them as needed. In this case, the flow’s element comes as a Kotlin’s Deferred AST value. We’ll then wait for this value to get an up-to-date AST to count all the top-level functions in it.
  • We don’t need to think about the action’s completion. It will be active until it’s canceled as a result of deleting the entities referenced in withEntities or unloading the plugin itself. 

Working with a Kotlin source code

The final piece of functionality is the countFunctions function. It represents a small exercise in navigating through Kotlin’s source code AST:

private fun countFunctions(tree: AST<*>?): Int =
   tree?.root()
       ?.children()
       ?.count {
           it.type.toString() == "FUN"
       } ?: 0

To use the corresponding types and methods, we had to introduce a dependency to the fleet.kotlin plugin earlier. To simplify the implementation, we return 0 for every unexpected value in AST, without thinking too much about error handling. 

Conclusion

Fleet plugin APIs and the corresponding tooling are a work in progress. This blog post offers a sneak peek at how plugin developers are supposed to develop their own plugins for Fleet. The whole plugin development experience is based on two principles: (1) Fleet is a distributed database, and (2) Fleet embraces structured concurrency with coroutines. To make it easier to contribute custom functionality, Fleet provides a set of APIs for database entity and transaction management, actions, notifications, and many other things. Stay tuned for the Fleet Plugins Public Preview to develop your own plugins for Fleet!

image description

Discover more