From da90c4f4d94f0b1d98b87252998a3f7541198e31 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 6 Oct 2026 11:23:23 +0800 Subject: [PATCH 01/14] docs: add next-generation editor roadmap --- docs/roadmap/2026-editor-next.md | 458 +++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 docs/roadmap/2026-editor-next.md diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md new file mode 100644 index 0000000..d08615a --- /dev/null +++ b/docs/roadmap/2026-editor-next.md @@ -0,0 +1,458 @@ +# JEditor Next-Generation Editor Roadmap + +> Status: planning / draft +> Target base: `dev` +> Scope: the next architectural generation of JEditor, covering the 11 capabilities below. +> +> This document is deliberately a roadmap, not an implementation PR. Each milestone should land as a +> separate, reviewable PR with tests and documentation. The order below is dependency-driven. + +## Goals + +Turn JEditor from a feature-rich desktop editor into a reusable editor platform: + +1. keep the current PySide6 desktop application working while the internals are migrated; +2. make workspace, document, diagnostics, debug, terminal and remote execution services independent of + the main window; +3. make the editor embeddable as a normal Qt component without requiring the full IDE shell; +4. make syntax parsing, language-server diagnostics and debugging first-class services; +5. keep provider-specific integrations (LLMs, remote transports, language servers) behind small + interfaces. + +## Current state + +JEditor already provides several foundations: + +- PySide6 desktop UI and an `EditorMain(extend=True)` embedding mode; +- a public `EditorWidget` / `FullEditorWidget` API and `EDITOR_EXTEND_TAB`; +- configurable shortcut registry with conflict detection; +- English / Traditional Chinese / Simplified Chinese / Japanese dictionaries and plugin language + registration; +- LSP sessions and diagnostics, including a Problems panel; +- a basic debugger based on pdb/process control; +- LangChain + OpenAI AI chat; +- project file indexing and a single project-root-oriented editor workflow; +- pluggable syntax highlighting; +- Sphinx documentation and API documentation. + +The roadmap therefore focuses on **consolidation and architectural boundaries**, not replacing working +features merely for the sake of replacement. + +## Architecture direction + +The target dependency direction is: + +``` +Qt Application / IDE Shell + | + +-- Workspace UI + +-- Editor UI + +-- Problems / Debug / Terminal UI + +-- AI UI + | + v +JEditor Core Services + | + +-- Workspace / Project Model + +-- Document Model + +-- Language Service (Tree-sitter + LSP) + +-- Diagnostics Model + +-- Debug Adapter Protocol + +-- Task / Process Service + +-- Remote Session Service + +-- AI Provider Interface + | + v +Adapters / Providers + | + +-- local filesystem / process + +-- SSH / remote transport + +-- LSP servers + +-- DAP servers + +-- OpenAI / Anthropic / future providers +``` + +The Qt widgets should consume these services instead of owning their protocols and process lifetime. +Existing APIs remain compatibility shims during migration. + +--- + +## Milestone plan + +### M0 — Foundation and compatibility boundary + +**Purpose:** create the seams required by the rest of the roadmap without changing the visible product. + +- Define stable interfaces for: + - workspace and project roots; + - documents / buffers; + - diagnostics; + - language services; + - debug sessions; + - process / task execution; + - remote sessions; + - AI providers. +- Move protocol/data objects into pure-Python modules where possible. +- Keep the current `EditorMain` and `EditorWidget` as compatibility facades. +- Add architecture tests that prevent core services from importing Qt widgets. +- Preserve current public exports and `extend=True` behavior. + +**Exit criteria:** the new service layer can be instantiated in a non-GUI test and the existing editor +still starts unchanged. + +--- + +### M1 — UI redesign + shortcut system + i18n completion + +**Scope** + +1. UI 重新設計 +2. 可以自訂快捷鍵 +3. 補齊其他語系 dict + +**UI direction** + +- Introduce a consistent shell layout: activity/navigation area, editor area, secondary panel and + bottom panel. +- Separate layout state from feature state so panels can be rearranged without reconstructing the + editor. +- Use semantic commands rather than widget-specific actions as the UI contract. +- Keep the current qt-material theming compatible during the transition. + +**Shortcuts** + +- Make every user-facing command register through the command/shortcut registry. +- Support multi-stroke sequences where Qt permits them. +- Detect conflicts before applying settings. +- Persist only overrides from defaults. +- Expose a command identifier independent from translated labels. + +**i18n** + +- Keep English as the canonical key set. +- Add a parity checker that verifies every dictionary has the same keys and placeholders. +- Prefer English fallback for incomplete translations. +- Make locale loading data-driven rather than hard-coded. +- Do not translate language names or other identifiers that must remain recognizable. + +**Exit criteria:** changing language or shortcuts does not rebuild/destroy host-owned widgets; every +registered command has a stable ID; dictionary parity is CI-enforced. + +--- + +### M2 — Tree-sitter syntax engine + LSP diagnostics model + +**Scope** + +4. 語法高亮改用 Tree-sitter +5. Problem 面板整合語言伺服器診斷的嚴重度過濾 + +**Tree-sitter** + +- Introduce a parser service independent from Qt. +- Parse incrementally from the document buffer. +- Use Tree-sitter queries for syntax categories and structural regions. +- Keep the existing highlighter behind an adapter during migration. +- Reuse the parse tree later for folding, symbols, selection expansion and structural navigation. + +**Diagnostics** + +Normalize diagnostics from ruff and LSP into one model: + +- source; +- severity: Error / Warning / Information / Hint; +- code; +- message; +- URI/path; +- range; +- related information; +- optional quick-fix/edit metadata. + +Problems panel filters should support: + +- All; +- Errors; +- Warnings; +- Information; +- Hints; +- source/provider. + +**Exit criteria:** LSP and ruff findings render through the same diagnostic model; severity filters +are deterministic; Tree-sitter can parse supported languages without the widget layer knowing parser +details. + +--- + +### M3 — Workspace + multi-root projects + +**Scope** + +6. 工作區與多根專案 + +Introduce a first-class `Workspace` model: + +- one workspace can contain zero or more project roots; +- each root has its own URI/path, language/tool configuration and environment; +- documents resolve against the owning root; +- LSP sessions are keyed by server + root/workspace context; +- search, indexing, TODO scanning, Git and diagnostics become workspace-aware; +- session restore stores workspace identity and open documents. + +**Important design rule:** a single-root project remains a valid workspace and should behave almost +exactly as it does today. + +**Exit criteria:** opening two unrelated roots in one window works; language servers receive the +correct root; project-wide search and Problems aggregate both roots without path collisions. + +--- + +### M4 — Debugger migration to DAP + +**Scope** + +7. 除錯器補齊到 DAP + +Replace the current debugger-specific control path with a DAP client/service. + +Required baseline: + +- initialize / launch / attach; +- breakpoints and conditional breakpoints; +- stack frames; +- scopes / variables; +- continue / pause / terminate; +- step over / into / out; +- exception information; +- source locations; +- evaluate expression; +- threads. + +The existing pdb workflow should become a DAP adapter where practical rather than a second debugger +architecture. + +**Exit criteria:** the debugger UI depends on the DAP service, not on pdb implementation details; at +least one local adapter is covered by integration tests; the public debug API can later host remote +adapters. + +--- + +### M5 — AI provider abstraction + Anthropic backend + +**Scope** + +8. AI 助理加 Anthropic 後端 + +Refactor the existing LangChain/OpenAI implementation into a provider-neutral interface. + +Suggested abstraction: + +- model metadata; +- streaming response; +- cancellation; +- system prompt; +- conversation history; +- tool/context attachments; +- token/error reporting. + +Providers: + +- OpenAI — existing behavior preserved; +- Anthropic — first new backend; +- future providers should not require changes to the chat widget. + +Configuration should be provider-scoped rather than OpenAI-specific. + +**Exit criteria:** the same chat UI can switch between OpenAI and Anthropic without provider-specific +branches in the UI. + +--- + +### M6 — Remote development, moved down into JEditor + +**Scope** + +9. 遠端開發 (下沉到 JEditor) + +Remote development should be a **core service**, not a feature owned by the desktop shell. + +Introduce: + +- remote session lifecycle; +- filesystem abstraction; +- process/task execution; +- port forwarding abstraction; +- environment/interpreter discovery; +- remote LSP process launch; +- remote DAP process launch; +- reconnect/disconnect state. + +Initial transport can be SSH, but the service API must not expose SSH-specific concepts. + +The workspace model from M3 becomes the integration point: a root may be local or remote. + +**Exit criteria:** an editor document can be opened from a remote workspace and the same LSP/debug/task +APIs work without the UI knowing whether the process is local or remote. + +--- + +### M7 — Embeddable editor component + +**Scope** + +10. 發展成可嵌入的編輯器元件 + +Split the product into two explicit layers: + +**JEditor Core** + +- document/buffer; +- commands; +- workspace; +- language services; +- diagnostics; +- debug; +- task/process; +- remote; +- AI provider interfaces. + +**JEditor Widgets** + +- editor view; +- tabs; +- minimap; +- gutter; +- diagnostics presentation; +- completion UI; +- navigation UI. + +**JEditor IDE** + +- menus; +- toolbar; +- terminal; +- Git; +- browser; +- project explorer; +- settings; +- system tray. + +Target usage: + +```python +from je_editor.widgets import JEditor + +editor = JEditor(parent) +editor.open_workspace(...) +``` + +The exact API can be finalized during implementation, but the goal is a small constructor surface and +no implicit application-wide side effects. + +Embedding requirements: + +- no forced `QApplication`; +- no root logger reconfiguration; +- no automatic browser/tray/system integration; +- configurable persistence location; +- explicit lifecycle / shutdown; +- host-controlled theme and translation hooks. + +**Exit criteria:** a small third-party PySide6 application can embed the editor widget without +creating the JEditor IDE shell. + +--- + +### M8 — Tutorial and architecture documentation + +**Scope** + +11. 教學文件:怎麼用 Qt 跟這專案寫一個編輯器 + +Create a tutorial series rather than one large page: + +1. JEditor architecture in 10 minutes; +2. create a minimal PySide6 window; +3. embed the JEditor component; +4. open files and manage documents; +5. add commands and shortcuts; +6. add a language server; +7. consume diagnostics; +8. add a DAP debugger; +9. create a workspace / multi-root project; +10. add a remote transport; +11. add an AI provider; +12. package a custom editor application. + +Every tutorial should be executable from a clean environment and use public APIs only. + +--- + +## Dependency graph + +``` +M0 Foundation + ├── M1 UI / Commands / i18n + │ └── M7 Embeddable component + ├── M2 Tree-sitter / Diagnostics + │ ├── M3 Workspace / Multi-root + │ │ ├── M4 DAP + │ │ └── M6 Remote development + │ └── M7 Embeddable component + └── M5 AI providers + +M3 Workspace + ├── M4 DAP + └── M6 Remote development + +M7 Embeddable component + └── M8 Tutorials +``` + +Recommended implementation order: + +**M0 → M1 → M2 → M3 → M4 → M5 → M6 → M7 → M8** + +M5 can run in parallel after M0 because it has little dependency on workspace or parser work. + +## Cross-cutting requirements + +Every implementation PR should include: + +- unit tests for pure service logic; +- Qt tests for widget behavior; +- at least one regression test for the old API path when migrating an existing feature; +- documentation updates for public APIs; +- no UI-thread blocking for filesystem, Git, LSP, DAP, AI or remote I/O; +- explicit lifecycle tests for threads, processes and remote sessions; +- compatibility notes when public APIs change. + +For structural changes, update `architecture.md` and `architecture_explore.md` in the same PR. + +## Non-goals + +This roadmap does **not** mean: + +- rewriting the whole editor in another toolkit; +- replacing PySide6; +- removing the plugin system; +- forcing every feature into the core package; +- requiring every language to ship a Tree-sitter grammar; +- requiring every AI provider to use LangChain internally; +- making the standalone IDE and embeddable component share the same UI shell. + +The key objective is to make the existing feature set composable, testable and reusable while keeping +JEditor usable as a desktop application throughout the migration. + +## Definition of done for the roadmap + +The roadmap is complete when: + +- JEditor can be used as a standalone IDE and as an embedded Qt component; +- a workspace can contain multiple local or remote roots; +- Tree-sitter provides the structural syntax layer; +- LSP diagnostics share one severity-aware Problems model; +- debugging is exposed through DAP; +- AI providers include OpenAI and Anthropic behind one interface; +- shortcuts, translations and commands are data-driven and testable; +- remote development uses the same workspace/language/debug/task abstractions as local development; +- the public APIs are documented with runnable Qt examples. From 6761437b3f89e6dcb1597d490ee67578577c86cd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 01:25:35 +0800 Subject: [PATCH 02/14] Add the Qt-free core service layer (roadmap M0) je_editor/core/ holds the interfaces and data models the next-generation roadmap builds on: the workspace and its project roots, the open documents, one diagnostic model for every source, the language service registry, and the interfaces for debug sessions, task execution, remote sessions and AI providers. Nothing in it imports Qt or pyside_ui, and EditorServices is built per editor instead of being a module-level singleton, so a host can embed two editors that share nothing. The editor window does not use the layer yet: EditorMain and EditorWidget are unchanged, and the debug, task, remote and AI interfaces have no implementation in core/ until their milestones. test_core_architecture.py holds the boundary. It walks the import graph under core/, lists the only two modules below the UI that may import upwards, and builds the services in a process where Qt cannot be imported. test_public_api_contract.py pins the names PyBreeze and plugins import and the EditorMain constructor, so the coming moves cannot drop them silently. --- PROGRESS.md | 38 ++ README.md | 24 ++ README/README_zh-CN.md | 20 + README/README_zh-TW.md | 20 + architecture.md | 46 ++- architecture_explore.md | 82 +++- docs/roadmap/2026-editor-next.md | 16 + docs/source/docs/Eng/api_reference.rst | 7 + docs/source/docs/Eng/core_services.rst | 296 ++++++++++++++ docs/source/docs/Eng/eng_index.rst | 1 + docs/source/docs/Zh/api_reference.rst | 7 + docs/source/docs/Zh/core_services.rst | 285 ++++++++++++++ docs/source/docs/Zh/zh_index.rst | 1 + docs/updates/2026-10.md | 27 ++ docs/updates/README.md | 3 +- je_editor/__init__.py | 3 +- je_editor/core/__init__.py | 63 +++ je_editor/core/ai/__init__.py | 0 je_editor/core/ai/ai_provider.py | 167 ++++++++ je_editor/core/debug/__init__.py | 0 je_editor/core/debug/debug_session.py | 205 ++++++++++ je_editor/core/diagnostics/__init__.py | 0 .../core/diagnostics/diagnostic_model.py | 325 ++++++++++++++++ .../core/diagnostics/legacy_diagnostics.py | 80 ++++ je_editor/core/document/__init__.py | 0 je_editor/core/document/document_model.py | 224 +++++++++++ je_editor/core/events/__init__.py | 0 je_editor/core/events/event_hook.py | 99 +++++ je_editor/core/language/__init__.py | 0 je_editor/core/language/language_service.py | 204 ++++++++++ je_editor/core/process/__init__.py | 0 je_editor/core/process/task_service.py | 169 ++++++++ je_editor/core/registry/__init__.py | 0 je_editor/core/registry/named_registry.py | 116 ++++++ je_editor/core/remote/__init__.py | 0 je_editor/core/remote/remote_session.py | 91 +++++ je_editor/core/services/__init__.py | 0 je_editor/core/services/editor_services.py | 77 ++++ je_editor/core/uri/__init__.py | 0 je_editor/core/uri/resource_uri.py | 91 +++++ je_editor/core/workspace/__init__.py | 0 je_editor/core/workspace/workspace_model.py | 265 +++++++++++++ je_editor/utils/exception/exceptions.py | 4 + test/test_core_architecture.py | 275 +++++++++++++ test/test_core_diagnostics.py | 315 +++++++++++++++ test/test_core_documents.py | 151 ++++++++ test/test_core_event_hook.py | 152 ++++++++ test/test_core_language_services.py | 177 +++++++++ test/test_core_services.py | 364 ++++++++++++++++++ test/test_core_workspace.py | 224 +++++++++++ test/test_exceptions.py | 2 + test/test_public_api_contract.py | 89 +++++ 52 files changed, 4780 insertions(+), 25 deletions(-) create mode 100644 docs/source/docs/Eng/core_services.rst create mode 100644 docs/source/docs/Zh/core_services.rst create mode 100644 je_editor/core/__init__.py create mode 100644 je_editor/core/ai/__init__.py create mode 100644 je_editor/core/ai/ai_provider.py create mode 100644 je_editor/core/debug/__init__.py create mode 100644 je_editor/core/debug/debug_session.py create mode 100644 je_editor/core/diagnostics/__init__.py create mode 100644 je_editor/core/diagnostics/diagnostic_model.py create mode 100644 je_editor/core/diagnostics/legacy_diagnostics.py create mode 100644 je_editor/core/document/__init__.py create mode 100644 je_editor/core/document/document_model.py create mode 100644 je_editor/core/events/__init__.py create mode 100644 je_editor/core/events/event_hook.py create mode 100644 je_editor/core/language/__init__.py create mode 100644 je_editor/core/language/language_service.py create mode 100644 je_editor/core/process/__init__.py create mode 100644 je_editor/core/process/task_service.py create mode 100644 je_editor/core/registry/__init__.py create mode 100644 je_editor/core/registry/named_registry.py create mode 100644 je_editor/core/remote/__init__.py create mode 100644 je_editor/core/remote/remote_session.py create mode 100644 je_editor/core/services/__init__.py create mode 100644 je_editor/core/services/editor_services.py create mode 100644 je_editor/core/uri/__init__.py create mode 100644 je_editor/core/uri/resource_uri.py create mode 100644 je_editor/core/workspace/__init__.py create mode 100644 je_editor/core/workspace/workspace_model.py create mode 100644 test/test_core_architecture.py create mode 100644 test/test_core_diagnostics.py create mode 100644 test/test_core_documents.py create mode 100644 test/test_core_event_hook.py create mode 100644 test/test_core_language_services.py create mode 100644 test/test_core_services.py create mode 100644 test/test_core_workspace.py create mode 100644 test/test_public_api_contract.py diff --git a/PROGRESS.md b/PROGRESS.md index f58ec15..e123ca4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -11,3 +11,41 @@ `# 初始化並記錄日誌` 被當成「註解掉的程式碼」。這是本專案雙語註解的正常寫法,不該刪。 要清掉這一項得在 SonarCloud 把 issue 轉成 False Positive(用 API 改狀態需要 Administer Issues 權限)。 +- **#18** 〔決定〕`ruff` 沒有釘版本、repo 也沒有 ruff 設定,而 ruff 0.16 的預設規則從 118 條變成 + 826 條。2026-10-08 用 ruff 0.16.10 跑 `ruff check`:全 repo 526 筆(`I001` 匯入排序 276、`UP006` 93、 + `UP035` 35、`RUF100` 27、`UP045` 23、`BLE001` 22…,449 筆可自動修正);同一份程式碼用 ruff 0.15.8, + 或用 0.16.10 加 `--select E4,E7,E9,F`(舊的預設),都是乾淨的。要選一個:釘 `ruff<0.16`、在 + `pyproject.toml` 寫明規則,或整個 repo 照新規則修一輪。`je_editor/core/` 與它的測試在新規則下只剩 + `I001`(9 筆,寫法跟現有程式碼一致)與 `RUF022`(1 筆,`__all__` 依主題分組)。 +- **#19** 〔未確認〕`test_file_scan.py::TestIndexProjectFiles::test_depth_limit_prunes_deep_trees` 在沒有 + 開啟長路徑的 Windows(`LongPathsEnabled = 0`)上失敗:`deep.mkdir()` 丟 `WinError 206`,建出來的目錄樹 + 超過 260 字元。2026-10-08 在 `da90c4f` 的乾淨工作樹上同樣失敗,所以跟當時的修改無關;CI 與原本的 + 開發機沒有這個問題。要不要讓測試不依賴長路徑設定,還沒看。 +- **#20** 〔未確認〕pytest 9.1 對「class 範圍的 fixture 寫成實例方法」發出 `PytestRemovedIn10Warning`, + `test_logging_hygiene.py` 的 `probe_result` 是這種寫法,pytest 10 會變成錯誤。 + +### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) + +M0(`je_editor/core/` 服務層)已完成,見 U-20261008-01。以下每個里程碑一個 PR,順序依相依關係; +#13 只相依 M0,可以先做。 + +- **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。先做不改變外觀的部分:每個指令有不隨翻譯變動的 + ID;字典的鍵與佔位符 parity 檢查進 CI。 +- **#10** M2(Tree-sitter、統一診斷)。問題面板、底線、縮圖改用 `core/diagnostics`(現在靠 + `legacy_diagnostics.py` 互轉);`utils/lsp/lsp_protocol.diagnostic_entries` 沒有帶出伺服器給的 + 嚴重度,要補上;`LanguageService` 的「發問、等回覆」呼叫形式在這裡定。 +- **#11** M3(工作區與多根專案)。`EditorMain` 持有 `EditorServices`,`working_dir` 改由 `Workspace` + 提供;LSP 連線以「伺服器 + 根目錄」為鍵;搜尋、索引、TODO、Git、診斷改成認得工作區。 +- **#12** M4(除錯器改走 DAP)。實作 `DebugSession`;堆疊、變數、求值的非同步查詢形式在這裡定; + 需要一個本機的 `TaskRunner` 實作來啟動轉接器。 +- **#13** M5(AI 供應者 + Anthropic)。`LangChainInterface` 改成 `AIProvider` 的實作並新增 + Anthropic;設定改成依供應者分組。 +- **#14** M6(遠端開發)。實作 `RemoteSession`,並補上遠端檔案系統、連接埠轉送、直譯器探索的介面。 + 〔決定〕第一個傳輸是不是 SSH(PR #270 的審查問題 3)。 +- **#15** M7(可嵌入元件)。`import je_editor.core` 不再載入 Qt(頂層 `__init__` 要改成延後匯入); + 搬動 `pyside_ui/` 底下不含 Qt 的模組時,保留 PyBreeze 以模組路徑匯入的名稱 + (`test/test_public_api_contract.py` 列著)。 +- **#16** M8(教學文件)。相依 M7。 +- **#17** 〔決定〕PR #270 的審查問題 1、5 還沒有答覆:里程碑是否都以 `dev` 為整合分支、哪些里程碑要 + 另開追蹤 issue。M0 目前直接提交在 PR #270 的分支 `roadmap/editor-next` 上;PR 的描述仍寫著 + 「這個 PR 不含實作」,要改。 diff --git a/README.md b/README.md index e7879e0..0070725 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,25 @@ start_editor() The editor launches maximized with a dark amber theme by default. +The parts of the editor that are not widgets — the workspace, open documents and diagnostics, plus +the interfaces for language services, debugging, task execution, remote sessions and AI providers +— live in `je_editor.core` and need no window: + +```python +from je_editor.core import Diagnostic, EditorServices, Severity, TextRange, Workspace, to_uri + +services = EditorServices(Workspace.single_root("my_project")) +uri = to_uri("my_project/main.py") +services.diagnostics.publish("ruff", uri, [ + Diagnostic("`os` imported but unused", TextRange.from_lines(1, 8), Severity.WARNING, code="F401"), +]) +print(services.diagnostics.counts()[Severity.WARNING]) # 1 +services.shutdown() +``` + +This layer is the foundation of the next-generation editor; the editor window does not consume it +yet. See the *Core Services* page of the [documentation](https://je-editor.readthedocs.io/en/latest/). + --- ## Feature Details @@ -540,6 +559,8 @@ je_editor/ │ ├── dialog/ Search & replace, shortcuts, snippets, file dialogs │ ├── git_ui/ Git client, commit graph, diff viewers │ └── main_ui/ Main window, menus, toolbar, panels, settings, AI, console +├── core/ Service layer, no Qt: workspace, documents, diagnostics, and the +│ interfaces for language services, debugging, tasks, remote and AI ├── code_scan/ Ruff execution and watchdog file monitoring ├── git_client/ Git operations (GitPython + git CLI) ├── plugins/ Plugin registry and loader @@ -552,6 +573,9 @@ manager in `pyside_ui/` wires it to widgets. Folding, for example, is `utils/cod `pyside_ui/code/folding/`. That is why most of the behaviour above can be tested without opening a window. +`core/` sits between the two: it composes that logic into services a host application can use +without the JEditor window. A test walks its import graph and fails on any Qt import beneath it. + A module-by-module reference — what every file does, the threading model, the global singletons and the settings layout — is kept in **[`architecture_explore.md`](architecture_explore.md)**. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 901b3ad..fd67aba 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -287,6 +287,22 @@ start_editor() 编辑器默认会以最大化窗口与深色琥珀色主题启动。 +编辑器里不属于组件的部分——工作区、打开的文档与诊断,以及语言服务、调试、任务执行、远程会话与 AI 提供者的接口——都在 `je_editor.core`,不需要窗口就能使用: + +```python +from je_editor.core import Diagnostic, EditorServices, Severity, TextRange, Workspace, to_uri + +services = EditorServices(Workspace.single_root("my_project")) +uri = to_uri("my_project/main.py") +services.diagnostics.publish("ruff", uri, [ + Diagnostic("`os` imported but unused", TextRange.from_lines(1, 8), Severity.WARNING, code="F401"), +]) +print(services.diagnostics.counts()[Severity.WARNING]) # 1 +services.shutdown() +``` + +这一层是下一代编辑器的基础;编辑器窗口目前还没有改用它。请参阅[文档](https://je-editor.readthedocs.io/en/latest/)中的“核心服务”页面。 + --- ## 功能详情 @@ -505,6 +521,8 @@ je_editor/ │ ├── dialog/ 搜索与替换、快捷键、代码片段、文件对话框 │ ├── git_ui/ Git 客户端、提交图、差异查看器 │ └── main_ui/ 主窗口、菜单、工具栏、面板、设置、AI、控制台 +├── core/ 服务层,不依赖 Qt:工作区、文档、诊断,以及语言服务、 +│ 调试、任务执行、远程与 AI 的接口 ├── code_scan/ Ruff 执行与 watchdog 文件监控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 插件注册表与加载器 @@ -514,6 +532,8 @@ je_editor/ 功能都拆成两半来构建:算法放在 `utils/` 中且不 import Qt,`pyside_ui/` 中的一层轻薄管理器再把它接到组件上。以折叠为例,就是 `utils/code_folding/` 加上 `pyside_ui/code/folding/`。这正是上面大部分行为都能不开窗口就测试的原因。 +`core/` 位于两者之间:它把这些逻辑组成服务,让宿主程序不必创建 JEditor 窗口就能使用。有一个测试会遍历它的导入关系,底下只要出现 Qt 的导入就失败。 + 逐模块的参考——每个文件做什么、线程模型、全局单例与设置布局——记录在 **[`architecture_explore.md`](../architecture_explore.md)** 中。 --- diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c10f1a0..7f3c298 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -287,6 +287,22 @@ start_editor() 編輯器預設會以最大化視窗與深色琥珀色主題啟動。 +編輯器裡不屬於元件的部分——工作區、開著的文件與診斷,以及語言服務、除錯、工作執行、遠端工作階段與 AI 供應者的介面——都在 `je_editor.core`,不需要視窗就能使用: + +```python +from je_editor.core import Diagnostic, EditorServices, Severity, TextRange, Workspace, to_uri + +services = EditorServices(Workspace.single_root("my_project")) +uri = to_uri("my_project/main.py") +services.diagnostics.publish("ruff", uri, [ + Diagnostic("`os` imported but unused", TextRange.from_lines(1, 8), Severity.WARNING, code="F401"), +]) +print(services.diagnostics.counts()[Severity.WARNING]) # 1 +services.shutdown() +``` + +這一層是下一代編輯器的基礎;編輯器視窗目前還沒有改用它。請參閱[文件](https://je-editor.readthedocs.io/en/latest/)中的「核心服務」頁面。 + --- ## 功能詳情 @@ -505,6 +521,8 @@ je_editor/ │ ├── dialog/ 搜尋與取代、快捷鍵、程式碼片段、檔案對話框 │ ├── git_ui/ Git 用戶端、提交圖、差異檢視器 │ └── main_ui/ 主視窗、選單、工具列、面板、設定、AI、主控台 +├── core/ 服務層,不依賴 Qt:工作區、文件、診斷,以及語言服務、 +│ 除錯、工作執行、遠端與 AI 的介面 ├── code_scan/ Ruff 執行與 watchdog 檔案監控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 外掛註冊表與載入器 @@ -514,6 +532,8 @@ je_editor/ 功能都拆成兩半來建構:演算法放在 `utils/` 中且不 import Qt,`pyside_ui/` 中的一層輕薄管理器再把它接到元件上。以折疊為例,就是 `utils/code_folding/` 加上 `pyside_ui/code/folding/`。這正是上面大部分行為都能不開視窗就測試的原因。 +`core/` 位於兩者之間:它把這些邏輯組成服務,讓宿主程式不必建立 JEditor 視窗就能使用。有一個測試會走訪它的匯入關係,底下只要出現 Qt 的匯入就失敗。 + 逐模組的參考——每個檔案做什麼、執行緒模型、全域單例與設定布局——記錄在 **[`architecture_explore.md`](../architecture_explore.md)** 中。 --- diff --git a/architecture.md b/architecture.md index e5ca8c7..ae4b8fd 100644 --- a/architecture.md +++ b/architecture.md @@ -1,7 +1,8 @@ # JEditor Architecture > Short overview for people and agents. Per-module detail lives in [`architecture_explore.md`](architecture_explore.md). -> Last verified: 2026-09-22 against `509dbfd` on `dev`. +> Last verified: 2026-09-22 against `509dbfd` on `dev`. §2, §3, §5 and §6 re-checked 2026-10-08 on +> `roadmap/editor-next` when the core service layer was added; §6 against PyBreeze `16214a5`. ## 1. Purpose @@ -20,6 +21,7 @@ window, and plugins extend it through a small registry API. | `je_editor/pyside_ui/main_ui/` | Main window `EditorMain` (`main_editor.py`), editor tab `EditorWidget` (`editor/`), menus (`menu/`), toolbar, panels, command palette, console, IPython, chat panel (`ai_widget/`), plugin browser, settings persistence (`save_settings/`) | | `je_editor/pyside_ui/code/` | `CodeEditor` (`plaintext_code_edit/`) plus its managers (folding, bookmarks, lint, LSP, diff/blame, snippets, multi-cursor), highlighters (`syntax/`), process runners (`code_process/`, `shell_process/`, `base_process_manager.py`) | | `je_editor/pyside_ui/dialog/`, `git_ui/`, `browser/` | Search/replace, shortcut, snippet and file dialogs; Git panel, commit graph, diff viewers; embedded QtWebEngine browser | +| `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window does not consume it yet (roadmap M0, `docs/roadmap/2026-editor-next.md`) | | `je_editor/utils/` | Pure logic with no widgets (only `multi_language/locale_match.py` imports Qt): text operations, encodings, sessions, diffs, symbols, LSP protocol, shortcut registry, theme colors, translations (`multi_language/`), logging, stdout/stderr redirect | | `je_editor/code_scan/` | ruff runner and watchdog file monitor, run on worker threads | | `je_editor/git_client/` | Git access: `GitService` (GitPython) and `GitCLI` (subprocess), blame, HEAD baseline, hunk staging | @@ -31,8 +33,13 @@ window, and plugins extend it through a small registry API. | `.github/workflows/` | `dev.yml`, `stable.yml`: tests on a Windows Python matrix, then one publish job each on `ubuntu-latest` (§3 PyPI packages) | | `.github/requirements/` | `publish.in` and the `publish.txt` generated from it: the build tooling of the two publish jobs, build backend (`setuptools`) included, pinned by version and hash. The jobs install nothing else and build with `python -m build --no-isolation`, so the backend is the locked one; the lock has to satisfy `build-system.requires` of `pyproject.toml` and `dev.toml` (`test/test_workflow_actions.py`). Dependabot keeps it current | -Dependencies point downwards: `pyside_ui/` → `code_scan/`, `git_client/`, `plugins/` → `utils/`. -Most features are split into a pure function in `utils/` plus a thin Qt layer in `pyside_ui/`. +Dependencies point downwards: `pyside_ui/` → `core/` → `code_scan/`, `git_client/`, `plugins/` → +`utils/`. Most features are split into a pure function in `utils/` plus a thin Qt layer in +`pyside_ui/`. `test/test_core_architecture.py` enforces the direction: nothing `core/` imports, +directly or indirectly, may be Qt or `pyside_ui/`; the packages below the UI may not import Qt or +`pyside_ui/` except two listed modules (`utils/multi_language/locale_match.py` for `QLocale`, +`plugins/__init__.py` for the highlighting tables); and the services are built in a process where +Qt cannot be imported. ## 3. Entry points and public interfaces @@ -48,7 +55,14 @@ Most features are split into a pure function in `utils/` plus a thin Qt layer in - **Other exports**: `EditorWidget`, `FullEditorWidget`, `ExecManager`, `ShellManager`, `MainBrowserWidget`, `PythonHighlighter`, `syntax_rule_setting_dict`, `syntax_extend_setting_dict`, `language_wrapper`, `english_word_dict`, `traditional_chinese_word_dict`, `user_setting_dict`, - `user_setting_color_dict`, `jeditor_logger`, the `JEditorException` family. + `user_setting_color_dict`, `jeditor_logger`, the `JEditorException` family (including + `JEditorServiceException`, raised by the core services). +- **Core services**: `je_editor.core` (`__all__` in `je_editor/core/__init__.py`). A host builds + `EditorServices(workspace)` and calls `shutdown()` when it closes; there is no module-level + instance. `Document`, `LanguageService`, `DebugSession`, `TaskRunner`/`TaskHandle`, + `RemoteSession` and `AIProvider` are `typing.Protocol`s, so a `QObject` can satisfy them without + a metaclass clash. Changes are announced through `EventHook`, on the thread that caused them. + `import je_editor.core` still runs `je_editor/__init__.py`, which imports Qt. - **Persisted state**: `.jeditor/` under the working directory (`user_setting.json`, `user_color_setting.json`, `snippets.json`, `.bak` backups). - **PyPI packages**: `je_editor` (stable) and `je_editor_dev` (dev channel), both published by CI. @@ -108,17 +122,28 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre command and merges in user settings. - **Shortcuts / colors / UI strings**: single sources in `utils/shortcuts/shortcut_registry.py`, `utils/theme/theme_colors.py`, and the dictionaries in `utils/multi_language/`. +- **Core service providers**: an `EditorServices` instance takes implementations by name through + its `NamedRegistry` attributes — `ai_providers`, `debug_adapters` (session factories), + `task_runners`, `remote_transports` (by URI scheme) — and language services through + `languages.register()`. Any source reports findings with `diagnostics.publish(source, uri, ...)`. + No implementation ships in `core/` yet; the roadmap milestones add them. ## 6. Cross-project boundaries - **PyBreeze (downstream)**: `PyBreezeMainWindow` subclasses `EditorMain` with `extend=True` (`pybreeze/pybreeze_ui/editor_main/main_ui.py`). `pybreeze/__init__.py` re-exports `load_external_plugins`, `register_natural_language` and `register_programming_language`. PyBreeze - also imports some non-exported internals: `PluginBrowserWidget`, `DestroyDock`, - `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file` and `actually_color_dict`. - Grep PyBreeze before you move or rename a module. It merges its UI strings by mutating the exported - `english_word_dict` and `traditional_chinese_word_dict` in place. Treat these names, `EditorMain`'s - constructor and the attributes PyBreeze uses (`tab_widget`, `menu`, `help_menu`) as a contract. + also imports some internals by module path: `PluginBrowserWidget`, `DestroyDock`, + `FullEditorWidget`, `user_setting_dict`, `actually_color_dict`, `choose_file_get_save_file_path`, + `auto_save_manager_dict` / `file_is_open_manager_dict` / `init_new_auto_save_thread`, + `check_and_choose_venv`, `RedirectStdErr`, `DEFAULT_ENCODING` / `LINE_ENDING_LF` and + `write_file_with_encoding` (`write_file`, listed here before, is pinned too). Grep PyBreeze + before you move or rename a module. It merges its UI strings by mutating the exported + `english_word_dict` and `traditional_chinese_word_dict` in place. Treat these names, + `EditorMain`'s constructor and the attributes PyBreeze uses (`tab_widget`, `menu`, `help_menu`) + as a contract. `test/test_public_api_contract.py` pins the exported names, those module paths + and the constructor's arguments; it cannot see behaviour or attributes, and its list is a copy + that has to be updated when PyBreeze starts importing something new. - **Translations**: a JEditor translation change must keep PyBreeze's `test/test_utils/test_language_parity.py` green. Run PyBreeze tests as `pytest test/test_utils`. - **FrontEngine (upstream)**: `frontengine` is a runtime dependency. `FrontEngineMainUI` is embedded @@ -161,7 +186,8 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre Update it in the same commit when any of these changes: - a top-level package or directory in §2; -- an entry point or an export in `je_editor/__init__.py`; +- an entry point or an export in `je_editor/__init__.py` or `je_editor/core/__init__.py`; +- the dependency direction in §2, or the list of modules allowed to break it; - the startup, run or plugin flow in §4; - an extension point in §5; - a cross-repo contract in §6 (`EditorMain` signature or extend mode, the exported language dicts, diff --git a/architecture_explore.md b/architecture_explore.md index 0b0d379..154062d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration -> 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`) -> 涵蓋範圍:`je_editor/` 全部 277 個 `.py`(170 個實作模組 + 107 個 `__init__.py`),共 30,465 行。 +> 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 +> 涵蓋範圍:`je_editor/` 全部 303 個 `.py`(183 個實作模組 + 120 個 `__init__.py`),共 32,957 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -13,24 +13,25 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 項目 | 內容 | | --- | --- | -| 語言 / 版本 | Python 3.10+(CI 測 3.10 / 3.11 / 3.12) | -| UI 框架 | PySide6 6.11.0 + qt-material 主題 | +| 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | +| UI 框架 | PySide6 6.11.2 + qt-material 主題 | | 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`frontengine` | -| 測試 | pytest + pytest-qt,93 個測試檔、約 13,920 行 | +| 測試 | pytest + pytest-qt,106 個測試檔、約 16,400 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 98 | 20,330 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 59 | 8,668 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `pyside_ui/` | 98 | 20,418 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 59 | 8,752 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `core/` | 13 | 2,176 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 365 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 30,608 行。) +(行數含各層 `__init__.py`,合計 32,957 行。) --- @@ -58,6 +59,11 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 │ multi_cursor / breakpoint / selection │ └──────────────────┬───────────────────────┘ ┌──────────────────▼───────────────────────┐ + 服務層 │ core/(EditorServices:工作區、文件、診斷、 │ + │ 語言服務、除錯、工作執行、遠端、AI 的介面) │ + │ 不含 Qt;視窗層目前還沒有改用它 │ + └──────────────────┬───────────────────────┘ + ┌──────────────────▼───────────────────────┐ 邏輯層 │ utils/(純函式與資料類別,不含 Qt) │ │ code_scan/ · git_client/ · plugins/ │ └──────────────────────────────────────────┘ @@ -66,10 +72,15 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 以 duck typing 操作 widget、`utils/multi_language/locale_match.py` 讀 Qt 的 QLocale)。 ``` +這個方向由 `test/test_core_architecture.py` 守著:`core/` 直接或間接匯入的任何模組都不能是 Qt 或 +`pyside_ui/`;UI 層以下的套件(`core/`、`utils/`、`code_scan/`、`git_client/`、`plugins/`)裡,允許向上匯入的 +只有測試列出的兩個模組(`utils/multi_language/locale_match.py` 的 `QLocale`、`plugins/__init__.py` 匯入 +`pyside_ui/code/syntax/syntax_setting` 的高亮規則表)。 + **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 93 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 106 個測試檔的原因。 --- @@ -118,6 +129,9 @@ start_editor(debug_mode) je_editor/start_editor.py | `EDITOR_EXTEND_TAB` | `main_ui/main_editor.py` | 給下游專案(PyBreeze)塞自訂分頁的掛載點 | | `_plugin_metadata_list` 等 | `plugins/__init__.py` | 已註冊的語言 / 翻譯 / 執行設定 / 中繼資料 | +`core/` 刻意沒有模組層級的單例:`EditorServices` 由建立它的人持有,所以同一個行程嵌入兩個編輯器時不會共用 +工作區與診斷。 + --- ## 5. 模組逐一說明 @@ -126,13 +140,13 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `__init__.py` | 57 | 公開 API 匯總(`__all__`):`start_editor`、`EditorMain`、`EditorWidget`、例外類別、語言字典、插件註冊函式 | +| `__init__.py` | 58 | 公開 API 匯總(`__all__`):`start_editor`、`EditorMain`、`EditorWidget`、例外類別、語言字典、插件註冊函式 | | `__main__.py` | 18 | `python -m je_editor -s` 的 argparse 進入點 | | `start_editor.py` | 56 | 建 `QApplication`、載插件、套 qt-material 主題、最大化顯示、`os._exit` 收場 | --- -### 5.2 `utils/` — 純邏輯層(59 模組 / 8,544 行) +### 5.2 `utils/` — 純邏輯層(59 模組 / 8,752 行) #### 文字與行操作 @@ -224,7 +238,7 @@ start_editor(debug_mode) je_editor/start_editor.py | --- | ---: | --- | | `logging/loggin_instance.py` | 148 | `jeditor_logger` 與 `JEditorLoggingHandler`(RotatingFileHandler 子類);日誌檔在 `$JE_EDITOR_LOG_FILE` 或 `~/.je_editor/logs/JEditor.log`,第一筆紀錄才開檔、附加、UTF-8 | | `redirect_manager/redirect_manager_class.py` | 130 | 把 stdout / stderr 導入兩個 Queue,同時也是 logging Handler | -| `exception/exceptions.py` | 30 | 8 個 `JEditorException` 家族的例外類別 | +| `exception/exceptions.py` | 34 | 9 個 `JEditorException` 家族的例外類別(`JEditorServiceException` 由 `core/` 丟出) | | `exception/exception_tags.py` | 28 | 例外訊息字串常數 | | `browser/chromium_flags.py` | 64 | 設定 `QTWEBENGINE_CHROMIUM_FLAGS`,壓下內嵌 Chromium 的日誌 | @@ -399,6 +413,32 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | +### 5.11 `core/` — 核心服務層(13 模組 / 2,176 行) + +下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 +逐個里程碑改接過來。目前 `pyside_ui/` 還沒有任何模組匯入 `core/`。整層不匯入 Qt 也不匯入 `pyside_ui/`; +介面一律用 `typing.Protocol`,之後由 `QObject` 持有資源的轉接器才不會遇到中繼類別衝突。 + +| 模組 | 行 | 功用 | +| --- | ---: | --- | +| `__init__.py` | 63 | 核心層的公開 API(`__all__`) | +| `services/editor_services.py` | 77 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | +| `events/event_hook.py` | 99 | `EventHook`:不靠 Qt 的訂閱與通知;在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄、不擋其他人 | +| `registry/named_registry.py` | 116 | `NamedRegistry[T]`:名稱對應實作的登記表,AI 供應者、除錯轉接器、遠端傳輸、工作執行器共用 | +| `uri/resource_uri.py` | 91 | 資源 URI:`to_uri` / `to_path`(沿用 `utils/lsp/lsp_protocol` 的轉換)、`uri_scheme`、`uri_key`(同一個本機檔案的不同寫法得到同一個鍵) | +| `workspace/workspace_model.py` | 265 | `ProjectRoot`(以 URI 指認,可以不在本機;`resolve()` 擋住跑出根目錄的路徑)與 `Workspace`(零到多個根目錄、`root_for()` / `root_for_uri()` 取最深的那一個、`relative_path()`) | +| `document/document_model.py` | 224 | `Document` 協定、記憶體實作 `TextDocument`、以 URI 為鍵的 `DocumentStore`(`opened` / `changed` / `closed` 事件) | +| `diagnostics/diagnostic_model.py` | 325 | 統一的診斷模型:`Severity`(數值同 LSP)、`Position` / `TextRange`(1 起算)、`RelatedInformation`、`TextEdit` / `QuickFix`、`Diagnostic`;`DiagnosticStore` 依「來源 × 資源」整組取代,`select()` 依嚴重度 / 來源 / 資源篩選且順序固定 | +| `diagnostics/legacy_diagnostics.py` | 80 | 統一模型與 `utils/lint/ruff_diagnostics.Diagnostic` 之間的雙向轉換,讓底線、縮圖、問題面板可以分批改用新模型 | +| `language/language_service.py` | 204 | `LanguageCapability`、`LanguageService` 協定,以及 `LanguageServiceRegistry`:把 `DocumentStore` 的開啟 / 變更 / 關閉轉給處理該文件的服務,晚登記的服務會補收已開文件的「開啟」 | +| `debug/debug_session.py` | 205 | `DebugSession` 協定與資料物件(`DebugLaunchRequest`、`Breakpoint`、`StackFrame`、`Variable`、`DebugState`、`StepKind`),名稱對應 DAP 的概念 | +| `process/task_service.py` | 169 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream` | +| `remote/remote_session.py` | 91 | `RemoteSession` 協定與 `RemoteState`;`task_runner()` 回傳與本機相同的 `TaskRunner` 介面 | +| `ai/ai_provider.py` | 167 | `AIProvider` 協定與資料物件(`ChatRequest`、`ChatMessage`、`ChatRole`、`ChatResponse`、`ModelInfo`、`CancelToken`) | + +除錯、工作執行、遠端與 AI 四項目前只有介面,`core/` 裡沒有實作;既有的 pdb 除錯、`BaseProcessManager` 與 +LangChain 對話仍然走原本的路徑。 + --- ## 6. 橫切主題 @@ -418,6 +458,9 @@ UI 執行緒不做 I/O 是硬性規則,重活分成三類: 已知地雷(`conftest.py` 與註解都有記錄):`QThread` 若在執行中被銷毀,Qt 會 `qFatal` 直接中止行程。 因此每個 QThread 子類都會 `setObjectName(...)`,測試有 autouse fixture 等工具列的背景掃描結束。 +`core/` 自己不開執行緒。它的 `EventHook` 在發出通知的那個執行緒上呼叫訂閱者,所以之後接上來的 Qt 訂閱者要 +自己轉回 UI 執行緒;各個儲存區與登記表以 `threading.Lock` 保護內部狀態。 + ### 6.2 設定與持久化 全部集中在工作目錄下的 `.jeditor/`: @@ -480,7 +523,11 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff ## 7. 測試與 CI -- `test/` 98 個測試檔、約 14,360 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 106 個測試檔、約 16,400 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 + 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 + `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 + 內部名稱,以及 `EditorMain` 建構子的引數。 - `conftest.py` 提供 session 級 `qapp`、`tmp_dir`、`tmp_file`,以及 autouse 的「等工具列背景執行緒結束」fixture; `collect_ignore_glob` 排除會真的開視窗的 `start_qt_ui.py` / `extend_test.py`。 - `pyproject.toml` 設定 `testpaths = ["test"]`、`qt_api = "pyside6"`;bandit 排除測試目錄(pytest 慣用 `assert`)。 @@ -526,3 +573,12 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 讓「純邏輯層」的界線稍微模糊。 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 +7. **`core/` 還沒有消費者**:服務層已經可以獨立使用,但視窗層仍然各自持有狀態,所以現在同一件事有兩個模型 + (例如 `utils/lint` 的 `Diagnostic` 與 `core/diagnostics` 的 `Diagnostic`,靠 `legacy_diagnostics.py` 互轉)。 + 這是遷移期間的狀態,藍圖的 M1 之後逐步收斂。 +8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt + 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, + 得等可嵌入元件那個里程碑處理頂層 `__init__`。 +9. **`pyside_ui/` 底下還有不含 Qt 的模組**:`ai_widget/ai_config.py`、`plugin_browser/github_api.py`、 + `save_settings/user_setting_file.py`、`code/running_process_manager.py` 等本身不匯入 Qt,卻放在 UI 套件裡; + 其中幾個路徑是 PyBreeze 的契約,搬動時要留相容匯入。 diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index d08615a..5ff50cf 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -7,6 +7,22 @@ > This document is deliberately a roadmap, not an implementation PR. Each milestone should land as a > separate, reviewable PR with tests and documentation. The order below is dependency-driven. +## Implementation status + +| Milestone | Status | Record | +| --- | --- | --- | +| M0 — Foundation and compatibility boundary | Implemented: `je_editor/core/` | `docs/updates/2026-10.md`, U-20261008-01 | +| M1 – M8 | Not started | `PROGRESS.md` | + +M0 defines the service layer and proves it runs without Qt. It deliberately stops short of three +things, each left to the milestone that needs it: + +- the editor window does not consume the services yet; +- debugging, task execution, remote sessions and AI providers are interfaces with no + implementation in `core/` (M4, M6 and M5 supply them), and the request-and-reply calls of a + language service (completion, hover and the rest) take their shape in M2; +- `import je_editor.core` still runs `je_editor/__init__.py`, which imports Qt (M7). + ## Goals Turn JEditor from a feature-rich desktop editor into a reusable editor platform: diff --git a/docs/source/docs/Eng/api_reference.rst b/docs/source/docs/Eng/api_reference.rst index 6eea8d1..48d710c 100644 --- a/docs/source/docs/Eng/api_reference.rst +++ b/docs/source/docs/Eng/api_reference.rst @@ -278,4 +278,11 @@ JEditor defines a hierarchy of custom exceptions: JEditorContentFileException, # File content errors JEditorCantFindLanguageException, # Language not found JEditorJsonException, # JSON parsing errors + JEditorServiceException, # Core service errors ) + +Core Services +-------------- + +The workspace, document, diagnostics, language service, debug, task, remote and AI provider +interfaces live in ``je_editor.core`` and need no window. See :doc:`core_services`. diff --git a/docs/source/docs/Eng/core_services.rst b/docs/source/docs/Eng/core_services.rst new file mode 100644 index 0000000..739fab8 --- /dev/null +++ b/docs/source/docs/Eng/core_services.rst @@ -0,0 +1,296 @@ +Core Services +============== + +``je_editor.core`` holds the parts of the editor that are not widgets: the workspace, the open +documents, the diagnostics, and the interfaces for language services, debugging, task execution, +remote sessions and AI providers. Nothing in it imports Qt, so it can be used from a test, a +command-line tool or a host application that never builds the JEditor window. + +.. note:: + + This layer is the foundation of the next-generation editor roadmap. The data models work + today. The editor window does not consume them yet: its panels still talk to their own + back ends, and they move onto these services one milestone at a time. + +Quick Example +-------------- + +.. code-block:: python + + from je_editor.core import ( + Diagnostic, EditorServices, Severity, TextDocument, TextRange, Workspace, to_uri + ) + + services = EditorServices(Workspace.single_root("my_project")) + + uri = to_uri("my_project/main.py") + services.documents.open(TextDocument(uri, "import os\n", "python")) + + services.diagnostics.changed.subscribe(lambda changed_uri: print("changed:", changed_uri)) + services.diagnostics.publish("ruff", uri, [ + Diagnostic("`os` imported but unused", TextRange.from_lines(1, 8, 1, 10), + Severity.WARNING, code="F401"), + ]) + + for diagnostic in services.diagnostics.select([Severity.WARNING]): + print(diagnostic.source, diagnostic.label) + + services.shutdown() + +Each ``EditorServices`` is independent. There is no module-level instance, so two editors +embedded in one application never share a workspace or diagnostics. Call ``shutdown()`` when +the owner closes: language services and task runners may hold processes and threads. + +EditorServices +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Attribute + - What it holds + * - ``workspace`` + - The ``Workspace``: zero or more project roots + * - ``documents`` + - The ``DocumentStore``: every open document, keyed by URI + * - ``diagnostics`` + - The ``DiagnosticStore``: what every source reported + * - ``languages`` + - The ``LanguageServiceRegistry``, fed by ``documents`` + * - ``debug_adapters`` + - Debug session factories, registered by adapter type + * - ``task_runners`` + - Task runners, registered by where they run + * - ``remote_transports`` + - Remote session factories, registered by URI scheme + * - ``ai_providers`` + - AI providers, registered by name + +The last four are ``NamedRegistry`` objects: ``register(name, item)``, ``get(name)``, +``require(name)`` (raises ``JEditorServiceException`` and lists what is registered), +``unregister(name)`` and ``names()``. + +Workspace +---------- + +A workspace is a list of ``ProjectRoot`` objects. One root is a perfectly valid workspace and +is what a project directory has always been. + +.. code-block:: python + + from je_editor.core import Workspace + + workspace = Workspace.single_root("frontend") + workspace.add_root("backend") + + owner = workspace.root_for("backend/src/main.py") # the "backend" root + root, relative = workspace.relative_path("backend/src/main.py") + print(root.name, relative) # backend src/main.py + +- Roots are named by URI. A local directory is a ``file://`` URI; ``ProjectRoot.is_local`` and + ``ProjectRoot.path`` tell the two cases apart. +- When roots nest, ``root_for`` returns the deepest one. ``root_for_uri`` answers the same + question for a URI, which is how a document or a diagnostic finds its root. +- ``ProjectRoot.resolve(relative_path)`` joins a path onto the root and raises + ``JEditorServiceException`` when the result would leave it, as ``..`` can. +- ``workspace.changed`` fires after a root is added or removed. + +Documents +---------- + +``Document`` is a protocol: anything with ``uri``, ``language_id``, ``version`` and ``text()`` +is a document. ``TextDocument`` is the in-memory implementation. + +.. code-block:: python + + from je_editor.core import DocumentStore, TextDocument, to_uri + + documents = DocumentStore() + uri = to_uri("notes.py") + documents.open(TextDocument(uri, "x = 1\n", "python")) + documents.replace_text(uri, "x = 2\n") # raises the version and fires ``changed`` + documents.close(uri) + +A document whose text lives elsewhere (an editor widget, for example) is opened the same way, +and its owner calls ``documents.notify_changed(uri)`` after each change. ``opened``, ``changed`` +and ``closed`` each pass the document concerned. + +Diagnostics +------------ + +Every source reports into one model: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Meaning + * - ``message`` + - The human-readable text + * - ``range`` + - A ``TextRange`` of two ``Position`` objects; lines and columns count from one + * - ``severity`` + - ``Severity.ERROR``, ``WARNING``, ``INFORMATION`` or ``HINT`` (LSP's numbers) + * - ``source`` + - Who reported it, such as ``ruff`` or a language server's name + * - ``code`` + - The rule code + * - ``uri`` + - The resource it is in + * - ``related`` + - ``RelatedInformation`` entries: other locations that relate to it + * - ``fixes`` + - ``QuickFix`` entries, each a title and the ``TextEdit`` list that applies it + +``DiagnosticStore.publish(source, uri, diagnostics)`` replaces everything that source said +about that resource, which is what LSP's ``publishDiagnostics`` means; an empty list clears +it. ``select(severities=None, sources=None, uri=None)`` filters, and always returns the same +order for the same content: by resource, then position, then severity. ``counts()`` gives the +total per severity and ``sources()`` the sources that have findings. + +Language Services +------------------ + +A language service is told when a document it handles opens, changes or closes, and says what +it offers through ``LanguageCapability``. + +.. code-block:: python + + from je_editor.core import ( + Diagnostic, EditorServices, LanguageCapability, Severity, TextDocument, TextRange, to_uri + ) + + + class TodoFinder: + """Reports every line that contains TODO.""" + + name = "todo-finder" + + def __init__(self, services): + self._services = services + + def capabilities(self): + return frozenset({LanguageCapability.DIAGNOSTICS}) + + def handles(self, document): + return document.language_id == "python" + + def document_opened(self, document): + self._check(document) + + def document_changed(self, document): + self._check(document) + + def document_closed(self, document): + self._services.diagnostics.publish(self.name, document.uri, []) + + def shutdown(self): + self._services.diagnostics.clear(source=self.name) + + def _check(self, document): + found = [ + Diagnostic("TODO left in the code", TextRange.from_lines(number), Severity.HINT) + for number, line in enumerate(document.text().splitlines(), start=1) + if "TODO" in line + ] + self._services.diagnostics.publish(self.name, document.uri, found) + + + services = EditorServices() + services.languages.register(TodoFinder(services)) + services.documents.open(TextDocument(to_uri("a.py"), "x = 1 # TODO rename\n", "python")) + print(len(services.diagnostics)) # 1 + +A service registered after documents are open is told about each one it handles, so a server +that starts late still learns what is open. ``services_for(document, capability)`` finds the +services for a document. + +Debugging, Tasks, Remote Sessions and AI Providers +--------------------------------------------------- + +These four are interfaces with their data objects. JEditor ships no implementation of them in +this layer yet; a host or a plugin registers its own. + +.. list-table:: + :header-rows: 1 + :widths: 22 30 48 + + * - Area + - Interface + - Data objects + * - Debugging + - ``DebugSession`` + - ``DebugLaunchRequest``, ``Breakpoint``, ``StackFrame``, ``Variable``, ``DebugState``, + ``StepKind`` + * - Task execution + - ``TaskRunner``, ``TaskHandle`` + - ``TaskSpec``, ``TaskState``, ``OutputStream`` + * - Remote sessions + - ``RemoteSession`` + - ``RemoteState`` + * - AI providers + - ``AIProvider`` + - ``ChatRequest``, ``ChatMessage``, ``ChatRole``, ``ChatResponse``, ``ModelInfo``, + ``CancelToken`` + +- A ``TaskSpec`` command is always a list of arguments. There is no form that hands a line to + a shell, and a string is refused. +- ``TaskRunner.create(spec)`` returns a handle that has not started. Subscribe to its + ``output`` and ``finished`` events, then call ``start()``, so no early output is missed. +- ``RemoteSession.task_runner()`` returns the same ``TaskRunner`` interface, so a caller never + has to tell where a process runs. +- ``AIProvider.complete(request, on_text, cancel)`` blocks until the reply is complete. Call it + from a worker thread. ``on_text`` receives the reply piece by piece and a ``CancelToken`` + stops it part-way. + +.. code-block:: python + + from je_editor.core import ( + ChatMessage, ChatRequest, ChatResponse, ChatRole, EditorServices, ModelInfo + ) + + + class UpperCaseProvider: + """Answers by shouting the question back.""" + + name = "upper" + + def models(self): + return [ModelInfo("upper-1", "Upper Case")] + + def complete(self, request, on_text=None, cancel=None): + text = request.messages[-1].content.upper() + if on_text is not None: + on_text(text) + return ChatResponse(text, "upper-1") + + + services = EditorServices() + services.ai_providers.register(UpperCaseProvider.name, UpperCaseProvider()) + provider = services.ai_providers.require("upper") + reply = provider.complete(ChatRequest((ChatMessage(ChatRole.USER, "hello"),))) + print(reply.text) # HELLO + +Events and Threads +------------------- + +The services announce changes through ``EventHook`` objects rather than Qt signals. +``hook.subscribe(listener)`` returns a function that undoes the subscription. + +A listener runs on whichever thread caused the event. A listener that updates widgets has to +hand over to the UI thread itself, for example by emitting a Qt signal of its own. One +listener raising does not stop the others; the failure is written to the JEditor log. + +Staying Qt-Free +---------------- + +``test/test_core_architecture.py`` holds this boundary in three ways: it walks the import +graph of ``je_editor.core`` and fails on any Qt or ``je_editor.pyside_ui`` import beneath it, +it lists the only modules below the UI that may reach upwards, and it builds the services in +a process where importing Qt is blocked. + +``import je_editor.core`` still runs ``je_editor/__init__.py`` first, as importing any +sub-package does, and that file imports the Qt application. The services themselves need +neither Qt nor a ``QApplication``. diff --git a/docs/source/docs/Eng/eng_index.rst b/docs/source/docs/Eng/eng_index.rst index 3fcc8a8..679f31a 100644 --- a/docs/source/docs/Eng/eng_index.rst +++ b/docs/source/docs/Eng/eng_index.rst @@ -24,4 +24,5 @@ a significantly richer feature set. configuration keyboard_shortcuts api_reference + core_services how_to_extend_using_pyside diff --git a/docs/source/docs/Zh/api_reference.rst b/docs/source/docs/Zh/api_reference.rst index 515d8ff..3d0ab32 100644 --- a/docs/source/docs/Zh/api_reference.rst +++ b/docs/source/docs/Zh/api_reference.rst @@ -274,4 +274,11 @@ JEditor 定義了一套自訂例外類別階層: JEditorContentFileException, # 檔案��容錯誤 JEditorCantFindLanguageException, # 找不到語言 JEditorJsonException, # JSON 解析錯誤 + JEditorServiceException, # 核心服務錯誤 ) + +核心服務 +--------- + +工作區、文件、診斷、語言服務、除錯、工作執行、遠端與 AI 供應者的介面都在 ``je_editor.core``, +不需要視窗就能使用。請見 :doc:`core_services`。 diff --git a/docs/source/docs/Zh/core_services.rst b/docs/source/docs/Zh/core_services.rst new file mode 100644 index 0000000..a007e23 --- /dev/null +++ b/docs/source/docs/Zh/core_services.rst @@ -0,0 +1,285 @@ +核心服務 +======== + +``je_editor.core`` 放的是編輯器裡不屬於元件的部分:工作區、開著的文件、診斷,以及語言服務、 +除錯、工作執行、遠端工作階段與 AI 供應者的介面。這一層完全不匯入 Qt,所以測試、命令列工具, +或從不建立 JEditor 視窗的宿主程式都可以使用。 + +.. note:: + + 這一層是下一代編輯器藍圖的基礎。資料模型現在就能用,但編輯器視窗還沒有改用它們:各個面板 + 仍然各自連到自己的後端,之後會隨著每個里程碑逐一改接到這些服務上。 + +快速範例 +-------- + +.. code-block:: python + + from je_editor.core import ( + Diagnostic, EditorServices, Severity, TextDocument, TextRange, Workspace, to_uri + ) + + services = EditorServices(Workspace.single_root("my_project")) + + uri = to_uri("my_project/main.py") + services.documents.open(TextDocument(uri, "import os\n", "python")) + + services.diagnostics.changed.subscribe(lambda changed_uri: print("changed:", changed_uri)) + services.diagnostics.publish("ruff", uri, [ + Diagnostic("`os` imported but unused", TextRange.from_lines(1, 8, 1, 10), + Severity.WARNING, code="F401"), + ]) + + for diagnostic in services.diagnostics.select([Severity.WARNING]): + print(diagnostic.source, diagnostic.label) + + services.shutdown() + +每一個 ``EditorServices`` 都是獨立的。這裡沒有模組層級的實例,所以同一個應用程式嵌入兩個編輯器時, +它們不會共用工作區或診斷。擁有者關閉時要呼叫 ``shutdown()``:語言服務與工作執行器可能持有程序與 +執行緒。 + +EditorServices +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - 屬性 + - 內容 + * - ``workspace`` + - ``Workspace``:零到多個專案根目錄 + * - ``documents`` + - ``DocumentStore``:所有開著的文件,以 URI 為鍵 + * - ``diagnostics`` + - ``DiagnosticStore``:每個來源回報的診斷 + * - ``languages`` + - ``LanguageServiceRegistry``,文件事件來自 ``documents`` + * - ``debug_adapters`` + - 除錯工作階段的建立函式,以轉接器種類登記 + * - ``task_runners`` + - 工作執行器,以執行的地方登記 + * - ``remote_transports`` + - 遠端工作階段的建立函式,以 URI 的 scheme 登記 + * - ``ai_providers`` + - AI 供應者,以名稱登記 + +後面四個是 ``NamedRegistry``:``register(name, item)``、``get(name)``、``require(name)`` +(找不到時丟出 ``JEditorServiceException`` 並列出已登記的名稱)、``unregister(name)`` 與 +``names()``。 + +工作區 +------ + +工作區是一串 ``ProjectRoot``。只有一個根目錄的工作區完全合法,也就是原本的「專案目錄」。 + +.. code-block:: python + + from je_editor.core import Workspace + + workspace = Workspace.single_root("frontend") + workspace.add_root("backend") + + owner = workspace.root_for("backend/src/main.py") # "backend" 這個根目錄 + root, relative = workspace.relative_path("backend/src/main.py") + print(root.name, relative) # backend src/main.py + +- 根目錄以 URI 指認。本機目錄是 ``file://`` URI;``ProjectRoot.is_local`` 與 ``ProjectRoot.path`` + 可以分辨兩種情況。 +- 根目錄互相包含時,``root_for`` 回傳最深的那一個。``root_for_uri`` 以 URI 回答同一個問題,文件與診斷 + 就是靠它找到自己的根目錄。 +- ``ProjectRoot.resolve(relative_path)`` 把路徑接到根目錄上;結果會跑到根目錄外面時(例如 + ``..``)丟出 ``JEditorServiceException``。 +- 根目錄增減之後會發出 ``workspace.changed``。 + +文件 +---- + +``Document`` 是一個協定(protocol):只要有 ``uri``、``language_id``、``version`` 與 ``text()`` +就是文件。``TextDocument`` 是內容放在記憶體裡的實作。 + +.. code-block:: python + + from je_editor.core import DocumentStore, TextDocument, to_uri + + documents = DocumentStore() + uri = to_uri("notes.py") + documents.open(TextDocument(uri, "x = 1\n", "python")) + documents.replace_text(uri, "x = 2\n") # 版本加一,並發出 ``changed`` + documents.close(uri) + +內容放在別處的文件(例如編輯器元件)用同樣的方式開啟,每次內容改變後由擁有者呼叫 +``documents.notify_changed(uri)``。``opened``、``changed`` 與 ``closed`` 三個事件都會帶著那份文件。 + +診斷 +---- + +所有來源都回報成同一種模型: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - 欄位 + - 意義 + * - ``message`` + - 給人看的說明文字 + * - ``range`` + - 由兩個 ``Position`` 組成的 ``TextRange``;行與欄都從一起算 + * - ``severity`` + - ``Severity.ERROR``、``WARNING``、``INFORMATION`` 或 ``HINT``,數值與 LSP 相同 + * - ``source`` + - 誰報的,例如 ``ruff`` 或語言伺服器的名稱 + * - ``code`` + - 規則代碼 + * - ``uri`` + - 所在的資源 + * - ``related`` + - ``RelatedInformation``:跟這筆診斷有關的其他位置 + * - ``fixes`` + - ``QuickFix``:每一筆有名稱,以及套用時要做的 ``TextEdit`` 清單 + +``DiagnosticStore.publish(source, uri, diagnostics)`` 會把該來源對該資源的診斷整組換掉,這是 LSP +``publishDiagnostics`` 的語意;給空清單就是清掉。``select(severities=None, sources=None, uri=None)`` +負責篩選,而且同樣的內容一定回傳同樣的順序:先依資源,再依位置,再依嚴重度。``counts()`` 回傳各 +嚴重度的數量,``sources()`` 回傳目前有回報診斷的來源。 + +語言服務 +-------- + +語言服務會在它處理的文件開啟、變更或關閉時收到通知,並透過 ``LanguageCapability`` 說明自己提供 +哪些功能。 + +.. code-block:: python + + from je_editor.core import ( + Diagnostic, EditorServices, LanguageCapability, Severity, TextDocument, TextRange, to_uri + ) + + + class TodoFinder: + """回報每一行含有 TODO 的程式碼。""" + + name = "todo-finder" + + def __init__(self, services): + self._services = services + + def capabilities(self): + return frozenset({LanguageCapability.DIAGNOSTICS}) + + def handles(self, document): + return document.language_id == "python" + + def document_opened(self, document): + self._check(document) + + def document_changed(self, document): + self._check(document) + + def document_closed(self, document): + self._services.diagnostics.publish(self.name, document.uri, []) + + def shutdown(self): + self._services.diagnostics.clear(source=self.name) + + def _check(self, document): + found = [ + Diagnostic("TODO left in the code", TextRange.from_lines(number), Severity.HINT) + for number, line in enumerate(document.text().splitlines(), start=1) + if "TODO" in line + ] + self._services.diagnostics.publish(self.name, document.uri, found) + + + services = EditorServices() + services.languages.register(TodoFinder(services)) + services.documents.open(TextDocument(to_uri("a.py"), "x = 1 # TODO rename\n", "python")) + print(len(services.diagnostics)) # 1 + +在文件已經開著之後才登記的服務,會收到它處理的每一份文件的「開啟」通知,所以晚啟動的伺服器仍然 +知道有哪些文件開著。``services_for(document, capability)`` 用來找出處理某份文件的服務。 + +除錯、工作執行、遠端工作階段與 AI 供應者 +---------------------------------------- + +這四項是介面加上各自的資料物件。JEditor 目前在這一層還沒有提供它們的實作,由宿主程式或外掛自行 +登記。 + +.. list-table:: + :header-rows: 1 + :widths: 22 30 48 + + * - 領域 + - 介面 + - 資料物件 + * - 除錯 + - ``DebugSession`` + - ``DebugLaunchRequest``、``Breakpoint``、``StackFrame``、``Variable``、``DebugState``、 + ``StepKind`` + * - 工作執行 + - ``TaskRunner``、``TaskHandle`` + - ``TaskSpec``、``TaskState``、``OutputStream`` + * - 遠端工作階段 + - ``RemoteSession`` + - ``RemoteState`` + * - AI 供應者 + - ``AIProvider`` + - ``ChatRequest``、``ChatMessage``、``ChatRole``、``ChatResponse``、``ModelInfo``、 + ``CancelToken`` + +- ``TaskSpec`` 的指令一律是引數清單。沒有「把一整行交給 shell」的形式,給字串會被拒絕。 +- ``TaskRunner.create(spec)`` 回傳一個尚未啟動的把手。先訂閱它的 ``output`` 與 ``finished`` 事件, + 再呼叫 ``start()``,才不會漏掉一開始的輸出。 +- ``RemoteSession.task_runner()`` 回傳的是同一個 ``TaskRunner`` 介面,所以呼叫端不必分辨程序在哪裡 + 執行。 +- ``AIProvider.complete(request, on_text, cancel)`` 會等到回覆完成才返回,請在背景執行緒呼叫。 + ``on_text`` 會一段一段收到回覆,``CancelToken`` 可以中途取消。 + +.. code-block:: python + + from je_editor.core import ( + ChatMessage, ChatRequest, ChatResponse, ChatRole, EditorServices, ModelInfo + ) + + + class UpperCaseProvider: + """把問題轉成大寫當作回答。""" + + name = "upper" + + def models(self): + return [ModelInfo("upper-1", "Upper Case")] + + def complete(self, request, on_text=None, cancel=None): + text = request.messages[-1].content.upper() + if on_text is not None: + on_text(text) + return ChatResponse(text, "upper-1") + + + services = EditorServices() + services.ai_providers.register(UpperCaseProvider.name, UpperCaseProvider()) + provider = services.ai_providers.require("upper") + reply = provider.complete(ChatRequest((ChatMessage(ChatRole.USER, "hello"),))) + print(reply.text) # HELLO + +事件與執行緒 +------------ + +這些服務用 ``EventHook`` 而不是 Qt 訊號來通知變更。``hook.subscribe(listener)`` 會回傳一個用來 +取消這次訂閱的函式。 + +訂閱者在引發事件的那個執行緒上被呼叫。要更新元件的訂閱者得自己轉回 UI 執行緒,例如發出自己的 Qt +訊號。一個訂閱者丟出例外不會擋住其他訂閱者,錯誤會寫進 JEditor 的日誌。 + +維持不依賴 Qt +------------- + +``test/test_core_architecture.py`` 用三種方式守住這條界線:走訪 ``je_editor.core`` 的匯入關係, +底下只要出現 Qt 或 ``je_editor.pyside_ui`` 的匯入就失敗;列出 UI 層以下唯一允許向上匯入的模組; +並在一個擋掉 Qt 匯入的行程裡實際建立這些服務。 + +匯入任何子套件都會先執行 ``je_editor/__init__.py``,``import je_editor.core`` 也不例外,而那個檔案會 +匯入整個 Qt 應用程式。這些服務本身不需要 Qt,也不需要 ``QApplication``。 diff --git a/docs/source/docs/Zh/zh_index.rst b/docs/source/docs/Zh/zh_index.rst index b25025b..1ab103a 100644 --- a/docs/source/docs/Zh/zh_index.rst +++ b/docs/source/docs/Zh/zh_index.rst @@ -23,4 +23,5 @@ JEditor 繁體中文使用文件 configuration keyboard_shortcuts api_reference + core_services how_to_extend_using_pyside diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index a5749a0..bd4a0d3 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -98,3 +98,30 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **做了什麼**:`publish.txt` 第一次產生(2026-10-01)時沒有指定截止日,所以釘到了 2026-09-30 才上傳的 `charset-normalizer` 3.5.2 與 `cryptography` 50.0.2,還在本專案 Dependabot 對新版本設的七天等待期內。這次以 `--exclude-newer 2026-09-24` 重新產生:這兩個退回 3.5.1 與 50.0.1(其他專案的發佈鎖檔也是這兩版),其餘釘住的版本都沒有變。 - **檔案**:`.github/requirements/publish.txt`、`.github/requirements/publish.in`(記錄的指令)。 - **待辦**:無。 + +## U-20261008-01 · 2026-10-08 · 藍圖 M0:不依賴 Qt 的核心服務層 je_editor/core · #migration #roadmap #core + +- **做了什麼**:下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270)的 M0。新增 `je_editor/core/`:不匯入 Qt、也不匯入 `pyside_ui/` 的服務層,13 個模組、2,176 行。 + - `EditorServices`(`services/`)把下列服務組在一起,由建立它的人持有,沒有模組層級的實例;`shutdown()` 關閉語言服務與工作執行器、清掉診斷、關閉文件。 + - 有實作的模型:`Workspace` / `ProjectRoot`(零到多個根目錄,以 URI 指認,巢狀時取最深的根目錄,`resolve()` 擋住跑出根目錄的相對路徑)、`DocumentStore` / `TextDocument`、`DiagnosticStore` 與統一的 `Diagnostic`(來源、`Severity` 四級、代碼、URI、範圍、相關位置、可套用的修正),`select()` 依嚴重度與來源篩選且順序固定。 + - `LanguageServiceRegistry`:把文件的開啟、變更、關閉轉給處理該文件的語言服務;晚登記的服務會補收已開文件。 + - 只有介面與資料物件、還沒有實作的四項:`DebugSession`、`TaskRunner` / `TaskHandle`、`RemoteSession`、`AIProvider`,各自以 `NamedRegistry` 登記實作。介面一律用 `typing.Protocol`,之後由 `QObject` 持有資源的轉接器才不會遇到中繼類別衝突。 + - `EventHook` 取代 Qt 訊號:在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄。 + - `legacy_diagnostics.py` 讓統一模型與 `utils/lint` 的 `Diagnostic` 互轉,底線、縮圖、問題面板可以分批改用。 + - 新增 `JEditorServiceException`(`JEditorException` 家族),並從 `je_editor` 匯出。 +- **刻意沒做的**:視窗層還沒有改用這些服務(`pyside_ui/` 沒有任何模組匯入 `core/`),`EditorMain`、`EditorWidget` 沒有動;`LanguageService` 的「發問、等回覆」呼叫形式與 `DebugSession` 的堆疊 / 變數查詢留給 M2、M4 決定;`import je_editor.core` 仍然會先執行 `je_editor/__init__.py` 而載入 Qt(M7)。既有的協定與資料模組(`utils/lsp`、`utils/lint`、`utils/debugger`)本來就不含 Qt,所以沒有搬動;`pyside_ui/` 底下不含 Qt 的模組(`ai_config.py`、`github_api.py`、`user_setting_file.py` 等)也沒有搬,其中幾個路徑是 PyBreeze 的契約。 +- **測試**: + - `test/test_core_architecture.py`:以 `ast` 走訪 `core/` 的匯入關係(函式內的匯入也算,頂層 `je_editor/__init__.py` 除外),底下出現 Qt 或 `pyside_ui/` 就失敗;UI 層以下五個套件裡向上匯入的模組必須正好是列出的兩個(`utils/multi_language/locale_match.py`、`plugins/__init__.py`);另外在子行程裡把 `je_editor` 登記成空套件、擋掉 `PySide6` / `shiboken6` / `qt_material` / `qtconsole` / `frontengine` 的匯入後,實際建立 `EditorServices`、開文件、回報診斷。 + - 驗證過這些守門測試會失敗:在 `core/uri/resource_uri.py` 的函式裡加一行 `from PySide6.QtCore import QObject`,兩支靜態測試失敗;在 `core/events/event_hook.py` 的模組層級加 `import PySide6.QtCore`,四支都失敗(子行程回報 `PySide6 is blocked in this probe`)。 + - `test/test_public_api_contract.py`:`je_editor.__all__` 的 37 個既有名稱不能少、15 個內部名稱要在原本的模組路徑上(PyBreeze `16214a5` 實際匯入的 14 個,加上 `architecture.md` §6 原本列的 `write_file`)、`EditorMain` 建構子維持 `debug_mode, show_system_tray_ray, extend` 且預設都是 `False`。 + - `test_core_event_hook.py`、`test_core_workspace.py`、`test_core_documents.py`、`test_core_diagnostics.py`、`test_core_language_services.py`、`test_core_services.py`:各模型與登記表的行為;除錯、工作、遠端、AI 四個介面以假實作驗證協定與登記表的用法。 +- **結果**:這台機器沒有專案的虛擬環境,新建了 `.venv`(Python 3.11.9、PySide6 6.11.2、pytest 9.1.1、ruff 0.16.10),安裝內容跟 CI 相同(`dev_requirements.txt` 加 `pip install -e .`)。 + - `QT_QPA_PLATFORM=offscreen python -m pytest test/ --ignore=test/qt_ui`:2231 passed、1 failed。失敗的是既有的 `test_file_scan.py::TestIndexProjectFiles::test_depth_limit_prunes_deep_trees`(`WinError 206`,這台機器沒有開 Windows 長路徑);在 `da90c4f` 的乾淨工作樹上同樣失敗,跟這次修改無關,記在 `PROGRESS.md` #19。 + - 新增與改到的九個測試檔共 238 個測試(235 個是新的),不設 `QT_QPA_PLATFORM`(CI 單元測試的跑法)也全過。 + - `start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束。另外以 `EditorMain(debug_mode=True, extend=True)` 啟動一次:十個選單、沒有 Plugins 選單,`tab_widget` 與 `help_menu` 都在,事件迴圈正常結束。 + - ruff:0.15.8 的預設規則下整個 repo 乾淨。0.16.10 的預設規則從 118 條變成 826 條,整個 repo 526 筆,其中這次新增的檔案 10 筆(`I001` 9 筆、`RUF022` 1 筆,寫法跟現有程式碼一致);用 0.16.10 加 `--select E4,E7,E9,F` 則整個 repo 乾淨。這是既有的狀況,記在 `PROGRESS.md` #18,這次沒有改 ruff 的版本或設定。 + - Sphinx 9.0.4 建置(`-D html_theme=alabaster`,本機沒有裝 `sphinx_rtd_theme`):兩個新頁面沒有警告,各有 3 個表格、5 段範例、107 個行內程式碼。 +- **文件**:新增 `docs/source/docs/Eng/core_services.rst` 與 `Zh/core_services.rst`(兩份索引與 `api_reference.rst` 都加了連結,例外清單加上 `JEditorServiceException`);三份 README 的「作為函式庫使用」與「專案架構」;`architecture.md` §2、§3、§5、§6、§8;`architecture_explore.md` §1、§2、§4、§5.11、§6.1、§7、§8(各套件規模依現況重算,並更正 PySide6 版本與 CI 的 Python 範圍);藍圖加上實作狀態。文件與 README 裡的每一段 `je_editor.core` 範例都實際執行過。 +- **`architecture.md` §6 的更正**:PyBreeze 以模組路徑匯入的內部名稱,原本列的是 6 個,對照 PyBreeze `16214a5` 實際是 14 個:原本的 6 個裡 `write_file` 沒有被匯入,另外多了 9 個(`FullEditorWidget`、`user_setting_dict`、自動儲存的三個名稱、`RedirectStdErr`、`DEFAULT_ENCODING`、`LINE_ENDING_LF`、`write_file_with_encoding`)。 +- **檔案**:`je_editor/core/`(新,26 個檔)、`je_editor/__init__.py`、`je_editor/utils/exception/exceptions.py`、`test/test_core_*.py`(新,7 個)、`test/test_public_api_contract.py`(新)、`test/test_exceptions.py`、`docs/source/docs/{Eng,Zh}/core_services.rst`(新)、`docs/source/docs/{Eng,Zh}/api_reference.rst`、`docs/source/docs/Eng/eng_index.rst`、`docs/source/docs/Zh/zh_index.rst`、`README.md`、`README/README_zh-TW.md`、`README/README_zh-CN.md`、`architecture.md`、`architecture_explore.md`、`docs/roadmap/2026-editor-next.md`、`PROGRESS.md`。 +- **待辦**:`PROGRESS.md` #9 ~ #17(M1 ~ M8,以及 PR #270 尚未答覆的審查問題)、#18(ruff 0.16 的預設規則)、#19(長路徑測試)、#20(pytest 10 會拒絕的 fixture 寫法)。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 038173f..970b1b2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-01 | 2026-10-08 | 藍圖 M0:不依賴 Qt 的核心服務層 je_editor/core | #migration #roadmap #core | [2026-10](2026-10.md) | | U-20261001-08 | 2026-10-01 | 發佈鎖檔改用和其他鎖檔一樣的七天截止日解析 | #ci #security #X-13 | [2026-10](2026-10.md) | | U-20261001-07 | 2026-10-01 | 發佈工作用鎖定的 setuptools 建置,不再下載當下最新的版本 | #done #ci #security #X-13 | [2026-10](2026-10.md) | | U-20261001-06 | 2026-10-01 | 發佈工作的建置工具改照雜湊鎖定的清單安裝 | #done #ci #security #X-13 | [2026-10](2026-10.md) | @@ -91,5 +92,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 8 | +| [2026-10.md](2026-10.md) | 2026-10 | 9 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/__init__.py b/je_editor/__init__.py index c4afe81..e03f874 100644 --- a/je_editor/__init__.py +++ b/je_editor/__init__.py @@ -18,6 +18,7 @@ from je_editor.utils.exception.exceptions import JEditorOpenFileException from je_editor.utils.exception.exceptions import JEditorRunOnShellException from je_editor.utils.exception.exceptions import JEditorSaveFileException +from je_editor.utils.exception.exceptions import JEditorServiceException from je_editor.utils.multi_language.english import english_word_dict from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.multi_language.traditional_chinese import traditional_chinese_word_dict @@ -45,7 +46,7 @@ "JEditorCantFindLanguageException", "JEditorJsonException", "PythonHighlighter", "user_setting_dict", "user_setting_color_dict", "EditorWidget", "MainBrowserWidget", "ExecManager", "ShellManager", "traditional_chinese_word_dict", "english_word_dict", - "language_wrapper", "jeditor_logger", + "language_wrapper", "jeditor_logger", "JEditorServiceException", # Plugin API "register_programming_language", "get_programming_language_plugin", "get_all_programming_language_suffixes", diff --git a/je_editor/core/__init__.py b/je_editor/core/__init__.py new file mode 100644 index 0000000..3b0d645 --- /dev/null +++ b/je_editor/core/__init__.py @@ -0,0 +1,63 @@ +""" +JEditor 的核心服務層 +JEditor's core service layer. + +工作區、文件、診斷、語言服務、除錯、工作執行、遠端與 AI 供應者的介面與資料物件。 +這一層不匯入 Qt,也不匯入 ``je_editor.pyside_ui``,所以可以在沒有視窗的地方使用; +``test/test_core_architecture.py`` 守著這條界線。 +The interfaces and data objects for the workspace, documents, diagnostics, +language services, debugging, task execution, remote sessions and AI providers. +Nothing here imports Qt or ``je_editor.pyside_ui``, so it works where there is +no window; ``test/test_core_architecture.py`` holds that line. +""" +from je_editor.core.ai.ai_provider import ( + AIProvider, CancelToken, ChatMessage, ChatRequest, ChatResponse, ChatRole, ModelInfo +) +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugLaunchRequest, DebugSession, DebugSessionFactory, DebugState, + StackFrame, StepKind, Variable +) +from je_editor.core.diagnostics.diagnostic_model import ( + Diagnostic, DiagnosticStore, Position, QuickFix, RelatedInformation, Severity, + TextEdit, TextRange, filter_diagnostics +) +from je_editor.core.document.document_model import Document, DocumentStore, TextDocument +from je_editor.core.events.event_hook import EventHook +from je_editor.core.language.language_service import ( + LanguageCapability, LanguageService, LanguageServiceRegistry +) +from je_editor.core.process.task_service import ( + OutputStream, TaskHandle, TaskRunner, TaskSpec, TaskState +) +from je_editor.core.registry.named_registry import NamedRegistry +from je_editor.core.remote.remote_session import RemoteSession, RemoteState, RemoteTransport +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.uri.resource_uri import is_local_uri, to_path, to_uri, uri_key, uri_scheme +from je_editor.core.workspace.workspace_model import ProjectRoot, Workspace +from je_editor.utils.exception.exceptions import JEditorServiceException + +__all__ = [ + # Services + "EditorServices", "EventHook", "NamedRegistry", "JEditorServiceException", + # Workspace + "Workspace", "ProjectRoot", + # Resource URIs + "to_uri", "to_path", "is_local_uri", "uri_key", "uri_scheme", + # Documents + "Document", "TextDocument", "DocumentStore", + # Diagnostics + "Diagnostic", "DiagnosticStore", "Severity", "Position", "TextRange", + "RelatedInformation", "TextEdit", "QuickFix", "filter_diagnostics", + # Language services + "LanguageService", "LanguageServiceRegistry", "LanguageCapability", + # Debugging + "DebugSession", "DebugSessionFactory", "DebugState", "DebugLaunchRequest", + "Breakpoint", "StackFrame", "Variable", "StepKind", + # Task execution + "TaskRunner", "TaskHandle", "TaskSpec", "TaskState", "OutputStream", + # Remote sessions + "RemoteSession", "RemoteState", "RemoteTransport", + # AI providers + "AIProvider", "ChatRequest", "ChatResponse", "ChatMessage", "ChatRole", + "ModelInfo", "CancelToken", +] diff --git a/je_editor/core/ai/__init__.py b/je_editor/core/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/ai/ai_provider.py b/je_editor/core/ai/ai_provider.py new file mode 100644 index 0000000..4374a84 --- /dev/null +++ b/je_editor/core/ai/ai_provider.py @@ -0,0 +1,167 @@ +""" +AI 供應者的介面 +The interface for an AI provider. + +對話面板只該知道「送出一段對話、拿回回覆」,不該知道背後是哪一家的模型或哪一個 +SDK。每一家各自實作這個介面並登記,面板就不必為了新增一家而改。 +The chat panel should know only how to send a conversation and get a reply, +never whose model or which SDK is behind it. Each vendor implements this +interface and registers itself, so adding one never changes the panel. + +這裡只有介面與資料物件,不連線到任何服務。 +This holds the interface and its data objects only, and connects to no service. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from threading import Event +from typing import Protocol, runtime_checkable + + +class ChatRole(Enum): + """ + 一則訊息是誰說的 + Who said a message. + """ + + USER = "user" + ASSISTANT = "assistant" + + +@dataclass(frozen=True) +class ChatMessage: + """ + 對話裡的一則訊息 + One message of a conversation. + + :param role: 誰說的 / who said it + :param content: 內容 / what was said + """ + + role: ChatRole + content: str + + +@dataclass(frozen=True) +class ModelInfo: + """ + 供應者提供的一個模型 + One model a provider offers. + + :param model_id: 呼叫時使用的識別字 / the identifier used when calling it + :param display_name: 給使用者看的名稱 / the name to show the user + :param supports_streaming: 是否能邊產生邊回傳 / whether it can answer as it generates + """ + + model_id: str + display_name: str = "" + supports_streaming: bool = False + + +@dataclass(frozen=True) +class ChatRequest: + """ + 一次對話請求 + One chat request. + + :param messages: 到目前為止的對話,最後一則是這次要回答的 + the conversation so far, the last message being the one to answer + :param model_id: 要用的模型,空字串表示供應者的預設 + the model to use, empty for the provider's default + :param system_prompt: 系統提示詞 / the system prompt + """ + + messages: tuple[ChatMessage, ...] + model_id: str = "" + system_prompt: str = "" + + +@dataclass(frozen=True) +class ChatResponse: + """ + 一次對話的回覆 + The reply to a chat request. + + :param text: 回覆的全文 / the whole reply + :param model_id: 實際回答的模型 / the model that answered + :param input_tokens: 請求用掉的 token 數,供應者沒回報時為 ``None`` + the tokens the request used, ``None`` when the provider does not say + :param output_tokens: 回覆用掉的 token 數,供應者沒回報時為 ``None`` + the tokens the reply used, ``None`` when the provider does not say + :param cancelled: 是否在完成前被取消 / whether it was cancelled before it finished + """ + + text: str + model_id: str = "" + input_tokens: int | None = None + output_tokens: int | None = None + cancelled: bool = False + + +class CancelToken: + """ + 讓呼叫端取消一次進行中的請求 + Lets the caller cancel a request in flight. + + 請求在背景執行緒進行,取消則來自 UI 執行緒,所以用執行緒安全的旗標。 + The request runs on a worker thread while the cancel comes from the UI + thread, hence a thread-safe flag. + """ + + def __init__(self) -> None: + self._event = Event() + + def cancel(self) -> None: + """要求取消 / Ask for the request to be cancelled.""" + self._event.set() + + @property + def cancelled(self) -> bool: + """是否已經要求取消 / Whether a cancel has been asked for.""" + return self._event.is_set() + + +# 收到一段剛產生的文字時呼叫 / Called with each piece of text as it is generated +TextListener = Callable[[str], None] + + +@runtime_checkable +class AIProvider(Protocol): + """ + 一家 AI 服務 + One AI service. + """ + + @property + def name(self) -> str: + """供應者名稱,在登記表裡不能重複 / The provider's name, unique in the registry.""" + + def models(self) -> list[ModelInfo]: + """ + 這個供應者提供哪些模型 + The models this provider offers. + + :return: 模型清單 / the models + """ + + def complete(self, request: ChatRequest, on_text: TextListener | None = None, + cancel: CancelToken | None = None) -> ChatResponse: + """ + 送出對話並等待回覆 + Send a conversation and wait for the reply. + + 這個呼叫會等到回覆完成,所以要在背景執行緒呼叫,不能在 UI 執行緒。 + This call blocks until the reply is complete, so it belongs on a worker + thread and never on the UI thread. + + :param request: 對話請求 / the chat request + :param on_text: 每產生一段文字就呼叫一次;``None`` 表示只要最後的結果 + called with each piece of text as it is generated, or ``None`` to + get only the final result + :param cancel: 用來中途取消的旗標 / the token that cancels it part-way + :return: 回覆 / the reply + :raises JEditorServiceException: 服務回報錯誤,或設定不完整 + when the service reports an error or the configuration is incomplete + """ diff --git a/je_editor/core/debug/__init__.py b/je_editor/core/debug/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/debug/debug_session.py b/je_editor/core/debug/debug_session.py new file mode 100644 index 0000000..05714bc --- /dev/null +++ b/je_editor/core/debug/debug_session.py @@ -0,0 +1,205 @@ +""" +除錯工作階段的介面 +The interface for a debug session. + +除錯面板只該知道「一個可以啟動、暫停、逐步執行的工作階段」,不該知道背後是 pdb +還是哪一個 Debug Adapter Protocol 伺服器。這裡的名稱與資料跟 DAP 的概念對應, +所以之後接上 DAP 不必改介面。 +The debug panel should know one session it can launch, pause and step, never +whether pdb or some Debug Adapter Protocol server is behind it. The names and +data here follow DAP's concepts, so connecting DAP later needs no change to the +interface. + +堆疊、變數與運算式求值的查詢要等 DAP 那個里程碑決定非同步的形式,這裡先固定 +控制指令與資料物件。 +Queries for the stack, variables and expression evaluation wait for the DAP +milestone to settle how they answer asynchronously; this fixes the control +commands and the data objects first. +""" +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import Enum +from typing import Protocol, runtime_checkable + +from je_editor.core.events.event_hook import EventHook +from je_editor.utils.exception.exceptions import JEditorServiceException + + +class DebugState(Enum): + """ + 除錯工作階段的狀態 + The state a debug session is in. + """ + + IDLE = "idle" + STARTING = "starting" + RUNNING = "running" + PAUSED = "paused" + TERMINATED = "terminated" + + +class StepKind(Enum): + """ + 逐步執行的方式 + The ways to step. + """ + + OVER = "over" + INTO = "into" + OUT = "out" + + +@dataclass(frozen=True) +class Breakpoint: + """ + 一個中斷點 + One breakpoint. + + :param uri: 所在資源的 URI / the URI of the resource it is in + :param line: 1 起算的行號 / the 1-based line + :param condition: 成立才停下來的條件,空字串表示一律停 + the condition that has to hold to stop, empty to stop every time + :param enabled: 是否啟用 / whether it is in effect + """ + + uri: str + line: int + condition: str = "" + enabled: bool = True + + +@dataclass(frozen=True) +class StackFrame: + """ + 呼叫堆疊裡的一層 + One frame of the call stack. + + :param frame_id: 轉接器給這一層的編號 / the id the adapter gives this frame + :param name: 函式名稱 / the function's name + :param uri: 原始碼的 URI / the URI of the source + :param line: 1 起算的行號 / the 1-based line + :param column: 1 起算的欄號 / the 1-based column + """ + + frame_id: int + name: str + uri: str + line: int + column: int = 1 + + +@dataclass(frozen=True) +class Variable: + """ + 一個變數 + One variable. + + :param name: 變數名稱 / the variable's name + :param value: 顯示用的值 / the value, as text to show + :param type_name: 型別名稱 / the name of its type + :param children_reference: 用來查子項目的編號,零表示沒有子項目 + the handle for asking for its children, zero when it has none + """ + + name: str + value: str + type_name: str = "" + children_reference: int = 0 + + +@dataclass(frozen=True) +class DebugLaunchRequest: + """ + 啟動一次除錯所需的資訊 + What it takes to launch a debug run. + + :param program: 要除錯的程式 / the program to debug + :param arguments: 交給程式的引數 / the arguments for the program + :param working_directory: 工作目錄,空字串表示沿用目前的 + the working directory, empty to keep the current one + :param stop_on_entry: 是否一進入程式就停下來 / whether to stop on the first line + :raises JEditorServiceException: 沒有指定程式 / when no program is given + """ + + program: str + arguments: tuple[str, ...] = () + working_directory: str = "" + stop_on_entry: bool = False + + def __post_init__(self) -> None: + if not self.program: + raise JEditorServiceException("A debug launch needs a program") + + +@runtime_checkable +class DebugSession(Protocol): + """ + 一個除錯工作階段 + One debug session. + """ + + @property + def state_changed(self) -> EventHook: + """狀態改變後發出,引數是新的 :class:`DebugState` / Fired with the new state.""" + + def state(self) -> DebugState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + + def launch(self, request: DebugLaunchRequest) -> bool: + """ + 啟動程式並開始除錯 + Launch the program under the debugger. + + :param request: 啟動所需的資訊 / what to launch + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + + def set_breakpoints(self, uri: str, breakpoints: Sequence[Breakpoint]) -> None: + """ + 設定某個資源的全部中斷點 + Set every breakpoint of one resource. + + 每次都給整份清單,沒列出的就是清掉——跟 DAP 的 ``setBreakpoints`` 一樣。 + The whole list is given each time and anything left out is cleared, as + DAP's ``setBreakpoints`` works. + + :param uri: 資源的 URI / the resource's URI + :param breakpoints: 這個資源現在所有的中斷點 / all its breakpoints now + """ + + def resume(self) -> None: + """ + 繼續執行 + Carry on running. + """ + + def pause(self) -> None: + """ + 暫停執行 + Pause the run. + """ + + def step(self, kind: StepKind) -> None: + """ + 逐步執行 + Take one step. + + :param kind: 逐步的方式 / how to step + """ + + def terminate(self) -> None: + """ + 結束除錯並放掉它持有的程序 + End the run and release the process it holds. + """ + + +# 建立一個新的除錯工作階段 / Builds one new debug session +DebugSessionFactory = Callable[[], DebugSession] diff --git a/je_editor/core/diagnostics/__init__.py b/je_editor/core/diagnostics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/diagnostics/diagnostic_model.py b/je_editor/core/diagnostics/diagnostic_model.py new file mode 100644 index 0000000..f0dca7a --- /dev/null +++ b/je_editor/core/diagnostics/diagnostic_model.py @@ -0,0 +1,325 @@ +""" +統一的診斷模型 +One model for every diagnostic. + +ruff 與語言伺服器各有自己的欄位與嚴重度寫法。這裡定義所有來源共用的形式:誰報的、 +多嚴重、在哪個資源的哪一段、有沒有相關位置與可套用的修正。 +ruff and a language server each have their own fields and their own way of +spelling a severity. This defines the shape every source shares: who reported +it, how severe it is, which span of which resource it is on, and whether it +carries related locations or a fix that can be applied. + +行與欄都是 1 起算,跟編輯器畫面上的一致。 +Lines and columns count from one, as the editor shows them. + +純邏輯:不執行任何檢查,也不碰 Qt。 +Pure logic: it runs no check and touches no Qt. +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, replace +from enum import IntEnum +from threading import Lock + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.uri.resource_uri import uri_key + +# 行與欄的起算值 / What lines and columns count from +FIRST_LINE = 1 +FIRST_COLUMN = 1 + + +class Severity(IntEnum): + """ + 診斷的嚴重度 + How severe a diagnostic is. + + 數值跟 LSP 的 ``DiagnosticSeverity`` 相同,所以伺服器給的數字可以直接轉換; + 數值越小越嚴重。 + The numbers are LSP's ``DiagnosticSeverity``, so a server's number converts + directly, and a smaller number is more severe. + """ + + ERROR = 1 + WARNING = 2 + INFORMATION = 3 + HINT = 4 + + +@dataclass(frozen=True, order=True) +class Position: + """ + 文件裡的一個位置 + One position in a document. + + :param line: 1 起算的行號 / the 1-based line + :param column: 1 起算的欄號 / the 1-based column + """ + + line: int = FIRST_LINE + column: int = FIRST_COLUMN + + +@dataclass(frozen=True) +class TextRange: + """ + 文件裡的一段範圍 + A span of a document. + + :param start: 起點 / where it starts + :param end: 終點 / where it ends + """ + + start: Position + end: Position + + @classmethod + def from_lines(cls, line: int, column: int = FIRST_COLUMN, + end_line: int | None = None, end_column: int | None = None) -> TextRange: + """ + 由行列數字建立範圍,不合理的數字會被修正 + Build a range from line and column numbers, correcting nonsense. + + 工具的輸出偶爾會有小於一的行號或在起點之前的終點;診斷仍然要能顯示,所以 + 這裡修正而不是報錯。 + A tool's output now and then has a line below one or an end before the + start. The diagnostic still has to be shown, so this corrects rather + than raises. + + :param line: 起始行 / the start line + :param column: 起始欄 / the start column + :param end_line: 結束行,沒給時同起始行 / the end line, the start line when omitted + :param end_column: 結束欄,沒給時同起始欄 / the end column, the start column + when omitted + :return: 範圍 / the range + """ + start = Position(max(FIRST_LINE, line), max(FIRST_COLUMN, column)) + end = Position( + start.line if end_line is None else end_line, + start.column if end_column is None else max(FIRST_COLUMN, end_column), + ) + return cls(start, max(start, end)) + + +@dataclass(frozen=True) +class RelatedInformation: + """ + 跟一筆診斷有關的另一個位置 + Another location that has to do with a diagnostic. + + :param uri: 那個位置所在的資源 / the resource that location is in + :param range: 那個位置的範圍 / the span of that location + :param message: 說明 / what it has to do with the diagnostic + """ + + uri: str + range: TextRange + message: str + + +@dataclass(frozen=True) +class TextEdit: + """ + 一筆文字替換 + One replacement of text. + + :param range: 要換掉的範圍 / the span to replace + :param new_text: 換上去的文字 / the text to put there + """ + + range: TextRange + new_text: str + + +@dataclass(frozen=True) +class QuickFix: + """ + 一筆診斷可以套用的修正 + A fix that can be applied for a diagnostic. + + :param title: 給使用者看的名稱 / the name to show the user + :param edits: 套用時要做的替換 / the replacements that apply it + """ + + title: str + edits: tuple[TextEdit, ...] = () + + +@dataclass(frozen=True) +class Diagnostic: + """ + 一筆診斷 + One diagnostic. + + :param message: 說明文字 / the human-readable message + :param range: 所在範圍 / the span it is on + :param severity: 嚴重度 / how severe it is + :param source: 誰報的,例如 ``ruff`` 或語言伺服器的名稱 / who reported it, + such as ``ruff`` or a language server's name + :param code: 規則代碼 / the rule code + :param uri: 所在資源的 URI / the URI of the resource it is in + :param related: 相關的其他位置 / other locations that relate to it + :param fixes: 可以套用的修正 / the fixes that can be applied + """ + + message: str + range: TextRange + severity: Severity = Severity.ERROR + source: str = "" + code: str = "" + uri: str = "" + related: tuple[RelatedInformation, ...] = () + fixes: tuple[QuickFix, ...] = () + + @property + def label(self) -> str: + """給面板顯示的一行說明 / A single line for a panel.""" + return f"{self.code} {self.message}" if self.code else self.message + + @property + def sort_key(self) -> tuple[str, Position, int, str, str, str]: + """讓清單順序固定的排序鍵 / The key that gives a list one fixed order.""" + return (uri_key(self.uri), self.range.start, int(self.severity), + self.source, self.code, self.message) + + +def filter_diagnostics(diagnostics: Iterable[Diagnostic], + severities: Iterable[Severity] | None = None, + sources: Iterable[str] | None = None) -> list[Diagnostic]: + """ + 依嚴重度與來源篩選診斷 + Filter diagnostics by severity and by source. + + 結果一律依資源、位置、嚴重度排序,所以同一組診斷不管怎麼篩,順序都相同。 + The result is always ordered by resource, position and severity, so one set + of diagnostics comes out in the same order however it is filtered. + + :param diagnostics: 要篩選的診斷 / the diagnostics to filter + :param severities: 要保留的嚴重度,``None`` 表示全部 / the severities to keep, + ``None`` for all of them + :param sources: 要保留的來源,``None`` 表示全部 / the sources to keep, ``None`` + for all of them + :return: 符合條件的診斷 / the diagnostics that match + """ + wanted_severities = None if severities is None else set(severities) + wanted_sources = None if sources is None else set(sources) + kept = [ + item for item in diagnostics + if (wanted_severities is None or item.severity in wanted_severities) + and (wanted_sources is None or item.source in wanted_sources) + ] + return sorted(kept, key=lambda item: item.sort_key) + + +class DiagnosticStore: + """ + 所有來源回報的診斷 + The diagnostics every source has reported. + + 每個來源對每個資源各有一組診斷,重新回報時整組換掉——這是 LSP + ``publishDiagnostics`` 的語意,ruff 每檢查一次也是整份重來。 + Each source holds one set of diagnostics per resource and a new report + replaces that set, which is what LSP's ``publishDiagnostics`` means and what + a ruff run amounts to as well. + """ + + def __init__(self) -> None: + # (來源, 資源的鍵) -> 診斷 / (source, resource key) -> diagnostics + self._reports: dict[tuple[str, str], tuple[Diagnostic, ...]] = {} + self._lock = Lock() + # 某個資源的診斷變了之後發出,引數是它的 URI + # Fired after a resource's diagnostics change, with its URI + self.changed = EventHook("diagnostics changed") + + def publish(self, source: str, uri: str, diagnostics: Iterable[Diagnostic]) -> bool: + """ + 回報某個來源對某個資源的整組診斷 + Report everything one source has to say about one resource. + + :param source: 來源名稱 / the source's name + :param uri: 資源的 URI / the resource's URI + :param diagnostics: 這個來源目前對它的所有診斷;空的表示沒有問題 + everything this source now reports for it; empty means no findings + :return: 內容是否改變(沒變時訂閱者不會被通知)/ whether anything changed; + subscribers are not told when nothing did + """ + report = tuple(replace(item, source=source, uri=uri) for item in diagnostics) + key = (source, uri_key(uri)) + with self._lock: + if self._reports.get(key, ()) == report: + return False + if report: + self._reports[key] = report + else: + del self._reports[key] + self.changed.emit(uri) + return True + + def clear(self, source: str | None = None, uri: str | None = None) -> bool: + """ + 清掉診斷 + Drop diagnostics. + + :param source: 只清這個來源的;``None`` 表示所有來源 / only this source's, + or every source's when ``None`` + :param uri: 只清這個資源的;``None`` 表示所有資源 / only this resource's, + or every resource's when ``None`` + :return: 是否真的清掉了什麼 / whether anything was dropped + """ + wanted_key = None if uri is None else uri_key(uri) + with self._lock: + doomed = [ + key for key in self._reports + if (source is None or key[0] == source) + and (wanted_key is None or key[1] == wanted_key) + ] + affected = {self._reports.pop(key)[0].uri for key in doomed} + for affected_uri in sorted(affected): + self.changed.emit(affected_uri) + return bool(doomed) + + def select(self, severities: Iterable[Severity] | None = None, + sources: Iterable[str] | None = None, uri: str | None = None) -> list[Diagnostic]: + """ + 取出符合條件的診斷 + The diagnostics that match. + + :param severities: 要保留的嚴重度,``None`` 表示全部 / the severities to + keep, ``None`` for all of them + :param sources: 要保留的來源,``None`` 表示全部 / the sources to keep, + ``None`` for all of them + :param uri: 只看這個資源,``None`` 表示全部 / only this resource, ``None`` + for all of them + :return: 排序好的診斷 / the diagnostics, in a fixed order + """ + wanted_key = None if uri is None else uri_key(uri) + with self._lock: + gathered = [ + item for key, report in self._reports.items() + if wanted_key is None or key[1] == wanted_key + for item in report + ] + return filter_diagnostics(gathered, severities, sources) + + def counts(self) -> dict[Severity, int]: + """ + 各嚴重度的診斷數量 + How many diagnostics there are at each severity. + + :return: 每一種嚴重度的數量,沒有的也列為零 / the count for every severity, + zero included + """ + totals = {severity: 0 for severity in Severity} + for item in self.select(): + totals[item.severity] += 1 + return totals + + def sources(self) -> list[str]: + """目前有回報診斷的來源,依名稱排序 / The sources with findings, sorted by name.""" + with self._lock: + return sorted({key[0] for key in self._reports}) + + def __len__(self) -> int: + with self._lock: + return sum(len(report) for report in self._reports.values()) diff --git a/je_editor/core/diagnostics/legacy_diagnostics.py b/je_editor/core/diagnostics/legacy_diagnostics.py new file mode 100644 index 0000000..05c425e --- /dev/null +++ b/je_editor/core/diagnostics/legacy_diagnostics.py @@ -0,0 +1,80 @@ +""" +舊診斷形式與統一模型之間的轉換 +Conversion between the older diagnostic shape and the unified model. + +編輯器的底線、縮圖與問題面板目前都吃 ``utils/lint`` 的 ``Diagnostic``。在它們 +逐一改用統一模型之前,這裡讓兩邊可以互轉,兩種形式就不必同時改。 +The editor's underlines, minimap and problems panel all take the ``Diagnostic`` +from ``utils/lint`` today. Until each of them moves to the unified model, this +converts both ways so the two shapes need not change at once. +""" +from __future__ import annotations + +from je_editor.core.diagnostics.diagnostic_model import Diagnostic, Severity, TextRange +from je_editor.core.uri.resource_uri import to_path, to_uri +from je_editor.utils.lint.ruff_diagnostics import ( + SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING +) +from je_editor.utils.lint.ruff_diagnostics import Diagnostic as LegacyDiagnostic + +# 舊形式的嚴重度字串對應的嚴重度 / The severity each older severity string means +_SEVERITY_FROM_LEGACY = { + SEVERITY_ERROR: Severity.ERROR, + SEVERITY_WARNING: Severity.WARNING, + SEVERITY_INFO: Severity.INFORMATION, +} + +# 舊形式沒有「提示」這一級,併入資訊 / The older shape has no hint level, so it joins information +_LEGACY_FROM_SEVERITY = { + Severity.ERROR: SEVERITY_ERROR, + Severity.WARNING: SEVERITY_WARNING, + Severity.INFORMATION: SEVERITY_INFO, + Severity.HINT: SEVERITY_INFO, +} + + +def from_legacy(diagnostic: LegacyDiagnostic, source: str, uri: str = "") -> Diagnostic: + """ + 把舊形式的診斷轉成統一模型 + Convert an older diagnostic into the unified model. + + :param diagnostic: 舊形式的診斷 / the older diagnostic + :param source: 誰報的;舊形式沒有記這件事 / who reported it, which the older + shape does not record + :param uri: 所在資源的 URI;舊診斷自己帶著檔案路徑時以它為準 + the resource's URI; a file path the older diagnostic carries wins over it + :return: 統一模型的診斷 / the diagnostic in the unified model + """ + return Diagnostic( + message=diagnostic.message, + range=TextRange.from_lines( + diagnostic.line, diagnostic.column, diagnostic.end_line, diagnostic.end_column), + severity=_SEVERITY_FROM_LEGACY.get(diagnostic.level, Severity.INFORMATION), + source=source, + code=diagnostic.code, + uri=to_uri(diagnostic.file_path) if diagnostic.file_path else uri, + ) + + +def to_legacy(diagnostic: Diagnostic) -> LegacyDiagnostic: + """ + 把統一模型的診斷轉回舊形式 + Convert a unified diagnostic back into the older shape. + + 來源、相關位置與修正在舊形式裡沒有位置,會被丟掉。 + The source, the related locations and the fixes have no place in the older + shape and are dropped. + + :param diagnostic: 統一模型的診斷 / the diagnostic in the unified model + :return: 舊形式的診斷 / the older diagnostic + """ + return LegacyDiagnostic( + line=diagnostic.range.start.line, + column=diagnostic.range.start.column, + end_line=diagnostic.range.end.line, + end_column=diagnostic.range.end.column, + code=diagnostic.code, + message=diagnostic.message, + severity=_LEGACY_FROM_SEVERITY[diagnostic.severity], + file_path=to_path(diagnostic.uri), + ) diff --git a/je_editor/core/document/__init__.py b/je_editor/core/document/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/document/document_model.py b/je_editor/core/document/document_model.py new file mode 100644 index 0000000..2899684 --- /dev/null +++ b/je_editor/core/document/document_model.py @@ -0,0 +1,224 @@ +""" +文件模型:服務眼中的「一份開著的文件」 +The document model: what an open document looks like to a service. + +語言服務、診斷與除錯都只需要知道文件的 URI、語言、版本與內容,不需要知道它是 +哪一種 Qt 元件。編輯器的緩衝區之後以轉接器的方式符合 :class:`Document`,這些服務 +就不必認得編輯器。 +Language services, diagnostics and debugging only need a document's URI, +language, version and text, never which Qt widget holds it. The editor's buffer +will satisfy :class:`Document` through an adapter, so those services never have +to know the editor. + +純邏輯:不讀寫磁碟,也不碰 Qt。 +Pure logic: it reads nothing from disk and touches no Qt. +""" +from __future__ import annotations + +from threading import Lock +from typing import Protocol, runtime_checkable + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.uri.resource_uri import uri_key + +# 文件剛開啟時的版本 / The version a document has when it is first opened +INITIAL_VERSION = 1 + + +@runtime_checkable +class Document(Protocol): + """ + 服務需要知道的文件資訊 + What a service needs to know about a document. + + 用 ``Protocol`` 而不是基底類別:之後由 ``QObject`` 持有緩衝區的轉接器可以直接 + 符合,不必同時繼承兩種中繼類別。 + A ``Protocol`` rather than a base class, so an adapter whose buffer lives in + a ``QObject`` can satisfy it without inheriting from two metaclasses. + """ + + @property + def uri(self) -> str: + """文件的 URI / The document's URI.""" + + @property + def language_id(self) -> str: + """LSP 的 language id,例如 ``python`` / The LSP language id, such as ``python``.""" + + @property + def version(self) -> int: + """內容每改一次就加一 / Goes up by one each time the text changes.""" + + def text(self) -> str: + """ + 取得目前的全文 + The whole current text. + + :return: 文件內容 / the document's text + """ + + +class TextDocument: + """ + 內容放在記憶體裡的文件 + A document whose text is held in memory. + + 用在沒有編輯器元件的地方:測試、命令列工具,或宿主程式自己管理的緩衝區。 + For places with no editor widget: tests, command-line tools, or a buffer the + host application manages itself. + """ + + def __init__(self, uri: str, text: str = "", language_id: str = "") -> None: + """ + :param uri: 文件的 URI / the document's URI + :param text: 一開始的內容 / the text to start with + :param language_id: LSP 的 language id / the LSP language id + """ + self._uri = uri + self._text = text + self._language_id = language_id + self._version = INITIAL_VERSION + + @property + def uri(self) -> str: + """文件的 URI / The document's URI.""" + return self._uri + + @property + def language_id(self) -> str: + """LSP 的 language id / The LSP language id.""" + return self._language_id + + @property + def version(self) -> int: + """內容每改一次就加一 / Goes up by one each time the text changes.""" + return self._version + + def text(self) -> str: + """ + 取得目前的全文 + The whole current text. + + :return: 文件內容 / the document's text + """ + return self._text + + def set_text(self, text: str) -> bool: + """ + 換掉全文 + Replace the whole text. + + :param text: 新的內容 / the new text + :return: 內容是否真的變了(沒變就不加版本)/ whether the text changed; the + version only goes up when it did + """ + if text == self._text: + return False + self._text = text + self._version += 1 + return True + + +class DocumentStore: + """ + 目前開著的文件 + The documents that are currently open. + + 以 URI 為鍵,所以同一個檔案不會被開成兩份。 + Keyed by URI, so one file is never opened as two documents. + """ + + def __init__(self) -> None: + self._documents: dict[str, Document] = {} + self._lock = Lock() + # 三個事件的引數都是那份文件 / Each event passes the document concerned + self.opened = EventHook("document opened") + self.changed = EventHook("document changed") + self.closed = EventHook("document closed") + + def open(self, document: Document) -> bool: + """ + 登記一份開著的文件 + Record a document as open. + + :param document: 要登記的文件 / the document + :return: 是否真的登記了(同一個 URI 已經開著時為 ``False``) + whether it was recorded, ``False`` when that URI is already open + """ + key = uri_key(document.uri) + with self._lock: + if key in self._documents: + return False + self._documents[key] = document + self.opened.emit(document) + return True + + def close(self, uri: str) -> bool: + """ + 放掉一份文件 + Let go of a document. + + :param uri: 文件的 URI / the document's URI + :return: 是否真的放掉了什麼 / whether a document was let go + """ + with self._lock: + document = self._documents.pop(uri_key(uri), None) + if document is None: + return False + self.closed.emit(document) + return True + + def get(self, uri: str) -> Document | None: + """ + 取得某個 URI 的文件 + The open document with a URI. + + :param uri: 文件的 URI / the document's URI + :return: 文件,沒開著時為 ``None`` / the document, or ``None`` + """ + with self._lock: + return self._documents.get(uri_key(uri)) + + def documents(self) -> list[Document]: + """目前開著的文件,依開啟順序 / The open documents, in the order they were opened.""" + with self._lock: + return list(self._documents.values()) + + def replace_text(self, uri: str, text: str) -> bool: + """ + 換掉一份記憶體文件的全文,並通知訂閱者 + Replace the text of an in-memory document and tell the subscribers. + + :param uri: 文件的 URI / the document's URI + :param text: 新的內容 / the new text + :return: 內容是否真的變了;文件沒開著,或不是 :class:`TextDocument` 時為 + ``False`` / whether the text changed; ``False`` when the document is + not open or is not a :class:`TextDocument` + """ + document = self.get(uri) + if not isinstance(document, TextDocument) or not document.set_text(text): + return False + self.changed.emit(document) + return True + + def notify_changed(self, uri: str) -> bool: + """ + 告訴訂閱者某份文件的內容變了 + Tell the subscribers a document's text changed. + + 給緩衝區不在這裡的文件用:編輯器自己改了內容之後呼叫這個。 + For a document whose buffer lives elsewhere: the editor calls this after + changing the text itself. + + :param uri: 文件的 URI / the document's URI + :return: 文件是否開著 / whether the document is open + """ + document = self.get(uri) + if document is None: + return False + self.changed.emit(document) + return True + + def __len__(self) -> int: + with self._lock: + return len(self._documents) diff --git a/je_editor/core/events/__init__.py b/je_editor/core/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/events/event_hook.py b/je_editor/core/events/event_hook.py new file mode 100644 index 0000000..a95bd2a --- /dev/null +++ b/je_editor/core/events/event_hook.py @@ -0,0 +1,99 @@ +""" +不靠 Qt 的事件通知 +Event notification that does not need Qt. + +核心服務要能在沒有 ``QApplication`` 的地方使用,因此不能用 Qt 的訊號。這裡提供同樣 +的「訂閱、通知」,Qt 這一層再把它接到自己的訊號上。 +The core services have to work where there is no ``QApplication``, which rules +out Qt signals. This gives the same subscribe-and-notify shape, and the Qt layer +connects it to signals of its own. + +訂閱者在發出通知的那個執行緒上被呼叫;要更新畫面的訂閱者得自己轉回 UI 執行緒。 +A subscriber is called on whichever thread emits, so one that updates widgets +has to hand over to the UI thread itself. +""" +from __future__ import annotations + +from collections.abc import Callable +from threading import Lock + +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 訂閱者的型別 / What a subscriber looks like +Listener = Callable[..., None] + + +class EventHook: + """ + 一個可訂閱的事件 + One event that can be subscribed to. + """ + + def __init__(self, name: str = "") -> None: + """ + :param name: 事件名稱,只用在日誌 / the event's name, used only in the log + """ + self._name = name + self._listeners: list[Listener] = [] + self._lock = Lock() + + def subscribe(self, listener: Listener) -> Callable[[], None]: + """ + 訂閱這個事件 + Subscribe to this event. + + :param listener: 事件發生時要呼叫的函式 / what to call when it fires + :return: 取消這次訂閱的函式 / a function that undoes this subscription + """ + with self._lock: + self._listeners.append(listener) + + def unsubscribe() -> None: + self.unsubscribe(listener) + + return unsubscribe + + def unsubscribe(self, listener: Listener) -> bool: + """ + 取消訂閱 + Stop a listener from being called. + + :param listener: 先前訂閱的函式 / the function subscribed earlier + :return: 是否真的移除了訂閱 / whether a subscription was removed + """ + with self._lock: + if listener not in self._listeners: + return False + self._listeners.remove(listener) + return True + + def emit(self, *args: object) -> None: + """ + 通知每一個訂閱者 + Call every subscriber. + + 一個訂閱者出錯不會擋住其他訂閱者:問題面板的例外不該讓縮圖收不到同一筆 + 通知。錯誤會寫進日誌。 + One failing subscriber does not stop the rest: an error in the problems + panel must not keep the minimap from hearing the same news. The failure + is logged. + + :param args: 交給訂閱者的引數 / what to pass to each subscriber + """ + with self._lock: + # 複製一份再呼叫,訂閱者才能在回呼裡取消訂閱 + # Call a copy, so a subscriber can unsubscribe from inside its callback + listeners = tuple(self._listeners) + for listener in listeners: + try: + listener(*args) + # 訂閱者是別人的程式碼,會丟什麼例外無從列舉;這裡是隔離邊界,記錄後繼續 + # Subscribers are foreign code whose exceptions cannot be enumerated; + # this is the isolation boundary, so log and carry on + except Exception: # noqa: BLE001 + jeditor_logger.exception("event %s: a subscriber failed", self._name or "") + + def __len__(self) -> int: + """目前有幾個訂閱者 / How many subscribers there are.""" + with self._lock: + return len(self._listeners) diff --git a/je_editor/core/language/__init__.py b/je_editor/core/language/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/language/language_service.py b/je_editor/core/language/language_service.py new file mode 100644 index 0000000..91995aa --- /dev/null +++ b/je_editor/core/language/language_service.py @@ -0,0 +1,204 @@ +""" +語言服務的介面與登記表 +The interface for language services, and the registry that holds them. + +語言伺服器、之後的 Tree-sitter 解析器,或任何「看得懂某種語言」的東西,對編輯器 +來說都是同一件事:告訴它文件開了、改了、關了,它就提供自己會的功能。登記表負責 +把文件的生命週期轉給每一個處理該語言的服務。 +A language server, the Tree-sitter parser to come, or anything else that +understands a language is one and the same thing to the editor: tell it a +document opened, changed or closed, and it offers what it can do. The registry +passes each document's lifecycle on to every service that handles its language. + +補全、懸停說明這類「發問、等回覆」的呼叫形式由 Tree-sitter 與診斷那個里程碑決定, +這裡先固定生命週期與能力查詢。 +The request-and-reply calls, completion and hover among them, take their shape +in the Tree-sitter and diagnostics milestone; this fixes the lifecycle and the +capability lookup first. +""" +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import Protocol, runtime_checkable + +from je_editor.core.document.document_model import Document, DocumentStore +from je_editor.core.registry.named_registry import NamedRegistry + + +class LanguageCapability(Enum): + """ + 語言服務可以提供的功能 + What a language service can offer. + """ + + DIAGNOSTICS = "diagnostics" + COMPLETION = "completion" + HOVER = "hover" + SIGNATURE_HELP = "signature_help" + DEFINITION = "definition" + REFERENCES = "references" + RENAME = "rename" + FORMATTING = "formatting" + CODE_ACTION = "code_action" + DOCUMENT_SYMBOLS = "document_symbols" + SYNTAX_TREE = "syntax_tree" + + +@runtime_checkable +class LanguageService(Protocol): + """ + 一個看得懂某些語言的服務 + A service that understands some languages. + """ + + @property + def name(self) -> str: + """服務名稱,在登記表裡不能重複 / The service's name, unique in the registry.""" + + def capabilities(self) -> frozenset[LanguageCapability]: + """ + 這個服務提供哪些功能 + What this service offers. + + :return: 功能的集合 / the set of capabilities + """ + + def handles(self, document: Document) -> bool: + """ + 這個服務是否處理某份文件 + Whether this service handles a document. + + :param document: 要判斷的文件 / the document in question + :return: 會處理時為 ``True`` / ``True`` when it does + """ + + def document_opened(self, document: Document) -> None: + """ + 有一份它處理的文件開了 + A document it handles was opened. + + :param document: 開啟的文件 / the document that opened + """ + + def document_changed(self, document: Document) -> None: + """ + 有一份它處理的文件內容變了 + A document it handles changed. + + :param document: 內容變了的文件 / the document that changed + """ + + def document_closed(self, document: Document) -> None: + """ + 有一份它處理的文件關了 + A document it handles was closed. + + :param document: 關閉的文件 / the document that closed + """ + + def shutdown(self) -> None: + """ + 放掉這個服務持有的程序、執行緒與連線 + Release the processes, threads and connections this service holds. + """ + + +class LanguageServiceRegistry: + """ + 已登記的語言服務 + The language services that are registered. + """ + + def __init__(self, documents: DocumentStore) -> None: + """ + :param documents: 文件的來源;它的開啟、變更與關閉會轉給各個服務 + where documents come from; its opens, changes and closes are passed + on to the services + """ + self._documents = documents + self._services: NamedRegistry[LanguageService] = NamedRegistry("language service") + self._unsubscribe: list[Callable[[], None]] = [ + documents.opened.subscribe(self._on_opened), + documents.changed.subscribe(self._on_changed), + documents.closed.subscribe(self._on_closed), + ] + + def register(self, service: LanguageService) -> None: + """ + 登記一個語言服務 + Register a language service. + + 已經開著的文件會補送一次「開啟」,晚啟動的伺服器才知道有哪些文件。 + Documents that are already open are announced to it, so a server that + starts late still learns what is open. + + :param service: 要登記的服務 / the service + :raises JEditorServiceException: 名稱是空的或已被使用 / when its name is + empty or already taken + """ + self._services.register(service.name, service) + for document in self._documents.documents(): + if service.handles(document): + service.document_opened(document) + + def unregister(self, name: str) -> bool: + """ + 移除並關閉一個語言服務 + Remove a language service and shut it down. + + :param name: 服務名稱 / the service's name + :return: 是否真的移除了 / whether a service was removed + """ + service = self._services.unregister(name) + if service is None: + return False + service.shutdown() + return True + + def services(self) -> list[LanguageService]: + """已登記的服務,依登記順序 / The registered services, in registration order.""" + return [service for _name, service in self._services.items()] + + def services_for(self, document: Document, + capability: LanguageCapability | None = None) -> list[LanguageService]: + """ + 找出處理某份文件的服務 + The services that handle a document. + + :param document: 文件 / the document + :param capability: 只要提供這個功能的服務,``None`` 表示不限 + only services offering this, or any service when ``None`` + :return: 符合的服務,依登記順序 / the matching services, in registration order + """ + return [ + service for service in self.services() + if service.handles(document) + and (capability is None or capability in service.capabilities()) + ] + + def shutdown(self) -> None: + """ + 關閉每一個服務,並停止轉送文件事件 + Shut every service down and stop passing document events on. + """ + for unsubscribe in self._unsubscribe: + unsubscribe() + self._unsubscribe = [] + for name in self._services.names(): + self.unregister(name) + + def _on_opened(self, document: Document) -> None: + """把「開啟」轉給處理它的服務 / Pass an open on to the services handling it.""" + for service in self.services_for(document): + service.document_opened(document) + + def _on_changed(self, document: Document) -> None: + """把「變更」轉給處理它的服務 / Pass a change on to the services handling it.""" + for service in self.services_for(document): + service.document_changed(document) + + def _on_closed(self, document: Document) -> None: + """把「關閉」轉給處理它的服務 / Pass a close on to the services handling it.""" + for service in self.services_for(document): + service.document_closed(document) diff --git a/je_editor/core/process/__init__.py b/je_editor/core/process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/process/task_service.py b/je_editor/core/process/task_service.py new file mode 100644 index 0000000..2622875 --- /dev/null +++ b/je_editor/core/process/task_service.py @@ -0,0 +1,169 @@ +""" +執行外部程式的介面 +The interface for running an external program. + +執行使用者的程式、啟動語言伺服器或除錯轉接器,都是「啟動一個程序、讀它的輸出、 +等它結束」。把這件事變成介面之後,程序在本機還是在遠端對呼叫端就沒有差別。 +Running the user's program, starting a language server and starting a debug +adapter are all one thing: start a process, read its output, wait for it to end. +Behind an interface, the caller no longer cares whether that process is on this +machine or a remote one. + +這裡只有介面與資料物件,不啟動任何程序。 +This holds the interface and its data objects only, and starts no process. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum +from types import MappingProxyType +from typing import Protocol, runtime_checkable + +from je_editor.core.events.event_hook import EventHook +from je_editor.utils.exception.exceptions import JEditorServiceException + + +class TaskState(Enum): + """ + 一個工作的狀態 + The state a task is in. + """ + + PENDING = "pending" + RUNNING = "running" + FINISHED = "finished" + FAILED_TO_START = "failed_to_start" + CANCELLED = "cancelled" + + +class OutputStream(Enum): + """ + 輸出來自哪一個串流 + Which stream a piece of output came from. + """ + + STDOUT = "stdout" + STDERR = "stderr" + + +@dataclass(frozen=True) +class TaskSpec: + """ + 要執行什麼 + What to run. + + 指令一律是引數清單,沒有「一整行交給 shell」的形式,所以檔名裡的空白或引號 + 不會被當成指令的一部分。 + The command is always a list of arguments and there is no form that hands a + whole line to a shell, so a space or a quote in a file name can never become + part of the command. + + :param command: 程式與它的引數 / the program and its arguments + :param working_directory: 工作目錄,空字串表示沿用目前的 + the working directory, empty to keep the current one + :param environment: 要加上或覆寫的環境變數 / environment variables to add or override + :param name: 給使用者看的名稱 / the name to show the user + :raises JEditorServiceException: 指令是空的,或裡面有不是字串的項目 + when the command is empty or holds something that is not a string + """ + + command: tuple[str, ...] + working_directory: str = "" + environment: Mapping[str, str] = field(default_factory=dict) + name: str = "" + + def __post_init__(self) -> None: + if isinstance(self.command, str) or not self.command: + raise JEditorServiceException("A task command has to be a non-empty list of arguments") + if not all(isinstance(part, str) and part for part in self.command): + raise JEditorServiceException("Every part of a task command has to be a non-empty string") + # 複製並凍結,之後就不會被呼叫端改掉 / Copy and freeze, so the caller cannot change them later + object.__setattr__(self, "command", tuple(self.command)) + object.__setattr__(self, "environment", MappingProxyType(dict(self.environment))) + + +@runtime_checkable +class TaskHandle(Protocol): + """ + 一個準備好或正在執行的工作 + One task that is ready to run or is running. + + 先訂閱輸出再呼叫 :meth:`start`,才不會漏掉一開始的輸出。 + Subscribe to the output before calling :meth:`start`, so none of the early + output is missed. + """ + + @property + def spec(self) -> TaskSpec: + """這個工作要執行什麼 / What this task runs.""" + + @property + def output(self) -> EventHook: + """有輸出時發出,引數是 :class:`OutputStream` 與文字 / Fired with the stream and the text.""" + + @property + def finished(self) -> EventHook: + """結束後發出,引數是結束代碼 / Fired with the exit code once it ends.""" + + def state(self) -> TaskState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + + def exit_code(self) -> int | None: + """ + 結束代碼 + The exit code. + + :return: 結束代碼,還沒結束時為 ``None`` / the code, or ``None`` while it runs + """ + + def start(self) -> bool: + """ + 啟動程序 + Start the process. + + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + + def write(self, text: str) -> bool: + """ + 寫到程序的標準輸入 + Write to the process's standard input. + + :param text: 要寫入的文字 / the text to write + :return: 有寫入時為 ``True`` / ``True`` when it was written + """ + + def cancel(self) -> None: + """ + 結束程序 + Stop the process. + """ + + +@runtime_checkable +class TaskRunner(Protocol): + """ + 能執行工作的地方 + Somewhere tasks can run. + """ + + def create(self, spec: TaskSpec) -> TaskHandle: + """ + 準備一個工作,但還不啟動 + Prepare a task without starting it. + + :param spec: 要執行什麼 / what to run + :return: 這個工作的把手 / the handle for the task + """ + + def shutdown(self) -> None: + """ + 結束所有還在執行的工作 + Stop every task that is still running. + """ diff --git a/je_editor/core/registry/__init__.py b/je_editor/core/registry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/registry/named_registry.py b/je_editor/core/registry/named_registry.py new file mode 100644 index 0000000..56d7e42 --- /dev/null +++ b/je_editor/core/registry/named_registry.py @@ -0,0 +1,116 @@ +""" +以名稱保管可替換的實作 +Keep interchangeable implementations by name. + +AI 供應者、除錯轉接器、遠端傳輸與工作執行器都是「同一個介面、好幾個實作」,而且 +都要讓外掛或宿主程式自己加。它們共用這一個登記表,而不是各寫一份。 +AI providers, debug adapters, remote transports and task runners are each one +interface with several implementations, and each has to accept more from a +plugin or a host application. They share this one registry instead of having one +apiece. +""" +from __future__ import annotations + +from threading import Lock +from typing import Generic, TypeVar + +from je_editor.core.events.event_hook import EventHook +from je_editor.utils.exception.exceptions import JEditorServiceException + +ItemT = TypeVar("ItemT") + + +class NamedRegistry(Generic[ItemT]): + """ + 名稱對應實作的登記表 + A registry from a name to an implementation. + """ + + def __init__(self, kind: str) -> None: + """ + :param kind: 登記的是哪一種東西,用在錯誤訊息 / what is being registered, + used in error messages + """ + self._kind = kind + self._items: dict[str, ItemT] = {} + self._lock = Lock() + # 登記或移除之後發出,引數是名稱 / Fired after a change, with the name + self.changed = EventHook(f"{kind} registry changed") + + def register(self, name: str, item: ItemT, replace: bool = False) -> None: + """ + 登記一個實作 + Register an implementation. + + :param name: 名稱 / the name to register under + :param item: 實作 / the implementation + :param replace: 名稱已被使用時是否取代 / whether to replace an existing entry + :raises JEditorServiceException: 名稱是空的,或已被使用而且沒有要求取代 + when the name is empty, or taken and *replace* is not set + """ + if not isinstance(name, str) or not name.strip(): + raise JEditorServiceException(f"A {self._kind} needs a non-empty name") + with self._lock: + if name in self._items and not replace: + raise JEditorServiceException(f"A {self._kind} named {name!r} is already registered") + self._items[name] = item + self.changed.emit(name) + + def unregister(self, name: str) -> ItemT | None: + """ + 移除一個實作 + Remove an implementation. + + :param name: 名稱 / the name it was registered under + :return: 被移除的實作,沒有這個名稱時為 ``None`` / what was removed, or ``None`` + """ + with self._lock: + item = self._items.pop(name, None) + if item is not None: + self.changed.emit(name) + return item + + def get(self, name: str) -> ItemT | None: + """ + 取得某個名稱的實作 + The implementation registered under a name. + + :param name: 名稱 / the name + :return: 實作,沒有時為 ``None`` / the implementation, or ``None`` + """ + with self._lock: + return self._items.get(name) + + def require(self, name: str) -> ItemT: + """ + 取得某個名稱的實作,沒有就報錯 + The implementation registered under a name, which has to exist. + + :param name: 名稱 / the name + :return: 實作 / the implementation + :raises JEditorServiceException: 沒有這個名稱 / when nothing has that name + """ + item = self.get(name) + if item is None: + known = ", ".join(self.names()) or "none" + raise JEditorServiceException( + f"No {self._kind} named {name!r} is registered (registered: {known})") + return item + + def names(self) -> list[str]: + """已登記的名稱,依登記順序 / The registered names, in registration order.""" + with self._lock: + return list(self._items) + + def items(self) -> list[tuple[str, ItemT]]: + """已登記的名稱與實作 / The registered names and implementations.""" + with self._lock: + return list(self._items.items()) + + def __contains__(self, name: object) -> bool: + with self._lock: + return name in self._items + + def __len__(self) -> int: + with self._lock: + return len(self._items) diff --git a/je_editor/core/remote/__init__.py b/je_editor/core/remote/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/remote/remote_session.py b/je_editor/core/remote/remote_session.py new file mode 100644 index 0000000..160a936 --- /dev/null +++ b/je_editor/core/remote/remote_session.py @@ -0,0 +1,91 @@ +""" +遠端工作階段的介面 +The interface for a remote session. + +工作區的根目錄之後可以在另一台機器上。服務只認得「一個可以連線、可以在上面執行 +工作的工作階段」,不認得 SSH 或其他傳輸方式——那些是傳輸實作自己的事。 +A workspace root may later be on another machine. A service knows one session +that can connect and can run tasks, never SSH or any other transport, which stay +the business of the transport that implements this. + +遠端檔案系統、連接埠轉送與直譯器探索要等遠端開發那個里程碑,這裡先固定連線的 +生命週期。 +The remote file system, port forwarding and interpreter discovery wait for the +remote development milestone; this fixes the connection's lifecycle first. +""" +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import Protocol, runtime_checkable + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import TaskRunner + + +class RemoteState(Enum): + """ + 遠端連線的狀態 + The state a remote connection is in. + """ + + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + RECONNECTING = "reconnecting" + FAILED = "failed" + + +@runtime_checkable +class RemoteSession(Protocol): + """ + 一條到遠端機器的連線 + One connection to a remote machine. + """ + + @property + def authority(self) -> str: + """連到哪裡,也就是根目錄 URI 裡的主機部分 / Where it connects to: the host part of a root's URI.""" + + @property + def state_changed(self) -> EventHook: + """狀態改變後發出,引數是新的 :class:`RemoteState` / Fired with the new state.""" + + def state(self) -> RemoteState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + + def connect(self) -> bool: + """ + 建立連線 + Open the connection. + + :return: 有連上時為 ``True`` / ``True`` when it connected + """ + + def disconnect(self) -> None: + """ + 中斷連線並放掉它持有的資源 + Close the connection and release what it holds. + """ + + def task_runner(self) -> TaskRunner: + """ + 取得在遠端執行工作的執行器 + The runner that runs tasks on the remote machine. + + 跟本機的執行器是同一個介面,呼叫端不必分辨程序在哪裡。 + It has the interface the local runner has, so a caller never has to tell + where a process runs. + + :return: 工作執行器 / the task runner + """ + + +# 由主機部分建立一條連線;登記時的名稱是 URI 的 scheme +# Builds a session from an authority; it is registered under the URI scheme it serves +RemoteTransport = Callable[[str], RemoteSession] diff --git a/je_editor/core/services/__init__.py b/je_editor/core/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/services/editor_services.py b/je_editor/core/services/editor_services.py new file mode 100644 index 0000000..54cd546 --- /dev/null +++ b/je_editor/core/services/editor_services.py @@ -0,0 +1,77 @@ +""" +把核心服務組在一起 +The core services, put together. + +每個視窗或每個嵌入的編輯器各有一組自己的服務,由建立它的人持有。這裡刻意沒有 +模組層級的單例:宿主程式嵌入兩個編輯器時,它們不該共用工作區與診斷。 +Each window, or each embedded editor, has a set of services of its own, held by +whoever built it. There is deliberately no module-level singleton here: a host +that embeds two editors must not find them sharing a workspace and diagnostics. + +建立這組服務不需要 ``QApplication``,也不會啟動任何程序或執行緒。 +Building this needs no ``QApplication`` and starts no process or thread. +""" +from __future__ import annotations + +from je_editor.core.ai.ai_provider import AIProvider +from je_editor.core.debug.debug_session import DebugSessionFactory +from je_editor.core.diagnostics.diagnostic_model import DiagnosticStore +from je_editor.core.document.document_model import DocumentStore +from je_editor.core.language.language_service import LanguageServiceRegistry +from je_editor.core.process.task_service import TaskRunner +from je_editor.core.registry.named_registry import NamedRegistry +from je_editor.core.remote.remote_session import RemoteTransport +from je_editor.core.workspace.workspace_model import Workspace + + +class EditorServices: + """ + 一個編輯器用到的所有核心服務 + Every core service one editor uses. + """ + + def __init__(self, workspace: Workspace | None = None) -> None: + """ + :param workspace: 要處理的工作區,沒給時從空的工作區開始 + the workspace to work on, an empty one when omitted + """ + self.workspace: Workspace = workspace if workspace is not None else Workspace() + self.documents = DocumentStore() + self.diagnostics = DiagnosticStore() + self.languages = LanguageServiceRegistry(self.documents) + # 以轉接器的種類登記,例如 ``pdb`` / Registered by adapter type, such as ``pdb`` + self.debug_adapters: NamedRegistry[DebugSessionFactory] = NamedRegistry("debug adapter") + # 以執行的地方登記,例如 ``local`` / Registered by where they run, such as ``local`` + self.task_runners: NamedRegistry[TaskRunner] = NamedRegistry("task runner") + # 以 URI 的 scheme 登記,例如 ``ssh`` / Registered by URI scheme, such as ``ssh`` + self.remote_transports: NamedRegistry[RemoteTransport] = NamedRegistry("remote transport") + # 以供應者名稱登記 / Registered by provider name + self.ai_providers: NamedRegistry[AIProvider] = NamedRegistry("AI provider") + self._shut_down = False + + @property + def is_shut_down(self) -> bool: + """是否已經關閉 / Whether this has been shut down.""" + return self._shut_down + + def shutdown(self) -> None: + """ + 放掉這組服務持有的所有資源 + Release everything these services hold. + + 語言服務與工作執行器可能持有程序與執行緒,所以擁有者關閉時一定要呼叫這個。 + 重複呼叫沒有作用。 + Language services and task runners may hold processes and threads, so + the owner has to call this when it closes. Calling it again does nothing. + """ + if self._shut_down: + return + self._shut_down = True + self.languages.shutdown() + for name in self.task_runners.names(): + runner = self.task_runners.unregister(name) + if runner is not None: + runner.shutdown() + self.diagnostics.clear() + for document in self.documents.documents(): + self.documents.close(document.uri) diff --git a/je_editor/core/uri/__init__.py b/je_editor/core/uri/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/uri/resource_uri.py b/je_editor/core/uri/resource_uri.py new file mode 100644 index 0000000..bf239fd --- /dev/null +++ b/je_editor/core/uri/resource_uri.py @@ -0,0 +1,91 @@ +""" +核心服務用來指認資源的 URI +The URIs the core services use to name a resource. + +工作區根目錄、文件與診斷都以 URI 指認,而不是檔案路徑:之後根目錄可以在遠端, +路徑在那裡沒有意義。本機檔案仍然是 ``file://`` URI,轉換沿用 LSP 那一份。 +Workspace roots, documents and diagnostics are named by URI rather than by file +path, because a root may later live on another machine where a local path means +nothing. A local file is still a ``file://`` URI, converted by the same code the +LSP client uses. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from je_editor.utils.lsp.lsp_protocol import file_uri, path_from_uri + +# 本機檔案的 URI 開頭 / What a local file's URI starts with +FILE_SCHEME_PREFIX = "file://" +# scheme 與其餘部分的分隔 / What separates the scheme from the rest +_SCHEME_SEPARATOR = "://" + + +def to_uri(path: str | Path) -> str: + """ + 把本機路徑轉成 URI + Turn a local path into a URI. + + :param path: 檔案或目錄路徑,相對路徑以目前工作目錄為準 / the path; a relative + one is taken from the current working directory + :return: ``file://`` URI / a ``file://`` URI + """ + return file_uri(os.path.abspath(str(path))) + + +def to_path(uri: str) -> str: + """ + 把 URI 轉回本機路徑 + Turn a URI back into a local path. + + :param uri: 資源的 URI / the resource's URI + :return: 本機路徑;不是本機檔案時為空字串 / the path, or an empty string when + the URI does not name a local file + """ + path = path_from_uri(uri) + return os.path.normpath(path) if path else "" + + +def is_local_uri(uri: str) -> bool: + """ + 判斷 URI 是不是本機檔案 + Whether a URI names a local file. + + :param uri: 資源的 URI / the resource's URI + :return: 是本機檔案時為 ``True`` / ``True`` for a local file + """ + return isinstance(uri, str) and uri.startswith(FILE_SCHEME_PREFIX) + + +def uri_scheme(uri: str) -> str: + """ + 取出 URI 的 scheme + The scheme of a URI. + + :param uri: 資源的 URI / the resource's URI + :return: 小寫的 scheme,沒有時為空字串 / the scheme in lower case, or an empty string + """ + if not isinstance(uri, str) or _SCHEME_SEPARATOR not in uri: + return "" + return uri.split(_SCHEME_SEPARATOR, 1)[0].lower() + + +def uri_key(uri: str) -> str: + """ + 取得拿來比對與當作字典鍵的形式 + The form of a URI to compare and to key a dictionary with. + + 同一個本機檔案可以有好幾種寫法(Windows 不分大小寫、斜線方向、``..``),直接 + 拿字串比會把同一個檔案當成兩個。 + One local file can be spelled several ways (Windows ignores case, slashes go + either way, ``..`` appears), and comparing the strings would treat it as two + files. + + :param uri: 資源的 URI / the resource's URI + :return: 正規化後的鍵 / the normalised key + """ + path = to_path(uri) + if not path: + return uri + return FILE_SCHEME_PREFIX + os.path.normcase(os.path.abspath(path)) diff --git a/je_editor/core/workspace/__init__.py b/je_editor/core/workspace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/workspace/workspace_model.py b/je_editor/core/workspace/workspace_model.py new file mode 100644 index 0000000..f12d3a4 --- /dev/null +++ b/je_editor/core/workspace/workspace_model.py @@ -0,0 +1,265 @@ +""" +工作區:零到多個專案根目錄 +A workspace: zero or more project roots. + +編輯器原本只認得「一個專案目錄」。工作區把它變成清單,但只有一個根目錄的工作區 +仍然是完全合法的工作區,行為要跟原本一樣。 +The editor used to know one project directory. A workspace turns that into a +list, while a workspace holding a single root stays perfectly valid and has to +behave as the editor always did. + +純邏輯:不讀寫磁碟,也不碰 Qt。 +Pure logic: it reads nothing from disk and touches no Qt. +""" +from __future__ import annotations + +import os +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from threading import Lock + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.uri.resource_uri import ( + is_local_uri, to_path, to_uri, uri_key, uri_scheme +) +from je_editor.utils.exception.exceptions import JEditorServiceException + + +@dataclass(frozen=True) +class ProjectRoot: + """ + 工作區裡的一個根目錄 + One root inside a workspace. + + :param uri: 根目錄的 URI / the root's URI + :param name: 顯示名稱 / the name to show + """ + + uri: str + name: str + + @classmethod + def from_path(cls, path: str | Path, name: str = "") -> ProjectRoot: + """ + 由本機目錄建立根目錄 + Build a root from a local directory. + + 目錄不必已經存在:還原工作階段時,上次的目錄可能暫時不在。 + The directory need not exist: when a session is restored, last time's + directory may be missing for now. + + :param path: 目錄路徑 / the directory + :param name: 顯示名稱,沒給時用目錄名稱 / the name to show, the directory's + own name when omitted + :return: 根目錄 / the root + """ + absolute = os.path.abspath(str(path)) + return cls(uri=to_uri(absolute), name=name or Path(absolute).name or absolute) + + @property + def is_local(self) -> bool: + """這個根目錄是否在本機 / Whether this root is on this machine.""" + return is_local_uri(self.uri) + + @property + def path(self) -> str: + """本機路徑;不在本機時為空字串 / The local path, empty when not local.""" + return to_path(self.uri) + + def contains(self, path: str | Path) -> bool: + """ + 判斷某個本機路徑是否在這個根目錄底下 + Whether a local path lies inside this root. + + :param path: 檔案或目錄路徑 / the file or directory + :return: 在底下(或就是根目錄本身)時為 ``True`` / ``True`` when it is + inside, or is the root itself + """ + if not self.is_local: + return False + root_key = _path_key(self.path) + candidate = _path_key(path) + return candidate == root_key or candidate.startswith(root_key.rstrip(os.sep) + os.sep) + + def resolve(self, relative_path: str | Path) -> str: + """ + 把根目錄內的相對路徑轉成完整路徑 + Turn a path relative to this root into a full path. + + :param relative_path: 相對於根目錄的路徑 / the path relative to the root + :return: 完整的本機路徑 / the full local path + :raises JEditorServiceException: 根目錄不在本機,或結果跑到根目錄外面 + (例如 ``..``)/ when the root is not local, or the result escapes + the root, as ``..`` can + """ + if not self.is_local: + raise JEditorServiceException(f"{self.uri} is not a local root") + resolved = os.path.abspath(os.path.join(self.path, str(relative_path))) + if not self.contains(resolved): + raise JEditorServiceException(f"{relative_path} leaves the project root {self.path}") + return resolved + + +def _path_key(path: str | Path) -> str: + """比對路徑用的正規化形式 / The normalised form used to compare paths.""" + return os.path.normcase(os.path.abspath(str(path))) + + +class Workspace: + """ + 一個視窗正在處理的所有根目錄 + Every root one window is working on. + """ + + def __init__(self, roots: Iterable[ProjectRoot] = ()) -> None: + """ + :param roots: 一開始就有的根目錄 / the roots to start with + """ + self._roots: tuple[ProjectRoot, ...] = () + self._lock = Lock() + # 根目錄增減之後發出,引數是這個工作區 + # Fired after a root is added or removed, with this workspace + self.changed = EventHook("workspace changed") + for root in roots: + self._insert(root) + + @classmethod + def single_root(cls, path: str | Path) -> Workspace: + """ + 建立只有一個根目錄的工作區,也就是原本的「專案目錄」 + A workspace with one root, which is what a project directory used to be. + + :param path: 專案目錄 / the project directory + :return: 工作區 / the workspace + """ + return cls([ProjectRoot.from_path(path)]) + + @property + def roots(self) -> tuple[ProjectRoot, ...]: + """目前的根目錄,依加入順序 / The roots, in the order they were added.""" + return self._roots + + @property + def primary_root(self) -> ProjectRoot | None: + """第一個根目錄;沒有根目錄時為 ``None`` / The first root, or ``None``.""" + roots = self._roots + return roots[0] if roots else None + + @property + def is_multi_root(self) -> bool: + """是否有一個以上的根目錄 / Whether there is more than one root.""" + return len(self._roots) > 1 + + def add_root(self, root: ProjectRoot | str | Path) -> bool: + """ + 加入一個根目錄 + Add a root. + + :param root: 根目錄,或本機目錄路徑 / the root, or a local directory + :return: 是否真的加入(已經有同一個根目錄時為 ``False``) + whether it was added, ``False`` when that root is already present + """ + added = self._insert(root if isinstance(root, ProjectRoot) else ProjectRoot.from_path(root)) + if added: + self.changed.emit(self) + return added + + def remove_root(self, root: ProjectRoot | str | Path) -> bool: + """ + 移除一個根目錄 + Remove a root. + + :param root: 根目錄、它的 URI,或本機目錄路徑 / the root, its URI, or a + local directory + :return: 是否真的移除了 / whether a root was removed + """ + key = _root_key(root) + with self._lock: + kept = tuple(item for item in self._roots if uri_key(item.uri) != key) + removed = len(kept) != len(self._roots) + self._roots = kept + if removed: + self.changed.emit(self) + return removed + + def root_for(self, path: str | Path) -> ProjectRoot | None: + """ + 找出某個本機路徑屬於哪個根目錄 + The root a local path belongs to. + + 根目錄可以互相包含,這時取最深的那一個:``/a/b/x.py`` 屬於 ``/a/b`` 而 + 不是 ``/a``。 + Roots may nest, in which case the deepest one wins: ``/a/b/x.py`` belongs + to ``/a/b`` rather than ``/a``. + + :param path: 檔案或目錄路徑 / the file or directory + :return: 所屬的根目錄,不屬於任何一個時為 ``None`` / the owning root, or ``None`` + """ + owners = [root for root in self._roots if root.contains(path)] + if not owners: + return None + return max(owners, key=lambda root: len(_path_key(root.path))) + + def root_for_uri(self, uri: str) -> ProjectRoot | None: + """ + 找出某個資源屬於哪個根目錄 + The root a resource belongs to. + + 文件與診斷都以 URI 指認,所以這是它們找根目錄的入口;本機與不在本機的根 + 目錄都找得到。 + Documents and diagnostics are named by URI, so this is how they find + their root, whether that root is on this machine or not. + + :param uri: 資源的 URI / the resource's URI + :return: 所屬的根目錄,不屬於任何一個時為 ``None`` / the owning root, or ``None`` + """ + path = to_path(uri) + if path: + return self.root_for(path) + owners = [ + root for root in self._roots + if not root.is_local and _uri_inside(root.uri, uri) + ] + return max(owners, key=lambda root: len(root.uri)) if owners else None + + def relative_path(self, path: str | Path) -> tuple[ProjectRoot, str] | None: + """ + 把本機路徑拆成「根目錄」與「根目錄內的相對路徑」 + Split a local path into its root and the path inside that root. + + 兩個根目錄底下可以有同名的檔案,所以相對路徑要跟根目錄一起才不會撞在一起。 + Two roots can each hold a file of the same name, so a relative path only + identifies a file together with its root. + + :param path: 檔案或目錄路徑 / the file or directory + :return: ``(根目錄, 相對路徑)``,不屬於任何根目錄時為 ``None`` + ``(root, relative path)``, or ``None`` outside every root + """ + root = self.root_for(path) + if root is None: + return None + relative = os.path.relpath(os.path.abspath(str(path)), root.path) + return root, Path(relative).as_posix() + + def _insert(self, root: ProjectRoot) -> bool: + """加入根目錄,重複的不加 / Add a root unless it is already there.""" + key = uri_key(root.uri) + with self._lock: + if any(uri_key(item.uri) == key for item in self._roots): + return False + self._roots = self._roots + (root,) + return True + + +def _uri_inside(root_uri: str, uri: str) -> bool: + """判斷 URI 是否在某個根目錄的 URI 底下 / Whether a URI lies beneath a root's URI.""" + return uri == root_uri or uri.startswith(root_uri.rstrip("/") + "/") + + +def _root_key(root: ProjectRoot | str | Path) -> str: + """把各種指認根目錄的方式轉成同一種鍵 / One key for every way of naming a root.""" + if isinstance(root, ProjectRoot): + return uri_key(root.uri) + text = str(root) + return uri_key(text if uri_scheme(text) else to_uri(text)) diff --git a/je_editor/utils/exception/exceptions.py b/je_editor/utils/exception/exceptions.py index 144bf1e..ac45677 100644 --- a/je_editor/utils/exception/exceptions.py +++ b/je_editor/utils/exception/exceptions.py @@ -28,3 +28,7 @@ class JEditorCantFindLanguageException(JEditorException): class JEditorJsonException(JEditorException): pass + + +class JEditorServiceException(JEditorException): + pass diff --git a/test/test_core_architecture.py b/test/test_core_architecture.py new file mode 100644 index 0000000..e82d330 --- /dev/null +++ b/test/test_core_architecture.py @@ -0,0 +1,275 @@ +"""Tests that hold the line between the core service layer and Qt.""" +from __future__ import annotations + +import ast +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from je_editor import core + +REPO_ROOT = Path(__file__).resolve().parent.parent +PACKAGE_ROOT = REPO_ROOT / "je_editor" +CORE_ROOT = PACKAGE_ROOT / "core" +# The legacy facade: it imports the whole Qt application, and every import of a +# sub-package runs it. It is left out of the import graph for that reason, and +# the runtime probe below is what shows the core layer works without it. +TOP_LEVEL_INIT = PACKAGE_ROOT / "__init__.py" + +# Top-level modules that bring Qt, or an application built on it, into a process +QT_MODULES = frozenset({"PySide6", "shiboken6", "qt_material", "qtconsole", "frontengine"}) +# The parts of je_editor that are the Qt application +UI_MODULES = ("je_editor.pyside_ui", "je_editor.start_editor") +# The packages below the UI: logic only +LOGIC_PACKAGES = ("core", "utils", "code_scan", "git_client", "plugins") +# The modules below the UI that reach upwards today. This is a ratchet: the set +# may shrink, and a new entry needs a reason as good as these. +KNOWN_UPWARD_IMPORTS = { + # Reads the system locale through QLocale (QtCore, no widgets) + "je_editor/utils/multi_language/locale_match.py": {"PySide6.QtCore"}, + # The highlighting tables the plugin API fills; that module itself is Qt-free + "je_editor/plugins/__init__.py": {"je_editor.pyside_ui.code.syntax.syntax_setting"}, +} +# Run in a child process: build the services where no Qt module can be imported. +# argv[1] is the je_editor directory, argv[2] the comma-separated modules to refuse. +QT_FREE_PROBE = textwrap.dedent( + """ + import sys + import types + + package_root, blocked = sys.argv[1], set(sys.argv[2].split(",")) + + + class RefuseQt: + def find_spec(self, name, path=None, target=None): + if name.split(".")[0] in blocked: + raise ImportError(f"{name} is blocked in this probe") + return None + + + sys.meta_path.insert(0, RefuseQt()) + bare = types.ModuleType("je_editor") + bare.__path__ = [package_root] + sys.modules["je_editor"] = bare + + from je_editor.core import ( + Diagnostic, EditorServices, Severity, TextDocument, TextRange, Workspace, to_uri + ) + + services = EditorServices(Workspace.single_root(package_root)) + uri = to_uri(package_root + "/probe.py") + services.documents.open(TextDocument(uri, "import os\\n", "python")) + services.diagnostics.publish("probe", uri, [ + Diagnostic("unused import", TextRange.from_lines(1), Severity.WARNING)]) + loaded_qt = sorted(name for name in sys.modules if name.split(".")[0] in blocked) + print(len(services.workspace.roots), len(services.documents), + services.diagnostics.counts()[Severity.WARNING], loaded_qt) + services.shutdown() + """ +) + + +def imports_in(source: str, package: str = "") -> set[str]: + """ + Every module a piece of source imports, wherever the import statement is. + + ``from a import b`` reports both ``a`` and ``a.b``, since ``b`` may be a + sub-module. Imports inside functions count: they run as soon as the function + does. + """ + found: set[str] = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + if node.level: + parents = package.split(".")[:len(package.split(".")) - node.level + 1] + base = ".".join(part for part in (*parents, base) if part) + found.add(base) + found.update(f"{base}.{alias.name}" for alias in node.names) + return found + + +def is_forbidden(module: str) -> bool: + """Whether importing a module pulls in Qt or the Qt application.""" + if module.split(".")[0] in QT_MODULES: + return True + return any(module == ui or module.startswith(f"{ui}.") for ui in UI_MODULES) + + +def module_file(module: str) -> Path | None: + """The file a dotted je_editor module name refers to, if it is a module.""" + base = REPO_ROOT.joinpath(*module.split(".")) + for candidate in (base.with_suffix(".py"), base / "__init__.py"): + if candidate.is_file(): + return candidate + return None + + +def package_of(path: Path) -> str: + """The dotted package a file belongs to.""" + parts = path.relative_to(REPO_ROOT).with_suffix("").parts + return ".".join(parts[:-1]) + + +def files_imported_by(path: Path) -> set[Path]: + """The je_editor files importing *path* causes to run, package inits included.""" + files: set[Path] = set() + for module in imports_in(path.read_text(encoding="utf-8"), package_of(path)): + if module.split(".")[0] != PACKAGE_ROOT.name: + continue + parts = module.split(".") + for depth in range(2, len(parts) + 1): + found = module_file(".".join(parts[:depth])) + if found is not None and found != TOP_LEVEL_INIT: + files.add(found) + return files + + +def reachable_from(start: list[Path]) -> set[Path]: + """Every je_editor file the start files import, directly or through another.""" + seen: set[Path] = set() + pending = list(start) + while pending: + path = pending.pop() + if path in seen: + continue + seen.add(path) + pending.extend(files_imported_by(path)) + return seen + + +def forbidden_imports(path: Path) -> set[str]: + """ + The imports of a file that pull in Qt or the Qt application. + + ``from PySide6.QtCore import QLocale`` is reported once, as ``PySide6.QtCore``: + a name beneath a module that is already listed adds nothing. + """ + modules = imports_in(path.read_text(encoding="utf-8"), package_of(path)) + hits = {module for module in modules if is_forbidden(module)} + return { + module for module in hits + if not any(module.startswith(f"{other}.") for other in hits) + } + + +@pytest.fixture(scope="module") +def reachable(): + """Every je_editor file the core package causes to be imported.""" + return reachable_from(sorted(CORE_ROOT.rglob("*.py"))) + + +@pytest.fixture(scope="module") +def probe(): + """The result of building the services in a process that cannot import Qt.""" + # This interpreter running a literal defined above; no shell, and the only + # arguments are a path and a list of names from this file. + return subprocess.run( # nosemgrep # nosec B603 + [sys.executable, "-c", QT_FREE_PROBE, str(PACKAGE_ROOT), ",".join(sorted(QT_MODULES))], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=120, cwd=str(REPO_ROOT), check=False, + ) + + +class TestTheImportScanner: + """The guard is only worth having if it sees what it is meant to catch.""" + + def test_a_plain_import_is_seen(self): + assert "PySide6.QtWidgets" in imports_in("import PySide6.QtWidgets\n") + + def test_an_import_inside_a_function_is_seen(self): + source = "def build():\n from PySide6.QtWidgets import QWidget\n return QWidget\n" + assert "PySide6.QtWidgets" in imports_in(source) + + def test_a_sub_module_named_in_a_from_import_is_seen(self): + assert "je_editor.pyside_ui" in imports_in("from je_editor import pyside_ui\n") + + def test_a_relative_import_is_resolved(self): + assert "je_editor.core.uri" in imports_in("from ..uri import x\n", "je_editor.core.workspace") + + @pytest.mark.parametrize("module", [ + "PySide6", "PySide6.QtCore", "qt_material", "qtconsole.rich_jupyter_widget", + "je_editor.pyside_ui", "je_editor.pyside_ui.main_ui.main_editor", "je_editor.start_editor", + ]) + def test_qt_and_the_ui_are_forbidden(self, module): + assert is_forbidden(module) + + @pytest.mark.parametrize("module", [ + "json", "je_editor.utils.lsp.lsp_protocol", "je_editor.core", "PySide6_helper", + "je_editor.pyside_ui_notes", + ]) + def test_everything_else_is_allowed(self, module): + assert not is_forbidden(module) + + +class TestTheCoreLayerIsQtFree: + """ + Nothing the core layer imports, however indirectly, is Qt or a widget. + + The core services have to be usable by a host application that never builds + the JEditor window, and one Qt import anywhere beneath them ends that. + """ + + def test_the_scan_covers_the_core_package(self, reachable): + assert CORE_ROOT / "services" / "editor_services.py" in reachable + + def test_the_scan_follows_imports_out_of_the_core_package(self, reachable): + assert PACKAGE_ROOT / "utils" / "lsp" / "lsp_protocol.py" in reachable + + def test_nothing_reachable_imports_qt_or_the_ui(self, reachable): + offenders = { + path.relative_to(REPO_ROOT).as_posix(): sorted(forbidden_imports(path)) + for path in reachable if forbidden_imports(path) + } + assert offenders == {} + + +class TestTheLogicPackagesStayBelowTheUi: + """ + The packages under the UI import neither Qt nor the UI, bar the known cases. + + ``pyside_ui`` depends on them, never the other way round. The two modules + that already reach upwards are written down so a third cannot slip in. + """ + + def test_only_the_known_modules_reach_upwards(self): + found = {} + for package in LOGIC_PACKAGES: + for path in sorted((PACKAGE_ROOT / package).rglob("*.py")): + hits = forbidden_imports(path) + if hits: + found[path.relative_to(REPO_ROOT).as_posix()] = hits + assert found == KNOWN_UPWARD_IMPORTS + + +class TestTheCoreLayerRunsWithoutQt: + """ + The service layer is built and used in a process where Qt cannot be imported. + + ``je_editor`` is registered as a bare package first, so its ``__init__`` (the + Qt application's facade) does not run, and an import hook refuses every Qt + module. If the probe still builds the services, opens a document and reports + a diagnostic, the layer really does stand on its own. + """ + + def test_the_probe_runs_to_the_end(self, probe): + assert probe.returncode == 0, probe.stderr[-2000:] + + def test_the_services_work_and_no_qt_module_was_loaded(self, probe): + assert probe.stdout.split() == ["1", "1", "1", "[]"] + + +class TestThePublicNamesOfTheCoreLayer: + """Every name the core package promises is really there.""" + + def test_each_exported_name_exists(self): + missing = [name for name in core.__all__ if not hasattr(core, name)] + assert missing == [] + + def test_no_name_is_exported_twice(self): + assert len(core.__all__) == len(set(core.__all__)) diff --git a/test/test_core_diagnostics.py b/test/test_core_diagnostics.py new file mode 100644 index 0000000..4f7fec7 --- /dev/null +++ b/test/test_core_diagnostics.py @@ -0,0 +1,315 @@ +"""Tests for the unified diagnostic model, its store, and the bridge to the older shape.""" +from __future__ import annotations + +import pytest + +from je_editor.core.diagnostics.diagnostic_model import ( + Diagnostic, DiagnosticStore, Position, QuickFix, RelatedInformation, Severity, + TextEdit, TextRange, filter_diagnostics +) +from je_editor.core.diagnostics.legacy_diagnostics import from_legacy, to_legacy +from je_editor.core.uri.resource_uri import to_path, to_uri +from je_editor.utils.lint.ruff_diagnostics import ( + SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING +) +from je_editor.utils.lint.ruff_diagnostics import Diagnostic as LegacyDiagnostic +from je_editor.utils.lint.ruff_diagnostics import parse_ruff_json +from je_editor.utils.lsp.lsp_protocol import diagnostic_entries + +RUFF = "ruff" +SERVER = "rust-analyzer" +# What ruff's JSON output looks like for one unused import +RUFF_OUTPUT = ( + '[{"code": "F401", "message": "`os` imported but unused", ' + '"location": {"row": 1, "column": 8}, "end_location": {"row": 1, "column": 10}}]' +) +# The parameters of one ``publishDiagnostics`` notification from a language server +SERVER_NOTIFICATION = {"diagnostics": [{ + "range": {"start": {"line": 4, "character": 0}, "end": {"line": 4, "character": 6}}, + "severity": 2, "code": "unused_variables", "message": "unused variable: `x`", +}]} + + +def finding(message: str, line: int = 1, severity: Severity = Severity.ERROR, + code: str = "") -> Diagnostic: + return Diagnostic(message, TextRange.from_lines(line), severity, code=code) + + +@pytest.fixture() +def uri(tmp_path): + return to_uri(tmp_path / "sample.py") + + +@pytest.fixture() +def other_uri(tmp_path): + return to_uri(tmp_path / "other.py") + + +class TestSeverity: + @pytest.mark.parametrize("lsp_number, expected", [ + (1, Severity.ERROR), (2, Severity.WARNING), (3, Severity.INFORMATION), (4, Severity.HINT), + ]) + def test_the_numbers_are_the_ones_lsp_uses(self, lsp_number, expected): + assert Severity(lsp_number) is expected + + def test_a_more_severe_level_sorts_first(self): + assert sorted(Severity) == [ + Severity.ERROR, Severity.WARNING, Severity.INFORMATION, Severity.HINT] + + +class TestTextRange: + def test_a_single_point_is_an_empty_range(self): + span = TextRange.from_lines(3, 5) + assert span.start == span.end == Position(3, 5) + + def test_all_four_numbers_are_kept(self): + span = TextRange.from_lines(3, 5, 4, 2) + assert (span.start, span.end) == (Position(3, 5), Position(4, 2)) + + @pytest.mark.parametrize("line, column", [(0, 0), (-4, 7), (2, -1)]) + def test_numbers_below_one_are_raised_to_one(self, line, column): + span = TextRange.from_lines(line, column) + assert span.start.line >= 1 and span.start.column >= 1 + + @pytest.mark.parametrize("end_line, end_column", [(2, 9), (5, 3)]) + def test_an_end_before_the_start_falls_back_to_the_start(self, end_line, end_column): + span = TextRange.from_lines(5, 4, end_line, end_column) + assert span.end == span.start + + +class TestDiagnostic: + def test_the_label_leads_with_the_code(self): + assert finding("unused import", code="F401").label == "F401 unused import" + + def test_the_label_is_the_message_when_there_is_no_code(self): + assert finding("unexpected token").label == "unexpected token" + + def test_it_carries_related_locations_and_fixes(self, uri): + related = RelatedInformation(uri, TextRange.from_lines(9), "first defined here") + fix = QuickFix("Remove the import", (TextEdit(TextRange.from_lines(1, 1, 2, 1), ""),)) + diagnostic = Diagnostic("redefinition", TextRange.from_lines(12), related=(related,), + fixes=(fix,)) + assert diagnostic.related[0].message == "first defined here" + assert diagnostic.fixes[0].edits[0].new_text == "" + + +class TestFilterDiagnostics: + @pytest.fixture() + def mixed(self): + return [ + Diagnostic("hint", TextRange.from_lines(4), Severity.HINT, source=SERVER), + Diagnostic("error", TextRange.from_lines(3), Severity.ERROR, source=RUFF), + Diagnostic("info", TextRange.from_lines(2), Severity.INFORMATION, source=SERVER), + Diagnostic("warning", TextRange.from_lines(1), Severity.WARNING, source=RUFF), + ] + + def test_no_filter_keeps_everything_in_position_order(self, mixed): + assert [item.message for item in filter_diagnostics(mixed)] == [ + "warning", "info", "error", "hint"] + + @pytest.mark.parametrize("severity, message", [ + (Severity.ERROR, "error"), (Severity.WARNING, "warning"), + (Severity.INFORMATION, "info"), (Severity.HINT, "hint"), + ]) + def test_each_severity_can_be_picked_alone(self, mixed, severity, message): + assert [item.message for item in filter_diagnostics(mixed, [severity])] == [message] + + def test_several_severities_can_be_picked_together(self, mixed): + kept = filter_diagnostics(mixed, [Severity.ERROR, Severity.WARNING]) + assert [item.message for item in kept] == ["warning", "error"] + + def test_a_source_can_be_picked(self, mixed): + kept = filter_diagnostics(mixed, sources=[SERVER]) + assert [item.message for item in kept] == ["info", "hint"] + + def test_severity_and_source_narrow_together(self, mixed): + kept = filter_diagnostics(mixed, [Severity.HINT, Severity.ERROR], [SERVER]) + assert [item.message for item in kept] == ["hint"] + + def test_an_empty_filter_keeps_nothing(self, mixed): + assert filter_diagnostics(mixed, severities=[]) == [] + + def test_the_order_does_not_depend_on_the_order_given(self, mixed): + assert filter_diagnostics(mixed) == filter_diagnostics(list(reversed(mixed))) + + def test_on_one_line_the_more_severe_finding_comes_first(self): + same_line = [finding("minor", severity=Severity.HINT), finding("major")] + assert [item.message for item in filter_diagnostics(same_line)] == ["major", "minor"] + + +class TestDiagnosticStore: + def test_a_report_is_stamped_with_its_source_and_resource(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("unused import")]) + stored = store.select()[0] + assert (stored.source, stored.uri) == (RUFF, uri) + + def test_a_new_report_replaces_the_previous_one(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("first"), finding("second", 2)]) + store.publish(RUFF, uri, [finding("third")]) + assert [item.message for item in store.select()] == ["third"] + + def test_one_source_does_not_replace_another(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("from ruff")]) + store.publish(SERVER, uri, [finding("from the server", 2)]) + assert [item.source for item in store.select()] == [RUFF, SERVER] + assert store.sources() == sorted([RUFF, SERVER]) + + def test_an_empty_report_clears_that_source_only(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("from ruff")]) + store.publish(SERVER, uri, [finding("from the server")]) + assert store.publish(RUFF, uri, []) is True + assert [item.source for item in store.select()] == [SERVER] + + def test_publishing_the_same_report_again_changes_nothing(self, uri): + store = DiagnosticStore() + announced = [] + store.changed.subscribe(announced.append) + assert store.publish(RUFF, uri, [finding("same")]) is True + assert store.publish(RUFF, uri, [finding("same")]) is False + assert store.publish(SERVER, uri, []) is False + assert announced == [uri] + + def test_two_resources_are_kept_apart(self, uri, other_uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("here")]) + store.publish(RUFF, other_uri, [finding("there")]) + assert [item.message for item in store.select(uri=uri)] == ["here"] + assert [item.message for item in store.select(uri=other_uri)] == ["there"] + assert len(store) == 2 + + def test_another_spelling_of_the_path_reaches_the_same_report(self, tmp_path): + store = DiagnosticStore() + store.publish(RUFF, to_uri(tmp_path / "sample.py"), [finding("first")]) + store.publish(RUFF, to_uri(tmp_path / "pkg" / ".." / "sample.py"), [finding("second")]) + assert [item.message for item in store.select()] == ["second"] + + def test_the_store_filters_by_severity_and_source(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("error"), finding("warning", 2, Severity.WARNING)]) + store.publish(SERVER, uri, [finding("hint", 3, Severity.HINT)]) + assert [item.message for item in store.select([Severity.WARNING])] == ["warning"] + assert [item.message for item in store.select(sources=[SERVER])] == ["hint"] + + def test_counts_cover_every_severity(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("a"), finding("b", 2), finding("c", 3, Severity.HINT)]) + assert store.counts() == { + Severity.ERROR: 2, Severity.WARNING: 0, Severity.INFORMATION: 0, Severity.HINT: 1} + + def test_clearing_a_source_leaves_the_others(self, uri, other_uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("ruff here")]) + store.publish(RUFF, other_uri, [finding("ruff there")]) + store.publish(SERVER, uri, [finding("server here")]) + assert store.clear(source=RUFF) is True + assert [item.message for item in store.select()] == ["server here"] + + def test_clearing_a_resource_leaves_the_others(self, uri, other_uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("here")]) + store.publish(RUFF, other_uri, [finding("there")]) + assert store.clear(uri=uri) is True + assert [item.message for item in store.select()] == ["there"] + + def test_clearing_announces_each_resource_once(self, uri, other_uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [finding("a")]) + store.publish(SERVER, uri, [finding("b")]) + store.publish(RUFF, other_uri, [finding("c")]) + announced = [] + store.changed.subscribe(announced.append) + assert store.clear() is True + assert sorted(announced) == sorted([uri, other_uri]) + assert len(store) == 0 + + def test_clearing_an_empty_store_reports_nothing_dropped(self): + store = DiagnosticStore() + announced = [] + store.changed.subscribe(announced.append) + assert store.clear() is False + assert announced == [] + + +class TestLegacyBridge: + @pytest.mark.parametrize("legacy_severity, expected", [ + (SEVERITY_ERROR, Severity.ERROR), (SEVERITY_WARNING, Severity.WARNING), + (SEVERITY_INFO, Severity.INFORMATION), + ]) + def test_an_explicit_severity_is_carried_over(self, legacy_severity, expected): + legacy = LegacyDiagnostic(1, 1, 1, 2, "X1", "message", severity=legacy_severity) + assert from_legacy(legacy, RUFF).severity is expected + + @pytest.mark.parametrize("code, expected", [ + ("F401", Severity.ERROR), ("W291", Severity.WARNING), ("D100", Severity.INFORMATION), + ]) + def test_a_severity_derived_from_the_rule_code_is_carried_over(self, code, expected): + legacy = LegacyDiagnostic(1, 1, 1, 2, code, "message") + assert from_legacy(legacy, RUFF).severity is expected + + def test_position_code_message_and_source_are_carried_over(self, uri): + legacy = LegacyDiagnostic(3, 5, 4, 2, "F401", "unused import") + converted = from_legacy(legacy, RUFF, uri) + assert converted.range == TextRange(Position(3, 5), Position(4, 2)) + assert (converted.code, converted.message, converted.source) == ( + "F401", "unused import", RUFF) + + def test_the_resource_falls_back_to_the_given_uri(self, uri): + legacy = LegacyDiagnostic(1, 1, 1, 1, "F401", "buffer only") + assert from_legacy(legacy, RUFF, uri).uri == uri + + def test_a_file_path_on_the_older_diagnostic_wins(self, tmp_path, uri): + target = tmp_path / "project_wide.py" + legacy = LegacyDiagnostic(1, 1, 1, 1, "F401", "from a project check", + file_path=str(target)) + assert from_legacy(legacy, RUFF, uri).uri == to_uri(target) + + def test_a_round_trip_gives_back_an_equivalent_older_diagnostic(self, tmp_path): + legacy = LegacyDiagnostic(3, 5, 4, 2, "W291", "trailing whitespace", + file_path=to_path(to_uri(tmp_path / "a.py"))) + back = to_legacy(from_legacy(legacy, RUFF)) + assert back.level == legacy.level + assert (back.line, back.column, back.end_line, back.end_column, back.code, + back.message, back.file_path) == ( + legacy.line, legacy.column, legacy.end_line, legacy.end_column, legacy.code, + legacy.message, legacy.file_path) + + def test_a_hint_becomes_information_in_the_older_shape(self): + assert to_legacy(finding("consider renaming", severity=Severity.HINT)).level == SEVERITY_INFO + + def test_a_diagnostic_without_a_resource_has_no_file_path(self): + assert to_legacy(finding("buffer only")).file_path == "" + + +class TestBothSourcesShareTheModel: + """ + ruff output and a language server's notification land in one store. + + The existing LSP parser does not carry the server's severity through yet, so + the fixture supplies it; reading it from the notification is the diagnostics + milestone's job. + """ + + @pytest.fixture() + def store(self, uri): + store = DiagnosticStore() + store.publish(RUFF, uri, [ + from_legacy(item, RUFF) for item in parse_ruff_json(RUFF_OUTPUT)]) + store.publish(SERVER, uri, [ + Diagnostic(entry["message"], + TextRange.from_lines(entry["line"], entry["column"], entry["end_line"], + entry["end_column"]), + Severity.WARNING, code=entry["code"]) + for entry in diagnostic_entries(SERVER_NOTIFICATION)]) + return store + + def test_both_are_listed_together_in_position_order(self, store): + assert [(item.source, item.code, item.range.start.line) for item in store.select()] == [ + (RUFF, "F401", 1), (SERVER, "unused_variables", 5)] + + def test_a_severity_filter_cuts_across_both_sources(self, store): + assert [item.source for item in store.select([Severity.WARNING])] == [SERVER] + assert [item.source for item in store.select([Severity.ERROR])] == [RUFF] diff --git a/test/test_core_documents.py b/test/test_core_documents.py new file mode 100644 index 0000000..0e7facf --- /dev/null +++ b/test/test_core_documents.py @@ -0,0 +1,151 @@ +"""Tests for the document model and the store of open documents.""" +from __future__ import annotations + +import pytest + +from je_editor.core.document.document_model import ( + INITIAL_VERSION, Document, DocumentStore, TextDocument +) +from je_editor.core.uri.resource_uri import to_uri + + +@pytest.fixture() +def uri(tmp_path): + return to_uri(tmp_path / "sample.py") + + +class TestTextDocument: + def test_it_satisfies_the_document_protocol(self, uri): + assert isinstance(TextDocument(uri), Document) + + def test_an_object_missing_the_text_is_not_a_document(self): + class Incomplete: + uri = "file:///x" + language_id = "python" + version = INITIAL_VERSION + + assert not isinstance(Incomplete(), Document) + + def test_it_reports_what_it_was_built_with(self, uri): + document = TextDocument(uri, "print(1)\n", "python") + assert (document.uri, document.text(), document.language_id) == (uri, "print(1)\n", "python") + + def test_a_new_document_starts_at_the_initial_version(self, uri): + assert TextDocument(uri).version == INITIAL_VERSION + + def test_changing_the_text_raises_the_version(self, uri): + document = TextDocument(uri, "a") + assert document.set_text("b") is True + assert document.text() == "b" + assert document.version == INITIAL_VERSION + 1 + + def test_setting_the_same_text_keeps_the_version(self, uri): + document = TextDocument(uri, "a") + assert document.set_text("a") is False + assert document.version == INITIAL_VERSION + + +class TestDocumentStore: + def test_an_opened_document_is_found_by_its_uri(self, uri): + store = DocumentStore() + document = TextDocument(uri) + assert store.open(document) is True + assert store.get(uri) is document + assert len(store) == 1 + + def test_the_same_uri_is_not_opened_twice(self, uri): + store = DocumentStore() + first = TextDocument(uri, "first") + assert store.open(first) is True + assert store.open(TextDocument(uri, "second")) is False + assert store.get(uri) is first + + def test_another_spelling_of_the_path_finds_the_same_document(self, tmp_path): + store = DocumentStore() + document = TextDocument(to_uri(tmp_path / "sample.py")) + store.open(document) + assert store.get(to_uri(tmp_path / "pkg" / ".." / "sample.py")) is document + + def test_documents_are_listed_in_the_order_they_were_opened(self, tmp_path): + store = DocumentStore() + names = ["zeta.py", "alpha.py", "mid.py"] + for name in names: + store.open(TextDocument(to_uri(tmp_path / name))) + assert [document.uri for document in store.documents()] == [ + to_uri(tmp_path / name) for name in names] + + def test_closing_lets_the_document_go(self, uri): + store = DocumentStore() + store.open(TextDocument(uri)) + assert store.close(uri) is True + assert store.get(uri) is None + assert store.close(uri) is False + + def test_replacing_the_text_updates_the_document(self, uri): + store = DocumentStore() + document = TextDocument(uri, "old") + store.open(document) + assert store.replace_text(uri, "new") is True + assert document.text() == "new" + + def test_replacing_the_text_of_an_unopened_document_does_nothing(self, uri): + assert DocumentStore().replace_text(uri, "new") is False + + def test_a_document_held_elsewhere_cannot_have_its_text_replaced(self, uri): + store = DocumentStore() + store.open(_WidgetBackedDocument(uri)) + assert store.replace_text(uri, "new") is False + + def test_each_lifecycle_step_is_announced_with_the_document(self, uri): + store = DocumentStore() + events = [] + store.opened.subscribe(lambda doc: events.append(("opened", doc.version))) + store.changed.subscribe(lambda doc: events.append(("changed", doc.version))) + store.closed.subscribe(lambda doc: events.append(("closed", doc.version))) + store.open(TextDocument(uri, "a")) + store.replace_text(uri, "b") + store.close(uri) + assert events == [ + ("opened", INITIAL_VERSION), + ("changed", INITIAL_VERSION + 1), + ("closed", INITIAL_VERSION + 1), + ] + + def test_nothing_is_announced_when_nothing_happened(self, uri): + store = DocumentStore() + store.open(TextDocument(uri, "a")) + events = [] + for hook in (store.opened, store.changed, store.closed): + hook.subscribe(events.append) + store.open(TextDocument(uri, "duplicate")) + store.replace_text(uri, "a") + store.close("file:///never/opened.py") + assert events == [] + + def test_an_adapter_announces_its_own_changes(self, uri): + store = DocumentStore() + document = _WidgetBackedDocument(uri) + store.open(document) + changed = [] + store.changed.subscribe(changed.append) + assert store.notify_changed(uri) is True + assert changed == [document] + + def test_a_change_to_an_unopened_document_is_not_announced(self, uri): + store = DocumentStore() + changed = [] + store.changed.subscribe(changed.append) + assert store.notify_changed(uri) is False + assert changed == [] + + +class _WidgetBackedDocument: + """Stands in for an adapter whose buffer lives in an editor widget.""" + + def __init__(self, uri: str) -> None: + self.uri = uri + self.language_id = "python" + self.version = INITIAL_VERSION + + def text(self) -> str: + return "held by the widget" diff --git a/test/test_core_event_hook.py b/test/test_core_event_hook.py new file mode 100644 index 0000000..5ad4f22 --- /dev/null +++ b/test/test_core_event_hook.py @@ -0,0 +1,152 @@ +"""Tests for the Qt-free event hook and the named registry built on it.""" +from __future__ import annotations + +import logging + +import pytest + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.registry.named_registry import NamedRegistry +from je_editor.utils.exception.exceptions import JEditorException, JEditorServiceException + + +class TestEventHook: + def test_a_subscriber_receives_the_arguments(self): + hook = EventHook() + received = [] + hook.subscribe(lambda *args: received.append(args)) + hook.emit("uri", 3) + assert received == [("uri", 3)] + + def test_subscribers_are_called_in_subscription_order(self): + hook = EventHook() + order = [] + hook.subscribe(lambda: order.append("first")) + hook.subscribe(lambda: order.append("second")) + hook.emit() + assert order == ["first", "second"] + + def test_the_returned_function_unsubscribes(self): + hook = EventHook() + received = [] + unsubscribe = hook.subscribe(received.append) + unsubscribe() + hook.emit("ignored") + assert received == [] + + def test_unsubscribing_reports_whether_anything_was_removed(self): + hook = EventHook() + listener = print + hook.subscribe(listener) + assert hook.unsubscribe(listener) is True + assert hook.unsubscribe(listener) is False + + def test_the_length_is_the_number_of_subscribers(self): + hook = EventHook() + hook.subscribe(print) + hook.subscribe(repr) + assert len(hook) == 2 + + def test_a_subscriber_may_unsubscribe_while_being_called(self): + hook = EventHook() + calls = [] + + def once() -> None: + calls.append("once") + hook.unsubscribe(once) + + hook.subscribe(once) + hook.emit() + hook.emit() + assert calls == ["once"] + + def test_a_failing_subscriber_does_not_stop_the_others(self): + hook = EventHook("sample") + received = [] + + def broken(_value: str) -> None: + raise RuntimeError("subscriber bug") + + hook.subscribe(broken) + hook.subscribe(received.append) + hook.emit("delivered") + assert received == ["delivered"] + + def test_a_failing_subscriber_is_logged_with_the_event_name(self, caplog): + hook = EventHook("sample event") + + def broken() -> None: + raise RuntimeError("subscriber bug") + + hook.subscribe(broken) + with caplog.at_level(logging.ERROR, logger="JEditor"): + hook.emit() + assert "sample event" in caplog.text + assert "subscriber bug" in caplog.text + + +class TestNamedRegistry: + def test_a_registered_item_is_found_by_name(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + assert registry.get("one") == 1 + assert "one" in registry + + def test_an_unknown_name_gives_none(self): + assert NamedRegistry("number").get("missing") is None + + def test_names_keep_registration_order(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("zeta", 1) + registry.register("alpha", 2) + assert registry.names() == ["zeta", "alpha"] + assert registry.items() == [("zeta", 1), ("alpha", 2)] + + def test_a_taken_name_is_refused(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + with pytest.raises(JEditorServiceException, match="already registered"): + registry.register("one", 2) + assert registry.get("one") == 1 + + def test_a_taken_name_can_be_replaced_on_request(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + registry.register("one", 2, replace=True) + assert registry.get("one") == 2 + assert len(registry) == 1 + + @pytest.mark.parametrize("name", ["", " ", None]) + def test_an_empty_name_is_refused(self, name): + with pytest.raises(JEditorServiceException, match="non-empty name"): + NamedRegistry("number").register(name, 1) + + def test_require_returns_the_item(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + assert registry.require("one") == 1 + + def test_require_names_what_is_registered_when_it_fails(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + with pytest.raises(JEditorServiceException, match="registered: one"): + registry.require("two") + + def test_unregister_returns_what_was_removed(self): + registry: NamedRegistry[int] = NamedRegistry("number") + registry.register("one", 1) + assert registry.unregister("one") == 1 + assert registry.unregister("one") is None + assert len(registry) == 0 + + def test_changes_are_announced_with_the_name(self): + registry: NamedRegistry[int] = NamedRegistry("number") + changed = [] + registry.changed.subscribe(changed.append) + registry.register("one", 1) + registry.unregister("one") + registry.unregister("one") + assert changed == ["one", "one"] + + def test_the_service_exception_belongs_to_the_editor_family(self): + assert issubclass(JEditorServiceException, JEditorException) diff --git a/test/test_core_language_services.py b/test/test_core_language_services.py new file mode 100644 index 0000000..dd77b39 --- /dev/null +++ b/test/test_core_language_services.py @@ -0,0 +1,177 @@ +"""Tests for the language service interface and its registry.""" +from __future__ import annotations + +import pytest + +from je_editor.core.document.document_model import Document, DocumentStore, TextDocument +from je_editor.core.language.language_service import ( + LanguageCapability, LanguageService, LanguageServiceRegistry +) +from je_editor.core.uri.resource_uri import to_uri +from je_editor.utils.exception.exceptions import JEditorServiceException + + +class RecordingService: + """A language service that writes down everything it is told.""" + + def __init__(self, name: str, language_id: str, + capabilities: frozenset[LanguageCapability] = frozenset()) -> None: + self.name = name + self._language_id = language_id + self._capabilities = capabilities + self.events: list[tuple[str, str]] = [] + + def capabilities(self) -> frozenset[LanguageCapability]: + return self._capabilities + + def handles(self, document: Document) -> bool: + return document.language_id == self._language_id + + def document_opened(self, document: Document) -> None: + self.events.append(("opened", document.uri)) + + def document_changed(self, document: Document) -> None: + self.events.append(("changed", document.uri)) + + def document_closed(self, document: Document) -> None: + self.events.append(("closed", document.uri)) + + def shutdown(self) -> None: + self.events.append(("shutdown", "")) + + +@pytest.fixture() +def documents(): + return DocumentStore() + + +@pytest.fixture() +def registry(documents): + return LanguageServiceRegistry(documents) + + +@pytest.fixture() +def python_uri(tmp_path): + return to_uri(tmp_path / "main.py") + + +@pytest.fixture() +def rust_uri(tmp_path): + return to_uri(tmp_path / "main.rs") + + +class TestTheInterface: + def test_a_complete_service_satisfies_the_protocol(self): + assert isinstance(RecordingService("python", "python"), LanguageService) + + def test_an_object_without_the_lifecycle_does_not(self): + class NameOnly: + name = "incomplete" + + assert not isinstance(NameOnly(), LanguageService) + + +class TestRegistration: + def test_a_registered_service_is_listed(self, registry): + service = RecordingService("python", "python") + registry.register(service) + assert registry.services() == [service] + + def test_two_services_cannot_share_a_name(self, registry): + registry.register(RecordingService("python", "python")) + with pytest.raises(JEditorServiceException, match="already registered"): + registry.register(RecordingService("python", "rust")) + + def test_a_late_service_is_told_about_documents_already_open( + self, registry, documents, python_uri, rust_uri): + documents.open(TextDocument(python_uri, language_id="python")) + documents.open(TextDocument(rust_uri, language_id="rust")) + service = RecordingService("python", "python") + registry.register(service) + assert service.events == [("opened", python_uri)] + + def test_unregistering_shuts_the_service_down(self, registry): + service = RecordingService("python", "python") + registry.register(service) + assert registry.unregister("python") is True + assert service.events == [("shutdown", "")] + assert registry.services() == [] + + def test_unregistering_an_unknown_name_does_nothing(self, registry): + assert registry.unregister("missing") is False + + +class TestLifecycleForwarding: + def test_a_service_hears_the_whole_life_of_its_documents( + self, registry, documents, python_uri): + service = RecordingService("python", "python") + registry.register(service) + documents.open(TextDocument(python_uri, "a", "python")) + documents.replace_text(python_uri, "b") + documents.close(python_uri) + assert service.events == [ + ("opened", python_uri), ("changed", python_uri), ("closed", python_uri)] + + def test_a_service_hears_nothing_about_other_languages( + self, registry, documents, rust_uri): + service = RecordingService("python", "python") + registry.register(service) + documents.open(TextDocument(rust_uri, "fn main() {}", "rust")) + documents.replace_text(rust_uri, "fn main() { }") + documents.close(rust_uri) + assert service.events == [] + + def test_every_service_for_a_language_is_told(self, registry, documents, python_uri): + linter = RecordingService("linter", "python") + parser = RecordingService("parser", "python") + registry.register(linter) + registry.register(parser) + documents.open(TextDocument(python_uri, language_id="python")) + assert linter.events == parser.events == [("opened", python_uri)] + + +class TestLookup: + @pytest.fixture() + def populated(self, registry): + registry.register(RecordingService( + "server", "python", frozenset({LanguageCapability.COMPLETION, LanguageCapability.HOVER}))) + registry.register(RecordingService( + "parser", "python", frozenset({LanguageCapability.SYNTAX_TREE}))) + registry.register(RecordingService( + "rust", "rust", frozenset({LanguageCapability.COMPLETION}))) + return registry + + def test_services_are_found_by_the_document_they_handle(self, populated, python_uri): + document = TextDocument(python_uri, language_id="python") + assert [service.name for service in populated.services_for(document)] == [ + "server", "parser"] + + def test_a_capability_narrows_the_answer(self, populated, python_uri): + document = TextDocument(python_uri, language_id="python") + found = populated.services_for(document, LanguageCapability.SYNTAX_TREE) + assert [service.name for service in found] == ["parser"] + + def test_no_service_offers_what_nobody_registered(self, populated, python_uri): + document = TextDocument(python_uri, language_id="python") + assert populated.services_for(document, LanguageCapability.RENAME) == [] + + def test_an_unknown_language_has_no_service(self, populated, tmp_path): + document = TextDocument(to_uri(tmp_path / "notes.txt"), language_id="plaintext") + assert populated.services_for(document) == [] + + +class TestShutdown: + def test_every_service_is_shut_down_and_removed(self, registry): + first = RecordingService("first", "python") + second = RecordingService("second", "rust") + registry.register(first) + registry.register(second) + registry.shutdown() + assert first.events == second.events == [("shutdown", "")] + assert registry.services() == [] + + def test_document_events_are_no_longer_listened_to(self, registry, documents): + listening_before = (len(documents.opened), len(documents.changed), len(documents.closed)) + registry.shutdown() + assert listening_before == (1, 1, 1) + assert (len(documents.opened), len(documents.changed), len(documents.closed)) == (0, 0, 0) diff --git a/test/test_core_services.py b/test/test_core_services.py new file mode 100644 index 0000000..4186dca --- /dev/null +++ b/test/test_core_services.py @@ -0,0 +1,364 @@ +"""Tests for the service container and the debug, task, remote and AI interfaces.""" +from __future__ import annotations + +import pytest +from PySide6.QtCore import QCoreApplication + +from je_editor.core.ai.ai_provider import ( + AIProvider, CancelToken, ChatMessage, ChatRequest, ChatResponse, ChatRole, ModelInfo +) +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugLaunchRequest, DebugSession, DebugState, StepKind +) +from je_editor.core.diagnostics.diagnostic_model import Diagnostic, TextRange +from je_editor.core.document.document_model import TextDocument +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import ( + OutputStream, TaskHandle, TaskRunner, TaskSpec, TaskState +) +from je_editor.core.remote.remote_session import RemoteSession, RemoteState +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.uri.resource_uri import to_uri, uri_scheme +from je_editor.core.workspace.workspace_model import Workspace +from je_editor.utils.exception.exceptions import JEditorServiceException + +REMOTE_ROOT_URI = "ssh://build-host/srv/project" + + +class FakeTask: + """A task that plays back fixed output instead of starting a process.""" + + def __init__(self, spec: TaskSpec) -> None: + self.spec = spec + self.output = EventHook("task output") + self.finished = EventHook("task finished") + self._state = TaskState.PENDING + self._exit_code: int | None = None + self.written: list[str] = [] + + def state(self) -> TaskState: + return self._state + + def exit_code(self) -> int | None: + return self._exit_code + + def start(self) -> bool: + self._state = TaskState.RUNNING + self.output.emit(OutputStream.STDOUT, f"ran {' '.join(self.spec.command)}") + self._finish(TaskState.FINISHED, 0) + return True + + def write(self, text: str) -> bool: + self.written.append(text) + return True + + def cancel(self) -> None: + if self._state in (TaskState.PENDING, TaskState.RUNNING): + self._finish(TaskState.CANCELLED, -1) + + def _finish(self, state: TaskState, exit_code: int) -> None: + self._state = state + self._exit_code = exit_code + self.finished.emit(exit_code) + + +class FakeRunner: + """A task runner that hands out fake tasks and remembers being shut down.""" + + def __init__(self) -> None: + self.created: list[FakeTask] = [] + self.shut_down = False + + def create(self, spec: TaskSpec) -> FakeTask: + task = FakeTask(spec) + self.created.append(task) + return task + + def shutdown(self) -> None: + self.shut_down = True + for task in self.created: + task.cancel() + + +class FakeDebugSession: + """A debug session that only tracks its state and what it was told.""" + + def __init__(self) -> None: + self.state_changed = EventHook("debug state changed") + self._state = DebugState.IDLE + self.breakpoints: dict[str, list[Breakpoint]] = {} + + def state(self) -> DebugState: + return self._state + + def _move_to(self, state: DebugState) -> None: + self._state = state + self.state_changed.emit(state) + + def launch(self, request: DebugLaunchRequest) -> bool: + self._move_to(DebugState.PAUSED if request.stop_on_entry else DebugState.RUNNING) + return True + + def set_breakpoints(self, uri: str, breakpoints) -> None: + self.breakpoints[uri] = list(breakpoints) + + def resume(self) -> None: + self._move_to(DebugState.RUNNING) + + def pause(self) -> None: + self._move_to(DebugState.PAUSED) + + def step(self, kind: StepKind) -> None: + self._move_to(DebugState.PAUSED) + + def terminate(self) -> None: + self._move_to(DebugState.TERMINATED) + + +class FakeRemoteSession: + """A remote session that connects instantly and runs tasks on a fake runner.""" + + def __init__(self, authority: str) -> None: + self.authority = authority + self.state_changed = EventHook("remote state changed") + self._state = RemoteState.DISCONNECTED + self._runner = FakeRunner() + + def state(self) -> RemoteState: + return self._state + + def connect(self) -> bool: + self._state = RemoteState.CONNECTED + self.state_changed.emit(self._state) + return True + + def disconnect(self) -> None: + self._state = RemoteState.DISCONNECTED + self.state_changed.emit(self._state) + + def task_runner(self) -> FakeRunner: + return self._runner + + +class EchoProvider: + """An AI provider that answers with the last message, word by word.""" + + name = "echo" + + def models(self) -> list[ModelInfo]: + return [ModelInfo("echo-1", "Echo", supports_streaming=True)] + + def complete(self, request: ChatRequest, on_text=None, cancel=None) -> ChatResponse: + said: list[str] = [] + for word in request.messages[-1].content.split(): + if cancel is not None and cancel.cancelled: + return ChatResponse(" ".join(said), "echo-1", cancelled=True) + said.append(word) + if on_text is not None: + on_text(word) + return ChatResponse(" ".join(said), "echo-1", len(request.messages), len(said)) + + +class TestBuildingTheServices: + def test_no_application_object_is_created(self): + before = QCoreApplication.instance() + EditorServices() + assert QCoreApplication.instance() is before + + def test_it_starts_with_an_empty_workspace(self): + services = EditorServices() + assert services.workspace.roots == () + assert len(services.documents) == 0 + assert len(services.diagnostics) == 0 + + def test_a_given_workspace_is_used(self, tmp_path): + workspace = Workspace.single_root(tmp_path) + assert EditorServices(workspace).workspace is workspace + + def test_two_sets_of_services_share_nothing(self, tmp_path): + first, second = EditorServices(), EditorServices() + first.workspace.add_root(tmp_path) + first.documents.open(TextDocument(to_uri(tmp_path / "a.py"))) + first.ai_providers.register("echo", EchoProvider()) + assert second.workspace.roots == () + assert len(second.documents) == 0 + assert second.ai_providers.names() == [] + + def test_every_provider_registry_starts_empty(self): + services = EditorServices() + assert [len(registry) for registry in ( + services.debug_adapters, services.task_runners, + services.remote_transports, services.ai_providers)] == [0, 0, 0, 0] + + +class TestShuttingDown: + @pytest.fixture() + def busy(self, tmp_path): + services = EditorServices(Workspace.single_root(tmp_path)) + uri = to_uri(tmp_path / "a.py") + services.documents.open(TextDocument(uri, "import os\n", "python")) + services.diagnostics.publish("ruff", uri, [Diagnostic("unused", TextRange.from_lines(1))]) + services.task_runners.register("local", FakeRunner()) + return services + + def test_task_runners_are_shut_down_and_removed(self, busy): + runner = busy.task_runners.require("local") + busy.shutdown() + assert runner.shut_down is True + assert busy.task_runners.names() == [] + + def test_a_task_that_has_not_finished_is_cancelled(self, busy): + task = busy.task_runners.require("local").create(TaskSpec(("python", "server.py"))) + busy.shutdown() + assert task.state() is TaskState.CANCELLED + + def test_documents_and_diagnostics_are_released(self, busy): + busy.shutdown() + assert len(busy.documents) == 0 + assert len(busy.diagnostics) == 0 + + def test_open_documents_are_announced_as_closed(self, busy): + closed = [] + busy.documents.closed.subscribe(lambda document: closed.append(document.uri)) + expected = [document.uri for document in busy.documents.documents()] + busy.shutdown() + assert closed == expected + + def test_shutting_down_twice_is_harmless(self, busy): + busy.shutdown() + busy.task_runners.register("late", FakeRunner()) + busy.shutdown() + assert busy.is_shut_down + assert busy.task_runners.require("late").shut_down is False + + +class TestTaskInterface: + def test_the_fakes_satisfy_the_protocols(self): + runner = FakeRunner() + assert isinstance(runner, TaskRunner) + assert isinstance(runner.create(TaskSpec(("python", "-V"))), TaskHandle) + + def test_a_command_given_as_a_list_is_frozen(self): + command = ["python", "main.py"] + spec = TaskSpec(command) + command.append("--injected") + assert spec.command == ("python", "main.py") + + def test_the_environment_is_copied_and_cannot_be_changed(self): + environment = {"PYTHONUTF8": "1"} + spec = TaskSpec(("python", "main.py"), environment=environment) + environment["INJECTED"] = "1" + assert dict(spec.environment) == {"PYTHONUTF8": "1"} + with pytest.raises(TypeError): + spec.environment["INJECTED"] = "1" + + @pytest.mark.parametrize("command", [(), [], "python main.py", ("python", ""), ("python", 3)]) + def test_a_command_that_is_not_an_argument_list_is_refused(self, command): + with pytest.raises(JEditorServiceException, match="task command"): + TaskSpec(command) + + def test_subscribing_before_the_start_catches_the_first_output(self): + task = FakeRunner().create(TaskSpec(("python", "main.py"), name="Run main")) + output, codes = [], [] + task.output.subscribe(lambda stream, text: output.append((stream, text))) + task.finished.subscribe(codes.append) + assert task.state() is TaskState.PENDING + assert task.start() is True + assert output == [(OutputStream.STDOUT, "ran python main.py")] + assert codes == [0] + assert (task.state(), task.exit_code()) == (TaskState.FINISHED, 0) + + +class TestDebugInterface: + def test_the_fake_satisfies_the_protocol(self): + assert isinstance(FakeDebugSession(), DebugSession) + + def test_a_launch_needs_a_program(self): + with pytest.raises(JEditorServiceException, match="needs a program"): + DebugLaunchRequest(program="") + + def test_an_adapter_is_built_from_the_registry_and_driven(self, tmp_path): + services = EditorServices() + services.debug_adapters.register("fake", FakeDebugSession) + session = services.debug_adapters.require("fake")() + states = [] + session.state_changed.subscribe(states.append) + uri = to_uri(tmp_path / "main.py") + session.set_breakpoints(uri, [Breakpoint(uri, 3), Breakpoint(uri, 9, condition="x > 1")]) + session.launch(DebugLaunchRequest(str(tmp_path / "main.py"), stop_on_entry=True)) + session.step(StepKind.OVER) + session.resume() + session.terminate() + assert states == [ + DebugState.PAUSED, DebugState.PAUSED, DebugState.RUNNING, DebugState.TERMINATED] + assert [point.line for point in session.breakpoints[uri]] == [3, 9] + + def test_an_unknown_adapter_is_reported_with_the_known_ones(self): + services = EditorServices() + services.debug_adapters.register("fake", FakeDebugSession) + with pytest.raises(JEditorServiceException, match="registered: fake"): + services.debug_adapters.require("dap") + + +class TestRemoteInterface: + def test_the_fake_satisfies_the_protocol(self): + assert isinstance(FakeRemoteSession("build-host"), RemoteSession) + + def test_a_transport_is_found_by_the_scheme_of_a_root(self): + services = EditorServices() + services.remote_transports.register("ssh", FakeRemoteSession) + transport = services.remote_transports.require(uri_scheme(REMOTE_ROOT_URI)) + session = transport("build-host") + assert session.connect() is True + assert (session.authority, session.state()) == ("build-host", RemoteState.CONNECTED) + + def test_a_remote_task_looks_like_a_local_one(self): + session = FakeRemoteSession("build-host") + session.connect() + task = session.task_runner().create(TaskSpec(("pytest", "-q"))) + assert isinstance(task, TaskHandle) + assert task.start() is True + + +class TestAIProviderInterface: + @pytest.fixture() + def request_for_reply(self): + return ChatRequest( + (ChatMessage(ChatRole.USER, "explain this function please"),), + system_prompt="Answer briefly.") + + def test_the_fake_satisfies_the_protocol(self): + assert isinstance(EchoProvider(), AIProvider) + + def test_a_provider_is_chosen_by_name(self, request_for_reply): + services = EditorServices() + services.ai_providers.register(EchoProvider.name, EchoProvider()) + response = services.ai_providers.require("echo").complete(request_for_reply) + assert response.text == "explain this function please" + assert (response.input_tokens, response.output_tokens) == (1, 4) + + def test_text_arrives_piece_by_piece_when_asked_for(self, request_for_reply): + pieces = [] + EchoProvider().complete(request_for_reply, on_text=pieces.append) + assert pieces == ["explain", "this", "function", "please"] + + def test_a_cancel_stops_the_reply_part_way(self, request_for_reply): + cancel = CancelToken() + pieces = [] + + def stop_after_two(piece: str) -> None: + pieces.append(piece) + if len(pieces) == 2: + cancel.cancel() + + response = EchoProvider().complete(request_for_reply, stop_after_two, cancel) + assert response.cancelled is True + assert response.text == "explain this" + + def test_a_token_is_not_cancelled_until_asked(self): + assert CancelToken().cancelled is False + + def test_the_models_describe_themselves(self): + model = EchoProvider().models()[0] + assert (model.model_id, model.display_name, model.supports_streaming) == ( + "echo-1", "Echo", True) diff --git a/test/test_core_workspace.py b/test/test_core_workspace.py new file mode 100644 index 0000000..9e9ba57 --- /dev/null +++ b/test/test_core_workspace.py @@ -0,0 +1,224 @@ +"""Tests for resource URIs and the workspace model.""" +from __future__ import annotations + +import os + +import pytest + +from je_editor.core.uri.resource_uri import is_local_uri, to_path, to_uri, uri_key, uri_scheme +from je_editor.core.workspace.workspace_model import ProjectRoot, Workspace +from je_editor.utils.exception.exceptions import JEditorServiceException + +REMOTE_URI = "ssh://build-host/srv/project" + + +class TestResourceUri: + def test_a_local_path_becomes_a_file_uri(self, tmp_path): + assert to_uri(tmp_path / "a.py").startswith("file://") + + def test_a_uri_converts_back_to_the_same_path(self, tmp_path): + target = tmp_path / "pkg" / "a.py" + assert to_path(to_uri(target)) == os.path.normpath(str(target)) + + @pytest.mark.usefixtures("tmp_dir") + def test_a_relative_path_is_taken_from_the_working_directory(self): + assert to_path(to_uri("a.py")) == os.path.join(os.getcwd(), "a.py") + + def test_a_remote_uri_has_no_local_path(self): + assert to_path(REMOTE_URI) == "" + + @pytest.mark.parametrize("uri, expected", [ + ("file:///tmp/a.py", True), (REMOTE_URI, False), ("", False), (None, False), + ]) + def test_only_file_uris_are_local(self, uri, expected): + assert is_local_uri(uri) is expected + + @pytest.mark.parametrize("uri, expected", [ + ("file:///tmp/a.py", "file"), (REMOTE_URI, "ssh"), ("SSH://host/x", "ssh"), + ("no scheme here", ""), (None, ""), + ]) + def test_the_scheme_is_read_in_lower_case(self, uri, expected): + assert uri_scheme(uri) == expected + + def test_two_spellings_of_one_file_share_a_key(self, tmp_path): + direct = to_uri(tmp_path / "a.py") + roundabout = to_uri(tmp_path / "pkg" / ".." / "a.py") + assert uri_key(direct) == uri_key(roundabout) + + @pytest.mark.skipif(os.path.normcase("A") == "A", reason="this file system is case-sensitive") + def test_case_does_not_matter_where_the_file_system_ignores_it(self, tmp_path): + uri = to_uri(tmp_path / "Module.py") + assert uri_key(uri) == uri_key(uri.replace("Module", "MODULE")) + + def test_different_files_have_different_keys(self, tmp_path): + assert uri_key(to_uri(tmp_path / "a.py")) != uri_key(to_uri(tmp_path / "b.py")) + + def test_a_remote_uri_is_its_own_key(self): + assert uri_key(REMOTE_URI) == REMOTE_URI + + +class TestProjectRoot: + def test_the_name_defaults_to_the_directory_name(self, tmp_path): + assert ProjectRoot.from_path(tmp_path / "service").name == "service" + + def test_a_given_name_is_kept(self, tmp_path): + assert ProjectRoot.from_path(tmp_path, name="Backend").name == "Backend" + + def test_the_path_comes_back_out_of_the_uri(self, tmp_path): + assert ProjectRoot.from_path(tmp_path).path == os.path.normpath(str(tmp_path)) + + def test_a_directory_that_does_not_exist_is_still_a_root(self, tmp_path): + root = ProjectRoot.from_path(tmp_path / "not-created-yet") + assert root.is_local + + def test_a_remote_root_is_not_local_and_has_no_path(self): + root = ProjectRoot(uri=REMOTE_URI, name="build") + assert not root.is_local + assert root.path == "" + + def test_a_file_inside_is_contained(self, tmp_path): + assert ProjectRoot.from_path(tmp_path).contains(tmp_path / "pkg" / "a.py") + + def test_the_root_contains_itself(self, tmp_path): + assert ProjectRoot.from_path(tmp_path).contains(tmp_path) + + def test_a_sibling_with_the_same_prefix_is_not_contained(self, tmp_path): + root = ProjectRoot.from_path(tmp_path / "app") + assert not root.contains(tmp_path / "app-old" / "a.py") + + def test_a_remote_root_contains_no_local_path(self, tmp_path): + assert not ProjectRoot(uri=REMOTE_URI, name="build").contains(tmp_path) + + def test_resolve_joins_a_relative_path_onto_the_root(self, tmp_path): + resolved = ProjectRoot.from_path(tmp_path).resolve("pkg/a.py") + assert resolved == os.path.normpath(str(tmp_path / "pkg" / "a.py")) + + @pytest.mark.parametrize("escape", ["../outside.py", "pkg/../../outside.py"]) + def test_resolve_refuses_to_leave_the_root(self, tmp_path, escape): + root = ProjectRoot.from_path(tmp_path / "project") + with pytest.raises(JEditorServiceException, match="leaves the project root"): + root.resolve(escape) + + def test_resolve_refuses_an_absolute_path_outside_the_root(self, tmp_path): + root = ProjectRoot.from_path(tmp_path / "project") + with pytest.raises(JEditorServiceException, match="leaves the project root"): + root.resolve(str(tmp_path / "elsewhere" / "a.py")) + + def test_resolve_needs_a_local_root(self): + with pytest.raises(JEditorServiceException, match="not a local root"): + ProjectRoot(uri=REMOTE_URI, name="build").resolve("a.py") + + +class TestWorkspace: + def test_a_new_workspace_has_no_roots(self): + workspace = Workspace() + assert workspace.roots == () + assert workspace.primary_root is None + assert not workspace.is_multi_root + + def test_a_single_root_workspace_is_the_old_project_directory(self, tmp_path): + workspace = Workspace.single_root(tmp_path) + assert [root.path for root in workspace.roots] == [os.path.normpath(str(tmp_path))] + assert workspace.primary_root == workspace.roots[0] + assert not workspace.is_multi_root + + def test_roots_keep_the_order_they_were_added_in(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path / "zeta") + workspace.add_root(tmp_path / "alpha") + assert [root.name for root in workspace.roots] == ["zeta", "alpha"] + assert workspace.is_multi_root + + def test_the_same_directory_is_not_added_twice(self, tmp_path): + workspace = Workspace.single_root(tmp_path) + assert workspace.add_root(tmp_path / "pkg" / "..") is False + assert len(workspace.roots) == 1 + + def test_a_remote_root_sits_beside_a_local_one(self, tmp_path): + workspace = Workspace.single_root(tmp_path) + assert workspace.add_root(ProjectRoot(uri=REMOTE_URI, name="build")) is True + assert [root.is_local for root in workspace.roots] == [True, False] + + @pytest.mark.parametrize("spelling", ["root", "path", "uri"]) + def test_a_root_can_be_removed_however_it_is_named(self, tmp_path, spelling): + workspace = Workspace.single_root(tmp_path) + root = workspace.roots[0] + target = {"root": root, "path": tmp_path, "uri": root.uri}[spelling] + assert workspace.remove_root(target) is True + assert workspace.roots == () + + def test_removing_an_unknown_root_changes_nothing(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "app") + assert workspace.remove_root(tmp_path / "other") is False + assert len(workspace.roots) == 1 + + def test_a_remote_root_is_removed_by_its_uri(self): + workspace = Workspace([ProjectRoot(uri=REMOTE_URI, name="build")]) + assert workspace.remove_root(REMOTE_URI) is True + + def test_a_file_belongs_to_the_root_that_holds_it(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path / "frontend") + workspace.add_root(tmp_path / "backend") + owner = workspace.root_for(tmp_path / "backend" / "api.py") + assert owner is not None and owner.name == "backend" + + def test_the_deepest_root_wins_when_roots_nest(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path) + workspace.add_root(tmp_path / "vendor" / "library") + owner = workspace.root_for(tmp_path / "vendor" / "library" / "x.py") + assert owner is not None and owner.name == "library" + + def test_a_file_outside_every_root_has_no_owner(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "app") + assert workspace.root_for(tmp_path / "elsewhere" / "x.py") is None + assert workspace.relative_path(tmp_path / "elsewhere" / "x.py") is None + + def test_a_local_document_finds_its_root_by_uri(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path / "frontend") + workspace.add_root(tmp_path / "backend") + owner = workspace.root_for_uri(to_uri(tmp_path / "backend" / "api.py")) + assert owner is not None and owner.name == "backend" + + def test_a_remote_document_finds_its_remote_root(self, tmp_path): + workspace = Workspace.single_root(tmp_path) + workspace.add_root(ProjectRoot(uri=REMOTE_URI, name="build")) + owner = workspace.root_for_uri(f"{REMOTE_URI}/src/main.py") + assert owner is not None and owner.name == "build" + + def test_the_deepest_remote_root_wins(self): + workspace = Workspace([ + ProjectRoot(uri=REMOTE_URI, name="build"), + ProjectRoot(uri=f"{REMOTE_URI}/vendor", name="vendor"), + ]) + owner = workspace.root_for_uri(f"{REMOTE_URI}/vendor/lib.py") + assert owner is not None and owner.name == "vendor" + + @pytest.mark.parametrize("uri", [ + f"{REMOTE_URI}-old/src/main.py", "ssh://other-host/srv/project/main.py", + ]) + def test_a_resource_outside_every_remote_root_has_no_owner(self, uri): + workspace = Workspace([ProjectRoot(uri=REMOTE_URI, name="build")]) + assert workspace.root_for_uri(uri) is None + + def test_same_named_files_in_two_roots_do_not_collide(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path / "frontend") + workspace.add_root(tmp_path / "backend") + first = workspace.relative_path(tmp_path / "frontend" / "src" / "main.py") + second = workspace.relative_path(tmp_path / "backend" / "src" / "main.py") + assert first is not None and second is not None + assert first[1] == second[1] == "src/main.py" + assert first[0] != second[0] + + def test_changes_are_announced_once_each(self, tmp_path): + workspace = Workspace() + announced = [] + workspace.changed.subscribe(lambda changed: announced.append(len(changed.roots))) + workspace.add_root(tmp_path / "a") + workspace.add_root(tmp_path / "a") + workspace.remove_root(tmp_path / "a") + workspace.remove_root(tmp_path / "a") + assert announced == [1, 0] diff --git a/test/test_exceptions.py b/test/test_exceptions.py index f6d21e7..5f28f7f 100644 --- a/test/test_exceptions.py +++ b/test/test_exceptions.py @@ -10,6 +10,7 @@ JEditorContentFileException, JEditorCantFindLanguageException, JEditorJsonException, + JEditorServiceException, ) @@ -23,6 +24,7 @@ def test_all_inherit_from_jeditor_exception(self): JEditorContentFileException, JEditorCantFindLanguageException, JEditorJsonException, + JEditorServiceException, ): assert issubclass(exc_cls, JEditorException) diff --git a/test/test_public_api_contract.py b/test/test_public_api_contract.py new file mode 100644 index 0000000..3a5dfa4 --- /dev/null +++ b/test/test_public_api_contract.py @@ -0,0 +1,89 @@ +""" +Tests that pin what other projects import from JEditor. + +The editor's internals are being moved behind a service layer. PyBreeze subclasses +the main window and imports a handful of names by their full module path, and +plugins import the registry functions, so those names have to survive every move. +""" +from __future__ import annotations + +import importlib +import inspect + +import pytest + +import je_editor + +# What ``je_editor`` exported when the service layer was introduced. Names may be +# added to the package; none of these may leave it. +EXPORTED_NAMES = frozenset({ + "start_editor", "EditorMain", "EDITOR_EXTEND_TAB", "EditorWidget", "FullEditorWidget", + "MainBrowserWidget", "ExecManager", "ShellManager", "PythonHighlighter", + "syntax_rule_setting_dict", "syntax_extend_setting_dict", + "user_setting_dict", "user_setting_color_dict", + "language_wrapper", "english_word_dict", "traditional_chinese_word_dict", "jeditor_logger", + "JEditorException", "JEditorExecException", "JEditorRunOnShellException", + "JEditorSaveFileException", "JEditorOpenFileException", "JEditorContentFileException", + "JEditorCantFindLanguageException", "JEditorJsonException", + "register_programming_language", "get_programming_language_plugin", + "get_all_programming_language_suffixes", + "register_natural_language", "get_natural_language_plugin", "get_all_natural_languages", + "register_plugin_run_config", "get_all_plugin_run_configs", "get_plugin_run_config_by_suffix", + "register_plugin_metadata", "get_all_plugin_metadata", "load_external_plugins", +}) + +# Names PyBreeze imports by module path rather than from ``je_editor`` itself +DOWNSTREAM_INTERNALS = [ + ("je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", "PluginBrowserWidget"), + ("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock"), + ("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget"), + ("je_editor.pyside_ui.main_ui.save_settings.user_setting_file", "user_setting_dict"), + ("je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", "actually_color_dict"), + ("je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", "choose_file_get_save_file_path"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "auto_save_manager_dict"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "file_is_open_manager_dict"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "init_new_auto_save_thread"), + ("je_editor.utils.venv_check.check_venv", "check_and_choose_venv"), + ("je_editor.utils.redirect_manager.redirect_manager_class", "RedirectStdErr"), + ("je_editor.utils.encodings.text_codec", "DEFAULT_ENCODING"), + ("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF"), + ("je_editor.utils.file.save.save_file", "write_file_with_encoding"), + ("je_editor.utils.file.save.save_file", "write_file"), +] + +# The constructor arguments a host application passes, in order +EDITOR_MAIN_ARGUMENTS = ["debug_mode", "show_system_tray_ray", "extend"] + + +class TestThePackageExports: + def test_no_exported_name_has_left(self): + assert EXPORTED_NAMES - set(je_editor.__all__) == set() + + def test_every_exported_name_exists(self): + missing = [name for name in je_editor.__all__ if not hasattr(je_editor, name)] + assert missing == [] + + +class TestTheInternalsDownstreamImports: + @pytest.mark.parametrize("module_name, attribute", DOWNSTREAM_INTERNALS) + def test_the_name_is_still_at_its_module_path(self, module_name, attribute): + assert hasattr(importlib.import_module(module_name), attribute) + + +@pytest.fixture(scope="module") +def parameters(): + """The arguments of the main window's constructor, without ``self``.""" + signature = inspect.signature(je_editor.EditorMain.__init__) + return [parameter for name, parameter in signature.parameters.items() if name != "self"] + + +class TestTheMainWindowConstructor: + def test_the_arguments_keep_their_names_and_order(self, parameters): + assert [parameter.name for parameter in parameters] == EDITOR_MAIN_ARGUMENTS + + def test_every_argument_is_off_by_default(self, parameters): + assert [parameter.default for parameter in parameters] == [False, False, False] + + def test_the_arguments_can_be_passed_by_position_or_by_name(self, parameters): + assert {parameter.kind for parameter in parameters} == { + inspect.Parameter.POSITIONAL_OR_KEYWORD} From d47ff4aa33cb0cc0319ecea54646fef0e96334a7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 01:33:43 +0800 Subject: [PATCH 03/14] Mark the contract test's import as taking literals only Codacy's Opengrep rule non-literal-import flags importlib.import_module() in test_public_api_contract.py as untrusted input. The module names come from a constant list in the same file, so the finding is wrong: keep the per-name import, which says exactly which name went missing, and suppress the rule on that line with the reason next to it. Record the CI result of the core service layer in docs/updates. --- docs/updates/2026-10.md | 8 ++++++++ docs/updates/README.md | 3 ++- test/test_public_api_contract.py | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index bd4a0d3..b72dad5 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -125,3 +125,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **`architecture.md` §6 的更正**:PyBreeze 以模組路徑匯入的內部名稱,原本列的是 6 個,對照 PyBreeze `16214a5` 實際是 14 個:原本的 6 個裡 `write_file` 沒有被匯入,另外多了 9 個(`FullEditorWidget`、`user_setting_dict`、自動儲存的三個名稱、`RedirectStdErr`、`DEFAULT_ENCODING`、`LINE_ENDING_LF`、`write_file_with_encoding`)。 - **檔案**:`je_editor/core/`(新,26 個檔)、`je_editor/__init__.py`、`je_editor/utils/exception/exceptions.py`、`test/test_core_*.py`(新,7 個)、`test/test_public_api_contract.py`(新)、`test/test_exceptions.py`、`docs/source/docs/{Eng,Zh}/core_services.rst`(新)、`docs/source/docs/{Eng,Zh}/api_reference.rst`、`docs/source/docs/Eng/eng_index.rst`、`docs/source/docs/Zh/zh_index.rst`、`README.md`、`README/README_zh-TW.md`、`README/README_zh-CN.md`、`architecture.md`、`architecture_explore.md`、`docs/roadmap/2026-editor-next.md`、`PROGRESS.md`。 - **待辦**:`PROGRESS.md` #9 ~ #17(M1 ~ M8,以及 PR #270 尚未答覆的審查問題)、#18(ruff 0.16 的預設規則)、#19(長路徑測試)、#20(pytest 10 會拒絕的 fixture 寫法)。 + +## U-20261008-02 · 2026-10-08 · M0 在 PR #270 的 CI 結果;Codacy 的動態匯入警告是誤判 · #decision #ci #roadmap + +- **CI**:U-20261008-01 的 commit `6761437` 推到 PR #270 之後,`build_dev_version` 在 Python 3.10、3.11、3.12、3.13、3.14 全部通過(單元測試加 `start_qt_ui.py`、`extend_test.py`),SonarCloud 通過。Codacy 回報 1 筆新問題。 +- **Codacy 的那一筆**:Opengrep 的 `python.lang.security.audit.non-literal-import`,位置是 `test/test_public_api_contract.py` 的 `importlib.import_module(module_name)`,說「不受信任的輸入可以載入任意程式碼」。查證:`module_name` 只來自同一個檔案裡的常數清單 `DOWNSTREAM_INTERNALS`,沒有任何外部輸入,所以是誤判。 +- **決定**:程式碼不改,在那一行加 `# nosemgrep` 並在上方註明原因(跟 `test_logging_hygiene.py` 對 `subprocess.run` 的做法相同)。沒有改成靜態匯入,因為那樣其中一個模組搬走時整個測試檔會在收集階段就失敗,看不出是哪一個名稱不見了;現在的寫法是每個名稱一個測試。 +- **檔案**:`test/test_public_api_contract.py`。 +- **待辦**:無。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 970b1b2..ff19760 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-02 | 2026-10-08 | M0 在 PR #270 的 CI 結果;Codacy 的動態匯入警告是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-01 | 2026-10-08 | 藍圖 M0:不依賴 Qt 的核心服務層 je_editor/core | #migration #roadmap #core | [2026-10](2026-10.md) | | U-20261001-08 | 2026-10-01 | 發佈鎖檔改用和其他鎖檔一樣的七天截止日解析 | #ci #security #X-13 | [2026-10](2026-10.md) | | U-20261001-07 | 2026-10-01 | 發佈工作用鎖定的 setuptools 建置,不再下載當下最新的版本 | #done #ci #security #X-13 | [2026-10](2026-10.md) | @@ -92,5 +93,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 9 | +| [2026-10.md](2026-10.md) | 2026-10 | 10 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/test/test_public_api_contract.py b/test/test_public_api_contract.py index 3a5dfa4..c9e874d 100644 --- a/test/test_public_api_contract.py +++ b/test/test_public_api_contract.py @@ -67,7 +67,10 @@ def test_every_exported_name_exists(self): class TestTheInternalsDownstreamImports: @pytest.mark.parametrize("module_name, attribute", DOWNSTREAM_INTERNALS) def test_the_name_is_still_at_its_module_path(self, module_name, attribute): - assert hasattr(importlib.import_module(module_name), attribute) + # Every module name is a literal from the list above; nothing from + # outside this file reaches the import. + module = importlib.import_module(module_name) # nosemgrep + assert hasattr(module, attribute) @pytest.fixture(scope="module") From 1d139a841eab7cfbc4f4771361e57124d9eec8ab Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 01:41:39 +0800 Subject: [PATCH 04/14] Note in PROGRESS that translation parity is already tested The M1 item listed a dictionary parity check as work to do. test/test_languages.py already holds keys, placeholders, blank values and the English fallback for all four dictionaries, so the item now names what is actually left: data-driven locale loading, stable command ids, and the layout decision. --- PROGRESS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index e123ca4..f559161 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -29,8 +29,10 @@ M0(`je_editor/core/` 服務層)已完成,見 U-20261008-01。以下每個里程碑一個 PR,順序依相依關係; #13 只相依 M0,可以先做。 -- **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。先做不改變外觀的部分:每個指令有不隨翻譯變動的 - ID;字典的鍵與佔位符 parity 檢查進 CI。 +- **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 + 變動的 ID。語系那一項大部分已經有了:四份字典各 438 個鍵,鍵與佔位符的 parity、空白值、退回英文都 + 由 `test/test_languages.py` 在 CI 守著;還沒做的是「語系載入改成資料驅動」。〔決定〕UI 的版面方向 + (活動列、編輯區、側邊面板、底部面板)要先定,才能動視窗層。 - **#10** M2(Tree-sitter、統一診斷)。問題面板、底線、縮圖改用 `core/diagnostics`(現在靠 `legacy_diagnostics.py` 互轉);`utils/lsp/lsp_protocol.diagnostic_entries` 沒有帶出伺服器給的 嚴重度,要補上;`LanguageService` 的「發問、等回覆」呼叫形式在這裡定。 From 132246e0a65dc8d9ad5dcf36f41c45293273b2d1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 01:56:17 +0800 Subject: [PATCH 05/14] Name the ruff rule set and make two tests independent of the machine ruff 0.16 widened its default rules from 118 to 826, so an unpinned ruff with no configuration turned a clean repository into 526 findings. pyproject.toml and dev.toml now select E4, E7, E9 and F, the set the project has always been checked against. test_depth_limit_prunes_deep_trees used directory names long enough to pass 260 characters, which fails on Windows without long paths; one-letter names test the same depth limit. The class-scoped fixture in test_logging_hygiene.py becomes a module fixture, which pytest 10 will require. Closes PROGRESS #18, #19 and #20. --- PROGRESS.md | 12 --------- architecture.md | 2 +- architecture_explore.md | 3 +++ dev.toml | 7 +++++ docs/updates/2026-10.md | 11 ++++++++ docs/updates/README.md | 3 ++- pyproject.toml | 7 +++++ test/test_dev_toml_parity.py | 10 +++++++ test/test_file_scan.py | 7 +++-- test/test_logging_hygiene.py | 51 +++++++++++++++++++----------------- 10 files changed, 73 insertions(+), 40 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f559161..8c9e33c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -11,18 +11,6 @@ `# 初始化並記錄日誌` 被當成「註解掉的程式碼」。這是本專案雙語註解的正常寫法,不該刪。 要清掉這一項得在 SonarCloud 把 issue 轉成 False Positive(用 API 改狀態需要 Administer Issues 權限)。 -- **#18** 〔決定〕`ruff` 沒有釘版本、repo 也沒有 ruff 設定,而 ruff 0.16 的預設規則從 118 條變成 - 826 條。2026-10-08 用 ruff 0.16.10 跑 `ruff check`:全 repo 526 筆(`I001` 匯入排序 276、`UP006` 93、 - `UP035` 35、`RUF100` 27、`UP045` 23、`BLE001` 22…,449 筆可自動修正);同一份程式碼用 ruff 0.15.8, - 或用 0.16.10 加 `--select E4,E7,E9,F`(舊的預設),都是乾淨的。要選一個:釘 `ruff<0.16`、在 - `pyproject.toml` 寫明規則,或整個 repo 照新規則修一輪。`je_editor/core/` 與它的測試在新規則下只剩 - `I001`(9 筆,寫法跟現有程式碼一致)與 `RUF022`(1 筆,`__all__` 依主題分組)。 -- **#19** 〔未確認〕`test_file_scan.py::TestIndexProjectFiles::test_depth_limit_prunes_deep_trees` 在沒有 - 開啟長路徑的 Windows(`LongPathsEnabled = 0`)上失敗:`deep.mkdir()` 丟 `WinError 206`,建出來的目錄樹 - 超過 260 字元。2026-10-08 在 `da90c4f` 的乾淨工作樹上同樣失敗,所以跟當時的修改無關;CI 與原本的 - 開發機沒有這個問題。要不要讓測試不依賴長路徑設定,還沒看。 -- **#20** 〔未確認〕pytest 9.1 對「class 範圍的 fixture 寫成實例方法」發出 `PytestRemovedIn10Warning`, - `test_logging_hygiene.py` 的 `probe_result` 是這種寫法,pytest 10 會變成錯誤。 ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) diff --git a/architecture.md b/architecture.md index ae4b8fd..52f5833 100644 --- a/architecture.md +++ b/architecture.md @@ -28,7 +28,7 @@ window, and plugins extend it through a small registry API. | `je_editor/plugins/` | Plugin registry (`__init__.py`) and `jeditor_plugins/` loader (`plugin_loader.py`) | | `test/` | pytest suites; `test/qt_ui/unit_test/` holds the launch scripts CI runs (`start_qt_ui.py`, `extend_test.py`) | | `docs/`, `exe/` | Sphinx docs; executable-build entry (`exe/start_editor.py`) and packaging configs | -| `pyproject.toml`, `dev.toml`, `MANIFEST.in` | Stable and dev package definitions. CI writes `dev.toml` to `pyproject.toml` to build the dev package, so their dependencies, Python floor, entry points and `[tool.setuptools]` must agree. Neither distribution carries `test/`: package discovery includes `je_editor` only (wheel) and `MANIFEST.in` prunes `test` (sdist). `test/test_dev_toml_parity.py` holds all of it | +| `pyproject.toml`, `dev.toml`, `MANIFEST.in` | Stable and dev package definitions. CI writes `dev.toml` to `pyproject.toml` to build the dev package, so their dependencies, Python floor, entry points and `[tool.setuptools]` must agree. Neither distribution carries `test/`: package discovery includes `je_editor` only (wheel) and `MANIFEST.in` prunes `test` (sdist). `test/test_dev_toml_parity.py` holds all of it. Both files also name the ruff rule set (`[tool.ruff.lint]`, `E4`/`E7`/`E9`/`F`): ruff's defaults change between releases, so "ruff check clean" is defined here and not by whichever ruff is installed | | `scripts/` | `dev_release.py`: release helper the `publish-dev` job runs (next dev version, wheel comparison); standard library only, not part of the package | | `.github/workflows/` | `dev.yml`, `stable.yml`: tests on a Windows Python matrix, then one publish job each on `ubuntu-latest` (§3 PyPI packages) | | `.github/requirements/` | `publish.in` and the `publish.txt` generated from it: the build tooling of the two publish jobs, build backend (`setuptools`) included, pinned by version and hash. The jobs install nothing else and build with `python -m build --no-isolation`, so the backend is the locked one; the lock has to satisfy `build-system.requires` of `pyproject.toml` and `dev.toml` (`test/test_workflow_actions.py`). Dependabot keeps it current | diff --git a/architecture_explore.md b/architecture_explore.md index 154062d..8498f58 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -547,6 +547,9 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff `dev.toml` 的下限調高時要重新產生 `publish.txt`;`test_workflow_actions.py` 守著這兩件事。 - 兩種發佈檔都不帶 `test/`:wheel 靠套件探索的 `include`(只收 `je_editor`),sdist 靠 `MANIFEST.in` 的 `prune test`(setuptools 預設會把 `test*/test*.py` 收進 sdist)。兩項都由 `test_dev_toml_parity.py` 守著。 +- ruff 的規則寫明在 `pyproject.toml` 與 `dev.toml` 的 `[tool.ruff.lint]`(`E4`、`E7`、`E9`、`F`,也就是 ruff 0.15 以前的 + 預設)。ruff 0.16 把預設規則從 118 條擴到 826 條,不寫明的話「`ruff check` 乾淨」會隨安裝到的版本改變; + `test_dev_toml_parity.py` 守著這組規則與兩個檔的一致。 --- diff --git a/dev.toml b/dev.toml index 80b5daa..3003696 100644 --- a/dev.toml +++ b/dev.toml @@ -54,6 +54,13 @@ addopts = "--ignore=test/qt_ui" # pytest relies on `assert`; exclude test directories to silence B101 exclude_dirs = ["test", "tests"] +[tool.ruff.lint] +# ruff 0.16 把預設規則從 118 條擴到 826 條。這裡寫明專案一直在用的那一組, +# 換 ruff 版本時「ruff check 乾淨」的意思才不會跟著變。 +# ruff 0.16 widened its default rules from 118 to 826. Naming the set this project has always +# used keeps "ruff check clean" meaning the same thing whichever ruff is installed. +select = ["E4", "E7", "E9", "F"] + [tool.setuptools.packages] # 只收 je_editor:test/ 有 __init__.py,不寫 include 會被一起裝進 site-packages。 # Ship je_editor only: test/ has an __init__.py and would be installed too without include. diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index b72dad5..68ca851 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -133,3 +133,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **決定**:程式碼不改,在那一行加 `# nosemgrep` 並在上方註明原因(跟 `test_logging_hygiene.py` 對 `subprocess.run` 的做法相同)。沒有改成靜態匯入,因為那樣其中一個模組搬走時整個測試檔會在收集階段就失敗,看不出是哪一個名稱不見了;現在的寫法是每個名稱一個測試。 - **檔案**:`test/test_public_api_contract.py`。 - **待辦**:無。 + +## U-20261008-03 · 2026-10-08 · PROGRESS #18、#19、#20:寫明 ruff 規則、長路徑測試、fixture 寫法 · #done #decision #tests + +- **做了什麼**:擁有者要求把 `PROGRESS.md` 全部做完,標了〔決定〕的項目採保守的預設並記在這裡。 + - **#18(決定)**:`pyproject.toml` 與 `dev.toml` 加上 `[tool.ruff.lint] select = ["E4", "E7", "E9", "F"]`,也就是 ruff 0.15 以前的預設,專案一直在用的那一組。沒有選「照 ruff 0.16 的 826 條預設把整個 repo 修一輪」:那是 449 筆自動修正加 77 筆要人看的改動,跟藍圖的工作混在一起會讓每個 diff 都難審。之後要放寬規則,改這一行就好。 + - **#19**:`test_depth_limit_prunes_deep_trees` 的目錄名稱從 `level_N` 改成一個字母。測的是深度上限,名稱長短無關;原本的寫法在沒開長路徑的 Windows 上會超過 260 字元(`WinError 206`)。 + - **#20**:`test_logging_hygiene.py` 的 `probe_result` 從「class 範圍、寫成實例方法」改成模組範圍的 fixture,pytest 9.1 不再發 `PytestRemovedIn10Warning`。 +- **測試**:`test_dev_toml_parity.py` 新增兩支:規則必須正好是那四組,兩個檔的 `[tool.ruff]` 要一致。 +- **結果**:ruff 0.16.10 與 0.15.8 跑 `ruff check` 都是 `All checks passed!`;改到的三個測試檔 43 個測試通過,其中長路徑那一支在這台 `LongPathsEnabled = 0` 的機器上現在通過。 +- **檔案**:`pyproject.toml`、`dev.toml`、`test/test_dev_toml_parity.py`、`test/test_file_scan.py`、`test/test_logging_hygiene.py`、`architecture.md`、`architecture_explore.md`、`PROGRESS.md`(刪 #18、#19、#20)。 +- **待辦**:無。`PROGRESS.md` #1(SonarCloud 誤判)要在網站上以有 Administer Issues 權限的帳號處理,這台機器沒有 `SonarCloudToken`,做不了。 diff --git a/docs/updates/README.md b/docs/updates/README.md index ff19760..08b824b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-03 | 2026-10-08 | PROGRESS #18、#19、#20:寫明 ruff 規則、長路徑測試、fixture 寫法 | #done #decision #tests | [2026-10](2026-10.md) | | U-20261008-02 | 2026-10-08 | M0 在 PR #270 的 CI 結果;Codacy 的動態匯入警告是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-01 | 2026-10-08 | 藍圖 M0:不依賴 Qt 的核心服務層 je_editor/core | #migration #roadmap #core | [2026-10](2026-10.md) | | U-20261001-08 | 2026-10-01 | 發佈鎖檔改用和其他鎖檔一樣的七天截止日解析 | #ci #security #X-13 | [2026-10](2026-10.md) | @@ -93,5 +94,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 10 | +| [2026-10.md](2026-10.md) | 2026-10 | 11 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/pyproject.toml b/pyproject.toml index 4b435ff..5d0c428 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,3 +53,10 @@ qt_api = "pyside6" # 測試程式碼使用 assert 是 pytest 的慣例,排除測試目錄以避免 B101 雜訊 # pytest relies on `assert`; exclude test directories to silence B101 exclude_dirs = ["test", "tests"] + +[tool.ruff.lint] +# ruff 0.16 把預設規則從 118 條擴到 826 條。這裡寫明專案一直在用的那一組, +# 換 ruff 版本時「ruff check 乾淨」的意思才不會跟著變。 +# ruff 0.16 widened its default rules from 118 to 826. Naming the set this project has always +# used keeps "ruff check clean" meaning the same thing whichever ruff is installed. +select = ["E4", "E7", "E9", "F"] diff --git a/test/test_dev_toml_parity.py b/test/test_dev_toml_parity.py index 5256618..65fde5e 100644 --- a/test/test_dev_toml_parity.py +++ b/test/test_dev_toml_parity.py @@ -64,6 +64,16 @@ def test_shipped_files_match(): assert DEV_FILE["tool"]["setuptools"] == STABLE_FILE["tool"]["setuptools"] +def test_the_lint_rules_are_named(): + # ruff's defaults change between releases (118 rules in 0.15, 826 in 0.16), so "ruff check + # clean" only means something while the rule set is written down. + assert STABLE_FILE["tool"]["ruff"]["lint"]["select"] == ["E4", "E7", "E9", "F"] + + +def test_lint_rules_match(): + assert DEV_FILE["tool"]["ruff"] == STABLE_FILE["tool"]["ruff"] + + def _manifest_commands() -> list[list[str]]: """Return the words of each ``MANIFEST.in`` command, comments and blank lines left out.""" lines = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8").splitlines() diff --git a/test/test_file_scan.py b/test/test_file_scan.py index 3d2bd8d..fab16a1 100644 --- a/test/test_file_scan.py +++ b/test/test_file_scan.py @@ -110,8 +110,11 @@ def test_should_stop_aborts_the_walk(self, tmp_path): def test_depth_limit_prunes_deep_trees(self, tmp_path): deep = tmp_path - for level in range(MAX_INDEX_DEPTH + 3): - deep = deep / f"level_{level}" + # One-letter names: the tree has to be deeper than the limit without + # being longer than the 260 characters a Windows path may have when + # long paths are switched off. + for _level in range(MAX_INDEX_DEPTH + 3): + deep = deep / "d" deep.mkdir() (deep / "buried.py").write_text("x = 1\n", encoding="utf-8") assert "buried.py" not in [path.rsplit("/", 1)[-1] for path in index_project_files(tmp_path)] diff --git a/test/test_logging_hygiene.py b/test/test_logging_hygiene.py index a1de86d..b07db25 100644 --- a/test/test_logging_hygiene.py +++ b/test/test_logging_hygiene.py @@ -11,6 +11,33 @@ from je_editor.utils.file.save.save_file import write_file DISTINCTIVE_LINE = "an unusually distinctive line the log must never carry" +# Run in a child process: the root logger's level and handler count before and +# after importing a module that used to call ``logging.basicConfig``. +ROOT_LOGGER_PROBE = textwrap.dedent( + """ + import logging + before = (logging.root.level, len(logging.root.handlers)) + import je_editor.pyside_ui.git_ui.git_client.git_branch_tree_widget # noqa: F401 + after = (logging.root.level, len(logging.root.handlers)) + print(before, after) + """ +) + + +@pytest.fixture(scope="module") +def probe_result(): + """The root logger's (level, handler count) before and after the import.""" + # This interpreter running a literal defined above; no shell, no input + # from anywhere outside this file. + finished = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + [sys.executable, "-c", ROOT_LOGGER_PROBE], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=180, + ) + if finished.returncode != 0: + pytest.skip(f"probe could not import the module: {finished.stderr[-400:]}") + before, after = finished.stdout.strip().rsplit(") (", 1) + return f"{before})", f"({after}" class TestSavingDoesNotLogTheFile: @@ -53,30 +80,6 @@ class TestImportingDoesNotReconfigureLogging: handlers, which makes ``basicConfig`` a silent no-op. """ - PROBE = textwrap.dedent( - """ - import logging - before = (logging.root.level, len(logging.root.handlers)) - import je_editor.pyside_ui.git_ui.git_client.git_branch_tree_widget # noqa: F401 - after = (logging.root.level, len(logging.root.handlers)) - print(before, after) - """ - ) - - @pytest.fixture(scope="class") - def probe_result(self): - # This interpreter running a literal defined above; no shell, no input - # from anywhere outside this file. - finished = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 - [sys.executable, "-c", self.PROBE], - capture_output=True, text=True, encoding="utf-8", errors="replace", - timeout=180, - ) - if finished.returncode != 0: - pytest.skip(f"probe could not import the module: {finished.stderr[-400:]}") - before, after = finished.stdout.strip().rsplit(") (", 1) - return f"{before})", f"({after}" - def test_the_root_level_is_left_alone(self, probe_result): before, after = probe_result assert before.split(",")[0] == after.split(",")[0] From ce20d002c195bae827b1dd817be6745cc76d3586 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 02:44:11 +0800 Subject: [PATCH 06/14] Show ruff and language-server diagnostics through one model The editor and the Problems panel now hold their findings in the unified diagnostic model from je_editor.core, so the panel filters both kinds alike: by severity (Error, Warning, Information, Hint) and by source, in an order that does not depend on the order they were reported in. A language server's severity and source used to be dropped while reading publishDiagnostics, which left every server finding with a severity guessed from its rule code. Both are kept now, and Hint is no longer folded into Information. LintManager.set_diagnostics() still accepts the older shape ruff's parser produces, stamping it with the source and the editor's file. Jumping to a finding only opens a tab when the finding is in another file than the current one. This is roadmap M2's diagnostics half; Tree-sitter remains in PROGRESS #10. An intermittent Qt abort seen while other test processes were running is recorded as PROGRESS #21: six isolated full runs, three on each side of this change, all passed. --- PROGRESS.md | 11 +- README.md | 3 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 2 +- architecture_explore.md | 84 ++++----- docs/source/docs/Eng/code_quality.rst | 13 +- docs/source/docs/Zh/code_quality.rst | 11 +- docs/updates/2026-10.md | 19 ++ docs/updates/README.md | 3 +- je_editor/code_scan/ruff_lint.py | 3 + .../core/diagnostics/legacy_diagnostics.py | 23 +++ je_editor/core/diagnostics/lsp_diagnostics.py | 82 +++++++++ je_editor/pyside_ui/code/lint/lint_manager.py | 37 +++- je_editor/pyside_ui/code/lsp/lsp_client.py | 16 ++ .../pyside_ui/code/minimap/minimap_widget.py | 3 +- .../code_edit_plaintext.py | 23 ++- .../problems_panel/problems_panel_widget.py | 173 +++++++++++++----- je_editor/utils/lsp/lsp_protocol.py | 10 + je_editor/utils/multi_language/english.py | 7 + je_editor/utils/multi_language/japanese.py | 7 + .../multi_language/simplified_chinese.py | 7 + .../multi_language/traditional_chinese.py | 7 + test/test_core_diagnostics.py | 23 ++- test/test_lint_manager.py | 29 ++- test/test_lsp_diagnostics.py | 72 ++++++++ test/test_problems_panel.py | 140 +++++++++++++- 27 files changed, 683 insertions(+), 129 deletions(-) create mode 100644 je_editor/core/diagnostics/lsp_diagnostics.py diff --git a/PROGRESS.md b/PROGRESS.md index 8c9e33c..64d6042 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -11,6 +11,11 @@ `# 初始化並記錄日誌` 被當成「註解掉的程式碼」。這是本專案雙語註解的正常寫法,不該刪。 要清掉這一項得在 SonarCloud 把 issue 轉成 False Positive(用 API 改狀態需要 Administer Issues 權限)。 +- **#21** 〔未確認〕整套測試在機器忙碌時偶爾被 Qt 中止(`Fatal Python error: Aborted`,結束代碼 3)。 + 2026-10-08 看到兩次,都是主工作樹裡同時還有別的 pytest 行程在跑的時候;其中一次留有紀錄,停在 + `test_toolbar_actions.py::TestTheBranchScan::test_a_subdirectory_still_finds_the_repository` 的 setup, + pytest-qt 的 `_process_events` 裡。在獨立的工作樹各跑三次(`132246e` 與診斷那次修改)六次都通過, + 所以不是那次修改造成的。還沒用 `pytest -s` 抓到 Qt 的訊息,不知道是哪個物件。 ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) @@ -21,9 +26,9 @@ M0(`je_editor/core/` 服務層)已完成,見 U-20261008-01。以下每個 變動的 ID。語系那一項大部分已經有了:四份字典各 438 個鍵,鍵與佔位符的 parity、空白值、退回英文都 由 `test/test_languages.py` 在 CI 守著;還沒做的是「語系載入改成資料驅動」。〔決定〕UI 的版面方向 (活動列、編輯區、側邊面板、底部面板)要先定,才能動視窗層。 -- **#10** M2(Tree-sitter、統一診斷)。問題面板、底線、縮圖改用 `core/diagnostics`(現在靠 - `legacy_diagnostics.py` 互轉);`utils/lsp/lsp_protocol.diagnostic_entries` 沒有帶出伺服器給的 - 嚴重度,要補上;`LanguageService` 的「發問、等回覆」呼叫形式在這裡定。 +- **#10** M2 剩下 Tree-sitter 那一半(診斷那一半已完成,見 U-20261008-04):不依賴 Qt 的解析服務、 + 以查詢檔決定語法分類與結構區塊、既有的高亮器改成轉接器;`LanguageService` 的「發問、等回覆」 + 呼叫形式也在這裡定。 - **#11** M3(工作區與多根專案)。`EditorMain` 持有 `EditorServices`,`working_dir` 改由 `Workspace` 提供;LSP 連線以「伺服器 + 根目錄」為鍵;搜尋、索引、TODO、Git、診斷改成認得工作區。 - **#12** M4(除錯器改走 DAP)。實作 `DebugSession`;堆疊、變數、求值的非同步查詢形式在這裡定; diff --git a/README.md b/README.md index 0070725..011e7d3 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,8 @@ same picker back into command mode. `ruff` runs on the **buffer** rather than the file on disk, on a worker thread once typing pauses, so unsaved edits are covered and a stale result from a superseded run is discarded. Findings are underlined in place and listed in the Problems panel, where **Apply Fixes** applies everything ruff -can fix by itself. +can fix by itself. A language server's diagnostics land in the same list, and the panel filters both +by severity (Error, Warning, Information, Hint) and by source.

Problems panel listing ruff diagnostics diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index fd67aba..d30e04e 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -103,7 +103,7 @@ JEDITOR 是原始 JEditor 项目的完全重写版本,从零开始重新打造 ### 随打随查的静态分析 -`ruff` 检查的是 **缓冲区** 而非磁盘上的文件,在停止输入后于工作线程运行,因此未保存的编辑也会被覆盖,而被取代的过时结果会被丢弃。检查结果会就地以下划线标示,并列在问题(Problems)面板中,其中的 **Apply Fixes** 会套用 ruff 自己能修的全部内容。 +`ruff` 检查的是 **缓冲区** 而非磁盘上的文件,在停止输入后于工作线程运行,因此未保存的编辑也会被覆盖,而被取代的过时结果会被丢弃。检查结果会就地以下划线标示,并列在问题(Problems)面板中,其中的 **Apply Fixes** 会套用 ruff 自己能修的全部内容。语言服务器的诊断也列在同一份清单里,面板可以按严重级别(错误、警告、信息、提示)与来源筛选两者。

列出 ruff 诊断的问题面板 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 7f3c298..ad5c7e3 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -103,7 +103,7 @@ JEDITOR 是原始 JEditor 專案的完全重寫版本,從零開始重新打造 ### 隨打隨查的靜態分析 -`ruff` 檢查的是 **緩衝區** 而非磁碟上的檔案,在停止輸入後於工作執行緒執行,因此未儲存的編輯也會被涵蓋,而被取代的過時結果會被丟棄。檢查結果會就地以底線標示,並列在問題(Problems)面板中,其中的 **Apply Fixes** 會套用 ruff 自己能修的全部內容。 +`ruff` 檢查的是 **緩衝區** 而非磁碟上的檔案,在停止輸入後於工作執行緒執行,因此未儲存的編輯也會被涵蓋,而被取代的過時結果會被丟棄。檢查結果會就地以底線標示,並列在問題(Problems)面板中,其中的 **Apply Fixes** 會套用 ruff 自己能修的全部內容。語言伺服器的診斷也列在同一份清單裡,面板可以依嚴重度(錯誤、警告、資訊、提示)與來源篩選兩者。

列出 ruff 診斷的問題面板 diff --git a/architecture.md b/architecture.md index 52f5833..345e17d 100644 --- a/architecture.md +++ b/architecture.md @@ -21,7 +21,7 @@ window, and plugins extend it through a small registry API. | `je_editor/pyside_ui/main_ui/` | Main window `EditorMain` (`main_editor.py`), editor tab `EditorWidget` (`editor/`), menus (`menu/`), toolbar, panels, command palette, console, IPython, chat panel (`ai_widget/`), plugin browser, settings persistence (`save_settings/`) | | `je_editor/pyside_ui/code/` | `CodeEditor` (`plaintext_code_edit/`) plus its managers (folding, bookmarks, lint, LSP, diff/blame, snippets, multi-cursor), highlighters (`syntax/`), process runners (`code_process/`, `shell_process/`, `base_process_manager.py`) | | `je_editor/pyside_ui/dialog/`, `git_ui/`, `browser/` | Search/replace, shortcut, snippet and file dialogs; Git panel, commit graph, diff viewers; embedded QtWebEngine browser | -| `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window does not consume it yet (roadmap M0, `docs/roadmap/2026-editor-next.md`) | +| `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window consumes the diagnostics part so far: the editor's `LintManager` and the Problems panel hold their findings in the unified model (roadmap `docs/roadmap/2026-editor-next.md`) | | `je_editor/utils/` | Pure logic with no widgets (only `multi_language/locale_match.py` imports Qt): text operations, encodings, sessions, diffs, symbols, LSP protocol, shortcut registry, theme colors, translations (`multi_language/`), logging, stdout/stderr redirect | | `je_editor/code_scan/` | ruff runner and watchdog file monitor, run on worker threads | | `je_editor/git_client/` | Git access: `GitService` (GitPython) and `GitCLI` (subprocess), blame, HEAD baseline, hunk staging | diff --git a/architecture_explore.md b/architecture_explore.md index 8498f58..6e1b324 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 303 個 `.py`(183 個實作模組 + 120 個 `__init__.py`),共 32,957 行。 +> 涵蓋範圍:`je_editor/` 全部 304 個 `.py`(184 個實作模組 + 120 個 `__init__.py`),共 33,234 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -16,22 +16,22 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | | 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`frontengine` | -| 測試 | pytest + pytest-qt,106 個測試檔、約 16,400 行 | +| 測試 | pytest + pytest-qt,106 個測試檔、約 16,700 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 98 | 20,418 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 59 | 8,752 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `core/` | 13 | 2,176 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | +| `pyside_ui/` | 98 | 20,549 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 59 | 8,790 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `core/` | 14 | 2,281 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | -| `code_scan/` | 4 | 365 | ruff 執行與 watchdog 檔案監看 | +| `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 32,957 行。) +(行數含各層 `__init__.py`,合計 33,234 行。) --- @@ -146,7 +146,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(59 模組 / 8,752 行) +### 5.2 `utils/` — 純邏輯層(59 模組 / 8,790 行) #### 文字與行操作 @@ -199,11 +199,11 @@ start_editor(debug_mode) je_editor/start_editor.py | `code_folding/fold_regions.py` | 123 | 以縮排計算可折疊區塊(掃描上限 50000 行) | | `code_folding/brace_regions.py` | 161 | 以大括號配對計算折疊區塊,會跳過字串與註解內容 | | `syntax/language_rules.py` | 136 | 各語言的關鍵字 / 註解 / 字串規則表 | -| `lsp/lsp_protocol.py` | 454 | LSP 訊息編解碼:`MessageReader`、request / notification、completion / hover / definition / references / symbols / rename / diagnostics 的回應解析 | +| `lsp/lsp_protocol.py` | 476 | LSP 訊息編解碼:`MessageReader`、request / notification、completion / hover / definition / references / symbols / rename / diagnostics 的回應解析 | | `lsp/language_servers.py` | 95 | 副檔名 → 伺服器指令對照,並併入使用者設定 | -| `test_runner/pytest_output.py` | 291 | 解析 pytest 輸出:每筆結果、失敗位置、traceback、覆蓋率、結尾統計 | +| `test_runner/pytest_output.py` | 302 | 解析 pytest 輸出:每筆結果、失敗位置、traceback、覆蓋率、結尾統計 | | `debugger/pdb_commands.py` | 105 | 組出 pdb 指令(設 / 清中斷點、step into/over/out) | -| `format_code/yapf_format.py` | 46 | 以 yapf(google style)格式化原始碼 | +| `format_code/yapf_format.py` | 48 | 以 yapf(google style)格式化原始碼 | #### 編輯器行為 @@ -218,16 +218,16 @@ start_editor(debug_mode) je_editor/start_editor.py | `minimap/minimap_layout.py` | 112 | 縮圖座標換算:取樣間隔、行↔像素、長條寬度、可視範圍方框 | | `shortcuts/shortcut_registry.py` | 329 | 快捷鍵正規化、`ShortcutRegistry` 衝突偵測、預設表 `WINDOW_SHORTCUTS` / `EDITOR_SHORTCUTS`、使用者覆寫清理 | | `status/status_text.py` | 71 | 狀態列文字:語言名稱、編碼、行尾、游標位置 | -| `theme/theme_colors.py` | 127 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | +| `theme/theme_colors.py` | 135 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | #### 多語系 | 模組 | 行 | 功用 | | --- | ---: | --- | -| `multi_language/english.py` | 492 | 英文字典(其他語言以此為鍵值基準) | -| `multi_language/traditional_chinese.py` | 482 | 繁體中文字典 | -| `multi_language/simplified_chinese.py` | 482 | 簡體中文字典 | -| `multi_language/japanese.py` | 486 | 日文字典 | +| `multi_language/english.py` | 503 | 英文字典(其他語言以此為鍵值基準) | +| `multi_language/traditional_chinese.py` | 493 | 繁體中文字典 | +| `multi_language/simplified_chinese.py` | 493 | 簡體中文字典 | +| `multi_language/japanese.py` | 497 | 日文字典 | | `multi_language/multi_language_wrapper.py` | 150 | `LanguageWrapper` 單例:註冊語言、切換、啟動語言決策 | | `multi_language/locale_match.py` | 116 | 系統語系 → 編輯器語言(含中文繁簡判定) | | `multi_language/retranslate_text.py` | 154 | 反查「這段文字是哪個鍵翻出來的」,用於換語言時就地換字 | @@ -248,7 +248,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `ruff_lint.py` | 171 | 找 ruff 執行檔、組指令、對「緩衝區內容」或整個專案跑 ruff(20 秒逾時)、套用 `--fix` | +| `ruff_lint.py` | 174 | 找 ruff 執行檔、組指令、對「緩衝區內容」或整個專案跑 ruff(20 秒逾時)、套用 `--fix` | | `ruff_thread.py` | 60 | 以 `threading.Thread` 執行 ruff 子程序並把輸出放進佇列 | | `watchdog_implement.py` | 56 | watchdog 事件處理:Python 檔被改動就觸發一次 ruff | | `watchdog_thread.py` | 78 | 跑 watchdog observer 的執行緒,含停止與輸出處理 | @@ -277,27 +277,27 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `plaintext_code_edit/code_edit_plaintext.py` | **3,222** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | +| `plaintext_code_edit/code_edit_plaintext.py` | **3,247** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | | `multi_cursor/multi_cursor_manager.py` | 530 | 額外游標的維護與批次套用(插入 / 刪除 / 移動 / 擴選 / 欄選取 / 下一個相同字) | | `snippets/snippet_manager.py` | 280 | 片段展開、定位點跳轉、複本同步;使用者片段存於 `.jeditor/snippets.json` | -| `lsp/lsp_client.py` | 438 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | +| `lsp/lsp_client.py` | 454 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | | `lsp/lsp_session.py` | 242 | `LspSession`(一個伺服器程序)與 `LspSessionRegistry`(同語言分頁共用、引用計數、關閉時 shutdown) | -| `code_process/code_exec.py` | 237 | `ExecManager`:執行使用者程式(含插件 run_config),輸出導回面板 | +| `code_process/code_exec.py` | 236 | `ExecManager`:執行使用者程式(含插件 run_config),輸出導回面板 | | `shell_process/shell_exec.py` | 132 | `ShellManager`:執行 shell 指令 | -| `base_process_manager.py` | 218 | 上兩者的共用基底:讀取執行緒、輸出佇列、pull timer、結束清理 | +| `base_process_manager.py` | 217 | 上兩者的共用基底:讀取執行緒、輸出佇列、pull timer、結束清理 | | `running_process_manager.py` | 62 | `RunInstanceManager` 單例:追蹤並統一關閉所有執行實例 | | `git_diff/diff_marker_manager.py` | 224 | `BaselineLoader(QThread)` 背景讀 HEAD 內容 + `DiffMarkerManager` 維護逐行差異狀態與 hunk 查詢 | | `git_diff/blame_manager.py` | 149 | `BlameLoader(QThread)` 背景取 blame + `BlameManager` 開關與快取 | -| `lint/lint_manager.py` | 170 | `LintWorker(QThread)` 背景跑 ruff + `LintManager` 保存診斷、供行號查詢 | +| `lint/lint_manager.py` | 189 | `LintWorker(QThread)` 背景跑 ruff + `LintManager` 以統一模型(`core/diagnostics`)保存這個編輯器的診斷、供行號查詢;`set_diagnostics()` 兩種形式都收,舊形式在這裡補上來源與檔案 URI | | `folding/folding_manager.py` | 190 | 折疊狀態:計算區塊、藏 / 顯示行、重新布局、換檔重算 | | `bookmark/bookmark_manager.py` | 134 | 書籤切換、跳轉、清空(Qt 整合層) | -| `breakpoint/breakpoint_manager.py` | 87 | 中斷點行號追蹤,並轉成 pdb 指令 | +| `breakpoint/breakpoint_manager.py` | 88 | 中斷點行號追蹤,並轉成 pdb 指令 | | `selection/smart_selection_manager.py` | 87 | 智慧選取的擴大 / 縮回堆疊 | -| `minimap/minimap_widget.py` | 184 | 右側縮圖:長條繪製、搜尋命中標記、可視範圍方框、點擊捲動 | +| `minimap/minimap_widget.py` | 185 | 右側縮圖:長條繪製、搜尋命中標記、可視範圍方框、點擊捲動 | | `split_view/split_editor_view.py` | 55 | 同一份 `QTextDocument` 的第二個檢視 | -| `syntax/python_syntax.py` | 93 | `PythonHighlighter`:Python 專用高亮(含插件規則) | +| `syntax/python_syntax.py` | 107 | `PythonHighlighter`:Python 專用高亮(含插件規則) | | `syntax/generic_syntax.py` | 134 | `GenericHighlighter`:依 `language_rules` 的通用高亮,處理跨行區塊註解 | -| `syntax/syntax_setting.py` | 95 | 高亮規則 / 關鍵字 / 插件擴充三個字典 | +| `syntax/syntax_setting.py` | 99 | 高亮規則 / 關鍵字 / 插件擴充三個字典 | | `code_format/pep8_format.py` | 124 | `PEP8FormatChecker`:pycodestyle Checker 子類,把檢查結果導到格式檢查面板 | | `textedit_code_result/code_record.py` | 89 | `CodeRecord(QTextEdit)`:輸出區,支援搜尋 | | `auto_save/auto_save_thread.py` | 120 | `CodeEditSaveThread`:定時存檔;`_TextFetcher` 確保在主執行緒取文字;存檔失敗只記錄,執行緒繼續 | @@ -310,7 +310,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `main_editor.py` | 609 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | +| `main_editor.py` | 614 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | | `editor/editor_widget.py` | 571 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | | `editor/editor_widget_dock.py` | 85 | `FullEditorWidget`:可停駐的單檔編輯器;關閉時只在有修改時,以檔案原本的編碼與行尾存回 | | `editor/process_input.py` | 104 | 對子程序(program / shell / debugger)送入標準輸入的視窗 | @@ -331,13 +331,13 @@ start_editor(debug_mode) je_editor/start_editor.py | `run_menu/under_run_menu/build_shell_menu.py` | 98 | 執行 shell 指令 | | `run_menu/under_run_menu/build_debug_menu.py` | 134 | 啟動 pdb、送出中斷點、除錯輸入視窗 | | `run_menu/under_run_menu/utils.py` | 41 | 「請先關掉正在執行的程式」訊息框 | -| `text_menu/build_text_menu.py` | 420 | 文字選單:統計、去行尾空白、縮排轉換、自動換行、縮排大小、字型;大量動作轉呼叫 `CodeEditor` 的方法 | +| `text_menu/build_text_menu.py` | 421 | 文字選單:統計、去行尾空白、縮排轉換、自動換行、縮排大小、字型;大量動作轉呼叫 `CodeEditor` 的方法 | | `check_style_menu/build_check_style_menu.py` | 127 | yapf 格式化、JSON 排版、PEP8 檢查、存檔時自動格式化開關 | | `tab_menu/build_tab_menu.py` | 187 | 分頁選單:新增編輯 / 瀏覽器 / 終端機分頁、片段編輯器、縮圖與分割檢視切換 | | `tab_menu/build_tab_git_menu.py` | 200 | Git 分頁:HEAD diff、staged diff、Git 用戶端、提交圖、diff 比對 | | `tab_menu/build_tab_tools_menu.py` | 155 | 工具分頁:IPython、變數檢視器、FrontEngine、AI 對話、TODO 面板、大綱面板 | -| `dock_menu/build_dock_menu.py` | 238 | 各種 dock 視窗的建立(含 FrontEngine 元件) | -| `style_menu/build_style_menu.py` | 170 | qt-material 樣式切換、縮排參考線 / 尾端空白開關、開啟快捷鍵設定 | +| `dock_menu/build_dock_menu.py` | 242 | 各種 dock 視窗的建立(含 FrontEngine 元件) | +| `style_menu/build_style_menu.py` | 179 | qt-material 樣式切換、縮排參考線 / 尾端空白開關、開啟快捷鍵設定 | | `language_menu/build_language_server.py` | 96 | 介面語言切換(含插件註冊的語言) | | `python_env_menu/build_venv_menu.py` | 243 | 建立 venv、pip 安裝 / 升級、選擇直譯器 | | `plugin_menu/build_plugin_menu.py` | 162 | 依已註冊插件建立「關於 / 執行」子選單,並開啟插件瀏覽器 | @@ -348,7 +348,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `problems_panel/problems_panel_widget.py` | 320 | 問題面板:列出診斷、依嚴重度篩選、跳到該行、整專案檢查、套用可自動修正項 | +| `problems_panel/problems_panel_widget.py` | 407 | 問題面板:診斷放在自己的 `DiagnosticStore`,依嚴重度(錯誤 / 警告 / 資訊 / 提示)與來源篩選且順序固定、跳到該行、整專案檢查、套用可自動修正項 | | `problems_panel/project_lint_worker.py` | 46 | `ProjectLintWorker(QThread)`:背景對整個目錄跑 ruff | | `todo_panel/todo_panel_widget.py` | 262 | TODO 面板:背景掃描(`TodoScanThread`)、依標籤篩選、雙擊開檔跳行 | | `test_panel/test_panel_widget.py` | 413 | 測試面板:組 pytest 指令(可含覆蓋率)、`PytestRunThread` 背景執行(600 秒逾時)、結果表、traceback、只跑選取 / 只跑失敗 | @@ -361,7 +361,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `console_widget/qprocess_adapter.py` | 120 | `QProcess` 包裝:啟動互動 shell、Windows 切 UTF-8 code page、送指令、停止 | | `ipython_widget/ipython_console.py` | 78 | qtconsole 的 IPython 分頁 | | `ai_widget/chat_ui.py` | 149 | AI 對話 UI:載入設定、送出問題、輪詢回覆 | -| `ai_widget/langchain_interface.py` | 82 | LangChain + OpenAI 的呼叫封裝 | +| `ai_widget/langchain_interface.py` | 84 | LangChain + OpenAI 的呼叫封裝 | | `ai_widget/ask_thread.py` | 36 | 在背景執行緒呼叫模型,避免卡 UI | | `ai_widget/ai_config.py` | 34 | 模型設定與訊息佇列 | | `plugin_browser/plugin_browser_widget.py` | 373 | 插件瀏覽器:列出遠端 repo 的插件、看中繼資料、下載到 `jeditor_plugins/` | @@ -372,7 +372,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `user_setting_file.py` | 66 | `user_setting_dict` 的定義與 `.jeditor/user_setting.json` 讀寫 | -| `user_color_setting_file.py` | 116 | 顏色設定讀寫、RGB → `QColor` 換算、依樣式套用深 / 淺色組 | +| `user_color_setting_file.py` | 96 | 顏色設定讀寫、RGB → `QColor` 換算、依樣式套用深 / 淺色組 | | `setting_utils.py` | 40 | 寫入前先備份(`.bak`)的 JSON 寫檔工具 | | `shortcut_setting.py` | 76 | 取得指令目前生效的按鍵、把 `QAction` 綁上去、設定改動後重新套用 | @@ -394,7 +394,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `git_client/git_client_gui.py` | 1,072 | `GitGui`:完整 Git 面板——開 repo、分支清單與切換、變更清單(未暫存 / 已暫存)、各種 diff 呈現(新增 / 刪除 / 改名 / 已暫存 / 修改)、暫存與提交、stash、衝突解決、clone、push、未推送數量;`_GitWorker(QObject)` 背景執行;`GitDiffHighlighter` 為 diff 上色 | +| `git_client/git_client_gui.py` | 1,070 | `GitGui`:完整 Git 面板——開 repo、分支清單與切換、變更清單(未暫存 / 已暫存)、各種 diff 呈現(新增 / 刪除 / 改名 / 已暫存 / 修改)、暫存與提交、stash、衝突解決、clone、push、未推送數量;`_GitWorker(QObject)` 背景執行;`GitDiffHighlighter` 為 diff 上色 | | `git_client/git_branch_tree_widget.py` | 151 | `GitTreeViewGUI`:提交歷史圖檢視(走 `GitCLI`),含檔案監看自動刷新 | | `git_client/graph_view.py` | 219 | `CommitGraphView(QGraphicsView)`:commit 圖繪製(lane 顏色、縮放、聚焦某列) | | `git_client/commit_table.py` | 65 | commit 清單表格 | @@ -413,15 +413,15 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | -### 5.11 `core/` — 核心服務層(13 模組 / 2,176 行) +### 5.11 `core/` — 核心服務層(14 模組 / 2,281 行) 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 -逐個里程碑改接過來。目前 `pyside_ui/` 還沒有任何模組匯入 `core/`。整層不匯入 Qt 也不匯入 `pyside_ui/`; +逐個里程碑改接過來。第一批使用者是診斷:編輯器的 `LintManager` 與問題面板都以統一模型保存診斷。整層不匯入 Qt 也不匯入 `pyside_ui/`; 介面一律用 `typing.Protocol`,之後由 `QObject` 持有資源的轉接器才不會遇到中繼類別衝突。 | 模組 | 行 | 功用 | | --- | ---: | --- | -| `__init__.py` | 63 | 核心層的公開 API(`__all__`) | +| `__init__.py` | 58 | 核心層的公開 API(`__all__`) | | `services/editor_services.py` | 77 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | | `events/event_hook.py` | 99 | `EventHook`:不靠 Qt 的訂閱與通知;在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄、不擋其他人 | | `registry/named_registry.py` | 116 | `NamedRegistry[T]`:名稱對應實作的登記表,AI 供應者、除錯轉接器、遠端傳輸、工作執行器共用 | @@ -429,7 +429,8 @@ start_editor(debug_mode) je_editor/start_editor.py | `workspace/workspace_model.py` | 265 | `ProjectRoot`(以 URI 指認,可以不在本機;`resolve()` 擋住跑出根目錄的路徑)與 `Workspace`(零到多個根目錄、`root_for()` / `root_for_uri()` 取最深的那一個、`relative_path()`) | | `document/document_model.py` | 224 | `Document` 協定、記憶體實作 `TextDocument`、以 URI 為鍵的 `DocumentStore`(`opened` / `changed` / `closed` 事件) | | `diagnostics/diagnostic_model.py` | 325 | 統一的診斷模型:`Severity`(數值同 LSP)、`Position` / `TextRange`(1 起算)、`RelatedInformation`、`TextEdit` / `QuickFix`、`Diagnostic`;`DiagnosticStore` 依「來源 × 資源」整組取代,`select()` 依嚴重度 / 來源 / 資源篩選且順序固定 | -| `diagnostics/legacy_diagnostics.py` | 80 | 統一模型與 `utils/lint/ruff_diagnostics.Diagnostic` 之間的雙向轉換,讓底線、縮圖、問題面板可以分批改用新模型 | +| `diagnostics/legacy_diagnostics.py` | 103 | 統一模型與 `utils/lint/ruff_diagnostics.Diagnostic`(ruff 解析器的輸出)之間的雙向轉換;`unify()` 把混著兩種形式的清單整理成統一模型,是視窗層接收診斷的入口 | +| `diagnostics/lsp_diagnostics.py` | 82 | 把 `lsp_protocol.diagnostic_entries` 的字典轉成統一模型,保留伺服器給的嚴重度(含 Hint)與來源;伺服器沒給嚴重度時當成錯誤 | | `language/language_service.py` | 204 | `LanguageCapability`、`LanguageService` 協定,以及 `LanguageServiceRegistry`:把 `DocumentStore` 的開啟 / 變更 / 關閉轉給處理該文件的服務,晚登記的服務會補收已開文件的「開啟」 | | `debug/debug_session.py` | 205 | `DebugSession` 協定與資料物件(`DebugLaunchRequest`、`Breakpoint`、`StackFrame`、`Variable`、`DebugState`、`StepKind`),名稱對應 DAP 的概念 | | `process/task_service.py` | 169 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream` | @@ -523,7 +524,7 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff ## 7. 測試與 CI -- `test/` 106 個測試檔、約 16,400 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 106 個測試檔、約 16,700 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -576,9 +577,8 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 讓「純邏輯層」的界線稍微模糊。 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 -7. **`core/` 還沒有消費者**:服務層已經可以獨立使用,但視窗層仍然各自持有狀態,所以現在同一件事有兩個模型 - (例如 `utils/lint` 的 `Diagnostic` 與 `core/diagnostics` 的 `Diagnostic`,靠 `legacy_diagnostics.py` 互轉)。 - 這是遷移期間的狀態,藍圖的 M1 之後逐步收斂。 +7. **`core/` 只接上了診斷**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。工作區、文件、語言服務、除錯、工作執行、遠端與 AI 還沒有接上, + 視窗層仍然各自持有這些狀態。 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, 得等可嵌入元件那個里程碑處理頂層 `__init__`。 diff --git a/docs/source/docs/Eng/code_quality.rst b/docs/source/docs/Eng/code_quality.rst index 242f136..6f4a8cb 100644 --- a/docs/source/docs/Eng/code_quality.rst +++ b/docs/source/docs/Eng/code_quality.rst @@ -50,7 +50,18 @@ Problems Panel Every diagnostic — from ruff for Python, and from the language server for other languages — is underlined in the editor and listed in the Problems dock panel with its -rule, message and line. Double-click a row to jump to it. +rule, message, line, file, severity and source. Double-click a row to jump to it. + +Both kinds of finding go through one model, so the two filters treat them alike: + +- **Severity**: All severities, Error, Warning, Information or Hint. A language server's + severity is shown as the server gave it; ruff's is worked out from the rule code. +- **Source**: All sources, or one of the tools that currently has findings, such as + ``ruff`` or the language server's name. + +The list always comes out in the same order: by file, then position, then severity. +**Whole project** checks every file with ruff on a worker thread, and **Apply Fixes** +applies everything ruff can fix by itself. Test Panel ----------- diff --git a/docs/source/docs/Zh/code_quality.rst b/docs/source/docs/Zh/code_quality.rst index 5cccfc3..2ef1a4e 100644 --- a/docs/source/docs/Zh/code_quality.rst +++ b/docs/source/docs/Zh/code_quality.rst @@ -48,7 +48,16 @@ Ruff 靜態分析 --------- 所有診斷——Python 來自 ruff,其他語言來自語言伺服器——都會在編輯器中以底線標示,並列在 -問題(Problems)停靠面板中,包含規則、訊息與行號。雙擊該列即可跳轉。 +問題(Problems)停靠面板中,包含規則、訊息、行號、檔案、嚴重度與來源。雙擊該列即可跳轉。 + +兩種診斷走同一個模型,所以兩個篩選器對它們一視同仁: + +- **嚴重度**:所有嚴重度、錯誤、警告、資訊或提示。語言伺服器的嚴重度照伺服器給的顯示; + ruff 的嚴重度由規則代碼推出。 +- **來源**:所有來源,或目前有診斷的其中一個工具,例如 ``ruff`` 或語言伺服器的名稱。 + +清單的順序固定:先依檔案,再依位置,再依嚴重度。**整個專案** 會在工作執行緒以 ruff 檢查 +每一個檔案,**套用修正** 會套用 ruff 自己能修的全部內容。 測試面板 --------- diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 68ca851..7b4d0c8 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -144,3 +144,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **結果**:ruff 0.16.10 與 0.15.8 跑 `ruff check` 都是 `All checks passed!`;改到的三個測試檔 43 個測試通過,其中長路徑那一支在這台 `LongPathsEnabled = 0` 的機器上現在通過。 - **檔案**:`pyproject.toml`、`dev.toml`、`test/test_dev_toml_parity.py`、`test/test_file_scan.py`、`test/test_logging_hygiene.py`、`architecture.md`、`architecture_explore.md`、`PROGRESS.md`(刪 #18、#19、#20)。 - **待辦**:無。`PROGRESS.md` #1(SonarCloud 誤判)要在網站上以有 Administer Issues 權限的帳號處理,這台機器沒有 `SonarCloudToken`,做不了。 + +## U-20261008-04 · 2026-10-08 · 藍圖 M2(診斷):ruff 與語言伺服器的診斷走同一個模型,問題面板依嚴重度與來源篩選 · #migration #roadmap #diagnostics + +- **做了什麼**:藍圖 M2 的診斷那一半(`PROGRESS.md` #10 的一部分)。ruff 與語言伺服器的診斷現在走同一個模型(`core/diagnostics` 的 `Diagnostic`),問題面板可以依四級嚴重度與來源篩選。這是視窗層第一次使用 `core/`。 + - **語言伺服器的嚴重度與來源原本會被丟掉**:`lsp_protocol._diagnostic_entry` 沒有把 `severity`、`source` 放進結果,所以伺服器回報的警告與提示都被當成「由代碼推出的嚴重度」(對 LSP 的代碼沒有意義)。現在兩個欄位都保留;沒給或不認得的嚴重度記為 0。 + - `core/diagnostics/lsp_diagnostics.py`(新):把那些字典轉成統一模型,Hint 不再併入 Information;伺服器沒給嚴重度時當成錯誤(LSP 規定由用戶端決定,當成錯誤才不會被篩選器藏起來),沒說來源時用伺服器的名稱(`LspClient.server_name`,新)。 + - `LintManager` 改以統一模型保存診斷。`set_diagnostics()` 兩種形式都收:ruff 解析器仍輸出舊形式,在這裡以 `legacy_diagnostics.unify()`(新)轉換並補上來源 `ruff` 與編輯器的檔案 URI。底線與縮圖改讀 `range`。 + - 問題面板:診斷放在自己的 `DiagnosticStore`;嚴重度選單是「全部 / 錯誤 / 警告 / 資訊 / 提示」(原本是未翻譯的 `error`、`warning`、`info`),新增來源選單(隨目前有診斷的來源變動,選著的來源消失時退回全部);清單多了嚴重度與來源兩欄,順序固定(檔案、位置、嚴重度)。原本四欄的位置沒有動。 + - 跳到診斷時,只有診斷的檔案不是目前分頁的檔案才會呼叫 `go_to_new_tab`:現在緩衝區的診斷也帶著檔案 URI,同一個檔案換一種寫法去開會被 `file_is_open_manager_dict` 當成另一個檔案。 +- **翻譯**:四份字典各加 7 個鍵(`problems_panel_all_sources`、四個 `problems_panel_severity_*`、`problems_panel_col_severity`、`problems_panel_col_source`)。 +- **測試**:`test_problems_panel.py`(四級嚴重度、來源篩選、兩個來源混在一起、順序與回報順序無關、兩個資料夾的同名檔、跳到別的檔案)、`test_lsp_diagnostics.py`(嚴重度與來源保留、轉成統一模型、編輯器收到 Hint 與來源)、`test_lint_manager.py`(舊形式仍被接受並補上來源與檔案)、`test_core_diagnostics.py`(`unify`)。`test_minimap.py` 沒有改,仍然以舊形式呼叫 `set_diagnostics()`,是舊路徑的回歸測試。 +- **結果**: + - 整套測試 2283 passed(修改前 2234)。在獨立的工作樹各跑三次:`132246e` 三次都是 2234 passed,這次修改三次都是 2283 passed。 + - 過程中主工作樹的整套測試有兩次被 Qt 中止(結束代碼 3),都發生在同時還有別的 pytest 行程在跑的時候;因為上面六次都通過,判斷不是這次修改造成的,記在 `PROGRESS.md` #21。 + - `ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束。 + - PyBreeze 的 `test_language_parity.py`(以這份 JEditor 執行,PyBreeze 在 `core/shared-contracts` 分支、工作樹有別人未提交的修改):25 passed、1 failed。失敗的是 `test_the_readmes_count_the_keys_there_are`,比的是 PyBreeze 自己的 README 與它自己的字典鍵數,跟 JEditor 的字典無關。 +- **文件**:`docs/source/docs/{Eng,Zh}/code_quality.rst` 的問題面板一節、三份 README 的「隨打隨查」、`architecture.md` §2、`architecture_explore.md`(§5.6、§5.7、§5.11、§8,各模組行數與套件規模改由程式碼重算,順便更正了原本就過時的幾個數字)。 +- **檔案**:`je_editor/core/diagnostics/lsp_diagnostics.py`(新)、`je_editor/core/diagnostics/legacy_diagnostics.py`、`je_editor/utils/lsp/lsp_protocol.py`、`je_editor/code_scan/ruff_lint.py`、`je_editor/pyside_ui/code/lint/lint_manager.py`、`je_editor/pyside_ui/code/lsp/lsp_client.py`、`je_editor/pyside_ui/code/minimap/minimap_widget.py`、`je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py`、`je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py`、四份語言字典、上述測試與文件、`PROGRESS.md`。 +- **待辦**:`PROGRESS.md` #10(Tree-sitter)、#21(偶發的 Qt 中止)。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 08b824b..ee394e9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-04 | 2026-10-08 | 藍圖 M2(診斷):ruff 與語言伺服器的診斷走同一個模型,問題面板依嚴重度與來源篩選 | #migration #roadmap #diagnostics | [2026-10](2026-10.md) | | U-20261008-03 | 2026-10-08 | PROGRESS #18、#19、#20:寫明 ruff 規則、長路徑測試、fixture 寫法 | #done #decision #tests | [2026-10](2026-10.md) | | U-20261008-02 | 2026-10-08 | M0 在 PR #270 的 CI 結果;Codacy 的動態匯入警告是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-01 | 2026-10-08 | 藍圖 M0:不依賴 Qt 的核心服務層 je_editor/core | #migration #roadmap #core | [2026-10](2026-10.md) | @@ -94,5 +95,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 11 | +| [2026-10.md](2026-10.md) | 2026-10 | 12 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/code_scan/ruff_lint.py b/je_editor/code_scan/ruff_lint.py index 5762d44..d5c248b 100644 --- a/je_editor/code_scan/ruff_lint.py +++ b/je_editor/code_scan/ruff_lint.py @@ -21,6 +21,9 @@ # ruff 執行檔名稱 / The ruff executable's name _RUFF_NAME = "ruff.exe" if sys.platform == "win32" else "ruff" +# 這裡產生的診斷在問題面板上顯示的來源名稱 +# The source name the diagnostics produced here carry in the problems panel +RUFF_SOURCE = "ruff" # 單次檢查的逾時(秒):ruff 很快,超過就是出了別的問題 # Timeout for one run: ruff is fast, so exceeding this means something else is wrong LINT_TIMEOUT_SECONDS = 20 diff --git a/je_editor/core/diagnostics/legacy_diagnostics.py b/je_editor/core/diagnostics/legacy_diagnostics.py index 05c425e..a25d9b5 100644 --- a/je_editor/core/diagnostics/legacy_diagnostics.py +++ b/je_editor/core/diagnostics/legacy_diagnostics.py @@ -10,6 +10,8 @@ """ from __future__ import annotations +from collections.abc import Iterable + from je_editor.core.diagnostics.diagnostic_model import Diagnostic, Severity, TextRange from je_editor.core.uri.resource_uri import to_path, to_uri from je_editor.utils.lint.ruff_diagnostics import ( @@ -56,6 +58,27 @@ def from_legacy(diagnostic: LegacyDiagnostic, source: str, uri: str = "") -> Dia ) +def unify(diagnostics: Iterable[Diagnostic | LegacyDiagnostic], source: str, + uri: str = "") -> list[Diagnostic]: + """ + 把一份可能混著兩種形式的清單整理成統一模型 + Bring a list that may mix both shapes into the unified model. + + 已經是統一模型的原樣保留;舊形式的才轉換,並補上它沒有記的來源與資源。 + Anything already unified is kept as it is. Only the older shape is + converted, gaining the source and the resource it never recorded. + + :param diagnostics: 兩種形式都可以的診斷 / diagnostics in either shape + :param source: 舊形式的診斷是誰報的 / who reported the older-shape ones + :param uri: 舊形式的診斷所在的資源 / the resource the older-shape ones are in + :return: 統一模型的診斷 / the diagnostics in the unified model + """ + return [ + item if isinstance(item, Diagnostic) else from_legacy(item, source, uri) + for item in diagnostics + ] + + def to_legacy(diagnostic: Diagnostic) -> LegacyDiagnostic: """ 把統一模型的診斷轉回舊形式 diff --git a/je_editor/core/diagnostics/lsp_diagnostics.py b/je_editor/core/diagnostics/lsp_diagnostics.py new file mode 100644 index 0000000..5deb2bc --- /dev/null +++ b/je_editor/core/diagnostics/lsp_diagnostics.py @@ -0,0 +1,82 @@ +""" +把語言伺服器回報的診斷轉成統一模型 +Convert what a language server reports into the unified model. + +``utils/lsp/lsp_protocol.diagnostic_entries`` 已經把 ``publishDiagnostics`` 讀成 +行列 1 起算的字典;這裡把那些字典變成 :class:`Diagnostic`,嚴重度與來源都保留。 +``utils/lsp/lsp_protocol.diagnostic_entries`` already reads ``publishDiagnostics`` +into dictionaries with 1-based lines and columns. This turns those dictionaries +into :class:`Diagnostic` objects, keeping the severity and the source. +""" +from __future__ import annotations + +from je_editor.core.diagnostics.diagnostic_model import Diagnostic, Severity, TextRange + +# LSP 規定沒給嚴重度時由用戶端決定;當成錯誤才不會被篩選器藏起來 +# LSP leaves a missing severity to the client; calling it an error keeps a filter +# from hiding it +DEFAULT_SEVERITY = Severity.ERROR + + +def _severity(raw: object) -> Severity: + """讀出嚴重度,不認得時用預設 / Read a severity, falling back when unrecognised.""" + if isinstance(raw, int) and not isinstance(raw, bool) and raw in tuple(Severity): + return Severity(raw) + return DEFAULT_SEVERITY + + +def from_lsp_entry(entry: object, uri: str = "", default_source: str = "") -> Diagnostic | None: + """ + 把一筆語言伺服器的診斷轉成統一模型 + Convert one language-server diagnostic into the unified model. + + :param entry: ``diagnostic_entries`` 給的一筆字典 / one dictionary from + ``diagnostic_entries`` + :param uri: 診斷所在的資源 / the resource the diagnostic is in + :param default_source: 伺服器沒有說明來源時使用的名稱,通常是伺服器的名稱 + the name to use when the server names no source, usually the server's own + :return: 診斷;資料不足(沒有訊息或行號)時為 ``None`` + the diagnostic, or ``None`` when the message or the line is missing + """ + if not isinstance(entry, dict): + return None + message = entry.get("message") + line = entry.get("line") + if not isinstance(message, str) or not message: + return None + if not isinstance(line, int) or line < 1: + return None + column = entry.get("column") + end_line = entry.get("end_line") + end_column = entry.get("end_column") + code = entry.get("code") + source = entry.get("source") + return Diagnostic( + message=message, + range=TextRange.from_lines( + line, + column if isinstance(column, int) else 1, + end_line if isinstance(end_line, int) else None, + end_column if isinstance(end_column, int) else None), + severity=_severity(entry.get("severity")), + source=source if isinstance(source, str) and source else default_source, + code=code if isinstance(code, str) else "", + uri=uri, + ) + + +def from_lsp_entries(entries: object, uri: str = "", default_source: str = "") -> list[Diagnostic]: + """ + 批次轉換語言伺服器的診斷 + Convert a batch of language-server diagnostics. + + :param entries: ``diagnostic_entries`` 的結果 / what ``diagnostic_entries`` returned + :param uri: 診斷所在的資源 / the resource the diagnostics are in + :param default_source: 伺服器沒有說明來源時使用的名稱 + the name to use when the server names no source + :return: 可用的診斷 / the usable diagnostics + """ + if not isinstance(entries, list): + return [] + converted = [from_lsp_entry(entry, uri, default_source) for entry in entries] + return [item for item in converted if item is not None] diff --git a/je_editor/pyside_ui/code/lint/lint_manager.py b/je_editor/pyside_ui/code/lint/lint_manager.py index 3425f53..cab5e36 100644 --- a/je_editor/pyside_ui/code/lint/lint_manager.py +++ b/je_editor/pyside_ui/code/lint/lint_manager.py @@ -12,8 +12,10 @@ from PySide6.QtCore import QObject, QThread, Signal -from je_editor.code_scan.ruff_lint import is_lintable, lint_text -from je_editor.utils.lint.ruff_diagnostics import Diagnostic, message_for_line +from je_editor.code_scan.ruff_lint import RUFF_SOURCE, is_lintable, lint_text +from je_editor.core.diagnostics.diagnostic_model import Diagnostic +from je_editor.core.diagnostics.legacy_diagnostics import unify +from je_editor.core.uri.resource_uri import to_uri class LintWorker(QThread): @@ -22,7 +24,7 @@ class LintWorker(QThread): Run ruff over a buffer's text off the UI thread. """ - linted = Signal(object) # list[Diagnostic] + linted = Signal(object) # ruff's findings, in the older diagnostic shape def __init__(self, text: str, file_path: str | Path, parent=None) -> None: """ @@ -78,7 +80,7 @@ def for_line(self, line: int) -> list[Diagnostic]: :param line: 1 起算的行號 / the 1-based line number :return: 該行的診斷 / the diagnostics on that line """ - return [item for item in self._diagnostics if item.line == line] + return [item for item in self._diagnostics if item.range.start.line == line] def message_for_line(self, line: int) -> str | None: """ @@ -88,7 +90,8 @@ def message_for_line(self, line: int) -> str | None: :param line: 1 起算的行號 / the 1-based line number :return: 說明文字,沒有診斷時為 ``None`` / the text, or ``None`` """ - return message_for_line(self._diagnostics, line) + on_line = [item.label for item in self.for_line(line)] + return "\n".join(on_line) if on_line else None def clear(self) -> bool: """ @@ -102,20 +105,36 @@ def clear(self) -> bool: self._diagnostics = [] return True - def set_diagnostics(self, diagnostics: list[Diagnostic]) -> bool: + def set_diagnostics(self, diagnostics: list, source: str = RUFF_SOURCE) -> bool: """ 套用一組診斷 Apply a set of diagnostics. - :param diagnostics: 新的診斷清單 / the diagnostics to apply + 診斷一律以統一模型保存。舊形式的診斷(ruff 的解析結果)在這裡轉換,並補上 + 來源與這個編輯器的檔案。 + The diagnostics are always kept in the unified model. Ones in the older + shape, which is what parsing ruff's output gives, are converted here and + gain the source and this editor's file. + + :param diagnostics: 新的診斷清單,兩種形式都可以 / the diagnostics to + apply, in either shape + :param source: 舊形式的診斷是誰報的 / who reported the older-shape ones :return: 內容是否改變(未改變時呼叫端可省下重繪) whether they differ from the previous set, so a repaint can be skipped """ - if diagnostics == self._diagnostics: + unified = unify(diagnostics, source, self._document_uri()) + if unified == self._diagnostics: return False - self._diagnostics = list(diagnostics) + self._diagnostics = unified return True + def _document_uri(self) -> str: + """這個編輯器的檔案 URI,還沒存檔時為空字串 / This editor's file as a URI, empty when unsaved.""" + current_file = getattr(self._code_edit, "current_file", None) + if not isinstance(current_file, (str, Path)) or not current_file: + return "" + return to_uri(current_file) + def request(self, file_path: str | Path | None) -> bool: """ 對目前緩衝區內容排一次檢查 diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index fc29407..27e5bf2 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -59,6 +59,8 @@ def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._session: LspSession | None = None self._file_path: str | None = None + # 目前接上的伺服器指令 / The command of the server attached right now + self._server_command: list[str] = [] self._version = 0 self._pending_completion_id: int | None = None self._pending_definition_id: int | None = None @@ -89,6 +91,18 @@ def running(self) -> bool: """伺服器是否正在執行 / Whether the server is running.""" return self._session is not None and self._session.running + @property + def server_name(self) -> str: + """ + 目前接上的伺服器名稱,沒接上時為空字串 + The name of the attached server, empty when none is attached. + + 伺服器自己沒有說明診斷來源時,問題面板用這個名稱當作來源。 + When a server names no source for a diagnostic, the problems panel shows + this as the source. + """ + return Path(self._server_command[0]).stem if self._server_command else "" + def start_for(self, file_path: str, servers: dict | None = None) -> bool: """ 接上負責這個檔案的語言伺服器 @@ -113,6 +127,7 @@ def start_for(self, file_path: str, servers: dict | None = None) -> bool: return False self._session = session self._file_path = file_path + self._server_command = list(command) session.register_document(file_uri(file_path), self) return True @@ -406,6 +421,7 @@ def stop(self) -> None: process is shut down once no editor is using that server any more. """ session, self._session = self._session, None + self._server_command = [] self._pending_completion_id = None self._pending_definition_id = None self._pending_hover_id = None diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py index 38f689b..461c020 100644 --- a/je_editor/pyside_ui/code/minimap/minimap_widget.py +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -92,7 +92,8 @@ def marker_lines(self) -> dict[str, list[int]]: :return: 標記種類對應行號(0 起算)/ marker kind -> 0-based line numbers """ editor = self._code_edit - diagnostics = sorted({item.line - 1 for item in editor.lint_manager.diagnostics()}) + diagnostics = sorted( + {item.range.start.line - 1 for item in editor.lint_manager.diagnostics()}) changes = sorted(editor.diff_marker_manager.statuses()) return { "diagnostic": diagnostics, diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index 5fa97dd..cbb74a2 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -14,6 +14,9 @@ QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog, QMenu ) +from je_editor.core.diagnostics.diagnostic_model import Diagnostic +from je_editor.core.diagnostics.lsp_diagnostics import from_lsp_entries +from je_editor.core.uri.resource_uri import to_uri from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager from je_editor.pyside_ui.code.breakpoint.breakpoint_manager import BreakpointManager from je_editor.pyside_ui.code.folding.folding_manager import FoldingManager @@ -42,7 +45,6 @@ ) from je_editor.utils.file_diff.line_status import apply_hunk from je_editor.utils.file_diff.unified import unified_diff_text -from je_editor.utils.lint.ruff_diagnostics import diagnostics_from_entries from je_editor.utils.macro.keystroke_macro import KeystrokeMacro from je_editor.utils.selection.surround import SURROUND_PAIRS, surround from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind, shortcut_for @@ -1056,13 +1058,17 @@ def apply_server_diagnostics(self, entries: list) -> bool: Show the diagnostics a language server reported. 與 ruff 的診斷走同一條顯示路徑,因此非 Python 檔也有波浪底線與問題面板。 + 伺服器給的嚴重度與來源都保留;伺服器沒說來源時用它自己的名稱。 These take the same path as ruff's, so a non-Python file gets the same - underlines and the same problems panel. + underlines and the same problems panel. The server's severity and source + are kept, and its own name stands in when it names no source. :param entries: 伺服器回報的診斷 / the diagnostics the server reported :return: 顯示內容有變時為 ``True`` / ``True`` when the display changed """ - if not self.lint_manager.set_diagnostics(diagnostics_from_entries(entries)): + uri = to_uri(self.current_file) if self.current_file else "" + diagnostics = from_lsp_entries(entries, uri, self.lsp_client.server_name) + if not self.lint_manager.set_diagnostics(diagnostics): return False self.refresh_lint_display() return True @@ -1093,18 +1099,19 @@ def _append_lint_selections(self, selections: list) -> None: @staticmethod def _diagnostic_cursor( - document: QTextDocument, diagnostic) -> QTextCursor | None: + document: QTextDocument, diagnostic: Diagnostic) -> QTextCursor | None: """ 取得診斷範圍的游標,範圍不存在時回傳 ``None`` Return a cursor spanning a diagnostic, or ``None`` when it is out of range. """ - block = document.findBlockByNumber(diagnostic.line - 1) + span = diagnostic.range + block = document.findBlockByNumber(span.start.line - 1) if not block.isValid(): return None - start = block.position() + max(0, diagnostic.column - 1) - end_block = document.findBlockByNumber(diagnostic.end_line - 1) + start = block.position() + max(0, span.start.column - 1) + end_block = document.findBlockByNumber(span.end.line - 1) if end_block.isValid(): - end = end_block.position() + max(0, diagnostic.end_column - 1) + end = end_block.position() + max(0, span.end.column - 1) else: end = block.position() + block.length() - 1 # 零寬度的範圍看不見,至少標一個字元 diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py index 32dcfe7..5f71805 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -5,6 +5,12 @@ 診斷是由編輯器在背景檢查後持有的,面板只負責顯示與跳轉,不自己執行 linter。 The editor already holds the diagnostics from its background check; the panel only displays them and jumps to a line, never running the linter itself. + +ruff 與語言伺服器的診斷都以統一模型放進同一個 ``DiagnosticStore``,所以嚴重度與 +來源的篩選對兩者一視同仁,清單的順序也固定。 +ruff's findings and a language server's go into one ``DiagnosticStore`` in the +unified model, so the severity and source filters treat both alike and the list +always comes out in the same order. """ from __future__ import annotations @@ -17,14 +23,14 @@ QTreeWidgetItem, QVBoxLayout, QWidget ) -from je_editor.code_scan.ruff_lint import apply_fixes +from je_editor.code_scan.ruff_lint import RUFF_SOURCE, apply_fixes +from je_editor.core.diagnostics.diagnostic_model import Diagnostic, DiagnosticStore, Severity +from je_editor.core.diagnostics.legacy_diagnostics import unify +from je_editor.core.uri.resource_uri import to_path, to_uri, uri_key from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( ProjectLintWorker ) from je_editor.utils.file.open.open_file import read_file_with_encoding -from je_editor.utils.lint.ruff_diagnostics import ( - SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING, Diagnostic -) from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper # 樹狀清單欄位索引 / Column indexes in the tree @@ -32,10 +38,30 @@ COLUMN_MESSAGE = 1 COLUMN_LINE = 2 COLUMN_FILE = 3 +COLUMN_SEVERITY = 4 +COLUMN_SOURCE = 5 # 訊息欄的預設寬度 / Default width of the message column MESSAGE_COLUMN_WIDTH = 460 # 「全部嚴重度」的篩選值 / Filter value meaning "every severity" ALL_SEVERITIES = "*" +# 「全部來源」的篩選值 / Filter value meaning "every source" +ALL_SOURCES = "*" +# 各欄標題的字典鍵,依欄位順序 / The dictionary key of each column's title, in column order +_COLUMN_TITLE_KEYS = ( + "problems_panel_col_code", + "problems_panel_col_message", + "problems_panel_col_line", + "problems_panel_col_file", + "problems_panel_col_severity", + "problems_panel_col_source", +) +# 各嚴重度名稱的字典鍵 / The dictionary key of each severity's name +_SEVERITY_NAME_KEYS = { + Severity.ERROR: "problems_panel_severity_error", + Severity.WARNING: "problems_panel_severity_warning", + Severity.INFORMATION: "problems_panel_severity_information", + Severity.HINT: "problems_panel_severity_hint", +} def current_code_editor(main_window): @@ -67,7 +93,9 @@ def __init__(self, main_window=None) -> None: super().__init__() word = language_wrapper.language_word_dict self._main_window = main_window - self._diagnostics: list[Diagnostic] = [] + # 面板目前顯示的所有診斷,依「來源 × 資源」存放 + # Everything the panel is showing, kept per source and per resource + self._store = DiagnosticStore() # 專案檢查在工作執行緒進行,這裡持有進行中的那一個 # The project check runs on a worker thread; this holds the one in flight self._project_worker: ProjectLintWorker | None = None @@ -78,21 +106,19 @@ def __init__(self, main_window=None) -> None: self.project_check.stateChanged.connect(self.refresh) self.severity_filter = QComboBox() self.severity_filter.addItem(word.get("problems_panel_all_severities"), ALL_SEVERITIES) - for severity in (SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO): - self.severity_filter.addItem(severity, severity) + for severity, name_key in _SEVERITY_NAME_KEYS.items(): + self.severity_filter.addItem(word.get(name_key), int(severity)) self.severity_filter.currentIndexChanged.connect(self._render_items) + self.source_filter = QComboBox() + self.source_filter.addItem(word.get("problems_panel_all_sources"), ALL_SOURCES) + self.source_filter.currentIndexChanged.connect(self._render_items) self.fix_button = QPushButton(word.get("problems_panel_fix")) self.fix_button.clicked.connect(self.apply_available_fixes) self.status_label = QLabel(word.get("problems_panel_ready")) self.result_tree = QTreeWidget() - self.result_tree.setColumnCount(4) - self.result_tree.setHeaderLabels([ - word.get("problems_panel_col_code"), - word.get("problems_panel_col_message"), - word.get("problems_panel_col_line"), - word.get("problems_panel_col_file"), - ]) + self.result_tree.setColumnCount(len(_COLUMN_TITLE_KEYS)) + self.result_tree.setHeaderLabels([word.get(key) for key in _COLUMN_TITLE_KEYS]) self.result_tree.setColumnWidth(COLUMN_MESSAGE, MESSAGE_COLUMN_WIDTH) self.result_tree.setRootIsDecorated(False) self.result_tree.itemDoubleClicked.connect(self._open_item) @@ -101,6 +127,7 @@ def __init__(self, main_window=None) -> None: controls.addWidget(self.refresh_button) controls.addWidget(self.project_check) controls.addWidget(self.severity_filter) + controls.addWidget(self.source_filter) controls.addWidget(self.fix_button) controls.addWidget(self.status_label) controls.addStretch() @@ -113,8 +140,25 @@ def __init__(self, main_window=None) -> None: self.refresh() def diagnostics(self) -> list[Diagnostic]: - """取得面板目前顯示的診斷 / The diagnostics currently listed.""" - return list(self._diagnostics) + """取得面板持有的所有診斷,不管篩選條件 / Every diagnostic held, whatever the filters say.""" + return self._store.select() + + def set_diagnostics(self, diagnostics: list, source: str = RUFF_SOURCE) -> None: + """ + 換掉面板持有的診斷並重畫清單 + Replace what the panel holds and redraw the list. + + :param diagnostics: 新的診斷,統一模型或舊形式都可以 / the diagnostics, in + the unified model or the older shape + :param source: 舊形式的診斷是誰報的 / who reported the older-shape ones + """ + reports: dict[tuple[str, str], list[Diagnostic]] = {} + for item in unify(diagnostics, source): + reports.setdefault((item.source, item.uri), []).append(item) + self._store.clear() + for (item_source, uri), items in reports.items(): + self._store.publish(item_source, uri, items) + self._render_items() def retranslate(self) -> None: """ @@ -132,12 +176,11 @@ def retranslate(self) -> None: self.project_check.setText(word.get("problems_panel_whole_project")) self.fix_button.setText(word.get("problems_panel_fix")) self.severity_filter.setItemText(0, word.get("problems_panel_all_severities")) - self.result_tree.setHeaderLabels([ - word.get("problems_panel_col_code"), - word.get("problems_panel_col_message"), - word.get("problems_panel_col_line"), - word.get("problems_panel_col_file"), - ]) + for severity, name_key in _SEVERITY_NAME_KEYS.items(): + self.severity_filter.setItemText( + self.severity_filter.findData(int(severity)), word.get(name_key)) + self.source_filter.setItemText(0, word.get("problems_panel_all_sources")) + self.result_tree.setHeaderLabels([word.get(key) for key in _COLUMN_TITLE_KEYS]) self._render_items() def refresh(self) -> None: @@ -157,11 +200,10 @@ def refresh(self) -> None: self._stop_project_check() code_edit = current_code_editor(self._main_window) if code_edit is None: - self._diagnostics = [] - else: - code_edit.request_lint() - self._diagnostics = code_edit.lint_manager.diagnostics() - self._render_items() + self.set_diagnostics([]) + return + code_edit.request_lint() + self.set_diagnostics(code_edit.lint_manager.diagnostics()) def start_project_check(self) -> bool: """ @@ -198,8 +240,7 @@ def _on_project_linted(self, diagnostics: list) -> None: """ if self.sender() is not self._project_worker: return - self._diagnostics = list(diagnostics) - self._render_items() + self.set_diagnostics(list(diagnostics)) def _on_worker_finished(self) -> None: """檢查結束後放掉參考 / Let go of the worker once it has finished.""" @@ -233,15 +274,40 @@ def _project_root(self) -> str: def visible_diagnostics(self) -> list[Diagnostic]: """ - 取得符合嚴重度篩選的診斷 - The diagnostics matching the severity filter. + 取得符合嚴重度與來源篩選的診斷 + The diagnostics matching the severity and source filters. - :return: 要顯示的診斷 / the diagnostics to show + :return: 要顯示的診斷,順序固定 / the diagnostics to show, in a fixed order + """ + severity = self.severity_filter.currentData() + source = self.source_filter.currentData() + return self._store.select( + None if severity in (None, ALL_SEVERITIES) else [Severity(severity)], + None if source in (None, ALL_SOURCES) else [source], + ) + + def _refresh_source_choices(self) -> None: + """ + 讓來源選單跟著目前有診斷的來源走 + Keep the source choices in step with the sources that have findings. + + 原本選著的來源還在就維持,不在了就退回「全部來源」。重建期間擋住訊號, + 否則每加一個項目都會再重畫一次清單。 + A source that was selected stays selected while it still exists, and + falls back to every source once it is gone. Signals are blocked while + rebuilding, or each added item would redraw the list again. """ - selected = self.severity_filter.currentData() - if selected in (None, ALL_SEVERITIES): - return list(self._diagnostics) - return [item for item in self._diagnostics if item.level == selected] + selected = self.source_filter.currentData() + all_sources_label = self.source_filter.itemText(0) + self.source_filter.blockSignals(True) + try: + self.source_filter.clear() + self.source_filter.addItem(all_sources_label, ALL_SOURCES) + for source in self._store.sources(): + self.source_filter.addItem(source, source) + self.source_filter.setCurrentIndex(max(self.source_filter.findData(selected), 0)) + finally: + self.source_filter.blockSignals(False) def apply_available_fixes(self) -> bool: """ @@ -283,12 +349,15 @@ def _reload_current_tab(self) -> None: def _render_items(self) -> None: """依目前診斷與篩選條件重建清單 / Rebuild the tree for the current filter.""" word = language_wrapper.language_word_dict + self._refresh_source_choices() visible = self.visible_diagnostics() self.result_tree.clear() for diagnostic in visible: + file_path = to_path(diagnostic.uri) row = QTreeWidgetItem([ - diagnostic.code, diagnostic.message, str(diagnostic.line), - Path(diagnostic.file_path).name if diagnostic.file_path else ""]) + diagnostic.code, diagnostic.message, str(diagnostic.range.start.line), + Path(file_path).name if file_path else "", + word.get(_SEVERITY_NAME_KEYS[diagnostic.severity]), diagnostic.source]) row.setData(COLUMN_CODE, Qt.ItemDataRole.UserRole, diagnostic) self.result_tree.addTopLevelItem(row) if visible: @@ -304,17 +373,35 @@ def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: return self.jump_to_diagnostic(diagnostic) - def jump_to_diagnostic(self, diagnostic: Diagnostic) -> bool: + def jump_to_diagnostic(self, diagnostic) -> bool: """ 跳到診斷所在的位置,必要時先開啟該檔案 Jump to a diagnostic, opening its file first when it is another one. - :param diagnostic: 目標診斷 / the diagnostic to jump to + :param diagnostic: 目標診斷,統一模型或舊形式都可以 / the diagnostic to + jump to, in the unified model or the older shape :return: 成功跳轉時為 ``True`` / ``True`` when the caret moved """ - if diagnostic.file_path and hasattr(self._main_window, "go_to_new_tab"): - self._main_window.go_to_new_tab(Path(diagnostic.file_path)) + target = unify([diagnostic], RUFF_SOURCE)[0] + file_path = to_path(target.uri) + if (file_path and not self._is_current_file(target.uri) + and hasattr(self._main_window, "go_to_new_tab")): + self._main_window.go_to_new_tab(Path(file_path)) code_edit = current_code_editor(self._main_window) if code_edit is None: return False - return code_edit.jump_to_line(diagnostic.line) + return code_edit.jump_to_line(target.range.start.line) + + def _is_current_file(self, uri: str) -> bool: + """ + 判斷某個資源是不是目前分頁的檔案 + Whether a resource is the file in the current tab. + + 目前分頁自己的診斷不必再「開啟」一次:同一個檔案換一種寫法去開,會被當成 + 另一個檔案而多開一個分頁。 + The current tab's own diagnostics need no opening: asking to open the + same file under another spelling would be taken for another file and + open a second tab. + """ + current = self._current_file() + return current is not None and uri_key(to_uri(current)) == uri_key(uri) diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py index 8f27aff..26b18ca 100644 --- a/je_editor/utils/lsp/lsp_protocol.py +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -20,6 +20,9 @@ HEADER_SEPARATOR = b"\r\n\r\n" # 內容長度標頭 / The content-length header CONTENT_LENGTH_HEADER = b"Content-Length:" +# LSP 定義的嚴重度編號:錯誤、警告、資訊、提示 +# The severity numbers LSP defines: error, warning, information, hint +_LSP_SEVERITIES = (1, 2, 3, 4) def encode_message(payload: dict) -> bytes: @@ -454,6 +457,8 @@ def _diagnostic_entry(item: object) -> dict | None: line, column = _position(span.get("start")) end_line, end_column = _position(span.get("end"), line - 1, column - 1) code = item.get("code") + severity = item.get("severity") + source = item.get("source") return { # LSP 的行列是 0 起算,編輯器用 1 起算 # LSP counts lines and columns from zero; the editor counts from one @@ -463,4 +468,9 @@ def _diagnostic_entry(item: object) -> dict | None: "end_column": end_column, "code": str(code) if isinstance(code, (str, int)) else "", "message": message, + # 伺服器沒給或給了不認得的嚴重度時為 0,由使用端決定怎麼看待 + # Zero when the server gave none, or one that is not recognised, leaving + # the reading of it to whoever consumes the entry + "severity": severity if severity in _LSP_SEVERITIES else 0, + "source": source if isinstance(source, str) else "", } diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index caba683..3eb7758 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -483,6 +483,13 @@ "problems_panel_col_file": "File", "problems_panel_whole_project": "Whole project", "problems_panel_all_severities": "All severities", + "problems_panel_all_sources": "All sources", + "problems_panel_severity_error": "Error", + "problems_panel_severity_warning": "Warning", + "problems_panel_severity_information": "Information", + "problems_panel_severity_hint": "Hint", + "problems_panel_col_severity": "Severity", + "problems_panel_col_source": "Source", "problems_panel_fix": "Apply Fixes", # Outline panel "tab_menu_outline_panel_tab_name": "Outline", diff --git a/je_editor/utils/multi_language/japanese.py b/je_editor/utils/multi_language/japanese.py index e6d22e1..32cefa1 100644 --- a/je_editor/utils/multi_language/japanese.py +++ b/je_editor/utils/multi_language/japanese.py @@ -477,6 +477,13 @@ "problems_panel_col_file": "ファイル", "problems_panel_whole_project": "プロジェクト全体", "problems_panel_all_severities": "すべての重大度", + "problems_panel_all_sources": "すべてのソース", + "problems_panel_severity_error": "エラー", + "problems_panel_severity_warning": "警告", + "problems_panel_severity_information": "情報", + "problems_panel_severity_hint": "ヒント", + "problems_panel_col_severity": "重大度", + "problems_panel_col_source": "ソース", "problems_panel_fix": "修正を適用", # Outline panel "tab_menu_outline_panel_tab_name": "アウトライン", diff --git a/je_editor/utils/multi_language/simplified_chinese.py b/je_editor/utils/multi_language/simplified_chinese.py index e8f1df9..74275da 100644 --- a/je_editor/utils/multi_language/simplified_chinese.py +++ b/je_editor/utils/multi_language/simplified_chinese.py @@ -473,6 +473,13 @@ "problems_panel_col_file": "文件", "problems_panel_whole_project": "整个项目", "problems_panel_all_severities": "所有严重级别", + "problems_panel_all_sources": "所有来源", + "problems_panel_severity_error": "错误", + "problems_panel_severity_warning": "警告", + "problems_panel_severity_information": "信息", + "problems_panel_severity_hint": "提示", + "problems_panel_col_severity": "严重级别", + "problems_panel_col_source": "来源", "problems_panel_fix": "应用修复", # Outline panel "tab_menu_outline_panel_tab_name": "大纲", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index e91d125..b53ab85 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -473,6 +473,13 @@ "problems_panel_col_file": "檔案", "problems_panel_whole_project": "整個專案", "problems_panel_all_severities": "所有嚴重度", + "problems_panel_all_sources": "所有來源", + "problems_panel_severity_error": "錯誤", + "problems_panel_severity_warning": "警告", + "problems_panel_severity_information": "資訊", + "problems_panel_severity_hint": "提示", + "problems_panel_col_severity": "嚴重度", + "problems_panel_col_source": "來源", "problems_panel_fix": "套用修正", # Outline panel "tab_menu_outline_panel_tab_name": "大綱", diff --git a/test/test_core_diagnostics.py b/test/test_core_diagnostics.py index 4f7fec7..54078c1 100644 --- a/test/test_core_diagnostics.py +++ b/test/test_core_diagnostics.py @@ -7,7 +7,7 @@ Diagnostic, DiagnosticStore, Position, QuickFix, RelatedInformation, Severity, TextEdit, TextRange, filter_diagnostics ) -from je_editor.core.diagnostics.legacy_diagnostics import from_legacy, to_legacy +from je_editor.core.diagnostics.legacy_diagnostics import from_legacy, to_legacy, unify from je_editor.core.uri.resource_uri import to_path, to_uri from je_editor.utils.lint.ruff_diagnostics import ( SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING @@ -284,6 +284,27 @@ def test_a_diagnostic_without_a_resource_has_no_file_path(self): assert to_legacy(finding("buffer only")).file_path == "" +class TestUnify: + def test_a_unified_diagnostic_is_kept_as_it_is(self, uri): + already = Diagnostic("from a server", TextRange.from_lines(2), Severity.HINT, + source=SERVER, uri=uri) + assert unify([already], RUFF, "file:///ignored.py") == [already] + + def test_an_older_diagnostic_gains_the_source_and_the_resource(self, uri): + legacy = LegacyDiagnostic(1, 1, 1, 2, "F401", "unused") + converted = unify([legacy], RUFF, uri)[0] + assert (converted.source, converted.uri, converted.code) == (RUFF, uri, "F401") + + def test_a_mixed_list_keeps_its_order(self, uri): + already = Diagnostic("second", TextRange.from_lines(2), source=SERVER) + legacy = LegacyDiagnostic(1, 1, 1, 2, "F401", "first") + assert [item.message for item in unify([legacy, already], RUFF, uri)] == [ + "first", "second"] + + def test_an_empty_list_stays_empty(self): + assert unify([], RUFF) == [] + + class TestBothSourcesShareTheModel: """ ruff output and a language server's notification land in one store. diff --git a/test/test_lint_manager.py b/test/test_lint_manager.py index de755b5..ca15d33 100644 --- a/test/test_lint_manager.py +++ b/test/test_lint_manager.py @@ -7,12 +7,17 @@ from PySide6.QtGui import QTextCharFormat from PySide6.QtWidgets import QApplication -from je_editor.utils.lint.ruff_diagnostics import Diagnostic +from je_editor.core.diagnostics.diagnostic_model import Diagnostic, Severity, TextRange +from je_editor.utils.lint.ruff_diagnostics import Diagnostic as OlderDiagnostic SAMPLE = Diagnostic( - line=1, column=1, end_line=1, end_column=7, code="F401", message="unused import") + "unused import", TextRange.from_lines(1, 1, 1, 7), Severity.ERROR, source="ruff", code="F401") OTHER = Diagnostic( - line=2, column=1, end_line=2, end_column=4, code="E701", message="multiple statements") + "multiple statements", TextRange.from_lines(2, 1, 2, 4), Severity.ERROR, source="ruff", + code="E701") +# The same finding as SAMPLE in the shape ruff's parser produces +OLDER_SAMPLE = OlderDiagnostic( + line=1, column=1, end_line=1, end_column=7, code="F401", message="unused import") @pytest.fixture(scope="module") @@ -82,6 +87,17 @@ def test_set_diagnostics_reports_a_change(self, editor): assert editor.lint_manager.set_diagnostics([SAMPLE]) is True assert editor.lint_manager.set_diagnostics([SAMPLE]) is False + def test_the_older_shape_is_still_accepted(self, editor): + editor.lint_manager.set_diagnostics([OLDER_SAMPLE]) + assert editor.lint_manager.diagnostics() == [SAMPLE] + + def test_the_older_shape_is_stamped_with_the_editor_file(self, editor, tmp_path): + from je_editor.core.uri.resource_uri import to_uri + editor.current_file = str(tmp_path / "module.py") + editor.lint_manager.set_diagnostics([OLDER_SAMPLE]) + stored = editor.lint_manager.diagnostics()[0] + assert (stored.source, stored.uri) == ("ruff", to_uri(tmp_path / "module.py")) + def test_for_line(self, editor): editor.lint_manager.set_diagnostics([SAMPLE, OTHER]) assert editor.lint_manager.for_line(1) == [SAMPLE] @@ -140,8 +156,7 @@ def test_underline_covers_the_reported_range(self, editor): def test_range_on_a_missing_line_is_refused(self, editor): editor.setPlainText("x = 1\n") - missing = Diagnostic( - line=99, column=1, end_line=99, end_column=4, code="E1", message="gone") + missing = Diagnostic("gone", TextRange.from_lines(99, 1, 99, 4), code="E1") assert editor._diagnostic_cursor(editor.document(), missing) is None def test_no_diagnostics_means_no_underline(self, editor): @@ -153,14 +168,14 @@ def test_no_diagnostics_means_no_underline(self, editor): def test_a_diagnostic_past_the_end_is_skipped(self, editor): editor.setPlainText("x = 1\n") editor.lint_manager.set_diagnostics([ - Diagnostic(line=99, column=1, end_line=99, end_column=4, code="E1", message="gone")]) + Diagnostic("gone", TextRange.from_lines(99, 1, 99, 4), code="E1")]) editor.refresh_lint_display() assert self._wave_selections(editor) == [] def test_zero_width_range_still_marks_a_character(self, editor): editor.setPlainText("x = 1\n") editor.lint_manager.set_diagnostics([ - Diagnostic(line=1, column=1, end_line=1, end_column=1, code="E2", message="here")]) + Diagnostic("here", TextRange.from_lines(1, 1, 1, 1), code="E2")]) editor.refresh_lint_display() assert len(self._wave_selections(editor)) == 1 diff --git a/test/test_lsp_diagnostics.py b/test/test_lsp_diagnostics.py index 2077f07..1fd6fa5 100644 --- a/test/test_lsp_diagnostics.py +++ b/test/test_lsp_diagnostics.py @@ -6,6 +6,8 @@ import pytest from PySide6.QtWidgets import QApplication +from je_editor.core.diagnostics.diagnostic_model import Position, Severity +from je_editor.core.diagnostics.lsp_diagnostics import from_lsp_entries, from_lsp_entry from je_editor.utils.lint.ruff_diagnostics import ( SYNTAX_ERROR_CODE, diagnostic_from_entry, @@ -40,6 +42,64 @@ def test_entry_without_a_range_still_works(self): def test_entry_without_a_message_is_dropped(self): assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] + @pytest.mark.parametrize("severity", [1, 2, 3, 4]) + def test_the_server_severity_is_kept(self, severity): + entry = diagnostic_entries({"diagnostics": [{"message": "m", "severity": severity}]})[0] + assert entry["severity"] == severity + + @pytest.mark.parametrize("severity", [None, 0, 5, "error"]) + def test_a_missing_or_unknown_severity_reads_as_zero(self, severity): + entry = diagnostic_entries({"diagnostics": [{"message": "m", "severity": severity}]})[0] + assert entry["severity"] == 0 + + def test_the_server_source_is_kept(self): + entry = diagnostic_entries({"diagnostics": [{"message": "m", "source": "clippy"}]})[0] + assert entry["source"] == "clippy" + + def test_a_missing_source_reads_as_empty(self): + assert diagnostic_entries(SERVER_NOTIFICATION)[0]["source"] == "" + + +class TestConversionToTheUnifiedModel: + def _convert(self, **fields): + raw = {"range": {"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 9}}, + "message": "Cannot find name 'foo'", **fields} + return from_lsp_entries( + diagnostic_entries({"diagnostics": [raw]}), "file:///app/main.ts", "tsserver")[0] + + @pytest.mark.parametrize("number, expected", [ + (1, Severity.ERROR), (2, Severity.WARNING), (3, Severity.INFORMATION), (4, Severity.HINT), + ]) + def test_each_severity_keeps_its_own_level(self, number, expected): + assert self._convert(severity=number).severity is expected + + def test_a_missing_severity_counts_as_an_error(self): + assert self._convert().severity is Severity.ERROR + + def test_the_position_is_one_based(self): + converted = self._convert() + assert (converted.range.start, converted.range.end) == (Position(4, 5), Position(4, 10)) + + def test_the_source_the_server_names_wins(self): + assert self._convert(source="eslint").source == "eslint" + + def test_the_server_name_stands_in_for_a_missing_source(self): + assert self._convert().source == "tsserver" + + def test_code_message_and_resource_are_carried(self): + converted = self._convert(code=2304) + assert (converted.code, converted.message, converted.uri) == ( + "2304", "Cannot find name 'foo'", "file:///app/main.ts") + + @pytest.mark.parametrize("entry", [ + {"message": "no line"}, {"line": 0, "message": "bad line"}, {"line": 2}, "nonsense", None, + ]) + def test_an_unusable_entry_is_dropped(self, entry): + assert from_lsp_entry(entry) is None + + def test_a_batch_that_is_not_a_list_gives_nothing(self): + assert from_lsp_entries("nonsense") == [] + class TestConversionToTheEditorShape: def test_server_entry_becomes_a_diagnostic(self): @@ -104,6 +164,18 @@ def test_diagnostics_reach_the_lint_manager(self, editor): })) is True assert editor.lint_manager.diagnostics()[0].message == "unused" + def test_severity_and_source_reach_the_lint_manager(self, editor): + editor.setPlainText("const value = foo;\n") + editor.apply_server_diagnostics(diagnostic_entries({ + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 6}, + "end": {"line": 0, "character": 11}}, + "message": "prefer const", "severity": 4, "source": "eslint", + }] + })) + stored = editor.lint_manager.diagnostics()[0] + assert (stored.severity, stored.source) == (Severity.HINT, "eslint") + def test_the_same_diagnostics_twice_change_nothing(self, editor): entries = diagnostic_entries(SERVER_NOTIFICATION) editor.setPlainText("\n".join("line" for _ in range(10))) diff --git a/test/test_problems_panel.py b/test/test_problems_panel.py index 6ae68ae..e7ae15f 100644 --- a/test/test_problems_panel.py +++ b/test/test_problems_panel.py @@ -8,7 +8,12 @@ import pytest from PySide6.QtWidgets import QApplication -from je_editor.code_scan.ruff_lint import apply_fixes, find_ruff_executable, lint_project +from je_editor.code_scan.ruff_lint import ( + RUFF_SOURCE, apply_fixes, find_ruff_executable, lint_project +) +from je_editor.core.diagnostics.diagnostic_model import Diagnostic as UnifiedDiagnostic +from je_editor.core.diagnostics.diagnostic_model import Severity, TextRange +from je_editor.core.uri.resource_uri import to_uri from je_editor.utils.lint.ruff_diagnostics import ( SEVERITY_ERROR, SEVERITY_INFO, @@ -132,26 +137,145 @@ def _diagnostics() -> list[Diagnostic]: ] +SERVER = "rust-analyzer" + + +def _server_diagnostics() -> list[UnifiedDiagnostic]: + """What a language server reported: one finding at each severity.""" + return [ + UnifiedDiagnostic("cannot find value", TextRange.from_lines(4), Severity.ERROR, + source=SERVER, code="E0425"), + UnifiedDiagnostic("unused variable", TextRange.from_lines(5), Severity.WARNING, + source=SERVER, code="unused_variables"), + UnifiedDiagnostic("consider borrowing", TextRange.from_lines(6), Severity.INFORMATION, + source=SERVER, code="clippy::needless_pass"), + UnifiedDiagnostic("could be const", TextRange.from_lines(7), Severity.HINT, + source=SERVER, code="clippy::const"), + ] + + +def _choose(combo, value) -> None: + combo.setCurrentIndex(combo.findData(value)) + + class TestSeverityFilter: def test_everything_is_shown_by_default(self, panel): - panel._diagnostics = _diagnostics() + panel.set_diagnostics(_diagnostics()) assert len(panel.visible_diagnostics()) == 3 def test_filtering_to_errors(self, panel): - panel._diagnostics = _diagnostics() - panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_ERROR)) + panel.set_diagnostics(_diagnostics()) + _choose(panel.severity_filter, int(Severity.ERROR)) assert [item.code for item in panel.visible_diagnostics()] == ["F401"] def test_filtering_to_warnings(self, panel): - panel._diagnostics = _diagnostics() - panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_WARNING)) + panel.set_diagnostics(_diagnostics()) + _choose(panel.severity_filter, int(Severity.WARNING)) assert [item.code for item in panel.visible_diagnostics()] == ["W291"] def test_the_tree_follows_the_filter(self, panel): - panel._diagnostics = _diagnostics() - panel.severity_filter.setCurrentIndex(panel.severity_filter.findData(SEVERITY_INFO)) + panel.set_diagnostics(_diagnostics()) + _choose(panel.severity_filter, int(Severity.INFORMATION)) assert panel.result_tree.topLevelItemCount() == 1 + def test_the_filter_offers_all_four_severities(self, panel): + offered = [panel.severity_filter.itemData(index) + for index in range(1, panel.severity_filter.count())] + assert offered == [int(severity) for severity in Severity] + + @pytest.mark.parametrize("severity, code", [ + (Severity.ERROR, "E0425"), (Severity.WARNING, "unused_variables"), + (Severity.INFORMATION, "clippy::needless_pass"), (Severity.HINT, "clippy::const"), + ]) + def test_a_server_severity_is_filtered_as_the_server_gave_it(self, panel, severity, code): + panel.set_diagnostics(_server_diagnostics()) + _choose(panel.severity_filter, int(severity)) + assert [item.code for item in panel.visible_diagnostics()] == [code] + + def test_the_severity_is_named_in_its_column(self, panel): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + COLUMN_SEVERITY + ) + panel.set_diagnostics(_server_diagnostics()) + shown = [panel.result_tree.topLevelItem(row).text(COLUMN_SEVERITY) + for row in range(panel.result_tree.topLevelItemCount())] + assert shown == ["Error", "Warning", "Information", "Hint"] + + +class TestSourceFilter: + """ruff's findings and a language server's sit in one list and filter alike.""" + + @pytest.fixture() + def mixed(self, panel): + panel.set_diagnostics(_diagnostics() + _server_diagnostics()) + return panel + + def test_both_sources_are_listed_together(self, mixed): + assert [item.source for item in mixed.diagnostics()] == [RUFF_SOURCE] * 3 + [SERVER] * 4 + + def test_the_filter_offers_the_sources_that_have_findings(self, mixed): + offered = [mixed.source_filter.itemData(index) + for index in range(1, mixed.source_filter.count())] + assert offered == sorted([RUFF_SOURCE, SERVER]) + + def test_one_source_can_be_picked(self, mixed): + _choose(mixed.source_filter, SERVER) + assert {item.source for item in mixed.visible_diagnostics()} == {SERVER} + assert mixed.result_tree.topLevelItemCount() == 4 + + def test_severity_and_source_narrow_together(self, mixed): + _choose(mixed.source_filter, SERVER) + _choose(mixed.severity_filter, int(Severity.WARNING)) + assert [item.code for item in mixed.visible_diagnostics()] == ["unused_variables"] + + def test_a_severity_filter_cuts_across_both_sources(self, mixed): + _choose(mixed.severity_filter, int(Severity.ERROR)) + assert [(item.source, item.code) for item in mixed.visible_diagnostics()] == [ + (RUFF_SOURCE, "F401"), (SERVER, "E0425")] + + def test_the_source_is_named_in_its_column(self, mixed): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import COLUMN_SOURCE + assert mixed.result_tree.topLevelItem(0).text(COLUMN_SOURCE) == RUFF_SOURCE + + def test_a_chosen_source_survives_a_recheck(self, mixed): + _choose(mixed.source_filter, SERVER) + mixed.set_diagnostics(_diagnostics() + _server_diagnostics()) + assert mixed.source_filter.currentData() == SERVER + + def test_a_source_that_went_away_falls_back_to_all(self, mixed): + _choose(mixed.source_filter, SERVER) + mixed.set_diagnostics(_diagnostics()) + assert mixed.source_filter.currentIndex() == 0 + assert len(mixed.visible_diagnostics()) == 3 + + def test_the_order_does_not_depend_on_the_order_reported(self, panel): + panel.set_diagnostics(_server_diagnostics() + _diagnostics()) + forwards = panel.visible_diagnostics() + panel.set_diagnostics(list(reversed(_diagnostics() + _server_diagnostics()))) + assert panel.visible_diagnostics() == forwards + + +class TestFilesAcrossTheProject: + def test_same_named_files_in_two_folders_are_both_listed(self, panel, tmp_path): + first, second = tmp_path / "frontend" / "main.py", tmp_path / "backend" / "main.py" + panel.set_diagnostics([ + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="F401", message="a", + file_path=str(first)), + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="F401", message="b", + file_path=str(second)), + ]) + assert sorted(item.uri for item in panel.diagnostics()) == sorted( + [to_uri(first), to_uri(second)]) + + def test_a_finding_in_another_file_opens_that_file(self, panel, tmp_path): + target = tmp_path / "pkg" / "module.py" + panel.set_diagnostics([Diagnostic( + line=3, column=1, end_line=3, end_column=2, code="F401", message="unused", + file_path=str(target))]) + panel.jump_to_diagnostic(panel.diagnostics()[0]) + panel._main_window.go_to_new_tab.assert_called_once() + assert str(panel._main_window.go_to_new_tab.call_args.args[0]) == str(target) + class TestProjectScope: """ From aa099c693dbc090ab3120c25dbe118d28bed7519 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 02:51:02 +0800 Subject: [PATCH 07/14] Make the AI chat panel provider-neutral and add an Anthropic backend The chat panel asks a registry for a provider and no longer contains code for one vendor. Two providers ship in the new je_editor.adapters package: "openai" keeps the LangChain ChatOpenAI path for any OpenAI-compatible endpoint, and "anthropic" streams replies through the official SDK. The panel now sends the whole conversation, shows a streamed reply as it arrives, can stop a request in flight, and reports token usage and failures. Requests run on a daemon thread and report back through signals; the error dialog used to be opened from the worker thread. Settings are grouped by provider and the older single-group file still loads. They apply to the session and are only written to disk when the user ticks saving, since the file holds the key as plain text. The editor no longer exports OPENAI_BASE_URL, OPENAI_API_KEY and CHAT_MODEL into its environment, which handed the key to every child process. EditorMain gains a services attribute holding the window's EditorServices. anthropic is pinned to 1.11.0, the newest release older than the seven-day cool-down. Neither provider was exercised against a live service: no key is available here, so the tests use fake clients plus checks against the installed SDK's signatures. Closes PROGRESS #13 (roadmap M5). --- PROGRESS.md | 6 +- README.md | 23 +- README/README_zh-CN.md | 17 +- README/README_zh-TW.md | 17 +- architecture.md | 18 +- architecture_explore.md | 69 +-- dev.toml | 3 +- dev_requirements.txt | 1 + docs/roadmap/2026-editor-next.md | 21 +- docs/source/docs/Eng/ai_assistant.rst | 170 ++++++-- docs/source/docs/Eng/configuration.rst | 10 +- docs/source/docs/Eng/core_services.rst | 5 +- docs/source/docs/Eng/getting_started.rst | 4 +- docs/source/docs/Zh/ai_assistant.rst | 170 ++++++-- docs/source/docs/Zh/configuration.rst | 8 +- docs/source/docs/Zh/core_services.rst | 4 +- docs/source/docs/Zh/getting_started.rst | 4 +- docs/updates/2026-10.md | 27 ++ docs/updates/README.md | 3 +- je_editor/adapters/__init__.py | 12 + je_editor/adapters/ai/__init__.py | 0 je_editor/adapters/ai/anthropic_provider.py | 201 +++++++++ je_editor/adapters/ai/builtin_providers.py | 50 +++ je_editor/adapters/ai/openai_provider.py | 144 ++++++ je_editor/adapters/ai/settings_file.py | 80 ++++ je_editor/adapters/default_services.py | 52 +++ je_editor/core/__init__.py | 4 +- je_editor/core/ai/ai_settings.py | 166 +++++++ je_editor/core/ai/chat_session.py | 94 ++++ je_editor/core/services/editor_services.py | 4 + .../dialog/ai_dialog/set_ai_dialog.py | 154 ++++--- .../pyside_ui/main_ui/ai_widget/ai_config.py | 34 -- .../pyside_ui/main_ui/ai_widget/ask_thread.py | 36 -- .../pyside_ui/main_ui/ai_widget/chat_ui.py | 410 +++++++++++++----- .../main_ui/ai_widget/chat_worker.py | 129 ++++++ .../main_ui/ai_widget/langchain_interface.py | 84 ---- je_editor/pyside_ui/main_ui/main_editor.py | 10 + je_editor/utils/multi_language/english.py | 16 + je_editor/utils/multi_language/japanese.py | 16 + .../multi_language/simplified_chinese.py | 16 + .../multi_language/traditional_chinese.py | 16 + pyproject.toml | 3 +- requirements.txt | 1 + test/test_ai_providers.py | 402 +++++++++++++++++ test/test_ai_settings.py | 162 +++++++ test/test_chat_session.py | 84 ++++ test/test_chat_ui.py | 394 +++++++++++++++++ test/test_core_architecture.py | 2 +- test/test_langchain_interface.py | 94 ---- 49 files changed, 2855 insertions(+), 595 deletions(-) create mode 100644 je_editor/adapters/__init__.py create mode 100644 je_editor/adapters/ai/__init__.py create mode 100644 je_editor/adapters/ai/anthropic_provider.py create mode 100644 je_editor/adapters/ai/builtin_providers.py create mode 100644 je_editor/adapters/ai/openai_provider.py create mode 100644 je_editor/adapters/ai/settings_file.py create mode 100644 je_editor/adapters/default_services.py create mode 100644 je_editor/core/ai/ai_settings.py create mode 100644 je_editor/core/ai/chat_session.py delete mode 100644 je_editor/pyside_ui/main_ui/ai_widget/ai_config.py delete mode 100644 je_editor/pyside_ui/main_ui/ai_widget/ask_thread.py create mode 100644 je_editor/pyside_ui/main_ui/ai_widget/chat_worker.py delete mode 100644 je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py create mode 100644 test/test_ai_providers.py create mode 100644 test/test_ai_settings.py create mode 100644 test/test_chat_session.py create mode 100644 test/test_chat_ui.py delete mode 100644 test/test_langchain_interface.py diff --git a/PROGRESS.md b/PROGRESS.md index 64d6042..27d276c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,8 +19,8 @@ ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) -M0(`je_editor/core/` 服務層)已完成,見 U-20261008-01。以下每個里程碑一個 PR,順序依相依關係; -#13 只相依 M0,可以先做。 +M0(`je_editor/core/` 服務層)、M2 的診斷那一半、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 +以下依相依關係排序。 - **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 變動的 ID。語系那一項大部分已經有了:四份字典各 438 個鍵,鍵與佔位符的 parity、空白值、退回英文都 @@ -33,8 +33,6 @@ M0(`je_editor/core/` 服務層)已完成,見 U-20261008-01。以下每個 提供;LSP 連線以「伺服器 + 根目錄」為鍵;搜尋、索引、TODO、Git、診斷改成認得工作區。 - **#12** M4(除錯器改走 DAP)。實作 `DebugSession`;堆疊、變數、求值的非同步查詢形式在這裡定; 需要一個本機的 `TaskRunner` 實作來啟動轉接器。 -- **#13** M5(AI 供應者 + Anthropic)。`LangChainInterface` 改成 `AIProvider` 的實作並新增 - Anthropic;設定改成依供應者分組。 - **#14** M6(遠端開發)。實作 `RemoteSession`,並補上遠端檔案系統、連接埠轉送、直譯器探索的介面。 〔決定〕第一個傳輸是不是 SSH(PR #270 的審查問題 3)。 - **#15** M7(可嵌入元件)。`import je_editor.core` 不再載入 Qt(頂層 `__init__` 要改成延後匯入); diff --git a/README.md b/README.md index 011e7d3..943454c 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ window never leaves you with dark-theme syntax colours. | **Execution** | Run Python scripts (F5), debug mode (F9), shell commands, virtual environment detection | | **Code Quality** | YAPF formatting, format on save, PEP8 checking, Ruff linting with a problems panel, language-server diagnostics and quick fixes, pytest panel with tracebacks and coverage, JSON reformatting | | **Git** | Branch management, commit history, side-by-side diff viewer, gutter change markers, per-change staging and revert, inline blame, stash, conflict resolution, audit logging | -| **AI** | OpenAI GPT integration via LangChain, interactive chat widget, configurable models & prompts | +| **AI** | Chat panel with interchangeable providers: OpenAI-compatible endpoints (LangChain) and Anthropic (streamed), per-provider models, keys & prompts | | **Console** | Interactive shell, Jupyter/IPython console, command history, multi-shell support | | **Browser** | Embedded web browser, URL navigation, in-page search | | **Plugins** | Custom syntax highlighting, UI translations, run configurations, auto-discovery | @@ -296,7 +296,8 @@ Core dependencies are installed automatically: | jedi | Python auto-completion & analysis | | ruff | Fast Python linter | | gitpython | Git repository operations | -| langchain_openai + langchain_core | AI/LLM integration | +| langchain_openai + langchain_core | OpenAI-compatible AI provider | +| anthropic | Anthropic AI provider | | watchdog | File system monitoring | | pycodestyle | PEP8 style checking | | qtconsole | Jupyter/IPython console widget | @@ -425,10 +426,17 @@ yet. See the *Core Services* page of the [documentation](https://je-editor.readt ### AI Assistant -- **OpenAI models via LangChain** -- Connect to OpenAI's language models. -- **Interactive chat widget** -- Conversational AI panel within the editor. -- **Configurable models** -- Set custom API keys, endpoints, model names, and system prompts. -- **Async messaging** -- Non-blocking AI interaction using a message queue. +- **Interchangeable providers** -- The chat panel talks to whichever provider is selected: any + OpenAI-compatible endpoint through LangChain, or Anthropic through its official SDK. A plugin can + register another one without touching the panel. +- **Conversations, not single prompts** -- Follow-up questions carry the conversation so far; **New + chat** starts over. +- **Streaming and cancelling** -- Anthropic replies appear as they are generated, and **Stop** cancels + a request in flight. +- **Per-provider settings** -- Each provider keeps its own API key, endpoint, model and system prompt. + Settings apply to the session and are only written to disk when you tick the box. +- **Never blocks the window** -- Requests run on a background thread; failures are explained in a + dialog and token usage is shown when the provider reports it. ### Console & REPL @@ -562,6 +570,7 @@ je_editor/ │ └── main_ui/ Main window, menus, toolbar, panels, settings, AI, console ├── core/ Service layer, no Qt: workspace, documents, diagnostics, and the │ interfaces for language services, debugging, tasks, remote and AI +├── adapters/ Implementations of those interfaces, no Qt: the AI providers ├── code_scan/ Ruff execution and watchdog file monitoring ├── git_client/ Git operations (GitPython + git CLI) ├── plugins/ Plugin registry and loader @@ -660,7 +669,7 @@ JEDITOR stores user settings in a `.jeditor/` directory inside the working direc | `user_setting.json` | General preferences (font, theme, language, recent files, open tabs, reassigned shortcuts) | | `user_color_setting.json` | Editor and output colours, including syntax highlighting | | `snippets.json` | Your own snippets, merged over the built-in sets | -| `ai_config.json` | AI assistant settings — read at startup, never written; create it yourself | +| `ai_config.json` | AI assistant settings, grouped by provider — written only when you tick saving in the AI settings dialog, since it holds the key as plain text | Each file is backed up to `.bak` before it is rewritten. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index d30e04e..39ce994 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -212,7 +212,7 @@ TODO 面板会扫描整个项目中的 `TODO`、`FIXME`、`HACK`、`XXX`、`BUG` | **执行** | 运行 Python 脚本(F5)、调试模式(F9)、Shell 命令、虚拟环境检测 | | **代码质量** | YAPF 格式化、保存时格式化、PEP8 检查、Ruff 静态分析与问题面板、语言服务器诊断与快速修复、带 traceback 与覆盖率的 pytest 面板、JSON 重新格式化 | | **Git** | 分支管理、提交历史、并排差异查看器、行号区变更标记、逐处变更暂存与还原、行内 blame、贮藏(stash)、冲突解决、审计日志 | -| **AI** | 通过 LangChain 集成 OpenAI GPT、交互式聊天面板、可配置模型与提示词 | +| **AI** | 可切换提供者的对话面板:OpenAI 兼容端点(LangChain)与 Anthropic(流式),各提供者有自己的模型、密钥与提示词 | | **控制台** | 交互式 Shell、Jupyter/IPython 控制台、命令历史、多 Shell 支持 | | **浏览器** | 内嵌网页浏览器、URL 导航、页面内搜索 | | **插件** | 自定义语法高亮、UI 翻译、运行配置、自动发现 | @@ -262,7 +262,8 @@ pip install . | jedi | Python 自动补全与分析 | | ruff | 快速 Python 静态分析工具 | | gitpython | Git 仓库操作 | -| langchain_openai + langchain_core | AI/LLM 集成 | +| langchain_openai + langchain_core | OpenAI 兼容的 AI 提供者 | +| anthropic | Anthropic 的 AI 提供者 | | watchdog | 文件系统监控 | | pycodestyle | PEP8 风格检查 | | qtconsole | Jupyter/IPython 控制台组件 | @@ -388,10 +389,11 @@ services.shutdown() ### AI 助手 -- **通过 LangChain 连接 OpenAI 模型** -- 连接 OpenAI 的语言模型。 -- **交互式聊天面板** -- 编辑器内的对话式 AI 面板。 -- **可配置模型** -- 设置自定义 API 密钥、端点、模型名称与系统提示词。 -- **异步消息** -- 使用消息队列实现非阻塞 AI 交互。 +- **可切换的提供者** -- 对话面板与当前选用的提供者对话:通过 LangChain 连接任何 OpenAI 兼容的端点,或通过官方 SDK 连接 Anthropic。插件可以再注册别的提供者,面板不必改。 +- **是对话,不是单句** -- 追问时会带着到目前为止的对话;**新对话** 重新开始。 +- **流式与取消** -- Anthropic 的回复会边生成边显示,**停止** 可以取消进行中的请求。 +- **每个提供者各自的设置** -- 每个提供者保管自己的 API 密钥、端点、模型与系统提示词。设置只应用于本次运行,勾选之后才会写入磁盘。 +- **不会卡住窗口** -- 请求在后台线程进行;失败时以对话框说明原因,提供者有回报时会显示 token 用量。 ### 控制台与 REPL @@ -523,6 +525,7 @@ je_editor/ │ └── main_ui/ 主窗口、菜单、工具栏、面板、设置、AI、控制台 ├── core/ 服务层,不依赖 Qt:工作区、文档、诊断,以及语言服务、 │ 调试、任务执行、远程与 AI 的接口 +├── adapters/ 上述接口的实现,不依赖 Qt:AI 提供者 ├── code_scan/ Ruff 执行与 watchdog 文件监控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 插件注册表与加载器 @@ -613,7 +616,7 @@ JEDITOR 将用户设置存储在工作目录中的 `.jeditor/` 目录里: | `user_setting.json` | 通用偏好设置(字体、主题、语言、最近打开的文件、打开的标签页、重新指定过的快捷键) | | `user_color_setting.json` | 编辑器与输出的配色,含语法高亮 | | `snippets.json` | 您自己的代码片段,叠加合并在内置片段集之上 | -| `ai_config.json` | AI 助手设置——启动时读取、从不写入,需自行创建 | +| `ai_config.json` | AI 助手设置,按提供者分组——只有在 AI 设置对话框勾选保存时才会写入,因为密钥是明文 | 每个文件在被重写前都会备份到 `.bak`。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index ad5c7e3..9b93acf 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -212,7 +212,7 @@ TODO 面板會掃描整個專案中的 `TODO`、`FIXME`、`HACK`、`XXX`、`BUG` | **執行** | 執行 Python 腳本(F5)、除錯模式(F9)、Shell 指令、虛擬環境偵測 | | **程式碼品質** | YAPF 格式化、儲存時格式化、PEP8 檢查、Ruff 靜態分析與問題面板、語言伺服器診斷與快速修正、含 traceback 與覆蓋率的 pytest 面板、JSON 重新格式化 | | **Git** | 分支管理、提交歷史、並排差異檢視器、行號區變更標記、逐個變更暫存與還原、行內 blame、擱置(stash)、衝突解決、稽核日誌 | -| **AI** | 透過 LangChain 整合 OpenAI GPT、互動式聊天面板、可設定模型與提示詞 | +| **AI** | 可切換供應者的對話面板:OpenAI 相容端點(LangChain)與 Anthropic(串流),各供應者有自己的模型、金鑰與提示詞 | | **主控台** | 互動式 Shell、Jupyter/IPython 主控台、指令歷史、多 Shell 支援 | | **瀏覽器** | 內嵌網頁瀏覽器、URL 導覽、頁面內搜尋 | | **外掛** | 自訂語法高亮、UI 翻譯、執行設定、自動探索 | @@ -262,7 +262,8 @@ pip install . | jedi | Python 自動補全與分析 | | ruff | 快速 Python 靜態分析工具 | | gitpython | Git 倉庫操作 | -| langchain_openai + langchain_core | AI/LLM 整合 | +| langchain_openai + langchain_core | OpenAI 相容的 AI 供應者 | +| anthropic | Anthropic 的 AI 供應者 | | watchdog | 檔案系統監控 | | pycodestyle | PEP8 風格檢查 | | qtconsole | Jupyter/IPython 主控台元件 | @@ -388,10 +389,11 @@ services.shutdown() ### AI 助手 -- **透過 LangChain 連接 OpenAI 模型** -- 連接 OpenAI 的語言模型。 -- **互動式聊天面板** -- 編輯器內的對話式 AI 面板。 -- **可設定模型** -- 設定自訂 API 金鑰、端點、模型名稱與系統提示詞。 -- **非同步訊息** -- 使用訊息佇列實現非阻塞 AI 互動。 +- **可切換的供應者** -- 對話面板與目前選用的供應者對話:透過 LangChain 連接任何 OpenAI 相容的端點,或透過官方 SDK 連接 Anthropic。外掛可以再登記別的供應者,面板不必改。 +- **是對話,不是單句** -- 追問時會帶著到目前為止的對話;**新對話** 重新開始。 +- **串流與取消** -- Anthropic 的回覆會邊產生邊顯示,**停止** 可以取消進行中的請求。 +- **每個供應者各自的設定** -- 每個供應者保管自己的 API 金鑰、端點、模型與系統提示詞。設定只套用在這次執行,勾選之後才會寫入磁碟。 +- **不會卡住視窗** -- 請求在背景執行緒進行;失敗時以對話框說明原因,供應者有回報時會顯示 token 用量。 ### 主控台與 REPL @@ -523,6 +525,7 @@ je_editor/ │ └── main_ui/ 主視窗、選單、工具列、面板、設定、AI、主控台 ├── core/ 服務層,不依賴 Qt:工作區、文件、診斷,以及語言服務、 │ 除錯、工作執行、遠端與 AI 的介面 +├── adapters/ 上述介面的實作,不依賴 Qt:AI 供應者 ├── code_scan/ Ruff 執行與 watchdog 檔案監控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 外掛註冊表與載入器 @@ -613,7 +616,7 @@ JEDITOR 將使用者設定儲存在工作目錄中的 `.jeditor/` 目錄裡: | `user_setting.json` | 一般偏好設定(字型、主題、語言、最近開啟的檔案、開啟中的分頁、重新指派過的快捷鍵) | | `user_color_setting.json` | 編輯器與輸出的配色,含語法高亮 | | `snippets.json` | 您自己的程式碼片段,疊加合併在內建片段集之上 | -| `ai_config.json` | AI 助手設定——啟動時讀取、從不寫入,需自行建立 | +| `ai_config.json` | AI 助手設定,以供應者分組——只有在 AI 設定對話框勾選存檔時才會寫入,因為金鑰是明文 | 每個檔案在被重寫前都會備份到 `.bak`。 diff --git a/architecture.md b/architecture.md index 345e17d..a2e486c 100644 --- a/architecture.md +++ b/architecture.md @@ -8,8 +8,8 @@ JEditor is a PySide6 code editor published as `je_editor` (stable) and `je_editor_dev` (dev). It provides syntax highlighting, folding, multi-cursor editing, an LSP client, ruff diagnostics, -a pytest panel, Git integration, an embedded browser, an IPython console and a LangChain/OpenAI -chat panel. It runs as a standalone app and also works as a library: PyBreeze subclasses its main +a pytest panel, Git integration, an embedded browser, an IPython console and an AI chat panel +with interchangeable providers (OpenAI-compatible and Anthropic). It runs as a standalone app and also works as a library: PyBreeze subclasses its main window, and plugins extend it through a small registry API. ## 2. Layers and directories @@ -22,6 +22,7 @@ window, and plugins extend it through a small registry API. | `je_editor/pyside_ui/code/` | `CodeEditor` (`plaintext_code_edit/`) plus its managers (folding, bookmarks, lint, LSP, diff/blame, snippets, multi-cursor), highlighters (`syntax/`), process runners (`code_process/`, `shell_process/`, `base_process_manager.py`) | | `je_editor/pyside_ui/dialog/`, `git_ui/`, `browser/` | Search/replace, shortcut, snippet and file dialogs; Git panel, commit graph, diff viewers; embedded QtWebEngine browser | | `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window consumes the diagnostics part so far: the editor's `LintManager` and the Problems panel hold their findings in the unified model (roadmap `docs/roadmap/2026-editor-next.md`) | +| `je_editor/adapters/` | Implementations of the `core/` interfaces, also Qt-free; third-party SDKs are imported at the point of use. `ai/`: `OpenAIProvider` (LangChain `ChatOpenAI`), `AnthropicProvider` (official `anthropic` SDK, streamed), the built-in registration and the `.jeditor/ai_config.json` reader/writer (which never logs the content). `default_services.py` builds an `EditorServices` with these registered | | `je_editor/utils/` | Pure logic with no widgets (only `multi_language/locale_match.py` imports Qt): text operations, encodings, sessions, diffs, symbols, LSP protocol, shortcut registry, theme colors, translations (`multi_language/`), logging, stdout/stderr redirect | | `je_editor/code_scan/` | ruff runner and watchdog file monitor, run on worker threads | | `je_editor/git_client/` | Git access: `GitService` (GitPython) and `GitCLI` (subprocess), blame, HEAD baseline, hunk staging | @@ -33,8 +34,8 @@ window, and plugins extend it through a small registry API. | `.github/workflows/` | `dev.yml`, `stable.yml`: tests on a Windows Python matrix, then one publish job each on `ubuntu-latest` (§3 PyPI packages) | | `.github/requirements/` | `publish.in` and the `publish.txt` generated from it: the build tooling of the two publish jobs, build backend (`setuptools`) included, pinned by version and hash. The jobs install nothing else and build with `python -m build --no-isolation`, so the backend is the locked one; the lock has to satisfy `build-system.requires` of `pyproject.toml` and `dev.toml` (`test/test_workflow_actions.py`). Dependabot keeps it current | -Dependencies point downwards: `pyside_ui/` → `core/` → `code_scan/`, `git_client/`, `plugins/` → -`utils/`. Most features are split into a pure function in `utils/` plus a thin Qt layer in +Dependencies point downwards: `pyside_ui/` → `adapters/` → `core/` → `code_scan/`, `git_client/`, +`plugins/` → `utils/`. Most features are split into a pure function in `utils/` plus a thin Qt layer in `pyside_ui/`. `test/test_core_architecture.py` enforces the direction: nothing `core/` imports, directly or indirectly, may be Qt or `pyside_ui/`; the packages below the UI may not import Qt or `pyside_ui/` except two listed modules (`utils/multi_language/locale_match.py` for `QLocale`, @@ -48,6 +49,9 @@ Qt cannot be imported. so headless CI can start it. - **Embedding**: `EditorMain(debug_mode, show_system_tray_ray, extend)`. `extend=True` skips the Windows app ID, the icon and tray, and the Plugins menu, so a host app can supply its own. +- **Window services**: `EditorMain.services` is the window's `EditorServices`, built by + `adapters/default_services.build_default_services()` and shut down in `closeEvent`. Panels read + it with `getattr(window, "services", None)` and build their own when a host window has none. - **Custom tabs**: `EDITOR_EXTEND_TAB: Dict[str, Type[QWidget]]` in `pyside_ui/main_ui/main_editor.py`. - **Plugin API** (`je_editor/plugins/__init__.py`, re-exported from `je_editor`): `register_programming_language`, `register_natural_language`, `register_plugin_run_config`, @@ -126,7 +130,9 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre its `NamedRegistry` attributes — `ai_providers`, `debug_adapters` (session factories), `task_runners`, `remote_transports` (by URI scheme) — and language services through `languages.register()`. Any source reports findings with `diagnostics.publish(source, uri, ...)`. - No implementation ships in `core/` yet; the roadmap milestones add them. + Implementations live in `adapters/`: the AI providers `openai` and `anthropic` so far. A plugin + adds another AI provider with `window.services.ai_providers.register(name, provider)`, and the + chat panel lists it with no change to the panel. ## 6. Cross-project boundaries @@ -141,7 +147,7 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre before you move or rename a module. It merges its UI strings by mutating the exported `english_word_dict` and `traditional_chinese_word_dict` in place. Treat these names, `EditorMain`'s constructor and the attributes PyBreeze uses (`tab_widget`, `menu`, `help_menu`) - as a contract. `test/test_public_api_contract.py` pins the exported names, those module paths + as a contract. `EditorMain` also sets `services`; PyBreeze does not use that name today. `test/test_public_api_contract.py` pins the exported names, those module paths and the constructor's arguments; it cannot see behaviour or attributes, and its list is a copy that has to be updated when PyBreeze starts importing something new. - **Translations**: a JEditor translation change must keep PyBreeze's diff --git a/architecture_explore.md b/architecture_explore.md index 6e1b324..4e873a1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 304 個 `.py`(184 個實作模組 + 120 個 `__init__.py`),共 33,234 行。 +> 涵蓋範圍:`je_editor/` 全部 311 個 `.py`(189 個實作模組 + 122 個 `__init__.py`),共 34,316 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -9,29 +9,30 @@ ## 1. 專案概觀 JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵蓋語法高亮、程式碼折疊、 -多重游標、LSP、ruff 診斷、pytest 面板、Git 整合、內嵌瀏覽器、IPython 主控台與 LangChain AI 對話。 +多重游標、LSP、ruff 診斷、pytest 面板、Git 整合、內嵌瀏覽器、IPython 主控台與可切換供應者的 AI 對話(OpenAI 相容、Anthropic)。 | 項目 | 內容 | | --- | --- | | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | -| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`frontengine` | -| 測試 | pytest + pytest-qt,106 個測試檔、約 16,700 行 | +| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`frontengine` | +| 測試 | pytest + pytest-qt,109 個測試檔、約 17,700 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 98 | 20,549 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 59 | 8,790 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `core/` | 14 | 2,281 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | +| `pyside_ui/` | 96 | 20,762 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 59 | 8,854 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `adapters/` | 5 | 539 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | +| `core/` | 16 | 2,547 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 33,234 行。) +(行數含各層 `__init__.py`,合計 34,316 行。) --- @@ -80,7 +81,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 106 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 109 個測試檔的原因。 --- @@ -146,7 +147,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(59 模組 / 8,790 行) +### 5.2 `utils/` — 純邏輯層(59 模組 / 8,854 行) #### 文字與行操作 @@ -224,10 +225,10 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `multi_language/english.py` | 503 | 英文字典(其他語言以此為鍵值基準) | -| `multi_language/traditional_chinese.py` | 493 | 繁體中文字典 | -| `multi_language/simplified_chinese.py` | 493 | 簡體中文字典 | -| `multi_language/japanese.py` | 497 | 日文字典 | +| `multi_language/english.py` | 519 | 英文字典(其他語言以此為鍵值基準) | +| `multi_language/traditional_chinese.py` | 509 | 繁體中文字典 | +| `multi_language/simplified_chinese.py` | 509 | 簡體中文字典 | +| `multi_language/japanese.py` | 513 | 日文字典 | | `multi_language/multi_language_wrapper.py` | 150 | `LanguageWrapper` 單例:註冊語言、切換、啟動語言決策 | | `multi_language/locale_match.py` | 116 | 系統語系 → 編輯器語言(含中文繁簡判定) | | `multi_language/retranslate_text.py` | 154 | 反查「這段文字是哪個鍵翻出來的」,用於換語言時就地換字 | @@ -310,7 +311,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `main_editor.py` | 614 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | +| `main_editor.py` | 624 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | | `editor/editor_widget.py` | 571 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | | `editor/editor_widget_dock.py` | 85 | `FullEditorWidget`:可停駐的單檔編輯器;關閉時只在有修改時,以檔案原本的編碼與行尾存回 | | `editor/process_input.py` | 104 | 對子程序(program / shell / debugger)送入標準輸入的視窗 | @@ -360,10 +361,8 @@ start_editor(debug_mode) je_editor/start_editor.py | `console_widget/console_gui.py` | 178 | 內嵌終端機 UI:指令歷史、切換工作目錄、輸出顯示 | | `console_widget/qprocess_adapter.py` | 120 | `QProcess` 包裝:啟動互動 shell、Windows 切 UTF-8 code page、送指令、停止 | | `ipython_widget/ipython_console.py` | 78 | qtconsole 的 IPython 分頁 | -| `ai_widget/chat_ui.py` | 149 | AI 對話 UI:載入設定、送出問題、輪詢回覆 | -| `ai_widget/langchain_interface.py` | 84 | LangChain + OpenAI 的呼叫封裝 | -| `ai_widget/ask_thread.py` | 36 | 在背景執行緒呼叫模型,避免卡 UI | -| `ai_widget/ai_config.py` | 34 | 模型設定與訊息佇列 | +| `ai_widget/chat_ui.py` | 321 | AI 對話面板:供應者與模型選單來自 `services.ai_providers`,對話由 `core/ai` 的 `ChatSession` 保管;送出、邊收邊顯示、停止、新對話、用量與錯誤對每個供應者都是同一段程式碼 | +| `ai_widget/chat_worker.py` | 129 | `ChatWorker(QObject)`:在 daemon 執行緒呼叫供應者,回覆片段、結果與錯誤以訊號送回 UI 執行緒;執行期間由模組層級的集合留住,面板先關掉也不會把訊號發送端刪掉 | | `plugin_browser/plugin_browser_widget.py` | 373 | 插件瀏覽器:列出遠端 repo 的插件、看中繼資料、下載到 `jeditor_plugins/` | | `plugin_browser/github_api.py` | 185 | GitHub API 存取:遞迴取檔案樹、解析插件中繼資料、下載(限制 scheme、目的路徑防穿越) | @@ -388,7 +387,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `file_dialog/open_file_dialog.py` | 102 | 開檔流程:選檔後交給目前分頁的 `EditorWidget.open_an_file()`(已開啟就切分頁、記下編碼與行尾);另有選資料夾更新專案樹 | | `file_dialog/save_file_dialog.py` | 157 | 另存新檔:依插件語言動態建立篩選器並依副檔名預選;寫檔失敗時分頁保留原路徑、不標已存;`report_save_failure()` 是各存檔路徑共用的失敗訊息 | | `file_dialog/create_file_dialog.py` | 77 | 建立新檔案 | -| `ai_dialog/set_ai_dialog.py` | 71 | 設定 AI 模型參數 | +| `ai_dialog/set_ai_dialog.py` | 127 | 依供應者設定位址、金鑰(輸入時遮蔽)、模型與系統提示詞;預設只套用到這次執行,勾選才寫入 `.jeditor/ai_config.json` | ### 5.9 `pyside_ui/git_ui/` @@ -413,7 +412,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | -### 5.11 `core/` — 核心服務層(14 模組 / 2,281 行) +### 5.11 `core/` — 核心服務層(16 模組 / 2,547 行) 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 逐個里程碑改接過來。第一批使用者是診斷:編輯器的 `LintManager` 與問題面板都以統一模型保存診斷。整層不匯入 Qt 也不匯入 `pyside_ui/`; @@ -422,7 +421,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `__init__.py` | 58 | 核心層的公開 API(`__all__`) | -| `services/editor_services.py` | 77 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | +| `services/editor_services.py` | 81 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | | `events/event_hook.py` | 99 | `EventHook`:不靠 Qt 的訂閱與通知;在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄、不擋其他人 | | `registry/named_registry.py` | 116 | `NamedRegistry[T]`:名稱對應實作的登記表,AI 供應者、除錯轉接器、遠端傳輸、工作執行器共用 | | `uri/resource_uri.py` | 91 | 資源 URI:`to_uri` / `to_path`(沿用 `utils/lsp/lsp_protocol` 的轉換)、`uri_scheme`、`uri_key`(同一個本機檔案的不同寫法得到同一個鍵) | @@ -436,9 +435,25 @@ start_editor(debug_mode) je_editor/start_editor.py | `process/task_service.py` | 169 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream` | | `remote/remote_session.py` | 91 | `RemoteSession` 協定與 `RemoteState`;`task_runner()` 回傳與本機相同的 `TaskRunner` 介面 | | `ai/ai_provider.py` | 167 | `AIProvider` 協定與資料物件(`ChatRequest`、`ChatMessage`、`ChatRole`、`ChatResponse`、`ModelInfo`、`CancelToken`) | +| `ai/ai_settings.py` | 166 | `ProviderSettings` 與 `AISettings`:依供應者分組的設定(金鑰、位址、模型、系統提示詞)與目前選用的供應者;舊格式的 `AI_model` 會被讀成 `openai` 那一組 | +| `ai/chat_session.py` | 94 | `ChatSession`:保管一段對話、組出下一個請求;失敗或被取消的那一句不留在對話裡 | -除錯、工作執行、遠端與 AI 四項目前只有介面,`core/` 裡沒有實作;既有的 pdb 除錯、`BaseProcessManager` 與 -LangChain 對話仍然走原本的路徑。 +除錯、工作執行、遠端三項目前只有介面;既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 +`adapters/ai/`,對話面板已經改走 `AIProvider`。 + +### 5.12 `adapters/` — 核心介面的實作(5 模組 / 539 行) + +`core/` 只有介面;真正去連某一家服務的程式碼放在這裡。跟 `core/` 一樣不匯入 Qt 與 `pyside_ui/` +(`test_core_architecture.py` 把它列進 UI 層以下的套件),第三方 SDK 都在用到的時候才匯入。 + +| 模組 | 行 | 功用 | +| --- | ---: | --- | +| `__init__.py` | 58 | 套件說明 | +| `default_services.py` | 52 | `build_default_services()`:建立 `EditorServices`、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | +| `ai/openai_provider.py` | 144 | `OpenAIProvider`:透過 LangChain 的 `ChatOpenAI` 呼叫 OpenAI 相容端點;回覆整份回來後去掉 `` 之前的思考過程 | +| `ai/anthropic_provider.py` | 201 | `AnthropicProvider`:官方 `anthropic` SDK 的串流請求;可中途取消、回報 token 用量、把 SDK 的錯誤類別轉成給使用者看的說明;會拒絕請求的模型啟用伺服器端 fallback | +| `ai/builtin_providers.py` | 50 | `register_builtin_ai_providers()`:每個供應者拿到「取得自己那組設定」的函式,所以改設定不必重新登記 | +| `ai/settings_file.py` | 80 | `.jeditor/ai_config.json` 的讀寫;日誌只記路徑、從不記內容(裡面有 API 金鑰) | --- @@ -451,7 +466,7 @@ UI 執行緒不做 I/O 是硬性規則,重活分成三類: | 方式 | 使用者 | | --- | --- | | `QThread` + Signal | `LintWorker`、`BaselineLoader`、`BlameLoader`、`ProjectLintWorker`、`TodoScanThread`、`PytestRunThread`、`FileIndexThread`、`_SearchWorker`、`_GitBranchScan`、`_GitCheckout` | -| `threading.Thread` | `CodeEditSaveThread`(自動儲存)、`RuffThread`、`WatchdogThread`、`AskThread`(AI)、插件瀏覽器的下載 worker | +| `threading.Thread` | `CodeEditSaveThread`(自動儲存)、`RuffThread`、`WatchdogThread`、`ChatWorker` 的請求執行緒(AI)、插件瀏覽器的下載 worker | | `QProcess` / `subprocess` | 執行使用者程式與 shell(`BaseProcessManager` 以讀取執行緒 + 佇列 + timer 拉取輸出)、終端機(`ConsoleProcessAdapter`)、ruff、pytest、git CLI | 搭配的節流機制:diff 標記 400ms、lint 900ms、jedi 補全 300ms、縮圖重畫 300ms、輸出拉取 50ms、設定儲存 60s。 @@ -524,7 +539,7 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff ## 7. 測試與 CI -- `test/` 106 個測試檔、約 16,700 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 109 個測試檔、約 17,700 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -582,6 +597,6 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, 得等可嵌入元件那個里程碑處理頂層 `__init__`。 -9. **`pyside_ui/` 底下還有不含 Qt 的模組**:`ai_widget/ai_config.py`、`plugin_browser/github_api.py`、 +9. **`pyside_ui/` 底下還有不含 Qt 的模組**:`plugin_browser/github_api.py`、 `save_settings/user_setting_file.py`、`code/running_process_manager.py` 等本身不匯入 Qt,卻放在 UI 套件裡; 其中幾個路徑是 PyBreeze 的契約,搬動時要留相容匯入。 diff --git a/dev.toml b/dev.toml index 3003696..272be54 100644 --- a/dev.toml +++ b/dev.toml @@ -19,7 +19,8 @@ license = "MIT" license-files = ["LICENSE"] dependencies = [ "PySide6==6.11.2", "qt-material", "yapf", "frontengine", "pycodestyle", "jedi", - "qtconsole", "langchain_openai==1.6.2", "langchain_core", "pydantic", "watchdog", "ruff", "gitpython>=3.1.59" + "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", + "watchdog", "ruff", "gitpython>=3.1.59" ] classifiers = [ "Programming Language :: Python :: 3.10", diff --git a/dev_requirements.txt b/dev_requirements.txt index 341faa0..25334d3 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,6 +1,7 @@ PySide6==6.11.2 langchain_openai==1.6.2 langchain_core +anthropic==1.11.0 ruff sphinx twine diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index 5ff50cf..f752bc2 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -12,15 +12,18 @@ | Milestone | Status | Record | | --- | --- | --- | | M0 — Foundation and compatibility boundary | Implemented: `je_editor/core/` | `docs/updates/2026-10.md`, U-20261008-01 | -| M1 – M8 | Not started | `PROGRESS.md` | - -M0 defines the service layer and proves it runs without Qt. It deliberately stops short of three -things, each left to the milestone that needs it: - -- the editor window does not consume the services yet; -- debugging, task execution, remote sessions and AI providers are interfaces with no - implementation in `core/` (M4, M6 and M5 supply them), and the request-and-reply calls of a - language service (completion, hover and the rest) take their shape in M2; +| M2 — diagnostics half | Implemented: one diagnostic model, severity and source filters | U-20261008-04 | +| M2 — Tree-sitter half | Not started | `PROGRESS.md` | +| M5 — AI provider abstraction + Anthropic | Implemented: `je_editor/adapters/ai/` | U-20261008-05 | +| M1, M3, M4, M6, M7, M8 | Not started | `PROGRESS.md` | + +M0 defines the service layer and proves it runs without Qt. What it left to later milestones: + +- the editor window consumes the services one area at a time: diagnostics and the AI chat panel + do so far; +- debugging, task execution and remote sessions are interfaces with no implementation yet (M4 + and M6 supply them), and the request-and-reply calls of a language service (completion, hover + and the rest) take their shape with Tree-sitter in M2; - `import je_editor.core` still runs `je_editor/__init__.py`, which imports Qt (M7). ## Goals diff --git a/docs/source/docs/Eng/ai_assistant.rst b/docs/source/docs/Eng/ai_assistant.rst index c3d484e..50789a3 100644 --- a/docs/source/docs/Eng/ai_assistant.rst +++ b/docs/source/docs/Eng/ai_assistant.rst @@ -1,75 +1,159 @@ AI Assistant ============= -JEditor integrates an AI-powered chat assistant using `LangChain `_ -and OpenAI-compatible APIs. The AI panel allows you to have conversations with a large language -model directly within the editor. +JEditor has a chat panel for talking to a large language model without leaving the editor. +The panel is provider-neutral: it sends a conversation to whichever provider is selected and +shows the reply, and it contains no code that belongs to one vendor. + +Two providers ship with the editor: + +.. list-table:: + :header-rows: 1 + :widths: 18 82 + + * - Provider + - What it talks to + * - ``openai`` + - Any OpenAI-compatible endpoint, through `LangChain `_. + You name the address, the key and the model. + * - ``anthropic`` + - Anthropic's Messages API, through the official ``anthropic`` SDK. The reply is + streamed, so it appears as it is generated. + +Open the panel from **Tab → ChatUI** or **Dock → AI**. Setup ------ -Before using the AI assistant, you need to configure it: - -1. Open the AI configuration dialog from the menu -2. Set the following parameters: +Press **Set AI setting** in the panel and fill in the provider you want to use. Every field +may be left empty. .. list-table:: :header-rows: 1 - :widths: 25 75 + :widths: 22 78 * - Setting - Description - * - **API Base URL** - - The API endpoint (e.g., ``https://api.openai.com/v1``) - * - **API Key** - - Your OpenAI API key - * - **Model** - - The model to use (e.g., ``gpt-3.5-turbo``, ``gpt-4``, or any custom model) - * - **System Prompt** - - A template that sets the AI's behavior and context - -What you enter in the dialog applies to the current session. To have the settings loaded -on every launch, write them to ``.jeditor/ai_config.json`` yourself — the editor reads -that file at startup but never writes it, so your key is only ever stored where you put -it: + * - **Provider** + - Which provider these settings belong to. Each provider keeps its own key, address, + model and prompt, so switching back and forth loses nothing. + * - **AI server URL** + - The service address. Required for ``openai`` (for example + ``https://api.openai.com/v1``). Leave it empty for ``anthropic`` unless you go + through a proxy. + * - **AI server API Key** + - The key, shown as dots while you type. Leave it empty for ``anthropic`` to use the + ``ANTHROPIC_API_KEY`` environment variable or a logged-in profile. + * - **AI Model** + - The model to use. ``anthropic`` offers ``claude-opus-5-5`` (the default), + ``claude-sonnet-5-5``, ``claude-haiku-4-5`` and ``claude-fable-5-1``; any other + model id can be typed in. ``openai`` has no fixed list. + * - **System prompt** + - Instructions sent with every request. + +**Apply** uses the settings for this session and writes nothing to disk, so a key is only +ever stored where you put it. Tick **Also save to .jeditor/ai_config.json** to keep them for +the next launch. The file stores the key as plain text; add ``.jeditor/`` to ``.gitignore`` +so it is never committed. + +The settings file is grouped by provider: .. code-block:: json { - "AI_model": { - "ai_base_url": "https://api.openai.com/v1", - "ai_api_key": "...", - "chat_model": "gpt-4", - "prompt_template": "" + "active_provider": "anthropic", + "providers": { + "anthropic": { + "api_key": "", + "base_url": "", + "model": "claude-opus-5-5", + "system_prompt": "Answer briefly." + }, + "openai": { + "api_key": "...", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "system_prompt": "" + } } } -``.jeditor/`` is worth adding to ``.gitignore`` so the key is never committed. Once a -model is configured, the editor exports ``OPENAI_BASE_URL``, ``OPENAI_API_KEY`` and -``CHAT_MODEL`` into the environment for the LangChain packages to pick up. +A file in the older format, with a single ``AI_model`` group, still loads: that group +becomes the ``openai`` provider. **Load AI setting** reads the file again. + +.. note:: + + Earlier versions exported ``OPENAI_BASE_URL``, ``OPENAI_API_KEY`` and ``CHAT_MODEL`` + into the editor's environment, which also handed the key to every program the editor + started. The editor no longer does this. Set those variables yourself if something you + run depends on them. Chat Interface --------------- -The AI chat panel provides: +- **Provider** and **Model** — Choose who answers. The model list comes from the provider, + and the box accepts a model id that is not listed. +- **Send prompt** — Sends what is in the input box (``Enter`` does the same). The whole + conversation so far goes with it, so follow-up questions have their context. +- **Stop** — Cancels the request in flight. A streamed reply stops at the next piece of + text; what had arrived stays on screen but does not become part of the conversation. +- **New chat** — Forgets the conversation and clears the panel. +- **Status** — Shows whether a reply is awaited, and afterwards how many tokens the request + and the reply used when the provider reports it. +- **Font size** — Changes the size of the text in the panel. -- **Message history** — Scrollable chat history with all previous messages -- **Input field** — Type your prompt at the bottom of the panel -- **Font size adjustment** — Customize the chat panel's font size -- **Read-only message area** — Chat history is displayed in a read-only area +Requests run on a background thread, so the editor stays usable while a reply is on its way. -Async Communication +Anthropic specifics -------------------- -AI requests are handled asynchronously to keep the editor responsive: - -- Messages are sent to the AI in a background thread -- Responses are pulled back using a configurable timer interval -- A message queue ensures orderly communication -- The UI remains fully interactive while waiting for responses +- Replies are streamed with a generous output limit, so long answers are not cut short. +- For ``claude-opus-5-5``, ``claude-opus-5``, ``claude-sonnet-5-5`` and + ``claude-fable-5-1`` the request opts into the server-side refusal fallback: if the + model's safety classifiers decline a request, the service re-runs it on another model + instead of returning the refusal. This is skipped when a custom **AI server URL** is set, + because the feature only exists on Anthropic's own API. +- If the model still declines, the panel reports it as a failed request and the partial + text is not kept as an answer. Error Handling --------------- -If the AI request fails (e.g., network error, invalid API key), JEditor shows a clear error -dialog describing the problem. The chat session continues to work after resolving the issue. +A failed request shows a dialog that says what went wrong: a rejected key, an unknown model, +rate limiting, a network failure or missing credentials each have their own message. The +prompt that failed is dropped from the conversation, so the next request does not carry a +question nobody answered. + +Adding a Provider +------------------ + +A provider is any object with a ``name``, a ``models()`` method and a ``complete()`` method; +see ``AIProvider`` in :doc:`core_services`. Register it on the window's services and it +appears in the panel's provider list: + +.. code-block:: python + + from je_editor.core import ChatResponse, ModelInfo + + + class ShoutingProvider: + """Answers by repeating the question in upper case.""" + + name = "shouting" + + def models(self): + return [ModelInfo("shout-1", "Shout")] + + def complete(self, request, on_text=None, cancel=None): + text = request.messages[-1].content.upper() + if on_text is not None: + on_text(text) + return ChatResponse(text, "shout-1") + + + def add_to(window): + """Call with the editor window, for example from a plugin's ``register()``.""" + window.services.ai_providers.register(ShoutingProvider.name, ShoutingProvider()) + +``complete()`` is called on a background thread and may block. Raise +``JEditorServiceException`` with a message for the user when the request fails. diff --git a/docs/source/docs/Eng/configuration.rst b/docs/source/docs/Eng/configuration.rst index 4ca3999..55e26fa 100644 --- a/docs/source/docs/Eng/configuration.rst +++ b/docs/source/docs/Eng/configuration.rst @@ -98,15 +98,17 @@ falls back to the current theme's value, so a partial file is fine. ai_config.json ^^^^^^^^^^^^^^^ -AI assistant configuration (see :doc:`ai_assistant` for details): +AI assistant configuration (see :doc:`ai_assistant` for details), grouped by provider. +Each provider has: - API base URL - API key - Model name -- System prompt template +- System prompt -Unlike the two files above, this one is read but never written — create it yourself if -you want the settings loaded on every launch. +The provider in use is recorded as well. Unlike the two files above, the editor does not +write this one by default — only when saving is ticked in the AI settings dialog, because +the key in it is plain text. You can also create it yourself. Theming -------- diff --git a/docs/source/docs/Eng/core_services.rst b/docs/source/docs/Eng/core_services.rst index 739fab8..f1f0ce5 100644 --- a/docs/source/docs/Eng/core_services.rst +++ b/docs/source/docs/Eng/core_services.rst @@ -210,8 +210,9 @@ services for a document. Debugging, Tasks, Remote Sessions and AI Providers --------------------------------------------------- -These four are interfaces with their data objects. JEditor ships no implementation of them in -this layer yet; a host or a plugin registers its own. +These four are interfaces with their data objects. The implementations live in +``je_editor.adapters``, outside this layer. So far that is the two AI providers (``openai`` and +``anthropic``, see :doc:`ai_assistant`); a host or a plugin registers its own for the rest. .. list-table:: :header-rows: 1 diff --git a/docs/source/docs/Eng/getting_started.rst b/docs/source/docs/Eng/getting_started.rst index 24f75f3..1708958 100644 --- a/docs/source/docs/Eng/getting_started.rst +++ b/docs/source/docs/Eng/getting_started.rst @@ -101,5 +101,5 @@ When JEditor starts, it creates a ``.jeditor/`` directory in the current working reassigned shortcuts - ``user_color_setting.json`` — Color scheme for editor and output -Both are created automatically on first launch. A third file, ``ai_config.json``, is read -if you write it yourself; see :doc:`ai_assistant`. +Both are created automatically on first launch. A third file, ``ai_config.json``, holds the +AI assistant's settings and is only written when you ask for it; see :doc:`ai_assistant`. diff --git a/docs/source/docs/Zh/ai_assistant.rst b/docs/source/docs/Zh/ai_assistant.rst index 062c3e6..5423cbe 100644 --- a/docs/source/docs/Zh/ai_assistant.rst +++ b/docs/source/docs/Zh/ai_assistant.rst @@ -1,73 +1,147 @@ -AI 助手 -======== +AI 助理 +======= -JEditor 整合了基於 `LangChain `_ 和 OpenAI 相容 API 的 -AI 聊天助手。AI 面板讓您可以直接在編輯器內與大型語言模型對話。 +JEditor 有一個對話面板,不必離開編輯器就能與大型語言模型對話。面板不綁定任何一家供應者: +它把對話送給目前選用的供應者並顯示回覆,裡面沒有任何一家專屬的程式碼。 -設定 ------ +編輯器內建兩個供應者: + +.. list-table:: + :header-rows: 1 + :widths: 18 82 + + * - 供應者 + - 連到哪裡 + * - ``openai`` + - 任何 OpenAI 相容的端點,透過 `LangChain `_ 呼叫。 + 位址、金鑰與模型都由您填寫。 + * - ``anthropic`` + - Anthropic 的 Messages API,透過官方的 ``anthropic`` SDK 呼叫。回覆以串流方式取得, + 所以會邊產生邊顯示。 -使用 AI 助手前,您需要先進行設定: +從 **Tab → ChatUI** 或 **Dock → AI** 開啟面板。 -1. 從選單開啟 AI 設定對話框 -2. 設定以下參數: +設定 +---- + +按面板上的 **設定 AI 設定**,填入要使用的供應者。每個欄位都可以留空。 .. list-table:: :header-rows: 1 - :widths: 25 75 + :widths: 22 78 - * - 設定項目 + * - 設定 - 說明 - * - **API Base URL** - - API 端點(例如 ``https://api.openai.com/v1``) - * - **API Key** - - 您的 OpenAI API 金鑰 - * - **Model** - - 使用的模型(例如 ``gpt-3.5-turbo``、``gpt-4`` 或任何自訂模型) - * - **System Prompt** - - 設定 AI 行為與上下文的範本 - -在對話框中輸入的內容只套用於目前這次執行。若希望每次啟動都載入設定,請自行撰寫 -``.jeditor/ai_config.json``——編輯器只在啟動時讀取這個檔案,從不寫入它,因此您的金鑰 -只會存在您自己放的地方: + * - **供應者** + - 這組設定屬於哪個供應者。每個供應者各自保管金鑰、位址、模型與提示詞,來回切換不會 + 互相蓋掉。 + * - **AI 伺服器 URL** + - 服務的位址。 ``openai`` 必填(例如 ``https://api.openai.com/v1`` )。 ``anthropic`` + 除非經過代理,否則留空。 + * - **AI 伺服器 API Key** + - 金鑰,輸入時顯示為圓點。 ``anthropic`` 留空時會使用環境變數 ``ANTHROPIC_API_KEY`` + 或已登入的設定檔。 + * - **AI Model** + - 要使用的模型。 ``anthropic`` 提供 ``claude-opus-5-5`` (預設)、 + ``claude-sonnet-5-5`` 、 ``claude-haiku-4-5`` 與 ``claude-fable-5-1`` ,也可以自行 + 輸入其他模型代號。 ``openai`` 沒有固定清單。 + * - **系統提示詞** + - 每次請求都會一併送出的指示。 + +**套用** 只把設定用在這次執行,不寫入磁碟,所以金鑰只會存在您自己放的地方。勾選 +**同時存到 .jeditor/ai_config.json** 才會保留到下次啟動。這個檔案以明文儲存金鑰;請把 +``.jeditor/`` 加進 ``.gitignore`` ,避免金鑰被提交。 + +設定檔以供應者分組: .. code-block:: json { - "AI_model": { - "ai_base_url": "https://api.openai.com/v1", - "ai_api_key": "...", - "chat_model": "gpt-4", - "prompt_template": "" + "active_provider": "anthropic", + "providers": { + "anthropic": { + "api_key": "", + "base_url": "", + "model": "claude-opus-5-5", + "system_prompt": "Answer briefly." + }, + "openai": { + "api_key": "...", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "system_prompt": "" + } } } -建議把 ``.jeditor/`` 加進 ``.gitignore``,金鑰才不會被提交。設定好模型之後,編輯器會把 -``OPENAI_BASE_URL``、``OPENAI_API_KEY`` 與 ``CHAT_MODEL`` 匯出到環境變數,供 LangChain -套件取用。 +舊格式的檔案(只有一組 ``AI_model`` )仍然讀得進來:那一組會成為 ``openai`` 供應者的設定。 +**載入 AI 設定** 會重新讀取這個檔案。 + +.. note:: -聊天介面 ---------- + 先前的版本會把 ``OPENAI_BASE_URL`` 、 ``OPENAI_API_KEY`` 與 ``CHAT_MODEL`` 匯出到編輯器的 + 環境變數,這也等於把金鑰交給編輯器啟動的每一個程式。現在不再這麼做。如果您執行的程式 + 依賴這些變數,請自行設定。 -AI 聊天面板提供: +對話介面 +-------- -- **訊息歷史** — 可捲動的聊天歷史,包含所有先前的訊息 -- **輸入欄位** — 在面板底部輸入提示詞 -- **字型大小調整** — 自訂聊天面板的字型大小 -- **唯讀訊息區域** — 聊天歷史以唯讀方式顯示 +- **供應者** 與 **模型** — 選擇由誰回答。模型清單來自供應者,也可以輸入清單以外的模型代號。 +- **傳送 prompt** — 送出輸入框的內容(按 ``Enter`` 也可以)。到目前為止的整段對話會一併 + 送出,所以追問時模型知道前後文。 +- **停止** — 取消進行中的請求。串流的回覆會在下一段文字到達時停下;已經收到的部分留在畫面上, + 但不算進對話。 +- **新對話** — 忘掉目前的對話並清空面板。 +- **狀態** — 顯示是否正在等待回覆;完成後,若供應者有回報,會顯示請求與回覆各用了多少 token。 +- **字型大小** — 調整面板文字的大小。 -非同步通訊 ------------ +請求在背景執行緒進行,等待回覆時編輯器仍然可以操作。 -AI 請求以非同步方式處理,保持編輯器的回應能力: +Anthropic 的細節 +---------------- -- 訊息在背景執行緒中傳送給 AI -- 回應透過可設定的計時器間隔拉取回來 -- 訊息佇列確保有序通訊 -- 等待回應期間 UI 保持完全互動 +- 回覆以串流取得,輸出上限設得夠大,長的回答不會被截斷。 +- 使用 ``claude-opus-5-5`` 、 ``claude-opus-5`` 、 ``claude-sonnet-5-5`` 與 + ``claude-fable-5-1`` 時,請求會啟用伺服器端的拒絕後備機制:模型的安全分類器拒絕請求時, + 服務會改用另一個模型重跑,而不是把拒絕傳回來。設定了自訂的 **AI 伺服器 URL** 時不會啟用, + 因為這個功能只存在於 Anthropic 自己的 API。 +- 如果模型仍然拒絕,面板會把它當成失敗的請求回報,已收到的片段不會被當成答案。 錯誤處理 ---------- +-------- + +請求失敗時會跳出對話框說明原因:金鑰被拒、模型不存在、被限流、網路失敗或找不到認證,各有自己的 +訊息。失敗的那一句會從對話中移除,所以下一次請求不會帶著一句沒人回答過的話。 + +新增供應者 +---------- + +供應者是任何具有 ``name`` 、 ``models()`` 方法與 ``complete()`` 方法的物件,介面請見 +:doc:`core_services` 的 ``AIProvider`` 。把它登記到視窗的服務上,面板的供應者清單就會出現它: + +.. code-block:: python + + from je_editor.core import ChatResponse, ModelInfo + + + class ShoutingProvider: + """把問題轉成大寫當作回答。""" + + name = "shouting" + + def models(self): + return [ModelInfo("shout-1", "Shout")] + + def complete(self, request, on_text=None, cancel=None): + text = request.messages[-1].content.upper() + if on_text is not None: + on_text(text) + return ChatResponse(text, "shout-1") + + + def add_to(window): + """以編輯器視窗呼叫,例如在外掛的 ``register()`` 裡。""" + window.services.ai_providers.register(ShoutingProvider.name, ShoutingProvider()) -如果 AI 請求失敗(例如網路錯誤、無效的 API 金鑰),JEditor 會顯示清楚的錯誤對話框 -描述問題。解決問題後,聊天工作階段可繼續正常使用。 +``complete()`` 會在背景執行緒被呼叫,可以等待。請求失敗時請丟出 ``JEditorServiceException`` , +訊息會顯示給使用者。 diff --git a/docs/source/docs/Zh/configuration.rst b/docs/source/docs/Zh/configuration.rst index a300b7d..f347e53 100644 --- a/docs/source/docs/Zh/configuration.rst +++ b/docs/source/docs/Zh/configuration.rst @@ -98,15 +98,15 @@ user_color_setting.json ai_config.json ^^^^^^^^^^^^^^^ -AI 助手設定(詳見 :doc:`ai_assistant`): +AI 助手設定(詳見 :doc:`ai_assistant`),以供應者分組。每個供應者各有: - API Base URL - API 金鑰 - 模型名稱 -- 系統提示詞範本 +- 系統提示詞 -與上面兩個檔案不同,這個檔案只會被讀取、不會被寫入——若希望每次啟動都載入設定,請自行 -建立它。 +另外記錄目前選用的供應者。與上面兩個檔案不同,編輯器預設不會寫入這個檔案——只有在 AI 設定 +對話框勾選存檔時才會寫入,因為裡面的金鑰是明文。您也可以自行建立它。 主題 ----- diff --git a/docs/source/docs/Zh/core_services.rst b/docs/source/docs/Zh/core_services.rst index a007e23..73f5190 100644 --- a/docs/source/docs/Zh/core_services.rst +++ b/docs/source/docs/Zh/core_services.rst @@ -204,8 +204,8 @@ EditorServices 除錯、工作執行、遠端工作階段與 AI 供應者 ---------------------------------------- -這四項是介面加上各自的資料物件。JEditor 目前在這一層還沒有提供它們的實作,由宿主程式或外掛自行 -登記。 +這四項是介面加上各自的資料物件。實作放在這一層之外的 ``je_editor.adapters`` ,目前有兩個 AI 供應者 +( ``openai`` 與 ``anthropic`` ,見 :doc:`ai_assistant` );其餘的由宿主程式或外掛自行登記。 .. list-table:: :header-rows: 1 diff --git a/docs/source/docs/Zh/getting_started.rst b/docs/source/docs/Zh/getting_started.rst index 47e107f..e7b3c54 100644 --- a/docs/source/docs/Zh/getting_started.rst +++ b/docs/source/docs/Zh/getting_started.rst @@ -100,5 +100,5 @@ JEditor 啟動時會在目前工作目錄下建立 ``.jeditor/`` 資料夾,用 重新指派過的快捷鍵 - ``user_color_setting.json`` — 編輯器與輸出的色彩配置 -這兩個檔案會在首次啟動時自動建立。第三個檔案 ``ai_config.json`` 需要您自行撰寫,編輯器 -只會讀取它;詳見 :doc:`ai_assistant`。 +這兩個檔案會在首次啟動時自動建立。第三個檔案 ``ai_config.json`` 保存 AI 助手的設定,只有 +在您要求時才會寫入;詳見 :doc:`ai_assistant`。 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 7b4d0c8..fadf679 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -163,3 +163,30 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **文件**:`docs/source/docs/{Eng,Zh}/code_quality.rst` 的問題面板一節、三份 README 的「隨打隨查」、`architecture.md` §2、`architecture_explore.md`(§5.6、§5.7、§5.11、§8,各模組行數與套件規模改由程式碼重算,順便更正了原本就過時的幾個數字)。 - **檔案**:`je_editor/core/diagnostics/lsp_diagnostics.py`(新)、`je_editor/core/diagnostics/legacy_diagnostics.py`、`je_editor/utils/lsp/lsp_protocol.py`、`je_editor/code_scan/ruff_lint.py`、`je_editor/pyside_ui/code/lint/lint_manager.py`、`je_editor/pyside_ui/code/lsp/lsp_client.py`、`je_editor/pyside_ui/code/minimap/minimap_widget.py`、`je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py`、`je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py`、四份語言字典、上述測試與文件、`PROGRESS.md`。 - **待辦**:`PROGRESS.md` #10(Tree-sitter)、#21(偶發的 Qt 中止)。 + +## U-20261008-05 · 2026-10-08 · 藍圖 M5:AI 對話面板改成可切換供應者,新增 Anthropic 後端 · #done #roadmap #ai #deps + +- **做了什麼**:完成 `PROGRESS.md` #13(藍圖 M5)。AI 對話面板不再綁定 LangChain + OpenAI:它只跟 `services.ai_providers` 要供應者,內建 `openai`(OpenAI 相容端點)與 `anthropic` 兩個。 + - 新套件 `je_editor/adapters/`:`core/` 介面的實作,同樣不匯入 Qt(已列進 `test_core_architecture.py` 的 UI 層以下套件),第三方 SDK 用到時才匯入。 + - `ai/openai_provider.py`:透過 LangChain 的 `ChatOpenAI`。回覆整份回來後去掉 `` 之前的思考過程(原本 `LangChainInterface` 的行為);現在會送出系統提示詞與整段對話,有回報時帶 token 用量。 + - `ai/anthropic_provider.py`:官方 `anthropic` SDK 的 `messages.stream()`,邊收邊交出文字、可中途取消、回報 token 用量與實際回答的模型;`stop_reason` 為 `refusal` 時當成失敗、不把已收到的片段當答案;SDK 的錯誤類別(金鑰被拒、無權限、模型不存在、限流、請求不合法、其他狀態錯誤、連線失敗)各有一句說明,找不到認證時也有。預設模型 `claude-opus-5-5`,選單另列 `claude-sonnet-5-5`、`claude-haiku-4-5`、`claude-fable-5-1`。 + - `ai/settings_file.py`:`.jeditor/ai_config.json` 的讀寫。沒有沿用共用的 `write_json`,因為它會把寫入的內容(含金鑰)整份記進日誌;這裡只記路徑。 + - `default_services.py`:`build_default_services()` 建立 `EditorServices`、載入設定、登記內建供應者。 + - `core/ai/ai_settings.py`:設定改成依供應者分組(金鑰、位址、模型、系統提示詞各一組,另記目前選用的供應者)。舊格式的 `AI_model` 仍讀得進來,當成 `openai` 那一組。`core/ai/chat_session.py`:保管對話、組出下一個請求,失敗或被取消的那一句不留在對話裡。 + - `EditorMain.services`(新屬性):視窗的 `EditorServices`,`closeEvent` 會取消還在等回覆的請求並呼叫 `shutdown()`。面板以 `getattr(window, "services", None)` 取得,宿主視窗沒有時自己建一組。 + - 對話面板重寫:供應者與模型選單、整段對話、串流顯示、停止、新對話、狀態列(等待中、token 用量、已取消、失敗)、`retranslate()`。請求改在 daemon 執行緒進行,結果以訊號送回 UI 執行緒;原本的錯誤對話框是從背景執行緒直接開的。 + - AI 設定對話框重寫:依供應者填寫,金鑰輸入時遮蔽。原本的對話框寫進設定的鍵名(`base_url`、`api_key`)跟讀取端用的(`ai_base_url`、`ai_api_key`)對不上,警告訊息用的字典鍵也拼錯,所以從對話框設定其實沒有作用。 + - 移除 `ai_widget/langchain_interface.py`、`ask_thread.py`、`ai_config.py`(PyBreeze 沒有使用,也不在 `je_editor.__all__`)。`test_langchain_interface.py` 的七個測試搬到 `test_ai_providers.py`,對象改成 `OpenAIProvider`。 +- **決定**: + - **用官方 `anthropic` SDK,不用 `langchain_anthropic`**:藍圖寫明不要求每個供應者都走 LangChain,直接用 SDK 才拿得到串流、取消與具體的錯誤類別。 + - **釘 `anthropic==1.11.0`**:最新的 1.12.0 是 2026-10-07 才上傳的,還在本專案對新版本的七天等待期內;1.11.0 是 2026-09-30 上傳的。四個地方都釘(`pyproject.toml`、`dev.toml`、兩個 requirements 檔)。 + - **設定預設不寫入磁碟**:文件原本就寫明「編輯器只讀這個檔、從不寫入,金鑰只會存在您放的地方」。對話框的「套用」只影響這次執行;要存檔得勾選,勾選處寫明金鑰是明文。 + - **不再把 `OPENAI_BASE_URL`、`OPENAI_API_KEY`、`CHAT_MODEL` 寫進環境變數**:那等於把金鑰交給編輯器啟動的每一個子程序(執行使用者程式、終端機、ruff、git)。`ChatOpenAI` 直接吃參數,不需要環境變數。文件加了相容性說明。 + - **會拒絕請求的模型預設啟用伺服器端 fallback**(`claude-opus-5-5`、`claude-opus-5`、`claude-sonnet-5-5`、`claude-fable-5-1`;beta `server-side-fallback-2026-07-01`、`fallbacks="default"`):安全分類器拒絕時由服務改用另一個模型重跑,而不是把拒絕丟回來。設定了自訂位址時不送,因為這個功能只存在於 Anthropic 自己的 API。 +- **沒有驗證的部分**:這台機器沒有任何 API 金鑰,所以兩個供應者都沒有對真正的服務發過請求。測試用的是假的 SDK 用戶端,加上對已安裝 SDK 的檢查:`anthropic` 1.11.0 的 `messages.stream` 與 `beta.messages.stream` 確實接受供應者送出的每個參數(`fallbacks` 的型別包含 `"default"`),錯誤類別以 SDK 自己的建構方式建立,真正的 `ChatOpenAI` 與 `anthropic.Anthropic` 用戶端能由設定建出來。 +- **翻譯**:四份字典各加 16 個鍵(`chat_ui_*` 13 個,以及 `ai_system_prompt_label`、`ai_apply_settings_button`、`ai_save_to_file_checkbox`)。 +- **測試**:`test_ai_settings.py`、`test_ai_providers.py`、`test_chat_session.py`、`test_chat_ui.py`(新);`test_core_architecture.py` 把 `adapters` 列進 UI 層以下的套件。 +- **結果**:整套測試 2411 passed(修改前 2283);`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束;Sphinx 建置沒有新警告,中英文兩頁結構相同(各 2 個表格、2 段範例、32 個行內程式碼);文件裡的範例都實際執行過;以 offscreen 把面板與對話框各畫成圖檢查過版面。PyBreeze 的 `test_language_parity.py` 仍是 25 passed、1 failed(同一個與 JEditor 無關的失敗,見 U-20261008-04)。 +- **文件**:`docs/source/docs/{Eng,Zh}/ai_assistant.rst`(重寫)、`configuration.rst`、`getting_started.rst`、`core_services.rst`;三份 README(主要特色、相依套件、AI 助手、設定檔、專案架構);`architecture.md` §1、§2、§3、§5、§6;`architecture_explore.md`(§1、§4.7 面板、§5.11、新的 §5.12、§6.1、§8);藍圖的實作狀態。 +- **檔案**:`je_editor/adapters/`(新,7 個檔)、`je_editor/core/ai/ai_settings.py`(新)、`je_editor/core/ai/chat_session.py`(新)、`je_editor/core/__init__.py`、`je_editor/core/services/editor_services.py`、`je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py`、`chat_worker.py`(新)、`je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py`、`je_editor/pyside_ui/main_ui/main_editor.py`、四份語言字典、`pyproject.toml`、`dev.toml`、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`(刪 #13)。刪除:`langchain_interface.py`、`ask_thread.py`、`ai_config.py`、`test/test_langchain_interface.py`。 +- **待辦**:無。 diff --git a/docs/updates/README.md b/docs/updates/README.md index ee394e9..83d8a61 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-05 | 2026-10-08 | 藍圖 M5:AI 對話面板改成可切換供應者,新增 Anthropic 後端 | #done #roadmap #ai #deps | [2026-10](2026-10.md) | | U-20261008-04 | 2026-10-08 | 藍圖 M2(診斷):ruff 與語言伺服器的診斷走同一個模型,問題面板依嚴重度與來源篩選 | #migration #roadmap #diagnostics | [2026-10](2026-10.md) | | U-20261008-03 | 2026-10-08 | PROGRESS #18、#19、#20:寫明 ruff 規則、長路徑測試、fixture 寫法 | #done #decision #tests | [2026-10](2026-10.md) | | U-20261008-02 | 2026-10-08 | M0 在 PR #270 的 CI 結果;Codacy 的動態匯入警告是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | @@ -95,5 +96,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 12 | +| [2026-10.md](2026-10.md) | 2026-10 | 13 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/adapters/__init__.py b/je_editor/adapters/__init__.py new file mode 100644 index 0000000..dbb55c5 --- /dev/null +++ b/je_editor/adapters/__init__.py @@ -0,0 +1,12 @@ +""" +核心服務介面的實作 +Implementations of the core service interfaces. + +``je_editor.core`` 只定義介面;真正去連某一家服務、啟動某一種程序的程式碼放在這裡。 +跟核心層一樣,這裡不匯入 Qt,也不匯入 ``je_editor.pyside_ui``。第三方 SDK 都在用到 +的時候才匯入,所以沒用到的供應者不會拖慢啟動。 +``je_editor.core`` only defines interfaces. The code that actually talks to one +vendor's service or starts one kind of process lives here. Like the core layer, +nothing here imports Qt or ``je_editor.pyside_ui``. Third-party SDKs are +imported at the point of use, so a provider nobody uses costs nothing at startup. +""" diff --git a/je_editor/adapters/ai/__init__.py b/je_editor/adapters/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/adapters/ai/anthropic_provider.py b/je_editor/adapters/ai/anthropic_provider.py new file mode 100644 index 0000000..c6e2377 --- /dev/null +++ b/je_editor/adapters/ai/anthropic_provider.py @@ -0,0 +1,201 @@ +""" +Anthropic 的供應者 +The provider for Anthropic. + +直接使用官方的 ``anthropic`` SDK,以串流方式取得回覆,所以答案可以邊產生邊顯示, +也可以中途取消。 +It uses the official ``anthropic`` SDK directly and reads the reply as a stream, +so the answer can be shown as it is generated and cancelled part-way. +""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from je_editor.core.ai.ai_provider import ( + CancelToken, ChatRequest, ChatResponse, ModelInfo, TextListener +) +from je_editor.core.ai.ai_settings import ProviderSettings +from je_editor.utils.exception.exceptions import JEditorServiceException + +PROVIDER_NAME = "anthropic" +# 沒有指定模型時使用的模型 / The model used when none is named +DEFAULT_MODEL = "claude-opus-5-5" +# 串流請求的輸出上限;串流不受 HTTP 逾時限制,給足空間才不會把答案切斷 +# The output cap for a streamed request; a stream is not bound by the HTTP +# timeout, and a generous cap keeps an answer from being cut short +MAX_OUTPUT_TOKENS = 64000 +# 列在模型選單裡的模型;使用者仍然可以自己填別的 +# The models offered in the model list; the user may still name another +_OFFERED_MODELS = ( + ModelInfo(DEFAULT_MODEL, "Claude Opus 5.5", supports_streaming=True), + ModelInfo("claude-sonnet-5-5", "Claude Sonnet 5.5", supports_streaming=True), + ModelInfo("claude-haiku-4-5", "Claude Haiku 4.5", supports_streaming=True), + ModelInfo("claude-fable-5-1", "Claude Fable 5.1", supports_streaming=True), +) +# 這些模型的安全分類器可能拒絕請求;伺服器端的 fallback 會在被拒絕時自動改用另一個 +# 模型重跑同一個請求,而不是把拒絕丟回來 +# The safety classifiers of these models may decline a request. The server-side +# fallback re-runs a declined request on another model by itself instead of +# handing the refusal back +_MODELS_WITH_FALLBACK = frozenset({ + "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5", "claude-fable-5-1"}) +_FALLBACK_BETA = "server-side-fallback-2026-07-01" +_FALLBACK_MODE = "default" +_REFUSAL = "refusal" + +# 取得目前設定的函式 / A function returning the settings as they are now +SettingsSource = Callable[[], ProviderSettings] +# 由設定建立 SDK 用戶端的函式 / Builds the SDK client from the settings +ClientFactory = Callable[[ProviderSettings], Any] + + +def _build_client(settings: ProviderSettings) -> Any: + """ + 建立 SDK 用戶端;用到時才匯入 SDK + Build the SDK client, importing the SDK only now. + + 沒有填金鑰時不傳金鑰,讓 SDK 自己從環境(``ANTHROPIC_API_KEY`` 或登入的設定檔) + 找認證。 + With no key in the settings none is passed, which lets the SDK find its + credentials in the environment (``ANTHROPIC_API_KEY`` or a logged-in profile). + """ + import anthropic + options: dict[str, str] = {} + if settings.api_key: + options["api_key"] = settings.api_key + if settings.base_url: + options["base_url"] = settings.base_url + return anthropic.Anthropic(**options) + + +class AnthropicProvider: + """ + Anthropic 的 Messages API + Anthropic's Messages API. + """ + + name = PROVIDER_NAME + + def __init__(self, settings: SettingsSource, client_factory: ClientFactory = _build_client) -> None: + """ + :param settings: 取得目前設定的函式;每次呼叫都重新讀,所以改設定不必重建供應者 + returns the settings as they are now; read on every call, so a change + of settings needs no new provider + :param client_factory: 建立 SDK 用戶端的函式,測試時換成假的 + builds the SDK client, replaced by a fake in tests + """ + self._settings = settings + self._client_factory = client_factory + + def models(self) -> list[ModelInfo]: + """ + 這個供應者提供哪些模型 + The models this provider offers. + + :return: 模型清單,第一個是預設 / the models, the default one first + """ + return list(_OFFERED_MODELS) + + def complete(self, request: ChatRequest, on_text: TextListener | None = None, + cancel: CancelToken | None = None) -> ChatResponse: + """ + 送出對話並等待回覆 + Send a conversation and wait for the reply. + + :param request: 對話請求 / the chat request + :param on_text: 每產生一段文字就呼叫一次 / called with each piece of text + as it is generated + :param cancel: 用來中途取消的旗標 / the token that cancels it part-way + :return: 回覆;被取消時是已經收到的部分 / the reply, or what had arrived + when it was cancelled + :raises JEditorServiceException: 找不到認證、服務回報錯誤,或模型拒絕回答 + when no credentials are found, the service reports an error, or the + model declines to answer + """ + import anthropic + settings = self._settings() + model = request.model_id or settings.model or DEFAULT_MODEL + try: + return self._stream(self._client_factory(settings), settings, model, request, + on_text, cancel) + except anthropic.APIError as error: + raise JEditorServiceException(_describe(error)) from error + # SDK 找不到任何認證時丟的是 TypeError / The SDK raises TypeError when it finds no credentials + except TypeError as error: + raise JEditorServiceException( + "No Anthropic credentials were found. Set an API key in the AI settings or the " + f"ANTHROPIC_API_KEY environment variable. ({error})") from error + + def _stream(self, client: Any, settings: ProviderSettings, model: str, request: ChatRequest, + on_text: TextListener | None, cancel: CancelToken | None) -> ChatResponse: + """開啟串流、逐段交出文字,最後整理成回覆 / Open the stream, hand text on piece by piece, sum up.""" + open_stream, parameters = _stream_call(client, settings, model, request) + pieces: list[str] = [] + with open_stream(**parameters) as stream: + for text in stream.text_stream: + # 取消時直接離開;離開 with 區塊就會關掉連線 + # On a cancel, just leave: leaving the with block closes the connection + if cancel is not None and cancel.cancelled: + return ChatResponse("".join(pieces), model, cancelled=True) + pieces.append(text) + if on_text is not None: + on_text(text) + message = stream.get_final_message() + if message.stop_reason == _REFUSAL: + # 已經收到的片段不是完整的答案,不能當成回覆 + # What arrived so far is not a whole answer and must not pass for one + category = getattr(message.stop_details, "category", None) or "unspecified" + raise JEditorServiceException( + f"The model declined to answer this request (category: {category})") + return ChatResponse( + "".join(pieces), message.model, message.usage.input_tokens, message.usage.output_tokens) + + +def _stream_call(client: Any, settings: ProviderSettings, model: str, + request: ChatRequest) -> tuple[Callable[..., Any], dict[str, Any]]: + """ + 決定要呼叫哪一個串流方法,以及它的參數 + Decide which stream method to call, and with what. + + fallback 是 beta 功能,只在直接連 Anthropic 的 API 時可用;設定了別的位址(代理、 + 其他平台)時不送,否則整個請求會被拒絕。 + The fallback is a beta feature and only exists on Anthropic's own API. With + another address set, a proxy or another platform, it is left out, since + sending it would get the whole request rejected. + """ + parameters: dict[str, Any] = { + "model": model, + "max_tokens": MAX_OUTPUT_TOKENS, + "messages": [{"role": message.role.value, "content": message.content} + for message in request.messages], + } + system_prompt = request.system_prompt or settings.system_prompt + if system_prompt: + parameters["system"] = system_prompt + if model in _MODELS_WITH_FALLBACK and not settings.base_url: + parameters["betas"] = [_FALLBACK_BETA] + parameters["fallbacks"] = _FALLBACK_MODE + return client.beta.messages.stream, parameters + return client.messages.stream, parameters + + +def _describe(error: Exception) -> str: + """ + 把 SDK 的錯誤說成使用者看得懂的一句話,最具體的類別先判斷 + Put an SDK error into a sentence the user can act on, most specific class first. + """ + import anthropic + explanations: tuple[tuple[type, str], ...] = ( + (anthropic.AuthenticationError, "Anthropic rejected the API key"), + (anthropic.PermissionDeniedError, "This API key may not use that model or feature"), + (anthropic.NotFoundError, "Anthropic does not know that model"), + (anthropic.RateLimitError, "Anthropic is rate limiting this key; try again shortly"), + (anthropic.BadRequestError, "Anthropic rejected the request"), + (anthropic.APIStatusError, "Anthropic returned an error"), + (anthropic.APIConnectionError, "Could not reach Anthropic; check the network"), + ) + for error_type, explanation in explanations: + if isinstance(error, error_type): + return f"{explanation}: {error}" + return f"The Anthropic request failed: {error}" diff --git a/je_editor/adapters/ai/builtin_providers.py b/je_editor/adapters/ai/builtin_providers.py new file mode 100644 index 0000000..ffae3cf --- /dev/null +++ b/je_editor/adapters/ai/builtin_providers.py @@ -0,0 +1,50 @@ +""" +登記編輯器內建的 AI 供應者 +Register the AI providers the editor ships with. + +對話面板只跟登記表要供應者,所以新增一家只要在這裡(或外掛裡)多登記一個,面板 +不必改。 +The chat panel only asks the registry for a provider, so adding a vendor is one +more registration here, or in a plugin, and nothing changes in the panel. +""" +from __future__ import annotations + +from collections.abc import Callable + +from je_editor.adapters.ai.anthropic_provider import AnthropicProvider +from je_editor.adapters.ai.openai_provider import OpenAIProvider +from je_editor.core.ai.ai_provider import AIProvider +from je_editor.core.ai.ai_settings import AISettings, ProviderSettings +from je_editor.core.registry.named_registry import NamedRegistry + +# 內建的供應者,依選單上的順序 / The built-in providers, in the order a menu shows them +BUILTIN_PROVIDER_TYPES = (OpenAIProvider, AnthropicProvider) + + +def register_builtin_ai_providers(registry: NamedRegistry[AIProvider], + settings: Callable[[], AISettings]) -> None: + """ + 把內建的供應者登記進登記表 + Register the built-in providers. + + 每個供應者拿到的是「取得自己那組設定」的函式,而不是設定本身,所以使用者改了 + 設定之後,下一次請求就會用新的,不必重新登記。 + Each provider is given a function that returns its own group of settings, + not the settings themselves, so a change the user makes is used by the next + request with nothing registered again. + + :param registry: 要登記到哪個登記表 / the registry to register into + :param settings: 取得目前全部設定的函式 / returns all the settings as they are now + :raises JEditorServiceException: 已經有同名的供應者 / when a provider of the + same name is already registered + """ + for provider_type in BUILTIN_PROVIDER_TYPES: + registry.register(provider_type.name, provider_type(_settings_of(settings, provider_type.name))) + + +def _settings_of(settings: Callable[[], AISettings], provider: str) -> Callable[[], ProviderSettings]: + """取得「某個供應者的設定」的函式 / A function returning one provider's settings.""" + def current() -> ProviderSettings: + return settings().settings_for(provider) + + return current diff --git a/je_editor/adapters/ai/openai_provider.py b/je_editor/adapters/ai/openai_provider.py new file mode 100644 index 0000000..6968633 --- /dev/null +++ b/je_editor/adapters/ai/openai_provider.py @@ -0,0 +1,144 @@ +""" +OpenAI 相容服務的供應者 +The provider for OpenAI-compatible services. + +透過 LangChain 的 ``ChatOpenAI`` 呼叫,所以任何 OpenAI 相容的端點(包含自架的)都 +能用,只要給它位址、金鑰與模型名稱。 +It calls through LangChain's ``ChatOpenAI``, so any OpenAI-compatible endpoint, +self-hosted ones included, works once it has an address, a key and a model name. +""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from je_editor.core.ai.ai_provider import ( + CancelToken, ChatRequest, ChatResponse, ChatRole, ModelInfo, TextListener +) +from je_editor.core.ai.ai_settings import ProviderSettings +from je_editor.utils.exception.exceptions import JEditorServiceException + +PROVIDER_NAME = "openai" +# 推理模型把思考過程放在這個標籤前面,答案在它後面 +# A reasoning model puts its thinking before this tag and the answer after it +_END_OF_REASONING = "" +# LangChain 用來標示訊息角色的名稱 / The names LangChain uses for message roles +_SYSTEM_ROLE = "system" +_ROLE_NAMES = {ChatRole.USER: "human", ChatRole.ASSISTANT: "ai"} + +# 取得目前設定的函式 / A function returning the settings as they are now +SettingsSource = Callable[[], ProviderSettings] +# 由設定與模型名稱建立聊天模型的函式 / Builds a chat model from the settings and a model name +ChatFactory = Callable[[ProviderSettings, str], Any] + + +def answer_after_reasoning(text: str) -> str: + """ + 去掉推理模型附在答案前面的思考過程 + Drop the thinking a reasoning model puts in front of its answer. + + 只認第一個結束標籤:答案本身也可能提到這個標籤。 + Only the first closing tag counts, since the answer itself may mention it. + + :param text: 模型的完整回覆 / the model's whole reply + :return: 答案;沒有思考過程時就是原文 / the answer, or the text unchanged when + it carries no thinking + """ + _reasoning, tag, answer = text.partition(_END_OF_REASONING) + return answer.strip() if tag else text + + +def _build_chat_model(settings: ProviderSettings, model: str) -> Any: + """建立 ``ChatOpenAI``;用到時才匯入 SDK / Build a ``ChatOpenAI``, importing the SDK only now.""" + from langchain_openai import ChatOpenAI + options: dict[str, Any] = {"model": model} + if settings.base_url: + options["base_url"] = settings.base_url + if settings.api_key: + options["api_key"] = settings.api_key + return ChatOpenAI(**options) + + +class OpenAIProvider: + """ + OpenAI 相容服務 + An OpenAI-compatible service. + """ + + name = PROVIDER_NAME + + def __init__(self, settings: SettingsSource, chat_factory: ChatFactory = _build_chat_model) -> None: + """ + :param settings: 取得目前設定的函式;每次呼叫都重新讀,所以改設定不必重建供應者 + returns the settings as they are now; read on every call, so a change + of settings needs no new provider + :param chat_factory: 建立聊天模型的函式,測試時換成假的 + builds the chat model, replaced by a fake in tests + """ + self._settings = settings + self._chat_factory = chat_factory + + def models(self) -> list[ModelInfo]: + """ + 這個供應者提供哪些模型 + The models this provider offers. + + OpenAI 相容的端點各有各的模型,沒有固定清單,由使用者自己填。 + Every OpenAI-compatible endpoint has models of its own, so there is no + fixed list and the user names the model. + + :return: 空清單 / an empty list + """ + return [] + + def complete(self, request: ChatRequest, on_text: TextListener | None = None, + cancel: CancelToken | None = None) -> ChatResponse: + """ + 送出對話並等待回覆 + Send a conversation and wait for the reply. + + 回覆是整份一次回來的:思考過程要等看到結束標籤才知道從哪裡切,邊收邊顯示 + 會把它也顯示出來。 + The reply arrives whole: where the thinking ends is only known once the + closing tag has been seen, and showing text as it arrives would show the + thinking too. + + :param request: 對話請求 / the chat request + :param on_text: 收到答案時呼叫一次 / called once, with the answer + :param cancel: 用來取消的旗標;回覆回來時若已取消就丟掉 + the token that cancels it; a reply that arrives after a cancel is dropped + :return: 回覆 / the reply + :raises JEditorServiceException: 沒有指定模型,或服務回報錯誤 + when no model is named or the service reports an error + """ + settings = self._settings() + model = request.model_id or settings.model + if not model: + raise JEditorServiceException("No model is set for the OpenAI-compatible provider") + message = self._invoke(settings, model, request) + if cancel is not None and cancel.cancelled: + return ChatResponse("", model, cancelled=True) + answer = answer_after_reasoning(message.text) + if on_text is not None and answer: + on_text(answer) + usage = getattr(message, "usage_metadata", None) or {} + return ChatResponse(answer, model, usage.get("input_tokens"), usage.get("output_tokens")) + + def _invoke(self, settings: ProviderSettings, model: str, request: ChatRequest) -> Any: + """呼叫模型,把 SDK 的錯誤轉成編輯器的例外 / Call the model, turning SDK errors into the editor's.""" + from openai import OpenAIError + try: + return self._chat_factory(settings, model).invoke(_as_langchain_messages(request, settings)) + # ValueError 也涵蓋 pydantic 對設定的驗證錯誤 / ValueError covers pydantic's validation of the settings too + except (OpenAIError, ValueError) as error: + raise JEditorServiceException(f"The OpenAI-compatible service failed: {error}") from error + + +def _as_langchain_messages(request: ChatRequest, settings: ProviderSettings) -> list[tuple[str, str]]: + """把對話轉成 LangChain 的(角色, 內容)清單 / The conversation as LangChain's (role, content) pairs.""" + messages: list[tuple[str, str]] = [] + system_prompt = request.system_prompt or settings.system_prompt + if system_prompt: + messages.append((_SYSTEM_ROLE, system_prompt)) + messages.extend((_ROLE_NAMES[message.role], message.content) for message in request.messages) + return messages diff --git a/je_editor/adapters/ai/settings_file.py b/je_editor/adapters/ai/settings_file.py new file mode 100644 index 0000000..99a9ceb --- /dev/null +++ b/je_editor/adapters/ai/settings_file.py @@ -0,0 +1,80 @@ +""" +讀寫 AI 助理的設定檔 +Read and write the AI assistant's settings file. + +設定檔裡有 API 金鑰,所以這裡的日誌只記路徑,從不記內容;也因此沒有沿用共用的 +JSON 寫檔函式——它會把寫入的內容整份記進日誌。 +The file holds API keys, so the log lines here carry the path and never the +content. That is also why the shared JSON writer is not used: it logs everything +it writes. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from je_editor.core.ai.ai_settings import AISettings +from je_editor.utils.exception.exceptions import JEditorJsonException, JEditorServiceException +from je_editor.utils.json.json_file import read_json +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 設定檔的位置:工作目錄下的 ``.jeditor/ai_config.json`` +# Where the file lives: ``.jeditor/ai_config.json`` under the working directory +SETTINGS_DIRECTORY = ".jeditor" +SETTINGS_FILE_NAME = "ai_config.json" +_JSON_INDENT = 4 + + +def ai_settings_path(directory: str | Path | None = None) -> Path: + """ + 取得設定檔的路徑 + The path of the settings file. + + :param directory: 放 ``.jeditor`` 的目錄,沒給時用目前工作目錄 + the directory holding ``.jeditor``, the working directory when omitted + :return: 設定檔路徑 / the path of the settings file + """ + base = Path(directory) if directory is not None else Path.cwd() + return base / SETTINGS_DIRECTORY / SETTINGS_FILE_NAME + + +def load_ai_settings(path: str | Path | None = None) -> AISettings: + """ + 讀取設定 + Load the settings. + + 檔案不存在、是空的或讀不懂時回傳空的設定:設定檔壞掉不該讓對話面板開不起來。 + A file that is missing, empty or unreadable gives empty settings: a broken + settings file must not keep the chat panel from opening. + + :param path: 設定檔路徑,沒給時用預設位置 / the file, the default place when omitted + :return: 設定 / the settings + """ + target = Path(path) if path is not None else ai_settings_path() + try: + return AISettings.from_dict(read_json(str(target))) + except JEditorJsonException: + jeditor_logger.warning("AI settings at %s could not be read; starting empty", target) + return AISettings() + + +def save_ai_settings(settings: AISettings, path: str | Path | None = None) -> Path: + """ + 寫入設定 + Save the settings. + + :param settings: 要寫入的設定 / the settings to save + :param path: 設定檔路徑,沒給時用預設位置 / the file, the default place when omitted + :return: 寫入的檔案路徑 / the file that was written + :raises JEditorServiceException: 檔案寫不進去 / when the file cannot be written + """ + target = Path(path) if path is not None else ai_settings_path() + content = json.dumps(settings.to_dict(), indent=_JSON_INDENT, ensure_ascii=False) + try: + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(content) + except OSError as error: + raise JEditorServiceException(f"The AI settings could not be saved to {target}") from error + jeditor_logger.info("AI settings saved to %s", target) + return target diff --git a/je_editor/adapters/default_services.py b/je_editor/adapters/default_services.py new file mode 100644 index 0000000..6623580 --- /dev/null +++ b/je_editor/adapters/default_services.py @@ -0,0 +1,52 @@ +""" +組出一組帶著內建實作的核心服務 +Build a set of core services with the built-in implementations registered. + +``EditorServices`` 本身是空的:沒有供應者,設定也沒有載入。編輯器視窗(以及嵌入 +編輯器的宿主程式)用這裡取得一組可以直接用的服務。 +``EditorServices`` on its own is empty: no provider, no settings loaded. The +editor window, and a host that embeds the editor, get a ready-to-use set here. +""" +from __future__ import annotations + +from pathlib import Path + +from je_editor.adapters.ai.builtin_providers import register_builtin_ai_providers +from je_editor.adapters.ai.settings_file import ai_settings_path, load_ai_settings +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.workspace.workspace_model import Workspace + + +def build_default_services(workspace: Workspace | None = None, + settings_directory: str | Path | None = None) -> EditorServices: + """ + 建立一組服務,載入 AI 設定並登記內建的 AI 供應者 + Build the services, load the AI settings and register the built-in AI providers. + + :param workspace: 要處理的工作區,沒給時從空的工作區開始 + the workspace to work on, an empty one when omitted + :param settings_directory: 放 ``.jeditor`` 的目錄,沒給時用目前工作目錄 + the directory holding ``.jeditor``, the working directory when omitted + :return: 可以直接使用的服務;擁有者關閉時要呼叫它的 ``shutdown()`` + services ready for use; the owner calls ``shutdown()`` on them when it closes + """ + services = EditorServices(workspace) + reload_ai_settings(services, settings_directory) + + def current_settings(): + return services.ai_settings + + register_builtin_ai_providers(services.ai_providers, current_settings) + return services + + +def reload_ai_settings(services: EditorServices, settings_directory: str | Path | None = None) -> None: + """ + 從設定檔重新載入 AI 設定 + Load the AI settings from their file again. + + :param services: 要更新的服務 / the services to update + :param settings_directory: 放 ``.jeditor`` 的目錄,沒給時用目前工作目錄 + the directory holding ``.jeditor``, the working directory when omitted + """ + services.ai_settings = load_ai_settings(ai_settings_path(settings_directory)) diff --git a/je_editor/core/__init__.py b/je_editor/core/__init__.py index 3b0d645..74c003a 100644 --- a/je_editor/core/__init__.py +++ b/je_editor/core/__init__.py @@ -13,6 +13,8 @@ from je_editor.core.ai.ai_provider import ( AIProvider, CancelToken, ChatMessage, ChatRequest, ChatResponse, ChatRole, ModelInfo ) +from je_editor.core.ai.ai_settings import AISettings, ProviderSettings +from je_editor.core.ai.chat_session import ChatSession from je_editor.core.debug.debug_session import ( Breakpoint, DebugLaunchRequest, DebugSession, DebugSessionFactory, DebugState, StackFrame, StepKind, Variable @@ -59,5 +61,5 @@ "RemoteSession", "RemoteState", "RemoteTransport", # AI providers "AIProvider", "ChatRequest", "ChatResponse", "ChatMessage", "ChatRole", - "ModelInfo", "CancelToken", + "ModelInfo", "CancelToken", "ChatSession", "AISettings", "ProviderSettings", ] diff --git a/je_editor/core/ai/ai_settings.py b/je_editor/core/ai/ai_settings.py new file mode 100644 index 0000000..17be1f0 --- /dev/null +++ b/je_editor/core/ai/ai_settings.py @@ -0,0 +1,166 @@ +""" +AI 助理的設定:每個供應者各有一組 +The AI assistant's settings: one set for each provider. + +原本的設定檔只認得一個「AI_model」,欄位名稱也綁著 OpenAI 相容的服務。這裡改成 +以供應者名稱分組,換供應者時各自的金鑰與模型不會互相蓋掉;舊格式的檔案仍然讀得 +進來,被當成 OpenAI 那一組。 +The settings file used to know a single ``AI_model`` whose fields assumed an +OpenAI-compatible service. Settings are now grouped by provider name, so +switching providers never overwrites another's key or model. A file in the older +format still loads, as the OpenAI group. + +純邏輯:只在字典與資料物件之間轉換,不讀寫檔案,也不碰 Qt。 +Pure logic: it converts between dictionaries and data objects only, reads and +writes no file, and touches no Qt. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict, dataclass, field, replace + +# 設定檔裡的鍵 / The keys of the settings file +ACTIVE_PROVIDER_KEY = "active_provider" +PROVIDERS_KEY = "providers" +# 舊格式唯一的一組設定,以及它對應的供應者 +# The single group the older format had, and the provider it belongs to +LEGACY_GROUP_KEY = "AI_model" +LEGACY_PROVIDER = "openai" +# 舊格式的欄位名稱對應到現在的欄位 / The older field names and what they are now +_LEGACY_FIELDS = { + "ai_base_url": "base_url", + "ai_api_key": "api_key", + "chat_model": "model", + "prompt_template": "system_prompt", +} +# 遮蔽金鑰時頭尾各保留幾個字元 / How many characters a masked key keeps at each end +_MASK_KEEP = 4 + + +@dataclass(frozen=True) +class ProviderSettings: + """ + 一個供應者的設定 + The settings of one provider. + + :param api_key: API 金鑰;空字串表示交給供應者自己從環境找 + the API key, empty to let the provider find its own in the environment + :param base_url: 服務的位址;空字串表示供應者的預設 / the service address, + empty for the provider's default + :param model: 要使用的模型;空字串表示供應者的預設 / the model to use, empty + for the provider's default + :param system_prompt: 系統提示詞 / the system prompt + """ + + api_key: str = "" + base_url: str = "" + model: str = "" + system_prompt: str = "" + + @property + def masked_api_key(self) -> str: + """ + 可以顯示在畫面或日誌上的金鑰 + The key in a form that may be shown on screen or written to a log. + + 只留頭尾各四個字元;太短的金鑰整個遮掉,免得一半以上都露出來。 + Only the first and last four characters are kept. A key too short for + that is hidden altogether rather than showing most of itself. + """ + if not self.api_key: + return "" + if len(self.api_key) <= _MASK_KEEP * 2: + return "*" * len(self.api_key) + return f"{self.api_key[:_MASK_KEEP]}...{self.api_key[-_MASK_KEEP:]}" + + +@dataclass +class AISettings: + """ + 所有供應者的設定,以及目前選用哪一個 + Every provider's settings, and which provider is in use. + + :param active_provider: 目前選用的供應者名稱;空字串表示還沒選 + the name of the provider in use, empty when none is chosen yet + :param providers: 供應者名稱對應它的設定 / each provider's settings, by name + """ + + active_provider: str = "" + providers: dict[str, ProviderSettings] = field(default_factory=dict) + + def settings_for(self, provider: str) -> ProviderSettings: + """ + 取得某個供應者的設定 + The settings of a provider. + + :param provider: 供應者名稱 / the provider's name + :return: 它的設定;還沒設定過時是一組空的 / its settings, an empty set + when it has none yet + """ + return self.providers.get(provider, ProviderSettings()) + + def update(self, provider: str, **changes: str) -> ProviderSettings: + """ + 修改某個供應者的設定 + Change the settings of a provider. + + :param provider: 供應者名稱 / the provider's name + :param changes: 要改的欄位 / the fields to change + :return: 修改後的設定 / the settings after the change + """ + updated = replace(self.settings_for(provider), **changes) + self.providers[provider] = updated + return updated + + def to_dict(self) -> dict: + """ + 轉成可以寫進設定檔的字典 + The dictionary to write to the settings file. + + :return: 設定的字典形式 / the settings as a dictionary + """ + return { + ACTIVE_PROVIDER_KEY: self.active_provider, + PROVIDERS_KEY: {name: asdict(item) for name, item in self.providers.items()}, + } + + @classmethod + def from_dict(cls, data: object) -> AISettings: + """ + 由設定檔的內容建立設定 + Build the settings from what the settings file holds. + + 設定檔可能被手動編輯,所以型別不對的項目會被略過,而不是讓整份設定失效。 + The file may have been edited by hand, so an entry of the wrong type is + skipped instead of invalidating everything. + + :param data: 設定檔解析後的內容,任何型別 / the parsed file, of any type + :return: 設定 / the settings + """ + settings = cls() + if not isinstance(data, Mapping): + return settings + stored = data.get(PROVIDERS_KEY) + if isinstance(stored, Mapping): + for name, raw in stored.items(): + if isinstance(name, str) and name and isinstance(raw, Mapping): + settings.providers[name] = _provider_settings(raw) + legacy = data.get(LEGACY_GROUP_KEY) + if isinstance(legacy, Mapping) and LEGACY_PROVIDER not in settings.providers: + migrated = _provider_settings( + {_LEGACY_FIELDS[key]: value for key, value in legacy.items() if key in _LEGACY_FIELDS}) + if migrated != ProviderSettings(): + settings.providers[LEGACY_PROVIDER] = migrated + active = data.get(ACTIVE_PROVIDER_KEY) + if isinstance(active, str) and active: + settings.active_provider = active + elif LEGACY_PROVIDER in settings.providers and isinstance(legacy, Mapping): + settings.active_provider = LEGACY_PROVIDER + return settings + + +def _provider_settings(raw: Mapping) -> ProviderSettings: + """只取認得而且是字串的欄位 / Keep only the fields that are known and are strings.""" + known = ProviderSettings.__dataclass_fields__ + return ProviderSettings(**{ + key: value for key, value in raw.items() if key in known and isinstance(value, str)}) diff --git a/je_editor/core/ai/chat_session.py b/je_editor/core/ai/chat_session.py new file mode 100644 index 0000000..6cfeee5 --- /dev/null +++ b/je_editor/core/ai/chat_session.py @@ -0,0 +1,94 @@ +""" +一段對話:記住說過的話,組出下一個請求 +One conversation: it remembers what was said and builds the next request. + +模型的 API 沒有狀態,每次都要把整段對話送過去。這裡保管那段對話,所以對話面板 +只需要說「使用者問了這句」與「模型答了這句」。 +A model's API keeps no state, so the whole conversation is sent every time. This +holds that conversation, leaving the chat panel to say only "the user asked +this" and "the model answered that". + +純邏輯:不連線到任何服務,也不碰 Qt。 +Pure logic: it connects to no service and touches no Qt. +""" +from __future__ import annotations + +from je_editor.core.ai.ai_provider import ChatMessage, ChatRequest, ChatResponse, ChatRole + + +class ChatSession: + """ + 一段進行中的對話 + A conversation in progress. + """ + + def __init__(self) -> None: + self._messages: list[ChatMessage] = [] + # 已經送出、還在等回覆的那一句 / The prompt that was sent and still awaits its reply + self._pending: ChatMessage | None = None + + @property + def messages(self) -> tuple[ChatMessage, ...]: + """到目前為止完成的對話,不含還在等回覆的那一句 / The finished exchange, without a pending prompt.""" + return tuple(self._messages) + + @property + def is_waiting(self) -> bool: + """是否有一句話還在等回覆 / Whether a prompt is still waiting for its reply.""" + return self._pending is not None + + def ask(self, prompt: str, model_id: str = "", system_prompt: str = "") -> ChatRequest | None: + """ + 記下使用者的一句話,並組出要送給供應者的請求 + Note what the user said and build the request for the provider. + + 這句話要等回覆回來才算進對話:失敗或取消時它不該留下來,否則下一次請求會 + 帶著一句沒人回答過的話。 + The prompt only joins the conversation once its reply arrives. After a + failure or a cancel it must not stay, or the next request would carry a + question nobody answered. + + :param prompt: 使用者輸入的文字 / what the user typed + :param model_id: 要用的模型 / the model to use + :param system_prompt: 系統提示詞 / the system prompt + :return: 請求;文字是空的,或上一句還在等回覆時為 ``None`` + the request, or ``None`` when the text is empty or a prompt is still waiting + """ + text = prompt.strip() + if not text or self.is_waiting: + return None + self._pending = ChatMessage(ChatRole.USER, text) + return ChatRequest((*self._messages, self._pending), model_id, system_prompt) + + def answered(self, response: ChatResponse) -> bool: + """ + 記下模型的回覆 + Note the model's reply. + + 被取消或是空的回覆不算進對話,連同那一句問話一起丟掉。 + A cancelled or empty reply does not join the conversation, and the + prompt it answered is dropped with it. + + :param response: 供應者給的回覆 / the reply the provider gave + :return: 這一問一答是否算進了對話 / whether the exchange joined the conversation + """ + pending, self._pending = self._pending, None + if pending is None or response.cancelled or not response.text: + return False + self._messages.extend((pending, ChatMessage(ChatRole.ASSISTANT, response.text))) + return True + + def failed(self) -> None: + """ + 請求失敗了:丟掉還在等回覆的那一句 + The request failed: drop the prompt that was waiting. + """ + self._pending = None + + def clear(self) -> None: + """ + 開始一段新的對話 + Start a new conversation. + """ + self._messages = [] + self._pending = None diff --git a/je_editor/core/services/editor_services.py b/je_editor/core/services/editor_services.py index 54cd546..72ce0e5 100644 --- a/je_editor/core/services/editor_services.py +++ b/je_editor/core/services/editor_services.py @@ -14,6 +14,7 @@ from __future__ import annotations from je_editor.core.ai.ai_provider import AIProvider +from je_editor.core.ai.ai_settings import AISettings from je_editor.core.debug.debug_session import DebugSessionFactory from je_editor.core.diagnostics.diagnostic_model import DiagnosticStore from je_editor.core.document.document_model import DocumentStore @@ -47,6 +48,9 @@ def __init__(self, workspace: Workspace | None = None) -> None: self.remote_transports: NamedRegistry[RemoteTransport] = NamedRegistry("remote transport") # 以供應者名稱登記 / Registered by provider name self.ai_providers: NamedRegistry[AIProvider] = NamedRegistry("AI provider") + # 每個供應者各一組的設定;從檔案載入是擁有者的事 + # One group of settings per provider; loading them from a file is the owner's job + self.ai_settings = AISettings() self._shut_down = False @property diff --git a/je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py b/je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py index 0fe0c7e..13793f1 100644 --- a/je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py +++ b/je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py @@ -1,71 +1,127 @@ -from PySide6.QtWidgets import QWidget, QLineEdit, QPushButton, QMessageBox, QGridLayout, QLabel +""" +設定 AI 供應者的對話框 +The dialog for configuring the AI providers. -from je_editor.pyside_ui.main_ui.ai_widget.ai_config import ai_config +每個供應者各有一組設定。對話框預設只把設定套用到這次執行,不寫進磁碟:API 金鑰 +只會存在使用者自己放的地方。要存檔得自己勾選,而且勾選處會說明金鑰是明文。 +Each provider has a group of settings of its own. By default the dialog applies +them to this run only and writes nothing to disk, so an API key is only ever +stored where the user put it. Saving has to be ticked, and the tick box says the +key is kept as plain text. +""" +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QGridLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, + QPushButton, QWidget +) + +from je_editor.adapters.ai.settings_file import save_ai_settings +from je_editor.core.services.editor_services import EditorServices +from je_editor.utils.exception.exceptions import JEditorServiceException from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper class SetAIDialog(QWidget): """ - 設定 AI 模型的對話框 - Dialog for configuring AI model settings + 設定 AI 供應者 + Configure the AI providers. """ - def __init__(self) -> None: + # 設定套用之後發出 / Fired after the settings were applied + settings_applied = Signal() + + def __init__(self, services: EditorServices, provider: str = "") -> None: + """ + :param services: 持有 AI 設定與供應者登記表的核心服務 + the core services holding the AI settings and the provider registry + :param provider: 一開始要顯示哪個供應者 / the provider to show first + """ jeditor_logger.info("Init SetAIDialog") super().__init__() + self._services = services + word = language_wrapper.language_word_dict - # Base URL 輸入欄位 / Base URL input field - self.base_url_label = QLabel(language_wrapper.language_word_dict.get("base_url_label")) + self.provider_label = QLabel(word.get("chat_ui_provider_label")) + self.provider_combobox = QComboBox() + self.provider_combobox.addItems(services.ai_providers.names()) + self.base_url_label = QLabel(word.get("base_url_label")) self.base_url_input = QLineEdit() - - # API Key 輸入欄位 / API Key input field - self.api_key_label = QLabel(language_wrapper.language_word_dict.get("api_key_label")) + self.api_key_label = QLabel(word.get("api_key_label")) self.api_key_input = QLineEdit() - - # Chat Model 輸入欄位 / Chat model input field - self.chat_model_label = QLabel(language_wrapper.language_word_dict.get("ai_model_label")) + # 金鑰不該在畫面上被旁人看到 / A key should not be readable over the user's shoulder + self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) + self.chat_model_label = QLabel(word.get("ai_model_label")) self.chat_model_input = QLineEdit() - - # 新增 AI 設定按鈕 / Button to add AI configuration - self.add_ai_info_button = QPushButton() - self.add_ai_info_button.setText(language_wrapper.language_word_dict.get("add_ai_model_pushbutton")) + self.system_prompt_label = QLabel(word.get("ai_system_prompt_label")) + self.system_prompt_input = QPlainTextEdit() + self.save_to_file_checkbox = QCheckBox(word.get("ai_save_to_file_checkbox")) + self.add_ai_info_button = QPushButton(word.get("ai_apply_settings_button")) self.add_ai_info_button.clicked.connect(self.update_ai_config) - # 使用 GridLayout 排版 / Use GridLayout for layout self.grid_layout = QGridLayout() - self.grid_layout.addWidget(self.base_url_label, 0, 0) - self.grid_layout.addWidget(self.base_url_input, 0, 1) - self.grid_layout.addWidget(self.api_key_label, 1, 0) - self.grid_layout.addWidget(self.api_key_input, 1, 1) - self.grid_layout.addWidget(self.chat_model_label, 2, 0) - self.grid_layout.addWidget(self.chat_model_input, 2, 1) - self.grid_layout.addWidget(self.add_ai_info_button, 3, 1) - - # 設定視窗標題 / Set window title - self.setWindowTitle(language_wrapper.language_word_dict.get("add_ai_model_title")) + fields = ( + (self.provider_label, self.provider_combobox), + (self.base_url_label, self.base_url_input), + (self.api_key_label, self.api_key_input), + (self.chat_model_label, self.chat_model_input), + (self.system_prompt_label, self.system_prompt_input), + ) + for row, (label, field) in enumerate(fields): + self.grid_layout.addWidget(label, row, 0) + self.grid_layout.addWidget(field, row, 1) + self.grid_layout.addWidget(self.save_to_file_checkbox, len(fields), 1) + self.grid_layout.addWidget(self.add_ai_info_button, len(fields) + 1, 1) + self.setWindowTitle(word.get("add_ai_model_title")) self.setLayout(self.grid_layout) - def update_ai_config(self) -> None: - """ - 更新 AI 設定,將使用者輸入的 base_url、api_key、chat_model - 儲存到 ai_config.choosable_ai 中 - Update AI configuration with user inputs (base_url, api_key, chat_model) + if provider in services.ai_providers: + self.provider_combobox.setCurrentText(provider) + self.provider_combobox.currentIndexChanged.connect(self.show_provider_settings) + self.show_provider_settings() + + def show_provider_settings(self) -> None: + """把目前選的供應者的設定填進欄位 / Fill the fields with the settings of the provider picked.""" + settings = self._services.ai_settings.settings_for(self.provider_combobox.currentText()) + self.base_url_input.setText(settings.base_url) + self.api_key_input.setText(settings.api_key) + self.chat_model_input.setText(settings.model) + self.system_prompt_input.setPlainText(settings.system_prompt) + + def update_ai_config(self) -> bool: """ - base_url = self.base_url_input.text().strip() - api_key = self.api_key_input.text().strip() - chat_model = self.chat_model_input.text().strip() + 套用欄位裡的設定,並把這個供應者設為目前使用的 + Apply what the fields hold and make this provider the one in use. + + 每個欄位都可以留空:金鑰留空表示交給供應者自己從環境找,位址與模型留空 + 表示用供應者的預設。 + Every field may be left empty: no key leaves the provider to find one in + the environment, and no address or model means the provider's default. - if base_url and chat_model: - # 更新設定字典 / Update configuration dictionary - ai_config.choosable_ai.update( - {"AI_model": {"base_url": base_url, "api_key": api_key, "chat_model": chat_model}} - ) - else: - # 若缺少必要欄位,顯示警告訊息框 - # Show warning message box if required fields are missing - QMessageBox.warning( - self, - language_wrapper.language_word_dict.get("set_ai_model_warring_title"), - language_wrapper.language_word_dict.get("set_ai_model_warring_text") - ) + :return: 是否套用成功(要求存檔而存不進去時為 ``False``) + whether it was applied; ``False`` when saving was asked for and failed + """ + provider = self.provider_combobox.currentText() + if not provider: + return False + settings = self._services.ai_settings + settings.update( + provider, + base_url=self.base_url_input.text().strip(), + api_key=self.api_key_input.text().strip(), + model=self.chat_model_input.text().strip(), + system_prompt=self.system_prompt_input.toPlainText().strip(), + ) + settings.active_provider = provider + word = language_wrapper.language_word_dict + if self.save_to_file_checkbox.isChecked(): + try: + save_ai_settings(settings) + except JEditorServiceException as error: + QMessageBox.warning(self, word.get("set_ai_model_waring_title"), str(error)) + return False + self.settings_applied.emit() + self.close() + return True diff --git a/je_editor/pyside_ui/main_ui/ai_widget/ai_config.py b/je_editor/pyside_ui/main_ui/ai_widget/ai_config.py deleted file mode 100644 index ebd125c..0000000 --- a/je_editor/pyside_ui/main_ui/ai_widget/ai_config.py +++ /dev/null @@ -1,34 +0,0 @@ -from queue import Queue - - -class AIConfig(object): - """ - AIConfig 類別:用來管理 AI 模型的設定與訊息佇列 - AIConfig class: manages AI model configuration and message queue - """ - - def __init__(self) -> None: - # 當前 AI 模型的系統提示詞 (system prompt) - # Current AI model system prompt - self.current_ai_model_system_prompt: str = "" - - # 可選擇的 AI 模型設定字典 - # Dictionary of choosable AI model configurations - # 結構: { "AI_model": { "ai_base_url": ..., "ai_api_key": ..., "chat_model": ..., "prompt_template": ... } } - self.choosable_ai: dict[str, dict[str, str]] = { - "AI_model": { - "ai_base_url": "", # AI 服務的基礎 URL / Base URL of AI service - "ai_api_key": "", # API 金鑰 / API key - "chat_model": "", # 模型名稱 / Model name - "prompt_template": "", # 提示詞模板 / Prompt template - } - } - - # 訊息佇列,用來暫存與 AI 的互動訊息 - # Message queue for storing AI interaction messages - self.message_queue = Queue() - - -# 建立全域唯一的 AIConfig 實例 -# Create a global singleton instance of AIConfig -ai_config = AIConfig() diff --git a/je_editor/pyside_ui/main_ui/ai_widget/ask_thread.py b/je_editor/pyside_ui/main_ui/ai_widget/ask_thread.py deleted file mode 100644 index ed0b766..0000000 --- a/je_editor/pyside_ui/main_ui/ai_widget/ask_thread.py +++ /dev/null @@ -1,36 +0,0 @@ -from threading import Thread - -from je_editor.pyside_ui.main_ui.ai_widget.ai_config import ai_config -from je_editor.pyside_ui.main_ui.ai_widget.langchain_interface import LangChainInterface - - -class AskThread(Thread): - """ - AskThread 類別:用來在背景執行緒中呼叫 AI 模型,避免阻塞主執行緒 - AskThread class: runs AI model calls in a background thread to avoid blocking the main thread - """ - - def __init__(self, lang_chain_interface: LangChainInterface, prompt: str) -> None: - """ - 初始化 AskThread - Initialize AskThread - :param lang_chain_interface: LangChainInterface 實例,用來呼叫 AI 模型 - LangChainInterface instance for calling AI model - :param prompt: 傳給 AI 模型的提示詞 - Prompt to send to the AI model - """ - super().__init__() - self.lang_chain_interface = lang_chain_interface - self.prompt = prompt - - def run(self) -> None: - """ - 執行緒的主要邏輯: - 1. 呼叫 AI 模型並取得回應 - 2. 將回應放入 ai_config 的 message_queue 中,供主程式使用 - Thread main logic: - 1. Call AI model and get response - 2. Put response into ai_config.message_queue for main program to consume - """ - ai_response = self.lang_chain_interface.call_ai_model(prompt=self.prompt) - ai_config.message_queue.put(ai_response) diff --git a/je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py b/je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py index 37607b3..0fd1897 100644 --- a/je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py +++ b/je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py @@ -1,149 +1,321 @@ +""" +AI 對話面板 +The AI chat panel. + +面板只認得「登記表裡的某個供應者」,不認得任何一家的 SDK:送出對話、邊收邊顯示、 +取消與報錯對每個供應者都是同一段程式碼。 +The panel knows a provider from the registry and nobody's SDK: sending a +conversation, showing the reply as it arrives, cancelling and reporting an error +are the same code for every provider. +""" from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QFontDatabase -from PySide6.QtWidgets import QWidget, QPlainTextEdit, QScrollArea, QLabel, QComboBox, QGridLayout, QPushButton, \ - QMessageBox, QSizePolicy, QLineEdit +from PySide6.QtCore import Qt +from PySide6.QtGui import QFontDatabase, QTextCursor +from PySide6.QtWidgets import ( + QComboBox, QGridLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, + QSizePolicy, QWidget +) +from je_editor.adapters.default_services import build_default_services, reload_ai_settings +from je_editor.core.ai.ai_provider import AIProvider, ChatResponse +from je_editor.core.ai.chat_session import ChatSession +from je_editor.core.services.editor_services import EditorServices from je_editor.pyside_ui.dialog.ai_dialog.set_ai_dialog import SetAIDialog -from je_editor.pyside_ui.main_ui.ai_widget.ai_config import AIConfig, ai_config -from je_editor.pyside_ui.main_ui.ai_widget.ask_thread import AskThread -from je_editor.pyside_ui.main_ui.ai_widget.langchain_interface import LangChainInterface -from je_editor.utils.json.json_file import read_json +from je_editor.pyside_ui.main_ui.ai_widget.chat_worker import ChatWorker from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper if TYPE_CHECKING: from je_editor.pyside_ui.main_ui.main_editor import EditorMain +# 字型大小選單的範圍與預設值 / The range and the default of the font size list +_FONT_SIZE_MIN = 2 +_FONT_SIZE_MAX = 100 +_FONT_SIZE_STEP = 2 +_DEFAULT_FONT_SIZE = 16 +# 面板的欄數 / How many columns the panel's grid has +_GRID_COLUMNS = 4 + + +def services_for(main_window: object) -> EditorServices: + """ + 取得視窗的核心服務;宿主視窗沒有的話就自己建一組 + The window's core services, or a set of its own when the host window has none. + + :param main_window: 開啟這個面板的視窗 / the window that opened this panel + :return: 核心服務 / the core services + """ + services = getattr(main_window, "services", None) + return services if isinstance(services, EditorServices) else build_default_services() + class ChatUI(QWidget): + """ + 與 AI 助理對話的面板 + The panel for talking to the AI assistant. + """ - def __init__(self, main_window: EditorMain) -> None: + def __init__(self, main_window: EditorMain | None = None) -> None: + """ + :param main_window: 開啟這個面板的視窗 / the window that opened this panel + """ super().__init__() - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) # 關閉視窗時自動釋放資源 / Auto delete on close + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.main_window = main_window + self._services = services_for(main_window) + self._session = ChatSession() + self._worker: ChatWorker | None = None + self.set_ai_config_dialog: SetAIDialog | None = None + self._build_widgets() + self._lay_out() + self.retranslate() + self.refresh_providers() + self._set_waiting(False) - # ---------------- Chat Panel 聊天面板 ---------------- - self.chat_panel = QPlainTextEdit() # 顯示聊天訊息的文字框 / Text area for chat messages - self.chat_panel.setLineWrapMode(self.chat_panel.LineWrapMode.NoWrap) # 不自動換行 / Disable line wrap - self.chat_panel.setReadOnly(True) # 設為唯讀,避免使用者直接輸入 / Read-only - self.chat_panel_scroll_area = QScrollArea() # 加入滾動區域 / Scroll area for chat panel - self.chat_panel_scroll_area.setWidgetResizable(True) - self.chat_panel_scroll_area.setViewportMargins(0, 0, 0, 0) - self.chat_panel_scroll_area.setWidget(self.chat_panel) - self.chat_panel.setFont(QFontDatabase.font(self.font().family(), "", 16)) # 設定字體大小 / Set font size - - # ---------------- Prompt Input 輸入框 ---------------- - self.prompt_input = QLineEdit() # 使用者輸入提示詞 / Input field for prompts + # ---- construction ---------------------------------------------------- + + def _build_widgets(self) -> None: + """建立面板上的元件並接好訊號 / Build the panel's widgets and connect their signals.""" + self.chat_panel = QPlainTextEdit() + self.chat_panel.setReadOnly(True) + self.chat_panel.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + self.prompt_input = QLineEdit() self.prompt_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.prompt_input.returnPressed.connect(self.call_ai_model) # 按 Enter 時呼叫 AI / Call AI on Enter + self.prompt_input.returnPressed.connect(self.call_ai_model) + + self.provider_label = QLabel() + self.provider_combobox = QComboBox() + self.provider_combobox.currentIndexChanged.connect(self._on_provider_changed) + self.model_label = QLabel() + self.model_combobox = QComboBox() + self.model_combobox.setEditable(True) - # ---------------- Font Size Combobox 字體大小選單 ---------------- - self.font_size_label = QLabel(language_wrapper.language_word_dict.get("font_size")) # 標籤 / Label - self.font_size_combobox = QComboBox() # 下拉選單 / Dropdown for font size - for font_size in range(2, 101, 2): # 提供 2~100 的字體大小選項 / Font size options + self.font_size_combobox = QComboBox() + for font_size in range(_FONT_SIZE_MIN, _FONT_SIZE_MAX + 1, _FONT_SIZE_STEP): self.font_size_combobox.addItem(str(font_size)) - self.font_size_combobox.setCurrentText("16") # 預設字體大小 / Default font size + self.font_size_combobox.setCurrentText(str(_DEFAULT_FONT_SIZE)) self.font_size_combobox.currentTextChanged.connect(self.update_panel_text_size) + self.update_panel_text_size() - # ---------------- Buttons 按鈕 ---------------- - self.set_ai_config_button = QPushButton(language_wrapper.language_word_dict.get("chat_ui_set_ai_button")) - self.set_ai_config_button.clicked.connect(self.set_ai_config) # 開啟 AI 設定視窗 / Open AI config dialog - - self.load_ai_config_button = QPushButton(language_wrapper.language_word_dict.get("chat_ui_load_ai_button")) - self.load_ai_config_button.clicked.connect( - lambda: self.load_ai_config(show_load_complete=True)) # 載入設定 / Load config - - self.call_ai_model_button = QPushButton(language_wrapper.language_word_dict.get("chat_ui_call_ai_model_button")) - self.call_ai_model_button.clicked.connect(self.call_ai_model) # 呼叫 AI / Call AI + self.call_ai_model_button = QPushButton() + self.call_ai_model_button.clicked.connect(self.call_ai_model) + self.stop_button = QPushButton() + self.stop_button.clicked.connect(self.stop) + self.new_chat_button = QPushButton() + self.new_chat_button.clicked.connect(self.new_chat) + self.set_ai_config_button = QPushButton() + self.set_ai_config_button.clicked.connect(self.set_ai_config) + self.load_ai_config_button = QPushButton() + self.load_ai_config_button.clicked.connect(self._reload_from_file) + self.status_label = QLabel() - # ---------------- Layout 版面配置 ---------------- + def _lay_out(self) -> None: + """把元件排進格線 / Place the widgets in the grid.""" self.grid_layout = QGridLayout() - self.grid_layout.addWidget(self.chat_panel_scroll_area, 0, 0, 1, 4) # 聊天面板 / Chat panel - self.grid_layout.addWidget(self.call_ai_model_button, 1, 0) # 呼叫 AI 按鈕 / Call AI button - self.grid_layout.addWidget(self.font_size_combobox, 1, 1) # 字體大小選單 / Font size combobox - self.grid_layout.addWidget(self.set_ai_config_button, 1, 2) # 設定 AI 按鈕 / Set AI config button - self.grid_layout.addWidget(self.load_ai_config_button, 1, 3) # 載入設定按鈕 / Load AI config button - self.grid_layout.addWidget(self.prompt_input, 2, 0, 1, 4) # 輸入框 / Prompt input - - # ---------------- Variables 變數 ---------------- - self.ai_config: AIConfig = ai_config # AI 設定物件 / AI config object - self.lang_chain_interface: LangChainInterface | None = None # LangChain 介面 / LangChain interface - self.set_ai_config_dialog = None # 設定對話框 / Config dialog - - # ---------------- Timer 計時器 ---------------- - self.pull_message_timer = QTimer(self) # 定時檢查訊息佇列 / Timer to pull messages - self.pull_message_timer.setInterval(1000) # 每秒檢查一次 / Check every 1 second - self.pull_message_timer.timeout.connect(self.pull_message) - self.pull_message_timer.start() - - # ---------------- Set Layout 設定版面 ---------------- + rows = ( + (self.provider_label, self.provider_combobox, self.model_label, self.model_combobox), + (self.chat_panel,), + (self.call_ai_model_button, self.stop_button, self.new_chat_button, + self.font_size_combobox), + (self.set_ai_config_button, self.load_ai_config_button, self.status_label), + (self.prompt_input,), + ) + for row, widgets in enumerate(rows): + for column, widget in enumerate(widgets): + # 一列裡最後一個元件佔滿剩下的欄 / The last widget of a row takes the columns left + span = _GRID_COLUMNS - column if column == len(widgets) - 1 else 1 + self.grid_layout.addWidget(widget, row, column, 1, span) self.setLayout(self.grid_layout) - # ---------------- Load AI Config 載入 AI 設定 ---------------- - self.load_ai_config() + def retranslate(self) -> None: + """ + 換語言後重新標示自己 + Relabel after the language changes. - # 更新聊天面板字體大小 / Update chat panel font size - def update_panel_text_size(self) -> None: - self.chat_panel.setFont( - QFontDatabase.font(self.font().family(), "", int(self.font_size_combobox.currentText()))) + 面板握著進行中的對話,所以不能整個拆掉重建。 + The panel holds the conversation in progress, so it cannot be rebuilt. + """ + word = language_wrapper.language_word_dict + self.provider_label.setText(word.get("chat_ui_provider_label")) + self.model_label.setText(word.get("chat_ui_model_label")) + self.call_ai_model_button.setText(word.get("chat_ui_call_ai_model_button")) + self.stop_button.setText(word.get("chat_ui_stop_button")) + self.new_chat_button.setText(word.get("chat_ui_new_chat_button")) + self.set_ai_config_button.setText(word.get("chat_ui_set_ai_button")) + self.load_ai_config_button.setText(word.get("chat_ui_load_ai_button")) + if not self._session.is_waiting: + self.status_label.setText(word.get("chat_ui_status_ready")) + + # ---- providers and settings ------------------------------------------ + + def refresh_providers(self) -> None: + """ + 讓供應者選單跟著登記表與設定走 + Bring the provider list in step with the registry and the settings. + """ + names = self._services.ai_providers.names() + active = self._services.ai_settings.active_provider + self.provider_combobox.blockSignals(True) + try: + self.provider_combobox.clear() + self.provider_combobox.addItems(names) + if active in names: + self.provider_combobox.setCurrentText(active) + finally: + self.provider_combobox.blockSignals(False) + self._refresh_models() + + def current_provider(self) -> AIProvider | None: + """目前選用的供應者,沒有時為 ``None`` / The provider in use, or ``None``.""" + return self._services.ai_providers.get(self.provider_combobox.currentText()) + + def _on_provider_changed(self) -> None: + """換了供應者:記下選擇並換上它的模型 / A new provider was picked: note it and show its models.""" + self._services.ai_settings.active_provider = self.provider_combobox.currentText() + self._refresh_models() + + def _refresh_models(self) -> None: + """列出目前供應者的模型,並選上設定裡的那一個 / List the provider's models and pick the configured one.""" + provider = self.current_provider() + offered = [model.model_id for model in provider.models()] if provider is not None else [] + configured = self._services.ai_settings.settings_for( + self.provider_combobox.currentText()).model + self.model_combobox.clear() + self.model_combobox.addItems(offered) + self.model_combobox.setCurrentText(configured or (offered[0] if offered else "")) - # 載入 AI 設定檔 / Load AI configuration file - def load_ai_config(self, show_load_complete: bool = False) -> None: - ai_config_file = Path.cwd() / ".jeditor" / "ai_config.json" - if ai_config_file.exists(): - json_data: dict = read_json(str(ai_config_file)) - if json_data: - # 確認 AI_model 設定存在且包含必要欄位 / Ensure AI_model config exists with required fields - if json_data.get("AI_model") and isinstance(json_data.get("AI_model"), dict): - ai_info: dict = json_data.get("AI_model") - if ai_info.get("ai_base_url") and ai_info.get("chat_model"): - ai_config.choosable_ai.update(json_data) # 更新全域設定 / Update global config - # 建立 LangChain 介面 / Initialize LangChain interface - self.lang_chain_interface = LangChainInterface( - main_window=self, - api_key=ai_info.get("ai_api_key"), - base_url=ai_info.get("ai_base_url"), - chat_model=ai_info.get("chat_model"), - prompt_template=ai_info.get("prompt_template"), - ) - if show_load_complete: - load_complete = QMessageBox(self) - load_complete.setWindowTitle(language_wrapper.language_word_dict.get("load_ai_messagebox_title")) - load_complete.setText(language_wrapper.language_word_dict.get("load_ai_messagebox_text")) - load_complete.exec() - - # 呼叫 AI 模型 / Call AI model - def call_ai_model(self) -> None: - if isinstance(self.lang_chain_interface, LangChainInterface): - # 建立新執行緒處理 AI 請求 / Start a new thread for AI request - # Store reference to prevent garbage collection before thread completes - self._ask_thread = AskThread(lang_chain_interface=self.lang_chain_interface, prompt=self.prompt_input.text()) - self._ask_thread.start() - else: - # 若未正確設定 AI,顯示錯誤訊息 / Show error if AI not configured - ai_info = ai_config.choosable_ai.get('AI_model', {}) - # Mask API key to prevent leaking credentials - api_key = ai_info.get('ai_api_key', '') - masked_key = f"{api_key[:4]}...{api_key[-4:]}" if api_key and len(api_key) > 8 else "(not set)" - QMessageBox.warning(self, - language_wrapper.language_word_dict.get("call_ai_model_error_title"), - f"ai_api_key: {masked_key}, \n" - f"ai_base_url: {ai_info.get('ai_base_url')}, \n" - f"chat_model: {ai_info.get('chat_model')}, \n" - f"prompt_template: {ai_info.get('prompt_template')}") - - # 從訊息佇列中取出 AI 回覆並顯示 / Pull AI response from queue - def pull_message(self) -> None: - if not ai_config.message_queue.empty(): - ai_response = ai_config.message_queue.get_nowait() - self.chat_panel.appendPlainText(ai_response) # 顯示回覆 / Display response - self.chat_panel.appendPlainText("\n") - - # 開啟 AI 設定對話框 / Open AI config dialog def set_ai_config(self) -> None: - self.set_ai_config_dialog = SetAIDialog() + """開啟 AI 設定對話框 / Open the AI settings dialog.""" + self.set_ai_config_dialog = SetAIDialog(self._services, self.provider_combobox.currentText()) + self.set_ai_config_dialog.settings_applied.connect(self.refresh_providers) self.set_ai_config_dialog.show() + + def _reload_from_file(self) -> None: + """從設定檔重新載入,並告訴使用者載入完成 / Reload from the settings file and say so.""" + self.load_ai_config(show_load_complete=True) + + def load_ai_config(self, show_load_complete: bool = False) -> None: + """ + 從設定檔重新載入 AI 設定 + Load the AI settings from their file again. + + :param show_load_complete: 是否跳出「載入完成」的訊息 / whether to say that loading finished + """ + reload_ai_settings(self._services) + self.refresh_providers() + if show_load_complete: + word = language_wrapper.language_word_dict + QMessageBox.information( + self, word.get("load_ai_messagebox_title"), word.get("load_ai_messagebox_text")) + + # ---- conversation ---------------------------------------------------- + + def update_panel_text_size(self) -> None: + """套用選單選的字型大小 / Apply the font size picked in the list.""" + self.chat_panel.setFont(QFontDatabase.font( + self.font().family(), "", int(self.font_size_combobox.currentText()))) + + def call_ai_model(self) -> bool: + """ + 把輸入框的文字送給目前的供應者 + Send what is in the input box to the provider in use. + + :return: 是否真的送出了請求 / whether a request was actually sent + """ + word = language_wrapper.language_word_dict + provider = self.current_provider() + if provider is None: + QMessageBox.warning( + self, word.get("call_ai_model_error_title"), word.get("chat_ui_no_provider")) + return False + settings = self._services.ai_settings.settings_for(provider.name) + request = self._session.ask( + self.prompt_input.text(), self.model_combobox.currentText().strip(), + settings.system_prompt) + if request is None: + return False + self._append_line(f"{word.get('chat_ui_you_prefix')}: {request.messages[-1].content}") + self._append_line(f"{word.get('chat_ui_assistant_prefix')}: ") + self.prompt_input.clear() + worker = ChatWorker(provider, request) + worker.text_ready.connect(self._on_text) + worker.replied.connect(self._on_replied) + worker.failed.connect(self._on_failed) + self._worker = worker + self._set_waiting(True) + worker.start_request() + return True + + def stop(self) -> None: + """取消進行中的請求 / Cancel the request in flight.""" + if self._worker is not None: + self._worker.cancel() + + def new_chat(self) -> None: + """丟掉目前的對話,重新開始 / Drop the conversation and start again.""" + self.stop() + self._worker = None + self._session.clear() + self.chat_panel.clear() + self._set_waiting(False) + + def _on_text(self, piece: str) -> None: + """把剛收到的一段回覆接在畫面最後 / Add a piece of the reply to the end of what is shown.""" + if self.sender() is not self._worker: + return + cursor = self.chat_panel.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + cursor.insertText(piece) + self.chat_panel.setTextCursor(cursor) + + def _on_replied(self, response: ChatResponse) -> None: + """回覆完成:記進對話並顯示用量 / The reply is complete: note it and show the usage.""" + if self.sender() is not self._worker: + return + word = language_wrapper.language_word_dict + self._session.answered(response) + self._worker = None + self._append_line("") + self._set_waiting(False) + if response.cancelled: + self.status_label.setText(word.get("chat_ui_status_cancelled")) + elif response.input_tokens is None or response.output_tokens is None: + self.status_label.setText(word.get("chat_ui_status_done")) + else: + self.status_label.setText(word.get("chat_ui_status_tokens").format( + input=response.input_tokens, output=response.output_tokens)) + + def _on_failed(self, message: str) -> None: + """請求失敗:丟掉那一句並告訴使用者原因 / The request failed: drop the prompt and say why.""" + if self.sender() is not self._worker: + return + word = language_wrapper.language_word_dict + self._session.failed() + self._worker = None + self._append_line("") + self._set_waiting(False) + self.status_label.setText(word.get("chat_ui_status_failed")) + QMessageBox.warning(self, word.get("call_ai_model_error_title"), message) + + def _append_line(self, text: str) -> None: + """在畫面最後另起一行 / Start a new line at the end of what is shown.""" + self.chat_panel.appendPlainText(text) + + def _set_waiting(self, waiting: bool) -> None: + """依是否在等回覆切換按鈕與狀態文字 / Switch the buttons and the status for waiting or not.""" + self.call_ai_model_button.setEnabled(not waiting) + self.prompt_input.setEnabled(not waiting) + self.stop_button.setEnabled(waiting) + word = language_wrapper.language_word_dict + self.status_label.setText( + word.get("chat_ui_status_waiting" if waiting else "chat_ui_status_ready")) + + def closeEvent(self, event) -> None: + """關閉前取消還在進行的請求 / Cancel a request still in flight before closing.""" + self.stop() + self._worker = None + if self.set_ai_config_dialog is not None: + self.set_ai_config_dialog.close() + super().closeEvent(event) diff --git a/je_editor/pyside_ui/main_ui/ai_widget/chat_worker.py b/je_editor/pyside_ui/main_ui/ai_widget/chat_worker.py new file mode 100644 index 0000000..20f8910 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/ai_widget/chat_worker.py @@ -0,0 +1,129 @@ +""" +在背景執行緒向 AI 供應者發出請求 +Ask an AI provider on a worker thread. + +供應者的 ``complete()`` 會等到整個回覆回來,在 UI 執行緒呼叫會讓視窗整段時間沒有 +反應。這裡把它搬到背景,回覆的每一段、最後的結果與錯誤都用訊號送回 UI 執行緒。 +A provider's ``complete()`` waits for the whole reply, and calling it on the UI +thread would leave the window unresponsive for all of it. This moves the call to +a worker thread and hands each piece of the reply, the final result and any +error back to the UI thread as signals. + +用的是 Python 的 daemon 執行緒而不是 ``QThread``:有些請求無法中途打斷,視窗關閉 +時它可能還在等網路。執行中的 ``QThread`` 被銷毀會讓 Qt 中止整個程序,daemon 執行緒 +則只是跟著程序一起結束。 +A Python daemon thread is used rather than a ``QThread``: some requests cannot +be interrupted, and one may still be waiting on the network when the window +closes. Qt aborts the process if a running ``QThread`` is destroyed, whereas a +daemon thread simply ends with the process. +""" +from __future__ import annotations + +from threading import Thread + +from PySide6.QtCore import QObject, Signal + +from je_editor.core.ai.ai_provider import AIProvider, CancelToken, ChatRequest +from je_editor.utils.exception.exceptions import JEditorServiceException +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 等一個請求結束的預設時間(毫秒)/ How long to wait for one request by default, in milliseconds +DEFAULT_WAIT_MS = 5000 +_MS_PER_SECOND = 1000 +# 還在執行的工作。面板可能在回覆回來之前就關掉,訊號的發送端不能跟著它被刪除, +# 所以由這裡留住,跑完才放掉。 +# The workers still running. A panel may close before the reply arrives and the +# object emitting the signals must not be deleted with it, so each one is kept +# here until it has finished. +_live_workers: set[ChatWorker] = set() + + +class ChatWorker(QObject): + """ + 送出一個對話請求 + Send one chat request. + """ + + text_ready = Signal(str) # one piece of the reply, as it is generated + replied = Signal(object) # the finished ChatResponse + failed = Signal(str) # what went wrong, in words for the user + finished = Signal() # always last, whatever happened + + def __init__(self, provider: AIProvider, request: ChatRequest) -> None: + """ + :param provider: 要詢問的供應者 / the provider to ask + :param request: 對話請求 / the chat request + """ + # 不設父物件:它的壽命由 _live_workers 管,不跟著面板 + # No parent: _live_workers decides how long it lives, not the panel + super().__init__() + self._provider = provider + self._request = request + self._cancel = CancelToken() + self._thread = Thread(target=self._run, name="ChatWorker", daemon=True) + self.finished.connect(self._release) + + def start_request(self) -> None: + """開始執行,並在執行期間留住自己 / Start, and keep itself alive while it runs.""" + _live_workers.add(self) + self._thread.start() + + def cancel(self) -> None: + """要求取消;供應者會在下一段文字到達時停下來 / Ask for a cancel; the provider stops at the next piece.""" + self._cancel.cancel() + + def wait(self, timeout_ms: int = DEFAULT_WAIT_MS) -> bool: + """ + 等這個請求結束 + Wait for this request to finish. + + :param timeout_ms: 最多等多久(毫秒)/ how long to wait at most, in milliseconds + :return: 是否已經結束 / whether it has finished + """ + if self._thread.ident is not None: + self._thread.join(timeout_ms / _MS_PER_SECOND) + return not self._thread.is_alive() + + def _run(self) -> None: + """呼叫供應者並回報結果 / Call the provider and report what came of it.""" + try: + response = self._provider.complete(self._request, self.text_ready.emit, self._cancel) + except JEditorServiceException as error: + self.failed.emit(str(error)) + # 供應者可以來自外掛,會丟什麼例外無從列舉;這裡是執行緒的邊界,漏掉的例外 + # 會讓面板永遠停在「等待回覆」 + # A provider may come from a plugin and its exceptions cannot be + # enumerated. This is the thread's boundary: an exception that escaped + # would leave the panel waiting for a reply for ever + except Exception as error: + jeditor_logger.exception("ChatWorker: the provider raised an unexpected error") + self.failed.emit(f"{type(error).__name__}: {error}") + else: + self.replied.emit(response) + finally: + self.finished.emit() + + def _release(self) -> None: + """跑完了:放掉參考並刪除自己 / Finished: let go of the reference and delete itself.""" + _live_workers.discard(self) + self.deleteLater() + + +def cancel_chat_workers() -> None: + """ + 取消所有還在執行的請求 + Cancel every request that is still running. + """ + for worker in list(_live_workers): + worker.cancel() + + +def wait_for_chat_workers(timeout_ms: int = DEFAULT_WAIT_MS) -> bool: + """ + 等所有還在執行的請求結束 + Wait for every request that is still running. + + :param timeout_ms: 每個請求最多等多久(毫秒)/ how long to wait for each one, in milliseconds + :return: 是否全部都結束了 / whether all of them finished + """ + return all(worker.wait(timeout_ms) for worker in list(_live_workers)) diff --git a/je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py b/je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py deleted file mode 100644 index f52d603..0000000 --- a/je_editor/pyside_ui/main_ui/ai_widget/langchain_interface.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import os -import re -from typing import TYPE_CHECKING - -from PySide6.QtWidgets import QMessageBox -from langchain_core.prompts.chat import SystemMessagePromptTemplate -from langchain_openai import ChatOpenAI -from pydantic import SecretStr - -from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper - -if TYPE_CHECKING: - from je_editor.pyside_ui.main_ui.ai_widget.chat_ui import ChatUI - - -class LangChainInterface(object): - """ - LangChainInterface 負責與 LangChain + OpenAI 模型互動 - LangChainInterface is responsible for interacting with LangChain + OpenAI model - """ - - def __init__(self, main_window: ChatUI, prompt_template: str, base_url: str, - api_key: SecretStr | str, chat_model: str) -> None: - """ - 初始化 LangChainInterface - Initialize LangChainInterface - - :param main_window: 主視窗,用於顯示錯誤訊息 / Main window, used for showing error messages - :param prompt_template: 系統提示詞模板 / System prompt template - :param base_url: OpenAI API 的基礎 URL / Base URL for OpenAI API - :param api_key: OpenAI API 金鑰 / OpenAI API key - :param chat_model: 使用的聊天模型名稱 / Chat model name - """ - # 建立系統提示詞模板 / Create system message prompt template - self.system_message_prompt = SystemMessagePromptTemplate.from_template(prompt_template) - - # 儲存基本設定 / Store basic settings - self.base_url = base_url - self.api_key = api_key - self.chat_model = chat_model - self.main_window = main_window - - # 將設定寫入環境變數,方便其他套件讀取 - # Save settings into environment variables for other packages to read - os.environ["OPENAI_BASE_URL"] = self.base_url - os.environ["OPENAI_API_KEY"] = self.api_key - os.environ["CHAT_MODEL"] = self.chat_model - - # 初始化 ChatOpenAI 物件,用於呼叫模型 - # Initialize ChatOpenAI object for model invocation - self.chat_ai = ChatOpenAI(base_url=self.base_url, api_key=self.api_key, model=self.chat_model) - - def call_ai_model(self, prompt: str) -> str | None: - """ - 呼叫 AI 模型並回傳結果 - Call AI model and return response - - :param prompt: 使用者輸入的提示詞 / User input prompt - :return: AI 回覆文字或 None / AI response text or None - """ - message = None - try: - # 呼叫 AI 並取得回覆;``text`` 是屬性,當成方法呼叫已被 langchain 標為棄用 - # Invoke AI and get response. ``text`` is a property: calling it as a - # method is deprecated in langchain and will stop working. - message = self.chat_ai.invoke(prompt).text - - # 嘗試過濾掉 標籤前的內容,只保留主要回覆 - # Try to filter out content before , keep only main response - match = re.search(r"\s*(.*)", message, re.DOTALL) - if match: - message = match.group(1).strip() - - except Exception as error: - # 發生錯誤時,彈出警告視窗顯示錯誤訊息 - # Show error message in a warning dialog if exception occurs - QMessageBox.warning( - self.main_window, - language_wrapper.language_word_dict.get("call_ai_model_error_title"), - str(error) - ) - return message diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 385c74c..0b64b2a 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -19,9 +19,11 @@ # 匯入專案內部模組 (自訂 UI 與功能) # Import project-specific modules (custom UI and features) +from je_editor.adapters.default_services import build_default_services from je_editor.pyside_ui.browser.browser_widget import BrowserWidget from je_editor.pyside_ui.browser.main_browser_widget import MainBrowserWidget from je_editor.pyside_ui.code.auto_save.auto_save_manager import init_new_auto_save_thread, file_is_open_manager_dict +from je_editor.pyside_ui.main_ui.ai_widget.chat_worker import cancel_chat_workers from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( @@ -91,6 +93,10 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, self.font_menu = None self.working_dir = None self.show_system_tray_ray = show_system_tray_ray + # 不屬於任何元件的狀態(工作區、診斷、AI 供應者與設定);面板向它要,而不是各自保管 + # The state that belongs to no widget (workspace, diagnostics, AI providers + # and settings); panels ask it instead of each keeping a copy + self.services = build_default_services() self.extend = extend # 是否為擴充模式(如 PyBreeze)/ Whether in extend mode (e.g. PyBreeze) # 確保外部插件已載入(若尚未載入) @@ -563,6 +569,10 @@ def closeEvent(self, event: QCloseEvent) -> None: stop_background_threads ) stop_background_threads() + # 還在等回覆的 AI 請求先取消,再放掉服務持有的資源 + # Cancel AI requests still waiting for a reply, then release what the services hold + cancel_chat_workers() + self.services.shutdown() write_user_setting() write_user_color_setting() super().closeEvent(event) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 3eb7758..9c56935 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -191,6 +191,22 @@ "chat_ui_set_ai_button": "Set AI setting", "chat_ui_load_ai_button": "Load AI setting", "chat_ui_call_ai_model_button": "Send prompt", + "chat_ui_stop_button": "Stop", + "chat_ui_new_chat_button": "New chat", + "chat_ui_provider_label": "Provider", + "chat_ui_model_label": "Model", + "chat_ui_you_prefix": "You", + "chat_ui_assistant_prefix": "Assistant", + "chat_ui_status_ready": "Ready", + "chat_ui_status_waiting": "Waiting for the reply...", + "chat_ui_status_done": "Reply complete", + "chat_ui_status_tokens": "Reply complete: {input} tokens in, {output} tokens out", + "chat_ui_status_cancelled": "Cancelled", + "chat_ui_status_failed": "The request failed", + "chat_ui_no_provider": "No AI provider is available", + "ai_system_prompt_label": "System prompt", + "ai_apply_settings_button": "Apply", + "ai_save_to_file_checkbox": "Also save to .jeditor/ai_config.json (the key is stored as plain text)", "base_url_label": "AI server URL", "api_key_label": "AI server API Key", "ai_model_label": "AI Model", diff --git a/je_editor/utils/multi_language/japanese.py b/je_editor/utils/multi_language/japanese.py index 32cefa1..ad7c21f 100644 --- a/je_editor/utils/multi_language/japanese.py +++ b/je_editor/utils/multi_language/japanese.py @@ -186,6 +186,22 @@ "chat_ui_set_ai_button": "AI 設定を保存", "chat_ui_load_ai_button": "AI 設定を読み込む", "chat_ui_call_ai_model_button": "プロンプトを送信", + "chat_ui_stop_button": "停止", + "chat_ui_new_chat_button": "新しいチャット", + "chat_ui_provider_label": "プロバイダー", + "chat_ui_model_label": "モデル", + "chat_ui_you_prefix": "あなた", + "chat_ui_assistant_prefix": "アシスタント", + "chat_ui_status_ready": "準備完了", + "chat_ui_status_waiting": "応答を待っています……", + "chat_ui_status_done": "応答が完了しました", + "chat_ui_status_tokens": "応答が完了しました:入力 {input} トークン、出力 {output} トークン", + "chat_ui_status_cancelled": "キャンセルしました", + "chat_ui_status_failed": "リクエストに失敗しました", + "chat_ui_no_provider": "利用できる AI プロバイダーがありません", + "ai_system_prompt_label": "システムプロンプト", + "ai_apply_settings_button": "適用", + "ai_save_to_file_checkbox": ".jeditor/ai_config.json にも保存する(キーは平文で保存されます)", "base_url_label": "AI サーバー URL", "api_key_label": "AI サーバー API Key", "ai_model_label": "AI Model", diff --git a/je_editor/utils/multi_language/simplified_chinese.py b/je_editor/utils/multi_language/simplified_chinese.py index 74275da..75694c0 100644 --- a/je_editor/utils/multi_language/simplified_chinese.py +++ b/je_editor/utils/multi_language/simplified_chinese.py @@ -182,6 +182,22 @@ "chat_ui_set_ai_button": "设置 AI 配置", "chat_ui_load_ai_button": "加载 AI 配置", "chat_ui_call_ai_model_button": "发送 prompt", + "chat_ui_stop_button": "停止", + "chat_ui_new_chat_button": "新对话", + "chat_ui_provider_label": "提供者", + "chat_ui_model_label": "模型", + "chat_ui_you_prefix": "您", + "chat_ui_assistant_prefix": "助手", + "chat_ui_status_ready": "就绪", + "chat_ui_status_waiting": "正在等待回复……", + "chat_ui_status_done": "回复完成", + "chat_ui_status_tokens": "回复完成:输入 {input} 个 token,输出 {output} 个 token", + "chat_ui_status_cancelled": "已取消", + "chat_ui_status_failed": "请求失败", + "chat_ui_no_provider": "没有可用的 AI 提供者", + "ai_system_prompt_label": "系统提示词", + "ai_apply_settings_button": "应用", + "ai_save_to_file_checkbox": "同时保存到 .jeditor/ai_config.json(密钥会以明文保存)", "base_url_label": "AI 服务器 URL", "api_key_label": "AI 服务器 API Key", "ai_model_label": "AI Model", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b53ab85..b464160 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -182,6 +182,22 @@ "chat_ui_set_ai_button": "設定 AI 設定", "chat_ui_load_ai_button": "載入 AI 設定", "chat_ui_call_ai_model_button": "傳送 prompt", + "chat_ui_stop_button": "停止", + "chat_ui_new_chat_button": "新對話", + "chat_ui_provider_label": "供應者", + "chat_ui_model_label": "模型", + "chat_ui_you_prefix": "您", + "chat_ui_assistant_prefix": "助理", + "chat_ui_status_ready": "就緒", + "chat_ui_status_waiting": "等待回覆中……", + "chat_ui_status_done": "回覆完成", + "chat_ui_status_tokens": "回覆完成:輸入 {input} 個 token,輸出 {output} 個 token", + "chat_ui_status_cancelled": "已取消", + "chat_ui_status_failed": "請求失敗", + "chat_ui_no_provider": "沒有可用的 AI 供應者", + "ai_system_prompt_label": "系統提示詞", + "ai_apply_settings_button": "套用", + "ai_save_to_file_checkbox": "同時存到 .jeditor/ai_config.json(金鑰會以明文儲存)", "base_url_label": "AI 伺服器 URL", "api_key_label": "AI 伺服器 API Key", "ai_model_label": "AI Model", diff --git a/pyproject.toml b/pyproject.toml index 5d0c428..f99da73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ license = "MIT" license-files = ["LICENSE"] dependencies = [ "PySide6==6.11.2", "qt-material", "yapf", "frontengine", "pycodestyle", "jedi", - "qtconsole", "langchain_openai==1.6.2", "langchain_core", "pydantic", "watchdog", "ruff", "gitpython>=3.1.59" + "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", + "watchdog", "ruff", "gitpython>=3.1.59" ] classifiers = [ "Programming Language :: Python :: 3.10", diff --git a/requirements.txt b/requirements.txt index 2ecc4fa..b54ae7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ PySide6==6.11.2 langchain_openai==1.6.2 langchain_core +anthropic==1.11.0 ruff sphinx twine diff --git a/test/test_ai_providers.py b/test/test_ai_providers.py new file mode 100644 index 0000000..568fb70 --- /dev/null +++ b/test/test_ai_providers.py @@ -0,0 +1,402 @@ +"""Tests for the OpenAI-compatible and Anthropic providers, against fakes of their SDK clients.""" +from __future__ import annotations + +import warnings +from types import SimpleNamespace + +import anthropic +import httpx2 +import pytest +from langchain_core.messages import AIMessage + +from je_editor.adapters.ai.anthropic_provider import ( + DEFAULT_MODEL, MAX_OUTPUT_TOKENS, AnthropicProvider +) +from je_editor.adapters.ai.builtin_providers import register_builtin_ai_providers +from je_editor.adapters.ai.openai_provider import OpenAIProvider, answer_after_reasoning +from je_editor.core.ai.ai_provider import ( + AIProvider, CancelToken, ChatMessage, ChatRequest, ChatRole +) +from je_editor.core.ai.ai_settings import AISettings, ProviderSettings +from je_editor.core.registry.named_registry import NamedRegistry +from je_editor.utils.exception.exceptions import JEditorServiceException + +FALLBACK_BETA = "server-side-fallback-2026-07-01" + + +def ask(text: str, **fields) -> ChatRequest: + return ChatRequest((ChatMessage(ChatRole.USER, text),), **fields) + + +# --- OpenAI-compatible ------------------------------------------------------ + +class FakeChat: + """Stands in for ChatOpenAI, returning a real AIMessage without any network.""" + + def __init__(self, content: str, usage: dict | None = None) -> None: + self._content = content + self._usage = usage + self.calls: list[list[tuple[str, str]]] = [] + + def invoke(self, messages: list[tuple[str, str]]) -> AIMessage: + self.calls.append(messages) + return AIMessage(content=self._content, usage_metadata=self._usage) + + +def openai_provider(content: str, settings: ProviderSettings | None = None, usage=None): + """A provider whose model is a fake answering with ``content``, plus that fake and the build log.""" + chat = FakeChat(content, usage) + built: list[tuple[ProviderSettings, str]] = [] + + def factory(current: ProviderSettings, model: str) -> FakeChat: + built.append((current, model)) + return chat + + current = settings or ProviderSettings(model="gpt-4o-mini") + return OpenAIProvider(lambda: current, factory), chat, built + + +class TestOpenAIReadingTheReply: + def test_a_plain_reply_comes_back_unchanged(self): + provider, _chat, _built = openai_provider("hello there") + assert provider.complete(ask("hi")).text == "hello there" + + def test_the_prompt_reaches_the_model(self): + provider, chat, _built = openai_provider("anything") + provider.complete(ask("what is 2 + 2?")) + assert chat.calls == [[("human", "what is 2 + 2?")]] + + def test_an_empty_reply_stays_empty(self): + provider, _chat, _built = openai_provider("") + assert provider.complete(ask("hi")).text == "" + + def test_reading_the_text_warns_of_nothing_deprecated(self): + # ``text`` is a property upstream; calling it as a method still returns + # the right value but is deprecated, so only a recorded warning shows it. + provider, _chat, _built = openai_provider("hello there") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert provider.complete(ask("hi")).text == "hello there" + deprecated = [str(item.message) for item in caught + if issubclass(item.category, DeprecationWarning)] + assert not deprecated, deprecated + + +class TestStrippingTheThinkingBlock: + def test_content_before_the_closing_tag_is_dropped(self): + provider, _chat, _built = openai_provider("internal reasoning\n the answer ") + assert provider.complete(ask("hi")).text == "the answer" + + def test_a_reply_without_the_tag_is_left_alone(self): + assert answer_after_reasoning("just the answer") == "just the answer" + + def test_only_the_first_closing_tag_starts_the_answer(self): + assert answer_after_reasoning("afirstsecond") == "firstsecond" + + def test_the_listener_never_sees_the_thinking(self): + provider, _chat, _built = openai_provider("secret planthe answer") + heard: list[str] = [] + provider.complete(ask("hi"), on_text=heard.append) + assert heard == ["the answer"] + + +class TestOpenAIRequestShape: + def test_the_system_prompt_and_the_history_are_sent_in_order(self): + provider, chat, _built = openai_provider("ok") + provider.complete(ChatRequest( + (ChatMessage(ChatRole.USER, "first"), ChatMessage(ChatRole.ASSISTANT, "reply"), + ChatMessage(ChatRole.USER, "second")), + system_prompt="Be brief.")) + assert chat.calls == [[("system", "Be brief."), ("human", "first"), ("ai", "reply"), + ("human", "second")]] + + def test_the_system_prompt_falls_back_to_the_settings(self): + provider, chat, _built = openai_provider( + "ok", ProviderSettings(model="gpt-4o-mini", system_prompt="From the settings.")) + provider.complete(ask("hi")) + assert chat.calls[0][0] == ("system", "From the settings.") + + def test_the_model_of_the_request_wins_over_the_settings(self): + provider, _chat, built = openai_provider("ok") + response = provider.complete(ask("hi", model_id="another-model")) + assert built[0][1] == "another-model" + assert response.model_id == "another-model" + + def test_no_model_anywhere_is_refused_before_any_call(self): + provider, chat, _built = openai_provider("ok", ProviderSettings()) + with pytest.raises(JEditorServiceException, match="No model is set"): + provider.complete(ask("hi")) + assert chat.calls == [] + + def test_token_usage_is_reported_when_the_service_gives_it(self): + usage = {"input_tokens": 12, "output_tokens": 3, "total_tokens": 15} + provider, _chat, _built = openai_provider("ok", usage=usage) + response = provider.complete(ask("hi")) + assert (response.input_tokens, response.output_tokens) == (12, 3) + + def test_missing_usage_is_reported_as_unknown(self): + provider, _chat, _built = openai_provider("ok") + response = provider.complete(ask("hi")) + assert (response.input_tokens, response.output_tokens) == (None, None) + + def test_a_reply_that_arrives_after_a_cancel_is_dropped(self): + provider, _chat, _built = openai_provider("too late") + cancel = CancelToken() + cancel.cancel() + heard: list[str] = [] + response = provider.complete(ask("hi"), heard.append, cancel) + assert (response.cancelled, response.text, heard) == (True, "", []) + + def test_a_failure_of_the_service_becomes_the_editor_exception(self): + from openai import OpenAIError + + class Failing: + def invoke(self, _messages): + raise OpenAIError("connection refused") + + provider = OpenAIProvider(lambda: ProviderSettings(model="m"), lambda _s, _m: Failing()) + with pytest.raises(JEditorServiceException, match="connection refused") as caught: + provider.complete(ask("hi")) + assert isinstance(caught.value.__cause__, OpenAIError) + + def test_the_real_chat_model_is_built_from_the_settings(self): + from je_editor.adapters.ai.openai_provider import _build_chat_model + chat = _build_chat_model( + ProviderSettings(api_key="not-a-real-key", base_url="https://example.invalid/v1"), + "gpt-4o-mini") + assert (chat.model_name, str(chat.openai_api_base)) == ( + "gpt-4o-mini", "https://example.invalid/v1") + + +# --- Anthropic -------------------------------------------------------------- + +class FakeStream: + """One open stream: yields the given pieces, then describes the finished message.""" + + def __init__(self, pieces, final) -> None: + self.text_stream = iter(pieces) + self._final = final + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + self.closed = True + + def get_final_message(self): + return self._final + + +class FakeMessages: + """Records the parameters of each ``stream`` call.""" + + def __init__(self, owner, surface: str) -> None: + self._owner = owner + self._surface = surface + + def stream(self, **parameters): + self._owner.calls.append((self._surface, parameters)) + if self._owner.error is not None: + raise self._owner.error + self._owner.stream = FakeStream(self._owner.pieces, self._owner.final) + return self._owner.stream + + +class FakeAnthropic: + """Stands in for ``anthropic.Anthropic``: both the plain and the beta message surfaces.""" + + def __init__(self, pieces=("Hello", ", ", "world"), stop_reason="end_turn", error=None, + stop_details=None, model="claude-opus-5-5") -> None: + self.pieces = list(pieces) + self.error = error + self.final = SimpleNamespace( + stop_reason=stop_reason, stop_details=stop_details, model=model, + usage=SimpleNamespace(input_tokens=21, output_tokens=len(self.pieces))) + self.calls: list[tuple[str, dict]] = [] + self.stream: FakeStream | None = None + self.messages = FakeMessages(self, "messages") + self.beta = SimpleNamespace(messages=FakeMessages(self, "beta.messages")) + + +def anthropic_provider(client: FakeAnthropic, settings: ProviderSettings | None = None): + current = settings or ProviderSettings() + return AnthropicProvider(lambda: current, lambda _settings: client) + + +def status_error(error_type, status: int, message: str): + """A real SDK status error, built the way the SDK builds one from a response.""" + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + return error_type(message, response=httpx2.Response(status, request=request), body=None) + + +class TestAnthropicStreaming: + def test_the_pieces_arrive_one_by_one_and_add_up_to_the_reply(self): + heard: list[str] = [] + response = anthropic_provider(FakeAnthropic()).complete(ask("hi"), heard.append) + assert heard == ["Hello", ", ", "world"] + assert response.text == "Hello, world" + + def test_token_usage_and_the_answering_model_are_reported(self): + response = anthropic_provider(FakeAnthropic(model="claude-opus-4-8")).complete(ask("hi")) + assert (response.input_tokens, response.output_tokens, response.model_id) == ( + 21, 3, "claude-opus-4-8") + + def test_the_stream_is_closed_afterwards(self): + client = FakeAnthropic() + anthropic_provider(client).complete(ask("hi")) + assert client.stream.closed is True + + def test_a_cancel_stops_part_way_and_keeps_what_arrived(self): + client = FakeAnthropic(pieces=("one ", "two ", "three")) + cancel = CancelToken() + heard: list[str] = [] + + def stop_after_the_first(piece: str) -> None: + heard.append(piece) + cancel.cancel() + + response = anthropic_provider(client).complete(ask("hi"), stop_after_the_first, cancel) + assert (response.cancelled, response.text, heard) == (True, "one ", ["one "]) + assert client.stream.closed is True + + def test_a_refusal_is_reported_and_not_passed_off_as_an_answer(self): + client = FakeAnthropic(pieces=("Sure, the first", ), stop_reason="refusal", + stop_details=SimpleNamespace(category="cyber")) + with pytest.raises(JEditorServiceException, match="declined.*cyber"): + anthropic_provider(client).complete(ask("hi")) + + def test_a_refusal_without_details_still_reads_sensibly(self): + client = FakeAnthropic(pieces=(), stop_reason="refusal", stop_details=None) + with pytest.raises(JEditorServiceException, match="category: unspecified"): + anthropic_provider(client).complete(ask("hi")) + + +class TestAnthropicRequestShape: + def _parameters(self, request: ChatRequest, settings: ProviderSettings | None = None): + client = FakeAnthropic() + anthropic_provider(client, settings).complete(request) + return client.calls[0] + + def test_the_default_model_is_used_when_none_is_named(self): + _surface, parameters = self._parameters(ask("hi")) + assert parameters["model"] == DEFAULT_MODEL + + def test_the_model_of_the_request_wins_over_the_settings(self): + _surface, parameters = self._parameters( + ask("hi", model_id="claude-haiku-4-5"), ProviderSettings(model="claude-sonnet-5-5")) + assert parameters["model"] == "claude-haiku-4-5" + + def test_the_history_keeps_its_roles_and_order(self): + _surface, parameters = self._parameters(ChatRequest(( + ChatMessage(ChatRole.USER, "first"), ChatMessage(ChatRole.ASSISTANT, "reply"), + ChatMessage(ChatRole.USER, "second")))) + assert parameters["messages"] == [ + {"role": "user", "content": "first"}, {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "second"}] + + def test_the_system_prompt_is_a_parameter_of_its_own(self): + _surface, parameters = self._parameters(ask("hi", system_prompt="Be brief.")) + assert parameters["system"] == "Be brief." + + def test_no_system_prompt_sends_no_system_parameter(self): + _surface, parameters = self._parameters(ask("hi")) + assert "system" not in parameters + + def test_the_output_cap_leaves_room_for_a_long_answer(self): + _surface, parameters = self._parameters(ask("hi")) + assert parameters["max_tokens"] == MAX_OUTPUT_TOKENS + + def test_nothing_the_newer_models_reject_is_sent(self): + _surface, parameters = self._parameters(ask("hi")) + assert not {"temperature", "top_p", "top_k", "thinking"} & set(parameters) + + @pytest.mark.parametrize("model", [ + "claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1", "claude-opus-5"]) + def test_a_model_that_may_decline_gets_the_server_side_fallback(self, model): + surface, parameters = self._parameters(ask("hi", model_id=model)) + assert surface == "beta.messages" + assert (parameters["betas"], parameters["fallbacks"]) == ([FALLBACK_BETA], "default") + + @pytest.mark.parametrize("model", ["claude-haiku-4-5", "some-other-model"]) + def test_other_models_use_the_plain_surface(self, model): + surface, parameters = self._parameters(ask("hi", model_id=model)) + assert surface == "messages" + assert "fallbacks" not in parameters and "betas" not in parameters + + def test_another_address_turns_the_fallback_off(self): + surface, parameters = self._parameters( + ask("hi"), ProviderSettings(base_url="https://proxy.example.invalid")) + assert surface == "messages" + assert "fallbacks" not in parameters + + +class TestAnthropicErrors: + @pytest.mark.parametrize("error, expected", [ + (status_error(anthropic.AuthenticationError, 401, "invalid x-api-key"), "rejected the API key"), + (status_error(anthropic.PermissionDeniedError, 403, "no access"), "may not use that model"), + (status_error(anthropic.NotFoundError, 404, "model: nope"), "does not know that model"), + (status_error(anthropic.RateLimitError, 429, "slow down"), "rate limiting"), + (status_error(anthropic.BadRequestError, 400, "bad field"), "rejected the request"), + (status_error(anthropic.InternalServerError, 500, "overloaded"), "returned an error"), + (anthropic.APIConnectionError(request=httpx2.Request("POST", "https://api.anthropic.com")), + "Could not reach Anthropic"), + ]) + def test_each_kind_of_failure_gets_its_own_explanation(self, error, expected): + with pytest.raises(JEditorServiceException, match=expected) as caught: + anthropic_provider(FakeAnthropic(error=error)).complete(ask("hi")) + assert caught.value.__cause__ is error + + def test_missing_credentials_are_explained(self): + missing = TypeError("Could not resolve authentication method.") + with pytest.raises(JEditorServiceException, match="No Anthropic credentials"): + anthropic_provider(FakeAnthropic(error=missing)).complete(ask("hi")) + + +class TestTheRealAnthropicClient: + """The provider's calls match the SDK that is installed, checked without any network.""" + + @pytest.fixture() + def client(self): + from je_editor.adapters.ai.anthropic_provider import _build_client + return _build_client(ProviderSettings(api_key="not-a-real-key")) + + def test_the_key_and_the_address_reach_the_client(self): + from je_editor.adapters.ai.anthropic_provider import _build_client + built = _build_client(ProviderSettings( + api_key="not-a-real-key", base_url="https://proxy.example.invalid")) + assert built.api_key == "not-a-real-key" + assert str(built.base_url).startswith("https://proxy.example.invalid") + + def test_both_stream_methods_take_the_parameters_the_provider_sends(self, client): + import inspect + plain = set(inspect.signature(client.messages.stream).parameters) + beta = set(inspect.signature(client.beta.messages.stream).parameters) + assert {"model", "max_tokens", "messages", "system"} <= plain + assert {"model", "max_tokens", "messages", "system", "betas", "fallbacks"} <= beta + + +class TestTheBuiltInProviders: + @pytest.fixture() + def registry(self): + settings = AISettings() + registry: NamedRegistry[AIProvider] = NamedRegistry("AI provider") + register_builtin_ai_providers(registry, lambda: settings) + return registry, settings + + def test_both_are_registered_under_their_names(self, registry): + assert registry[0].names() == ["openai", "anthropic"] + + def test_each_satisfies_the_provider_interface(self, registry): + assert all(isinstance(provider, AIProvider) for _name, provider in registry[0].items()) + + def test_only_anthropic_offers_a_fixed_model_list(self, registry): + assert registry[0].require("openai").models() == [] + assert registry[0].require("anthropic").models()[0].model_id == DEFAULT_MODEL + + def test_a_provider_reads_its_own_group_of_the_latest_settings(self, registry): + providers, settings = registry + settings.update("openai", model="set-after-registering") + settings.update("anthropic", model="claude-haiku-4-5") + assert providers.require("openai")._settings().model == "set-after-registering" + assert providers.require("anthropic")._settings().model == "claude-haiku-4-5" diff --git a/test/test_ai_settings.py b/test/test_ai_settings.py new file mode 100644 index 0000000..2c1f859 --- /dev/null +++ b/test/test_ai_settings.py @@ -0,0 +1,162 @@ +"""Tests for the provider-scoped AI settings and the file that holds them.""" +from __future__ import annotations + +import json +import logging + +import pytest + +from je_editor.adapters.ai.settings_file import ( + ai_settings_path, load_ai_settings, save_ai_settings +) +from je_editor.core.ai.ai_settings import AISettings, ProviderSettings +from je_editor.utils.exception.exceptions import JEditorServiceException + +SECRET_KEY = "sk-test-0123456789abcdefghij" +# What the settings file looked like before settings were grouped by provider +OLDER_FILE = {"AI_model": { + "ai_base_url": "https://example.invalid/v1", + "ai_api_key": SECRET_KEY, + "chat_model": "gpt-4o-mini", + "prompt_template": "You are a careful reviewer.", +}} + + +class TestProviderSettings: + def test_a_new_set_is_empty(self): + assert ProviderSettings() == ProviderSettings("", "", "", "") + + def test_a_long_key_is_masked_to_its_ends(self): + masked = ProviderSettings(api_key=SECRET_KEY).masked_api_key + assert masked == "sk-t...ghij" + assert SECRET_KEY[4:-4] not in masked + + @pytest.mark.parametrize("key", ["a", "abcd", "abcdefgh"]) + def test_a_short_key_is_hidden_entirely(self, key): + assert ProviderSettings(api_key=key).masked_api_key == "*" * len(key) + + def test_no_key_masks_to_nothing(self): + assert ProviderSettings().masked_api_key == "" + + +class TestAISettings: + def test_an_unknown_provider_has_empty_settings(self): + assert AISettings().settings_for("anthropic") == ProviderSettings() + + def test_updating_one_provider_leaves_the_other_alone(self): + settings = AISettings() + settings.update("openai", api_key="openai-key", model="gpt-4o-mini") + settings.update("anthropic", api_key="anthropic-key") + assert settings.settings_for("openai").api_key == "openai-key" + assert settings.settings_for("anthropic").api_key == "anthropic-key" + + def test_an_update_keeps_the_fields_it_does_not_name(self): + settings = AISettings() + settings.update("openai", api_key="key", model="first") + settings.update("openai", model="second") + assert settings.settings_for("openai") == ProviderSettings(api_key="key", model="second") + + def test_a_round_trip_through_a_dictionary_loses_nothing(self): + settings = AISettings(active_provider="anthropic") + settings.update("anthropic", api_key="key", model="claude-opus-5-5", system_prompt="Be brief.") + settings.update("openai", base_url="https://example.invalid/v1", model="gpt-4o-mini") + assert AISettings.from_dict(settings.to_dict()) == settings + + @pytest.mark.parametrize("data", [None, [], "text", 7, {"providers": "not a mapping"}]) + def test_unusable_content_gives_empty_settings(self, data): + assert AISettings.from_dict(data) == AISettings() + + def test_entries_of_the_wrong_type_are_skipped(self): + loaded = AISettings.from_dict({"providers": { + "openai": {"api_key": 42, "model": "gpt-4o-mini", "unknown_field": "ignored"}, + "broken": "not a mapping", + "": {"model": "nameless"}, + }}) + assert loaded.providers == {"openai": ProviderSettings(model="gpt-4o-mini")} + + +class TestTheOlderFileFormat: + def test_the_single_group_becomes_the_openai_provider(self): + loaded = AISettings.from_dict(OLDER_FILE) + assert loaded.settings_for("openai") == ProviderSettings( + api_key=SECRET_KEY, base_url="https://example.invalid/v1", model="gpt-4o-mini", + system_prompt="You are a careful reviewer.") + + def test_the_openai_provider_becomes_the_one_in_use(self): + assert AISettings.from_dict(OLDER_FILE).active_provider == "openai" + + def test_an_empty_older_group_adds_nothing(self): + loaded = AISettings.from_dict({"AI_model": {"ai_base_url": "", "chat_model": ""}}) + assert loaded.providers == {} + + def test_newer_settings_win_over_the_older_group(self): + data = dict(OLDER_FILE, providers={"openai": {"model": "newer-model"}}, + active_provider="anthropic") + loaded = AISettings.from_dict(data) + assert loaded.settings_for("openai").model == "newer-model" + assert loaded.active_provider == "anthropic" + + +class TestTheSettingsFile: + def test_it_lives_under_the_dot_directory(self, tmp_path): + assert ai_settings_path(tmp_path) == tmp_path / ".jeditor" / "ai_config.json" + + def test_the_default_place_is_the_working_directory(self, tmp_dir): + assert ai_settings_path().parent.name == ".jeditor" + assert ai_settings_path().parent.parent.samefile(tmp_dir) + + def test_a_missing_file_gives_empty_settings(self, tmp_path): + assert load_ai_settings(tmp_path / "absent.json") == AISettings() + + def test_what_is_saved_is_what_is_loaded(self, tmp_path): + settings = AISettings(active_provider="anthropic") + settings.update("anthropic", api_key=SECRET_KEY, model="claude-opus-5-5") + target = save_ai_settings(settings, ai_settings_path(tmp_path)) + assert load_ai_settings(target) == settings + + def test_saving_creates_the_directory(self, tmp_path): + target = ai_settings_path(tmp_path / "fresh-project") + save_ai_settings(AISettings(active_provider="openai"), target) + assert target.is_file() + + def test_text_outside_ascii_is_kept_readable(self, tmp_path): + settings = AISettings() + settings.update("openai", system_prompt="請用繁體中文回答") + target = save_ai_settings(settings, tmp_path / "ai_config.json") + assert "請用繁體中文回答" in target.read_text(encoding="utf-8") + + def test_an_older_file_on_disk_is_read(self, tmp_path): + target = tmp_path / "ai_config.json" + target.write_text(json.dumps(OLDER_FILE), encoding="utf-8") + assert load_ai_settings(target).settings_for("openai").model == "gpt-4o-mini" + + def test_a_corrupt_file_gives_empty_settings(self, tmp_path): + target = tmp_path / "ai_config.json" + target.write_text("{not json", encoding="utf-8") + assert load_ai_settings(target) == AISettings() + + def test_a_file_that_cannot_be_written_is_reported(self, tmp_path): + blocked = tmp_path / "a-file" + blocked.write_text("in the way", encoding="utf-8") + with pytest.raises(JEditorServiceException, match="could not be saved"): + save_ai_settings(AISettings(), blocked / "ai_config.json") + + +class TestTheKeyStaysOutOfTheLog: + """Saving and loading record where the file is, never what is in it.""" + + @pytest.fixture() + def logged(self, tmp_path, caplog): + settings = AISettings(active_provider="anthropic") + settings.update("anthropic", api_key=SECRET_KEY) + with caplog.at_level(logging.DEBUG, logger="JEditor"): + target = save_ai_settings(settings, tmp_path / "ai_config.json") + load_ai_settings(target) + return caplog.text + + def test_the_save_is_recorded_with_its_path(self, logged): + assert "AI settings saved" in logged + assert "ai_config.json" in logged + + def test_the_key_is_not_recorded(self, logged): + assert SECRET_KEY not in logged diff --git a/test/test_chat_session.py b/test/test_chat_session.py new file mode 100644 index 0000000..42cbf0f --- /dev/null +++ b/test/test_chat_session.py @@ -0,0 +1,84 @@ +"""Tests for the conversation a chat panel keeps.""" +from __future__ import annotations + +import pytest + +from je_editor.core.ai.ai_provider import ChatResponse, ChatRole +from je_editor.core.ai.chat_session import ChatSession + + +class TestAsking: + def test_the_first_request_holds_only_the_prompt(self): + request = ChatSession().ask("hello") + assert [(message.role, message.content) for message in request.messages] == [ + (ChatRole.USER, "hello")] + + def test_the_model_and_the_system_prompt_go_into_the_request(self): + request = ChatSession().ask("hello", "claude-opus-5-5", "Be brief.") + assert (request.model_id, request.system_prompt) == ("claude-opus-5-5", "Be brief.") + + def test_surrounding_space_is_trimmed(self): + assert ChatSession().ask(" hello \n").messages[-1].content == "hello" + + @pytest.mark.parametrize("prompt", ["", " ", "\n\t"]) + def test_an_empty_prompt_builds_no_request(self, prompt): + session = ChatSession() + assert session.ask(prompt) is None + assert not session.is_waiting + + def test_a_second_prompt_waits_for_the_first_reply(self): + session = ChatSession() + session.ask("first") + assert session.is_waiting + assert session.ask("second") is None + + +class TestTheReply: + def test_an_answered_exchange_joins_the_conversation(self): + session = ChatSession() + session.ask("hello") + assert session.answered(ChatResponse("hi there")) is True + assert [(message.role, message.content) for message in session.messages] == [ + (ChatRole.USER, "hello"), (ChatRole.ASSISTANT, "hi there")] + + def test_the_next_request_carries_the_conversation(self): + session = ChatSession() + session.ask("hello") + session.answered(ChatResponse("hi there")) + request = session.ask("and then?") + assert [message.content for message in request.messages] == [ + "hello", "hi there", "and then?"] + + def test_the_prompt_is_not_part_of_the_conversation_until_it_is_answered(self): + session = ChatSession() + session.ask("hello") + assert session.messages == () + + @pytest.mark.parametrize("response", [ + ChatResponse("partial", cancelled=True), ChatResponse(""), + ]) + def test_a_cancelled_or_empty_reply_drops_the_exchange(self, response): + session = ChatSession() + session.ask("hello") + assert session.answered(response) is False + assert session.messages == () and not session.is_waiting + + def test_a_reply_nobody_asked_for_is_ignored(self): + session = ChatSession() + assert session.answered(ChatResponse("unsolicited")) is False + assert session.messages == () + + def test_a_failure_drops_the_prompt_and_frees_the_session(self): + session = ChatSession() + session.ask("hello") + session.failed() + assert not session.is_waiting + assert len(session.ask("again").messages) == 1 + + def test_clearing_starts_over(self): + session = ChatSession() + session.ask("hello") + session.answered(ChatResponse("hi there")) + session.ask("pending") + session.clear() + assert session.messages == () and not session.is_waiting diff --git a/test/test_chat_ui.py b/test/test_chat_ui.py new file mode 100644 index 0000000..b1c3c92 --- /dev/null +++ b/test/test_chat_ui.py @@ -0,0 +1,394 @@ +"""Tests for the chat panel, its worker and the AI settings dialog, driven by fake providers.""" +from __future__ import annotations + +from threading import Event +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from PySide6.QtWidgets import QApplication, QLineEdit + +from je_editor.adapters.ai.settings_file import ai_settings_path, load_ai_settings +from je_editor.core.ai.ai_provider import ChatMessage, ChatRequest, ChatResponse, ChatRole, ModelInfo +from je_editor.core.services.editor_services import EditorServices +from je_editor.pyside_ui.dialog.ai_dialog.set_ai_dialog import SetAIDialog +from je_editor.pyside_ui.main_ui.ai_widget import chat_worker +from je_editor.pyside_ui.main_ui.ai_widget.chat_ui import ChatUI, services_for +from je_editor.pyside_ui.main_ui.ai_widget.chat_worker import ( + ChatWorker, cancel_chat_workers, wait_for_chat_workers +) +from je_editor.utils.exception.exceptions import JEditorServiceException +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper +from je_editor.utils.multi_language.traditional_chinese import traditional_chinese_word_dict + +# Long enough that a slow machine still finishes; a hang fails rather than blocks. +TIMEOUT_MS = 10_000 +CHAT_UI = "je_editor.pyside_ui.main_ui.ai_widget.chat_ui" +DIALOG = "je_editor.pyside_ui.dialog.ai_dialog.set_ai_dialog" + + +class EchoProvider: + """Answers with the last message in upper case, one word at a time.""" + + name = "echo" + + def __init__(self) -> None: + self.requests: list[ChatRequest] = [] + + def models(self) -> list[ModelInfo]: + return [ModelInfo("echo-large"), ModelInfo("echo-small")] + + def complete(self, request, on_text=None, cancel=None) -> ChatResponse: + self.requests.append(request) + words = request.messages[-1].content.upper().split() + for index, word in enumerate(words): + on_text(word if index == 0 else f" {word}") + return ChatResponse(" ".join(words), request.model_id, len(request.messages), len(words)) + + +class FailingProvider: + """Fails every request with the error it was built with.""" + + name = "failing" + + def __init__(self, error: Exception) -> None: + self._error = error + + def models(self) -> list[ModelInfo]: + return [] + + def complete(self, request, on_text=None, cancel=None) -> ChatResponse: + raise self._error + + +class SlowProvider: + """Sends one piece, then waits until it is cancelled or released.""" + + name = "slow" + + def __init__(self) -> None: + self.started = Event() + self.release = Event() + + def models(self) -> list[ModelInfo]: + return [] + + def complete(self, request, on_text=None, cancel=None) -> ChatResponse: + on_text("partial") + self.started.set() + while not self.release.wait(0.01): + if cancel.cancelled: + return ChatResponse("partial", cancelled=True) + return ChatResponse("partial and the rest") + + +def ask(text: str) -> ChatRequest: + return ChatRequest((ChatMessage(ChatRole.USER, text),)) + + +@pytest.fixture(autouse=True) +def _no_chat_requests_left_running(): + """A request left in flight would deliver its signals into the next test.""" + yield + cancel_chat_workers() + assert wait_for_chat_workers(TIMEOUT_MS) + QApplication.processEvents() + + +@pytest.fixture() +def services(): + built = EditorServices() + built.ai_providers.register("echo", EchoProvider()) + built.ai_settings.active_provider = "echo" + return built + + +@pytest.fixture() +def panel(qapp, qtbot, services): + widget = ChatUI(SimpleNamespace(services=services)) + qtbot.addWidget(widget) + return widget + + +def send(panel: ChatUI, qtbot, text: str) -> None: + """Send a prompt and wait until its reply, or its failure, has been handled.""" + panel.prompt_input.setText(text) + assert panel.call_ai_model() is True + qtbot.waitUntil(lambda: panel._worker is None, timeout=TIMEOUT_MS) + + +class TestTheWorker: + def test_pieces_then_the_reply_then_the_end_are_signalled(self, qapp, qtbot): + worker = ChatWorker(EchoProvider(), ask("hello world")) + events: list[tuple] = [] + worker.text_ready.connect(lambda piece: events.append(("text", piece))) + worker.replied.connect(lambda response: events.append(("replied", response.text))) + worker.finished.connect(lambda: events.append(("finished",))) + worker.start_request() + qtbot.waitUntil(lambda: ("finished",) in events, timeout=TIMEOUT_MS) + assert events == [("text", "HELLO"), ("text", " WORLD"), ("replied", "HELLO WORLD"), + ("finished",)] + + @pytest.mark.parametrize("error, expected", [ + (JEditorServiceException("the key was rejected"), "the key was rejected"), + (RuntimeError("a plugin bug"), "RuntimeError: a plugin bug"), + ]) + def test_a_failure_is_signalled_in_words(self, qapp, qtbot, error, expected): + worker = ChatWorker(FailingProvider(error), ask("hi")) + failures: list[str] = [] + worker.failed.connect(failures.append) + worker.start_request() + qtbot.waitUntil(lambda: bool(failures), timeout=TIMEOUT_MS) + assert failures == [expected] + + def test_a_running_worker_is_kept_alive_and_let_go_when_done(self, qapp, qtbot): + provider = SlowProvider() + worker = ChatWorker(provider, ask("hi")) + worker.start_request() + assert provider.started.wait(TIMEOUT_MS / 1000) + assert worker in chat_worker._live_workers + provider.release.set() + qtbot.waitUntil(lambda: worker not in chat_worker._live_workers, timeout=TIMEOUT_MS) + + def test_waiting_reports_whether_it_finished(self, qapp): + provider = SlowProvider() + worker = ChatWorker(provider, ask("hi")) + worker.start_request() + assert provider.started.wait(TIMEOUT_MS / 1000) + assert worker.wait(20) is False + worker.cancel() + assert worker.wait(TIMEOUT_MS) is True + + def test_a_worker_that_never_started_counts_as_finished(self, qapp): + assert ChatWorker(EchoProvider(), ask("hi")).wait(1) is True + + +class TestProvidersAndModels: + def test_the_providers_come_from_the_registry(self, panel, services): + services.ai_providers.register("failing", FailingProvider(RuntimeError("unused"))) + panel.refresh_providers() + listed = [panel.provider_combobox.itemText(i) for i in range(panel.provider_combobox.count())] + assert listed == ["echo", "failing"] + + def test_the_provider_in_use_is_selected(self, panel): + assert panel.provider_combobox.currentText() == "echo" + assert panel.current_provider().name == "echo" + + def test_the_models_of_the_provider_are_offered(self, panel): + offered = [panel.model_combobox.itemText(i) for i in range(panel.model_combobox.count())] + assert offered == ["echo-large", "echo-small"] + assert panel.model_combobox.currentText() == "echo-large" + + def test_the_configured_model_is_selected_even_when_not_offered(self, panel, services): + services.ai_settings.update("echo", model="echo-custom") + panel.refresh_providers() + assert panel.model_combobox.currentText() == "echo-custom" + + def test_picking_another_provider_makes_it_the_one_in_use(self, panel, services): + services.ai_providers.register("failing", FailingProvider(RuntimeError("unused"))) + panel.refresh_providers() + panel.provider_combobox.setCurrentText("failing") + assert services.ai_settings.active_provider == "failing" + assert panel.model_combobox.count() == 0 + + def test_a_window_with_services_shares_them(self, services): + assert services_for(SimpleNamespace(services=services)) is services + + def test_a_window_without_services_gets_the_built_in_providers(self, tmp_dir): + assert services_for(object()).ai_providers.names() == ["openai", "anthropic"] + + +class TestSendingAPrompt: + def test_the_exchange_is_shown_and_the_reply_arrives_in_pieces(self, panel, qtbot): + send(panel, qtbot, "hello world") + shown = panel.chat_panel.toPlainText() + assert "You: hello world" in shown + assert "Assistant: HELLO WORLD" in shown + + def test_the_input_is_cleared_and_the_buttons_come_back(self, panel, qtbot): + send(panel, qtbot, "hello") + assert panel.prompt_input.text() == "" + assert panel.call_ai_model_button.isEnabled() and not panel.stop_button.isEnabled() + + def test_the_token_usage_is_shown(self, panel, qtbot): + send(panel, qtbot, "hello world") + assert panel.status_label.text() == "Reply complete: 1 tokens in, 2 tokens out" + + def test_the_selected_model_and_the_system_prompt_are_sent(self, panel, qtbot, services): + services.ai_settings.update("echo", system_prompt="Be brief.") + panel.model_combobox.setCurrentText("echo-small") + send(panel, qtbot, "hello") + request = services.ai_providers.require("echo").requests[0] + assert (request.model_id, request.system_prompt) == ("echo-small", "Be brief.") + + def test_the_second_prompt_carries_the_conversation_so_far(self, panel, qtbot, services): + send(panel, qtbot, "first") + send(panel, qtbot, "second") + sent = services.ai_providers.require("echo").requests[1].messages + assert [(message.role, message.content) for message in sent] == [ + (ChatRole.USER, "first"), (ChatRole.ASSISTANT, "FIRST"), (ChatRole.USER, "second")] + + @pytest.mark.parametrize("text", ["", " "]) + def test_an_empty_prompt_sends_nothing(self, panel, services, text): + panel.prompt_input.setText(text) + assert panel.call_ai_model() is False + assert services.ai_providers.require("echo").requests == [] + + def test_a_new_chat_forgets_the_conversation(self, panel, qtbot, services): + send(panel, qtbot, "first") + panel.new_chat() + send(panel, qtbot, "second") + assert panel.chat_panel.toPlainText().count("You:") == 1 + assert len(services.ai_providers.require("echo").requests[1].messages) == 1 + + def test_no_provider_is_reported_instead_of_sending(self, qapp, qtbot): + empty = ChatUI(SimpleNamespace(services=EditorServices())) + qtbot.addWidget(empty) + empty.prompt_input.setText("hello") + with patch(f"{CHAT_UI}.QMessageBox.warning") as warning: + assert empty.call_ai_model() is False + assert warning.call_args.args[2] == "No AI provider is available" + + +class TestFailuresAndCancelling: + @pytest.fixture() + def failing_panel(self, qapp, qtbot): + services = EditorServices() + services.ai_providers.register( + "failing", FailingProvider(JEditorServiceException("Anthropic rejected the API key"))) + widget = ChatUI(SimpleNamespace(services=services)) + qtbot.addWidget(widget) + return widget + + def test_the_reason_is_shown_to_the_user(self, failing_panel, qtbot): + with patch(f"{CHAT_UI}.QMessageBox.warning") as warning: + send(failing_panel, qtbot, "hello") + assert warning.call_args.args[2] == "Anthropic rejected the API key" + assert failing_panel.status_label.text() == "The request failed" + + def test_the_unanswered_prompt_does_not_stay_in_the_conversation(self, failing_panel, qtbot): + with patch(f"{CHAT_UI}.QMessageBox.warning"): + send(failing_panel, qtbot, "hello") + assert failing_panel._session.messages == () + assert failing_panel.call_ai_model_button.isEnabled() + + @pytest.fixture() + def slow(self, qapp, qtbot): + provider = SlowProvider() + services = EditorServices() + services.ai_providers.register("slow", provider) + widget = ChatUI(SimpleNamespace(services=services)) + qtbot.addWidget(widget) + widget.prompt_input.setText("take your time") + assert widget.call_ai_model() is True + assert provider.started.wait(TIMEOUT_MS / 1000) + return widget, provider + + def test_sending_is_off_while_a_reply_is_awaited(self, slow): + widget, _provider = slow + assert not widget.call_ai_model_button.isEnabled() and widget.stop_button.isEnabled() + assert widget.status_label.text() == "Waiting for the reply..." + + def test_stopping_cancels_and_keeps_the_conversation_clean(self, slow, qtbot): + widget, _provider = slow + widget.stop() + qtbot.waitUntil(lambda: widget._worker is None, timeout=TIMEOUT_MS) + assert widget.status_label.text() == "Cancelled" + assert widget._session.messages == () + + def test_closing_the_panel_mid_reply_leaves_nothing_running(self, slow): + widget, _provider = slow + widget.close() + assert wait_for_chat_workers(TIMEOUT_MS) + + def test_a_reply_to_a_dropped_conversation_is_ignored(self, slow, qtbot): + widget, provider = slow + widget.new_chat() + provider.release.set() + assert wait_for_chat_workers(TIMEOUT_MS) + QApplication.processEvents() + assert widget.chat_panel.toPlainText() == "" + assert widget._session.messages == () + + +class TestRelabelling: + def test_the_panel_moves_language_without_losing_the_conversation(self, panel, qtbot): + send(panel, qtbot, "hello") + language_wrapper.reset_language("Traditional_Chinese") + try: + panel.retranslate() + assert panel.stop_button.text() == traditional_chinese_word_dict["chat_ui_stop_button"] + assert "HELLO" in panel.chat_panel.toPlainText() + finally: + language_wrapper.reset_language("English") + panel.retranslate() + + +class TestTheSettingsDialog: + @pytest.fixture() + def configured(self): + services = EditorServices() + services.ai_providers.register("echo", EchoProvider()) + services.ai_providers.register("failing", FailingProvider(RuntimeError("unused"))) + services.ai_settings.update("echo", api_key="echo-key", model="echo-large", + base_url="https://echo.invalid", system_prompt="Be brief.") + services.ai_settings.update("failing", model="other-model") + return services + + @pytest.fixture() + def dialog(self, qapp, qtbot, configured, tmp_dir): + widget = SetAIDialog(configured, "echo") + qtbot.addWidget(widget) + return widget + + def test_it_opens_on_the_given_provider_with_its_settings(self, dialog): + assert dialog.provider_combobox.currentText() == "echo" + assert (dialog.base_url_input.text(), dialog.api_key_input.text(), + dialog.chat_model_input.text(), dialog.system_prompt_input.toPlainText()) == ( + "https://echo.invalid", "echo-key", "echo-large", "Be brief.") + + def test_the_key_is_not_readable_on_screen(self, dialog): + assert dialog.api_key_input.echoMode() == QLineEdit.EchoMode.Password + + def test_another_provider_shows_its_own_settings(self, dialog): + dialog.provider_combobox.setCurrentText("failing") + assert (dialog.chat_model_input.text(), dialog.api_key_input.text()) == ("other-model", "") + + def test_applying_changes_only_the_provider_shown(self, dialog, configured): + dialog.chat_model_input.setText("echo-small") + assert dialog.update_ai_config() is True + assert configured.ai_settings.settings_for("echo").model == "echo-small" + assert configured.ai_settings.settings_for("failing").model == "other-model" + assert configured.ai_settings.active_provider == "echo" + + def test_applying_announces_itself(self, dialog, qtbot): + with qtbot.waitSignal(dialog.settings_applied, timeout=TIMEOUT_MS): + dialog.update_ai_config() + + def test_nothing_is_written_to_disk_by_default(self, dialog): + dialog.update_ai_config() + assert not ai_settings_path().exists() + + def test_ticking_the_box_saves_the_settings(self, dialog): + dialog.save_to_file_checkbox.setChecked(True) + dialog.update_ai_config() + assert load_ai_settings(ai_settings_path()).settings_for("echo").api_key == "echo-key" + + def test_a_save_that_fails_is_reported_and_the_dialog_stays(self, dialog): + dialog.save_to_file_checkbox.setChecked(True) + failure = JEditorServiceException("The AI settings could not be saved") + with patch(f"{DIALOG}.save_ai_settings", side_effect=failure), \ + patch(f"{DIALOG}.QMessageBox.warning") as warning: + assert dialog.update_ai_config() is False + assert "could not be saved" in warning.call_args.args[2] + + def test_the_panel_picks_up_what_the_dialog_applied(self, qapp, qtbot, configured, tmp_dir): + panel = ChatUI(SimpleNamespace(services=configured)) + qtbot.addWidget(panel) + panel.set_ai_config() + dialog = panel.set_ai_config_dialog + dialog.provider_combobox.setCurrentText("failing") + dialog.chat_model_input.setText("picked-in-the-dialog") + dialog.update_ai_config() + assert panel.provider_combobox.currentText() == "failing" + assert panel.model_combobox.currentText() == "picked-in-the-dialog" diff --git a/test/test_core_architecture.py b/test/test_core_architecture.py index e82d330..2897acc 100644 --- a/test/test_core_architecture.py +++ b/test/test_core_architecture.py @@ -24,7 +24,7 @@ # The parts of je_editor that are the Qt application UI_MODULES = ("je_editor.pyside_ui", "je_editor.start_editor") # The packages below the UI: logic only -LOGIC_PACKAGES = ("core", "utils", "code_scan", "git_client", "plugins") +LOGIC_PACKAGES = ("core", "adapters", "utils", "code_scan", "git_client", "plugins") # The modules below the UI that reach upwards today. This is a ratchet: the set # may shrink, and a new entry needs a reason as good as these. KNOWN_UPWARD_IMPORTS = { diff --git a/test/test_langchain_interface.py b/test/test_langchain_interface.py deleted file mode 100644 index 41a5fea..0000000 --- a/test/test_langchain_interface.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for how the AI widget reads a reply out of a langchain message.""" -from __future__ import annotations - -import os -import warnings - -import pytest -from langchain_core.messages import AIMessage - -from je_editor.pyside_ui.main_ui.ai_widget.langchain_interface import LangChainInterface - - -class FakeChat: - """Stand in for ChatOpenAI, returning a real AIMessage without any network.""" - - def __init__(self, content: str) -> None: - self._content = content - self.prompts: list[str] = [] - - def invoke(self, prompt: str) -> AIMessage: - self.prompts.append(prompt) - return AIMessage(content=self._content) - - -@pytest.fixture(autouse=True) -def _restore_openai_environment(): - """The interface writes its settings into os.environ; put them back afterwards.""" - keys = ("OPENAI_BASE_URL", "OPENAI_API_KEY", "CHAT_MODEL") - saved = {key: os.environ.get(key) for key in keys} - yield - for key, value in saved.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - - -def build_interface(content: str) -> LangChainInterface: - """An interface whose model is replaced by a fake that answers with ``content``.""" - interface = LangChainInterface( - main_window=None, - prompt_template="You are a {role}.", - base_url="https://example.invalid/v1", - api_key="not-a-real-key", - chat_model="gpt-4o-mini", - ) - interface.chat_ai = FakeChat(content) - return interface - - -class TestReadingTheReply: - def test_a_plain_reply_comes_back_unchanged(self): - assert build_interface("hello there").call_ai_model("hi") == "hello there" - - def test_the_prompt_reaches_the_model(self): - interface = build_interface("anything") - interface.call_ai_model("what is 2 + 2?") - assert interface.chat_ai.prompts == ["what is 2 + 2?"] - - def test_an_empty_reply_stays_empty(self): - assert build_interface("").call_ai_model("hi") == "" - - def test_reading_the_text_warns_of_nothing_deprecated(self): - """ - ``text`` is a property; calling it as a method is deprecated upstream and - will eventually stop working. Nothing else here would notice, because the - deprecated form still returns the right value. - - The warnings are recorded rather than raised: ``call_ai_model`` catches - every exception and answers with a message box, so a raised warning would - be swallowed there instead of failing the test. - """ - interface = build_interface("hello there") - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - assert interface.call_ai_model("hi") == "hello there" - deprecated = [ - str(warning.message) for warning in caught - if issubclass(warning.category, DeprecationWarning) - ] - assert not deprecated, deprecated - - -class TestStrippingTheThinkingBlock: - def test_content_before_the_closing_tag_is_dropped(self): - interface = build_interface("internal reasoning\n the answer ") - assert interface.call_ai_model("hi") == "the answer" - - def test_a_reply_without_the_tag_is_left_alone(self): - assert build_interface("just the answer").call_ai_model("hi") == "just the answer" - - def test_only_the_first_closing_tag_starts_the_answer(self): - interface = build_interface("afirstsecond") - assert interface.call_ai_model("hi") == "firstsecond" From fb2855c79b63f72544d5d8bee7fb304d5078feb9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 03:00:05 +0800 Subject: [PATCH 08/14] Answer three analyser findings on the AI providers The fake key in test_ai_settings.py was named SECRET_KEY and began with "sk-", so it read like a real credential; it is now an obvious placeholder. Codacy's rule that the OpenAI SDK must be imported together with a guardrails library fires on two lines that import only the OpenAIError type, to recognise or simulate a failure. The requests are made by LangChain, not by this code, so the rule does not apply; both lines are marked with the reason next to them. --- docs/updates/2026-10.md | 10 ++++++++++ docs/updates/README.md | 3 ++- je_editor/adapters/ai/openai_provider.py | 5 ++++- test/test_ai_providers.py | 3 ++- test/test_ai_settings.py | 19 ++++++++++--------- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index fadf679..d2022f0 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -190,3 +190,13 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **文件**:`docs/source/docs/{Eng,Zh}/ai_assistant.rst`(重寫)、`configuration.rst`、`getting_started.rst`、`core_services.rst`;三份 README(主要特色、相依套件、AI 助手、設定檔、專案架構);`architecture.md` §1、§2、§3、§5、§6;`architecture_explore.md`(§1、§4.7 面板、§5.11、新的 §5.12、§6.1、§8);藍圖的實作狀態。 - **檔案**:`je_editor/adapters/`(新,7 個檔)、`je_editor/core/ai/ai_settings.py`(新)、`je_editor/core/ai/chat_session.py`(新)、`je_editor/core/__init__.py`、`je_editor/core/services/editor_services.py`、`je_editor/pyside_ui/main_ui/ai_widget/chat_ui.py`、`chat_worker.py`(新)、`je_editor/pyside_ui/dialog/ai_dialog/set_ai_dialog.py`、`je_editor/pyside_ui/main_ui/main_editor.py`、四份語言字典、`pyproject.toml`、`dev.toml`、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`(刪 #13)。刪除:`langchain_interface.py`、`ask_thread.py`、`ai_config.py`、`test/test_langchain_interface.py`。 - **待辦**:無。 + +## U-20261008-06 · 2026-10-08 · M2 診斷與 M5 的 CI 結果;Codacy 三筆:一筆改掉、兩筆是誤判 · #decision #ci #roadmap + +- **CI**:U-20261008-04 與 -05 的 commit(`ce20d00`、`aa099c6`)推到 PR #270 之後,`build_dev_version` 在 Python 3.10 ~ 3.14 全部通過(包含新相依 `anthropic==1.11.0` 的安裝),SonarCloud 通過。Codacy 回報 3 筆新問題。 +- **`test/test_ai_settings.py` 的「可能寫死的金鑰」**(Prospector dodgy):那是測試用的假值 `sk-test-…`,但名稱叫 `SECRET_KEY`、開頭又是 `sk-`,長得就像真的金鑰。改成 `PLACEHOLDER_KEY = "placeholder-…"`,不需要抑制。 +- **「匯入 OpenAI SDK 卻沒有匯入 Guardrails」兩筆**(Semgrep `codacy.python.openai.import-without-guardrails`,`je_editor/adapters/ai/openai_provider.py` 與 `test/test_ai_providers.py`):查證後是誤判。這兩處只匯入例外類別 `OpenAIError` 來辨認(或在測試裡模擬)服務的錯誤,請求本身是 LangChain 的 `ChatOpenAI` 發出的,這裡沒有直接呼叫 SDK。 +- **決定**:不為了這條規則加一個護欄函式庫當相依;兩處各加 `# nosemgrep` 並在上方註明原因。也沒有改成不匯入 `openai` 而去抓更寬的例外,那會把不相干的錯誤也當成服務失敗。 +- **結果**:改到的兩個測試檔 82 passed。 +- **檔案**:`je_editor/adapters/ai/openai_provider.py`、`test/test_ai_providers.py`、`test/test_ai_settings.py`。 +- **待辦**:無。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 83d8a61..4803d7b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-06 | 2026-10-08 | M2 診斷與 M5 的 CI 結果;Codacy 三筆:一筆改掉、兩筆是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-05 | 2026-10-08 | 藍圖 M5:AI 對話面板改成可切換供應者,新增 Anthropic 後端 | #done #roadmap #ai #deps | [2026-10](2026-10.md) | | U-20261008-04 | 2026-10-08 | 藍圖 M2(診斷):ruff 與語言伺服器的診斷走同一個模型,問題面板依嚴重度與來源篩選 | #migration #roadmap #diagnostics | [2026-10](2026-10.md) | | U-20261008-03 | 2026-10-08 | PROGRESS #18、#19、#20:寫明 ruff 規則、長路徑測試、fixture 寫法 | #done #decision #tests | [2026-10](2026-10.md) | @@ -96,5 +97,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 13 | +| [2026-10.md](2026-10.md) | 2026-10 | 14 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/adapters/ai/openai_provider.py b/je_editor/adapters/ai/openai_provider.py index 6968633..ebb8b90 100644 --- a/je_editor/adapters/ai/openai_provider.py +++ b/je_editor/adapters/ai/openai_provider.py @@ -126,7 +126,10 @@ def complete(self, request: ChatRequest, on_text: TextListener | None = None, def _invoke(self, settings: ProviderSettings, model: str, request: ChatRequest) -> Any: """呼叫模型,把 SDK 的錯誤轉成編輯器的例外 / Call the model, turning SDK errors into the editor's.""" - from openai import OpenAIError + # 只匯入例外類別來辨認錯誤;請求本身是 LangChain 發出的,這裡沒有直接呼叫這個 SDK + # Only the exception type is imported, to recognise a failure. The request + # itself is made by LangChain, and nothing here calls this SDK directly + from openai import OpenAIError # nosemgrep try: return self._chat_factory(settings, model).invoke(_as_langchain_messages(request, settings)) # ValueError 也涵蓋 pydantic 對設定的驗證錯誤 / ValueError covers pydantic's validation of the settings too diff --git a/test/test_ai_providers.py b/test/test_ai_providers.py index 568fb70..d718525 100644 --- a/test/test_ai_providers.py +++ b/test/test_ai_providers.py @@ -148,7 +148,8 @@ def test_a_reply_that_arrives_after_a_cancel_is_dropped(self): assert (response.cancelled, response.text, heard) == (True, "", []) def test_a_failure_of_the_service_becomes_the_editor_exception(self): - from openai import OpenAIError + # Only the exception type, to raise what the service would raise. + from openai import OpenAIError # nosemgrep class Failing: def invoke(self, _messages): diff --git a/test/test_ai_settings.py b/test/test_ai_settings.py index 2c1f859..7fa6fcc 100644 --- a/test/test_ai_settings.py +++ b/test/test_ai_settings.py @@ -12,11 +12,12 @@ from je_editor.core.ai.ai_settings import AISettings, ProviderSettings from je_editor.utils.exception.exceptions import JEditorServiceException -SECRET_KEY = "sk-test-0123456789abcdefghij" +# A made-up value that only has to be long enough to be masked; it opens nothing. +PLACEHOLDER_KEY = "placeholder-0123456789-abcdefghij" # What the settings file looked like before settings were grouped by provider OLDER_FILE = {"AI_model": { "ai_base_url": "https://example.invalid/v1", - "ai_api_key": SECRET_KEY, + "ai_api_key": PLACEHOLDER_KEY, "chat_model": "gpt-4o-mini", "prompt_template": "You are a careful reviewer.", }} @@ -27,9 +28,9 @@ def test_a_new_set_is_empty(self): assert ProviderSettings() == ProviderSettings("", "", "", "") def test_a_long_key_is_masked_to_its_ends(self): - masked = ProviderSettings(api_key=SECRET_KEY).masked_api_key - assert masked == "sk-t...ghij" - assert SECRET_KEY[4:-4] not in masked + masked = ProviderSettings(api_key=PLACEHOLDER_KEY).masked_api_key + assert masked == "plac...ghij" + assert PLACEHOLDER_KEY[4:-4] not in masked @pytest.mark.parametrize("key", ["a", "abcd", "abcdefgh"]) def test_a_short_key_is_hidden_entirely(self, key): @@ -79,7 +80,7 @@ class TestTheOlderFileFormat: def test_the_single_group_becomes_the_openai_provider(self): loaded = AISettings.from_dict(OLDER_FILE) assert loaded.settings_for("openai") == ProviderSettings( - api_key=SECRET_KEY, base_url="https://example.invalid/v1", model="gpt-4o-mini", + api_key=PLACEHOLDER_KEY, base_url="https://example.invalid/v1", model="gpt-4o-mini", system_prompt="You are a careful reviewer.") def test_the_openai_provider_becomes_the_one_in_use(self): @@ -110,7 +111,7 @@ def test_a_missing_file_gives_empty_settings(self, tmp_path): def test_what_is_saved_is_what_is_loaded(self, tmp_path): settings = AISettings(active_provider="anthropic") - settings.update("anthropic", api_key=SECRET_KEY, model="claude-opus-5-5") + settings.update("anthropic", api_key=PLACEHOLDER_KEY, model="claude-opus-5-5") target = save_ai_settings(settings, ai_settings_path(tmp_path)) assert load_ai_settings(target) == settings @@ -148,7 +149,7 @@ class TestTheKeyStaysOutOfTheLog: @pytest.fixture() def logged(self, tmp_path, caplog): settings = AISettings(active_provider="anthropic") - settings.update("anthropic", api_key=SECRET_KEY) + settings.update("anthropic", api_key=PLACEHOLDER_KEY) with caplog.at_level(logging.DEBUG, logger="JEditor"): target = save_ai_settings(settings, tmp_path / "ai_config.json") load_ai_settings(target) @@ -159,4 +160,4 @@ def test_the_save_is_recorded_with_its_path(self, logged): assert "ai_config.json" in logged def test_the_key_is_not_recorded(self, logged): - assert SECRET_KEY not in logged + assert PLACEHOLDER_KEY not in logged From 0be0d77ffa78bae361b9c32e263354ac64fa7f90 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 03:08:57 +0800 Subject: [PATCH 09/14] Let one window work on several project folders A window's workspace is now the working directory plus any folders added beside it. With a single folder nothing changes: the same tree, the same paths, the same commands. Open Folder still switches project, moving the working directory and replacing every root. File gains Add Folder to Workspace and Remove Folder from Workspace for the extra roots, which are remembered per project in workspace_roots of user_setting.json. With several roots, quick open, the TODO panel, the Problems panel's whole project check and project search cover every root, and a path is shown with its root's name so same-named files stay apart. Each editor tab gets a list above its file tree to choose the root shown. A language server starts at the root a file belongs to rather than at the file's own folder. Replace in files keeps refusing anything outside the workspace, now checked against every root. Five places used to decide for themselves where the project is; they all ask pyside_ui/main_ui/workspace/workspace_roots.py now, which falls back to the old rule for a window without services. Running programs, the test panel, the terminal, the Git toolbar and the interpreter still use the primary root only; that is PROGRESS #22. Closes PROGRESS #11 (roadmap M3). --- PROGRESS.md | 9 +- README.md | 1 + README/README_zh-CN.md | 1 + README/README_zh-TW.md | 1 + architecture.md | 5 + architecture_explore.md | 77 ++-- docs/roadmap/2026-editor-next.md | 7 +- docs/source/docs/Eng/configuration.rst | 2 + docs/source/docs/Eng/editor.rst | 32 ++ docs/source/docs/Zh/configuration.rst | 2 + docs/source/docs/Zh/editor.rst | 27 ++ docs/updates/2026-10.md | 21 + docs/updates/README.md | 3 +- je_editor/core/workspace/workspace_model.py | 96 +++++ je_editor/pyside_ui/code/lsp/lsp_client.py | 7 +- .../code_edit_plaintext.py | 21 +- .../dialog/file_dialog/open_file_dialog.py | 7 + .../dialog/search_ui/search_replace_widget.py | 108 +++-- .../command_palette/quick_open_dialog.py | 51 ++- .../pyside_ui/main_ui/editor/editor_widget.py | 55 ++- je_editor/pyside_ui/main_ui/main_editor.py | 30 +- .../main_ui/menu/file_menu/build_file_menu.py | 19 + .../problems_panel/problems_panel_widget.py | 25 +- .../problems_panel/project_lint_worker.py | 27 +- .../save_settings/user_setting_file.py | 2 + .../main_ui/test_panel/test_panel_widget.py | 7 +- .../main_ui/todo_panel/todo_panel_widget.py | 72 +++- .../pyside_ui/main_ui/workspace/__init__.py | 0 .../main_ui/workspace/workspace_actions.py | 95 +++++ .../main_ui/workspace/workspace_roots.py | 119 ++++++ je_editor/utils/file_scan/workspace_scan.py | 135 ++++++ je_editor/utils/multi_language/english.py | 5 + je_editor/utils/multi_language/japanese.py | 5 + .../multi_language/simplified_chinese.py | 5 + .../multi_language/traditional_chinese.py | 5 + test/test_core_workspace.py | 64 +++ test/test_workspace_ui.py | 393 ++++++++++++++++++ 37 files changed, 1386 insertions(+), 155 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/workspace/__init__.py create mode 100644 je_editor/pyside_ui/main_ui/workspace/workspace_actions.py create mode 100644 je_editor/pyside_ui/main_ui/workspace/workspace_roots.py create mode 100644 je_editor/utils/file_scan/workspace_scan.py create mode 100644 test/test_workspace_ui.py diff --git a/PROGRESS.md b/PROGRESS.md index 27d276c..f70766a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,18 +19,19 @@ ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) -M0(`je_editor/core/` 服務層)、M2 的診斷那一半、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 +M0(`je_editor/core/` 服務層)、M2 的診斷那一半、M3(工作區與多根專案)、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 以下依相依關係排序。 - **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 - 變動的 ID。語系那一項大部分已經有了:四份字典各 438 個鍵,鍵與佔位符的 parity、空白值、退回英文都 + 變動的 ID。語系那一項大部分已經有了:四份字典的鍵與佔位符的 parity、空白值、退回英文都 由 `test/test_languages.py` 在 CI 守著;還沒做的是「語系載入改成資料驅動」。〔決定〕UI 的版面方向 (活動列、編輯區、側邊面板、底部面板)要先定,才能動視窗層。 - **#10** M2 剩下 Tree-sitter 那一半(診斷那一半已完成,見 U-20261008-04):不依賴 Qt 的解析服務、 以查詢檔決定語法分類與結構區塊、既有的高亮器改成轉接器;`LanguageService` 的「發問、等回覆」 呼叫形式也在這裡定。 -- **#11** M3(工作區與多根專案)。`EditorMain` 持有 `EditorServices`,`working_dir` 改由 `Workspace` - 提供;LSP 連線以「伺服器 + 根目錄」為鍵;搜尋、索引、TODO、Git、診斷改成認得工作區。 +- **#22** M3 沒有涵蓋的部分(工作區本身已完成,見 U-20261008-07):執行程式、測試面板、終端機、Git + 工具列與 Python 直譯器(venv)仍然只認主要的根目錄,也就是工作目錄。藍圖要的「每個根目錄有自己的 + 語言 / 工具設定與環境」還沒做;Git 面板也還沒有依根目錄切換。 - **#12** M4(除錯器改走 DAP)。實作 `DebugSession`;堆疊、變數、求值的非同步查詢形式在這裡定; 需要一個本機的 `TaskRunner` 實作來啟動轉接器。 - **#14** M6(遠端開發)。實作 `RemoteSession`,並補上遠端檔案系統、連接埠轉送、直譯器探索的介面。 diff --git a/README.md b/README.md index 943454c..4a43b18 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,7 @@ yet. See the *Core Services* page of the [documentation](https://je-editor.readt - **Create, open, save** files with standard shortcuts (Ctrl+N, Ctrl+O, Ctrl+S). - **Open folders** (Ctrl+K) -- Navigate project directory structures. +- **Multi-root workspace** -- Add more folders beside the project (File → Add Folder to Workspace). Quick open, the TODO and Problems panels, project search and language servers then cover every folder, and same-named files in different folders stay apart. - **Auto-save** -- Automatic periodic file saving to prevent data loss. - **Session restore** -- Reopens every file that was open at the last shutdown, not just the last one. Missing, duplicate and already-open files are skipped, the list is capped, and a corrupt or hand-edited settings file can never block startup. Disable by setting `restore_session` to `false` in `.jeditor/user_setting.json`. - **Multi-encoding** -- Seamlessly handle UTF-8, GBK, Latin-1, and other encodings with automatic detection. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 39ce994..5b1359f 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -371,6 +371,7 @@ services.shutdown() - **创建、打开、保存**文件,使用标准快捷键(Ctrl+N、Ctrl+O、Ctrl+S)。 - **打开文件夹**(Ctrl+K)-- 浏览项目目录结构。 +- **多根目录工作区** -- 在项目旁边添加更多文件夹(文件 → 将文件夹添加到工作区)。快速打开、TODO 与问题面板、项目搜索与语言服务器都会涵盖每个文件夹,不同文件夹里的同名文件也分得开。 - **自动保存** -- 自动定期保存文件,防止数据丢失。 - **会话恢复** -- 重新打开上次关闭时所有打开的文件,而不只是最后一个。不存在、重复与已打开的文件会被跳过,列表有上限,损坏或手工改过的配置文件也绝不会挡住启动。可在 `.jeditor/user_setting.json` 中将 `restore_session` 设为 `false` 禁用。 - **多编码支持** -- 无缝处理 UTF-8、GBK、Latin-1 及其他编码,具备自动检测功能。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 9b93acf..49aa252 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -371,6 +371,7 @@ services.shutdown() - **建立、開啟、儲存**檔案,使用標準快捷鍵(Ctrl+N、Ctrl+O、Ctrl+S)。 - **開啟資料夾**(Ctrl+K)-- 瀏覽專案目錄結構。 +- **多根目錄工作區** -- 在專案旁邊加入更多資料夾(檔案 → 將資料夾加入工作區)。快速開啟、TODO 與問題面板、專案搜尋與語言伺服器都會涵蓋每個資料夾,不同資料夾裡的同名檔案也分得開。 - **自動儲存** -- 自動定期儲存檔案,防止資料遺失。 - **工作階段還原** -- 重新開啟上次關閉時所有開著的檔案,而不只是最後一個。不存在、重複與已開啟的檔案會被略過,清單有上限,損壞或手動改過的設定檔也絕不會擋住啟動。可在 `.jeditor/user_setting.json` 中將 `restore_session` 設為 `false` 停用。 - **多編碼支援** -- 無縫處理 UTF-8、GBK、Latin-1 及其他編碼,具備自動偵測功能。 diff --git a/architecture.md b/architecture.md index a2e486c..591bf0b 100644 --- a/architecture.md +++ b/architecture.md @@ -67,6 +67,11 @@ Qt cannot be imported. `RemoteSession` and `AIProvider` are `typing.Protocol`s, so a `QObject` can satisfy them without a metaclass clash. Changes are announced through `EventHook`, on the thread that caused them. `import je_editor.core` still runs `je_editor/__init__.py`, which imports Qt. +- **Workspace**: `EditorMain.services.workspace` lists the window's roots. The working directory is + the primary root; File → Add Folder to Workspace appends others, recorded per project in + `workspace_roots` of `user_setting.json`. Panels read the roots through + `pyside_ui/main_ui/workspace/workspace_roots.py`, never from `working_dir` or the current + directory themselves. Open Folder still changes the working directory and replaces every root. - **Persisted state**: `.jeditor/` under the working directory (`user_setting.json`, `user_color_setting.json`, `snippets.json`, `.bak` backups). - **PyPI packages**: `je_editor` (stable) and `je_editor_dev` (dev channel), both published by CI. diff --git a/architecture_explore.md b/architecture_explore.md index 4e873a1..455e693 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 311 個 `.py`(189 個實作模組 + 122 個 `__init__.py`),共 34,316 行。 +> 涵蓋範圍:`je_editor/` 全部 315 個 `.py`(192 個實作模組 + 123 個 `__init__.py`),共 34,989 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -16,23 +16,23 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | | 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`frontengine` | -| 測試 | pytest + pytest-qt,109 個測試檔、約 17,700 行 | +| 測試 | pytest + pytest-qt,110 個測試檔、約 18,100 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 96 | 20,762 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 59 | 8,854 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `adapters/` | 5 | 539 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | -| `core/` | 16 | 2,547 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | +| `pyside_ui/` | 98 | 21,181 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 60 | 9,009 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `adapters/` | 5 | 542 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | +| `core/` | 16 | 2,643 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 34,316 行。) +(行數含各層 `__init__.py`,合計 34,989 行。) --- @@ -81,7 +81,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 109 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 110 個測試檔的原因。 --- @@ -147,7 +147,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(59 模組 / 8,854 行) +### 5.2 `utils/` — 純邏輯層(60 模組 / 9,009 行) #### 文字與行操作 @@ -181,6 +181,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `file_scan/file_indexer.py` | 106 | 專案檔案索引(深度上限 24、檔案上限 20000),供快速開啟 | | `file_scan/ignore_rules.py` | 73 | 掃描時共用的忽略規則(`.git`、`__pycache__`、二進位副檔名、null byte 偵測) | | `file_scan/todo_scanner.py` | 138 | 掃描 TODO / FIXME 註解,支援多種註解符號 | +| `file_scan/workspace_scan.py` | 135 | 對工作區的每個根目錄各跑一次索引與 TODO 掃描;有好幾個根目錄時顯示路徑前面加上根目錄的顯示名稱,並帶著開檔用的完整路徑 | | `venv_check/check_venv.py` | 75 | 找出 venv 的 Python 執行檔路徑 | #### 差異、Git 與診斷 @@ -225,10 +226,10 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `multi_language/english.py` | 519 | 英文字典(其他語言以此為鍵值基準) | -| `multi_language/traditional_chinese.py` | 509 | 繁體中文字典 | -| `multi_language/simplified_chinese.py` | 509 | 簡體中文字典 | -| `multi_language/japanese.py` | 513 | 日文字典 | +| `multi_language/english.py` | 524 | 英文字典(其他語言以此為鍵值基準) | +| `multi_language/traditional_chinese.py` | 514 | 繁體中文字典 | +| `multi_language/simplified_chinese.py` | 514 | 簡體中文字典 | +| `multi_language/japanese.py` | 518 | 日文字典 | | `multi_language/multi_language_wrapper.py` | 150 | `LanguageWrapper` 單例:註冊語言、切換、啟動語言決策 | | `multi_language/locale_match.py` | 116 | 系統語系 → 編輯器語言(含中文繁簡判定) | | `multi_language/retranslate_text.py` | 154 | 反查「這段文字是哪個鍵翻出來的」,用於換語言時就地換字 | @@ -278,10 +279,10 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `plaintext_code_edit/code_edit_plaintext.py` | **3,247** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | +| `plaintext_code_edit/code_edit_plaintext.py` | **3,266** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | | `multi_cursor/multi_cursor_manager.py` | 530 | 額外游標的維護與批次套用(插入 / 刪除 / 移動 / 擴選 / 欄選取 / 下一個相同字) | | `snippets/snippet_manager.py` | 280 | 片段展開、定位點跳轉、複本同步;使用者片段存於 `.jeditor/snippets.json` | -| `lsp/lsp_client.py` | 454 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | +| `lsp/lsp_client.py` | 457 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | | `lsp/lsp_session.py` | 242 | `LspSession`(一個伺服器程序)與 `LspSessionRegistry`(同語言分頁共用、引用計數、關閉時 shutdown) | | `code_process/code_exec.py` | 236 | `ExecManager`:執行使用者程式(含插件 run_config),輸出導回面板 | | `shell_process/shell_exec.py` | 132 | `ShellManager`:執行 shell 指令 | @@ -311,8 +312,8 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `main_editor.py` | 624 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | -| `editor/editor_widget.py` | 571 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | +| `main_editor.py` | 652 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | +| `editor/editor_widget.py` | 604 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | | `editor/editor_widget_dock.py` | 85 | `FullEditorWidget`:可停駐的單檔編輯器;關閉時只在有修改時,以檔案原本的編碼與行尾存回 | | `editor/process_input.py` | 104 | 對子程序(program / shell / debugger)送入標準輸入的視窗 | | `dock/destroy_dock.py` | 52 | `DestroyDock`:關閉時會真的銷毀內容的 `QDockWidget` | @@ -325,7 +326,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `set_menu_bar.py` | 69 | 依序組裝 10 個子選單;extend 模式不建插件選單 | -| `file_menu/build_file_menu.py` | 297 | 檔案選單:開 / 存 / 另存、最近檔案(上限 10)、編碼、行尾、字型與大小 | +| `file_menu/build_file_menu.py` | 316 | 檔案選單:開 / 存 / 另存、最近檔案(上限 10)、編碼、行尾、字型與大小 | | `file_menu/encoding_actions.py` | 186 | 實際套用編碼 / 行尾、存檔前格式化、儲存所有分頁(一個分頁存不了,其他照存並回報) | | `run_menu/build_run_menu.py` | 155 | 執行選單骨架、停止程式、清除輸出、說明 | | `run_menu/under_run_menu/build_program_menu.py` | 108 | 執行使用者程式(解析插件 run_config) | @@ -349,14 +350,14 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `problems_panel/problems_panel_widget.py` | 407 | 問題面板:診斷放在自己的 `DiagnosticStore`,依嚴重度(錯誤 / 警告 / 資訊 / 提示)與來源篩選且順序固定、跳到該行、整專案檢查、套用可自動修正項 | -| `problems_panel/project_lint_worker.py` | 46 | `ProjectLintWorker(QThread)`:背景對整個目錄跑 ruff | -| `todo_panel/todo_panel_widget.py` | 262 | TODO 面板:背景掃描(`TodoScanThread`)、依標籤篩選、雙擊開檔跳行 | -| `test_panel/test_panel_widget.py` | 413 | 測試面板:組 pytest 指令(可含覆蓋率)、`PytestRunThread` 背景執行(600 秒逾時)、結果表、traceback、只跑選取 / 只跑失敗 | +| `problems_panel/problems_panel_widget.py` | 410 | 問題面板:診斷放在自己的 `DiagnosticStore`,依嚴重度(錯誤 / 警告 / 資訊 / 提示)與來源篩選且順序固定、跳到該行、整專案檢查、套用可自動修正項 | +| `problems_panel/project_lint_worker.py` | 59 | `ProjectLintWorker(QThread)`:背景對整個目錄跑 ruff | +| `todo_panel/todo_panel_widget.py` | 296 | TODO 面板:背景掃描(`TodoScanThread`)、依標籤篩選、雙擊開檔跳行 | +| `test_panel/test_panel_widget.py` | 410 | 測試面板:組 pytest 指令(可含覆蓋率)、`PytestRunThread` 背景執行(600 秒逾時)、結果表、traceback、只跑選取 / 只跑失敗 | | `outline_panel/outline_panel_widget.py` | 215 | 大綱面板:Python 用 `ast`,其他語言問語言伺服器,點擊跳行 | | `command_palette/command_palette_dialog.py` | 193 | 指令面板:模糊搜尋所有選單指令並執行 | | `command_palette/menu_command_collector.py` | 104 | 走訪選單列蒐集可觸發動作(深度上限 8、數量上限 4000) | -| `command_palette/quick_open_dialog.py` | 205 | 快速開啟檔案(`FileIndexThread` 背景索引),輸入 `>` 切回指令模式 | +| `command_palette/quick_open_dialog.py` | 214 | 快速開啟檔案(`FileIndexThread` 背景索引),輸入 `>` 切回指令模式 | | `command_palette/go_to_symbol_dialog.py` | 108 | 在目前檔案中以模糊搜尋跳到符號 | | `console_widget/console_gui.py` | 178 | 內嵌終端機 UI:指令歷史、切換工作目錄、輸出顯示 | | `console_widget/qprocess_adapter.py` | 120 | `QProcess` 包裝:啟動互動 shell、Windows 切 UTF-8 code page、送指令、停止 | @@ -370,21 +371,28 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `user_setting_file.py` | 66 | `user_setting_dict` 的定義與 `.jeditor/user_setting.json` 讀寫 | +| `user_setting_file.py` | 68 | `user_setting_dict` 的定義與 `.jeditor/user_setting.json` 讀寫 | | `user_color_setting_file.py` | 96 | 顏色設定讀寫、RGB → `QColor` 換算、依樣式套用深 / 淺色組 | | `setting_utils.py` | 40 | 寫入前先備份(`.bak`)的 JSON 寫檔工具 | + +#### 工作區(`workspace/`) + +| 模組 | 行 | 功用 | +| --- | ---: | --- | +| `workspace/workspace_roots.py` | 119 | 從視窗取得工作區與根目錄:`window_workspace()`(視窗沒有 `services` 時退回「工作目錄,沒有就用目前目錄」)、`primary_root_path()`、`local_root_paths()`、`labelled_root_paths()`;各面板原本各寫一份的專案目錄判斷都改問這裡。另有把額外根目錄記進 / 讀出使用者設定的兩個函式 | +| `workspace/workspace_actions.py` | 95 | 「將資料夾加入工作區」與「從工作區移除資料夾」;變更後立刻寫設定檔 | | `shortcut_setting.py` | 76 | 取得指令目前生效的按鍵、把 `QAction` 綁上去、設定改動後重新套用 | ### 5.8 `pyside_ui/dialog/` | 模組 | 行 | 功用 | | --- | ---: | --- | -| `search_ui/search_replace_widget.py` | 602 | 搜尋與取代對話框:三種範圍(目前檔案 / 資料夾 / 整個專案),`_SearchWorker(QThread)` 背景搜尋、正規表示式、結果雙擊跳行、批次取代 | +| `search_ui/search_replace_widget.py` | 640 | 搜尋與取代對話框:三種範圍(目前檔案 / 資料夾 / 整個專案),`_SearchWorker(QThread)` 背景搜尋、正規表示式、結果雙擊跳行、批次取代 | | `search_ui/search_text_box.py` | 49 | 簡易搜尋框元件 | | `search_ui/search_error_box.py` | 49 | 搜尋結果 / 錯誤提示框 | | `shortcut_dialog/shortcut_settings_dialog.py` | 179 | 列出所有指令、改按鍵、即時衝突提示、還原預設 | | `snippet_dialog/snippet_editor_dialog.py` | 148 | 使用者片段的新增 / 刪除 / 編輯,存檔後重載已開分頁 | -| `file_dialog/open_file_dialog.py` | 102 | 開檔流程:選檔後交給目前分頁的 `EditorWidget.open_an_file()`(已開啟就切分頁、記下編碼與行尾);另有選資料夾更新專案樹 | +| `file_dialog/open_file_dialog.py` | 109 | 開檔流程:選檔後交給目前分頁的 `EditorWidget.open_an_file()`(已開啟就切分頁、記下編碼與行尾);另有選資料夾更新專案樹 | | `file_dialog/save_file_dialog.py` | 157 | 另存新檔:依插件語言動態建立篩選器並依副檔名預選;寫檔失敗時分頁保留原路徑、不標已存;`report_save_failure()` 是各存檔路徑共用的失敗訊息 | | `file_dialog/create_file_dialog.py` | 77 | 建立新檔案 | | `ai_dialog/set_ai_dialog.py` | 127 | 依供應者設定位址、金鑰(輸入時遮蔽)、模型與系統提示詞;預設只套用到這次執行,勾選才寫入 `.jeditor/ai_config.json` | @@ -412,7 +420,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | -### 5.11 `core/` — 核心服務層(16 模組 / 2,547 行) +### 5.11 `core/` — 核心服務層(16 模組 / 2,643 行) 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 逐個里程碑改接過來。第一批使用者是診斷:編輯器的 `LintManager` 與問題面板都以統一模型保存診斷。整層不匯入 Qt 也不匯入 `pyside_ui/`; @@ -425,7 +433,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `events/event_hook.py` | 99 | `EventHook`:不靠 Qt 的訂閱與通知;在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄、不擋其他人 | | `registry/named_registry.py` | 116 | `NamedRegistry[T]`:名稱對應實作的登記表,AI 供應者、除錯轉接器、遠端傳輸、工作執行器共用 | | `uri/resource_uri.py` | 91 | 資源 URI:`to_uri` / `to_path`(沿用 `utils/lsp/lsp_protocol` 的轉換)、`uri_scheme`、`uri_key`(同一個本機檔案的不同寫法得到同一個鍵) | -| `workspace/workspace_model.py` | 265 | `ProjectRoot`(以 URI 指認,可以不在本機;`resolve()` 擋住跑出根目錄的路徑)與 `Workspace`(零到多個根目錄、`root_for()` / `root_for_uri()` 取最深的那一個、`relative_path()`) | +| `workspace/workspace_model.py` | 361 | `ProjectRoot`(以 URI 指認,可以不在本機;`resolve()` 擋住跑出根目錄的路徑)與 `Workspace`(零到多個根目錄、`set_roots()` 整組換掉、`root_for()` / `root_for_uri()` 取最深的那一個、`labelled_roots()` 給同名的根目錄不重複的顯示名稱、`display_path()` / `resolve_display_path()`) | | `document/document_model.py` | 224 | `Document` 協定、記憶體實作 `TextDocument`、以 URI 為鍵的 `DocumentStore`(`opened` / `changed` / `closed` 事件) | | `diagnostics/diagnostic_model.py` | 325 | 統一的診斷模型:`Severity`(數值同 LSP)、`Position` / `TextRange`(1 起算)、`RelatedInformation`、`TextEdit` / `QuickFix`、`Diagnostic`;`DiagnosticStore` 依「來源 × 資源」整組取代,`select()` 依嚴重度 / 來源 / 資源篩選且順序固定 | | `diagnostics/legacy_diagnostics.py` | 103 | 統一模型與 `utils/lint/ruff_diagnostics.Diagnostic`(ruff 解析器的輸出)之間的雙向轉換;`unify()` 把混著兩種形式的清單整理成統一模型,是視窗層接收診斷的入口 | @@ -441,7 +449,7 @@ start_editor(debug_mode) je_editor/start_editor.py 除錯、工作執行、遠端三項目前只有介面;既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 `adapters/ai/`,對話面板已經改走 `AIProvider`。 -### 5.12 `adapters/` — 核心介面的實作(5 模組 / 539 行) +### 5.12 `adapters/` — 核心介面的實作(5 模組 / 542 行) `core/` 只有介面;真正去連某一家服務的程式碼放在這裡。跟 `core/` 一樣不匯入 Qt 與 `pyside_ui/` (`test_core_architecture.py` 把它列進 UI 層以下的套件),第三方 SDK 都在用到的時候才匯入。 @@ -450,7 +458,7 @@ start_editor(debug_mode) je_editor/start_editor.py | --- | ---: | --- | | `__init__.py` | 58 | 套件說明 | | `default_services.py` | 52 | `build_default_services()`:建立 `EditorServices`、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | -| `ai/openai_provider.py` | 144 | `OpenAIProvider`:透過 LangChain 的 `ChatOpenAI` 呼叫 OpenAI 相容端點;回覆整份回來後去掉 `` 之前的思考過程 | +| `ai/openai_provider.py` | 147 | `OpenAIProvider`:透過 LangChain 的 `ChatOpenAI` 呼叫 OpenAI 相容端點;回覆整份回來後去掉 `` 之前的思考過程 | | `ai/anthropic_provider.py` | 201 | `AnthropicProvider`:官方 `anthropic` SDK 的串流請求;可中途取消、回報 token 用量、把 SDK 的錯誤類別轉成給使用者看的說明;會拒絕請求的模型啟用伺服器端 fallback | | `ai/builtin_providers.py` | 50 | `register_builtin_ai_providers()`:每個供應者拿到「取得自己那組設定」的函式,所以改設定不必重新登記 | | `ai/settings_file.py` | 80 | `.jeditor/ai_config.json` 的讀寫;日誌只記路徑、從不記內容(裡面有 API 金鑰) | @@ -483,7 +491,7 @@ UI 執行緒不做 I/O 是硬性規則,重活分成三類: | 檔案 | 內容 | | --- | --- | -| `user_setting.json` | 字型、語言、樣式、編碼、縮排、最近檔案、開啟分頁與其游標 / 書籤 / 折疊狀態、快捷鍵覆寫 | +| `user_setting.json` | 字型、語言、樣式、編碼、縮排、最近檔案、開啟分頁與其游標 / 書籤 / 折疊狀態、快捷鍵覆寫、另外加入工作區的資料夾(`workspace_roots`) | | `user_color_setting.json` | 編輯器自訂顏色 | | `snippets.json` | 使用者程式碼片段 | | `*.bak` | 每次寫入前的備份(`setting_utils.write_setting`) | @@ -539,7 +547,7 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff ## 7. 測試與 CI -- `test/` 109 個測試檔、約 17,700 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 110 個測試檔、約 18,100 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -592,8 +600,9 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 讓「純邏輯層」的界線稍微模糊。 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 -7. **`core/` 只接上了診斷**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。工作區、文件、語言服務、除錯、工作執行、遠端與 AI 還沒有接上, - 視窗層仍然各自持有這些狀態。 +7. **`core/` 接上了診斷、AI 與工作區**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、除錯、工作執行與遠端還沒有接上,視窗層仍然 + 各自持有這些狀態。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 + 主要的根目錄(工作目錄)。 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, 得等可嵌入元件那個里程碑處理頂層 `__init__`。 diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index f752bc2..586e831 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -14,13 +14,14 @@ | M0 — Foundation and compatibility boundary | Implemented: `je_editor/core/` | `docs/updates/2026-10.md`, U-20261008-01 | | M2 — diagnostics half | Implemented: one diagnostic model, severity and source filters | U-20261008-04 | | M2 — Tree-sitter half | Not started | `PROGRESS.md` | +| M3 — Workspace + multi-root | Implemented; per-root environments and Git are left over | U-20261008-07, `PROGRESS.md` | | M5 — AI provider abstraction + Anthropic | Implemented: `je_editor/adapters/ai/` | U-20261008-05 | -| M1, M3, M4, M6, M7, M8 | Not started | `PROGRESS.md` | +| M1, M4, M6, M7, M8 | Not started | `PROGRESS.md` | M0 defines the service layer and proves it runs without Qt. What it left to later milestones: -- the editor window consumes the services one area at a time: diagnostics and the AI chat panel - do so far; +- the editor window consumes the services one area at a time: diagnostics, the AI chat panel and + the workspace do so far; - debugging, task execution and remote sessions are interfaces with no implementation yet (M4 and M6 supply them), and the request-and-reply calls of a language service (completion, hover and the rest) take their shape with Tree-sitter in M2; diff --git a/docs/source/docs/Eng/configuration.rst b/docs/source/docs/Eng/configuration.rst index 55e26fa..1a31ade 100644 --- a/docs/source/docs/Eng/configuration.rst +++ b/docs/source/docs/Eng/configuration.rst @@ -47,6 +47,8 @@ The main settings file controls editor behavior and appearance: - The tabs that were open at the last shutdown * - ``restore_session`` - Whether to reopen those tabs on launch (default: ``true``) + * - ``workspace_roots`` + - The folders added to the workspace beside the working directory * - ``shortcuts`` - Keys the user reassigned; only what differs from a default is stored diff --git a/docs/source/docs/Eng/editor.rst b/docs/source/docs/Eng/editor.rst index 7ba1fdb..eed8697 100644 --- a/docs/source/docs/Eng/editor.rst +++ b/docs/source/docs/Eng/editor.rst @@ -130,6 +130,38 @@ When you open a folder (``Ctrl+K``), JEditor displays a file tree on the left si - Supports expanding and collapsing directories - Scrollable navigation for large projects +Workspace with Several Folders +------------------------------- + +A window works on a *workspace*: the folder you opened, plus any number of folders you add +beside it. A workspace with a single folder is the ordinary case and behaves exactly as a +project always has. + +- **File → Open Folder** (``Ctrl+K``) switches project. The working directory moves to that + folder and the workspace becomes that folder alone. +- **File → Add Folder to Workspace** puts another folder beside the current project. The + working directory does not move. +- **File → Remove Folder from Workspace** takes an added folder away again. The folder you + opened cannot be removed this way; open another folder to change it. + +With more than one folder in the workspace: + +- A list appears above the file tree to choose which folder the tree shows. +- **Quick Open** (``Ctrl+P``) lists the files of every folder, each path starting with its + folder's name, so two files called ``main.py`` stay apart. +- The **TODO** panel scans every folder, and **Problems** with **Whole project** ticked checks + every folder. +- **Search in Files** with the project scope searches every folder. Replacing only ever writes + to files beneath one of the workspace's folders. +- A language server is started at the folder a file belongs to, so each project's own + configuration is found. Files outside every folder use their own directory, as before. + +The added folders are remembered per project, in ``workspace_roots`` of +``.jeditor/user_setting.json``, and come back the next time that project is opened. + +Running programs, the test panel, the terminal and the Git toolbar keep working in the folder +you opened. Two folders that share a name are told apart by a number: ``src`` and ``src (2)``. + Encoding and Line Endings -------------------------- diff --git a/docs/source/docs/Zh/configuration.rst b/docs/source/docs/Zh/configuration.rst index f347e53..da621b6 100644 --- a/docs/source/docs/Zh/configuration.rst +++ b/docs/source/docs/Zh/configuration.rst @@ -47,6 +47,8 @@ user_setting.json - 上次關閉時開啟的分頁 * - ``restore_session`` - 啟動時是否重新開啟這些分頁(預設:``true``) + * - ``workspace_roots`` + - 工作目錄以外,另外加入工作區的資料夾 * - ``shortcuts`` - 使用者改過的快捷鍵;只記錄與預設值不同的項目 diff --git a/docs/source/docs/Zh/editor.rst b/docs/source/docs/Zh/editor.rst index 0338c25..287912e 100644 --- a/docs/source/docs/Zh/editor.rst +++ b/docs/source/docs/Zh/editor.rst @@ -127,6 +127,33 @@ JEditor 整合了 `Jedi `_ 提供智慧型 Python - 支援展開與收合目錄 - 大型專案可捲動瀏覽 +含有多個資料夾的工作區 +---------------------- + +一個視窗處理的是一個 *工作區*:您開啟的資料夾,加上任意數量另外加入的資料夾。只有一個資料夾的 +工作區是最常見的情況,行為跟原本的專案完全一樣。 + +- **檔案 → 開啟資料夾** (``Ctrl+K``)是切換專案。工作目錄會移到那個資料夾,工作區只剩那一個資料夾。 +- **檔案 → 將資料夾加入工作區** 在目前的專案旁邊多放一個資料夾。工作目錄不會移動。 +- **檔案 → 從工作區移除資料夾** 把另外加入的資料夾拿掉。您開啟的那個資料夾不能這樣移除;要換它請 + 開啟另一個資料夾。 + +工作區有一個以上的資料夾時: + +- 檔案樹上方會出現一個選單,用來選擇檔案樹顯示哪個資料夾。 +- **前往檔案** (``Ctrl+P``)會列出每個資料夾的檔案,路徑以資料夾名稱開頭,所以兩個都叫 ``main.py`` + 的檔案分得開。 +- **待辦事項面板** 會掃描每個資料夾;**問題** 面板勾選 **整個專案** 時會檢查每個資料夾。 +- 搜尋與取代選擇專案範圍時會搜尋每個資料夾。取代只會寫入工作區某個資料夾底下的檔案。 +- 語言伺服器會以檔案所屬的資料夾啟動,所以找得到每個專案自己的設定。不在任何資料夾底下的檔案 + 跟原本一樣,使用它自己所在的目錄。 + +另外加入的資料夾會依專案記在 ``.jeditor/user_setting.json`` 的 ``workspace_roots`` ,下次開啟那個 +專案時會加回來。 + +執行程式、測試面板、終端機與 Git 工具列仍然在您開啟的那個資料夾運作。兩個同名的資料夾會以編號 +區分: ``src`` 與 ``src (2)`` 。 + 編碼與換行字元 --------------- diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index d2022f0..493e858 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -200,3 +200,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **結果**:改到的兩個測試檔 82 passed。 - **檔案**:`je_editor/adapters/ai/openai_provider.py`、`test/test_ai_providers.py`、`test/test_ai_settings.py`。 - **待辦**:無。 + +## U-20261008-07 · 2026-10-08 · 藍圖 M3:工作區與多根專案 · #done #roadmap #workspace + +- **做了什麼**:完成 `PROGRESS.md` #11(藍圖 M3)。一個視窗可以同時處理好幾個不相干的資料夾:工作目錄是主要的根目錄,另外加入的資料夾排在後面。只有一個根目錄時,畫面與行為都跟原本一樣。 + - **決定:「開啟資料夾」仍然是切換專案**(工作目錄跟著換、整個工作區換成那個資料夾),新增「檔案 → 將資料夾加入工作區 / 從工作區移除資料夾」來增減額外的根目錄;主要的根目錄不能在那裡移除。這樣 `.jeditor/` 仍然跟著工作目錄走,既有的單一專案流程不變。 + - `core/workspace`:`Workspace` 新增 `set_roots()`、`labelled_roots()`(同名的根目錄得到 `src`、`src (2)` 這樣不重複的顯示名稱)、`display_path()` / `resolve_display_path()`。 + - `pyside_ui/main_ui/workspace/workspace_roots.py`(新):所有面板取得根目錄的唯一入口。原本問題面板、TODO 面板、測試面板、快速開啟各寫了一份「用視窗的工作目錄,沒有就用目前目錄」,搜尋對話框另外用檔案樹的根路徑;現在都問這裡。沒有 `services` 的視窗(宿主程式、測試替身)照原本的規則得到單一根目錄。 + - `utils/file_scan/workspace_scan.py`(新,純邏輯):對每個根目錄各跑一次索引與 TODO 掃描,有好幾個根目錄時顯示路徑加上根目錄名稱,並帶著開檔用的完整路徑。 + - 快速開啟、TODO 面板、問題面板的「整個專案」、搜尋的專案範圍都涵蓋每個根目錄。TODO 面板每次掃描都重新問工作區,所以之後加入的根目錄也掃得到。 + - 搜尋對話框的取代:原本只允許改寫「專案根目錄底下」的檔案,現在是「工作區任何一個根目錄底下」,根目錄以外與以 `..` 跑出去的路徑一樣被拒絕。 + - 語言伺服器的根目錄:原本一律用檔案所在的資料夾,現在用檔案所屬的工作區根目錄(不在任何根目錄底下時維持原樣),伺服器才找得到專案設定;連線本來就以「指令 + 根目錄」為鍵,所以兩個根目錄各有自己的伺服器。 + - 每個編輯分頁的檔案樹上方多了根目錄選單,只在有一個以上的根目錄時顯示;工作區變動時每個分頁都會更新,原本選的根目錄會保留。 + - 額外的根目錄記在 `user_setting.json` 的 `workspace_roots`(依專案),加入或移除後立刻寫檔,啟動與「開啟資料夾」時加回來。 +- **沒有涵蓋的部分**:執行程式、測試面板、終端機、Git 工具列與 Python 直譯器仍然只認主要的根目錄;「每個根目錄有自己的工具設定與環境」沒有做。記在 `PROGRESS.md` #22。 +- **相容性**:`FileIndexThread`、`TodoScanThread`、`ProjectLintWorker`、`_SearchWorker`、`QuickOpenDialog`、`TodoPanelWidget(root=...)` 仍然接受單一路徑;`resolve_project_root`、`resolve_todo_root`、`resolve_working_dir`、`open_todo_item` 都還在,既有的測試沒有改。`LspClient.start_for()` 多了選用的 `root` 參數。 +- **翻譯**:四份字典各加 5 個鍵(兩個選單項目、移除時的提示、兩則訊息)。新的兩個選單項目沒有登記快捷鍵,等 M1 的指令登記表。 +- **測試**:`test_workspace_ui.py`(新,51 個):視窗的工作區與退回規則、額外根目錄的記錄與還原、跨根目錄的索引與 TODO、加入與移除資料夾、各面板與搜尋涵蓋兩個根目錄、取代的路徑檢查、檔案樹的根目錄選單、語言伺服器的根目錄。`test_core_workspace.py` 新增工作區模型的測試。 +- **結果**:整套測試 2475 passed(修改前 2411);`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束;Sphinx 建置沒有新警告,中英文的編輯器頁結構相同(各 68 個行內程式碼)。另外以真正的 `EditorMain`(offscreen)跑過一次:加入第二個資料夾後分頁的檔案樹出現兩個根目錄的選單、`user_setting.json` 記下那個資料夾,移除後回到單一根目錄,關閉視窗時 `services` 有被關閉。PyBreeze 的 `test_language_parity.py` 仍是 25 passed、1 failed(同一個與 JEditor 無關的失敗)。 +- **文件**:`docs/source/docs/{Eng,Zh}/editor.rst` 新增「含有多個資料夾的工作區」一節、`configuration.rst` 的設定鍵、三份 README 的檔案操作、`architecture.md` §3、`architecture_explore.md`(§5.2、§5.7 新的「工作區」小節、§5.11、§6.2、§8)、藍圖的實作狀態。 +- **檔案**:`je_editor/core/workspace/workspace_model.py`、`je_editor/utils/file_scan/workspace_scan.py`(新)、`je_editor/pyside_ui/main_ui/workspace/`(新,3 個檔)、`main_editor.py`、`editor/editor_widget.py`、`menu/file_menu/build_file_menu.py`、`save_settings/user_setting_file.py`、`command_palette/quick_open_dialog.py`、`todo_panel/todo_panel_widget.py`、`problems_panel/problems_panel_widget.py`、`problems_panel/project_lint_worker.py`、`test_panel/test_panel_widget.py`、`dialog/search_ui/search_replace_widget.py`、`dialog/file_dialog/open_file_dialog.py`、`code/lsp/lsp_client.py`、`code/plaintext_code_edit/code_edit_plaintext.py`、四份語言字典、上述測試與文件、`PROGRESS.md`(刪 #11、加 #22)。 +- **待辦**:`PROGRESS.md` #22。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 4803d7b..5336ce4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-07 | 2026-10-08 | 藍圖 M3:工作區與多根專案 | #done #roadmap #workspace | [2026-10](2026-10.md) | | U-20261008-06 | 2026-10-08 | M2 診斷與 M5 的 CI 結果;Codacy 三筆:一筆改掉、兩筆是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-05 | 2026-10-08 | 藍圖 M5:AI 對話面板改成可切換供應者,新增 Anthropic 後端 | #done #roadmap #ai #deps | [2026-10](2026-10.md) | | U-20261008-04 | 2026-10-08 | 藍圖 M2(診斷):ruff 與語言伺服器的診斷走同一個模型,問題面板依嚴重度與來源篩選 | #migration #roadmap #diagnostics | [2026-10](2026-10.md) | @@ -97,5 +98,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 14 | +| [2026-10.md](2026-10.md) | 2026-10 | 15 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/core/workspace/workspace_model.py b/je_editor/core/workspace/workspace_model.py index f12d3a4..5a23896 100644 --- a/je_editor/core/workspace/workspace_model.py +++ b/je_editor/core/workspace/workspace_model.py @@ -165,6 +165,30 @@ def add_root(self, root: ProjectRoot | str | Path) -> bool: self.changed.emit(self) return added + def set_roots(self, roots: Iterable[ProjectRoot | str | Path]) -> bool: + """ + 換掉所有的根目錄 + Replace every root. + + 開啟另一個專案資料夾就是這件事:整個工作區換成新的,而不是多加一個根目錄。 + This is what opening another project folder means: the whole workspace + becomes the new one, rather than gaining a root. + + :param roots: 新的根目錄,或本機目錄路徑 / the new roots, or local directories + :return: 內容是否改變 / whether anything changed + """ + replacement: list[ProjectRoot] = [] + for root in roots: + candidate = root if isinstance(root, ProjectRoot) else ProjectRoot.from_path(root) + if all(uri_key(item.uri) != uri_key(candidate.uri) for item in replacement): + replacement.append(candidate) + with self._lock: + if tuple(replacement) == self._roots: + return False + self._roots = tuple(replacement) + self.changed.emit(self) + return True + def remove_root(self, root: ProjectRoot | str | Path) -> bool: """ 移除一個根目錄 @@ -242,6 +266,78 @@ def relative_path(self, path: str | Path) -> tuple[ProjectRoot, str] | None: relative = os.path.relpath(os.path.abspath(str(path)), root.path) return root, Path(relative).as_posix() + def labelled_roots(self) -> list[tuple[str, ProjectRoot]]: + """ + 給每個根目錄一個不重複的顯示名稱 + A display name for every root that no other root shares. + + 兩個根目錄可以同名(都叫 ``src``)。清單與相對路徑要分得出是哪一個,所以 + 後出現的同名根目錄會加上編號。 + Two roots may share a name, both being ``src`` for instance. A list and + a relative path have to tell them apart, so a later root with a name + already taken gets a number. + + :return: ``(顯示名稱, 根目錄)``,依加入順序 / ``(label, root)``, in the order added + """ + seen: dict[str, int] = {} + labelled = [] + for root in self._roots: + seen[root.name] = seen.get(root.name, 0) + 1 + count = seen[root.name] + labelled.append((root.name if count == 1 else f"{root.name} ({count})", root)) + return labelled + + def display_path(self, path: str | Path) -> str: + """ + 取得給使用者看的路徑 + The path to show the user. + + 只有一個根目錄時就是根目錄內的相對路徑,跟原本的單一專案一樣;有好幾個時 + 前面加上根目錄的顯示名稱,同名的檔案才分得開。 + With one root it is the path inside that root, as a single project always + showed it. With several, the root's label goes in front, which keeps + files of the same name apart. + + :param path: 檔案或目錄路徑 / the file or directory + :return: 顯示用的路徑;不屬於任何根目錄時是完整路徑 + the path to show, or the full path outside every root + """ + located = self.relative_path(path) + if located is None: + return Path(os.path.abspath(str(path))).as_posix() + root, relative = located + if not self.is_multi_root: + return relative + label = next(label for label, item in self.labelled_roots() if item is root) + return label if relative == "." else f"{label}/{relative}" + + def resolve_display_path(self, display_path: str) -> str | None: + """ + 把 :meth:`display_path` 給的路徑轉回完整路徑 + Turn a path from :meth:`display_path` back into a full path. + + :param display_path: 顯示用的路徑 / the path as it was shown + :return: 完整的本機路徑;對不上任何根目錄,或會跑到根目錄外面時為 ``None`` + the full local path, or ``None`` when it matches no root or would + leave its root + """ + roots = self.labelled_roots() + if not roots: + return None + if len(roots) == 1: + return self._resolved(roots[0][1], display_path) + label, _separator, relative = display_path.partition("/") + owner = next((root for name, root in roots if name == label), None) + return None if owner is None else self._resolved(owner, relative or ".") + + @staticmethod + def _resolved(root: ProjectRoot, relative: str) -> str | None: + """在根目錄內解析相對路徑,不合法時回傳 ``None`` / Resolve inside a root, ``None`` when refused.""" + try: + return root.resolve(relative) + except JEditorServiceException: + return None + def _insert(self, root: ProjectRoot) -> bool: """加入根目錄,重複的不加 / Add a root unless it is already there.""" key = uri_key(root.uri) diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index 27e5bf2..0d76a40 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -103,7 +103,8 @@ def server_name(self) -> str: """ return Path(self._server_command[0]).stem if self._server_command else "" - def start_for(self, file_path: str, servers: dict | None = None) -> bool: + def start_for(self, file_path: str, servers: dict | None = None, + root: str | None = None) -> bool: """ 接上負責這個檔案的語言伺服器 Attach to the language server that handles a file. @@ -115,13 +116,15 @@ def start_for(self, file_path: str, servers: dict | None = None) -> bool: :param file_path: 檔案路徑 / the file to serve :param servers: 伺服器對照表 / the server mapping to consult + :param root: 檔案所屬的專案根目錄;沒給時用檔案所在的資料夾 + the project root the file belongs to, its own folder when omitted :return: 有接上時為 ``True`` / ``True`` when a server was attached """ command = server_command(Path(file_path).suffix, servers) if command is None: return False self.stop() - root = str(Path(file_path).parent) + root = root or str(Path(file_path).parent) session = session_registry.session_for(command, root, file_uri(root)) if session is None: return False diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index cbb74a2..f9905b5 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -24,6 +24,7 @@ from je_editor.pyside_ui.code.git_diff.diff_marker_manager import DiffMarkerManager from je_editor.pyside_ui.code.lint.lint_manager import LintManager from je_editor.pyside_ui.code.lsp.lsp_client import LspClient +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import window_workspace from je_editor.pyside_ui.code.multi_cursor.multi_cursor_manager import MultiCursorManager from je_editor.pyside_ui.code.selection.smart_selection_manager import SmartSelectionManager from je_editor.pyside_ui.code.snippets.snippet_manager import SnippetManager @@ -2342,11 +2343,29 @@ def start_language_server(self) -> bool: if self.current_file is None or Path(str(self.current_file)).suffix.lower() == ".py": self.lsp_client.stop() return False - if not self.lsp_client.start_for(str(self.current_file)): + if not self.lsp_client.start_for(str(self.current_file), root=self._workspace_root()): return False self.lsp_client.did_open(self.toPlainText()) return True + def _workspace_root(self) -> str | None: + """ + 取得目前檔案所屬的工作區根目錄 + The workspace root the current file belongs to. + + 語言伺服器要以專案的根目錄啟動才找得到專案設定;工作區有好幾個根目錄時, + 每個檔案交給它自己那個根目錄的伺服器。 + A language server has to start at the project's root to find the + project's configuration, and with several roots each file goes to the + server of its own root. + + :return: 根目錄路徑;檔案不在任何根目錄底下時為 ``None`` + the root's path, or ``None`` when the file is under no root + """ + window = getattr(self.main_window, "main_window", None) + root = window_workspace(window).root_for(str(self.current_file)) + return root.path if root is not None else None + def request_language_server_completion(self) -> bool: """ 向語言伺服器要求游標位置的補全 diff --git a/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py index f70eaf4..25874c1 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/open_file_dialog.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict, read_user_setting +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import restore_extra_roots, window_workspace from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.venv_check.check_venv import check_and_choose_venv @@ -78,6 +79,10 @@ def choose_dir_get_dir_path(parent_qt_instance: EditorMain) -> None: # 更新工作目錄 / Update working directory parent_qt_instance.working_dir = dir_path os.chdir(dir_path) + # 換了專案:整個工作區換成這個資料夾,原本另外加入的資料夾屬於上一個專案 + # Another project: the whole workspace becomes this folder, since the + # folders added before belonged to the previous project + window_workspace(parent_qt_instance).set_roots([dir_path]) # 更新所有編輯器的專案樹與環境檢查 / Update project tree and check env for all editors for code_editor in range(parent_qt_instance.tab_widget.count()): @@ -96,6 +101,8 @@ def choose_dir_get_dir_path(parent_qt_instance: EditorMain) -> None: # 重新讀取使用者設定並套用啟動設定 / Reload user settings and apply startup settings read_user_setting() + # 這個專案上次另外加入的資料夾 / The folders this project had added last time + restore_extra_roots(window_workspace(parent_qt_instance)) parent_qt_instance.startup_setting() # 重設語言設定 / Reset language diff --git a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py index a3e275e..15473b8 100644 --- a/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py +++ b/je_editor/pyside_ui/dialog/search_ui/search_replace_widget.py @@ -18,6 +18,7 @@ IGNORED_DIRECTORY_NAMES as _SKIP_DIRS, is_binary_file as _is_binary, ) +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import local_root_paths from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper if TYPE_CHECKING: @@ -32,13 +33,21 @@ class _SearchWorker(QThread): match_found = Signal(str, int, str) # (file_path, line_number, line_text) finished_signal = Signal(int) # total_matches - def __init__(self, root: str, pattern: str, case_sensitive: bool, use_regex: bool) -> None: + def __init__(self, root: str | list[str], pattern: str, case_sensitive: bool, + use_regex: bool) -> None: + """ + :param root: 要搜尋的目錄;工作區有好幾個根目錄時是一份清單 + the directory to search, or a list of them when the workspace has several roots + :param pattern: 要找的文字或正規表示式 / the text or regular expression to find + :param case_sensitive: 是否區分大小寫 / whether case matters + :param use_regex: ``pattern`` 是否為正規表示式 / whether ``pattern`` is a regular expression + """ super().__init__() # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 # A named thread, so Qt's abort message says which one if it is ever # destroyed while still running self.setObjectName("SearchWorker") - self.root = root + self.roots = [root] if isinstance(root, str) else list(root) self.pattern = pattern self.case_sensitive = case_sensitive self.use_regex = use_regex @@ -77,8 +86,13 @@ def run(self) -> None: if compiled is None: self.finished_signal.emit(0) return + total = sum(self._scan_root(root, compiled) for root in self.roots) + self.finished_signal.emit(total) + + def _scan_root(self, root: str, compiled: re.Pattern) -> int: + """搜尋一個目錄底下的所有檔案,回傳匹配數 / Scan every file under one directory.""" total = 0 - for dirpath, dirnames, filenames in os.walk(self.root): + for dirpath, dirnames, filenames in os.walk(root): if self._stop: break dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] @@ -89,7 +103,7 @@ def run(self) -> None: if _is_binary(fpath): continue total += self._scan_file(fpath, compiled) - self.finished_signal.emit(total) + return total class SearchReplaceDialog(QDialog): @@ -237,8 +251,7 @@ def do_search(self) -> None: return self._search_in_directory(pattern, folder) elif scope == "project": - project_root = self._get_project_root() - self._search_in_directory(pattern, project_root) + self._search_in_directory(pattern, self._project_roots()) def _search_in_current_file(self, pattern: str) -> None: """在目前檔案中搜尋 / Search in current file""" @@ -272,8 +285,8 @@ def _search_in_current_file(self, pattern: str) -> None: # 同時高亮第一個匹配 / Also highlight first match self.find_next() - def _search_in_directory(self, pattern: str, root: str) -> None: - """在資料夾中搜尋 / Search in directory""" + def _search_in_directory(self, pattern: str, root: str | list[str]) -> None: + """在一個或多個資料夾中搜尋 / Search in one directory, or in several""" self.result_tree.clear() self.status_label.setText(self._lang("search_replace_searching")) @@ -414,8 +427,7 @@ def do_replace_all(self) -> None: return self._replace_all_in_directory(pattern, replacement, folder) elif scope == "project": - project_root = self._get_project_root() - self._replace_all_in_directory(pattern, replacement, project_root) + self._replace_all_in_directory(pattern, replacement, self._project_roots()) def _replace_in_current_editor(self) -> None: """在編輯器中取代目前選取的匹配 / Replace currently selected match in editor""" @@ -486,12 +498,11 @@ def _replace_selected_result(self) -> None: # Rebuild the target path from the trusted project root plus a # validated relative component so user-controlled data never flows # directly into write_text() (SonarCloud S2083). - project_root = Path(self._get_project_root()).resolve() - try: - relative_part = Path(file_path).resolve().relative_to(project_root) - except ValueError: + located = self._locate_in_project(file_path) + if located is None: self.status_label.setText(f"Error: invalid file path {file_path}") return + project_root, relative_part = located p = project_root.joinpath(*relative_part.parts) if not p.is_file(): self.status_label.setText(f"Error: invalid file path {file_path}") @@ -539,8 +550,10 @@ def _replace_all_in_single_file(self, fpath: Path, compiled: re.Pattern, replace except OSError: return 0 - def _replace_all_in_directory(self, pattern: str, replacement: str, root: str) -> None: - """在整個目錄中全部取代 / Replace all matches inside every file under the directory.""" + def _replace_all_in_directory(self, pattern: str, replacement: str, + root: str | list[str]) -> None: + """在一個或多個目錄中全部取代 / Replace all matches in every file under the directories.""" + roots = [root] if isinstance(root, str) else list(root) case = self.chk_case.isChecked() use_regex = self.chk_regex.isChecked() flags = 0 if case else re.IGNORECASE @@ -554,7 +567,7 @@ def _replace_all_in_directory(self, pattern: str, replacement: str, root: str) - confirm = QMessageBox.question( self, self._lang("search_replace_confirm_title"), - self._lang("search_replace_confirm_replace_all").format(root=root), + self._lang("search_replace_confirm_replace_all").format(root=", ".join(roots)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm != QMessageBox.StandardButton.Yes: @@ -563,16 +576,11 @@ def _replace_all_in_directory(self, pattern: str, replacement: str, root: str) - total_files = 0 total_replacements = 0 - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] - for fname in filenames: - fpath = Path(dirpath) / fname - if _is_binary(fpath): - continue - count = self._replace_all_in_single_file(fpath, compiled, replacement) - if count > 0: - total_files += 1 - total_replacements += count + for fpath in self._files_under(roots): + count = self._replace_all_in_single_file(fpath, compiled, replacement) + if count > 0: + total_files += 1 + total_replacements += count self.status_label.setText( self._lang("search_replace_replaced_summary").format( @@ -582,14 +590,44 @@ def _replace_all_in_directory(self, pattern: str, replacement: str, root: str) - # ---------- Helpers ---------- - def _get_project_root(self) -> str: - """取得專案根目錄 / Get project root directory""" - # 使用 treeview 的根目錄,或者 cwd - if self.editor_widget.project_treeview_model: - root = self.editor_widget.project_treeview_model.rootPath() - if root: - return root - return os.getcwd() + @staticmethod + def _files_under(roots: list[str]): + """走訪這些目錄底下所有可以取代的文字檔 / Walk every text file under these directories.""" + for root in roots: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] + for fname in filenames: + fpath = Path(dirpath) / fname + if not _is_binary(fpath): + yield fpath + + def _project_roots(self) -> list[str]: + """取得工作區的每個根目錄 / Every root of the workspace.""" + return local_root_paths(getattr(self.editor_widget, "main_window", None)) + + def _locate_in_project(self, file_path: str) -> tuple[Path, Path] | None: + """ + 找出檔案屬於哪個根目錄,以及它在那個根目錄內的相對路徑 + The root a file belongs to, and its path inside that root. + + 搜尋結果裡的路徑不直接拿來寫檔:只有落在工作區某個根目錄底下的檔案才會被 + 改寫,其餘一律拒絕。 + A path from the search results is never written to as it stands: only a + file beneath one of the workspace's roots is rewritten, and anything else + is refused. + + :param file_path: 搜尋結果裡的檔案路徑 / the file path from the search results + :return: ``(根目錄, 相對路徑)``,不在任何根目錄底下時為 ``None`` + ``(root, relative path)``, or ``None`` outside every root + """ + target = Path(file_path).resolve() + for root in self._project_roots(): + resolved_root = Path(root).resolve() + try: + return resolved_root, target.relative_to(resolved_root) + except ValueError: + continue + return None def closeEvent(self, event: QCloseEvent) -> None: """關閉對話框時停止搜尋執行緒並清掉縮圖標記 / Stop the worker and clear the marks""" diff --git a/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py b/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py index fde1f65..a534fbe 100644 --- a/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py +++ b/je_editor/pyside_ui/main_ui/command_palette/quick_open_dialog.py @@ -4,7 +4,6 @@ """ from __future__ import annotations -import os from pathlib import Path from typing import TYPE_CHECKING @@ -14,7 +13,13 @@ CommandPaletteDialog ) from je_editor.utils.command_palette.fuzzy_matcher import CommandEntry -from je_editor.utils.file_scan.file_indexer import build_file_entries, index_project_files +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + labelled_root_paths, primary_root_path +) +from je_editor.utils.file_scan.file_indexer import build_file_entries +from je_editor.utils.file_scan.workspace_scan import ( + FoundFile, LabelledRoot, as_labelled_roots, index_workspace_files +) from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -32,19 +37,24 @@ class FileIndexThread(QThread): Index project files in the background so a large tree never blocks the UI. """ - indexed = Signal(list) # list[str] of project-relative paths + indexed = Signal(list) # list[str] of the paths to show - def __init__(self, root: str) -> None: + def __init__(self, root: str | list[LabelledRoot]) -> None: """ - :param root: 要索引的專案根目錄 / The project root to index + :param root: 要索引的專案根目錄;工作區有好幾個根目錄時是「顯示名稱與路徑」的清單 + / The project root to index, or each root's label and path when the + workspace has several """ super().__init__() # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 # A named thread, so Qt's abort message says which one if it is ever # destroyed while still running self.setObjectName("FileIndexThread") - self._root = root + self._roots = as_labelled_roots(root) self._stop_requested = False + # 索引結果,含開檔用的完整路徑;在送出 indexed 之前就填好 + # What the index found, full paths included; filled before indexed is emitted + self.found: list[FoundFile] = [] def stop(self) -> None: """要求提前結束索引 / Ask the walk to finish early.""" @@ -53,11 +63,13 @@ def stop(self) -> None: def run(self) -> None: """執行索引並送出結果 / Run the index and emit the result.""" try: - paths = index_project_files(self._root, should_stop=lambda: self._stop_requested) + found = index_workspace_files( + self._roots, should_stop=lambda: self._stop_requested) except OSError as error: jeditor_logger.error(f"quick_open_dialog.py index failed: {error!r}") - paths = [] - self.indexed.emit(paths) + found = [] + self.found = found + self.indexed.emit([item.display_path for item in found]) class QuickOpenDialog(CommandPaletteDialog): @@ -79,11 +91,12 @@ class QuickOpenDialog(CommandPaletteDialog): _command_entries: tuple = () def __init__( - self, parent, root: str, command_entries: list[CommandEntry], + self, parent, root: str | list[LabelledRoot], command_entries: list[CommandEntry], main_window=None) -> None: """ :param parent: Qt 父視窗 / The Qt parent widget - :param root: 專案根目錄 / The project root being indexed + :param root: 專案根目錄,或每個根目錄的顯示名稱與路徑 + / The project root being indexed, or each root's label and path :param command_entries: 指令模式使用的選單指令 / Menu commands for command mode :param main_window: 用來開檔的主視窗,``None`` 時沿用 ``parent`` / The window used to open files; ``None`` reuses ``parent`` @@ -94,7 +107,6 @@ def __init__( title=word.get("quick_open_title"), placeholder=word.get("quick_open_placeholder"), ) - self._root = root self._main_window = main_window if main_window is not None else parent self._file_entries = [] self._command_entries = command_entries @@ -108,9 +120,9 @@ def _on_indexed(self, relative_paths: list) -> None: """索引完成後建立項目並套用 / Build entries once indexing finishes.""" jeditor_logger.info(f"quick_open_dialog.py indexed {len(relative_paths)} files") entries = build_file_entries(relative_paths) + full_paths = {item.display_path: item.full_path for item in self._index_thread.found} for entry in entries: - entry.payload = make_file_opener( - self._main_window, Path(self._root) / entry.path) + entry.payload = make_file_opener(self._main_window, Path(full_paths[entry.path])) self._file_entries = entries if not self._in_command_mode: self.set_commands(entries) @@ -180,10 +192,7 @@ def resolve_project_root(main_window: EditorMain) -> str: :param main_window: 主編輯器視窗 / The main editor window :return: 專案根目錄路徑 / The project root path """ - working_dir = getattr(main_window, "working_dir", None) - if working_dir and Path(working_dir).is_dir(): - return str(working_dir) - return os.getcwd() + return primary_root_path(main_window) def open_quick_open(main_window: EditorMain) -> QuickOpenDialog: @@ -197,9 +206,9 @@ def open_quick_open(main_window: EditorMain) -> QuickOpenDialog: from je_editor.pyside_ui.main_ui.command_palette.menu_command_collector import ( collect_menu_commands ) - root = resolve_project_root(main_window) - jeditor_logger.info(f"quick_open_dialog.py open_quick_open root: {root}") + roots = labelled_root_paths(main_window) + jeditor_logger.info(f"quick_open_dialog.py open_quick_open roots: {roots}") dialog = QuickOpenDialog( - main_window, root, collect_menu_commands(getattr(main_window, "menu", None))) + main_window, roots, collect_menu_commands(getattr(main_window, "menu", None))) dialog.show() return dialog diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 65d3a98..02ecadf 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -18,7 +18,7 @@ from PySide6.QtCore import Qt, QFileInfo, QDir, QFileSystemWatcher from PySide6.QtGui import QDragEnterEvent, QDropEvent from PySide6.QtWidgets import QWidget, QGridLayout, QSplitter, QScrollArea, QFileSystemModel, QTreeView, QTabWidget, \ - QMessageBox, QHBoxLayout + QMessageBox, QHBoxLayout, QComboBox, QVBoxLayout from je_editor.pyside_ui.code.auto_save.auto_save_manager import auto_save_manager_dict, init_new_auto_save_thread, \ file_is_open_manager_dict @@ -33,6 +33,7 @@ from je_editor.pyside_ui.code.textedit_code_result.code_record import CodeRecord from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import labelled_root_paths from je_editor.utils.encodings.text_codec import ( DEFAULT_ENCODING, LINE_ENDING_LF ) @@ -186,7 +187,7 @@ def __init__(self, main_window: EditorMain) -> None: self.edit_splitter.setStretchFactor(1, 1) self.edit_splitter.setSizes([300, 100]) - self.full_splitter.addWidget(self.project_treeview) + self.full_splitter.addWidget(self.project_tree_panel) self.full_splitter.addWidget(self.edit_splitter) self.full_splitter.setStretchFactor(0, 1) self.full_splitter.setStretchFactor(1, 3) @@ -217,15 +218,10 @@ def set_project_treeview(self) -> None: self.project_treeview = QTreeView() self.project_treeview.setModel(self.project_treeview_model) - # 設定根目錄 (工作目錄或當前路徑) / Set root directory (working dir or current path) - if self.main_window.working_dir is None: - self.project_treeview.setRootIndex( - self.project_treeview_model.index(str(Path.cwd())) - ) - else: - self.project_treeview.setRootIndex( - self.project_treeview_model.index(self.main_window.working_dir) - ) + # 工作區有好幾個根目錄時,用這個選單決定樹狀檢視顯示哪一個 + # With several workspace roots, this list decides which one the tree shows + self.project_root_combobox = QComboBox() + self.project_root_combobox.currentIndexChanged.connect(self._show_selected_root) # 包裝成可捲動區域 / Wrap in scroll area self.tree_view_scroll_area = QScrollArea() @@ -234,9 +230,46 @@ def set_project_treeview(self) -> None: self.tree_view_scroll_area.setWidget(self.project_treeview) self.grid_layout.addWidget(self.tree_view_scroll_area, 0, 0, 0, 1) + # 根目錄選單在上、樹狀檢視在下 / The root list above, the tree below + self.project_tree_panel = QWidget() + panel_layout = QVBoxLayout(self.project_tree_panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.addWidget(self.project_root_combobox) + panel_layout.addWidget(self.project_treeview) + self.refresh_project_roots() + # 點擊檔案時觸發 / Connect click event self.project_treeview.clicked.connect(self.treeview_click) + def refresh_project_roots(self) -> None: + """ + 讓樹狀檢視上方的根目錄選單跟著工作區走 + Bring the root list above the file tree in step with the workspace. + + 只有一個根目錄時不必選,選單收起來,畫面跟原本一樣。 + One root needs no choosing, so the list is hidden and the panel looks as + it always did. + """ + roots = labelled_root_paths(self.main_window) + shown = self.project_root_combobox.currentData() + self.project_root_combobox.blockSignals(True) + try: + self.project_root_combobox.clear() + for label, path in roots: + self.project_root_combobox.addItem(label, path) + self.project_root_combobox.setCurrentIndex( + max(self.project_root_combobox.findData(shown), 0)) + finally: + self.project_root_combobox.blockSignals(False) + self.project_root_combobox.setVisible(len(roots) > 1) + self._show_selected_root() + + def _show_selected_root(self) -> None: + """讓樹狀檢視顯示選單選中的根目錄 / Make the tree show the root picked in the list.""" + path = self.project_root_combobox.currentData() + if path: + self.project_treeview.setRootIndex(self.project_treeview_model.index(path)) + def check_is_open(self, path: Path) -> bool: """ 檢查檔案是否已經開啟,如果已開啟則切換到該分頁。 diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 0b64b2a..9e28e1a 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -20,6 +20,7 @@ # 匯入專案內部模組 (自訂 UI 與功能) # Import project-specific modules (custom UI and features) from je_editor.adapters.default_services import build_default_services +from je_editor.core.workspace.workspace_model import Workspace from je_editor.pyside_ui.browser.browser_widget import BrowserWidget from je_editor.pyside_ui.browser.main_browser_widget import MainBrowserWidget from je_editor.pyside_ui.code.auto_save.auto_save_manager import init_new_auto_save_thread, file_is_open_manager_dict @@ -38,6 +39,9 @@ write_user_setting ) from je_editor.pyside_ui.main_ui.system_tray.extend_system_tray import ExtendSystemTray +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + remember_extra_roots, restore_extra_roots +) from je_editor.utils.file.open.open_file import read_file from je_editor.utils.session.editor_state import editor_state, restore_editor_state from je_editor.utils.status.status_text import ( @@ -96,7 +100,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # 不屬於任何元件的狀態(工作區、診斷、AI 供應者與設定);面板向它要,而不是各自保管 # The state that belongs to no widget (workspace, diagnostics, AI providers # and settings); panels ask it instead of each keeping a copy - self.services = build_default_services() + self.services = build_default_services(Workspace.single_root(os.getcwd())) self.extend = extend # 是否為擴充模式(如 PyBreeze)/ Whether in extend mode (e.g. PyBreeze) # 確保外部插件已載入(若尚未載入) @@ -107,6 +111,14 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # Read user settings read_user_setting() + # 工作區:工作目錄是主要的根目錄,再加回上次另外加入的資料夾;之後根目錄有 + # 增減就記進設定並更新每個分頁的檔案樹 + # The workspace: the working directory is the primary root, and the + # folders added last time join it. Later changes to the roots are + # recorded in the settings and shown by every tab's file tree + restore_extra_roots(self.services.workspace) + self.services.workspace.changed.subscribe(self._on_workspace_changed) + # 設定語言 (多語系支援);第一次啟動時照系統語系挑一個 # Set language (multi-language support), following the system on a first run language_wrapper.reset_language(resolve_startup_language(user_setting_dict)) @@ -545,6 +557,22 @@ def _periodic_save_settings(self) -> None: except Exception as e: jeditor_logger.warning(f"Periodic settings save failed: {e}") + def _on_workspace_changed(self, workspace: Workspace) -> None: + """ + 工作區的根目錄變了:記進設定,並更新每個編輯分頁的檔案樹 + The workspace's roots changed: record them, and update each editor tab's file tree. + + :param workspace: 變動後的工作區 / the workspace after the change + """ + remember_extra_roots(workspace) + tab_widget = getattr(self, "tab_widget", None) + if tab_widget is None: + return + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.refresh_project_roots() + def closeEvent(self, event: QCloseEvent) -> None: """ 視窗關閉事件:關閉所有分頁並儲存使用者設定 diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py index 15780b1..96d5983 100644 --- a/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/build_file_menu.py @@ -9,6 +9,9 @@ # 匯入使用者設定字典,用來保存 UI 設定 # Import user settings dictionary for saving UI preferences from je_editor.pyside_ui.main_ui.save_settings.shortcut_setting import bind +from je_editor.pyside_ui.main_ui.workspace.workspace_actions import ( + add_folder_to_workspace, remove_folder_from_workspace +) from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) @@ -88,6 +91,22 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.open_folder_action) + # 工作區:在目前的專案旁邊多放一個資料夾,或把它拿掉 + # Workspace: put another folder beside the current project, or take one away + ui_we_want_to_set.file_menu.add_workspace_folder_action = QAction( + language_wrapper.language_word_dict.get("file_menu_add_workspace_folder_label")) + ui_we_want_to_set.file_menu.add_workspace_folder_action.triggered.connect( + lambda: add_folder_to_workspace(ui_we_want_to_set) + ) + ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.add_workspace_folder_action) + ui_we_want_to_set.file_menu.remove_workspace_folder_action = QAction( + language_wrapper.language_word_dict.get("file_menu_remove_workspace_folder_label")) + ui_we_want_to_set.file_menu.remove_workspace_folder_action.triggered.connect( + lambda: remove_folder_from_workspace(ui_we_want_to_set) + ) + ui_we_want_to_set.file_menu.addAction( + ui_we_want_to_set.file_menu.remove_workspace_folder_action) + # 儲存檔案動作 # Save File action ui_we_want_to_set.file_menu.save_file_action = QAction( diff --git a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py index 5f71805..9a3f178 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -14,7 +14,6 @@ """ from __future__ import annotations -import os from pathlib import Path from PySide6.QtCore import Qt @@ -30,6 +29,9 @@ from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( ProjectLintWorker ) +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + local_root_paths, primary_root_path +) from je_editor.utils.file.open.open_file import read_file_with_encoding from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -217,7 +219,7 @@ def start_project_check(self) -> bool: :return: 是否啟動了檢查 / whether a check was started """ self._stop_project_check() - worker = ProjectLintWorker(self._project_root(), self) + worker = ProjectLintWorker(local_root_paths(self._main_window), self) self._project_worker = worker worker.linted.connect(self._on_project_linted) # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 @@ -266,11 +268,8 @@ def closeEvent(self, event) -> None: super().closeEvent(event) def _project_root(self) -> str: - """取得要檢查的專案根目錄 / The project root to check.""" - working_dir = getattr(self._main_window, "working_dir", None) - if working_dir and Path(str(working_dir)).is_dir(): - return str(working_dir) - return os.getcwd() + """取得主要的專案根目錄 / The primary project root.""" + return primary_root_path(self._main_window) def visible_diagnostics(self) -> list[Diagnostic]: """ @@ -321,10 +320,14 @@ def apply_available_fixes(self) -> bool: :return: ruff 可用並已執行時為 ``True`` / ``True`` when ruff ran """ - target = self._project_root() if self.project_check.isChecked() else self._current_file() - if target is None: - return False - if not apply_fixes(target): + if self.project_check.isChecked(): + targets = local_root_paths(self._main_window) + else: + current = self._current_file() + targets = [] if current is None else [current] + # 每個目標都要試:一個根目錄沒有可修的東西,不代表下一個也沒有 + # Every target is tried: one root having nothing to fix says nothing about the next + if not [target for target in targets if apply_fixes(target)]: return False self._reload_current_tab() self.refresh() diff --git a/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py b/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py index 10076b4..d1e934c 100644 --- a/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py +++ b/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py @@ -28,19 +28,32 @@ class ProjectLintWorker(QThread): linted = Signal(object) # list[Diagnostic] - def __init__(self, root: str | Path, parent=None) -> None: + def __init__(self, root: str | Path | list[str], parent=None) -> None: """ - :param root: 要檢查的專案根目錄 / the project root to check + :param root: 要檢查的專案根目錄;工作區有好幾個根目錄時是一份清單 + the project root to check, or a list of them when the workspace has several :param parent: Qt 父物件 / the Qt parent """ super().__init__(parent) - self._root = str(root) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("ProjectLintWorker") + self._roots = [str(root)] if isinstance(root, (str, Path)) else [str(item) for item in root] @property def root(self) -> str: - """這次檢查的目錄 / The directory being checked.""" - return self._root + """這次檢查的第一個目錄 / The first directory being checked.""" + return self._roots[0] if self._roots else "" + + @property + def roots(self) -> list[str]: + """這次檢查的所有目錄 / Every directory being checked.""" + return list(self._roots) def run(self) -> None: - """執行檢查並回報結果 / Lint and report the result.""" - self.linted.emit(lint_project(self._root)) + """逐一檢查每個根目錄並回報合併的結果 / Lint each root in turn and report them together.""" + found = [] + for root in self._roots: + found.extend(lint_project(root)) + self.linted.emit(found) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py index 10ab1c6..4faf639 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py @@ -31,6 +31,8 @@ "indent_size": 4, # 縮排空格數 / Indent size (spaces) "open_files": [], # 上次關閉時開啟的分頁 / Tabs open at last shutdown "restore_session": True, # 啟動時還原分頁 / Restore tabs on startup + # 工作目錄以外另外加入工作區的資料夾 / Folders added to the workspace beside the working directory + "workspace_roots": [], # 使用者改過的快捷鍵,只記與預設不同的 / Shortcuts the user changed, defaults omitted "shortcuts": {}, } diff --git a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py index 9e0a39d..36bcf9c 100644 --- a/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -10,7 +10,6 @@ """ from __future__ import annotations -import os import subprocess # nosec B404 - 以引數清單執行 pytest,未使用 shell import sys from pathlib import Path @@ -21,6 +20,7 @@ QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import primary_root_path from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper from je_editor.utils.test_runner.pytest_output import ( @@ -388,10 +388,7 @@ def resolve_working_dir(main_window) -> str: :param main_window: 主編輯器視窗,可為 ``None`` / the main window, may be ``None`` :return: 目錄路徑 / the directory path """ - working_dir = getattr(main_window, "working_dir", None) - if working_dir and Path(str(working_dir)).is_dir(): - return str(working_dir) - return os.getcwd() + return primary_root_path(main_window) def jump_to_line(main_window, line: int) -> bool: diff --git a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py index d3f675c..7a3e66c 100644 --- a/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py +++ b/je_editor/pyside_ui/main_ui/todo_panel/todo_panel_widget.py @@ -4,7 +4,6 @@ """ from __future__ import annotations -import os from pathlib import Path from PySide6.QtCore import Qt, QThread, Signal @@ -13,7 +12,13 @@ QVBoxLayout, QWidget ) -from je_editor.utils.file_scan.todo_scanner import DEFAULT_TAGS, TodoItem, scan_project_todos +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + labelled_root_paths, primary_root_path +) +from je_editor.utils.file_scan.todo_scanner import DEFAULT_TAGS, TodoItem +from je_editor.utils.file_scan.workspace_scan import ( + FoundTodo, LabelledRoot, as_labelled_roots, scan_workspace_todos +) from je_editor.utils.logging.loggin_instance import jeditor_logger from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper @@ -36,17 +41,22 @@ class TodoScanThread(QThread): scanned = Signal(list) # list[TodoItem] - def __init__(self, root: str) -> None: + def __init__(self, root: str | list[LabelledRoot]) -> None: """ - :param root: 要掃描的專案根目錄 / The project root to scan + :param root: 要掃描的專案根目錄;工作區有好幾個根目錄時是「顯示名稱與路徑」的清單 + / The project root to scan, or each root's label and path when the + workspace has several """ super().__init__() # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 # A named thread, so Qt's abort message says which one if it is ever # destroyed while still running self.setObjectName("TodoScanThread") - self._root = root + self._roots = as_labelled_roots(root) self._stop_requested = False + # 掃描結果,含開檔用的完整路徑;在送出 scanned 之前就填好 + # What the scan found, full paths included; filled before scanned is emitted + self.found: list[FoundTodo] = [] def stop(self) -> None: """要求提前結束掃描 / Ask the scan to finish early.""" @@ -55,12 +65,13 @@ def stop(self) -> None: def run(self) -> None: """執行掃描並送出結果 / Run the scan and emit the result.""" try: - items = scan_project_todos( - self._root, should_stop=lambda: self._stop_requested) + found = scan_workspace_todos( + self._roots, should_stop=lambda: self._stop_requested) except OSError as error: jeditor_logger.error(f"todo_panel_widget.py scan failed: {error!r}") - items = [] - self.scanned.emit(items) + found = [] + self.found = found + self.scanned.emit([entry.item for entry in found]) class TodoPanelWidget(QWidget): @@ -75,13 +86,17 @@ class TodoPanelWidget(QWidget): def __init__(self, main_window=None, root: str | None = None) -> None: """ :param main_window: 用來開檔的主視窗 / The window used to open files - :param root: 專案根目錄,``None`` 時自動判斷 / The project root; ``None`` auto-detects + :param root: 專案根目錄,``None`` 時掃描視窗工作區的每個根目錄 + / The project root; ``None`` scans every root of the window's workspace """ super().__init__() word = language_wrapper.language_word_dict self._main_window = main_window - self._root = root if root is not None else resolve_todo_root(main_window) + self._root = root self._items: list[TodoItem] = [] + # 每筆項目開檔用的完整路徑,以(顯示路徑, 行號)為鍵 + # The full path to open each item by, keyed by (shown path, line) + self._full_paths: dict[tuple[str, int], str] = {} self._scan_thread: TodoScanThread | None = None self.refresh_button = QPushButton(word.get("todo_panel_refresh")) @@ -132,7 +147,11 @@ def start_scan(self) -> None: return self.refresh_button.setEnabled(False) self.status_label.setText(language_wrapper.language_word_dict.get("todo_panel_scanning")) - self._scan_thread = TodoScanThread(self._root) + # 沒有指定根目錄時每次掃描都重新問工作區,之後加進來的根目錄才掃得到 + # With no root given, the workspace is asked afresh for each scan, so a + # root added later is scanned too + roots = self._root if self._root is not None else labelled_root_paths(self._main_window) + self._scan_thread = TodoScanThread(roots) self._scan_thread.scanned.connect(self._on_scanned) self._scan_thread.start() @@ -152,6 +171,9 @@ def _on_scanned(self, items: list) -> None: """掃描完成後更新畫面 / Update the view once the scan finishes.""" jeditor_logger.info(f"todo_panel_widget.py scanned {len(items)} todo items") self._items = items + found = self._scan_thread.found if self._scan_thread is not None else [] + self._full_paths = { + (entry.item.path, entry.item.line): entry.full_path for entry in found} self.refresh_button.setEnabled(True) self._render_items() @@ -193,7 +215,9 @@ def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: item = row.data(COLUMN_TAG, Qt.ItemDataRole.UserRole) if item is None: return - open_todo_item(self._main_window, self._root, item) + full_path = self._full_paths.get((item.path, item.line)) + if full_path is not None: + open_todo_at(self._main_window, full_path, item.line) def closeEvent(self, event) -> None: """ @@ -220,10 +244,7 @@ def resolve_todo_root(main_window) -> str: :param main_window: 主編輯器視窗,可為 ``None`` / The main window, may be ``None`` :return: 專案根目錄路徑 / The project root path """ - working_dir = getattr(main_window, "working_dir", None) - if working_dir and Path(working_dir).is_dir(): - return str(working_dir) - return os.getcwd() + return primary_root_path(main_window) def open_todo_item(main_window, root: str, item: TodoItem) -> bool: @@ -236,10 +257,23 @@ def open_todo_item(main_window, root: str, item: TodoItem) -> bool: :param item: 要開啟的項目 / The item to open :return: 成功要求開檔時為 ``True`` / ``True`` when the open was requested """ + return open_todo_at(main_window, Path(root) / item.path, item.line) + + +def open_todo_at(main_window, full_path: str | Path, line: int) -> bool: + """ + 在編輯器中開啟某個檔案的某一行 + Open one line of a file in the editor. + + :param main_window: 用來開檔的主視窗 / The window used to open files + :param full_path: 檔案的完整路徑 / The file's full path + :param line: 1 起算的行號 / The 1-based line number + :return: 成功要求開檔時為 ``True`` / ``True`` when the open was requested + """ if main_window is None or not hasattr(main_window, "go_to_new_tab"): return False - main_window.go_to_new_tab(Path(root) / item.path) - jump_to_item_line(main_window, item.line) + main_window.go_to_new_tab(Path(full_path)) + jump_to_item_line(main_window, line) return True diff --git a/je_editor/pyside_ui/main_ui/workspace/__init__.py b/je_editor/pyside_ui/main_ui/workspace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/pyside_ui/main_ui/workspace/workspace_actions.py b/je_editor/pyside_ui/main_ui/workspace/workspace_actions.py new file mode 100644 index 0000000..68ce92f --- /dev/null +++ b/je_editor/pyside_ui/main_ui/workspace/workspace_actions.py @@ -0,0 +1,95 @@ +""" +把資料夾加進工作區、從工作區移除 +Add a folder to the workspace, and remove one from it. + +「開啟資料夾」是換一個專案:工作目錄跟著換,整個工作區換成那個資料夾。這裡做的是 +另一件事:在目前的專案旁邊多放一個根目錄,工作目錄不動。 +"Open Folder" switches project: the working directory moves and the whole +workspace becomes that folder. This does something else: it puts another root +beside the current project and leaves the working directory alone. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog, QInputDialog, QMessageBox, QWidget + +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import write_user_setting +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + remember_extra_roots, window_workspace +) +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + + +def add_folder_to_workspace(main_window: QWidget, folder: str | None = None) -> bool: + """ + 把一個資料夾加進工作區 + Add a folder to the workspace. + + :param main_window: 主視窗 / the main window + :param folder: 要加入的資料夾;沒給時讓使用者挑 / the folder to add, chosen by + the user when omitted + :return: 是否真的加入了 / whether a folder was added + """ + word = language_wrapper.language_word_dict + if folder is None: + folder = QFileDialog.getExistingDirectory( + main_window, word.get("file_menu_add_workspace_folder_label")) + if not folder or not Path(folder).is_dir(): + return False + workspace = window_workspace(main_window) + if not workspace.add_root(folder): + QMessageBox.information( + main_window, word.get("file_menu_add_workspace_folder_label"), + word.get("workspace_folder_already_added")) + return False + jeditor_logger.info("workspace: added the folder %s", folder) + _save_roots(main_window) + return True + + +def remove_folder_from_workspace(main_window: QWidget, folder: str | None = None) -> bool: + """ + 把一個另外加入的資料夾從工作區移除 + Remove an added folder from the workspace. + + 主要的根目錄就是工作目錄,不能在這裡移除;要換它得用「開啟資料夾」。 + The primary root is the working directory and cannot be removed here; + changing it is what "Open Folder" is for. + + :param main_window: 主視窗 / the main window + :param folder: 要移除的資料夾;沒給時讓使用者從清單挑 / the folder to remove, + picked from a list by the user when omitted + :return: 是否真的移除了 / whether a folder was removed + """ + word = language_wrapper.language_word_dict + title = word.get("file_menu_remove_workspace_folder_label") + workspace = window_workspace(main_window) + extra = [root.path for root in workspace.roots[1:] if root.is_local] + if not extra: + QMessageBox.information(main_window, title, word.get("workspace_no_extra_folders")) + return False + if folder is None: + folder, accepted = QInputDialog.getItem( + main_window, title, word.get("workspace_remove_folder_prompt"), extra, 0, False) + if not accepted: + return False + if folder not in extra or not workspace.remove_root(folder): + return False + jeditor_logger.info("workspace: removed the folder %s", folder) + _save_roots(main_window) + return True + + +def _save_roots(main_window: QWidget) -> None: + """ + 把工作區的根目錄記進設定並立刻寫檔 + Record the workspace's roots in the settings and write the file at once. + + 不等定期存檔:使用者接著就切到別的專案的話,這個專案的根目錄清單就丟了。 + The periodic save is not waited for: were the user to switch project straight + away, this project's list of roots would be lost. + """ + remember_extra_roots(window_workspace(main_window)) + write_user_setting() diff --git a/je_editor/pyside_ui/main_ui/workspace/workspace_roots.py b/je_editor/pyside_ui/main_ui/workspace/workspace_roots.py new file mode 100644 index 0000000..99ad544 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/workspace/workspace_roots.py @@ -0,0 +1,119 @@ +""" +從視窗取得工作區與它的根目錄 +Get the workspace, and its roots, from a window. + +問題面板、TODO 面板、測試面板、快速開啟與搜尋原本各自寫了一份「用視窗的工作目錄, +沒有就用目前目錄」。現在都問這裡,所以它們對「專案在哪裡」的答案一定相同,而且 +工作區有好幾個根目錄時也都知道。 +The problems panel, the TODO panel, the test panel, quick open and search each +used to spell out "the window's working directory, or the current one". They all +ask here now, so their answer to where the project is always agrees, and all of +them learn about it when the workspace has several roots. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.workspace.workspace_model import ProjectRoot, Workspace +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + +# 使用者設定裡記錄「主要根目錄以外的根目錄」的鍵 +# The user-setting key that records the roots beyond the primary one +EXTRA_ROOTS_SETTING = "workspace_roots" + + +def window_workspace(main_window: object) -> Workspace: + """ + 取得視窗的工作區 + The workspace of a window. + + 視窗沒有核心服務(宿主程式自己的視窗、測試用的替身)時,照原本的規則組一個 + 只有一個根目錄的工作區:視窗的工作目錄,沒有就用目前目錄。 + A window without core services, a host's own window or a test double, gets a + single-root workspace built by the old rule: the window's working directory, + or the current directory when it has none. + + :param main_window: 主視窗 / the main window + :return: 工作區 / the workspace + """ + services = getattr(main_window, "services", None) + if isinstance(services, EditorServices) and services.workspace.roots: + return services.workspace + working_dir = getattr(main_window, "working_dir", None) + if working_dir and Path(str(working_dir)).is_dir(): + return Workspace.single_root(str(working_dir)) + return Workspace.single_root(os.getcwd()) + + +def primary_root_path(main_window: object) -> str: + """ + 取得主要根目錄的路徑,也就是原本的「專案目錄」 + The path of the primary root, which is what the project directory used to be. + + :param main_window: 主視窗 / the main window + :return: 目錄路徑 / the directory + """ + return local_root_paths(main_window)[0] + + +def local_root_paths(main_window: object) -> list[str]: + """ + 取得每個本機根目錄的路徑 + The path of every local root. + + :param main_window: 主視窗 / the main window + :return: 目錄路徑,至少一個 / the directories, at least one + """ + paths = [root.path for root in window_workspace(main_window).roots if root.is_local] + return paths or [os.getcwd()] + + +def labelled_root_paths(main_window: object) -> list[tuple[str, str]]: + """ + 取得每個本機根目錄的顯示名稱與路徑 + The display name and the path of every local root. + + 交給背景執行緒的是這份清單而不是工作區本身,執行緒就不會碰到 UI 執行緒正在改 + 的物件。 + A worker thread is handed this list rather than the workspace itself, so it + never touches an object the UI thread may be changing. + + :param main_window: 主視窗 / the main window + :return: ``(顯示名稱, 路徑)`` / ``(label, path)`` + """ + return [(label, root.path) + for label, root in window_workspace(main_window).labelled_roots() if root.is_local] + + +def restore_extra_roots(workspace: Workspace) -> None: + """ + 把使用者設定裡記的額外根目錄加回工作區 + Add the extra roots recorded in the user settings back to a workspace. + + 設定檔可能被手動編輯,所以不是字串的項目會被略過。 + The settings file may have been edited by hand, so an entry that is not a + string is skipped. + + :param workspace: 已經有主要根目錄的工作區 / a workspace that already has its primary root + """ + stored = user_setting_dict.get(EXTRA_ROOTS_SETTING) + for path in stored if isinstance(stored, list) else []: + if isinstance(path, str) and path: + workspace.add_root(ProjectRoot.from_path(path)) + + +def remember_extra_roots(workspace: Workspace) -> None: + """ + 把主要根目錄以外的根目錄記進使用者設定 + Record the roots beyond the primary one in the user settings. + + 主要根目錄就是工作目錄,設定檔本來就放在它底下,所以不必記。 + The primary root is the working directory, which the settings file already + lives under, so it needs no recording. + + :param workspace: 要記錄的工作區 / the workspace to record + """ + user_setting_dict[EXTRA_ROOTS_SETTING] = [ + root.path for root in workspace.roots[1:] if root.is_local] diff --git a/je_editor/utils/file_scan/workspace_scan.py b/je_editor/utils/file_scan/workspace_scan.py new file mode 100644 index 0000000..ffd0481 --- /dev/null +++ b/je_editor/utils/file_scan/workspace_scan.py @@ -0,0 +1,135 @@ +""" +跨好幾個根目錄的檔案索引與 TODO 掃描 +File indexing and TODO scanning across several roots. + +單一根目錄的索引與掃描已經有了;這裡只是對每個根目錄各跑一次,並把結果的路徑變成 +分得出根目錄的形式。只有一個根目錄時,顯示的路徑跟原本完全一樣。 +Indexing and scanning one root already exist. This runs them once per root and +gives each result a path that tells the roots apart. With a single root, the +path shown is exactly what it always was. + +純邏輯:不碰 Qt,因此可以在背景執行緒直接呼叫。 +Pure logic: it touches no Qt, so a worker thread can call it directly. +""" +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from pathlib import Path + +from je_editor.utils.file_scan.file_indexer import DEFAULT_FILE_LIMIT, index_project_files +from je_editor.utils.file_scan.todo_scanner import ( + DEFAULT_TAGS, DEFAULT_TODO_LIMIT, TodoItem, scan_project_todos +) + +# 一個根目錄:顯示名稱與路徑 / One root: its label and its path +LabelledRoot = tuple[str, str] + + +@dataclass(frozen=True) +class FoundFile: + """ + 索引到的一個檔案 + One file the index found. + + :param display_path: 給使用者看的路徑 / the path to show the user + :param full_path: 用來開檔的完整路徑 / the full path to open it by + """ + + display_path: str + full_path: str + + +@dataclass(frozen=True) +class FoundTodo: + """ + 掃描到的一筆待辦事項 + One TODO the scan found. + + :param item: 待辦事項,其中的路徑是給使用者看的 / the item, whose path is the one to show + :param full_path: 用來開檔的完整路徑 / the full path to open it by + """ + + item: TodoItem + full_path: str + + +def as_labelled_roots(roots: str | Path | Sequence[LabelledRoot]) -> list[LabelledRoot]: + """ + 接受單一路徑或一份根目錄清單,一律回傳清單 + Accept one path or a list of roots, and always give back a list. + + :param roots: 單一根目錄的路徑,或每個根目錄的顯示名稱與路徑 + the path of a single root, or each root's label and path + :return: 每個根目錄的顯示名稱與路徑 / each root's label and path + """ + if isinstance(roots, (str, Path)): + return [(Path(roots).name or str(roots), str(roots))] + return [(label, str(path)) for label, path in roots] + + +def shown_path(label: str, relative: str, several_roots: bool) -> str: + """ + 取得某個根目錄內的檔案要顯示的路徑 + The path to show for a file inside a root. + + :param label: 根目錄的顯示名稱 / the root's label + :param relative: 根目錄內的相對路徑 / the path inside the root + :param several_roots: 工作區是否有一個以上的根目錄 / whether the workspace has more than one root + :return: 顯示用的路徑 / the path to show + """ + return f"{label}/{relative}" if several_roots else relative + + +def index_workspace_files( + roots: Sequence[LabelledRoot], + limit: int = DEFAULT_FILE_LIMIT, + should_stop: Callable[[], bool] | None = None) -> list[FoundFile]: + """ + 索引每個根目錄裡可編輯的檔案 + Index the editable files of every root. + + :param roots: 每個根目錄的顯示名稱與路徑 / each root's label and path + :param limit: 回傳檔案數量上限,所有根目錄合計 / the cap on files returned, over all roots + :param should_stop: 回傳 ``True`` 時提前中止 / returning ``True`` stops early + :return: 找到的檔案,依根目錄順序 / the files found, in root order + """ + several = len(roots) > 1 + found: list[FoundFile] = [] + for label, root in roots: + if should_stop is not None and should_stop(): + break + for relative in index_project_files(root, limit - len(found), should_stop): + found.append(FoundFile(shown_path(label, relative, several), str(Path(root) / relative))) + if len(found) >= limit: + break + return found + + +def scan_workspace_todos( + roots: Sequence[LabelledRoot], + tags: Sequence[str] = DEFAULT_TAGS, + should_stop: Callable[[], bool] | None = None, + limit: int = DEFAULT_TODO_LIMIT) -> list[FoundTodo]: + """ + 掃描每個根目錄裡的 TODO 註解 + Scan the TODO comments of every root. + + :param roots: 每個根目錄的顯示名稱與路徑 / each root's label and path + :param tags: 要比對的標籤 / the tags to match + :param should_stop: 回傳 ``True`` 時提前中止 / returning ``True`` stops early + :param limit: 回傳數量上限,所有根目錄合計 / the cap on items returned, over all roots + :return: 找到的項目,依根目錄順序 / the items found, in root order + """ + several = len(roots) > 1 + found: list[FoundTodo] = [] + for label, root in roots: + if should_stop is not None and should_stop(): + break + for item in scan_project_todos(root, tags, should_stop, limit - len(found)): + found.append(FoundTodo( + replace(item, path=shown_path(label, item.path, several)), + str(Path(root) / item.path))) + if len(found) >= limit: + break + return found diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 9c56935..6730b57 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -87,6 +87,11 @@ "file_menu_new_file_label": "New File", "file_menu_open_file_label": "Open File", "file_menu_open_folder_label": "Open Folder", + "file_menu_add_workspace_folder_label": "Add Folder to Workspace", + "file_menu_remove_workspace_folder_label": "Remove Folder from Workspace", + "workspace_remove_folder_prompt": "Folder to remove:", + "workspace_no_extra_folders": "The workspace has no added folders", + "workspace_folder_already_added": "That folder is already in the workspace", "file_menu_save_file_label": "Save File", "file_menu_encoding_label": "Encodings", "file_menu_line_ending_label": "Line Endings", diff --git a/je_editor/utils/multi_language/japanese.py b/je_editor/utils/multi_language/japanese.py index ad7c21f..a9d17fe 100644 --- a/je_editor/utils/multi_language/japanese.py +++ b/je_editor/utils/multi_language/japanese.py @@ -88,6 +88,11 @@ "file_menu_new_file_label": "新しいファイル", "file_menu_open_file_label": "ファイルを開く", "file_menu_open_folder_label": "フォルダーを開く", + "file_menu_add_workspace_folder_label": "フォルダーをワークスペースに追加", + "file_menu_remove_workspace_folder_label": "フォルダーをワークスペースから削除", + "workspace_remove_folder_prompt": "削除するフォルダー:", + "workspace_no_extra_folders": "ワークスペースに追加されたフォルダーはありません", + "workspace_folder_already_added": "そのフォルダーは既にワークスペースにあります", "file_menu_save_file_label": "ファイルを保存", "file_menu_encoding_label": "エンコーディング", "file_menu_line_ending_label": "改行コード", diff --git a/je_editor/utils/multi_language/simplified_chinese.py b/je_editor/utils/multi_language/simplified_chinese.py index 75694c0..20372f0 100644 --- a/je_editor/utils/multi_language/simplified_chinese.py +++ b/je_editor/utils/multi_language/simplified_chinese.py @@ -84,6 +84,11 @@ "file_menu_new_file_label": "新建文件", "file_menu_open_file_label": "打开文件", "file_menu_open_folder_label": "打开文件夹", + "file_menu_add_workspace_folder_label": "将文件夹添加到工作区", + "file_menu_remove_workspace_folder_label": "从工作区移除文件夹", + "workspace_remove_folder_prompt": "要移除的文件夹:", + "workspace_no_extra_folders": "工作区没有另外添加的文件夹", + "workspace_folder_already_added": "这个文件夹已经在工作区里", "file_menu_save_file_label": "保存文件", "file_menu_encoding_label": "编码", "file_menu_line_ending_label": "行尾", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index b464160..40adc40 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -84,6 +84,11 @@ "file_menu_new_file_label": "新檔案", "file_menu_open_file_label": "開啟檔案", "file_menu_open_folder_label": "開啟資料夾", + "file_menu_add_workspace_folder_label": "將資料夾加入工作區", + "file_menu_remove_workspace_folder_label": "從工作區移除資料夾", + "workspace_remove_folder_prompt": "要移除的資料夾:", + "workspace_no_extra_folders": "工作區沒有另外加入的資料夾", + "workspace_folder_already_added": "這個資料夾已經在工作區裡", "file_menu_save_file_label": "儲存檔案", "file_menu_encoding_label": "編碼", "file_menu_line_ending_label": "行尾", diff --git a/test/test_core_workspace.py b/test/test_core_workspace.py index 9e9ba57..c2d67d7 100644 --- a/test/test_core_workspace.py +++ b/test/test_core_workspace.py @@ -213,6 +213,70 @@ def test_same_named_files_in_two_roots_do_not_collide(self, tmp_path): assert first[1] == second[1] == "src/main.py" assert first[0] != second[0] + def test_setting_the_roots_replaces_them_all_in_one_announcement(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "old") + workspace.add_root(tmp_path / "extra") + announced = [] + workspace.changed.subscribe(lambda changed: announced.append(len(changed.roots))) + assert workspace.set_roots([tmp_path / "new"]) is True + assert [root.name for root in workspace.roots] == ["new"] + assert announced == [1] + + def test_setting_the_same_roots_changes_nothing(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "app") + announced = [] + workspace.changed.subscribe(announced.append) + assert workspace.set_roots([tmp_path / "app"]) is False + assert announced == [] + + def test_setting_roots_drops_a_repeated_one(self, tmp_path): + workspace = Workspace() + workspace.set_roots([tmp_path / "app", tmp_path / "pkg" / ".." / "app", tmp_path / "lib"]) + assert [root.name for root in workspace.roots] == ["app", "lib"] + + def test_roots_of_the_same_name_get_distinct_labels(self, tmp_path): + workspace = Workspace() + for parent in ("one", "two", "three"): + workspace.add_root(tmp_path / parent / "src") + workspace.add_root(tmp_path / "docs") + assert [label for label, _root in workspace.labelled_roots()] == [ + "src", "src (2)", "src (3)", "docs"] + + def test_one_root_shows_the_path_inside_it(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "app") + assert workspace.display_path(tmp_path / "app" / "pkg" / "main.py") == "pkg/main.py" + + def test_several_roots_put_the_label_in_front(self, tmp_path): + workspace = Workspace() + workspace.add_root(tmp_path / "frontend") + workspace.add_root(tmp_path / "backend") + assert workspace.display_path(tmp_path / "backend" / "src" / "main.py") == "backend/src/main.py" + assert workspace.display_path(tmp_path / "frontend") == "frontend" + + def test_a_path_outside_every_root_is_shown_in_full(self, tmp_path): + workspace = Workspace.single_root(tmp_path / "app") + outside = tmp_path / "elsewhere" / "x.py" + assert workspace.display_path(outside) == outside.as_posix() + + @pytest.mark.parametrize("roots", [["app"], ["frontend", "backend"], ["one/src", "two/src"]]) + def test_a_shown_path_leads_back_to_the_file(self, tmp_path, roots): + workspace = Workspace() + for root in roots: + workspace.add_root(tmp_path / root) + target = tmp_path / roots[-1] / "pkg" / "main.py" + shown = workspace.display_path(target) + assert workspace.resolve_display_path(shown) == os.path.normpath(str(target)) + + @pytest.mark.parametrize("shown", ["unknown-root/main.py", "frontend/../../escape.py"]) + def test_a_shown_path_that_matches_nothing_resolves_to_none(self, tmp_path, shown): + workspace = Workspace() + workspace.add_root(tmp_path / "frontend") + workspace.add_root(tmp_path / "backend") + assert workspace.resolve_display_path(shown) is None + + def test_an_empty_workspace_resolves_nothing(self): + assert Workspace().resolve_display_path("main.py") is None + def test_changes_are_announced_once_each(self, tmp_path): workspace = Workspace() announced = [] diff --git a/test/test_workspace_ui.py b/test/test_workspace_ui.py new file mode 100644 index 0000000..5ad5b0b --- /dev/null +++ b/test/test_workspace_ui.py @@ -0,0 +1,393 @@ +"""Tests for a workspace with several roots, as the window, its panels and its dialogs see it.""" +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QTabWidget + +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.workspace.workspace_model import Workspace +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.pyside_ui.main_ui.workspace import workspace_actions +from je_editor.pyside_ui.main_ui.workspace.workspace_actions import ( + add_folder_to_workspace, remove_folder_from_workspace +) +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import ( + EXTRA_ROOTS_SETTING, labelled_root_paths, local_root_paths, primary_root_path, + remember_extra_roots, restore_extra_roots, window_workspace +) +from je_editor.utils.file_scan.workspace_scan import ( + as_labelled_roots, index_workspace_files, scan_workspace_todos +) +from je_editor.utils.lint.ruff_diagnostics import Diagnostic + +# Long enough that a slow machine still finishes; a hang fails rather than blocks. +TIMEOUT_MS = 10_000 +ACTIONS = "je_editor.pyside_ui.main_ui.workspace.workspace_actions" + + +def norm(path) -> str: + return os.path.normpath(str(path)) + + +@pytest.fixture() +def two_roots(tmp_path): + """Two unrelated projects, each with a ``main.py`` and a TODO in it.""" + for name in ("frontend", "backend"): + (tmp_path / name / "src").mkdir(parents=True) + (tmp_path / name / "main.py").write_text(f"# TODO: finish {name}\n", encoding="utf-8") + (tmp_path / name / "src" / f"{name}_only.py").write_text("x = 1\n", encoding="utf-8") + return tmp_path / "frontend", tmp_path / "backend" + + +@pytest.fixture() +def window(two_roots): + """A stand-in for the main window whose workspace holds both projects.""" + services = EditorServices(Workspace.single_root(two_roots[0])) + services.workspace.add_root(two_roots[1]) + return SimpleNamespace(services=services, working_dir=None, go_to_new_tab=MagicMock(), + tab_widget=None) + + +@pytest.fixture() +def _settings_untouched(): + """Put the recorded extra roots back as they were.""" + saved = user_setting_dict.get(EXTRA_ROOTS_SETTING) + yield + user_setting_dict[EXTRA_ROOTS_SETTING] = saved if saved is not None else [] + + +class TestTheWindowsWorkspace: + def test_a_window_with_services_uses_their_workspace(self, window): + assert window_workspace(window) is window.services.workspace + + def test_every_root_is_listed_in_order(self, window, two_roots): + assert local_root_paths(window) == [norm(two_roots[0]), norm(two_roots[1])] + assert primary_root_path(window) == norm(two_roots[0]) + assert labelled_root_paths(window) == [ + ("frontend", norm(two_roots[0])), ("backend", norm(two_roots[1]))] + + def test_a_window_without_services_falls_back_to_its_working_directory(self, tmp_path): + assert local_root_paths(SimpleNamespace(working_dir=str(tmp_path))) == [norm(tmp_path)] + + @pytest.mark.parametrize("stand_in", [None, SimpleNamespace(working_dir=None), MagicMock()]) + def test_a_window_that_knows_nothing_falls_back_to_the_current_directory(self, stand_in): + assert local_root_paths(stand_in) == [norm(os.getcwd())] + + def test_a_working_directory_that_is_gone_falls_back_too(self, tmp_path): + missing = SimpleNamespace(working_dir=str(tmp_path / "removed")) + assert local_root_paths(missing) == [norm(os.getcwd())] + + def test_services_with_an_empty_workspace_fall_back(self, tmp_path): + stand_in = SimpleNamespace(services=EditorServices(), working_dir=str(tmp_path)) + assert local_root_paths(stand_in) == [norm(tmp_path)] + + +@pytest.mark.usefixtures("_settings_untouched") +class TestRememberingTheExtraRoots: + def test_only_the_roots_beyond_the_first_are_recorded(self, window, two_roots): + remember_extra_roots(window.services.workspace) + assert user_setting_dict[EXTRA_ROOTS_SETTING] == [norm(two_roots[1])] + + def test_recorded_roots_are_added_back(self, two_roots): + user_setting_dict[EXTRA_ROOTS_SETTING] = [str(two_roots[1])] + workspace = Workspace.single_root(two_roots[0]) + restore_extra_roots(workspace) + assert [root.name for root in workspace.roots] == ["frontend", "backend"] + + @pytest.mark.parametrize("stored", [None, "not a list", [42, None, ""], {"a": 1}]) + def test_a_hand_edited_setting_adds_nothing(self, two_roots, stored): + user_setting_dict[EXTRA_ROOTS_SETTING] = stored + workspace = Workspace.single_root(two_roots[0]) + restore_extra_roots(workspace) + assert len(workspace.roots) == 1 + + +class TestScanningEveryRoot: + def test_one_root_shows_paths_as_a_single_project_did(self, two_roots): + found = index_workspace_files(as_labelled_roots(str(two_roots[0]))) + assert [item.display_path for item in found] == ["main.py", "src/frontend_only.py"] + + def test_several_roots_put_the_label_in_front(self, window): + shown = [item.display_path for item in index_workspace_files(labelled_root_paths(window))] + assert shown == ["frontend/main.py", "frontend/src/frontend_only.py", + "backend/main.py", "backend/src/backend_only.py"] + + def test_same_named_files_keep_their_own_full_paths(self, window, two_roots): + found = {item.display_path: item.full_path + for item in index_workspace_files(labelled_root_paths(window))} + assert norm(found["frontend/main.py"]) == norm(two_roots[0] / "main.py") + assert norm(found["backend/main.py"]) == norm(two_roots[1] / "main.py") + + def test_the_limit_counts_over_all_roots(self, window): + assert len(index_workspace_files(labelled_root_paths(window), limit=3)) == 3 + + def test_stopping_yields_nothing(self, window): + assert index_workspace_files(labelled_root_paths(window), should_stop=lambda: True) == [] + + def test_todos_come_from_every_root(self, window, two_roots): + found = scan_workspace_todos(labelled_root_paths(window)) + assert [(entry.item.path, entry.item.message) for entry in found] == [ + ("frontend/main.py", "finish frontend"), ("backend/main.py", "finish backend")] + assert norm(found[1].full_path) == norm(two_roots[1] / "main.py") + + def test_a_plain_path_counts_as_one_root(self, two_roots): + assert as_labelled_roots(two_roots[0]) == [("frontend", str(two_roots[0]))] + + +@pytest.mark.usefixtures("qapp", "_settings_untouched", "tmp_dir") +class TestAddingAndRemovingFolders: + def test_adding_a_folder_makes_it_a_root_and_records_it(self, tmp_path, two_roots): + services = EditorServices(Workspace.single_root(two_roots[0])) + stand_in = SimpleNamespace(services=services) + assert add_folder_to_workspace(stand_in, str(two_roots[1])) is True + assert [root.name for root in services.workspace.roots] == ["frontend", "backend"] + assert user_setting_dict[EXTRA_ROOTS_SETTING] == [norm(two_roots[1])] + + def test_the_change_is_written_to_the_settings_file_at_once(self, two_roots): + stand_in = SimpleNamespace(services=EditorServices(Workspace.single_root(two_roots[0]))) + with patch.object(workspace_actions, "write_user_setting") as write: + add_folder_to_workspace(stand_in, str(two_roots[1])) + write.assert_called_once() + + def test_a_folder_already_in_the_workspace_is_reported(self, window, two_roots): + with patch(f"{ACTIONS}.QMessageBox.information") as told: + assert add_folder_to_workspace(window, str(two_roots[1])) is False + assert told.call_args.args[2] == "That folder is already in the workspace" + + def test_a_folder_that_does_not_exist_is_not_added(self, window, tmp_path): + assert add_folder_to_workspace(window, str(tmp_path / "missing")) is False + assert len(window.services.workspace.roots) == 2 + + def test_cancelling_the_folder_dialog_adds_nothing(self, window): + with patch(f"{ACTIONS}.QFileDialog.getExistingDirectory", return_value=""): + assert add_folder_to_workspace(window) is False + + def test_an_added_folder_can_be_removed(self, window, two_roots): + assert remove_folder_from_workspace(window, norm(two_roots[1])) is True + assert [root.name for root in window.services.workspace.roots] == ["frontend"] + assert user_setting_dict[EXTRA_ROOTS_SETTING] == [] + + def test_the_primary_root_cannot_be_removed_here(self, window, two_roots): + assert remove_folder_from_workspace(window, norm(two_roots[0])) is False + assert len(window.services.workspace.roots) == 2 + + def test_nothing_to_remove_is_reported(self, two_roots): + stand_in = SimpleNamespace(services=EditorServices(Workspace.single_root(two_roots[0]))) + with patch(f"{ACTIONS}.QMessageBox.information") as told: + assert remove_folder_from_workspace(stand_in) is False + assert told.call_args.args[2] == "The workspace has no added folders" + + def test_the_user_picks_the_folder_from_the_added_ones(self, window, two_roots): + with patch(f"{ACTIONS}.QInputDialog.getItem", + return_value=(norm(two_roots[1]), True)) as asked: + assert remove_folder_from_workspace(window) is True + assert asked.call_args.args[3] == [norm(two_roots[1])] + + def test_cancelling_the_choice_removes_nothing(self, window, two_roots): + with patch(f"{ACTIONS}.QInputDialog.getItem", return_value=(norm(two_roots[1]), False)): + assert remove_folder_from_workspace(window) is False + assert len(window.services.workspace.roots) == 2 + + +@pytest.mark.usefixtures("qapp") +class TestThePanelsSeeEveryRoot: + def test_quick_open_lists_both_roots_and_opens_the_right_file(self, qtbot, window, two_roots): + from je_editor.pyside_ui.main_ui.command_palette.quick_open_dialog import QuickOpenDialog + dialog = QuickOpenDialog(None, labelled_root_paths(window), [], main_window=window) + qtbot.waitUntil(lambda: dialog.result_list.count() > 0, timeout=TIMEOUT_MS) + entries = {entry.path: entry for entry in dialog._file_entries} + assert sorted(entries) == ["backend/main.py", "backend/src/backend_only.py", + "frontend/main.py", "frontend/src/frontend_only.py"] + entries["backend/main.py"].payload() + assert norm(window.go_to_new_tab.call_args.args[0]) == norm(two_roots[1] / "main.py") + dialog.close() + + def test_the_todo_panel_scans_both_roots_and_opens_the_right_file( + self, qtbot, window, two_roots): + from je_editor.pyside_ui.main_ui.todo_panel.todo_panel_widget import TodoPanelWidget + panel = TodoPanelWidget(main_window=window) + qtbot.addWidget(panel) + qtbot.waitUntil(lambda: panel.result_tree.topLevelItemCount() == 2, timeout=TIMEOUT_MS) + assert [item.path for item in panel.visible_items()] == [ + "frontend/main.py", "backend/main.py"] + panel._open_item(panel.result_tree.topLevelItem(1), 0) + assert norm(window.go_to_new_tab.call_args.args[0]) == norm(two_roots[1] / "main.py") + panel.close() + + def test_a_root_added_later_is_scanned_on_the_next_refresh(self, qtbot, two_roots): + from je_editor.pyside_ui.main_ui.todo_panel.todo_panel_widget import TodoPanelWidget + services = EditorServices(Workspace.single_root(two_roots[0])) + panel = TodoPanelWidget(main_window=SimpleNamespace(services=services)) + qtbot.addWidget(panel) + qtbot.waitUntil(lambda: not panel._scan_thread.isRunning(), timeout=TIMEOUT_MS) + QApplication.processEvents() + services.workspace.add_root(two_roots[1]) + panel.start_scan() + qtbot.waitUntil(lambda: panel.result_tree.topLevelItemCount() == 2, timeout=TIMEOUT_MS) + panel.close() + + def test_the_project_check_lints_every_root(self, qtbot, window, two_roots): + from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( + ProjectLintWorker + ) + + def lint(root: str) -> list[Diagnostic]: + return [Diagnostic(1, 1, 1, 2, "F401", f"in {Path(root).name}", + file_path=str(Path(root) / "main.py"))] + + worker = ProjectLintWorker(local_root_paths(window)) + with patch("je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", + side_effect=lint) as linted: + with qtbot.waitSignal(worker.linted, timeout=TIMEOUT_MS) as blocker: + worker.start() + worker.wait(TIMEOUT_MS) + assert [call.args[0] for call in linted.call_args_list] == [ + norm(two_roots[0]), norm(two_roots[1])] + assert [item.message for item in blocker.args[0]] == ["in frontend", "in backend"] + + def test_the_problems_panel_keeps_same_named_files_apart(self, qtbot, window, two_roots): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( + ProjectLintWorker + ) + panel = ProblemsPanelWidget(window) + qtbot.addWidget(panel) + + def lint(root: str) -> list[Diagnostic]: + return [Diagnostic(1, 1, 1, 2, "F401", "unused", file_path=str(Path(root) / "main.py"))] + + with patch("je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", + side_effect=lint): + panel.project_check.setChecked(True) + worker = panel._project_worker + assert isinstance(worker, ProjectLintWorker) and len(worker.roots) == 2 + worker.wait(TIMEOUT_MS) + QApplication.processEvents() + assert len(panel.diagnostics()) == 2 + panel.close() + + def test_a_single_root_worker_still_takes_a_plain_path(self, two_roots): + from je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker import ( + ProjectLintWorker + ) + worker = ProjectLintWorker(str(two_roots[0])) + assert (worker.root, worker.roots) == (str(two_roots[0]), [str(two_roots[0])]) + + +@pytest.mark.usefixtures("qapp") +class TestSearchingEveryRoot: + def _search(self, qtbot, roots, pattern: str) -> list[str]: + from je_editor.pyside_ui.dialog.search_ui.search_replace_widget import _SearchWorker + worker = _SearchWorker(roots, pattern, case_sensitive=True, use_regex=False) + found: list[str] = [] + worker.match_found.connect(lambda path, _line, _text: found.append(path)) + with qtbot.waitSignal(worker.finished_signal, timeout=TIMEOUT_MS): + worker.start() + worker.wait(TIMEOUT_MS) + QApplication.processEvents() + return sorted(Path(path).parent.name for path in found) + + def test_a_project_search_covers_both_roots(self, qtbot, window): + assert self._search(qtbot, local_root_paths(window), "TODO") == ["backend", "frontend"] + + def test_a_single_folder_search_still_takes_a_plain_path(self, qtbot, two_roots): + assert self._search(qtbot, str(two_roots[0]), "TODO") == ["frontend"] + + @pytest.fixture() + def locate(self, window): + from je_editor.pyside_ui.dialog.search_ui.search_replace_widget import SearchReplaceDialog + stand_in = SimpleNamespace(_project_roots=lambda: local_root_paths(window)) + return lambda path: SearchReplaceDialog._locate_in_project(stand_in, str(path)) + + def test_a_file_in_the_second_root_may_be_rewritten(self, locate, two_roots): + root, relative = locate(two_roots[1] / "src" / "backend_only.py") + assert (root, relative.as_posix()) == (two_roots[1].resolve(), "src/backend_only.py") + + def test_a_file_outside_every_root_is_refused(self, locate, tmp_path): + assert locate(tmp_path / "elsewhere" / "secret.py") is None + + def test_a_path_that_climbs_out_of_a_root_is_refused(self, locate, two_roots): + assert locate(two_roots[0] / ".." / "elsewhere" / "secret.py") is None + + +@pytest.fixture() +def editor_tab(qapp, window): + """A real editor tab whose window is the two-root stand-in.""" + window.tab_widget = QTabWidget() + window.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as venv: + venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(window) + window.tab_widget.addTab(widget, "Test") + yield widget + widget.close() + widget.deleteLater() + window.tab_widget.deleteLater() + + +class TestTheFileTree: + def _shown_root(self, tab) -> str: + return norm(tab.project_treeview_model.filePath(tab.project_treeview.rootIndex())) + + def test_the_root_list_offers_every_root(self, editor_tab, two_roots): + offered = [editor_tab.project_root_combobox.itemData(index) + for index in range(editor_tab.project_root_combobox.count())] + assert offered == [norm(two_roots[0]), norm(two_roots[1])] + assert not editor_tab.project_root_combobox.isHidden() + + def test_the_tree_starts_on_the_primary_root(self, editor_tab, two_roots): + assert self._shown_root(editor_tab) == norm(two_roots[0]) + + def test_picking_a_root_shows_it_in_the_tree(self, editor_tab, two_roots): + editor_tab.project_root_combobox.setCurrentIndex(1) + assert self._shown_root(editor_tab) == norm(two_roots[1]) + + def test_the_picked_root_survives_a_change_to_the_workspace( + self, editor_tab, window, two_roots, tmp_path): + editor_tab.project_root_combobox.setCurrentIndex(1) + (tmp_path / "docs").mkdir() + window.services.workspace.add_root(tmp_path / "docs") + editor_tab.refresh_project_roots() + assert editor_tab.project_root_combobox.count() == 3 + assert self._shown_root(editor_tab) == norm(two_roots[1]) + + def test_one_root_needs_no_list(self, editor_tab, window, two_roots): + window.services.workspace.remove_root(two_roots[1]) + editor_tab.refresh_project_roots() + assert editor_tab.project_root_combobox.isHidden() + assert self._shown_root(editor_tab) == norm(two_roots[0]) + + +class TestTheLanguageServerRoot: + def test_a_file_is_served_from_its_own_root(self, editor_tab, two_roots): + editor_tab.code_edit.current_file = str(two_roots[1] / "src" / "lib.rs") + assert editor_tab.code_edit._workspace_root() == norm(two_roots[1]) + + def test_a_file_outside_every_root_has_no_root(self, editor_tab, tmp_path): + editor_tab.code_edit.current_file = str(tmp_path / "elsewhere" / "lib.rs") + assert editor_tab.code_edit._workspace_root() is None + + @pytest.mark.parametrize("given, expected_parent", [(True, False), (False, True)]) + def test_the_session_is_started_at_the_given_root_or_the_files_folder( + self, qapp, two_roots, given, expected_parent): + from je_editor.pyside_ui.code.lsp import lsp_client + file_path = str(two_roots[1] / "src" / "lib.rs") + client = lsp_client.LspClient() + with patch.object(lsp_client, "server_command", return_value=["rust-analyzer"]), \ + patch.object(lsp_client.session_registry, "session_for", + return_value=MagicMock()) as session_for: + client.start_for(file_path, root=norm(two_roots[1]) if given else None) + started_at = session_for.call_args.args[1] + assert (norm(started_at) == norm(two_roots[1] / "src")) is expected_parent + client._session = None + client.deleteLater() From 45655aaa0b24c02cc054419e7773e3ad5f2d178e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 05:03:06 +0800 Subject: [PATCH 10/14] Colour Python, JavaScript and JSON from a Tree-sitter parse The editor's highlighters matched regular expressions line by line, so a string over several lines, an f-string expression or a function name was beyond them. These three languages are now parsed, and the widgets only ever see which columns of which line are what: the parse tree stays in je_editor/adapters/syntax, behind the SyntaxEngine and SyntaxSession interfaces in je_editor/core/syntax. The parse follows each edit. The smallest span that differs is handed to the old tree, only what it touches is parsed again, and the session reports the lines whose syntax came out different, which can reach far beyond the edited line. Lines after the edit are repainted in Qt's own pass; lines before it on the next turn of the event loop. The pattern-based highlighters stay as the fallback: for every other language, when Tree-sitter or a grammar is not installed, and when "syntax_engine" is set to "classic". Keywords a plugin registers for a suffix are laid over whichever highlighter is in use. Language services now answer questions through one call, request(LanguageRequest, on_reply), which returns a cancel function. The registry guarantees one reply at most and none after a cancel. The syntax engine is registered as a language service offering the syntax tree and the document's symbols. Four defects found on the way are fixed here: - Walking QApplication.allWidgets() to find menus could run a garbage collection half way, which deleted unreferenced widgets still in the list being wrapped and corrupted the heap. This change happened to move a collection onto that spot, and the suite crashed there three times out of three; the collector is now held off for the walk. - A replaced highlighter stayed attached to the document and kept colouring on every edit. - Opening a file or switching theme left the tab marked unsaved, because a highlighter repainting sends textChanged. The mark now follows the document's contentsChange. - Keywords a plugin registered for a suffix that has a keyword table, .json and .yaml among them, were never applied. tree-sitter 0.26.0 drops a reference on every read of Point.row and Point.column, which crashes the interpreter after enough reads; points are read by index instead, and a test watches for it. New dependencies, pinned: tree-sitter 0.26.0, tree-sitter-python 0.25.0, tree-sitter-javascript 0.25.0, tree-sitter-json 0.24.8. The query files are declared as package data. Closes PROGRESS #10 (roadmap M2). --- PROGRESS.md | 15 +- README.md | 6 +- README/README_zh-CN.md | 6 +- README/README_zh-TW.md | 6 +- architecture.md | 33 +- architecture_explore.md | 77 ++- dev.toml | 10 +- dev_requirements.txt | 4 + docs/roadmap/2026-editor-next.md | 6 +- docs/source/docs/Eng/configuration.rst | 7 +- docs/source/docs/Eng/core_services.rst | 125 +++- docs/source/docs/Eng/editor.rst | 35 +- docs/source/docs/Eng/getting_started.rst | 6 +- docs/source/docs/Zh/configuration.rst | 7 +- docs/source/docs/Zh/core_services.rst | 112 +++- docs/source/docs/Zh/editor.rst | 34 +- docs/source/docs/Zh/getting_started.rst | 6 +- docs/updates/2026-10.md | 31 + docs/updates/README.md | 3 +- je_editor/adapters/default_services.py | 9 +- je_editor/adapters/syntax/__init__.py | 0 je_editor/adapters/syntax/grammar_table.py | 130 +++++ .../syntax/queries/javascript/regions.scm | 30 + .../syntax/queries/json/highlights.scm | 8 + .../adapters/syntax/queries/json/regions.scm | 6 + .../syntax/queries/python/highlights.scm | 6 + .../syntax/queries/python/regions.scm | 21 + .../syntax/syntax_language_service.py | 142 +++++ .../adapters/syntax/tree_sitter_engine.py | 536 ++++++++++++++++++ je_editor/core/__init__.py | 11 + .../core/language/language_capability.py | 30 + je_editor/core/language/language_request.py | 152 +++++ je_editor/core/language/language_service.py | 77 ++- je_editor/core/services/editor_services.py | 4 + je_editor/core/syntax/__init__.py | 0 je_editor/core/syntax/syntax_model.py | 244 ++++++++ .../code_edit_plaintext.py | 35 +- .../pyside_ui/code/syntax/generic_syntax.py | 17 +- .../pyside_ui/code/syntax/highlight_rules.py | 137 +++++ .../code/syntax/highlighter_factory.py | 103 ++++ .../pyside_ui/code/syntax/python_syntax.py | 100 ++-- .../code/syntax/tree_sitter_highlighter.py | 216 +++++++ .../pyside_ui/main_ui/editor/editor_widget.py | 11 +- .../pyside_ui/main_ui/menu/submenu_map.py | 34 +- .../save_settings/user_setting_file.py | 2 + je_editor/utils/theme/theme_colors.py | 2 + pyproject.toml | 10 +- requirements.txt | 4 + test/test_command_palette.py | 60 +- test/test_core_language_services.py | 164 +++++- test/test_editor_widget.py | 16 + test/test_generic_syntax.py | 15 +- test/test_public_api_contract.py | 17 + test/test_syntax_engine.py | 527 +++++++++++++++++ test/test_tree_sitter_highlighter.py | 479 ++++++++++++++++ 55 files changed, 3681 insertions(+), 203 deletions(-) create mode 100644 je_editor/adapters/syntax/__init__.py create mode 100644 je_editor/adapters/syntax/grammar_table.py create mode 100644 je_editor/adapters/syntax/queries/javascript/regions.scm create mode 100644 je_editor/adapters/syntax/queries/json/highlights.scm create mode 100644 je_editor/adapters/syntax/queries/json/regions.scm create mode 100644 je_editor/adapters/syntax/queries/python/highlights.scm create mode 100644 je_editor/adapters/syntax/queries/python/regions.scm create mode 100644 je_editor/adapters/syntax/syntax_language_service.py create mode 100644 je_editor/adapters/syntax/tree_sitter_engine.py create mode 100644 je_editor/core/language/language_capability.py create mode 100644 je_editor/core/language/language_request.py create mode 100644 je_editor/core/syntax/__init__.py create mode 100644 je_editor/core/syntax/syntax_model.py create mode 100644 je_editor/pyside_ui/code/syntax/highlight_rules.py create mode 100644 je_editor/pyside_ui/code/syntax/highlighter_factory.py create mode 100644 je_editor/pyside_ui/code/syntax/tree_sitter_highlighter.py create mode 100644 test/test_syntax_engine.py create mode 100644 test/test_tree_sitter_highlighter.py diff --git a/PROGRESS.md b/PROGRESS.md index f70766a..5e97b82 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -19,16 +19,23 @@ ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) -M0(`je_editor/core/` 服務層)、M2 的診斷那一半、M3(工作區與多根專案)、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 +M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引擎)、M3(工作區與多根專案)、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 以下依相依關係排序。 - **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 變動的 ID。語系那一項大部分已經有了:四份字典的鍵與佔位符的 parity、空白值、退回英文都 由 `test/test_languages.py` 在 CI 守著;還沒做的是「語系載入改成資料驅動」。〔決定〕UI 的版面方向 (活動列、編輯區、側邊面板、底部面板)要先定,才能動視窗層。 -- **#10** M2 剩下 Tree-sitter 那一半(診斷那一半已完成,見 U-20261008-04):不依賴 Qt 的解析服務、 - 以查詢檔決定語法分類與結構區塊、既有的高亮器改成轉接器;`LanguageService` 的「發問、等回覆」 - 呼叫形式也在這裡定。 +- **#23** M2 沒有涵蓋的部分(語法引擎與高亮已完成,見 U-20261008-08):語法樹目前只用來上色。大綱 + (`utils/symbols`)、折疊(`utils/code_folding`)與智慧選取(`utils/selection`)仍然用各自的分析, + 還沒有改用 `SyntaxSession.regions()`;編輯器是直接向引擎要 session,沒有經過 `DocumentStore`, + 所以 `SyntaxLanguageService` 只有宿主程式自己開文件時才用得到;`LspClient` 也還沒有包成 + `LanguageService`。支援的語言只有 Python、JavaScript、JSON,其餘仍用關鍵字表。 +- **#24** 升級 `tree-sitter` 之前要重新確認:0.26.0 的 `Point.row` / `Point.column` 每讀一次就少算 + 那個整數一次參考(Python 3.11 上讀幾萬次後行程當掉),引擎因此一律以索引讀取位置, + `test_syntax_engine.py::TestTheBindingIsUsedSafely` 守著。這個問題還沒有回報給上游(這台機器沒有 + `gh`)。另外 `tree-sitter-json` 0.24.8 自帶的高亮查詢是照「先寫的規則優先」排的,跟 Python、 + JavaScript 的文法相反,所以 `queries/json/highlights.scm` 重新指定了鍵;文法升級後可能不再需要。 - **#22** M3 沒有涵蓋的部分(工作區本身已完成,見 U-20261008-07):執行程式、測試面板、終端機、Git 工具列與 Python 直譯器(venv)仍然只認主要的根目錄,也就是工作目錄。藍圖要的「每個根目錄有自己的 語言 / 工具設定與環境」還沒做;Git 面板也還沒有依根目錄切換。 diff --git a/README.md b/README.md index 4a43b18..43189ca 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Core dependencies are installed automatically: | gitpython | Git repository operations | | langchain_openai + langchain_core | OpenAI-compatible AI provider | | anthropic | Anthropic AI provider | +| tree-sitter + tree-sitter-python / -javascript / -json | Syntax parsing for highlighting | | watchdog | File system monitoring | | pycodestyle | PEP8 style checking | | qtconsole | Jupyter/IPython console widget | @@ -348,7 +349,7 @@ yet. See the *Core Services* page of the [documentation](https://je-editor.readt ### Code Editing - **Multi-tab editor** -- Work on multiple files simultaneously with closable tabs. -- **Syntax highlighting** -- Built-in Python highlighting with extensible plugin support for additional languages. +- **Syntax highlighting** -- Python, JavaScript and JSON are coloured from a real parse (Tree-sitter) that follows each edit, so function and type names, f-string expressions and multi-line strings come out right. Other languages use keyword tables, and plugins add more. - **Auto-completion** -- Context-aware code suggestions powered by Jedi. - **Line numbers** -- Displayed alongside the editor with current line highlighting. - **Search & Replace** -- Search within the current file, across folders, or project-wide with regex and case-sensitive options. Runs in background threads for large projects. @@ -571,7 +572,8 @@ je_editor/ │ └── main_ui/ Main window, menus, toolbar, panels, settings, AI, console ├── core/ Service layer, no Qt: workspace, documents, diagnostics, and the │ interfaces for language services, debugging, tasks, remote and AI -├── adapters/ Implementations of those interfaces, no Qt: the AI providers +├── adapters/ Implementations of those interfaces, no Qt: the AI providers and +│ the Tree-sitter syntax engine ├── code_scan/ Ruff execution and watchdog file monitoring ├── git_client/ Git operations (GitPython + git CLI) ├── plugins/ Plugin registry and loader diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 5b1359f..1852f02 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -264,6 +264,7 @@ pip install . | gitpython | Git 仓库操作 | | langchain_openai + langchain_core | OpenAI 兼容的 AI 提供者 | | anthropic | Anthropic 的 AI 提供者 | +| tree-sitter + tree-sitter-python / -javascript / -json | 语法高亮用的语法解析 | | watchdog | 文件系统监控 | | pycodestyle | PEP8 风格检查 | | qtconsole | Jupyter/IPython 控制台组件 | @@ -311,7 +312,7 @@ services.shutdown() ### 代码编辑 - **多标签页编辑器** -- 同时处理多个文件,支持关闭标签页。 -- **语法高亮** -- 内置 Python 语法高亮,可通过插件扩展支持更多语言。 +- **语法高亮** -- Python、JavaScript 与 JSON 以真正的语法解析(Tree-sitter)上色,并跟着每一次编辑更新,所以函数与类型名称、f-string 里的表达式、跨行字符串都分得对。其他语言使用关键字表,插件可以再加。 - **自动补全** -- 由 Jedi 驱动的上下文感知代码建议。 - **行号显示** -- 编辑器旁显示行号,并高亮当前行。 - **搜索与替换** -- 支持在当前文件、文件夹或整个项目中搜索,提供正则表达式与区分大小写选项。大型项目使用后台线程处理。 @@ -526,7 +527,8 @@ je_editor/ │ └── main_ui/ 主窗口、菜单、工具栏、面板、设置、AI、控制台 ├── core/ 服务层,不依赖 Qt:工作区、文档、诊断,以及语言服务、 │ 调试、任务执行、远程与 AI 的接口 -├── adapters/ 上述接口的实现,不依赖 Qt:AI 提供者 +├── adapters/ 上述接口的实现,不依赖 Qt:AI 提供者与 +│ Tree-sitter 语法引擎 ├── code_scan/ Ruff 执行与 watchdog 文件监控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 插件注册表与加载器 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 49aa252..c0c5604 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -264,6 +264,7 @@ pip install . | gitpython | Git 倉庫操作 | | langchain_openai + langchain_core | OpenAI 相容的 AI 供應者 | | anthropic | Anthropic 的 AI 供應者 | +| tree-sitter + tree-sitter-python / -javascript / -json | 語法高亮用的語法解析 | | watchdog | 檔案系統監控 | | pycodestyle | PEP8 風格檢查 | | qtconsole | Jupyter/IPython 主控台元件 | @@ -311,7 +312,7 @@ services.shutdown() ### 程式碼編輯 - **多分頁編輯器** -- 同時處理多個檔案,支援關閉分頁。 -- **語法高亮** -- 內建 Python 語法高亮,可透過外掛擴展支援更多語言。 +- **語法高亮** -- Python、JavaScript 與 JSON 以真正的語法解析(Tree-sitter)上色,並跟著每一次編輯更新,所以函式與型別名稱、f-string 裡的運算式、跨行字串都分得對。其他語言使用關鍵字表,外掛可以再加。 - **自動補全** -- 由 Jedi 驅動的上下文感知程式碼建議。 - **行號顯示** -- 編輯器旁顯示行號,並高亮目前行。 - **搜尋與取代** -- 支援在目前檔案、資料夾或整個專案中搜尋,提供正則表達式與區分大小寫選項。大型專案使用背景執行緒處理。 @@ -526,7 +527,8 @@ je_editor/ │ └── main_ui/ 主視窗、選單、工具列、面板、設定、AI、主控台 ├── core/ 服務層,不依賴 Qt:工作區、文件、診斷,以及語言服務、 │ 除錯、工作執行、遠端與 AI 的介面 -├── adapters/ 上述介面的實作,不依賴 Qt:AI 供應者 +├── adapters/ 上述介面的實作,不依賴 Qt:AI 供應者與 +│ Tree-sitter 語法引擎 ├── code_scan/ Ruff 執行與 watchdog 檔案監控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 外掛註冊表與載入器 diff --git a/architecture.md b/architecture.md index 591bf0b..a465730 100644 --- a/architecture.md +++ b/architecture.md @@ -22,7 +22,7 @@ window, and plugins extend it through a small registry API. | `je_editor/pyside_ui/code/` | `CodeEditor` (`plaintext_code_edit/`) plus its managers (folding, bookmarks, lint, LSP, diff/blame, snippets, multi-cursor), highlighters (`syntax/`), process runners (`code_process/`, `shell_process/`, `base_process_manager.py`) | | `je_editor/pyside_ui/dialog/`, `git_ui/`, `browser/` | Search/replace, shortcut, snippet and file dialogs; Git panel, commit graph, diff viewers; embedded QtWebEngine browser | | `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window consumes the diagnostics part so far: the editor's `LintManager` and the Problems panel hold their findings in the unified model (roadmap `docs/roadmap/2026-editor-next.md`) | -| `je_editor/adapters/` | Implementations of the `core/` interfaces, also Qt-free; third-party SDKs are imported at the point of use. `ai/`: `OpenAIProvider` (LangChain `ChatOpenAI`), `AnthropicProvider` (official `anthropic` SDK, streamed), the built-in registration and the `.jeditor/ai_config.json` reader/writer (which never logs the content). `default_services.py` builds an `EditorServices` with these registered | +| `je_editor/adapters/` | Implementations of the `core/` interfaces, also Qt-free; third-party SDKs are imported at the point of use. `ai/`: `OpenAIProvider` (LangChain `ChatOpenAI`), `AnthropicProvider` (official `anthropic` SDK, streamed), the built-in registration and the `.jeditor/ai_config.json` reader/writer (which never logs the content). `default_services.py` builds an `EditorServices` with these registered `syntax/`: the Tree-sitter `SyntaxEngine` (`tree_sitter_engine.py`), its grammar table and query files (`grammar_table.py`, `queries//*.scm`), and `SyntaxLanguageService` | | `je_editor/utils/` | Pure logic with no widgets (only `multi_language/locale_match.py` imports Qt): text operations, encodings, sessions, diffs, symbols, LSP protocol, shortcut registry, theme colors, translations (`multi_language/`), logging, stdout/stderr redirect | | `je_editor/code_scan/` | ruff runner and watchdog file monitor, run on worker threads | | `je_editor/git_client/` | Git access: `GitService` (GitPython) and `GitCLI` (subprocess), blame, HEAD baseline, hunk staging | @@ -103,6 +103,20 @@ Run menu → run_program() (menu/run_menu/under_run_menu/build_program_menu.py) → BaseProcessManager reader threads → queues → QTimer pull_text() → CodeRecord output pane ``` +**Syntax highlighting** + +``` +CodeEditor.reset_highlighter() → dispose_highlighter(old) → build_highlighter(document, file, engine) + (pyside_ui/code/syntax/highlighter_factory.py; engine = EditorMain.services.syntax) + → engine.language_for(file) → engine.open_session(language) → TreeSitterHighlighter + | no session (unknown language, grammar missing, "syntax_engine": "classic") + → GenericHighlighter (keyword table) | PythonHighlighter +edit → document.contentsChange → TreeSitterHighlighter._analyse_again() + → session.update(text) [smallest changed span → tree.edit → reparse → changed lines] + → Qt repaints the edited lines → highlightBlock() → session.spans(line) → theme colours + → lines after the edit: block state toggled so Qt carries on; lines before it: next event-loop turn +``` + **Plugin install and load** ``` @@ -126,7 +140,12 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre - **Host-app mode**: `EditorMain(extend=True)`. In this mode `pyside_ui/main_ui/menu/set_menu_bar.py` skips the built-in Plugins menu (`menu/plugin_menu/build_plugin_menu.py`). - **Python highlighting rules**: `pyside_ui/code/syntax/syntax_setting.py` - (`syntax_rule_setting_dict`, `syntax_extend_setting_dict`). + (`syntax_rule_setting_dict`, `syntax_extend_setting_dict`). They drive the pattern-based + highlighters; keywords registered for a suffix are also laid over the Tree-sitter highlighter. +- **Parsed languages**: one `GrammarSpec` row in `adapters/syntax/grammar_table.py` (language ID, + suffixes, a function importing the grammar package) plus `queries//highlights.scm` and + `regions.scm`. The grammar package becomes a pinned dependency in `pyproject.toml`, `dev.toml` + and both requirements files. - **Language servers**: `je_editor/utils/lsp/language_servers.py` maps a file suffix to a server command and merges in user settings. - **Shortcuts / colors / UI strings**: single sources in `utils/shortcuts/shortcut_registry.py`, @@ -135,7 +154,10 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre its `NamedRegistry` attributes — `ai_providers`, `debug_adapters` (session factories), `task_runners`, `remote_transports` (by URI scheme) — and language services through `languages.register()`. Any source reports findings with `diagnostics.publish(source, uri, ...)`. - Implementations live in `adapters/`: the AI providers `openai` and `anthropic` so far. A plugin + Implementations live in `adapters/`: the AI providers `openai` and `anthropic`, and the + Tree-sitter syntax engine, which `build_default_services()` sets as `services.syntax` and + registers as the `syntax` language service. Questions to language services go through + `languages.request(LanguageRequest, on_reply)`, which returns a cancel function. A plugin adds another AI provider with `window.services.ai_providers.register(name, provider)`, and the chat panel lists it with no change to the panel. @@ -152,7 +174,10 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre before you move or rename a module. It merges its UI strings by mutating the exported `english_word_dict` and `traditional_chinese_word_dict` in place. Treat these names, `EditorMain`'s constructor and the attributes PyBreeze uses (`tab_widget`, `menu`, `help_menu`) - as a contract. `EditorMain` also sets `services`; PyBreeze does not use that name today. `test/test_public_api_contract.py` pins the exported names, those module paths + as a contract. PyBreeze calls `CodeEditor.reset_highlighter()` after changing a tab's file and + after `register_programming_language()`; the keywords it registers for `.json` and YAML suffixes + are laid over whichever highlighter colours those files. `EditorMain` also sets `services`; + PyBreeze does not use that name today. `test/test_public_api_contract.py` pins the exported names, those module paths and the constructor's arguments; it cannot see behaviour or attributes, and its list is a copy that has to be updated when PyBreeze starts importing something new. - **Translations**: a JEditor translation change must keep PyBreeze's diff --git a/architecture_explore.md b/architecture_explore.md index 455e693..5cd4962 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 315 個 `.py`(192 個實作模組 + 123 個 `__init__.py`),共 34,989 行。 +> 涵蓋範圍:`je_editor/` 全部 326 個 `.py`(201 個實作模組 + 125 個 `__init__.py`),共 36,760 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -15,24 +15,24 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | --- | --- | | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | -| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`frontengine` | -| 測試 | pytest + pytest-qt,110 個測試檔、約 18,100 行 | +| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`tree-sitter` 與三個文法套件(`tree-sitter-python` / `-javascript` / `-json`)、`frontengine` | +| 測試 | pytest + pytest-qt,112 個測試檔、約 19,400 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 98 | 21,181 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 60 | 9,009 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `adapters/` | 5 | 542 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | -| `core/` | 16 | 2,643 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | +| `pyside_ui/` | 101 | 21,667 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 60 | 9,011 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `adapters/` | 8 | 1,355 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | +| `core/` | 19 | 3,113 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 34,989 行。) +(行數含各層 `__init__.py`,合計 36,760 行。) --- @@ -81,7 +81,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 110 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 112 個測試檔的原因。 --- @@ -127,11 +127,12 @@ start_editor(debug_mode) je_editor/start_editor.py | `session_registry` | `pyside_ui/code/lsp/lsp_session.py` | 語言伺服器程序池,同語言的分頁共用一個程序 | | `auto_save_manager_dict` / `file_is_open_manager_dict` | `pyside_ui/code/auto_save/auto_save_manager.py` | 自動儲存執行緒表、避免同檔重複開分頁 | | `syntax_rule_setting_dict` 等三個 | `pyside_ui/code/syntax/syntax_setting.py` | Python 高亮的規則、關鍵字與插件擴充位 | +| `shared_syntax_engine()` | `adapters/syntax/tree_sitter_engine.py` | 整個行程共用的語法引擎(第一次呼叫時才建立)。裡面只有載入好的文法與編譯好的查詢,都是不會變的資料;每份文件自己的語法樹在 session 裡,不在這裡 | | `EDITOR_EXTEND_TAB` | `main_ui/main_editor.py` | 給下游專案(PyBreeze)塞自訂分頁的掛載點 | | `_plugin_metadata_list` 等 | `plugins/__init__.py` | 已註冊的語言 / 翻譯 / 執行設定 / 中繼資料 | `core/` 刻意沒有模組層級的單例:`EditorServices` 由建立它的人持有,所以同一個行程嵌入兩個編輯器時不會共用 -工作區與診斷。 +工作區與診斷。`adapters/` 唯一的例外是上表的語法引擎,共用的只有唯讀的文法。 --- @@ -147,7 +148,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(60 模組 / 9,009 行) +### 5.2 `utils/` — 純邏輯層(60 模組 / 9,011 行) #### 文字與行操作 @@ -220,7 +221,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `minimap/minimap_layout.py` | 112 | 縮圖座標換算:取樣間隔、行↔像素、長條寬度、可視範圍方框 | | `shortcuts/shortcut_registry.py` | 329 | 快捷鍵正規化、`ShortcutRegistry` 衝突偵測、預設表 `WINDOW_SHORTCUTS` / `EDITOR_SHORTCUTS`、使用者覆寫清理 | | `status/status_text.py` | 71 | 狀態列文字:語言名稱、編碼、行尾、游標位置 | -| `theme/theme_colors.py` | 135 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | +| `theme/theme_colors.py` | 137 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | #### 多語系 @@ -279,7 +280,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `plaintext_code_edit/code_edit_plaintext.py` | **3,266** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | +| `plaintext_code_edit/code_edit_plaintext.py` | **3,275** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | | `multi_cursor/multi_cursor_manager.py` | 530 | 額外游標的維護與批次套用(插入 / 刪除 / 移動 / 擴選 / 欄選取 / 下一個相同字) | | `snippets/snippet_manager.py` | 280 | 片段展開、定位點跳轉、複本同步;使用者片段存於 `.jeditor/snippets.json` | | `lsp/lsp_client.py` | 457 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | @@ -297,8 +298,11 @@ start_editor(debug_mode) je_editor/start_editor.py | `selection/smart_selection_manager.py` | 87 | 智慧選取的擴大 / 縮回堆疊 | | `minimap/minimap_widget.py` | 185 | 右側縮圖:長條繪製、搜尋命中標記、可視範圍方框、點擊捲動 | | `split_view/split_editor_view.py` | 55 | 同一份 `QTextDocument` 的第二個檢視 | -| `syntax/python_syntax.py` | 107 | `PythonHighlighter`:Python 專用高亮(含插件規則) | -| `syntax/generic_syntax.py` | 134 | `GenericHighlighter`:依 `language_rules` 的通用高亮,處理跨行區塊註解 | +| `syntax/highlighter_factory.py` | 103 | `build_highlighter()`:依檔名與 `syntax_engine` 設定挑高亮器(語法引擎會的語言 → `TreeSitterHighlighter`,有關鍵字表的 → `GenericHighlighter`,其餘 → `PythonHighlighter`);`dispose_highlighter()` 把換下來的高亮器從文件上拿掉並刪除;`syntax_engine_for()` 從視窗取得引擎 | +| `syntax/tree_sitter_highlighter.py` | 217 | `TreeSitterHighlighter`:把 `SyntaxSession` 回報的分類依主題畫到文件上,不知道語法樹是什麼。自己的 slot 排在 Qt 重畫編輯行之前,先更新語法分析;編輯位置之後受影響的行以切換區塊狀態讓 Qt 在同一輪接著畫,之前的行在事件迴圈下一輪補畫 | +| `syntax/highlight_rules.py` | 137 | 三個高亮器共用的正規表示式規則:`regex_rules()` / `word_rules()` / `plugin_rules()`(插件為副檔名登記的關鍵字,疊在任何一種高亮器之上)、`apply_rules()`,以及 `release_document()`(拿掉高亮器時擋住文件的訊號,不讓分頁被當成已編輯) | +| `syntax/python_syntax.py` | 75 | `PythonHighlighter`:以正規表示式逐行比對的 Python 高亮;語法引擎不能用、使用者選了 `classic`,或副檔名沒有任何規則時使用 | +| `syntax/generic_syntax.py` | 145 | `GenericHighlighter`:依 `language_rules` 的通用高亮,處理跨行區塊註解;插件的關鍵字排在註解之前套用 | | `syntax/syntax_setting.py` | 99 | 高亮規則 / 關鍵字 / 插件擴充三個字典 | | `code_format/pep8_format.py` | 124 | `PEP8FormatChecker`:pycodestyle Checker 子類,把檢查結果導到格式檢查面板 | | `textedit_code_result/code_record.py` | 89 | `CodeRecord(QTextEdit)`:輸出區,支援搜尋 | @@ -313,7 +317,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `main_editor.py` | 652 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | -| `editor/editor_widget.py` | 604 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | +| `editor/editor_widget.py` | 613 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | | `editor/editor_widget_dock.py` | 85 | `FullEditorWidget`:可停駐的單檔編輯器;關閉時只在有修改時,以檔案原本的編碼與行尾存回 | | `editor/process_input.py` | 104 | 對子程序(program / shell / debugger)送入標準輸入的視窗 | | `dock/destroy_dock.py` | 52 | `DestroyDock`:關閉時會真的銷毀內容的 `QDockWidget` | @@ -344,7 +348,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `python_env_menu/build_venv_menu.py` | 243 | 建立 venv、pip 安裝 / 升級、選擇直譯器 | | `plugin_menu/build_plugin_menu.py` | 162 | 依已註冊插件建立「關於 / 執行」子選單,並開啟插件瀏覽器 | | `help_menu/build_help_menu.py` | 100 | 說明連結(開內嵌瀏覽器分頁)與關於 | -| `submenu_map.py` | 42 | 建立「動作 → 子選單」對照表(避免用 `QAction.menu()`) | +| `submenu_map.py` | 72 | 建立「動作 → 子選單」對照表(避免用 `QAction.menu()`)。`menus_in()` 走訪應用程式的所有元件時會先停住循環垃圾回收:PySide 把 `allWidgets()` 的指標一個一個包成物件,中途若發生回收,會刪掉清單裡那些沒人參考的元件,接著包到它們時行程就壞掉 | #### 面板與對話框 @@ -371,7 +375,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `user_setting_file.py` | 68 | `user_setting_dict` 的定義與 `.jeditor/user_setting.json` 讀寫 | +| `user_setting_file.py` | 70 | `user_setting_dict` 的定義與 `.jeditor/user_setting.json` 讀寫 | | `user_color_setting_file.py` | 96 | 顏色設定讀寫、RGB → `QColor` 換算、依樣式套用深 / 淺色組 | | `setting_utils.py` | 40 | 寫入前先備份(`.bak`)的 JSON 寫檔工具 | @@ -420,16 +424,16 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | -### 5.11 `core/` — 核心服務層(16 模組 / 2,643 行) +### 5.11 `core/` — 核心服務層(19 模組 / 3,113 行) 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 -逐個里程碑改接過來。第一批使用者是診斷:編輯器的 `LintManager` 與問題面板都以統一模型保存診斷。整層不匯入 Qt 也不匯入 `pyside_ui/`; +逐個里程碑改接過來。目前診斷(`LintManager` 與問題面板)、AI 對話面板、工作區與語法高亮已經在用。整層不匯入 Qt 也不匯入 `pyside_ui/`; 介面一律用 `typing.Protocol`,之後由 `QObject` 持有資源的轉接器才不會遇到中繼類別衝突。 | 模組 | 行 | 功用 | | --- | ---: | --- | | `__init__.py` | 58 | 核心層的公開 API(`__all__`) | -| `services/editor_services.py` | 81 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | +| `services/editor_services.py` | 85 | `EditorServices`:把下列服務組在一起,`shutdown()` 依序關閉語言服務與工作執行器、清掉診斷、關閉文件;沒有模組層級的實例 | | `events/event_hook.py` | 99 | `EventHook`:不靠 Qt 的訂閱與通知;在發出通知的執行緒上呼叫訂閱者,一個訂閱者出錯只記錄、不擋其他人 | | `registry/named_registry.py` | 116 | `NamedRegistry[T]`:名稱對應實作的登記表,AI 供應者、除錯轉接器、遠端傳輸、工作執行器共用 | | `uri/resource_uri.py` | 91 | 資源 URI:`to_uri` / `to_path`(沿用 `utils/lsp/lsp_protocol` 的轉換)、`uri_scheme`、`uri_key`(同一個本機檔案的不同寫法得到同一個鍵) | @@ -438,7 +442,10 @@ start_editor(debug_mode) je_editor/start_editor.py | `diagnostics/diagnostic_model.py` | 325 | 統一的診斷模型:`Severity`(數值同 LSP)、`Position` / `TextRange`(1 起算)、`RelatedInformation`、`TextEdit` / `QuickFix`、`Diagnostic`;`DiagnosticStore` 依「來源 × 資源」整組取代,`select()` 依嚴重度 / 來源 / 資源篩選且順序固定 | | `diagnostics/legacy_diagnostics.py` | 103 | 統一模型與 `utils/lint/ruff_diagnostics.Diagnostic`(ruff 解析器的輸出)之間的雙向轉換;`unify()` 把混著兩種形式的清單整理成統一模型,是視窗層接收診斷的入口 | | `diagnostics/lsp_diagnostics.py` | 82 | 把 `lsp_protocol.diagnostic_entries` 的字典轉成統一模型,保留伺服器給的嚴重度(含 Hint)與來源;伺服器沒給嚴重度時當成錯誤 | -| `language/language_service.py` | 204 | `LanguageCapability`、`LanguageService` 協定,以及 `LanguageServiceRegistry`:把 `DocumentStore` 的開啟 / 變更 / 關閉轉給處理該文件的服務,晚登記的服務會補收已開文件的「開啟」 | +| `language/language_capability.py` | 30 | `LanguageCapability`:語言服務可以提供的功能;獨立一個模組,發問的形式與服務的介面才能都引用它 | +| `language/language_request.py` | 152 | 向語言服務發問的形式:`LanguageRequest`、`LanguageReply`,以及保證「回覆最多一次、取消之後不再送達」的 `ReplyOnce`(以鎖保護,服務可以從自己的執行緒回覆) | +| `language/language_service.py` | 233 | `LanguageService` 協定(文件生命週期加上 `request()`)與 `LanguageServiceRegistry`:把 `DocumentStore` 的開啟 / 變更 / 關閉轉給處理該文件的服務,晚登記的服務會補收已開文件的「開啟」;`request()` 把問題交給第一個處理那份文件又提供那個功能的服務 | +| `syntax/syntax_model.py` | 244 | 語法分析的模型與介面:`SyntaxCategory`、`SyntaxSpan`(一行裡的一段,欄號 1 起算、以 UTF-16 單位計)、`LineSpan`、`RegionKind` / `StructuralRegion`,`SyntaxSession` 與 `SyntaxEngine` 兩個協定,以及什麼語言都不會的 `NoSyntaxEngine`(`EditorServices.syntax` 的預設值) | | `debug/debug_session.py` | 205 | `DebugSession` 協定與資料物件(`DebugLaunchRequest`、`Breakpoint`、`StackFrame`、`Variable`、`DebugState`、`StepKind`),名稱對應 DAP 的概念 | | `process/task_service.py` | 169 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream` | | `remote/remote_session.py` | 91 | `RemoteSession` 協定與 `RemoteState`;`task_runner()` 回傳與本機相同的 `TaskRunner` 介面 | @@ -447,9 +454,10 @@ start_editor(debug_mode) je_editor/start_editor.py | `ai/chat_session.py` | 94 | `ChatSession`:保管一段對話、組出下一個請求;失敗或被取消的那一句不留在對話裡 | 除錯、工作執行、遠端三項目前只有介面;既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 -`adapters/ai/`,對話面板已經改走 `AIProvider`。 +`adapters/ai/`,對話面板已經改走 `AIProvider`;語法分析的實作在 `adapters/syntax/`,編輯器的高亮已經改走 +`SyntaxEngine`。 -### 5.12 `adapters/` — 核心介面的實作(5 模組 / 542 行) +### 5.12 `adapters/` — 核心介面的實作(8 模組 / 1,355 行) `core/` 只有介面;真正去連某一家服務的程式碼放在這裡。跟 `core/` 一樣不匯入 Qt 與 `pyside_ui/` (`test_core_architecture.py` 把它列進 UI 層以下的套件),第三方 SDK 都在用到的時候才匯入。 @@ -457,11 +465,15 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `__init__.py` | 58 | 套件說明 | -| `default_services.py` | 52 | `build_default_services()`:建立 `EditorServices`、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | +| `default_services.py` | 57 | `build_default_services()`:建立 `EditorServices`、接上共用的語法引擎並登記 `SyntaxLanguageService`、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | | `ai/openai_provider.py` | 147 | `OpenAIProvider`:透過 LangChain 的 `ChatOpenAI` 呼叫 OpenAI 相容端點;回覆整份回來後去掉 `` 之前的思考過程 | | `ai/anthropic_provider.py` | 201 | `AnthropicProvider`:官方 `anthropic` SDK 的串流請求;可中途取消、回報 token 用量、把 SDK 的錯誤類別轉成給使用者看的說明;會拒絕請求的模型啟用伺服器端 fallback | | `ai/builtin_providers.py` | 50 | `register_builtin_ai_providers()`:每個供應者拿到「取得自己那組設定」的函式,所以改設定不必重新登記 | | `ai/settings_file.py` | 80 | `.jeditor/ai_config.json` 的讀寫;日誌只記路徑、從不記內容(裡面有 API 金鑰) | +| `syntax/grammar_table.py` | 130 | 內建文法的表(`GrammarSpec`:語言 ID、副檔名、匯入文法套件的函式)、查詢名稱到 `SyntaxCategory` 的對照(`function.builtin` 找不到時退回 `function`),以及讀專案自己查詢檔的 `own_query()`。多支援一種語言就是加一列與一組查詢檔 | +| `syntax/tree_sitter_engine.py` | 536 | `TreeSitterEngine` 與 `TreeSitterSession`,唯一知道 Tree-sitter 的地方。更新時找出新舊文字不同的最小一段(對齊到字元邊界)告訴舊的樹,只重新解析受影響的部分,並回報語法變了的行;分類以 64 行為一塊、用到才算;同一個節點被多條規則抓到時取查詢裡寫在後面的那一條;位元組欄換算成 UTF-16 欄;超過 2 MB 不解析。文法或查詢載不起來時那個語言變成不支援,不丟例外。讀 Tree-sitter 的位置一律用索引(原因寫在模組裡:0.26.0 的 `.row` / `.column` 會弄壞參考計數) | +| `syntax/syntax_language_service.py` | 142 | `SyntaxLanguageService`:把語法引擎接成語言服務,文件一開就有語法樹、一變就更新;回答 `SYNTAX_TREE`(那份文件的 session)與 `DOCUMENT_SYMBOLS`(有名稱的結構區塊) | +| `syntax/queries/<語言>/*.scm` | — | Tree-sitter 查詢檔:`highlights.scm` 接在文法自帶的高亮查詢之後,`regions.scm` 指出結構區塊(`@region.class` / `function` / `block` / `collection`)。不是 `.py`,靠 `pyproject.toml` 與 `dev.toml` 的 `package-data` 才會進 wheel | --- @@ -491,7 +503,7 @@ UI 執行緒不做 I/O 是硬性規則,重活分成三類: | 檔案 | 內容 | | --- | --- | -| `user_setting.json` | 字型、語言、樣式、編碼、縮排、最近檔案、開啟分頁與其游標 / 書籤 / 折疊狀態、快捷鍵覆寫、另外加入工作區的資料夾(`workspace_roots`) | +| `user_setting.json` | 字型、語言、樣式、編碼、縮排、最近檔案、開啟分頁與其游標 / 書籤 / 折疊狀態、快捷鍵覆寫、另外加入工作區的資料夾(`workspace_roots`)、語法高亮用哪個引擎(`syntax_engine`) | | `user_color_setting.json` | 編輯器自訂顏色 | | `snippets.json` | 使用者程式碼片段 | | `*.bak` | 每次寫入前的備份(`setting_utils.write_setting`) | @@ -522,10 +534,13 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 換算結果放在 `actually_color_dict`。 `update_actually_color_dict` 的鍵與備用值直接取自 `DARK_COLORS`,所以調色盤加新顏色不必動它。 -兩個高亮器都只認顏色鍵:`syntax_setting.py` 的內建規則存的是鍵名而非寫死的 `QColor`(插件仍可直接給 `QColor`), -`generic_syntax.py` 亦然。高亮器在建立時就把顏色取走,因此 `build_style_menu._repaint_editors` 換主題時會呼叫 +三個高亮器都只認顏色鍵:`syntax_setting.py` 的內建規則存的是鍵名而非寫死的 `QColor`(插件仍可直接給 `QColor`), +`generic_syntax.py` 亦然,`tree_sitter_highlighter.py` 則以 `CATEGORY_COLOURS` 把語法分類對到顏色鍵(函式名稱用新增的 `syntax_function_color`)。 +高亮器在建立時就把顏色取走,因此 `build_style_menu._repaint_editors` 換主題時會呼叫 `reset_highlighter()` 重建,否則語法顏色會停在上一個主題。 +Qt 的高亮器重畫時會送出 `textChanged`(不是 `contentsChange`),拿掉高亮器時則會送出 `contentsChange`。所以分頁的未儲存標記聽的是文件的 `contentsChange`,而換高亮器時由 `release_document()` 擋住文件的訊號;兩者缺一,開檔或換主題之後分頁就會被標成已修改。 + ### 6.6 插件系統 四種註冊面向,全部經由 `je_editor` 的公開 API: @@ -547,7 +562,7 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff ## 7. 測試與 CI -- `test/` 110 個測試檔、約 18,100 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 112 個測試檔、約 19,400 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -600,8 +615,8 @@ qt-material 負責視窗樣式;編輯器自身的顏色(語法高亮、diff 讓「純邏輯層」的界線稍微模糊。 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 -7. **`core/` 接上了診斷、AI 與工作區**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、除錯、工作執行與遠端還沒有接上,視窗層仍然 - 各自持有這些狀態。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 +7. **`core/` 接上了診斷、AI、工作區與語法高亮**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、除錯、工作執行與遠端還沒有接上,視窗層仍然 + 各自持有這些狀態。語法引擎目前只用來上色:編輯器直接向引擎要 session,沒有經過 `DocumentStore`,大綱、折疊與智慧選取也還在用各自的分析(`utils/symbols`、`utils/code_folding`、`utils/selection`)。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 主要的根目錄(工作目錄)。 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, diff --git a/dev.toml b/dev.toml index 272be54..165a589 100644 --- a/dev.toml +++ b/dev.toml @@ -20,7 +20,9 @@ license-files = ["LICENSE"] dependencies = [ "PySide6==6.11.2", "qt-material", "yapf", "frontengine", "pycodestyle", "jedi", "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", - "watchdog", "ruff", "gitpython>=3.1.59" + "watchdog", "ruff", "gitpython>=3.1.59", + "tree-sitter==0.26.0", "tree-sitter-python==0.25.0", "tree-sitter-javascript==0.25.0", + "tree-sitter-json==0.24.8" ] classifiers = [ "Programming Language :: Python :: 3.10", @@ -66,3 +68,9 @@ select = ["E4", "E7", "E9", "F"] # 只收 je_editor:test/ 有 __init__.py,不寫 include 會被一起裝進 site-packages。 # Ship je_editor only: test/ has an __init__.py and would be installed too without include. find = { include = ["je_editor", "je_editor.*"], namespaces = false } + +[tool.setuptools.package-data] +# Tree-sitter 的查詢檔不是 .py,不寫在這裡就不會進 wheel,裝好的編輯器會沒有結構區塊。 +# The Tree-sitter query files are not .py: unlisted, they stay out of the wheel and an installed +# editor has no structural regions. +"je_editor.adapters.syntax" = ["queries/*/*.scm"] diff --git a/dev_requirements.txt b/dev_requirements.txt index 25334d3..50cc300 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -2,6 +2,10 @@ PySide6==6.11.2 langchain_openai==1.6.2 langchain_core anthropic==1.11.0 +tree-sitter==0.26.0 +tree-sitter-python==0.25.0 +tree-sitter-javascript==0.25.0 +tree-sitter-json==0.24.8 ruff sphinx twine diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index 586e831..1a564c3 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -13,15 +13,15 @@ | --- | --- | --- | | M0 — Foundation and compatibility boundary | Implemented: `je_editor/core/` | `docs/updates/2026-10.md`, U-20261008-01 | | M2 — diagnostics half | Implemented: one diagnostic model, severity and source filters | U-20261008-04 | -| M2 — Tree-sitter half | Not started | `PROGRESS.md` | +| M2 — Tree-sitter half | Implemented: `je_editor/adapters/syntax/` colours Python, JavaScript and JSON; folding, outline and selection still use their own analysers | U-20261008-08, `PROGRESS.md` | | M3 — Workspace + multi-root | Implemented; per-root environments and Git are left over | U-20261008-07, `PROGRESS.md` | | M5 — AI provider abstraction + Anthropic | Implemented: `je_editor/adapters/ai/` | U-20261008-05 | | M1, M4, M6, M7, M8 | Not started | `PROGRESS.md` | M0 defines the service layer and proves it runs without Qt. What it left to later milestones: -- the editor window consumes the services one area at a time: diagnostics, the AI chat panel and - the workspace do so far; +- the editor window consumes the services one area at a time: diagnostics, the AI chat panel, + the workspace and syntax highlighting do so far; - debugging, task execution and remote sessions are interfaces with no implementation yet (M4 and M6 supply them), and the request-and-reply calls of a language service (completion, hover and the rest) take their shape with Tree-sitter in M2; diff --git a/docs/source/docs/Eng/configuration.rst b/docs/source/docs/Eng/configuration.rst index 1a31ade..cf918c9 100644 --- a/docs/source/docs/Eng/configuration.rst +++ b/docs/source/docs/Eng/configuration.rst @@ -49,6 +49,9 @@ The main settings file controls editor behavior and appearance: - Whether to reopen those tabs on launch (default: ``true``) * - ``workspace_roots`` - The folders added to the workspace beside the working directory + * - ``syntax_engine`` + - What colours the syntax: ``tree_sitter`` (default) parses Python, JavaScript and JSON; + ``classic`` uses the earlier pattern-based highlighters for every language * - ``shortcuts`` - Keys the user reassigned; only what differs from a default is stored @@ -73,7 +76,9 @@ Controls the color scheme for the editor and output: - Output panel text * - ``syntax_keyword_color`` / ``syntax_string_color`` / ``syntax_comment_color`` / ``syntax_number_color`` - - Syntax highlighting + - Syntax highlighting: keywords, strings, comments, numbers + * - ``syntax_function_color`` / ``syntax_builtin_color`` / ``syntax_self_color`` + - Syntax highlighting: function names, built-ins and type names, ``self`` / ``this`` * - ``diff_added_marker_color`` / ``diff_modified_marker_color`` / ``diff_removed_marker_color`` - Git change markers in the gutter diff --git a/docs/source/docs/Eng/core_services.rst b/docs/source/docs/Eng/core_services.rst index f1f0ce5..4eb1e57 100644 --- a/docs/source/docs/Eng/core_services.rst +++ b/docs/source/docs/Eng/core_services.rst @@ -2,15 +2,16 @@ Core Services ============== ``je_editor.core`` holds the parts of the editor that are not widgets: the workspace, the open -documents, the diagnostics, and the interfaces for language services, debugging, task execution, -remote sessions and AI providers. Nothing in it imports Qt, so it can be used from a test, a +documents, the diagnostics, syntax analysis, and the interfaces for language services, debugging, +task execution, remote sessions and AI providers. Nothing in it imports Qt, so it can be used from a test, a command-line tool or a host application that never builds the JEditor window. .. note:: - This layer is the foundation of the next-generation editor roadmap. The data models work - today. The editor window does not consume them yet: its panels still talk to their own - back ends, and they move onto these services one milestone at a time. + This layer is the foundation of the next-generation editor roadmap. The editor window moves + onto it one area at a time: diagnostics, the AI chat panel, the workspace and syntax + highlighting use it so far, while debugging, task execution and remote sessions are + interfaces only. Quick Example -------------- @@ -58,6 +59,9 @@ EditorServices - The ``DiagnosticStore``: what every source reported * - ``languages`` - The ``LanguageServiceRegistry``, fed by ``documents`` + * - ``syntax`` + - The ``SyntaxEngine``. It knows no language until a parser is plugged in; + ``build_default_services()`` plugs in Tree-sitter * - ``debug_adapters`` - Debug session factories, registered by adapter type * - ``task_runners`` @@ -159,7 +163,8 @@ it offers through ``LanguageCapability``. .. code-block:: python from je_editor.core import ( - Diagnostic, EditorServices, LanguageCapability, Severity, TextDocument, TextRange, to_uri + Diagnostic, EditorServices, LanguageCapability, LanguageReply, Severity, TextDocument, + TextRange, to_uri ) @@ -186,6 +191,10 @@ it offers through ``LanguageCapability``. def document_closed(self, document): self._services.diagnostics.publish(self.name, document.uri, []) + def request(self, request, on_reply): + on_reply(LanguageReply(request, self.name, error="todo-finder answers no questions")) + return lambda: None + def shutdown(self): self._services.diagnostics.clear(source=self.name) @@ -207,6 +216,110 @@ A service registered after documents are open is told about each one it handles, that starts late still learns what is open. ``services_for(document, capability)`` finds the services for a document. +Asking a Language Service +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Completion, hover, symbols and every other question go through one call: hand over a function +for the reply and get back a function to cancel with. ``services.languages.request()`` puts the +question to the first registered service that handles the document and offers the capability. + +.. code-block:: python + + from je_editor.adapters.default_services import build_default_services + from je_editor.core import LanguageCapability, LanguageRequest, TextDocument, to_uri + + services = build_default_services() + document = TextDocument(to_uri("greeter.py"), + "class Greeter:\n def greet(self):\n return 'hi'\n") + services.documents.open(document) + + + def show(reply): + if reply.ok: + print(reply.service, [(region.kind.value, region.name) for region in reply.value]) + else: + print(reply.error) + + + cancel = services.languages.request( + LanguageRequest(LanguageCapability.DOCUMENT_SYMBOLS, document), show) + # syntax [('class', 'Greeter'), ('function', 'greet')] + services.languages.request(LanguageRequest(LanguageCapability.HOVER, document), show) + # no language service offers hover for file:///.../greeter.py + services.shutdown() + +The same call works whether the service answers at once or after a while: + +- A service that has the answer at hand, as the syntax service does, calls the function before + ``request()`` returns. +- A service that has to wait, as a language server does, calls it later and may do so from a + thread of its own. A caller that updates widgets moves the reply to the widget thread itself. +- The reply arrives at most once, and never after the cancel function was called. The registry + enforces both, so a service does not have to. +- When nobody can answer, the reply carries an ``error`` and ``reply.ok`` is ``False``. Nothing + is raised. + +What ``reply.value`` holds depends on the capability: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Capability + - ``reply.value`` + * - ``SYNTAX_TREE`` + - The document's ``SyntaxSession`` (see below) + * - ``DOCUMENT_SYMBOLS`` + - A tuple of the named ``StructuralRegion`` objects: classes, functions and methods + * - The others + - Decided when the first service offers them + +Syntax Analysis +---------------- + +A ``SyntaxEngine`` turns text into the two things an editor needs from a parser: which stretches +of a line are a keyword, a string or a function name, and which classes, functions and blocks a +document has. The parse tree itself never leaves the engine, so the widgets do not depend on the +parser. + +.. code-block:: python + + from je_editor.adapters.syntax.tree_sitter_engine import TreeSitterEngine + + engine = TreeSitterEngine() + print(engine.language_ids()) # ('python', 'javascript', 'json') + session = engine.open_session(engine.language_for("main.py")) + + print(session.update("def greet(name):\n return name\n")) + # LineSpan(first=1, last=3) + print([(span.column, span.length, span.category.value) for span in session.spans(1)]) + # [(1, 3, 'keyword'), (5, 5, 'function'), (11, 4, 'variable')] + + print(session.update("def greet(name):\n return name.upper()\n")) + # LineSpan(first=2, last=2) + print([(region.kind.value, region.name, region.is_multiline) for region in session.regions()]) + # [('function', 'greet', True)] + +- ``open_session(language_id)`` gives one document its own ``SyntaxSession``; it returns + ``None`` for a language the engine cannot analyse. +- ``update(text)`` takes the whole text and returns the ``LineSpan`` whose categories may have + changed, or ``None`` when the text is the same. Only the part an edit touched is parsed + again, and the span can reach beyond the edited line: opening a string changes every line + after it. +- ``spans(line)`` gives the ``SyntaxSpan`` objects of a line, outer ones before the ones inside + them, so applying them in order lets an interpolation override the string around it. +- ``regions()`` gives the ``StructuralRegion`` objects, outer ones first. ``kind`` is one of + ``RegionKind.CLASS``, ``FUNCTION``, ``BLOCK`` and ``COLLECTION``. +- Lines and columns are 1-based, as in diagnostics. A column counts UTF-16 units, which is what + Qt and the language server protocol count. + +``je_editor.adapters.syntax`` implements the engine with Tree-sitter for Python, JavaScript and +JSON. A language is one ``GrammarSpec`` row in ``grammar_table.py`` plus query files under +``queries//``: ``highlights.scm`` is appended to the highlight query the grammar ships, +and ``regions.scm`` names the structural regions. A grammar that is not installed, or a query +that does not compile, makes that language unsupported rather than raising, and the editor +falls back to its pattern-based highlighter. + Debugging, Tasks, Remote Sessions and AI Providers --------------------------------------------------- diff --git a/docs/source/docs/Eng/editor.rst b/docs/source/docs/Eng/editor.rst index eed8697..250a619 100644 --- a/docs/source/docs/Eng/editor.rst +++ b/docs/source/docs/Eng/editor.rst @@ -51,18 +51,32 @@ JEditor includes an automatic save feature that periodically saves your work: Syntax Highlighting -------------------- -JEditor provides built-in Python syntax highlighting and supports additional languages through plugins. - -**Built-in Python Highlighting** includes: - -- Keywords (``if``, ``else``, ``for``, ``while``, ``def``, ``class``, etc.) -- Built-in functions (``print``, ``len``, ``range``, etc.) -- Strings (single-line and multi-line) -- Comments +JEditor colours Python, JavaScript and JSON from a real parse of the file, other common languages +from keyword tables, and further languages through plugins. + +**Parsed languages** are Python (``.py``, ``.pyw``, ``.pyi``), JavaScript (``.js``, ``.mjs``, +``.cjs``, ``.jsx``) and JSON (``.json``). They are parsed with Tree-sitter, which gives: + +- Keywords (``if``, ``else``, ``for``, ``while``, ``def``, ``class``, etc.), strings, comments and numbers +- Function and method names, and class and type names +- Built-in functions (``print``, ``len``, ``range``, etc.), and ``self`` / ``this`` +- Escape sequences, and the expressions inside f-strings and template strings +- JSON keys told apart from string values +- Strings and comments over several lines stay coloured to their end, and opening or closing one + recolours every line it affects - Decorators -- Numbers - Customizable colors via the color settings +The parse follows each edit and re-reads only what the edit touched. A new tab that has no file +name yet is coloured as Python. A file larger than 2 MB is left uncoloured. + +To go back to the earlier, pattern-based highlighting, set ``"syntax_engine": "classic"`` in +``.jeditor/user_setting.json`` (see :doc:`configuration`). The editor also falls back to it by +itself when Tree-sitter or a grammar is not installed. + +**Keyword-table languages** are TypeScript, C, C++, Go, Java, Rust, shell, SQL, TOML and YAML: +keywords, strings, comments and numbers are coloured. + **Plugin-based Language Support:** Additional languages can be added through the plugin system. Pre-built plugins are available for: @@ -73,6 +87,9 @@ Additional languages can be added through the plugin system. Pre-built plugins a - Java (``.java``) - Rust (``.rs``) +Keywords a plugin registers for a suffix are laid over whichever highlighter colours that suffix, +so a plugin can add its own words to JSON or YAML files too. + See :doc:`plugins` for details on creating language plugins. Auto-Completion diff --git a/docs/source/docs/Eng/getting_started.rst b/docs/source/docs/Eng/getting_started.rst index 1708958..f9d4704 100644 --- a/docs/source/docs/Eng/getting_started.rst +++ b/docs/source/docs/Eng/getting_started.rst @@ -62,7 +62,11 @@ JEditor will automatically install the following dependencies: * - gitpython - Git operations * - langchain_openai / langchain_core - - AI assistant (LLM integration) + - AI assistant: OpenAI-compatible provider + * - anthropic + - AI assistant: Anthropic provider + * - tree-sitter / tree-sitter-python / tree-sitter-javascript / tree-sitter-json + - Syntax parsing for highlighting * - watchdog - File system monitoring * - pycodestyle diff --git a/docs/source/docs/Zh/configuration.rst b/docs/source/docs/Zh/configuration.rst index da621b6..65efdd0 100644 --- a/docs/source/docs/Zh/configuration.rst +++ b/docs/source/docs/Zh/configuration.rst @@ -49,6 +49,9 @@ user_setting.json - 啟動時是否重新開啟這些分頁(預設:``true``) * - ``workspace_roots`` - 工作目錄以外,另外加入工作區的資料夾 + * - ``syntax_engine`` + - 語法由誰上色: ``tree_sitter`` (預設)會解析 Python、JavaScript 與 JSON; + ``classic`` 則每一種語言都用原本以樣式比對的高亮器 * - ``shortcuts`` - 使用者改過的快捷鍵;只記錄與預設值不同的項目 @@ -73,7 +76,9 @@ user_color_setting.json - 輸出面板文字 * - ``syntax_keyword_color`` / ``syntax_string_color`` / ``syntax_comment_color`` / ``syntax_number_color`` - - 語法高亮 + - 語法高亮:關鍵字、字串、註解、數字 + * - ``syntax_function_color`` / ``syntax_builtin_color`` / ``syntax_self_color`` + - 語法高亮:函式名稱、內建名稱與型別名稱、 ``self`` / ``this`` * - ``diff_added_marker_color`` / ``diff_modified_marker_color`` / ``diff_removed_marker_color`` - 行號區的 Git 變更標記 diff --git a/docs/source/docs/Zh/core_services.rst b/docs/source/docs/Zh/core_services.rst index 73f5190..1975b3a 100644 --- a/docs/source/docs/Zh/core_services.rst +++ b/docs/source/docs/Zh/core_services.rst @@ -1,14 +1,14 @@ 核心服務 ======== -``je_editor.core`` 放的是編輯器裡不屬於元件的部分:工作區、開著的文件、診斷,以及語言服務、 -除錯、工作執行、遠端工作階段與 AI 供應者的介面。這一層完全不匯入 Qt,所以測試、命令列工具, +``je_editor.core`` 放的是編輯器裡不屬於元件的部分:工作區、開著的文件、診斷、語法分析,以及 +語言服務、除錯、工作執行、遠端工作階段與 AI 供應者的介面。這一層完全不匯入 Qt,所以測試、命令列工具, 或從不建立 JEditor 視窗的宿主程式都可以使用。 .. note:: - 這一層是下一代編輯器藍圖的基礎。資料模型現在就能用,但編輯器視窗還沒有改用它們:各個面板 - 仍然各自連到自己的後端,之後會隨著每個里程碑逐一改接到這些服務上。 + 這一層是下一代編輯器藍圖的基礎。編輯器視窗一次改接一個部分:目前診斷、AI 對話面板、工作區 + 與語法高亮已經在用它,除錯、工作執行與遠端工作階段還只有介面。 快速範例 -------- @@ -56,6 +56,9 @@ EditorServices - ``DiagnosticStore``:每個來源回報的診斷 * - ``languages`` - ``LanguageServiceRegistry``,文件事件來自 ``documents`` + * - ``syntax`` + - ``SyntaxEngine``。接上解析器之前它什麼語言都不會; ``build_default_services()`` 會接上 + Tree-sitter * - ``debug_adapters`` - 除錯工作階段的建立函式,以轉接器種類登記 * - ``task_runners`` @@ -154,7 +157,8 @@ EditorServices .. code-block:: python from je_editor.core import ( - Diagnostic, EditorServices, LanguageCapability, Severity, TextDocument, TextRange, to_uri + Diagnostic, EditorServices, LanguageCapability, LanguageReply, Severity, TextDocument, + TextRange, to_uri ) @@ -181,6 +185,10 @@ EditorServices def document_closed(self, document): self._services.diagnostics.publish(self.name, document.uri, []) + def request(self, request, on_reply): + on_reply(LanguageReply(request, self.name, error="todo-finder answers no questions")) + return lambda: None + def shutdown(self): self._services.diagnostics.clear(source=self.name) @@ -201,6 +209,100 @@ EditorServices 在文件已經開著之後才登記的服務,會收到它處理的每一份文件的「開啟」通知,所以晚啟動的伺服器仍然 知道有哪些文件開著。``services_for(document, capability)`` 用來找出處理某份文件的服務。 +向語言服務發問 +~~~~~~~~~~~~~~ + +補全、懸停說明、符號,以及其他任何問題都走同一個呼叫:給一個收回覆的函式,拿回一個取消的函式。 +``services.languages.request()`` 會把問題交給登記順序裡第一個處理那份文件、又提供那個功能的服務。 + +.. code-block:: python + + from je_editor.adapters.default_services import build_default_services + from je_editor.core import LanguageCapability, LanguageRequest, TextDocument, to_uri + + services = build_default_services() + document = TextDocument(to_uri("greeter.py"), + "class Greeter:\n def greet(self):\n return 'hi'\n") + services.documents.open(document) + + + def show(reply): + if reply.ok: + print(reply.service, [(region.kind.value, region.name) for region in reply.value]) + else: + print(reply.error) + + + cancel = services.languages.request( + LanguageRequest(LanguageCapability.DOCUMENT_SYMBOLS, document), show) + # syntax [('class', 'Greeter'), ('function', 'greet')] + services.languages.request(LanguageRequest(LanguageCapability.HOVER, document), show) + # no language service offers hover for file:///.../greeter.py + services.shutdown() + +不論服務是當場回答還是過一陣子才回答,呼叫的方式都一樣: + +- 手上就有答案的服務(例如語法服務)會在 ``request()`` 回傳之前呼叫那個函式。 +- 要等的服務(例如語言伺服器)之後才呼叫,而且可能從自己的執行緒呼叫。要更新畫面的呼叫端得 + 自己把回覆轉回畫面執行緒。 +- 回覆最多只會送達一次,呼叫取消函式之後不會再送達。這兩點由登記表保證,服務不必自己處理。 +- 沒有人能回答時,回覆會帶著 ``error`` ,而且 ``reply.ok`` 是 ``False`` 。不會丟出例外。 + +``reply.value`` 的內容依功能而定: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - 功能 + - ``reply.value`` + * - ``SYNTAX_TREE`` + - 那份文件的 ``SyntaxSession`` (見下一節) + * - ``DOCUMENT_SYMBOLS`` + - 有名稱的 ``StructuralRegion`` 組成的 tuple:類別、函式與方法 + * - 其他 + - 等第一個提供它的服務出現時決定 + +語法分析 +-------- + +``SyntaxEngine`` 把文字變成編輯器需要解析器提供的兩樣東西:一行裡哪幾段是關鍵字、字串或函式 +名稱,以及一份文件有哪些類別、函式與區塊。語法樹本身不會離開引擎,所以畫面層不依賴解析器。 + +.. code-block:: python + + from je_editor.adapters.syntax.tree_sitter_engine import TreeSitterEngine + + engine = TreeSitterEngine() + print(engine.language_ids()) # ('python', 'javascript', 'json') + session = engine.open_session(engine.language_for("main.py")) + + print(session.update("def greet(name):\n return name\n")) + # LineSpan(first=1, last=3) + print([(span.column, span.length, span.category.value) for span in session.spans(1)]) + # [(1, 3, 'keyword'), (5, 5, 'function'), (11, 4, 'variable')] + + print(session.update("def greet(name):\n return name.upper()\n")) + # LineSpan(first=2, last=2) + print([(region.kind.value, region.name, region.is_multiline) for region in session.regions()]) + # [('function', 'greet', True)] + +- ``open_session(language_id)`` 為一份文件開一個自己的 ``SyntaxSession`` ;引擎不能分析那個語言 + 時回傳 ``None`` 。 +- ``update(text)`` 接收整份文字,回傳分類可能變了的 ``LineSpan`` ;文字沒變時回傳 ``None`` 。 + 只有編輯影響到的部分會重新解析,而回傳的範圍可以超出被編輯的那一行:打開一個字串會改變它 + 之後的每一行。 +- ``spans(line)`` 回傳一行的 ``SyntaxSpan`` ,外層的在前、內層的在後,所以照順序套用時,插值會 + 蓋過包住它的字串。 +- ``regions()`` 回傳 ``StructuralRegion`` ,外層的在前。 ``kind`` 是 ``RegionKind.CLASS`` 、 + ``FUNCTION`` 、 ``BLOCK`` 與 ``COLLECTION`` 其中之一。 +- 行號與欄號跟診斷一樣從 1 起算。欄號以 UTF-16 的單位計,也就是 Qt 與語言伺服器協定用的單位。 + +``je_editor.adapters.syntax`` 以 Tree-sitter 實作這個引擎,支援 Python、JavaScript 與 JSON。 +一種語言就是 ``grammar_table.py`` 裡的一列 ``GrammarSpec`` ,加上 ``queries/<語言>/`` 底下的查詢檔: +``highlights.scm`` 會接在文法自帶的高亮查詢之後, ``regions.scm`` 則指出結構區塊。文法沒有安裝、 +或查詢檔編譯不過時,那個語言只是變成不支援,不會丟出例外,編輯器會退回以樣式比對的高亮器。 + 除錯、工作執行、遠端工作階段與 AI 供應者 ---------------------------------------- diff --git a/docs/source/docs/Zh/editor.rst b/docs/source/docs/Zh/editor.rst index 287912e..d9258ad 100644 --- a/docs/source/docs/Zh/editor.rst +++ b/docs/source/docs/Zh/editor.rst @@ -51,18 +51,31 @@ JEditor 內建自動儲存功能,定期儲存您的工作: 語法高亮 --------- -JEditor 內建 Python 語法高亮,並透過插件系統支援其他程式語言。 - -**內建 Python 高亮** 包括: - -- 關鍵字(``if``、``else``、``for``、``while``、``def``、``class`` 等) -- 內建函式(``print``、``len``、``range`` 等) -- 字串(單行與多行) -- 註解 +JEditor 以真正的語法解析為 Python、JavaScript 與 JSON 上色,其他常見語言使用關鍵字表,更多語言 +則透過插件支援。 + +**解析式的語言** 有 Python( ``.py`` 、 ``.pyw`` 、 ``.pyi`` )、JavaScript( ``.js`` 、 ``.mjs`` 、 +``.cjs`` 、 ``.jsx`` )與 JSON( ``.json`` )。它們以 Tree-sitter 解析,因此有: + +- 關鍵字( ``if`` 、 ``else`` 、 ``for`` 、 ``while`` 、 ``def`` 、 ``class`` 等)、字串、註解與數字 +- 函式與方法的名稱,以及類別與型別的名稱 +- 內建函式( ``print`` 、 ``len`` 、 ``range`` 等),以及 ``self`` / ``this`` +- 跳脫字元,以及 f-string 與樣板字串裡的運算式 +- JSON 的鍵與字串值分得開 +- 跨好幾行的字串與註解會一路上色到結尾;打開或關上一個時,受影響的每一行都會重新上色 - 裝飾器 -- 數字 - 可透過色彩設定自訂顏色 +解析會跟著每一次編輯更新,而且只重讀編輯影響到的部分。還沒有檔名的新分頁以 Python 上色。 +超過 2 MB 的檔案不上色。 + +要換回原本以樣式比對的高亮,請在 ``.jeditor/user_setting.json`` 設定 +``"syntax_engine": "classic"`` (見 :doc:`configuration` )。沒有安裝 Tree-sitter 或某個文法時, +編輯器也會自己退回去。 + +**使用關鍵字表的語言** 有 TypeScript、C、C++、Go、Java、Rust、shell、SQL、TOML 與 YAML:關鍵字、 +字串、註解與數字會上色。 + **透過插件支援更多語言:** 可透過插件系統新增額外的語言支援。預先提供的插件包括: @@ -73,6 +86,9 @@ JEditor 內建 Python 語法高亮,並透過插件系統支援其他程式語 - Java(``.java``) - Rust(``.rs``) +插件為某個副檔名登記的關鍵字,會疊在為那個副檔名上色的高亮器之上,所以插件也可以替 JSON 或 +YAML 檔案加上自己的關鍵字。 + 詳情請參閱 :doc:`plugins`。 自動補全 diff --git a/docs/source/docs/Zh/getting_started.rst b/docs/source/docs/Zh/getting_started.rst index e7b3c54..19bd794 100644 --- a/docs/source/docs/Zh/getting_started.rst +++ b/docs/source/docs/Zh/getting_started.rst @@ -62,7 +62,11 @@ JEditor 會自動安裝以下依賴套件: * - gitpython - Git 操作 * - langchain_openai / langchain_core - - AI 助手(LLM 整合) + - AI 助理:OpenAI 相容的供應者 + * - anthropic + - AI 助理:Anthropic 供應者 + * - tree-sitter / tree-sitter-python / tree-sitter-javascript / tree-sitter-json + - 語法高亮用的語法解析 * - watchdog - 檔案系統監控 * - pycodestyle diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 493e858..7276b7f 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -221,3 +221,34 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **文件**:`docs/source/docs/{Eng,Zh}/editor.rst` 新增「含有多個資料夾的工作區」一節、`configuration.rst` 的設定鍵、三份 README 的檔案操作、`architecture.md` §3、`architecture_explore.md`(§5.2、§5.7 新的「工作區」小節、§5.11、§6.2、§8)、藍圖的實作狀態。 - **檔案**:`je_editor/core/workspace/workspace_model.py`、`je_editor/utils/file_scan/workspace_scan.py`(新)、`je_editor/pyside_ui/main_ui/workspace/`(新,3 個檔)、`main_editor.py`、`editor/editor_widget.py`、`menu/file_menu/build_file_menu.py`、`save_settings/user_setting_file.py`、`command_palette/quick_open_dialog.py`、`todo_panel/todo_panel_widget.py`、`problems_panel/problems_panel_widget.py`、`problems_panel/project_lint_worker.py`、`test_panel/test_panel_widget.py`、`dialog/search_ui/search_replace_widget.py`、`dialog/file_dialog/open_file_dialog.py`、`code/lsp/lsp_client.py`、`code/plaintext_code_edit/code_edit_plaintext.py`、四份語言字典、上述測試與文件、`PROGRESS.md`(刪 #11、加 #22)。 - **待辦**:`PROGRESS.md` #22。 + +## U-20261008-08 · 2026-10-08 · 藍圖 M2:Tree-sitter 語法引擎與高亮;語言服務的發問形式;四個既有問題 · #done #decision #roadmap #syntax + +- **做了什麼**:完成 `PROGRESS.md` #10(藍圖 M2 剩下的 Tree-sitter 那一半)。Python、JavaScript 與 JSON 改由 Tree-sitter 解析後上色;畫面層只拿到「第幾行的哪幾段是什麼分類」,不知道解析器是什麼。 + - `core/syntax/syntax_model.py`(新):`SyntaxCategory`、`SyntaxSpan`、`LineSpan`、`RegionKind` / `StructuralRegion`,`SyntaxSession` 與 `SyntaxEngine` 兩個協定,以及什麼語言都不會的 `NoSyntaxEngine`。行與欄跟診斷模型一樣從 1 起算,欄以 UTF-16 單位計。`EditorServices` 多了 `syntax`。 + - `adapters/syntax/`(新):`TreeSitterEngine` / `TreeSitterSession`、文法表 `grammar_table.py`、`SyntaxLanguageService`、查詢檔 `queries/<語言>/*.scm`。文字每變一次,找出新舊內容不同的最小一段告訴舊的樹,只重新解析受影響的部分,並回報語法變了的行;分類以 64 行為一塊、用到才算。 + - `pyside_ui/code/syntax/`:新增 `tree_sitter_highlighter.py`、`highlighter_factory.py`、`highlight_rules.py`。`CodeEditor.reset_highlighter()` 改由 `build_highlighter()` 挑選。既有的 `PythonHighlighter` 與 `GenericHighlighter` 留著當退路。 + - 編輯影響到的不只是被編輯的那一行(打開一個三引號,之後每一行都變字串):編輯位置之後的行,以切換區塊狀態讓 Qt 在同一輪接著畫;之前的行在事件迴圈下一輪補畫。 + - 新的主題顏色 `syntax_function_color`(函式與方法名稱);其餘分類沿用既有的六個顏色鍵。 +- **決定**: + - **`LanguageService` 的發問形式**:`request(LanguageRequest, on_reply) -> 取消函式`。當場能答的服務在回傳之前就呼叫 `on_reply`,要等的服務之後再呼叫(可以從自己的執行緒)。「回覆最多一次、取消之後不再送達」由登記表的 `ReplyOnce` 保證,不交給每個服務各自處理。答不出來是帶 `error` 的回覆,不是例外。`reply.value` 的型別依功能而定,目前定了 `SYNTAX_TREE` 與 `DOCUMENT_SYMBOLS` 兩種,其餘等第一個提供它的服務出現。 + - **查詢檔**:高亮沿用文法套件自帶的查詢,後面接上專案自己的 `highlights.scm`(同一個節點以寫在後面的規則為準);結構區塊用專案自己的 `regions.scm`。 + - **先支援三種語言**。每多一種就多一個要釘版本、要審查的相依套件;加法是文法表的一列加一組查詢檔,之後要加不必改引擎或畫面層。 + - **`syntax_engine` 設定**(`tree_sitter` / `classic`)可以換回原本的高亮,只寫在 `user_setting.json`,沒有做選單項目。Tree-sitter 或某個文法沒裝好時也會自己退回去,不會報錯。 + - **語法引擎整個行程共用一個**(`shared_syntax_engine()`)。裡面只有載入好的文法,是不會變的資料;文件各自的語法樹在 session 裡。這是 `adapters/` 唯一的模組層級單例,已記在 `architecture_explore.md` §4。 +- **過程中找到的四個既有問題**(都不是這次引進的,都在這個 commit 修掉): + 1. **走訪所有元件時發生垃圾回收,行程會壞掉**。`submenus_of()`(切換語言與指令面板都會用到)以 `QApplication.allWidgets()` 找選單;PySide 把那串指標一個一個包成物件,中途若觸發循環垃圾回收,會刪掉清單裡那些由 Python 擁有、已經沒人參考的元件,接著包到它們時就是寫入已釋放的記憶體。這次的修改讓自動回收的時間點剛好落在那裡,整套測試因此連續三次在同一個測試當掉(`0xC0000374`,heap corruption)。查證方式:關掉循環垃圾回收後整套通過;記錄每一次完整回收,最後一次正是從 `submenu_map.py:41` 開始、回收了十萬多個物件之後當掉;在那個測試之前強制回收一次,被刪掉的是 17 個沒人參考的頂層 `QTabWidget`(測試替身留下的)與它們的子元件,之後整套通過。修法是走訪期間停住回收(`menus_in()`)。 + 2. **換高亮器時舊的還留在文件上**。高亮器是文件的子物件,`reset_highlighter()` 只換掉 Python 這邊的參照,舊的仍然連著、每次編輯都繼續上色。現在會先拿掉並刪除。 + 3. **開檔或換主題之後,分頁被標成未儲存**。Qt 的高亮器重畫時會送出 `textChanged`,而分頁的未儲存標記聽的就是它。在修改前的程式碼上以真正的視窗重現過:開檔當下是乾淨的,事件迴圈跑過之後標題多了 ` *`。現在改聽文件的 `contentsChange`,換高亮器時則擋住文件的訊號。 + 4. **插件為 `.json`、`.yaml` 這類副檔名登記的關鍵字沒有生效**。這些副檔名用的是通用高亮器,而它不看插件。現在插件的關鍵字疊在任何一種高亮器之上(PyBreeze 為 `.json` 與 YAML 登記的關鍵字因此會上色)。 +- **第三方套件的兩個問題**(記在 `PROGRESS.md` #24): + - `tree-sitter` 0.26.0 的 `Point.row` / `Point.column` 每讀一次就少算那個整數一次參考。在 Python 3.11 上讀幾萬次之後行程當掉(3.12 之後小整數不會被回收,但大的列號一樣有問題)。以索引讀取(`point[0]`)沒有這個問題,引擎一律這樣讀,並有測試守著。其他用到的呼叫都量過參考計數與記憶體,九千次模擬編輯之後沒有變化。 + - `tree-sitter-json` 0.24.8 自帶的高亮查詢是照「先寫的規則優先」排的,跟另外兩個文法相反,照「後寫的優先」解讀時鍵會被當成一般字串;`queries/json/highlights.scm` 重新指定了鍵。 +- **相容性**:`PythonHighlighter` 仍然匯出,建構子多了選用的 `suffix`;`highlighter_for()` 多了選用的 `extra_rules`。`LanguageService` 協定多了 `request()`,既有的實作者只有測試替身與文件範例,都已補上。`LanguageCapability` 搬到 `language_capability.py`,從 `language_service` 與 `je_editor.core` 匯入的寫法不變。PyBreeze 只呼叫 `reset_highlighter()` 與 `register_programming_language()`,都照舊。PyBreeze 自己有一份契約測試(`test/test_utils/test_jeditor_contract.py`),連私有名稱與原始碼片段都釘:它要 `PythonHighlighter._make_format` 存在、而且裡面查的是 `actually_color_dict`,所以這個名稱留著,指向搬到 `highlight_rules.py` 的同一個函式,並在本專案的 `test_public_api_contract.py` 也釘一份。 +- **效能**(這台機器):900 行的檔案每次編輯重新解析 0.5 ms,9,000 行 7 ms;60,000 行(1.3 MB)約 45 ms,所以超過 2 MB 不解析。算一塊 64 行的分類約 1 ms。 +- **沒有涵蓋的部分**:語法樹目前只用來上色,大綱、折疊與智慧選取還在用各自的分析;編輯器直接向引擎要 session,沒有經過 `DocumentStore`。記在 `PROGRESS.md` #23。 +- **測試**:`test_syntax_engine.py`(新,106 個)、`test_tree_sitter_highlighter.py`(新,64 個)、`test_core_language_services.py` 新增發問形式的測試、`test_command_palette.py` 新增走訪選單的測試、`test_editor_widget.py` 新增未儲存標記的測試、`test_generic_syntax.py` 兩個測試改成新的行為。 +- **結果**:整套測試 2666 passed(修改前 2475)。修好走訪選單的問題之前,整套連續三次在同一個測試當掉;修好之後連續四次通過。`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束;Sphinx 沒有新警告,中英文頁面結構相同(`editor` 各 80 個行內程式碼,`core_services` 各 146 個與 7 個程式碼區塊);`core_services` 的 14 個範例全部實際執行過。實際建了 wheel,確認 5 個 `.scm` 與四個釘住版本的相依都在裡面。以真正的視窗(offscreen)開 Python、JSON、JavaScript 檔截圖看過配色;開檔之後分頁沒有被標成未儲存,打一個字之後有,文件上只掛著一個高亮器。PyBreeze 的 `test/test_utils`(它的環境沒有裝 tree-sitter,所以走的是退回舊高亮器的路):4260 passed、15 failed,另有 11 個模組因為它的環境缺 `hypothesis` 收不進來;把失敗的那幾個檔案拿去對這一系列修改之前的 JEditor(`da90c4f`)跑,其中 14 個一樣失敗,是它自己環境的問題;唯一多出來的是 `test_jeditor_contract.py` 釘住的 `LspClient.start_for` 參數清單,那是上一個階段(U-20261008-07)加了 `root` 參數造成的,接著的 commit 修。 +- **文件**:`docs/source/docs/{Eng,Zh}/editor.rst`(語法高亮一節重寫)、`core_services.rst`(新增「向語言服務發問」與「語法分析」,每個範例都實際執行過)、`configuration.rst`(`syntax_engine` 與顏色鍵)、`getting_started.rst`(相依套件表,順便補上先前漏掉的 `anthropic`)、三份 README、`architecture.md`(§2、§4 新增高亮流程、§5、§6)、`architecture_explore.md`、藍圖的實作狀態。 +- **檔案**:`je_editor/core/syntax/`(新)、`je_editor/core/language/language_capability.py`、`language_request.py`(新)、`language_service.py`、`je_editor/core/services/editor_services.py`、`je_editor/core/__init__.py`、`je_editor/adapters/syntax/`(新,含 5 個 `.scm`)、`je_editor/adapters/default_services.py`、`je_editor/pyside_ui/code/syntax/`(3 個新檔,`python_syntax.py`、`generic_syntax.py`)、`code_edit_plaintext.py`、`main_ui/editor/editor_widget.py`、`main_ui/menu/submenu_map.py`、`save_settings/user_setting_file.py`、`utils/theme/theme_colors.py`、`pyproject.toml`、`dev.toml`(相依與 `package-data`)、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`(刪 #10、加 #23 與 #24)。 +- **待辦**:`PROGRESS.md` #23、#24。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 5336ce4..4ee719f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-08 | 2026-10-08 | 藍圖 M2:Tree-sitter 語法引擎與高亮;語言服務的發問形式;四個既有問題 | #done #decision #roadmap #syntax | [2026-10](2026-10.md) | | U-20261008-07 | 2026-10-08 | 藍圖 M3:工作區與多根專案 | #done #roadmap #workspace | [2026-10](2026-10.md) | | U-20261008-06 | 2026-10-08 | M2 診斷與 M5 的 CI 結果;Codacy 三筆:一筆改掉、兩筆是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-05 | 2026-10-08 | 藍圖 M5:AI 對話面板改成可切換供應者,新增 Anthropic 後端 | #done #roadmap #ai #deps | [2026-10](2026-10.md) | @@ -98,5 +99,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 15 | +| [2026-10.md](2026-10.md) | 2026-10 | 16 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/adapters/default_services.py b/je_editor/adapters/default_services.py index 6623580..742a7ca 100644 --- a/je_editor/adapters/default_services.py +++ b/je_editor/adapters/default_services.py @@ -13,6 +13,8 @@ from je_editor.adapters.ai.builtin_providers import register_builtin_ai_providers from je_editor.adapters.ai.settings_file import ai_settings_path, load_ai_settings +from je_editor.adapters.syntax.syntax_language_service import SyntaxLanguageService +from je_editor.adapters.syntax.tree_sitter_engine import shared_syntax_engine from je_editor.core.services.editor_services import EditorServices from je_editor.core.workspace.workspace_model import Workspace @@ -20,8 +22,9 @@ def build_default_services(workspace: Workspace | None = None, settings_directory: str | Path | None = None) -> EditorServices: """ - 建立一組服務,載入 AI 設定並登記內建的 AI 供應者 - Build the services, load the AI settings and register the built-in AI providers. + 建立一組服務:接上語法引擎、載入 AI 設定並登記內建的 AI 供應者 + Build the services: plug the syntax engine in, load the AI settings and + register the built-in AI providers. :param workspace: 要處理的工作區,沒給時從空的工作區開始 the workspace to work on, an empty one when omitted @@ -31,6 +34,8 @@ def build_default_services(workspace: Workspace | None = None, services ready for use; the owner calls ``shutdown()`` on them when it closes """ services = EditorServices(workspace) + services.syntax = shared_syntax_engine() + services.languages.register(SyntaxLanguageService(services.syntax)) reload_ai_settings(services, settings_directory) def current_settings(): diff --git a/je_editor/adapters/syntax/__init__.py b/je_editor/adapters/syntax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/adapters/syntax/grammar_table.py b/je_editor/adapters/syntax/grammar_table.py new file mode 100644 index 0000000..774280a --- /dev/null +++ b/je_editor/adapters/syntax/grammar_table.py @@ -0,0 +1,130 @@ +""" +內建的 Tree-sitter 文法,以及查詢名稱到語法分類的對照 +The built-in Tree-sitter grammars, and the mapping from capture names to categories. + +多支援一種語言就是在這裡加一列、在 ``queries/<語言>/`` 放查詢檔,引擎與畫面層都 +不用改。 +Supporting one more language is a row here and query files under +``queries//``; neither the engine nor the widgets change. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +from je_editor.core.syntax.syntax_model import SyntaxCategory + +# 專案自己的查詢檔放在這裡 / Where the project's own query files live +QUERY_DIRECTORY = Path(__file__).parent / "queries" +# 接在文法自帶的高亮查詢之後 / Appended to the highlight query the grammar ships +HIGHLIGHTS_QUERY_FILE = "highlights.scm" +# 結構區塊的查詢 / The query for structural regions +REGIONS_QUERY_FILE = "regions.scm" + + +@dataclass(frozen=True) +class GrammarSpec: + """ + 一種語言的文法從哪裡來 + Where the grammar of one language comes from. + + :param language_id: 語言 ID / the language ID + :param suffixes: 屬於這個語言的副檔名,小寫、含點 + the file suffixes of the language, lower case and with the dot + :param load: 匯入文法套件並回傳它 / imports the grammar package and returns it + """ + + language_id: str + suffixes: tuple[str, ...] + load: Callable[[], ModuleType] + + +def _load_python() -> ModuleType: + """匯入 Python 的文法 / Import the Python grammar.""" + import tree_sitter_python + return tree_sitter_python + + +def _load_javascript() -> ModuleType: + """匯入 JavaScript 的文法 / Import the JavaScript grammar.""" + import tree_sitter_javascript + return tree_sitter_javascript + + +def _load_json() -> ModuleType: + """匯入 JSON 的文法 / Import the JSON grammar.""" + import tree_sitter_json + return tree_sitter_json + + +BUILTIN_GRAMMARS: tuple[GrammarSpec, ...] = ( + GrammarSpec("python", (".py", ".pyw", ".pyi"), _load_python), + GrammarSpec("javascript", (".js", ".mjs", ".cjs", ".jsx"), _load_javascript), + GrammarSpec("json", (".json",), _load_json), +) + +# 查詢裡的名稱是有層次的(``function.builtin``);找不到完整名稱時退回上一層。 +# Capture names are hierarchical (``function.builtin``): a name with no entry of +# its own falls back to its parent. +CAPTURE_CATEGORIES: dict[str, SyntaxCategory] = { + "comment": SyntaxCategory.COMMENT, + "string": SyntaxCategory.STRING, + "string.special.key": SyntaxCategory.KEY, + "string.escape": SyntaxCategory.ESCAPE, + "escape": SyntaxCategory.ESCAPE, + "number": SyntaxCategory.NUMBER, + "float": SyntaxCategory.NUMBER, + "boolean": SyntaxCategory.LITERAL, + "keyword": SyntaxCategory.KEYWORD, + "operator": SyntaxCategory.OPERATOR, + "punctuation": SyntaxCategory.PUNCTUATION, + "function": SyntaxCategory.FUNCTION, + "function.builtin": SyntaxCategory.BUILTIN, + "method": SyntaxCategory.FUNCTION, + "constructor": SyntaxCategory.TYPE, + "type": SyntaxCategory.TYPE, + "constant": SyntaxCategory.CONSTANT, + "constant.builtin": SyntaxCategory.LITERAL, + "variable": SyntaxCategory.VARIABLE, + "variable.builtin": SyntaxCategory.SPECIAL_VARIABLE, + "property": SyntaxCategory.PROPERTY, + "attribute": SyntaxCategory.PROPERTY, + "embedded": SyntaxCategory.EMBEDDED, +} + + +def category_for(capture_name: str) -> SyntaxCategory | None: + """ + 找出一個查詢名稱對應的語法分類 + The category a capture name stands for. + + :param capture_name: 查詢裡的名稱,例如 ``function.builtin`` + the name in the query, such as ``function.builtin`` + :return: 分類;這個名稱與它的每一層上層都沒有對應時為 ``None`` + the category, or ``None`` when neither the name nor any parent has one + """ + name = capture_name + while name: + category = CAPTURE_CATEGORIES.get(name) + if category is not None: + return category + name = name.rpartition(".")[0] + return None + + +def own_query(directory: Path, language_id: str, file_name: str) -> str: + """ + 讀出專案為某個語言準備的查詢檔 + Read the query file the project keeps for a language. + + :param directory: 查詢檔的根目錄 / the directory holding the query files + :param language_id: 語言 ID / the language ID + :param file_name: 查詢檔名 / the query file's name + :return: 查詢內容,沒有這個檔案時為空字串 / the query, empty when there is no such file + """ + path = directory / language_id / file_name + if not path.is_file(): + return "" + return path.read_text(encoding="utf-8") diff --git a/je_editor/adapters/syntax/queries/javascript/regions.scm b/je_editor/adapters/syntax/queries/javascript/regions.scm new file mode 100644 index 0000000..5a2fb8c --- /dev/null +++ b/je_editor/adapters/syntax/queries/javascript/regions.scm @@ -0,0 +1,30 @@ +; Structural regions: what can be folded, listed as a symbol or selected whole. + +[ + (class_declaration) + (class) +] @region.class + +[ + (function_declaration) + (function_expression) + (generator_function_declaration) + (generator_function) + (method_definition) + (arrow_function) +] @region.function + +[ + (if_statement) + (for_statement) + (for_in_statement) + (while_statement) + (do_statement) + (try_statement) + (switch_statement) +] @region.block + +[ + (object) + (array) +] @region.collection diff --git a/je_editor/adapters/syntax/queries/json/highlights.scm b/je_editor/adapters/syntax/queries/json/highlights.scm new file mode 100644 index 0000000..7544f5c --- /dev/null +++ b/je_editor/adapters/syntax/queries/json/highlights.scm @@ -0,0 +1,8 @@ +; Added after the grammar's own highlight query. A later pattern wins over an +; earlier one on the same node. + +; The grammar's query was written for the opposite precedence: it names the key +; first and every string second, which under "later wins" paints a key as a plain +; string. Naming the key again here puts it back. +(pair + key: (string) @string.special.key) diff --git a/je_editor/adapters/syntax/queries/json/regions.scm b/je_editor/adapters/syntax/queries/json/regions.scm new file mode 100644 index 0000000..555625f --- /dev/null +++ b/je_editor/adapters/syntax/queries/json/regions.scm @@ -0,0 +1,6 @@ +; Structural regions: what can be folded or selected whole. + +[ + (object) + (array) +] @region.collection diff --git a/je_editor/adapters/syntax/queries/python/highlights.scm b/je_editor/adapters/syntax/queries/python/highlights.scm new file mode 100644 index 0000000..3c158cc --- /dev/null +++ b/je_editor/adapters/syntax/queries/python/highlights.scm @@ -0,0 +1,6 @@ +; Added after the grammar's own highlight query. A later pattern wins over an +; earlier one on the same node. + +; The receiver of a method keeps the colour the editor has always given it. +((identifier) @variable.builtin + (#match? @variable.builtin "^(self|cls)$")) diff --git a/je_editor/adapters/syntax/queries/python/regions.scm b/je_editor/adapters/syntax/queries/python/regions.scm new file mode 100644 index 0000000..bd36f7a --- /dev/null +++ b/je_editor/adapters/syntax/queries/python/regions.scm @@ -0,0 +1,21 @@ +; Structural regions: what can be folded, listed as a symbol or selected whole. + +(class_definition) @region.class + +(function_definition) @region.function + +[ + (if_statement) + (for_statement) + (while_statement) + (try_statement) + (with_statement) + (match_statement) +] @region.block + +[ + (dictionary) + (list) + (set) + (tuple) +] @region.collection diff --git a/je_editor/adapters/syntax/syntax_language_service.py b/je_editor/adapters/syntax/syntax_language_service.py new file mode 100644 index 0000000..6b11c3b --- /dev/null +++ b/je_editor/adapters/syntax/syntax_language_service.py @@ -0,0 +1,142 @@ +""" +把語法引擎接成語言服務 +The syntax engine, offered as a language service. + +登記之後,文件一開就有語法樹、文字一變就更新,其他服務或宿主程式可以用同一套 +「發問、收回覆」取得某份文件的語法分析或它的符號,不必自己再解析一次。 +Once registered, a document has a tree as soon as it opens and the tree follows +every change, and another service or a host application can ask for a document's +syntax analysis or its symbols through the ordinary request and reply, without +parsing it a second time. +""" +from __future__ import annotations + +from je_editor.core.document.document_model import Document +from je_editor.core.language.language_capability import LanguageCapability +from je_editor.core.language.language_request import ( + CancelRequest, LanguageReply, LanguageRequest, ReplyHandler, nothing_to_cancel +) +from je_editor.core.syntax.syntax_model import SyntaxEngine, SyntaxSession +from je_editor.core.uri.resource_uri import uri_key + +SERVICE_NAME = "syntax" + + +class SyntaxLanguageService: + """ + 提供語法樹與文件符號的語言服務 + The language service that offers the syntax tree and the document's symbols. + """ + + def __init__(self, engine: SyntaxEngine) -> None: + """ + :param engine: 實際做分析的語法引擎 / the syntax engine that does the analysing + """ + self._engine = engine + self._sessions: dict[str, SyntaxSession] = {} + + @property + def name(self) -> str: + """服務名稱 / The service's name.""" + return SERVICE_NAME + + def capabilities(self) -> frozenset[LanguageCapability]: + """ + 這個服務提供哪些功能 + What this service offers. + + :return: 語法樹與文件符號 / the syntax tree and document symbols + """ + return frozenset({LanguageCapability.SYNTAX_TREE, LanguageCapability.DOCUMENT_SYMBOLS}) + + def handles(self, document: Document) -> bool: + """ + 引擎是否會分析這份文件的語言 + Whether the engine can analyse the document's language. + + :param document: 要判斷的文件 / the document in question + :return: 會分析時為 ``True`` / ``True`` when it can + """ + return self._language_of(document) is not None + + def document_opened(self, document: Document) -> None: + """ + 為開啟的文件建立語法樹 + Build the tree of a document that opened. + + :param document: 開啟的文件 / the document that opened + """ + language_id = self._language_of(document) + session = None if language_id is None else self._engine.open_session(language_id) + if session is None: + return + session.update(document.text()) + self._sessions[uri_key(document.uri)] = session + + def document_changed(self, document: Document) -> None: + """ + 讓語法樹跟上文件的新內容 + Bring the tree up to date with a document's new text. + + :param document: 內容變了的文件 / the document that changed + """ + session = self._sessions.get(uri_key(document.uri)) + if session is None: + self.document_opened(document) + return + session.update(document.text()) + + def document_closed(self, document: Document) -> None: + """ + 放掉關閉的文件的語法樹 + Let go of the tree of a document that closed. + + :param document: 關閉的文件 / the document that closed + """ + self._sessions.pop(uri_key(document.uri), None) + + def session_for(self, document: Document) -> SyntaxSession | None: + """ + 取得一份開著的文件的語法分析 + The syntax analysis of a document that is open. + + :param document: 文件 / the document + :return: 它的 session,文件沒開或語言不支援時為 ``None`` + its session, or ``None`` when it is not open or its language is not supported + """ + return self._sessions.get(uri_key(document.uri)) + + def request(self, request: LanguageRequest, on_reply: ReplyHandler) -> CancelRequest: + """ + 回答語法樹或文件符號的問題;答案是現成的,所以當場回覆 + Answer a question about the syntax tree or the document's symbols. The + answer is at hand, so the reply comes at once. + + :param request: 問題 / the question + :param on_reply: 收回覆的函式 / receives the reply + :return: 沒有東西可以取消的取消函式 / a cancel function with nothing to cancel + """ + session = self.session_for(request.document) + if session is None: + on_reply(LanguageReply(request, self.name, + error=f"{request.document.uri} is not open in the syntax service")) + elif request.capability is LanguageCapability.SYNTAX_TREE: + on_reply(LanguageReply(request, self.name, value=session)) + elif request.capability is LanguageCapability.DOCUMENT_SYMBOLS: + symbols = tuple(region for region in session.regions() if region.name) + on_reply(LanguageReply(request, self.name, value=symbols)) + else: + on_reply(LanguageReply( + request, self.name, + error=f"the syntax service does not offer {request.capability.value}")) + return nothing_to_cancel + + def shutdown(self) -> None: + """放掉每一棵語法樹 / Let go of every tree.""" + self._sessions.clear() + + def _language_of(self, document: Document) -> str | None: + """文件的語言:先看它自己說的,再看檔名 / A document's language: what it says first, then its name.""" + if document.language_id and document.language_id in self._engine.language_ids(): + return document.language_id + return self._engine.language_for(document.uri) diff --git a/je_editor/adapters/syntax/tree_sitter_engine.py b/je_editor/adapters/syntax/tree_sitter_engine.py new file mode 100644 index 0000000..f6bbd11 --- /dev/null +++ b/je_editor/adapters/syntax/tree_sitter_engine.py @@ -0,0 +1,536 @@ +""" +以 Tree-sitter 實作的語法引擎 +The syntax engine, implemented with Tree-sitter. + +這裡是唯一知道 Tree-sitter 的地方:把文字變成語法樹、用查詢檔把節點變成語法分類與 +結構區塊,再以核心層的模型交出去。畫面層拿到的只有「第幾行的第幾欄到第幾欄是什麼」。 +This is the only place that knows Tree-sitter: it turns text into a tree, uses +query files to turn nodes into categories and structural regions, and hands them +over in the core layer's model. All the widgets ever get is which columns of +which line are what. + +文字每變一次就重新分析,但不是從頭來:先找出新舊文字不同的那一段、告訴舊的樹它被 +改了哪裡,解析器就只重做受影響的部分,並回報哪幾行的語法因此不同。 +The text is analysed again on every change, though not from scratch: the stretch +that differs between the old and the new text is found, the old tree is told +where it was edited, and the parser redoes only what that touches and reports +which lines came out different. + +Tree-sitter 或某個文法沒有安裝時不會報錯,那個語言只是變成「不支援」,編輯器退回 +原本的高亮器。 +A missing Tree-sitter, or a missing grammar, is not an error: the language simply +counts as unsupported and the editor falls back to its older highlighter. +""" +from __future__ import annotations + +import threading +from collections.abc import Iterable +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +from je_editor.adapters.syntax.grammar_table import ( + BUILTIN_GRAMMARS, HIGHLIGHTS_QUERY_FILE, QUERY_DIRECTORY, REGIONS_QUERY_FILE, GrammarSpec, + category_for, own_query +) +from je_editor.core.diagnostics.diagnostic_model import Position, TextRange +from je_editor.core.syntax.syntax_model import ( + LineSpan, RegionKind, StructuralRegion, SyntaxCategory, SyntaxSession, SyntaxSpan +) +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 解析器吃 UTF-8:換行位元組不會出現在多位元組字元裡,數行數才不會數錯 +# The parser is fed UTF-8: a newline byte never occurs inside a multi-byte +# character, so counting rows cannot go wrong +_ENCODING = "utf-8" +_UTF16 = "utf-16-le" +_UTF16_UNIT_BYTES = 2 +_NEWLINE = b"\n" +# UTF-8 的後續位元組是 10xxxxxx / A UTF-8 continuation byte is 10xxxxxx +_CONTINUATION_MASK = 0xC0 +_CONTINUATION_BITS = 0x80 +# 一次算這麼多行的分類:一行一行查太慢,整份文件一次查則每按一個鍵都要重做 +# Categories are worked out this many lines at a time: one line per query is too +# slow, and the whole document per query would be redone on every keystroke +SPAN_CHUNK_LINES = 64 +# 超過這個大小就不解析,文字保持沒有顏色 / Beyond this the text is not parsed and stays uncoloured +MAX_SOURCE_BYTES = 2_000_000 +_REGION_PREFIX = "region." +_NAME_FIELD = "name" +# Tree-sitter 的位置是 (列, 欄)。一律用索引或解開來讀,不要用 ``.row`` 與 ``.column``: +# tree-sitter 0.26.0 的這兩個屬性每讀一次就少算那個整數一次參考,讀得夠多次之後 +# 直譯器會當掉(Python 3.12 之前連 0 這種小整數都會)。 +# A Tree-sitter point is (row, column). Read it by index or by unpacking, never +# through ``.row`` and ``.column``: in tree-sitter 0.26.0 each read of those +# attributes drops a reference to the integer, and after enough reads the +# interpreter crashes (before Python 3.12 even a small integer such as 0 does it). +_ROW = 0 + + +def changed_bytes(old: bytes, new: bytes) -> tuple[int, int, int]: + """ + 找出把舊內容變成新內容的最小一段修改 + Find the smallest edit that turns the old content into the new. + + 兩端都對齊到字元邊界,樹才不會被告知「修改從一個字的中間開始」。 + Both ends are moved onto character boundaries, so the tree is never told an + edit starts in the middle of a character. + + :param old: 舊的 UTF-8 內容 / the old UTF-8 content + :param new: 新的 UTF-8 內容 / the new UTF-8 content + :return: ``(起點, 舊內容的終點, 新內容的終點)``,都是位元組位置 + ``(start, end in the old content, end in the new)``, all byte offsets + """ + prefix = _common_prefix(old, new) + while prefix and (_continues(old, prefix) or _continues(new, prefix)): + prefix -= 1 + suffix = _common_suffix(old, new, min(len(old), len(new)) - prefix) + while suffix and _continues(old, len(old) - suffix): + suffix -= 1 + return prefix, len(old) - suffix, len(new) - suffix + + +def _continues(data: bytes, index: int) -> bool: + """這個位置是不是在一個字元的中間 / Whether this offset is inside a character.""" + return index < len(data) and data[index] & _CONTINUATION_MASK == _CONTINUATION_BITS + + +def _common_prefix(first: bytes, second: bytes) -> int: + """ + 兩段內容開頭相同的位元組數 + How many leading bytes two contents share. + + 以二分法比對整段,比對本身由 C 完成;逐一比較位元組在大檔案上慢得多。 + A binary search over slices, so the comparing is done in C. Walking the bytes + one at a time is far slower on a large file. + """ + low, high = 0, min(len(first), len(second)) + while low < high: + middle = (low + high + 1) // 2 + if first[low:middle] == second[low:middle]: + low = middle + else: + high = middle - 1 + return low + + +def _common_suffix(first: bytes, second: bytes, limit: int) -> int: + """兩段內容結尾相同的位元組數,不超過上限 / How many trailing bytes two contents share, up to a limit.""" + low, high = 0, limit + while low < high: + middle = (low + high + 1) // 2 + if first[len(first) - middle:len(first) - low] == second[len(second) - middle:len(second) - low]: + low = middle + else: + high = middle - 1 + return low + + +def point_at(source: bytes, offset: int) -> tuple[int, int]: + """ + 把位元組位置換成 Tree-sitter 的 (列, 欄) + Turn a byte offset into Tree-sitter's (row, column). + + :param source: UTF-8 內容 / the UTF-8 content + :param offset: 位元組位置 / the byte offset + :return: 0 起算的列,以及那一列裡的位元組欄 + the 0-based row, and the byte column within it + """ + return source.count(_NEWLINE, 0, offset), offset - (source.rfind(_NEWLINE, 0, offset) + 1) + + +def suffix_of(file_name: str) -> str: + """ + 取出檔名、路徑或 URI 的副檔名 + The suffix of a file name, path or URI. + + :param file_name: 檔名、路徑或 URI / the file name, path or URI + :return: 小寫、含點的副檔名,沒有時為空字串 / the lower-case suffix with its dot, or empty + """ + tail = file_name.replace("\\", "/").rsplit("/", 1)[-1] + dot = tail.rfind(".") + return tail[dot:].lower() if dot > 0 else "" + + +class _ColumnMap: + """ + 把 UTF-8 的位元組欄換成 UTF-16 的欄 + Turns byte columns of UTF-8 lines into UTF-16 columns. + """ + + def __init__(self, lines: list[str]) -> None: + """ + :param lines: 文件的每一行 / the lines of the document + """ + self._lines = lines + # 只含 ASCII 的行記成 None:位元組欄就是 UTF-16 欄 + # A line of ASCII only is kept as None: its byte columns are its UTF-16 columns + self._encoded: dict[int, bytes | None] = {} + + def units(self, row: int, byte_column: int | None) -> int: + """ + :param row: 0 起算的列 / the 0-based row + :param byte_column: 位元組欄,``None`` 表示行尾 / the byte column, or ``None`` + for the end of the line + :return: 0 起算的 UTF-16 欄 / the 0-based UTF-16 column + """ + if row >= len(self._lines): + return 0 + line = self._lines[row] + if row not in self._encoded: + self._encoded[row] = None if line.isascii() else line.encode(_ENCODING, "replace") + encoded = self._encoded[row] + if encoded is None: + return len(line) if byte_column is None else min(byte_column, len(line)) + before = encoded if byte_column is None else encoded[:byte_column] + return len(before.decode(_ENCODING, "replace").encode(_UTF16)) // _UTF16_UNIT_BYTES + + +@dataclass(frozen=True) +class LoadedGrammar: + """ + 載入完成、可以拿來解析的文法 + A grammar that is loaded and ready to parse with. + + :param language_id: 語言 ID / the language ID + :param language: Tree-sitter 的語言物件 / Tree-sitter's language object + :param highlights: 高亮查詢 / the highlight query + :param categories: 查詢裡每個有對應的名稱所代表的分類 + the category behind each capture name of the query that has one + :param regions: 結構區塊的查詢,沒有時為 ``None`` / the region query, if any + """ + + language_id: str + language: Any + highlights: Any + categories: dict[str, SyntaxCategory] + regions: Any = None + + +def load_grammar(spec: GrammarSpec, query_directory: Path) -> LoadedGrammar: + """ + 匯入一個文法並編譯它的查詢 + Import a grammar and compile its queries. + + :param spec: 文法的來源 / where the grammar comes from + :param query_directory: 專案自己的查詢檔所在的目錄 + the directory of the project's own query files + :return: 載入完成的文法 / the loaded grammar + :raises ImportError: Tree-sitter 或這個文法沒有安裝 + when Tree-sitter or the grammar is not installed + :raises ValueError: 文法的版本不相容,或查詢寫錯了 + when the grammar's version is incompatible or a query is wrong + :raises OSError: 查詢檔讀不出來 / when a query file cannot be read + """ + from tree_sitter import Language, Query + + module = spec.load() + language = Language(module.language()) + shipped = getattr(module, "HIGHLIGHTS_QUERY", "") + extra = own_query(query_directory, spec.language_id, HIGHLIGHTS_QUERY_FILE) + highlights = Query(language, f"{shipped}\n{extra}") + names = (highlights.capture_name(index) for index in range(highlights.capture_count)) + categories = {name: category for name in names + if (category := category_for(name)) is not None} + regions_text = own_query(query_directory, spec.language_id, REGIONS_QUERY_FILE) + regions = Query(language, regions_text) if regions_text.strip() else None + return LoadedGrammar(spec.language_id, language, highlights, categories, regions) + + +class TreeSitterSession: + """ + 一份文件的語法樹,文字每變一次就更新一次 + One document's syntax tree, brought up to date whenever its text changes. + """ + + def __init__(self, grammar: LoadedGrammar) -> None: + """ + :param grammar: 這份文件的語言的文法 / the grammar of the document's language + """ + from tree_sitter import Parser, QueryCursor + + self._grammar = grammar + self._parser = Parser(grammar.language) + self._highlight_cursor = QueryCursor(grammar.highlights) + self._tree: Any = None + self._parsed = False + self._text = "" + self._source = b"" + self._lines: list[str] = [""] + self._span_cache: dict[int, tuple[SyntaxSpan, ...]] = {} + self._regions: tuple[StructuralRegion, ...] | None = None + + @property + def language_id(self) -> str: + """這個 session 解析的語言 / The language this session parses.""" + return self._grammar.language_id + + @property + def has_errors(self) -> bool: + """目前的文字是否有語法錯誤 / Whether the current text has a syntax error.""" + return self._tree is not None and self._tree.root_node.has_error + + @property + def line_count(self) -> int: + """目前的文字有幾行 / How many lines the current text has.""" + return len(self._lines) + + def update(self, text: str) -> LineSpan | None: + """ + 換成新的文字並重新分析 + Take the new text and analyse it again. + + :param text: 整份文件的文字,以 ``\\n`` 分行 / the whole text, lines + separated by ``\\n`` + :return: 分類可能變了的那幾行;文字沒變時為 ``None`` + the lines whose categories may have changed, or ``None`` when the + text is the same + """ + if self._parsed and text == self._text: + return None + source = text.encode(_ENCODING, "replace") + rows = self._reparse(source) + self._parsed, self._text, self._source = True, text, source + self._lines = text.split("\n") + self._span_cache.clear() + self._regions = None + last = len(self._lines) + if rows is None: + return LineSpan(1, last) + return LineSpan(min(rows[0] + 1, last), min(rows[1] + 1, last)) + + def _reparse(self, source: bytes) -> tuple[int, int] | None: + """ + 解析新的內容,能沿用舊的樹就沿用 + Parse the new content, reusing the old tree where there is one. + + :return: 語法變了的列(0 起算,頭尾包含);整份都要重看時為 ``None`` + the rows whose syntax changed, 0-based and inclusive, or ``None`` + when the whole document has to be looked at again + """ + old_tree = self._tree + if len(source) > MAX_SOURCE_BYTES: + self._tree = None + # 本來就沒有樹的話沒有顏色可以清,只回報第一列 + # With no tree before there are no colours to clear: report the first row only + return None if old_tree is not None or not self._parsed else (0, 0) + if old_tree is None: + self._tree = self._parser.parse(source) + return None + start, old_end, new_end = changed_bytes(self._source, source) + start_point = point_at(source, start) + new_end_point = point_at(source, new_end) + old_tree.edit(start_byte=start, old_end_byte=old_end, new_end_byte=new_end, + start_point=start_point, old_end_point=point_at(self._source, old_end), + new_end_point=new_end_point) + self._tree = self._parser.parse(source, old_tree) + first, last = start_point[_ROW], new_end_point[_ROW] + for changed in old_tree.changed_ranges(self._tree): + first = min(first, changed.start_point[_ROW]) + last = max(last, changed.end_point[_ROW]) + return first, last + + def spans(self, line: int) -> tuple[SyntaxSpan, ...]: + """ + 取得一行的分類,外層的在前、內層的在後 + The categories of one line, outer spans before the spans inside them. + + :param line: 1 起算的行號 / the 1-based line + :return: 那一行的每一段;行號不存在時為空 + the spans on that line, none when there is no such line + """ + if self._tree is None or not 1 <= line <= len(self._lines): + return () + row = line - 1 + if row not in self._span_cache: + self._fill_chunk(row - row % SPAN_CHUNK_LINES) + return self._span_cache.get(row, ()) + + def _fill_chunk(self, first_row: int) -> None: + """算出從某一列開始的一整塊的分類 / Work out the categories of the chunk starting at a row.""" + end_row = min(first_row + SPAN_CHUNK_LINES, len(self._lines)) + rows: dict[int, list[SyntaxSpan]] = {row: [] for row in range(first_row, end_row)} + columns = _ColumnMap(self._lines) + for category, node in self._captured(first_row, end_row): + (start_row, start_column), (last_row, end_column) = node.start_point, node.end_point + for row in range(max(start_row, first_row), min(last_row, end_row - 1) + 1): + begin = columns.units(row, start_column if row == start_row else 0) + finish = columns.units(row, end_column if row == last_row else None) + if finish > begin: + rows[row].append(SyntaxSpan(begin + 1, finish - begin, category)) + for row, found in rows.items(): + self._span_cache[row] = tuple(found) + + def _captured(self, first_row: int, end_row: int) -> list[tuple[SyntaxCategory, Any]]: + """ + 查出一段列數裡被分類的節點,外層的在前 + The categorised nodes within some rows, outer nodes first. + + 同一個節點被好幾條規則抓到時,查詢裡寫在後面的那一條算數;這是 Tree-sitter + 的慣例,文法自帶的查詢也是照這個順序寫的。 + When several patterns capture one node, the pattern written later in the + query counts. That is Tree-sitter's convention, and the queries the + grammars ship are ordered for it. + """ + self._highlight_cursor.set_point_range((first_row, 0), (end_row, 0)) + best: dict[tuple[int, int], tuple[int, SyntaxCategory, Any]] = {} + for pattern, captures in self._highlight_cursor.matches(self._tree.root_node): + for name, nodes in captures.items(): + category = self._grammar.categories.get(name) + if category is None: + continue + for node in nodes: + key = (node.start_byte, node.end_byte) + if key not in best or pattern >= best[key][0]: + best[key] = (pattern, category, node) + ordered = sorted(best.items(), key=lambda item: (item[0][0], -item[0][1])) + return [(category, node) for _key, (_pattern, category, node) in ordered] + + def regions(self) -> tuple[StructuralRegion, ...]: + """ + 取得文件的結構區塊 + The structural regions of the document. + + :return: 依起點排序,外層的在前 / ordered by start, outer regions first + """ + if self._regions is None: + self._regions = tuple(self._find_regions()) + return self._regions + + def _find_regions(self) -> list[StructuralRegion]: + """用區塊查詢從樹裡找出結構區塊 / Find the structural regions in the tree with the region query.""" + if self._tree is None or self._grammar.regions is None: + return [] + from tree_sitter import QueryCursor + + columns = _ColumnMap(self._lines) + found: list[tuple[int, int, StructuralRegion]] = [] + captures = QueryCursor(self._grammar.regions).captures(self._tree.root_node) + for name, nodes in captures.items(): + kind = _region_kind(name) + if kind is None: + continue + for node in nodes: + found.append((node.start_byte, -node.end_byte, self._region(kind, node, columns))) + found.sort(key=lambda item: item[:2]) + return [region for _start, _end, region in found] + + def _region(self, kind: RegionKind, node: Any, columns: _ColumnMap) -> StructuralRegion: + """把一個節點變成結構區塊 / Turn one node into a structural region.""" + (start_row, start_column), (end_row, end_column) = node.start_point, node.end_point + text_range = TextRange( + Position(start_row + 1, columns.units(start_row, start_column) + 1), + Position(end_row + 1, columns.units(end_row, end_column) + 1)) + name_node = node.child_by_field_name(_NAME_FIELD) + name = "" if name_node is None else ( + self._source[name_node.start_byte:name_node.end_byte].decode(_ENCODING, "replace")) + return StructuralRegion(kind, text_range, name) + + +def _region_kind(capture_name: str) -> RegionKind | None: + """``region.class`` 這樣的名稱代表哪一種區塊 / The kind a name such as ``region.class`` stands for.""" + if not capture_name.startswith(_REGION_PREFIX): + return None + try: + return RegionKind(capture_name[len(_REGION_PREFIX):]) + except ValueError: + jeditor_logger.warning("unknown structural region capture: %s", capture_name) + return None + + +class TreeSitterEngine: + """ + 以 Tree-sitter 分析語法的引擎 + The engine that analyses syntax with Tree-sitter. + + 文法在第一次用到時才載入,之後共用;每份文件各自有 session。 + A grammar is loaded the first time it is needed and shared from then on. Each + document has a session of its own. + """ + + def __init__(self, grammars: Iterable[GrammarSpec] = BUILTIN_GRAMMARS, + query_directory: str | Path = QUERY_DIRECTORY) -> None: + """ + :param grammars: 要提供的文法 / the grammars to offer + :param query_directory: 專案自己的查詢檔所在的目錄 + the directory of the project's own query files + """ + self._specs = {spec.language_id: spec for spec in grammars} + self._suffixes = {suffix: spec.language_id + for spec in self._specs.values() for suffix in spec.suffixes} + self._query_directory = Path(query_directory) + self._loaded: dict[str, LoadedGrammar | None] = {} + self._lock = threading.Lock() + + def language_ids(self) -> tuple[str, ...]: + """ + 這個引擎目前能分析的語言 + The languages this engine can analyse right now. + + :return: 語言 ID;文法沒裝好的不算 / language IDs, leaving out any whose + grammar is not usable + """ + return tuple(language_id for language_id in self._specs + if self._grammar(language_id) is not None) + + def language_for(self, file_name: str) -> str | None: + """ + 依檔名判斷語言 + The language of a file, going by its name. + + :param file_name: 檔名、路徑或 URI / a file name, path or URI + :return: 語言 ID,不認得或文法不能用時為 ``None`` + the language ID, or ``None`` when it is unknown or its grammar is not usable + """ + language_id = self._suffixes.get(suffix_of(file_name)) + if language_id is None or self._grammar(language_id) is None: + return None + return language_id + + def open_session(self, language_id: str) -> SyntaxSession | None: + """ + 為一份文件開一個分析 session + Open an analysis session for one document. + + :param language_id: 語言 ID / the language ID + :return: 空的 session,不能分析這個語言時為 ``None`` + an empty session, or ``None`` when the language cannot be analysed + """ + grammar = self._grammar(language_id) + return None if grammar is None else TreeSitterSession(grammar) + + def _grammar(self, language_id: str) -> LoadedGrammar | None: + """取得載入好的文法,載入失敗的只試一次 / The loaded grammar; a failed load is tried only once.""" + with self._lock: + if language_id not in self._loaded: + self._loaded[language_id] = self._load(language_id) + return self._loaded[language_id] + + def _load(self, language_id: str) -> LoadedGrammar | None: + """載入一個文法,載不起來時記錄原因 / Load a grammar, logging why when it will not load.""" + spec = self._specs.get(language_id) + if spec is None: + return None + try: + return load_grammar(spec, self._query_directory) + except (ImportError, ValueError, OSError) as error: + jeditor_logger.warning("syntax grammar %s is unavailable: %s", language_id, error) + return None + + +@lru_cache(maxsize=1) +def shared_syntax_engine() -> TreeSitterEngine: + """ + 整個程序共用的語法引擎 + The syntax engine the whole process shares. + + 載入好的文法是不會變的資料,沒有理由每個視窗各載一次;文件各自的狀態在 session + 裡,不在引擎裡。 + A loaded grammar is data that never changes, so there is no reason for every + window to load its own. What belongs to a document lives in its session, not + in the engine. + + :return: 內建文法的引擎 / the engine for the built-in grammars + """ + return TreeSitterEngine() diff --git a/je_editor/core/__init__.py b/je_editor/core/__init__.py index 74c003a..a12cdc2 100644 --- a/je_editor/core/__init__.py +++ b/je_editor/core/__init__.py @@ -25,6 +25,9 @@ ) from je_editor.core.document.document_model import Document, DocumentStore, TextDocument from je_editor.core.events.event_hook import EventHook +from je_editor.core.language.language_request import ( + CancelRequest, LanguageReply, LanguageRequest, ReplyHandler +) from je_editor.core.language.language_service import ( LanguageCapability, LanguageService, LanguageServiceRegistry ) @@ -34,6 +37,10 @@ from je_editor.core.registry.named_registry import NamedRegistry from je_editor.core.remote.remote_session import RemoteSession, RemoteState, RemoteTransport from je_editor.core.services.editor_services import EditorServices +from je_editor.core.syntax.syntax_model import ( + LineSpan, NoSyntaxEngine, RegionKind, StructuralRegion, SyntaxCategory, SyntaxEngine, + SyntaxSession, SyntaxSpan +) from je_editor.core.uri.resource_uri import is_local_uri, to_path, to_uri, uri_key, uri_scheme from je_editor.core.workspace.workspace_model import ProjectRoot, Workspace from je_editor.utils.exception.exceptions import JEditorServiceException @@ -52,6 +59,10 @@ "RelatedInformation", "TextEdit", "QuickFix", "filter_diagnostics", # Language services "LanguageService", "LanguageServiceRegistry", "LanguageCapability", + "LanguageRequest", "LanguageReply", "ReplyHandler", "CancelRequest", + # Syntax analysis + "SyntaxEngine", "SyntaxSession", "SyntaxSpan", "SyntaxCategory", "LineSpan", + "StructuralRegion", "RegionKind", "NoSyntaxEngine", # Debugging "DebugSession", "DebugSessionFactory", "DebugState", "DebugLaunchRequest", "Breakpoint", "StackFrame", "Variable", "StepKind", diff --git a/je_editor/core/language/language_capability.py b/je_editor/core/language/language_capability.py new file mode 100644 index 0000000..ba21e3d --- /dev/null +++ b/je_editor/core/language/language_capability.py @@ -0,0 +1,30 @@ +""" +語言服務可以提供的功能 +What a language service can offer. + +獨立成一個模組,發問的形式與服務的介面才能都引用它而不互相匯入。 +A module of its own, so that the request shape and the service interface can +both refer to it without importing each other. +""" +from __future__ import annotations + +from enum import Enum + + +class LanguageCapability(Enum): + """ + 語言服務可以提供的功能 + What a language service can offer. + """ + + DIAGNOSTICS = "diagnostics" + COMPLETION = "completion" + HOVER = "hover" + SIGNATURE_HELP = "signature_help" + DEFINITION = "definition" + REFERENCES = "references" + RENAME = "rename" + FORMATTING = "formatting" + CODE_ACTION = "code_action" + DOCUMENT_SYMBOLS = "document_symbols" + SYNTAX_TREE = "syntax_tree" diff --git a/je_editor/core/language/language_request.py b/je_editor/core/language/language_request.py new file mode 100644 index 0000000..a712686 --- /dev/null +++ b/je_editor/core/language/language_request.py @@ -0,0 +1,152 @@ +""" +向語言服務發問與收到回覆的形式 +The shape of a question put to a language service, and of its reply. + +語言伺服器要過一陣子才回答,語法解析器當場就能回答;呼叫的人不該需要知道是哪一種。 +所以發問一律是「給一個收回覆的函式,拿回一個取消的函式」:當場能答的服務在 +``request()`` 回傳之前就呼叫它,要等的服務之後再呼叫。 +A language server answers after a while and a syntax parser answers on the spot, +and the caller should not have to know which it is talking to. So a question +always hands over a function to receive the reply and gets back a function to +cancel with: a service that can answer at once calls it before ``request()`` +returns, and one that has to wait calls it later. + +回覆最多只會送達一次,取消之後不會再送達。等待中的服務可能從自己的執行緒回覆, +要更新畫面的呼叫端得自己把它轉回畫面執行緒。 +A reply arrives at most once and never after a cancel. A service that waits may +reply from a thread of its own, so a caller that updates widgets has to move the +reply back to the widget thread itself. +""" +from __future__ import annotations + +import threading +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field + +from je_editor.core.diagnostics.diagnostic_model import Position +from je_editor.core.document.document_model import Document +from je_editor.core.language.language_capability import LanguageCapability + + +@dataclass(frozen=True) +class LanguageRequest: + """ + 向語言服務問的一個問題 + One question put to a language service. + + :param capability: 問的是哪一種功能 / which capability is being asked for + :param document: 問的是哪一份文件 / the document it is about + :param position: 問的是文件裡的哪個位置;跟位置無關的問題不用給 + the position in the document, omitted for questions that have none + :param options: 該功能自己的額外參數 / further parameters of that capability + """ + + capability: LanguageCapability + document: Document + position: Position | None = None + options: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LanguageReply: + """ + 語言服務對一個問題的回覆 + A language service's reply to one question. + + ``value`` 的型別由功能決定:``SYNTAX_TREE`` 是那份文件的 + :class:`~je_editor.core.syntax.syntax_model.SyntaxSession`,``DOCUMENT_SYMBOLS`` + 是有名稱的 :class:`~je_editor.core.syntax.syntax_model.StructuralRegion`。 + 其餘功能的型別在第一個提供它的服務出現時決定。 + The type of ``value`` depends on the capability: for ``SYNTAX_TREE`` it is + the document's :class:`~je_editor.core.syntax.syntax_model.SyntaxSession`, + and for ``DOCUMENT_SYMBOLS`` the named + :class:`~je_editor.core.syntax.syntax_model.StructuralRegion` objects. The + other capabilities get theirs when the first service offers them. + + :param request: 這是哪個問題的回覆 / the question this answers + :param service: 回答的服務名稱,沒有服務能回答時為空字串 + the name of the service that answered, empty when none could + :param value: 答案 / the answer + :param error: 答不出來的原因,答得出來時為空字串 + why there is no answer, empty when there is one + """ + + request: LanguageRequest + service: str = "" + value: object = None + error: str = "" + + @property + def ok(self) -> bool: + """是否答出來了 / Whether there is an answer.""" + return not self.error + + +ReplyHandler = Callable[[LanguageReply], None] +CancelRequest = Callable[[], None] + + +def nothing_to_cancel() -> None: + """已經答完的問題沒有東西可以取消 / A question already answered has nothing to cancel.""" + + +class ReplyOnce: + """ + 保證回覆最多送達一次、取消之後不再送達 + Makes sure a reply arrives at most once, and never after a cancel. + + 登記表把它包在呼叫端的函式外面,所以每個服務不必各自處理「回覆與取消同時發生」。 + The registry wraps the caller's function in this, so no service has to deal + with a reply and a cancel happening at the same moment on its own. + """ + + def __init__(self, on_reply: ReplyHandler) -> None: + """ + :param on_reply: 呼叫端收回覆的函式 / the caller's function for the reply + """ + self._on_reply = on_reply + self._lock = threading.Lock() + self._settled = False + self._cancel_service: CancelRequest = nothing_to_cancel + + @property + def settled(self) -> bool: + """是否已經回覆或取消 / Whether it has been answered or cancelled.""" + return self._settled + + def __call__(self, reply: LanguageReply) -> None: + """ + 送達回覆;已經回覆或取消過的話就丟掉 + Deliver the reply, or drop it when one was delivered or it was cancelled. + + :param reply: 服務的回覆 / the service's reply + """ + if self._settle(): + self._on_reply(reply) + + def cancel(self) -> None: + """ + 取消這個問題,並請服務停止處理 + Cancel the question and ask the service to stop working on it. + """ + if self._settle(): + self._cancel_service() + + def attach(self, cancel_service: CancelRequest) -> CancelRequest: + """ + 記下服務給的取消函式 + Remember the cancel function the service returned. + + :param cancel_service: 服務的取消函式 / the service's cancel function + :return: 呼叫端用來取消的函式 / the function the caller cancels with + """ + self._cancel_service = cancel_service + return self.cancel + + def _settle(self) -> bool: + """搶到「第一個」時回傳 ``True`` / ``True`` for whoever gets there first.""" + with self._lock: + if self._settled: + return False + self._settled = True + return True diff --git a/je_editor/core/language/language_service.py b/je_editor/core/language/language_service.py index 91995aa..86293c2 100644 --- a/je_editor/core/language/language_service.py +++ b/je_editor/core/language/language_service.py @@ -10,39 +10,25 @@ document opened, changed or closed, and it offers what it can do. The registry passes each document's lifecycle on to every service that handles its language. -補全、懸停說明這類「發問、等回覆」的呼叫形式由 Tree-sitter 與診斷那個里程碑決定, -這裡先固定生命週期與能力查詢。 -The request-and-reply calls, completion and hover among them, take their shape -in the Tree-sitter and diagnostics milestone; this fixes the lifecycle and the -capability lookup first. +補全、懸停說明、語法樹這類問題都走同一個 ``request()``:給一個收回覆的函式,拿回 +一個取消的函式。形式定義在 ``language_request.py``。 +Completion, hover, the syntax tree and every other question go through the one +``request()``: hand over a function for the reply, get back a function to cancel +with. The shape is defined in ``language_request.py``. """ from __future__ import annotations from collections.abc import Callable -from enum import Enum from typing import Protocol, runtime_checkable from je_editor.core.document.document_model import Document, DocumentStore +from je_editor.core.language.language_capability import LanguageCapability +from je_editor.core.language.language_request import ( + CancelRequest, LanguageReply, LanguageRequest, ReplyHandler, ReplyOnce, nothing_to_cancel +) from je_editor.core.registry.named_registry import NamedRegistry - -class LanguageCapability(Enum): - """ - 語言服務可以提供的功能 - What a language service can offer. - """ - - DIAGNOSTICS = "diagnostics" - COMPLETION = "completion" - HOVER = "hover" - SIGNATURE_HELP = "signature_help" - DEFINITION = "definition" - REFERENCES = "references" - RENAME = "rename" - FORMATTING = "formatting" - CODE_ACTION = "code_action" - DOCUMENT_SYMBOLS = "document_symbols" - SYNTAX_TREE = "syntax_tree" +__all__ = ["LanguageCapability", "LanguageService", "LanguageServiceRegistry"] @runtime_checkable @@ -97,6 +83,24 @@ def document_closed(self, document: Document) -> None: :param document: 關閉的文件 / the document that closed """ + def request(self, request: LanguageRequest, on_reply: ReplyHandler) -> CancelRequest: + """ + 回答一個問題 + Answer a question. + + 能當場回答的服務在回傳之前就呼叫 ``on_reply``;要等的服務之後再呼叫,可以從 + 自己的執行緒呼叫。不提供那個功能時回覆一個帶 ``error`` 的結果,不要丟例外。 + A service that can answer at once calls ``on_reply`` before returning; one + that has to wait calls it later, and may do so from a thread of its own. + Asked for a capability it does not offer, it replies with an ``error`` + rather than raising. + + :param request: 問題 / the question + :param on_reply: 收回覆的函式,最多呼叫一次 / receives the reply, called at + most once + :return: 取消這個問題的函式 / a function that cancels the question + """ + def shutdown(self) -> None: """ 放掉這個服務持有的程序、執行緒與連線 @@ -177,6 +181,31 @@ def services_for(self, document: Document, and (capability is None or capability in service.capabilities()) ] + def request(self, request: LanguageRequest, on_reply: ReplyHandler) -> CancelRequest: + """ + 把一個問題交給第一個能回答它的服務 + Put a question to the first service that can answer it. + + 「第一個」是登記順序裡第一個處理那份文件、又提供那個功能的服務。沒有這樣的 + 服務時,``on_reply`` 立刻收到一個帶 ``error`` 的回覆。 + The first one is the earliest registered service that handles the + document and offers the capability. When there is none, ``on_reply`` gets + a reply carrying an ``error`` straight away. + + :param request: 問題 / the question + :param on_reply: 收回覆的函式;最多呼叫一次,取消之後不會再呼叫 + receives the reply: called at most once, and never after a cancel + :return: 取消這個問題的函式 / a function that cancels the question + """ + reply_once = ReplyOnce(on_reply) + services = self.services_for(request.document, request.capability) + if not services: + reply_once(LanguageReply( + request, error=f"no language service offers {request.capability.value} " + f"for {request.document.uri}")) + return nothing_to_cancel + return reply_once.attach(services[0].request(request, reply_once)) + def shutdown(self) -> None: """ 關閉每一個服務,並停止轉送文件事件 diff --git a/je_editor/core/services/editor_services.py b/je_editor/core/services/editor_services.py index 72ce0e5..92f1daa 100644 --- a/je_editor/core/services/editor_services.py +++ b/je_editor/core/services/editor_services.py @@ -22,6 +22,7 @@ from je_editor.core.process.task_service import TaskRunner from je_editor.core.registry.named_registry import NamedRegistry from je_editor.core.remote.remote_session import RemoteTransport +from je_editor.core.syntax.syntax_model import NoSyntaxEngine, SyntaxEngine from je_editor.core.workspace.workspace_model import Workspace @@ -40,6 +41,9 @@ def __init__(self, workspace: Workspace | None = None) -> None: self.documents = DocumentStore() self.diagnostics = DiagnosticStore() self.languages = LanguageServiceRegistry(self.documents) + # 語法引擎;預設什麼語言都不會,由擁有者換成真正的解析器 + # The syntax engine: one that knows no language until the owner plugs a parser in + self.syntax: SyntaxEngine = NoSyntaxEngine() # 以轉接器的種類登記,例如 ``pdb`` / Registered by adapter type, such as ``pdb`` self.debug_adapters: NamedRegistry[DebugSessionFactory] = NamedRegistry("debug adapter") # 以執行的地方登記,例如 ``local`` / Registered by where they run, such as ``local`` diff --git a/je_editor/core/syntax/__init__.py b/je_editor/core/syntax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/core/syntax/syntax_model.py b/je_editor/core/syntax/syntax_model.py new file mode 100644 index 0000000..fe3a376 --- /dev/null +++ b/je_editor/core/syntax/syntax_model.py @@ -0,0 +1,244 @@ +""" +語法分析結果的模型,以及語法引擎的介面 +What syntax analysis yields, and the interface of the engine that produces it. + +編輯器要的是「這一行哪幾段是字串、哪幾段是關鍵字」以及「這份文件有哪些類別、函式 +與區塊」,而不是語法樹本身。這裡只定義這兩種答案與取得它們的介面;用什麼解析器、 +樹長什麼樣子,是轉接器的事,畫面層看不到。 +An editor wants to know which parts of a line are a string or a keyword, and +which classes, functions and blocks a document has. It does not want the syntax +tree. Only those two answers and the way to ask for them are defined here; which +parser is used and what its tree looks like is the adapter's business, and the +widgets never see it. + +行號與欄號跟診斷模型一樣從 1 起算。欄號以 UTF-16 的單位計,也就是 Qt 與語言伺服器 +協定用的單位,所以一個 emoji 佔兩欄。 +Lines and columns are 1-based, as in the diagnostics model. A column counts +UTF-16 units, which is what Qt and the language server protocol count, so an +emoji takes two. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol, runtime_checkable + +from je_editor.core.diagnostics.diagnostic_model import TextRange + + +class SyntaxCategory(Enum): + """ + 一段文字在語法上是什麼 + What a piece of text is, syntactically. + + 分類只說「是什麼」,不說「什麼顏色」;顏色由畫面層依主題決定,沒有顏色的分類 + 照樣回報,內層的插值才蓋得掉外層字串的顏色。 + A category says what something is, never what colour it has: the widgets pick + colours from the theme. Categories that get no colour are reported all the + same, which is how an interpolation resets the colour of the string around it. + """ + + COMMENT = "comment" + STRING = "string" + ESCAPE = "escape" + NUMBER = "number" + KEYWORD = "keyword" + OPERATOR = "operator" + PUNCTUATION = "punctuation" + FUNCTION = "function" + BUILTIN = "builtin" + TYPE = "type" + CONSTANT = "constant" + LITERAL = "literal" + VARIABLE = "variable" + SPECIAL_VARIABLE = "special_variable" + PROPERTY = "property" + KEY = "key" + EMBEDDED = "embedded" + + +@dataclass(frozen=True) +class SyntaxSpan: + """ + 一行裡屬於同一個分類的一段 + A stretch of one line that belongs to one category. + + :param column: 1 起算的起始欄 / the 1-based column it starts at + :param length: 長度,UTF-16 單位 / its length in UTF-16 units + :param category: 分類 / the category + """ + + column: int + length: int + category: SyntaxCategory + + +@dataclass(frozen=True) +class LineSpan: + """ + 連續的幾行,頭尾都包含 + A run of lines, both ends included. + + :param first: 1 起算的第一行 / the 1-based first line + :param last: 最後一行 / the last line + """ + + first: int + last: int + + +class RegionKind(Enum): + """ + 結構區塊的種類 + The kinds of structural region. + """ + + CLASS = "class" + FUNCTION = "function" + BLOCK = "block" + COLLECTION = "collection" + + +@dataclass(frozen=True) +class StructuralRegion: + """ + 文件裡的一個結構區塊:類別、函式、控制流程區塊或集合字面值 + One structural region of a document: a class, a function, a control-flow + block or a collection literal. + + :param kind: 種類 / its kind + :param range: 涵蓋的範圍 / the range it covers + :param name: 名稱,沒有名稱時為空字串 / its name, empty when it has none + """ + + kind: RegionKind + range: TextRange + name: str = "" + + @property + def is_multiline(self) -> bool: + """是否跨越一行以上,也就是能不能摺疊 / Whether it spans lines, and so can fold.""" + return self.range.end.line > self.range.start.line + + +@runtime_checkable +class SyntaxSession(Protocol): + """ + 一份文件的語法分析,文字每變一次就更新一次 + The syntax analysis of one document, brought up to date as its text changes. + + 一個 session 只屬於一份文件,也只在擁有那份文件的執行緒上使用。 + A session belongs to one document and is used only on the thread that owns + that document. + """ + + @property + def language_id(self) -> str: + """這個 session 解析的語言 / The language this session parses.""" + + @property + def has_errors(self) -> bool: + """目前的文字是否有語法錯誤 / Whether the current text has a syntax error.""" + + def update(self, text: str) -> LineSpan | None: + """ + 換成新的文字並重新分析 + Take the new text and analyse it again. + + :param text: 整份文件的文字,以 ``\\n`` 分行 / the whole text, lines + separated by ``\\n`` + :return: 分類可能變了的那幾行;文字沒變時為 ``None`` + the lines whose categories may have changed, or ``None`` when the + text is the same + """ + + def spans(self, line: int) -> tuple[SyntaxSpan, ...]: + """ + 取得一行的分類 + The categories of one line. + + 外層的在前、內層的在後,所以照順序套用時內層會蓋過外層。 + Outer spans come before the spans inside them, so applying them in order + lets the inner ones win. + + :param line: 1 起算的行號 / the 1-based line + :return: 那一行的每一段;行號不存在時為空 + the spans on that line, none when there is no such line + """ + + def regions(self) -> tuple[StructuralRegion, ...]: + """ + 取得文件的結構區塊 + The structural regions of the document. + + :return: 依起點排序,外層的在前 / ordered by start, outer regions first + """ + + +@runtime_checkable +class SyntaxEngine(Protocol): + """ + 能分析某些語言的語法引擎 + A syntax engine that can analyse some languages. + """ + + def language_ids(self) -> tuple[str, ...]: + """ + 這個引擎目前能分析的語言 + The languages this engine can analyse right now. + + :return: 語言 ID,例如 ``python`` / language IDs such as ``python`` + """ + + def language_for(self, file_name: str) -> str | None: + """ + 依檔名判斷語言 + The language of a file, going by its name. + + :param file_name: 檔名、路徑或 URI / a file name, path or URI + :return: 語言 ID,引擎不認得時為 ``None`` + the language ID, or ``None`` when the engine does not know it + """ + + def open_session(self, language_id: str) -> SyntaxSession | None: + """ + 為一份文件開一個分析 session + Open an analysis session for one document. + + :param language_id: 語言 ID / the language ID + :return: 空的 session,引擎不能分析這個語言時為 ``None`` + an empty session, or ``None`` when the engine cannot analyse it + """ + + +class NoSyntaxEngine: + """ + 什麼語言都不會的引擎,是還沒有接上解析器時的預設值 + An engine that knows no language: the default until a parser is plugged in. + """ + + def language_ids(self) -> tuple[str, ...]: + """沒有任何語言 / No languages at all.""" + return () + + def language_for(self, file_name: str) -> str | None: + """ + 任何檔名都不認得 + No file name is recognised. + + :param file_name: 檔名 / the file name + :return: 一律為 ``None`` / always ``None`` + """ + del file_name + return None + + def open_session(self, language_id: str) -> SyntaxSession | None: + """ + 不開任何 session + No session is ever opened. + + :param language_id: 語言 ID / the language ID + :return: 一律為 ``None`` / always ``None`` + """ + del language_id + return None diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index f9905b5..cb18f2b 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -59,8 +59,9 @@ find_occurrences, lines_containing, replace_whole_word, word_at ) from je_editor.utils.text_cleanup.text_cleanup import trim_trailing_whitespace -from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for -from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter +from je_editor.pyside_ui.code.syntax.highlighter_factory import ( + build_highlighter, dispose_highlighter, syntax_engine_for +) from je_editor.pyside_ui.dialog.search_ui.search_text_box import SearchBox from je_editor.pyside_ui.dialog.search_ui.search_replace_widget import SearchReplaceDialog from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict @@ -289,9 +290,10 @@ def __init__(self, main_window: EditorWidget | FullEditorWidget) -> None: QtGui.QFontMetricsF(self.font()).horizontalAdvance(" ") ) - # Python 語法高亮 - self.highlighter = PythonHighlighter(self.document(), main_window=self) - self.highlight_current_line() + # 語法高亮;依檔名挑選,新分頁先當成 Python + # Syntax highlighting, chosen by file name; a new tab counts as Python for now + self.highlighter: QtGui.QSyntaxHighlighter | None = None + self.reset_highlighter() # 關閉自動換行,改為單行顯示 self.setLineWrapMode(self.LineWrapMode.NoWrap) @@ -416,17 +418,24 @@ def reset_highlighter(self) -> None: 依目前檔案的副檔名重設語法高亮 Reset the syntax highlighter to match the current file's suffix. - Python 用專屬的高亮器;其他有規則的語言用通用高亮器;都不符合時仍套用 + 語法引擎認得的語言由它上色;其他有規則的語言用通用高亮器;都不符合時仍套用 Python 的(新檔案還沒有副檔名,多半就是要寫 Python)。 - Python gets its own highlighter, another language with rules gets the - generic one, and anything else still gets Python's — a new file has no - suffix yet and is usually about to become Python. + A language the syntax engine knows is coloured by it, another language + with rules gets the generic highlighter, and anything else still gets + Python's — a new file has no suffix yet and is usually about to become + Python. + + 舊的高亮器要先從文件上拿掉;它是文件的子物件,只換掉參照的話會留在那裡 + 繼續上色。 + The old highlighter is taken off the document first: it is a child of the + document, and replacing only the reference would leave it there, still + colouring. """ jeditor_logger.info("CodeEditor reset_highlighter") - suffix = Path(str(self.current_file)).suffix if self.current_file else "" - generic = highlighter_for(self.document(), suffix) if suffix else None - self.highlighter = generic if generic is not None else PythonHighlighter( - self.document(), main_window=self) + dispose_highlighter(self.highlighter) + file_path = str(self.current_file) if self.current_file else None + engine = syntax_engine_for(getattr(self.main_window, "main_window", None)) + self.highlighter = build_highlighter(self.document(), file_path, engine) self.highlight_current_line() def check_env(self) -> None: diff --git a/je_editor/pyside_ui/code/syntax/generic_syntax.py b/je_editor/pyside_ui/code/syntax/generic_syntax.py index 62d4f41..454d2fa 100644 --- a/je_editor/pyside_ui/code/syntax/generic_syntax.py +++ b/je_editor/pyside_ui/code/syntax/generic_syntax.py @@ -11,10 +11,12 @@ from __future__ import annotations import re +from collections.abc import Sequence from PySide6.QtCore import QRegularExpression from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument +from je_editor.pyside_ui.code.syntax.highlight_rules import HighlightRule, apply_rules from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.utils.syntax.language_rules import LanguageRules, rules_for @@ -58,13 +60,17 @@ class GenericHighlighter(QSyntaxHighlighter): A highlighter that colours a document from a language's rules. """ - def __init__(self, document: QTextDocument, rules: LanguageRules) -> None: + def __init__(self, document: QTextDocument, rules: LanguageRules, + extra_rules: Sequence[HighlightRule] = ()) -> None: """ :param document: 要上色的文件 / the document to highlight :param rules: 該語言的規則 / that language's rules + :param extra_rules: 疊在語言規則之上的規則,例如插件登記的關鍵字 + rules laid over the language's own, such as keywords a plugin registered """ super().__init__(document) self._rules = rules + self._extra_rules = list(extra_rules) self._patterns: list[tuple[QRegularExpression, QTextCharFormat]] = [] if rules.keywords: self._patterns.append( @@ -87,6 +93,9 @@ def highlightBlock(self, text: str) -> None: while matches.hasNext(): match = matches.next() self.setFormat(match.capturedStart(), match.capturedLength(), text_format) + # 插件的關鍵字排在註解之前:註解裡的字不該被它們上色 + # Plugin keywords go before the comments, which must not be coloured by them + apply_rules(self, text, self._extra_rules) self._highlight_line_comment(text) self._highlight_block_comment(text) @@ -121,14 +130,16 @@ def _highlight_block_comment(self, text: str) -> None: self.setCurrentBlockState(0) -def highlighter_for(document: QTextDocument, suffix: str) -> GenericHighlighter | None: +def highlighter_for(document: QTextDocument, suffix: str, + extra_rules: Sequence[HighlightRule] = ()) -> GenericHighlighter | None: """ 為某個副檔名建立高亮器 Build a highlighter for a file suffix. :param document: 要上色的文件 / the document to highlight :param suffix: 副檔名(含點)/ the file suffix, dot included + :param extra_rules: 疊在語言規則之上的規則 / rules laid over the language's own :return: 高亮器,沒有對應規則時為 ``None`` / the highlighter, or ``None`` """ rules = rules_for(suffix) - return GenericHighlighter(document, rules) if rules is not None else None + return GenericHighlighter(document, rules, extra_rules) if rules is not None else None diff --git a/je_editor/pyside_ui/code/syntax/highlight_rules.py b/je_editor/pyside_ui/code/syntax/highlight_rules.py new file mode 100644 index 0000000..e296a11 --- /dev/null +++ b/je_editor/pyside_ui/code/syntax/highlight_rules.py @@ -0,0 +1,137 @@ +""" +以正規表示式寫成的高亮規則,各個高亮器共用 +Highlight rules written as regular expressions, shared by every highlighter. + +插件以「副檔名加一組關鍵字」來擴充高亮,這套規則要疊在任何一種高亮器之上都一樣 +生效,所以從 Python 的高亮器裡搬出來放在這裡。 +A plugin extends highlighting with a suffix and a set of keywords, and those +rules have to work the same on top of whichever highlighter is in use, so they +were moved out of the Python highlighter into this module. +""" +from __future__ import annotations + +from PySide6.QtCore import QRegularExpression +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat + +from je_editor.pyside_ui.code.syntax.syntax_setting import syntax_extend_setting_dict +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict + +HighlightRule = tuple[QRegularExpression, QTextCharFormat] + + +def make_format(color: object) -> QTextCharFormat: + """ + 建立含前景色的格式 + Build a format with the given foreground. + + 顏色可以是主題顏色的鍵(內建規則都是這種),也可以是直接給的 QColor(插件沿用 + 的寫法)。 + The colour may be a theme colour key, as every built-in rule uses, or a + QColor given directly, which is what plugins do. + + :param color: 主題顏色的鍵或 QColor / a theme colour key or a QColor + :return: 格式;鍵不存在時沒有前景色 / the format, without a foreground when the + key is unknown + """ + text_format = QTextCharFormat() + if isinstance(color, str): + themed = actually_color_dict.get(color) + if themed is not None: + text_format.setForeground(themed) + return text_format + text_format.setForeground(color) + return text_format + + +def regex_rules(rule_setting: dict) -> list[HighlightRule]: + """ + 把一組「正則規則」設定變成規則 + Turn a group of regex rule settings into rules. + + :param rule_setting: ``{名稱: {"rules": (...), "color": ...}}`` + ``{name: {"rules": (...), "color": ...}}`` + :return: 規則清單 / the rules + """ + found: list[HighlightRule] = [] + for setting in rule_setting.values(): + text_format = make_format(setting.get("color")) + found.extend((QRegularExpression(rule), text_format) for rule in setting.get("rules", ())) + return found + + +def word_rules(word_setting: dict) -> list[HighlightRule]: + """ + 把一組「關鍵字」設定變成整字比對的規則 + Turn a group of keyword settings into whole-word rules. + + :param word_setting: ``{名稱: {"words": (...), "color": ...}}`` + ``{name: {"words": (...), "color": ...}}`` + :return: 規則清單 / the rules + """ + found: list[HighlightRule] = [] + for setting in word_setting.values(): + text_format = make_format(setting.get("color")) + found.extend((QRegularExpression(rf"\b{word}\b"), text_format) + for word in setting.get("words", ())) + return found + + +def plugin_rules(suffix: str) -> list[HighlightRule]: + """ + 取得插件為某個副檔名登記的規則 + The rules plugins registered for a file suffix. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 規則清單,沒有插件登記時為空 / the rules, none when no plugin registered any + """ + from je_editor.plugins import get_programming_language_plugin + + plugin = get_programming_language_plugin(suffix) + if plugin: + return regex_rules(plugin.get("syntax_rules", {})) + word_rules(plugin.get("syntax_words", {})) + # 向後相容:直接寫進 syntax_extend_setting_dict 的舊寫法 + # Backward compatible: the old way of writing into syntax_extend_setting_dict directly + legacy = syntax_extend_setting_dict.get(suffix) + return word_rules(legacy) if legacy else [] + + +def apply_rules(highlighter: QSyntaxHighlighter, text: str, rules: list[HighlightRule]) -> None: + """ + 把規則套用到高亮器正在處理的那一行 + Apply rules to the line a highlighter is working on. + + :param highlighter: 正在處理一行的高亮器 / the highlighter, in the middle of a line + :param text: 那一行的文字 / the text of that line + :param rules: 要套用的規則,後面的蓋過前面的 / the rules, later ones over earlier ones + """ + for pattern, text_format in rules: + matches = pattern.globalMatch(text) + while matches.hasNext(): + match = matches.next() + highlighter.setFormat(match.capturedStart(), match.capturedLength(), text_format) + + +def release_document(highlighter: QSyntaxHighlighter) -> None: + """ + 把高亮器從文件上拿掉,而不讓文件以為自己被編輯了 + Take a highlighter off its document without the document looking edited. + + 拿掉高亮器時 Qt 會清掉它畫的顏色,並為此送出「內容變了」的訊號;聽那個訊號的 + 人(例如分頁的未儲存標記)分不出這跟真正的編輯有什麼不同,所以這段期間先把 + 文件的訊號擋住。版面是直接被通知的,不靠訊號,畫面照樣會更新。 + Qt clears the colours a highlighter painted when it is taken off, and sends + the content-changed signal for it. Whoever listens, the tab's unsaved mark + for one, cannot tell that from a real edit, so the document's signals are + held back meanwhile. The layout is told directly rather than by signal, and + the view still updates. + + :param highlighter: 要拿掉的高亮器 / the highlighter to take off + """ + document = highlighter.document() + if document is None: + return + was_blocked = document.blockSignals(True) + try: + highlighter.setDocument(None) + finally: + document.blockSignals(was_blocked) diff --git a/je_editor/pyside_ui/code/syntax/highlighter_factory.py b/je_editor/pyside_ui/code/syntax/highlighter_factory.py new file mode 100644 index 0000000..72138a5 --- /dev/null +++ b/je_editor/pyside_ui/code/syntax/highlighter_factory.py @@ -0,0 +1,103 @@ +""" +為一份文件挑選並建立高亮器 +Choose and build the highlighter for a document. + +語法引擎會的語言用它來上色;其餘的照原本的規則:有關鍵字表的語言用通用高亮器, +都不符合的用 Python 的。插件為副檔名登記的關鍵字疊在任何一種之上。 +A language the syntax engine knows is coloured by it. Everything else follows the +older rules: a language with a keyword table gets the generic highlighter, and +whatever is left gets Python's. Keywords a plugin registered for the suffix are +laid over whichever one is used. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtGui import QSyntaxHighlighter, QTextDocument + +from je_editor.adapters.syntax.tree_sitter_engine import shared_syntax_engine +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.syntax.syntax_model import SyntaxEngine, SyntaxSession +from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for +from je_editor.pyside_ui.code.syntax.highlight_rules import plugin_rules, release_document +from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter +from je_editor.pyside_ui.code.syntax.tree_sitter_highlighter import TreeSitterHighlighter +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + +# 使用者設定裡選擇語法引擎的鍵與它的兩個值 +# The user setting that selects the syntax engine, and its two values +SYNTAX_ENGINE_SETTING = "syntax_engine" +TREE_SITTER_ENGINE = "tree_sitter" +CLASSIC_ENGINE = "classic" +# 還沒有檔名的新分頁當成這個語言 / A new tab that has no file name yet counts as this language +UNNAMED_LANGUAGE = "python" +UNNAMED_SUFFIX = ".py" + + +def syntax_engine_for(main_window: object) -> SyntaxEngine: + """ + 取得視窗的語法引擎 + The syntax engine of a window. + + :param main_window: 主視窗 / the main window + :return: 視窗核心服務裡的引擎;視窗沒有核心服務(宿主程式自己的視窗、測試替身) + 時用整個程序共用的那一個 + the engine in the window's core services, or the one the whole process + shares when the window has none, as a host's own window or a test double + """ + services = getattr(main_window, "services", None) + return services.syntax if isinstance(services, EditorServices) else shared_syntax_engine() + + +def _session_for(engine: SyntaxEngine, file_path: str | None) -> SyntaxSession | None: + """照設定與檔名開一個語法 session,不該用語法引擎時為 ``None`` / A session by setting and file name, or ``None``.""" + if user_setting_dict.get(SYNTAX_ENGINE_SETTING, TREE_SITTER_ENGINE) != TREE_SITTER_ENGINE: + return None + language_id = engine.language_for(file_path) if file_path else UNNAMED_LANGUAGE + return None if language_id is None else engine.open_session(language_id) + + +def build_highlighter(document: QTextDocument, file_path: str | None, + engine: SyntaxEngine) -> QSyntaxHighlighter: + """ + 為文件建立適合它的高亮器 + Build the highlighter that suits a document. + + :param document: 要上色的文件 / the document to highlight + :param file_path: 文件的檔案路徑,還沒有檔名時為 ``None`` + the document's file path, or ``None`` when it has no name yet + :param engine: 語法引擎 / the syntax engine + :return: 已經接上文件的高亮器 / a highlighter already attached to the document + """ + suffix = Path(file_path).suffix if file_path else "" + extra_rules = plugin_rules(suffix) if suffix else [] + session = _session_for(engine, file_path) + if session is not None: + return TreeSitterHighlighter(document, session, extra_rules) + generic = highlighter_for(document, suffix, extra_rules) if suffix else None + if generic is not None: + return generic + # Python 的高亮器自己會加上插件的規則 / Python's highlighter adds the plugin rules itself + return PythonHighlighter(document, suffix=suffix if file_path else UNNAMED_SUFFIX) + + +def dispose_highlighter(highlighter: QSyntaxHighlighter | None) -> None: + """ + 把不再使用的高亮器從文件上拿掉並刪除 + Take a highlighter that is no longer used off its document and delete it. + + 高亮器是文件的子物件;只把 Python 這邊的參照換掉的話,舊的那一個還連在文件上, + 每次編輯都繼續上色。 + A highlighter is a child of its document: replacing only the Python reference + leaves the old one connected, colouring on every edit. + + :param highlighter: 要丟掉的高亮器,``None`` 時什麼都不做 + the highlighter to drop; ``None`` does nothing + """ + if highlighter is None: + return + if isinstance(highlighter, TreeSitterHighlighter): + highlighter.detach() + else: + release_document(highlighter) + highlighter.deleteLater() diff --git a/je_editor/pyside_ui/code/syntax/python_syntax.py b/je_editor/pyside_ui/code/syntax/python_syntax.py index 79bd893..9c34f8f 100644 --- a/je_editor/pyside_ui/code/syntax/python_syntax.py +++ b/je_editor/pyside_ui/code/syntax/python_syntax.py @@ -8,18 +8,17 @@ # Only imported during type checking to avoid circular imports from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor -from PySide6.QtCore import QRegularExpression -from PySide6.QtGui import QSyntaxHighlighter -from PySide6.QtGui import QTextCharFormat, QTextDocument +from PySide6.QtGui import QSyntaxHighlighter, QTextDocument -# 匯入語法設定,包括關鍵字、規則、擴展設定 -# Import syntax settings: keywords, rules, and extended settings +from je_editor.pyside_ui.code.syntax.highlight_rules import ( + HighlightRule, apply_rules, make_format, plugin_rules, regex_rules, word_rules +) +# 匯入語法設定,包括關鍵字與規則 +# Import syntax settings: keywords and rules from je_editor.pyside_ui.code.syntax.syntax_setting import ( syntax_word_setting_dict, syntax_rule_setting_dict, - syntax_extend_setting_dict ) -from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -27,81 +26,50 @@ class PythonHighlighter(QSyntaxHighlighter): """ Python 語法高亮類別,繼承自 QSyntaxHighlighter Python syntax highlighter class, inherits from QSyntaxHighlighter + + 以正規表示式一行一行比對。語法引擎認得的檔案改由 + ``TreeSitterHighlighter`` 上色;這個類別留給語法引擎不能用、或使用者選了 + ``classic`` 的時候,以及沒有任何規則可用的副檔名。 + It matches regular expressions line by line. Files the syntax engine knows + are coloured by ``TreeSitterHighlighter`` instead; this class remains for + when the engine is unavailable or the user chose ``classic``, and for + suffixes nothing else has rules for. """ - @staticmethod - def _make_format(color: object) -> QTextCharFormat: - """ - 建立含前景色的 QTextCharFormat - Build a QTextCharFormat with the given foreground. + # 留著原本的名稱:PyBreeze 的契約測試從這裡確認「顏色可以是主題顏色的鍵」 + # Kept under its old name: PyBreeze's contract test checks here that a colour + # may be a theme colour key + _make_format = staticmethod(make_format) - 顏色可以是主題顏色的鍵(內建規則都是這種),也可以是直接給的 QColor - (插件沿用的寫法)。 - The colour may be a theme colour key, as every built-in rule uses, or a - QColor given directly, which is what plugins do. + def __init__(self, parent: QTextDocument | None = None, main_window: CodeEditor = None, + suffix: str | None = None) -> None: + """ + :param parent: 要上色的文件 / the document to highlight + :param main_window: 文件所在的編輯器,用它目前的檔名判斷副檔名 + the editor holding the document, whose current file gives the suffix + :param suffix: 直接指定副檔名(含點);給了就不看 ``main_window`` + the suffix, dot included, given directly; ``main_window`` is then ignored """ - fmt = QTextCharFormat() - if isinstance(color, str): - themed = actually_color_dict.get(color) - if themed is not None: - fmt.setForeground(themed) - return fmt - fmt.setForeground(color) - return fmt - - def _add_regex_rules(self, rule_setting: dict) -> None: - """加入一組「正則規則」設定 / Append a group of regex highlight rules.""" - for rule_variable_dict in rule_setting.values(): - fmt = self._make_format(rule_variable_dict.get("color")) - for rule in rule_variable_dict.get("rules", ()): # 正則規則 / regex rules - self.highlight_rules.append((QRegularExpression(rule), fmt)) - - def _add_word_rules(self, word_setting: dict) -> None: - """加入一組「關鍵字」規則 (使用 \\b 包住) / Append whole-word keyword rules.""" - for rule_variable_dict in word_setting.values(): - fmt = self._make_format(rule_variable_dict.get("color")) - for word in rule_variable_dict.get("words", ()): # 關鍵字清單 / keyword list - self.highlight_rules.append((QRegularExpression(rf"\b{word}\b"), fmt)) - - def _add_plugin_rules(self, current_file_suffix: str) -> None: - """依副檔名載入插件或相容設定的語法規則 / Load plugin/legacy syntax rules for the suffix.""" - from je_editor.plugins import get_programming_language_plugin - plugin = get_programming_language_plugin(current_file_suffix) - if plugin: - self._add_regex_rules(plugin.get("syntax_rules", {})) - self._add_word_rules(plugin.get("syntax_words", {})) - return - legacy = syntax_extend_setting_dict.get(current_file_suffix) - if legacy: - # 向後相容:使用舊的 syntax_extend_setting_dict - # Backward compatible: use old syntax_extend_setting_dict - self._add_word_rules(legacy) - - def __init__(self, parent: QTextDocument | None = None, main_window: CodeEditor = None) -> None: jeditor_logger.info(f"Init PythonHighlighter parent: {parent}") super().__init__(parent) - self.highlight_rules = [] # 儲存所有高亮規則 / store all highlight rules - - if main_window is not None and main_window.current_file is not None: + if suffix is not None: + current_file_suffix = suffix + elif main_window is not None and main_window.current_file is not None: current_file_suffix = Path(main_window.current_file).suffix else: current_file_suffix = ".py" - self._add_regex_rules(syntax_rule_setting_dict) + # 儲存所有高亮規則 / store all highlight rules + self.highlight_rules: list[HighlightRule] = regex_rules(syntax_rule_setting_dict) if current_file_suffix == ".py": - self._add_word_rules(syntax_word_setting_dict) + self.highlight_rules += word_rules(syntax_word_setting_dict) else: - self._add_plugin_rules(current_file_suffix) + self.highlight_rules += plugin_rules(current_file_suffix) def highlightBlock(self, text: str) -> None: """ 對每一行文字進行語法高亮 Apply syntax highlighting to each block of text """ - for pattern, pattern_format in self.highlight_rules: - match_iterator = pattern.globalMatch(text) # 全域比對 / global regex match - while match_iterator.hasNext(): - match = match_iterator.next() - # 設定比對到的文字格式 / apply format to matched text - self.setFormat(match.capturedStart(), match.capturedLength(), pattern_format) + apply_rules(self, text, self.highlight_rules) diff --git a/je_editor/pyside_ui/code/syntax/tree_sitter_highlighter.py b/je_editor/pyside_ui/code/syntax/tree_sitter_highlighter.py new file mode 100644 index 0000000..d4ae155 --- /dev/null +++ b/je_editor/pyside_ui/code/syntax/tree_sitter_highlighter.py @@ -0,0 +1,216 @@ +""" +把語法引擎的分類畫到文件上的高亮器 +The highlighter that paints a syntax engine's categories onto a document. + +這個高亮器不知道語法樹是什麼:它只把文件的文字交給 session,再問「第幾行有哪幾段、 +各是什麼分類」,然後依主題上色。換一個解析器不必動這裡。 +This highlighter does not know what a syntax tree is. It hands the document's +text to a session, asks which stretches of which line are what, and colours them +from the theme. Swapping the parser changes nothing here. + +Qt 的高亮器只會重畫被編輯的那幾行,但語法上的影響可以遠得多:打開一個三引號, +後面每一行都變成字串。session 會回報語法因為這次編輯而不同的那幾行;編輯位置之後 +的行,靠改變區塊狀態讓 Qt 在同一輪裡接著畫下去,之前的行則在事件迴圈的下一輪補畫。 +Qt's highlighter repaints only the lines that were edited, yet the effect on the +syntax can reach much further: open a triple quote and every line after it turns +into a string. The session reports the lines whose syntax came out different. +Those after the edit are reached by changing the block state, which makes Qt +carry on within the same pass; those before it are repainted on the next turn of +the event loop. +""" +from __future__ import annotations + +from collections.abc import Sequence + +from PySide6.QtCore import QTimer +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument + +from je_editor.core.syntax.syntax_model import LineSpan, SyntaxCategory, SyntaxSession +from je_editor.pyside_ui.code.syntax.highlight_rules import ( + HighlightRule, apply_rules, make_format, release_document +) + +# 每一種分類用哪個主題顏色;沒列出的分類用預設的文字顏色 +# The theme colour each category uses; a category not listed takes the default text colour +CATEGORY_COLOURS: dict[SyntaxCategory, str] = { + SyntaxCategory.COMMENT: "syntax_comment_color", + SyntaxCategory.STRING: "syntax_string_color", + SyntaxCategory.ESCAPE: "syntax_builtin_color", + SyntaxCategory.NUMBER: "syntax_number_color", + SyntaxCategory.KEYWORD: "syntax_keyword_color", + SyntaxCategory.LITERAL: "syntax_keyword_color", + SyntaxCategory.KEY: "syntax_keyword_color", + SyntaxCategory.FUNCTION: "syntax_function_color", + SyntaxCategory.BUILTIN: "syntax_builtin_color", + SyntaxCategory.TYPE: "syntax_builtin_color", + SyntaxCategory.SPECIAL_VARIABLE: "syntax_self_color", +} +# Qt 把段落之間記成這個字元;語法引擎要的是換行 +# Qt stores this character between paragraphs; the syntax engine wants a newline +_PARAGRAPH_SEPARATOR = "
" +# 區塊狀態只用來告訴 Qt「下一行也要重畫」,在這兩個值之間來回切換 +# The block state only tells Qt that the next line needs repainting too, by +# switching between these two values +_STATE_A = 0 +_STATE_B = 1 +# 編輯位置之前要補畫的行超過這個數目時,整份文件一次重畫;一行一行畫每行都會送出 +# 一次「文字變了」的訊號 +# When more lines than this need repainting before the edit, the whole document is +# repainted at once: line by line, every one of them sends a text-changed signal +SINGLE_REPAINT_LIMIT = 50 + + +def category_formats() -> dict[SyntaxCategory, QTextCharFormat]: + """ + 依目前的主題為每一種分類建立格式 + Build the format of every category from the current theme. + + 沒有顏色的分類也有一個(空的)格式:字串裡的插值要靠它把字串的顏色蓋回預設。 + A category without a colour gets a format too, an empty one: that is what + puts an interpolation inside a string back to the default colour. + + :return: 每一種分類的格式 / the format of each category + """ + return {category: make_format(CATEGORY_COLOURS.get(category, "")) + for category in SyntaxCategory} + + +class TreeSitterHighlighter(QSyntaxHighlighter): + """ + 依語法引擎的分類上色的高亮器 + The highlighter that colours by a syntax engine's categories. + """ + + def __init__(self, document: QTextDocument, session: SyntaxSession, + extra_rules: Sequence[HighlightRule] = ()) -> None: + """ + :param document: 要上色的文件 / the document to highlight + :param session: 這份文件的語法分析 / the syntax analysis of this document + :param extra_rules: 疊在語法分類之上的規則,例如插件登記的關鍵字 + rules laid over the categories, such as keywords a plugin registered + """ + # 先不給文件:Qt 依連接的順序呼叫 slot,這個高亮器更新語法分析的 slot 要排在 + # Qt 自己重畫編輯行的 slot 之前,重畫時問到的才是新的語法樹。 + # No document yet: Qt calls slots in the order they were connected, and + # this highlighter's slot that updates the analysis has to come before + # Qt's own slot that repaints the edited lines, so the repaint asks an + # up-to-date tree. + super().__init__(None) + self.setParent(document) + self._session = session + self._extra_rules = list(extra_rules) + self._formats = category_formats() + self._document: QTextDocument | None = document + self._line_count = document.blockCount() + # Qt 這一輪要一路畫到第幾行 / The line Qt's current pass has to carry on to + self._carry_on_to = 0 + # 編輯位置之前、等著補畫的行 / The lines before the edit that wait to be repainted + self._earlier: LineSpan | None = None + self._catch_up = QTimer(self) + self._catch_up.setSingleShot(True) + self._catch_up.setInterval(0) + self._catch_up.timeout.connect(self._repaint_earlier) + document.contentsChange.connect(self._analyse_again) + self._session.update(self._document_text()) + self.setDocument(document) + + @property + def session(self) -> SyntaxSession: + """這份文件的語法分析 / The syntax analysis of this document.""" + return self._session + + def detach(self) -> None: + """ + 停止上色並放開文件 + Stop highlighting and let go of the document. + + 換成另一個高亮器之前要呼叫;不然舊的那一個仍然連在文件上繼續做白工。 + Called before another highlighter takes over: otherwise the old one stays + connected to the document and keeps working for nothing. + """ + self._catch_up.stop() + self._earlier = None + if self._document is not None: + self._document.contentsChange.disconnect(self._analyse_again) + self._document = None + release_document(self) + + def highlightBlock(self, text: str) -> None: + """為一行上色 / Colour one line.""" + line = self.currentBlock().blockNumber() + 1 + for span in self._session.spans(line): + self.setFormat(span.column - 1, span.length, self._formats[span.category]) + apply_rules(self, text, self._extra_rules) + if line < self._carry_on_to: + # 狀態跟原本不同,Qt 就會接著畫下一行 + # A state that differs from before makes Qt go on to the next line + self.setCurrentBlockState( + _STATE_B if self.currentBlockState() != _STATE_B else _STATE_A) + else: + self._carry_on_to = 0 + + def _document_text(self) -> str: + """文件的文字,一個段落一行 / The document's text, one line per paragraph.""" + if self._document is None: + return "" + return self._document.toRawText().replace(_PARAGRAPH_SEPARATOR, "\n") + + def _analyse_again(self, position: int, _removed: int, _added: int) -> None: + """ + 文件變了:更新語法分析,並記下這次編輯之外還有哪些行要重畫 + The document changed: update the analysis, and note which lines beyond + the edit itself have to be repainted. + + :param position: 編輯開始的位置 / where the edit starts + """ + if self._document is None: + return + changed = self._session.update(self._document_text()) + if changed is None: + return + line_count = self._document.blockCount() + shift, self._line_count = line_count - self._line_count, line_count + self._carry_on_to = changed.last + edited_line = self._document.findBlock(position).blockNumber() + 1 + if changed.first < edited_line: + before = LineSpan(changed.first, edited_line - 1) + self._earlier = _merged(self._earlier, before, shift, line_count) + self._catch_up.start() + + def _repaint_earlier(self) -> None: + """補畫編輯位置之前、語法也跟著變了的行 / Repaint the lines before the edit whose syntax changed too.""" + span, self._earlier = self._earlier, None + if span is None or self._document is None: + return + if span.last - span.first >= SINGLE_REPAINT_LIMIT: + self.rehighlight() + return + for line in range(span.first, span.last + 1): + block = self._document.findBlockByNumber(line - 1) + if block.isValid(): + self.rehighlightBlock(block) + + +def _merged(waiting: LineSpan | None, changed: LineSpan, shift: int, line_count: int) -> LineSpan: + """ + 把新回報的行併進還沒畫完的行 + Merge newly reported lines into the ones still waiting. + + 還在等的行可能因為這次編輯多了或少了幾行而移位,所以往移動的方向放寬。多畫幾行 + 沒有壞處,少畫才會留下舊的顏色。 + The waiting lines may have moved because this edit added or removed lines, so + the span is widened in the direction they moved. Repainting a few lines too + many is harmless; too few would leave stale colours behind. + + :param waiting: 還沒畫完的行 / the lines still waiting + :param changed: 這次分析回報的行 / the lines this analysis reported + :param shift: 這次編輯讓行數增加了多少,減少時為負 + how many lines this edit added, negative when it removed some + :param line_count: 文件現在的行數 / how many lines the document has now + :return: 要重畫的行 / the lines to repaint + """ + if waiting is None: + return changed + first = min(changed.first, waiting.first + min(shift, 0)) + last = max(changed.last, waiting.last + max(shift, 0)) + return LineSpan(max(first, 1), min(last, line_count)) diff --git a/je_editor/pyside_ui/main_ui/editor/editor_widget.py b/je_editor/pyside_ui/main_ui/editor/editor_widget.py index 02ecadf..8615fd9 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -127,7 +127,12 @@ def __init__(self, main_window: EditorMain) -> None: self.code_result = CodeRecord() # 監聽文字變更以標記未儲存狀態 / Track text changes for unsaved indicator - self.code_edit.textChanged.connect(self._on_text_changed) + # 用 contentsChange 而不是 textChanged:高亮器重畫時也會送出 textChanged, + # 開檔或換主題之後分頁就被標成「未儲存」;contentsChange 只在內容真的變了才送 + # contentsChange rather than textChanged: a highlighter repainting sends + # textChanged too, which left a tab marked unsaved after opening a file or + # switching theme. contentsChange is sent only when the content changed + self.code_edit.document().contentsChange.connect(self._on_contents_change) self.code_result_cursor = self.code_result.textCursor() # 捲動區包裝編輯器與輸出 / Scroll areas for editor and result @@ -377,6 +382,10 @@ def treeview_click(self) -> None: if path.is_file(): self.open_an_file(path) + def _on_contents_change(self, _position: int, _removed: int, _added: int) -> None: + """文件內容變了 / The document's content changed.""" + self._on_text_changed() + def _on_text_changed(self) -> None: """ 文字變更時標記為未儲存,並在 tab 標題加上 * diff --git a/je_editor/pyside_ui/main_ui/menu/submenu_map.py b/je_editor/pyside_ui/main_ui/menu/submenu_map.py index 910bbe0..f9dc070 100644 --- a/je_editor/pyside_ui/main_ui/menu/submenu_map.py +++ b/je_editor/pyside_ui/main_ui/menu/submenu_map.py @@ -15,9 +15,40 @@ """ from __future__ import annotations +import gc + from PySide6.QtWidgets import QApplication, QMenu, QMenuBar +def menus_in(application: QApplication) -> list[QMenu]: + """ + 找出應用程式裡現有的每一個選單 + Every menu that exists in the application. + + ``allWidgets()`` 交回來的是一串指標,PySide 再一個一個包成 Python 物件;每包一個 + 都是一次配置,而配置可能觸發循環垃圾回收。回收會刪掉那些由 Python 擁有、卻已經 + 沒有人參考的元件——它們正好也在那串指標裡。之後再去包那個指標,就是往已釋放的 + 記憶體寫入,整個行程會壞掉。所以走訪期間先把回收停住,每個指標才都有效。 + ``allWidgets()`` hands back a list of pointers, which PySide then wraps one + at a time. Every wrapper is an allocation, and an allocation can start a + collection of cyclic garbage. A collection deletes the widgets Python owns + that nothing refers to any more, and those are among the pointers in that + very list: wrapping one afterwards writes into freed memory and takes the + process down. Holding the collector off for the walk keeps every pointer + valid. + + :param application: 執行中的應用程式 / the running application + :return: 所有的選單 / all of its menus + """ + was_enabled = gc.isenabled() + gc.disable() + try: + return [widget for widget in application.allWidgets() if isinstance(widget, QMenu)] + finally: + if was_enabled: + gc.enable() + + def submenus_of(menu_bar: QMenuBar) -> dict: """ 建立「動作 → 子選單」對照表 @@ -37,6 +68,5 @@ def submenus_of(menu_bar: QMenuBar) -> dict: menus = list(menu_bar.findChildren(QMenu)) application = QApplication.instance() if application is not None: - menus.extend( - widget for widget in application.allWidgets() if isinstance(widget, QMenu)) + menus.extend(menus_in(application)) return {menu.menuAction(): menu for menu in menus} diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py index 4faf639..71072dd 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_setting_file.py @@ -33,6 +33,8 @@ "restore_session": True, # 啟動時還原分頁 / Restore tabs on startup # 工作目錄以外另外加入工作區的資料夾 / Folders added to the workspace beside the working directory "workspace_roots": [], + # 語法高亮用哪個引擎:tree_sitter 或 classic / Which engine colours syntax: tree_sitter or classic + "syntax_engine": "tree_sitter", # 使用者改過的快捷鍵,只記與預設不同的 / Shortcuts the user changed, defaults omitted "shortcuts": {}, } diff --git a/je_editor/utils/theme/theme_colors.py b/je_editor/utils/theme/theme_colors.py index 81658cd..3f7f290 100644 --- a/je_editor/utils/theme/theme_colors.py +++ b/je_editor/utils/theme/theme_colors.py @@ -51,6 +51,7 @@ "syntax_number_color": [181, 206, 168], "syntax_builtin_color": [78, 201, 176], "syntax_self_color": [197, 134, 192], + "syntax_function_color": [220, 220, 170], "trailing_whitespace_color": [120, 70, 70], } @@ -82,6 +83,7 @@ "syntax_number_color": [9, 134, 88], "syntax_builtin_color": [38, 127, 153], "syntax_self_color": [154, 0, 154], + "syntax_function_color": [121, 94, 38], "trailing_whitespace_color": [255, 205, 205], } diff --git a/pyproject.toml b/pyproject.toml index f99da73..fec244d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,9 @@ license-files = ["LICENSE"] dependencies = [ "PySide6==6.11.2", "qt-material", "yapf", "frontengine", "pycodestyle", "jedi", "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", - "watchdog", "ruff", "gitpython>=3.1.59" + "watchdog", "ruff", "gitpython>=3.1.59", + "tree-sitter==0.26.0", "tree-sitter-python==0.25.0", "tree-sitter-javascript==0.25.0", + "tree-sitter-json==0.24.8" ] classifiers = [ "Programming Language :: Python :: 3.10", @@ -46,6 +48,12 @@ content-type = "text/markdown" # Ship je_editor only: test/ has an __init__.py and would be installed too without include. find = { include = ["je_editor", "je_editor.*"], namespaces = false } +[tool.setuptools.package-data] +# Tree-sitter 的查詢檔不是 .py,不寫在這裡就不會進 wheel,裝好的編輯器會沒有結構區塊。 +# The Tree-sitter query files are not .py: unlisted, they stay out of the wheel and an installed +# editor has no structural regions. +"je_editor.adapters.syntax" = ["queries/*/*.scm"] + [tool.pytest.ini_options] testpaths = ["test"] qt_api = "pyside6" diff --git a/requirements.txt b/requirements.txt index b54ae7c..c0016fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,10 @@ PySide6==6.11.2 langchain_openai==1.6.2 langchain_core anthropic==1.11.0 +tree-sitter==0.26.0 +tree-sitter-python==0.25.0 +tree-sitter-javascript==0.25.0 +tree-sitter-json==0.24.8 ruff sphinx twine diff --git a/test/test_command_palette.py b/test/test_command_palette.py index a3640c2..49c94ff 100644 --- a/test/test_command_palette.py +++ b/test/test_command_palette.py @@ -1,9 +1,11 @@ """Tests for the command palette fuzzy matcher and menu command collector.""" from __future__ import annotations +import gc + import pytest from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMenu, QMenuBar +from PySide6.QtWidgets import QMenu, QMenuBar, QWidget from je_editor.pyside_ui.main_ui.command_palette.command_palette_dialog import ( CommandPaletteDialog, @@ -13,6 +15,7 @@ clean_action_text, collect_menu_commands, ) +from je_editor.pyside_ui.main_ui.menu.submenu_map import menus_in, submenus_of from je_editor.utils.command_palette.fuzzy_matcher import ( CommandEntry, fuzzy_score, @@ -307,3 +310,58 @@ def test_move_selection_on_empty_list_is_a_no_op(self): dialog._move_selection(1) assert dialog.result_list.currentRow() == -1 dialog.close() + + +class RecordingApplication: + """Stands in for the application and notes whether the collector was on when asked for its widgets.""" + + def __init__(self, widgets: list) -> None: + self._widgets = widgets + self.collector_was_on: bool | None = None + + def allWidgets(self) -> list: + self.collector_was_on = gc.isenabled() + return self._widgets + + +@pytest.mark.usefixtures("qapp") +class TestWalkingEveryMenu: + """ + Wrapping every widget of the application allocates, an allocation can start a + collection, and a collection deletes unreferenced widgets that are in the very + list being wrapped. That took the process down, so the collector is held off + for the walk. + """ + + def test_the_collector_is_off_during_the_walk_and_back_on_after(self): + menu, other = QMenu(), QWidget() + application = RecordingApplication([menu, other]) + assert gc.isenabled() + assert menus_in(application) == [menu] + assert (application.collector_was_on, gc.isenabled()) == (False, True) + + def test_a_collector_that_was_off_stays_off(self): + gc.disable() + try: + menus_in(RecordingApplication([])) + assert gc.isenabled() is False + finally: + gc.enable() + + def test_the_collector_comes_back_on_when_the_walk_fails(self): + class Failing: + def allWidgets(self) -> list: + raise RuntimeError("the application is going away") + + with pytest.raises(RuntimeError): + menus_in(Failing()) + assert gc.isenabled() + + def test_a_menu_attached_without_becoming_a_child_is_found(self): + menu_bar = QMenuBar() + own = menu_bar.addMenu("File") + attached = QMenu("Automation") + menu_bar.addMenu(attached) + table = submenus_of(menu_bar) + assert table[attached.menuAction()] is attached + assert table[own.menuAction()] is own diff --git a/test/test_core_language_services.py b/test/test_core_language_services.py index dd77b39..2328859 100644 --- a/test/test_core_language_services.py +++ b/test/test_core_language_services.py @@ -3,7 +3,13 @@ import pytest +import threading + +from je_editor.core.diagnostics.diagnostic_model import Position from je_editor.core.document.document_model import Document, DocumentStore, TextDocument +from je_editor.core.language.language_request import ( + LanguageReply, LanguageRequest, ReplyOnce, nothing_to_cancel +) from je_editor.core.language.language_service import ( LanguageCapability, LanguageService, LanguageServiceRegistry ) @@ -15,11 +21,15 @@ class RecordingService: """A language service that writes down everything it is told.""" def __init__(self, name: str, language_id: str, - capabilities: frozenset[LanguageCapability] = frozenset()) -> None: + capabilities: frozenset[LanguageCapability] = frozenset(), + answer_at_once: bool = True) -> None: self.name = name self._language_id = language_id self._capabilities = capabilities + self._answer_at_once = answer_at_once self.events: list[tuple[str, str]] = [] + self.pending: list = [] + self.cancelled = 0 def capabilities(self) -> frozenset[LanguageCapability]: return self._capabilities @@ -36,6 +46,21 @@ def document_changed(self, document: Document) -> None: def document_closed(self, document: Document) -> None: self.events.append(("closed", document.uri)) + def request(self, request, on_reply): + self.events.append(("request", request.capability.value)) + if self._answer_at_once: + on_reply(LanguageReply(request, self.name, value=f"{self.name} answers")) + return nothing_to_cancel + self.pending.append((request, on_reply)) + return self._cancel + + def _cancel(self) -> None: + self.cancelled += 1 + + def answer_pending(self) -> None: + for request, on_reply in self.pending: + on_reply(LanguageReply(request, self.name, value=f"{self.name} answers late")) + def shutdown(self) -> None: self.events.append(("shutdown", "")) @@ -175,3 +200,140 @@ def test_document_events_are_no_longer_listened_to(self, registry, documents): registry.shutdown() assert listening_before == (1, 1, 1) assert (len(documents.opened), len(documents.changed), len(documents.closed)) == (0, 0, 0) + + + +class TestAskingAQuestion: + """ + A question goes to the first service that can answer it, and the reply comes + back through one function whether the service answers at once or later. + """ + + @pytest.fixture() + def document(self, documents, python_uri): + opened = TextDocument(python_uri, "x = 1\n", "python") + documents.open(opened) + return opened + + @staticmethod + def _hover(document) -> LanguageRequest: + return LanguageRequest(LanguageCapability.HOVER, document, Position(1, 1)) + + def test_a_service_that_answers_at_once_replies_before_the_call_returns( + self, registry, document): + registry.register(RecordingService("jedi", "python", frozenset({LanguageCapability.HOVER}))) + replies: list[LanguageReply] = [] + registry.request(self._hover(document), replies.append) + assert [(reply.ok, reply.service, reply.value) for reply in replies] == [ + (True, "jedi", "jedi answers")] + + def test_the_reply_names_the_question_it_answers(self, registry, document): + registry.register(RecordingService("jedi", "python", frozenset({LanguageCapability.HOVER}))) + replies: list[LanguageReply] = [] + question = self._hover(document) + registry.request(question, replies.append) + assert replies[0].request is question + + def test_the_first_registered_service_that_can_answer_is_asked(self, registry, document): + first = RecordingService("first", "python", frozenset({LanguageCapability.HOVER})) + second = RecordingService("second", "python", frozenset({LanguageCapability.HOVER})) + registry.register(first) + registry.register(second) + replies: list[LanguageReply] = [] + registry.request(self._hover(document), replies.append) + assert replies[0].service == "first" + assert ("request", "hover") not in second.events + + def test_a_service_without_the_capability_is_passed_over(self, registry, document): + registry.register(RecordingService("lint", "python", + frozenset({LanguageCapability.DIAGNOSTICS}))) + registry.register(RecordingService("jedi", "python", frozenset({LanguageCapability.HOVER}))) + replies: list[LanguageReply] = [] + registry.request(self._hover(document), replies.append) + assert replies[0].service == "jedi" + + def test_nobody_to_ask_is_an_error_reply_not_an_exception(self, registry, document): + replies: list[LanguageReply] = [] + cancel = registry.request(self._hover(document), replies.append) + assert (replies[0].ok, replies[0].service, replies[0].value) == (False, "", None) + assert "hover" in replies[0].error + cancel() + + def test_a_service_that_waits_replies_later(self, registry, document): + slow = RecordingService("server", "python", frozenset({LanguageCapability.HOVER}), + answer_at_once=False) + registry.register(slow) + replies: list[LanguageReply] = [] + registry.request(self._hover(document), replies.append) + assert replies == [] + slow.answer_pending() + assert [reply.value for reply in replies] == ["server answers late"] + + def test_a_cancelled_question_is_never_answered(self, registry, document): + slow = RecordingService("server", "python", frozenset({LanguageCapability.HOVER}), + answer_at_once=False) + registry.register(slow) + replies: list[LanguageReply] = [] + cancel = registry.request(self._hover(document), replies.append) + cancel() + slow.answer_pending() + assert (replies, slow.cancelled) == ([], 1) + + def test_cancelling_twice_tells_the_service_once(self, registry, document): + slow = RecordingService("server", "python", frozenset({LanguageCapability.HOVER}), + answer_at_once=False) + registry.register(slow) + cancel = registry.request(self._hover(document), lambda _reply: None) + cancel() + cancel() + assert slow.cancelled == 1 + + def test_cancelling_after_the_reply_does_not_reach_the_service(self, registry, document): + slow = RecordingService("server", "python", frozenset({LanguageCapability.HOVER}), + answer_at_once=False) + registry.register(slow) + cancel = registry.request(self._hover(document), lambda _reply: None) + slow.answer_pending() + cancel() + assert slow.cancelled == 0 + + def test_a_service_that_replies_twice_is_heard_once(self, registry, document): + slow = RecordingService("server", "python", frozenset({LanguageCapability.HOVER}), + answer_at_once=False) + registry.register(slow) + replies: list[LanguageReply] = [] + registry.request(self._hover(document), replies.append) + slow.answer_pending() + slow.answer_pending() + assert len(replies) == 1 + + def test_a_question_without_a_position_and_with_options(self, document): + question = LanguageRequest(LanguageCapability.FORMATTING, document, + options={"tab_size": 4}) + assert (question.position, question.options["tab_size"]) == (None, 4) + + +class TestReplyOnce: + def test_replies_racing_from_many_threads_deliver_one(self, documents, python_uri): + document = TextDocument(python_uri, "", "python") + question = LanguageRequest(LanguageCapability.HOVER, document) + delivered: list[LanguageReply] = [] + reply_once = ReplyOnce(delivered.append) + start = threading.Event() + + def reply() -> None: + start.wait() + reply_once(LanguageReply(question, "racer")) + + threads = [threading.Thread(target=reply) for _ in range(8)] + for thread in threads: + thread.start() + start.set() + for thread in threads: + thread.join() + assert (len(delivered), reply_once.settled) == (1, True) + + def test_an_error_reply_is_not_ok(self, python_uri): + question = LanguageRequest(LanguageCapability.HOVER, TextDocument(python_uri, "")) + assert LanguageReply(question, error="not available").ok is False + assert LanguageReply(question, "jedi", value=None).ok is True diff --git a/test/test_editor_widget.py b/test/test_editor_widget.py index 31cb51f..a232043 100644 --- a/test/test_editor_widget.py +++ b/test/test_editor_widget.py @@ -82,6 +82,22 @@ def test_tab_title_asterisk(self, editor_widget): title = editor_widget.tab_manager.tabText(idx) assert title.endswith(" *") + def test_a_highlighter_repainting_is_not_an_edit(self, editor_widget): + # Qt sends textChanged when a highlighter repaints, and every highlighter + # repaints once after it is attached: an opened file must not look unsaved. + editor_widget.code_edit.setPlainText("value = 1") + editor_widget.mark_saved() + editor_widget.code_edit.reset_highlighter() + editor_widget.code_edit.highlighter.rehighlight() + QApplication.processEvents() + assert editor_widget._is_modified is False + + def test_typing_after_a_repaint_still_counts(self, editor_widget): + editor_widget.mark_saved() + editor_widget.code_edit.highlighter.rehighlight() + editor_widget.code_edit.insertPlainText("x") + assert editor_widget._is_modified is True + def test_mark_saved_removes_asterisk(self, editor_widget): editor_widget.code_edit.setPlainText("trigger again") editor_widget.mark_saved() diff --git a/test/test_generic_syntax.py b/test/test_generic_syntax.py index 15fe79d..f07b933 100644 --- a/test/test_generic_syntax.py +++ b/test/test_generic_syntax.py @@ -149,6 +149,7 @@ def test_every_syntax_colour_is_defined(self, app): for key in ( "syntax_keyword_color", "syntax_string_color", "syntax_comment_color", "syntax_number_color", + "syntax_builtin_color", "syntax_self_color", "syntax_function_color", ): assert actually_color_dict.get(key) is not None @@ -175,11 +176,12 @@ def test_a_typescript_file_uses_the_generic_highlighter(self, editor): editor.reset_highlighter() assert isinstance(editor.highlighter, GenericHighlighter) - def test_a_python_file_keeps_its_own_highlighter(self, editor): - from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + def test_a_python_file_is_coloured_by_the_syntax_engine(self, editor): + from je_editor.pyside_ui.code.syntax.tree_sitter_highlighter import TreeSitterHighlighter editor.current_file = "module.py" editor.reset_highlighter() - assert isinstance(editor.highlighter, PythonHighlighter) + assert isinstance(editor.highlighter, TreeSitterHighlighter) + assert editor.highlighter.session.language_id == "python" def test_an_unknown_suffix_falls_back_to_python(self, editor): from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter @@ -187,8 +189,9 @@ def test_an_unknown_suffix_falls_back_to_python(self, editor): editor.reset_highlighter() assert isinstance(editor.highlighter, PythonHighlighter) - def test_a_file_without_a_name_falls_back_to_python(self, editor): - from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + def test_a_file_without_a_name_counts_as_python(self, editor): + from je_editor.pyside_ui.code.syntax.tree_sitter_highlighter import TreeSitterHighlighter editor.current_file = None editor.reset_highlighter() - assert isinstance(editor.highlighter, PythonHighlighter) + assert isinstance(editor.highlighter, TreeSitterHighlighter) + assert editor.highlighter.session.language_id == "python" diff --git a/test/test_public_api_contract.py b/test/test_public_api_contract.py index c9e874d..99a38a3 100644 --- a/test/test_public_api_contract.py +++ b/test/test_public_api_contract.py @@ -90,3 +90,20 @@ def test_every_argument_is_off_by_default(self, parameters): def test_the_arguments_can_be_passed_by_position_or_by_name(self, parameters): assert {parameter.kind for parameter in parameters} == { inspect.Parameter.POSITIONAL_OR_KEYWORD} + + +class TestTheShapesPyBreezePins: + """ + PyBreeze keeps contract tests of its own (``test/test_utils/test_jeditor_contract.py`` + there) that pin parameter lists and even fragments of source. These are the + ones a change here has broken before; the full set has to be run from PyBreeze. + """ + + def test_a_highlight_colour_may_be_a_theme_colour_key(self): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + assert "actually_color_dict.get(color)" in inspect.getsource(PythonHighlighter._make_format) + + def test_the_editor_methods_pybreeze_calls_after_renaming_a_file(self): + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): + assert list(inspect.signature(getattr(CodeEditor, method)).parameters) == ["self"] diff --git a/test/test_syntax_engine.py b/test/test_syntax_engine.py new file mode 100644 index 0000000..830d531 --- /dev/null +++ b/test/test_syntax_engine.py @@ -0,0 +1,527 @@ +"""Tests for the syntax model, the Tree-sitter engine and the syntax language service.""" +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest + +from je_editor.adapters.default_services import build_default_services +from je_editor.adapters.syntax.grammar_table import ( + BUILTIN_GRAMMARS, CAPTURE_CATEGORIES, HIGHLIGHTS_QUERY_FILE, QUERY_DIRECTORY, + REGIONS_QUERY_FILE, GrammarSpec, category_for, own_query +) +from je_editor.adapters.syntax.syntax_language_service import SERVICE_NAME, SyntaxLanguageService +from je_editor.adapters.syntax.tree_sitter_engine import ( + MAX_SOURCE_BYTES, SPAN_CHUNK_LINES, TreeSitterEngine, changed_bytes, load_grammar, point_at, + shared_syntax_engine, suffix_of +) +from je_editor.core.diagnostics.diagnostic_model import Position, TextRange +from je_editor.core.document.document_model import TextDocument +from je_editor.core.language.language_capability import LanguageCapability +from je_editor.core.language.language_request import LanguageReply, LanguageRequest +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.syntax.syntax_model import ( + LineSpan, NoSyntaxEngine, RegionKind, StructuralRegion, SyntaxCategory, SyntaxEngine, + SyntaxSession, SyntaxSpan +) +from je_editor.core.uri.resource_uri import to_uri + +PYTHON = ( + "import os\n" + "\n" + "\n" + "class Greeter:\n" + " def greet(self, name: str) -> str:\n" + " # say hello\n" + ' return f"hi {name}\\n" + str(MAX)\n' +) +LANGUAGES = [spec.language_id for spec in BUILTIN_GRAMMARS] + + +@pytest.fixture(scope="module") +def engine(): + return TreeSitterEngine() + + +@pytest.fixture() +def python(engine): + session = engine.open_session("python") + session.update(PYTHON) + return session + + +def shown(session, line: int) -> list[tuple[str, str]]: + """Each span of a line as (text, category), in the order the session gives them.""" + text = session._lines[line - 1] + return [(text[span.column - 1:span.column - 1 + span.length], span.category.value) + for span in session.spans(line)] + + +class TestTheModel: + def test_a_region_on_one_line_cannot_fold(self): + region = StructuralRegion(RegionKind.FUNCTION, TextRange.from_lines(3, 1, 3, 20), "f") + assert region.is_multiline is False + + def test_a_region_over_several_lines_can(self): + region = StructuralRegion(RegionKind.CLASS, TextRange.from_lines(3, 1, 9, 1)) + assert (region.is_multiline, region.name) == (True, "") + + def test_spans_and_line_spans_compare_by_value(self): + assert SyntaxSpan(1, 3, SyntaxCategory.KEYWORD) == SyntaxSpan(1, 3, SyntaxCategory.KEYWORD) + assert LineSpan(2, 5) == LineSpan(2, 5) + + def test_the_engine_that_knows_nothing(self): + nothing = NoSyntaxEngine() + assert (nothing.language_ids(), nothing.language_for("main.py"), + nothing.open_session("python")) == ((), None, None) + + def test_both_engines_satisfy_the_interface(self, engine): + assert isinstance(NoSyntaxEngine(), SyntaxEngine) + assert isinstance(engine, SyntaxEngine) + + def test_a_session_satisfies_the_interface(self, python): + assert isinstance(python, SyntaxSession) + + def test_services_start_without_a_parser(self): + assert isinstance(EditorServices().syntax, NoSyntaxEngine) + + +class TestChoosingALanguage: + def test_the_built_in_languages_are_available(self, engine): + assert engine.language_ids() == ("python", "javascript", "json") + + @pytest.mark.parametrize("name, language", [ + ("main.py", "python"), ("stubs.pyi", "python"), ("C:\\src\\Tool.PYW", "python"), + ("app.js", "javascript"), ("lib/index.mjs", "javascript"), ("view.jsx", "javascript"), + ("data.json", "json"), ("file:///c:/project/package.json", "json"), + ]) + def test_a_file_name_path_or_uri_names_its_language(self, engine, name, language): + assert engine.language_for(name) == language + + @pytest.mark.parametrize("name", ["notes.txt", "app.ts", "Makefile", ".json", "", "dir.py/readme"]) + def test_an_unknown_name_has_no_language(self, engine, name): + assert engine.language_for(name) is None + + def test_an_unknown_language_opens_no_session(self, engine): + assert engine.open_session("cobol") is None + + def test_every_session_is_its_own(self, engine): + first, second = engine.open_session("python"), engine.open_session("python") + first.update("x = 1\n") + assert (first is not second, second.spans(1)) == (True, ()) + + @pytest.mark.parametrize("name, suffix", [ + ("a.py", ".py"), ("A.PY", ".py"), ("dir/b.tar.gz", ".gz"), ("c:\\x\\y.json", ".json"), + (".hidden", ""), ("none", ""), ("trailing.", "."), + ]) + def test_the_suffix_of_a_name(self, name, suffix): + assert suffix_of(name) == suffix + + def test_the_shared_engine_is_one_object(self): + assert shared_syntax_engine() is shared_syntax_engine() + + +class TestCategories: + def test_keywords_names_and_types(self, python): + assert shown(python, 4) == [("class", "keyword"), ("Greeter", "type")] + + def test_a_definition_its_receiver_and_its_annotations(self, python): + assert shown(python, 5) == [ + ("def", "keyword"), ("greet", "function"), ("self", "special_variable"), + ("name", "variable"), ("str", "type"), ("->", "operator"), ("str", "type")] + + def test_a_comment(self, python): + assert shown(python, 6) == [("# say hello", "comment")] + + def test_what_is_inside_a_string_comes_after_the_string(self, python): + assert shown(python, 7) == [ + ("return", "keyword"), ('f"hi {name}\\n"', "string"), ("{name}", "embedded"), + ("{", "punctuation"), ("name", "variable"), ("}", "punctuation"), ("\\n", "escape"), + ("+", "operator"), ("str", "builtin"), ("MAX", "constant")] + + def test_an_empty_line_and_a_missing_line_have_nothing(self, python): + assert (python.spans(2), python.spans(0), python.spans(99)) == ((), (), ()) + + def test_a_later_pattern_wins_on_the_same_node(self, python): + # ``self`` is an identifier (variable) first and the receiver second. + categories = [span.category for span in python.spans(5) if span.column == 15] + assert categories == [SyntaxCategory.SPECIAL_VARIABLE] + + def test_javascript(self, engine): + session = engine.open_session("javascript") + session.update("class A { run() { return `a ${1} b`; } }\n") + found = shown(session, 1) + assert ("class", "keyword") in found and ("run", "function") in found + assert ("`a ${1} b`", "string") in found and ("1", "number") in found + + def test_a_json_key_is_told_from_a_string_value(self, engine): + session = engine.open_session("json") + session.update('{"key": [1, true, null, "v"]}') + assert shown(session, 1) == [ + ('"key"', "key"), ("1", "number"), ("true", "literal"), ("null", "literal"), + ('"v"', "string")] + + def test_a_string_over_several_lines_colours_each_of_them(self, engine): + session = engine.open_session("python") + session.update('x = """one\ntwo\nthree"""\ny = 1\n') + assert [shown(session, line)[-1] for line in (1, 2, 3)] == [ + ('"""one', "string"), ("two", "string"), ('three"""', "string")] + assert ("y", "variable") in shown(session, 4) + + def test_lines_past_the_first_chunk(self, engine): + session = engine.open_session("python") + session.update("\n".join(f"value_{index} = {index}" for index in range(SPAN_CHUNK_LINES * 3))) + line = SPAN_CHUNK_LINES * 2 + 5 + assert shown(session, line) == [ + (f"value_{line - 1}", "variable"), ("=", "operator"), (str(line - 1), "number")] + + @pytest.mark.parametrize("name, category", [ + ("comment", SyntaxCategory.COMMENT), ("function.builtin", SyntaxCategory.BUILTIN), + ("function.method", SyntaxCategory.FUNCTION), ("string.special.key", SyntaxCategory.KEY), + ("string.special", SyntaxCategory.STRING), ("punctuation.bracket", SyntaxCategory.PUNCTUATION), + ("variable.builtin", SyntaxCategory.SPECIAL_VARIABLE), ("no.such.name", None), ("", None), + ]) + def test_a_capture_name_falls_back_to_its_parent(self, name, category): + assert category_for(name) is category + + def test_every_category_is_reachable_from_a_capture_name(self): + assert set(CAPTURE_CATEGORIES.values()) == set(SyntaxCategory) + + +class TestColumnsCountUtf16Units: + """Qt and the language server protocol count UTF-16 units, so an emoji is two columns.""" + + def test_text_after_wide_characters(self, engine): + session = engine.open_session("python") + session.update('x = "中文😀" + y # 註解😀\n') + assert [(span.column, span.length, span.category.value) for span in session.spans(1)] == [ + (1, 1, "variable"), (3, 1, "operator"), (5, 6, "string"), (12, 1, "operator"), + (14, 1, "variable"), (17, 6, "comment")] + + def test_a_region_starting_after_wide_characters(self, engine): + session = engine.open_session("python") + session.update('x = "😀"; y = [\n 1,\n]\n') + collection = session.regions()[0] + assert (collection.range.start, collection.range.end) == (Position(1, 15), Position(3, 2)) + + def test_a_lone_surrogate_does_not_stop_the_analysis(self, engine): + session = engine.open_session("python") + session.update('x = "\ud83d" + y\n') + assert [(span.column, span.category.value) for span in session.spans(1)][-1] == ( + 11, "variable") + + +class TestFollowingAnEdit: + def test_the_first_text_changes_every_line(self, engine): + session = engine.open_session("python") + assert session.update(PYTHON) == LineSpan(1, 8) + + def test_the_same_text_changes_nothing(self, python): + assert python.update(PYTHON) is None + + def test_typing_in_one_line_changes_that_line(self, python): + assert python.update(PYTHON.replace("import os", "import oss")) == LineSpan(1, 1) + assert shown(python, 1) == [("import", "keyword"), ("oss", "variable")] + + # Three statements and a quote left open at the end; a quote added on top closes over them. + BELOW_AN_OPEN_QUOTE = 'a = 1\nb = 2\nc = 3\n"""\n' + + def test_opening_a_string_reaches_every_line_after_it(self, engine): + session = engine.open_session("python") + session.update(self.BELOW_AN_OPEN_QUOTE) + assert shown(session, 2) == [("b", "variable"), ("=", "operator"), ("2", "number")] + changed = session.update('"""\n' + self.BELOW_AN_OPEN_QUOTE) + assert changed.first == 1 and changed.last >= 5 + assert [shown(session, line) for line in (2, 3, 4)] == [ + [("a = 1", "string")], [("b = 2", "string")], [("c = 3", "string")]] + assert session.has_errors is False + + def test_removing_it_again_puts_the_lines_back(self, engine): + session = engine.open_session("python") + session.update('"""\n' + self.BELOW_AN_OPEN_QUOTE) + changed = session.update(self.BELOW_AN_OPEN_QUOTE) + assert changed.first == 1 and changed.last >= 4 + assert shown(session, 2) == [("b", "variable"), ("=", "operator"), ("2", "number")] + assert session.has_errors is True + + def test_an_edit_can_change_a_line_above_it(self, engine): + session = engine.open_session("python") + session.update("def f(\n a,\n") + changed = session.update("def f(\n a,\n): pass\n") + assert changed.first == 1 + + def test_adding_and_removing_lines(self, python): + longer = PYTHON + "\nprint(Greeter())\n" + assert python.update(longer).last == python.line_count + assert ("print", "builtin") in shown(python, 9) + python.update("x = 1\n") + assert (python.line_count, python.spans(5)) == (2, ()) + + def test_emptying_the_text(self, python): + assert python.update("") == LineSpan(1, 1) + assert (python.spans(1), python.regions(), python.has_errors) == ((), (), False) + + def test_the_result_matches_a_fresh_analysis(self, engine): + edited = engine.open_session("python") + text = PYTHON + for old, new in (("os", "sys"), ("Greeter", "G"), ("# say hello", "pass # 中文"), + (" return", " yield")): + text = text.replace(old, new, 1) + edited.update(text) + fresh = engine.open_session("python") + fresh.update(text) + lines = range(1, fresh.line_count + 1) + assert [edited.spans(line) for line in lines] == [fresh.spans(line) for line in lines] + assert edited.regions() == fresh.regions() + + @pytest.mark.parametrize("old, new, expected", [ + (b"abc", b"abc", (3, 3, 3)), (b"", b"xyz", (0, 0, 3)), (b"xyz", b"", (0, 3, 0)), + (b"abcd", b"abXd", (2, 3, 3)), (b"abcabc", b"abc", (3, 6, 3)), + (b"ab", b"aXXb", (1, 1, 3)), + ]) + def test_the_smallest_edit_between_two_contents(self, old, new, expected): + assert changed_bytes(old, new) == expected + + def test_an_edit_never_starts_or_ends_inside_a_character(self): + old, new = "a中b".encode(), "a丮b".encode() + # The two characters share their first two bytes; the edit covers them whole. + assert changed_bytes(old, new) == (1, 4, 4) + assert changed_bytes("é".encode(), "è".encode()) == (0, 2, 2) + + def test_a_byte_offset_as_row_and_column(self): + source = "ab\n中c\n".encode() + assert [point_at(source, offset) for offset in (0, 2, 3, 6, 8)] == [ + (0, 0), (0, 2), (1, 0), (1, 3), (2, 0)] + + +class TestStructuralRegions: + def test_classes_functions_and_their_names(self, python): + assert [(region.kind, region.name, region.range.start.line, region.range.end.line) + for region in python.regions()] == [ + (RegionKind.CLASS, "Greeter", 4, 7), (RegionKind.FUNCTION, "greet", 5, 7)] + + def test_outer_regions_come_first(self, engine): + session = engine.open_session("python") + session.update("def outer():\n for item in []:\n data = {\n 1: 2,\n }\n") + assert [region.kind for region in session.regions()] == [ + RegionKind.FUNCTION, RegionKind.BLOCK, RegionKind.COLLECTION, RegionKind.COLLECTION] + + def test_a_region_knows_whether_it_spans_lines(self, engine): + session = engine.open_session("python") + session.update("def one(): pass\n\n\ndef two():\n pass\n") + assert [(region.name, region.is_multiline) for region in session.regions()] == [ + ("one", False), ("two", True)] + + def test_javascript_regions(self, engine): + session = engine.open_session("javascript") + session.update("class A {\n run() {\n return [1].map((x) => x);\n }\n}\n") + assert [(region.kind.value, region.name) for region in session.regions()] == [ + ("class", "A"), ("function", "run"), ("collection", ""), ("function", "")] + + def test_json_regions(self, engine): + session = engine.open_session("json") + session.update('{\n "a": [\n 1\n ]\n}\n') + assert [(region.kind, region.range.start.line, region.range.end.line) + for region in session.regions()] == [ + (RegionKind.COLLECTION, 1, 5), (RegionKind.COLLECTION, 2, 4)] + + def test_regions_follow_an_edit(self, python): + python.update(PYTHON + "\n\ndef added():\n pass\n") + assert [region.name for region in python.regions()] == ["Greeter", "greet", "added"] + + +class TestQueryFiles: + @pytest.mark.parametrize("language_id", LANGUAGES) + def test_every_language_has_a_region_query(self, language_id): + assert own_query(QUERY_DIRECTORY, language_id, REGIONS_QUERY_FILE).strip() + + @pytest.mark.parametrize("spec", BUILTIN_GRAMMARS, ids=LANGUAGES) + def test_the_queries_compile_and_name_known_things(self, spec): + grammar = load_grammar(spec, QUERY_DIRECTORY) + assert grammar.categories and grammar.regions is not None + region_names = {grammar.regions.capture_name(index) + for index in range(grammar.regions.capture_count)} + assert region_names <= {f"region.{kind.value}" for kind in RegionKind} + + def test_a_language_without_a_query_file_has_no_text(self, tmp_path): + assert own_query(tmp_path, "python", HIGHLIGHTS_QUERY_FILE) == "" + + def test_no_suffix_belongs_to_two_languages(self): + suffixes = [suffix for spec in BUILTIN_GRAMMARS for suffix in spec.suffixes] + assert len(suffixes) == len(set(suffixes)) + assert all(suffix == suffix.lower() and suffix.startswith(".") for suffix in suffixes) + + +class TestWhenAGrammarCannotBeUsed: + """A missing package or a broken query makes a language unsupported; it never raises.""" + + @staticmethod + def _missing(): + raise ImportError("No module named 'tree_sitter_cobol'") + + def test_a_grammar_that_is_not_installed(self, caplog): + engine = TreeSitterEngine([GrammarSpec("cobol", (".cob",), self._missing)]) + assert (engine.language_ids(), engine.language_for("a.cob"), + engine.open_session("cobol")) == ((), None, None) + assert "cobol" in caplog.text + + def test_the_load_is_tried_once(self): + attempts: list[int] = [] + + def load(): + attempts.append(1) + raise ImportError("not installed") + + engine = TreeSitterEngine([GrammarSpec("cobol", (".cob",), load)]) + for _ in range(3): + engine.language_for("a.cob") + assert len(attempts) == 1 + + def test_a_broken_query_file(self, tmp_path): + (tmp_path / "python").mkdir() + (tmp_path / "python" / REGIONS_QUERY_FILE).write_text("(no_such_node) @region.block", + encoding="utf-8") + engine = TreeSitterEngine(BUILTIN_GRAMMARS, tmp_path) + assert "python" not in engine.language_ids() + assert "json" in engine.language_ids() + + def test_an_unknown_region_name_is_left_out(self, tmp_path): + (tmp_path / "json").mkdir() + (tmp_path / "json" / REGIONS_QUERY_FILE).write_text( + "(object) @region.collection\n(array) @region.mystery\n(pair) @other", + encoding="utf-8") + session = TreeSitterEngine(BUILTIN_GRAMMARS, tmp_path).open_session("json") + session.update('{"a": [1]}') + assert [region.kind for region in session.regions()] == [RegionKind.COLLECTION] + + def test_a_grammar_that_ships_no_highlight_query(self): + import tree_sitter_json + bare = SimpleNamespace(language=tree_sitter_json.language) + engine = TreeSitterEngine([GrammarSpec("json", (".json",), lambda: bare)]) + session = engine.open_session("json") + session.update('{"a": 1}') + assert [span.category for span in session.spans(1)] == [SyntaxCategory.KEY] + + def test_a_text_too_large_is_left_uncoloured(self, engine, monkeypatch): + monkeypatch.setattr("je_editor.adapters.syntax.tree_sitter_engine.MAX_SOURCE_BYTES", 40) + session = engine.open_session("python") + session.update("x = 1\n") + assert session.spans(1) != () + assert session.update("x = 1\n" * 20) == LineSpan(1, 21) + assert (session.spans(1), session.regions(), session.has_errors) == ((), (), False) + # Still too large: nothing had colours, so only the first line is reported. + assert session.update("y = 2\n" * 20) == LineSpan(1, 1) + assert session.update("x = 1\n") == LineSpan(1, 2) + assert session.spans(1) != () + + def test_the_limit_is_generous(self): + assert MAX_SOURCE_BYTES >= 1_000_000 + + +class TestTheBindingIsUsedSafely: + def test_reading_rows_does_not_take_references_away(self, engine): + """ + ``Point.row`` and ``Point.column`` of tree-sitter 0.26.0 drop a reference + to the integer on every read, which crashes the interpreter after enough + of them. The engine reads points by index; this notices if that changes. + """ + session = engine.open_session("python") + text = "\n".join(f"def function_{index}(value):\n return value + {index}" + for index in range(400)) + watched = (0, 1, 4) + before = [sys.getrefcount(value) for value in watched] + for round_number in range(3): + session.update(text + f"\n# round {round_number}\n") + for line in range(1, session.line_count + 1): + session.spans(line) + session.regions() + drift = [sys.getrefcount(value) - count for value, count in zip(watched, before)] + # Thousands of reads happened; a leak of one reference each would show as thousands. + assert min(drift) > -200 + + +class TestTheSyntaxLanguageService: + @pytest.fixture() + def services(self, tmp_path): + built = build_default_services(settings_directory=tmp_path) + yield built + built.shutdown() + + @pytest.fixture() + def document(self, services, tmp_path): + opened = TextDocument(to_uri(tmp_path / "main.py"), PYTHON) + services.documents.open(opened) + return opened + + @staticmethod + def _ask(services, capability, document) -> LanguageReply: + replies: list[LanguageReply] = [] + services.languages.request(LanguageRequest(capability, document), replies.append) + return replies[0] + + def test_the_default_services_have_the_engine_and_the_service(self, services): + assert services.syntax is shared_syntax_engine() + assert [service.name for service in services.languages.services()] == [SERVICE_NAME] + + def test_an_open_document_has_a_syntax_tree(self, services, document): + reply = self._ask(services, LanguageCapability.SYNTAX_TREE, document) + assert (reply.ok, reply.service) == (True, SERVICE_NAME) + assert isinstance(reply.value, SyntaxSession) + assert reply.value.spans(4)[0] == SyntaxSpan(1, 5, SyntaxCategory.KEYWORD) + + def test_the_symbols_are_the_named_regions(self, services, document): + reply = self._ask(services, LanguageCapability.DOCUMENT_SYMBOLS, document) + assert [(region.kind, region.name) for region in reply.value] == [ + (RegionKind.CLASS, "Greeter"), (RegionKind.FUNCTION, "greet")] + + def test_the_tree_follows_the_document(self, services, document): + services.documents.replace_text(document.uri, "def renamed():\n pass\n") + reply = self._ask(services, LanguageCapability.DOCUMENT_SYMBOLS, document) + assert [region.name for region in reply.value] == ["renamed"] + + def test_a_closed_document_has_no_tree(self, services, document): + services.documents.close(document.uri) + service = services.languages.services()[0] + assert service.session_for(document) is None + + def test_the_language_id_counts_before_the_file_name(self, services, tmp_path): + opened = TextDocument(to_uri(tmp_path / "settings.conf"), '{"a": 1}', "json") + services.documents.open(opened) + assert self._ask(services, LanguageCapability.SYNTAX_TREE, opened).value.language_id == "json" + + def test_a_language_the_engine_does_not_know_is_not_handled(self, services, tmp_path): + opened = TextDocument(to_uri(tmp_path / "main.rs"), "fn main() {}", "rust") + services.documents.open(opened) + reply = self._ask(services, LanguageCapability.SYNTAX_TREE, opened) + assert (reply.ok, reply.service) == (False, "") + + def test_a_capability_it_does_not_offer_is_an_error_reply(self, document): + service = SyntaxLanguageService(shared_syntax_engine()) + service.document_opened(document) + replies: list[LanguageReply] = [] + service.request(LanguageRequest(LanguageCapability.HOVER, document), replies.append) + assert (replies[0].ok, "hover" in replies[0].error) == (False, True) + + def test_a_document_it_was_never_told_about_is_an_error_reply(self, tmp_path): + service = SyntaxLanguageService(shared_syntax_engine()) + unknown = TextDocument(to_uri(tmp_path / "other.py"), "x = 1\n") + replies: list[LanguageReply] = [] + service.request(LanguageRequest(LanguageCapability.SYNTAX_TREE, unknown), replies.append) + assert replies[0].ok is False + + def test_a_change_to_a_document_it_missed_opens_it(self, tmp_path): + service = SyntaxLanguageService(shared_syntax_engine()) + late = TextDocument(to_uri(tmp_path / "late.py"), "x = 1\n") + service.document_changed(late) + assert service.session_for(late) is not None + + def test_shutting_down_lets_go_of_every_tree(self, services, document): + service = services.languages.services()[0] + services.shutdown() + assert service.session_for(document) is None + + def test_with_no_parser_nothing_is_handled(self, document): + service = SyntaxLanguageService(NoSyntaxEngine()) + service.document_opened(document) + assert (service.handles(document), service.session_for(document)) == (False, None) diff --git a/test/test_tree_sitter_highlighter.py b/test/test_tree_sitter_highlighter.py new file mode 100644 index 0000000..cd824b4 --- /dev/null +++ b/test/test_tree_sitter_highlighter.py @@ -0,0 +1,479 @@ +"""Tests for the highlighter that paints a syntax engine's categories, and for choosing a highlighter.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtCore import QCoreApplication, QEvent +from PySide6.QtGui import QColor, QSyntaxHighlighter, QTextCursor, QTextDocument +from PySide6.QtWidgets import QApplication, QPlainTextDocumentLayout + +from je_editor.adapters.syntax.tree_sitter_engine import TreeSitterEngine, shared_syntax_engine +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.syntax.syntax_model import LineSpan, NoSyntaxEngine, SyntaxCategory +from je_editor.plugins import _programming_language_plugins, register_programming_language +from je_editor.pyside_ui.code.syntax.generic_syntax import GenericHighlighter +from je_editor.pyside_ui.code.syntax.highlight_rules import ( + make_format, plugin_rules, regex_rules, word_rules +) +from je_editor.pyside_ui.code.syntax.highlighter_factory import ( + CLASSIC_ENGINE, SYNTAX_ENGINE_SETTING, build_highlighter, dispose_highlighter, + syntax_engine_for +) +from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter +from je_editor.pyside_ui.code.syntax.syntax_setting import syntax_extend_setting_dict +from je_editor.pyside_ui.code.syntax.tree_sitter_highlighter import ( + CATEGORY_COLOURS, SINGLE_REPAINT_LIMIT, TreeSitterHighlighter, _merged, category_formats +) +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + +pytestmark = pytest.mark.usefixtures("qapp") +UTF16_UNIT_BYTES = 2 + + +def new_document(text: str) -> QTextDocument: + document = QTextDocument() + document.setDocumentLayout(QPlainTextDocumentLayout(document)) + document.setPlainText(text) + return document + + +def highlighted(file_name: str | None, text: str, engine=None): + """A document holding *text*, already coloured by the highlighter chosen for *file_name*.""" + document = new_document(text) + highlighter = build_highlighter(document, file_name, engine or shared_syntax_engine()) + highlighter.rehighlight() + return highlighter, document + + +def coloured(document: QTextDocument, line: int) -> list[tuple[str, str]]: + """Each coloured run of a 1-based line as (text, theme colour key).""" + keys = {colour.name(): key for key, colour in actually_color_dict.items() + if key.startswith("syntax_")} + block = document.findBlockByNumber(line - 1) + units = block.text().encode("utf-16-le") + return [ + (units[run.start * UTF16_UNIT_BYTES:(run.start + run.length) * UTF16_UNIT_BYTES] + .decode("utf-16-le"), + keys.get(run.format.foreground().color().name(), run.format.foreground().color().name())) + for run in block.layout().formats() + ] + + +def deliver_deferred_deletes() -> None: + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QApplication.processEvents() + + +def attached_highlighters(document: QTextDocument) -> list[QSyntaxHighlighter]: + return [child for child in document.children() + if isinstance(child, QSyntaxHighlighter) and child.document() is document] + + +class TestColours: + def test_a_python_definition(self): + _highlighter, document = highlighted("a.py", "def greet(self): return 1 # done\n") + assert coloured(document, 1) == [ + ("def", "syntax_keyword_color"), ("greet", "syntax_function_color"), + ("self", "syntax_self_color"), ("return", "syntax_keyword_color"), + ("1", "syntax_number_color"), ("# done", "syntax_comment_color")] + + def test_an_interpolation_is_not_painted_as_string(self): + _highlighter, document = highlighted("a.py", 'x = f"hi {name}!"\n') + assert coloured(document, 1) == [ + ('f"hi ', "syntax_string_color"), ('!"', "syntax_string_color")] + + def test_builtins_types_and_escapes(self): + _highlighter, document = highlighted("a.py", 'class Abc: pass\nprint("a\\n", True)\n') + assert coloured(document, 1) == [ + ("class", "syntax_keyword_color"), ("Abc", "syntax_builtin_color"), + ("pass", "syntax_keyword_color")] + assert coloured(document, 2) == [ + ("print", "syntax_builtin_color"), ('"a', "syntax_string_color"), + ("\\n", "syntax_builtin_color"), ('"', "syntax_string_color"), + ("True", "syntax_keyword_color")] + + def test_json_keys_and_values(self): + _highlighter, document = highlighted("a.json", '{"key": ["value", 1, null]}') + assert coloured(document, 1) == [ + ('"key"', "syntax_keyword_color"), ('"value"', "syntax_string_color"), + ("1", "syntax_number_color"), ("null", "syntax_keyword_color")] + + def test_javascript(self): + _highlighter, document = highlighted("a.js", "function run() { return 'x'; }\n") + assert coloured(document, 1)[:3] == [ + ("function", "syntax_keyword_color"), ("run", "syntax_function_color"), + ("return", "syntax_keyword_color")] + + def test_columns_after_wide_characters(self): + _highlighter, document = highlighted("a.py", 'x = "中文😀" + 1 # 註解😀\n') + assert coloured(document, 1) == [ + ('"中文😀"', "syntax_string_color"), ("1", "syntax_number_color"), + ("# 註解😀", "syntax_comment_color")] + + def test_a_string_over_several_lines(self): + _highlighter, document = highlighted("a.py", 'x = """one\ntwo\nthree"""\ny = 1\n') + assert [coloured(document, line) for line in (1, 2, 3, 4)] == [ + [('"""one', "syntax_string_color")], [("two", "syntax_string_color")], + [('three"""', "syntax_string_color")], [("1", "syntax_number_color")]] + + def test_every_category_has_a_format(self): + assert set(category_formats()) == set(SyntaxCategory) + + def test_every_colour_a_category_uses_is_in_the_theme(self): + assert all(key in actually_color_dict for key in CATEGORY_COLOURS.values()) + + def test_a_category_without_a_colour_gets_an_empty_format(self): + assert category_formats()[SyntaxCategory.VARIABLE].foreground().style().name == "NoBrush" + + +class TestFollowingEdits: + """An edit can change the syntax of lines Qt would not repaint by itself.""" + + BODY = 'a = 1\nb = 2\nc = 3\n"""\n' + + def test_a_string_opened_above_reaches_the_lines_below_at_once(self): + _highlighter, document = highlighted("a.py", self.BODY) + assert coloured(document, 2) == [("2", "syntax_number_color")] + QTextCursor(document).insertText('"""\n') + assert [coloured(document, line) for line in (2, 3, 4, 5)] == [ + [("a = 1", "syntax_string_color")], [("b = 2", "syntax_string_color")], + [("c = 3", "syntax_string_color")], [('"""', "syntax_string_color")]] + + def test_removing_it_puts_the_lines_back(self): + _highlighter, document = highlighted("a.py", '"""\n' + self.BODY) + assert coloured(document, 3) == [("b = 2", "syntax_string_color")] + cursor = QTextCursor(document) + cursor.movePosition(QTextCursor.MoveOperation.NextBlock, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + assert [coloured(document, line) for line in (1, 2, 3)] == [ + [("1", "syntax_number_color")], [("2", "syntax_number_color")], + [("3", "syntax_number_color")]] + + def test_a_line_above_the_edit_is_repainted_on_the_next_turn(self, qtbot): + _highlighter, document = highlighted("a.py", "foo(\n 1,\n") + assert coloured(document, 1) == [] + cursor = QTextCursor(document) + cursor.movePosition(QTextCursor.MoveOperation.End) + cursor.insertText(")") + qtbot.waitUntil(lambda: coloured(document, 1) == [("foo", "syntax_function_color")]) + + def test_typing_keeps_the_line_right(self): + _highlighter, document = highlighted("a.py", "value = 1\n") + cursor = QTextCursor(document) + cursor.insertText("# ") + assert coloured(document, 1) == [("# value = 1", "syntax_comment_color")] + cursor.deletePreviousChar() + cursor.deletePreviousChar() + assert coloured(document, 1) == [("1", "syntax_number_color")] + + def test_undo_and_redo_are_followed(self): + _highlighter, document = highlighted("a.py", "value = 1\n") + QTextCursor(document).insertText("# ") + document.undo() + assert coloured(document, 1) == [("1", "syntax_number_color")] + document.redo() + assert coloured(document, 1) == [("# value = 1", "syntax_comment_color")] + + def test_replacing_the_whole_text(self): + _highlighter, document = highlighted("a.py", "value = 1\n") + document.setPlainText("def f(): pass\n") + assert coloured(document, 1)[:2] == [ + ("def", "syntax_keyword_color"), ("f", "syntax_function_color")] + + +class ScriptedSession: + """A session that reports whatever the test tells it to.""" + + language_id = "scripted" + has_errors = False + + def __init__(self) -> None: + self.next_report: LineSpan | None = None + self.texts: list[str] = [] + + def update(self, text: str) -> LineSpan | None: + self.texts.append(text) + report, self.next_report = self.next_report, None + return report + + def spans(self, line: int) -> tuple: + del line + return () + + def regions(self) -> tuple: + return () + + +class Recording(TreeSitterHighlighter): + """Writes down every line it is asked to paint.""" + + def __init__(self, document: QTextDocument, session: ScriptedSession) -> None: + self.painted: list[int] = [] + super().__init__(document, session) + + def highlightBlock(self, text: str) -> None: + self.painted.append(self.currentBlock().blockNumber() + 1) + super().highlightBlock(text) + + +@pytest.fixture() +def scripted(): + """A hundred-line document with a scripted session; painting starts from a clean record.""" + session = ScriptedSession() + document = new_document("\n".join(f"line {number}" for number in range(1, 101))) + highlighter = Recording(document, session) + highlighter.rehighlight() + QApplication.processEvents() + highlighter.painted.clear() + yield SimpleNamespace(session=session, document=document, highlighter=highlighter) + highlighter.detach() + + +def type_at(document: QTextDocument, line: int) -> None: + QTextCursor(document.findBlockByNumber(line - 1)).insertText("x") + + +class TestRepaintingBeyondTheEdit: + def test_nothing_reported_paints_the_edited_line_only(self, scripted): + type_at(scripted.document, 3) + QApplication.processEvents() + assert scripted.highlighter.painted == [3] + + def test_lines_after_the_edit_are_painted_in_the_same_pass(self, scripted): + scripted.session.next_report = LineSpan(3, 7) + type_at(scripted.document, 3) + assert scripted.highlighter.painted == [3, 4, 5, 6, 7] + + def test_the_pass_stops_where_the_report_ends(self, scripted): + scripted.session.next_report = LineSpan(3, 5) + type_at(scripted.document, 3) + scripted.highlighter.painted.clear() + type_at(scripted.document, 4) + assert scripted.highlighter.painted == [4] + + def test_lines_before_the_edit_are_painted_on_the_next_turn(self, scripted, qtbot): + scripted.session.next_report = LineSpan(2, 5) + type_at(scripted.document, 5) + assert scripted.highlighter.painted == [5] + qtbot.waitUntil(lambda: scripted.highlighter.painted == [5, 2, 3, 4]) + + def test_many_lines_before_the_edit_repaint_the_document_once(self, scripted, qtbot): + scripted.session.next_report = LineSpan(1, SINGLE_REPAINT_LIMIT + 20) + type_at(scripted.document, SINGLE_REPAINT_LIMIT + 20) + qtbot.waitUntil(lambda: len(scripted.highlighter.painted) > 1) + assert scripted.highlighter.painted[1:] == list(range(1, 101)) + + def test_the_session_gets_the_text_with_plain_newlines(self, scripted): + type_at(scripted.document, 1) + assert scripted.session.texts[-1].startswith("xline 1\nline 2\n") + assert "
" not in scripted.session.texts[-1] + + def test_a_detached_highlighter_does_nothing(self, scripted): + calls = len(scripted.session.texts) + scripted.highlighter.detach() + type_at(scripted.document, 3) + QApplication.processEvents() + assert (len(scripted.session.texts), scripted.highlighter.painted) == (calls, []) + assert scripted.highlighter.document() is None + + def test_detaching_twice_is_harmless(self, scripted): + scripted.highlighter.detach() + scripted.highlighter.detach() + assert scripted.highlighter.document() is None + + @pytest.mark.parametrize("waiting, changed, shift, expected", [ + (None, LineSpan(4, 6), 0, LineSpan(4, 6)), + (LineSpan(10, 12), LineSpan(4, 6), 0, LineSpan(4, 12)), + (LineSpan(10, 12), LineSpan(4, 6), 3, LineSpan(4, 15)), + (LineSpan(10, 12), LineSpan(14, 15), -3, LineSpan(7, 15)), + (LineSpan(1, 2), LineSpan(1, 1), -5, LineSpan(1, 2)), + (LineSpan(90, 99), LineSpan(95, 96), 50, LineSpan(90, 100)), + ]) + def test_waiting_lines_are_widened_the_way_the_edit_moved_them( + self, waiting, changed, shift, expected): + assert _merged(waiting, changed, shift, line_count=100) == expected + + +@pytest.fixture() +def _plugins_untouched(): + """Leave the plugin registry and the legacy table as they were.""" + saved_plugins = dict(_programming_language_plugins) + saved_legacy = dict(syntax_extend_setting_dict) + yield + _programming_language_plugins.clear() + _programming_language_plugins.update(saved_plugins) + syntax_extend_setting_dict.clear() + syntax_extend_setting_dict.update(saved_legacy) + + +@pytest.mark.usefixtures("_plugins_untouched") +class TestPluginKeywords: + """Keywords a plugin registers for a suffix are laid over whichever highlighter is used.""" + + WORDS = {"automation": {"words": {"AC_click"}, "color": "syntax_self_color"}} + + def test_over_the_syntax_engine(self): + register_programming_language(".json", self.WORDS) + _highlighter, document = highlighted("steps.json", '["AC_click", "other"]') + assert coloured(document, 1) == [ + ('"', "syntax_string_color"), ("AC_click", "syntax_self_color"), + ('"', "syntax_string_color"), ('"other"', "syntax_string_color")] + + def test_over_the_generic_highlighter(self): + register_programming_language(".yaml", self.WORDS) + highlighter, document = highlighted("steps.yaml", "- AC_click: 1\n") + assert isinstance(highlighter, GenericHighlighter) + assert ("AC_click", "syntax_self_color") in coloured(document, 1) + + def test_a_comment_wins_over_a_plugin_keyword_in_the_generic_highlighter(self): + register_programming_language(".yaml", self.WORDS) + _highlighter, document = highlighted("steps.yaml", "# AC_click here\n") + assert coloured(document, 1) == [("# AC_click here", "syntax_comment_color")] + + def test_over_the_python_highlighter_for_an_unknown_suffix(self): + register_programming_language(".robot", self.WORDS) + highlighter, document = highlighted("steps.robot", "AC_click now\n") + assert isinstance(highlighter, PythonHighlighter) + assert coloured(document, 1) == [("AC_click", "syntax_self_color")] + + def test_rules_and_a_direct_colour(self): + register_programming_language( + ".json", {}, {"marker": {"rules": (r"@\w+",), "color": QColor(1, 2, 3)}}) + _highlighter, document = highlighted("steps.json", '["@tag"]') + assert ("@tag", QColor(1, 2, 3).name()) in coloured(document, 1) + + def test_the_legacy_table_still_counts(self): + syntax_extend_setting_dict[".cfgx"] = self.WORDS + assert len(plugin_rules(".cfgx")) == 1 + + def test_no_plugin_means_no_rules(self): + assert plugin_rules(".nothing-registered") == [] + + def test_rule_builders(self): + assert len(regex_rules({"a": {"rules": ("x", "y"), "color": "syntax_number_color"}})) == 2 + assert len(word_rules({"a": {"words": ("x",), "color": "syntax_number_color"}})) == 1 + assert regex_rules({"a": {"color": "syntax_number_color"}}) == [] + + def test_an_unknown_colour_key_gives_a_format_without_a_colour(self): + assert make_format("no_such_colour").foreground().style().name == "NoBrush" + + +@pytest.fixture() +def _engine_setting_untouched(): + saved = user_setting_dict.get(SYNTAX_ENGINE_SETTING) + yield + user_setting_dict[SYNTAX_ENGINE_SETTING] = saved + + +@pytest.mark.usefixtures("_engine_setting_untouched") +class TestChoosingAHighlighter: + @pytest.mark.parametrize("file_name, language", [ + ("main.py", "python"), ("stubs.pyi", "python"), ("app.js", "javascript"), + ("data.json", "json"), (None, "python"), + ]) + def test_a_language_the_engine_knows_uses_it(self, file_name, language): + highlighter, _document = highlighted(file_name, "") + assert isinstance(highlighter, TreeSitterHighlighter) + assert highlighter.session.language_id == language + + @pytest.mark.parametrize("file_name, expected", [ + ("app.ts", GenericHighlighter), ("main.rs", GenericHighlighter), + ("notes.txt", PythonHighlighter), ("Makefile", PythonHighlighter), + ]) + def test_other_files_keep_the_older_highlighters(self, file_name, expected): + highlighter, _document = highlighted(file_name, "") + assert type(highlighter) is expected + + @pytest.mark.parametrize("file_name, expected", [ + ("main.py", PythonHighlighter), ("data.json", GenericHighlighter), + (None, PythonHighlighter), + ]) + def test_the_classic_setting_turns_the_engine_off(self, file_name, expected): + user_setting_dict[SYNTAX_ENGINE_SETTING] = CLASSIC_ENGINE + highlighter, _document = highlighted(file_name, "") + assert type(highlighter) is expected + + def test_the_classic_python_highlighter_still_colours(self): + user_setting_dict[SYNTAX_ENGINE_SETTING] = CLASSIC_ENGINE + _highlighter, document = highlighted("main.py", "def f(self): return 1\n") + assert ("def", "syntax_keyword_color") in coloured(document, 1) + assert ("self", "syntax_self_color") in coloured(document, 1) + + def test_an_engine_that_knows_nothing_falls_back(self): + highlighter, _document = highlighted("main.py", "", engine=NoSyntaxEngine()) + assert type(highlighter) is PythonHighlighter + + def test_a_grammar_that_will_not_load_falls_back(self): + def missing(): + raise ImportError("not installed") + + from je_editor.adapters.syntax.grammar_table import GrammarSpec + engine = TreeSitterEngine([GrammarSpec("json", (".json",), missing)]) + highlighter, _document = highlighted("data.json", "", engine=engine) + assert type(highlighter) is GenericHighlighter + + def test_a_window_with_services_uses_their_engine(self): + services = EditorServices() + assert syntax_engine_for(SimpleNamespace(services=services)) is services.syntax + + @pytest.mark.parametrize("window", [None, SimpleNamespace(), MagicMock()]) + def test_a_window_without_services_uses_the_shared_engine(self, window): + assert syntax_engine_for(window) is shared_syntax_engine() + + +class TestReplacingAHighlighter: + @pytest.fixture() + def editor(self): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as venv: + venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor + code_editor = CodeEditor(parent) + yield code_editor + code_editor.close() + code_editor.deleteLater() + + def test_only_the_new_one_stays_on_the_document(self, editor): + for name in ("main.py", "app.ts", "notes.txt", "data.json"): + editor.current_file = name + editor.reset_highlighter() + deliver_deferred_deletes() + assert attached_highlighters(editor.document()) == [editor.highlighter] + assert sum(isinstance(child, QSyntaxHighlighter) + for child in editor.document().children()) == 1 + + def test_the_text_is_recoloured_for_the_new_language(self, editor): + editor.setPlainText('{"key": 1}') + editor.current_file = "data.json" + editor.reset_highlighter() + editor.highlighter.rehighlight() + assert coloured(editor.document(), 1) == [ + ('"key"', "syntax_keyword_color"), ("1", "syntax_number_color")] + + def test_disposing_nothing_is_allowed(self): + dispose_highlighter(None) + + def test_disposing_does_not_look_like_an_edit(self): + for file_name in ("main.py", "app.ts", "notes.txt"): + highlighter, document = highlighted(file_name, "x = 1\n") + edits: list[int] = [] + document.contentsChange.connect(lambda position, *_: edits.append(position)) + dispose_highlighter(highlighter) + assert (edits, document.signalsBlocked()) == ([], False) + + def test_the_python_highlighter_can_be_told_its_suffix(self): + document = new_document("") + assert len(PythonHighlighter(document, suffix=".py").highlight_rules) > len( + PythonHighlighter(document, suffix=".unknown").highlight_rules) + + def test_disposing_takes_a_highlighter_off_its_document(self): + for file_name in ("main.py", "app.ts", "notes.txt"): + highlighter, document = highlighted(file_name, "x = 1\n") + dispose_highlighter(highlighter) + assert attached_highlighters(document) == [] From d4c8f2728f60256b718eba6712657a63f9c02661 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 05:08:35 +0800 Subject: [PATCH 11/14] Give LspClient.start_for back the parameter list PyBreeze pins The workspace change added an optional root parameter to start_for so a language server starts at the root its file belongs to. Callers were not affected, but PyBreeze's own contract test pins that method's parameters to exactly file_path and servers, and has failed since. The root now comes from a root_resolver the editor sets on its client: given a file, the workspace root it belongs to. Without one, or without an answer, the server starts in the file's own folder as before. Nothing in this repository notices such a break. PyBreeze's test_jeditor_contract.py pins parameter lists, private names and fragments of source, so CLAUDE.md and architecture.md now say to run it against this tree after any change PyBreeze could see, and the two shapes broken this week are pinned in test_public_api_contract.py as well. --- CLAUDE.md | 7 ++++ architecture.md | 8 ++++- architecture_explore.md | 10 +++--- docs/updates/2026-10.md | 9 +++++ docs/updates/README.md | 3 +- je_editor/pyside_ui/code/lsp/lsp_client.py | 26 +++++++++++---- .../code_edit_plaintext.py | 16 +++++++-- test/test_public_api_contract.py | 11 +++++++ test/test_workspace_ui.py | 33 +++++++++++++++---- 9 files changed, 100 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a31eaf..978f47a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,3 +193,10 @@ Workspace rule shared by every repository under `D:\Codes` (full text: `D:\Codes - Run PyBreeze's tests as `pytest test/test_utils`. A bare `pytest` or `pytest test` also collects `test/unit_test/start_automation`, which launches the app and ends in "no output, exit 0". +- **PyBreeze pins JEditor's shapes in its own `test/test_utils/test_jeditor_contract.py`**: parameter + lists, private names and fragments of source. Adding even an optional parameter to a method it + pins fails there, and nothing in this repository notices. After any change to a class or function + PyBreeze could see, run that file against this tree — from PyBreeze, + `PYTHONPATH= pytest test/test_utils/test_jeditor_contract.py` — and judge the + result against the same run on the commit before, since PyBreeze's own environment has failures + of its own. diff --git a/architecture.md b/architecture.md index a465730..141ee73 100644 --- a/architecture.md +++ b/architecture.md @@ -177,7 +177,13 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre as a contract. PyBreeze calls `CodeEditor.reset_highlighter()` after changing a tab's file and after `register_programming_language()`; the keywords it registers for `.json` and YAML suffixes are laid over whichever highlighter colours those files. `EditorMain` also sets `services`; - PyBreeze does not use that name today. `test/test_public_api_contract.py` pins the exported names, those module paths + PyBreeze does not use that name today. **PyBreeze keeps contract tests of its own**, + `test/test_utils/test_jeditor_contract.py` in its repository: they pin parameter lists + (`LspClient.start_for(file_path, servers)`, `EditorMain.close_tab(index)`, + `FullEditorWidget.__init__`, `server_command(suffix, servers)`), private names + (`PythonHighlighter._make_format`) and even fragments of source. An optional parameter added + here fails them, so run that file against this tree after any change PyBreeze could see: + `PYTHONPATH= pytest test/test_utils/test_jeditor_contract.py` from PyBreeze. `test/test_public_api_contract.py` pins the exported names, those module paths and the constructor's arguments; it cannot see behaviour or attributes, and its list is a copy that has to be updated when PyBreeze starts importing something new. - **Translations**: a JEditor translation change must keep PyBreeze's diff --git a/architecture_explore.md b/architecture_explore.md index 5cd4962..ef80598 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 326 個 `.py`(201 個實作模組 + 125 個 `__init__.py`),共 36,760 行。 +> 涵蓋範圍:`je_editor/` 全部 326 個 `.py`(201 個實作模組 + 125 個 `__init__.py`),共 36,784 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -23,7 +23,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 101 | 21,667 | View / Controller:所有 Qt 元件與選單 | +| `pyside_ui/` | 101 | 21,691 | View / Controller:所有 Qt 元件與選單 | | `utils/` | 60 | 9,011 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | | `adapters/` | 8 | 1,355 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | | `core/` | 19 | 3,113 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | @@ -32,7 +32,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 36,760 行。) +(行數含各層 `__init__.py`,合計 36,784 行。) --- @@ -280,10 +280,10 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `plaintext_code_edit/code_edit_plaintext.py` | **3,275** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | +| `plaintext_code_edit/code_edit_plaintext.py` | **3,287** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | | `multi_cursor/multi_cursor_manager.py` | 530 | 額外游標的維護與批次套用(插入 / 刪除 / 移動 / 擴選 / 欄選取 / 下一個相同字) | | `snippets/snippet_manager.py` | 280 | 片段展開、定位點跳轉、複本同步;使用者片段存於 `.jeditor/snippets.json` | -| `lsp/lsp_client.py` | 457 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出 | +| `lsp/lsp_client.py` | 469 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出。伺服器以 `root_resolver`(編輯器設定:檔案 → 所屬的工作區根目錄)回答的根目錄啟動,問不到時用檔案所在的資料夾;`start_for(file_path, servers)` 的參數清單是 PyBreeze 釘住的契約 | | `lsp/lsp_session.py` | 242 | `LspSession`(一個伺服器程序)與 `LspSessionRegistry`(同語言分頁共用、引用計數、關閉時 shutdown) | | `code_process/code_exec.py` | 236 | `ExecManager`:執行使用者程式(含插件 run_config),輸出導回面板 | | `shell_process/shell_exec.py` | 132 | `ShellManager`:執行 shell 指令 | diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 7276b7f..3a9428e 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -252,3 +252,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **文件**:`docs/source/docs/{Eng,Zh}/editor.rst`(語法高亮一節重寫)、`core_services.rst`(新增「向語言服務發問」與「語法分析」,每個範例都實際執行過)、`configuration.rst`(`syntax_engine` 與顏色鍵)、`getting_started.rst`(相依套件表,順便補上先前漏掉的 `anthropic`)、三份 README、`architecture.md`(§2、§4 新增高亮流程、§5、§6)、`architecture_explore.md`、藍圖的實作狀態。 - **檔案**:`je_editor/core/syntax/`(新)、`je_editor/core/language/language_capability.py`、`language_request.py`(新)、`language_service.py`、`je_editor/core/services/editor_services.py`、`je_editor/core/__init__.py`、`je_editor/adapters/syntax/`(新,含 5 個 `.scm`)、`je_editor/adapters/default_services.py`、`je_editor/pyside_ui/code/syntax/`(3 個新檔,`python_syntax.py`、`generic_syntax.py`)、`code_edit_plaintext.py`、`main_ui/editor/editor_widget.py`、`main_ui/menu/submenu_map.py`、`save_settings/user_setting_file.py`、`utils/theme/theme_colors.py`、`pyproject.toml`、`dev.toml`(相依與 `package-data`)、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`(刪 #10、加 #23 與 #24)。 - **待辦**:`PROGRESS.md` #23、#24。 + +## U-20261008-09 · 2026-10-08 · 恢復 PyBreeze 釘住的 LspClient.start_for 參數清單;把 PyBreeze 的契約測試列為檢查 · #fix #decision #contract + +- **問題**:U-20261008-07(工作區)為了讓語言伺服器以工作區的根目錄啟動,給 `LspClient.start_for()` 加了一個選用的 `root` 參數。對呼叫的人來說是相容的,但 PyBreeze 自己有一份契約測試(它那邊的 `test/test_utils/test_jeditor_contract.py`),把這個方法的參數清單釘成剛好 `file_path, servers`,所以那個 commit 推上去之後 PyBreeze 的測試就壞了一個。本專案沒有任何測試會發現,是在 U-20261008-08 拿 PyBreeze 的整套測試來對照時才看到的。 +- **做了什麼**:`start_for(file_path, servers)` 的參數清單恢復原狀。根目錄改由客戶端的 `root_resolver`(給檔案路徑、回傳它所屬的根目錄)決定,由 `CodeEditor` 在建立客戶端時設定;沒設定或它回答不出來時,仍然用檔案所在的資料夾。行為跟 U-20261008-07 一樣,只是換了傳進去的方式。 +- **決定:PyBreeze 的契約測試要當成本專案的檢查來跑**。它釘的不只是匯入路徑,還有參數清單、私有名稱(`PythonHighlighter._make_format`)與原始碼片段,本專案的 `test_public_api_contract.py` 只是其中一小部分的副本。規則寫進 `CLAUDE.md` 的「Cross-repo (PyBreeze)」與 `architecture.md` §6:動到 PyBreeze 看得到的類別或函式之後,從 PyBreeze 以 `PYTHONPATH=<本專案>` 跑那個檔,並且跟前一個 commit 的結果比(PyBreeze 自己的環境本來就有失敗)。這次壞掉的兩個形狀也補進了 `test_public_api_contract.py`。 +- **結果**:PyBreeze 的 `test_jeditor_contract.py` 對這個工作樹 46 passed(修正前 45 passed、1 failed)。本專案整套測試 2670 passed;`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束。 +- **檔案**:`je_editor/pyside_ui/code/lsp/lsp_client.py`、`je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py`、`test/test_workspace_ui.py`、`test/test_public_api_contract.py`、`architecture.md`、`architecture_explore.md`、`CLAUDE.md`。 +- **待辦**:無。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 4ee719f..fee83ae 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-09 | 2026-10-08 | 恢復 PyBreeze 釘住的 LspClient.start_for 參數清單;把 PyBreeze 的契約測試列為檢查 | #fix #decision #contract | [2026-10](2026-10.md) | | U-20261008-08 | 2026-10-08 | 藍圖 M2:Tree-sitter 語法引擎與高亮;語言服務的發問形式;四個既有問題 | #done #decision #roadmap #syntax | [2026-10](2026-10.md) | | U-20261008-07 | 2026-10-08 | 藍圖 M3:工作區與多根專案 | #done #roadmap #workspace | [2026-10](2026-10.md) | | U-20261008-06 | 2026-10-08 | M2 診斷與 M5 的 CI 結果;Codacy 三筆:一筆改掉、兩筆是誤判 | #decision #ci #roadmap | [2026-10](2026-10.md) | @@ -99,5 +100,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 16 | +| [2026-10.md](2026-10.md) | 2026-10 | 17 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py index 0d76a40..a6ed827 100644 --- a/je_editor/pyside_ui/code/lsp/lsp_client.py +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -14,6 +14,7 @@ """ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from PySide6.QtCore import QObject, Signal @@ -59,6 +60,12 @@ def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._session: LspSession | None = None self._file_path: str | None = None + # 由擁有者設定:給檔案路徑,回傳它所屬的專案根目錄(沒有就回傳 None)。 + # 這不是 start_for 的參數,因為 PyBreeze 的契約測試釘住了那個方法的參數清單。 + # Set by the owner: given a file path, the project root it belongs to, or + # None. It is not a parameter of start_for because PyBreeze's contract + # test pins that method's parameter list. + self.root_resolver: Callable[[str], str | None] | None = None # 目前接上的伺服器指令 / The command of the server attached right now self._server_command: list[str] = [] self._version = 0 @@ -103,28 +110,28 @@ def server_name(self) -> str: """ return Path(self._server_command[0]).stem if self._server_command else "" - def start_for(self, file_path: str, servers: dict | None = None, - root: str | None = None) -> bool: + def start_for(self, file_path: str, servers: dict | None = None) -> bool: """ 接上負責這個檔案的語言伺服器 Attach to the language server that handles a file. 同一個指令與專案根目錄底下的檔案共用一個伺服器程序,因此開第二個同語言的 - 檔案不會再啟動一個。 + 檔案不會再啟動一個。根目錄由 ``root_resolver`` 決定,沒有設定或它回答不出來 + 時用檔案所在的資料夾。 Files under the same command and project root share one process, so - opening a second file of that language does not start another. + opening a second file of that language does not start another. The root + comes from ``root_resolver``, and is the file's own folder when none is + set or it has no answer. :param file_path: 檔案路徑 / the file to serve :param servers: 伺服器對照表 / the server mapping to consult - :param root: 檔案所屬的專案根目錄;沒給時用檔案所在的資料夾 - the project root the file belongs to, its own folder when omitted :return: 有接上時為 ``True`` / ``True`` when a server was attached """ command = server_command(Path(file_path).suffix, servers) if command is None: return False self.stop() - root = root or str(Path(file_path).parent) + root = self._root_for(file_path) session = session_registry.session_for(command, root, file_uri(root)) if session is None: return False @@ -134,6 +141,11 @@ def start_for(self, file_path: str, servers: dict | None = None, session.register_document(file_uri(file_path), self) return True + def _root_for(self, file_path: str) -> str: + """檔案的專案根目錄,問不到時是它所在的資料夾 / A file's project root, or its own folder.""" + resolved = self.root_resolver(file_path) if self.root_resolver is not None else None + return resolved or str(Path(file_path).parent) + def _send(self, payload: dict) -> bool: """把訊息寫給伺服器 / Write a message to the server.""" return self._session.send(payload) if self._session is not None else False diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index cb18f2b..fceb93c 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -254,6 +254,7 @@ def __init__(self, main_window: EditorWidget | FullEditorWidget) -> None: # The language server connection: the lint pass asks whether it is # supplying diagnostics, so it has to exist by then too self.lsp_client = LspClient(self) + self.lsp_client.root_resolver = self._workspace_root_of # 定義哪些按鍵不會觸發補全視窗 self.skip_popup_behavior_list = [ @@ -2352,7 +2353,7 @@ def start_language_server(self) -> bool: if self.current_file is None or Path(str(self.current_file)).suffix.lower() == ".py": self.lsp_client.stop() return False - if not self.lsp_client.start_for(str(self.current_file), root=self._workspace_root()): + if not self.lsp_client.start_for(str(self.current_file)): return False self.lsp_client.did_open(self.toPlainText()) return True @@ -2368,11 +2369,22 @@ def _workspace_root(self) -> str | None: project's configuration, and with several roots each file goes to the server of its own root. + :return: 根目錄路徑;檔案不在任何根目錄底下時為 ``None`` + the root's path, or ``None`` when the file is under no root + """ + return self._workspace_root_of(str(self.current_file)) + + def _workspace_root_of(self, file_path: str) -> str | None: + """ + 取得某個檔案所屬的工作區根目錄 + The workspace root a file belongs to. + + :param file_path: 檔案路徑 / the file's path :return: 根目錄路徑;檔案不在任何根目錄底下時為 ``None`` the root's path, or ``None`` when the file is under no root """ window = getattr(self.main_window, "main_window", None) - root = window_workspace(window).root_for(str(self.current_file)) + root = window_workspace(window).root_for(file_path) return root.path if root is not None else None def request_language_server_completion(self) -> bool: diff --git a/test/test_public_api_contract.py b/test/test_public_api_contract.py index 99a38a3..856989a 100644 --- a/test/test_public_api_contract.py +++ b/test/test_public_api_contract.py @@ -103,6 +103,17 @@ def test_a_highlight_colour_may_be_a_theme_colour_key(self): from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter assert "actually_color_dict.get(color)" in inspect.getsource(PythonHighlighter._make_format) + def test_the_language_server_is_started_from_a_file_and_a_server_table(self): + # PyBreeze puts its own server in the table and has the editors look again + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + from je_editor.utils.lsp import language_servers + assert list(inspect.signature(LspClient.start_for).parameters) == [ + "self", "file_path", "servers"] + assert "server_command(" in inspect.getsource(LspClient.start_for) + assert list(inspect.signature(language_servers.server_command).parameters) == [ + "suffix", "servers"] + assert inspect.signature(language_servers.server_command).parameters["servers"].default is None + def test_the_editor_methods_pybreeze_calls_after_renaming_a_file(self): from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): diff --git a/test/test_workspace_ui.py b/test/test_workspace_ui.py index 5ad5b0b..4a24871 100644 --- a/test/test_workspace_ui.py +++ b/test/test_workspace_ui.py @@ -377,17 +377,36 @@ def test_a_file_outside_every_root_has_no_root(self, editor_tab, tmp_path): editor_tab.code_edit.current_file = str(tmp_path / "elsewhere" / "lib.rs") assert editor_tab.code_edit._workspace_root() is None - @pytest.mark.parametrize("given, expected_parent", [(True, False), (False, True)]) - def test_the_session_is_started_at_the_given_root_or_the_files_folder( - self, qapp, two_roots, given, expected_parent): + @staticmethod + def _started_at(file_path: str, resolver) -> str: from je_editor.pyside_ui.code.lsp import lsp_client - file_path = str(two_roots[1] / "src" / "lib.rs") client = lsp_client.LspClient() + client.root_resolver = resolver with patch.object(lsp_client, "server_command", return_value=["rust-analyzer"]), \ patch.object(lsp_client.session_registry, "session_for", return_value=MagicMock()) as session_for: - client.start_for(file_path, root=norm(two_roots[1]) if given else None) - started_at = session_for.call_args.args[1] - assert (norm(started_at) == norm(two_roots[1] / "src")) is expected_parent + client.start_for(file_path) client._session = None client.deleteLater() + return norm(session_for.call_args.args[1]) + + def test_the_session_is_started_at_the_root_the_resolver_names(self, qapp, two_roots): + file_path = str(two_roots[1] / "src" / "lib.rs") + asked: list[str] = [] + + def resolver(path: str) -> str: + asked.append(path) + return norm(two_roots[1]) + + assert self._started_at(file_path, resolver) == norm(two_roots[1]) + assert asked == [file_path] + + @pytest.mark.parametrize("resolver", [None, lambda _path: None, lambda _path: ""]) + def test_without_an_answer_it_starts_at_the_files_own_folder(self, qapp, two_roots, resolver): + file_path = str(two_roots[1] / "src" / "lib.rs") + assert self._started_at(file_path, resolver) == norm(two_roots[1] / "src") + + def test_the_editor_gives_its_client_the_workspace_as_resolver(self, editor_tab, two_roots): + resolver = editor_tab.code_edit.lsp_client.root_resolver + assert resolver(str(two_roots[0] / "main.rs")) == norm(two_roots[0]) + assert resolver(str(two_roots[1] / "deep" / "lib.rs")) == norm(two_roots[1]) From 083be9f90f1457deee986e8c1e64c538b64f3e45 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 05:36:33 +0800 Subject: [PATCH 12/14] Stop two assertions comparing an expression with itself SonarCloud rated the new code's reliability C for two assertions in test_syntax_engine.py whose two sides were the same expression (S5863). They looked like a mistake, so they now check the same thing another way: equal values put in a set leave a single copy. The Python 3.10 job failed once on the previous commit, in the unit-test step with exit code 1, and passed on the next one. The same code passes twice on a local Python 3.10, so PROGRESS #21 records it as intermittent. --- PROGRESS.md | 5 +++++ docs/updates/2026-10.md | 8 ++++++++ docs/updates/README.md | 3 ++- test/test_syntax_engine.py | 9 ++++++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5e97b82..8c8d599 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -16,6 +16,11 @@ `test_toolbar_actions.py::TestTheBranchScan::test_a_subdirectory_still_finds_the_repository` 的 setup, pytest-qt 的 `_process_events` 裡。在獨立的工作樹各跑三次(`132246e` 與診斷那次修改)六次都通過, 所以不是那次修改造成的。還沒用 `pytest -s` 抓到 Qt 的訊息,不知道是哪個物件。 + 同一天 CI 也有一次:`45655aa` 的 Python 3.10 那一格在單元測試那一步以結束代碼 1 失敗(是測試失敗,不是當掉), + 3.11 ~ 3.14 通過;下一個 commit `d4c8f27`(同一份程式碼加一個小修正)五個版本都通過,在本機以 Python 3.10.22 + 跑同一份程式碼兩次也都通過(2654 passed)。那一格的記錄檔要登入才看得到,所以不知道是哪個測試。 + 另外,U-20261008-08 找到並修掉了這一類問題的其中一個成因(走訪所有元件時發生垃圾回收), + 但那個的表現是當掉,跟這裡的兩種都不一樣。 ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 3a9428e..ad64ea2 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -261,3 +261,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **結果**:PyBreeze 的 `test_jeditor_contract.py` 對這個工作樹 46 passed(修正前 45 passed、1 failed)。本專案整套測試 2670 passed;`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束。 - **檔案**:`je_editor/pyside_ui/code/lsp/lsp_client.py`、`je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py`、`test/test_workspace_ui.py`、`test/test_public_api_contract.py`、`architecture.md`、`architecture_explore.md`、`CLAUDE.md`。 - **待辦**:無。 + +## U-20261008-10 · 2026-10-08 · M2 的 CI 結果:SonarCloud 兩筆 S5863 改掉;Python 3.10 一次偶發失敗 · #ci #roadmap + +- **CI**:U-20261008-08 的 commit(`45655aa`)推上去之後,Python 3.11 ~ 3.14 與 Codacy(0 筆)通過;SonarCloud 的品質門檻沒過,Python 3.10 那一格失敗。接著的 `d4c8f27`(U-20261008-09)五個 Python 版本都通過,SonarCloud 仍然沒過。 +- **SonarCloud**:新程式碼的可靠度評等是 C(門檻是 A),原因是 2 筆被歸為 bug 的 S5863:`test_syntax_engine.py` 裡 `assert SyntaxSpan(...) == SyntaxSpan(...)` 與 `assert LineSpan(2, 5) == LineSpan(2, 5)` 兩邊是一模一樣的運算式。規則說得對,那樣寫看起來像是寫錯;改成把值放進集合、確認相同的值只留一份,測的東西一樣(依值相等、可雜湊)。SonarCloud 的公開 API 不用金鑰就查得到這個專案的 PR 問題清單。 +- **Python 3.10 那一次失敗**:不是當掉,是單元測試那一步以結束代碼 1 結束;那一格的記錄檔要登入才能讀,所以不知道是哪個測試。為了重現,在本機裝了獨立的 Python 3.10.22 與一個 3.10 的虛擬環境,對同一份程式碼跑了兩次整套測試,都是 2654 passed、4 skipped;下一個 commit 在 CI 上 3.10 也通過。判斷是偶發的,記進 `PROGRESS.md` #21。 +- **檔案**:`test/test_syntax_engine.py`、`PROGRESS.md`。 +- **待辦**:`PROGRESS.md` #21。 diff --git a/docs/updates/README.md b/docs/updates/README.md index fee83ae..4c06459 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-10 | 2026-10-08 | M2 的 CI 結果:SonarCloud 兩筆 S5863 改掉;Python 3.10 一次偶發失敗 | #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-09 | 2026-10-08 | 恢復 PyBreeze 釘住的 LspClient.start_for 參數清單;把 PyBreeze 的契約測試列為檢查 | #fix #decision #contract | [2026-10](2026-10.md) | | U-20261008-08 | 2026-10-08 | 藍圖 M2:Tree-sitter 語法引擎與高亮;語言服務的發問形式;四個既有問題 | #done #decision #roadmap #syntax | [2026-10](2026-10.md) | | U-20261008-07 | 2026-10-08 | 藍圖 M3:工作區與多根專案 | #done #roadmap #workspace | [2026-10](2026-10.md) | @@ -100,5 +101,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 17 | +| [2026-10.md](2026-10.md) | 2026-10 | 18 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/test/test_syntax_engine.py b/test/test_syntax_engine.py index 830d531..53cccf0 100644 --- a/test/test_syntax_engine.py +++ b/test/test_syntax_engine.py @@ -67,9 +67,12 @@ def test_a_region_over_several_lines_can(self): region = StructuralRegion(RegionKind.CLASS, TextRange.from_lines(3, 1, 9, 1)) assert (region.is_multiline, region.name) == (True, "") - def test_spans_and_line_spans_compare_by_value(self): - assert SyntaxSpan(1, 3, SyntaxCategory.KEYWORD) == SyntaxSpan(1, 3, SyntaxCategory.KEYWORD) - assert LineSpan(2, 5) == LineSpan(2, 5) + def test_spans_and_line_spans_are_values(self): + # Equal fields make one value, so a set keeps a single copy of it. + fields = (1, 3, SyntaxCategory.KEYWORD) + spans = {SyntaxSpan(*fields), SyntaxSpan(*fields), SyntaxSpan(2, 3, SyntaxCategory.KEYWORD)} + lines = {LineSpan(2, 5), LineSpan(*(2, 5)), LineSpan(2, 6)} + assert (len(spans), len(lines)) == (2, 2) def test_the_engine_that_knows_nothing(self): nothing = NoSyntaxEngine() From 56cfe34bcad6d2ef82950fb87b260a527ab856b9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 05:59:09 +0800 Subject: [PATCH 13/14] Add a debug session that speaks the Debug Adapter Protocol The debugger behind the editor is pdb driven through its console. This adds the service the roadmap's debugger milestone needs, without touching the debugger's widgets yet: a DebugSession that launches or attaches, sets breakpoints with conditions, steps, and answers questions about threads, the stack, variables, expressions and exceptions, by talking DAP to an adapter. DapSession knows neither the language nor where the adapter runs. The adapter's command, its launch arguments and the channel used to attach are given from outside, and the process is started through a TaskRunner, so remote debugging is another runner or channel away. debugpy is the adapter for Python: it runs on the editor's interpreter while the program may run on another. Queries take a function for the reply, the shape language services use, and reply with an error instead of raising. Replies and events arrive on the session's own thread. Attaching to a program started with "debugpy --listen" goes over TCP: the program waits with an adapter of its own, and attaching through a second adapter process stalled in the handshake. LocalTaskRunner is the first TaskRunner. It starts child processes from an argument list, never through a shell, and TaskSpec gains "binary" for protocols framed in bytes. debugpy 1.8.22 is pinned; it was already installed through qtconsole. Integration tests drive the real adapter: breakpoints, a conditional one, stop on entry, stepping over, into and out, an uncaught exception, pause, and attach. The debugger UI still uses pdb; PROGRESS #12 keeps that half. --- PROGRESS.md | 9 +- README.md | 6 +- README/README_zh-CN.md | 5 +- README/README_zh-TW.md | 5 +- architecture.md | 6 +- architecture_explore.md | 38 +- dev.toml | 2 +- dev_requirements.txt | 1 + docs/roadmap/2026-editor-next.md | 3 +- docs/source/docs/Eng/core_services.rst | 88 ++- docs/source/docs/Eng/getting_started.rst | 2 + docs/source/docs/Zh/core_services.rst | 80 ++- docs/source/docs/Zh/getting_started.rst | 2 + docs/updates/2026-10.md | 20 + docs/updates/README.md | 3 +- je_editor/adapters/debug/__init__.py | 0 je_editor/adapters/debug/dap_session.py | 439 ++++++++++++ je_editor/adapters/debug/debugpy_adapter.py | 132 ++++ je_editor/adapters/debug/socket_channel.py | 158 +++++ je_editor/adapters/default_services.py | 16 +- je_editor/adapters/process/__init__.py | 0 .../adapters/process/local_task_runner.py | 246 +++++++ je_editor/core/__init__.py | 9 +- je_editor/core/debug/debug_session.py | 290 +++++++- je_editor/core/process/task_service.py | 19 +- je_editor/utils/dap/__init__.py | 0 je_editor/utils/dap/dap_protocol.py | 120 ++++ pyproject.toml | 2 +- requirements.txt | 1 + test/test_core_services.py | 49 +- test/test_dap_session.py | 634 ++++++++++++++++++ test/test_debugpy_integration.py | 243 +++++++ 32 files changed, 2554 insertions(+), 74 deletions(-) create mode 100644 je_editor/adapters/debug/__init__.py create mode 100644 je_editor/adapters/debug/dap_session.py create mode 100644 je_editor/adapters/debug/debugpy_adapter.py create mode 100644 je_editor/adapters/debug/socket_channel.py create mode 100644 je_editor/adapters/process/__init__.py create mode 100644 je_editor/adapters/process/local_task_runner.py create mode 100644 je_editor/utils/dap/__init__.py create mode 100644 je_editor/utils/dap/dap_protocol.py create mode 100644 test/test_dap_session.py create mode 100644 test/test_debugpy_integration.py diff --git a/PROGRESS.md b/PROGRESS.md index 8c8d599..b1d1bfc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,7 +24,7 @@ ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) -M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引擎)、M3(工作區與多根專案)、M5(AI 供應者)已完成,見 `docs/updates/2026-10.md`。 +M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引擎)、M3(工作區與多根專案)、M5(AI 供應者)已完成,M4 完成了服務那一半,見 `docs/updates/2026-10.md`。 以下依相依關係排序。 - **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 @@ -44,8 +44,11 @@ M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引 - **#22** M3 沒有涵蓋的部分(工作區本身已完成,見 U-20261008-07):執行程式、測試面板、終端機、Git 工具列與 Python 直譯器(venv)仍然只認主要的根目錄,也就是工作目錄。藍圖要的「每個根目錄有自己的 語言 / 工具設定與環境」還沒做;Git 面板也還沒有依根目錄切換。 -- **#12** M4(除錯器改走 DAP)。實作 `DebugSession`;堆疊、變數、求值的非同步查詢形式在這裡定; - 需要一個本機的 `TaskRunner` 實作來啟動轉接器。 +- **#12** M4(除錯器改走 DAP)剩下畫面這一半。服務這一半已完成(U-20261008-11):`DapSession`、 + 本機 `TaskRunner`、debugpy 轉接器,對真正的 debugpy 有整合測試。還沒做的是除錯面板(執行緒、堆疊、 + 變數、求值、輸出)、編輯器裡標出目前執行的那一行、條件中斷點的輸入方式,以及把「執行除錯器」與 + 逐步執行的快捷鍵從 pdb 主控台改接到 `services.debug_adapters`。既有的 pdb 主控台在 debugpy 不能用時 + (例如打包成執行檔、`sys.executable` 不是直譯器)要留作退路。 - **#14** M6(遠端開發)。實作 `RemoteSession`,並補上遠端檔案系統、連接埠轉送、直譯器探索的介面。 〔決定〕第一個傳輸是不是 SSH(PR #270 的審查問題 3)。 - **#15** M7(可嵌入元件)。`import je_editor.core` 不再載入 Qt(頂層 `__init__` 要改成延後匯入); diff --git a/README.md b/README.md index 43189ca..66ce189 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,7 @@ Core dependencies are installed automatically: | langchain_openai + langchain_core | OpenAI-compatible AI provider | | anthropic | Anthropic AI provider | | tree-sitter + tree-sitter-python / -javascript / -json | Syntax parsing for highlighting | +| debugpy | Python debug adapter (Debug Adapter Protocol) | | watchdog | File system monitoring | | pycodestyle | PEP8 style checking | | qtconsole | Jupyter/IPython console widget | @@ -572,8 +573,9 @@ je_editor/ │ └── main_ui/ Main window, menus, toolbar, panels, settings, AI, console ├── core/ Service layer, no Qt: workspace, documents, diagnostics, and the │ interfaces for language services, debugging, tasks, remote and AI -├── adapters/ Implementations of those interfaces, no Qt: the AI providers and -│ the Tree-sitter syntax engine +├── adapters/ Implementations of those interfaces, no Qt: the AI providers, the +│ Tree-sitter syntax engine, the local task runner and the DAP +│ debug session ├── code_scan/ Ruff execution and watchdog file monitoring ├── git_client/ Git operations (GitPython + git CLI) ├── plugins/ Plugin registry and loader diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 1852f02..22185b5 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -265,6 +265,7 @@ pip install . | langchain_openai + langchain_core | OpenAI 兼容的 AI 提供者 | | anthropic | Anthropic 的 AI 提供者 | | tree-sitter + tree-sitter-python / -javascript / -json | 语法高亮用的语法解析 | +| debugpy | Python 的调试适配器(Debug Adapter Protocol) | | watchdog | 文件系统监控 | | pycodestyle | PEP8 风格检查 | | qtconsole | Jupyter/IPython 控制台组件 | @@ -527,8 +528,8 @@ je_editor/ │ └── main_ui/ 主窗口、菜单、工具栏、面板、设置、AI、控制台 ├── core/ 服务层,不依赖 Qt:工作区、文档、诊断,以及语言服务、 │ 调试、任务执行、远程与 AI 的接口 -├── adapters/ 上述接口的实现,不依赖 Qt:AI 提供者与 -│ Tree-sitter 语法引擎 +├── adapters/ 上述接口的实现,不依赖 Qt:AI 提供者、Tree-sitter 语法引擎、 +│ 本机任务执行器与 DAP 调试会话 ├── code_scan/ Ruff 执行与 watchdog 文件监控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 插件注册表与加载器 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c0c5604..740df5c 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -265,6 +265,7 @@ pip install . | langchain_openai + langchain_core | OpenAI 相容的 AI 供應者 | | anthropic | Anthropic 的 AI 供應者 | | tree-sitter + tree-sitter-python / -javascript / -json | 語法高亮用的語法解析 | +| debugpy | Python 的除錯轉接器(Debug Adapter Protocol) | | watchdog | 檔案系統監控 | | pycodestyle | PEP8 風格檢查 | | qtconsole | Jupyter/IPython 主控台元件 | @@ -527,8 +528,8 @@ je_editor/ │ └── main_ui/ 主視窗、選單、工具列、面板、設定、AI、主控台 ├── core/ 服務層,不依賴 Qt:工作區、文件、診斷,以及語言服務、 │ 除錯、工作執行、遠端與 AI 的介面 -├── adapters/ 上述介面的實作,不依賴 Qt:AI 供應者與 -│ Tree-sitter 語法引擎 +├── adapters/ 上述介面的實作,不依賴 Qt:AI 供應者、Tree-sitter 語法引擎、 +│ 本機工作執行器與 DAP 除錯工作階段 ├── code_scan/ Ruff 執行與 watchdog 檔案監控 ├── git_client/ Git 操作(GitPython + git CLI) ├── plugins/ 外掛註冊表與載入器 diff --git a/architecture.md b/architecture.md index 141ee73..23f3d72 100644 --- a/architecture.md +++ b/architecture.md @@ -22,7 +22,7 @@ window, and plugins extend it through a small registry API. | `je_editor/pyside_ui/code/` | `CodeEditor` (`plaintext_code_edit/`) plus its managers (folding, bookmarks, lint, LSP, diff/blame, snippets, multi-cursor), highlighters (`syntax/`), process runners (`code_process/`, `shell_process/`, `base_process_manager.py`) | | `je_editor/pyside_ui/dialog/`, `git_ui/`, `browser/` | Search/replace, shortcut, snippet and file dialogs; Git panel, commit graph, diff viewers; embedded QtWebEngine browser | | `je_editor/core/` | Service layer with no Qt import: `EditorServices` (`services/`) bundles the workspace model (`workspace/`), open documents (`document/`), the unified diagnostic model and store (`diagnostics/`), the language service registry (`language/`), and the interfaces for debug sessions (`debug/`), task execution (`process/`), remote sessions (`remote/`) and AI providers (`ai/`). `events/` and `registry/` replace Qt signals and per-feature registries. The window consumes the diagnostics part so far: the editor's `LintManager` and the Problems panel hold their findings in the unified model (roadmap `docs/roadmap/2026-editor-next.md`) | -| `je_editor/adapters/` | Implementations of the `core/` interfaces, also Qt-free; third-party SDKs are imported at the point of use. `ai/`: `OpenAIProvider` (LangChain `ChatOpenAI`), `AnthropicProvider` (official `anthropic` SDK, streamed), the built-in registration and the `.jeditor/ai_config.json` reader/writer (which never logs the content). `default_services.py` builds an `EditorServices` with these registered `syntax/`: the Tree-sitter `SyntaxEngine` (`tree_sitter_engine.py`), its grammar table and query files (`grammar_table.py`, `queries//*.scm`), and `SyntaxLanguageService` | +| `je_editor/adapters/` | Implementations of the `core/` interfaces, also Qt-free; third-party SDKs are imported at the point of use. `ai/`: `OpenAIProvider` (LangChain `ChatOpenAI`), `AnthropicProvider` (official `anthropic` SDK, streamed), the built-in registration and the `.jeditor/ai_config.json` reader/writer (which never logs the content). `default_services.py` builds an `EditorServices` with these registered `syntax/`: the Tree-sitter `SyntaxEngine` (`tree_sitter_engine.py`), its grammar table and query files (`grammar_table.py`, `queries//*.scm`), and `SyntaxLanguageService` `process/`: `LocalTaskRunner`, child processes started from an argument list. `debug/`: `DapSession`, a `DebugSession` that talks the Debug Adapter Protocol to an adapter process or a TCP port (`socket_channel.py`), and the debugpy adapter for Python | | `je_editor/utils/` | Pure logic with no widgets (only `multi_language/locale_match.py` imports Qt): text operations, encodings, sessions, diffs, symbols, LSP protocol, shortcut registry, theme colors, translations (`multi_language/`), logging, stdout/stderr redirect | | `je_editor/code_scan/` | ruff runner and watchdog file monitor, run on worker threads | | `je_editor/git_client/` | Git access: `GitService` (GitPython) and `GitCLI` (subprocess), blame, HEAD baseline, hunk staging | @@ -154,7 +154,9 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre its `NamedRegistry` attributes — `ai_providers`, `debug_adapters` (session factories), `task_runners`, `remote_transports` (by URI scheme) — and language services through `languages.register()`. Any source reports findings with `diagnostics.publish(source, uri, ...)`. - Implementations live in `adapters/`: the AI providers `openai` and `anthropic`, and the + Implementations live in `adapters/`: the AI providers `openai` and `anthropic`, the local task + runner (`task_runners["local"]`), the `debugpy` debug adapter (`debug_adapters["debugpy"]`, + a factory returning a new `DebugSession`; the window does not use it yet), and the Tree-sitter syntax engine, which `build_default_services()` sets as `services.syntax` and registers as the `syntax` language service. Questions to language services go through `languages.request(LanguageRequest, on_reply)`, which returns a cancel function. A plugin diff --git a/architecture_explore.md b/architecture_explore.md index ef80598..3ce03f7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 326 個 `.py`(201 個實作模組 + 125 個 `__init__.py`),共 36,784 行。 +> 涵蓋範圍:`je_editor/` 全部 334 個 `.py`(206 個實作模組 + 128 個 `__init__.py`),共 38,177 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -15,8 +15,8 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | --- | --- | | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | -| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`tree-sitter` 與三個文法套件(`tree-sitter-python` / `-javascript` / `-json`)、`frontengine` | -| 測試 | pytest + pytest-qt,112 個測試檔、約 19,400 行 | +| 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`tree-sitter` 與三個文法套件(`tree-sitter-python` / `-javascript` / `-json`)、`debugpy`、`frontengine` | +| 測試 | pytest + pytest-qt,114 個測試檔、約 20,300 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 @@ -24,15 +24,15 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | | `pyside_ui/` | 101 | 21,691 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 60 | 9,011 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `adapters/` | 8 | 1,355 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | -| `core/` | 19 | 3,113 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | +| `utils/` | 61 | 9,131 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `adapters/` | 12 | 2,340 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | +| `core/` | 19 | 3,401 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 36,784 行。) +(行數含各層 `__init__.py`,合計 38,177 行。) --- @@ -81,7 +81,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 112 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 114 個測試檔的原因。 --- @@ -148,7 +148,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(60 模組 / 9,011 行) +### 5.2 `utils/` — 純邏輯層(61 模組 / 9,131 行) #### 文字與行操作 @@ -424,7 +424,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `browser_serach_lineedit.py` | 52 | 網址 / 搜尋輸入列 | | `browser_download_window.py` | 75 | 下載進度與狀態視窗 | -### 5.11 `core/` — 核心服務層(19 模組 / 3,113 行) +### 5.11 `core/` — 核心服務層(19 模組 / 3,401 行) 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`)的 M0:先把服務的介面與資料物件定下來,視窗層之後 逐個里程碑改接過來。目前診斷(`LintManager` 與問題面板)、AI 對話面板、工作區與語法高亮已經在用。整層不匯入 Qt 也不匯入 `pyside_ui/`; @@ -446,18 +446,18 @@ start_editor(debug_mode) je_editor/start_editor.py | `language/language_request.py` | 152 | 向語言服務發問的形式:`LanguageRequest`、`LanguageReply`,以及保證「回覆最多一次、取消之後不再送達」的 `ReplyOnce`(以鎖保護,服務可以從自己的執行緒回覆) | | `language/language_service.py` | 233 | `LanguageService` 協定(文件生命週期加上 `request()`)與 `LanguageServiceRegistry`:把 `DocumentStore` 的開啟 / 變更 / 關閉轉給處理該文件的服務,晚登記的服務會補收已開文件的「開啟」;`request()` 把問題交給第一個處理那份文件又提供那個功能的服務 | | `syntax/syntax_model.py` | 244 | 語法分析的模型與介面:`SyntaxCategory`、`SyntaxSpan`(一行裡的一段,欄號 1 起算、以 UTF-16 單位計)、`LineSpan`、`RegionKind` / `StructuralRegion`,`SyntaxSession` 與 `SyntaxEngine` 兩個協定,以及什麼語言都不會的 `NoSyntaxEngine`(`EditorServices.syntax` 的預設值) | -| `debug/debug_session.py` | 205 | `DebugSession` 協定與資料物件(`DebugLaunchRequest`、`Breakpoint`、`StackFrame`、`Variable`、`DebugState`、`StepKind`),名稱對應 DAP 的概念 | -| `process/task_service.py` | 169 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream` | +| `debug/debug_session.py` | 477 | `DebugSession` 協定與資料物件,名稱對應 DAP 的概念:啟動(`DebugLaunchRequest`)與接上(`DebugAttachRequest`)、中斷點(可帶條件)與轉接器的回報(`BreakpointStatus`)、控制指令(繼續 / 暫停 / 逐步,可指定執行緒)、查詢(執行緒、堆疊、變數群組、變數、求值、例外資訊;給一個收回覆的函式,回覆是 `DebugReply`)、事件(`state_changed`、`stopped`、`output`、`breakpoints_reported`) | +| `process/task_service.py` | 182 | `TaskSpec`(指令只能是引數清單,建立後指令與環境變數都不能再改)、`TaskHandle` / `TaskRunner` 協定、`TaskState`、`OutputStream`;`TaskSpec.binary` 讓輸出與寫入都以位元組進行,給除錯轉接器這類以位元組組框的協定用 | | `remote/remote_session.py` | 91 | `RemoteSession` 協定與 `RemoteState`;`task_runner()` 回傳與本機相同的 `TaskRunner` 介面 | | `ai/ai_provider.py` | 167 | `AIProvider` 協定與資料物件(`ChatRequest`、`ChatMessage`、`ChatRole`、`ChatResponse`、`ModelInfo`、`CancelToken`) | | `ai/ai_settings.py` | 166 | `ProviderSettings` 與 `AISettings`:依供應者分組的設定(金鑰、位址、模型、系統提示詞)與目前選用的供應者;舊格式的 `AI_model` 會被讀成 `openai` 那一組 | | `ai/chat_session.py` | 94 | `ChatSession`:保管一段對話、組出下一個請求;失敗或被取消的那一句不留在對話裡 | -除錯、工作執行、遠端三項目前只有介面;既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 +遠端目前只有介面。除錯與工作執行在 `adapters/` 已經有實作(DAP 與本機子程序),但視窗還沒有改用:既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 `adapters/ai/`,對話面板已經改走 `AIProvider`;語法分析的實作在 `adapters/syntax/`,編輯器的高亮已經改走 `SyntaxEngine`。 -### 5.12 `adapters/` — 核心介面的實作(8 模組 / 1,355 行) +### 5.12 `adapters/` — 核心介面的實作(12 模組 / 2,340 行) `core/` 只有介面;真正去連某一家服務的程式碼放在這裡。跟 `core/` 一樣不匯入 Qt 與 `pyside_ui/` (`test_core_architecture.py` 把它列進 UI 層以下的套件),第三方 SDK 都在用到的時候才匯入。 @@ -465,11 +465,15 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | | `__init__.py` | 58 | 套件說明 | -| `default_services.py` | 57 | `build_default_services()`:建立 `EditorServices`、接上共用的語法引擎並登記 `SyntaxLanguageService`、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | +| `default_services.py` | 67 | `build_default_services()`:建立 `EditorServices`、接上共用的語法引擎並登記 `SyntaxLanguageService`、登記本機工作執行器(`local`)與內建的除錯轉接器(`debugpy`)、載入 AI 設定、登記內建的 AI 供應者;`EditorMain` 與沒有 `services` 的宿主視窗都用它 | | `ai/openai_provider.py` | 147 | `OpenAIProvider`:透過 LangChain 的 `ChatOpenAI` 呼叫 OpenAI 相容端點;回覆整份回來後去掉 `` 之前的思考過程 | | `ai/anthropic_provider.py` | 201 | `AnthropicProvider`:官方 `anthropic` SDK 的串流請求;可中途取消、回報 token 用量、把 SDK 的錯誤類別轉成給使用者看的說明;會拒絕請求的模型啟用伺服器端 fallback | | `ai/builtin_providers.py` | 50 | `register_builtin_ai_providers()`:每個供應者拿到「取得自己那組設定」的函式,所以改設定不必重新登記 | | `ai/settings_file.py` | 80 | `.jeditor/ai_config.json` 的讀寫;日誌只記路徑、從不記內容(裡面有 API 金鑰) | +| `process/local_task_runner.py` | 246 | `LocalTaskRunner` / `LocalTask`:`TaskRunner` 的本機實作。以引數清單啟動子程序(從不經過 shell),標準輸出與標準錯誤各一條執行緒讀取,另一條等程序結束並通知結束代碼;`wait()` 等到結束代碼通知出去為止 | +| `debug/dap_session.py` | 439 | `DapSession`:以 DAP 實作的 `DebugSession`。啟動轉接器後照協定的順序打招呼(`initialize` → `launch` / `attach` → 等 `initialized` 事件 → 送中斷點 → `configurationDone`),把回應與事件轉成核心層的資料物件;不知道被除錯的是哪種語言,轉接器的指令、啟動引數與接上既有程式時的通道都由外面給 | +| `debug/socket_channel.py` | 158 | `SocketChannel`:把一條 TCP 連線包成跟位元組模式的工作一樣的形狀。接上已經帶著轉接器在連接埠等待的程式時用它,之後經 SSH 轉送的遠端除錯也是 | +| `debug/debugpy_adapter.py` | 132 | Python 的轉接器 debugpy:轉接器的指令(編輯器自己的直譯器)、`launch` / `attach` 引數、接上時直接連到連接埠;`register_builtin_debug_adapters()` 在 debugpy 有安裝時才登記 | | `syntax/grammar_table.py` | 130 | 內建文法的表(`GrammarSpec`:語言 ID、副檔名、匯入文法套件的函式)、查詢名稱到 `SyntaxCategory` 的對照(`function.builtin` 找不到時退回 `function`),以及讀專案自己查詢檔的 `own_query()`。多支援一種語言就是加一列與一組查詢檔 | | `syntax/tree_sitter_engine.py` | 536 | `TreeSitterEngine` 與 `TreeSitterSession`,唯一知道 Tree-sitter 的地方。更新時找出新舊文字不同的最小一段(對齊到字元邊界)告訴舊的樹,只重新解析受影響的部分,並回報語法變了的行;分類以 64 行為一塊、用到才算;同一個節點被多條規則抓到時取查詢裡寫在後面的那一條;位元組欄換算成 UTF-16 欄;超過 2 MB 不解析。文法或查詢載不起來時那個語言變成不支援,不丟例外。讀 Tree-sitter 的位置一律用索引(原因寫在模組裡:0.26.0 的 `.row` / `.column` 會弄壞參考計數) | | `syntax/syntax_language_service.py` | 142 | `SyntaxLanguageService`:把語法引擎接成語言服務,文件一開就有語法樹、一變就更新;回答 `SYNTAX_TREE`(那份文件的 session)與 `DOCUMENT_SYMBOLS`(有名稱的結構區塊) | @@ -562,7 +566,7 @@ Qt 的高亮器重畫時會送出 `textChanged`(不是 `contentsChange`), ## 7. 測試與 CI -- `test/` 112 個測試檔、約 19,400 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 114 個測試檔、約 20,300 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -616,7 +620,7 @@ Qt 的高亮器重畫時會送出 `textChanged`(不是 `contentsChange`), 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 7. **`core/` 接上了診斷、AI、工作區與語法高亮**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、除錯、工作執行與遠端還沒有接上,視窗層仍然 - 各自持有這些狀態。語法引擎目前只用來上色:編輯器直接向引擎要 session,沒有經過 `DocumentStore`,大綱、折疊與智慧選取也還在用各自的分析(`utils/symbols`、`utils/code_folding`、`utils/selection`)。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 + 各自持有這些狀態(除錯與工作執行的實作已經在 `adapters/`,差的是視窗這一端)。語法引擎目前只用來上色:編輯器直接向引擎要 session,沒有經過 `DocumentStore`,大綱、折疊與智慧選取也還在用各自的分析(`utils/symbols`、`utils/code_folding`、`utils/selection`)。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 主要的根目錄(工作目錄)。 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, diff --git a/dev.toml b/dev.toml index 165a589..9b5fc68 100644 --- a/dev.toml +++ b/dev.toml @@ -22,7 +22,7 @@ dependencies = [ "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", "watchdog", "ruff", "gitpython>=3.1.59", "tree-sitter==0.26.0", "tree-sitter-python==0.25.0", "tree-sitter-javascript==0.25.0", - "tree-sitter-json==0.24.8" + "tree-sitter-json==0.24.8", "debugpy==1.8.22" ] classifiers = [ "Programming Language :: Python :: 3.10", diff --git a/dev_requirements.txt b/dev_requirements.txt index 50cc300..b2e8b30 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,6 +6,7 @@ tree-sitter==0.26.0 tree-sitter-python==0.25.0 tree-sitter-javascript==0.25.0 tree-sitter-json==0.24.8 +debugpy==1.8.22 ruff sphinx twine diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index 1a564c3..414938f 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -16,7 +16,8 @@ | M2 — Tree-sitter half | Implemented: `je_editor/adapters/syntax/` colours Python, JavaScript and JSON; folding, outline and selection still use their own analysers | U-20261008-08, `PROGRESS.md` | | M3 — Workspace + multi-root | Implemented; per-root environments and Git are left over | U-20261008-07, `PROGRESS.md` | | M5 — AI provider abstraction + Anthropic | Implemented: `je_editor/adapters/ai/` | U-20261008-05 | -| M1, M4, M6, M7, M8 | Not started | `PROGRESS.md` | +| M4 — Debugger migration to DAP | Service implemented and tested against debugpy: `je_editor/adapters/debug/`; the debugger UI still drives pdb | U-20261008-11, `PROGRESS.md` | +| M1, M6, M7, M8 | Not started | `PROGRESS.md` | M0 defines the service layer and proves it runs without Qt. What it left to later milestones: diff --git a/docs/source/docs/Eng/core_services.rst b/docs/source/docs/Eng/core_services.rst index 4eb1e57..07e75b4 100644 --- a/docs/source/docs/Eng/core_services.rst +++ b/docs/source/docs/Eng/core_services.rst @@ -10,8 +10,8 @@ command-line tool or a host application that never builds the JEditor window. This layer is the foundation of the next-generation editor roadmap. The editor window moves onto it one area at a time: diagnostics, the AI chat panel, the workspace and syntax - highlighting use it so far, while debugging, task execution and remote sessions are - interfaces only. + highlighting use it so far. Debugging and task execution have implementations the window + does not use yet, and remote sessions are an interface only. Quick Example -------------- @@ -320,12 +320,80 @@ and ``regions.scm`` names the structural regions. A grammar that is not installe that does not compile, makes that language unsupported rather than raising, and the editor falls back to its pattern-based highlighter. -Debugging, Tasks, Remote Sessions and AI Providers ---------------------------------------------------- +Debugging +---------- + +A ``DebugSession`` is one program being debugged: launch it or attach to it, set breakpoints, +step, and ask about threads, the stack, variables and expressions. Its names follow the Debug +Adapter Protocol (DAP), and ``je_editor.adapters.debug`` implements it by talking DAP to an +adapter. ``build_default_services()`` registers ``debugpy``, the adapter for Python. + +.. code-block:: python + + import tempfile + import threading + from pathlib import Path + + from je_editor.adapters.default_services import build_default_services + from je_editor.core import Breakpoint, DebugLaunchRequest, to_uri + + program = Path(tempfile.mkdtemp()) / "program.py" + program.write_text("total = 0\nfor number in range(3):\n total += number\nprint(total)\n", + encoding="utf-8") + + services = build_default_services() + session = services.debug_adapters.require("debugpy")() + stops = [] + stopped, answered = threading.Event(), threading.Event() + session.stopped.subscribe(lambda stop: (stops.append(stop), stopped.set())) + + uri = to_uri(program) + session.set_breakpoints(uri, [Breakpoint(uri, 3, condition="number == 2")]) + session.launch(DebugLaunchRequest(str(program))) + stopped.wait(60) + print(stops[0].reason, session.state().value) # breakpoint paused + + + def show(reply): + print([(frame.name, frame.line) for frame in reply.value]) + answered.set() + + + session.stack_trace(stops[0].thread_id, show) # [('', 3)] + answered.wait(60) + session.terminate() + services.shutdown() -These four are interfaces with their data objects. The implementations live in -``je_editor.adapters``, outside this layer. So far that is the two AI providers (``openai`` and -``anthropic``, see :doc:`ai_assistant`); a host or a plugin registers its own for the rest. +- **Control commands** return as soon as they are sent: ``resume()``, ``pause()``, + ``step(StepKind.OVER)`` (also ``INTO`` and ``OUT``) and ``terminate()``. Each takes an optional + thread and otherwise acts on the thread that stopped last. +- **Queries** take a function for the reply, as language services do: ``threads()``, + ``stack_trace(thread_id)``, ``scopes(frame_id)``, ``variables(reference)``, + ``evaluate(expression, frame_id)`` and ``exception_info(thread_id)``. The reply is a + ``DebugReply`` with ``value``, ``error`` and ``ok``; a query that cannot be answered replies + with an ``error`` and the empty value rather than raising. +- **Events** are ``state_changed`` (``DebugState``), ``stopped`` (``StopEvent``: why and which + thread), ``output`` (``OutputEvent``) and ``breakpoints_reported`` (``BreakpointStatus``). +- ``set_breakpoints(uri, breakpoints)`` gives the whole list for one file each time. A + ``Breakpoint`` may carry a ``condition``. Breakpoints set before the launch are sent once + the adapter is ready. +- ``attach(DebugAttachRequest(port, host))`` connects to a program that is already waiting for + a debugger, such as one started with ``python -m debugpy --listen 5678 --wait-for-client``. +- Replies and events arrive on the session's own thread. Code that updates widgets has to + move them to the widget thread. + +The adapter process is started through a ``TaskRunner``, so a runner that starts processes +somewhere else turns the same session into remote debugging. Another adapter is a ``DapSession`` +with its own command and launch arguments, registered under a name in +``services.debug_adapters``. + +Tasks, Remote Sessions and AI Providers +---------------------------------------- + +These are interfaces with their data objects. The implementations live in +``je_editor.adapters``, outside this layer: the two AI providers (``openai`` and ``anthropic``, +see :doc:`ai_assistant`) and a ``TaskRunner`` for this machine, registered as ``local``. Remote +sessions have no implementation yet; a host or a plugin registers its own. .. list-table:: :header-rows: 1 @@ -334,10 +402,6 @@ These four are interfaces with their data objects. The implementations live in * - Area - Interface - Data objects - * - Debugging - - ``DebugSession`` - - ``DebugLaunchRequest``, ``Breakpoint``, ``StackFrame``, ``Variable``, ``DebugState``, - ``StepKind`` * - Task execution - ``TaskRunner``, ``TaskHandle`` - ``TaskSpec``, ``TaskState``, ``OutputStream`` @@ -353,6 +417,8 @@ These four are interfaces with their data objects. The implementations live in a shell, and a string is refused. - ``TaskRunner.create(spec)`` returns a handle that has not started. Subscribe to its ``output`` and ``finished`` events, then call ``start()``, so no early output is missed. +- A ``TaskSpec`` with ``binary=True`` delivers output as the bytes that were read and takes + bytes in ``write()``. Protocols framed in bytes, a debug adapter's among them, need that. - ``RemoteSession.task_runner()`` returns the same ``TaskRunner`` interface, so a caller never has to tell where a process runs. - ``AIProvider.complete(request, on_text, cancel)`` blocks until the reply is complete. Call it diff --git a/docs/source/docs/Eng/getting_started.rst b/docs/source/docs/Eng/getting_started.rst index f9d4704..22aae22 100644 --- a/docs/source/docs/Eng/getting_started.rst +++ b/docs/source/docs/Eng/getting_started.rst @@ -67,6 +67,8 @@ JEditor will automatically install the following dependencies: - AI assistant: Anthropic provider * - tree-sitter / tree-sitter-python / tree-sitter-javascript / tree-sitter-json - Syntax parsing for highlighting + * - debugpy + - Python debug adapter (Debug Adapter Protocol) * - watchdog - File system monitoring * - pycodestyle diff --git a/docs/source/docs/Zh/core_services.rst b/docs/source/docs/Zh/core_services.rst index 1975b3a..0d27c66 100644 --- a/docs/source/docs/Zh/core_services.rst +++ b/docs/source/docs/Zh/core_services.rst @@ -8,7 +8,7 @@ .. note:: 這一層是下一代編輯器藍圖的基礎。編輯器視窗一次改接一個部分:目前診斷、AI 對話面板、工作區 - 與語法高亮已經在用它,除錯、工作執行與遠端工作階段還只有介面。 + 與語法高亮已經在用它。除錯與工作執行已經有實作,但視窗還沒有改用;遠端工作階段還只有介面。 快速範例 -------- @@ -303,11 +303,75 @@ EditorServices ``highlights.scm`` 會接在文法自帶的高亮查詢之後, ``regions.scm`` 則指出結構區塊。文法沒有安裝、 或查詢檔編譯不過時,那個語言只是變成不支援,不會丟出例外,編輯器會退回以樣式比對的高亮器。 -除錯、工作執行、遠端工作階段與 AI 供應者 ----------------------------------------- +除錯 +---- + +``DebugSession`` 代表一個正在被除錯的程式:啟動它或接上它、設定中斷點、逐步執行,並查詢 +執行緒、堆疊、變數與運算式。它的名稱沿用 Debug Adapter Protocol(DAP)的概念, +``je_editor.adapters.debug`` 則以 DAP 跟轉接器對話來實作它。 ``build_default_services()`` 會登記 +Python 的轉接器 ``debugpy`` 。 + +.. code-block:: python + + import tempfile + import threading + from pathlib import Path + + from je_editor.adapters.default_services import build_default_services + from je_editor.core import Breakpoint, DebugLaunchRequest, to_uri + + program = Path(tempfile.mkdtemp()) / "program.py" + program.write_text("total = 0\nfor number in range(3):\n total += number\nprint(total)\n", + encoding="utf-8") + + services = build_default_services() + session = services.debug_adapters.require("debugpy")() + stops = [] + stopped, answered = threading.Event(), threading.Event() + session.stopped.subscribe(lambda stop: (stops.append(stop), stopped.set())) + + uri = to_uri(program) + session.set_breakpoints(uri, [Breakpoint(uri, 3, condition="number == 2")]) + session.launch(DebugLaunchRequest(str(program))) + stopped.wait(60) + print(stops[0].reason, session.state().value) # breakpoint paused + + + def show(reply): + print([(frame.name, frame.line) for frame in reply.value]) + answered.set() + + + session.stack_trace(stops[0].thread_id, show) # [('', 3)] + answered.wait(60) + session.terminate() + services.shutdown() -這四項是介面加上各自的資料物件。實作放在這一層之外的 ``je_editor.adapters`` ,目前有兩個 AI 供應者 -( ``openai`` 與 ``anthropic`` ,見 :doc:`ai_assistant` );其餘的由宿主程式或外掛自行登記。 +- **控制指令** 送出就回來: ``resume()`` 、 ``pause()`` 、 ``step(StepKind.OVER)`` (另有 ``INTO`` + 與 ``OUT`` )與 ``terminate()`` 。每一個都可以指定執行緒,沒指定時作用在上次停下來的那一條。 +- **查詢** 跟語言服務一樣,要給一個收回覆的函式: ``threads()`` 、 ``stack_trace(thread_id)`` 、 + ``scopes(frame_id)`` 、 ``variables(reference)`` 、 ``evaluate(expression, frame_id)`` 與 + ``exception_info(thread_id)`` 。回覆是 ``DebugReply`` ,有 ``value`` 、 ``error`` 與 ``ok`` ; + 答不出來的查詢回覆的是 ``error`` 與空值,不會丟出例外。 +- **事件** 有 ``state_changed`` ( ``DebugState`` )、 ``stopped`` ( ``StopEvent`` :為什麼停、 + 哪一條執行緒)、 ``output`` ( ``OutputEvent`` )與 ``breakpoints_reported`` + ( ``BreakpointStatus`` )。 +- ``set_breakpoints(uri, breakpoints)`` 每次都給一個檔案的整份清單。 ``Breakpoint`` 可以帶 + ``condition`` 。啟動之前設定的中斷點會在轉接器準備好之後送出。 +- ``attach(DebugAttachRequest(port, host))`` 接上一個已經在等除錯器的程式,例如以 + ``python -m debugpy --listen 5678 --wait-for-client`` 啟動的程式。 +- 回覆與事件都在工作階段自己的執行緒上送達。要更新畫面的程式得自己把它們轉回畫面執行緒。 + +轉接器程序是透過 ``TaskRunner`` 啟動的,所以換成一個在別處啟動程序的執行器,同一個工作階段 +就成了遠端除錯。別的轉接器就是一個帶著自己的指令與啟動引數的 ``DapSession`` ,以名稱登記在 +``services.debug_adapters`` 。 + +工作執行、遠端工作階段與 AI 供應者 +---------------------------------- + +這幾項是介面加上各自的資料物件。實作放在這一層之外的 ``je_editor.adapters`` :兩個 AI 供應者 +( ``openai`` 與 ``anthropic`` ,見 :doc:`ai_assistant` )與這台機器的 ``TaskRunner`` +(登記名稱是 ``local`` )。遠端工作階段還沒有實作,由宿主程式或外掛自行登記。 .. list-table:: :header-rows: 1 @@ -316,10 +380,6 @@ EditorServices * - 領域 - 介面 - 資料物件 - * - 除錯 - - ``DebugSession`` - - ``DebugLaunchRequest``、``Breakpoint``、``StackFrame``、``Variable``、``DebugState``、 - ``StepKind`` * - 工作執行 - ``TaskRunner``、``TaskHandle`` - ``TaskSpec``、``TaskState``、``OutputStream`` @@ -334,6 +394,8 @@ EditorServices - ``TaskSpec`` 的指令一律是引數清單。沒有「把一整行交給 shell」的形式,給字串會被拒絕。 - ``TaskRunner.create(spec)`` 回傳一個尚未啟動的把手。先訂閱它的 ``output`` 與 ``finished`` 事件, 再呼叫 ``start()``,才不會漏掉一開始的輸出。 +- ``TaskSpec`` 設了 ``binary=True`` 時,輸出以讀到的位元組原樣送出, ``write()`` 也收位元組。 + 以位元組組框的協定(除錯轉接器的就是)需要這個。 - ``RemoteSession.task_runner()`` 回傳的是同一個 ``TaskRunner`` 介面,所以呼叫端不必分辨程序在哪裡 執行。 - ``AIProvider.complete(request, on_text, cancel)`` 會等到回覆完成才返回,請在背景執行緒呼叫。 diff --git a/docs/source/docs/Zh/getting_started.rst b/docs/source/docs/Zh/getting_started.rst index 19bd794..2d62c29 100644 --- a/docs/source/docs/Zh/getting_started.rst +++ b/docs/source/docs/Zh/getting_started.rst @@ -67,6 +67,8 @@ JEditor 會自動安裝以下依賴套件: - AI 助理:Anthropic 供應者 * - tree-sitter / tree-sitter-python / tree-sitter-javascript / tree-sitter-json - 語法高亮用的語法解析 + * - debugpy + - Python 的除錯轉接器(Debug Adapter Protocol) * - watchdog - 檔案系統監控 * - pycodestyle diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index ad64ea2..9a2842a 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -269,3 +269,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Python 3.10 那一次失敗**:不是當掉,是單元測試那一步以結束代碼 1 結束;那一格的記錄檔要登入才能讀,所以不知道是哪個測試。為了重現,在本機裝了獨立的 Python 3.10.22 與一個 3.10 的虛擬環境,對同一份程式碼跑了兩次整套測試,都是 2654 passed、4 skipped;下一個 commit 在 CI 上 3.10 也通過。判斷是偶發的,記進 `PROGRESS.md` #21。 - **檔案**:`test/test_syntax_engine.py`、`PROGRESS.md`。 - **待辦**:`PROGRESS.md` #21。 + +## U-20261008-11 · 2026-10-08 · 藍圖 M4(上):DAP 除錯服務、本機工作執行器、debugpy 轉接器 · #decision #roadmap #debug + +- **做了什麼**:藍圖 M4(除錯器改走 DAP)的服務那一半。現在有一個不依賴 Qt、以 Debug Adapter Protocol 跟轉接器對話的除錯工作階段,並對真正的 debugpy 做過整合測試。編輯器的除錯畫面還沒有改接,仍然是 pdb 主控台(`PROGRESS.md` #12 留著畫面那一半)。 + - `core/debug/debug_session.py`:`DebugSession` 補齊。新增 `attach()`、查詢(`threads`、`stack_trace`、`scopes`、`variables`、`evaluate`、`exception_info`)、事件(`stopped`、`output`、`breakpoints_reported`),以及 `DebugThread`、`Scope`、`StopEvent`、`OutputEvent`、`ExceptionInfo`、`EvaluateResult`、`BreakpointStatus`、`DebugAttachRequest`、`DebugReply`。`DebugLaunchRequest` 多了 `interpreter` 與 `just_my_code`;控制指令可以指定執行緒。 + - `utils/dap/dap_protocol.py`(新,純邏輯):DAP 訊息的組法與讀法。組框跟 LSP 一樣,直接沿用 `lsp_protocol` 的 `encode_message` / `MessageReader`。 + - `adapters/process/local_task_runner.py`(新):`TaskRunner` 的本機實作,以引數清單啟動子程序(從不經過 shell)。`TaskSpec` 多了 `binary`,讓輸出與寫入以位元組進行。 + - `adapters/debug/dap_session.py`(新):`DapSession`。照協定的順序打招呼(`initialize` → `launch` / `attach` → 等 `initialized` 事件 → 送中斷點與例外中斷 → `configurationDone`),把回應與事件轉成核心層的資料物件。 + - `adapters/debug/debugpy_adapter.py`、`socket_channel.py`(新):Python 的轉接器 debugpy;`build_default_services()` 登記本機執行器(`local`)與 `debugpy`。 +- **決定**: + - **查詢的形式跟語言服務一樣**:給一個收回覆的函式,回覆是 `DebugReply(value, error)`;答不出來是帶 `error` 與空值的回覆,不丟例外。控制指令送出就回來。回覆與事件在工作階段自己的執行緒上送達,要更新畫面的人自己轉回畫面執行緒。 + - **轉接器經由 `TaskRunner` 啟動**,`DapSession` 不知道程序在哪裡跑,也不知道被除錯的是哪種語言(指令、啟動引數、接上時的通道都由外面給)。之後的遠端除錯換一個執行器或通道就好。 + - **Python 的轉接器用 debugpy,不自己把 pdb 包成 DAP**。轉接器用編輯器自己的直譯器跑,被除錯的程式可以用別的直譯器(`DebugLaunchRequest.interpreter`),那個環境不必安裝 debugpy。debugpy 沒有安裝時不登記,畫面那一半要以此決定是否退回 pdb 主控台。 + - **接上既有程式走 TCP,不另開轉接器程序**。以 `debugpy --listen` 啟動的程式自己帶著轉接器在那個連接埠等用戶端;一開始照啟動的做法另開一個轉接器再送 `attach`,握手停在等 `initialized`,永遠等不到。改成 `SocketChannel` 直接連過去講協定之後就通了。`DapSession` 的 `attach_channel` 是選用的,沒給的轉接器仍然走「另開程序」。 +- **相依**:`debugpy==1.8.22`(2026-09-15 發佈)加進 `pyproject.toml`、`dev.toml` 與兩份 requirements。它原本就會被 `qtconsole` 連帶裝進來,現在是直接使用,所以明確列出並釘版本。 +- **測試**:`test_dap_session.py`(新,93 個:訊息、本機執行器對真正的子程序、`DapSession` 對一個照劇本回話的假轉接器、`SocketChannel` 對真正的 socket、debugpy 的引數);`test_debugpy_integration.py`(新,8 個,對真正的 debugpy):中斷點停下來之後查執行緒 / 堆疊 / 區域變數 / 求值、逐步越過與跳出、繼續到下一次中斷;條件中斷點;一進入就停;逐步進入;未捕捉的例外與例外資訊;暫停執行中的程式;程式不存在;接上等待中的程式。沒有 debugpy 時整個檔案會跳過。 +- **結果**:整套測試 2773 passed(修改前 2670);`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束;Sphinx 沒有新警告,`core_services` 中英文結構相同(各 181 個行內程式碼、16 個標題、8 個程式碼區塊);兩種語言的文件範例全部實際執行過,除錯那一個印出 `breakpoint paused` 與 `[('', 3)]`。PyBreeze 的 `test_jeditor_contract.py` 對這個工作樹 46 passed。整合測試在這台機器上約 30 秒。`test_core_services.py` 裡既有的假除錯工作階段補上了新的成員。另外,上一個 commit(`083be9f`)在 CI 上五個 Python 版本、Codacy 與 SonarCloud 全部通過。 +- **文件**:`docs/source/docs/{Eng,Zh}/core_services.rst` 新增「Debugging」一節(範例實際啟動 debugpy、停在條件中斷點、查堆疊),原本合在一起的那一節改成「Tasks, Remote Sessions and AI Providers」;`getting_started.rst` 與三份 README 的相依套件表與目錄說明;`architecture.md` §2、§5;`architecture_explore.md`;藍圖的實作狀態。 +- **檔案**:`je_editor/core/debug/debug_session.py`、`je_editor/core/process/task_service.py`、`je_editor/core/__init__.py`、`je_editor/utils/dap/`(新)、`je_editor/adapters/process/`(新)、`je_editor/adapters/debug/`(新)、`je_editor/adapters/default_services.py`、`pyproject.toml`、`dev.toml`、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`。 +- **待辦**:`PROGRESS.md` #12(畫面那一半)。 diff --git a/docs/updates/README.md b/docs/updates/README.md index 4c06459..fa66504 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-11 | 2026-10-08 | 藍圖 M4(上):DAP 除錯服務、本機工作執行器、debugpy 轉接器 | #decision #roadmap #debug | [2026-10](2026-10.md) | | U-20261008-10 | 2026-10-08 | M2 的 CI 結果:SonarCloud 兩筆 S5863 改掉;Python 3.10 一次偶發失敗 | #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-09 | 2026-10-08 | 恢復 PyBreeze 釘住的 LspClient.start_for 參數清單;把 PyBreeze 的契約測試列為檢查 | #fix #decision #contract | [2026-10](2026-10.md) | | U-20261008-08 | 2026-10-08 | 藍圖 M2:Tree-sitter 語法引擎與高亮;語言服務的發問形式;四個既有問題 | #done #decision #roadmap #syntax | [2026-10](2026-10.md) | @@ -101,5 +102,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 18 | +| [2026-10.md](2026-10.md) | 2026-10 | 19 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/adapters/debug/__init__.py b/je_editor/adapters/debug/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/adapters/debug/dap_session.py b/je_editor/adapters/debug/dap_session.py new file mode 100644 index 0000000..0b35b78 --- /dev/null +++ b/je_editor/adapters/debug/dap_session.py @@ -0,0 +1,439 @@ +""" +透過 Debug Adapter Protocol 跟除錯轉接器對話的除錯工作階段 +A debug session that talks to a debug adapter over the Debug Adapter Protocol. + +轉接器是一個子程序,以標準輸入與標準輸出收發 DAP 訊息。這個類別負責啟動它、照 +協定規定的順序打招呼(``initialize`` → ``launch`` 或 ``attach`` → 等 ``initialized`` +事件 → 送出中斷點 → ``configurationDone``),再把之後的事件與回應轉成核心層的 +資料物件。它不知道被除錯的是哪一種語言:轉接器的指令與啟動引數都由外面給。 +The adapter is a child process that exchanges DAP messages over its standard +input and output. This class starts it, greets it in the order the protocol +demands (``initialize``, then ``launch`` or ``attach``, then wait for the +``initialized`` event, send the breakpoints, ``configurationDone``), and turns +the events and responses that follow into the core layer's data objects. It +does not know which language is being debugged: the adapter's command and the +launch arguments are given from outside. + +回應與事件都在讀取轉接器輸出的那條執行緒上處理,訂閱者與收回覆的函式也在那條 +執行緒上被呼叫。 +Responses and events are handled on the thread that reads the adapter's output, +and subscribers and reply functions are called on that thread too. +""" +from __future__ import annotations + +import threading +from collections.abc import Callable, Sequence + +from je_editor.core.debug.debug_session import ( + Breakpoint, BreakpointStatus, DebugAttachRequest, DebugLaunchRequest, DebugReply, DebugState, + DebugThread, EvaluateResult, ExceptionInfo, OutputEvent, Scope, StackFrame, StepKind, + StopEvent, Variable +) +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import OutputStream, TaskHandle, TaskRunner, TaskSpec +from je_editor.core.uri.resource_uri import to_path, to_uri +from je_editor.utils.dap.dap_protocol import ( + TYPE_EVENT, TYPE_RESPONSE, MessageReader, encode_message, items_of, message_kind, number_of, + request, source_breakpoints, text_of +) +from je_editor.utils.logging.loggin_instance import jeditor_logger + +ResponseHandler = Callable[[dict], None] +# 送出 disconnect 之後等轉接器自己結束的秒數,超過就直接結束它 +# Seconds to wait for the adapter to exit by itself after disconnect, before it is stopped +DISCONNECT_GRACE_SECONDS = 3.0 +# 一次最多要幾層堆疊 / The most stack frames asked for at once +MAX_STACK_FRAMES = 200 +_STEP_COMMANDS = {StepKind.OVER: "next", StepKind.INTO: "stepIn", StepKind.OUT: "stepOut"} +_CONSOLE = "console" + + +def _nothing(_response: dict) -> None: + """不需要看回應的請求用這個 / For requests whose response nobody needs.""" + + +class DapSession: + """ + 以 DAP 轉接器實作的除錯工作階段 + The debug session implemented with a DAP adapter. + """ + + def __init__(self, adapter_command: Sequence[str], runner: TaskRunner, adapter_id: str, + launch_arguments: Callable[[DebugLaunchRequest], dict], + attach_arguments: Callable[[DebugAttachRequest], dict], + attach_channel: Callable[[DebugAttachRequest], TaskHandle] | None = None) -> None: + """ + :param adapter_command: 啟動轉接器的指令 / the command that starts the adapter + :param runner: 用來啟動轉接器的地方;換成遠端的就是遠端除錯 + where the adapter is started; a remote one makes this remote debugging + :param adapter_id: 轉接器的名稱,會放進 ``initialize`` / the adapter's name, sent in ``initialize`` + :param launch_arguments: 把啟動請求變成這個轉接器的 ``launch`` 引數 + turns a launch request into this adapter's ``launch`` arguments + :param attach_arguments: 把連線請求變成這個轉接器的 ``attach`` 引數 + turns an attach request into this adapter's ``attach`` arguments + :param attach_channel: 接上既有程式時要用哪條通道講協定;沒給時跟啟動一樣, + 另開一個轉接器程序。對方自己帶著轉接器等連線的(例如 debugpy)要給這個 + the channel the protocol is spoken over when attaching; when omitted + an adapter process is started as for a launch. Needed where the other + side waits with an adapter of its own, as debugpy does + """ + self._adapter_command = tuple(adapter_command) + self._runner = runner + self._adapter_id = adapter_id + self._launch_arguments = launch_arguments + self._attach_arguments = attach_arguments + self._attach_channel = attach_channel + self._state_changed = EventHook() + self._stopped = EventHook() + self._output = EventHook() + self._breakpoints_reported = EventHook() + self._state = DebugState.IDLE + self._task: TaskHandle | None = None + self._reader = MessageReader() + self._lock = threading.Lock() + self._seq = 0 + self._pending: dict[int, ResponseHandler] = {} + self._breakpoints: dict[str, tuple[Breakpoint, ...]] = {} + self._configured = False + self._stopped_thread = 0 + + @property + def state_changed(self) -> EventHook: + """狀態改變後發出,引數是新的狀態 / Fired with the new state.""" + return self._state_changed + + @property + def stopped(self) -> EventHook: + """程式停下來時發出,引數是 ``StopEvent`` / Fired with a ``StopEvent`` when the program stops.""" + return self._stopped + + @property + def output(self) -> EventHook: + """有輸出時發出,引數是 ``OutputEvent`` / Fired with an ``OutputEvent``.""" + return self._output + + @property + def breakpoints_reported(self) -> EventHook: + """轉接器回報中斷點時發出,引數是一組 ``BreakpointStatus`` / Fired with the reported breakpoints.""" + return self._breakpoints_reported + + def state(self) -> DebugState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + return self._state + + def launch(self, request_: DebugLaunchRequest) -> bool: + """ + 啟動程式並開始除錯 + Launch the program under the debugger. + + :param request_: 啟動所需的資訊 / what to launch + :return: 轉接器有啟動時為 ``True`` / ``True`` when the adapter started + """ + return self._begin("launch", self._launch_arguments(request_), self._adapter_process()) + + def attach(self, request_: DebugAttachRequest) -> bool: + """ + 接上一個已經在執行的程式 + Attach to a program that is already running. + + :param request_: 要接上哪裡 / where to attach + :return: 有連上轉接器時為 ``True`` / ``True`` when the adapter was reached + """ + channel = (self._adapter_process() if self._attach_channel is None + else self._attach_channel(request_)) + return self._begin("attach", self._attach_arguments(request_), channel) + + def set_breakpoints(self, uri: str, breakpoints: Sequence[Breakpoint]) -> None: + """ + 設定某個資源的全部中斷點 + Set every breakpoint of one resource. + + 還沒啟動時先記著,轉接器準備好之後一起送出。 + Before the launch they are kept, and sent once the adapter is ready. + + :param uri: 資源的 URI / the resource's URI + :param breakpoints: 這個資源現在所有的中斷點 / all its breakpoints now + """ + self._breakpoints[uri] = tuple(item for item in breakpoints if item.enabled) + if self._configured: + self._send_breakpoints(uri) + + def resume(self, thread_id: int = 0) -> None: + """ + 繼續執行 + Carry on running. + + :param thread_id: 要繼續的執行緒,零表示上次停下來的那一條 + the thread to resume, zero for the one that stopped last + """ + self._send("continue", {"threadId": thread_id or self._stopped_thread}, _nothing) + + def pause(self, thread_id: int = 0) -> None: + """ + 暫停執行 + Pause the run. + + :param thread_id: 要暫停的執行緒,零表示上次停下來的那一條 + the thread to pause, zero for the one that stopped last + """ + self._send("pause", {"threadId": thread_id or self._stopped_thread}, _nothing) + + def step(self, kind: StepKind, thread_id: int = 0) -> None: + """ + 逐步執行 + Take one step. + + :param kind: 逐步的方式 / how to step + :param thread_id: 要逐步的執行緒,零表示上次停下來的那一條 + the thread to step, zero for the one that stopped last + """ + self._send(_STEP_COMMANDS[kind], {"threadId": thread_id or self._stopped_thread}, _nothing) + + def threads(self, on_reply: Callable[[DebugReply[tuple[DebugThread, ...]]], None]) -> None: + """ + 查詢程式裡的執行緒 + Ask for the program's threads. + + :param on_reply: 收回覆的函式 / receives the reply + """ + self._query("threads", {}, on_reply, (), lambda body: tuple( + DebugThread(number_of(item, "id"), text_of(item, "name")) + for item in items_of(body, "threads"))) + + def stack_trace(self, thread_id: int, + on_reply: Callable[[DebugReply[tuple[StackFrame, ...]]], None]) -> None: + """ + 查詢一條執行緒的呼叫堆疊,最裡面的一層在最前面 + Ask for a thread's call stack, innermost frame first. + + :param thread_id: 執行緒編號 / the thread's id + :param on_reply: 收回覆的函式 / receives the reply + """ + arguments = {"threadId": thread_id, "startFrame": 0, "levels": MAX_STACK_FRAMES} + self._query("stackTrace", arguments, on_reply, (), lambda body: tuple( + _frame(item) for item in items_of(body, "stackFrames"))) + + def scopes(self, frame_id: int, on_reply: Callable[[DebugReply[tuple[Scope, ...]]], None]) -> None: + """ + 查詢一層堆疊裡有哪幾組變數 + Ask which groups of variables a frame has. + + :param frame_id: 堆疊那一層的編號 / the frame's id + :param on_reply: 收回覆的函式 / receives the reply + """ + self._query("scopes", {"frameId": frame_id}, on_reply, (), lambda body: tuple( + Scope(text_of(item, "name"), number_of(item, "variablesReference"), + item.get("expensive") is True) + for item in items_of(body, "scopes"))) + + def variables(self, reference: int, + on_reply: Callable[[DebugReply[tuple[Variable, ...]]], None]) -> None: + """ + 查詢一組變數,或一個變數的子項目 + Ask for a group of variables, or for a variable's children. + + :param reference: ``Scope`` 或 ``Variable`` 給的編號 / the handle a scope or a variable gave + :param on_reply: 收回覆的函式 / receives the reply + """ + self._query("variables", {"variablesReference": reference}, on_reply, (), lambda body: tuple( + Variable(text_of(item, "name"), text_of(item, "value"), text_of(item, "type"), + number_of(item, "variablesReference")) + for item in items_of(body, "variables"))) + + def evaluate(self, expression: str, frame_id: int, + on_reply: Callable[[DebugReply[EvaluateResult | None]], None]) -> None: + """ + 在某一層堆疊裡求一個運算式的值 + Evaluate an expression in a frame. + + :param expression: 運算式 / the expression + :param frame_id: 堆疊那一層的編號,零表示全域 / the frame's id, zero for the global scope + :param on_reply: 收回覆的函式 / receives the reply + """ + arguments: dict = {"expression": expression, "context": "repl"} + if frame_id: + arguments["frameId"] = frame_id + self._query("evaluate", arguments, on_reply, None, lambda body: EvaluateResult( + text_of(body, "result"), text_of(body, "type"), number_of(body, "variablesReference"))) + + def exception_info(self, thread_id: int, + on_reply: Callable[[DebugReply[ExceptionInfo | None]], None]) -> None: + """ + 查詢一條執行緒停在哪個例外上 + Ask which exception a thread stopped on. + + :param thread_id: 執行緒編號 / the thread's id + :param on_reply: 收回覆的函式 / receives the reply + """ + self._query("exceptionInfo", {"threadId": thread_id}, on_reply, None, lambda body: ExceptionInfo( + text_of(body, "exceptionId"), text_of(body, "description"), text_of(body, "breakMode"), + text_of(body.get("details"), "stackTrace"))) + + def terminate(self) -> None: + """ + 結束除錯並放掉它持有的程序 + End the run and release the process it holds. + + 先請轉接器結束被除錯的程式;它沒有在期限內自己結束的話,就直接結束它。 + The adapter is asked to end the program being debugged first, and is + stopped outright when it has not exited by itself in time. + """ + task = self._task + if task is None or self._state in (DebugState.IDLE, DebugState.TERMINATED): + return + self._send("disconnect", {"terminateDebuggee": True}, _nothing) + timer = threading.Timer(DISCONNECT_GRACE_SECONDS, task.cancel) + timer.daemon = True + timer.start() + + def _adapter_process(self) -> TaskHandle: + """準備一個轉接器程序,還不啟動 / Prepare an adapter process without starting it.""" + return self._runner.create(TaskSpec(self._adapter_command, name="debug adapter", binary=True)) + + def _begin(self, command: str, arguments: dict, task: TaskHandle) -> bool: + """接上轉接器並開始打招呼 / Reach the adapter and begin the handshake.""" + if self._state not in (DebugState.IDLE, DebugState.TERMINATED): + return False + self._reader = MessageReader() + self._configured = False + self._stopped_thread = 0 + task.output.subscribe(self._on_adapter_output) + task.finished.subscribe(self._on_adapter_finished) + self._task = task + if not task.start(): + self._task = None + return False + self._set_state(DebugState.STARTING) + self._send("initialize", { + "clientID": "jeditor", "clientName": "JEditor", "adapterID": self._adapter_id, + "linesStartAt1": True, "columnsStartAt1": True, "pathFormat": "path", + "supportsVariableType": True, "supportsRunInTerminalRequest": False, + }, lambda _response: self._send(command, arguments, self._on_started)) + return True + + def _on_started(self, response: dict) -> None: + """``launch`` 或 ``attach`` 的回應 / The response to ``launch`` or ``attach``.""" + if response.get("success") is not True: + self._output.emit(OutputEvent(_CONSOLE, text_of(response, "message", "the debugger could not start"))) + self.terminate() + elif self._state is DebugState.STARTING: + self._set_state(DebugState.RUNNING) + + def _configure(self) -> None: + """轉接器準備好了:送出記著的中斷點,再宣告設定完成 / The adapter is ready: send the breakpoints, then finish configuring.""" + self._configured = True + for uri in list(self._breakpoints): + self._send_breakpoints(uri) + self._send("setExceptionBreakpoints", {"filters": ["uncaught"]}, _nothing) + self._send("configurationDone", {}, _nothing) + + def _send_breakpoints(self, uri: str) -> None: + """把一個資源的中斷點送給轉接器 / Send one resource's breakpoints to the adapter.""" + wanted = self._breakpoints.get(uri, ()) + arguments = { + "source": {"path": to_path(uri)}, + "breakpoints": source_breakpoints([(item.line, item.condition) for item in wanted]), + } + + def report(response: dict) -> None: + statuses = tuple( + BreakpointStatus(uri, number_of(item, "line"), item.get("verified") is True, + text_of(item, "message")) + for item in items_of(response.get("body"), "breakpoints")) + self._breakpoints_reported.emit(statuses) + + self._send("setBreakpoints", arguments, report) + + def _query(self, command: str, arguments: dict, on_reply: Callable, empty: object, + parse: Callable[[dict], object]) -> None: + """送出一個查詢,把回應變成 ``DebugReply`` / Send a query and turn its response into a ``DebugReply``.""" + + def respond(response: dict) -> None: + body = response.get("body") + if response.get("success") is True and isinstance(body, dict): + on_reply(DebugReply(parse(body))) + else: + on_reply(DebugReply(empty, text_of(response, "message", f"{command} failed"))) + + if not self._send(command, arguments, respond): + on_reply(DebugReply(empty, "the debugger is not running")) + + def _send(self, command: str, arguments: dict, on_response: ResponseHandler) -> bool: + """送出一個請求,並記下誰要它的回應 / Send a request and remember who wants its response.""" + task = self._task + if task is None: + return False + with self._lock: + self._seq += 1 + seq = self._seq + self._pending[seq] = on_response + if task.write(encode_message(request(seq, command, arguments))): + return True + with self._lock: + self._pending.pop(seq, None) + return False + + def _on_adapter_output(self, stream: OutputStream, data: bytes) -> None: + """轉接器寫了東西:標準輸出是協定訊息,標準錯誤是給人看的 / The adapter wrote: protocol on stdout, text for people on stderr.""" + if stream is OutputStream.STDERR: + self._output.emit(OutputEvent(_CONSOLE, data.decode("utf-8", "replace"))) + return + for message in self._reader.feed(data): + kind = message_kind(message) + if kind == TYPE_RESPONSE: + self._on_response(message) + elif kind == TYPE_EVENT: + self._on_event(text_of(message, "event"), message.get("body")) + + def _on_response(self, message: dict) -> None: + """把回應交給當初送出請求的人 / Hand a response to whoever sent the request.""" + with self._lock: + handler = self._pending.pop(number_of(message, "request_seq"), None) + if handler is not None: + handler(message) + + def _on_event(self, event: str, body: object) -> None: + """處理轉接器送來的事件 / Handle an event from the adapter.""" + if event == "initialized": + self._configure() + elif event == "stopped": + self._stopped_thread = number_of(body, "threadId") + self._set_state(DebugState.PAUSED) + all_stopped = not isinstance(body, dict) or body.get("allThreadsStopped") is not False + self._stopped.emit(StopEvent(text_of(body, "reason"), self._stopped_thread, + text_of(body, "description"), text_of(body, "text"), all_stopped)) + elif event == "continued": + self._set_state(DebugState.RUNNING) + elif event == "output": + self._output.emit(OutputEvent(text_of(body, "category", _CONSOLE), text_of(body, "output"))) + elif event == "terminated": + self.terminate() + + def _on_adapter_finished(self, exit_code: int) -> None: + """轉接器結束了:沒等到回應的請求都算失敗 / The adapter ended: every request still waiting has failed.""" + jeditor_logger.info("debug adapter %s exited with %s", self._adapter_id, exit_code) + with self._lock: + waiting, self._pending = list(self._pending.values()), {} + self._task = None + self._configured = False + for handler in waiting: + handler({"success": False, "message": "the debugger has ended"}) + self._set_state(DebugState.TERMINATED) + + def _set_state(self, state: DebugState) -> None: + """換狀態,真的變了才通知 / Change state, announcing it only when it really changed.""" + if state is not self._state: + self._state = state + self._state_changed.emit(state) + + +def _frame(item: dict) -> StackFrame: + """把 DAP 的一層堆疊變成 ``StackFrame`` / Turn one DAP stack frame into a ``StackFrame``.""" + path = text_of(item.get("source"), "path") + return StackFrame(number_of(item, "id"), text_of(item, "name"), to_uri(path) if path else "", + number_of(item, "line", 1), number_of(item, "column", 1)) diff --git a/je_editor/adapters/debug/debugpy_adapter.py b/je_editor/adapters/debug/debugpy_adapter.py new file mode 100644 index 0000000..fbd06cd --- /dev/null +++ b/je_editor/adapters/debug/debugpy_adapter.py @@ -0,0 +1,132 @@ +""" +Python 的除錯轉接器:debugpy +The debug adapter for Python: debugpy. + +debugpy 的轉接器以 ``python -m debugpy.adapter`` 啟動,從標準輸入與輸出講 DAP。 +轉接器用編輯器自己的直譯器跑(debugpy 裝在那裡),被除錯的程式則可以用別的 +直譯器:debugpy 會把自己帶進去,那個環境不必另外安裝。 +debugpy's adapter is started with ``python -m debugpy.adapter`` and speaks DAP +over standard input and output. The adapter runs on the editor's own +interpreter, where debugpy is installed, while the program being debugged may +use another one: debugpy brings itself along, so that environment needs no +install of its own. +""" +from __future__ import annotations + +import importlib.util +import sys +from collections.abc import Callable + +from je_editor.adapters.debug.dap_session import DapSession +from je_editor.adapters.debug.socket_channel import SocketChannel +from je_editor.core.debug.debug_session import ( + DebugAttachRequest, DebugLaunchRequest, DebugSessionFactory +) +from je_editor.core.process.task_service import TaskRunner +from je_editor.core.registry.named_registry import NamedRegistry + +ADAPTER_NAME = "debugpy" +_ADAPTER_MODULE = "debugpy.adapter" +_TYPE = "python" + + +def debugpy_available() -> bool: + """ + 這個直譯器能不能啟動 debugpy 的轉接器 + Whether this interpreter can start debugpy's adapter. + + :return: debugpy 有安裝時為 ``True`` / ``True`` when debugpy is installed + """ + return importlib.util.find_spec(ADAPTER_NAME) is not None + + +def adapter_command() -> tuple[str, ...]: + """ + 啟動 debugpy 轉接器的指令 + The command that starts debugpy's adapter. + + :return: 引數清單 / the argument list + """ + return (sys.executable, "-m", _ADAPTER_MODULE) + + +def launch_arguments(request: DebugLaunchRequest) -> dict: + """ + 把啟動請求變成 debugpy 的 ``launch`` 引數 + Turn a launch request into debugpy's ``launch`` arguments. + + 輸出走除錯協定回來(``internalConsole``),不另外開終端機。 + Output comes back through the protocol (``internalConsole``); no terminal is opened. + + :param request: 啟動所需的資訊 / what to launch + :return: ``launch`` 的引數 / the ``launch`` arguments + """ + arguments: dict = { + "request": "launch", "type": _TYPE, "program": request.program, + "args": list(request.arguments), "console": "internalConsole", "redirectOutput": True, + "stopOnEntry": request.stop_on_entry, "justMyCode": request.just_my_code, + } + if request.working_directory: + arguments["cwd"] = request.working_directory + if request.interpreter: + arguments["python"] = [request.interpreter] + return arguments + + +def attach_arguments(request: DebugAttachRequest) -> dict: + """ + 把連線請求變成 debugpy 的 ``attach`` 引數 + Turn an attach request into debugpy's ``attach`` arguments. + + 以 ``debugpy --listen`` 啟動的程式自己帶著轉接器在那個連接埠等,所以連線是直接 + 連過去講協定(見 :func:`attach_channel`),這裡的 ``connect`` 只是說明連到哪裡。 + A program started with ``debugpy --listen`` waits on that port with an + adapter of its own, so the protocol is spoken straight to it (see + :func:`attach_channel`) and ``connect`` here only says where that is. + + :param request: 要接上哪裡 / where to attach + :return: ``attach`` 的引數 / the ``attach`` arguments + """ + return { + "request": "attach", "type": _TYPE, "justMyCode": request.just_my_code, + "connect": {"host": request.host, "port": request.port}, + } + + +def attach_channel(request: DebugAttachRequest) -> SocketChannel: + """ + 接上等待中的程式要用的通道:直接連到它的連接埠 + The channel for attaching to a waiting program: a connection straight to its port. + + :param request: 要接上哪裡 / where to attach + :return: 還沒連線的通道 / a channel that has not connected yet + """ + return SocketChannel(request.host, request.port) + + +def debugpy_session(runner: TaskRunner) -> DapSession: + """ + 建立一個以 debugpy 除錯的工作階段 + Build a session that debugs with debugpy. + + :param runner: 用來啟動轉接器的地方 / where the adapter is started + :return: 還沒啟動的工作階段 / a session that has not started yet + """ + return DapSession(adapter_command(), runner, ADAPTER_NAME, launch_arguments, attach_arguments, + attach_channel) + + +def register_builtin_debug_adapters(registry: NamedRegistry[DebugSessionFactory], + runner: Callable[[], TaskRunner]) -> None: + """ + 登記內建的除錯轉接器 + Register the built-in debug adapters. + + debugpy 沒有安裝時不登記,編輯器就會退回原本的 pdb 主控台。 + Without debugpy nothing is registered, and the editor falls back to its pdb console. + + :param registry: 除錯轉接器的登記表 / the registry of debug adapters + :param runner: 取得要用來啟動轉接器的執行器 / gives the runner the adapter is started with + """ + if debugpy_available(): + registry.register(ADAPTER_NAME, lambda: debugpy_session(runner())) diff --git a/je_editor/adapters/debug/socket_channel.py b/je_editor/adapters/debug/socket_channel.py new file mode 100644 index 0000000..524c0e8 --- /dev/null +++ b/je_editor/adapters/debug/socket_channel.py @@ -0,0 +1,158 @@ +""" +以 TCP 連線收發除錯協定訊息 +Debug protocol messages over a TCP connection. + +啟動程式時,除錯轉接器是編輯器自己開的子程序;但接上一個已經在執行的程式時, +對方通常已經帶著轉接器在某個連接埠等著,要做的是直接連過去。這個類別把一條 TCP +連線包成跟「以位元組收發的工作」一樣的形狀,除錯工作階段就不必分辨兩者。之後 +經過 SSH 轉送的遠端除錯用的也是它。 +When a program is launched, the debug adapter is a child process the editor +starts. When attaching to a program that is already running, the other side +usually has its adapter waiting on a port already, and what has to be done is +connect to it. This class gives a TCP connection the same shape as a task that +exchanges bytes, so the debug session need not tell the two apart. Remote +debugging through a port forwarded over SSH uses it as well. +""" +from __future__ import annotations + +import socket +import threading + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import OutputStream, TaskSpec, TaskState +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 連線最多等幾秒 / Seconds to wait for the connection at most +CONNECT_TIMEOUT_SECONDS = 5.0 +_READ_SIZE = 65536 +_SCHEME = "tcp" + + +class SocketChannel: + """ + 一條收發位元組的 TCP 連線,形狀跟位元組模式的工作一樣 + A TCP connection exchanging bytes, shaped like a task in binary mode. + """ + + def __init__(self, host: str, port: int) -> None: + """ + :param host: 要連到的主機 / the host to connect to + :param port: 要連到的連接埠 / the port to connect to + """ + self._address = (host, port) + self._spec = TaskSpec((_SCHEME, f"{host}:{port}"), name=f"{_SCHEME}://{host}:{port}", binary=True) + self._output = EventHook() + self._finished = EventHook() + self._state = TaskState.PENDING + self._socket: socket.socket | None = None + self._lock = threading.Lock() + self._exit_code: int | None = None + + @property + def spec(self) -> TaskSpec: + """這條連線連到哪裡 / Where this connection goes.""" + return self._spec + + @property + def output(self) -> EventHook: + """收到資料時發出,引數是 ``OutputStream.STDOUT`` 與位元組 / Fired with ``OutputStream.STDOUT`` and the bytes received.""" + return self._output + + @property + def finished(self) -> EventHook: + """連線結束後發出,引數是零 / Fired with zero once the connection has ended.""" + return self._finished + + def state(self) -> TaskState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + return self._state + + def exit_code(self) -> int | None: + """ + 連線結束了沒有 + Whether the connection has ended. + + :return: 結束後為零,還連著時為 ``None`` / zero once it ended, ``None`` while connected + """ + return self._exit_code + + def start(self) -> bool: + """ + 連線 + Connect. + + :return: 連上時為 ``True``;連不上或已經連過時為 ``False`` + ``True`` when connected, ``False`` when it could not or already had + """ + if self._state is not TaskState.PENDING: + return False + try: + connection = socket.create_connection(self._address, CONNECT_TIMEOUT_SECONDS) + except OSError as error: + jeditor_logger.info("could not connect to %s: %s", self._spec.name, error) + self._state = TaskState.FAILED_TO_START + return False + connection.settimeout(None) + self._socket = connection + self._state = TaskState.RUNNING + threading.Thread(target=self._read, name="SocketChannel-read", daemon=True).start() + return True + + def write(self, text: str | bytes) -> bool: + """ + 送出資料 + Send data. + + :param text: 要送出的位元組或文字 / the bytes or text to send + :return: 有送出時為 ``True`` / ``True`` when it was sent + """ + connection = self._socket + if connection is None or self._state is not TaskState.RUNNING: + return False + data = text.encode("utf-8") if isinstance(text, str) else text + try: + with self._lock: + connection.sendall(data) + except OSError as error: + jeditor_logger.debug("sending to %s failed: %s", self._spec.name, error) + return False + return True + + def cancel(self) -> None: + """ + 關閉連線 + Close the connection. + """ + connection = self._socket + if connection is None or self._state is not TaskState.RUNNING: + return + self._state = TaskState.CANCELLED + try: + connection.shutdown(socket.SHUT_RDWR) + except OSError as error: + # 對方已經先關了 / The other side closed first + jeditor_logger.debug("shutting down %s: %s", self._spec.name, error) + connection.close() + + def _read(self) -> None: + """把連線讀到結束,讀到什麼就通知什麼 / Read the connection to its end, passing on what is read.""" + connection = self._socket + while connection is not None: + try: + data = connection.recv(_READ_SIZE) + except OSError: + break + if not data: + break + self._output.emit(OutputStream.STDOUT, data) + if self._state is TaskState.RUNNING: + self._state = TaskState.FINISHED + if connection is not None: + connection.close() + self._exit_code = 0 + self._finished.emit(0) diff --git a/je_editor/adapters/default_services.py b/je_editor/adapters/default_services.py index 742a7ca..92f969d 100644 --- a/je_editor/adapters/default_services.py +++ b/je_editor/adapters/default_services.py @@ -13,18 +13,25 @@ from je_editor.adapters.ai.builtin_providers import register_builtin_ai_providers from je_editor.adapters.ai.settings_file import ai_settings_path, load_ai_settings +from je_editor.adapters.debug.debugpy_adapter import register_builtin_debug_adapters +from je_editor.adapters.process.local_task_runner import LocalTaskRunner from je_editor.adapters.syntax.syntax_language_service import SyntaxLanguageService from je_editor.adapters.syntax.tree_sitter_engine import shared_syntax_engine from je_editor.core.services.editor_services import EditorServices from je_editor.core.workspace.workspace_model import Workspace +# 本機工作執行器在登記表裡的名稱 / The name the local task runner is registered under +LOCAL_RUNNER = "local" + + def build_default_services(workspace: Workspace | None = None, settings_directory: str | Path | None = None) -> EditorServices: """ - 建立一組服務:接上語法引擎、載入 AI 設定並登記內建的 AI 供應者 - Build the services: plug the syntax engine in, load the AI settings and - register the built-in AI providers. + 建立一組服務:接上語法引擎、本機工作執行器與除錯轉接器,載入 AI 設定並登記 + 內建的 AI 供應者 + Build the services: plug in the syntax engine, the local task runner and the + debug adapters, load the AI settings and register the built-in AI providers. :param workspace: 要處理的工作區,沒給時從空的工作區開始 the workspace to work on, an empty one when omitted @@ -36,6 +43,9 @@ def build_default_services(workspace: Workspace | None = None, services = EditorServices(workspace) services.syntax = shared_syntax_engine() services.languages.register(SyntaxLanguageService(services.syntax)) + services.task_runners.register(LOCAL_RUNNER, LocalTaskRunner()) + register_builtin_debug_adapters( + services.debug_adapters, lambda: services.task_runners.require(LOCAL_RUNNER)) reload_ai_settings(services, settings_directory) def current_settings(): diff --git a/je_editor/adapters/process/__init__.py b/je_editor/adapters/process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/adapters/process/local_task_runner.py b/je_editor/adapters/process/local_task_runner.py new file mode 100644 index 0000000..f443130 --- /dev/null +++ b/je_editor/adapters/process/local_task_runner.py @@ -0,0 +1,246 @@ +""" +在本機執行工作 +Tasks that run on this machine. + +``TaskRunner`` 的本機實作:以引數清單啟動子程序(從不經過 shell),各用一條執行緒 +讀標準輸出與標準錯誤,讀到什麼就通知訂閱者。除錯轉接器靠它啟動;之後的遠端執行 +會是同一個介面的另一個實作。 +The local implementation of ``TaskRunner``: it starts a child process from an +argument list, never through a shell, reads standard output and standard error +on a thread each, and tells subscribers what was read. Debug adapters are +started through it, and remote execution will be another implementation of the +same interface. +""" +from __future__ import annotations + +import os +import subprocess +import threading +from typing import IO + +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import OutputStream, TaskSpec, TaskState +from je_editor.utils.logging.loggin_instance import jeditor_logger + +_ENCODING = "utf-8" +# 一次最多讀這麼多位元組;read1 有多少給多少,不會等到湊滿 +# At most this many bytes per read; read1 returns what there is without waiting to fill it +_READ_SIZE = 65536 +# 取消之後等程序結束的秒數,超過就強制結束 +# Seconds to wait for the process after a cancel before it is killed +_STOP_TIMEOUT = 3.0 + + +class LocalTask: + """ + 本機的一個工作:一個子程序與讀它輸出的執行緒 + One local task: a child process and the threads reading its output. + """ + + def __init__(self, spec: TaskSpec) -> None: + """ + :param spec: 要執行什麼 / what to run + """ + self._spec = spec + self._output = EventHook() + self._finished = EventHook() + self._state = TaskState.PENDING + self._process: subprocess.Popen | None = None + self._exit_code: int | None = None + self._lock = threading.Lock() + self._readers: list[threading.Thread] = [] + # 結束代碼已經記下、也通知過訂閱者之後才設起來 + # Set once the exit code is recorded and the subscribers have been told + self._done = threading.Event() + + @property + def spec(self) -> TaskSpec: + """這個工作要執行什麼 / What this task runs.""" + return self._spec + + @property + def output(self) -> EventHook: + """有輸出時發出,引數是串流與內容 / Fired with the stream and what was read.""" + return self._output + + @property + def finished(self) -> EventHook: + """結束後發出,引數是結束代碼 / Fired with the exit code once it ends.""" + return self._finished + + def state(self) -> TaskState: + """ + 目前的狀態 + The current state. + + :return: 狀態 / the state + """ + return self._state + + def exit_code(self) -> int | None: + """ + 結束代碼 + The exit code. + + :return: 結束代碼,還沒結束時為 ``None`` / the code, or ``None`` while it runs + """ + return self._exit_code + + def start(self) -> bool: + """ + 啟動程序 + Start the process. + + :return: 有啟動時為 ``True``;已經啟動過或啟動失敗時為 ``False`` + ``True`` when it started, ``False`` when it already had or could not + """ + with self._lock: + if self._state is not TaskState.PENDING: + return False + try: + self._process = subprocess.Popen( + list(self._spec.command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, cwd=self._spec.working_directory or None, + env={**os.environ, **self._spec.environment}, shell=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except OSError as error: + jeditor_logger.warning("task %s could not start: %s", self._spec.command[0], error) + self._state = TaskState.FAILED_TO_START + return False + self._state = TaskState.RUNNING + self._readers = [ + self._reader(self._process.stdout, OutputStream.STDOUT), + self._reader(self._process.stderr, OutputStream.STDERR), + ] + threading.Thread(target=self._wait, name="LocalTask-wait", daemon=True).start() + return True + + def write(self, text: str | bytes) -> bool: + """ + 寫到程序的標準輸入 + Write to the process's standard input. + + :param text: 要寫入的文字或位元組 / the text or bytes to write + :return: 有寫入時為 ``True`` / ``True`` when it was written + """ + process = self._process + if process is None or process.stdin is None or self._state is not TaskState.RUNNING: + return False + data = text.encode(_ENCODING) if isinstance(text, str) else text + try: + with self._lock: + process.stdin.write(data) + process.stdin.flush() + except (OSError, ValueError) as error: + # 程序已經結束、管線關了 / The process is gone and the pipe is closed + jeditor_logger.debug("write to task %s failed: %s", self._spec.command[0], error) + return False + return True + + def cancel(self) -> None: + """ + 結束程序 + Stop the process. + + 先請它結束,等不到再強制結束。 + It is asked to end first, and killed when it does not. + """ + process = self._process + if process is None or process.poll() is not None: + return + self._state = TaskState.CANCELLED + process.terminate() + try: + process.wait(_STOP_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + + def wait(self, timeout: float | None = None) -> bool: + """ + 等到程序結束、輸出讀完、結束代碼也通知出去 + Wait until the process has ended, its output has been read and its exit + code has been announced. + + :param timeout: 最多等幾秒,``None`` 表示一直等 / seconds to wait at most, ``None`` to wait for ever + :return: 結束了為 ``True``;沒有啟動過的工作也算結束 + ``True`` once it has ended; a task that never started counts as ended + """ + if self._process is None: + return True + return self._done.wait(timeout) + + def _reader(self, pipe: IO[bytes] | None, stream: OutputStream) -> threading.Thread: + """開一條執行緒讀一個串流 / Start a thread reading one stream.""" + thread = threading.Thread(target=self._read, args=(pipe, stream), + name=f"LocalTask-{stream.value}", daemon=True) + thread.start() + return thread + + def _read(self, pipe: IO[bytes] | None, stream: OutputStream) -> None: + """把一個串流讀到結束,讀到什麼就通知什麼 / Read a stream to its end, passing on what is read.""" + if pipe is None: + return + while True: + try: + data = pipe.read1(_READ_SIZE) + except (OSError, ValueError): + break + if not data: + break + self._output.emit(stream, data if self._spec.binary else data.decode(_ENCODING, "replace")) + pipe.close() + + def _wait(self) -> None: + """等程序結束,再通知結束代碼 / Wait for the process to end, then announce its exit code.""" + process = self._process + if process is None: + return + code = process.wait() + for reader in self._readers: + reader.join() + if process.stdin is not None: + try: + process.stdin.close() + except OSError as error: + jeditor_logger.debug("closing the input of task %s: %s", self._spec.command[0], error) + self._exit_code = code + if self._state is TaskState.RUNNING: + self._state = TaskState.FINISHED + self._finished.emit(code) + self._done.set() + + +class LocalTaskRunner: + """ + 在本機執行工作的地方 + Somewhere tasks run on this machine. + """ + + def __init__(self) -> None: + self._tasks: list[LocalTask] = [] + self._lock = threading.Lock() + + def create(self, spec: TaskSpec) -> LocalTask: + """ + 準備一個工作,但還不啟動 + Prepare a task without starting it. + + :param spec: 要執行什麼 / what to run + :return: 這個工作的把手 / the handle for the task + """ + task = LocalTask(spec) + with self._lock: + # 順手丟掉已經結束的,清單才不會一直長 / Drop the ones that have ended so the list does not grow for ever + self._tasks = [held for held in self._tasks if held.exit_code() is None] + self._tasks.append(task) + return task + + def shutdown(self) -> None: + """ + 結束所有還在執行的工作 + Stop every task that is still running. + """ + with self._lock: + tasks, self._tasks = self._tasks, [] + for task in tasks: + task.cancel() diff --git a/je_editor/core/__init__.py b/je_editor/core/__init__.py index a12cdc2..26d44cd 100644 --- a/je_editor/core/__init__.py +++ b/je_editor/core/__init__.py @@ -16,8 +16,9 @@ from je_editor.core.ai.ai_settings import AISettings, ProviderSettings from je_editor.core.ai.chat_session import ChatSession from je_editor.core.debug.debug_session import ( - Breakpoint, DebugLaunchRequest, DebugSession, DebugSessionFactory, DebugState, - StackFrame, StepKind, Variable + Breakpoint, BreakpointStatus, DebugAttachRequest, DebugLaunchRequest, DebugReply, + DebugSession, DebugSessionFactory, DebugState, DebugThread, EvaluateResult, ExceptionInfo, + OutputEvent, Scope, StackFrame, StepKind, StopEvent, Variable ) from je_editor.core.diagnostics.diagnostic_model import ( Diagnostic, DiagnosticStore, Position, QuickFix, RelatedInformation, Severity, @@ -65,7 +66,9 @@ "StructuralRegion", "RegionKind", "NoSyntaxEngine", # Debugging "DebugSession", "DebugSessionFactory", "DebugState", "DebugLaunchRequest", - "Breakpoint", "StackFrame", "Variable", "StepKind", + "DebugAttachRequest", "Breakpoint", "BreakpointStatus", "StackFrame", "Variable", "StepKind", + "DebugThread", "Scope", "StopEvent", "OutputEvent", "ExceptionInfo", "EvaluateResult", + "DebugReply", # Task execution "TaskRunner", "TaskHandle", "TaskSpec", "TaskState", "OutputStream", # Remote sessions diff --git a/je_editor/core/debug/debug_session.py b/je_editor/core/debug/debug_session.py index 05714bc..b5d8d73 100644 --- a/je_editor/core/debug/debug_session.py +++ b/je_editor/core/debug/debug_session.py @@ -10,23 +10,29 @@ data here follow DAP's concepts, so connecting DAP later needs no change to the interface. -堆疊、變數與運算式求值的查詢要等 DAP 那個里程碑決定非同步的形式,這裡先固定 -控制指令與資料物件。 -Queries for the stack, variables and expression evaluation wait for the DAP -milestone to settle how they answer asynchronously; this fixes the control -commands and the data objects first. +控制指令(繼續、暫停、逐步)送出就回來;查詢(執行緒、堆疊、變數、求值)則給一個 +收回覆的函式,答案好了再呼叫它,跟語言服務的發問形式一樣。回覆與事件都在工作階段 +自己的執行緒上送達,要更新畫面的人得自己轉回畫面執行緒。 +Control commands, resume, pause and step, return as soon as they are sent. A +query, for threads, the stack, variables or an evaluation, takes a function for +the reply and calls it once the answer is there, the same shape language +services use. Replies and events arrive on the session's own thread, and whoever +updates widgets moves them to the widget thread. """ from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum -from typing import Protocol, runtime_checkable +from typing import Generic, Protocol, TypeVar, runtime_checkable from je_editor.core.events.event_hook import EventHook from je_editor.utils.exception.exceptions import JEditorServiceException +MAX_PORT = 65535 + + class DebugState(Enum): """ 除錯工作階段的狀態 @@ -109,6 +115,152 @@ class Variable: children_reference: int = 0 +@dataclass(frozen=True) +class DebugThread: + """ + 被除錯的程式裡的一條執行緒 + One thread of the program being debugged. + + :param thread_id: 轉接器給它的編號 / the id the adapter gives it + :param name: 執行緒名稱 / the thread's name + """ + + thread_id: int + name: str = "" + + +@dataclass(frozen=True) +class Scope: + """ + 一層堆疊裡的一組變數,例如區域變數或全域變數 + One group of variables in a frame, such as the locals or the globals. + + :param name: 這一組的名稱 / the group's name + :param variables_reference: 用來查這一組變數的編號 / the handle for asking for its variables + :param expensive: 取得這一組是否很花時間,花時間的不該自動展開 + whether fetching it is slow, in which case it should not be expanded unasked + """ + + name: str + variables_reference: int + expensive: bool = False + + +@dataclass(frozen=True) +class StopEvent: + """ + 程式停下來了 + The program has stopped. + + :param reason: 為什麼停,例如 ``breakpoint``、``step``、``exception``、``pause``、``entry`` + why, such as ``breakpoint``, ``step``, ``exception``, ``pause`` or ``entry`` + :param thread_id: 停下來的執行緒 / the thread that stopped + :param description: 給使用者看的說明 / a description to show the user + :param text: 進一步的文字,例外時是例外的名稱 / further text, the exception's name for one + :param all_threads_stopped: 是否所有執行緒都停了 / whether every thread stopped + """ + + reason: str + thread_id: int = 0 + description: str = "" + text: str = "" + all_threads_stopped: bool = True + + +@dataclass(frozen=True) +class OutputEvent: + """ + 被除錯的程式或轉接器的一段輸出 + A piece of output from the program being debugged, or from the adapter. + + :param category: ``stdout``、``stderr``、``console`` 等 / ``stdout``, ``stderr``, ``console`` and so on + :param text: 輸出的文字 / the text + """ + + category: str + text: str + + +@dataclass(frozen=True) +class ExceptionInfo: + """ + 程式停在哪一個例外上 + The exception the program stopped on. + + :param exception_id: 例外的型別名稱 / the exception's type name + :param description: 例外的訊息 / the exception's message + :param break_mode: 為什麼會停:``always``、``unhandled``、``userUnhandled`` 或 ``never`` + why it broke: ``always``, ``unhandled``, ``userUnhandled`` or ``never`` + :param stack_trace: 轉接器提供的追蹤文字,沒有時為空字串 + the traceback text the adapter supplies, empty when it has none + """ + + exception_id: str + description: str = "" + break_mode: str = "" + stack_trace: str = "" + + +@dataclass(frozen=True) +class EvaluateResult: + """ + 運算式求值的結果 + The result of evaluating an expression. + + :param value: 顯示用的值 / the value, as text to show + :param type_name: 型別名稱 / the name of its type + :param children_reference: 用來查子項目的編號,零表示沒有子項目 + the handle for asking for its children, zero when it has none + """ + + value: str + type_name: str = "" + children_reference: int = 0 + + +@dataclass(frozen=True) +class BreakpointStatus: + """ + 轉接器對一個中斷點的回報 + What the adapter reports about one breakpoint. + + :param uri: 所在資源的 URI / the URI of the resource it is in + :param line: 實際生效的行號(1 起算),可能跟要求的不同 + the 1-based line it took effect on, which may differ from the one asked for + :param verified: 是否真的設上了 / whether it was really set + :param message: 沒設上的原因 / why it was not + """ + + uri: str + line: int + verified: bool = True + message: str = "" + + +Answer = TypeVar("Answer") + + +@dataclass(frozen=True) +class DebugReply(Generic[Answer]): + """ + 除錯工作階段對一個查詢的回覆 + A debug session's reply to one query. + + :param value: 答案;答不出來時是該查詢的空值(空的 tuple 或 ``None``) + the answer, or the query's empty value, an empty tuple or ``None``, when there is none + :param error: 答不出來的原因,答得出來時為空字串 + why there is no answer, empty when there is one + """ + + value: Answer + error: str = "" + + @property + def ok(self) -> bool: + """是否答出來了 / Whether there is an answer.""" + return not self.error + + @dataclass(frozen=True) class DebugLaunchRequest: """ @@ -120,6 +272,10 @@ class DebugLaunchRequest: :param working_directory: 工作目錄,空字串表示沿用目前的 the working directory, empty to keep the current one :param stop_on_entry: 是否一進入程式就停下來 / whether to stop on the first line + :param interpreter: 用哪個直譯器執行程式,空字串表示由轉接器決定 + the interpreter that runs the program, empty to let the adapter choose + :param just_my_code: 是否只在使用者自己的程式碼裡停下來與逐步執行 + whether to stop and step in the user's own code only :raises JEditorServiceException: 沒有指定程式 / when no program is given """ @@ -127,12 +283,37 @@ class DebugLaunchRequest: arguments: tuple[str, ...] = () working_directory: str = "" stop_on_entry: bool = False + interpreter: str = "" + just_my_code: bool = True def __post_init__(self) -> None: if not self.program: raise JEditorServiceException("A debug launch needs a program") +@dataclass(frozen=True) +class DebugAttachRequest: + """ + 接上一個已經在執行、等著除錯器連線的程式 + Attach to a program that is already running and waiting for a debugger. + + :param host: 程式所在的主機 / the host the program is on + :param port: 它等候連線的連接埠 / the port it listens on + :param just_my_code: 是否只在使用者自己的程式碼裡停下來與逐步執行 + whether to stop and step in the user's own code only + :raises JEditorServiceException: 連接埠不在 1 到 65535 之間 + when the port is not between 1 and 65535 + """ + + port: int + host: str = "127.0.0.1" + just_my_code: bool = True + + def __post_init__(self) -> None: + if not 0 < self.port <= MAX_PORT: + raise JEditorServiceException(f"A debug attach needs a port between 1 and {MAX_PORT}") + + @runtime_checkable class DebugSession(Protocol): """ @@ -144,6 +325,21 @@ class DebugSession(Protocol): def state_changed(self) -> EventHook: """狀態改變後發出,引數是新的 :class:`DebugState` / Fired with the new state.""" + @property + def stopped(self) -> EventHook: + """程式停下來時發出,引數是 :class:`StopEvent` / Fired with a :class:`StopEvent` when the program stops.""" + + @property + def output(self) -> EventHook: + """有輸出時發出,引數是 :class:`OutputEvent` / Fired with an :class:`OutputEvent`.""" + + @property + def breakpoints_reported(self) -> EventHook: + """ + 轉接器回報中斷點的狀態時發出,引數是一組 :class:`BreakpointStatus` + Fired with a tuple of :class:`BreakpointStatus` when the adapter reports on breakpoints. + """ + def state(self) -> DebugState: """ 目前的狀態 @@ -161,6 +357,15 @@ def launch(self, request: DebugLaunchRequest) -> bool: :return: 有啟動時為 ``True`` / ``True`` when it started """ + def attach(self, request: DebugAttachRequest) -> bool: + """ + 接上一個已經在執行的程式 + Attach to a program that is already running. + + :param request: 要接上哪裡 / where to attach + :return: 有開始連線時為 ``True`` / ``True`` when the attach was started + """ + def set_breakpoints(self, uri: str, breakpoints: Sequence[Breakpoint]) -> None: """ 設定某個資源的全部中斷點 @@ -174,24 +379,91 @@ def set_breakpoints(self, uri: str, breakpoints: Sequence[Breakpoint]) -> None: :param breakpoints: 這個資源現在所有的中斷點 / all its breakpoints now """ - def resume(self) -> None: + def resume(self, thread_id: int = 0) -> None: """ 繼續執行 Carry on running. + + :param thread_id: 要繼續的執行緒,零表示上次停下來的那一條 + the thread to resume, zero for the one that stopped last """ - def pause(self) -> None: + def pause(self, thread_id: int = 0) -> None: """ 暫停執行 Pause the run. + + :param thread_id: 要暫停的執行緒,零表示上次停下來的那一條 + the thread to pause, zero for the one that stopped last """ - def step(self, kind: StepKind) -> None: + def step(self, kind: StepKind, thread_id: int = 0) -> None: """ 逐步執行 Take one step. :param kind: 逐步的方式 / how to step + :param thread_id: 要逐步的執行緒,零表示上次停下來的那一條 + the thread to step, zero for the one that stopped last + """ + + def threads(self, on_reply: Callable[[DebugReply[tuple[DebugThread, ...]]], None]) -> None: + """ + 查詢程式裡的執行緒 + Ask for the program's threads. + + :param on_reply: 收回覆的函式 / receives the reply + """ + + def stack_trace(self, thread_id: int, + on_reply: Callable[[DebugReply[tuple[StackFrame, ...]]], None]) -> None: + """ + 查詢一條執行緒的呼叫堆疊,最裡面的一層在最前面 + Ask for a thread's call stack, innermost frame first. + + :param thread_id: 執行緒編號 / the thread's id + :param on_reply: 收回覆的函式 / receives the reply + """ + + def scopes(self, frame_id: int, on_reply: Callable[[DebugReply[tuple[Scope, ...]]], None]) -> None: + """ + 查詢一層堆疊裡有哪幾組變數 + Ask which groups of variables a frame has. + + :param frame_id: 堆疊那一層的編號 / the frame's id + :param on_reply: 收回覆的函式 / receives the reply + """ + + def variables(self, reference: int, + on_reply: Callable[[DebugReply[tuple[Variable, ...]]], None]) -> None: + """ + 查詢一組變數,或一個變數的子項目 + Ask for a group of variables, or for a variable's children. + + :param reference: :class:`Scope` 或 :class:`Variable` 給的編號 + the handle a :class:`Scope` or a :class:`Variable` gave + :param on_reply: 收回覆的函式 / receives the reply + """ + + def evaluate(self, expression: str, frame_id: int, + on_reply: Callable[[DebugReply[EvaluateResult | None]], None]) -> None: + """ + 在某一層堆疊裡求一個運算式的值 + Evaluate an expression in a frame. + + :param expression: 運算式 / the expression + :param frame_id: 堆疊那一層的編號,零表示全域 / the frame's id, zero for the global scope + :param on_reply: 收回覆的函式 / receives the reply + """ + + def exception_info(self, thread_id: int, + on_reply: Callable[[DebugReply[ExceptionInfo | None]], None]) -> None: + """ + 查詢一條執行緒停在哪個例外上 + Ask which exception a thread stopped on. + + :param thread_id: 執行緒編號 / the thread's id + :param on_reply: 收回覆的函式 / receives the reply """ def terminate(self) -> None: diff --git a/je_editor/core/process/task_service.py b/je_editor/core/process/task_service.py index 2622875..807bae7 100644 --- a/je_editor/core/process/task_service.py +++ b/je_editor/core/process/task_service.py @@ -64,6 +64,10 @@ class TaskSpec: the working directory, empty to keep the current one :param environment: 要加上或覆寫的環境變數 / environment variables to add or override :param name: 給使用者看的名稱 / the name to show the user + :param binary: 為真時輸出以讀到的位元組原樣送出、寫入也收位元組;給除錯轉接器 + 這種以位元組組框的協定用 + when true, output is delivered as the bytes that were read and writes + take bytes: for protocols framed in bytes, such as a debug adapter's :raises JEditorServiceException: 指令是空的,或裡面有不是字串的項目 when the command is empty or holds something that is not a string """ @@ -72,6 +76,7 @@ class TaskSpec: working_directory: str = "" environment: Mapping[str, str] = field(default_factory=dict) name: str = "" + binary: bool = False def __post_init__(self) -> None: if isinstance(self.command, str) or not self.command: @@ -100,7 +105,14 @@ def spec(self) -> TaskSpec: @property def output(self) -> EventHook: - """有輸出時發出,引數是 :class:`OutputStream` 與文字 / Fired with the stream and the text.""" + """ + 有輸出時發出,引數是 :class:`OutputStream` 與內容 + Fired with the stream and what was read. + + 內容是文字;工作是 ``binary`` 時則是位元組。訂閱者在讀取輸出的執行緒上被呼叫。 + What was read is text, or bytes for a ``binary`` task. Subscribers are + called on the thread that reads the output. + """ @property def finished(self) -> EventHook: @@ -130,12 +142,13 @@ def start(self) -> bool: :return: 有啟動時為 ``True`` / ``True`` when it started """ - def write(self, text: str) -> bool: + def write(self, text: str | bytes) -> bool: """ 寫到程序的標準輸入 Write to the process's standard input. - :param text: 要寫入的文字 / the text to write + :param text: 要寫入的文字;工作是 ``binary`` 時給位元組 + the text to write, or bytes for a ``binary`` task :return: 有寫入時為 ``True`` / ``True`` when it was written """ diff --git a/je_editor/utils/dap/__init__.py b/je_editor/utils/dap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/utils/dap/dap_protocol.py b/je_editor/utils/dap/dap_protocol.py new file mode 100644 index 0000000..4a1e53d --- /dev/null +++ b/je_editor/utils/dap/dap_protocol.py @@ -0,0 +1,120 @@ +""" +Debug Adapter Protocol 的訊息:怎麼組、怎麼讀 +The messages of the Debug Adapter Protocol: how to build them and how to read them. + +DAP 的傳輸格式跟語言伺服器協定一樣(``Content-Length`` 標頭加一段 JSON),所以 +組框與拆框直接沿用 ``lsp_protocol``。不同的是訊息本身:請求、回應與事件各有 +``type``,請求以 ``seq`` 編號,回應以 ``request_seq`` 對回去。 +DAP is framed exactly as the language server protocol is, a ``Content-Length`` +header and a JSON body, so framing reuses ``lsp_protocol``. What differs is the +message: requests, responses and events each carry a ``type``, a request is +numbered by ``seq`` and its response points back with ``request_seq``. + +轉接器送來的東西一律當成不可信的輸入:欄位缺了、型別不對都以預設值帶過,不丟例外。 +Whatever an adapter sends is treated as untrusted input: a missing field or a +wrong type becomes a default rather than an exception. + +純邏輯,不含 Qt。 +Pure logic, with no Qt. +""" +from __future__ import annotations + +from je_editor.utils.lsp.lsp_protocol import MessageReader, encode_message + +__all__ = [ + "MessageReader", "encode_message", "request", "message_kind", "source_breakpoints", + "text_of", "number_of", "items_of", +] + +TYPE_REQUEST = "request" +TYPE_RESPONSE = "response" +TYPE_EVENT = "event" + + +def request(seq: int, command: str, arguments: dict | None = None) -> dict: + """ + 組出一個請求 + Build one request. + + :param seq: 這個請求的編號 / the number of this request + :param command: 指令名稱,例如 ``stackTrace`` / the command, such as ``stackTrace`` + :param arguments: 指令的引數 / the command's arguments + :return: 可以交給 ``encode_message`` 的訊息 / a message ready for ``encode_message`` + """ + message: dict = {"seq": seq, "type": TYPE_REQUEST, "command": command} + if arguments: + message["arguments"] = arguments + return message + + +def message_kind(message: object) -> str: + """ + 判斷一則訊息是請求、回應還是事件 + Tell whether a message is a request, a response or an event. + + :param message: 轉接器送來的訊息 / a message from the adapter + :return: ``request``、``response``、``event``,看不懂時為空字串 + ``request``, ``response`` or ``event``, empty when it is none of them + """ + kind = message.get("type") if isinstance(message, dict) else None + return kind if kind in (TYPE_REQUEST, TYPE_RESPONSE, TYPE_EVENT) else "" + + +def text_of(source: object, key: str, default: str = "") -> str: + """ + 從字典取出一個字串欄位 + Read a text field out of a mapping. + + :param source: 可能是字典的東西 / something that may be a mapping + :param key: 欄位名稱 / the field's name + :param default: 欄位不存在或不是字串時用的值 / the value when it is absent or not text + :return: 欄位的值 / the field's value + """ + value = source.get(key) if isinstance(source, dict) else None + return value if isinstance(value, str) else default + + +def number_of(source: object, key: str, default: int = 0) -> int: + """ + 從字典取出一個整數欄位 + Read an integer field out of a mapping. + + :param source: 可能是字典的東西 / something that may be a mapping + :param key: 欄位名稱 / the field's name + :param default: 欄位不存在或不是整數時用的值 / the value when it is absent or not an integer + :return: 欄位的值 / the field's value + """ + value = source.get(key) if isinstance(source, dict) else None + # bool 是 int 的子類別,但 true/false 不是編號 / bool is an int, yet true and false are not numbers + return value if isinstance(value, int) and not isinstance(value, bool) else default + + +def items_of(source: object, key: str) -> list[dict]: + """ + 從字典取出一個「字典的清單」欄位 + Read a list-of-mappings field out of a mapping. + + :param source: 可能是字典的東西 / something that may be a mapping + :param key: 欄位名稱 / the field's name + :return: 清單裡是字典的那些項目 / the entries of the list that are mappings + """ + value = source.get(key) if isinstance(source, dict) else None + return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] + + +def source_breakpoints(lines_and_conditions: list[tuple[int, str]]) -> list[dict]: + """ + 組出 ``setBreakpoints`` 要的中斷點清單 + Build the breakpoint list ``setBreakpoints`` takes. + + :param lines_and_conditions: 每個中斷點的行號(1 起算)與條件,條件可以是空字串 + each breakpoint's 1-based line and its condition, which may be empty + :return: DAP 的 ``SourceBreakpoint`` 清單 / DAP ``SourceBreakpoint`` entries + """ + found = [] + for line, condition in lines_and_conditions: + entry: dict = {"line": line} + if condition: + entry["condition"] = condition + found.append(entry) + return found diff --git a/pyproject.toml b/pyproject.toml index fec244d..cbe9547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "qtconsole", "langchain_openai==1.6.2", "langchain_core", "anthropic==1.11.0", "pydantic", "watchdog", "ruff", "gitpython>=3.1.59", "tree-sitter==0.26.0", "tree-sitter-python==0.25.0", "tree-sitter-javascript==0.25.0", - "tree-sitter-json==0.24.8" + "tree-sitter-json==0.24.8", "debugpy==1.8.22" ] classifiers = [ "Programming Language :: Python :: 3.10", diff --git a/requirements.txt b/requirements.txt index c0016fe..545f550 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ tree-sitter==0.26.0 tree-sitter-python==0.25.0 tree-sitter-javascript==0.25.0 tree-sitter-json==0.24.8 +debugpy==1.8.22 ruff sphinx twine diff --git a/test/test_core_services.py b/test/test_core_services.py index 4186dca..33c1031 100644 --- a/test/test_core_services.py +++ b/test/test_core_services.py @@ -8,7 +8,7 @@ AIProvider, CancelToken, ChatMessage, ChatRequest, ChatResponse, ChatRole, ModelInfo ) from je_editor.core.debug.debug_session import ( - Breakpoint, DebugLaunchRequest, DebugSession, DebugState, StepKind + Breakpoint, DebugLaunchRequest, DebugReply, DebugSession, DebugState, StepKind ) from je_editor.core.diagnostics.diagnostic_model import Diagnostic, TextRange from je_editor.core.document.document_model import TextDocument @@ -85,6 +85,9 @@ class FakeDebugSession: def __init__(self) -> None: self.state_changed = EventHook("debug state changed") + self.stopped = EventHook("debug stopped") + self.output = EventHook("debug output") + self.breakpoints_reported = EventHook("debug breakpoints reported") self._state = DebugState.IDLE self.breakpoints: dict[str, list[Breakpoint]] = {} @@ -99,18 +102,40 @@ def launch(self, request: DebugLaunchRequest) -> bool: self._move_to(DebugState.PAUSED if request.stop_on_entry else DebugState.RUNNING) return True + def attach(self, request) -> bool: + self._move_to(DebugState.RUNNING) + return True + def set_breakpoints(self, uri: str, breakpoints) -> None: self.breakpoints[uri] = list(breakpoints) - def resume(self) -> None: + def resume(self, thread_id: int = 0) -> None: self._move_to(DebugState.RUNNING) - def pause(self) -> None: + def pause(self, thread_id: int = 0) -> None: self._move_to(DebugState.PAUSED) - def step(self, kind: StepKind) -> None: + def step(self, kind: StepKind, thread_id: int = 0) -> None: self._move_to(DebugState.PAUSED) + def threads(self, on_reply) -> None: + on_reply(DebugReply(())) + + def stack_trace(self, thread_id: int, on_reply) -> None: + on_reply(DebugReply(())) + + def scopes(self, frame_id: int, on_reply) -> None: + on_reply(DebugReply(())) + + def variables(self, reference: int, on_reply) -> None: + on_reply(DebugReply(())) + + def evaluate(self, expression: str, frame_id: int, on_reply) -> None: + on_reply(DebugReply(None, "the fake evaluates nothing")) + + def exception_info(self, thread_id: int, on_reply) -> None: + on_reply(DebugReply(None)) + def terminate(self) -> None: self._move_to(DebugState.TERMINATED) @@ -273,6 +298,22 @@ class TestDebugInterface: def test_the_fake_satisfies_the_protocol(self): assert isinstance(FakeDebugSession(), DebugSession) + def test_a_session_without_the_queries_does_not(self): + class ControlOnly: + state_changed = EventHook("state") + + def state(self) -> DebugState: + return DebugState.IDLE + + assert not isinstance(ControlOnly(), DebugSession) + + def test_a_reply_is_ok_unless_it_carries_an_error(self): + replies: list[DebugReply] = [] + session = FakeDebugSession() + session.threads(replies.append) + session.evaluate("x", 0, replies.append) + assert [(reply.ok, reply.value) for reply in replies] == [(True, ()), (False, None)] + def test_a_launch_needs_a_program(self): with pytest.raises(JEditorServiceException, match="needs a program"): DebugLaunchRequest(program="") diff --git a/test/test_dap_session.py b/test/test_dap_session.py new file mode 100644 index 0000000..09f846b --- /dev/null +++ b/test/test_dap_session.py @@ -0,0 +1,634 @@ +"""Tests for the DAP messages, the local task runner and the DAP debug session against a scripted adapter.""" +from __future__ import annotations + +import socket +import sys +import threading + +import pytest + +from je_editor.adapters.debug import debugpy_adapter +from je_editor.adapters.debug.dap_session import MAX_STACK_FRAMES, DapSession +from je_editor.adapters.debug.socket_channel import SocketChannel +from je_editor.adapters.default_services import LOCAL_RUNNER, build_default_services +from je_editor.adapters.process.local_task_runner import LocalTask, LocalTaskRunner +from je_editor.core.debug.debug_session import ( + Breakpoint, BreakpointStatus, DebugAttachRequest, DebugLaunchRequest, DebugReply, DebugSession, + DebugState, DebugThread, EvaluateResult, ExceptionInfo, OutputEvent, Scope, StackFrame, + StepKind, StopEvent, Variable +) +from je_editor.core.events.event_hook import EventHook +from je_editor.core.process.task_service import ( + OutputStream, TaskHandle, TaskRunner, TaskSpec, TaskState +) +from je_editor.core.registry.named_registry import NamedRegistry +from je_editor.core.uri.resource_uri import to_path, to_uri +from je_editor.utils.dap.dap_protocol import ( + MessageReader, encode_message, items_of, message_kind, number_of, request, source_breakpoints, + text_of +) +from je_editor.utils.exception.exceptions import JEditorServiceException + +WAIT_SECONDS = 20 + + +class TestMessages: + def test_a_request_carries_its_number_command_and_arguments(self): + assert request(3, "stackTrace", {"threadId": 1}) == { + "seq": 3, "type": "request", "command": "stackTrace", "arguments": {"threadId": 1}} + + def test_a_request_without_arguments_has_no_arguments_field(self): + assert request(1, "threads") == {"seq": 1, "type": "request", "command": "threads"} + + def test_a_message_survives_framing(self): + reader = MessageReader() + frames = encode_message(request(1, "threads")) + encode_message(request(2, "pause")) + assert [message["seq"] for message in reader.feed(frames[:30]) + reader.feed(frames[30:])] == [1, 2] + + @pytest.mark.parametrize("message, kind", [ + ({"type": "response"}, "response"), ({"type": "event"}, "event"), + ({"type": "request"}, "request"), ({"type": "other"}, ""), ({}, ""), ("text", ""), (None, ""), + ]) + def test_the_kind_of_a_message(self, message, kind): + assert message_kind(message) == kind + + @pytest.mark.parametrize("source", [None, "text", 7, [], {"name": 5}, {"other": "x"}]) + def test_a_missing_or_wrong_text_field_is_the_default(self, source): + assert text_of(source, "name", "fallback") == "fallback" + + @pytest.mark.parametrize("source", [None, {"id": "7"}, {"id": 1.5}, {"id": True}, {}]) + def test_a_missing_or_wrong_number_field_is_the_default(self, source): + assert number_of(source, "id", -1) == -1 + + def test_fields_that_are_right_are_read(self): + assert (text_of({"name": "main"}, "name"), number_of({"id": 7}, "id")) == ("main", 7) + + @pytest.mark.parametrize("source", [None, {"frames": "x"}, {"frames": None}, {}]) + def test_a_missing_or_wrong_list_field_is_empty(self, source): + assert items_of(source, "frames") == [] + + def test_only_mappings_are_kept_from_a_list_field(self): + assert items_of({"frames": [{"id": 1}, "junk", 3, {"id": 2}]}, "frames") == [{"id": 1}, {"id": 2}] + + def test_breakpoints_carry_a_condition_only_when_there_is_one(self): + assert source_breakpoints([(3, ""), (9, "x > 1")]) == [ + {"line": 3}, {"line": 9, "condition": "x > 1"}] + + +def run_python(runner: LocalTaskRunner, code: str, **options) -> tuple[LocalTask, list, list]: + """Start ``python -c code`` and collect what it writes and its exit code.""" + task = runner.create(TaskSpec((sys.executable, "-c", code), **options)) + written: list = [] + codes: list = [] + task.output.subscribe(lambda stream, data: written.append((stream, data))) + task.finished.subscribe(codes.append) + return task, written, codes + + +@pytest.fixture() +def runner(): + local = LocalTaskRunner() + yield local + local.shutdown() + + +class TestTheLocalTaskRunner: + def test_it_satisfies_the_interfaces(self, runner): + task = runner.create(TaskSpec((sys.executable, "-c", "pass"))) + assert isinstance(runner, TaskRunner) and isinstance(task, TaskHandle) + assert (task.state(), task.exit_code()) == (TaskState.PENDING, None) + + def test_output_and_the_exit_code_are_reported(self, runner): + task, written, codes = run_python(runner, "print('hello')") + assert task.start() is True + assert task.wait(WAIT_SECONDS) + text = "".join(data for stream, data in written if stream is OutputStream.STDOUT) + assert text.strip() == "hello" + assert (task.state(), task.exit_code()) == (TaskState.FINISHED, 0) + assert codes == [0] + + def test_standard_error_is_told_apart(self, runner): + task, written, _codes = run_python(runner, "import sys; sys.stderr.write('oops'); sys.exit(3)") + task.start() + task.wait(WAIT_SECONDS) + assert "".join(data for stream, data in written if stream is OutputStream.STDERR) == "oops" + assert task.exit_code() == 3 + + def test_a_binary_task_delivers_bytes_and_takes_bytes(self, runner): + code = "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read(3)[::-1]); sys.stdout.flush()" + task, written, _codes = run_python(runner, code, binary=True) + task.start() + assert task.write(b"\x00\x01\xff") is True + task.wait(WAIT_SECONDS) + assert b"".join(data for _stream, data in written) == b"\xff\x01\x00" + + def test_text_can_be_written_to_its_input(self, runner): + task, written, _codes = run_python(runner, "print(input().upper())") + task.start() + assert task.write("quiet\n") is True + task.wait(WAIT_SECONDS) + assert "".join(data for _stream, data in written).strip() == "QUIET" + + def test_a_program_that_does_not_exist_fails_to_start(self, runner, tmp_path): + task = runner.create(TaskSpec((str(tmp_path / "no-such-program"),))) + assert task.start() is False + assert task.state() is TaskState.FAILED_TO_START + assert task.write("x") is False + + def test_it_starts_only_once(self, runner): + task, _written, _codes = run_python(runner, "pass") + assert (task.start(), task.start()) == (True, False) + task.wait(WAIT_SECONDS) + + def test_a_running_task_can_be_cancelled(self, runner): + task, _written, codes = run_python(runner, "import time; time.sleep(120)") + task.start() + task.cancel() + assert task.wait(WAIT_SECONDS) + assert task.state() is TaskState.CANCELLED + assert len(codes) == 1 + + def test_writing_after_the_end_is_refused(self, runner): + task, _written, _codes = run_python(runner, "pass") + task.start() + task.wait(WAIT_SECONDS) + assert task.write("late\n") is False + + def test_the_working_directory_and_environment_are_passed_on(self, runner, tmp_path): + code = "import os; print(os.getcwd()); print(os.environ['JEDITOR_TASK_PROBE'])" + task, written, _codes = run_python(runner, code, working_directory=str(tmp_path), + environment={"JEDITOR_TASK_PROBE": "seen"}) + task.start() + task.wait(WAIT_SECONDS) + lines = "".join(data for _stream, data in written).split() + assert (to_path(to_uri(lines[0])), lines[1]) == (to_path(to_uri(str(tmp_path))), "seen") + + def test_shutting_the_runner_down_stops_what_is_running(self, runner): + task, _written, _codes = run_python(runner, "import time; time.sleep(120)") + task.start() + runner.shutdown() + assert task.wait(WAIT_SECONDS) + assert task.state() is TaskState.CANCELLED + + def test_cancelling_before_the_start_or_after_the_end_does_nothing(self, runner): + task, _written, _codes = run_python(runner, "pass") + task.cancel() + task.start() + task.wait(WAIT_SECONDS) + task.cancel() + assert task.state() is TaskState.FINISHED + + +class ScriptedAdapter: + """Stands in for the adapter process: records what is sent to it and says what the test tells it to.""" + + def __init__(self, starts: bool = True) -> None: + self.spec: TaskSpec | None = None + self.output = EventHook() + self.finished = EventHook() + self.sent: list[dict] = [] + self.cancelled = 0 + self._starts = starts + self._alive = False + self._reader = MessageReader() + + def create(self, spec: TaskSpec) -> ScriptedAdapter: + self.spec = spec + return self + + def shutdown(self) -> None: + self.cancel() + + def state(self) -> TaskState: + return TaskState.RUNNING if self._alive else TaskState.PENDING + + def exit_code(self) -> int | None: + return None + + def start(self) -> bool: + self._alive = self._starts + return self._starts + + def write(self, data) -> bool: + if not self._alive: + return False + self.sent.extend(self._reader.feed(data)) + return True + + def cancel(self) -> None: + self.cancelled += 1 + + def commands(self) -> list[str]: + return [message["command"] for message in self.sent] + + def last(self, command: str) -> dict: + return [message for message in self.sent if message["command"] == command][-1] + + def says(self, message: dict) -> None: + self.output.emit(OutputStream.STDOUT, encode_message(message)) + + def answers(self, command: str, body: dict | None = None, success: bool = True, + message: str = "") -> None: + reply = {"type": "response", "request_seq": self.last(command)["seq"], "command": command, + "success": success} + if body is not None: + reply["body"] = body + if message: + reply["message"] = message + self.says(reply) + + def event(self, name: str, body: dict | None = None) -> None: + self.says({"type": "event", "event": name, "body": body or {}}) + + def exits(self, code: int = 0) -> None: + self._alive = False + self.finished.emit(code) + + +def session_with(adapter: ScriptedAdapter) -> DapSession: + return DapSession(("adapter",), adapter, "scripted", + lambda launch: {"program": launch.program}, + lambda attach: {"port": attach.port}) + + +@pytest.fixture() +def adapter(): + return ScriptedAdapter() + + +@pytest.fixture() +def session(adapter): + return session_with(adapter) + + +@pytest.fixture() +def running(adapter, session): + """A session taken through the whole handshake, with the program running.""" + session.launch(DebugLaunchRequest("main.py")) + adapter.answers("initialize", {}) + adapter.event("initialized") + adapter.answers("launch") + return session + + +@pytest.fixture() +def paused(adapter, running): + adapter.event("stopped", {"reason": "breakpoint", "threadId": 7, "allThreadsStopped": True}) + return running + + +def replies_to(ask) -> list[DebugReply]: + collected: list[DebugReply] = [] + ask(collected.append) + return collected + + +class TestTheHandshake: + def test_the_session_satisfies_the_interface(self, session): + assert isinstance(session, DebugSession) + assert session.state() is DebugState.IDLE + + def test_the_adapter_is_started_as_a_binary_task(self, adapter, session): + assert session.launch(DebugLaunchRequest("main.py")) is True + assert (adapter.spec.command, adapter.spec.binary) == (("adapter",), True) + assert session.state() is DebugState.STARTING + + def test_initialize_comes_first_and_launch_only_after_its_answer(self, adapter, session): + session.launch(DebugLaunchRequest("main.py")) + assert adapter.commands() == ["initialize"] + assert adapter.last("initialize")["arguments"]["linesStartAt1"] is True + adapter.answers("initialize", {}) + assert adapter.commands() == ["initialize", "launch"] + assert adapter.last("launch")["arguments"] == {"program": "main.py"} + + def test_breakpoints_are_sent_once_the_adapter_says_it_is_ready(self, adapter, session, tmp_path): + uri = to_uri(tmp_path / "main.py") + session.set_breakpoints(uri, [Breakpoint(uri, 3), Breakpoint(uri, 9, "x > 1"), + Breakpoint(uri, 12, enabled=False)]) + session.launch(DebugLaunchRequest("main.py")) + adapter.answers("initialize", {}) + assert "setBreakpoints" not in adapter.commands() + adapter.event("initialized") + assert adapter.commands()[2:] == ["setBreakpoints", "setExceptionBreakpoints", "configurationDone"] + sent = adapter.last("setBreakpoints")["arguments"] + assert sent["source"]["path"] == to_path(uri) + assert sent["breakpoints"] == [{"line": 3}, {"line": 9, "condition": "x > 1"}] + + def test_the_program_runs_once_the_launch_is_answered(self, adapter, session): + states: list[DebugState] = [] + session.state_changed.subscribe(states.append) + session.launch(DebugLaunchRequest("main.py")) + adapter.answers("initialize", {}) + adapter.event("initialized") + adapter.answers("launch") + assert states == [DebugState.STARTING, DebugState.RUNNING] + + def test_a_second_launch_while_one_is_running_is_refused(self, running): + assert running.launch(DebugLaunchRequest("other.py")) is False + + def test_an_adapter_that_does_not_start_leaves_the_session_idle(self): + session = session_with(ScriptedAdapter(starts=False)) + assert session.launch(DebugLaunchRequest("main.py")) is False + assert session.state() is DebugState.IDLE + + def test_a_refused_launch_is_reported_and_the_adapter_is_told_to_go(self, adapter, session): + told: list[OutputEvent] = [] + session.output.subscribe(told.append) + session.launch(DebugLaunchRequest("main.py")) + adapter.answers("initialize", {}) + adapter.answers("launch", success=False, message="no such file") + assert told == [OutputEvent("console", "no such file")] + assert adapter.commands()[-1] == "disconnect" + + def test_attaching_sends_attach(self, adapter, session): + assert session.attach(DebugAttachRequest(5678)) is True + adapter.answers("initialize", {}) + assert adapter.last("attach")["arguments"] == {"port": 5678} + + def test_attaching_can_go_over_a_channel_of_the_adapters_choosing(self): + process, channel = ScriptedAdapter(), ScriptedAdapter() + asked: list[DebugAttachRequest] = [] + + def choose(attach: DebugAttachRequest) -> ScriptedAdapter: + asked.append(attach) + return channel + + session = DapSession(("adapter",), process, "scripted", lambda launch: {}, + lambda attach: {"port": attach.port}, choose) + assert session.attach(DebugAttachRequest(5678)) is True + channel.answers("initialize", {}) + assert (channel.commands(), process.sent, asked) == ( + ["initialize", "attach"], [], [DebugAttachRequest(5678)]) + + def test_a_channel_that_cannot_connect_leaves_the_session_idle(self): + session = DapSession(("adapter",), ScriptedAdapter(), "scripted", lambda launch: {}, + lambda attach: {}, lambda attach: ScriptedAdapter(starts=False)) + assert session.attach(DebugAttachRequest(5678)) is False + assert session.state() is DebugState.IDLE + + def test_breakpoints_changed_while_running_are_sent_at_once(self, adapter, running, tmp_path): + uri = to_uri(tmp_path / "late.py") + running.set_breakpoints(uri, [Breakpoint(uri, 4)]) + assert adapter.last("setBreakpoints")["arguments"]["breakpoints"] == [{"line": 4}] + + def test_what_the_adapter_says_about_breakpoints_is_passed_on(self, adapter, running, tmp_path): + uri = to_uri(tmp_path / "late.py") + reported: list[tuple] = [] + running.breakpoints_reported.subscribe(reported.append) + running.set_breakpoints(uri, [Breakpoint(uri, 4), Breakpoint(uri, 5)]) + adapter.answers("setBreakpoints", {"breakpoints": [ + {"verified": True, "line": 4}, {"verified": False, "line": 6, "message": "no code here"}]}) + assert reported == [(BreakpointStatus(uri, 4, True), BreakpointStatus(uri, 6, False, "no code here"))] + + +class TestStoppingAndStepping: + def test_a_stop_pauses_the_session_and_says_why(self, adapter, running): + stops: list[StopEvent] = [] + running.stopped.subscribe(stops.append) + adapter.event("stopped", {"reason": "exception", "threadId": 4, "description": "Paused", + "text": "ValueError", "allThreadsStopped": False}) + assert running.state() is DebugState.PAUSED + assert stops == [StopEvent("exception", 4, "Paused", "ValueError", False)] + + def test_resuming_names_the_thread_that_stopped(self, adapter, paused): + paused.resume() + assert adapter.last("continue")["arguments"] == {"threadId": 7} + + def test_a_thread_can_be_named_explicitly(self, adapter, paused): + paused.pause(3) + assert adapter.last("pause")["arguments"] == {"threadId": 3} + + @pytest.mark.parametrize("kind, command", [ + (StepKind.OVER, "next"), (StepKind.INTO, "stepIn"), (StepKind.OUT, "stepOut")]) + def test_each_kind_of_step_has_its_command(self, adapter, paused, kind, command): + paused.step(kind) + assert adapter.last(command)["arguments"] == {"threadId": 7} + + def test_the_adapter_saying_it_continued_means_running(self, adapter, paused): + adapter.event("continued", {"threadId": 7}) + assert paused.state() is DebugState.RUNNING + + def test_program_output_is_passed_on_with_its_category(self, adapter, running): + told: list[OutputEvent] = [] + running.output.subscribe(told.append) + adapter.event("output", {"category": "stdout", "output": "hello\n"}) + adapter.event("output", {"output": "note"}) + assert told == [OutputEvent("stdout", "hello\n"), OutputEvent("console", "note")] + + def test_what_the_adapter_writes_to_standard_error_is_shown(self, adapter, running): + told: list[OutputEvent] = [] + running.output.subscribe(told.append) + adapter.output.emit(OutputStream.STDERR, b"adapter trouble") + assert told == [OutputEvent("console", "adapter trouble")] + + def test_garbage_from_the_adapter_is_ignored(self, adapter, running): + adapter.says({"type": "mystery"}) + adapter.says({"type": "response", "request_seq": 9999, "success": True}) + adapter.says({"type": "event", "event": "stopped", "body": "not a mapping"}) + assert running.state() is DebugState.PAUSED + + +class TestQueries: + def test_threads(self, adapter, paused): + replies = replies_to(paused.threads) + adapter.answers("threads", {"threads": [{"id": 7, "name": "MainThread"}, {"id": 8}]}) + assert replies == [DebugReply((DebugThread(7, "MainThread"), DebugThread(8, "")))] + + def test_the_stack_innermost_first_with_uris(self, adapter, paused, tmp_path): + path = str(tmp_path / "main.py") + replies = replies_to(lambda reply: paused.stack_trace(7, reply)) + assert adapter.last("stackTrace")["arguments"] == { + "threadId": 7, "startFrame": 0, "levels": MAX_STACK_FRAMES} + adapter.answers("stackTrace", {"stackFrames": [ + {"id": 2, "name": "add", "source": {"path": path}, "line": 3, "column": 5}, + {"id": 1, "name": ""}]}) + assert replies[0].value == (StackFrame(2, "add", to_uri(path), 3, 5), + StackFrame(1, "", "", 1, 1)) + + def test_scopes_and_variables(self, adapter, paused): + scopes = replies_to(lambda reply: paused.scopes(2, reply)) + adapter.answers("scopes", {"scopes": [ + {"name": "Locals", "variablesReference": 5}, + {"name": "Globals", "variablesReference": 6, "expensive": True}]}) + assert scopes[0].value == (Scope("Locals", 5), Scope("Globals", 6, True)) + found = replies_to(lambda reply: paused.variables(5, reply)) + assert adapter.last("variables")["arguments"] == {"variablesReference": 5} + adapter.answers("variables", {"variables": [ + {"name": "a", "value": "1", "type": "int"}, + {"name": "items", "value": "[1, 2]", "type": "list", "variablesReference": 9}]}) + assert found[0].value == (Variable("a", "1", "int"), Variable("items", "[1, 2]", "list", 9)) + + def test_evaluating_in_a_frame_and_globally(self, adapter, paused): + replies = replies_to(lambda reply: paused.evaluate("a + 1", 2, reply)) + assert adapter.last("evaluate")["arguments"] == { + "expression": "a + 1", "context": "repl", "frameId": 2} + adapter.answers("evaluate", {"result": "2", "type": "int"}) + assert replies == [DebugReply(EvaluateResult("2", "int"))] + paused.evaluate("1", 0, lambda _reply: None) + assert "frameId" not in adapter.last("evaluate")["arguments"] + + def test_which_exception_stopped_a_thread(self, adapter, paused): + replies = replies_to(lambda reply: paused.exception_info(7, reply)) + adapter.answers("exceptionInfo", { + "exceptionId": "ZeroDivisionError", "description": "division by zero", + "breakMode": "unhandled", "details": {"stackTrace": "Traceback ..."}}) + assert replies == [DebugReply(ExceptionInfo( + "ZeroDivisionError", "division by zero", "unhandled", "Traceback ..."))] + + def test_a_refused_query_is_an_error_reply_with_the_empty_value(self, adapter, paused): + frames = replies_to(lambda reply: paused.stack_trace(7, reply)) + adapter.answers("stackTrace", success=False, message="thread is running") + value = replies_to(lambda reply: paused.evaluate("x", 2, reply)) + adapter.answers("evaluate", success=False) + assert (frames[0].ok, frames[0].value, frames[0].error) == (False, (), "thread is running") + assert (value[0].ok, value[0].value, value[0].error) == (False, None, "evaluate failed") + + def test_asking_when_nothing_is_running_answers_at_once(self, session): + replies = replies_to(session.threads) + assert (replies[0].ok, replies[0].value) == (False, ()) + + +class TestEnding: + def test_terminating_asks_the_adapter_to_end_the_program(self, adapter, running): + running.terminate() + assert adapter.last("disconnect")["arguments"] == {"terminateDebuggee": True} + + def test_the_program_ending_by_itself_disconnects(self, adapter, running): + adapter.event("terminated") + assert adapter.commands()[-1] == "disconnect" + + def test_the_adapter_exiting_ends_the_session_and_fails_what_was_waiting(self, adapter, paused): + replies = replies_to(paused.threads) + adapter.exits(0) + assert paused.state() is DebugState.TERMINATED + assert (replies[0].ok, replies[0].error) == (False, "the debugger has ended") + + def test_a_session_that_ended_can_be_launched_again(self, adapter, running): + adapter.exits(0) + assert running.launch(DebugLaunchRequest("main.py")) is True + assert running.state() is DebugState.STARTING + + def test_terminating_twice_or_before_a_launch_does_nothing(self, adapter, session): + session.terminate() + assert adapter.sent == [] + + def test_the_adapter_is_stopped_if_it_does_not_leave(self, adapter, running, monkeypatch): + monkeypatch.setattr("je_editor.adapters.debug.dap_session.DISCONNECT_GRACE_SECONDS", 0.05) + stopped = threading.Event() + adapter.cancel = stopped.set + running.terminate() + assert stopped.wait(WAIT_SECONDS) + + +class TestTheSocketChannel: + """A TCP connection shaped like a binary task, for adapters that wait on a port.""" + + @pytest.fixture() + def listener(self): + with socket.socket() as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + yield server + + def test_it_satisfies_the_task_interface(self): + channel = SocketChannel("127.0.0.1", 1) + assert isinstance(channel, TaskHandle) + assert (channel.state(), channel.spec.binary, channel.exit_code()) == ( + TaskState.PENDING, True, None) + + def test_bytes_go_both_ways_and_the_end_is_announced(self, listener): + channel = SocketChannel(*listener.getsockname()) + received: list[bytes] = [] + ended = threading.Event() + channel.output.subscribe(lambda _stream, data: received.append(data)) + channel.finished.subscribe(lambda _code: ended.set()) + assert channel.start() is True + peer, _address = listener.accept() + with peer: + assert channel.write(b"ping") is True + assert peer.recv(16) == b"ping" + peer.sendall(b"pong") + assert ended.wait(WAIT_SECONDS) + assert (b"".join(received), channel.state(), channel.exit_code()) == ( + b"pong", TaskState.FINISHED, 0) + assert channel.write(b"late") is False + + def test_nothing_listening_means_it_does_not_start(self, listener): + address = listener.getsockname() + listener.close() + channel = SocketChannel(*address) + assert channel.start() is False + assert (channel.state(), channel.write(b"x")) == (TaskState.FAILED_TO_START, False) + + def test_cancelling_closes_the_connection(self, listener): + channel = SocketChannel(*listener.getsockname()) + ended = threading.Event() + channel.finished.subscribe(lambda _code: ended.set()) + channel.start() + peer, _address = listener.accept() + with peer: + channel.cancel() + assert peer.recv(16) == b"" + assert ended.wait(WAIT_SECONDS) + assert channel.state() is TaskState.CANCELLED + + def test_it_connects_only_once(self, listener): + channel = SocketChannel(*listener.getsockname()) + assert (channel.start(), channel.start()) == (True, False) + channel.cancel() + + +class TestRequests: + def test_a_launch_needs_a_program(self): + with pytest.raises(JEditorServiceException): + DebugLaunchRequest("") + + @pytest.mark.parametrize("port", [0, -1, 70000]) + def test_an_attach_needs_a_real_port(self, port): + with pytest.raises(JEditorServiceException): + DebugAttachRequest(port) + + +class TestTheDebugpyAdapter: + def test_launch_arguments(self): + launch = DebugLaunchRequest("main.py", ("--fast",), "C:/work", True, "C:/env/python.exe", False) + assert debugpy_adapter.launch_arguments(launch) == { + "request": "launch", "type": "python", "program": "main.py", "args": ["--fast"], + "console": "internalConsole", "redirectOutput": True, "stopOnEntry": True, + "justMyCode": False, "cwd": "C:/work", "python": ["C:/env/python.exe"]} + + def test_the_defaults_leave_the_folder_and_interpreter_to_the_adapter(self): + arguments = debugpy_adapter.launch_arguments(DebugLaunchRequest("main.py")) + assert "cwd" not in arguments and "python" not in arguments + + def test_attach_arguments(self): + assert debugpy_adapter.attach_arguments(DebugAttachRequest(5678, "10.0.0.2")) == { + "request": "attach", "type": "python", "justMyCode": True, + "connect": {"host": "10.0.0.2", "port": 5678}} + + def test_attaching_connects_straight_to_the_waiting_program(self): + channel = debugpy_adapter.attach_channel(DebugAttachRequest(5678, "10.0.0.2")) + assert channel.spec.command == ("tcp", "10.0.0.2:5678") + + def test_the_adapter_runs_on_this_interpreter(self): + assert debugpy_adapter.adapter_command() == (sys.executable, "-m", "debugpy.adapter") + + def test_it_is_registered_when_debugpy_is_installed(self): + registry: NamedRegistry = NamedRegistry("debug adapter") + debugpy_adapter.register_builtin_debug_adapters(registry, ScriptedAdapter) + assert registry.names() == ["debugpy"] + assert isinstance(registry.require("debugpy")(), DapSession) + + def test_it_is_not_registered_without_debugpy(self, monkeypatch): + monkeypatch.setattr(debugpy_adapter, "debugpy_available", lambda: False) + registry: NamedRegistry = NamedRegistry("debug adapter") + debugpy_adapter.register_builtin_debug_adapters(registry, ScriptedAdapter) + assert registry.names() == [] + + def test_the_default_services_carry_the_runner_and_the_adapter(self, tmp_path): + services = build_default_services(settings_directory=tmp_path) + try: + assert isinstance(services.task_runners.require(LOCAL_RUNNER), LocalTaskRunner) + assert services.debug_adapters.names() == ["debugpy"] + finally: + services.shutdown() diff --git a/test/test_debugpy_integration.py b/test/test_debugpy_integration.py new file mode 100644 index 0000000..0fb3736 --- /dev/null +++ b/test/test_debugpy_integration.py @@ -0,0 +1,243 @@ +""" +Integration tests: the DAP debug session driving the real debugpy adapter. + +Each test starts an adapter process and a program under it, so these are slower +than the rest; what they prove is that the session's handshake, its queries and +its control commands work against an adapter nobody here wrote. +""" +from __future__ import annotations + +import queue +import socket +import subprocess +import sys +import threading +import time + +import pytest + +from je_editor.adapters.debug.debugpy_adapter import debugpy_available, debugpy_session +from je_editor.adapters.process.local_task_runner import LocalTaskRunner +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugAttachRequest, DebugLaunchRequest, DebugReply, DebugState, StepKind, StopEvent +) +from je_editor.core.uri.resource_uri import to_uri, uri_key + +pytestmark = pytest.mark.skipif(not debugpy_available(), reason="debugpy is not installed") + +# A debugger starts two processes and a socket between them; a busy machine needs room. +WAIT_SECONDS = 60 +ADD_LINE = 5 +RETURN_LINE = 6 +PROGRAM = '''\ +import sys + + +def add(first, second): + total = first + second + return total + + +values = [add(number, 10) for number in range(3)] +print("done", values) +''' +FAILING = "numbers = [1, 0]\nprint(numbers[0] / numbers[1])\n" +LOOPING = "import time\n\nwhile True:\n time.sleep(0.05)\n" + + +class Watcher: + """Collects what a session announces from its own thread.""" + + def __init__(self, session) -> None: + self.stops: queue.Queue[StopEvent] = queue.Queue() + self.output: list[str] = [] + self.states: list[DebugState] = [] + self._ended = threading.Event() + self._running = threading.Event() + session.stopped.subscribe(self.stops.put) + session.output.subscribe(lambda event: self.output.append(event.text)) + session.state_changed.subscribe(self._on_state) + + def _on_state(self, state: DebugState) -> None: + self.states.append(state) + if state is DebugState.RUNNING: + self._running.set() + if state is DebugState.TERMINATED: + self._ended.set() + + def next_stop(self) -> StopEvent: + return self.stops.get(timeout=WAIT_SECONDS) + + def wait_until_running(self) -> bool: + return self._running.wait(WAIT_SECONDS) + + def wait_until_ended(self) -> bool: + return self._ended.wait(WAIT_SECONDS) + + +def ask(query, *arguments): + """Put a query to the session and wait for its reply.""" + answered = threading.Event() + replies: list[DebugReply] = [] + + def receive(reply: DebugReply) -> None: + replies.append(reply) + answered.set() + + query(*arguments, receive) + assert answered.wait(WAIT_SECONDS), "the debugger did not answer" + assert replies[0].ok, replies[0].error + return replies[0].value + + +def named(variables) -> dict[str, str]: + return {variable.name: variable.value for variable in variables} + + +@pytest.fixture() +def runner(): + local = LocalTaskRunner() + yield local + local.shutdown() + + +@pytest.fixture() +def session(runner): + built = debugpy_session(runner) + yield built + built.terminate() + + +def write_program(tmp_path, text: str) -> tuple[str, str]: + path = tmp_path / "program.py" + path.write_text(text, encoding="utf-8") + return str(path), to_uri(path) + + +def locals_of(session, frame) -> dict[str, str]: + scopes = ask(session.scopes, frame.frame_id) + local_scope = next(scope for scope in scopes if scope.name.lower().startswith("local")) + return named(ask(session.variables, local_scope.variables_reference)) + + +class TestLaunching: + def test_a_breakpoint_stops_the_program_and_everything_can_be_inspected(self, session, tmp_path): + program, uri = write_program(tmp_path, PROGRAM) + watcher = Watcher(session) + session.set_breakpoints(uri, [Breakpoint(uri, ADD_LINE)]) + assert session.launch(DebugLaunchRequest(program, working_directory=str(tmp_path))) is True + + stop = watcher.next_stop() + assert (stop.reason, session.state()) == ("breakpoint", DebugState.PAUSED) + assert stop.thread_id in [thread.thread_id for thread in ask(session.threads)] + + frames = ask(session.stack_trace, stop.thread_id) + assert (frames[0].name, frames[0].line) == ("add", ADD_LINE) + assert uri_key(frames[0].uri) == uri_key(uri) + assert locals_of(session, frames[0]) == {"first": "0", "second": "10"} + assert ask(session.evaluate, "first + second", frames[0].frame_id).value == "10" + + session.step(StepKind.OVER) + assert watcher.next_stop().reason == "step" + frames = ask(session.stack_trace, stop.thread_id) + assert frames[0].line == RETURN_LINE + assert locals_of(session, frames[0])["total"] == "10" + + session.step(StepKind.OUT) + assert watcher.next_stop().reason == "step" + assert ask(session.stack_trace, stop.thread_id)[0].name != "add" + + session.resume() + assert watcher.next_stop().reason == "breakpoint" + frames = ask(session.stack_trace, stop.thread_id) + assert locals_of(session, frames[0])["first"] == "1" + + session.terminate() + assert watcher.wait_until_ended() + assert session.state() is DebugState.TERMINATED + + def test_a_conditional_breakpoint_stops_only_when_its_condition_holds(self, session, tmp_path): + program, uri = write_program(tmp_path, PROGRAM) + watcher = Watcher(session) + session.set_breakpoints(uri, [Breakpoint(uri, ADD_LINE, "first == 2")]) + session.launch(DebugLaunchRequest(program)) + stop = watcher.next_stop() + frames = ask(session.stack_trace, stop.thread_id) + assert locals_of(session, frames[0])["first"] == "2" + + def test_stopping_on_entry_then_running_to_the_end(self, session, tmp_path): + program, _uri = write_program(tmp_path, PROGRAM) + watcher = Watcher(session) + session.launch(DebugLaunchRequest(program, stop_on_entry=True)) + stop = watcher.next_stop() + assert stop.reason == "entry" + assert ask(session.stack_trace, stop.thread_id)[0].line == 1 + session.resume() + assert watcher.wait_until_ended() + assert "done [10, 11, 12]" in "".join(watcher.output) + + def test_stepping_into_a_call(self, session, tmp_path): + program, uri = write_program(tmp_path, PROGRAM) + watcher = Watcher(session) + session.set_breakpoints(uri, [Breakpoint(uri, RETURN_LINE + 3)]) + session.launch(DebugLaunchRequest(program)) + stop = watcher.next_stop() + names = set() + for _attempt in range(4): + session.step(StepKind.INTO) + watcher.next_stop() + names.add(ask(session.stack_trace, stop.thread_id)[0].name) + assert "add" in names + + def test_an_uncaught_exception_stops_the_program_and_can_be_asked_about(self, session, tmp_path): + program, _uri = write_program(tmp_path, FAILING) + watcher = Watcher(session) + session.launch(DebugLaunchRequest(program)) + stop = watcher.next_stop() + assert stop.reason == "exception" + info = ask(session.exception_info, stop.thread_id) + assert "ZeroDivisionError" in info.exception_id + assert "division by zero" in info.description + + def test_a_running_program_can_be_paused(self, session, tmp_path): + program, _uri = write_program(tmp_path, LOOPING) + watcher = Watcher(session) + session.launch(DebugLaunchRequest(program)) + assert watcher.wait_until_running() + threads = ask(session.threads) + session.pause(threads[0].thread_id) + assert watcher.next_stop().reason == "pause" + assert session.state() is DebugState.PAUSED + + def test_a_program_that_is_not_there_ends_the_session(self, session, tmp_path): + watcher = Watcher(session) + session.launch(DebugLaunchRequest(str(tmp_path / "missing.py"))) + assert watcher.wait_until_ended() + assert session.state() is DebugState.TERMINATED + + +class TestAttaching: + def test_attaching_to_a_program_that_waits_for_a_debugger(self, session, tmp_path): + program, uri = write_program(tmp_path, PROGRAM) + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + waiting = subprocess.Popen( + [sys.executable, "-m", "debugpy", "--listen", f"127.0.0.1:{port}", "--wait-for-client", + program], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + watcher = Watcher(session) + session.set_breakpoints(uri, [Breakpoint(uri, ADD_LINE)]) + # The program needs a moment before its port accepts a debugger. + deadline = time.monotonic() + WAIT_SECONDS + while not session.attach(DebugAttachRequest(port)): + assert time.monotonic() < deadline, "the program never listened" + time.sleep(0.2) + stop = watcher.next_stop() + assert stop.reason == "breakpoint" + assert ask(session.stack_trace, stop.thread_id)[0].name == "add" + session.terminate() + assert watcher.wait_until_ended() + finally: + waiting.kill() + waiting.wait(WAIT_SECONDS) From 3bffa5a079bc9a9b970965d0403b4b0773157ec7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 06:24:25 +0800 Subject: [PATCH 14/14] Debug through the Debug Adapter Protocol from a Debug Panel F9 now runs the program under debugpy through the DAP session added in the previous debugger commit, and a Debug Panel opens beside the editor: continue, pause, step over, into and out, stop; the threads; the call stack; the variables of the chosen frame, opened level by level; the program's output; and a box to evaluate an expression in that frame. The line the program stopped on is marked in the editor. A breakpoint can carry a condition (Run > Debug > Breakpoint Condition...), and Run > Debug > Attach to Process... connects to a program started with "debugpy --listen". The panel and the editor know only a DebugController, one per window, which turns what the session says on its own thread into Qt signals. Stepping shortcuts and breakpoint changes go to it while a program is being debugged. The pdb console stays as the fallback for when the debugpy adapter is not registered; PROGRESS #25 says what has to be true before it can go. The dock entry is named Debug Panel rather than Debugger: an existing menu title is also "Debugger" in English and differs in Chinese, so the two could not be told apart when the language changes. Codacy's four notices on the previous commit are about using subprocess at all. A task runner exists to start processes, always from a validated argument list and never through a shell; they are annotated the way the other process launchers in this repository are. Closes PROGRESS #12 (roadmap M4). --- PROGRESS.md | 12 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 16 +- architecture_explore.md | 57 +- docs/roadmap/2026-editor-next.md | 4 +- docs/source/docs/Eng/code_execution.rst | 45 +- docs/source/docs/Eng/configuration.rst | 2 + docs/source/docs/Zh/code_execution.rst | 38 +- docs/source/docs/Zh/configuration.rst | 2 + docs/updates/2026-10.md | 23 + docs/updates/README.md | 3 +- .../adapters/process/local_task_runner.py | 7 +- .../code/breakpoint/breakpoint_manager.py | 52 +- .../code_edit_plaintext.py | 101 +++ .../pyside_ui/main_ui/debug_panel/__init__.py | 0 .../main_ui/debug_panel/debug_actions.py | 219 ++++++ .../main_ui/debug_panel/debug_controller.py | 241 +++++++ .../main_ui/debug_panel/debug_panel_widget.py | 325 +++++++++ je_editor/pyside_ui/main_ui/main_editor.py | 4 + .../main_ui/menu/dock_menu/build_dock_menu.py | 11 + .../under_run_menu/build_debug_menu.py | 29 + je_editor/utils/debugger/attach_address.py | 32 + je_editor/utils/multi_language/english.py | 23 + je_editor/utils/multi_language/japanese.py | 23 + .../multi_language/simplified_chinese.py | 23 + .../multi_language/traditional_chinese.py | 23 + je_editor/utils/theme/theme_colors.py | 2 + test/test_debug_panel.py | 643 ++++++++++++++++++ test/test_debugpy_integration.py | 6 +- 31 files changed, 1920 insertions(+), 52 deletions(-) create mode 100644 je_editor/pyside_ui/main_ui/debug_panel/__init__.py create mode 100644 je_editor/pyside_ui/main_ui/debug_panel/debug_actions.py create mode 100644 je_editor/pyside_ui/main_ui/debug_panel/debug_controller.py create mode 100644 je_editor/pyside_ui/main_ui/debug_panel/debug_panel_widget.py create mode 100644 je_editor/utils/debugger/attach_address.py create mode 100644 test/test_debug_panel.py diff --git a/PROGRESS.md b/PROGRESS.md index b1d1bfc..ecdca51 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,7 +24,7 @@ ### 下一代編輯器藍圖(`docs/roadmap/2026-editor-next.md`,PR #270) -M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引擎)、M3(工作區與多根專案)、M5(AI 供應者)已完成,M4 完成了服務那一半,見 `docs/updates/2026-10.md`。 +M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引擎)、M3(工作區與多根專案)、M5(AI 供應者)已完成,M4(DAP 除錯)已完成,見 `docs/updates/2026-10.md`。 以下依相依關係排序。 - **#9** M1(UI 重新設計、指令與快捷鍵、語系補齊)。可以先做不改變外觀的部分:每個指令有不隨翻譯 @@ -44,11 +44,11 @@ M0(`je_editor/core/` 服務層)、M2(診斷模型與 Tree-sitter 語法引 - **#22** M3 沒有涵蓋的部分(工作區本身已完成,見 U-20261008-07):執行程式、測試面板、終端機、Git 工具列與 Python 直譯器(venv)仍然只認主要的根目錄,也就是工作目錄。藍圖要的「每個根目錄有自己的 語言 / 工具設定與環境」還沒做;Git 面板也還沒有依根目錄切換。 -- **#12** M4(除錯器改走 DAP)剩下畫面這一半。服務這一半已完成(U-20261008-11):`DapSession`、 - 本機 `TaskRunner`、debugpy 轉接器,對真正的 debugpy 有整合測試。還沒做的是除錯面板(執行緒、堆疊、 - 變數、求值、輸出)、編輯器裡標出目前執行的那一行、條件中斷點的輸入方式,以及把「執行除錯器」與 - 逐步執行的快捷鍵從 pdb 主控台改接到 `services.debug_adapters`。既有的 pdb 主控台在 debugpy 不能用時 - (例如打包成執行檔、`sys.executable` 不是直譯器)要留作退路。 +- **#25** M4 沒有涵蓋的部分(除錯服務與除錯面板已完成,見 U-20261008-11、-12):pdb 主控台還留著當 + debugpy 沒有登記時的退路,要等確認每一種發佈方式(含打包成執行檔,那時 `sys.executable` 不是直譯器) + 都帶得到 debugpy 之後才能拿掉;例外中斷目前固定只停在未捕捉的例外,沒有做設定;沒有監看式、 + 沒有滑鼠停在變數上顯示值;中斷點的條件與 `BreakpointStatus`(轉接器說某個中斷點沒設上)還沒有顯示在 + 行號區;執行程式(非除錯)仍然走 `BaseProcessManager`,還沒有改用 `TaskRunner`。 - **#14** M6(遠端開發)。實作 `RemoteSession`,並補上遠端檔案系統、連接埠轉送、直譯器探索的介面。 〔決定〕第一個傳輸是不是 SSH(PR #270 的審查問題 3)。 - **#15** M7(可嵌入元件)。`import je_editor.core` 不再載入 Qt(頂層 `__init__` 要改成延後匯入); diff --git a/README.md b/README.md index 66ce189..cfcdfe9 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ yet. See the *Core Services* page of the [documentation](https://je-editor.readt ### Code Execution & Debugging - **Run Python scripts** (F5) -- Execute the current file with real-time output streaming. -- **Debug mode** (F9) -- Launch the Python debugger for step-through debugging, with breakpoints toggled from the gutter (`Ctrl+F9`). +- **Debug mode** (F9) -- Debug through the Debug Adapter Protocol (debugpy): a Debug Panel with threads, the call stack, variables you can open, expression evaluation and the program's output; the stopped line is marked in the editor. Breakpoints are toggled from the gutter (`Ctrl+F9`) and can carry a condition, and a program started with `debugpy --listen` can be attached to. - **Shell commands** -- Execute arbitrary shell/terminal commands from within the editor. - **Virtual environment detection** -- Automatically detects and activates Python virtual environments. - **Process management** -- Stop individual or all running processes. diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 22185b5..99c575b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -351,7 +351,7 @@ services.shutdown() ### 程序执行与调试 - **运行 Python 脚本**(F5)-- 执行当前文件并实时流式输出。 -- **调试模式**(F9)-- 启动 Python 调试器进行逐步调试,可从行号区切换断点(`Ctrl+F9`)。 +- **调试模式**(F9)-- 通过 Debug Adapter Protocol(debugpy)调试:调试面板有线程、调用堆栈、可展开的变量、表达式求值与程序输出,停下来的那一行会在编辑器里标出。断点从行号区切换(`Ctrl+F9`),可以加上条件;也可以附加到以 `debugpy --listen` 启动的程序。 - **Shell 命令** -- 在编辑器内直接执行任意 Shell/终端命令。 - **虚拟环境检测** -- 自动检测并激活 Python 虚拟环境。 - **进程管理** -- 停止单个或所有运行中的进程。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 740df5c..a6a26c8 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -351,7 +351,7 @@ services.shutdown() ### 程式執行與除錯 - **執行 Python 腳本**(F5)-- 執行目前檔案並即時串流輸出。 -- **除錯模式**(F9)-- 啟動 Python 除錯器進行逐步除錯,可從行號區切換中斷點(`Ctrl+F9`)。 +- **除錯模式**(F9)-- 透過 Debug Adapter Protocol(debugpy)除錯:除錯面板有執行緒、呼叫堆疊、可展開的變數、運算式求值與程式輸出,停下來的那一行會在編輯器裡標出。中斷點從行號區切換(`Ctrl+F9`),可以加上條件;也可以接上以 `debugpy --listen` 啟動的程式。 - **Shell 指令** -- 在編輯器內直接執行任意 Shell/終端機指令。 - **虛擬環境偵測** -- 自動偵測並啟用 Python 虛擬環境。 - **程序管理** -- 停止單一或所有執行中的程序。 diff --git a/architecture.md b/architecture.md index 23f3d72..4f3c1cc 100644 --- a/architecture.md +++ b/architecture.md @@ -117,6 +117,20 @@ edit → document.contentsChange → TreeSitterHighlighter._analyse_again() → lines after the edit: block state toggled so Qt carries on; lines before it: next event-loop turn ``` +**Debugging** + +``` +F9 / Run → Debug → run_debugger() (menu/run_menu/under_run_menu/build_debug_menu.py) + → controller_of(window) [EditorMain.debug_controller, when the debugpy adapter is registered] + → start_debugging() (main_ui/debug_panel/debug_actions.py): breakpoints of every open editor + → DebugController.launch() → services.debug_adapters["debugpy"]() → DapSession + → LocalTaskRunner starts `python -m debugpy.adapter` → DAP handshake → program runs + | no adapter → ExecManager runs `python -m pdb` with ProcessInput (the earlier console) +session thread: stopped / output / replies → DebugController Qt signals → DebugPanelWidget + → frame chosen → show_execution_line() → go_to_new_tab(path) → CodeEditor.set_execution_line() +editor shortcuts (continue, step) → CodeEditor.send_debugger_command() → controller, else pdb +``` + **Plugin install and load** ``` @@ -156,7 +170,7 @@ Plugin browser (pyside_ui/main_ui/plugin_browser/) → github_api.fetch_repo_tre `languages.register()`. Any source reports findings with `diagnostics.publish(source, uri, ...)`. Implementations live in `adapters/`: the AI providers `openai` and `anthropic`, the local task runner (`task_runners["local"]`), the `debugpy` debug adapter (`debug_adapters["debugpy"]`, - a factory returning a new `DebugSession`; the window does not use it yet), and the + a factory returning a new `DebugSession`, which `EditorMain.debug_controller` drives), and the Tree-sitter syntax engine, which `build_default_services()` sets as `services.syntax` and registers as the `syntax` language service. Questions to language services go through `languages.request(LanguageRequest, on_reply)`, which returns a cancel function. A plugin diff --git a/architecture_explore.md b/architecture_explore.md index 3ce03f7..7a09a53 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,7 +1,7 @@ # JEditor 架構導覽 / Architecture Exploration > 產出時間:2026-08-03 對應版本:`dev` 分支(commit `f17e07a`);2026-10-08 加入 `core/` 並重算各套件規模。 -> 涵蓋範圍:`je_editor/` 全部 334 個 `.py`(206 個實作模組 + 128 個 `__init__.py`),共 38,177 行。 +> 涵蓋範圍:`je_editor/` 全部 339 個 `.py`(210 個實作模組 + 129 個 `__init__.py`),共 39,286 行。 > 這份文件記錄「每個模組負責什麼」與「模組之間怎麼串起來」,不是使用手冊(使用說明見 `README.md`、插件說明見 `PLUGIN_GUIDE.md`)。 --- @@ -16,23 +16,23 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 | 語言 / 版本 | Python 3.10+(CI 測 3.10 ~ 3.14) | | UI 框架 | PySide6 6.11.2 + qt-material 主題 | | 主要相依 | `jedi`(Python 補全)、`ruff`(診斷)、`yapf` / `pycodestyle`(格式化與檢查)、`gitpython`、`watchdog`、`qtconsole` + `IPython`、`langchain_openai` + `langchain_core`、`anthropic`、`tree-sitter` 與三個文法套件(`tree-sitter-python` / `-javascript` / `-json`)、`debugpy`、`frontengine` | -| 測試 | pytest + pytest-qt,114 個測試檔、約 20,300 行 | +| 測試 | pytest + pytest-qt,115 個測試檔、約 21,000 行 | | 靜態分析 | ruff、SonarCloud(`sonar.sources=je_editor`)、Codacy、bandit | ### 各套件規模 | 套件 | 模組數 | 行數 | 定位 | | --- | ---: | ---: | --- | -| `pyside_ui/` | 101 | 21,691 | View / Controller:所有 Qt 元件與選單 | -| `utils/` | 61 | 9,131 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | -| `adapters/` | 12 | 2,340 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | +| `pyside_ui/` | 104 | 22,671 | View / Controller:所有 Qt 元件與選單 | +| `utils/` | 62 | 9,257 | 純邏輯層(絕大多數不 import Qt,可單獨測試) | +| `adapters/` | 12 | 2,343 | 核心介面的實作(同樣不 import Qt):AI 供應者、設定檔讀寫、預設服務的組裝 | | `core/` | 19 | 3,401 | 核心服務層:工作區、文件、診斷的模型,以及語言服務、除錯、工作執行、遠端、AI 的介面(完全不 import Qt) | | `git_client/` | 6 | 777 | Git 操作(GitPython + git CLI 兩條路) | | `code_scan/` | 4 | 368 | ruff 執行與 watchdog 檔案監看 | | `plugins/` | 1 | 337 | 插件註冊表與外部插件載入器 | | 頂層 | 2 | 131 | `__main__.py`、`start_editor.py`(另有 `__init__.py` 匯出公開 API) | -(行數含各層 `__init__.py`,合計 38,177 行。) +(行數含各層 `__init__.py`,合計 39,286 行。) --- @@ -81,7 +81,7 @@ JEditor 是以 PySide6(Qt for Python)寫成的程式碼編輯器,功能涵 **設計慣例**:幾乎每個功能都拆成「純邏輯 + Qt 整合層」兩塊。 例如折疊 = `utils/code_folding/fold_regions.py`(算區塊)+ `pyside_ui/code/folding/folding_manager.py`(藏行、重畫); 書籤 = `utils/bookmark/bookmark_navigation.py` + `pyside_ui/code/bookmark/bookmark_manager.py`。 -這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 114 個測試檔的原因。 +這讓大部分邏輯可以不開視窗就測試,也是 `test/` 能有 115 個測試檔的原因。 --- @@ -148,7 +148,7 @@ start_editor(debug_mode) je_editor/start_editor.py --- -### 5.2 `utils/` — 純邏輯層(61 模組 / 9,131 行) +### 5.2 `utils/` — 純邏輯層(62 模組 / 9,257 行) #### 文字與行操作 @@ -182,6 +182,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `file_scan/file_indexer.py` | 106 | 專案檔案索引(深度上限 24、檔案上限 20000),供快速開啟 | | `file_scan/ignore_rules.py` | 73 | 掃描時共用的忽略規則(`.git`、`__pycache__`、二進位副檔名、null byte 偵測) | | `file_scan/todo_scanner.py` | 138 | 掃描 TODO / FIXME 註解,支援多種註解符號 | +| `debugger/attach_address.py` | 32 | 解析「接上哪個程式」的位址:`host:port` 或只有連接埠 | | `file_scan/workspace_scan.py` | 135 | 對工作區的每個根目錄各跑一次索引與 TODO 掃描;有好幾個根目錄時顯示路徑前面加上根目錄的顯示名稱,並帶著開檔用的完整路徑 | | `venv_check/check_venv.py` | 75 | 找出 venv 的 Python 執行檔路徑 | @@ -221,16 +222,16 @@ start_editor(debug_mode) je_editor/start_editor.py | `minimap/minimap_layout.py` | 112 | 縮圖座標換算:取樣間隔、行↔像素、長條寬度、可視範圍方框 | | `shortcuts/shortcut_registry.py` | 329 | 快捷鍵正規化、`ShortcutRegistry` 衝突偵測、預設表 `WINDOW_SHORTCUTS` / `EDITOR_SHORTCUTS`、使用者覆寫清理 | | `status/status_text.py` | 71 | 狀態列文字:語言名稱、編碼、行尾、游標位置 | -| `theme/theme_colors.py` | 137 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | +| `theme/theme_colors.py` | 139 | 深 / 淺色調色盤,換主題時保留使用者自訂的顏色 | #### 多語系 | 模組 | 行 | 功用 | | --- | ---: | --- | -| `multi_language/english.py` | 524 | 英文字典(其他語言以此為鍵值基準) | -| `multi_language/traditional_chinese.py` | 514 | 繁體中文字典 | -| `multi_language/simplified_chinese.py` | 514 | 簡體中文字典 | -| `multi_language/japanese.py` | 518 | 日文字典 | +| `multi_language/english.py` | 547 | 英文字典(其他語言以此為鍵值基準) | +| `multi_language/traditional_chinese.py` | 537 | 繁體中文字典 | +| `multi_language/simplified_chinese.py` | 537 | 簡體中文字典 | +| `multi_language/japanese.py` | 541 | 日文字典 | | `multi_language/multi_language_wrapper.py` | 150 | `LanguageWrapper` 單例:註冊語言、切換、啟動語言決策 | | `multi_language/locale_match.py` | 116 | 系統語系 → 編輯器語言(含中文繁簡判定) | | `multi_language/retranslate_text.py` | 154 | 反查「這段文字是哪個鍵翻出來的」,用於換語言時就地換字 | @@ -280,7 +281,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `plaintext_code_edit/code_edit_plaintext.py` | **3,287** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | +| `plaintext_code_edit/code_edit_plaintext.py` | **3,388** | `CodeEditor(QPlainTextEdit)`:整個編輯器的中樞。行號區 `LineNumber`、gutter(中斷點 / 書籤 / 折疊 / diff 標記)、自繪縮排參考線與 blame、jedi 背景補全 `_JediCompleteWorker`、括號配對、出現次數高亮、所有文字轉換動作、註解切換、縮放、快捷鍵註冊、LSP 訊號接線、右鍵選單 | | `multi_cursor/multi_cursor_manager.py` | 530 | 額外游標的維護與批次套用(插入 / 刪除 / 移動 / 擴選 / 欄選取 / 下一個相同字) | | `snippets/snippet_manager.py` | 280 | 片段展開、定位點跳轉、複本同步;使用者片段存於 `.jeditor/snippets.json` | | `lsp/lsp_client.py` | 469 | 單一檔案這端的 LSP 連線:didOpen / didChange、completion / hover / rename / formatting / signature / references / codeAction / symbols / definition,回應以 Qt 訊號送出。伺服器以 `root_resolver`(編輯器設定:檔案 → 所屬的工作區根目錄)回答的根目錄啟動,問不到時用檔案所在的資料夾;`start_for(file_path, servers)` 的參數清單是 PyBreeze 釘住的契約 | @@ -294,7 +295,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `lint/lint_manager.py` | 189 | `LintWorker(QThread)` 背景跑 ruff + `LintManager` 以統一模型(`core/diagnostics`)保存這個編輯器的診斷、供行號查詢;`set_diagnostics()` 兩種形式都收,舊形式在這裡補上來源與檔案 URI | | `folding/folding_manager.py` | 190 | 折疊狀態:計算區塊、藏 / 顯示行、重新布局、換檔重算 | | `bookmark/bookmark_manager.py` | 134 | 書籤切換、跳轉、清空(Qt 整合層) | -| `breakpoint/breakpoint_manager.py` | 88 | 中斷點行號追蹤,並轉成 pdb 指令 | +| `breakpoint/breakpoint_manager.py` | 138 | 中斷點行號追蹤(跟著文字移動),每個中斷點可以帶條件;`breakpoints()` 給除錯工作階段用,`pdb_lines()` 給退路的 pdb 主控台用 | | `selection/smart_selection_manager.py` | 87 | 智慧選取的擴大 / 縮回堆疊 | | `minimap/minimap_widget.py` | 185 | 右側縮圖:長條繪製、搜尋命中標記、可視範圍方框、點擊捲動 | | `split_view/split_editor_view.py` | 55 | 同一份 `QTextDocument` 的第二個檢視 | @@ -316,7 +317,7 @@ start_editor(debug_mode) je_editor/start_editor.py | 模組 | 行 | 功用 | | --- | ---: | --- | -| `main_editor.py` | 652 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | +| `main_editor.py` | 656 | `EditorMain(QMainWindow)`:分頁容器、輸出重導計時器、狀態列更新、設定定期儲存、工作階段還原 / 儲存、關閉時收尾;`EDITOR_EXTEND_TAB` 掛載點 | | `editor/editor_widget.py` | 613 | `EditorWidget`:一個編輯分頁=左側專案樹 + 上方 `CodeEditor` + 下方輸出分頁(執行結果 / 格式檢查 / 除錯 / 終端機 / 變數檢視 / Git),含拖放開檔、外部變更偵測、縮圖與分割檢視切換。所有開檔都經 `open_an_file()`:讀不了時 `report_open_failure()` 告訴使用者並撤掉「已開啟」紀錄;外部變更後重新載入用檔案自己的編碼 | | `editor/editor_widget_dock.py` | 85 | `FullEditorWidget`:可停駐的單檔編輯器;關閉時只在有修改時,以檔案原本的編碼與行尾存回 | | `editor/process_input.py` | 104 | 對子程序(program / shell / debugger)送入標準輸入的視窗 | @@ -335,14 +336,14 @@ start_editor(debug_mode) je_editor/start_editor.py | `run_menu/build_run_menu.py` | 155 | 執行選單骨架、停止程式、清除輸出、說明 | | `run_menu/under_run_menu/build_program_menu.py` | 108 | 執行使用者程式(解析插件 run_config) | | `run_menu/under_run_menu/build_shell_menu.py` | 98 | 執行 shell 指令 | -| `run_menu/under_run_menu/build_debug_menu.py` | 134 | 啟動 pdb、送出中斷點、除錯輸入視窗 | +| `run_menu/under_run_menu/build_debug_menu.py` | 163 | 啟動 pdb、送出中斷點、除錯輸入視窗 | | `run_menu/under_run_menu/utils.py` | 41 | 「請先關掉正在執行的程式」訊息框 | | `text_menu/build_text_menu.py` | 421 | 文字選單:統計、去行尾空白、縮排轉換、自動換行、縮排大小、字型;大量動作轉呼叫 `CodeEditor` 的方法 | | `check_style_menu/build_check_style_menu.py` | 127 | yapf 格式化、JSON 排版、PEP8 檢查、存檔時自動格式化開關 | | `tab_menu/build_tab_menu.py` | 187 | 分頁選單:新增編輯 / 瀏覽器 / 終端機分頁、片段編輯器、縮圖與分割檢視切換 | | `tab_menu/build_tab_git_menu.py` | 200 | Git 分頁:HEAD diff、staged diff、Git 用戶端、提交圖、diff 比對 | | `tab_menu/build_tab_tools_menu.py` | 155 | 工具分頁:IPython、變數檢視器、FrontEngine、AI 對話、TODO 面板、大綱面板 | -| `dock_menu/build_dock_menu.py` | 242 | 各種 dock 視窗的建立(含 FrontEngine 元件) | +| `dock_menu/build_dock_menu.py` | 253 | 各種 dock 視窗的建立(含 FrontEngine 元件) | | `style_menu/build_style_menu.py` | 179 | qt-material 樣式切換、縮排參考線 / 尾端空白開關、開啟快捷鍵設定 | | `language_menu/build_language_server.py` | 96 | 介面語言切換(含插件註冊的語言) | | `python_env_menu/build_venv_menu.py` | 243 | 建立 venv、pip 安裝 / 升級、選擇直譯器 | @@ -379,6 +380,14 @@ start_editor(debug_mode) je_editor/start_editor.py | `user_color_setting_file.py` | 96 | 顏色設定讀寫、RGB → `QColor` 換算、依樣式套用深 / 淺色組 | | `setting_utils.py` | 40 | 寫入前先備份(`.bak`)的 JSON 寫檔工具 | +#### 除錯面板(`debug_panel/`) + +| 模組 | 行 | 功用 | +| --- | ---: | --- | +| `debug_panel/debug_controller.py` | 241 | `DebugController(QObject)`:視窗這一端的除錯控制。向 `services.debug_adapters` 要一個工作階段、訂閱它的事件,把事件與查詢的回覆變成 Qt 訊號(工作階段在自己的執行緒上回覆,訊號跨執行緒時由 Qt 排進畫面執行緒);給工具看的 `telemetry` 輸出不往外送 | +| `debug_panel/debug_panel_widget.py` | 325 | `DebugPanelWidget`:控制按鈕與狀態、執行緒、呼叫堆疊、變數樹(展開時才查子項目)、輸出與求值。只認 `DebugController`;清空堆疊清單時擋住訊號,Qt 途中移動的「目前項目」才不會被當成使用者選的 | +| `debug_panel/debug_actions.py` | 219 | 選單、工具列與快捷鍵呼叫的動作:`start_debugging()`、`attach_to_process()`、`edit_breakpoint_condition()`、`show_execution_line()` / `clear_execution_lines()`、`show_debug_panel()`;`controller_of()` 在視窗沒有控制器或轉接器沒登記時回傳 `None`,呼叫端據此退回 pdb 主控台 | + #### 工作區(`workspace/`) | 模組 | 行 | 功用 | @@ -453,11 +462,11 @@ start_editor(debug_mode) je_editor/start_editor.py | `ai/ai_settings.py` | 166 | `ProviderSettings` 與 `AISettings`:依供應者分組的設定(金鑰、位址、模型、系統提示詞)與目前選用的供應者;舊格式的 `AI_model` 會被讀成 `openai` 那一組 | | `ai/chat_session.py` | 94 | `ChatSession`:保管一段對話、組出下一個請求;失敗或被取消的那一句不留在對話裡 | -遠端目前只有介面。除錯與工作執行在 `adapters/` 已經有實作(DAP 與本機子程序),但視窗還沒有改用:既有的 pdb 除錯與 `BaseProcessManager` 仍然走原本的路徑。AI 的實作在 +遠端目前只有介面。除錯已經改走 `adapters/debug/` 的 DAP 工作階段(視窗的 `DebugController` 與除錯面板),pdb 主控台只在 debugpy 沒有登記時當退路;執行程式仍然走 `BaseProcessManager`,還沒有改用 `TaskRunner`。AI 的實作在 `adapters/ai/`,對話面板已經改走 `AIProvider`;語法分析的實作在 `adapters/syntax/`,編輯器的高亮已經改走 `SyntaxEngine`。 -### 5.12 `adapters/` — 核心介面的實作(12 模組 / 2,340 行) +### 5.12 `adapters/` — 核心介面的實作(12 模組 / 2,343 行) `core/` 只有介面;真正去連某一家服務的程式碼放在這裡。跟 `core/` 一樣不匯入 Qt 與 `pyside_ui/` (`test_core_architecture.py` 把它列進 UI 層以下的套件),第三方 SDK 都在用到的時候才匯入。 @@ -470,7 +479,7 @@ start_editor(debug_mode) je_editor/start_editor.py | `ai/anthropic_provider.py` | 201 | `AnthropicProvider`:官方 `anthropic` SDK 的串流請求;可中途取消、回報 token 用量、把 SDK 的錯誤類別轉成給使用者看的說明;會拒絕請求的模型啟用伺服器端 fallback | | `ai/builtin_providers.py` | 50 | `register_builtin_ai_providers()`:每個供應者拿到「取得自己那組設定」的函式,所以改設定不必重新登記 | | `ai/settings_file.py` | 80 | `.jeditor/ai_config.json` 的讀寫;日誌只記路徑、從不記內容(裡面有 API 金鑰) | -| `process/local_task_runner.py` | 246 | `LocalTaskRunner` / `LocalTask`:`TaskRunner` 的本機實作。以引數清單啟動子程序(從不經過 shell),標準輸出與標準錯誤各一條執行緒讀取,另一條等程序結束並通知結束代碼;`wait()` 等到結束代碼通知出去為止 | +| `process/local_task_runner.py` | 249 | `LocalTaskRunner` / `LocalTask`:`TaskRunner` 的本機實作。以引數清單啟動子程序(從不經過 shell),標準輸出與標準錯誤各一條執行緒讀取,另一條等程序結束並通知結束代碼;`wait()` 等到結束代碼通知出去為止 | | `debug/dap_session.py` | 439 | `DapSession`:以 DAP 實作的 `DebugSession`。啟動轉接器後照協定的順序打招呼(`initialize` → `launch` / `attach` → 等 `initialized` 事件 → 送中斷點 → `configurationDone`),把回應與事件轉成核心層的資料物件;不知道被除錯的是哪種語言,轉接器的指令、啟動引數與接上既有程式時的通道都由外面給 | | `debug/socket_channel.py` | 158 | `SocketChannel`:把一條 TCP 連線包成跟位元組模式的工作一樣的形狀。接上已經帶著轉接器在連接埠等待的程式時用它,之後經 SSH 轉送的遠端除錯也是 | | `debug/debugpy_adapter.py` | 132 | Python 的轉接器 debugpy:轉接器的指令(編輯器自己的直譯器)、`launch` / `attach` 引數、接上時直接連到連接埠;`register_builtin_debug_adapters()` 在 debugpy 有安裝時才登記 | @@ -566,7 +575,7 @@ Qt 的高亮器重畫時會送出 `textChanged`(不是 `contentsChange`), ## 7. 測試與 CI -- `test/` 114 個測試檔、約 20,300 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 +- `test/` 115 個測試檔、約 21,000 行,與模組大致一對一(`test_fold_regions.py`、`test_shortcut_registry.py`…)。 - `core/` 的測試是 `test_core_*.py` 七個檔。其中 `test_core_architecture.py` 守分層:以 `ast` 走訪 `core/` 的 匯入關係(函式內的匯入也算)、列出 UI 層以下允許向上匯入的模組,並在子行程裡擋掉 Qt 的匯入後實際建立 `EditorServices`。`test_public_api_contract.py` 釘住 `je_editor.__all__` 的既有名稱、PyBreeze 以模組路徑匯入的 @@ -619,8 +628,8 @@ Qt 的高亮器重畫時會送出 `textChanged`(不是 `contentsChange`), 讓「純邏輯層」的界線稍微模糊。 6. **命名遺留**:`utils/logging/loggin_instance.py`、`browser/browser_serach_lineedit.py` 兩處拼字錯誤已成公開路徑, 要改需同時處理下游 import。 -7. **`core/` 接上了診斷、AI、工作區與語法高亮**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、除錯、工作執行與遠端還沒有接上,視窗層仍然 - 各自持有這些狀態(除錯與工作執行的實作已經在 `adapters/`,差的是視窗這一端)。語法引擎目前只用來上色:編輯器直接向引擎要 session,沒有經過 `DocumentStore`,大綱、折疊與智慧選取也還在用各自的分析(`utils/symbols`、`utils/code_folding`、`utils/selection`)。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 +7. **`core/` 接上了診斷、AI、工作區、語法高亮與除錯**:編輯器的診斷與問題面板已經改用統一模型,ruff 解析器(`utils/lint`)仍然輸出舊形式、在 `LintManager` 與面板的入口以 `unify()` 轉換。文件、語言服務、工作執行與遠端還沒有接上,視窗層仍然 + 各自持有這些狀態(執行程式用的仍是 `BaseProcessManager`,`LocalTaskRunner` 目前只用來啟動除錯轉接器)。語法引擎目前只用來上色:編輯器直接向引擎要 session,沒有經過 `DocumentStore`,大綱、折疊與智慧選取也還在用各自的分析(`utils/symbols`、`utils/code_folding`、`utils/selection`)。工作區只管「有哪些根目錄」:執行程式、測試面板、終端機、Git 工具列與直譯器仍然只認 主要的根目錄(工作目錄)。 8. **`import je_editor.core` 仍會載入 Qt**:匯入任何子套件都會先執行 `je_editor/__init__.py`,而它匯入整個 Qt 應用程式。服務本身不需要 Qt(測試在擋掉 Qt 的行程裡驗證過),但要讓「只用核心」的宿主程式完全不載入 Qt, diff --git a/docs/roadmap/2026-editor-next.md b/docs/roadmap/2026-editor-next.md index 414938f..2b82bb5 100644 --- a/docs/roadmap/2026-editor-next.md +++ b/docs/roadmap/2026-editor-next.md @@ -16,13 +16,13 @@ | M2 — Tree-sitter half | Implemented: `je_editor/adapters/syntax/` colours Python, JavaScript and JSON; folding, outline and selection still use their own analysers | U-20261008-08, `PROGRESS.md` | | M3 — Workspace + multi-root | Implemented; per-root environments and Git are left over | U-20261008-07, `PROGRESS.md` | | M5 — AI provider abstraction + Anthropic | Implemented: `je_editor/adapters/ai/` | U-20261008-05 | -| M4 — Debugger migration to DAP | Service implemented and tested against debugpy: `je_editor/adapters/debug/`; the debugger UI still drives pdb | U-20261008-11, `PROGRESS.md` | +| M4 — Debugger migration to DAP | Implemented: `je_editor/adapters/debug/` and the Debugger panel; the pdb console remains as the fallback without debugpy | U-20261008-11, U-20261008-12, `PROGRESS.md` | | M1, M6, M7, M8 | Not started | `PROGRESS.md` | M0 defines the service layer and proves it runs without Qt. What it left to later milestones: - the editor window consumes the services one area at a time: diagnostics, the AI chat panel, - the workspace and syntax highlighting do so far; + the workspace, syntax highlighting and debugging do so far; - debugging, task execution and remote sessions are interfaces with no implementation yet (M4 and M6 supply them), and the request-and-reply calls of a language service (completion, hover and the rest) take their shape with Tree-sitter in M2; diff --git a/docs/source/docs/Eng/code_execution.rst b/docs/source/docs/Eng/code_execution.rst index 9649be1..e3fae7f 100644 --- a/docs/source/docs/Eng/code_execution.rst +++ b/docs/source/docs/Eng/code_execution.rst @@ -24,15 +24,36 @@ You can also manually select a Python interpreter from the **Python Env** menu. Debugging ---------- -Press **F9** to launch the current Python file in debug mode. The debugger is -``python -m pdb``, driven from the editor rather than from a separate terminal. +Press **F9** to debug the current Python file. The program runs under ``debugpy`` through the +Debug Adapter Protocol, and the **Debug Panel** opens beside the editor. The program may use +another interpreter than the editor's (the one chosen in **Python Env**); debugpy does not have +to be installed in that environment. **Breakpoints** ``Ctrl+F9`` toggles a breakpoint on the current line, and a red dot appears in the gutter. Breakpoints are anchored to the text, so they follow their code when lines are -inserted or removed above them. Every breakpoint in the file is sent to pdb when the -debug run starts, and toggling one during a run takes effect immediately. +inserted or removed above them. The breakpoints of every open file are sent when the debug run +starts, and toggling one during a run takes effect immediately. + +**Run > Debug > Breakpoint Condition...** gives the breakpoint on the current line a condition, +an expression such as ``count > 10``: the program stops there only when it is true. Leave it +empty to stop every time. A line without a breakpoint gets one. + +**The Debug Panel** + +- **Continue**, **Pause**, **Step Over**, **Step Into**, **Step Out** and **Stop**, with the + current state beside them (``Running``, ``Paused: breakpoint``, ``Ended``). +- **Thread** lists the program's threads and **Call Stack** the frames of the chosen one, + innermost first. Choosing a frame opens its file and marks the line it is on. +- The variables of the chosen frame, grouped as the debugger groups them (locals, globals). + A list, dictionary or object opens to show what it holds, fetched when you open it. +- The program's output, and below it a box to evaluate an expression in the chosen frame. +- When the program stops on an uncaught exception, its type, message and traceback are shown + in the output. + +The line where the program has stopped is highlighted in the editor +(``debug_execution_line_color``). The panel can also be opened from **Dock > Debug Panel**. **Stepping** @@ -51,8 +72,20 @@ debug run starts, and toggling one during a run takes effect immediately. * - ``Shift+F11`` - Step out of the current function -The same commands are on the **Run > Debug** menu, and anything pdb understands can be -typed directly into the debugger input. +**Attaching to a running program** + +Start the program so that it waits for a debugger:: + + python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py + +then choose **Run > Debug > Attach to Process...** and enter ``127.0.0.1:5678``. A port alone +means this machine. + +**Without debugpy** + +When debugpy is not installed the editor falls back to its earlier debugger, ``python -m pdb`` +driven through the **Debugger** output tab: the shortcuts above send the matching pdb commands, +and anything pdb understands can be typed into **Run > Debug > Show Debugger Input**. Variable inspection during execution is covered by the Variable Inspector below. diff --git a/docs/source/docs/Eng/configuration.rst b/docs/source/docs/Eng/configuration.rst index cf918c9..29bea44 100644 --- a/docs/source/docs/Eng/configuration.rst +++ b/docs/source/docs/Eng/configuration.rst @@ -79,6 +79,8 @@ Controls the color scheme for the editor and output: - Syntax highlighting: keywords, strings, comments, numbers * - ``syntax_function_color`` / ``syntax_builtin_color`` / ``syntax_self_color`` - Syntax highlighting: function names, built-ins and type names, ``self`` / ``this`` + * - ``debug_execution_line_color`` + - The line the program being debugged has stopped on * - ``diff_added_marker_color`` / ``diff_modified_marker_color`` / ``diff_removed_marker_color`` - Git change markers in the gutter diff --git a/docs/source/docs/Zh/code_execution.rst b/docs/source/docs/Zh/code_execution.rst index b7477cf..ae01410 100644 --- a/docs/source/docs/Zh/code_execution.rst +++ b/docs/source/docs/Zh/code_execution.rst @@ -24,15 +24,33 @@ JEditor 會自動偵測專案根目錄中的 ``venv`` 資料夾,並使用虛 除錯模式 --------- -按下 **F9** 以除錯模式啟動目前的 Python 檔案。除錯器就是 ``python -m pdb``,由編輯器 -驅動,不必另外開終端機。 +按下 **F9** 為目前的 Python 檔案除錯。程式透過 Debug Adapter Protocol 在 ``debugpy`` 底下執行, +**除錯面板** 會在編輯器旁邊開啟。程式可以用跟編輯器不同的直譯器( **Python Env** 選的那一個), +那個環境不必安裝 debugpy。 **中斷點** ``Ctrl+F9`` 可在目前行切換中斷點,行號區會出現一個紅點。中斷點錨定在文字上,因此在其 -上方插入或刪除行時會跟著程式碼移動。開始除錯時,檔案中的所有中斷點都會送給 pdb;除錯 +上方插入或刪除行時會跟著程式碼移動。開始除錯時,每個開著的檔案的中斷點都會送出;除錯 進行中切換中斷點也會立即生效。 +**Run > Debug > 中斷點條件...** 為目前行的中斷點加上條件,也就是像 ``count > 10`` 這樣的運算式: +只有它成立時程式才會停在那裡。留空表示一律停。那一行還沒有中斷點的話會加上。 + +**除錯面板** + +- **繼續** 、 **暫停** 、 **逐步越過** 、 **逐步進入** 、 **逐步跳出** 與 **停止** ,旁邊顯示目前的狀態 + ( ``執行中`` 、 ``已暫停:breakpoint`` 、 ``已結束`` )。 +- **執行緒** 列出程式的執行緒, **呼叫堆疊** 列出所選執行緒的每一層,最裡面的在最上面。選一層 + 就會開啟它的檔案並標出它所在的那一行。 +- 所選那一層的變數,依除錯器的分組顯示(區域變數、全域變數)。清單、字典或物件可以展開看 + 裡面的內容,展開時才去取。 +- 程式的輸出,下面有一個輸入框,可以在所選那一層裡求運算式的值。 +- 程式停在未捕捉的例外時,輸出裡會顯示它的型別、訊息與追蹤。 + +程式停下來的那一行會在編輯器裡以底色標出( ``debug_execution_line_color`` )。面板也可以從 +**Dock > 除錯面板** 開啟。 + **逐步執行** .. list-table:: @@ -50,7 +68,19 @@ JEditor 會自動偵測專案根目錄中的 ``venv`` 資料夾,並使用虛 * - ``Shift+F11`` - 跳出目前的函式 -同樣的指令也在 **Run > Debug** 選單中,而 pdb 認得的任何指令都可以直接輸入除錯器輸入框。 +**接上執行中的程式** + +先讓程式啟動後等待除錯器:: + + python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py + +再選 **Run > Debug > 接上執行中的程式...** 並輸入 ``127.0.0.1:5678`` 。只輸入連接埠表示這台機器。 + +**沒有 debugpy 時** + +沒有安裝 debugpy 時,編輯器會退回原本的除錯器:以 **Debugger** 輸出分頁驅動的 +``python -m pdb`` 。上面的快捷鍵會送出對應的 pdb 指令,pdb 認得的任何指令也都可以從 +**Run > Debug > Show Debugger Input** 輸入。 執行期間的變數檢視請見下方的「變數檢視器」。 diff --git a/docs/source/docs/Zh/configuration.rst b/docs/source/docs/Zh/configuration.rst index 65efdd0..ec9782b 100644 --- a/docs/source/docs/Zh/configuration.rst +++ b/docs/source/docs/Zh/configuration.rst @@ -79,6 +79,8 @@ user_color_setting.json - 語法高亮:關鍵字、字串、註解、數字 * - ``syntax_function_color`` / ``syntax_builtin_color`` / ``syntax_self_color`` - 語法高亮:函式名稱、內建名稱與型別名稱、 ``self`` / ``this`` + * - ``debug_execution_line_color`` + - 除錯時程式停下來的那一行 * - ``diff_added_marker_color`` / ``diff_modified_marker_color`` / ``diff_removed_marker_color`` - 行號區的 Git 變更標記 diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 9a2842a..650dc8d 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -289,3 +289,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **文件**:`docs/source/docs/{Eng,Zh}/core_services.rst` 新增「Debugging」一節(範例實際啟動 debugpy、停在條件中斷點、查堆疊),原本合在一起的那一節改成「Tasks, Remote Sessions and AI Providers」;`getting_started.rst` 與三份 README 的相依套件表與目錄說明;`architecture.md` §2、§5;`architecture_explore.md`;藍圖的實作狀態。 - **檔案**:`je_editor/core/debug/debug_session.py`、`je_editor/core/process/task_service.py`、`je_editor/core/__init__.py`、`je_editor/utils/dap/`(新)、`je_editor/adapters/process/`(新)、`je_editor/adapters/debug/`(新)、`je_editor/adapters/default_services.py`、`pyproject.toml`、`dev.toml`、`requirements.txt`、`dev_requirements.txt`、上述測試與文件、`PROGRESS.md`。 - **待辦**:`PROGRESS.md` #12(畫面那一半)。 + +## U-20261008-12 · 2026-10-08 · 藍圖 M4(下):除錯面板改走 DAP;條件中斷點;接上執行中的程式 · #done #decision #roadmap #debug + +- **做了什麼**:完成 `PROGRESS.md` #12(藍圖 M4)。除錯畫面改走 U-20261008-11 的 DAP 服務:按 F9 之後程式在 debugpy 底下執行,旁邊開出除錯面板。 + - `main_ui/debug_panel/debug_controller.py`(新):`DebugController`,一個視窗一個。向 `services.debug_adapters` 要工作階段,把它在自己執行緒上送來的事件與回覆變成 Qt 訊號。 + - `main_ui/debug_panel/debug_panel_widget.py`(新):控制按鈕與狀態、執行緒、呼叫堆疊、變數樹(清單、字典、物件展開時才去取)、程式輸出、在所選那一層求值、停在例外時顯示型別 / 訊息 / 追蹤。 + - `main_ui/debug_panel/debug_actions.py`(新):開始除錯、接上執行中的程式(Run > Debug > Attach to Process...)、中斷點條件(Run > Debug > Breakpoint Condition...)、在編輯器標出停下來的那一行。 + - `BreakpointManager` 的每個中斷點可以帶條件,條件跟著那一行移動。`CodeEditor` 多了 `set_execution_line()`(新的主題顏色 `debug_execution_line_color`);逐步執行的快捷鍵與切換中斷點在除錯中會交給控制器。 + - 面板登記成 Dock(Dock > Debug Panel),開始除錯時自動開啟。英文原本叫 Debugger,跟既有的「Debugger」選單標題同字、中文卻不同,換語言時分不出是哪一個(既有的 `test_retranslate.py` 抓到),所以改名。 +- **決定**: + - **pdb 主控台留作退路**。`controller_of()` 在視窗沒有控制器、或 debugpy 轉接器沒有登記時回傳 `None`,「執行除錯器」就照原本的方式啟動 `python -m pdb`。沒有直接拿掉,是因為還沒確認每一種發佈方式都帶得到 debugpy(打包成執行檔時 `sys.executable` 不是直譯器,轉接器啟動不了)。拿掉的條件記在 `PROGRESS.md` #25。 + - **編輯器不直接碰工作階段**。面板與編輯器只認 `DebugController`;選單、工具列、快捷鍵都經過 `debug_actions.py`。 + - **被除錯的程式用 Python Env 選的直譯器**(`python_compiler`),工作目錄是工作區的主要根目錄;轉接器本身用編輯器的直譯器。 +- **過程中修掉的兩個問題**(都是這次新寫的程式碼): + - `CodeEditor.__init__` 在設定執行行的欄位之前就會畫一次目前行,欄位要提早設。這是在寫測試之前、先用真正的視窗(offscreen)把整個流程跑一次時看到的;視窗啟動後標準錯誤被導進輸出面板,第一次只看到結束代碼 1,改成把例外寫到真正的標準錯誤才看到原因。 + - 清空呼叫堆疊清單時,Qt 會先把「目前項目」移到下一個再刪,每移一次送一次訊號;面板因此在程式執行中去查那一層的變數,還請編輯器標出它的行。清空時改成先擋住訊號。 +- **翻譯**:四份字典各加 24 個鍵(面板的按鈕、欄位、狀態,兩個選單項目與它們的對話框)。 +- **測試**:`test_debug_panel.py`(新,86 個):位址解析、控制器(對一個照劇本回話的假工作階段)、面板、中斷點條件、執行行、從編輯器逐步、各個動作、「執行除錯器」選單的兩條路,另有一個對真正的 debugpy 從面板除錯到結束的測試。 +- **上一個 commit 的 CI**:`56cfe34`(U-20261008-11)五個 Python 版本與 SonarCloud 通過;Codacy 4 筆,都是「使用了 subprocess」的稽核提示(Bandit B404 / B603、Semgrep dangerous-subprocess-use-audit),落在 `local_task_runner.py` 與整合測試啟動等待中程式的那一行。查證後照本專案既有的寫法標註並寫明原因:工作執行器的用途就是啟動程序,指令來自建立時驗證過的 `TaskSpec`、一律是引數清單、`shell=False`。 +- **結果**:整套測試 2859 passed(修改前 2773);`ruff check` 乾淨;`start_qt_ui.py`、`extend_test.py`(offscreen)都以 0 結束;Sphinx 沒有新警告,`code_execution` 中英文結構相同(各 24 個行內程式碼、11 個標題、2 個表格、37 處粗體)。PyBreeze 的 `test_jeditor_contract.py` 46 passed、`test_language_parity.py` 26 passed。另外以真正的 `EditorMain`(offscreen)跑過一次完整流程並截圖看過:設條件中斷點 `first == 1` → 執行除錯器 → 面板顯示 `Paused: breakpoint`、執行緒、三層堆疊、區域變數 `first=1`、`second=10`,編輯器標出第 2 行、分頁沒有被標成未儲存 → 求值 `first + second` 得 11 → 逐步越過到第 3 行 → 繼續到結束,輸出裡有程式印的那一行、執行行的標記清掉、關閉視窗時服務有關閉。 +- **文件**:`docs/source/docs/{Eng,Zh}/code_execution.rst` 的除錯一節重寫(面板、條件中斷點、接上執行中的程式、沒有 debugpy 時);`configuration.rst` 的顏色鍵;三份 README;`architecture.md` §4 新增除錯流程、§5;`architecture_explore.md`(§5.7 新的「除錯面板」小節等);藍圖的實作狀態。 +- **檔案**:`je_editor/pyside_ui/main_ui/debug_panel/`(新,4 個檔)、`je_editor/utils/debugger/attach_address.py`(新)、`je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py`、`code_edit_plaintext.py`、`main_ui/main_editor.py`、`menu/run_menu/under_run_menu/build_debug_menu.py`、`menu/dock_menu/build_dock_menu.py`、`utils/theme/theme_colors.py`、四份語言字典、上述測試與文件、`PROGRESS.md`(刪 #12、加 #25)。 +- **待辦**:`PROGRESS.md` #25。 diff --git a/docs/updates/README.md b/docs/updates/README.md index fa66504..95223f9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-12 | 2026-10-08 | 藍圖 M4(下):除錯面板改走 DAP;條件中斷點;接上執行中的程式 | #done #decision #roadmap #debug | [2026-10](2026-10.md) | | U-20261008-11 | 2026-10-08 | 藍圖 M4(上):DAP 除錯服務、本機工作執行器、debugpy 轉接器 | #decision #roadmap #debug | [2026-10](2026-10.md) | | U-20261008-10 | 2026-10-08 | M2 的 CI 結果:SonarCloud 兩筆 S5863 改掉;Python 3.10 一次偶發失敗 | #ci #roadmap | [2026-10](2026-10.md) | | U-20261008-09 | 2026-10-08 | 恢復 PyBreeze 釘住的 LspClient.start_for 參數清單;把 PyBreeze 的契約測試列為檢查 | #fix #decision #contract | [2026-10](2026-10.md) | @@ -102,5 +103,5 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 19 | +| [2026-10.md](2026-10.md) | 2026-10 | 20 | | [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/je_editor/adapters/process/local_task_runner.py b/je_editor/adapters/process/local_task_runner.py index f443130..cbce7b8 100644 --- a/je_editor/adapters/process/local_task_runner.py +++ b/je_editor/adapters/process/local_task_runner.py @@ -14,7 +14,7 @@ from __future__ import annotations import os -import subprocess +import subprocess # nosec B404 - 執行工作就是它的用途;一律以引數清單啟動,shell=False import threading from typing import IO @@ -98,7 +98,10 @@ def start(self) -> bool: if self._state is not TaskState.PENDING: return False try: - self._process = subprocess.Popen( + # 指令來自 TaskSpec:建立時就確認過是非空字串組成的清單,而且從不經過 shell + # The command comes from a TaskSpec, checked on creation to be a list of + # non-empty strings, and never goes through a shell + self._process = subprocess.Popen( # nosemgrep # noqa: S603 # nosec B603 list(self._spec.command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self._spec.working_directory or None, env={**os.environ, **self._spec.environment}, shell=False, diff --git a/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py index f80542e..f4cbc90 100644 --- a/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py +++ b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py @@ -26,6 +26,9 @@ def __init__(self, code_edit) -> None: """ self._code_edit = code_edit self._cursors: list[QTextCursor] = [] + # 跟 _cursors 一一對應:每個中斷點的條件,空字串表示一律停 + # In step with _cursors: each breakpoint's condition, empty to stop every time + self._conditions: list[str] = [] def lines(self) -> list[int]: """ @@ -57,15 +60,61 @@ def toggle(self, line: int) -> bool: existing = next( (cursor for cursor in self._cursors if cursor.blockNumber() == line), None) if existing is not None: - self._cursors.remove(existing) + index = self._cursors.index(existing) + del self._cursors[index] + del self._conditions[index] return False block = self._code_edit.document().findBlockByNumber(line) if not block.isValid(): return False cursor = QTextCursor(block) self._cursors.append(cursor) + self._conditions.append("") return True + def condition(self, line: int) -> str: + """ + 取得某一行中斷點的條件 + The condition of the breakpoint on a line. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 條件;那一行沒有中斷點或沒有條件時為空字串 + the condition, empty when the line has no breakpoint or no condition + """ + return dict(self.breakpoints()).get(line, "") + + def set_condition(self, line: int, condition: str) -> bool: + """ + 設定某一行中斷點的條件;那一行沒有中斷點時先加上 + Set the condition of the breakpoint on a line, adding the breakpoint first when there is none. + + :param line: 以 0 起算的行號 / the 0-based line number + :param condition: 成立才停下來的條件,空字串表示一律停 + the condition that has to hold to stop, empty to stop every time + :return: 那一行現在有中斷點時為 ``True`` / ``True`` when the line now has a breakpoint + """ + if not self.has_breakpoint(line) and not self.toggle(line): + return False + for index, cursor in enumerate(self._cursors): + if cursor.blockNumber() == line: + self._conditions[index] = condition + return True + + def breakpoints(self) -> list[tuple[int, str]]: + """ + 取得每個中斷點的行號與條件 + Every breakpoint's line and condition. + + 編輯可能讓兩個中斷點落在同一行,這時只留先設的那一個。 + Editing can bring two breakpoints onto one line, and then the one set first is kept. + + :return: ``(以 0 起算的行號, 條件)``,依行號排序 / ``(0-based line, condition)``, sorted by line + """ + found: dict[int, str] = {} + for cursor, condition in zip(self._cursors, self._conditions): + found.setdefault(cursor.blockNumber(), condition) + return sorted(found.items()) + def clear(self) -> bool: """ 清除所有中斷點 @@ -76,6 +125,7 @@ def clear(self) -> bool: if not self._cursors: return False self._cursors = [] + self._conditions = [] return True def pdb_lines(self) -> list[int]: diff --git a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py index fceb93c..9be1dee 100644 --- a/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py +++ b/je_editor/pyside_ui/code/plaintext_code_edit/code_edit_plaintext.py @@ -15,6 +15,7 @@ ) from je_editor.core.diagnostics.diagnostic_model import Diagnostic +from je_editor.core.debug.debug_session import Breakpoint, StepKind from je_editor.core.diagnostics.lsp_diagnostics import from_lsp_entries from je_editor.core.uri.resource_uri import to_uri from je_editor.pyside_ui.code.bookmark.bookmark_manager import BookmarkManager @@ -291,6 +292,10 @@ def __init__(self, main_window: EditorWidget | FullEditorWidget) -> None: QtGui.QFontMetricsF(self.font()).horizontalAdvance(" ") ) + # 除錯時程式停在的那一行;要在第一次畫目前行之前就有值 + # The line the program being debugged has stopped on; it has to exist + # before the current line is first painted + self._execution_cursor: QTextCursor | None = None # 語法高亮;依檔名挑選,新分頁先當成 Python # Syntax highlighting, chosen by file name; a new tab counts as Python for now self.highlighter: QtGui.QSyntaxHighlighter | None = None @@ -1463,9 +1468,51 @@ def highlight_current_line(self) -> None: selections.append(selection) selection.format.setBackground(color_of_the_line) selection.format.setProperty(QTextFormat.FullWidthSelection, True) + self._append_execution_selection(selections) self._append_lint_selections(selections) self.setExtraSelections(selections) + def _append_execution_selection(self, selections: list) -> None: + """把「程式停在這一行」的底色加進去 / Add the background of the line the program stopped on.""" + if self._execution_cursor is None: + return + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(actually_color_dict.get("debug_execution_line_color")) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + selection.cursor = QTextCursor(self._execution_cursor) + selection.cursor.clearSelection() + selections.append(selection) + + def set_execution_line(self, line: int | None) -> bool: + """ + 標出(或取消)除錯時程式停在的那一行 + Mark, or unmark, the line the program being debugged has stopped on. + + :param line: 1 起算的行號,``None`` 表示取消 / the 1-based line, or ``None`` to unmark + :return: 有標出來時為 ``True`` / ``True`` when a line is now marked + """ + block = self.document().findBlockByNumber(line - 1) if line is not None else None + if block is None or not block.isValid(): + changed = self._execution_cursor is not None + self._execution_cursor = None + if changed: + self.highlight_current_line() + return False + self._execution_cursor = QTextCursor(block) + self.setTextCursor(QTextCursor(block)) + self.centerCursor() + self.highlight_current_line() + return True + + def execution_line(self) -> int | None: + """ + 目前標著的執行行 + The execution line that is marked. + + :return: 1 起算的行號,沒有標時為 ``None`` / the 1-based line, or ``None`` when none is marked + """ + return None if self._execution_cursor is None else self._execution_cursor.blockNumber() + 1 + def _highlight_matching_bracket(self) -> None: """ 高亮匹配的括號 @@ -1505,6 +1552,7 @@ def _highlight_matching_bracket(self) -> None: selections.append(sel) self._append_occurrence_selections(selections, text, pos) + self._append_execution_selection(selections) self._append_lint_selections(selections) self.setExtraSelections(selections) self._show_lint_message_for_caret() @@ -2436,8 +2484,43 @@ def toggle_breakpoint(self) -> bool: result = self.breakpoint_manager.toggle(line) self.line_number.update() self.send_breakpoint_change(line, result) + self.sync_debug_breakpoints() return result + def debug_breakpoints(self) -> list[Breakpoint]: + """ + 取得這個檔案的中斷點,給除錯工作階段用 + This file's breakpoints, for a debug session. + + :return: 中斷點;還沒有檔名的分頁沒有 / the breakpoints, none for a tab with no file yet + """ + if self.current_file is None: + return [] + uri = to_uri(str(self.current_file)) + return [Breakpoint(uri, line + 1, condition) + for line, condition in self.breakpoint_manager.breakpoints()] + + def _debug_controller(self): + """取得視窗的除錯控制,沒有時為 ``None`` / The window's debugging control, or ``None``.""" + from je_editor.pyside_ui.main_ui.debug_panel.debug_controller import DebugController + + window = getattr(self.main_window, "main_window", None) + controller = getattr(window, "debug_controller", None) + return controller if isinstance(controller, DebugController) else None + + def sync_debug_breakpoints(self) -> bool: + """ + 除錯中途把這個檔案的中斷點重新交給除錯工作階段 + Hand this file's breakpoints to the debug session again while debugging. + + :return: 有送出時為 ``True`` / ``True`` when they were sent + """ + controller = self._debug_controller() + if controller is None or not controller.is_active() or self.current_file is None: + return False + controller.set_breakpoints(to_uri(str(self.current_file)), self.debug_breakpoints()) + return True + def send_breakpoint_change(self, line: int, is_set: bool) -> bool: """ 把一行的中斷點改動送給正在執行的除錯器 @@ -2475,11 +2558,29 @@ def send_debugger_command(self, action: str) -> bool: the action's name :return: 有送出時為 ``True`` / ``True`` when the command was sent """ + if self._send_to_debug_session(action): + return True command = step_command(action) if command is None: return False return self._write_debugger_line(command) + def _send_to_debug_session(self, action: str) -> bool: + """把動作交給除錯工作階段;沒有在除錯時回傳 ``False`` / Hand an action to the debug session, or return ``False``.""" + controller = self._debug_controller() + if controller is None or not controller.is_active(): + return False + steps = {"over": StepKind.OVER, "into": StepKind.INTO, "out": StepKind.OUT} + if action in steps: + controller.step(steps[action]) + elif action == "continue": + controller.resume() + elif action == "quit": + controller.stop() + else: + return False + return True + def send_breakpoints_to_debugger(self) -> int: """ 把目前的中斷點送給正在執行的除錯器 diff --git a/je_editor/pyside_ui/main_ui/debug_panel/__init__.py b/je_editor/pyside_ui/main_ui/debug_panel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_editor/pyside_ui/main_ui/debug_panel/debug_actions.py b/je_editor/pyside_ui/main_ui/debug_panel/debug_actions.py new file mode 100644 index 0000000..c96a690 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/debug_panel/debug_actions.py @@ -0,0 +1,219 @@ +""" +除錯相關的動作:開始除錯、接上程式、中斷點條件、標出執行到哪一行 +The debugging actions: start, attach, breakpoint conditions, and marking where execution is. + +選單、工具列與快捷鍵都呼叫這裡,這裡再去找視窗的 ``DebugController``。視窗沒有 +控制器、或轉接器沒有登記時,每個函式都回傳 ``False``,呼叫端就知道要退回原本的 +pdb 主控台。 +Menus, the toolbar and shortcuts all call in here, and from here the window's +``DebugController`` is found. With no controller on the window, or no adapter +registered, every function returns ``False``, which tells the caller to fall +back to the pdb console. +""" +from __future__ import annotations + +import shiboken6 +from PySide6.QtWidgets import QInputDialog, QWidget + +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugAttachRequest, DebugLaunchRequest, DebugState +) +from je_editor.core.uri.resource_uri import to_uri +from je_editor.pyside_ui.main_ui.debug_panel.debug_controller import DebugController +from je_editor.pyside_ui.main_ui.debug_panel.debug_panel_widget import DebugPanelWidget +from je_editor.pyside_ui.main_ui.workspace.workspace_roots import primary_root_path +from je_editor.utils.debugger.attach_address import parse_address +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +DOCK_NAME = "debug_panel" +_PANEL_ATTRIBUTE = "debug_panel" + + +def controller_of(main_window: object) -> DebugController | None: + """ + 取得視窗的除錯控制 + The debugging control of a window. + + :param main_window: 主視窗 / the main window + :return: 控制器;視窗沒有,或轉接器沒有登記時為 ``None`` + the controller, or ``None`` when the window has none or no adapter is registered + """ + controller = getattr(main_window, "debug_controller", None) + if isinstance(controller, DebugController) and controller.available(): + return controller + return None + + +def _editors(main_window: object) -> list: + """視窗裡每個編輯分頁的編輯器 / The editor of every editor tab in the window.""" + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + + tab_widget = getattr(main_window, "tab_widget", None) + if tab_widget is None: + return [] + tabs = (tab_widget.widget(index) for index in range(tab_widget.count())) + return [tab.code_edit for tab in tabs if isinstance(tab, EditorWidget)] + + +def collect_breakpoints(main_window: object) -> dict[str, list[Breakpoint]]: + """ + 收集每個開著的檔案的中斷點 + Collect the breakpoints of every open file. + + :param main_window: 主視窗 / the main window + :return: 檔案的 URI 對應它的中斷點 / each file's URI and its breakpoints + """ + found: dict[str, list[Breakpoint]] = {} + for code_edit in _editors(main_window): + breakpoints = code_edit.debug_breakpoints() + if breakpoints: + found[breakpoints[0].uri] = breakpoints + return found + + +def start_debugging(main_window: object, file_path: str | None) -> bool: + """ + 以除錯轉接器開始除錯一個檔案 + Start debugging a file through the debug adapter. + + :param main_window: 主視窗 / the main window + :param file_path: 要除錯的檔案 / the file to debug + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + controller = controller_of(main_window) + if controller is None or not file_path or controller.is_active(): + return False + request = DebugLaunchRequest( + str(file_path), working_directory=primary_root_path(main_window), + interpreter=str(getattr(main_window, "python_compiler", None) or "")) + show_debug_panel(main_window) + return controller.launch(request, collect_breakpoints(main_window)) + + +def attach_to_process(main_window: QWidget, address: str | None = None) -> bool: + """ + 接上一個等著除錯器的程式 + Attach to a program that waits for a debugger. + + :param main_window: 主視窗 / the main window + :param address: ``host:port``;沒給時問使用者 / ``host:port``, asked for when omitted + :return: 有連上時為 ``True`` / ``True`` when it connected + """ + controller = controller_of(main_window) + if controller is None or controller.is_active(): + return False + words = language_wrapper.language_word_dict + if address is None: + address, accepted = QInputDialog.getText( + main_window, words.get("debug_menu_attach"), words.get("debug_attach_prompt")) + if not accepted: + return False + parsed = parse_address(address) + if parsed is None: + controller.output.emit("console", words.get("debug_attach_invalid") + "\n") + show_debug_panel(main_window) + return False + show_debug_panel(main_window) + return controller.attach(DebugAttachRequest(parsed[1], parsed[0]), collect_breakpoints(main_window)) + + +def edit_breakpoint_condition(main_window: QWidget, condition: str | None = None) -> bool: + """ + 設定游標那一行中斷點的條件;那一行沒有中斷點時會加上 + Set the condition of the breakpoint on the caret's line, adding one when the line has none. + + :param main_window: 主視窗 / the main window + :param condition: 條件,空字串表示一律停;沒給時問使用者 + the condition, empty to stop every time, asked for when omitted + :return: 有設定時為 ``True`` / ``True`` when it was set + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + + tab_widget = getattr(main_window, "tab_widget", None) + tab = tab_widget.currentWidget() if tab_widget is not None else None + if not isinstance(tab, EditorWidget): + return False + code_edit = tab.code_edit + line = code_edit.textCursor().blockNumber() + if condition is None: + words = language_wrapper.language_word_dict + condition, accepted = QInputDialog.getText( + main_window, words.get("debug_menu_breakpoint_condition"), + words.get("debug_breakpoint_condition_prompt").format(line=line + 1), + text=code_edit.breakpoint_manager.condition(line)) + if not accepted: + return False + code_edit.breakpoint_manager.set_condition(line, condition.strip()) + code_edit.line_number.update() + code_edit.sync_debug_breakpoints() + return True + + +def build_debug_panel(main_window: object) -> DebugPanelWidget: + """ + 建立除錯面板並接上視窗 + Build the debug panel and connect it to the window. + + :param main_window: 主視窗 / the main window + :return: 新的面板 / the new panel + """ + controller = getattr(main_window, "debug_controller", None) + panel = DebugPanelWidget(controller) + panel.frame_selected.connect(lambda path, line: show_execution_line(main_window, path, line)) + controller.state_changed.connect( + lambda state: clear_execution_lines(main_window) if state is not DebugState.PAUSED else None) + setattr(main_window, _PANEL_ATTRIBUTE, panel) + return panel + + +def show_debug_panel(main_window: object) -> None: + """ + 顯示除錯面板,還沒有的話就開一個 + Show the debug panel, opening one when there is none. + + :param main_window: 主視窗 / the main window + """ + from je_editor.pyside_ui.main_ui.menu.dock_menu.build_dock_menu import add_dock_widget + + panel = getattr(main_window, _PANEL_ATTRIBUTE, None) + if isinstance(panel, DebugPanelWidget) and shiboken6.isValid(panel): + dock = panel.parentWidget() + if dock is not None: + dock.show() + dock.raise_() + return + add_dock_widget(main_window, DOCK_NAME) + + +def show_execution_line(main_window: object, path: str, line: int) -> bool: + """ + 開啟某個檔案並標出執行到的那一行 + Open a file and mark the line execution has reached. + + :param main_window: 主視窗 / the main window + :param path: 檔案路徑,空字串表示那一層沒有原始碼 / the file path, empty when the frame has no source + :param line: 1 起算的行號 / the 1-based line + :return: 有標出來時為 ``True`` / ``True`` when a line was marked + """ + clear_execution_lines(main_window) + opener = getattr(main_window, "go_to_new_tab", None) + if not path or not callable(opener): + return False + opener(path) + wanted = to_uri(path) + for code_edit in _editors(main_window): + if code_edit.current_file and to_uri(str(code_edit.current_file)) == wanted: + code_edit.set_execution_line(line) + return True + return False + + +def clear_execution_lines(main_window: object) -> None: + """ + 把每個編輯器裡「執行到這一行」的標記拿掉 + Remove the execution mark from every editor. + + :param main_window: 主視窗 / the main window + """ + for code_edit in _editors(main_window): + code_edit.set_execution_line(None) diff --git a/je_editor/pyside_ui/main_ui/debug_panel/debug_controller.py b/je_editor/pyside_ui/main_ui/debug_panel/debug_controller.py new file mode 100644 index 0000000..e1ee287 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/debug_panel/debug_controller.py @@ -0,0 +1,241 @@ +""" +視窗這一端的除錯控制:把除錯工作階段接到 Qt +Debugging as the window sees it: a debug session connected to Qt. + +除錯工作階段在自己的執行緒上回覆與通知;畫面只能在畫面執行緒上更新。這個類別 +站在中間:它訂閱工作階段的事件、把查詢的回覆變成 Qt 訊號,訊號跨執行緒時 Qt 會 +自動排進畫面執行緒。面板與編輯器只認這個類別,不認工作階段背後是哪個轉接器。 +A debug session replies and announces on a thread of its own, and widgets may +only be touched on the widget thread. This class stands between the two: it +subscribes to the session's events and turns replies into Qt signals, which Qt +queues onto the widget thread when they cross threads. The panel and the editor +know this class only, never which adapter is behind the session. +""" +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence + +from PySide6.QtCore import QObject, Signal + +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugAttachRequest, DebugLaunchRequest, DebugSession, DebugState, StepKind +) +from je_editor.core.services.editor_services import EditorServices +from je_editor.utils.exception.exceptions import JEditorServiceException +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 預設使用的轉接器 / The adapter used unless told otherwise +DEFAULT_ADAPTER = "debugpy" +_ACTIVE_STATES = (DebugState.STARTING, DebugState.RUNNING, DebugState.PAUSED) +# 這一類輸出是轉接器給工具看的,不是給人看的 / Output of this kind is for tools, not for people +_HIDDEN_OUTPUT = "telemetry" + + +class DebugController(QObject): + """ + 一個視窗的除錯控制 + The debugging control of one window. + """ + + state_changed = Signal(object) # DebugState + stopped = Signal(object) # StopEvent + output = Signal(str, str) # 類別與文字 / category and text + threads_ready = Signal(object) # tuple[DebugThread, ...] + stack_ready = Signal(int, object) # 執行緒編號與堆疊 / thread id and frames + scopes_ready = Signal(int, object) # 堆疊層編號與變數群組 / frame id and scopes + variables_ready = Signal(int, object) # 編號與變數 / reference and variables + evaluated = Signal(str, object) # 運算式與 DebugReply / expression and its DebugReply + exception_ready = Signal(object) # ExceptionInfo | None + + def __init__(self, services: EditorServices, parent: QObject | None = None, + adapter: str = DEFAULT_ADAPTER) -> None: + """ + :param services: 視窗的核心服務,除錯轉接器登記在裡面 + the window's core services, where debug adapters are registered + :param parent: Qt 父物件 / the Qt parent + :param adapter: 要用哪個轉接器 / which adapter to use + """ + super().__init__(parent) + self.setObjectName("DebugController") + self._services = services + self._adapter = adapter + self._session: DebugSession | None = None + self._unsubscribe: list[Callable[[], None]] = [] + + def available(self) -> bool: + """ + 這個視窗能不能用除錯轉接器除錯 + Whether this window can debug through a debug adapter. + + :return: 轉接器有登記時為 ``True`` / ``True`` when the adapter is registered + """ + return self._adapter in self._services.debug_adapters.names() + + def state(self) -> DebugState: + """ + 目前的除錯狀態 + The current debugging state. + + :return: 狀態;沒有除錯過時為 ``IDLE`` / the state, ``IDLE`` before any debugging + """ + return DebugState.IDLE if self._session is None else self._session.state() + + def is_active(self) -> bool: + """ + 是否正在除錯 + Whether a program is being debugged right now. + + :return: 啟動中、執行中或暫停時為 ``True`` / ``True`` while starting, running or paused + """ + return self.state() in _ACTIVE_STATES + + def launch(self, request: DebugLaunchRequest, + breakpoints: Mapping[str, Sequence[Breakpoint]]) -> bool: + """ + 啟動程式並開始除錯 + Launch a program under the debugger. + + :param request: 啟動所需的資訊 / what to launch + :param breakpoints: 每個檔案(URI)的中斷點 / the breakpoints of each file, by URI + :return: 有啟動時為 ``True`` / ``True`` when it started + """ + session = self._new_session(breakpoints) + return session is not None and session.launch(request) + + def attach(self, request: DebugAttachRequest, + breakpoints: Mapping[str, Sequence[Breakpoint]]) -> bool: + """ + 接上一個已經在等除錯器的程式 + Attach to a program that is already waiting for a debugger. + + :param request: 要接上哪裡 / where to attach + :param breakpoints: 每個檔案(URI)的中斷點 / the breakpoints of each file, by URI + :return: 有連上時為 ``True`` / ``True`` when it connected + """ + session = self._new_session(breakpoints) + return session is not None and session.attach(request) + + def set_breakpoints(self, uri: str, breakpoints: Sequence[Breakpoint]) -> None: + """ + 除錯中途更新一個檔案的中斷點 + Update one file's breakpoints while debugging. + + :param uri: 檔案的 URI / the file's URI + :param breakpoints: 這個檔案現在所有的中斷點 / all its breakpoints now + """ + if self._session is not None and self.is_active(): + self._session.set_breakpoints(uri, breakpoints) + + def resume(self) -> None: + """繼續執行 / Carry on running.""" + if self._session is not None and self.state() is DebugState.PAUSED: + self._session.resume() + + def pause(self) -> None: + """暫停執行 / Pause the run.""" + if self._session is not None and self.state() is DebugState.RUNNING: + self._session.pause() + + def step(self, kind: StepKind) -> bool: + """ + 逐步執行 + Take one step. + + :param kind: 逐步的方式 / how to step + :return: 有送出時為 ``True``;沒有暫停中的程式時為 ``False`` + ``True`` when it was sent, ``False`` when no program is paused + """ + if self._session is None or self.state() is not DebugState.PAUSED: + return False + self._session.step(kind) + return True + + def stop(self) -> None: + """結束除錯 / End the debugging.""" + if self._session is not None: + self._session.terminate() + + def request_threads(self) -> None: + """查詢執行緒,結果由 ``threads_ready`` 送出 / Ask for the threads; ``threads_ready`` carries them.""" + if self._session is not None: + self._session.threads(lambda reply: self.threads_ready.emit(reply.value)) + + def request_stack(self, thread_id: int) -> None: + """ + 查詢一條執行緒的堆疊,結果由 ``stack_ready`` 送出 + Ask for a thread's stack; ``stack_ready`` carries it. + + :param thread_id: 執行緒編號 / the thread's id + """ + if self._session is not None: + self._session.stack_trace( + thread_id, lambda reply: self.stack_ready.emit(thread_id, reply.value)) + + def request_scopes(self, frame_id: int) -> None: + """ + 查詢一層堆疊的變數群組,結果由 ``scopes_ready`` 送出 + Ask for a frame's scopes; ``scopes_ready`` carries them. + + :param frame_id: 堆疊那一層的編號 / the frame's id + """ + if self._session is not None: + self._session.scopes(frame_id, lambda reply: self.scopes_ready.emit(frame_id, reply.value)) + + def request_variables(self, reference: int) -> None: + """ + 查詢一組變數或一個變數的子項目,結果由 ``variables_ready`` 送出 + Ask for a group of variables or a variable's children; ``variables_ready`` carries them. + + :param reference: 變數群組或變數給的編號 / the handle a scope or a variable gave + """ + if self._session is not None: + self._session.variables( + reference, lambda reply: self.variables_ready.emit(reference, reply.value)) + + def evaluate(self, expression: str, frame_id: int) -> None: + """ + 求一個運算式的值,結果由 ``evaluated`` 送出 + Evaluate an expression; ``evaluated`` carries the result. + + :param expression: 運算式 / the expression + :param frame_id: 堆疊那一層的編號,零表示全域 / the frame's id, zero for the global scope + """ + if self._session is not None: + self._session.evaluate( + expression, frame_id, lambda reply: self.evaluated.emit(expression, reply)) + + def request_exception(self, thread_id: int) -> None: + """ + 查詢一條執行緒停在哪個例外上,結果由 ``exception_ready`` 送出 + Ask which exception a thread stopped on; ``exception_ready`` carries it. + + :param thread_id: 執行緒編號 / the thread's id + """ + if self._session is not None: + self._session.exception_info(thread_id, lambda reply: self.exception_ready.emit(reply.value)) + + def _new_session(self, breakpoints: Mapping[str, Sequence[Breakpoint]]) -> DebugSession | None: + """建立新的工作階段、訂閱它的事件並交給它中斷點 / Build a session, subscribe to it and hand it the breakpoints.""" + if self.is_active(): + return None + try: + session = self._services.debug_adapters.require(self._adapter)() + except JEditorServiceException as error: + jeditor_logger.warning("no debug adapter to start: %s", error) + return None + for unsubscribe in self._unsubscribe: + unsubscribe() + self._session = session + self._unsubscribe = [ + session.state_changed.subscribe(self.state_changed.emit), + session.stopped.subscribe(self.stopped.emit), + session.output.subscribe(self._on_output), + ] + for uri, items in breakpoints.items(): + session.set_breakpoints(uri, items) + return session + + def _on_output(self, event) -> None: + """把給人看的輸出送出去 / Pass on the output meant for people.""" + if event.category != _HIDDEN_OUTPUT: + self.output.emit(event.category, event.text) diff --git a/je_editor/pyside_ui/main_ui/debug_panel/debug_panel_widget.py b/je_editor/pyside_ui/main_ui/debug_panel/debug_panel_widget.py new file mode 100644 index 0000000..b707f01 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/debug_panel/debug_panel_widget.py @@ -0,0 +1,325 @@ +""" +除錯面板:控制按鈕、執行緒、呼叫堆疊、變數、求值與輸出 +The debug panel: controls, threads, the call stack, variables, evaluation and output. + +面板只跟 ``DebugController`` 說話。它要什麼就向控制器要,答案從控制器的訊號回來, +所以這裡沒有任何一行知道轉接器或協定。 +The panel talks to the ``DebugController`` only. Whatever it needs it asks the +controller for, and the answers come back through the controller's signals, so +nothing here knows about adapters or the protocol. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QPlainTextEdit, QPushButton, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.core.debug.debug_session import ( + DebugReply, DebugState, ExceptionInfo, Scope, StackFrame, StepKind, StopEvent, Variable +) +from je_editor.core.uri.resource_uri import to_path +from je_editor.pyside_ui.main_ui.debug_panel.debug_controller import DebugController +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +COLUMN_NAME = 0 +COLUMN_VALUE = 1 +COLUMN_TYPE = 2 +# 樹狀項目上記著「展開時要用哪個編號去查子項目」/ Where a tree item keeps the handle for fetching its children +REFERENCE_ROLE = Qt.ItemDataRole.UserRole +# 列表項目上記著那一層堆疊 / Where a list item keeps its stack frame +FRAME_ROLE = Qt.ItemDataRole.UserRole +EXCEPTION_REASON = "exception" +# 輸出最多留這麼多行,長時間除錯才不會把記憶體吃光 +# The most lines of output kept, so a long session does not eat the memory +MAX_OUTPUT_LINES = 5000 +_STATE_KEYS = { + DebugState.IDLE: "debug_panel_state_idle", + DebugState.STARTING: "debug_panel_state_starting", + DebugState.RUNNING: "debug_panel_state_running", + DebugState.PAUSED: "debug_panel_state_paused", + DebugState.TERMINATED: "debug_panel_state_terminated", +} + + +def _word(key: str) -> str: + """取得目前語言的介面文字 / The UI text in the current language.""" + return language_wrapper.language_word_dict.get(key, key) + + +class DebugPanelWidget(QWidget): + """ + 顯示並控制一次除錯的面板 + The panel that shows and controls a debug run. + """ + + # 使用者選了一層堆疊:檔案路徑與行號(1 起算);沒有原始碼時路徑是空字串 + # A frame was chosen: its file path and 1-based line; the path is empty when it has no source + frame_selected = Signal(str, int) + + def __init__(self, controller: DebugController, parent: QWidget | None = None) -> None: + """ + :param controller: 這個視窗的除錯控制 / the window's debugging control + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + self._controller = controller + self._thread_id = 0 + self._stop_reason = "" + self._build_controls() + self._build_views() + self._connect() + self.retranslate() + self._show_state(controller.state()) + + def _build_controls(self) -> None: + """建立上方的控制按鈕與狀態 / Build the control buttons and the status line.""" + self.continue_button = QPushButton() + self.pause_button = QPushButton() + self.step_over_button = QPushButton() + self.step_into_button = QPushButton() + self.step_out_button = QPushButton() + self.stop_button = QPushButton() + self.status_label = QLabel() + self._controls = QHBoxLayout() + for button in (self.continue_button, self.pause_button, self.step_over_button, + self.step_into_button, self.step_out_button, self.stop_button): + self._controls.addWidget(button) + self._controls.addWidget(self.status_label, 1) + + def _build_views(self) -> None: + """建立堆疊、變數、輸出與求值 / Build the stack, the variables, the output and the evaluator.""" + self.thread_label = QLabel() + self.thread_combobox = QComboBox() + self.stack_label = QLabel() + self.stack_list = QListWidget() + stack_side = QWidget() + stack_layout = QVBoxLayout(stack_side) + stack_layout.setContentsMargins(0, 0, 0, 0) + for widget in (self.thread_label, self.thread_combobox, self.stack_label, self.stack_list): + stack_layout.addWidget(widget) + self.variable_tree = QTreeWidget() + self.variable_tree.setColumnCount(COLUMN_TYPE + 1) + # 名稱那一欄跟著內容變寬,長一點的變數名稱才不會被截掉 + # The name column grows with its content, so a longer variable name is not cut off + self.variable_tree.header().setSectionResizeMode( + COLUMN_NAME, QHeaderView.ResizeMode.ResizeToContents) + self.output_view = QPlainTextEdit() + self.output_view.setReadOnly(True) + self.output_view.setMaximumBlockCount(MAX_OUTPUT_LINES) + self.evaluate_input = QLineEdit() + upper = QSplitter(Qt.Orientation.Horizontal) + upper.addWidget(stack_side) + upper.addWidget(self.variable_tree) + lower = QWidget() + lower_layout = QVBoxLayout(lower) + lower_layout.setContentsMargins(0, 0, 0, 0) + lower_layout.addWidget(self.output_view) + lower_layout.addWidget(self.evaluate_input) + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(upper) + splitter.addWidget(lower) + layout = QVBoxLayout(self) + layout.addLayout(self._controls) + layout.addWidget(splitter) + + def _connect(self) -> None: + """把按鈕接到控制器、把控制器的訊號接到畫面 / Wire the buttons to the controller and its signals to the views.""" + controller = self._controller + self.continue_button.clicked.connect(controller.resume) + self.pause_button.clicked.connect(controller.pause) + self.step_over_button.clicked.connect(lambda: controller.step(StepKind.OVER)) + self.step_into_button.clicked.connect(lambda: controller.step(StepKind.INTO)) + self.step_out_button.clicked.connect(lambda: controller.step(StepKind.OUT)) + self.stop_button.clicked.connect(controller.stop) + self.thread_combobox.activated.connect(self._on_thread_chosen) + self.stack_list.currentItemChanged.connect(self._on_frame_chosen) + self.variable_tree.itemExpanded.connect(self._on_item_expanded) + self.evaluate_input.returnPressed.connect(self._evaluate) + controller.state_changed.connect(self._show_state) + controller.stopped.connect(self._on_stopped) + controller.output.connect(self._on_output) + controller.threads_ready.connect(self._show_threads) + controller.stack_ready.connect(self._show_stack) + controller.scopes_ready.connect(self._show_scopes) + controller.variables_ready.connect(self._show_variables) + controller.evaluated.connect(self._show_evaluation) + controller.exception_ready.connect(self._show_exception) + + def retranslate(self) -> None: + """換語言之後重設每一段文字 / Reset every piece of text after a language change.""" + self.continue_button.setText(_word("debug_panel_continue")) + self.pause_button.setText(_word("debug_panel_pause")) + self.step_over_button.setText(_word("debug_panel_step_over")) + self.step_into_button.setText(_word("debug_panel_step_into")) + self.step_out_button.setText(_word("debug_panel_step_out")) + self.stop_button.setText(_word("debug_panel_stop")) + self.thread_label.setText(_word("debug_panel_threads")) + self.stack_label.setText(_word("debug_panel_call_stack")) + self.variable_tree.setHeaderLabels([ + _word("debug_panel_col_name"), _word("debug_panel_col_value"), + _word("debug_panel_col_type")]) + self.evaluate_input.setPlaceholderText(_word("debug_panel_evaluate_placeholder")) + self._show_state(self._controller.state()) + + def selected_frame(self) -> StackFrame | None: + """ + 目前選的那一層堆疊 + The stack frame that is selected. + + :return: 那一層堆疊,沒有選時為 ``None`` / the frame, or ``None`` when none is selected + """ + item = self.stack_list.currentItem() + return None if item is None else item.data(FRAME_ROLE) + + def _show_state(self, state: DebugState) -> None: + """依狀態更新按鈕與狀態文字 / Update the buttons and the status text for a state.""" + paused = state is DebugState.PAUSED + running = state is DebugState.RUNNING + active = paused or running or state is DebugState.STARTING + self.continue_button.setEnabled(paused) + self.pause_button.setEnabled(running) + for button in (self.step_over_button, self.step_into_button, self.step_out_button): + button.setEnabled(paused) + self.stop_button.setEnabled(active) + self.evaluate_input.setEnabled(paused) + text = _word(_STATE_KEYS[state]) + self.status_label.setText(text.format(reason=self._stop_reason) if paused else text) + if not paused: + self._clear_stack() + self.variable_tree.clear() + + def _clear_stack(self) -> None: + """ + 清空堆疊清單,而不把途中的項目當成使用者選的 + Empty the stack list without taking an item passed on the way for the user's choice. + + 清空時 Qt 會先把「目前項目」移到下一個再刪,每移一次都送出訊號;不擋住的話, + 程式明明在跑,面板卻去查那一層的變數、還請編輯器標出它的行。 + While clearing, Qt moves the current item to the next one before deleting, + and signals each move. Left unblocked, the panel would ask for that + frame's variables and have its line marked while the program is running. + """ + was_blocked = self.stack_list.blockSignals(True) + self.stack_list.clear() + self.stack_list.blockSignals(was_blocked) + + def _on_stopped(self, stop: StopEvent) -> None: + """程式停下來了:記下原因,開始查執行緒與堆疊 / The program stopped: note why, then ask for threads and the stack.""" + self._thread_id = stop.thread_id + self._stop_reason = stop.description or stop.reason + self._show_state(DebugState.PAUSED) + self._controller.request_threads() + self._controller.request_stack(stop.thread_id) + if stop.reason == EXCEPTION_REASON: + self._controller.request_exception(stop.thread_id) + + def _on_output(self, _category: str, text: str) -> None: + """把輸出接在最後面 / Append output at the end.""" + self.output_view.moveCursor(self.output_view.textCursor().MoveOperation.End) + self.output_view.insertPlainText(text) + + def _show_threads(self, threads: tuple) -> None: + """列出執行緒,選中停下來的那一條 / List the threads and select the one that stopped.""" + self.thread_combobox.clear() + for thread in threads: + self.thread_combobox.addItem(thread.name or str(thread.thread_id), thread.thread_id) + index = self.thread_combobox.findData(self._thread_id) + if index >= 0: + self.thread_combobox.setCurrentIndex(index) + + def _on_thread_chosen(self, index: int) -> None: + """使用者換了一條執行緒:查它的堆疊 / Another thread was chosen: ask for its stack.""" + thread_id = self.thread_combobox.itemData(index) + if isinstance(thread_id, int): + self._thread_id = thread_id + self._controller.request_stack(thread_id) + + def _show_stack(self, thread_id: int, frames: tuple) -> None: + """列出堆疊並選中最裡面的一層 / List the stack and select the innermost frame.""" + if thread_id != self._thread_id: + return + self._clear_stack() + for frame in frames: + path = to_path(frame.uri) if frame.uri else "" + label = f"{frame.name} {path.replace(chr(92), '/').rsplit('/', 1)[-1]}:{frame.line}" + item = QListWidgetItem(label if path else frame.name) + item.setData(FRAME_ROLE, frame) + item.setToolTip(path) + self.stack_list.addItem(item) + if frames: + self.stack_list.setCurrentRow(0) + + def _on_frame_chosen(self, current: QListWidgetItem | None, _previous: object) -> None: + """選了一層堆疊:查它的變數,並請視窗顯示那一行 / A frame was chosen: ask for its variables and have its line shown.""" + self.variable_tree.clear() + if current is None: + return + frame = current.data(FRAME_ROLE) + self._controller.request_scopes(frame.frame_id) + self.frame_selected.emit(to_path(frame.uri) if frame.uri else "", frame.line) + + def _show_scopes(self, frame_id: int, scopes: tuple[Scope, ...]) -> None: + """列出變數群組;不花時間的群組直接展開 / List the scopes, expanding those that are cheap to fetch.""" + frame = self.selected_frame() + if frame is None or frame.frame_id != frame_id: + return + self.variable_tree.clear() + for scope in scopes: + item = QTreeWidgetItem(self.variable_tree, [scope.name, "", ""]) + self._make_expandable(item, scope.variables_reference) + if not scope.expensive: + item.setExpanded(True) + + @staticmethod + def _make_expandable(item: QTreeWidgetItem, reference: int) -> None: + """有子項目的項目先放一個佔位,展開時才去查 / An item with children gets a placeholder, fetched on expanding.""" + item.setData(COLUMN_NAME, REFERENCE_ROLE, reference) + if reference: + item.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) + + def _on_item_expanded(self, item: QTreeWidgetItem) -> None: + """展開一個還沒查過的項目:去查它的子項目 / An item not fetched yet was expanded: ask for its children.""" + reference = item.data(COLUMN_NAME, REFERENCE_ROLE) + if isinstance(reference, int) and reference and item.childCount() == 0: + self._controller.request_variables(reference) + + def _show_variables(self, reference: int, variables: tuple[Variable, ...]) -> None: + """把查到的變數放到等著它們的那個項目底下 / Put the variables under the item that waits for them.""" + for parent in self._items_waiting_for(reference): + for variable in variables: + child = QTreeWidgetItem(parent, [variable.name, variable.value, variable.type_name]) + self._make_expandable(child, variable.children_reference) + + def _items_waiting_for(self, reference: int) -> list[QTreeWidgetItem]: + """找出記著某個編號、還沒有子項目的項目 / The items holding a handle that have no children yet.""" + waiting = [] + pending = [self.variable_tree.topLevelItem(index) + for index in range(self.variable_tree.topLevelItemCount())] + while pending: + item = pending.pop() + if item.data(COLUMN_NAME, REFERENCE_ROLE) == reference and item.childCount() == 0: + waiting.append(item) + pending.extend(item.child(index) for index in range(item.childCount())) + return waiting + + def _evaluate(self) -> None: + """在選中的那一層堆疊裡求值 / Evaluate in the selected frame.""" + expression = self.evaluate_input.text().strip() + if not expression: + return + frame = self.selected_frame() + self._controller.evaluate(expression, 0 if frame is None else frame.frame_id) + self.evaluate_input.clear() + + def _show_evaluation(self, expression: str, reply: DebugReply) -> None: + """把求值的結果寫進輸出 / Write the result of an evaluation into the output.""" + result = reply.value.value if reply.ok and reply.value is not None else reply.error + self._on_output("", f">>> {expression}\n{result}\n") + + def _show_exception(self, info: ExceptionInfo | None) -> None: + """把例外的名稱、訊息與追蹤寫進輸出 / Write an exception's name, message and traceback into the output.""" + if info is None: + return + self._on_output("", f"{info.exception_id}: {info.description}\n{info.stack_trace}\n") diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 9e28e1a..f7e94f3 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -25,6 +25,7 @@ from je_editor.pyside_ui.browser.main_browser_widget import MainBrowserWidget from je_editor.pyside_ui.code.auto_save.auto_save_manager import init_new_auto_save_thread, file_is_open_manager_dict from je_editor.pyside_ui.main_ui.ai_widget.chat_worker import cancel_chat_workers +from je_editor.pyside_ui.main_ui.debug_panel.debug_controller import DebugController from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.menu.set_menu_bar import set_menu_bar from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( @@ -101,6 +102,8 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # The state that belongs to no widget (workspace, diagnostics, AI providers # and settings); panels ask it instead of each keeping a copy self.services = build_default_services(Workspace.single_root(os.getcwd())) + # 除錯面板、選單與編輯器都透過它除錯 / The debug panel, the menus and the editors debug through it + self.debug_controller = DebugController(self.services, self) self.extend = extend # 是否為擴充模式(如 PyBreeze)/ Whether in extend mode (e.g. PyBreeze) # 確保外部插件已載入(若尚未載入) @@ -600,6 +603,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # 還在等回覆的 AI 請求先取消,再放掉服務持有的資源 # Cancel AI requests still waiting for a reply, then release what the services hold cancel_chat_workers() + self.debug_controller.stop() self.services.shutdown() write_user_setting() write_user_color_setting() diff --git a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py index c04cf25..365aee9 100644 --- a/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/dock_menu/build_dock_menu.py @@ -22,6 +22,8 @@ from je_editor.pyside_ui.main_ui.ipython_widget.ipython_console import IpythonWidget from je_editor.pyside_ui.main_ui.outline_panel.outline_panel_widget import OutlinePanelWidget from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ProblemsPanelWidget +from je_editor.pyside_ui.main_ui.debug_panel.debug_actions import DOCK_NAME as DEBUG_DOCK +from je_editor.pyside_ui.main_ui.debug_panel.debug_actions import build_debug_panel from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget from je_editor.pyside_ui.main_ui.todo_panel.todo_panel_widget import TodoPanelWidget from je_editor.utils.exception.exceptions import JEditorOpenFileException @@ -158,6 +160,13 @@ def set_dock_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_test_panel) + ui_we_want_to_set.dock_menu.new_debug_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_debug_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_debug_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, DEBUG_DOCK) + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_debug_panel) + # === Outline Panel Dock === ui_we_want_to_set.dock_menu.new_outline_panel = QAction( language_wrapper.language_word_dict.get("tab_menu_outline_panel_tab_name")) @@ -214,6 +223,8 @@ def _dock_builders(ui_we_want_to_set: EditorMain) -> dict: lambda: ProblemsPanelWidget(ui_we_want_to_set)), "test_panel": ("tab_menu_test_panel_tab_name", lambda: TestPanelWidget(ui_we_want_to_set)), + DEBUG_DOCK: ("tab_menu_debug_panel_tab_name", + lambda: build_debug_panel(ui_we_want_to_set)), } diff --git a/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py b/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py index dfb299f..bd08b07 100644 --- a/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/run_menu/under_run_menu/build_debug_menu.py @@ -33,6 +33,9 @@ # 匯入檔案儲存對話框 # Import file save dialog from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path +from je_editor.pyside_ui.main_ui.debug_panel.debug_actions import ( + attach_to_process, controller_of, edit_breakpoint_condition, start_debugging +) # 匯入多語言包裝器 # Import multi-language wrapper for UI localization @@ -66,6 +69,22 @@ def set_debug_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.debug_menu.addAction(ui_we_want_to_set.debug_menu.show_shell_input) + # 接上一個等著除錯器的程式 / Attach to a program that waits for a debugger + ui_we_want_to_set.debug_menu.attach_action = QAction( + language_wrapper.language_word_dict.get("debug_menu_attach")) + ui_we_want_to_set.debug_menu.attach_action.triggered.connect( + lambda: attach_to_process(ui_we_want_to_set) + ) + ui_we_want_to_set.debug_menu.addAction(ui_we_want_to_set.debug_menu.attach_action) + + # 設定游標那一行中斷點的條件 / Set the condition of the breakpoint on the caret's line + ui_we_want_to_set.debug_menu.breakpoint_condition_action = QAction( + language_wrapper.language_word_dict.get("debug_menu_breakpoint_condition")) + ui_we_want_to_set.debug_menu.breakpoint_condition_action.triggered.connect( + lambda: edit_breakpoint_condition(ui_we_want_to_set) + ) + ui_we_want_to_set.debug_menu.addAction(ui_we_want_to_set.debug_menu.breakpoint_condition_action) + # 把編輯器上設定的中斷點交給剛啟動的除錯器 # Hand the breakpoints set in the editor to the debugger that just started @@ -97,6 +116,16 @@ def run_debugger(ui_we_want_to_set: EditorMain) -> None: jeditor_logger.info(f"build_debug_menu.py run_debugger ui_we_want_to_set: {ui_we_want_to_set}") widget = ui_we_want_to_set.tab_widget.currentWidget() if isinstance(widget, EditorWidget): + # 有除錯轉接器就用它;沒有(例如沒裝 debugpy)才退回底下的 pdb 主控台 + # Use the debug adapter when there is one; the pdb console below is the + # fallback for when there is none, as without debugpy + controller = controller_of(ui_we_want_to_set) + if controller is not None: + if controller.is_active(): + please_close_current_running_messagebox(ui_we_want_to_set) + elif choose_file_get_save_file_path(ui_we_want_to_set): + start_debugging(ui_we_want_to_set, widget.current_file) + return # 確保沒有正在執行的除錯器 # Ensure no debugger is already running if widget.exec_python_debugger is None: diff --git a/je_editor/utils/debugger/attach_address.py b/je_editor/utils/debugger/attach_address.py new file mode 100644 index 0000000..5864621 --- /dev/null +++ b/je_editor/utils/debugger/attach_address.py @@ -0,0 +1,32 @@ +""" +解析「要接上哪個程式」的位址 +Parse the address of the program to attach to. + +純邏輯,不含 Qt。 +Pure logic, with no Qt. +""" +from __future__ import annotations + +DEFAULT_HOST = "127.0.0.1" +MAX_PORT = 65535 + + +def parse_address(text: str | None) -> tuple[str, int] | None: + """ + 把 ``host:port`` 或只有 ``port`` 的文字變成主機與連接埠 + Turn ``host:port``, or a port alone, into a host and a port. + + :param text: 使用者輸入的文字 / what the user typed + :return: ``(主機, 連接埠)``;不是合法的位址時為 ``None`` + ``(host, port)``, or ``None`` when it is not a usable address + """ + cleaned = (text or "").strip() + host, separator, port_text = cleaned.rpartition(":") + if not separator: + host = DEFAULT_HOST + if not host.strip() or not port_text.isascii() or not port_text.isdigit(): + return None + port = int(port_text) + if not 0 < port <= MAX_PORT: + return None + return host.strip(), port diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 6730b57..82d2fb2 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -448,6 +448,29 @@ "context_menu_format_document": "Format Document", # Test panel "tab_menu_test_panel_tab_name": "Tests", + "tab_menu_debug_panel_tab_name": "Debug Panel", + "debug_panel_continue": "Continue", + "debug_panel_pause": "Pause", + "debug_panel_step_over": "Step Over", + "debug_panel_step_into": "Step Into", + "debug_panel_step_out": "Step Out", + "debug_panel_stop": "Stop", + "debug_panel_threads": "Thread", + "debug_panel_call_stack": "Call Stack", + "debug_panel_col_name": "Name", + "debug_panel_col_value": "Value", + "debug_panel_col_type": "Type", + "debug_panel_evaluate_placeholder": "Evaluate an expression in the selected frame", + "debug_panel_state_idle": "Not running", + "debug_panel_state_starting": "Starting...", + "debug_panel_state_running": "Running", + "debug_panel_state_paused": "Paused: {reason}", + "debug_panel_state_terminated": "Ended", + "debug_menu_attach": "Attach to Process...", + "debug_attach_prompt": "Address of the waiting program (host:port):", + "debug_attach_invalid": "Enter the address as host:port, for example 127.0.0.1:5678", + "debug_menu_breakpoint_condition": "Breakpoint Condition...", + "debug_breakpoint_condition_prompt": "Stop on line {line} only when this is true (empty to always stop):", "test_panel_run": "Run Tests", "test_panel_run_selected": "Run Selected", "test_panel_rerun_failures": "Re-run Failures", diff --git a/je_editor/utils/multi_language/japanese.py b/je_editor/utils/multi_language/japanese.py index a9d17fe..fb70439 100644 --- a/je_editor/utils/multi_language/japanese.py +++ b/je_editor/utils/multi_language/japanese.py @@ -442,6 +442,29 @@ "context_menu_format_document": "ドキュメント全体を整形", # Test panel "tab_menu_test_panel_tab_name": "テスト", + "tab_menu_debug_panel_tab_name": "デバッグパネル", + "debug_panel_continue": "続行", + "debug_panel_pause": "一時停止", + "debug_panel_step_over": "ステップオーバー", + "debug_panel_step_into": "ステップイン", + "debug_panel_step_out": "ステップアウト", + "debug_panel_stop": "停止", + "debug_panel_threads": "スレッド", + "debug_panel_call_stack": "コールスタック", + "debug_panel_col_name": "名前", + "debug_panel_col_value": "値", + "debug_panel_col_type": "型", + "debug_panel_evaluate_placeholder": "選択したフレームで式を評価", + "debug_panel_state_idle": "未実行", + "debug_panel_state_starting": "起動中...", + "debug_panel_state_running": "実行中", + "debug_panel_state_paused": "一時停止中:{reason}", + "debug_panel_state_terminated": "終了", + "debug_menu_attach": "プロセスにアタッチ...", + "debug_attach_prompt": "待機中のプログラムのアドレス(host:port):", + "debug_attach_invalid": "アドレスは host:port の形式で入力してください(例:127.0.0.1:5678)", + "debug_menu_breakpoint_condition": "ブレークポイントの条件...", + "debug_breakpoint_condition_prompt": "この条件が真のときだけ {line} 行目で停止します(空欄なら常に停止):", "test_panel_run": "テストを実行", "test_panel_run_selected": "選択したものだけ実行", "test_panel_rerun_failures": "失敗したものを再実行", diff --git a/je_editor/utils/multi_language/simplified_chinese.py b/je_editor/utils/multi_language/simplified_chinese.py index 20372f0..d6992e9 100644 --- a/je_editor/utils/multi_language/simplified_chinese.py +++ b/je_editor/utils/multi_language/simplified_chinese.py @@ -438,6 +438,29 @@ "context_menu_format_document": "格式化整个文档", # Test panel "tab_menu_test_panel_tab_name": "测试", + "tab_menu_debug_panel_tab_name": "调试面板", + "debug_panel_continue": "继续", + "debug_panel_pause": "暂停", + "debug_panel_step_over": "单步跳过", + "debug_panel_step_into": "单步进入", + "debug_panel_step_out": "单步跳出", + "debug_panel_stop": "停止", + "debug_panel_threads": "线程", + "debug_panel_call_stack": "调用堆栈", + "debug_panel_col_name": "名称", + "debug_panel_col_value": "值", + "debug_panel_col_type": "类型", + "debug_panel_evaluate_placeholder": "在选中的堆栈帧里求表达式的值", + "debug_panel_state_idle": "尚未运行", + "debug_panel_state_starting": "启动中...", + "debug_panel_state_running": "运行中", + "debug_panel_state_paused": "已暂停:{reason}", + "debug_panel_state_terminated": "已结束", + "debug_menu_attach": "附加到运行中的程序...", + "debug_attach_prompt": "等待中的程序的地址(host:port):", + "debug_attach_invalid": "请以 host:port 的形式输入地址,例如 127.0.0.1:5678", + "debug_menu_breakpoint_condition": "断点条件...", + "debug_breakpoint_condition_prompt": "只有在这个条件成立时才停在第 {line} 行(留空表示总是停):", "test_panel_run": "运行测试", "test_panel_run_selected": "只运行选中的", "test_panel_rerun_failures": "重跑失败的", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 40adc40..df0a4eb 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -438,6 +438,29 @@ "context_menu_format_document": "格式化整份文件", # Test panel "tab_menu_test_panel_tab_name": "測試", + "tab_menu_debug_panel_tab_name": "除錯面板", + "debug_panel_continue": "繼續", + "debug_panel_pause": "暫停", + "debug_panel_step_over": "逐步越過", + "debug_panel_step_into": "逐步進入", + "debug_panel_step_out": "逐步跳出", + "debug_panel_stop": "停止", + "debug_panel_threads": "執行緒", + "debug_panel_call_stack": "呼叫堆疊", + "debug_panel_col_name": "名稱", + "debug_panel_col_value": "值", + "debug_panel_col_type": "型別", + "debug_panel_evaluate_placeholder": "在選取的那一層堆疊裡求運算式的值", + "debug_panel_state_idle": "尚未執行", + "debug_panel_state_starting": "啟動中...", + "debug_panel_state_running": "執行中", + "debug_panel_state_paused": "已暫停:{reason}", + "debug_panel_state_terminated": "已結束", + "debug_menu_attach": "接上執行中的程式...", + "debug_attach_prompt": "等待中的程式的位址(host:port):", + "debug_attach_invalid": "請以 host:port 的形式輸入位址,例如 127.0.0.1:5678", + "debug_menu_breakpoint_condition": "中斷點條件...", + "debug_breakpoint_condition_prompt": "只有在這個條件成立時才停在第 {line} 行(留空表示一律停):", "test_panel_run": "執行測試", "test_panel_run_selected": "只跑選取的", "test_panel_rerun_failures": "重跑失敗的", diff --git a/je_editor/utils/theme/theme_colors.py b/je_editor/utils/theme/theme_colors.py index 3f7f290..1d88d1c 100644 --- a/je_editor/utils/theme/theme_colors.py +++ b/je_editor/utils/theme/theme_colors.py @@ -52,6 +52,7 @@ "syntax_builtin_color": [78, 201, 176], "syntax_self_color": [197, 134, 192], "syntax_function_color": [220, 220, 170], + "debug_execution_line_color": [92, 84, 26], "trailing_whitespace_color": [120, 70, 70], } @@ -84,6 +85,7 @@ "syntax_builtin_color": [38, 127, 153], "syntax_self_color": [154, 0, 154], "syntax_function_color": [121, 94, 38], + "debug_execution_line_color": [255, 240, 150], "trailing_whitespace_color": [255, 205, 205], } diff --git a/test/test_debug_panel.py b/test/test_debug_panel.py new file mode 100644 index 0000000..055b497 --- /dev/null +++ b/test/test_debug_panel.py @@ -0,0 +1,643 @@ +"""Tests for the debug panel, its controller, breakpoint conditions and the debugging actions.""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QTabWidget + +from je_editor.adapters.debug.debugpy_adapter import debugpy_available +from je_editor.adapters.default_services import build_default_services +from je_editor.core.debug.debug_session import ( + Breakpoint, DebugAttachRequest, DebugLaunchRequest, DebugReply, DebugSession, DebugState, + DebugThread, EvaluateResult, ExceptionInfo, OutputEvent, Scope, StackFrame, StepKind, + StopEvent, Variable +) +from je_editor.core.events.event_hook import EventHook +from je_editor.core.services.editor_services import EditorServices +from je_editor.core.uri.resource_uri import to_uri +from je_editor.pyside_ui.main_ui.debug_panel import debug_actions +from je_editor.pyside_ui.main_ui.debug_panel.debug_actions import ( + attach_to_process, clear_execution_lines, collect_breakpoints, controller_of, + edit_breakpoint_condition, show_execution_line, start_debugging +) +from je_editor.pyside_ui.main_ui.debug_panel.debug_controller import DebugController +from je_editor.pyside_ui.main_ui.debug_panel.debug_panel_widget import DebugPanelWidget +from je_editor.pyside_ui.main_ui.menu.run_menu.under_run_menu import build_debug_menu +from je_editor.utils.debugger.attach_address import parse_address + +pytestmark = pytest.mark.usefixtures("qapp") +TIMEOUT_MS = 60_000 +LOCALS, GLOBALS, ITEMS = 5, 6, 9 + + +class FakeSession: + """A debug session that answers from a script and notes what it was told.""" + + def __init__(self) -> None: + self.state_changed = EventHook("state") + self.stopped = EventHook("stopped") + self.output = EventHook("output") + self.breakpoints_reported = EventHook("breakpoints") + self._state = DebugState.IDLE + self.calls: list[tuple] = [] + self.breakpoints: dict[str, list[Breakpoint]] = {} + self.source = "" + + def state(self) -> DebugState: + return self._state + + def move_to(self, state: DebugState) -> None: + self._state = state + self.state_changed.emit(state) + + def stop_at(self, reason: str = "breakpoint", thread_id: int = 7) -> None: + self._state = DebugState.PAUSED + self.state_changed.emit(DebugState.PAUSED) + self.stopped.emit(StopEvent(reason, thread_id)) + + def launch(self, request: DebugLaunchRequest) -> bool: + self.calls.append(("launch", request)) + self.move_to(DebugState.RUNNING) + return True + + def attach(self, request: DebugAttachRequest) -> bool: + self.calls.append(("attach", request)) + self.move_to(DebugState.RUNNING) + return True + + def set_breakpoints(self, uri: str, breakpoints) -> None: + self.breakpoints[uri] = list(breakpoints) + + def resume(self, thread_id: int = 0) -> None: + self.calls.append(("resume",)) + self.move_to(DebugState.RUNNING) + + def pause(self, thread_id: int = 0) -> None: + self.calls.append(("pause",)) + + def step(self, kind: StepKind, thread_id: int = 0) -> None: + self.calls.append(("step", kind)) + + def terminate(self) -> None: + self.calls.append(("terminate",)) + self.move_to(DebugState.TERMINATED) + + def threads(self, on_reply) -> None: + on_reply(DebugReply((DebugThread(7, "MainThread"), DebugThread(8, "")))) + + def stack_trace(self, thread_id: int, on_reply) -> None: + self.calls.append(("stack", thread_id)) + on_reply(DebugReply((StackFrame(2, "add", self.source, 3), StackFrame(1, "", "", 1)))) + + def scopes(self, frame_id: int, on_reply) -> None: + on_reply(DebugReply((Scope("Locals", LOCALS), Scope("Globals", GLOBALS, True)))) + + def variables(self, reference: int, on_reply) -> None: + self.calls.append(("variables", reference)) + found = { + LOCALS: (Variable("first", "1", "int"), Variable("items", "[1, 2]", "list", ITEMS)), + ITEMS: (Variable("0", "1", "int"), Variable("1", "2", "int")), + } + on_reply(DebugReply(found.get(reference, ()))) + + def evaluate(self, expression: str, frame_id: int, on_reply) -> None: + self.calls.append(("evaluate", expression, frame_id)) + if expression == "boom": + on_reply(DebugReply(None, "NameError: boom")) + else: + on_reply(DebugReply(EvaluateResult("42", "int"))) + + def exception_info(self, thread_id: int, on_reply) -> None: + on_reply(DebugReply(ExceptionInfo("ZeroDivisionError", "division by zero", "unhandled", "Traceback"))) + + +@pytest.fixture() +def fake(): + return FakeSession() + + +@pytest.fixture() +def services(fake): + built = EditorServices() + built.debug_adapters.register("debugpy", lambda: fake) + yield built + built.shutdown() + + +@pytest.fixture() +def controller(services): + built = DebugController(services) + yield built + built.deleteLater() + + +@pytest.fixture() +def panel(controller, qtbot): + built = DebugPanelWidget(controller) + qtbot.addWidget(built) + return built + + +@pytest.fixture() +def paused(controller, fake, panel): + """A panel showing a program stopped at a breakpoint.""" + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.stop_at() + return panel + + +class TestTheAddressToAttachTo: + @pytest.mark.parametrize("text, expected", [ + ("127.0.0.1:5678", ("127.0.0.1", 5678)), (" build-box:9000 ", ("build-box", 9000)), + ("5678", ("127.0.0.1", 5678)), ("[::1]:5678", ("[::1]", 5678)), + ]) + def test_a_usable_address(self, text, expected): + assert parse_address(text) == expected + + @pytest.mark.parametrize("text", ["", None, "host", "host:", ":5678", "host:0", "host:70000", + "host:12ab", "host:-1", "host:5678"]) + def test_an_unusable_address(self, text): + assert parse_address(text) is None + + +class TestTheController: + def test_the_fake_is_a_debug_session(self, fake): + assert isinstance(fake, DebugSession) + + def test_it_is_available_only_with_its_adapter_registered(self, controller): + assert controller.available() is True + assert DebugController(EditorServices()).available() is False + + def test_before_any_debugging_it_is_idle(self, controller): + assert (controller.state(), controller.is_active()) == (DebugState.IDLE, False) + + def test_launching_hands_the_breakpoints_over_first(self, controller, fake, tmp_path): + uri = to_uri(tmp_path / "main.py") + request = DebugLaunchRequest("main.py") + assert controller.launch(request, {uri: [Breakpoint(uri, 3)]}) is True + assert (fake.breakpoints, fake.calls) == ({uri: [Breakpoint(uri, 3)]}, [("launch", request)]) + assert controller.is_active() is True + + def test_a_second_launch_while_active_is_refused(self, controller): + controller.launch(DebugLaunchRequest("main.py"), {}) + assert controller.launch(DebugLaunchRequest("other.py"), {}) is False + + def test_without_an_adapter_nothing_is_launched(self): + assert DebugController(EditorServices()).launch(DebugLaunchRequest("main.py"), {}) is False + + def test_attaching(self, controller, fake): + request = DebugAttachRequest(5678) + assert controller.attach(request, {}) is True + assert fake.calls == [("attach", request)] + + def test_the_sessions_events_become_signals(self, controller, fake, qtbot): + controller.launch(DebugLaunchRequest("main.py"), {}) + with qtbot.waitSignal(controller.stopped, timeout=TIMEOUT_MS) as stopped: + fake.stop_at("step", 4) + assert stopped.args[0] == StopEvent("step", 4) + with qtbot.waitSignal(controller.output, timeout=TIMEOUT_MS) as written: + fake.output.emit(OutputEvent("stdout", "hello")) + assert written.args == ["stdout", "hello"] + + def test_output_meant_for_tools_is_not_shown(self, controller, fake): + shown: list = [] + controller.output.connect(lambda category, text: shown.append(text)) + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.output.emit(OutputEvent("telemetry", "debugpy")) + fake.output.emit(OutputEvent("stderr", "oops")) + assert shown == ["oops"] + + def test_stepping_needs_a_paused_program(self, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + assert controller.step(StepKind.OVER) is False + fake.stop_at() + assert controller.step(StepKind.INTO) is True + assert fake.calls[-1] == ("step", StepKind.INTO) + + def test_resume_and_pause_follow_the_state(self, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + controller.resume() + controller.pause() + fake.stop_at() + controller.pause() + controller.resume() + assert [call[0] for call in fake.calls] == ["launch", "pause", "resume"] + + def test_breakpoints_are_passed_on_only_while_debugging(self, controller, fake, tmp_path): + uri = to_uri(tmp_path / "main.py") + controller.set_breakpoints(uri, [Breakpoint(uri, 1)]) + assert fake.breakpoints == {} + controller.launch(DebugLaunchRequest("main.py"), {}) + controller.set_breakpoints(uri, [Breakpoint(uri, 2)]) + assert fake.breakpoints == {uri: [Breakpoint(uri, 2)]} + + def test_stopping_ends_the_session_and_allows_another(self, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + controller.stop() + assert (controller.state(), controller.is_active()) == (DebugState.TERMINATED, False) + assert controller.launch(DebugLaunchRequest("main.py"), {}) is True + + def test_queries_before_any_session_do_nothing(self, controller): + controller.request_threads() + controller.request_stack(1) + controller.request_scopes(1) + controller.request_variables(1) + controller.evaluate("x", 0) + controller.request_exception(1) + controller.stop() + assert controller.state() is DebugState.IDLE + + +class TestThePanel: + def test_before_debugging_only_nothing_can_be_pressed(self, panel): + buttons = (panel.continue_button, panel.pause_button, panel.step_over_button, + panel.step_into_button, panel.step_out_button, panel.stop_button) + assert [button.isEnabled() for button in buttons] == [False] * 6 + assert panel.status_label.text() == "Not running" + + def test_while_running_it_can_be_paused_or_stopped(self, controller, panel): + controller.launch(DebugLaunchRequest("main.py"), {}) + assert (panel.pause_button.isEnabled(), panel.stop_button.isEnabled(), + panel.continue_button.isEnabled(), panel.evaluate_input.isEnabled()) == ( + True, True, False, False) + assert panel.status_label.text() == "Running" + + def test_a_stop_fills_the_threads_and_the_stack(self, paused, fake): + assert paused.status_label.text() == "Paused: breakpoint" + assert [paused.thread_combobox.itemText(index) for index in range(2)] == ["MainThread", "8"] + assert paused.thread_combobox.currentData() == 7 + assert [paused.stack_list.item(row).text() for row in range(2)] == ["add", ""] + assert paused.selected_frame() == StackFrame(2, "add", "", 3) + assert paused.step_over_button.isEnabled() and not paused.pause_button.isEnabled() + + def test_a_frame_with_a_source_shows_its_file_and_line(self, controller, fake, panel, tmp_path): + fake.source = to_uri(tmp_path / "main.py") + chosen: list = [] + panel.frame_selected.connect(lambda path, line: chosen.append((path, line))) + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.stop_at() + assert panel.stack_list.item(0).text() == "add main.py:3" + assert [(path.replace("\\", "/").rsplit("/", 1)[-1], line) for path, line in chosen] == [ + ("main.py", 3)] + + def test_cheap_scopes_are_opened_and_expensive_ones_wait(self, paused, fake): + top = [paused.variable_tree.topLevelItem(index) for index in range(2)] + assert [item.text(0) for item in top] == ["Locals", "Globals"] + assert [top[0].child(row).text(0) for row in range(top[0].childCount())] == ["first", "items"] + assert (top[0].child(0).text(1), top[0].child(0).text(2)) == ("1", "int") + assert top[1].childCount() == 0 + assert ("variables", GLOBALS) not in fake.calls + + def test_a_variable_with_children_is_fetched_when_opened(self, paused, fake): + items = paused.variable_tree.topLevelItem(0).child(1) + assert items.childCount() == 0 + items.setExpanded(True) + assert [items.child(row).text(1) for row in range(items.childCount())] == ["1", "2"] + items.setExpanded(False) + items.setExpanded(True) + assert fake.calls.count(("variables", ITEMS)) == 1 + + def test_choosing_another_thread_asks_for_its_stack(self, paused, fake): + paused.thread_combobox.setCurrentIndex(1) + paused._on_thread_chosen(1) + assert ("stack", 8) in fake.calls + + def test_evaluating_in_the_selected_frame(self, paused, fake): + paused.evaluate_input.setText("first + 41") + paused._evaluate() + assert fake.calls[-1] == ("evaluate", "first + 41", 2) + assert paused.output_view.toPlainText().endswith(">>> first + 41\n42\n") + assert paused.evaluate_input.text() == "" + + def test_a_failed_evaluation_shows_why(self, paused): + paused.evaluate_input.setText("boom") + paused._evaluate() + assert "NameError: boom" in paused.output_view.toPlainText() + + def test_an_empty_expression_is_not_sent(self, paused, fake): + before = len(fake.calls) + paused.evaluate_input.setText(" ") + paused._evaluate() + assert len(fake.calls) == before + + def test_stopping_on_an_exception_shows_it(self, controller, fake, panel): + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.stop_at("exception") + assert "ZeroDivisionError: division by zero\nTraceback" in panel.output_view.toPlainText() + + def test_program_output_is_appended(self, controller, fake, panel): + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.output.emit(OutputEvent("stdout", "one\n")) + fake.output.emit(OutputEvent("stdout", "two\n")) + assert panel.output_view.toPlainText() == "one\ntwo\n" + + def test_the_buttons_drive_the_session(self, paused, fake): + paused.step_over_button.click() + paused.step_into_button.click() + paused.step_out_button.click() + paused.continue_button.click() + paused.stop_button.click() + assert fake.calls[-5:] == [("step", StepKind.OVER), ("step", StepKind.INTO), + ("step", StepKind.OUT), ("resume",), ("terminate",)] + + def test_resuming_empties_the_stack_and_the_variables(self, paused, controller): + controller.resume() + assert (paused.stack_list.count(), paused.variable_tree.topLevelItemCount()) == (0, 0) + + def test_emptying_the_stack_asks_the_adapter_nothing_and_selects_no_frame( + self, paused, controller, fake): + # Qt walks the current item down the list while clearing it. + chosen: list = [] + paused.frame_selected.connect(lambda path, line: chosen.append(line)) + before = len(fake.calls) + controller.resume() + assert (fake.calls[before:], chosen) == ([("resume",)], []) + + def test_the_end_is_shown(self, paused, controller): + controller.stop() + assert paused.status_label.text() == "Ended" + assert paused.stop_button.isEnabled() is False + + def test_the_texts_follow_the_language(self, panel): + from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + previous = language_wrapper.language + try: + language_wrapper.reset_language("Traditional_Chinese") + panel.retranslate() + assert (panel.continue_button.text(), panel.status_label.text()) == ("繼續", "尚未執行") + finally: + language_wrapper.reset_language(previous) + panel.retranslate() + + +@pytest.fixture() +def window(services, controller, tmp_path): + """A stand-in main window with one real editor tab on a saved file.""" + stand_in = SimpleNamespace(services=services, debug_controller=controller, working_dir=None, + python_compiler=None, tab_widget=QTabWidget(), encoding="utf-8") + stand_in.go_to_new_tab = MagicMock() + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as venv: + venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab = EditorWidget(stand_in) + stand_in.tab_widget.addTab(tab, "main.py") + path = tmp_path / "main.py" + path.write_text("first = 1\nsecond = 2\nthird = 3\n", encoding="utf-8") + tab.current_file = str(path) + tab.code_edit.current_file = str(path) + tab.code_edit.setPlainText(path.read_text(encoding="utf-8")) + stand_in.tab = tab + stand_in.edit = tab.code_edit + stand_in.uri = to_uri(path) + yield stand_in + tab.close() + tab.deleteLater() + stand_in.tab_widget.deleteLater() + + +class TestBreakpointsInTheEditor: + def test_a_breakpoint_starts_without_a_condition(self, window): + manager = window.edit.breakpoint_manager + manager.toggle(1) + assert (manager.breakpoints(), manager.condition(1), manager.condition(0)) == ([(1, "")], "", "") + + def test_a_condition_can_be_set_and_changed(self, window): + manager = window.edit.breakpoint_manager + manager.toggle(1) + manager.set_condition(1, "first > 0") + manager.set_condition(1, "first > 1") + assert manager.breakpoints() == [(1, "first > 1")] + + def test_setting_a_condition_on_a_bare_line_adds_the_breakpoint(self, window): + manager = window.edit.breakpoint_manager + assert manager.set_condition(2, "third") is True + assert (manager.lines(), manager.condition(2)) == ([2], "third") + + def test_a_line_that_does_not_exist_gets_nothing(self, window): + assert window.edit.breakpoint_manager.set_condition(99, "x") is False + + def test_removing_a_breakpoint_removes_its_condition(self, window): + manager = window.edit.breakpoint_manager + manager.set_condition(0, "a") + manager.set_condition(2, "c") + manager.toggle(0) + assert manager.breakpoints() == [(2, "c")] + manager.clear() + assert manager.breakpoints() == [] + + def test_the_condition_follows_its_line_through_an_edit(self, window): + window.edit.breakpoint_manager.set_condition(1, "second") + cursor = window.edit.textCursor() + cursor.movePosition(cursor.MoveOperation.Start) + cursor.insertText("# added\n") + assert window.edit.breakpoint_manager.breakpoints() == [(2, "second")] + + def test_the_editor_gives_them_as_one_based_breakpoints(self, window): + window.edit.breakpoint_manager.set_condition(1, "second") + assert window.edit.debug_breakpoints() == [Breakpoint(window.uri, 2, "second")] + + def test_a_tab_without_a_file_has_none_to_give(self, window): + window.edit.breakpoint_manager.toggle(0) + window.edit.current_file = None + assert window.edit.debug_breakpoints() == [] + + def test_toggling_while_debugging_reaches_the_session(self, window, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + window.edit.toggle_breakpoint() + assert fake.breakpoints == {window.uri: [Breakpoint(window.uri, 1)]} + + def test_toggling_when_not_debugging_stays_in_the_editor(self, window, fake): + window.edit.toggle_breakpoint() + assert (fake.breakpoints, window.edit.sync_debug_breakpoints()) == ({}, False) + + +class TestTheExecutionLine: + def test_marking_and_unmarking(self, window): + assert window.edit.set_execution_line(2) is True + assert (window.edit.execution_line(), window.edit.textCursor().blockNumber()) == (2, 1) + assert window.edit.set_execution_line(None) is False + assert window.edit.execution_line() is None + + def test_a_line_that_does_not_exist_marks_nothing(self, window): + assert window.edit.set_execution_line(99) is False + assert window.edit.execution_line() is None + + def test_the_mark_is_painted_as_a_full_line(self, window): + window.edit.set_execution_line(2) + blocks = [selection.cursor.blockNumber() for selection in window.edit.extraSelections()] + assert blocks.count(1) >= 2 # the current line and the execution line + + def test_showing_a_frame_opens_its_file_and_marks_the_line(self, window): + assert show_execution_line(window, window.tab.current_file, 3) is True + window.go_to_new_tab.assert_called_once_with(window.tab.current_file) + assert window.edit.execution_line() == 3 + + def test_a_frame_without_a_source_marks_nothing(self, window): + window.edit.set_execution_line(2) + assert show_execution_line(window, "", 1) is False + assert window.edit.execution_line() is None + + def test_clearing_reaches_every_editor(self, window): + window.edit.set_execution_line(2) + clear_execution_lines(window) + assert window.edit.execution_line() is None + + +class TestSteppingFromTheEditor: + @pytest.mark.parametrize("action, expected", [ + ("over", ("step", StepKind.OVER)), ("into", ("step", StepKind.INTO)), + ("out", ("step", StepKind.OUT)), ("continue", ("resume",)), ("quit", ("terminate",)), + ]) + def test_a_shortcut_goes_to_the_session_while_debugging(self, window, controller, fake, + action, expected): + controller.launch(DebugLaunchRequest("main.py"), {}) + fake.stop_at() + assert window.edit.send_debugger_command(action) is True + assert fake.calls[-1] == expected + + def test_an_unknown_action_is_not_sent(self, window, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + assert window.edit.send_debugger_command("dance") is False + + def test_without_a_session_it_falls_to_the_pdb_console(self, window): + with patch.object(window.edit, "_write_debugger_line", return_value=True) as written: + assert window.edit.send_debugger_command("over") is True + written.assert_called_once_with("next") + + +class TestTheActions: + def test_a_window_without_a_controller_has_none(self): + assert controller_of(SimpleNamespace()) is None + assert controller_of(SimpleNamespace(debug_controller=MagicMock())) is None + + def test_a_controller_without_its_adapter_does_not_count(self): + assert controller_of(SimpleNamespace(debug_controller=DebugController(EditorServices()))) is None + + def test_breakpoints_are_collected_from_every_open_file(self, window): + window.edit.breakpoint_manager.set_condition(0, "first") + assert collect_breakpoints(window) == {window.uri: [Breakpoint(window.uri, 1, "first")]} + assert collect_breakpoints(SimpleNamespace()) == {} + + def test_starting_launches_the_file_with_its_breakpoints(self, window, fake): + window.edit.breakpoint_manager.toggle(2) + window.python_compiler = "C:/env/python.exe" + with patch.object(debug_actions, "show_debug_panel") as shown: + assert start_debugging(window, window.tab.current_file) is True + launch = fake.calls[0][1] + assert (launch.program, launch.interpreter) == (window.tab.current_file, "C:/env/python.exe") + assert fake.breakpoints == {window.uri: [Breakpoint(window.uri, 3)]} + shown.assert_called_once_with(window) + + def test_starting_is_refused_without_a_file_or_while_debugging(self, window, controller): + with patch.object(debug_actions, "show_debug_panel"): + assert start_debugging(window, None) is False + assert start_debugging(window, window.tab.current_file) is True + assert start_debugging(window, window.tab.current_file) is False + + def test_attaching_to_an_address(self, window, fake): + with patch.object(debug_actions, "show_debug_panel"): + assert attach_to_process(window, "10.0.0.2:5678") is True + assert fake.calls == [("attach", DebugAttachRequest(5678, "10.0.0.2"))] + + def test_an_address_that_makes_no_sense_is_explained(self, window, controller, fake): + told: list = [] + controller.output.connect(lambda _category, text: told.append(text)) + with patch.object(debug_actions, "show_debug_panel"): + assert attach_to_process(window, "nonsense") is False + assert (fake.calls, "host:port" in told[0]) == ([], True) + + def test_cancelling_the_address_dialog_attaches_nothing(self, window, fake): + with patch.object(debug_actions.QInputDialog, "getText", return_value=("1.2.3.4:5", False)): + assert attach_to_process(window) is False + assert fake.calls == [] + + def test_a_condition_is_set_on_the_carets_line(self, window): + cursor = window.edit.textCursor() + cursor.movePosition(cursor.MoveOperation.Down) + window.edit.setTextCursor(cursor) + assert edit_breakpoint_condition(window, " second > 1 ") is True + assert window.edit.breakpoint_manager.breakpoints() == [(1, "second > 1")] + + def test_the_condition_dialog_starts_from_the_current_condition(self, window): + window.edit.breakpoint_manager.set_condition(0, "old") + with patch.object(debug_actions.QInputDialog, "getText", return_value=("new", True)) as asked: + assert edit_breakpoint_condition(window) is True + assert asked.call_args.kwargs["text"] == "old" + assert "line 1" in asked.call_args.args[2] + assert window.edit.breakpoint_manager.condition(0) == "new" + + def test_cancelling_the_condition_dialog_changes_nothing(self, window): + with patch.object(debug_actions.QInputDialog, "getText", return_value=("x", False)): + assert edit_breakpoint_condition(window) is False + assert window.edit.breakpoint_manager.breakpoints() == [] + + def test_a_condition_set_while_debugging_reaches_the_session(self, window, controller, fake): + controller.launch(DebugLaunchRequest("main.py"), {}) + edit_breakpoint_condition(window, "first") + assert fake.breakpoints == {window.uri: [Breakpoint(window.uri, 1, "first")]} + + def test_no_editor_tab_means_no_condition(self): + assert edit_breakpoint_condition(SimpleNamespace(tab_widget=QTabWidget()), "x") is False + + +class TestRunDebuggerFromTheMenu: + def test_the_adapter_is_used_when_there_is_one(self, window, fake): + with patch.object(build_debug_menu, "choose_file_get_save_file_path", return_value=True), \ + patch.object(debug_actions, "show_debug_panel"): + build_debug_menu.run_debugger(window) + assert fake.calls[0][0] == "launch" + assert window.tab.exec_python_debugger is None + + def test_an_unsaved_file_starts_nothing(self, window, fake): + with patch.object(build_debug_menu, "choose_file_get_save_file_path", return_value=False): + build_debug_menu.run_debugger(window) + assert fake.calls == [] + + def test_a_second_run_while_debugging_is_refused_with_a_message(self, window, controller): + controller.launch(DebugLaunchRequest("main.py"), {}) + with patch.object(build_debug_menu, "please_close_current_running_messagebox") as told: + build_debug_menu.run_debugger(window) + told.assert_called_once_with(window) + + def test_without_an_adapter_the_pdb_console_is_started(self, window): + window.debug_controller = DebugController(EditorServices()) + with patch.object(build_debug_menu, "choose_file_get_save_file_path", return_value=True), \ + patch.object(build_debug_menu, "ExecManager") as manager, \ + patch.object(build_debug_menu, "ProcessInput"): + build_debug_menu.run_debugger(window) + manager.return_value.exec_code.assert_called_once_with( + window.tab.current_file, exec_prefix=["-m", "pdb"]) + + +@pytest.mark.skipif(not debugpy_available(), reason="debugpy is not installed") +class TestAgainstTheRealAdapter: + def test_a_program_is_debugged_from_the_panel(self, qtbot, tmp_path): + program = tmp_path / "program.py" + program.write_text("def add(first, second):\n total = first + second\n return total\n" + "\n\nprint('sum', add(1, 2))\n", encoding="utf-8") + services = build_default_services(settings_directory=tmp_path) + controller = DebugController(services) + panel = DebugPanelWidget(controller) + qtbot.addWidget(panel) + uri = to_uri(program) + try: + assert controller.launch(DebugLaunchRequest(str(program)), {uri: [Breakpoint(uri, 2)]}) + qtbot.waitUntil(lambda: panel.stack_list.count() > 0, timeout=TIMEOUT_MS) + assert panel.stack_list.item(0).text() == "add program.py:2" + qtbot.waitUntil(lambda: panel.variable_tree.topLevelItemCount() > 0 + and panel.variable_tree.topLevelItem(0).childCount() == 2, + timeout=TIMEOUT_MS) + scope = panel.variable_tree.topLevelItem(0) + assert {scope.child(row).text(0): scope.child(row).text(1) for row in range(2)} == { + "first": "1", "second": "2"} + panel.continue_button.click() + qtbot.waitUntil(lambda: controller.state() is DebugState.TERMINATED, timeout=TIMEOUT_MS) + QApplication.processEvents() + assert "sum 3" in panel.output_view.toPlainText() + finally: + controller.stop() + services.shutdown() diff --git a/test/test_debugpy_integration.py b/test/test_debugpy_integration.py index 0fb3736..2d8f886 100644 --- a/test/test_debugpy_integration.py +++ b/test/test_debugpy_integration.py @@ -9,7 +9,7 @@ import queue import socket -import subprocess +import subprocess # nosec B404 - 啟動一個等待除錯器的程式來測試「接上」 import sys import threading import time @@ -222,7 +222,9 @@ def test_attaching_to_a_program_that_waits_for_a_debugger(self, session, tmp_pat with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] - waiting = subprocess.Popen( + # 這台機器的直譯器與剛寫出來的測試檔,以引數清單啟動 + # This machine's interpreter and the test file just written, started from an argument list + waiting = subprocess.Popen( # nosemgrep # noqa: S603 # nosec B603 [sys.executable, "-m", "debugpy", "--listen", f"127.0.0.1:{port}", "--wait-for-client", program], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: