diff --git a/.omp/lsp.json b/.omp/lsp.json new file mode 100644 index 0000000..0fde758 --- /dev/null +++ b/.omp/lsp.json @@ -0,0 +1,12 @@ +{ + "servers": { + "kotlin-lsp": { + "command": "kotlin-language-server", + "args": [], + "fileTypes": [".kt", ".kts"], + "rootMarkers": ["build.gradle", "settings.gradle"], + "warmupTimeoutMs": 60000 + } + }, + "idleTimeoutMs": 600000 +} diff --git a/AGENTS.md b/AGENTS.md index 67b6427..8271e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **android-backup-gui** (1614 symbols, 4022 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index b804a9b..3f4ddd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **android-backup-gui** (1614 symbols, 4022 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/app/build.gradle b/app/build.gradle index 5ea0f7d..67281ca 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,8 +24,8 @@ android { applicationId "com.example.androidbackupgui" minSdk 24 targetSdk 34 - versionCode 14 - versionName "1.14" + versionCode 15 + versionName "1.15" } buildFeatures { compose = true diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..8dca888 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,1742 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/release/AndroidBackupGUI-release.apk b/app/release/AndroidBackupGUI-release.apk new file mode 100644 index 0000000..4a6d7ee Binary files /dev/null and b/app/release/AndroidBackupGUI-release.apk differ diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt index 523e960..06038ff 100644 --- a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt +++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt @@ -72,16 +72,29 @@ object BackupOperation { // Create backup structure val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId") - backupRoot.mkdirs() + if (!backupRoot.mkdirs() && !backupRoot.isDirectory) { + LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}") + return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) + } LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}") // Write app list — includes ALL packages in [apps] (selected + legacy from snapshot) val appListFile = File(backupRoot, "appList.txt") - appListFile.writeText(apps.joinToString("\n") { it.packageName.value }) + try { + appListFile.writeText(apps.joinToString("\n") { it.packageName.value }) + } catch (e: Exception) { + LogUtil.e(TAG, "backupApps: failed to write appList.txt — ${e.message}") + return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) + } // Write metadata JSON — fresh metadata for selected apps, legacy for historical apps val metaFile = File(backupRoot, "app_details.json") - metaFile.writeText(buildAppDetailsJson(apps, legacyApps)) + try { + metaFile.writeText(buildAppDetailsJson(apps, legacyApps)) + } catch (e: Exception) { + LogUtil.e(TAG, "backupApps: failed to write app_details.json — ${e.message}") + return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0) + } val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs } val totalCount = backupTargets.size @@ -341,15 +354,23 @@ object BackupOperation { ?.substringBefore("\"") ?.takeIf { it.isNotBlank() } if (value != null) { - File(appDir, "ssaid.txt").writeText(value) - Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value") + try { + File(appDir, "ssaid.txt").writeText(value) + Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value") + } catch (e: Exception) { + Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName", e) + } } } private suspend fun backupPermissions(packageName: String, appDir: File) { val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'") if (result.output.isNotBlank()) { - File(appDir, "permissions.txt").writeText(result.output) + try { + File(appDir, "permissions.txt").writeText(result.output) + } catch (e: Exception) { + Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName", e) + } } } diff --git a/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt b/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt index 7cabc91..8786d54 100644 --- a/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt +++ b/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt @@ -3,6 +3,7 @@ package com.example.androidbackupgui.ui import android.content.Intent import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import android.util.Log import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SortByAlpha @@ -269,8 +270,18 @@ fun BackupScreen() { } } } catch (e: Exception) { - statusText = "备份异常: ${e.message}" - } finally { + val errMsg = e.message ?: "未知错误" + Log.e("BackupScreen", "备份异常", e) + val hint = when { + errMsg.contains("EPERM", ignoreCase = true) || errMsg.contains("Operation not permitted", ignoreCase = true) -> + "写入备份目录被拒绝,请检查输出路径权限或改用内置存储" + errMsg.contains("EACCES", ignoreCase = true) || errMsg.contains("Permission denied", ignoreCase = true) -> + "权限不足,请检查存储权限" + else -> null + } + statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}" + } + finally { isRunning = false try { val stopIntent = Intent(context, BackupService::class.java).apply { diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..7560adf --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/android/util/Log.java b/app/src/test/java/android/util/Log.java new file mode 100644 index 0000000..deb1a03 --- /dev/null +++ b/app/src/test/java/android/util/Log.java @@ -0,0 +1,23 @@ +package android.util; + +/** + * Test-only stub for android.util.Log. + * Prevents RuntimeException("Stub!") from android.jar during JVM unit tests. + */ +public final class Log { + public static int v(String tag, String msg) { return 0; } + public static int v(String tag, String msg, Throwable tr) { return 0; } + public static int d(String tag, String msg) { return 0; } + public static int d(String tag, String msg, Throwable tr) { return 0; } + public static int i(String tag, String msg) { return 0; } + public static int i(String tag, String msg, Throwable tr) { return 0; } + public static int w(String tag, String msg) { return 0; } + public static int w(String tag, String msg, Throwable tr) { return 0; } + public static int e(String tag, String msg) { return 0; } + public static int e(String tag, String msg, Throwable tr) { return 0; } + public static int wtf(String tag, String msg) { return 0; } + public static int wtf(String tag, String msg, Throwable tr) { return 0; } + public static String getStackTraceString(Throwable tr) { return ""; } + public static boolean isLoggable(String tag, int level) { return false; } + public static int println(int priority, String tag, String msg) { return 0; } +} diff --git a/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt new file mode 100644 index 0000000..a489cb9 --- /dev/null +++ b/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt @@ -0,0 +1,273 @@ +package com.example.androidbackupgui.backup + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.string +import io.kotest.property.checkAll +import java.io.IOException + +class AppErrorTest : FunSpec({ + + context("AppError.Network") { + test("has correct defaults") { + val error = AppError.Network("connection timeout") + error.message shouldBe "connection timeout" + error.cause.shouldBeNull() + error.retryable shouldBe true + } + + test("preserves cause and retryable overrides") { + val cause = RuntimeException("DNS failed") + val error = AppError.Network("DNS resolution failed", cause = cause, retryable = false) + error.cause shouldBe cause + error.retryable shouldBe false + } + + test("property: message is preserved") { + checkAll(Arb.string(1..200)) { msg -> + val error = AppError.Network(msg) + error.message shouldBe msg + } + } + } + + context("AppError.Remote") { + test("preserves phase, cause, isNotFound, retryable") { + val cause = RuntimeException("underlying error") + val error = AppError.Remote("upload failed", "upload", cause = cause) + error.message shouldBe "upload failed" + error.phase shouldBe "upload" + error.cause shouldBe cause + error.isNotFound shouldBe false + error.retryable shouldBe false + } + + test("with isNotFound=true") { + val error = AppError.Remote("not found", "list", isNotFound = true) + error.isNotFound shouldBe true + } + } + + context("AppError.Shell") { + test("preserves command, exitCode, and stderr") { + val error = AppError.Shell("cp failed", "cp /a /b", 1, "permission denied") + error.message shouldBe "cp failed" + error.command shouldBe "cp /a /b" + error.exitCode shouldBe 1 + error.stderr shouldBe "permission denied" + } + } + + context("AppError.LocalIO") { + test("preserves path and optional cause") { + val error = AppError.LocalIO("file not found", "/data/test.txt") + error.message shouldBe "file not found" + error.path shouldBe "/data/test.txt" + error.cause.shouldBeNull() + } + + test("preserves cause when provided") { + val cause = IOException("disk full") + val error = AppError.LocalIO("write failed", "/data/test.txt", cause = cause) + error.cause shouldBe cause + } + } + + context("AppError.Restic") { + test("preserves exit code and stderr") { + val error = AppError.Restic("restic failed", 1, "permission denied") + error.message shouldBe "restic failed" + error.exitCode shouldBe 1 + error.stderr shouldBe "permission denied" + } + + test("property: any exit code is preserved") { + checkAll(Arb.int()) { code -> + val error = AppError.Restic("err", code, "stderr output") + error.exitCode shouldBe code + } + } + } + + context("AppError.Parse") { + test("preserves message and detail") { + val error = AppError.Parse("bad json", "expected '{'") + error.message shouldBe "bad json" + error.detail shouldBe "expected '{'" + } + + test("detail defaults to empty string") { + val error = AppError.Parse("bad json") + error.detail shouldBe "" + } + } + + context("AppError.Cancelled") { + test("is a data object with fixed message") { + val error = AppError.Cancelled + error.message shouldBe "操作被取消" + // Verify singleton behavior + val error2 = AppError.Cancelled + error shouldBe error2 + } + } + + context("AppResult.Success") { + test("holds a value") { + val result: AppResult = AppResult.Success("hello") + result.isSuccess shouldBe true + result.isFailure shouldBe false + result.getOrNull() shouldBe "hello" + result.getOrDefault("fallback") shouldBe "hello" + result.getOrThrow() shouldBe "hello" + result.exceptionOrNull().shouldBeNull() + result.errorOrNull().shouldBeNull() + } + + test("fold calls onSuccess") { + val result: AppResult = AppResult.Success(42) + val folded = + result.fold( + onSuccess = { it * 2 }, + onFailure = { 0 }, + ) + folded shouldBe 84 + } + + test("map transforms value") { + val result: AppResult = AppResult.Success(42) + val mapped = result.map { it.toString() } + mapped shouldBe AppResult.Success("42") + } + + test("mapError passes through success") { + val result: AppResult = AppResult.Success(42) + val mapped = result.mapError { AppError.Parse("should not happen") } + mapped shouldBe AppResult.Success(42) + } + } + + context("AppResult.Failure via err()") { + test("creates failure result") { + val result: AppResult = err(AppError.Parse("bad json")) + result.isSuccess shouldBe false + result.isFailure shouldBe true + result.getOrNull().shouldBeNull() + result.getOrDefault("fallback") shouldBe "fallback" + result.errorOrNull() shouldBe AppError.Parse("bad json") + result.errorOrNull()?.message shouldBe "bad json" + } + + test("exceptionOrNull returns RuntimeException with AppError message") { + val result: AppResult = err(AppError.Parse("bad json")) + val ex = result.exceptionOrNull() + ex.shouldBeInstanceOf() + ex?.message shouldBe "bad json" + } + + test("getOrThrow throws RuntimeException") { + val result: AppResult = err(AppError.Parse("bad json")) + val ex = shouldThrow { result.getOrThrow() } + ex.message shouldBe "bad json" + } + + test("wraps any AppError subtype") { + val errors = + listOf( + AppError.Network("net err"), + AppError.Remote("remote err", "connect"), + AppError.Shell("shell err", "ls", 1, ""), + AppError.LocalIO("io err", "/tmp"), + AppError.Restic("restic err", 1, ""), + AppError.Parse("parse err"), + AppError.Cancelled, + ) + errors.forEach { error -> + val result: AppResult = err(error) + result.isFailure shouldBe true + result.errorOrNull()?.message shouldBe error.message + } + } + } + + context("AppResult.Failure direct") { + test("holds an error") { + val error = AppError.Network("network error") + val result: AppResult = AppResult.Failure(error) + result.isSuccess shouldBe false + result.isFailure shouldBe true + result.errorOrNull() shouldBe error + } + + test("fold calls onFailure") { + val result: AppResult = AppResult.Failure(AppError.Parse("parse failed")) + val folded = + result.fold( + onSuccess = { 0 }, + onFailure = { error -> error.message.length }, + ) + folded shouldBe "parse failed".length + } + + test("map passes through failure") { + val error = AppError.Parse("no data") + val result: AppResult = AppResult.Failure(error) + val mapped = result.map { it + 1 } + mapped shouldBe AppResult.Failure(error) + } + + test("mapError transforms error") { + val result: AppResult = AppResult.Failure(AppError.Parse("old error")) + val mapped = result.mapError { AppError.Remote("mapped: ${it.message}", "transform") } + mapped.errorOrNull()?.message shouldBe "mapped: old error" + } + } + + context("AppResult exhaustive when") { + test("can pattern match with is AppResult.Success") { + val result: AppResult = AppResult.Success("data") + val output = + when (result) { + is AppResult.Success -> "got: ${result.data}" + is AppResult.Failure -> "err: ${result.error.message}" + } + output shouldBe "got: data" + } + + test("can pattern match with is AppResult.Failure") { + val result: AppResult = AppResult.Failure(AppError.Cancelled) + val output = + when (result) { + is AppResult.Success -> "got: ${result.data}" + is AppResult.Failure -> "err: ${result.error.message}" + } + output shouldBe "err: 操作被取消" + } + } + + context("AppResult type inference") { + test("AppResult.Success with Unit") { + val result: AppResult = AppResult.Success(Unit) + result.isSuccess shouldBe true + result.getOrDefault(Unit) shouldBe Unit + } + + test("AppResult.Failure with Nothing") { + val result: AppResult = AppResult.Failure(AppError.Cancelled) + result.isFailure shouldBe true + } + } + + context("err function short-form") { + test("err() returns AppResult.Failure") { + val result: AppResult = err(AppError.Remote("upload failed", "upload")) + result.shouldBeInstanceOf() + (result as AppResult.Failure).error shouldBe AppError.Remote("upload failed", "upload") + } + } +}) diff --git a/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt new file mode 100644 index 0000000..1f456c8 --- /dev/null +++ b/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt @@ -0,0 +1,13 @@ +package com.example.androidbackupgui.backup + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class ResticBinaryTest : FunSpec({ + + context("ResticBinary") { + test("isReady returns false before prepare is called") { + ResticBinary.isReady() shouldBe false + } + } +}) diff --git a/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt new file mode 100644 index 0000000..46e8f44 --- /dev/null +++ b/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt @@ -0,0 +1,110 @@ +package com.example.androidbackupgui.backup + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.string +import io.kotest.property.checkAll +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class ResticCommandRunnerTest : FunSpec({ + + val defaultRunner = ResticCommandRunner() + + context("buildCommandArgs") { + test("prepends default binary path") { + val args = defaultRunner.buildCommandArgs(listOf("init", "--json")) + args shouldBe listOf("restic", "init", "--json") + } + + test("uses custom binary path") { + val runner = ResticCommandRunner() + runner.binaryPath = "/data/data/com.termux/files/usr/bin/restic" + val args = runner.buildCommandArgs(listOf("backup", "/sdcard")) + args shouldBe + listOf( + "/data/data/com.termux/files/usr/bin/restic", + "backup", + "/sdcard", + ) + } + + test("returns empty args list when called with empty list") { + val args = defaultRunner.buildCommandArgs(emptyList()) + args shouldBe listOf("restic") + } + + test("preserves argument order") { + val runner = ResticCommandRunner() + runner.binaryPath = "restic" + val args = runner.buildCommandArgs(listOf("a", "b", "c")) + args shouldBe listOf("restic", "a", "b", "c") + } + + test("property: any list of string args mainatains length") { + checkAll(Arb.list(Arb.string(1..20), 0..10)) { inputArgs -> + val args = defaultRunner.buildCommandArgs(inputArgs) + args shouldHaveSize (inputArgs.size + 1) + args[0] shouldBe "restic" + } + } + } + + context("runRestic(vararg)") { + test("delegates to runRestic(List) and returns failure on nonexistent binary") { + val runner = ResticCommandRunner() + runner.binaryPath = "/nonexistent/restic" + val result = runner.runRestic(mapOf("RESTIC_REPOSITORY" to "/tmp/repo"), "version") + result.exitCode shouldBe -1 + result.stdout shouldBe "" + } + } + + context("CommandResult serialization") { + test("serializes and deserializes correctly") { + val original = + ResticCommandRunner.CommandResult( + stdout = "some output", + stderr = "", + exitCode = 0, + ) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + decoded.stdout shouldBe "some output" + decoded.stderr shouldBe "" + decoded.exitCode shouldBe 0 + } + + test("roundtrip property: preserves exit code") { + checkAll(Arb.int()) { code -> + val original = + ResticCommandRunner.CommandResult( + stdout = "out", + stderr = code.toString(), + exitCode = code, + ) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + decoded.exitCode shouldBe code + decoded.stderr shouldBe code.toString() + } + } + } + + context("ResticCommandRunner instantiation") { + test("default binary path is restic") { + defaultRunner.binaryPath shouldBe "restic" + } + + test("can set custom binary path") { + val runner = ResticCommandRunner() + runner.binaryPath = "/custom/path/restic" + runner.binaryPath shouldBe "/custom/path/restic" + } + } +}) diff --git a/ktlint.py b/ktlint.py new file mode 100755 index 0000000..6e874b0 --- /dev/null +++ b/ktlint.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Kotlin LSP client for code diagnostics. Collects all LSP messages.""" +import subprocess, json, sys, os, signal, time +from pathlib import Path + +def run_diagnostics(project_dir: str, file_path: str, timeout: int = 60): + proc = subprocess.Popen( + ['/usr/local/bin/kotlin-language-server'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=project_dir, preexec_fn=lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL) + ) + + def send(msg): + data = json.dumps(msg).encode('utf-8') + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode('utf-8')) + proc.stdin.write(data) + proc.stdin.flush() + + def recv(timeout_s=5): + content_length = 0 + end = time.time() + timeout_s + while time.time() < end: + if proc.poll() is not None: + return None + ready = proc.stdout.readable() + if not ready: + time.sleep(0.05) + continue + line = proc.stdout.readline() + if not line: + time.sleep(0.05) + continue + line = line.decode('utf-8', errors='replace').strip() + if line.startswith('Content-Length:'): + content_length = int(line.split(':')[1].strip()) + elif line == '' and content_length > 0: + body = proc.stdout.read(content_length).decode('utf-8', errors='replace') + return json.loads(body) + return 'TIMEOUT' + + all_msgs = [] + + send({ + 'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', + 'params': { + 'processId': os.getpid(), + 'capabilities': { + 'textDocument': {'diagnostics': {'dynamicRegistration': False}}, + 'workspace': {'didChangeWatchedFiles': {'dynamicRegistration': False}} + }, + 'rootUri': f'file://{project_dir}', + 'workspaceFolders': [{'uri': f'file://{project_dir}', 'name': Path(project_dir).name}] + } + }) + + # Read all messages until we get initialize result + end = time.time() + timeout + init_ok = False + while time.time() < end and not init_ok: + msg = recv(5) + if msg is None: + break + if msg == 'TIMEOUT': + continue + all_msgs.append(('init', msg)) + if msg.get('id') == 1 and 'result' in msg: + init_ok = True + + if not init_ok: + return all_msgs, f'INIT_TIMEOUT after {timeout}s' + + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + + # Open file + file_uri = f'file://{file_path}' + with open(file_path) as f: + content = f.read() + + send({ + 'jsonrpc': '2.0', 'method': 'textDocument/didOpen', + 'params': { + 'textDocument': { + 'uri': file_uri, 'languageId': 'kotlin', + 'version': 1, 'text': content + } + } + }) + + # Collect messages for remaining time + end = time.time() + 30 + while time.time() < end: + msg = recv(3) + if msg is None or msg == 'TIMEOUT': + continue + all_msgs.append(('open', msg)) + + # Shutdown + send({'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown', 'params': {}}) + + try: + proc.terminate() + proc.wait(timeout=3) + except: + proc.kill() + + return all_msgs, 'OK' + +if __name__ == '__main__': + file_path = os.path.abspath(sys.argv[1]) + project_dir = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else os.getcwd() + print(f'Project: {project_dir}') + print(f'File: {file_path}\n') + + msgs, status = run_diagnostics(project_dir, file_path) + + print(f'Status: {status}') + print(f'Messages received: {len(msgs)}\n') + + diag_count = 0 + for phase, msg in msgs: + method = msg.get('method', '?') + if 'id' in msg: + method = f'response(id={msg["id"]})' + if 'error' in msg: + print(f' [{phase}] {method} ERROR: {msg["error"]}') + elif method == 'window/logMessage': + print(f' [{phase}] log: {msg.get("params",{}).get("message","")}') + elif method == 'window/showMessage': + print(f' [{phase}] show: {msg.get("params",{}).get("message","")}') + elif method == 'textDocument/publishDiagnostics': + diags = msg.get('params', {}).get('diagnostics', []) + diag_count += len(diags) + uri = msg.get('params', {}).get('uri', '') + print(f' [{phase}] publishDiagnostics ({len(diags)} items): {os.path.basename(uri)}') + for d in diags: + r = d.get('range', {}) + s = r.get('start', {}) + sev = {1:'E',2:'W',3:'I',4:'H'}.get(d.get('severity'),'?') + print(f' {sev} {s.get("line",0)+1}:{s.get("character",0)+1} {d.get("message","")}') + elif method.startswith('response'): + if 'result' in msg: + caps = msg.get('result', {}).get('capabilities', {}) + print(f' [{phase}] {method} capabilities: {json.dumps(caps, indent=2)[:400]}') + else: + print(f' [{phase}] {method}') + else: + print(f' [{phase}] {method}: {json.dumps(msg, indent=2)[:200]}') + + print(f'\nTotal diagnostics: {diag_count}')