It runs on my machine 01

Introduction

I've been using LLMs for code assistance, in the form of completion/copilot suggestions or through a CLI like Claude Code, for some time now, and while I've got a running list of things I like and do not like, I can see value in the idea of LLM-assisted code writing.

Some things keep nagging at me:

  • the tools do not work on a plane i.e., when there is no internet connection or when I'm not otherwise connected
  • I've got a powerful laptop: why do I have to use it like a terminal in the original sense of the word? The compute power is not local to my machine, it's in a server somewhere I have to connect to, and all my RAM and CPU/GPU is wasted
  • I have only a superficial understanding of how these tools work in the context of the IDE I use for work all-day long

The last point, in particular, triggers me into wanting to build my own copilot plugin for my IDE of choice: an IntelliJ one and, more specifically, PHPStorm.

Previous attempts and art

I've tried to switch away from PHPStorm to other Visual-Studio-Code-like forks and variations, but the muscle memory, the tooling and the code inspection capabilities are not where I would like them to be.
I've tried setting up the VS-Code-likes using all the recommended plugins and tools (including licensed ones), I've tried other IDEs as well (e.g. Zed or neovim) and all of them are great.
But they are not great for me, my idea of IDE perfection that I get out of the box with PHPStorm always another plugin/extension away.

The one thing that really surprised me is the llama.vim plugins for vim/neovim.

With a relatively small model (Qwen2.5-Coder-3B) running on my laptop, it provides excellent suggestion with a " look-around" quality that other tools lack.
The lack of an IntelliJ version is the inspiration for this project of mine.

Can I build an IntelliJ plugin providing auto-completions inspired by the llama.vim plugin?

It should be noted I know nothing of Kotlin, the language I will use to build the plugin.

Level 0

I've done some research, and if I'm building a plugin for an IntelliJ-based IDE, I should use the plugin template.
Once cloned and configured I can run build the plugin using the gradlew buildPlugin task and install it in my IDE of choice (PHPStorm) from the Plugins section of the IDE using the Install Plugin from Disk option.

I've customized the project and set its name: "completamente". It means "completely" in Italian and, again in Italian is a composition of the words "complete" and "mind".
It's meant to be a word play around the concept of completion and mind.
Naming things is hard; I'm not a poet.

Since building the plugin and installing it in my work IDE (i.e. the IDE I work with all-day) is not ideal, I've set up a run configuration to run the plugin in PHPStorm to debug it.

The CLI command to start a different copy of PHPStorm with my plugin installed in it would be:

./gradlew runIdeForUiTests -PplatformType=PS -PplatformVersion=2025.1.3

This is convenient, but I'm still not getting a step debugging environment with this.

To start a debug version of PHPStorm including my plugin I've added a run configuration to the project in the .run/Run Plugin in PHPStorm.run.xml file:


<component name="ProjectRunConfigurationManager">
    <configuration default="false" name="Run Plugin in PHPStorm" type="GradleRunConfiguration" factoryName="Gradle">
        <log_file alias="IDE logs" path="$PROJECT_DIR$/build/idea-sandbox/*/log/idea.log" show_all="true"/>
        <ExternalSystemSettings>
            <option name="executionName"/>
            <option name="externalProjectPath" value="$PROJECT_DIR$"/>
            <option name="externalSystemIdString" value="GRADLE"/>
            <option name="scriptParameters" value=""/>
            <option name="taskDescriptions">
                <list/>
            </option>
            <option name="taskNames">
                <list>
                    <option value="runIde"/>
                </list>
            </option>
            <option name="vmOptions" value=""/>
        </ExternalSystemSettings>
        <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
        <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
        <DebugAllEnabled>false</DebugAllEnabled>
        <RunAsTest>false</RunAsTest>
        <method v="2"/>
    </configuration>
</component>

Now I can run or debug a copy of PHPStorm with my plugin installed:

Run debug menu in the IDE

PHPStorm runnning in debug mode

Tapping into the completion API

The first task is connecting my plugin to the reccomended IDE completion API through the plugin configuration file ( src/main/resources/META-INF/plugin.xml):

<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
<idea-plugin>
    <id>com.github.lucatume.completamente</id>
    <name>completamente</name>
    <vendor>lucatume</vendor>

    <depends>com.intellij.modules.platform</depends>

    <resource-bundle>messages.MyBundle</resource-bundle>

    <extensions defaultExtensionNs="com.intellij">
        <inline.completion.provider implementation="com.github.lucatume.completamente.completion.Service"/>
    </extensions>
</idea-plugin>

The Service class is, at this stage, not doing much if not providing the same completion, "Hello World!" over and over:

package com.github.lucatume.completamente.completion

import com.intellij.codeInsight.inline.completion.InlineCompletionEvent
import com.intellij.codeInsight.inline.completion.InlineCompletionProvider
import com.intellij.codeInsight.inline.completion.InlineCompletionProviderID
import com.intellij.codeInsight.inline.completion.InlineCompletionRequest
import com.intellij.codeInsight.inline.completion.elements.InlineCompletionGrayTextElement
import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSingleSuggestion
import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSuggestion
import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionVariant

class Service : InlineCompletionProvider {
    // A class to represent a single string suggestion.
    class StringSuggestion(private val suggestion: String) : InlineCompletionSingleSuggestion {
        override suspend fun getVariant() = InlineCompletionVariant.build {
            emit(InlineCompletionGrayTextElement(suggestion))
        }
    }

    override val id: InlineCompletionProviderID
        get() = InlineCompletionProviderID("completamente")

    override suspend fun getSuggestion(request: InlineCompletionRequest): InlineCompletionSuggestion {
        // No computation going on here, the same string every time.
        return StringSuggestion("Hello World!")
    }

    override fun isEnabled(event: InlineCompletionEvent): Boolean {
        // It's always enabled.
        return true
    }
}

I've started the plugin in debug mode and I can see the completion in action in the context of my di52 project:

Hello world completion

It works. Badly and poorly, but it compiles and it works.

Tests

I'm doing this project to learn.
The easiest way for me to learn things is to test them.

I've started simple with a test to cover the really basic StringSuggestion class:

// src/test/kotlin/com/github/lucatume/completamente/completion/StringSuggestionTest.kt
package com.github.lucatume.completamente.completion

import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.flow.count
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

class StringSuggestionTest : BasePlatformTestCase() {
    fun testItReturnsTheSameString() {
        val stringSuggestion = StringSuggestion("hello")
        val variant = runBlocking { stringSuggestion.getVariant() }
        val size = runBlocking { variant.elements.count() }
        val suggestedString = runBlocking { variant.elements.first().text }

        assertEquals(1, size)
        assertEquals("hello", suggestedString)
    }
}

Things got complicated as I entered the world of Kotlin and found myself immediately staring down the abyss of async, multiprocess code.

The StringSuggestion::getVariant function is marked with the suspend keyword, which means it's going to be executed in a different thread; asynchronously, that is.

From my meager understanding of Kotlin coroutines, I've worked out that suspend is like JavaScript async and runBlocking is like await.
If I was to write the code in JavaScript, I'd write something like this:

const variant = await stringSuggestion.getVariant();

// The `count` function is a coroutine that returns the number of elements in the flow.
// That too is asynchronous.
const size = await variant.elements.count();

// The `first` function is a coroutine that returns the first element in the flow.
// That too is ... asynchronous.
const suggestedString = await variant.elements.first().text;

Running the tests with gradlew test shows the test passing.

Testing the service itself required me to dig a bit into the IntelliJ API, but I managed to put together a testing approach that works using real files and with limited mocking.
In the test below, the file src/test/testData/completion/text-file.txt is a real file that I've created for testing purposes and the makeInlineeCompletionRequest function provides a convenient API to create a InlineCompletionRequest object from a file path and offsets:

class ServiceTest : BasePlatformTestCase() {
    // Where I'm getting tests files from, allow me to use relative paths in the tests.
    override fun getTestDataPath() = "src/test/testData/completion"

    // Create an inline completion for tests.
    fun makeInlineCompletionEvent(): InlineCompletionEvent {
        return object : InlineCompletionEvent {
            override fun toRequest(): InlineCompletionRequest {
                throw UnsupportedOperationException("event.toRequest() is not supported in tests")
            }
        }
    }

    // Create an inline completion request for tests.
    fun makeInlineCompletionRequest(filePath: String, startOffset: Int, endOffset: Int): InlineCompletionRequest {
        val event = makeInlineCompletionEvent()
        val file = myFixture.configureByFile(filePath)
        val editor = myFixture.editor
        val document = myFixture.getDocument(file)

        return InlineCompletionRequest(event, file, editor, document, startOffset, endOffset)
    }

    fun testId() {
        val service = Service()

        assertEquals("completamente", service.id.id)
    }

    fun testIsEnabled() {
        val service = Service()

        assertTrue(service.isEnabled(makeInlineCompletionEvent()))
    }

    // This function is more about knowing I can write and run tests than about testing the service.
    fun testGetSuggestion() {
        val request = makeInlineCompletionRequest("text-file.txt", 0, 0)
        val service = Service()

        val suggestion: InlineCompletionSuggestion = runBlocking { service.getSuggestion(request) }
        val variants: List<InlineCompletionVariant> = runBlocking { suggestion.getVariants() }

        assertEquals(1, variants.size)

        assertEquals(1, runBlocking { variants.first().elements.count() })
        assertEquals("Hello World!", runBlocking { variants.first().elements.first().text })
    }
}

The tests are not currently testing much, but I'm getting acquainted with the IntelliJ API, and I need to nail the basics before moving on.

The tests are currently passing. Progress.

Next

Keeping the llama.vim code to the side, I will incrementally reproduce the implementation approach in my plugin.
The next step will be connecting to an external HTTP completion API to obtain my first real completion.