fix(editor): improve indentation for tree sitter languages

The indentation provider for tree sitter languages has been adapted from nvim-treesitter's indentation logic.
This commit is contained in:
Akash Yadav
2024-03-19 10:32:32 +05:30
parent ab101fb59d
commit cdb15cea83
13 changed files with 944 additions and 167 deletions
@@ -29,7 +29,7 @@ class TreeSitterQueryMatch @JvmOverloads internal constructor(
id: Int = 0,
patternIndex: Int = 0,
captures: Array<out TSQueryCapture?>? = null
) : TSQueryMatch(id, patternIndex, captures),
) : TSQueryMatch(id, patternIndex, captures, null),
RecyclableObjectPool.Recyclable by DefaultRecyclable() {
companion object {
@@ -0,0 +1,29 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.treesitter
/**
* Get the first node at the line and column.
*
* @param line The 0-based line number.
* @param colBytes The 0-based column number in bytes.
*/
fun TSNode.getNodeAt(line: Int, colBytes: Int): TSNode? {
return getDescendantForPointRange(TSPoint.create(line, colBytes),
TSPoint.create(line, colBytes + 1))
}
@@ -0,0 +1,30 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.treesitter
/**
* The 0-based start line number.
*/
val TSRange.startLine: Int
get() = getStartPoint().getRow()
/**
* The 0-based end line number.
*/
val TSRange.endLine: Int
get() = getEndPoint().getRow()
@@ -38,7 +38,9 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme
open var styles = Styles()
private var analyzeWorker: TsAnalyzeWorker? = null
private var _analyzeWorker: TsAnalyzeWorker? = null
val analyzeWorker: TsAnalyzeWorker?
get() = _analyzeWorker
open fun updateTheme(theme: TsTheme) {
this.theme = theme
@@ -49,7 +51,7 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme
override fun setReceiver(receiver: StyleReceiver?) {
stylesReceiver = receiver
analyzeWorker?.stylesReceiver = receiver
_analyzeWorker?.stylesReceiver = receiver
}
override fun reset(content: ContentReference, extraArguments: Bundle) {
@@ -70,7 +72,13 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme
lineCount = reference!!.lineCount
edit(edit)
}
analyzeWorker?.onMod(Mod(TextMod(start.index, end.index, edit, insertedContent.toString())))
_analyzeWorker?.onMod(Mod(TextMod(
start.index,
end.index,
edit,
insertedContent.toString(),
reference?.documentVersion ?: 0
)))
}
override fun delete(start: CharPosition, end: CharPosition, deletedContent: CharSequence) {
@@ -86,12 +94,18 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme
lineCount = reference!!.lineCount
edit(edit)
}
analyzeWorker?.onMod(Mod(TextMod(start.index, end.index, edit, null)))
_analyzeWorker?.onMod(Mod(TextMod(
start.index,
end.index,
edit,
null,
reference?.documentVersion ?: 0
)))
}
override fun rerun() {
analyzeWorker?.stop()
analyzeWorker = null
_analyzeWorker?.stop()
_analyzeWorker = null
(styles.spans as LineSpansGenerator?)?.tree?.close()
styles.spans = null
@@ -99,17 +113,17 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme
val initText = reference?.reference?.toString() ?: ""
analyzeWorker = TsAnalyzeWorker(this, languageSpec, theme, styles, reference!!, spanFactory)
analyzeWorker!!.apply {
_analyzeWorker = TsAnalyzeWorker(this, languageSpec, theme, styles, reference!!, spanFactory)
_analyzeWorker!!.apply {
this.stylesReceiver = this@TsAnalyzeManager.stylesReceiver
init(Init(initText))
init(Init(TextInit(initText, reference?.documentVersion ?: 0)))
start()
}
}
override fun destroy() {
analyzeWorker?.stop()
analyzeWorker = null
_analyzeWorker?.stop()
_analyzeWorker = null
(styles.spans as LineSpansGenerator?)?.tree?.close()
styles.spans = null
@@ -18,13 +18,12 @@
package io.github.rosemoe.sora.editor.ts
import com.itsaky.androidide.treesitter.TSInputEdit
import com.itsaky.androidide.treesitter.TSParser
import com.itsaky.androidide.treesitter.TSQueryCursor
import com.itsaky.androidide.treesitter.TSTree
import com.itsaky.androidide.treesitter.api.TreeSitterInputEdit
import com.itsaky.androidide.treesitter.api.TreeSitterQueryCapture
import com.itsaky.androidide.treesitter.api.safeExecQueryCursor
import com.itsaky.androidide.treesitter.string.UTF16StringFactory
import com.itsaky.androidide.treesitter.string.UTF16String
import io.github.rosemoe.sora.data.ObjectAllocator
import io.github.rosemoe.sora.editor.ts.spans.TsSpanFactory
import io.github.rosemoe.sora.lang.analysis.StyleReceiver
@@ -46,7 +45,7 @@ import java.util.concurrent.LinkedBlockingQueue
/**
* @author Akash Yadav
*/
internal class TsAnalyzeWorker(
class TsAnalyzeWorker(
private val analyzer: TsAnalyzeManager,
private val languageSpec: TsLanguageSpec,
private val theme: TsTheme,
@@ -72,13 +71,15 @@ internal class TsAnalyzeWorker(
private var isInitialized = false
private var isDestroyed = false
private var tree: TSTree? = null
private val localText = UTF16StringFactory.newString()
private val parser = TSParser.create().also {
it.language = languageSpec.language
}
val document = TsTextDocument(languageSpec.language)
fun init(init: Init) {
internal val tree: TSTree?
get() = document.tree
internal val text: UTF16String
get() = document.text
internal fun init(init: Init) {
if (isDestroyed) {
log.warn("Received Init after TsAnalyzeWorker has been destroyed. Ignoring...")
return
@@ -87,7 +88,7 @@ internal class TsAnalyzeWorker(
messageChannel.offer(init)
}
fun onMod(mod: Mod) {
internal fun onMod(mod: Mod) {
if (isDestroyed) {
log.warn("Received Mod after TsAnalyzeWorker has been destroyed. Ignoring...")
return
@@ -100,17 +101,13 @@ internal class TsAnalyzeWorker(
log.debug("Stopping TsAnalyzeWorker...")
isDestroyed = true
if (parser.isParsing) {
parser.requestCancellationAndWait()
}
document.requestCancellationAndWaitIfParsing()
analyzerContext.close()
messageChannel.clear()
analyzerJob?.cancel(CancellationException("Requested to be stopped"))
analyzerScope.cancel(CancellationException("Requested to be stopped"))
localText.close()
tree?.close()
parser.close()
document.close()
}
fun start() {
@@ -160,16 +157,14 @@ internal class TsAnalyzeWorker(
}
private fun doInit(init: Init) {
if (parser.isParsing) {
parser.requestCancellationAndWait()
}
document.requestCancellationAndWaitIfParsing()
check(!isInitialized) {
"'Init' must be the first message to TsAnalyzeWorker"
}
localText.append(init.data)
tree = parser.parseString(localText)
document.doInit(init.data)
document.reparse()
updateStyles()
isInitialized = true
@@ -183,45 +178,35 @@ internal class TsAnalyzeWorker(
val textMod = mod.data
val edit = textMod.edit
val newText = textMod.changedText
val oldTree = tree!!
oldTree.edit(edit)
if (newText == null) {
localText.deleteBytes(edit.startByte, edit.oldEndByte)
} else {
if (textMod.start == localText.length) {
localText.append(newText)
} else {
localText.insert(textMod.start, newText)
}
}
document.doMod(textMod)
(edit as? TreeSitterInputEdit?)?.recycle()
if (parser.isParsing) {
parser.requestCancellationAndWait()
}
document.requestCancellationAndWaitIfParsing()
if (isDestroyed) {
return
}
tree = parser.parseString(oldTree, localText)
document.reparse(oldTree)
oldTree.close()
updateStyles()
}
private fun updateStyles() {
if (isDestroyed || messageChannel.isNotEmpty() || this.tree?.canAccess() != true) {
if (isDestroyed || messageChannel.isNotEmpty() || tree?.canAccess() != true) {
// analyzer stopped or
// more message need to be processed
return
}
val tree = this.tree!!
val scopedVariables = TsScopedVariables(tree, localText, languageSpec)
val tree = tree!!
val scopedVariables = TsScopedVariables(tree, text, languageSpec)
val oldTree = (styles.spans as? LineSpansGenerator?)?.tree
val copied = tree.copy()
@@ -267,7 +252,7 @@ internal class TsAnalyzeWorker(
) { match ->
if (!languageSpec.blocksPredicator.doPredicate(
languageSpec.predicates,
localText,
text,
match
)
) {
@@ -315,13 +300,19 @@ internal interface Message<T> {
val data: T
}
internal data class Init(override val data: String) : Message<String>
internal data class Init(override val data: TextInit) : Message<TextInit>
internal data class Mod(override val data: TextMod) : Message<TextMod>
internal data class TextInit(
val text: String,
val contentVersion: Long
)
internal data class TextMod(
val start: Int,
val end: Int,
val edit: TSInputEdit,
val changedText: String?
val changedText: String?,
val contentVersion: Long
)
@@ -0,0 +1,116 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package io.github.rosemoe.sora.editor.ts
import com.itsaky.androidide.treesitter.TSLanguage
import com.itsaky.androidide.treesitter.TSParser
import com.itsaky.androidide.treesitter.TSTree
import com.itsaky.androidide.treesitter.string.UTF16String
import com.itsaky.androidide.treesitter.string.UTF16StringFactory
/**
* A text document which maintains a [TSTree] and the associated [UTF16String].
*
* @author Akash Yadav
*/
class TsTextDocument(
language: TSLanguage
) : AutoCloseable {
@Volatile
private var documentVersion = 1L
/**
* The version of this text document.
*/
val version: Long
get() = documentVersion
/**
* The source text.
*/
val text = UTF16StringFactory.newString()
/**
* The parser used to parse the source text into a syntax tree.
*/
val parser = TSParser.create().also {
it.language = language
}
/**
* The syntax tree.
*/
var tree: TSTree? = null
internal set
/**
* Request the parser to cancel parsing if a parsing is in progress.
*/
fun requestCancellationAndWaitIfParsing() {
if (parser.isParsing) {
parser.requestCancellationAndWait()
}
}
/**
* Initialize the source text with the given initialization message. The caller is responsible
* for handling the source text state i.e. this method does not check whether the text is already
* initialized or not.
*/
internal fun doInit(init: TextInit) {
text.append(init.text)
documentVersion = init.contentVersion
}
/**
* Apply the given [text modification][TextMod] to the source text.
*
* @param mod The text modification.
*/
internal fun doMod(mod: TextMod) {
val edit = mod.edit
val newText = mod.changedText
if (newText == null) {
text.deleteBytes(edit.startByte, edit.oldEndByte)
} else {
if (mod.start == text.length) {
text.append(newText)
} else {
text.insert(mod.start, newText)
}
}
documentVersion = mod.contentVersion
}
/**
* Parse the source text into a syntax tree, using the given [oldTree] for incremental parsing.
*/
internal fun reparse(oldTree: TSTree? = null): TSTree? {
tree = parser.parseString(oldTree, text)
return tree
}
override fun close() {
text?.close()
tree?.close()
parser.close()
}
}
@@ -1,42 +1,42 @@
; Query for computing indents in tree-sitter language
; Capture names can be 'indent' or 'outdent'. Any other capture name will be ignored
; indent - increments indentation
; outdent - decrements indentation
; https://github.com/nvim-treesitter/nvim-treesitter/blob/57a8acf0c4ed5e7f6dda83c3f9b073f8a99a70f9/queries/java/indents.scm
(class_body
"{" @indent
"}" @outdent)
; format-ignore
[
; ... refers to the portion that this indent query will have effects on
(class_body) ; { ... } of `class X`
(enum_body) ; { ... } of `enum X`
(interface_body) ; { ... } of `interface X`
(constructor_body) ; { `modifier` X() {...} } inside `class X`
(annotation_type_body) ; { ... } of `@interface X`
(block) ; { ... } that's not mentioned in this scope
(switch_block) ; { ... } in `switch X`
(array_initializer) ; [1, 2]
(argument_list) ; foo(...)
(formal_parameters) ; method foo(...)
(annotation_argument_list) ; @Annotation(...)
(element_value_array_initializer) ; { a, b } inside @Annotation()
] @indent.begin
(enum_body
"{" @indent
"}" @outdent)
(expression_statement
(method_invocation) @indent.begin)
(interface_body
"{" @indent
"}" @outdent)
[
"("
")"
"{"
"}"
"["
"]"
] @indent.branch
(constructor_body
"{" @indent
"}" @outdent)
(annotation_argument_list
")" @indent.end) ; This should be a special cased as `()` here doesn't have ending `;`
(block
"{" @indent
"}" @outdent)
"}" @indent.end
(switch_block
"{" @indent
"}" @outdent)
(line_comment) @indent.ignore
(array_initializer
"{" @indent
"}" @outdent)
(formal_parameters
"(" @indent
")" @outdent)
(argument_list
"(" @indent
")" @outdent)
; (expression_statement (method_invocation) @indent)
[
(ERROR)
(block_comment)
] @indent.auto
@@ -0,0 +1,498 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.editor.language.treesitter
import androidx.collection.MutableLongObjectMap
import androidx.collection.mutableIntObjectMapOf
import androidx.collection.mutableLongObjectMapOf
import androidx.core.text.trimmedLength
import com.itsaky.androidide.editor.utils.getFirstNodeAtLine
import com.itsaky.androidide.editor.utils.getLastNodeAtLine
import com.itsaky.androidide.editor.utils.previousNonBlankLine
import com.itsaky.androidide.treesitter.TSNode
import com.itsaky.androidide.treesitter.TSParser
import com.itsaky.androidide.treesitter.TSPoint
import com.itsaky.androidide.treesitter.TSQuery
import com.itsaky.androidide.treesitter.TSQueryCapture
import com.itsaky.androidide.treesitter.TSQueryCursor
import com.itsaky.androidide.treesitter.TSQueryMatch
import com.itsaky.androidide.treesitter.TSTree
import com.itsaky.androidide.treesitter.getNodeAt
import com.itsaky.androidide.treesitter.predicate.SetDirectiveHandler
import io.github.rosemoe.sora.editor.ts.TsAnalyzeWorker
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.TextUtils
import io.github.rosemoe.sora.util.IntPair
import org.slf4j.LoggerFactory
import kotlin.math.max
/**
* Computes indentation for tree sitter languages using the indents query.
*
* This is based on Neovim's implementation of indentation in `nvim-treesitter`.
*
* @see <a href="https://github.com/nvim-treesitter/nvim-treesitter/blob/9bc21966f27d48ab8eac4c42d5b130ef6c411304/lua/nvim-treesitter/indent.lua">nvim-treesitter's indent.lua</a>
* @author Akash Yadav
*/
class TreeSitterIndentProvider(
private val languageSpec: TreeSitterLanguageSpec,
private val analyzer: TsAnalyzeWorker,
private val indentSize: Int
) {
companion object {
private const val IDENT_AUTO = "indent.auto"
private const val IDENT_BEGIN = "indent.begin"
private const val IDENT_END = "indent.end"
private const val IDENT_DEDENT = "indent.dedent"
private const val IDENT_BRANCH = "indent.branch"
private const val IDENT_IGNORE = "indent.ignore"
private const val IDENT_ALIGN = "indent.align"
private const val IDENT_ZERO = "indent.zero"
private const val IDENT_TYP_COUNT = 8 // increment this when adding a new indent type above
private val log = LoggerFactory.getLogger(TreeSitterIndentProvider::class.java)
internal const val DEF_IDENT_ADV = Int.MIN_VALUE
internal const val INDENT_ALIGN_ZERO = Int.MIN_VALUE
internal const val INDENT_AUTO = Int.MAX_VALUE
}
fun getIndent(content: Content, line: Int, default: Int = DEF_IDENT_ADV): Int {
log.trace("getIndent(Content({}),{})", content.length, line)
// not really needed, but just in case
if (content.isEmpty() || line < 0 || line >= content.lineCount) {
return default
}
val document = analyzer.document
TSParser.create().use { parser ->
parser.language = document.parser.language
var closeTree = true
val tree = if (content.documentVersion == document.version) {
// avoid converting the content to string if not really needed
log.info("Re-using cached tree from document version {}", document.version)
closeTree = false
document.tree
} else {
log.info(
"Re-parsing content for indentation as document version {} does not match version {}",
document.version,
content.documentVersion
)
(document.tree?.copy() ?: return default).use { copiedTree ->
parser.parseString(copiedTree, content.toString())
}
}
if (tree == null) {
log.info("Parsed tree is null, returning default indent: {}", default)
return default
}
try {
return max(0, computeIndent(tree, content, line))
.also { indent ->
log.debug("Computed indent: {}", indent)
}
} finally {
if (closeTree) {
tree.close()
}
}
}
}
private fun computeIndent(
tree: TSTree,
content: Content,
line: Int,
default: Int = DEF_IDENT_ADV
): Int {
log.trace("computeIndent({})", line)
val indentsQuery = languageSpec.indentsQuery ?: run {
log.info("Cannot compute indents. Indents query is null.")
return default
}
val rootNode = tree.rootNode ?: run {
log.info("Cannot compute indents. Root node is null.")
return default
}
TSQueryCursor.create().use { cursor ->
cursor.addPredicateHandler(SetDirectiveHandler())
cursor.exec(indentsQuery, tree.rootNode)
val indents = getIndents(languageSpec.indentsQuery, cursor)
val isEmptyLine = content.getLine(line).trimmedLength() == 0
var node: TSNode?
if (isEmptyLine) {
val prevlnum = content.previousNonBlankLine(line)
if (prevlnum == -1) {
log.error("Cannot compute indents. Unable to get previous non-blank line.")
return default
} else {
log.debug("Previous non-blank line: {}", prevlnum)
}
var prevline = content.getLine(line).trim()
val indent = TextUtils.countLeadingSpacesAndTabs(prevline).let {
(IntPair.getFirst(it) + IntPair.getSecond(it)) shl 1
}
// The final position can be trailing spaces, which should not affect indentation
node = content.getLastNodeAtLine(rootNode, prevlnum) ?: run {
log.error("Unable to get last node at line: {}", prevlnum)
return default
}
// TODO(itsaky): Make this an API
// Language defs must be able to specify captures which represent a comment
if (node.type == "comment") {
// The final node we capture of the previous line can be a comment node, which should also be ignored
// Unless the last line is an entire line of comment, ignore the comment range and find the last node again
val firstNode = rootNode.getNodeAt(line, indent)
val scol = node.startPoint.column
if (firstNode?.nodeId != node.nodeId) {
// In case the last captured node is a trailing comment node, re-trim the string
prevline = prevline.subSequence(0, (scol shr 1) - (indent shr 1)).trim()
val col = indent + ((prevline.length - 1) shl 1)
node = rootNode.getNodeAt(prevlnum, col)
}
}
if (indents[IDENT_END]!![node?.nodeId ?: 0] != null) {
node = content.getFirstNodeAtLine(rootNode, line)
}
} else {
node = content.getFirstNodeAtLine(rootNode, line)
}
if (node == null || !node.canAccess()) {
log.error(
"Cannot compute indents. Unable to get node at line: {}. node={} node.canAccess={}", line,
node, node?.canAccess())
return default
}
var indent = 0
if (indents[IDENT_ZERO]?.containsKey(node.nodeId) == true) {
// indent.zero: align the node to the start of the line
log.debug("Zero indent for node: {}", node)
return INDENT_ALIGN_ZERO
}
// map to store whether a given line is already processed
// this is to ensure that we do not accidentally apply multiple indent levels to the same line
val processedLines = mutableIntObjectMapOf<Boolean>()
while (node != null && node.canAccess()) {
val srow = node.startPoint.line
val erow = node.endPoint.line
// do 'auto indent' if not marked as '@indent'
if (!indents.hasNode(IDENT_BEGIN, node)
&& !indents.hasNode(IDENT_ALIGN, node)
&& indents.hasNode(IDENT_AUTO, node)
&& srow < line
&& line <= erow
) {
log.debug("Auto indent for node: {}", node)
return INDENT_AUTO
}
// Do not indent if we are inside an @ignore block.
// If a node spans from L1,C1 to L2,C2, we know that lines where L1 < line <= L2 would
// have their indentations contained by the node.
if (!indents.hasNode(IDENT_BEGIN, node)
&& indents.hasNode(IDENT_IGNORE, node)
&& srow < line
&& line <= erow
) {
log.debug("Ignore indent for node: {}", node)
return default
}
var isProcessed = false
if (!processedLines.containsKey(srow)
&& ((indents.hasNode(IDENT_BRANCH, node) && srow == line)
|| (indents.hasNode(IDENT_DEDENT, node) && srow != line))
) {
indent -= indentSize
isProcessed = true
}
// do not indent for nodes that starts-and-ends on same line and starts on target line (lnum)
val shouldProcess = !processedLines.containsKey(srow)
var isInError = false
if (shouldProcess) {
isInError = node.parent?.let { it.canAccess() && it.hasErrors() } == true
}
if (shouldProcess &&
(indents.hasNode(IDENT_BEGIN, node)
&& (srow != erow || isInError || indents.hasMeta(IDENT_BEGIN, node,
"indent.immediate"))
&& (srow != line || indents.hasMeta(IDENT_BEGIN, node, "indent.start_at_same_line")))
) {
indent += indentSize
isProcessed = true
}
if (isInError && !indents.hasNode(IDENT_ALIGN, node)) {
// only when the node is in error, promote the
// first child's aligned indent to the error node
// to work around ((ERROR "X" . (_)) @aligned_indent (#set! "delimiter" "AB"))
// matching for all X, instead set do
// (ERROR "X" @aligned_indent (#set! "delimiter" "AB") . (_))
// and we will fish it out here.
for (i in 0 until node.childCount) {
val child = node.getChild(i)
if (indents.hasNode(IDENT_ALIGN, child)) {
indents[IDENT_ALIGN]!![node.nodeId] = indents[IDENT_ALIGN]!![child.nodeId]!!
break
}
}
}
// do not indent for nodes that starts-and-ends on same line and starts on target line (lnum)
if (shouldProcess
&& indents.hasNode(IDENT_ALIGN, node)
&& (srow != erow || isInError)
&& (srow != line)
) {
val meta = indents.getMeta(IDENT_ALIGN, node)!!
var oDelimNode: TSNode?
var oIsLastInLine = false
var cDelimNode: TSNode?
var cIsLastInLine = false
var indentIsAbsolute = false
if (meta.containsKey("indent.open_delimiter")) {
val r = findDelimiter(content, node, meta["indent.open_delimiter"]!!)
oDelimNode = r.first
oIsLastInLine = r.second
} else {
oDelimNode = node
}
if (meta.containsKey("indent.close_delimiter")) {
val r = findDelimiter(content, node, meta["indent.close_delimiter"]!!)
cDelimNode = r.first
cIsLastInLine = r.second
} else {
cDelimNode = node
}
if (oDelimNode != null) {
val osrow = oDelimNode.startPoint.row
val oscol = oDelimNode.startPoint.column
var csrow: Int? = null
if (cDelimNode != null) {
csrow = cDelimNode.startPoint.row
}
if (oIsLastInLine) {
// hanging indent (previous line ended with starting delimiter)
// should be processed like indent
if (shouldProcess) {
indent += indentSize
if (cIsLastInLine) {
// If current line is outside the range of a node marked with `@aligned_indent`
// Then its indent level shouldn't be affected by `@aligned_indent` node
if (csrow != null && csrow < line) {
indent = max(indent - indentSize, 0)
}
}
}
} else {
// aligned indent
if (cIsLastInLine && csrow != null && osrow != csrow && csrow < line) {
// If current line is outside the range of a node marked with `@aligned_indent`
// Then its indent level shouldn't be affected by `@aligned_indent` node
indent = max(indent - indentSize, 0)
} else {
indent = oscol + (meta.getInt("indent.increment") ?: 1)
indentIsAbsolute = true
}
}
// deal with final line
var avoidLastMatchingNext = false
if (csrow != null && csrow != osrow && csrow == line) {
// delims end on current line, and are not open and closed same line.
// then this last line may need additional indent to avoid clashes
// with the next. `indent.avoid_last_matching_next` controls this behavior,
// for example this is needed for function parameters.
avoidLastMatchingNext = meta.getBolean("indent.avoid_last_matching_next")
?: false
}
if (avoidLastMatchingNext) {
// last line must be indented more in cases where
// it would be same indent as next line (we determine this as one
// width more than the open indent to avoid confusing with any
// hanging indents)
val osrowIndent = TextUtils.countLeadingSpaceCount(content.getLine(osrow), indentSize)
if (indent <= osrowIndent + indentSize) {
indent += indentSize
}
}
isProcessed = true
if (indentIsAbsolute) {
// don't allow further indenting by parent nodes, this is an absolute position
return indent
}
}
}
processedLines[srow] = processedLines.getOrDefault(srow, isProcessed)
node = node.parent
}
return indent
}
}
private fun findDelimiter(content: Content, node: TSNode,
delimiter: String): Pair<TSNode?, Boolean> {
for (i in 0 until node.childCount) {
val child = node.getChild(i)
if (child.type != delimiter) {
continue
}
val start = node.startPoint
val end = node.endPoint
val line = content.getLine(start.line)
val escapedDelim = delimiter.replace(Regex("""[\-.+\[\]()$^\\?*]"""), "\\\\$0")
val trimmedAfterDelim = line.substring((end.column shr 1) + 1)
.replace(Regex("""[\s$escapedDelim]*"""), "")
return child to trimmedAfterDelim.isEmpty()
}
return null to false
}
/**
* Get the indents from the query.
*
* @return The indent captures from the query. The returned map has the following structure :
* ```
* map[indentType][node_id] = capture
* ```
* where `indentType` is one of the indent types defined in [TreeSitterIndentProvider.Companion]`.IDENT_XXX`
* and `node_id` is same as [TSNode.getNodeId].
*/
private fun getIndents(
query: TSQuery,
cursor: TSQueryCursor
): IndentsContainer {
val indents = IndentsContainer()
var match: TSQueryMatch? = cursor.nextMatch()
while (match != null) {
for (capture in match.captures) {
val captureName = query.getCaptureNameForId(capture.index)
if (!indents.containsKey(captureName)) {
log.warn("Unknown capture name in indents query: {}", captureName)
continue
}
indents[captureName]!![capture.node.nodeId] = capture to match.metadata
}
match = cursor.nextMatch()
}
return indents
}
private inner class IndentsContainer {
private val data = HashMap<String, MutableLongObjectMap<Pair<TSQueryCapture, TSQueryMatch.Metadata>>>(
IDENT_TYP_COUNT)
init {
// pre-fill the indents type so we could report any unknown indent types later
data[IDENT_AUTO] = mutableLongObjectMapOf()
data[IDENT_BEGIN] = mutableLongObjectMapOf()
data[IDENT_END] = mutableLongObjectMapOf()
data[IDENT_DEDENT] = mutableLongObjectMapOf()
data[IDENT_BRANCH] = mutableLongObjectMapOf()
data[IDENT_IGNORE] = mutableLongObjectMapOf()
data[IDENT_ALIGN] = mutableLongObjectMapOf()
data[IDENT_ZERO] = mutableLongObjectMapOf()
}
fun containsKey(key: String): Boolean {
return data.containsKey(key)
}
fun get(type: String, node: TSNode) = get(type, node.nodeId)
fun get(type: String, nodeId: Long) = data[type]?.get(nodeId)
fun hasNode(type: String, node: TSNode) = hasNode(type, node.nodeId)
fun hasNode(type: String, nodeId: Long) = data[type]?.get(nodeId) != null
fun hasMeta(type: String, node: TSNode, metaKey: String) = hasMeta(type, node.nodeId, metaKey)
fun hasMeta(type: String, nodeId: Long, metaKey: String) =
data[type]?.get(nodeId)?.second?.get<Any>(metaKey) != null
fun getMeta(type: String, node: TSNode) = getMeta(type, node.nodeId)
fun getMeta(type: String, nodeId: Long) = data[type]?.get(nodeId)?.second
fun <T : Any?> getMetaValue(type: String, node: TSNode, metaKey: String) =
getMetaValue<T>(type, node.nodeId, metaKey)
fun <T : Any?> getMetaValue(type: String, nodeId: Long, metaKey: String) =
data[type]?.get(nodeId)?.second?.get<T>(metaKey)
operator fun get(
key: String): MutableLongObjectMap<Pair<TSQueryCapture, TSQueryMatch.Metadata>>? {
return data[key]
}
operator fun set(key: String,
value: MutableLongObjectMap<Pair<TSQueryCapture, TSQueryMatch.Metadata>>) {
data[key] = value
}
}
}
/**
* Alias for [TSPoint.row].
*/
private val TSPoint.line: Int
get() = this.row
private fun TSQueryMatch.Metadata.getInt(key: String) = getString(key).toIntOrNull()
private fun TSQueryMatch.Metadata.getBolean(key: String) = getString(key).toBooleanStrictOrNull()
@@ -26,16 +26,14 @@ import com.itsaky.androidide.editor.schemes.LanguageScheme
import com.itsaky.androidide.editor.schemes.LanguageSpecProvider.getLanguageSpec
import com.itsaky.androidide.editor.schemes.LocalCaptureSpecProvider.newLocalCaptureSpec
import com.itsaky.androidide.treesitter.TSLanguage
import com.itsaky.androidide.treesitter.TSParser
import com.itsaky.androidide.treesitter.TSQueryCapture
import com.itsaky.androidide.treesitter.TSQueryCursor
import com.itsaky.androidide.treesitter.TSQueryMatch
import io.github.rosemoe.sora.editor.ts.TsTheme
import io.github.rosemoe.sora.lang.Language.INTERRUPTION_LEVEL_STRONG
import io.github.rosemoe.sora.lang.analysis.AnalyzeManager
import io.github.rosemoe.sora.text.ContentReference
import io.github.rosemoe.sora.text.TextUtils
import io.github.rosemoe.sora.util.IntPair
import io.github.rosemoe.sora.widget.SymbolPairMatch
import org.slf4j.LoggerFactory
/**
* Tree Sitter language implementation.
@@ -50,11 +48,31 @@ abstract class TreeSitterLanguage(
private lateinit var tsTheme: TsTheme
private lateinit var languageSpec: TreeSitterLanguageSpec
private lateinit var _indentProvider: TreeSitterIndentProvider
private val analyzer by lazy { TreeSitterAnalyzeManager(languageSpec.spec, tsTheme) }
private val newlineHandlersLazy by lazy { createNewlineHandlers() }
private var languageScheme: LanguageScheme? = null
private val indentProvider: TreeSitterIndentProvider
get() {
if (!this::_indentProvider.isInitialized) {
this._indentProvider = TreeSitterIndentProvider(
languageSpec,
analyzer.analyzeWorker!!,
getTabSize()
)
}
return _indentProvider
}
companion object {
private val log = LoggerFactory.getLogger(TreeSitterLanguage::class.java)
private const val DEF_IDENT_ADV = TreeSitterIndentProvider.DEF_IDENT_ADV
}
init {
this.languageSpec = getLanguageSpec(context, langType, lang, newLocalCaptureSpec(langType))
this.tsTheme = TsTheme(languageSpec.spec.tsQuery)
@@ -67,10 +85,6 @@ abstract class TreeSitterLanguage(
langScheme?.styles?.forEach { tsTheme.putStyleRule(it.key, it.value.makeStyle()) }
}
open fun finalizeIndent(indent: Int): Int {
return indent * getTabSize()
}
override fun getAnalyzeManager(): AnalyzeManager {
return this.analyzer
}
@@ -92,57 +106,28 @@ abstract class TreeSitterLanguage(
}
override fun getIndentAdvance(content: ContentReference, line: Int, column: Int): Int {
return computeIndent(
content.toString(),
line,
column,
decrementBy = TextUtils.countLeadingSpaceCount(content.getLine(line), getTabSize())
)
}
protected open fun computeIndent(
content: String,
line: Int,
column: Int,
decrementBy: Int = 0
): Int {
val indentsQuery = languageSpec.indentsQuery ?: return 0
return TSParser.create().use { parser ->
parser.language = languageSpec.language
return@use parser.parseString(content).use { tree ->
TSQueryCursor.create().use { cursor ->
cursor.exec(indentsQuery, tree.rootNode)
var indent = 0
var match: TSQueryMatch? = cursor.nextMatch()
val captures = mutableListOf<TSQueryCapture>()
while (match != null) {
captures.addAll(match.captures)
match = cursor.nextMatch()
}
captures.sortBy { it.node.startByte }
for (capture in captures) {
val capLine = capture.node.startPoint.row
val capCol = capture.node.endPoint.column / 2
if (capLine > line || (capLine == line && capCol > column)) {
break
}
val captureName = indentsQuery.getCaptureNameForId(capture.index)
if (captureName == "indent") {
++indent
} else if (captureName == "outdent") {
--indent
}
}
finalizeIndent(indent) - decrementBy
}
return try {
if (line == content.reference.lineCount - 1) {
// line + 1 does not exist
// TODO(itsaky): Update this implementation when this behavior is fixed in sora-editor
return DEF_IDENT_ADV
}
}
val indentOnLine = TextUtils.countLeadingSpacesAndTabs(content.getLine(line)).let { count ->
IntPair.getFirst(count) + (getTabSize() * IntPair.getSecond(count))
}
val indent = this.indentProvider.getIndent(
content = content.reference,
line = line + 1, // "line" is the current line index, indentation is applied on the next line
default = DEF_IDENT_ADV,
)
indent - indentOnLine
} catch (e: Exception) {
log.error("An error occurred computing indentation at line:column::{}:{}", line, column, e)
DEF_IDENT_ADV
}.let { if (it == Int.MIN_VALUE) 0 else it }
}
override fun destroy() {
@@ -29,7 +29,8 @@ import java.io.Closeable
* @author Akash Yadav
*/
class TreeSitterLanguageSpec
@JvmOverloads constructor(val spec: TsLanguageSpec,
@JvmOverloads constructor(
val spec: TsLanguageSpec,
indentsQueryScm: String = ""
) : Closeable {
@@ -38,9 +39,12 @@ class TreeSitterLanguageSpec
get() = spec.language
// </editor-fold>
val indentsQuery: TSQuery? = if (indentsQueryScm.isBlank()) TSQuery.EMPTY else TSQuery.create(
language, indentsQueryScm)
.let { if (it.canAccess()) it else null }
val indentsQuery: TSQuery? = if (indentsQueryScm.isBlank()) {
TSQuery.EMPTY
} else {
TSQuery.create(language, indentsQueryScm)
.let { if (it.canAccess()) it else null }
}
init {
indentsQuery?.validateOrThrow(name = "indents")
@@ -102,10 +102,13 @@ import java.io.File
*
* @author Akash Yadav
*/
open class IDEEditor @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleRes: Int = 0,
private val editorFeatures: EditorFeatures = EditorFeatures()) :
CodeEditor(context, attrs, defStyleAttr, defStyleRes), IEditor by editorFeatures, ILspEditor {
open class IDEEditor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
private val editorFeatures: EditorFeatures = EditorFeatures()
) : CodeEditor(context, attrs, defStyleAttr, defStyleRes), IEditor by editorFeatures, ILspEditor {
@Suppress("PropertyName")
internal var _file: File? = null
@@ -909,4 +912,8 @@ open class IDEEditor @JvmOverloads constructor(context: Context, attrs: Attribut
log.error("{} failed", action)
}
}
override fun setSelectionAround(line: Int, column: Int) {
editorFeatures.setSelectionAround(line, column)
}
}
@@ -0,0 +1,103 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.editor.utils
import androidx.core.text.trimmedLength
import com.itsaky.androidide.treesitter.TSNode
import com.itsaky.androidide.treesitter.TSTree
import com.itsaky.androidide.treesitter.getNodeAt
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.TextUtils
import io.github.rosemoe.sora.util.IntPair
/**
* Returns the index of the previous non-blank line.
*
* @return The index of the previous non-blank line or -1 if not found.
*/
fun Content.previousNonBlankLine(line: Int) : Int {
for (i in line - 1 downTo 0) {
if (getLine(i).trim { it.isWhitespace() || it == '\r' }.isNotEmpty()) {
return i
}
}
return -1
}
/**
* Returns the index of the next non-blank line.
*
* @return The index of the next non-blank line or -1 if not found.
*/
fun Content.nextNonBlankLine(line: Int) : Int {
for (i in line + 1 until length) {
if (getLine(i).trimmedLength() > 0) {
return i
}
}
return -1
}
/**
* Returns the first [TSNode] at the given line number. The leading indentation is ignored.
*/
fun Content.getFirstNodeAtLine(tree: TSTree, line: Int) : TSNode? {
return getFirstNodeAtLine(tree.rootNode, line)
}
/**
* Returns the last [TSNode] at the given line number.
*/
fun Content.getLastNodeAtLine(tree: TSTree, line: Int) : TSNode? {
return getLastNodeAtLine(tree.rootNode, line)
}
/**
* Returns the first [TSNode] at the given line number. The leading indentation is ignored.
*/
fun Content.getFirstNodeAtLine(node: TSNode, line: Int) : TSNode? {
if (line < 0 || line >= lineCount) {
return null
}
val contentLine = getLine(line);
val (spaces, tabs) = TextUtils.countLeadingSpacesAndTabs(contentLine).let {
IntPair.getFirst(it) to IntPair.getSecond(it)
}
// we need the byte offset in the line, so we need to multiply the char offset by 2 (shl 1)
// also, we need not to expand the tabs to spaces as that would result in incorrect offset
return node.getNodeAt(line, (spaces + tabs) shl 1)
}
/**
* Returns the last [TSNode] at the given line number. This function also takes the leading
* indentation into consideration.
*/
fun Content.getLastNodeAtLine(node: TSNode, line: Int) : TSNode? {
if (line < 0 || line >= lineCount) {
return null
}
val contentLine = getLine(line);
// we need the byte offset in the line, so we need to multiply the char offset by 2 (shl 1)
return node.getNodeAt(line, (contentLine.length - 1) shl 1)
}
+13 -13
View File
@@ -5,7 +5,7 @@ gradle-tooling = "8.6"
junit-jupiter = "5.10.2"
kotlin = "1.9.22"
kotlin-coroutines = "1.8.0"
tree-sitter = "4.2.0"
tree-sitter = "4.3.1"
editor = "0.23.4"
glide = "4.16.0"
androidx-vectordrawable = "1.1.0"
@@ -24,16 +24,16 @@ logback = "1.5.3"
# Dependencies in composite build
composite-appintro = { module = "com.itsaky.androidide.build:appintro" }
composite-desugaringCore = { module = "com.itsaky.androidide.build:desugaring-core" }
composite-fuzzysearch = { module ="com.itsaky.androidide.build:fuzzysearch" }
composite-googleJavaFormat = { module ="com.itsaky.androidide.build:google-java-format" }
composite-javaCompiler = { module ="com.itsaky.androidide.build:java-compiler" }
composite-javac = { module ="com.itsaky.androidide.build:javac" }
composite-jdkCompiler = { module ="com.itsaky.androidide.build:jdk-compiler" }
composite-jdkJdeps = { module ="com.itsaky.androidide.build:jdk-jdeps" }
composite-javapoet = { module ="com.itsaky.androidide.build:javapoet" }
composite-jaxp = { module ="com.itsaky.androidide.build:jaxp" }
composite-jdt = { module ="com.itsaky.androidide.build:jdt" }
composite-layoutlibApi = { module ="com.itsaky.androidide.build:layoutlib-api" }
composite-fuzzysearch = { module = "com.itsaky.androidide.build:fuzzysearch" }
composite-googleJavaFormat = { module = "com.itsaky.androidide.build:google-java-format" }
composite-javaCompiler = { module = "com.itsaky.androidide.build:java-compiler" }
composite-javac = { module = "com.itsaky.androidide.build:javac" }
composite-jdkCompiler = { module = "com.itsaky.androidide.build:jdk-compiler" }
composite-jdkJdeps = { module = "com.itsaky.androidide.build:jdk-jdeps" }
composite-javapoet = { module = "com.itsaky.androidide.build:javapoet" }
composite-jaxp = { module = "com.itsaky.androidide.build:jaxp" }
composite-jdt = { module = "com.itsaky.androidide.build:jdt" }
composite-layoutlibApi = { module = "com.itsaky.androidide.build:layoutlib-api" }
# AndroidIDE
androidide-ts = { module = "com.itsaky.androidide.treesitter:android-tree-sitter", version.ref = "tree-sitter" }
@@ -95,9 +95,9 @@ androidx-nav-ui = { module = "androidx.navigation:navigation-ui-ktx", version.re
androidx-tracing = { module = "androidx.tracing:tracing", version = "1.2.0" }
androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version = "1.2.0" }
androidx-viewpager = { module = "androidx.viewpager:viewpager", version = "1.0.0" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.0.0"}
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.0.0" }
#noinspection GradleDependency
androidx-window-v1alpha9 = { module = "androidx.window:window", version="1.0.0-alpha09" }
androidx-window-v1alpha9 = { module = "androidx.window:window", version = "1.0.0-alpha09" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }