Skip to content

Commit 92a279e

Browse files
committed
[Phone][ZeroTermux][Bug][Github(/)]:
优化LSP逻辑 RootCause: / Modify: / update: #hanxinhao000 Known problem: / ApplyTo: All
1 parent ae69b21 commit 92a279e

16 files changed

Lines changed: 1311 additions & 193 deletions

app/src/main/java/com/termux/zerocore/activity/EditTextActivity.kt

Lines changed: 396 additions & 25 deletions
Large diffs are not rendered by default.

app/src/main/java/com/termux/zerocore/aidebug/ZtAiDebugApiDocs.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,15 @@ object ZtAiDebugApiDocs {
223223
"path" to "/api/editor/lsp/stderr",
224224
"desc" to if (lang == "zh") "jdt-ls stderr 尾部 ?lines=" else "jdt-ls stderr tail ?lines="
225225
),
226+
mapOf(
227+
"method" to "POST",
228+
"path" to "/api/editor/lsp/definition",
229+
"desc" to if (lang == "zh") {
230+
"探测转到定义 {path?,line?,column?,word?,occurrence?},需编辑器已打开"
231+
} else {
232+
"Probe go-to-definition {path?,line?,column?,word?,occurrence?}; editor must be open"
233+
}
234+
),
226235
mapOf("method" to "GET", "path" to "/api/llm/tools", "desc" to if (lang == "zh") "列出全部 LLM/智能体工具名" else "List all LLM agent tool names"),
227236
mapOf("method" to "POST", "path" to "/api/llm/tool", "desc" to if (lang == "zh") "执行 LLM 工具 {tool, arguments}" else "Run LLM tool {tool, arguments}"),
228237
mapOf("method" to "POST", "path" to "/api/config/get", "desc" to if (lang == "zh") "读 ZeroTermux 配置 {group?, keys?}" else "Get ZT config {group?, keys?}"),

app/src/main/java/com/termux/zerocore/aidebug/ZtAiDebugEditorLspHelper.kt

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package com.termux.zerocore.aidebug
22

33
import android.content.Context
44
import com.google.gson.Gson
5+
import com.termux.shared.termux.TermuxConstants
56
import com.termux.zerocore.editor.lsp.EditorClangdSupport
7+
import com.termux.zerocore.editor.lsp.EditorJdtClassFileSupport
68
import com.termux.zerocore.editor.lsp.EditorJdtLsSupport
79
import com.termux.zerocore.editor.lsp.EditorLspDebugStore
810
import com.termux.zerocore.editor.lsp.EditorLspInstaller
911
import com.termux.zerocore.editor.lsp.EditorLspManager
12+
import com.termux.zerocore.editor.lsp.EditorLspUris
13+
import java.io.File
1014

1115
object ZtAiDebugEditorLspHelper {
1216
private val gson = Gson()
@@ -30,7 +34,8 @@ object ZtAiDebugEditorLspHelper {
3034
"launcher" to EditorJdtLsSupport.findLauncherJar()?.absolutePath,
3135
"config_template" to EditorJdtLsSupport.findConfigTemplateDir()?.absolutePath,
3236
"config_runtime" to EditorJdtLsSupport.ensureRuntimeConfigDir()?.absolutePath,
33-
"arch" to (System.getProperty("os.arch") ?: "unknown")
37+
"arch" to (System.getProperty("os.arch") ?: "unknown"),
38+
"navigation_timeout_ms" to EditorJdtLsSupport.NAVIGATION_TIMEOUT_MILLIS
3439
),
3540
"c_clangd" to mapOf(
3641
"package_id" to EditorClangdSupport.PACKAGE_ID,
@@ -48,7 +53,7 @@ object ZtAiDebugEditorLspHelper {
4853
"diagnostics" to EditorLspDebugStore.diagnosticsSnapshot(),
4954
"recent_events" to EditorLspDebugStore.recentEvents(50),
5055
"stderr_tail" to EditorLspDebugStore.stderrTail(80),
51-
"hint_zh" to "打开编辑器并启用 LSP 后,diagnostics 会随 publishDiagnostics 更新;stderr_tail 为 jdt-ls 日志(不再 Toast)"
56+
"hint_zh" to "打开编辑器并启用 LSP 后可用 POST /api/editor/lsp/definition 探测转到定义"
5257
)
5358
)
5459
}
@@ -73,4 +78,164 @@ object ZtAiDebugEditorLspHelper {
7378
)
7479
)
7580
}
81+
82+
/**
83+
* 探测 textDocument/definition(及 typeDefinition 回退)与 class 源码解析。
84+
* JSON: {path?, line?, column?, word?, occurrence?}
85+
*/
86+
fun definitionJson(
87+
path: String?,
88+
line: Int?,
89+
column: Int?,
90+
word: String?,
91+
occurrence: Int = 0,
92+
navigate: Boolean = false
93+
): String {
94+
val manager = EditorLspManager.activeInstance
95+
?: return gson.toJson(
96+
mapOf(
97+
"ok" to false,
98+
"error" to "editor_lsp_inactive",
99+
"hint_zh" to "先 POST /api/editor/open 打开 Java 文件并等待 LSP 就绪(GET /api/editor/lsp/status)"
100+
)
101+
)
102+
val file = resolveProbeFile(path, manager)
103+
?: return gson.toJson(mapOf("ok" to false, "error" to "file_not_found", "path" to path))
104+
val languageId = EditorLspManager.languageIdForExtension(file.extension)
105+
?: return gson.toJson(
106+
mapOf(
107+
"ok" to false,
108+
"error" to "unsupported_language",
109+
"file" to file.absolutePath
110+
)
111+
)
112+
val content = runCatching { file.readText() }.getOrElse {
113+
return gson.toJson(
114+
mapOf(
115+
"ok" to false,
116+
"error" to "read_failed",
117+
"detail" to (it.message ?: "")
118+
)
119+
)
120+
}
121+
val pos = resolveProbePosition(content, line, column, word, occurrence)
122+
?: return gson.toJson(
123+
mapOf(
124+
"ok" to false,
125+
"error" to "position_not_found",
126+
"word" to word,
127+
"line" to line,
128+
"column" to column
129+
)
130+
)
131+
runCatching { manager.openDocument(file, languageId, content) }
132+
val started = System.currentTimeMillis()
133+
val probe = manager.definitionDetailed(file, languageId, pos.first, pos.second)
134+
val locations = probe.locations
135+
val resolved = locations.map { loc ->
136+
val target = manager.resolveNavigationLocation(file, languageId, loc)
137+
mapOf(
138+
"raw_uri" to loc.uri,
139+
"raw_file" to loc.file.absolutePath,
140+
"raw_line" to loc.line,
141+
"raw_column" to loc.column,
142+
"needs_class_contents" to EditorJdtClassFileSupport.needsClassFileContents(loc),
143+
"resolved_file" to target?.file?.absolutePath,
144+
"resolved_exists" to (target?.file?.isFile == true),
145+
"resolved_size" to (target?.file?.takeIf { it.isFile }?.length() ?: -1L),
146+
"resolved_line" to target?.line,
147+
"resolved_column" to target?.column
148+
)
149+
}
150+
val elapsed = System.currentTimeMillis() - started
151+
val snippetLine = content.lineSequence().elementAtOrNull(pos.first).orEmpty()
152+
var navigated = false
153+
var navigateError: String? = null
154+
if (navigate) {
155+
val target = locations.firstOrNull()?.let { loc ->
156+
manager.resolveNavigationLocation(file, languageId, loc)
157+
}
158+
if (target != null && target.file.isFile) {
159+
navigated = manager.openLocationInHost(target)
160+
if (!navigated) navigateError = "host_open_failed"
161+
} else {
162+
navigateError = "no_resolved_target"
163+
}
164+
}
165+
return gson.toJson(
166+
mapOf(
167+
"ok" to true,
168+
"file" to file.absolutePath,
169+
"language_id" to languageId,
170+
"query" to mapOf(
171+
"line" to pos.first,
172+
"column" to pos.second,
173+
"word" to word,
174+
"line_text" to snippetLine,
175+
"uri" to EditorLspUris.forFile(file)
176+
),
177+
"count" to locations.size,
178+
"elapsed_ms" to elapsed,
179+
"probe_source" to probe.source,
180+
"prefer_type" to probe.preferType,
181+
"raw_definition" to probe.rawDefinition,
182+
"raw_typeDefinition" to probe.rawTypeDefinition,
183+
"locations" to resolved,
184+
"navigate" to navigate,
185+
"navigated" to navigated,
186+
"navigate_error" to navigateError,
187+
"hint_zh" to if (locations.isEmpty()) {
188+
"定义为空:看 raw_definition;方法名应走 definition,类型名才走 typeDefinition"
189+
} else {
190+
"resolved_line/column 即为方法在源码中的位置;navigate=true 可在编辑器打开"
191+
},
192+
"recent_events" to EditorLspDebugStore.recentEvents(15)
193+
)
194+
)
195+
}
196+
197+
private fun resolveProbeFile(path: String?, manager: EditorLspManager): File? {
198+
val trimmed = path?.trim().orEmpty()
199+
if (trimmed.isNotEmpty()) {
200+
val direct = File(trimmed)
201+
if (direct.isFile) return direct
202+
val underHome = File(TermuxConstants.TERMUX_HOME_DIR, trimmed.removePrefix("/"))
203+
if (underHome.isFile) return underHome
204+
return null
205+
}
206+
val open = manager.debugStatus()["open_documents"] as? List<*>
207+
val firstUri = open?.firstOrNull()?.toString().orEmpty()
208+
if (firstUri.isBlank()) return null
209+
val p = EditorLspUris.pathOf(firstUri)
210+
return p.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
211+
}
212+
213+
private fun resolveProbePosition(
214+
content: String,
215+
line: Int?,
216+
column: Int?,
217+
word: String?,
218+
occurrence: Int
219+
): Pair<Int, Int>? {
220+
val w = word?.trim().orEmpty()
221+
if (w.isNotEmpty()) {
222+
var seen = 0
223+
content.lineSequence().forEachIndexed { idx, text ->
224+
var from = 0
225+
while (true) {
226+
val at = text.indexOf(w, from)
227+
if (at < 0) break
228+
if (seen == occurrence.coerceAtLeast(0)) {
229+
val mid = at + w.length / 2
230+
return idx to mid
231+
}
232+
seen++
233+
from = at + w.length
234+
}
235+
}
236+
return null
237+
}
238+
if (line == null || column == null) return null
239+
return line.coerceAtLeast(0) to column.coerceAtLeast(0)
240+
}
76241
}

app/src/main/java/com/termux/zerocore/aidebug/ZtAiDebugHttpServer.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ class ZtAiDebugHttpServer(
110110
val lines = session.parms["lines"]?.toIntOrNull() ?: 120
111111
textResponse(ZtAiDebugEditorLspHelper.stderrJson(lines), MIME_JSON)
112112
}
113+
uri == "/api/editor/lsp/definition" && method == Method.POST ->
114+
handleEditorLspDefinition(session)
113115
uri == "/api/root/status" && method == Method.GET ->
114116
textResponse(ZtAiDebugRootHelper.statusJson(appContext), MIME_JSON)
115117
uri == "/api/root/exec" && method == Method.POST ->
@@ -640,6 +642,37 @@ class ZtAiDebugHttpServer(
640642
)
641643
}
642644

645+
private fun handleEditorLspDefinition(session: IHTTPSession): Response {
646+
val body = readBody(session)
647+
var path: String? = session.parms["path"]
648+
var line: Int? = session.parms["line"]?.toIntOrNull()
649+
var column: Int? = session.parms["column"]?.toIntOrNull()
650+
var word: String? = session.parms["word"]
651+
var occurrence = session.parms["occurrence"]?.toIntOrNull() ?: 0
652+
var navigate = session.parms["navigate"]?.equals("true", true) == true ||
653+
session.parms["navigate"] == "1"
654+
if (body.isNotBlank()) {
655+
try {
656+
val obj = JsonParser.parseString(body).asJsonObject
657+
if (obj.has("path")) path = obj.get("path").asString
658+
if (obj.has("line") && !obj.get("line").isJsonNull) line = obj.get("line").asInt
659+
if (obj.has("column") && !obj.get("column").isJsonNull) column = obj.get("column").asInt
660+
if (obj.has("word")) word = obj.get("word").asString
661+
if (obj.has("occurrence") && !obj.get("occurrence").isJsonNull) {
662+
occurrence = obj.get("occurrence").asInt
663+
}
664+
if (obj.has("navigate") && !obj.get("navigate").isJsonNull) {
665+
navigate = obj.get("navigate").asBoolean
666+
}
667+
} catch (_: Exception) {
668+
}
669+
}
670+
return textResponse(
671+
ZtAiDebugEditorLspHelper.definitionJson(path, line, column, word, occurrence, navigate),
672+
MIME_JSON
673+
)
674+
}
675+
643676
private fun parseBool(session: IHTTPSession, body: String, vararg keys: String): Boolean {
644677
keys.forEach { key ->
645678
session.parms[key]?.trim()?.let {

app/src/main/java/com/termux/zerocore/editor/lsp/EditorJdtClassFileSupport.kt

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object EditorJdtClassFileSupport {
1818
val u = uri.trim()
1919
if (u.isEmpty()) return false
2020
if (u.startsWith("jdt:", ignoreCase = true)) return true
21+
if (u.startsWith("jar:", ignoreCase = true) && u.contains(".class", ignoreCase = true)) return true
2122
if (u.contains(".class?", ignoreCase = true)) return true
2223
if (u.endsWith(".class", ignoreCase = true)) return true
2324
val path = EditorLspUris.pathOf(u)
@@ -26,7 +27,19 @@ object EditorJdtClassFileSupport {
2627

2728
fun needsClassFileContents(location: EditorLspLocation): Boolean {
2829
if (location.uri.isNotBlank() && isClassContentUri(location.uri)) return true
29-
return location.file.name.endsWith(".class", ignoreCase = true)
30+
if (location.file.name.endsWith(".class", ignoreCase = true)) return true
31+
// 本地文件不存在时:JDK/依赖里的类型常只有 URI,需走 classFileContents 拉源码
32+
if (!location.file.isFile && location.uri.isNotBlank()) {
33+
val u = location.uri
34+
if (u.startsWith("jdt:", ignoreCase = true)) return true
35+
if (u.contains(".class", ignoreCase = true)) return true
36+
if (u.startsWith("jar:", ignoreCase = true)) return true
37+
// file:///…/src.zip!/java.base/java/lang/System.java
38+
if (u.contains("!/") || u.contains(".zip!", ignoreCase = true) || u.contains(".jar!", ignoreCase = true)) {
39+
return true
40+
}
41+
}
42+
return false
3043
}
3144

3245
fun cacheFileFor(uri: String): File {
@@ -60,18 +73,77 @@ object EditorJdtClassFileSupport {
6073
""
6174
}
6275
}
63-
if (uri.isBlank() || !isClassContentUri(uri)) {
76+
if (uri.isBlank()) {
6477
return location.takeIf { it.file.isFile }
6578
}
66-
val cache = cacheFileFor(uri)
67-
if (!cache.isFile || cache.length() == 0L) {
68-
val contents = client.classFileContents(uri)?.takeIf { it.isNotBlank() } ?: return null
69-
runCatching {
70-
cache.parentFile?.mkdirs()
71-
cache.writeText(contents)
72-
}.getOrElse { return null }
79+
if (location.file.isFile && !isClassContentUri(uri) && !uri.contains("!/")) {
80+
return location
81+
}
82+
// zip/jar 内 .java:优先解压到缓存
83+
extractZipEntryToCache(uri)?.let { cached ->
84+
return location.copy(file = cached, uri = uri)
7385
}
74-
return location.copy(file = cache, uri = uri)
86+
// jdt:// 或 *.class:java/classFileContents(反编译或附着源码)
87+
if (isClassContentUri(uri) || uri.startsWith("jdt:", ignoreCase = true)) {
88+
val cache = cacheFileFor(uri)
89+
if (!cache.isFile || cache.length() == 0L) {
90+
val contents = client.classFileContents(uri)?.takeIf { it.isNotBlank() }
91+
if (contents.isNullOrBlank()) {
92+
EditorLspDebugStore.recordEvent(
93+
"error",
94+
"classFileContents failed",
95+
mapOf("uri" to uri.take(240))
96+
)
97+
return null
98+
}
99+
runCatching {
100+
cache.parentFile?.mkdirs()
101+
cache.writeText(contents)
102+
}.getOrElse { return null }
103+
}
104+
return location.copy(file = cache, uri = uri)
105+
}
106+
return location.takeIf { it.file.isFile }
107+
}
108+
109+
/** file:///path/src.zip!/entry 或 jar:file:///path.jar!/entry → 缓存 .java */
110+
private fun extractZipEntryToCache(uri: String): File? {
111+
val decoded = runCatching {
112+
URLDecoder.decode(uri, StandardCharsets.UTF_8.name())
113+
}.getOrDefault(uri)
114+
val marker = "!/"
115+
val idx = decoded.indexOf(marker)
116+
if (idx < 0) return null
117+
var archivePart = decoded.substring(0, idx)
118+
val entryName = decoded.substring(idx + marker.length).trimStart('/')
119+
if (entryName.isBlank()) return null
120+
if (archivePart.startsWith("jar:", ignoreCase = true)) {
121+
archivePart = archivePart.removePrefix("jar:").removePrefix("JAR:")
122+
}
123+
val archivePath = when {
124+
archivePart.startsWith("file:", ignoreCase = true) -> EditorLspUris.pathOf(archivePart)
125+
archivePart.startsWith("/") -> archivePart
126+
else -> EditorLspUris.pathOf(archivePart)
127+
}
128+
if (archivePath.isBlank()) return null
129+
val archive = File(archivePath)
130+
if (!archive.isFile) return null
131+
val cache = cacheFileFor(uri)
132+
if (cache.isFile && cache.length() > 0L) return cache
133+
return runCatching {
134+
java.util.zip.ZipFile(archive).use { zip ->
135+
val entry = zip.getEntry(entryName)
136+
?: zip.getEntry(entryName.removePrefix("/"))
137+
?: return@use null
138+
zip.getInputStream(entry).bufferedReader(StandardCharsets.UTF_8).use { reader ->
139+
val text = reader.readText()
140+
if (text.isBlank()) return@use null
141+
cache.parentFile?.mkdirs()
142+
cache.writeText(text)
143+
cache
144+
}
145+
}
146+
}.getOrNull()
75147
}
76148

77149
private fun sha1Hex(text: String): String {

app/src/main/java/com/termux/zerocore/editor/lsp/EditorJdtLsSupport.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ object EditorJdtLsSupport {
2121
const val INIT_TIMEOUT_MILLIS = 120_000L
2222
/** 补全请求超时:过长会导致编辑器补全面板进度条一直转。 */
2323
const val COMPLETION_TIMEOUT_MILLIS = 8_000L
24+
/** 转到定义 / 引用 / class 源码拉取:JDK 类型首次解析较慢。 */
25+
const val NAVIGATION_TIMEOUT_MILLIS = 25_000L
2426

2527
fun installDir(): File = File(EditorLspInstaller.baseDir(), INSTALL_DIR_NAME)
2628

0 commit comments

Comments
 (0)