From 6943a9d5ba7d49f2f175812e490770d8aceb6b27 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Tue, 25 Aug 2026 23:43:46 +0100 Subject: [PATCH 01/20] ADFA-2448: Add outline symbol model and containment tree builder --- .../language/outline/OutlineProvider.kt | 10 ++ .../editor/language/outline/OutlineSymbol.kt | 31 ++++++ .../language/outline/OutlineTreeBuilder.kt | 54 +++++++++++ .../outline/OutlineTreeBuilderTest.kt | 94 +++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineProvider.kt create mode 100644 editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineSymbol.kt create mode 100644 editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilder.kt create mode 100644 editor/src/test/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilderTest.kt diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineProvider.kt new file mode 100644 index 0000000000..7ae47f33f2 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineProvider.kt @@ -0,0 +1,10 @@ +package com.itsaky.androidide.editor.language.outline + +interface OutlineProvider { + fun supports(fileExtension: String): Boolean + + suspend fun outlineOf( + fileExtension: String, + text: CharSequence, + ): List +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineSymbol.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineSymbol.kt new file mode 100644 index 0000000000..3a31d7523c --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineSymbol.kt @@ -0,0 +1,31 @@ +package com.itsaky.androidide.editor.language.outline + +import com.itsaky.androidide.models.Range + +enum class OutlineSymbolKind( + val badge: Char, +) { + CLASS('C'), + INTERFACE('I'), + ENUM('E'), + ENUM_MEMBER('V'), + RECORD('R'), + ANNOTATION('A'), + OBJECT('O'), + COMPANION('O'), + TYPE_ALIAS('T'), + CONSTRUCTOR('N'), + METHOD('M'), + FIELD('F'), + PROPERTY('P'), + ELEMENT('X'), +} + +data class OutlineSymbol( + val name: String, + val detail: String?, + val kind: OutlineSymbolKind, + val range: Range, + val selectionRange: Range, + val children: List, +) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilder.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilder.kt new file mode 100644 index 0000000000..1bc0e96237 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilder.kt @@ -0,0 +1,54 @@ +package com.itsaky.androidide.editor.language.outline + +import com.itsaky.androidide.models.Range + +internal data class RawOutlineSymbol( + val name: String, + val detail: String?, + val kind: OutlineSymbolKind, + val range: Range, + val selectionRange: Range, +) + +internal object OutlineTreeBuilder { + fun build(raw: List): List { + val sorted = + raw.sortedWith( + compareBy({ it.range.start.index }, { -it.range.end.index }), + ) + val roots = mutableListOf() + val stack = ArrayDeque() + for (symbol in sorted) { + while (stack.isNotEmpty() && stack + .last() + .raw.range.end.index <= symbol.range.start.index + ) { + stack.removeLast() + } + val node = MutableNode(symbol) + if (stack.isEmpty()) { + roots.add(node) + } else { + stack.last().children.add(node) + } + stack.addLast(node) + } + return roots.map { it.freeze() } + } + + private class MutableNode( + val raw: RawOutlineSymbol, + ) { + val children = mutableListOf() + + fun freeze(): OutlineSymbol = + OutlineSymbol( + name = raw.name, + detail = raw.detail, + kind = raw.kind, + range = raw.range, + selectionRange = raw.selectionRange, + children = children.map { it.freeze() }, + ) + } +} diff --git a/editor/src/test/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilderTest.kt b/editor/src/test/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilderTest.kt new file mode 100644 index 0000000000..2ead91780a --- /dev/null +++ b/editor/src/test/java/com/itsaky/androidide/editor/language/outline/OutlineTreeBuilderTest.kt @@ -0,0 +1,94 @@ +package com.itsaky.androidide.editor.language.outline + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import org.junit.Test + +class OutlineTreeBuilderTest { + private fun raw( + name: String, + start: Int, + end: Int, + kind: OutlineSymbolKind = OutlineSymbolKind.METHOD, + ) = RawOutlineSymbol( + name = name, + detail = null, + kind = kind, + range = Range(Position(0, 0, start), Position(0, 0, end)), + selectionRange = Range(Position(0, 0, start), Position(0, 0, start)), + ) + + @Test + fun `empty input produces empty tree`() { + assertThat(OutlineTreeBuilder.build(emptyList())).isEmpty() + } + + @Test + fun `single symbol is a root`() { + val result = OutlineTreeBuilder.build(listOf(raw("a", 0, 10))) + assertThat(result).hasSize(1) + assertThat(result[0].name).isEqualTo("a") + assertThat(result[0].children).isEmpty() + } + + @Test + fun `contained symbol becomes a child`() { + val result = + OutlineTreeBuilder.build( + listOf(raw("outer", 0, 100, OutlineSymbolKind.CLASS), raw("inner", 10, 50)), + ) + assertThat(result).hasSize(1) + assertThat(result[0].name).isEqualTo("outer") + assertThat(result[0].children.map { it.name }).containsExactly("inner") + } + + @Test + fun `adjacent symbols do not nest`() { + val result = OutlineTreeBuilder.build(listOf(raw("a", 0, 10), raw("b", 10, 20))) + assertThat(result.map { it.name }).containsExactly("a", "b").inOrder() + assertThat(result[0].children).isEmpty() + } + + @Test + fun `siblings keep document order`() { + val result = + OutlineTreeBuilder.build( + listOf( + raw("parent", 0, 100, OutlineSymbolKind.CLASS), + raw("second", 40, 60), + raw("first", 10, 30), + ), + ) + assertThat(result[0].children.map { it.name }).containsExactly("first", "second").inOrder() + } + + @Test + fun `same start offset nests the shorter inside the longer`() { + val result = + OutlineTreeBuilder.build( + listOf(raw("short", 0, 20), raw("long", 0, 100, OutlineSymbolKind.CLASS)), + ) + assertThat(result).hasSize(1) + assertThat(result[0].name).isEqualTo("long") + assertThat(result[0].children.map { it.name }).containsExactly("short") + } + + @Test + fun `deep nesting chains through the stack`() { + val result = + OutlineTreeBuilder.build( + listOf( + raw("l1", 0, 100, OutlineSymbolKind.CLASS), + raw("l2", 10, 90, OutlineSymbolKind.CLASS), + raw("l3", 20, 80), + raw("uncle", 91, 99), + ), + ) + assertThat(result).hasSize(1) + val l1 = result[0] + assertThat(l1.children.map { it.name }).containsExactly("l2", "uncle").inOrder() + val l2 = l1.children[0] + assertThat(l2.children.map { it.name }).containsExactly("l3") + } +} From d6e2f456e53e42a9984f47c732676d86a6e9756c Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 26 Aug 2026 00:26:02 +0100 Subject: [PATCH 02/20] ADFA-2448: Add outline tree-sitter queries for java, kotlin, xml --- editor/build.gradle.kts | 4 +- editor/src/androidTest/AndroidManifest.xml | 9 +++ .../outline/TreeSitterOutlineQueryTest.kt | 57 +++++++++++++++++++ .../assets/editor/treesitter/java/outline.scm | 29 ++++++++++ .../assets/editor/treesitter/kt/outline.scm | 27 +++++++++ .../assets/editor/treesitter/xml/outline.scm | 23 ++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 editor/src/androidTest/AndroidManifest.xml create mode 100644 editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineQueryTest.kt create mode 100644 editor/src/main/assets/editor/treesitter/java/outline.scm create mode 100644 editor/src/main/assets/editor/treesitter/kt/outline.scm create mode 100644 editor/src/main/assets/editor/treesitter/xml/outline.scm diff --git a/editor/build.gradle.kts b/editor/build.gradle.kts index c58ee88d42..04d67a8d9d 100644 --- a/editor/build.gradle.kts +++ b/editor/build.gradle.kts @@ -59,5 +59,7 @@ dependencies { implementation(projects.idetooltips) testImplementation(projects.testing.unit) - androidTestImplementation(projects.testing.android) + androidTestImplementation(projects.testing.android) { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-reflect") + } } diff --git a/editor/src/androidTest/AndroidManifest.xml b/editor/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..a0720473d9 --- /dev/null +++ b/editor/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineQueryTest.kt b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineQueryTest.kt new file mode 100644 index 0000000000..d8766c2948 --- /dev/null +++ b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineQueryTest.kt @@ -0,0 +1,57 @@ +package com.itsaky.androidide.editor.language.outline + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.treesitter.TSLanguage +import com.itsaky.androidide.treesitter.TSQuery +import com.itsaky.androidide.treesitter.TSQueryError +import com.itsaky.androidide.treesitter.TreeSitter +import com.itsaky.androidide.treesitter.java.TSLanguageJava +import com.itsaky.androidide.treesitter.kotlin.TSLanguageKotlin +import com.itsaky.androidide.treesitter.xml.TSLanguageXml +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TreeSitterOutlineQueryTest { + @Before + fun loadNative() { + TreeSitter.loadLibrary() + } + + @Test + fun javaOutlineQueryCompiles() { + assertCompiles("java", TSLanguageJava.getInstance()) + } + + @Test + fun kotlinOutlineQueryCompiles() { + assertCompiles("kt", TSLanguageKotlin.getInstance()) + } + + @Test + fun xmlOutlineQueryCompiles() { + assertCompiles("xml", TSLanguageXml.getInstance()) + } + + private fun assertCompiles( + type: String, + language: TSLanguage, + ) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val scm = + context.assets + .open("editor/treesitter/$type/outline.scm") + .reader() + .readText() + val query = TSQuery.create(language, scm) + try { + assertThat(query.errorType).isEqualTo(TSQueryError.None) + assertThat(query.patternCount).isGreaterThan(0) + } finally { + query.close() + } + } +} diff --git a/editor/src/main/assets/editor/treesitter/java/outline.scm b/editor/src/main/assets/editor/treesitter/java/outline.scm new file mode 100644 index 0000000000..913408e580 --- /dev/null +++ b/editor/src/main/assets/editor/treesitter/java/outline.scm @@ -0,0 +1,29 @@ +(class_declaration + name: (identifier) @name) @symbol.class + +(interface_declaration + name: (identifier) @name) @symbol.interface + +(enum_declaration + name: (identifier) @name) @symbol.enum + +(record_declaration + name: (identifier) @name) @symbol.record + +(annotation_type_declaration + name: (identifier) @name) @symbol.annotation + +(enum_constant + name: (identifier) @name) @symbol.enumMember + +(constructor_declaration + name: (identifier) @name + parameters: (formal_parameters) @detail) @symbol.constructor + +(method_declaration + name: (identifier) @name + parameters: (formal_parameters) @detail) @symbol.method + +(field_declaration + declarator: (variable_declarator + name: (identifier) @name)) @symbol.field diff --git a/editor/src/main/assets/editor/treesitter/kt/outline.scm b/editor/src/main/assets/editor/treesitter/kt/outline.scm new file mode 100644 index 0000000000..c515a659f6 --- /dev/null +++ b/editor/src/main/assets/editor/treesitter/kt/outline.scm @@ -0,0 +1,27 @@ +(class_declaration + (type_identifier) @name) @symbol.class + +(object_declaration + (type_identifier) @name) @symbol.object + +(companion_object) @symbol.companion + +(type_alias + (type_identifier) @name) @symbol.typeAlias + +(enum_entry + (simple_identifier) @name) @symbol.enumMember + +(function_declaration + (simple_identifier) @name + (function_value_parameters) @detail) @symbol.method + +(secondary_constructor + (function_value_parameters) @detail) @symbol.constructor + +(property_declaration + (variable_declaration + (simple_identifier) @name)) @symbol.property + +(class_parameter + (simple_identifier) @name) @symbol.property diff --git a/editor/src/main/assets/editor/treesitter/xml/outline.scm b/editor/src/main/assets/editor/treesitter/xml/outline.scm new file mode 100644 index 0000000000..9404840341 --- /dev/null +++ b/editor/src/main/assets/editor/treesitter/xml/outline.scm @@ -0,0 +1,23 @@ +(end_tag_element + (tag_start + tag_name: (name) @name)) @symbol.element + +(empty_element + tag_name: (name) @name) @symbol.element + +(empty_element + tag_name: (name) @name + (attribute + (xml_attr + attr_name: (name) @_a + (attr_value) @detail)) + (#match? @_a "^id$")) @symbol.element + +(end_tag_element + (tag_start + tag_name: (name) @name + (attribute + (xml_attr + attr_name: (name) @_a + (attr_value) @detail))) + (#match? @_a "^id$")) @symbol.element From 81c1eb6cbb777f191556eff7c1a80068b9d91531 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 26 Aug 2026 00:26:02 +0100 Subject: [PATCH 03/20] ADFA-2448: Add tree-sitter outline extraction provider --- .../outline/TreeSitterOutlineProviderTest.kt | 172 +++++++++++++++ .../outline/TreeSitterOutlineProvider.kt | 202 ++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt create mode 100644 editor/src/main/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProvider.kt diff --git a/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt new file mode 100644 index 0000000000..f9b355e9bf --- /dev/null +++ b/editor/src/androidTest/java/com/itsaky/androidide/editor/language/outline/TreeSitterOutlineProviderTest.kt @@ -0,0 +1,172 @@ +package com.itsaky.androidide.editor.language.outline + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TreeSitterOutlineProviderTest { + private lateinit var provider: TreeSitterOutlineProvider + + private val javaSource = + """ + public class Main { + private int count; + public Main() { + } + public void run(String[] args) { + } + interface Inner { + void call(); + } + enum Kind { + LOCAL, REMOTE + } + } + """.trimIndent() + + private val kotlinSource = + """ + class Repo(val name: String) { + companion object { + val EMPTY = Repo("") + } + var count = 0 + fun add(item: String): Boolean { + return true + } + constructor(name: String, count: Int) : this(name) { + this.count = count + } + } + fun topLevel() { + } + """.trimIndent() + + private val xmlSource = + """ + + + +