diff --git a/README.md b/README.md index 929b87e0..f5583717 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,17 @@ The editor launches in a maximized window with a dark amber theme by default. - **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. - **Code Folding** -- Collapse and expand indented blocks (functions, classes, loops) from the gutter fold triangles or the keyboard. Folding is indentation-based and only toggles line visibility -- it never modifies the text, so saving always writes the complete file. Folds self-heal after edits: a fold whose header no longer exists simply reopens instead of hiding the wrong lines. - **Bookmarks** -- Mark lines and jump between them with the keyboard, or click the gutter to toggle. Bookmarks are anchored to the text (via `QTextCursor`), so they follow their code when lines are inserted or removed above them instead of drifting. +- **Multiple Carets** -- `Ctrl+Shift+L` puts a caret at the end of every selected line, and `Alt`-click adds or removes one anywhere. Typing and Backspace then apply at all of them as a single undo step; `Esc` or a plain click returns to one caret. +- **Split View** (`Ctrl+Alt+\`) -- A second view of the same file, sharing one document: an edit in either side shows up in the other at once, while scrolling and the caret stay independent. +- **Minimap** (`Ctrl+Alt+M`) -- An overview of the whole file drawn as bars following each line's length and indentation, with a band marking what is on screen. Click or drag to jump. Large files are sampled rather than drawn line by line. +- **Snippets** -- Type a trigger word and press Tab to expand it, then Tab through the placeholders with each default value selected. Uses the usual `$1` / `${2:default}` / `$0` notation, so existing snippets drop straight into `snippets.json`; a missing or broken file falls back to the built-in Python set. +- **Test Panel** -- Run pytest from a dock panel and read the results, failures first, with the summary as the status line. Double-click a row to open that test, jumping to the failing line. +- **Language Server Support** -- Non-Python files complete through a language server over stdio (TypeScript, Rust, Go, C/C++, Lua, JSON and more, configurable per suffix), while Python keeps using jedi. A server that is not installed simply means no completions rather than an error. +- **Encoding & Line Endings** -- A file's encoding and line-ending style are detected when it opens and written back unchanged when it saves, so editing one line of a CRLF file no longer rewrites every line. Both can be changed from the File menu; changing the encoding re-reads an unmodified file so mojibake can be fixed in place, and never discards unsaved work. +- **Format on Save** -- Optionally run yapf when a file is saved, keeping the caret on its line. Source that cannot be parsed is left alone rather than blocking the save. +- **Indent Guides & Trailing Whitespace** -- A vertical guide at each indentation level and shading on stray end-of-line whitespace, both toggleable from the Style menu. +- **Lint Diagnostics** -- Findings from `ruff` are underlined in the editor and listed in a Problems dock panel (rule, message, line), with double-click to jump. The **buffer** is checked, not the file on disk, so unsaved edits are covered; the run happens on a worker thread after typing pauses, and a stale result from a superseded run is discarded. If `ruff` is not installed, or a run fails, the editor simply shows no diagnostics rather than reporting an error. +- **Git Change Markers** -- The gutter shows how the file differs from its last commit: a green bar for added lines, orange for modified, and a thin red line where lines were deleted. Jump between changes with `Ctrl+Alt+Down` / `Ctrl+Alt+Up`, revert the change under the caret back to its committed form with `Ctrl+Alt+Z` (one undo step), and open a side-by-side diff of the whole file against HEAD from the Git menu. `Ctrl+Alt+B` toggles inline blame, showing the commit, author and summary that last touched each line. The committed version is read on a background thread when the file opens, and the comparison itself is a pure in-memory diff, recomputed only after typing pauses -- so editing never waits on git. Files outside a repository, or not yet committed, simply show no markers. - **Occurrence Highlighting** -- Placing the caret on an identifier highlights every other whole-word occurrence of it in the file. Keywords and single characters are ignored, and the scan is skipped on very large files to keep caret movement instant. - **Line Operations** -- Delete the current line or selection (`Ctrl+Shift+D`), sort selected lines (`Ctrl+Alt+S`), join selected lines into one (`Ctrl+Shift+J`), and (from the Text menu) natural sort, remove duplicate lines, remove blank lines, reverse line order, or align lines on a delimiter (e.g. `=`). Each is a single undo step. - **Duplicate** (`Ctrl+D`) -- Duplicates the selection when there is one (selecting the new copy), or the whole line when there isn't. @@ -295,6 +306,7 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See | `Ctrl+O` | Open file | | `Ctrl+K` | Open folder | | `Ctrl+S` | Save file | +| `Ctrl+Shift+S` | Save every modified tab | | `Ctrl+Shift+A` | Command palette | | `Ctrl+P` | Quick open (go to file) | | `Ctrl+Shift+O` | Go to symbol | @@ -314,13 +326,33 @@ Plugins are automatically discovered from the `jeditor_plugins/` directory. See | `Ctrl+Alt+Up` | Increment number under caret | | `Ctrl+Alt+Down` | Decrement number under caret | | `F2` | Rename occurrences in file | +| `Ctrl+D` | Duplicate line / selection | +| `Alt+Up` / `Alt+Down` | Move line up / down | +| `Ctrl+/` | Toggle comment | +| `Ctrl+Shift+L` | Caret at the end of every selected line | +| `Ctrl+Alt+N` | Add caret at next occurrence | +| `Ctrl+Alt+Shift+Up` / `Down` | Add caret above / below | +| `Ctrl+Shift+Esc` | Back to a single caret | +| `Ctrl+Shift+R` | Start / stop macro recording | +| `Ctrl+Shift+G` | Play macro | +| `Ctrl+Alt+E` | Recent locations | +| `Ctrl+Alt+\` | Toggle split view | +| `Ctrl+Alt+M` | Toggle minimap | +| `F7` / `Shift+F7` | Next / previous change | +| `Ctrl+Alt+Z` | Revert the change at the caret | +| `Ctrl+Alt+B` | Toggle inline blame | | `Ctrl+J` | Reformat JSON | | `Ctrl+Shift+Y` | YAPF Python format | | `Ctrl+Alt+P` | PEP8 format checker | -| `Ctrl+F` | Find text (browser) | +| `Ctrl+F` | Find text (editor, browser) | +| `Ctrl+H` | Find and replace | +| `Ctrl+G` | Go to line | | `F5` | Run program | | `F9` | Debug | | `Shift+F5` | Stop program | +| `Ctrl+F9` | Toggle breakpoint | +| `Ctrl+F5` | Debugger: continue | +| `F10` / `F11` / `Shift+F11` | Debugger: step over / into / out | | `Up/Down` | Command history (console) | --- diff --git a/je_editor/code_scan/ruff_lint.py b/je_editor/code_scan/ruff_lint.py new file mode 100644 index 00000000..5762d444 --- /dev/null +++ b/je_editor/code_scan/ruff_lint.py @@ -0,0 +1,171 @@ +""" +對編輯中的內容執行 ruff,取得診斷 +Run ruff over the text being edited and collect its diagnostics. + +檢查的是**緩衝區內容**而不是磁碟上的檔案,所以還沒存檔的修改也會被檢查; +內容經由標準輸入傳給 ruff,只用 ``--stdin-filename`` 告訴它檔名,好讓 per-file +的設定與規則照常套用。 +This lints the **buffer**, not the file on disk, so unsaved edits are checked +too. The text goes in through standard input and only ``--stdin-filename`` tells +ruff what the file is called, so per-file configuration still applies. +""" +from __future__ import annotations + +import shutil +import subprocess # nosec B404 - 以引數清單呼叫 ruff,未使用 shell +import sys +from pathlib import Path + +from je_editor.utils.lint.ruff_diagnostics import Diagnostic, parse_ruff_json +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# ruff 執行檔名稱 / The ruff executable's name +_RUFF_NAME = "ruff.exe" if sys.platform == "win32" else "ruff" +# 單次檢查的逾時(秒):ruff 很快,超過就是出了別的問題 +# Timeout for one run: ruff is fast, so exceeding this means something else is wrong +LINT_TIMEOUT_SECONDS = 20 +# 可以檢查的副檔名 / Suffixes worth linting +PYTHON_SUFFIXES = (".py", ".pyi") + + +def find_ruff_executable() -> str | None: + """ + 找出可用的 ruff 執行檔 + Locate a usable ruff executable. + + 先找目前直譯器所在的環境(通常就是專案的虛擬環境),再退回 PATH。 + The running interpreter's environment is tried first, since that is usually + the project's virtual environment, then PATH. + + :return: 執行檔路徑,找不到時為 ``None`` / the path, or ``None`` when absent + """ + beside_interpreter = Path(sys.executable).parent / _RUFF_NAME + if beside_interpreter.is_file(): + return str(beside_interpreter) + return shutil.which("ruff") + + +def is_lintable(file_path: str | Path | None) -> bool: + """ + 判斷這個檔案是否值得檢查 + Whether this file is worth linting. + + :param file_path: 檔案路徑 / the file to consider + :return: 是 Python 檔時為 ``True`` / ``True`` for a Python file + """ + if file_path is None: + return False + return Path(file_path).suffix.lower() in PYTHON_SUFFIXES + + +def lint_command(executable: str, file_path: str | Path) -> list[str]: + """ + 組出檢查用的指令 + Build the command that lints one buffer. + + :param executable: ruff 執行檔 / the ruff executable + :param file_path: 緩衝區對應的檔名 / the name the buffer is saved under + :return: 引數清單(不經過 shell)/ the argument list, never a shell string + """ + return [ + executable, "check", + "--output-format", "json", + "--stdin-filename", str(file_path), + "-", + ] + + +def _run_ruff(arguments: list[str], text: str | None = None) -> str: + """ + 執行一次 ruff 並回傳其標準輸出 + Run ruff once and return what it printed. + + ruff 找到問題時回傳碼是 1,那是結果而不是失敗,因此不檢查回傳碼。 + ruff exits with 1 when it finds problems, which is a result rather than a + failure, so the return code is not inspected. + """ + executable = find_ruff_executable() + if executable is None: + return "" + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + [executable, *arguments], + input=text, + capture_output=True, + text=True, + encoding="utf-8", + timeout=LINT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}") + return "" + return completed.stdout or "" + + +def lint_project(root: str | Path) -> list[Diagnostic]: + """ + 檢查整個專案目錄 + Lint a whole project directory. + + 這裡檢查的是磁碟上的檔案,因此未存檔的修改不會反映在結果中——與單檔檢查 + 不同,那是直接檢查緩衝區。 + This lints the files on disk, so unsaved edits are not reflected, unlike the + single-file check which lints the buffer itself. + + :param root: 專案根目錄 / the project root + :return: 診斷清單,含各自的檔案路徑 / the diagnostics, each with its file + """ + return parse_ruff_json( + _run_ruff(["check", "--output-format", "json", str(root)])) + + +def apply_fixes(target: str | Path) -> bool: + """ + 對檔案或目錄套用 ruff 能自動修正的部分 + Apply the fixes ruff can make to a file or directory. + + 會改寫磁碟上的檔案,因此呼叫端要負責重新載入編輯器中的內容。 + This rewrites the files on disk, so the caller is responsible for reloading + whatever the editor is showing. + + :param target: 檔案或目錄 / the file or directory to fix + :return: ruff 可用且已執行時為 ``True`` / ``True`` when ruff ran + """ + if find_ruff_executable() is None: + return False + _run_ruff(["check", "--fix", "--output-format", "json", str(target)]) + return True + + +def lint_text(text: str, file_path: str | Path) -> list[Diagnostic]: + """ + 檢查一段內容並回傳診斷 + Lint *text* as if it were *file_path*. + + ruff 找到問題時回傳碼是 1,那是正常結果而不是失敗,因此不特別處理回傳碼。 + ruff exits with 1 when it finds problems, which is a result rather than a + failure, so the return code is not inspected. + + :param text: 要檢查的內容 / the text to lint + :param file_path: 內容對應的檔名 / the name the text is saved under + :return: 診斷清單;ruff 不存在或執行失敗時為空清單 + the diagnostics, or an empty list when ruff is missing or fails + """ + executable = find_ruff_executable() + if executable is None: + return [] + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + lint_command(executable, file_path), + input=text, + capture_output=True, + text=True, + encoding="utf-8", + timeout=LINT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.debug(f"ruff_lint: could not run ruff: {error!r}") + return [] + return parse_ruff_json(completed.stdout or "") diff --git a/je_editor/git_client/file_baseline.py b/je_editor/git_client/file_baseline.py new file mode 100644 index 00000000..2ab13a14 --- /dev/null +++ b/je_editor/git_client/file_baseline.py @@ -0,0 +1,72 @@ +""" +讀取檔案在 HEAD 的內容,作為變更標記的比較基準 +Read a file's committed content, to serve as the baseline for change markers. + +編輯器需要知道「上次提交時這個檔案長什麼樣」才能標出改動。這裡只做讀取, +不執行任何會改動工作區的 git 指令。 +The editor needs to know what a file looked like at the last commit before it +can mark what changed. This only reads; it never runs a git command that +touches the working tree. +""" +from __future__ import annotations + +from pathlib import Path + +from git import InvalidGitRepositoryError, NoSuchPathError, Repo +from git.exc import GitError + +from je_editor.utils.logging.loggin_instance import jeditor_logger + + +def open_repository(file_path: str | Path) -> Repo | None: + """ + 找出包含此檔案的 git 儲存庫 + Find the git repository that contains *file_path*. + + :param file_path: 檔案路徑 / the file to locate + :return: 儲存庫,不在儲存庫內時為 ``None`` / the repository, or ``None`` + """ + try: + return Repo(Path(file_path).parent, search_parent_directories=True) + except (InvalidGitRepositoryError, NoSuchPathError, ValueError, OSError): + # 不在 git 儲存庫內是常態,不是錯誤 / Not being in a repo is normal + return None + + +def baseline_text(file_path: str | Path) -> str | None: + """ + 取得檔案在 HEAD 的文字內容(行尾統一為 ``\\n``) + Return the file's content as of HEAD, with line endings normalised to ``\\n``. + + 編輯器的文件一律以 ``\\n`` 表示換行,基準也統一成同樣形式,比對才不會受 + 儲存庫的行尾設定(例如 Windows 的 ``core.autocrlf``)影響。 + A Qt document always uses ``\\n``, so the baseline is normalised to match and + the comparison is unaffected by how the repository stores line endings (for + example ``core.autocrlf`` on Windows). + + :param file_path: 檔案路徑 / the file to read + :return: HEAD 版本的文字;不在儲存庫、尚未提交過、或不是文字檔時為 ``None`` + the committed text, or ``None`` when the file is not in a repository, + has never been committed, or is not text + """ + repo = open_repository(file_path) + if repo is None: + return None + # 每個 Repo 都會開著常駐的 git 子程序,用完一定要關掉 + # Every Repo keeps long-lived git subprocesses open, so it must be closed + with repo: + try: + relative = Path(file_path).resolve().relative_to( + Path(repo.working_tree_dir).resolve()) + blob = repo.head.commit.tree / relative.as_posix() + text = blob.data_stream.read().decode("utf-8") + return text.replace("\r\n", "\n").replace("\r", "\n") + except UnicodeDecodeError: + # 必須排在 ValueError 之前,否則永遠輪不到它 + # Must come before ValueError, which is its base class + jeditor_logger.debug("file_baseline: %s is not utf-8 text", file_path) + return None + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + # 新檔案、尚無提交、或路徑不在工作區內 + # New file, no commit yet, or outside the tree + return None diff --git a/je_editor/git_client/file_blame.py b/je_editor/git_client/file_blame.py new file mode 100644 index 00000000..456d44a4 --- /dev/null +++ b/je_editor/git_client/file_blame.py @@ -0,0 +1,98 @@ +""" +取得檔案每一行最後修改的提交 +Find the commit that last touched each line of a file. + +用於在編輯器行尾顯示「這行是誰、哪個提交改的」。只讀取,不改動工作區。 +Used to show who last changed a line, and in which commit, at the end of that +line in the editor. Read-only; the working tree is never touched. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from git.exc import GitError + +from je_editor.git_client.file_baseline import open_repository +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 顯示用的提交編號長度 / How much of the commit hash to show +SHORT_HASH_LENGTH = 8 +# 摘要過長時截斷的長度 / Where an over-long summary is cut +MAX_SUMMARY_LENGTH = 60 + + +@dataclass(frozen=True) +class BlameLine: + """ + 一行的提交資訊 + The commit information for one line. + + :param commit: 提交編號(已截短)/ the shortened commit hash + :param author: 作者名稱 / the author's name + :param summary: 提交訊息第一行 / the first line of the commit message + """ + + commit: str + author: str + summary: str + + @property + def annotation(self) -> str: + """行尾顯示的一行文字 / The single line shown at the end of the code line.""" + return f"{self.commit} {self.author} {self.summary}" + + +def _short_summary(message: object) -> str: + """取提交訊息的第一行並截斷 / Take the first line of a commit message, shortened.""" + text = str(message or "").strip().splitlines() + if not text: + return "" + first = text[0] + return first if len(first) <= MAX_SUMMARY_LENGTH else f"{first[:MAX_SUMMARY_LENGTH]}…" + + +def blame_lines(file_path: str | Path) -> dict[int, BlameLine]: + """ + 取得檔案每一行的提交資訊 + Return the commit information for each line of a file. + + :param file_path: 檔案路徑 / the file to annotate + :return: 以 0 起算的行號對應提交資訊;無法取得時為空字典 + 0-based line number -> its commit, or an empty mapping when unavailable + """ + repo = open_repository(file_path) + if repo is None: + return {} + # 每個 Repo 都會開著常駐的 git 子程序,用完一定要關掉 + # Every Repo keeps long-lived git subprocesses open, so it must be closed + with repo: + try: + relative = Path(file_path).resolve().relative_to( + Path(repo.working_tree_dir).resolve()) + entries = repo.blame("HEAD", relative.as_posix()) + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError) as error: + # 未提交過、不在工作區內、或沒有 HEAD + # Never committed, outside the tree, or no HEAD + jeditor_logger.debug(f"file_blame: no blame for {file_path}: {error!r}") + return {} + # 提交資料在 Repo 關閉前就要讀完,關閉之後就取不到了 + # The commit data is read before the Repo closes; afterwards it is gone + return _annotations_from(entries) + + +def _annotations_from(entries: object) -> dict[int, BlameLine]: + """把 GitPython 的 blame 結果轉成逐行標註 / Turn a blame result into per-line notes.""" + annotations: dict[int, BlameLine] = {} + line_number = 0 + for entry in entries or []: + commit, lines = entry[0], entry[1] + blame = BlameLine( + commit=str(commit.hexsha)[:SHORT_HASH_LENGTH], + author=str(getattr(commit.author, "name", "")), + summary=_short_summary(getattr(commit, "summary", "")), + ) + for _line in lines or []: + annotations[line_number] = blame + line_number += 1 + return annotations diff --git a/je_editor/git_client/file_staging.py b/je_editor/git_client/file_staging.py new file mode 100644 index 00000000..76aeb454 --- /dev/null +++ b/je_editor/git_client/file_staging.py @@ -0,0 +1,96 @@ +""" +把內容寫進 git 索引,並讀回已暫存的版本 +Write content into the git index, and read the staged version back. + +``GitService`` 只能一次暫存整個檔案;要「只暫存這一段」就得自己組出該版本的內容 +再寫進索引,工作區的檔案則保持不動。 +``GitService`` can only stage a whole file. Staging one hunk means assembling the +content that version would have and writing it into the index, while the file in +the working tree is left untouched. +""" +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +from git import Blob, IndexEntry +from git.exc import GitError +from gitdb.base import IStream + +from je_editor.git_client.file_baseline import open_repository +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# git 中一般檔案的模式 / The mode git uses for a normal file +_FILE_MODE = 0o100644 + + +def _relative_to(repo, file_path: str | Path) -> str | None: + """取得檔案相對於工作區的路徑 / The file's path relative to the working tree.""" + try: + relative = Path(file_path).resolve().relative_to(Path(repo.working_tree_dir).resolve()) + except (ValueError, TypeError, AttributeError, OSError): + return None + return relative.as_posix() + + +def staged_text(file_path: str | Path) -> str | None: + """ + 取得檔案目前在索引中的內容 + Return the file's content as it currently stands in the index. + + :param file_path: 檔案路徑 / the file to read + :return: 已暫存的文字;不在索引或不是文字時為 ``None`` + the staged text, or ``None`` when it is not in the index or not text + """ + repo = open_repository(file_path) + if repo is None: + return None + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return None + try: + entry = repo.index.entries[(relative, 0)] + blob = Blob(repo, entry.binsha, entry.mode, relative) + text = blob.data_stream.read().decode("utf-8") + except UnicodeDecodeError: + # 必須排在 ValueError 之前,否則永遠輪不到它 + # Must come before ValueError, which is its base class + jeditor_logger.debug(f"file_staging: {file_path} is not utf-8 text") + return None + except (KeyError, ValueError, TypeError, AttributeError, GitError, OSError): + return None + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def stage_content(file_path: str | Path, content: str) -> bool: + """ + 把指定內容寫進索引,工作區的檔案不動 + Write the given content into the index, leaving the working tree alone. + + 這正是「只暫存這一段」的做法:索引拿到套用了該段變更的版本,磁碟上的檔案仍是 + 使用者正在編輯的樣子。 + This is how one hunk is staged: the index receives the version with that hunk + applied while the file on disk stays as the user is editing it. + + :param file_path: 檔案路徑 / the file to stage + :param content: 要暫存的內容 / the content to stage + :return: 寫入成功時為 ``True`` / ``True`` when the index was updated + """ + repo = open_repository(file_path) + if repo is None: + return False + with repo: + relative = _relative_to(repo, file_path) + if relative is None: + return False + data = content.encode("utf-8") + try: + stream = repo.odb.store(IStream(Blob.type, len(data), BytesIO(data))) + repo.index.add([IndexEntry.from_blob( + Blob(repo, stream.binsha, _FILE_MODE, relative))]) + repo.index.write() + except (ValueError, TypeError, AttributeError, GitError, OSError) as error: + jeditor_logger.error(f"file_staging: could not stage {file_path}: {error!r}") + return False + return True diff --git a/je_editor/pyside_ui/code/auto_save/auto_save_manager.py b/je_editor/pyside_ui/code/auto_save/auto_save_manager.py index a46c1417..06acc842 100644 --- a/je_editor/pyside_ui/code/auto_save/auto_save_manager.py +++ b/je_editor/pyside_ui/code/auto_save/auto_save_manager.py @@ -49,6 +49,9 @@ def init_new_auto_save_thread(file_path: str, widget: EditorWidget) -> None: file_to_save=widget.current_file, editor=widget.code_edit, before_write_callback=widget.mark_ignore_next_file_change, ) + # 沿用該分頁偵測到的編碼與行尾 / Keep the tab's detected encoding and line ending + widget.code_save_thread.encoding = getattr(widget, "file_encoding", "utf-8") + widget.code_save_thread.line_ending = getattr(widget, "line_ending", "\n") # 更新管理字典,記錄該檔案對應的執行緒 # Update manager dict with the new thread diff --git a/je_editor/pyside_ui/code/auto_save/auto_save_thread.py b/je_editor/pyside_ui/code/auto_save/auto_save_thread.py index e8f2852c..792aee8b 100644 --- a/je_editor/pyside_ui/code/auto_save/auto_save_thread.py +++ b/je_editor/pyside_ui/code/auto_save/auto_save_thread.py @@ -6,7 +6,8 @@ from PySide6.QtCore import QObject, Qt, Signal, Slot from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor -from je_editor.utils.file.save.save_file import write_file +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF +from je_editor.utils.file.save.save_file import write_file_with_encoding from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -66,6 +67,11 @@ def __init__( self.daemon = True self.skip_this_round: bool = False self.before_write_callback = before_write_callback + # 自動儲存要沿用該檔案原本的編碼與行尾,否則背景存檔會悄悄改寫整份檔案 + # Auto-save keeps the file's own encoding and line ending, or a background + # save would quietly rewrite the whole file + self.encoding: str = DEFAULT_ENCODING + self.line_ending: str = LINE_ENDING_LF # 建立主執行緒上的文字提取器 / Create text fetcher on main thread self._text_fetcher: Union[_TextFetcher, None] = None if editor is not None: @@ -91,7 +97,7 @@ def _attempt_save(self) -> None: return if self.before_write_callback is not None: self.before_write_callback() - write_file(self.file, text) + write_file_with_encoding(self.file, text, self.encoding, self.line_ending) except (OSError, RuntimeError) as e: jeditor_logger.error(f"Auto-save failed for {self.file}: {e}") diff --git a/je_editor/pyside_ui/code/breakpoint/__init__.py b/je_editor/pyside_ui/code/breakpoint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py new file mode 100644 index 00000000..f766e657 --- /dev/null +++ b/je_editor/pyside_ui/code/breakpoint/breakpoint_manager.py @@ -0,0 +1,87 @@ +""" +管理一個編輯器的中斷點 +Track the breakpoints set in one editor. + +中斷點跟著文字走:在上方插入或刪除行時,標記要跟著移動,否則加一行就會讓每個 +中斷點都指到錯的地方。書籤已經用 ``QTextCursor`` 解決過同樣的問題,這裡沿用。 +Breakpoints follow the text: inserting or removing a line above one has to move +it, or adding a line would leave every breakpoint pointing at the wrong place. +Bookmarks already solve this with ``QTextCursor``, and the same approach is used +here. +""" +from __future__ import annotations + +from PySide6.QtGui import QTextCursor + + +class BreakpointManager: + """ + 追蹤中斷點所在的行 + Track which lines have a breakpoint. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些中斷點所屬的編輯器 / the editor they belong to + """ + self._code_edit = code_edit + self._cursors: list[QTextCursor] = [] + + def lines(self) -> list[int]: + """ + 取得目前有中斷點的行(0 起算,已排序) + The lines that currently have a breakpoint, 0-based and sorted. + + :return: 行號 / the line numbers + """ + return sorted({cursor.blockNumber() for cursor in self._cursors}) + + def has_breakpoint(self, line: int) -> bool: + """ + 判斷某行是否有中斷點 + Whether a line has a breakpoint. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 有的話為 ``True`` / ``True`` when it has one + """ + return line in self.lines() + + def toggle(self, line: int) -> bool: + """ + 切換某一行的中斷點 + Add or remove the breakpoint on a line. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 切換後是否有中斷點 / whether the line now has one + """ + for cursor in list(self._cursors): + if cursor.blockNumber() == line: + self._cursors.remove(cursor) + return False + block = self._code_edit.document().findBlockByNumber(line) + if not block.isValid(): + return False + cursor = QTextCursor(block) + self._cursors.append(cursor) + return True + + def clear(self) -> bool: + """ + 清除所有中斷點 + Remove every breakpoint. + + :return: 是否真的清掉了什麼 / whether anything was removed + """ + if not self._cursors: + return False + self._cursors = [] + return True + + def pdb_lines(self) -> list[int]: + """ + 取得給 pdb 用的行號(1 起算) + The line numbers as pdb counts them, from one. + + :return: 行號 / the line numbers + """ + return [line + 1 for line in self.lines()] diff --git a/je_editor/pyside_ui/code/git_diff/__init__.py b/je_editor/pyside_ui/code/git_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/git_diff/blame_manager.py b/je_editor/pyside_ui/code/git_diff/blame_manager.py new file mode 100644 index 00000000..b02bc984 --- /dev/null +++ b/je_editor/pyside_ui/code/git_diff/blame_manager.py @@ -0,0 +1,149 @@ +""" +管理編輯器的行內 blame 標註 +Hold the inline blame annotations for one editor. + +blame 要跑 git,因此在背景執行緒取得;只有使用者開啟顯示時才會去取。 +Blame runs git, so it is fetched on a worker thread, and only once the user has +actually turned the display on. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal + +from je_editor.git_client.file_blame import BlameLine, blame_lines + + +class BlameLoader(QThread): + """ + 在背景取得檔案的 blame 資訊 + Fetch a file's blame off the UI thread. + """ + + loaded = Signal(object) # dict[int, BlameLine] + + def __init__(self, file_path: str | Path, parent=None) -> None: + """ + :param file_path: 要取得 blame 的檔案 / the file to annotate + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("BlameLoader") + self._file_path = file_path + + def run(self) -> None: + """取得並回報 blame 資訊 / Fetch the blame and report it.""" + self.loaded.emit(blame_lines(self._file_path)) + + +class BlameManager(QObject): + """ + 追蹤行內 blame 的開關與內容 + Track whether inline blame is shown, and what it says. + + 是編輯器的 QObject 子物件,取得執行緒的訊號才有接收者可斷開。 + A QObject child of the editor, so the loader's signal has a receiver Qt can + disconnect. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些標註所屬的編輯器 / the editor being annotated + """ + super().__init__(code_edit) + self._code_edit = code_edit + self._annotations: dict[int, BlameLine] = {} + self._enabled = False + self._loader: BlameLoader | None = None + + @property + def enabled(self) -> bool: + """目前是否顯示 blame / Whether the annotations are being shown.""" + return self._enabled + + def annotation(self, line: int) -> str: + """ + 取得某行的標註文字 + The annotation text for one line. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 標註文字,關閉或該行無資料時為空字串 / the text, or an empty string + """ + if not self._enabled: + return "" + blame = self._annotations.get(line) + return blame.annotation if blame is not None else "" + + def set_annotations(self, annotations: dict[int, BlameLine]) -> None: + """ + 套用一組標註 + Apply a set of annotations. + + :param annotations: 行號對應的提交資訊 / line number -> commit information + """ + self._annotations = dict(annotations) + + def clear(self) -> None: + """清除標註並關閉顯示 / Drop the annotations and turn the display off.""" + self._annotations = {} + self._enabled = False + + def toggle(self, file_path: str | Path | None) -> bool: + """ + 切換顯示;開啟時在背景取得資料 + Toggle the display, fetching the data in the background when turning on. + + :param file_path: 目前編輯的檔案 / the file being edited + :return: 切換後是否為開啟 / whether the display is now on + """ + self.stop() + if self._enabled: + self._enabled = False + self._annotations = {} + return False + if file_path is None: + return False + self._enabled = True + loader = BlameLoader(file_path, self) + self._loader = loader + loader.loaded.connect(self._on_loaded) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + loader.finished.connect(self._on_loader_finished) + loader.finished.connect(loader.deleteLater) + loader.start() + return True + + def _on_loader_finished(self) -> None: + """取得結束後放掉參考 / Let go of the loader once it has finished.""" + if self.sender() is self._loader: + self._loader = None + + def _on_loaded(self, annotations: dict) -> None: + """ + 套用背景取得的結果 + Apply annotations that finished loading. + + 只接受目前這個 loader 的結果 / Only the current loader's result is accepted. + """ + if self.sender() is not self._loader: + return + self.set_annotations(annotations) + self._code_edit.viewport().update() + + def stop(self) -> None: + """結束仍在進行的取得 / Stop a fetch that is still running.""" + loader, self._loader = self._loader, None + if loader is None: + return + try: + if loader.isRunning(): + loader.blockSignals(True) + loader.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return diff --git a/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py new file mode 100644 index 00000000..a27c2ea7 --- /dev/null +++ b/je_editor/pyside_ui/code/git_diff/diff_marker_manager.py @@ -0,0 +1,224 @@ +""" +管理編輯器的 git 變更標記狀態 +Hold the git change-marker state for one editor. + +基準文字(HEAD 版本)由背景執行緒讀取,因為那是 git 與檔案 I/O; +之後每次重算都只是記憶體內的比對,因此可以在輸入時便宜地重算。 +The baseline (the HEAD version) is read on a background thread because that is +git and file I/O; recomputing against it afterwards is a pure in-memory +comparison, cheap enough to redo while typing. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QThread, Signal + +from je_editor.git_client.file_baseline import baseline_text +from je_editor.utils.file_diff.line_status import ( + Hunk, + baseline_lines_of, + hunk_at_line, + line_statuses, + next_changed_line, + previous_changed_line, +) + + +class BaselineLoader(QThread): + """ + 在背景讀取檔案的 HEAD 內容 + Read a file's HEAD content off the UI thread. + """ + + loaded = Signal(object) # str | None + + def __init__(self, file_path: str | Path, parent=None) -> None: + """ + :param file_path: 要讀取基準的檔案 / the file whose baseline to read + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("BaselineLoader") + self._file_path = file_path + + def run(self) -> None: + """讀取並回報基準文字 / Read the baseline and report it.""" + self.loaded.emit(baseline_text(self._file_path)) + + +class DiffMarkerManager(QObject): + """ + 追蹤緩衝區相對於已提交版本的逐行差異 + Track how the buffer differs, line by line, from its committed version. + + 是編輯器的 QObject 子物件,讀取執行緒的訊號才有接收者可斷開;否則編輯器銷毀 + 後,佇列中的結果會打到已經釋放的物件上。 + A QObject child of the editor, so the loader's signal has a receiver Qt can + disconnect; otherwise a queued result would arrive at an editor that is + already gone. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些標記所屬的編輯器 / the editor these markers belong to + """ + super().__init__(code_edit) + self._code_edit = code_edit + self._baseline: str | None = None + self._statuses: dict[int, str] = {} + self._loader: BaselineLoader | None = None + + @property + def has_baseline(self) -> bool: + """是否已取得基準(檔案在 git 中且有提交過)/ Whether a baseline is known.""" + return self._baseline is not None + + def baseline(self) -> str | None: + """ + 取得目前的基準文字 + Return the baseline the markers are computed against. + + :return: HEAD 版本的內容,沒有基準時為 ``None`` / the committed text, or ``None`` + """ + return self._baseline + + def set_baseline(self, text: str | None) -> None: + """ + 設定基準文字並重算標記 + Set the baseline and recompute the markers. + + :param text: HEAD 版本的內容,``None`` 表示沒有基準 + the committed content, or ``None`` when there is none + """ + self._baseline = text + self.refresh() + + def clear(self) -> None: + """清除基準與標記 / Forget the baseline and every marker.""" + self._baseline = None + self._statuses = {} + + def refresh(self) -> bool: + """ + 依目前緩衝區內容重算標記 + Recompute the markers from the buffer's current text. + + :return: 標記是否改變(未改變時呼叫端可省下重繪) + whether the markers changed, so the caller can skip a repaint + """ + if self._baseline is None: + changed = bool(self._statuses) + self._statuses = {} + return changed + statuses = line_statuses(self._baseline, self._code_edit.toPlainText()) + if statuses == self._statuses: + return False + self._statuses = statuses + return True + + def status(self, line: int) -> str | None: + """ + 取得某一行的狀態 + Return one line's status. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 狀態字串,未變更時為 ``None`` / the status, or ``None`` + """ + return self._statuses.get(line) + + def statuses(self) -> dict[int, str]: + """取得所有變更行的狀態副本 / A copy of every changed line's status.""" + return dict(self._statuses) + + def next_change(self, line: int) -> int | None: + """下一個變更行 / The next changed line, wrapping around.""" + return next_changed_line(self._statuses, line) + + def previous_change(self, line: int) -> int | None: + """上一個變更行 / The previous changed line, wrapping around.""" + return previous_changed_line(self._statuses, line) + + def hunk_at(self, line: int) -> Hunk | None: + """ + 取得包含指定行的變更區塊 + Return the hunk of change containing *line*. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 變更區塊,該行沒有變更時為 ``None`` / the hunk, or ``None`` + """ + if self._baseline is None: + return None + return hunk_at_line(self._baseline, self._code_edit.toPlainText(), line) + + def baseline_lines(self, hunk: Hunk) -> list[str]: + """ + 取得一段變更在基準中的原始內容 + The baseline lines a hunk replaced. + + :param hunk: 目標變更區塊 / the hunk to look up + :return: 原始行 / the original lines + """ + if self._baseline is None: + return [] + return baseline_lines_of(self._baseline, hunk) + + def load_baseline(self, file_path: str | Path | None) -> None: + """ + 在背景重新讀取基準;沒有檔案時直接清除 + Reload the baseline in the background, or clear it when there is no file. + + :param file_path: 目前編輯的檔案 / the file being edited + """ + self.stop() + if file_path is None: + self.clear() + return + loader = BaselineLoader(file_path, self) + self._loader = loader + loader.loaded.connect(self._on_loaded) + # 先放掉參考再刪除:留著已被刪除的 wrapper,之後呼叫它就會拋 RuntimeError + # Drop the reference before deleting: keeping a wrapper whose C++ object + # is gone makes the next call on it raise RuntimeError + loader.finished.connect(self._on_loader_finished) + loader.finished.connect(loader.deleteLater) + loader.start() + + def _on_loader_finished(self) -> None: + """讀取結束後放掉參考 / Let go of the loader once it has finished.""" + if self.sender() is self._loader: + self._loader = None + + def _on_loaded(self, text: str | None) -> None: + """ + 套用背景讀取的結果 + Apply a baseline that finished loading. + + 只接受目前這個 loader 的結果,換檔案時舊結果就過期了。 + Only the current loader's result is accepted; switching files makes an + in-flight read stale. + """ + if self.sender() is not self._loader: + return + self.set_baseline(text) + self._code_edit.line_number.update() + + def stop(self) -> None: + """ + 結束仍在進行的背景讀取 + Stop a baseline read that is still running. + """ + loader, self._loader = self._loader, None + if loader is None: + return + try: + if loader.isRunning(): + loader.quit() + loader.wait() + except RuntimeError: + # 它已經跑完並被刪除了,沒有東西要停 + # It already finished and was deleted, so there is nothing to stop + return diff --git a/je_editor/pyside_ui/code/lint/__init__.py b/je_editor/pyside_ui/code/lint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/lint/lint_manager.py b/je_editor/pyside_ui/code/lint/lint_manager.py new file mode 100644 index 00000000..3425f530 --- /dev/null +++ b/je_editor/pyside_ui/code/lint/lint_manager.py @@ -0,0 +1,170 @@ +""" +管理一個編輯器的 lint 診斷狀態 +Hold the lint state for one editor. + +檢查在背景執行緒進行(那是子程序呼叫),完成後把診斷交回主執行緒。 +The check runs on a worker thread because it spawns a subprocess, and hands the +diagnostics back to the UI thread when it finishes. +""" +from __future__ import annotations + +from pathlib import Path + +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 + + +class LintWorker(QThread): + """ + 在背景對緩衝區內容執行 ruff + Run ruff over a buffer's text off the UI thread. + """ + + linted = Signal(object) # list[Diagnostic] + + def __init__(self, text: str, file_path: str | Path, parent=None) -> None: + """ + :param text: 要檢查的內容 / the text to lint + :param file_path: 內容對應的檔名 / the name the text is saved under + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + # 具名執行緒:萬一它在執行中被銷毀,Qt 的中止訊息才說得出是哪一條 + # A named thread, so Qt's abort message says which one if it is ever + # destroyed while still running + self.setObjectName("LintWorker") + self._text = text + self._file_path = file_path + + def run(self) -> None: + """執行檢查並回報結果 / Lint and report the result.""" + self.linted.emit(lint_text(self._text, self._file_path)) + + +class LintManager(QObject): + """ + 追蹤編輯器目前的診斷 + Track the diagnostics currently reported for an editor. + + 是編輯器的 QObject 子物件:worker 的訊號要接到「有接收者」的槽,編輯器被銷毀 + 時 Qt 才會自動斷開。用 lambda 接收會讓 Qt 找不到接收者,佇列中的訊號之後就會 + 打到已經釋放的編輯器上。 + A QObject child of the editor: a worker's signal must reach a slot that has a + receiver, so Qt disconnects it when the editor is destroyed. A lambda gives Qt + no receiver, which lets a queued signal arrive at an editor that is already + gone. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些診斷所屬的編輯器 / the editor being linted + """ + super().__init__(code_edit) + self._code_edit = code_edit + self._diagnostics: list[Diagnostic] = [] + self._worker: LintWorker | None = None + + def diagnostics(self) -> list[Diagnostic]: + """取得目前診斷的副本 / A copy of the current diagnostics.""" + return list(self._diagnostics) + + def for_line(self, line: int) -> list[Diagnostic]: + """ + 取得某行的診斷 + The diagnostics reported on one line. + + :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] + + def message_for_line(self, line: int) -> str | None: + """ + 取得某行診斷的說明文字 + The messages reported on one line, joined for display. + + :param line: 1 起算的行號 / the 1-based line number + :return: 說明文字,沒有診斷時為 ``None`` / the text, or ``None`` + """ + return message_for_line(self._diagnostics, line) + + def clear(self) -> bool: + """ + 清除所有診斷 + Drop every diagnostic. + + :return: 是否真的有東西被清掉 / whether anything was actually dropped + """ + if not self._diagnostics: + return False + self._diagnostics = [] + return True + + def set_diagnostics(self, diagnostics: list[Diagnostic]) -> bool: + """ + 套用一組診斷 + Apply a set of diagnostics. + + :param diagnostics: 新的診斷清單 / the diagnostics to apply + :return: 內容是否改變(未改變時呼叫端可省下重繪) + whether they differ from the previous set, so a repaint can be skipped + """ + if diagnostics == self._diagnostics: + return False + self._diagnostics = list(diagnostics) + return True + + def request(self, file_path: str | Path | None) -> bool: + """ + 對目前緩衝區內容排一次檢查 + Start one check of the buffer's current text. + + :param file_path: 目前編輯的檔案 / the file being edited + :return: 是否真的啟動了檢查 / whether a check was actually started + """ + self.stop() + if not is_lintable(file_path): + return False + worker = LintWorker(self._code_edit.toPlainText(), file_path, self) + self._worker = worker + worker.linted.connect(self._on_linted) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + worker.finished.connect(self._on_worker_finished) + worker.finished.connect(worker.deleteLater) + worker.start() + return True + + def _on_worker_finished(self) -> None: + """檢查結束後放掉參考 / Let go of the worker once it has finished.""" + if self.sender() is self._worker: + self._worker = None + + def _on_linted(self, diagnostics: list) -> None: + """ + 套用背景檢查的結果 + Apply diagnostics that finished arriving. + + 只接受目前這個 worker 的結果;換檔或再次輸入都會讓舊結果過期。 + Only the current worker's result is accepted: switching files or typing + again makes an in-flight check stale. + """ + if self.sender() is not self._worker: + return + if self.set_diagnostics(diagnostics): + self._code_edit.refresh_lint_display() + + def stop(self) -> None: + """結束仍在執行的檢查 / Stop a check that is still running.""" + worker, self._worker = self._worker, None + if worker is None: + return + try: + if worker.isRunning(): + worker.blockSignals(True) + worker.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return diff --git a/je_editor/pyside_ui/code/lsp/__init__.py b/je_editor/pyside_ui/code/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/lsp/lsp_client.py b/je_editor/pyside_ui/code/lsp/lsp_client.py new file mode 100644 index 00000000..d18b9b51 --- /dev/null +++ b/je_editor/pyside_ui/code/lsp/lsp_client.py @@ -0,0 +1,307 @@ +""" +以 stdio 與語言伺服器溝通 +Talk to a language server over stdio. + +用 QProcess 而不是自己開執行緒:QProcess 本來就是非同步的,讀到資料會發訊號, +所以不需要為了等待輸出而佔住一條執行緒。 +This uses QProcess rather than a thread of its own: QProcess is already +asynchronous and signals when data arrives, so no thread has to sit waiting for +output. + +伺服器沒安裝、啟動失敗或中途結束時都只是「沒有補全」,不會影響編輯。 +A server that is missing, fails to start, or dies mid-session simply means no +completions; editing carries on. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QProcess, Signal + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.lsp.language_servers import language_id, server_command +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, + completion_labels, + definition_location, + diagnostic_entries, + encode_message, + file_uri, + hover_text, + notification, + request, + text_edits, +) + +# 等待伺服器結束的時間(毫秒)/ How long to wait for the server to exit +_SHUTDOWN_WAIT_MS = 2000 + + +class LspClient(QObject): + """ + 一個檔案對應的語言伺服器連線 + One language server connection, for one file. + """ + + completions_ready = Signal(list) # list[str] + diagnostics_ready = Signal(list) # list[dict] + definition_ready = Signal(dict) # {"path": str, "line": int, "column": int} + hover_ready = Signal(str) + edits_ready = Signal(list) # list[dict] of text edits to apply + + def __init__(self, parent: QObject | None = None) -> None: + """ + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._process: QProcess | None = None + self._reader = MessageReader() + self._next_id = 1 + self._file_path: str | None = None + self._version = 0 + self._pending_completion_id: int | None = None + self._pending_definition_id: int | None = None + self._pending_hover_id: int | None = None + self._pending_edit_id: int | None = None + + @property + def running(self) -> bool: + """伺服器是否正在執行 / Whether the server is running.""" + return self._process is not None and self._process.state() != QProcess.ProcessState.NotRunning + + def start_for(self, file_path: str, servers: dict | None = None) -> bool: + """ + 為某個檔案啟動對應的語言伺服器 + Start the language server that handles a file. + + :param file_path: 檔案路徑 / the file to serve + :param servers: 伺服器對照表 / the server mapping to consult + :return: 有啟動時為 ``True`` / ``True`` when a server was started + """ + command = server_command(Path(file_path).suffix, servers) + if command is None: + return False + self.stop() + process = QProcess(self) + process.setProgram(command[0]) + process.setArguments(command[1:]) + process.readyReadStandardOutput.connect(self._read_output) + process.start() + if not process.waitForStarted(_SHUTDOWN_WAIT_MS): + jeditor_logger.debug(f"lsp_client: {command[0]} did not start") + process.deleteLater() + return False + self._process = process + self._file_path = file_path + self._send(request(self._take_id(), "initialize", { + "processId": None, + "rootUri": file_uri(str(Path(file_path).parent)), + "capabilities": {}, + })) + self._send(notification("initialized", {})) + return True + + def _take_id(self) -> int: + """取得下一個請求編號 / Take the next request id.""" + request_id = self._next_id + self._next_id += 1 + return request_id + + def _send(self, payload: dict) -> bool: + """把訊息寫給伺服器 / Write a message to the server.""" + if not self.running: + return False + self._process.write(encode_message(payload)) + return True + + def did_open(self, text: str) -> bool: + """ + 通知伺服器檔案已開啟 + Tell the server the file is open. + + :param text: 目前內容 / the current content + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + self._version = 1 + return self._send(notification("textDocument/didOpen", { + "textDocument": { + "uri": file_uri(self._file_path), + "languageId": language_id(Path(self._file_path).suffix), + "version": self._version, + "text": text, + } + })) + + def did_change(self, text: str) -> bool: + """ + 通知伺服器內容已變更(整份取代) + Tell the server the content changed, sending the whole document. + + :param text: 目前內容 / the current content + :return: 有送出時為 ``True`` / ``True`` when the notification was sent + """ + if self._file_path is None: + return False + self._version += 1 + return self._send(notification("textDocument/didChange", { + "textDocument": {"uri": file_uri(self._file_path), "version": self._version}, + "contentChanges": [{"text": text}], + })) + + def request_completion(self, line: int, column: int) -> bool: + """ + 要求某個位置的補全候選 + Ask for the completions at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_completion_id = request_id + return self._send(request(request_id, "textDocument/completion", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + })) + + def request_hover(self, line: int, column: int) -> bool: + """ + 要求某個位置的說明 + Ask for the description of what is at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request( + "textDocument/hover", line, column, "_pending_hover_id") + + def request_rename(self, line: int, column: int, new_name: str) -> bool: + """ + 要求把某個位置的符號重新命名 + Ask to rename the symbol at a position. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :param new_name: 新名稱 / the new name + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None or not new_name: + return False + request_id = self._take_id() + self._pending_edit_id = request_id + return self._send(request(request_id, "textDocument/rename", { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + "newName": new_name, + })) + + def request_formatting(self, tab_size: int = 4) -> bool: + """ + 要求格式化整份檔案 + Ask the server to format the whole file. + + :param tab_size: 縮排寬度 / the indent width to format with + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + if self._file_path is None: + return False + request_id = self._take_id() + self._pending_edit_id = request_id + return self._send(request(request_id, "textDocument/formatting", { + "textDocument": {"uri": file_uri(self._file_path)}, + "options": {"tabSize": tab_size, "insertSpaces": True}, + })) + + def request_definition(self, line: int, column: int) -> bool: + """ + 要求某個位置的定義位置 + Ask where the symbol at a position is defined. + + :param line: 以 0 起算的行號 / the 0-based line + :param column: 以 0 起算的欄位 / the 0-based column + :return: 有送出時為 ``True`` / ``True`` when the request was sent + """ + return self._position_request("textDocument/definition", line, column, "_pending_definition_id") + + def _position_request( + self, method: str, line: int, column: int, pending_attribute: str) -> bool: + """送出一則以游標位置為參數的請求 / Send one request about a caret position.""" + if self._file_path is None: + return False + request_id = self._take_id() + setattr(self, pending_attribute, request_id) + return self._send(request(request_id, method, { + "textDocument": {"uri": file_uri(self._file_path)}, + "position": {"line": line, "character": column}, + })) + + def handle_message(self, message: dict) -> None: + """ + 處理伺服器送來的一則訊息 + Handle one message from the server. + + :param message: 已解析的訊息 / the parsed message + """ + if message.get("method") == "textDocument/publishDiagnostics": + entries = diagnostic_entries(message.get("params")) + self.diagnostics_ready.emit(entries) + return + if "result" not in message: + return + if message.get("id") == self._pending_completion_id: + self._pending_completion_id = None + self.completions_ready.emit(completion_labels(message.get("result"))) + return + if message.get("id") == self._pending_definition_id: + self._pending_definition_id = None + location = definition_location(message.get("result")) + if location is not None: + self.definition_ready.emit(location) + return + if message.get("id") == self._pending_hover_id: + self._pending_hover_id = None + text = hover_text(message.get("result")) + if text: + self.hover_ready.emit(text) + return + if message.get("id") == self._pending_edit_id: + self._pending_edit_id = None + uri = file_uri(self._file_path) if self._file_path else "" + edits = text_edits(message.get("result"), uri) + if edits: + self.edits_ready.emit(edits) + + def _read_output(self) -> None: + """讀取伺服器輸出並逐則處理 / Read the server's output and handle each message.""" + if self._process is None: + return + data = bytes(self._process.readAllStandardOutput()) + for message in self._reader.feed(data): + self.handle_message(message) + + def stop(self) -> None: + """ + 結束伺服器 + Shut the server down. + + 先以 ``shutdown``/``exit`` 請它自己結束,逾時才強制終止。 + It is asked to finish with ``shutdown``/``exit`` first, and only killed + if it does not. + """ + process, self._process = self._process, None + self._reader = MessageReader() + self._pending_completion_id = None + if process is None: + return + if process.state() != QProcess.ProcessState.NotRunning: + process.write(encode_message(request(self._take_id(), "shutdown"))) + process.write(encode_message(notification("exit"))) + if not process.waitForFinished(_SHUTDOWN_WAIT_MS): + process.kill() + process.waitForFinished(_SHUTDOWN_WAIT_MS) + process.deleteLater() diff --git a/je_editor/pyside_ui/code/minimap/__init__.py b/je_editor/pyside_ui/code/minimap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/minimap/minimap_widget.py b/je_editor/pyside_ui/code/minimap/minimap_widget.py new file mode 100644 index 00000000..24ce54aa --- /dev/null +++ b/je_editor/pyside_ui/code/minimap/minimap_widget.py @@ -0,0 +1,168 @@ +""" +編輯器右側的縮圖 +The minimap shown beside the editor. + +把整份檔案畫成細長條的輪廓,並標出目前畫面的位置;點按或拖曳就能跳到該處。 +Draws the whole file as thin bars showing the shape of the code, marks where the +screen currently is, and jumps there when clicked or dragged. +""" +from __future__ import annotations + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtGui import QPainter +from PySide6.QtWidgets import QPlainTextEdit, QWidget + +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from je_editor.utils.minimap.minimap_layout import ( + LINE_PIXELS, + MINIMAP_WIDTH, + bar_offset, + bar_width, + line_at_row, + row_for_line, + sample_step, + viewport_band, +) + +# 文件變更後多久重畫縮圖(毫秒);輸入時不需要每個字都重畫 +# How long after an edit the minimap repaints; it need not follow every keystroke +_REPAINT_DELAY_MS = 300 +# 標記條的寬度(像素)/ Width in pixels of a marker tick +_MARKER_WIDTH = 3 + + +class MinimapWidget(QWidget): + """ + 顯示整份檔案輪廓的縮圖 + A minimap showing the shape of the whole file. + """ + + def __init__(self, code_edit: QPlainTextEdit, parent: QWidget | None = None) -> None: + """ + :param code_edit: 要對應的編輯器 / the editor this minimap follows + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + self._code_edit = code_edit + self.setFixedWidth(MINIMAP_WIDTH) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self._repaint_timer = QTimer(self) + self._repaint_timer.setSingleShot(True) + self._repaint_timer.setInterval(_REPAINT_DELAY_MS) + self._repaint_timer.timeout.connect(self.update) + + code_edit.document().contentsChanged.connect(self._repaint_timer.start) + code_edit.verticalScrollBar().valueChanged.connect(self.update) + # 游標移動會換掉「同字詞出現處」的標記,所以也要跟著重畫 + # Moving the caret changes which occurrences are marked, so repaint too + code_edit.cursorPositionChanged.connect(self._repaint_timer.start) + + def _step(self) -> int: + """目前的取樣間隔 / The sampling step in use right now.""" + return sample_step(self._code_edit.blockCount(), self.height()) + + def _visible_line_count(self) -> int: + """畫面上放得下幾行 / How many lines currently fit on screen.""" + line_height = max(1, self._code_edit.fontMetrics().height()) + return max(1, self._code_edit.viewport().height() // line_height) + + def paintEvent(self, event) -> None: + """畫出每一行的輪廓與目前的可視範圍 / Draw each line's bar and the visible band.""" + painter = QPainter(self) + painter.fillRect(event.rect(), actually_color_dict.get("minimap_background_color")) + step = self._step() + self._paint_bars(painter, step) + self._paint_markers(painter, step) + self._paint_viewport_band(painter, step) + painter.end() + + def marker_lines(self) -> dict[str, list[int]]: + """ + 取得要在縮圖上標記的行 + The lines the minimap should mark. + + 資料全部來自編輯器已經算好的東西——git 變更、診斷、游標所在字詞的其他 + 出現處——所以標記不需要自己再掃一次檔案。 + Everything comes from what the editor has already worked out: git + changes, diagnostics, and the other occurrences of the word under the + caret. The markers never rescan the file themselves. + + :return: 標記種類對應行號(0 起算)/ marker kind -> 0-based line numbers + """ + editor = self._code_edit + diagnostics = sorted({item.line - 1 for item in editor.lint_manager.diagnostics()}) + changes = sorted(editor.diff_marker_manager.statuses()) + document = editor.document() + occurrences = sorted({ + document.findBlock(position).blockNumber() + for position in editor.word_occurrences_under_cursor( + editor.toPlainText(), editor.textCursor().position()) + }) + return {"diagnostic": diagnostics, "change": changes, "occurrence": occurrences} + + def _paint_markers(self, painter: QPainter, step: int) -> None: + """在縮圖兩側畫出診斷、變更與出現處的標記 / Mark diagnostics, changes and hits.""" + markers = self.marker_lines() + for kind, colour_key, left, width in ( + ("diagnostic", "lint_underline_color", 0, _MARKER_WIDTH), + ("occurrence", "occurrence_highlight_color", + (self.width() - _MARKER_WIDTH) // 2, _MARKER_WIDTH), + ("change", "diff_modified_marker_color", + self.width() - _MARKER_WIDTH, _MARKER_WIDTH), + ): + colour = actually_color_dict.get(colour_key) + for line in markers[kind]: + row = row_for_line(line, step) + if row > self.height(): + break + painter.fillRect(left, row, width, LINE_PIXELS, colour) + + def _paint_bars(self, painter: QPainter, step: int) -> None: + """把每一行畫成一條與其長度相當的長條 / Draw each line as a bar of its length.""" + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(actually_color_dict.get("minimap_line_color")) + document = self._code_edit.document() + line = 0 + while line < document.blockCount(): + block = document.findBlockByNumber(line) + if not block.isValid(): + break + row = row_for_line(line, step) + if row > self.height(): + break + width = bar_width(block.text(), MINIMAP_WIDTH) + if width: + painter.drawRect(bar_offset(block.text()), row, width, LINE_PIXELS - 1) + line += step + + def _paint_viewport_band(self, painter: QPainter, step: int) -> None: + """標出目前畫面對應的範圍 / Mark the part of the file currently on screen.""" + first_visible = self._code_edit.firstVisibleBlock().blockNumber() + top, height = viewport_band(first_visible, self._visible_line_count(), step) + painter.fillRect( + 0, top, self.width(), height, actually_color_dict.get("minimap_viewport_color")) + + def line_at_position(self, y_position: int) -> int: + """ + 取得縮圖上某個位置對應的行號 + The line a position in the minimap points at. + + :param y_position: 縮圖上的 y 座標 / the y coordinate in the minimap + :return: 以 0 起算的行號 / the 0-based line number + """ + return line_at_row(y_position, self._step(), self._code_edit.blockCount()) + + def _scroll_to(self, y_position: int) -> None: + """把編輯器捲到縮圖上被按下的位置 / Scroll the editor to the position clicked.""" + self._code_edit.jump_to_line(self.line_at_position(y_position) + 1) + self.update() + + def mousePressEvent(self, event) -> None: + """按下時跳到該處 / Jump to the position pressed.""" + self._scroll_to(int(event.position().y())) + + def mouseMoveEvent(self, event) -> None: + """拖曳時持續跳轉 / Keep jumping while dragging.""" + if event.buttons() & Qt.MouseButton.LeftButton: + self._scroll_to(int(event.position().y())) diff --git a/je_editor/pyside_ui/code/multi_cursor/__init__.py b/je_editor/pyside_ui/code/multi_cursor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py new file mode 100644 index 00000000..b193d873 --- /dev/null +++ b/je_editor/pyside_ui/code/multi_cursor/multi_cursor_manager.py @@ -0,0 +1,344 @@ +""" +在編輯器中維護並套用多重游標 +Keep and apply extra carets in the editor. + +QPlainTextEdit 只有一個游標,所以額外的游標由這裡自己記錄位置、自己畫出來, +輸入與刪除時再逐一套用;整批編輯算一個復原步驟。 +QPlainTextEdit has only one caret, so the extra ones are tracked and drawn here +and each edit is applied to every position in turn, as a single undo step. +""" +from __future__ import annotations + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.multi_cursor.cursor_positions import ( + add_position, clamp_positions, column_caret_columns, column_span, toggle_position +) + + +class MultiCursorManager: + """ + 管理一個編輯器的額外游標 + Track one editor's extra carets. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 這些游標所屬的編輯器 / the editor the carets belong to + """ + self._code_edit = code_edit + self._positions: list[int] = [] + + @property + def active(self) -> bool: + """是否有額外游標 / Whether any extra caret exists.""" + return bool(self._positions) + + def positions(self) -> list[int]: + """取得額外游標的位置副本 / A copy of the extra caret positions.""" + return list(self._positions) + + def clear(self) -> bool: + """ + 清除所有額外游標 + Drop every extra caret. + + :return: 是否真的清掉了什麼 / whether anything was actually dropped + """ + if not self._positions: + return False + self._positions = [] + self._code_edit.viewport().update() + return True + + def toggle_at(self, position: int) -> None: + """ + 在指定位置加入或移除一個游標 + Add or remove a caret at a position. + + :param position: 文件中的字元位置 / a character position in the document + """ + self._positions = toggle_position(self._positions, position) + self._code_edit.viewport().update() + + def add_to_selected_lines(self) -> int: + """ + 在選取範圍的每一行加入游標 + Put a caret on every line of the selection. + + 游標放在各行的行尾,這是逐行改結尾(例如補上逗號)最常用的形式。 + Each caret goes to its line's end, which is the form most used for + editing line by line — appending a comma to each, say. + + :return: 加入的游標數量 / how many carets were added + """ + cursor = self._code_edit.textCursor() + if not cursor.hasSelection(): + return 0 + document = self._code_edit.document() + first = document.findBlock(cursor.selectionStart()).blockNumber() + last = document.findBlock(cursor.selectionEnd()).blockNumber() + positions: list[int] = [] + for line in range(first, last + 1): + block = document.findBlockByNumber(line) + if block.isValid(): + positions.append(block.position() + len(block.text())) + # 主游標留在最後一行,其餘交給額外游標 + # The primary caret keeps the last line; the rest become extra carets + self._positions = sorted(set(positions[:-1])) + main = self._code_edit.textCursor() + main.setPosition(positions[-1] if positions else cursor.position()) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + return len(self._positions) + + def _edit_targets(self) -> list[int]: + """ + 取得所有要編輯的位置(含主游標),由小到大 + Every position to edit, primary caret included, in ascending order. + """ + return sorted({*self._positions, self._code_edit.textCursor().position()}) + + def _apply_at_targets( + self, edit, shift_per_target: int, shift_others: int | None = None) -> None: + """ + 由後往前在每個位置套用一次編輯,再把游標移到新位置 + Apply one edit at each position from the end backwards, then move the + carets to where they ended up. + + 由後往前做,先做的編輯就不會影響還沒處理的位置;做完之後,第 n 個位置前面 + 共發生了 n 次編輯,加上自己那一次,所以位移量是 ``(n + 1) * 每次的位移``。 + Working backwards keeps an edit from moving a position not yet handled. + Afterwards, the n-th position has n edits before it plus its own, so it + moves by ``(n + 1) * shift_per_target``. + + :param edit: 對單一位置執行的編輯 / the edit to run at one position + :param shift_per_target: 這個位置自己編輯後要移動多少(插入為正,Backspace + 為負,Delete 為 0,因為刪的是游標後面的字元) + how far this position itself moves after its own edit: positive to + insert, negative for Backspace, zero for Delete, which removes the + character after the caret + :param shift_others: 每次編輯讓「後面的位置」移動多少;省略時與 + *shift_per_target* 相同 + how far each edit moves the positions after it; defaults to + *shift_per_target* + """ + following = shift_per_target if shift_others is None else shift_others + targets = self._edit_targets() + main_position = self._code_edit.textCursor().position() + cursor = self._code_edit.textCursor() + cursor.beginEditBlock() + try: + for position in reversed(targets): + edit(cursor, position) + finally: + cursor.endEditBlock() + + moved = { + position: position + index * following + shift_per_target + for index, position in enumerate(targets) + } + main = self._code_edit.textCursor() + main.setPosition(min( + max(0, moved.get(main_position, main_position)), self._document_limit())) + self._code_edit.setTextCursor(main) + self._positions = clamp_positions( + [moved[position] for position in self._positions if position in moved], + self._document_limit()) + self._code_edit.viewport().update() + + def insert_text(self, text: str) -> bool: + """ + 在每個游標插入文字 + Insert text at every caret. + + :param text: 要插入的文字 / the text to insert + :return: 有插入時為 ``True`` / ``True`` when anything was inserted + """ + if not self._positions or not text: + return False + + def insert(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.insertText(text) + + self._apply_at_targets(insert, len(text)) + return True + + def delete_before(self) -> bool: + """ + 在每個游標刪除前一個字元(Backspace) + Delete the character before every caret, as Backspace does. + + 任何一個游標已經在文件開頭時就整批不做,因為那一個沒有東西可刪,其餘照做 + 會讓各游標的相對位置錯開。 + Nothing is deleted when any caret sits at the very start: that one has + nothing to delete, and deleting for the others would pull the carets out + of step with each other. + + :return: 有刪除時為 ``True`` / ``True`` when anything was deleted + """ + if not self._positions or any(position <= 0 for position in self._edit_targets()): + return False + + def delete(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.deletePreviousChar() + + self._apply_at_targets(delete, -1) + return True + + def delete_after(self) -> bool: + """ + 在每個游標刪除後一個字元(Delete) + Delete the character after every caret, as Delete does. + + 任何一個游標已經在文件結尾時就整批不做,理由與 Backspace 相同。 + Nothing is deleted when any caret sits at the very end, for the same + reason as Backspace. + + :return: 有刪除時為 ``True`` / ``True`` when anything was deleted + """ + limit = self._document_limit() + if not self._positions or any(position >= limit for position in self._edit_targets()): + return False + + def delete(cursor: QTextCursor, position: int) -> None: + cursor.setPosition(position) + cursor.deleteChar() + + # 刪除的是游標之後的字元,游標自己不動,只有其後的位置要往前 + # The character after the caret goes, so the caret stays where it is and + # only what follows shifts back + self._apply_at_targets(delete, 0, shift_others=-1) + return True + + def insert_newline(self) -> bool: + """ + 在每個游標插入換行 + Insert a line break at every caret. + + :return: 有插入時為 ``True`` / ``True`` when anything was inserted + """ + return self.insert_text("\n") + + def move_all(self, offset: int) -> bool: + """ + 把每個額外游標左右移動 + Move every extra caret left or right. + + :param offset: 移動量(負數往左)/ how far to move, negative for left + :return: 有移動時為 ``True`` / ``True`` when the carets moved + """ + if not self._positions: + return False + self._positions = clamp_positions( + [position + offset for position in self._positions], self._document_limit()) + self._code_edit.viewport().update() + return True + + def select_column(self, anchor_position: int, current_position: int) -> int: + """ + 以矩形範圍在每一行放一個游標 + Put a caret on every line a rectangle covers. + + 這就是欄選取:從起點拖到目前位置,涵蓋的每一行都在同一欄得到一個游標, + 比該欄短的行則停在行尾。 + This is column selection: dragging from the anchor to here gives every + covered line a caret at the same column, with shorter lines stopping at + their end. + + :param anchor_position: 拖曳起點的字元位置 / where the drag started + :param current_position: 目前的字元位置 / where the pointer is now + :return: 額外游標的數量 / how many extra carets there now are + """ + document = self._code_edit.document() + anchor_block = document.findBlock(anchor_position) + current_block = document.findBlock(current_position) + lines = column_span(anchor_block.blockNumber(), current_block.blockNumber()) + blocks = [document.findBlockByNumber(line) for line in lines] + blocks = [block for block in blocks if block.isValid()] + if not blocks: + return 0 + columns = column_caret_columns( + anchor_position - anchor_block.position(), + current_position - current_block.position(), + [len(block.text()) for block in blocks], + ) + positions = [ + block.position() + column for block, column in zip(blocks, columns) + ] + # 主游標留在拖曳到的那一行,其餘是額外游標 + # The primary caret keeps the line the drag reached; the rest are extra + main_position = positions[-1] if current_block.blockNumber() >= anchor_block.blockNumber() \ + else positions[0] + self._positions = sorted(set(positions) - {main_position}) + main = self._code_edit.textCursor() + main.setPosition(min(main_position, self._document_limit())) + self._code_edit.setTextCursor(main) + self._code_edit.viewport().update() + return len(self._positions) + + def add_caret_on_neighbouring_line(self, direction: int) -> bool: + """ + 在上一行或下一行的同一欄加一個游標 + Add a caret on the line above or below, at the same column. + + :param direction: ``-1`` 為上一行,``1`` 為下一行 / ``-1`` above, ``1`` below + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + document = self._code_edit.document() + cursor = self._code_edit.textCursor() + reference = max(self._positions, default=cursor.position()) if direction > 0 else \ + min(self._positions, default=cursor.position()) + block = document.findBlock(reference) + column = reference - block.position() + target = document.findBlockByNumber(block.blockNumber() + direction) + if not target.isValid(): + return False + position = target.position() + min(column, len(target.text())) + self._positions = add_position(self._positions, position) + self._code_edit.viewport().update() + return True + + def add_caret_at_next_occurrence(self) -> bool: + """ + 在游標所在字詞的下一個出現處加一個游標 + Add a caret at the next occurrence of the word under the caret. + + 游標放在該字詞的結尾,接著輸入就會接在它後面——這是「同時改掉每一處」 + 最常用的形式。 + The caret goes to the end of that occurrence, so typing continues after + it, which is the form most used for changing every occurrence at once. + + :return: 找到並加入時為 ``True`` / ``True`` when another occurrence was found + """ + cursor = self._code_edit.textCursor() + word_cursor = self._code_edit.textCursor() + word_cursor.select(QTextCursor.SelectionType.WordUnderCursor) + word = word_cursor.selectedText() + if not word: + return False + text = self._code_edit.toPlainText() + start_from = max([cursor.position(), *self._positions], default=0) + found = text.find(word, start_from) + if found < 0: + # 找到檔尾就從頭再找一次,與其他編輯器的行為一致 + # Wrapping to the top matches what other editors do + found = text.find(word) + if found < 0 or found + len(word) in self._positions: + return False + self._positions = add_position(self._positions, found + len(word)) + self._code_edit.viewport().update() + return True + + def _document_limit(self) -> int: + """文件中最大的有效位置 / The highest valid position in the document.""" + return max(0, self._code_edit.document().characterCount() - 1) + + def refresh_after_external_edit(self) -> None: + """ + 文件被其他方式改動後,把游標拉回有效範圍 + Pull the carets back into range after the document changed elsewhere. + """ + self._positions = clamp_positions(self._positions, self._document_limit()) 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 e86e27c2..492a192e 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 @@ -5,20 +5,45 @@ import jedi # Python 自動補全與靜態分析工具 from PySide6 import QtGui -from PySide6.QtCore import Qt, QRect, QTimer, QThread, Signal, QObject +from PySide6.QtCore import Qt, QEvent, QRect, QTimer, QThread, Signal, QObject from PySide6.QtGui import ( QPainter, QTextCharFormat, QTextFormat, QKeyEvent, QAction, QTextDocument, QTextCursor, QTextOption, QColor, QWheelEvent ) -from PySide6.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog +from PySide6.QtWidgets import ( + QPlainTextEdit, QWidget, QTextEdit, QCompleter, QInputDialog, QMenu +) 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 +from je_editor.pyside_ui.code.git_diff.blame_manager import BlameManager +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.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 +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE +) from je_editor.utils.indentation.indent_convert import ( convert_leading_spaces_to_tabs, convert_leading_tabs_to_spaces, detect_indent_width, detect_indentation_uses_tabs ) +from je_editor.utils.indentation.indent_guides import ( + guide_columns, trailing_whitespace_start +) +from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.utils.debugger.pdb_commands import breakpoint_commands, step_command +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.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, ShortcutRegistry +) from je_editor.utils.line_ops.line_operations import ( join_lines, natural_sort, remove_blank_lines, reverse_lines, sort_lines, unique_lines ) @@ -28,10 +53,12 @@ find_occurrences, 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.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 +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict from je_editor.utils.align.align import align_by_delimiter from je_editor.utils.case_convert.case_convert import ( to_camel_case, to_kebab_case, to_pascal_case, to_snake_case @@ -74,10 +101,68 @@ def run(self) -> None: self.finished.emit([]) -# 行號區域中書籤欄與折疊欄的寬度(像素) -# Width in pixels of the bookmark and fold columns inside the gutter +# 行號區域中書籤欄、折疊欄與 git 變更欄的寬度(像素) +# Width in pixels of the bookmark, fold and git-change columns inside the gutter _BOOKMARK_MARKER_WIDTH = 14 _FOLD_MARKER_WIDTH = 14 +_DIFF_MARKER_WIDTH = 4 + +# 輸入停止多久之後才重算 git 變更標記(毫秒) +# How long typing must pause before the git change markers are recomputed +_DIFF_REFRESH_DELAY_MS = 400 + +# 輸入停止多久之後才重新執行 lint(毫秒);比變更標記長,因為要開子程序 +# How long typing must pause before ruff runs again; longer than the change +# markers because it spawns a subprocess +_LINT_REFRESH_DELAY_MS = 900 + +# 多重游標啟用時,這些按鍵各自對應一個整批動作 +# With extra carets active, each of these keys drives one batched action +_MULTI_CURSOR_KEYS = { + Qt.Key.Key_Escape: lambda manager: manager.clear(), + Qt.Key.Key_Backspace: lambda manager: manager.delete_before(), + Qt.Key.Key_Delete: lambda manager: manager.delete_after(), + Qt.Key.Key_Return: lambda manager: manager.insert_newline(), + Qt.Key.Key_Enter: lambda manager: manager.insert_newline(), + Qt.Key.Key_Left: lambda manager: manager.move_all(-1), + Qt.Key.Key_Right: lambda manager: manager.move_all(1), +} + + +def _document_position(document: QTextDocument, line: int, column: int) -> Union[int, None]: + """ + 把 1 起算的行列換成文件中的字元位置 + Turn a 1-based line and column into a character position in the document. + + :param document: 目標文件 / the document to look in + :param line: 1 起算的行號 / the 1-based line + :param column: 1 起算的欄位 / the 1-based column + :return: 字元位置,該行不存在時為 ``None`` / the position, or ``None`` + """ + block = document.findBlockByNumber(line - 1) + if not block.isValid(): + return None + return block.position() + min(max(0, column - 1), len(block.text())) + + +def _lint_underline_format() -> QTextCharFormat: + """診斷底線的樣式 / The format used to underline a diagnostic.""" + formats = QTextCharFormat() + formats.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline) + formats.setUnderlineColor(actually_color_dict.get("lint_underline_color")) + return formats + + +# 行尾文字與 blame 標註之間的間隔(像素) +# Gap in pixels between a line's text and its blame annotation +_BLAME_TEXT_GAP = 24 + +# 變更狀態對應的顏色設定鍵 / Colour setting key for each change status +_DIFF_MARKER_COLOR_KEYS = { + LINE_ADDED: "diff_added_marker_color", + LINE_MODIFIED: "diff_modified_marker_color", + LINE_REMOVED_ABOVE: "diff_removed_marker_color", +} # 游標移動幾行以上才視為「跳轉」並記入導覽歷史 # How many lines the caret must move to count as a "jump" recorded in history @@ -121,6 +206,15 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.main_window = main_window self.current_file = main_window.current_file + # lint 診斷狀態;必須在第一次高亮之前建立,因為高亮會附加診斷底線 + # Lint state, created before the first highlight because highlighting + # appends the diagnostic underlines + self.lint_manager = LintManager(self) + # 語言伺服器連線;lint 會問它是否正在提供診斷,所以要一起先建立 + # 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.skip_popup_behavior_list = [ Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Up, Qt.Key.Key_Down, @@ -161,23 +255,20 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: self.setLineWrapMode(self.LineWrapMode.NoWrap) self.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere) - # 搜尋功能 (Ctrl+F) - self.search_action = QAction("Search") - self.search_action.setShortcut("Ctrl+f") - self.search_action.triggered.connect(self.start_search_dialog) - self.addAction(self.search_action) - - # 搜尋與取代 (Ctrl+Shift+F) / Search & Replace shortcut - self.search_replace_action = QAction("Search & Replace") - self.search_replace_action.setShortcut("Ctrl+Shift+f") - self.search_replace_action.triggered.connect(self.open_search_replace_dialog) - self.addAction(self.search_replace_action) - - # 跳到指定行 (Ctrl+G) / Go to Line shortcut - self.goto_line_action = QAction("Go to Line") - self.goto_line_action.setShortcut("Ctrl+g") - self.goto_line_action.triggered.connect(self.go_to_line) - self.addAction(self.goto_line_action) + # 這個編輯器已經指派出去的快捷鍵;以選單與工具列佔用的組合作為起點, + # 重複指派時會留下警告紀錄 + # The sequences this editor has handed out, seeded with the ones the menus + # and toolbar already take; a clash is logged rather than passing silently + self.shortcut_registry = ShortcutRegistry(WINDOW_SHORTCUTS) + + # 搜尋 (Ctrl+F)、搜尋與取代 (Ctrl+H)、跳到指定行 (Ctrl+G) + # Search (Ctrl+F), search & replace (Ctrl+H) and go to line (Ctrl+G) + self.search_action = self._add_shortcut_action( + "Ctrl+F", "search", self.start_search_dialog) + self.search_replace_action = self._add_shortcut_action( + "Ctrl+H", "search_and_replace", self.open_search_replace_dialog) + self.goto_line_action = self._add_shortcut_action( + "Ctrl+G", "go_to_line", self.go_to_line) # 自動補全初始化 self.completer: Union[None, QCompleter] = None @@ -227,10 +318,71 @@ def __init__(self, main_window: Union[EditorWidget, FullEditorWidget]) -> None: # Per-file detected indent width (None means use the global setting) self._indent_size_override: Union[int, None] = None + # git 變更標記:基準在背景讀取,重算則在輸入停止後 debounce 執行 + # git change markers: the baseline loads in the background, and the + # recompute is debounced so typing does not diff on every keystroke + self.diff_marker_manager = DiffMarkerManager(self) + self.blame_manager = BlameManager(self) + self._diff_timer = QTimer(self) + self._diff_timer.setSingleShot(True) + self._diff_timer.setInterval(_DIFF_REFRESH_DELAY_MS) + self._diff_timer.timeout.connect(self._refresh_diff_markers) + self._register_diff_marker_actions() + self.load_git_baseline() + + # lint 檢查會開子程序,因此同樣等輸入停下來才跑(管理器已於前面建立) + # The lint check spawns a subprocess, so it too waits for a pause in + # typing; its manager was created earlier in __init__ + self._lint_timer = QTimer(self) + self._lint_timer.setSingleShot(True) + self._lint_timer.setInterval(_LINT_REFRESH_DELAY_MS) + self._lint_timer.timeout.connect(self.request_lint) + self.request_lint() + + # 片段展開與定位點 / Snippet expansion and its tab stops + self.snippet_manager = SnippetManager(self) + + # 多重游標;欄選取拖曳的起點在按下 Alt 時記下 + # Extra carets; a column drag records its anchor when Alt is pressed + self.multi_cursor_manager = MultiCursorManager(self) + self._column_anchor: Union[int, None] = None + self._column_dragged = False + self._register_multi_cursor_actions() + + # 巨集錄製 / Macro recording + self.macro = KeystrokeMacro() + self._register_macro_actions() + + # 中斷點 / Breakpoints + self.breakpoint_manager = BreakpointManager(self) + self._register_breakpoint_actions() + + # 非 Python 檔的補全與診斷都交給語言伺服器;Python 仍走 jedi 與 ruff + # A non-Python file gets its completion and diagnostics from a language + # server; Python keeps jedi and ruff + self.lsp_client.completions_ready.connect(self.set_complete) + self.lsp_client.diagnostics_ready.connect(self.apply_server_diagnostics) + self.lsp_client.hover_ready.connect(self.show_hover_text) + self.lsp_client.edits_ready.connect(self.apply_server_edits) + self.lsp_client.definition_ready.connect(self.go_to_definition_location) + self.start_language_server() + def reset_highlighter(self) -> None: - """重設語法高亮 / Reset syntax highlighter""" + """ + 依目前檔案的副檔名重設語法高亮 + 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. + """ jeditor_logger.info("CodeEditor reset_highlighter") - self.highlighter = PythonHighlighter(self.document(), main_window=self) + 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) self.highlight_current_line() def check_env(self) -> None: @@ -287,6 +439,11 @@ def complete(self) -> None: 使用 Jedi 在背景執行緒進行自動補全,避免阻塞 UI Run Jedi autocomplete in background thread to avoid blocking UI """ + # 非 Python 檔改問語言伺服器,jedi 只懂 Python + # A non-Python file asks its language server instead; jedi only knows Python + if self.request_language_server_completion(): + return + # 如果上一次補全還在執行,跳過 / Skip if previous completion is still running if self._complete_thread is not None and self._complete_thread.isRunning(): return @@ -391,22 +548,58 @@ def find_back_text(self) -> None: text = self.search_box.search_input.text() self.find(text, QTextDocument.FindFlag.FindBackward) + # ── 快捷鍵註冊 / Shortcut registration ──────────────────────── + + def _add_shortcut_action(self, sequence: str, command: str, handler) -> QAction: + """ + 建立一個綁定快捷鍵的動作,並登記到本編輯器的快捷鍵表 + Create an action bound to a key sequence and record it in this editor's table. + + 兩個動作共用同一組按鍵時,Qt 不會挑一個執行而是兩個都不執行,因此每組按鍵 + 都要先登記;重複指派會留下警告紀錄,測試也會直接失敗。 + Two actions sharing a sequence make Qt run neither of them, so every + sequence is recorded here first: a clash is logged, and a test fails on it + outright. + + :param sequence: 按鍵組合 / the key sequence + :param command: 指令名稱,用於衝突訊息 / the command name, used in clash messages + :param handler: 觸發時呼叫的函式 / what to call when it fires + :return: 建立好的動作 / the action that was created + """ + action = QAction(self) + action.setObjectName(command) + action.setShortcut(sequence) + action.triggered.connect(handler) + owner = self.shortcut_registry.register(sequence, command) + if owner is not None: + jeditor_logger.warning( + f"CodeEditor shortcut {sequence} for {command} is already taken by {owner}") + self.addAction(action) + return action + + def _add_shortcut_actions(self, bindings) -> None: + """ + 一次註冊多組快捷鍵 + Register several shortcuts at once. + + :param bindings: ``(按鍵, 指令名稱, 處理函式)`` 的序列 + / a sequence of ``(key sequence, command name, handler)`` + """ + for sequence, command, handler in bindings: + self._add_shortcut_action(sequence, command, handler) + # ── 程式碼折疊與書籤 / Code folding and bookmarks ────────────── def _register_fold_bookmark_actions(self) -> None: """註冊折疊與書籤的快捷鍵 / Register folding and bookmark shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+[", self.toggle_fold_at_cursor), - ("Ctrl+Alt+[", self.fold_all), - ("Ctrl+Alt+]", self.unfold_all), - ("Ctrl+Alt+K", self.toggle_bookmark), - ("Ctrl+Alt+L", self.next_bookmark), - ("Ctrl+Alt+J", self.previous_bookmark), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Shift+[", "toggle_fold", self.toggle_fold_at_cursor), + ("Ctrl+Alt+[", "fold_all", self.fold_all), + ("Ctrl+Alt+]", "unfold_all", self.unfold_all), + ("Ctrl+Alt+K", "toggle_bookmark", self.toggle_bookmark), + ("Ctrl+Alt+L", "next_bookmark", self.next_bookmark), + ("Ctrl+Alt+J", "previous_bookmark", self.previous_bookmark), + )) def _on_text_changed_for_features(self) -> None: """ @@ -420,6 +613,365 @@ def _on_text_changed_for_features(self) -> None: # Only re-apply when something is folded, so unfolded editing has no cost if self.folding_manager.is_any_folded(): self.folding_manager.refresh() + # 沒有基準時完全不需要排程重算 + # With no baseline there is nothing to recompute + if self.diff_marker_manager.has_baseline: + self._diff_timer.start() + self._lint_timer.start() + + def _refresh_diff_markers(self) -> None: + """重算 git 變更標記,有變化才重繪 / Recompute markers, repainting only on a change.""" + if self.diff_marker_manager.refresh(): + self.line_number.update() + + def load_git_baseline(self) -> None: + """ + 重新讀取目前檔案在 HEAD 的內容作為比較基準 + Reload the current file's committed content as the comparison baseline. + + 開檔或換檔後呼叫;讀取在背景執行緒進行。 + Call this after opening or switching files; the read runs in a thread. + """ + self.diff_marker_manager.load_baseline(self.current_file) + + def _register_diff_marker_actions(self) -> None: + """ + 註冊變更跳轉快捷鍵 + Register the change-navigation shortcuts. + + 用 F7/Shift+F7 而不是 Ctrl+Alt+Up/Down:後者是游標處數字加減的按鍵。 + F7 and Shift+F7 rather than Ctrl+Alt+Up/Down, which increment and + decrement the number under the caret. + """ + self._add_shortcut_actions(( + ("F7", "next_change", self.next_change), + ("Shift+F7", "previous_change", self.previous_change), + ("Ctrl+Alt+Z", "revert_change", self.revert_change_at_cursor), + ("Ctrl+Alt+B", "toggle_blame", self.toggle_blame), + )) + + def next_change(self) -> bool: + """ + 跳到下一個有變更的行 + Jump to the next changed line. + + :return: 是否有跳轉 / whether the caret moved + """ + return self._go_to_change( + self.diff_marker_manager.next_change(self.textCursor().blockNumber())) + + def previous_change(self) -> bool: + """ + 跳到上一個有變更的行 + Jump to the previous changed line. + + :return: 是否有跳轉 / whether the caret moved + """ + return self._go_to_change( + self.diff_marker_manager.previous_change(self.textCursor().blockNumber())) + + def _go_to_change(self, line: Union[int, None]) -> bool: + """移動游標到指定變更行 / Move the caret to a changed line.""" + if line is None: + return False + return self.jump_to_line(line + 1) + + def toggle_blame(self) -> bool: + """ + 切換行內 blame 顯示 + Toggle the inline blame annotations. + + :return: 切換後是否為開啟 / whether the annotations are now shown + """ + enabled = self.blame_manager.toggle(self.current_file) + self.viewport().update() + return enabled + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + """ + 繪製內容,並疊上縮排參考線、尾端空白與 blame 標註 + Paint the text, then overlay indent guides, trailing whitespace and the + blame annotations. + """ + # 參考線畫在文字之前,才不會蓋住字 + # Guides are painted first so they sit behind the text + if user_setting_dict.get("show_indent_guides", True): + self._paint_indent_guides() + QPlainTextEdit.paintEvent(self, event) + if user_setting_dict.get("show_trailing_whitespace", True): + self._paint_trailing_whitespace() + if self.multi_cursor_manager.active: + self._paint_extra_cursors() + if self.blame_manager.enabled: + self._paint_blame_annotations() + + def _visible_blocks(self): + """逐一產生可見的區塊與其頂端座標 / Yield each visible block and its top.""" + block = self.firstVisibleBlock() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom_limit = self.viewport().rect().bottom() + while block.isValid() and top <= bottom_limit: + if block.isVisible(): + yield block, top + top += self.blockBoundingRect(block).height() + block = block.next() + + def _paint_indent_guides(self) -> None: + """在每一層縮排畫出垂直參考線 / Draw a vertical line at each indent level.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("indent_guide_color")) + metrics = self.fontMetrics() + space_width = metrics.horizontalAdvance(" ") + line_height = metrics.height() + indent_size = self.indent_size() + for block, top in self._visible_blocks(): + for column in guide_columns(block.text(), indent_size): + position = int(column * space_width) + painter.drawLine(position, int(top), position, int(top) + line_height) + painter.end() + + def _paint_trailing_whitespace(self) -> None: + """標出每一行尾端多餘的空白 / Mark the stray whitespace at each line's end.""" + painter = QPainter(self.viewport()) + colour = actually_color_dict.get("trailing_whitespace_color") + metrics = self.fontMetrics() + line_height = metrics.height() + for block, top in self._visible_blocks(): + text = block.text() + start = trailing_whitespace_start(text) + if start is None: + continue + left = metrics.horizontalAdvance(text[:start]) + width = metrics.horizontalAdvance(text[start:]) + painter.fillRect(int(left), int(top), max(1, int(width)), line_height, colour) + painter.end() + + def _paint_blame_annotations(self) -> None: + """在每個可見行的文字後面畫出 blame 標註 / Draw blame after each visible line.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("blame_annotation_color")) + metrics = self.fontMetrics() + line_height = metrics.height() + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + while block.isValid() and top <= self.viewport().rect().bottom(): + if block.isVisible(): + annotation = self.blame_manager.annotation(block_number) + if annotation: + # 接在該行文字之後,中間留一段空白 + # Placed after the line's own text, with a gap between them + text_width = metrics.horizontalAdvance(block.text()) + painter.drawText( + int(text_width) + _BLAME_TEXT_GAP, int(top), + self.viewport().width(), line_height, + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + annotation) + top += self.blockBoundingRect(block).height() + block = block.next() + block_number += 1 + painter.end() + + def stage_change_at_cursor(self) -> bool: + """ + 只把游標所在的變更區塊加入索引 + Stage just the change block under the caret. + + 索引拿到「基準加上這一段」的版本,其他變更維持已提交的樣子,磁碟上的檔案 + 完全不動。 + The index receives the baseline with this one hunk applied, every other + change stays as committed, and the file on disk is untouched. + + :return: 有暫存時為 ``True`` / ``True`` when the hunk was staged + """ + baseline = self.diff_marker_manager.baseline() + if baseline is None or self.current_file is None: + return False + hunk = self.diff_marker_manager.hunk_at(self.textCursor().blockNumber()) + if hunk is None: + return False + staged = apply_hunk(baseline, self.toPlainText(), hunk) + return stage_content(str(self.current_file), staged) + + def staged_diff_text(self) -> str: + """ + 取得索引版本與編輯中內容的差異 + The diff between what is staged and what is being edited. + + :return: diff 文字;沒有差異或不在索引中時為空字串 / the diff, or an empty string + """ + if self.current_file is None: + return "" + staged = staged_text(str(self.current_file)) + if staged is None: + return "" + return unified_diff_text( + staged, self.toPlainText(), Path(str(self.current_file)).name) + + def revert_change_at_cursor(self) -> bool: + """ + 把游標所在的變更區塊還原成已提交的內容 + Restore the change block under the caret to its committed content. + + 還原是單一復原步驟,因此按一次 Ctrl+Z 就能取消。 + The revert is one undo step, so a single Ctrl+Z takes it back. + + :return: 有東西被還原時為 ``True`` / ``True`` when something was reverted + """ + line = self.textCursor().blockNumber() + hunk = self.diff_marker_manager.hunk_at(line) + if hunk is None: + return False + original = self.diff_marker_manager.baseline_lines(hunk) + cursor = self.textCursor() + cursor.beginEditBlock() + try: + self._replace_lines_with(cursor, hunk, original) + finally: + cursor.endEditBlock() + self._refresh_diff_markers() + return True + + def _replace_lines_with(self, cursor: QTextCursor, hunk, original: List[str]) -> None: + """ + 以基準內容取代一段變更的行 + Replace a hunk's lines with the baseline content. + + 純刪除在目前內容中沒有範圍,因此改為在該位置插回原本的行;純新增在基準 + 中沒有內容,因此要連同換行整行移除,只清掉文字會留下一個空行。 + A pure deletion spans no lines, so its content is inserted back instead; + a pure insertion has no baseline content, so the whole line is removed + including its line break — clearing only the text would leave a blank line. + """ + document = self.document() + if hunk.is_pure_deletion: + block = document.findBlockByNumber(min(hunk.start, document.blockCount() - 1)) + cursor.setPosition(block.position()) + cursor.insertText("\n".join(original) + "\n") + return + if not original: + self._remove_blocks(cursor, hunk.start, hunk.end) + return + start_block = document.findBlockByNumber(hunk.start) + end_block = document.findBlockByNumber(hunk.end - 1) + cursor.setPosition(start_block.position()) + cursor.setPosition( + end_block.position() + end_block.length() - 1, QTextCursor.MoveMode.KeepAnchor) + cursor.insertText("\n".join(original)) + + def _remove_blocks(self, cursor: QTextCursor, start: int, end: int) -> None: + """ + 移除整段行,包含行尾的換行 + Remove whole lines, line breaks included. + """ + document = self.document() + start_block = document.findBlockByNumber(start) + end_block = document.findBlockByNumber(end - 1) + start_position = start_block.position() + end_position = end_block.position() + end_block.length() + last_position = document.characterCount() - 1 + if end_position > last_position: + # 刪到檔尾:改為往前吃掉上一行的換行,才不會留下空行 + # Deleting to the end of the file: take the preceding line break + # instead, so no blank line is left behind + end_position = last_position + start_position = max(0, start_position - 1) + cursor.setPosition(start_position) + cursor.setPosition(end_position, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + + def _show_lint_message_for_caret(self) -> None: + """ + 把游標所在行的診斷顯示為提示文字 + Show the caret line's diagnostics as the editor's tooltip. + """ + message = self.lint_manager.message_for_line(self.textCursor().blockNumber() + 1) + self.setToolTip(message or "") + + def request_lint(self) -> bool: + """ + 對目前內容排一次 lint 檢查 + Start one lint check of the current text. + + :return: 是否真的啟動了檢查(非 Python 檔不檢查) + whether a check started; non-Python files are not linted + """ + started = self.lint_manager.request(self.current_file) + if started or self.lsp_client.running: + # 診斷由語言伺服器提供時不能清掉,否則每次輸入停下來就會被抹掉 + # Diagnostics from a language server must survive: clearing them here + # would wipe them at every pause in typing + return started + if self.lint_manager.clear(): + self.refresh_lint_display() + return False + + 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. + + :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)): + return False + self.refresh_lint_display() + return True + + def refresh_lint_display(self) -> None: + """重畫診斷底線 / Repaint the diagnostic underlines.""" + self._highlight_matching_bracket() + + def _append_lint_selections(self, selections: list) -> None: + """ + 把診斷位置加入波浪底線 + Append a wavy underline for each diagnostic. + + :param selections: 要附加的選取清單 / the selection list to append to + """ + diagnostics = self.lint_manager.diagnostics() + if not diagnostics: + return + document = self.document() + for diagnostic in diagnostics: + cursor = self._diagnostic_cursor(document, diagnostic) + if cursor is None: + continue + selection = QTextEdit.ExtraSelection() + selection.cursor = cursor + selection.format = _lint_underline_format() + selections.append(selection) + + @staticmethod + def _diagnostic_cursor( + document: QTextDocument, diagnostic) -> Union[QTextCursor, None]: + """ + 取得診斷範圍的游標,範圍不存在時回傳 ``None`` + Return a cursor spanning a diagnostic, or ``None`` when it is out of range. + """ + block = document.findBlockByNumber(diagnostic.line - 1) + if not block.isValid(): + return None + start = block.position() + max(0, diagnostic.column - 1) + end_block = document.findBlockByNumber(diagnostic.end_line - 1) + if end_block.isValid(): + end = end_block.position() + max(0, diagnostic.end_column - 1) + else: + end = block.position() + block.length() - 1 + # 零寬度的範圍看不見,至少標一個字元 + # A zero-width range is invisible, so mark at least one character + end = min(max(end, start + 1), document.characterCount() - 1) + if start >= end: + return None + cursor = QTextCursor(document) + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + return cursor def _foldable_header_lines(self) -> set: """取得可折疊標頭行號(快取)/ Foldable header lines (cached).""" @@ -459,14 +1011,10 @@ def previous_bookmark(self) -> None: def _register_history_actions(self) -> None: """註冊上一步/下一步快捷鍵 / Register back/forward shortcuts.""" - for shortcut, handler in ( - ("Alt+Left", self.navigate_back), - ("Alt+Right", self.navigate_forward), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Alt+Left", "navigate_back", self.navigate_back), + ("Alt+Right", "navigate_forward", self.navigate_forward), + )) def _record_cursor_jump(self) -> None: """ @@ -576,8 +1124,10 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: painter.fillRect(event.rect(), actually_color_dict.get("line_number_background_color")) bookmarked = set(self.bookmark_manager.bookmarked_lines()) + breakpoints = set(self.breakpoint_manager.lines()) fold_headers = self._foldable_header_lines() folded_headers = self.folding_manager.folded_header_lines() + diff_statuses = self.diff_marker_manager.statuses() gutter_width = self.line_number.width() line_height = self.fontMetrics().height() @@ -594,12 +1144,19 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: painter.drawText( _BOOKMARK_MARKER_WIDTH, int(top), - gutter_width - _BOOKMARK_MARKER_WIDTH - _FOLD_MARKER_WIDTH, + gutter_width - _BOOKMARK_MARKER_WIDTH - _FOLD_MARKER_WIDTH + - _DIFF_MARKER_WIDTH, line_height, Qt.AlignmentFlag.AlignCenter, str(block_number + 1), ) - if block_number in bookmarked: + diff_status = diff_statuses.get(block_number) + if diff_status is not None: + self._paint_diff_marker( + painter, top, line_height, gutter_width, diff_status) + if block_number in breakpoints: + self._paint_breakpoint_marker(painter, top, line_height) + elif block_number in bookmarked: self._paint_bookmark_marker(painter, top, line_height) if block_number in fold_headers: self._paint_fold_marker( @@ -610,6 +1167,28 @@ def line_number_paint(self, event: QtGui.QPaintEvent) -> None: bottom = top + self.blockBoundingRect(block).height() block_number += 1 + def _paint_breakpoint_marker( + self, painter: QPainter, top: float, line_height: int) -> None: + """ + 在行號左側繪製中斷點 + Draw the breakpoint dot on the gutter's left. + + 與書籤共用同一欄,因此畫在同一個位置;顏色不同,且中斷點優先顯示,因為 + 它會影響執行。 + It shares the bookmark column and so sits in the same place, but in a + different colour, and takes precedence because it changes what runs. + """ + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + colour = actually_color_dict.get("breakpoint_marker_color") + painter.setBrush(colour) + painter.setPen(colour) + radius = max(3, line_height // 4) + center_y = int(top) + line_height // 2 + painter.drawEllipse( + _BOOKMARK_MARKER_WIDTH // 2 - radius, center_y - radius, radius * 2, radius * 2) + painter.restore() + def _paint_bookmark_marker(self, painter: QPainter, top: float, line_height: int) -> None: """在行號左側繪製書籤圓點 / Draw the bookmark dot on the gutter's left.""" painter.save() @@ -640,13 +1219,36 @@ def _paint_fold_marker( ) painter.restore() + def _paint_diff_marker( + self, painter: QPainter, top: float, line_height: int, + gutter_width: int, status: str) -> None: + """ + 在折疊欄左側繪製 git 變更長條 + Draw the git change bar just left of the fold column. + """ + color = _DIFF_MARKER_COLOR_KEYS.get(status) + if color is None: + return + painter.save() + painter.fillRect( + gutter_width - _FOLD_MARKER_WIDTH - _DIFF_MARKER_WIDTH, + int(top), + _DIFF_MARKER_WIDTH, + # 刪除以細線表示,因為被刪的行已經不在畫面上 + # A deletion is a thin line: the removed lines are no longer on screen + 2 if status == LINE_REMOVED_ABOVE else line_height, + actually_color_dict.get(color), + ) + painter.restore() + def line_number_width(self) -> int: """ - 計算行號區域寬度(含書籤與折疊欄) - Calculate gutter width, including the bookmark and fold columns. + 計算行號區域寬度(含書籤、git 變更與折疊欄) + Calculate gutter width, including the bookmark, git-change and fold columns. """ digits = len(str(self.blockCount())) # 根據總行數決定位數 - return 12 * digits + _BOOKMARK_MARKER_WIDTH + _FOLD_MARKER_WIDTH + return (12 * digits + _BOOKMARK_MARKER_WIDTH + _FOLD_MARKER_WIDTH + + _DIFF_MARKER_WIDTH) def update_line_number_area_width(self, value: int) -> None: """ @@ -699,6 +1301,7 @@ def highlight_current_line(self) -> None: selections.append(selection) selection.format.setBackground(color_of_the_line) selection.format.setProperty(QTextFormat.FullWidthSelection, True) + self._append_lint_selections(selections) self.setExtraSelections(selections) def _highlight_matching_bracket(self) -> None: @@ -740,7 +1343,9 @@ def _highlight_matching_bracket(self) -> None: selections.append(sel) self._append_occurrence_selections(selections, text, pos) + self._append_lint_selections(selections) self.setExtraSelections(selections) + self._show_lint_message_for_caret() def word_occurrences_under_cursor(self, text: str, position: int) -> list[int]: """ @@ -862,14 +1467,10 @@ def _duplicate_selection(self, cursor: QTextCursor) -> None: def _register_smart_selection_actions(self) -> None: """註冊智慧選取快捷鍵 / Register smart selection shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Alt+Right", self.expand_selection), - ("Ctrl+Alt+Left", self.shrink_selection), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Alt+Right", "expand_selection", self.expand_selection), + ("Ctrl+Alt+Left", "shrink_selection", self.shrink_selection), + )) def expand_selection(self) -> None: """把選取擴大到下一個更大的範圍 / Expand the selection to the next larger range.""" @@ -883,15 +1484,11 @@ def shrink_selection(self) -> None: def _register_number_actions(self) -> None: """註冊數字加減快捷鍵 / Register number increment/decrement shortcuts.""" - for shortcut, delta in (("Ctrl+Alt+Up", 1), ("Ctrl+Alt+Down", -1)): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(lambda checked=False, step=delta: self.adjust_number(step)) - self.addAction(action) - rename_action = QAction(self) - rename_action.setShortcut("F2") - rename_action.triggered.connect(self.rename_word_under_cursor) - self.addAction(rename_action) + self._add_shortcut_actions(( + ("Ctrl+Alt+Up", "increment_number", lambda: self.adjust_number(1)), + ("Ctrl+Alt+Down", "decrement_number", lambda: self.adjust_number(-1)), + ("F2", "rename_occurrences", self.rename_word_under_cursor), + )) def rename_word_under_cursor(self) -> bool: """ @@ -951,15 +1548,11 @@ def adjust_number(self, delta: int) -> bool: def _register_line_operation_actions(self) -> None: """註冊行操作快捷鍵 / Register line-operation shortcuts.""" - for shortcut, handler in ( - ("Ctrl+Shift+D", self.delete_current_line), - ("Ctrl+Shift+J", self.join_selected_lines), - ("Ctrl+Alt+S", self.sort_selected_lines), - ): - action = QAction(self) - action.setShortcut(shortcut) - action.triggered.connect(handler) - self.addAction(action) + self._add_shortcut_actions(( + ("Ctrl+Shift+D", "delete_line", self.delete_current_line), + ("Ctrl+Shift+J", "join_lines", self.join_selected_lines), + ("Ctrl+Alt+S", "sort_lines", self.sort_selected_lines), + )) def _selected_block_range(self, cursor: QTextCursor) -> tuple[int, int]: """取得選取(或游標所在)涵蓋的 block 區間 / The block range covered by the selection.""" @@ -1568,6 +2161,8 @@ def _jump_to_definition(self) -> None: def _handle_tab_indent(self, event: QKeyEvent) -> bool: """處理 Tab/Shift+Tab 區塊縮排 / Handle block indent; return True if consumed.""" key = event.key() + if key == Qt.Key.Key_Tab and self._handle_snippet_tab(): + return True if key == Qt.Key.Key_Tab and self.textCursor().hasSelection(): self._indent_selection(indent=True) return True @@ -1577,6 +2172,378 @@ def _handle_tab_indent(self, event: QKeyEvent) -> bool: return True return False + def start_language_server(self) -> bool: + """ + 為目前檔案啟動語言伺服器(如果有對應的) + Start the language server for the current file, when one applies. + + Python 檔不啟動,因為補全已經由 jedi 負責。 + Python files start none, since jedi already provides their completion. + + :return: 有啟動時為 ``True`` / ``True`` when a server was started + """ + 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)): + return False + self.lsp_client.did_open(self.toPlainText()) + return True + + def request_language_server_completion(self) -> bool: + """ + 向語言伺服器要求游標位置的補全 + Ask the language server for completions at the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + if not self.lsp_client.running: + return False + cursor = self.textCursor() + self.lsp_client.did_change(self.toPlainText()) + return self.lsp_client.request_completion( + cursor.blockNumber(), cursor.positionInBlock()) + + def _register_breakpoint_actions(self) -> None: + """ + 註冊中斷點與逐步執行的快捷鍵 + Register breakpoint and stepping shortcuts. + + F9 與 F5 是工具列的「執行除錯器」與「執行」,因此切換中斷點與繼續執行改用 + Ctrl+F9 與 Ctrl+F5。 + The toolbar runs the debugger on F9 and the program on F5, so toggling a + breakpoint and continuing use Ctrl+F9 and Ctrl+F5 instead. + """ + self._add_shortcut_actions(( + ("Ctrl+F9", "toggle_breakpoint", self.toggle_breakpoint), + ("Ctrl+F5", "debug_continue", lambda: self.send_debugger_command("continue")), + ("F10", "debug_step_over", lambda: self.send_debugger_command("over")), + ("F11", "debug_step_into", lambda: self.send_debugger_command("into")), + ("Shift+F11", "debug_step_out", lambda: self.send_debugger_command("out")), + )) + + def toggle_breakpoint(self) -> bool: + """ + 切換游標所在行的中斷點 + Add or remove a breakpoint on the caret's line. + + :return: 切換後是否有中斷點 / whether the line now has one + """ + result = self.breakpoint_manager.toggle(self.textCursor().blockNumber()) + self.line_number.update() + return result + + def _debugger_process(self): + """取得正在執行的除錯器程序 / The debugger process that is running, if any.""" + manager = getattr(self.main_window, "exec_python_debugger", None) + return getattr(manager, "process", None) if manager is not None else None + + def send_debugger_command(self, action: str) -> bool: + """ + 送出一個逐步執行指令給除錯器 + Send one stepping command to the debugger. + + 除錯器沒在跑時什麼都不做,因為 pdb 的指令只有在它等待輸入時才有意義。 + Nothing happens when the debugger is not running, since a pdb command + only means something while it is waiting for input. + + :param action: 動作名稱(``into``/``over``/``out``/``continue``/``quit``) + the action's name + :return: 有送出時為 ``True`` / ``True`` when the command was sent + """ + command = step_command(action) + if command is None: + return False + return self._write_debugger_line(command) + + def send_breakpoints_to_debugger(self) -> int: + """ + 把目前的中斷點送給正在執行的除錯器 + Send the current breakpoints to the running debugger. + + :return: 送出的中斷點數量 / how many breakpoints were sent + """ + if self.current_file is None: + return 0 + sent = 0 + for command in breakpoint_commands( + str(self.current_file), self.breakpoint_manager.pdb_lines()): + if self._write_debugger_line(command): + sent += 1 + return sent + + def _write_debugger_line(self, command: str) -> bool: + """把一行指令寫進除錯器的標準輸入 / Write one command to the debugger's stdin.""" + process = self._debugger_process() + stdin = getattr(process, "stdin", None) if process is not None else None + if stdin is None: + return False + try: + stdin.write(command.encode() + b"\n") + stdin.flush() + except (OSError, ValueError) as error: + jeditor_logger.warning(f"CodeEditor debugger command failed: {error}") + return False + return True + + def _register_macro_actions(self) -> None: + """ + 註冊巨集與最近位置的快捷鍵 + Register the macro and recent-location shortcuts. + + 重播用 Ctrl+Shift+G,因為 Ctrl+Shift+P 是選單的 pip 安裝。 + Playback uses Ctrl+Shift+G because Ctrl+Shift+P installs packages with pip. + """ + self._add_shortcut_actions(( + ("Ctrl+Shift+R", "record_macro", self.toggle_macro_recording), + ("Ctrl+Shift+G", "play_macro", self.play_macro), + ("Ctrl+Alt+E", "recent_locations", self.show_recent_locations), + )) + + def recent_location_labels(self) -> list[str]: + """ + 取得最近去過的位置,最新的排在前面 + The locations visited recently, most recent first. + + :return: 每個位置的顯示文字 / a label for each location + """ + document = self.document() + labels: list[str] = [] + for line in reversed(self.location_history.entries): + block = document.findBlockByNumber(line) + text = block.text().strip() if block.isValid() else "" + labels.append(f"{line + 1}: {text}" if text else f"{line + 1}") + return labels + + def show_recent_locations(self) -> bool: + """ + 列出最近去過的位置,選一個就跳過去 + List the recently visited locations and jump to the chosen one. + + :return: 有跳轉時為 ``True`` / ``True`` when the caret moved + """ + labels = self.recent_location_labels() + if not labels: + return False + chosen, confirmed = QInputDialog.getItem( + self, language_wrapper.language_word_dict.get("recent_locations_title"), + language_wrapper.language_word_dict.get("recent_locations_prompt"), + labels, 0, False) + if not confirmed or not chosen: + return False + return self.jump_to_line(int(chosen.split(":", 1)[0])) + + def toggle_macro_recording(self) -> bool: + """ + 開始或結束錄製巨集 + Start recording a macro, or stop the one under way. + + :return: 切換後是否正在錄製 / whether recording is now under way + """ + return self.macro.toggle() + + def play_macro(self) -> bool: + """ + 重播錄好的巨集 + Play back the recorded macro. + + 重播用同步送出事件,而不是排入佇列,因此事件物件的生命週期完全在這次呼叫 + 之內。整段重播算一個復原步驟。 + Playback sends each event synchronously rather than queuing it, so every + event object lives only for the duration of this call, and the whole run + is a single undo step. + + :return: 有重播時為 ``True`` / ``True`` when anything was played back + """ + if self.macro.recording or self.macro.is_empty: + return False + cursor = self.textCursor() + cursor.beginEditBlock() + try: + for stroke in list(self.macro.keystrokes): + event = QKeyEvent( + QEvent.Type.KeyPress, stroke.key, + Qt.KeyboardModifier(stroke.modifiers), stroke.text) + self.keyPressEvent(event) + finally: + cursor.endEditBlock() + return True + + def surround_selection(self, opening: str) -> bool: + """ + 用成對字元包住選取的文字 + Wrap the selection in a matching pair of characters. + + :param opening: 開頭字元 / the opening character + :return: 有包住時為 ``True`` / ``True`` when the selection was wrapped + """ + cursor = self.textCursor() + if not cursor.hasSelection(): + return False + wrapped = surround(cursor.selectedText(), opening) + if wrapped is None: + return False + start = cursor.selectionStart() + cursor.insertText(wrapped) + # 重新選取原本的內容,方便接著再包一層 + # Re-select the original text so it can be wrapped again straight away + caret = self.textCursor() + caret.setPosition(start + 1) + caret.setPosition(start + len(wrapped) - 1, QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(caret) + return True + + def _register_multi_cursor_actions(self) -> None: + """ + 註冊多重游標的快捷鍵 + Register the multi-caret shortcuts. + + 「在下一個出現處加游標」用 Ctrl+Alt+N:Ctrl+D 一直是複製目前行。 + Adding a caret at the next occurrence uses Ctrl+Alt+N, because Ctrl+D has + always duplicated the current line. + """ + self._add_shortcut_actions(( + ("Ctrl+Shift+L", "cursors_on_selected_lines", self.add_cursors_to_selected_lines), + ("Ctrl+Shift+Escape", "clear_extra_cursors", self.clear_extra_cursors), + ("Ctrl+Alt+Shift+Up", "cursor_above", self.add_cursor_above), + ("Ctrl+Alt+Shift+Down", "cursor_below", self.add_cursor_below), + ("Ctrl+Alt+N", "cursor_at_next_occurrence", self.add_cursor_at_next_occurrence), + )) + + def add_cursors_to_selected_lines(self) -> int: + """ + 在選取範圍的每一行行尾放一個游標 + Put a caret at the end of every line in the selection. + + :return: 額外游標的數量 / how many extra carets were added + """ + return self.multi_cursor_manager.add_to_selected_lines() + + def add_cursor_above(self) -> bool: + """ + 在上一行的同一欄加一個游標 + Add a caret on the line above, at the same column. + + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + return self.multi_cursor_manager.add_caret_on_neighbouring_line(-1) + + def add_cursor_below(self) -> bool: + """ + 在下一行的同一欄加一個游標 + Add a caret on the line below, at the same column. + + :return: 有加入時為 ``True`` / ``True`` when a caret was added + """ + return self.multi_cursor_manager.add_caret_on_neighbouring_line(1) + + def add_cursor_at_next_occurrence(self) -> bool: + """ + 在游標所在字詞的下一個出現處加一個游標 + Add a caret at the next occurrence of the word under the caret. + + :return: 找到並加入時為 ``True`` / ``True`` when another occurrence was found + """ + return self.multi_cursor_manager.add_caret_at_next_occurrence() + + def clear_extra_cursors(self) -> bool: + """ + 清除所有額外游標 + Drop every extra caret. + + :return: 是否真的清掉了什麼 / whether anything was actually dropped + """ + return self.multi_cursor_manager.clear() + + def _handle_multi_cursor_click(self, event: QtGui.QMouseEvent) -> bool: + """ + Alt+點按新增或移除一個游標;一般點按則收掉額外游標 + Alt-click adds or removes a caret; a plain click drops the extra ones. + + :param event: 滑鼠事件 / the mouse event + :return: 事件是否已被處理 / whether the event was handled here + """ + if event.modifiers() & Qt.KeyboardModifier.AltModifier: + # 記下起點,之後拖曳就是欄選取;沒有拖曳就當作切換一個游標 + # Remember the anchor: dragging from here is a column selection, and + # not dragging counts as toggling one caret + self._column_anchor = self.cursorForPosition(event.pos()).position() + self._column_dragged = False + return True + self._column_anchor = None + if self.multi_cursor_manager.active: + # 一般點按等於重新開始,因此先收掉額外游標 + # A plain click starts over, so the extra carets go first + self.multi_cursor_manager.clear() + return False + + def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None: + """ + Alt+拖曳做欄選取,其餘照原本行為 + Alt-drag makes a column selection; every other drag behaves as before. + """ + if self._column_anchor is not None and event.buttons() & Qt.MouseButton.LeftButton: + self._column_dragged = True + self.multi_cursor_manager.select_column( + self._column_anchor, self.cursorForPosition(event.pos()).position()) + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None: + """ + 放開 Alt+點按時,若沒有拖曳就切換一個游標 + On releasing an Alt-click that did not drag, toggle a single caret. + """ + if self._column_anchor is not None: + if not self._column_dragged: + self.multi_cursor_manager.toggle_at(self._column_anchor) + self._column_anchor = None + return + super().mouseReleaseEvent(event) + + def _paint_extra_cursors(self) -> None: + """畫出每個額外游標 / Draw each extra caret.""" + painter = QPainter(self.viewport()) + painter.setPen(actually_color_dict.get("extra_cursor_color")) + cursor = QTextCursor(self.document()) + for position in self.multi_cursor_manager.positions(): + cursor.setPosition(min(position, self.document().characterCount() - 1)) + rect = self.cursorRect(cursor) + painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()) + painter.end() + + def _handle_multi_cursor_key(self, event: QKeyEvent) -> bool: + """ + 把輸入與退格套用到每個額外游標 + Apply typing and Backspace at every extra caret. + + :param event: 按鍵事件 / the key event + :return: 事件是否已被處理 / whether the event was handled here + """ + if not self.multi_cursor_manager.active: + return False + key = event.key() + handler = _MULTI_CURSOR_KEYS.get(key) + if handler is not None: + return handler(self.multi_cursor_manager) + if event.text() and event.text().isprintable(): + return self.multi_cursor_manager.insert_text(event.text()) + return False + + def _handle_snippet_tab(self) -> bool: + """ + 以 Tab 展開片段,或跳到片段的下一個定位點 + Expand a snippet on Tab, or move to its next stop. + + 兩者都不適用時回傳 ``False``,Tab 就維持原本的縮排行為。 + Returns ``False`` when neither applies, leaving Tab to indent as before. + + :return: Tab 是否被片段處理掉 / whether Tab was consumed by a snippet + """ + if self.snippet_manager.has_pending_stops: + return self.snippet_manager.next_stop() + return self.snippet_manager.expand_at_cursor() + def _handle_enter_autoindent(self, event: QKeyEvent) -> None: """Enter 自動縮排 / Auto-indent on Enter.""" cursor = self.textCursor() @@ -1623,6 +2590,20 @@ def keyPressEvent(self, event: QKeyEvent) -> None: key = event.key() modifiers = event.modifiers() + # 錄製中就先記下這個按鍵,再照常處理 + # While recording, note the keystroke before handling it as usual + self.macro.record(int(key), int(modifiers.value), event.text()) + + # 對選取範圍輸入成對字元時包住它,而不是取代掉 + # Typing a pairing character over a selection wraps it instead of + # replacing it + if event.text() in SURROUND_PAIRS and self.textCursor().hasSelection(): + if self.surround_selection(event.text()): + return + + if self._handle_multi_cursor_key(event): + return + if modifiers & Qt.KeyboardModifier.ControlModifier and self._handle_ctrl_shortcuts(event): return @@ -1659,11 +2640,202 @@ def keyPressEvent(self, event: QKeyEvent) -> None: self.completer.popup().close() self._complete_timer.start() + def build_context_menu(self) -> QMenu: + """ + 建立右鍵選單 + Build the right-click menu. + + 用 Qt 內建的編輯選單當底(剪下/複製/貼上/復原都在裡面,且啟用狀態由 Qt + 自己維護),再接上編輯器自己的動作。 + Qt's own edit menu is the base — cut, copy, paste and undo are already + there with Qt keeping their enabled state right — and the editor's own + actions are appended to it. + + :return: 可直接顯示的選單 / a menu ready to show + """ + word = language_wrapper.language_word_dict + menu = self.createStandardContextMenu() + menu.addSeparator() + for label_key, handler, enabled in ( + ("context_menu_toggle_comment", self.toggle_comment, True), + ("context_menu_toggle_bookmark", self.toggle_bookmark, True), + ("context_menu_go_to_definition", self.go_to_definition, + self.lsp_client.running), + ("context_menu_hover", self.request_hover, self.lsp_client.running), + ("context_menu_rename_symbol", self.rename_symbol, True), + ("context_menu_format_document", self.format_with_language_server, + self.lsp_client.running), + ("context_menu_stage_hunk", self.stage_change_at_cursor, + self.diff_marker_manager.has_baseline), + ("context_menu_revert_hunk", self.revert_change_at_cursor, + self.diff_marker_manager.has_baseline), + ): + action = menu.addAction(word.get(label_key)) + action.triggered.connect(handler) + action.setEnabled(enabled) + return menu + + def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None: + """顯示右鍵選單 / Show the right-click menu.""" + menu = self.build_context_menu() + menu.exec(event.globalPos()) + menu.deleteLater() + + def go_to_definition_location(self, location: dict) -> bool: + """ + 跳到語言伺服器回報的定義位置 + Jump to the definition a language server reported. + + 定義在別的檔案時交給主視窗開啟;在同一個檔案就直接跳行。 + A definition in another file is opened by the main window; one in this + file is just a jump. + + :param location: ``{"path", "line", "column"}`` + :return: 有跳轉時為 ``True`` / ``True`` when the caret moved + """ + path = location.get("path", "") + line = location.get("line", 1) + same_file = self.current_file is not None and Path(str(self.current_file)) == Path(path) + if not same_file and path: + window = getattr(self.main_window, "main_window", None) + if window is not None and hasattr(window, "go_to_new_tab"): + window.go_to_new_tab(Path(path)) + return True + return False + return self.jump_to_line(line) + + def apply_server_edits(self, edits: list) -> bool: + """ + 套用語言伺服器回傳的文字編輯 + Apply the text edits a language server returned. + + 由後往前套用,前面的編輯位置就不會被前一次改動推移;整批算一個復原步驟。 + The edits are applied from the end backwards so an earlier one is never + moved by a change already made after it, and the batch is one undo step. + + :param edits: 編輯清單 / the edits to apply + :return: 有套用時為 ``True`` / ``True`` when anything was applied + """ + if not edits: + return False + document = self.document() + ordered = sorted( + edits, key=lambda edit: (edit["start_line"], edit["start_column"]), reverse=True) + cursor = self.textCursor() + cursor.beginEditBlock() + try: + for edit in ordered: + start = _document_position(document, edit["start_line"], edit["start_column"]) + end = _document_position(document, edit["end_line"], edit["end_column"]) + if start is None or end is None: + continue + cursor.setPosition(start) + cursor.setPosition(max(end, start), QTextCursor.MoveMode.KeepAnchor) + cursor.insertText(edit["new_text"]) + finally: + cursor.endEditBlock() + return True + + def show_hover_text(self, text: str) -> None: + """ + 顯示語言伺服器回傳的說明 + Show the description a language server returned. + + :param text: 說明文字 / the description + """ + self.setToolTip(text) + + def request_hover(self) -> bool: + """ + 向語言伺服器要求游標所在符號的說明 + Ask the language server to describe the symbol under the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_hover( + cursor.blockNumber(), cursor.positionInBlock()) + + def rename_symbol(self) -> bool: + """ + 透過語言伺服器重新命名游標所在的符號 + Rename the symbol under the caret through the language server. + + 沒有伺服器時退回既有的整份檔案字詞取代,因此 Python 檔仍然可以改名。 + Without a server this falls back to the existing whole-file word replace, + so renaming still works in a Python file. + + :return: 有進行重新命名時為 ``True`` / ``True`` when a rename happened + """ + if not self.lsp_client.running: + return self.rename_word_under_cursor() + cursor = self.textCursor() + current = self.textCursor() + current.select(QTextCursor.SelectionType.WordUnderCursor) + new_name, confirmed = QInputDialog.getText( + self, language_wrapper.language_word_dict.get("context_menu_rename_symbol"), + language_wrapper.language_word_dict.get("context_menu_rename_prompt"), + text=current.selectedText()) + if not confirmed or not new_name: + return False + return self.lsp_client.request_rename( + cursor.blockNumber(), cursor.positionInBlock(), new_name) + + def format_with_language_server(self) -> bool: + """ + 請語言伺服器格式化整份檔案 + Ask the language server to format the whole file. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + return self.lsp_client.request_formatting(self.indent_size()) + + def go_to_definition(self) -> bool: + """ + 請語言伺服器跳到游標所在符號的定義 + Ask the language server to jump to the definition under the caret. + + :return: 有送出請求時為 ``True`` / ``True`` when a request was sent + """ + cursor = self.textCursor() + return self.lsp_client.request_definition( + cursor.blockNumber(), cursor.positionInBlock()) + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + """ + 關閉前停掉所有背景工作 + Stop every background worker before closing. + + lint、git 基準、blame 與語言伺服器都各自持有執行緒或子程序;編輯器被銷毀 + 時若它們還在跑,Qt 會直接讓程序中止(``QThread: Destroyed while thread is + still running``),而中止的位置離真正的原因很遠。 + The lint pass, the git baseline, blame and the language server each hold a + thread or a subprocess. If one is still running when the editor is + destroyed, Qt aborts the process outright (``QThread: Destroyed while + thread is still running``) somewhere far from the actual cause. + + 計時器要先停:它們是「等輸入停下來再做」的排程,若留著,關閉之後才觸發的 + 那一次會重新開一條執行緒,而那時已經沒有人會去等它結束了。 + The timers go first: they schedule the work that waits for a pause in + typing, and one firing after the close would start a fresh thread that + nothing is left to wait for. + """ + self._lint_timer.stop() + self._diff_timer.stop() + self._complete_timer.stop() + self.lint_manager.stop() + self.diff_marker_manager.stop() + self.blame_manager.stop() + self.lsp_client.stop() + super().closeEvent(event) + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: """ - 滑鼠點擊事件 - Mouse press event + 滑鼠點擊事件(Alt+點按用於多重游標) + Mouse press event; Alt-click drives the extra carets. """ + if self._handle_multi_cursor_click(event): + return super().mousePressEvent(event) self.highlight_current_line() diff --git a/je_editor/pyside_ui/code/snippets/__init__.py b/je_editor/pyside_ui/code/snippets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/snippets/snippet_manager.py b/je_editor/pyside_ui/code/snippets/snippet_manager.py new file mode 100644 index 00000000..b636c2b2 --- /dev/null +++ b/je_editor/pyside_ui/code/snippets/snippet_manager.py @@ -0,0 +1,180 @@ +""" +在編輯器中展開片段並在定位點之間移動 +Expand snippets in the editor and step between their tab stops. + +輸入觸發字後按 Tab 展開;展開後 Tab 會依序跳到下一個定位點,走完就恢復成一般的 +Tab 縮排。 +Type a trigger word and press Tab to expand it. After expanding, Tab moves to the +next stop, and once they are used up Tab goes back to indenting. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from PySide6.QtGui import QTextCursor + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.snippets.snippet_expand import SnippetStop, expand_snippet, merge_snippets + +# 使用者片段檔名(放在設定資料夾內)/ The user snippet file, kept beside the settings +SNIPPET_FILE_NAME = "snippets.json" +# 設定資料夾 / The settings directory +SETTING_DIR_NAME = ".jeditor" + + +def snippet_file_path() -> Path: + """ + 使用者片段檔的位置 + Where the user's snippet file lives. + + :return: 片段檔路徑 / the snippet file's path + """ + return Path.cwd() / SETTING_DIR_NAME / SNIPPET_FILE_NAME + + +def save_snippets(snippets: dict[str, str], path: Path | None = None) -> bool: + """ + 把片段寫回使用者的片段檔 + Write the snippets back to the user's snippet file. + + :param snippets: 要儲存的片段 / the snippets to store + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + :return: 寫入成功時為 ``True`` / ``True`` when the file was written + """ + target = path if path is not None else snippet_file_path() + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(snippets, indent=4, ensure_ascii=False), encoding="utf-8") + except OSError as error: + jeditor_logger.error(f"snippet_manager: could not save snippets: {error!r}") + return False + return True + + +def load_snippets(path: Path | None = None, suffix: str = "") -> dict[str, str]: + """ + 載入片段:內建的加上使用者定義的 + Load the snippets: the built-in set plus anything the user defined. + + 檔案不存在、不是 JSON、或內容不是物件時只回傳內建片段,因為片段損毀不該讓 + 編輯器無法輸入。 + A missing file, invalid JSON, or a non-object all fall back to the built-in + set: a broken snippet file must not stop the user typing. + + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + :return: 觸發字對應片段內容 / trigger word -> snippet body + """ + target = path if path is not None else snippet_file_path() + try: + stored = json.loads(target.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + jeditor_logger.debug(f"snippet_manager: using built-in snippets only: {error!r}") + return merge_snippets(None, suffix) + return merge_snippets(stored, suffix) + + +class SnippetManager: + """ + 管理一個編輯器的片段展開狀態 + Track one editor's snippet expansion. + """ + + def __init__(self, code_edit) -> None: + """ + :param code_edit: 要展開片段的編輯器 / the editor snippets expand into + """ + self._code_edit = code_edit + self._snippets = load_snippets(suffix=self._suffix()) + # 尚未走訪的定位點(文件中的絕對位置)/ Stops not yet visited, as document positions + self._pending: list[SnippetStop] = [] + + def snippets(self) -> dict[str, str]: + """取得目前可用的片段 / The snippets currently available.""" + return dict(self._snippets) + + def _suffix(self) -> str: + """目前檔案的副檔名 / The current file's suffix.""" + current = getattr(self._code_edit, "current_file", None) + return Path(str(current)).suffix if current else "" + + def reload(self, path: Path | None = None) -> None: + """ + 重新載入使用者片段,並套用目前檔案的語言 + Reload the user's snippets, for the current file's language. + + :param path: 片段檔路徑,``None`` 表示預設位置 / the file, or ``None`` for the default + """ + self._snippets = load_snippets(path, self._suffix()) + + @property + def has_pending_stops(self) -> bool: + """是否還有沒走訪的定位點 / Whether any tab stop is still waiting.""" + return bool(self._pending) + + def trigger_word(self) -> str: + """ + 取得游標前的觸發字 + The trigger word immediately before the caret. + + :return: 觸發字,取不到時為空字串 / the word, or an empty string + """ + cursor = self._code_edit.textCursor() + if cursor.hasSelection(): + return "" + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + return cursor.selectedText() + + def expand_at_cursor(self) -> bool: + """ + 若游標前是觸發字就展開對應片段 + Expand the snippet for the trigger word before the caret, if there is one. + + 展開是單一復原步驟,因此一次 Ctrl+Z 就能取消整個片段。 + The expansion is one undo step, so a single Ctrl+Z removes the whole + snippet. + + :return: 有展開時為 ``True`` / ``True`` when a snippet was expanded + """ + word = self.trigger_word() + body = self._snippets.get(word) + if not body: + return False + text, stops = expand_snippet(body) + cursor = self._code_edit.textCursor() + cursor.beginEditBlock() + cursor.select(QTextCursor.SelectionType.WordUnderCursor) + start = cursor.selectionStart() + cursor.insertText(text) + cursor.endEditBlock() + self._pending = [ + SnippetStop(position=start + stop.position, length=stop.length) + for stop in stops + ] + self.next_stop() + return True + + def next_stop(self) -> bool: + """ + 移到下一個定位點,並選取它的預設值 + Move to the next tab stop, selecting its default value. + + :return: 有移動時為 ``True`` / ``True`` when the caret moved + """ + if not self._pending: + return False + stop = self._pending.pop(0) + cursor = self._code_edit.textCursor() + cursor.setPosition(min(stop.position, self._code_edit.document().characterCount() - 1)) + if stop.length: + cursor.setPosition( + min(stop.position + stop.length, + self._code_edit.document().characterCount() - 1), + QTextCursor.MoveMode.KeepAnchor) + self._code_edit.setTextCursor(cursor) + return True + + def clear_stops(self) -> None: + """放棄剩下的定位點 / Give up on the remaining stops.""" + self._pending = [] diff --git a/je_editor/pyside_ui/code/split_view/__init__.py b/je_editor/pyside_ui/code/split_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/code/split_view/split_editor_view.py b/je_editor/pyside_ui/code/split_view/split_editor_view.py new file mode 100644 index 00000000..cd0616fc --- /dev/null +++ b/je_editor/pyside_ui/code/split_view/split_editor_view.py @@ -0,0 +1,55 @@ +""" +同一份文件的第二個檢視 +A second view of the same document. + +分割檢視共用 ``QTextDocument``:兩邊看到的是同一份內容,任一邊編輯另一邊立刻 +跟著變,捲動與游標則各自獨立,因此可以一邊看檔案開頭一邊改結尾。 +A split view shares the ``QTextDocument``: both sides show one piece of content +and an edit in either appears in the other at once, while scrolling and the +caret stay independent — so the top of a file can be read while its end is +edited. + +語法高亮是掛在文件上而不是編輯器上,因此第二個檢視不需要自己的高亮器就會上色。 +Syntax highlighting attaches to the document rather than to an editor, so the +second view is highlighted without needing a highlighter of its own. +""" +from __future__ import annotations + +from PySide6.QtGui import QFontMetricsF +from PySide6.QtWidgets import QPlainTextEdit, QWidget + + +class SplitEditorView(QPlainTextEdit): + """ + 共用文件的輕量編輯檢視 + A lightweight editing view onto a shared document. + """ + + def __init__(self, source_editor: QPlainTextEdit, parent: QWidget | None = None) -> None: + """ + :param source_editor: 要共用文件的主編輯器 / the editor whose document is shared + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + self.setDocument(source_editor.document()) + self.setFont(source_editor.font()) + self.setLineWrapMode(source_editor.lineWrapMode()) + self.setTabStopDistance( + QFontMetricsF(source_editor.font()).horizontalAdvance(" " * 8)) + # 從主編輯器目前的位置開始看,而不是從檔案開頭 + # Start where the main editor is, rather than at the top of the file + self.setTextCursor(source_editor.textCursor()) + + def closeEvent(self, event) -> None: + """ + 關閉前先放開共用文件 + Release the shared document before closing. + + 若仍持有主編輯器的文件就被銷毀,Qt 會連帶清掉那份文件的檢視狀態;改指向 + 一份空文件可以乾淨地脫鉤。 + Destroying this view while it still holds the main editor's document lets + Qt tear down view state that document still needs; pointing it at an + empty document detaches cleanly first. + """ + self.setDocument(None) + super().closeEvent(event) diff --git a/je_editor/pyside_ui/code/syntax/generic_syntax.py b/je_editor/pyside_ui/code/syntax/generic_syntax.py new file mode 100644 index 00000000..d9af1192 --- /dev/null +++ b/je_editor/pyside_ui/code/syntax/generic_syntax.py @@ -0,0 +1,111 @@ +""" +以關鍵字規則為基礎的通用語法高亮 +Keyword-driven highlighting for languages other than Python. + +Python 由專屬的高亮器處理;其他語言以「關鍵字、字串、註解、數字」四類上色, +雖然比不上真正的語法分析,但足以讓程式碼結構一眼可辨。 +Python has a highlighter of its own. Everything else is coloured in four +categories — keywords, strings, comments and numbers — which is short of real +parsing but enough to make the shape of the code readable. +""" +from __future__ import annotations + +import re + +from PySide6.QtCore import QRegularExpression +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument + +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 + +# 區塊註解的狀態值 / The block state marking "inside a block comment" +_INSIDE_BLOCK_COMMENT = 1 + + +def _format_for(colour_key: str) -> QTextCharFormat: + """建立指定顏色的格式 / Build a format in the given colour.""" + text_format = QTextCharFormat() + text_format.setForeground(actually_color_dict.get(colour_key)) + return text_format + + +class GenericHighlighter(QSyntaxHighlighter): + """ + 依語言規則上色的通用高亮器 + A highlighter that colours a document from a language's rules. + """ + + def __init__(self, document: QTextDocument, rules: LanguageRules) -> None: + """ + :param document: 要上色的文件 / the document to highlight + :param rules: 該語言的規則 / that language's rules + """ + super().__init__(document) + self._rules = rules + self._patterns: list[tuple[QRegularExpression, QTextCharFormat]] = [] + keyword_format = _format_for("syntax_keyword_color") + for keyword in rules.keywords: + self._patterns.append( + (QRegularExpression(rf"\b{re.escape(keyword)}\b"), keyword_format)) + number_format = _format_for("syntax_number_color") + self._patterns.append((QRegularExpression(r"\b\d+(\.\d+)?\b"), number_format)) + string_format = _format_for("syntax_string_color") + for quote in rules.string_delimiters: + escaped = re.escape(quote) + self._patterns.append( + (QRegularExpression(rf"{escaped}[^{escaped}\\]*(\\.[^{escaped}\\]*)*{escaped}"), + string_format)) + self._comment_format = _format_for("syntax_comment_color") + + def highlightBlock(self, text: str) -> None: + """為一行上色 / Colour one line.""" + for pattern, text_format in self._patterns: + matches = pattern.globalMatch(text) + while matches.hasNext(): + match = matches.next() + self.setFormat(match.capturedStart(), match.capturedLength(), text_format) + self._highlight_line_comment(text) + self._highlight_block_comment(text) + + def _highlight_line_comment(self, text: str) -> None: + """單行註解從標記處一路到行尾 / A line comment runs from its marker to the line end.""" + marker = self._rules.line_comment + if not marker: + return + start = text.find(marker) + if start >= 0: + self.setFormat(start, len(text) - start, self._comment_format) + + def _highlight_block_comment(self, text: str) -> None: + """ + 區塊註解可能跨行,因此用區塊狀態記住是否還在註解裡 + A block comment may span lines, so the block state remembers whether one + is still open. + """ + if self._rules.block_comment is None: + return + opening, closing = self._rules.block_comment + start = 0 if self.previousBlockState() == _INSIDE_BLOCK_COMMENT else text.find(opening) + while start >= 0: + end = text.find(closing, start + len(opening)) + if end < 0: + self.setFormat(start, len(text) - start, self._comment_format) + self.setCurrentBlockState(_INSIDE_BLOCK_COMMENT) + return + length = end - start + len(closing) + self.setFormat(start, length, self._comment_format) + start = text.find(opening, start + length) + self.setCurrentBlockState(0) + + +def highlighter_for(document: QTextDocument, suffix: str) -> GenericHighlighter | None: + """ + 為某個副檔名建立高亮器 + Build a highlighter for a file suffix. + + :param document: 要上色的文件 / the document to highlight + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 高亮器,沒有對應規則時為 ``None`` / the highlighter, or ``None`` + """ + rules = rules_for(suffix) + return GenericHighlighter(document, rules) if rules is not None else None diff --git a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py index b2554f22..15fe6dfe 100644 --- a/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py +++ b/je_editor/pyside_ui/dialog/file_dialog/save_file_dialog.py @@ -15,7 +15,9 @@ from PySide6.QtWidgets import QFileDialog from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget -from je_editor.utils.file.save.save_file import write_file +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import format_before_save +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF +from je_editor.utils.file.save.save_file import write_file_with_encoding def _build_save_file_filters() -> list[str]: @@ -98,8 +100,15 @@ def choose_file_get_save_file_path(parent_qt_instance: EditorMain) -> bool: # 更新目前檔案路徑 / Update current file path widget.current_file = file_path - # 將編輯器內容寫入檔案 / Write editor content to file - write_file(file_path, widget.code_edit.toPlainText()) + # 若已開啟「存檔時格式化」,先套用再寫出 + # Apply format-on-save, when that setting is on, before writing + format_before_save(widget) + + # 以該分頁的編碼與行尾寫入 / Write with the tab's encoding and line ending + write_file_with_encoding( + file_path, widget.code_edit.toPlainText(), + getattr(widget, "file_encoding", DEFAULT_ENCODING), + getattr(widget, "line_ending", LINE_ENDING_LF)) # 更新已開啟檔案管理字典 / Update opened file manager dictionary path = Path(file_path) diff --git a/je_editor/pyside_ui/dialog/snippet_dialog/__init__.py b/je_editor/pyside_ui/dialog/snippet_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py b/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py new file mode 100644 index 00000000..33d36fea --- /dev/null +++ b/je_editor/pyside_ui/dialog/snippet_dialog/snippet_editor_dialog.py @@ -0,0 +1,148 @@ +""" +編輯使用者片段的對話框 +A dialog for editing the user's snippets. + +先前只能手動改 ``snippets.json``;這裡讓新增、修改與刪除都在編輯器裡完成,存檔 +後立刻對開著的分頁生效。 +Editing ``snippets.json`` by hand used to be the only way. This adds, changes and +removes them inside the editor, and saving takes effect in the open tabs at once. +""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QListWidget, QPlainTextEdit, QPushButton, + QVBoxLayout, QWidget +) + +from je_editor.pyside_ui.code.snippets.snippet_manager import load_snippets, save_snippets +from je_editor.utils.multi_language.multi_language_wrapper import language_wrapper + +# 新片段的預設觸發字 / The trigger a new snippet starts with +NEW_TRIGGER = "new" + + +class SnippetEditorDialog(QDialog): + """ + 列出片段並讓使用者編輯 + List the snippets and let the user edit them. + """ + + def __init__(self, main_window=None, parent: QWidget | None = None) -> None: + """ + :param main_window: 儲存後要通知的主視窗 / the window told about a save + :param parent: Qt 父元件 / the Qt parent + """ + super().__init__(parent) + word = language_wrapper.language_word_dict + self._main_window = main_window + self.setWindowTitle(word.get("snippet_editor_title")) + self._snippets = load_snippets() + + self.trigger_list = QListWidget() + self.trigger_list.addItems(sorted(self._snippets)) + self.trigger_list.currentTextChanged.connect(self._show_body) + + self.body_edit = QPlainTextEdit() + self.body_edit.setPlaceholderText(word.get("snippet_editor_body_placeholder")) + + self.add_button = QPushButton(word.get("snippet_editor_add")) + self.add_button.clicked.connect(self.add_snippet) + self.remove_button = QPushButton(word.get("snippet_editor_remove")) + self.remove_button.clicked.connect(self.remove_snippet) + self.save_button = QPushButton(word.get("snippet_editor_save")) + self.save_button.clicked.connect(self.save) + + buttons = QHBoxLayout() + buttons.addWidget(self.add_button) + buttons.addWidget(self.remove_button) + buttons.addWidget(self.save_button) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(word.get("snippet_editor_hint"))) + body_row = QHBoxLayout() + body_row.addWidget(self.trigger_list, 1) + body_row.addWidget(self.body_edit, 2) + layout.addLayout(body_row) + layout.addLayout(buttons) + self.setLayout(layout) + if self.trigger_list.count(): + self.trigger_list.setCurrentRow(0) + + def snippets(self) -> dict[str, str]: + """取得目前編輯中的片段 / The snippets as they stand in the dialog.""" + return dict(self._snippets) + + def _show_body(self, trigger: str) -> None: + """切換到另一個片段時顯示它的內容 / Show a snippet's body when it is selected.""" + self._remember_body() + self._current_trigger = trigger + self.body_edit.setPlainText(self._snippets.get(trigger, "")) + + def _remember_body(self) -> None: + """把編輯中的內容記回目前的片段 / Keep the edited body against its trigger.""" + trigger = getattr(self, "_current_trigger", "") + if trigger: + self._snippets[trigger] = self.body_edit.toPlainText() + + def add_snippet(self) -> str: + """ + 新增一個片段 + Add a snippet. + + 觸發字重複時自動加上編號,因此新片段不會覆蓋既有的。 + A repeated trigger is numbered, so a new snippet never overwrites one + that is already there. + + :return: 新片段的觸發字 / the new snippet's trigger + """ + self._remember_body() + trigger = NEW_TRIGGER + index = 2 + while trigger in self._snippets: + trigger = f"{NEW_TRIGGER}{index}" + index += 1 + self._snippets[trigger] = "$0" + self.trigger_list.addItem(trigger) + self.trigger_list.setCurrentRow(self.trigger_list.count() - 1) + return trigger + + def remove_snippet(self) -> bool: + """ + 刪除選取的片段 + Remove the selected snippet. + + :return: 有刪除時為 ``True`` / ``True`` when one was removed + """ + row = self.trigger_list.currentRow() + if row < 0: + return False + trigger = self.trigger_list.item(row).text() + self._snippets.pop(trigger, None) + self._current_trigger = "" + self.trigger_list.takeItem(row) + self.body_edit.setPlainText("") + return True + + def save(self) -> bool: + """ + 儲存片段,並讓開著的分頁立刻套用 + Save the snippets and let the open tabs pick them up at once. + + :return: 儲存成功時為 ``True`` / ``True`` when they were saved + """ + self._remember_body() + if not save_snippets(self._snippets): + return False + self._reload_open_tabs() + return True + + def _reload_open_tabs(self) -> None: + """請每個編輯分頁重新載入片段 / Ask each editor tab to reload its snippets.""" + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(self._main_window, "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.code_edit.snippet_manager.reload() 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 15417b76..fde1f651 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 @@ -39,6 +39,10 @@ def __init__(self, root: str) -> None: :param root: 要索引的專案根目錄 / The project root to index """ 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._stop_requested = False 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 4f3461fa..b27c963a 100644 --- a/je_editor/pyside_ui/main_ui/editor/editor_widget.py +++ b/je_editor/pyside_ui/main_ui/editor/editor_widget.py @@ -19,7 +19,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 + QMessageBox, QHBoxLayout 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 @@ -29,10 +29,15 @@ from je_editor.pyside_ui.code.auto_save.auto_save_thread import CodeEditSaveThread from je_editor.pyside_ui.code.code_format.pep8_format import PEP8FormatChecker from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import CodeEditor +from je_editor.pyside_ui.code.minimap.minimap_widget import MinimapWidget +from je_editor.pyside_ui.code.split_view.split_editor_view import SplitEditorView 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.utils.file.open.open_file import read_file +from je_editor.utils.encodings.text_codec import ( + DEFAULT_ENCODING, LINE_ENDING_LF +) +from je_editor.utils.file.open.open_file import read_file, read_file_with_encoding class EditorWidget(QWidget): @@ -58,6 +63,11 @@ def __init__(self, main_window: EditorMain) -> None: # ---------------- Init variables 初始化變數 ---------------- self.checker: Union[PEP8FormatChecker, None] = None self.current_file = None + # 目前檔案的編碼與行尾,開檔時偵測,存檔時照原樣寫回 + # The current file's encoding and line ending, detected on open and + # written back unchanged on save + self.file_encoding: str = DEFAULT_ENCODING + self.line_ending: str = LINE_ENDING_LF self.tree_view_scroll_area = None self.project_treeview: Union[QTreeView, None] = None self.project_treeview_model = None @@ -143,8 +153,19 @@ def __init__(self, main_window: EditorMain) -> None: self.code_difference_result.addTab( self.git_gui, language_wrapper.language_word_dict.get("tab_menu_git_client_tab_name")) + # 同檔分割檢視與縮圖,開啟時才建立 + # The same-file split view and the minimap, both created on demand + self.split_view: Union[SplitEditorView, None] = None + self.minimap: Union[MinimapWidget, None] = None + # 編輯器與縮圖並排的容器 / Holds the editor and the minimap side by side + self.editor_row = QWidget() + self.editor_row_layout = QHBoxLayout(self.editor_row) + self.editor_row_layout.setContentsMargins(0, 0, 0, 0) + self.editor_row_layout.setSpacing(0) + self.editor_row_layout.addWidget(self.code_edit_scroll_area) + # 加入分割器 / Add widgets to splitters - self.edit_splitter.addWidget(self.code_edit_scroll_area) + self.edit_splitter.addWidget(self.editor_row) self.edit_splitter.addWidget(self.code_difference_result) self.edit_splitter.setStretchFactor(0, 3) self.edit_splitter.setStretchFactor(1, 1) @@ -243,11 +264,13 @@ def open_an_file(self, path: Path) -> bool: if self.code_save_thread: self.code_save_thread.skip_this_round = True - # 讀取檔案內容 / Read file content - result = read_file(str(path)) + # 讀取檔案內容,同時記下編碼與行尾,存檔時照原樣寫回 + # Read the content, remembering the encoding and line ending so a save + # writes the file back the way it was found + result = read_file_with_encoding(str(path)) if result is None: return False - file, file_content = result + file, file_content, self.file_encoding, self.line_ending = result self.code_edit.setPlainText(file_content) # 依內容偵測縮排寬度;失敗不可影響開檔 / Detect indent; must not break opening @@ -260,6 +283,10 @@ def open_an_file(self, path: Path) -> bool: self.current_file = file self.code_edit.current_file = file self.code_edit.reset_highlighter() + # 換檔後重新取得 git 比較基準與語言伺服器 + # Reload the git baseline and the language server for the new file + self.code_edit.load_git_baseline() + self.code_edit.start_language_server() # 更新使用者設定中的最後開啟檔案 / Update last opened file in user settings user_setting_dict.update({"last_file": str(self.current_file)}) @@ -321,6 +348,47 @@ def mark_saved(self) -> None: if title.endswith(" *"): self.tab_manager.setTabText(idx, title[:-2]) + def toggle_minimap(self) -> bool: + """ + 切換縮圖顯示 + Toggle the minimap. + + :return: 切換後是否為開啟 / whether the minimap is now shown + """ + if self.minimap is not None: + self.editor_row_layout.removeWidget(self.minimap) + self.minimap.setParent(None) + self.minimap.deleteLater() + self.minimap = None + return False + self.minimap = MinimapWidget(self.code_edit) + self.editor_row_layout.addWidget(self.minimap) + return True + + def toggle_split_view(self) -> bool: + """ + 切換同檔分割檢視 + Toggle the split view of the same file. + + 兩個檢視共用同一份文件,因此任一邊的編輯會立刻出現在另一邊,而捲動與游標 + 各自獨立。 + Both views share one document, so an edit in either appears in the other + at once, while scrolling and the caret stay independent. + + :return: 切換後是否為開啟 / whether the split view is now shown + """ + if self.split_view is not None: + self.split_view.close() + self.split_view.setParent(None) + self.split_view.deleteLater() + self.split_view = None + return False + self.split_view = SplitEditorView(self.code_edit) + # 插在主編輯器下方、輸出區上方 / Below the main editor, above the output + self.edit_splitter.insertWidget(1, self.split_view) + self.edit_splitter.setSizes([200, 200, 100]) + return True + def rename_self_tab(self) -> None: """ 將分頁的標籤名稱改為目前檔案名稱 (不限當前分頁)。 @@ -459,6 +527,15 @@ def close(self) -> bool: self.exec_shell = None self.exec_python_debugger = None + # 讓編輯器自己收掉背景工作:它的 closeEvent 除了停掉各個管理器,也會停掉 + # 那些「等輸入停下來再做」的計時器,否則關閉之後才觸發的那一次會重新開一 + # 條執行緒,而那時已經沒有人會等它結束 + # Let the editor stop its own background work: its closeEvent stops the + # managers and also the timers that schedule work after a pause in typing, + # one of which would otherwise fire after the close and start a fresh + # thread that nothing is left to wait for + self.code_edit.close() + if self.current_file: file_is_open_manager_dict.pop(str(Path(self.current_file)), None) auto_save_manager_dict.pop(self.current_file, None) diff --git a/je_editor/pyside_ui/main_ui/main_editor.py b/je_editor/pyside_ui/main_ui/main_editor.py index 1431eed2..1cc8023b 100644 --- a/je_editor/pyside_ui/main_ui/main_editor.py +++ b/je_editor/pyside_ui/main_ui/main_editor.py @@ -37,9 +37,13 @@ ) from je_editor.pyside_ui.main_ui.system_tray.extend_system_tray import ExtendSystemTray 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.session.open_files_session import ( SESSION_SETTING_KEY, + SESSION_STATE_KEY, + collect_file_states, collect_open_files, + restorable_file_state, restorable_files, ) from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -393,21 +397,44 @@ def _restore_open_files_session(self) -> None: user_setting_dict.get(SESSION_SETTING_KEY), already_open=[path for path in self._open_file_paths() if path], ) + stored_states = user_setting_dict.get(SESSION_STATE_KEY) for file_path in to_restore: self.go_to_new_tab(Path(file_path)) + restore_editor_state( + self.tab_widget.currentWidget(), + restorable_file_state(stored_states, file_path)) except Exception as error: jeditor_logger.warning(f"Restoring the open-file session failed: {error}") def _save_open_files_session(self) -> None: """ - 記錄目前開啟的分頁,供下次啟動還原 - Record the currently open tabs so the next startup can restore them. + 記錄目前開啟的分頁與其編輯狀態,供下次啟動還原 + Record the open tabs and their editor state so the next startup can + restore both. """ try: user_setting_dict[SESSION_SETTING_KEY] = collect_open_files(self._open_file_paths()) + user_setting_dict[SESSION_STATE_KEY] = collect_file_states( + self._open_file_states()) except Exception as error: jeditor_logger.warning(f"Saving the open-file session failed: {error}") + def _open_file_states(self) -> dict: + """ + 取得每個編輯分頁目前的游標、書籤與折疊狀態 + Collect each editor tab's caret, bookmarks and folds. + + :return: 路徑對應狀態 / path -> state + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + states: dict = {} + for index in range(self.tab_widget.count()): + widget = self.tab_widget.widget(index) + if not isinstance(widget, EditorWidget) or not widget.current_file: + continue + states[str(widget.current_file)] = editor_state(widget) + return states + def go_to_new_tab(self, file_path: Path) -> None: """ 開啟新分頁並載入檔案 diff --git a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py index ebedba19..ea1fc95f 100644 --- a/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/check_style_menu/build_check_style_menu.py @@ -10,8 +10,9 @@ from je_editor.pyside_ui.main_ui.main_editor import EditorMain # 僅在型別檢查時匯入 / Import only for type checking from PySide6.QtGui import QAction, QKeySequence # Qt 動作與快捷鍵 / Qt actions and shortcuts -from yapf.yapflib.yapf_api import FormatCode # YAPF 程式碼格式化工具 / YAPF code formatter +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict # 使用者設定 / User settings +from je_editor.utils.format_code.yapf_format import format_python_source # 格式化邏輯 / Formatting logic from je_editor.utils.json_format.json_process import reformat_json # JSON 格式化工具 / JSON reformatter @@ -61,6 +62,26 @@ def set_check_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.check_menu.addAction(ui_we_want_to_set.check_menu.check_python_format) + # === 4. 存檔時自動格式化 / Format on save === + ui_we_want_to_set.check_menu.format_on_save_action = QAction( + language_wrapper.language_word_dict.get("format_on_save_label")) + ui_we_want_to_set.check_menu.format_on_save_action.setCheckable(True) + ui_we_want_to_set.check_menu.format_on_save_action.setChecked( + bool(user_setting_dict.get("format_on_save", False))) + ui_we_want_to_set.check_menu.format_on_save_action.toggled.connect(set_format_on_save) + ui_we_want_to_set.check_menu.addAction(ui_we_want_to_set.check_menu.format_on_save_action) + + +def set_format_on_save(enabled: bool) -> None: + """ + 設定是否在存檔時自動格式化 + Turn format-on-save on or off. + + :param enabled: 是否開啟 / whether it should be on + """ + jeditor_logger.info(f"build_check_style_menu.py set_format_on_save enabled: {enabled}") + user_setting_dict.update({"format_on_save": bool(enabled)}) + def yapf_check_python_code(ui_we_want_to_set: EditorMain) -> None: """ @@ -72,12 +93,9 @@ def yapf_check_python_code(ui_we_want_to_set: EditorMain) -> None: if isinstance(widget, EditorWidget): code_text = widget.code_edit.toPlainText() # 取得編輯器文字 / Get code text widget.code_result.setPlainText("") # 清空結果區域 / Clear result area - format_code = FormatCode( - unformatted_source=code_text, - style_config="google" # 使用 Google 風格 / Use Google style - ) - if isinstance(format_code, tuple): - widget.code_edit.setPlainText(format_code[0]) # 將格式化後的程式碼寫回編輯器 / Write formatted code back + formatted = format_python_source(code_text) + if formatted != code_text: + widget.code_edit.setPlainText(formatted) # 寫回格式化結果 / Write the result back def reformat_json_text(ui_we_want_to_set: EditorMain) -> None: 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 83ca0983..0d30ed3a 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 @@ -20,6 +20,8 @@ from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget 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.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.file.open.open_file import read_file # 檔案讀取工具 / File reading utility from je_editor.utils.logging.loggin_instance import jeditor_logger # 日誌紀錄器 / Logger @@ -138,6 +140,22 @@ 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_todo_panel) + # === Problems Panel Dock === + ui_we_want_to_set.dock_menu.new_problems_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_problems_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_problems_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "problems_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_problems_panel) + + # === Test Panel Dock === + ui_we_want_to_set.dock_menu.new_test_panel = QAction( + language_wrapper.language_word_dict.get("tab_menu_test_panel_tab_name")) + ui_we_want_to_set.dock_menu.new_test_panel.triggered.connect( + lambda: add_dock_widget(ui_we_want_to_set, "test_panel") + ) + ui_we_want_to_set.dock_tools_menu.addAction(ui_we_want_to_set.dock_menu.new_test_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")) @@ -180,6 +198,10 @@ def _dock_builders(ui_we_want_to_set: EditorMain) -> dict: lambda: TodoPanelWidget(ui_we_want_to_set)), "outline_panel": ("tab_menu_outline_panel_tab_name", lambda: OutlinePanelWidget(ui_we_want_to_set)), + "problems_panel": ("tab_menu_problems_panel_tab_name", + lambda: ProblemsPanelWidget(ui_we_want_to_set)), + "test_panel": ("tab_menu_test_panel_tab_name", + lambda: TestPanelWidget(ui_we_want_to_set)), } 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 1107966a..eb045160 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 @@ -12,6 +12,12 @@ # 匯入 Python 編碼清單 (例如 utf-8, gbk 等) # Import list of Python encodings (e.g., utf-8, gbk, etc.) from je_editor.utils.encodings.python_encodings import python_encodings_list +from je_editor.utils.encodings.text_codec import ( + LINE_ENDING_CR, LINE_ENDING_CRLF, LINE_ENDING_LF, line_ending_name +) +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding, apply_line_ending_choice, save_all_tabs +) # 匯入日誌紀錄器 # Import logger instance from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -99,6 +105,18 @@ def set_file_menu(ui_we_want_to_set: EditorMain) -> None: add_font_menu(ui_we_want_to_set) add_font_size_menu(ui_we_want_to_set) add_encoding_menu(ui_we_want_to_set) + add_line_ending_menu(ui_we_want_to_set) + + # 儲存所有分頁 / Save every modified tab + ui_we_want_to_set.file_menu.save_all_action = QAction( + language_wrapper.language_word_dict.get("file_menu_save_all_label")) + # Ctrl+Alt+S 是編輯器的「排序選取的行」,因此這裡用慣例的 Ctrl+Shift+S + # Ctrl+Alt+S sorts the selected lines in the editor, so this uses the + # conventional Ctrl+Shift+S + ui_we_want_to_set.file_menu.save_all_action.setShortcut("Ctrl+Shift+S") + ui_we_want_to_set.file_menu.save_all_action.triggered.connect( + lambda: save_all_tabs(ui_we_want_to_set)) + ui_we_want_to_set.file_menu.addAction(ui_we_want_to_set.file_menu.save_all_action) # 最近開啟的檔案選單 / Recent Files menu @@ -174,6 +192,25 @@ def set_encoding(ui_we_want_to_set: EditorMain, action: QAction) -> None: f"action: {action}") ui_we_want_to_set.encoding = action.text() user_setting_dict.update({"encoding": action.text()}) + # 讓選擇真的作用到目前分頁的讀寫,而不只是記在設定裡 + # Make the choice reach the current tab's read and write path, rather than + # only being recorded in the settings + apply_encoding(ui_we_want_to_set, action.text()) + + +# 建立行尾選單 +# Add line-ending menu +def add_line_ending_menu(ui_we_want_to_set: EditorMain) -> None: + jeditor_logger.info("build_file_menu.py add_line_ending_menu") + ui_we_want_to_set.file_menu.line_ending_menu = ui_we_want_to_set.file_menu.addMenu( + language_wrapper.language_word_dict.get("file_menu_line_ending_label")) + for ending in (LINE_ENDING_LF, LINE_ENDING_CRLF, LINE_ENDING_CR): + ending_action = QAction( + line_ending_name(ending), parent=ui_we_want_to_set.file_menu.line_ending_menu) + ending_action.triggered.connect( + lambda checked=False, value=ending: apply_line_ending_choice( + ui_we_want_to_set, value)) + ui_we_want_to_set.file_menu.line_ending_menu.addAction(ending_action) # 顯示新建檔案對話框 diff --git a/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py new file mode 100644 index 00000000..4b77a9ba --- /dev/null +++ b/je_editor/pyside_ui/main_ui/menu/file_menu/encoding_actions.py @@ -0,0 +1,160 @@ +""" +套用編碼與行尾設定到目前的編輯分頁 +Apply an encoding or line-ending choice to the current editor tab. + +編碼選單原本只把選擇存進設定檔,讀寫檔案時並沒有用到;這裡讓它真的生效。 +The encoding menu used to only record the choice in the settings without it +reaching the read or write path; this makes the choice take effect. +""" +from __future__ import annotations + +from pathlib import Path + +from je_editor.utils.encodings.text_codec import LINE_ENDING_LF +from je_editor.utils.exception.exceptions import JEditorOpenFileException +from je_editor.utils.file.open.open_file import read_file_with_encoding +from je_editor.utils.logging.loggin_instance import jeditor_logger + + +def current_editor_tab(ui_we_want_to_set): + """ + 取得目前的編輯分頁 + Return the current editor tab. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 編輯分頁,目前分頁不是編輯器時為 ``None`` / the editor tab, or ``None`` + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return None + widget = tab_widget.currentWidget() + return widget if isinstance(widget, EditorWidget) else None + + +def _update_auto_save(widget) -> None: + """讓自動儲存沿用同樣的編碼與行尾 / Keep auto-save on the same encoding and ending.""" + thread = getattr(widget, "code_save_thread", None) + if thread is not None: + thread.encoding = widget.file_encoding + thread.line_ending = widget.line_ending + + +def apply_encoding(ui_we_want_to_set, encoding: str) -> bool: + """ + 以指定編碼重新解讀目前檔案,並用它存檔 + Re-read the current file with *encoding*, and save with it from now on. + + 只有在沒有未儲存修改時才重新讀取,否則改個編碼就會把使用者正在編輯的內容 + 丟掉;有修改時仍會改用新編碼存檔。 + The file is only re-read when nothing is unsaved, since re-reading would + otherwise throw away what the user is editing; the new encoding still + applies to the next save either way. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param encoding: 要使用的編碼 / the encoding to use + :return: 有套用到某個分頁時為 ``True`` / ``True`` when a tab was updated + """ + widget = current_editor_tab(ui_we_want_to_set) + if widget is None: + return False + widget.file_encoding = encoding + _update_auto_save(widget) + if widget.current_file is None or getattr(widget, "_is_modified", False): + return True + try: + result = read_file_with_encoding(str(widget.current_file), encoding) + except JEditorOpenFileException: + # 這個編碼解不開這個檔案:保留畫面上的內容,讓使用者再選一次 + # The file does not decode as that encoding: leave the text alone so the + # user can simply choose another + jeditor_logger.info(f"encoding_actions: {encoding} cannot decode {widget.current_file}") + return True + if result is not None: + _path, content, used_encoding, line_ending = result + widget.code_edit.setPlainText(content) + widget.file_encoding = used_encoding + widget.line_ending = line_ending + _update_auto_save(widget) + return True + + +def format_before_save(widget) -> bool: + """ + 存檔前套用格式化(若已開啟該設定) + Format the buffer before saving, when the setting is on. + + 只格式化 Python 檔;格式化失敗(例如程式碼還沒寫完)時保持原內容。 + Only Python files are formatted, and source that cannot be formatted — half + -written code, say — is left exactly as it is. + + :param widget: 要格式化的編輯分頁 / the editor tab to format + :return: 內容有被改寫時為 ``True`` / ``True`` when the text was rewritten + """ + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + from je_editor.utils.format_code.yapf_format import format_python_source + if not user_setting_dict.get("format_on_save", False): + return False + file_path = getattr(widget, "current_file", None) + if file_path is None or Path(str(file_path)).suffix.lower() != ".py": + return False + source = widget.code_edit.toPlainText() + formatted = format_python_source(source) + if formatted == source: + return False + # 保留游標所在行,格式化後才不會跳到檔頭 + # Keep the caret's line so formatting does not throw it back to the top + cursor = widget.code_edit.textCursor() + line = cursor.blockNumber() + widget.code_edit.setPlainText(formatted) + widget.code_edit.jump_to_line(line + 1) + return True + + +def save_all_tabs(ui_we_want_to_set) -> int: + """ + 儲存每個有未存修改的編輯分頁 + Save every editor tab that has unsaved changes. + + 只存已經有檔名的分頁;沒有檔名的需要「另存新檔」對話框,不能默默決定位置。 + Only tabs that already have a file name are saved: one without needs the + Save As dialog, and its location must not be decided silently. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 實際存檔的分頁數 / how many tabs were written + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING + from je_editor.utils.file.save.save_file import write_file_with_encoding + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return 0 + saved = 0 + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if not isinstance(widget, EditorWidget) or not widget.current_file: + continue + format_before_save(widget) + write_file_with_encoding( + str(widget.current_file), widget.code_edit.toPlainText(), + getattr(widget, "file_encoding", DEFAULT_ENCODING), + getattr(widget, "line_ending", LINE_ENDING_LF)) + saved += 1 + return saved + + +def apply_line_ending_choice(ui_we_want_to_set, line_ending: str = LINE_ENDING_LF) -> bool: + """ + 設定目前檔案存檔時使用的行尾 + Set the line ending the current file is saved with. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param line_ending: 要使用的行尾 / the line ending to write + :return: 有套用到某個分頁時為 ``True`` / ``True`` when a tab was updated + """ + widget = current_editor_tab(ui_we_want_to_set) + if widget is None: + return False + widget.line_ending = line_ending + _update_auto_save(widget) + return True 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 f11475ae..dfb299f9 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 @@ -67,6 +67,30 @@ 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) +# 把編輯器上設定的中斷點交給剛啟動的除錯器 +# Hand the breakpoints set in the editor to the debugger that just started +def send_breakpoints(widget: EditorWidget) -> int: + """ + 把這個分頁的中斷點送進 pdb + Send this tab's breakpoints into pdb. + + pdb 啟動後會停在第一行等待輸入,因此這時送出的 ``break`` 指令會在程式真正開始 + 跑之前生效。少了這一步,在編輯器邊條設定的中斷點就只是畫面上的標記。 + pdb stops on the first line waiting for input, so the ``break`` commands sent + now take effect before the program really starts. Without this step the + breakpoints set in the editor's gutter are only marks on the screen. + + :param widget: 正在除錯的分頁 / the tab being debugged + :return: 送出的中斷點數量 / how many breakpoints were sent + """ + code_edit = getattr(widget, "code_edit", None) + if code_edit is None: + return 0 + sent = code_edit.send_breakpoints_to_debugger() + jeditor_logger.info(f"build_debug_menu.py send_breakpoints sent: {sent}") + return sent + + # 執行除錯器 (pdb) # Run debugger (pdb) def run_debugger(ui_we_want_to_set: EditorMain) -> None: @@ -91,6 +115,7 @@ def run_debugger(ui_we_want_to_set: EditorMain) -> None: # 綁定除錯器與輸入介面 # Bind debugger and input interface widget.exec_python_debugger = code_exec + send_breakpoints(widget) widget.debugger_input = ProcessInput(widget) widget.debugger_input.show() else: diff --git a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py index 6c435680..7759c467 100644 --- a/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/style_menu/build_style_menu.py @@ -57,6 +57,53 @@ def set_style_menu(ui_we_want_to_set: EditorMain) -> None: # Add action to the Style menu ui_we_want_to_set.menu.style_menu.addAction(change_style_action) + # 編輯器疊加顯示的開關 / Toggles for the editor's overlays + ui_we_want_to_set.menu.style_menu.addSeparator() + add_overlay_toggles(ui_we_want_to_set) + + +def add_overlay_toggles(ui_we_want_to_set: EditorMain) -> None: + """ + 加入縮排參考線與尾端空白的顯示開關 + Add the toggles for indent guides and trailing-whitespace marking. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + """ + jeditor_logger.info("build_style_menu.py add_overlay_toggles") + for setting_key, label_key in ( + ("show_indent_guides", "style_menu_indent_guides_label"), + ("show_trailing_whitespace", "style_menu_trailing_whitespace_label"), + ): + toggle = QAction( + language_wrapper.language_word_dict.get(label_key), + parent=ui_we_want_to_set.menu.style_menu) + toggle.setCheckable(True) + toggle.setChecked(bool(user_setting_dict.get(setting_key, True))) + toggle.toggled.connect( + lambda checked, key=setting_key: set_overlay_setting(ui_we_want_to_set, key, checked)) + ui_we_want_to_set.menu.style_menu.addAction(toggle) + + +def set_overlay_setting(ui_we_want_to_set: EditorMain, setting_key: str, enabled: bool) -> None: + """ + 儲存疊加顯示的開關並立即重畫 + Store an overlay toggle and repaint straight away. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :param setting_key: 設定鍵名 / the settings key to update + :param enabled: 是否顯示 / whether the overlay is shown + """ + jeditor_logger.info(f"build_style_menu.py set_overlay_setting {setting_key}: {enabled}") + user_setting_dict.update({setting_key: bool(enabled)}) + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + for index in range(tab_widget.count()): + widget = tab_widget.widget(index) + if isinstance(widget, EditorWidget): + widget.code_edit.viewport().update() + # 套用選擇的樣式 # Apply the selected style diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py index 8650b221..413356a8 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_git_menu.py @@ -1,11 +1,14 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtGui import QAction from je_editor.pyside_ui.git_ui.code_diff_compare.code_diff_viewer_widget import DiffViewerWidget +from je_editor.utils.file_diff.unified import unified_diff_text + from je_editor.pyside_ui.git_ui.git_client.git_branch_tree_widget import GitTreeViewGUI from je_editor.pyside_ui.git_ui.git_client.git_client_gui import GitGui @@ -48,6 +51,77 @@ def set_tab_git_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.git_menu.addAction(ui_we_want_to_set.tab_menu.git_menu.add_code_diff_viewer_ui_action) + # === 目前檔案與 HEAD 的差異 === + # === Diff the current file against HEAD === + ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_diff_against_head_name")) + ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action.triggered.connect( + lambda: add_head_diff_tab(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.git_menu.addAction(ui_we_want_to_set.tab_menu.git_menu.add_file_diff_action) + + +def current_editor_widget(ui_we_want_to_set: EditorMain): + """ + 取得目前分頁的編輯器 + Return the editor in the current tab. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 編輯器分頁,目前分頁不是編輯器時為 ``None`` / the editor tab, or ``None`` + """ + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return None + widget = tab_widget.currentWidget() + return widget if isinstance(widget, EditorWidget) else None + + +def head_diff_text(ui_we_want_to_set: EditorMain) -> str: + """ + 取得目前檔案相對於 HEAD 的差異 + Return the current file's diff against its committed version. + + 使用編輯器已經在背景取得的基準,因此不需要再讀一次 git。 + Uses the baseline the editor already fetched in the background, so git is + not read again. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: diff 文字;沒有可比較的內容時為空字串 / the diff, or an empty string + """ + editor_widget = current_editor_widget(ui_we_want_to_set) + if editor_widget is None: + return "" + code_edit = editor_widget.code_edit + baseline = code_edit.diff_marker_manager.baseline() + if baseline is None: + return "" + name = Path(code_edit.current_file).name if code_edit.current_file else "" + return unified_diff_text(baseline, code_edit.toPlainText(), name) + + +def add_head_diff_tab(ui_we_want_to_set: EditorMain) -> bool: + """ + 以並排比對開啟目前檔案與 HEAD 的差異 + Open the current file's diff against HEAD in the side-by-side viewer. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 有差異並開啟分頁時為 ``True`` / ``True`` when a tab was opened + """ + jeditor_logger.info("build_tab_git_menu.py add head diff tab") + diff_text = head_diff_text(ui_we_want_to_set) + if not diff_text: + return False + viewer = DiffViewerWidget() + viewer.viewer.set_diff_text(diff_text) + ui_we_want_to_set.tab_widget.addTab( + viewer, + f"{language_wrapper.language_word_dict.get('tab_menu_diff_against_head_name')} " + f"{ui_we_want_to_set.tab_widget.count()}" + ) + return True + + def add_git_client_tab(ui_we_want_to_set: EditorMain) -> None: # 紀錄日誌:新增 Git Client 分頁 # Log: add a Git Client tab diff --git a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py index bfac0835..d767b9b1 100644 --- a/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py +++ b/je_editor/pyside_ui/main_ui/menu/tab_menu/build_tab_menu.py @@ -59,10 +59,92 @@ def set_tab_menu(ui_we_want_to_set: EditorMain) -> None: ) ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.add_console_widget_ui_action) + # === 同檔分割檢視 === + # === Split view of the same file === + ui_we_want_to_set.tab_menu.toggle_split_view_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_split_view_label")) + ui_we_want_to_set.tab_menu.toggle_split_view_action.setShortcut("Ctrl+Alt+\\") + ui_we_want_to_set.tab_menu.toggle_split_view_action.triggered.connect( + lambda: toggle_split_view(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_split_view_action) + + # === 縮圖 === + # === Minimap === + ui_we_want_to_set.tab_menu.toggle_minimap_action = QAction( + language_wrapper.language_word_dict.get("tab_menu_minimap_label")) + ui_we_want_to_set.tab_menu.toggle_minimap_action.setShortcut("Ctrl+Alt+M") + ui_we_want_to_set.tab_menu.toggle_minimap_action.triggered.connect( + lambda: toggle_minimap(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.toggle_minimap_action) + + # === 片段編輯器 === + # === Snippet editor === + ui_we_want_to_set.tab_menu.edit_snippets_action = QAction( + language_wrapper.language_word_dict.get("snippet_editor_title")) + ui_we_want_to_set.tab_menu.edit_snippets_action.triggered.connect( + lambda: show_snippet_editor(ui_we_want_to_set) + ) + ui_we_want_to_set.tab_menu.addAction(ui_we_want_to_set.tab_menu.edit_snippets_action) + set_tab_tools_menu(ui_we_want_to_set=ui_we_want_to_set) set_tab_git_menu(ui_we_want_to_set=ui_we_want_to_set) +def show_snippet_editor(ui_we_want_to_set: EditorMain): + """ + 開啟片段編輯器 + Open the snippet editor. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 開啟的對話框 / the dialog that was opened + """ + jeditor_logger.info("build_tab_menu.py show_snippet_editor") + from je_editor.pyside_ui.dialog.snippet_dialog.snippet_editor_dialog import ( + SnippetEditorDialog + ) + dialog = SnippetEditorDialog(ui_we_want_to_set) + dialog.show() + return dialog + + +def toggle_minimap(ui_we_want_to_set: EditorMain) -> bool: + """ + 切換目前分頁的縮圖 + Toggle the current tab's minimap. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 切換後是否為開啟 / whether the minimap is now shown + """ + jeditor_logger.info("build_tab_menu.py toggle_minimap") + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.toggle_minimap() + + +def toggle_split_view(ui_we_want_to_set: EditorMain) -> bool: + """ + 切換目前分頁的同檔分割檢視 + Toggle the split view of the current tab's file. + + :param ui_we_want_to_set: 主編輯器視窗 / the main editor window + :return: 切換後是否為開啟 / whether the split view is now shown + """ + jeditor_logger.info("build_tab_menu.py toggle_split_view") + tab_widget = getattr(ui_we_want_to_set, "tab_widget", None) + if tab_widget is None: + return False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.toggle_split_view() + + # === 以下為各分頁新增函式 === # === Functions to add each tab === diff --git a/je_editor/pyside_ui/main_ui/problems_panel/__init__.py b/je_editor/pyside_ui/main_ui/problems_panel/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..e2b2acbe --- /dev/null +++ b/je_editor/pyside_ui/main_ui/problems_panel/problems_panel_widget.py @@ -0,0 +1,296 @@ +""" +問題面板:列出目前分頁的 lint 診斷 +Problems panel: list the lint diagnostics of the current tab. + +診斷是由編輯器在背景檢查後持有的,面板只負責顯示與跳轉,不自己執行 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. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QHBoxLayout, QLabel, QPushButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget +) + +from je_editor.code_scan.ruff_lint import apply_fixes +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 +COLUMN_CODE = 0 +COLUMN_MESSAGE = 1 +COLUMN_LINE = 2 +COLUMN_FILE = 3 +# 訊息欄的預設寬度 / Default width of the message column +MESSAGE_COLUMN_WIDTH = 460 +# 「全部嚴重度」的篩選值 / Filter value meaning "every severity" +ALL_SEVERITIES = "*" + + +def current_code_editor(main_window): + """ + 取得目前分頁的程式碼編輯器 + Return the code editor of the current tab. + + :param main_window: 主編輯器視窗 / the main editor window + :return: 編輯器,目前分頁不是編輯器時為 ``None`` / the editor, or ``None`` + """ + 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 None + widget = tab_widget.currentWidget() + return widget.code_edit if isinstance(widget, EditorWidget) else None + + +class ProblemsPanelWidget(QWidget): + """ + 顯示目前檔案的 lint 診斷 + Show the lint diagnostics of the file in the current tab. + """ + + def __init__(self, main_window=None) -> None: + """ + :param main_window: 用來取得目前編輯器的主視窗 / the window holding the tabs + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + self._diagnostics: list[Diagnostic] = [] + # 專案檢查在工作執行緒進行,這裡持有進行中的那一個 + # The project check runs on a worker thread; this holds the one in flight + self._project_worker: ProjectLintWorker | None = None + + self.refresh_button = QPushButton(word.get("problems_panel_refresh")) + self.refresh_button.clicked.connect(self.refresh) + self.project_check = QCheckBox(word.get("problems_panel_whole_project")) + 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) + self.severity_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.setColumnWidth(COLUMN_MESSAGE, MESSAGE_COLUMN_WIDTH) + self.result_tree.setRootIsDecorated(False) + self.result_tree.itemDoubleClicked.connect(self._open_item) + + controls = QHBoxLayout() + controls.addWidget(self.refresh_button) + controls.addWidget(self.project_check) + controls.addWidget(self.severity_filter) + controls.addWidget(self.fix_button) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.result_tree) + self.setLayout(layout) + + self.refresh() + + def diagnostics(self) -> list[Diagnostic]: + """取得面板目前顯示的診斷 / The diagnostics currently listed.""" + return list(self._diagnostics) + + def refresh(self) -> None: + """ + 重新讀取診斷並重畫清單 + Re-read the diagnostics and rebuild the list. + + 單一檔案的診斷是編輯器持續在背景產生的,這裡只是取用最新結果;整個專案的 + 檢查則要另外啟動,結果會晚一點才到。 + A single file's diagnostics are produced in the background by the editor, + so those are just picked up; a project-wide check has to be started here + and its result arrives later. + """ + if self.project_check.isChecked(): + self.start_project_check() + return + 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() + + def start_project_check(self) -> bool: + """ + 在背景檢查整個專案 + Start a project-wide check on a worker thread. + + ruff 走遍整個專案要好幾秒,在 UI 執行緒做會讓視窗整段時間沒有反應。 + Walking a whole project with ruff takes seconds, and doing it on the UI + thread would leave the window unresponsive for all of it. + + :return: 是否啟動了檢查 / whether a check was started + """ + self._stop_project_check() + worker = ProjectLintWorker(self._project_root(), self) + self._project_worker = worker + worker.linted.connect(self._on_project_linted) + # 先放掉參考再刪除,避免之後對已刪除的物件呼叫方法 + # Drop the reference before deleting, so nothing calls a deleted object + worker.finished.connect(self._on_worker_finished) + worker.finished.connect(worker.deleteLater) + self.status_label.setText( + language_wrapper.language_word_dict.get("problems_panel_checking")) + worker.start() + return True + + def _on_project_linted(self, diagnostics: list) -> None: + """ + 套用背景檢查的結果 + Apply the diagnostics a background check produced. + + 只接受目前這個 worker 的結果:換過設定或再按一次重新檢查都會讓舊結果過期。 + Only the current worker's result is accepted, since changing the scope or + rechecking makes an in-flight run stale. + """ + if self.sender() is not self._project_worker: + return + self._diagnostics = list(diagnostics) + self._render_items() + + def _on_worker_finished(self) -> None: + """檢查結束後放掉參考 / Let go of the worker once it has finished.""" + if self.sender() is self._project_worker: + self._project_worker = None + + def _stop_project_check(self) -> None: + """結束仍在執行的專案檢查 / Stop a project check that is still running.""" + worker, self._project_worker = self._project_worker, None + if worker is None: + return + try: + if worker.isRunning(): + worker.blockSignals(True) + worker.wait() + except RuntimeError: + # 它已經跑完並被刪除了 / It already finished and was deleted + return + + def closeEvent(self, event) -> None: + """關閉前先收掉背景檢查 / Stop the background check before closing.""" + self._stop_project_check() + 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() + + def visible_diagnostics(self) -> list[Diagnostic]: + """ + 取得符合嚴重度篩選的診斷 + The diagnostics matching the severity filter. + + :return: 要顯示的診斷 / the diagnostics to show + """ + 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] + + def apply_available_fixes(self) -> bool: + """ + 套用 ruff 能自動修正的部分,並重新檢查 + Apply the fixes ruff can make, then check again. + + 修正會改寫磁碟上的檔案,因此完成後把目前分頁重新讀進來,畫面才不會停在 + 舊內容上。 + Fixing rewrites the files on disk, so the current tab is reloaded + afterwards rather than left showing the old content. + + :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): + return False + self._reload_current_tab() + self.refresh() + return True + + def _current_file(self) -> str | None: + """目前分頁的檔案 / The current tab's file.""" + code_edit = current_code_editor(self._main_window) + path = getattr(code_edit, "current_file", None) if code_edit is not None else None + return str(path) if path else None + + def _reload_current_tab(self) -> None: + """把目前分頁的內容重新讀進來 / Re-read the current tab's file from disk.""" + code_edit = current_code_editor(self._main_window) + path = self._current_file() + if code_edit is None or path is None: + return + result = read_file_with_encoding(path) + if result is not None: + code_edit.setPlainText(result[1]) + + def _render_items(self) -> None: + """依目前診斷與篩選條件重建清單 / Rebuild the tree for the current filter.""" + word = language_wrapper.language_word_dict + visible = self.visible_diagnostics() + self.result_tree.clear() + for diagnostic in visible: + row = QTreeWidgetItem([ + diagnostic.code, diagnostic.message, str(diagnostic.line), + Path(diagnostic.file_path).name if diagnostic.file_path else ""]) + row.setData(COLUMN_CODE, Qt.ItemDataRole.UserRole, diagnostic) + self.result_tree.addTopLevelItem(row) + if visible: + self.status_label.setText( + word.get("problems_panel_found").format(count=len(visible))) + else: + self.status_label.setText(word.get("problems_panel_clean")) + + def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: + """跳到被雙擊的診斷所在行 / Jump to the double-clicked diagnostic's line.""" + diagnostic = row.data(COLUMN_CODE, Qt.ItemDataRole.UserRole) + if diagnostic is None: + return + self.jump_to_diagnostic(diagnostic) + + def jump_to_diagnostic(self, diagnostic: Diagnostic) -> bool: + """ + 跳到診斷所在的位置,必要時先開啟該檔案 + Jump to a diagnostic, opening its file first when it is another one. + + :param diagnostic: 目標診斷 / the diagnostic to jump to + :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)) + code_edit = current_code_editor(self._main_window) + if code_edit is None: + return False + return code_edit.jump_to_line(diagnostic.line) 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 new file mode 100644 index 00000000..10076b44 --- /dev/null +++ b/je_editor/pyside_ui/main_ui/problems_panel/project_lint_worker.py @@ -0,0 +1,46 @@ +""" +在背景檢查整個專案 +Lint a whole project off the UI thread. + +專案檢查會開一個 ruff 子程序走遍所有檔案,大專案要跑上好幾秒。在 UI 執行緒做這件 +事會讓整個視窗在這段時間內完全沒有反應,因此改由工作執行緒執行,完成後再把診斷交 +回主執行緒。單檔檢查早就是這樣做的(見 ``lint_manager``),這裡採用同樣的做法。 +A project check spawns one ruff subprocess that walks every file, which takes +seconds on a large tree. Doing that on the UI thread freezes the whole window +for the duration, so it runs on a worker thread and hands the diagnostics back +when it finishes. The single-file check already works this way (see +``lint_manager``); this follows the same shape. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThread, Signal + +from je_editor.code_scan.ruff_lint import lint_project + + +class ProjectLintWorker(QThread): + """ + 在背景對一個目錄執行 ruff + Run ruff over a directory without blocking the UI. + """ + + linted = Signal(object) # list[Diagnostic] + + def __init__(self, root: str | Path, parent=None) -> None: + """ + :param root: 要檢查的專案根目錄 / the project root to check + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._root = str(root) + + @property + def root(self) -> str: + """這次檢查的目錄 / The directory being checked.""" + return self._root + + def run(self) -> None: + """執行檢查並回報結果 / Lint and report the result.""" + self.linted.emit(lint_project(self._root)) diff --git a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py index d4cb2a44..9fea4d8c 100644 --- a/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py +++ b/je_editor/pyside_ui/main_ui/save_settings/user_color_setting_file.py @@ -39,6 +39,22 @@ def update_actually_color_dict() -> None: "bookmark_marker_color": _to_qcolor("bookmark_marker_color", [66, 165, 245]), "fold_marker_color": _to_qcolor("fold_marker_color", [120, 120, 120]), "occurrence_highlight_color": _to_qcolor("occurrence_highlight_color", [80, 90, 60]), + "diff_added_marker_color": _to_qcolor("diff_added_marker_color", [76, 175, 80]), + "diff_modified_marker_color": _to_qcolor("diff_modified_marker_color", [255, 167, 38]), + "diff_removed_marker_color": _to_qcolor("diff_removed_marker_color", [229, 57, 53]), + "lint_underline_color": _to_qcolor("lint_underline_color", [255, 138, 101]), + "blame_annotation_color": _to_qcolor("blame_annotation_color", [130, 130, 130]), + "indent_guide_color": _to_qcolor("indent_guide_color", [90, 90, 110]), + "minimap_background_color": _to_qcolor("minimap_background_color", [40, 40, 48]), + "minimap_line_color": _to_qcolor("minimap_line_color", [130, 130, 150]), + "minimap_viewport_color": _to_qcolor("minimap_viewport_color", [80, 80, 110]), + "extra_cursor_color": _to_qcolor("extra_cursor_color", [255, 215, 64]), + "breakpoint_marker_color": _to_qcolor("breakpoint_marker_color", [229, 57, 53]), + "syntax_keyword_color": _to_qcolor("syntax_keyword_color", [86, 156, 214]), + "syntax_string_color": _to_qcolor("syntax_string_color", [206, 145, 120]), + "syntax_comment_color": _to_qcolor("syntax_comment_color", [106, 153, 85]), + "syntax_number_color": _to_qcolor("syntax_number_color", [181, 206, 168]), + "trailing_whitespace_color": _to_qcolor("trailing_whitespace_color", [120, 70, 70]), } ) @@ -54,7 +70,23 @@ def update_actually_color_dict() -> None: "warning_output_color": [204, 204, 0], "bookmark_marker_color": [66, 165, 245], "fold_marker_color": [120, 120, 120], - "occurrence_highlight_color": [80, 90, 60] + "occurrence_highlight_color": [80, 90, 60], + "diff_added_marker_color": [76, 175, 80], + "diff_modified_marker_color": [255, 167, 38], + "diff_removed_marker_color": [229, 57, 53], + "lint_underline_color": [255, 138, 101], + "blame_annotation_color": [130, 130, 130], + "indent_guide_color": [90, 90, 110], + "minimap_background_color": [40, 40, 48], + "minimap_line_color": [130, 130, 150], + "minimap_viewport_color": [80, 80, 110], + "extra_cursor_color": [255, 215, 64], + "breakpoint_marker_color": [229, 57, 53], + "syntax_keyword_color": [86, 156, 214], + "syntax_string_color": [206, 145, 120], + "syntax_comment_color": [106, 153, 85], + "syntax_number_color": [181, 206, 168], + "trailing_whitespace_color": [120, 70, 70] } # 實際使用的顏色字典 (以 QColor 表示) diff --git a/je_editor/pyside_ui/main_ui/test_panel/__init__.py b/je_editor/pyside_ui/main_ui/test_panel/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..dc0a0a0c --- /dev/null +++ b/je_editor/pyside_ui/main_ui/test_panel/test_panel_widget.py @@ -0,0 +1,329 @@ +""" +測試面板:執行 pytest 並列出結果 +Test panel: run pytest and list what it reported. + +測試在背景執行緒中執行(那是子程序),結束後把輸出解析成一列一列的結果;雙擊 +失敗的項目會跳到出錯的那一行。 +The run happens on a worker thread because it spawns a subprocess; its output is +then parsed into one row per test, and double-clicking a failure jumps to the +line it failed on. +""" +from __future__ import annotations + +import os +import subprocess # nosec B404 - 以引數清單執行 pytest,未使用 shell +import sys +from pathlib import Path + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget +) + +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 ( + PytestResult, failure_for_result, parse_failures, parse_results, parse_summary +) + +# 樹狀清單欄位索引 / Column indexes in the tree +COLUMN_OUTCOME = 0 +COLUMN_TEST = 1 +COLUMN_FILE = 2 +# 測試名稱欄的預設寬度 / Default width of the test column +TEST_COLUMN_WIDTH = 360 +# 單次測試執行的逾時(秒)/ Timeout for one test run +RUN_TIMEOUT_SECONDS = 600 + + +def pytest_command(node_ids: list[str] | None = None) -> list[str]: + """ + 組出執行測試的指令 + Build the command that runs the tests. + + ``-v`` 才會逐項列出結果,``--tb=line`` 讓每個失敗只印一行位置,剛好夠面板用。 + ``-v`` is what lists each test, and ``--tb=line`` prints one location per + failure, which is exactly what the panel needs. + + :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all when ``None`` + :return: 引數清單(不經過 shell)/ the argument list, never a shell string + """ + command = [sys.executable, "-m", "pytest", "-v", "--tb=line", "-p", "no:cacheprovider"] + # 節點名稱是 pytest 自己印出來的,原樣傳回去;仍然是獨立引數,不經過 shell + # The node ids came from pytest itself and go straight back as separate + # arguments, never through a shell + command.extend(node_ids or []) + return command + + +class PytestRunThread(QThread): + """ + 在背景執行 pytest + Run pytest off the UI thread. + """ + + finished_output = Signal(str) + + def __init__(self, working_dir: str, node_ids: list[str] | None = None, parent=None) -> None: + """ + :param working_dir: 執行測試的目錄 / the directory to run the tests in + :param node_ids: 只跑這些測試 / run only these tests + :param parent: Qt 父物件 / the Qt parent + """ + super().__init__(parent) + self._working_dir = working_dir + self._node_ids = node_ids + + def run(self) -> None: + """執行測試並回報輸出 / Run the tests and report their output.""" + try: + completed = subprocess.run( # nosemgrep # noqa: S603 # nosec B603 + pytest_command(self._node_ids), + cwd=self._working_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=RUN_TIMEOUT_SECONDS, + check=False, + ) + output = f"{completed.stdout or ''}\n{completed.stderr or ''}" + except (OSError, subprocess.SubprocessError) as error: + jeditor_logger.error(f"test_panel_widget.py could not run pytest: {error!r}") + output = "" + self.finished_output.emit(output) + + +class TestPanelWidget(QWidget): + """ + 執行專案測試並顯示結果 + Run the project's tests and show what they reported. + """ + + def __init__(self, main_window=None, working_dir: str | None = None) -> None: + """ + :param main_window: 用來開檔跳行的主視窗 / the window used to open files + :param working_dir: 執行測試的目錄,``None`` 時自動判斷 / where to run; auto when ``None`` + """ + super().__init__() + word = language_wrapper.language_word_dict + self._main_window = main_window + self._working_dir = working_dir or resolve_working_dir(main_window) + self._results: list[PytestResult] = [] + self._failures: list = [] + self._thread: PytestRunThread | None = None + + self.run_button = QPushButton(word.get("test_panel_run")) + self.run_button.clicked.connect(self.start_run) + self.run_selected_button = QPushButton(word.get("test_panel_run_selected")) + self.run_selected_button.clicked.connect(self.start_selected_run) + self.rerun_failures_button = QPushButton(word.get("test_panel_rerun_failures")) + self.rerun_failures_button.clicked.connect(self.start_failure_run) + self.filter_edit = QLineEdit() + self.filter_edit.setPlaceholderText(word.get("test_panel_filter_placeholder")) + self.filter_edit.textChanged.connect(self._render_items) + self.status_label = QLabel(word.get("test_panel_ready")) + + self.result_tree = QTreeWidget() + self.result_tree.setColumnCount(3) + self.result_tree.setHeaderLabels([ + word.get("test_panel_col_outcome"), + word.get("test_panel_col_test"), + word.get("test_panel_col_file"), + ]) + self.result_tree.setColumnWidth(COLUMN_TEST, TEST_COLUMN_WIDTH) + self.result_tree.setRootIsDecorated(False) + self.result_tree.itemDoubleClicked.connect(self._open_item) + + controls = QHBoxLayout() + controls.addWidget(self.run_button) + controls.addWidget(self.run_selected_button) + controls.addWidget(self.rerun_failures_button) + controls.addWidget(self.filter_edit) + controls.addWidget(self.status_label) + controls.addStretch() + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(self.result_tree) + self.setLayout(layout) + + def results(self) -> list[PytestResult]: + """取得目前顯示的結果 / The results currently listed.""" + return list(self._results) + + def start_run(self, node_ids: list[str] | None = None) -> bool: + """ + 啟動一次測試執行 + Start one test run. + + 已經在執行時會忽略重複觸發,避免覆寫仍在跑的執行緒。 + A re-entrant trigger is ignored so a still-running thread is never dropped. + + :param node_ids: 只跑這些測試,``None`` 表示全部 / run only these, or all + :return: 是否真的啟動 / whether a run actually started + """ + if self._thread is not None and self._thread.isRunning(): + return False + self.run_button.setEnabled(False) + self.status_label.setText(language_wrapper.language_word_dict.get("test_panel_running")) + self._thread = PytestRunThread(self._working_dir, node_ids) + self._thread.finished_output.connect(self.apply_output) + self._thread.finished.connect(self._thread.deleteLater) + self._thread.start() + return True + + def selected_node_ids(self) -> list[str]: + """ + 取得清單中被選取的測試 + The tests currently selected in the list. + + :return: 節點名稱 / their node ids + """ + node_ids: list[str] = [] + for item in self.result_tree.selectedItems(): + result = item.data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + if result is not None: + node_ids.append(result.node_id) + return node_ids + + def failed_node_ids(self) -> list[str]: + """ + 取得上一輪失敗的測試 + The tests that failed in the last run. + + :return: 節點名稱 / their node ids + """ + return [result.node_id for result in self._results if result.failed] + + def start_selected_run(self) -> bool: + """ + 只重跑選取的測試 + Re-run only the selected tests. + + :return: 是否真的啟動 / whether a run actually started + """ + selected = self.selected_node_ids() + return self.start_run(selected) if selected else False + + def start_failure_run(self) -> bool: + """ + 只重跑上一輪失敗的測試 + Re-run only the tests that failed last time. + + 修一個失敗之後不必等整輪跑完,這是這個面板最常用的動作。 + After fixing one failure there is no need to wait for the whole suite, + which makes this the panel's most used action. + + :return: 是否真的啟動 / whether a run actually started + """ + failures = self.failed_node_ids() + return self.start_run(failures) if failures else False + + def apply_output(self, output: str) -> None: + """ + 解析輸出並更新清單 + Parse the output and rebuild the list. + + :param output: pytest 的輸出 / pytest's output + """ + self._results = parse_results(output) + self._failures = parse_failures(output) + summary = parse_summary(output) + self.run_button.setEnabled(True) + self._render_items() + if summary: + self.status_label.setText(summary) + elif not self._results: + self.status_label.setText( + language_wrapper.language_word_dict.get("test_panel_no_results")) + + def visible_results(self) -> list[PytestResult]: + """ + 取得符合篩選條件的結果,失敗的排在最前面 + The results matching the filter, failures first. + + :return: 要顯示的結果 / the results to show + """ + needle = self.filter_edit.text().strip().lower() + matching = [ + result for result in self._results + if not needle or needle in result.node_id.lower() + ] + return sorted(matching, key=lambda result: not result.failed) + + def _render_items(self) -> None: + """依目前結果與篩選條件重建清單 / Rebuild the tree for the current filter.""" + self.result_tree.clear() + for result in self.visible_results(): + row = QTreeWidgetItem([result.outcome, result.name, result.file_path]) + row.setData(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole, result) + self.result_tree.addTopLevelItem(row) + + def _open_item(self, row: QTreeWidgetItem, _column: int) -> None: + """開啟被雙擊的測試 / Open the double-clicked test.""" + result = row.data(COLUMN_OUTCOME, Qt.ItemDataRole.UserRole) + if result is not None: + self.open_result(result) + + def open_result(self, result: PytestResult) -> bool: + """ + 在編輯器開啟一個測試,失敗的話跳到出錯的行 + Open a test in the editor, jumping to its failing line when it failed. + + :param result: 要開啟的測試結果 / the result to open + :return: 成功要求開檔時為 ``True`` / ``True`` when the open was requested + """ + if self._main_window is None or not hasattr(self._main_window, "go_to_new_tab"): + return False + failure = failure_for_result(result, self._failures) + path = Path(failure.path) if failure is not None else Path(self._working_dir) / result.file_path + self._main_window.go_to_new_tab(path) + if failure is not None: + jump_to_line(self._main_window, failure.line) + return True + + def closeEvent(self, event) -> None: + """ + 關閉前先停掉仍在執行的測試 + Stop a run that is still going before the panel goes away. + """ + thread = self._thread + if thread is not None and thread.isRunning(): + thread.blockSignals(True) + thread.wait() + super().closeEvent(event) + + +def resolve_working_dir(main_window) -> str: + """ + 取得執行測試的目錄 + Resolve the directory the tests should run in. + + :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() + + +def jump_to_line(main_window, line: int) -> bool: + """ + 把目前分頁的游標移到指定行 + Move the current tab's caret to a line. + + :param main_window: 主編輯器視窗 / the main editor window + :param line: 1 起算的行號 / the 1-based line number + :return: 成功跳轉時為 ``True`` / ``True`` when the caret moved + """ + 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 False + widget = tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + return False + return widget.code_edit.jump_to_line(line) 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 95e22d46..4964d7b5 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 @@ -41,6 +41,10 @@ def __init__(self, root: str) -> None: :param root: 要掃描的專案根目錄 / The project root to scan """ 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._stop_requested = False diff --git a/je_editor/utils/debugger/__init__.py b/je_editor/utils/debugger/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/debugger/pdb_commands.py b/je_editor/utils/debugger/pdb_commands.py new file mode 100644 index 00000000..bf172579 --- /dev/null +++ b/je_editor/utils/debugger/pdb_commands.py @@ -0,0 +1,75 @@ +""" +組出要送給 pdb 的指令 +Build the commands to send to pdb. + +編輯器的除錯器就是 ``python -m pdb``,它從標準輸入讀指令。中斷點與逐步執行因此 +只是「送出正確的指令字串」,這裡負責組字串,實際傳送交給既有的除錯輸入。 +The editor's debugger is ``python -m pdb``, which reads commands from standard +input. Breakpoints and stepping are therefore a matter of sending the right +command, and this module builds those strings; sending them is the existing +debugger input's job. + +純邏輯:不啟動任何程序。 +Pure logic: it starts no process. +""" +from __future__ import annotations + +from pathlib import Path + +# pdb 的逐步執行指令 / pdb's stepping commands +STEP_INTO = "step" +STEP_OVER = "next" +STEP_OUT = "return" +CONTINUE = "continue" +QUIT = "quit" + +# 逐步動作對應的指令 / The command for each stepping action +STEP_COMMANDS = { + "into": STEP_INTO, + "over": STEP_OVER, + "out": STEP_OUT, + "continue": CONTINUE, + "quit": QUIT, +} + + +def breakpoint_command(file_path: str | Path, line: int) -> str: + """ + 組出設定一個中斷點的指令 + Build the command that sets one breakpoint. + + pdb 的行號從 1 起算,與編輯器顯示的一致。 + pdb counts lines from one, the same as the editor shows. + + :param file_path: 檔案路徑 / the file to break in + :param line: 1 起算的行號 / the 1-based line number + :return: pdb 指令 / the pdb command + """ + return f"break {Path(file_path).as_posix()}:{max(1, line)}" + + +def breakpoint_commands(file_path: str | Path, lines: list[int]) -> list[str]: + """ + 組出設定多個中斷點的指令 + Build the commands that set several breakpoints. + + 行號排序且去重,因此送出的指令與畫面上的標記一一對應。 + The lines are sorted and de-duplicated, so the commands match the markers on + screen one for one. + + :param file_path: 檔案路徑 / the file to break in + :param lines: 1 起算的行號 / the 1-based line numbers + :return: pdb 指令 / the pdb commands + """ + return [breakpoint_command(file_path, line) for line in sorted(set(lines)) if line >= 1] + + +def step_command(action: str) -> str | None: + """ + 取得某個逐步動作的指令 + The command for a stepping action. + + :param action: 動作名稱 / the action's name + :return: pdb 指令,動作未知時為 ``None`` / the command, or ``None`` + """ + return STEP_COMMANDS.get(action) diff --git a/je_editor/utils/encodings/text_codec.py b/je_editor/utils/encodings/text_codec.py new file mode 100644 index 00000000..8ab317f3 --- /dev/null +++ b/je_editor/utils/encodings/text_codec.py @@ -0,0 +1,143 @@ +""" +處理文字檔的編碼與行尾 +Handle a text file's encoding and line endings. + +編輯器內部一律以 ``\\n`` 表示換行(Qt 文件就是這樣),因此讀進來要正規化、 +寫出去要換回檔案原本的行尾,否則存一次檔就會把整份檔案的行尾都改掉。 +The editor always uses ``\\n`` internally, as a Qt document does, so text is +normalised on the way in and converted back on the way out — otherwise a single +save would rewrite every line ending in the file. + +純邏輯:不碰 Qt,也不做檔案 I/O。 +Pure logic: no Qt and no file I/O. +""" +from __future__ import annotations + +import codecs + +# 行尾種類 / The line-ending styles +LINE_ENDING_LF = "\n" +LINE_ENDING_CRLF = "\r\n" +LINE_ENDING_CR = "\r" + +# 顯示名稱對應 / Display name for each style +LINE_ENDING_NAMES = { + LINE_ENDING_LF: "LF", + LINE_ENDING_CRLF: "CRLF", + LINE_ENDING_CR: "CR", +} + +# 預設編碼 / The encoding assumed when nothing else is known +DEFAULT_ENCODING = "utf-8" + +# BOM 與對應編碼;順序重要,UTF-32 的 BOM 開頭與 UTF-16 相同 +# Byte-order marks and their encodings. Order matters: a UTF-32 BOM starts with +# the same two bytes as a UTF-16 one. +_BOM_ENCODINGS = ( + (codecs.BOM_UTF8, "utf-8-sig"), + (codecs.BOM_UTF32_LE, "utf-32"), + (codecs.BOM_UTF32_BE, "utf-32"), + (codecs.BOM_UTF16_LE, "utf-16"), + (codecs.BOM_UTF16_BE, "utf-16"), +) + + +def detect_line_ending(text: str) -> str: + """ + 判斷文字使用的行尾 + Work out which line ending a text uses. + + 以出現次數最多的為準;沒有任何換行時視為 ``LF``。 + The most common one wins; a text with no line break at all counts as ``LF``. + + :param text: 要判斷的文字 / the text to inspect + :return: 行尾字串 / the line-ending string + """ + crlf = text.count(LINE_ENDING_CRLF) + # 單獨的 CR 與 LF 不能把 CRLF 算進去 / Lone CR and LF must not count CRLF twice + lone_cr = text.count(LINE_ENDING_CR) - crlf + lone_lf = text.count(LINE_ENDING_LF) - crlf + if crlf >= lone_lf and crlf >= lone_cr and crlf > 0: + return LINE_ENDING_CRLF + if lone_cr > lone_lf: + return LINE_ENDING_CR + return LINE_ENDING_LF + + +def line_ending_name(ending: str) -> str: + """ + 取得行尾的顯示名稱 + The display name of a line ending. + + :param ending: 行尾字串 / the line-ending string + :return: 名稱,未知時回傳 ``LF`` / its name, defaulting to ``LF`` + """ + return LINE_ENDING_NAMES.get(ending, LINE_ENDING_NAMES[LINE_ENDING_LF]) + + +def normalise_line_endings(text: str) -> str: + """ + 把所有行尾正規化為 ``\\n`` + Normalise every line ending to ``\\n``. + + :param text: 原始文字 / the original text + :return: 正規化後的文字 / the normalised text + """ + return text.replace(LINE_ENDING_CRLF, LINE_ENDING_LF).replace( + LINE_ENDING_CR, LINE_ENDING_LF) + + +def apply_line_ending(text: str, ending: str) -> str: + """ + 把文字的行尾換成指定樣式 + Convert a text's line endings to *ending*. + + :param text: 要轉換的文字(任何行尾)/ the text to convert, in any style + :param ending: 目標行尾 / the line ending to write + :return: 轉換後的文字 / the converted text + """ + normalised = normalise_line_endings(text) + if ending == LINE_ENDING_LF: + return normalised + return normalised.replace(LINE_ENDING_LF, ending) + + +def encoding_from_bom(raw: bytes) -> str | None: + """ + 由 BOM 判斷編碼 + Work out an encoding from a byte-order mark. + + :param raw: 檔案開頭的位元組 / the file's leading bytes + :return: 編碼名稱,沒有 BOM 時為 ``None`` / the encoding, or ``None`` + """ + for bom, encoding in _BOM_ENCODINGS: + if raw.startswith(bom): + return encoding + return None + + +def decode_bytes(raw: bytes, encoding: str | None = None) -> tuple[str, str]: + """ + 解碼檔案內容,並回報實際使用的編碼 + Decode a file's bytes, reporting which encoding actually worked. + + 指定編碼時就用它;沒有指定時先看 BOM,再試 UTF-8,最後退回 latin-1—— + latin-1 不會失敗,因此檔案至少能開起來讓使用者自己選正確的編碼。 + An explicit encoding is used as given. Otherwise a BOM decides, then UTF-8 is + tried, and latin-1 is the fallback: it cannot fail, so the file always opens + and the user can pick the right encoding themselves. + + :param raw: 檔案內容 / the file's bytes + :param encoding: 指定的編碼,``None`` 表示自動判斷 / the encoding, or ``None`` to detect + :return: ``(文字, 使用的編碼)`` / ``(text, encoding used)`` + :raises UnicodeDecodeError: 指定的編碼無法解碼時 / when an explicit encoding fails + """ + if encoding is not None: + return raw.decode(encoding), encoding + from_bom = encoding_from_bom(raw) + if from_bom is not None: + return raw.decode(from_bom), from_bom + try: + return raw.decode(DEFAULT_ENCODING), DEFAULT_ENCODING + except UnicodeDecodeError: + return raw.decode("latin-1"), "latin-1" diff --git a/je_editor/utils/file/open/open_file.py b/je_editor/utils/file/open/open_file.py index a6fc1506..ddf78e0f 100644 --- a/je_editor/utils/file/open/open_file.py +++ b/je_editor/utils/file/open/open_file.py @@ -4,6 +4,9 @@ # 匯入自訂例外與日誌工具 # Import custom exception and logging utility +from je_editor.utils.encodings.text_codec import ( + decode_bytes, detect_line_ending, normalise_line_endings +) from je_editor.utils.exception.exceptions import JEditorOpenFileException from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -12,6 +15,46 @@ _file_read_lock = Lock() +def read_file_with_encoding( + file_path: Optional[str], encoding: Optional[str] = None) -> tuple: + """ + 讀取檔案,並回報使用的編碼與原本的行尾 + Read a file, reporting the encoding used and the line ending it had. + + 內容的行尾會正規化為 ``\\n``,因為編輯器內部就是這樣表示換行;原本的行尾 + 另外回傳,存檔時才能寫回去。 + The content's line endings are normalised to ``\\n``, which is how the editor + represents them; the original style is returned separately so saving can put + it back. + + :param file_path: 檔案完整路徑 / the full path of the file to read + :param encoding: 指定編碼,``None`` 表示自動判斷 / the encoding, or ``None`` to detect + :return: ``(路徑, 內容, 編碼, 行尾)``;路徑無效時為 ``None`` + ``(path, content, encoding, line ending)``, or ``None`` for an invalid path + :raises JEditorOpenFileException: 讀取或解碼失敗時 / when the read or decode fails + """ + jeditor_logger.info(f"open_file.py read_file_with_encoding file_path: {file_path}") + if not file_path: + return None + path = Path(file_path) + try: + _file_read_lock.acquire() + if not (path.exists() and path.is_file()): + return None + raw = path.read_bytes() + except OSError as error: + jeditor_logger.error(f"Failed to read file {file_path}: {error}") + raise JEditorOpenFileException from error + finally: + _file_read_lock.release() + try: + text, used_encoding = decode_bytes(raw, encoding) + except (UnicodeDecodeError, LookupError) as error: + jeditor_logger.error(f"Failed to decode file {file_path}: {error}") + raise JEditorOpenFileException from error + return path, normalise_line_endings(text), used_encoding, detect_line_ending(text) + + def read_file(file_path: Optional[str]) -> list[Path | str] | None: """ 功能說明 (Function Description): diff --git a/je_editor/utils/file/save/save_file.py b/je_editor/utils/file/save/save_file.py index c8da33a7..4a9b1651 100644 --- a/je_editor/utils/file/save/save_file.py +++ b/je_editor/utils/file/save/save_file.py @@ -2,6 +2,9 @@ # 匯入自訂例外與日誌工具 # Import custom exception and logging utility +from je_editor.utils.encodings.text_codec import ( + DEFAULT_ENCODING, LINE_ENDING_LF, apply_line_ending +) from je_editor.utils.exception.exceptions import JEditorSaveFileException from je_editor.utils.logging.loggin_instance import jeditor_logger @@ -10,6 +13,42 @@ _file_write_lock = Lock() +def write_file_with_encoding( + file_path: str, content: str, + encoding: str = DEFAULT_ENCODING, line_ending: str = LINE_ENDING_LF) -> None: + """ + 以指定的編碼與行尾寫入檔案 + Write a file using a given encoding and line-ending style. + + 編輯器內部一律以 ``\\n`` 換行,寫出前轉回檔案原本的樣式,存檔才不會把整份 + 檔案的行尾都改掉。 + The editor always uses ``\\n``; converting back before writing keeps a save + from rewriting every line ending in the file. + + :param file_path: 要寫入的檔案路徑 / the file path to write + :param content: 要寫入的內容 / the content to write + :param encoding: 使用的編碼 / the encoding to write with + :param line_ending: 使用的行尾 / the line ending to write + :raises JEditorSaveFileException: 寫入或編碼失敗時 / when the write or encode fails + """ + jeditor_logger.info("save_file.py write_file_with_encoding " + f"file_path: {file_path} encoding: {encoding}") + if not file_path: + return + text = apply_line_ending(str(content), line_ending) + try: + _file_write_lock.acquire() + # newline="" 讓上面轉好的行尾原樣寫出,不再被 Python 轉換一次 + # newline="" writes the endings above as-is instead of translating again + with open(file_path, "w", encoding=encoding, newline="") as file_to_write: + file_to_write.write(text) + except (OSError, UnicodeEncodeError, LookupError) as error: + jeditor_logger.error(f"Failed to write file {file_path}: {error}") + raise JEditorSaveFileException from error + finally: + _file_write_lock.release() + + def write_file(file_path: str, content: str) -> None: """ 功能說明 (Function Description): diff --git a/je_editor/utils/file_diff/__init__.py b/je_editor/utils/file_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/file_diff/line_status.py b/je_editor/utils/file_diff/line_status.py new file mode 100644 index 00000000..898bf18c --- /dev/null +++ b/je_editor/utils/file_diff/line_status.py @@ -0,0 +1,238 @@ +""" +逐行比較緩衝區與基準文字,算出每一行的變更狀態 +Work out, line by line, how a buffer differs from its committed baseline. + +編輯器用這份結果在行號區畫出變更標記,讓「這次改了哪些行」一眼可見。 +The editor paints the result in its gutter, so what changed since the last +commit is visible without leaving the file. + +這個模組是純邏輯:不碰 Qt、不碰 git、不做 I/O,因此可以單獨測試。 +Pure logic: no Qt, no git, no I/O, so it can be tested on its own. +""" +from __future__ import annotations + +from dataclasses import dataclass +from difflib import SequenceMatcher + +# 一行相對於基準的狀態 / How one line differs from the baseline +LINE_ADDED = "added" +LINE_MODIFIED = "modified" +# 這一行的「上方」刪掉了內容 / Lines were deleted just above this one +LINE_REMOVED_ABOVE = "removed" + +# 超過這個行數就不比較:標記只是輔助,不值得讓編輯卡住 +# Buffers longer than this are not diffed: the markers are a convenience and are +# not worth stalling the edit they are meant to annotate. +MAX_DIFFED_LINES = 20000 + + +def _split_lines(text: str) -> list[str]: + """ + 切成行;空字串視為零行,這樣新檔案的每一行都算新增 + Split into lines, treating an empty text as no lines so that every line of a + brand-new file counts as added. + """ + return text.splitlines() if text else [] + + +def _mark_removal(statuses: dict[int, str], line: int, line_count: int) -> None: + """ + 在刪除位置的下一行標記;已有狀態的行不覆蓋 + Mark the line that follows a deletion, without overwriting a line that + already carries a status of its own. + """ + if line_count <= 0: + return + index = min(line, line_count - 1) + statuses.setdefault(index, LINE_REMOVED_ABOVE) + + +def line_statuses(baseline: str, current: str) -> dict[int, str]: + """ + 比對基準與目前內容,回傳每行的狀態 + Compare the baseline with the current text and report each line's status. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :return: 以 0 起算的行號對應狀態,未變更的行不在其中 + 0-based line number -> status, with unchanged lines left out + """ + current_lines = _split_lines(current) + if len(current_lines) > MAX_DIFFED_LINES: + return {} + baseline_lines = _split_lines(baseline) + if len(baseline_lines) > MAX_DIFFED_LINES: + return {} + + statuses: dict[int, str] = {} + matcher = SequenceMatcher(None, baseline_lines, current_lines, autojunk=False) + for tag, _base_start, _base_end, start, end in matcher.get_opcodes(): + if tag == "replace": + statuses.update({line: LINE_MODIFIED for line in range(start, end)}) + elif tag == "insert": + statuses.update({line: LINE_ADDED for line in range(start, end)}) + elif tag == "delete": + _mark_removal(statuses, start, len(current_lines)) + return statuses + + +@dataclass(frozen=True) +class Hunk: + """ + 一段連續的變更,以及它在基準中的對應範圍 + One run of change, and the baseline range it corresponds to. + + :param start: 目前內容中的起始行(0 起算)/ start line in the current text + :param end: 目前內容中的結束行(不含)/ end line in the current text, exclusive + :param baseline_start: 基準中的起始行 / start line in the baseline + :param baseline_end: 基準中的結束行(不含)/ end line in the baseline, exclusive + """ + + start: int + end: int + baseline_start: int + baseline_end: int + + @property + def is_pure_deletion(self) -> bool: + """這段變更是否只有刪除(目前內容中沒有對應的行)/ Whether only lines were removed.""" + return self.start == self.end + + def contains(self, line: int) -> bool: + """ + 判斷某行是否屬於這段變更 + Whether *line* belongs to this hunk. + + 純刪除在目前內容中沒有範圍,因此以刪除位置那一行為準。 + A pure deletion spans no lines, so the line at the deletion point counts. + + :param line: 以 0 起算的行號 / the 0-based line number + :return: 屬於這段變更時為 ``True`` / ``True`` when the line is inside + """ + if self.is_pure_deletion: + return line == self.start + return self.start <= line < self.end + + +def hunks(baseline: str, current: str) -> list[Hunk]: + """ + 找出所有變更區塊 + Find every run of change between the baseline and the current text. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :return: 變更區塊,依出現順序 / the hunks, in order + """ + baseline_lines = _split_lines(baseline) + current_lines = _split_lines(current) + if max(len(baseline_lines), len(current_lines)) > MAX_DIFFED_LINES: + return [] + matcher = SequenceMatcher(None, baseline_lines, current_lines, autojunk=False) + return [ + Hunk(start=start, end=end, baseline_start=base_start, baseline_end=base_end) + for tag, base_start, base_end, start, end in matcher.get_opcodes() + if tag != "equal" + ] + + +def hunk_at_line(baseline: str, current: str, line: int) -> Hunk | None: + """ + 取得包含指定行的變更區塊 + Return the hunk that contains *line*. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param line: 以 0 起算的行號 / the 0-based line number + :return: 該變更區塊,該行沒有變更時為 ``None`` / the hunk, or ``None`` + """ + for hunk in hunks(baseline, current): + if hunk.contains(line): + return hunk + return None + + +def baseline_lines_of(baseline: str, hunk: Hunk) -> list[str]: + """ + 取得一段變更在基準中的原始內容 + Return the baseline lines a hunk replaced. + + :param baseline: 已提交的內容 / the committed content + :param hunk: 目標變更區塊 / the hunk to look up + :return: 原始行(不含換行字元)/ the original lines, without line breaks + """ + return _split_lines(baseline)[hunk.baseline_start:hunk.baseline_end] + + +def apply_hunk(baseline: str, current: str, hunk: Hunk) -> str: + """ + 只把一段變更套用到基準內容上 + Apply one hunk of change to the baseline, leaving the rest of it alone. + + 這是「只暫存這一段」需要的內容:其他變更維持成已提交的樣子。 + This is the content needed to stage one hunk: every other change stays as it + was committed. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param hunk: 要套用的變更 / the hunk to apply + :return: 套用後的內容 / the content with that one hunk applied + """ + baseline_lines = _split_lines(baseline) + current_lines = _split_lines(current) + merged = ( + baseline_lines[:hunk.baseline_start] + + current_lines[hunk.start:hunk.end] + + baseline_lines[hunk.baseline_end:] + ) + if not merged: + return "" + return "\n".join(merged) + "\n" + + +def changed_line_numbers(statuses: dict[int, str]) -> list[int]: + """ + 取得有變更的行號(由小到大) + Return the changed line numbers, in order. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :return: 排序後的行號 / the line numbers, ascending + """ + return sorted(statuses) + + +def next_changed_line(statuses: dict[int, str], line: int) -> int | None: + """ + 找出 *line* 之後的下一個變更行,找不到就從頭繞回 + Return the next changed line after *line*, wrapping around to the first. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :param line: 目前所在行(0 起算)/ the current 0-based line + :return: 目標行號,沒有任何變更時為 ``None`` + the line to jump to, or ``None`` when nothing changed + """ + changed = changed_line_numbers(statuses) + if not changed: + return None + for candidate in changed: + if candidate > line: + return candidate + return changed[0] + + +def previous_changed_line(statuses: dict[int, str], line: int) -> int | None: + """ + 找出 *line* 之前的上一個變更行,找不到就繞回最後一個 + Return the previous changed line before *line*, wrapping around to the last. + + :param statuses: :func:`line_statuses` 的結果 / the statuses to read + :param line: 目前所在行(0 起算)/ the current 0-based line + :return: 目標行號,沒有任何變更時為 ``None`` + the line to jump to, or ``None`` when nothing changed + """ + changed = changed_line_numbers(statuses) + if not changed: + return None + for candidate in reversed(changed): + if candidate < line: + return candidate + return changed[-1] diff --git a/je_editor/utils/file_diff/unified.py b/je_editor/utils/file_diff/unified.py new file mode 100644 index 00000000..0a2d7cf2 --- /dev/null +++ b/je_editor/utils/file_diff/unified.py @@ -0,0 +1,46 @@ +""" +產生「已提交版本 vs 編輯中內容」的 unified diff +Produce a unified diff of the committed version against the buffer. + +輸出格式與 ``git diff`` 相同,因此既有的並排比對元件可以直接解析。 +The output has the same shape as ``git diff``, so the existing side-by-side +viewer can parse it as-is. Pure logic: no Qt, no git. +""" +from __future__ import annotations + +from difflib import unified_diff + +# diff 標頭中代表「已提交版本」與「編輯中內容」的標籤 +# Labels used in the diff header for the committed and edited sides +COMMITTED_LABEL = "HEAD" +WORKING_LABEL = "working copy" +# 變更前後保留的上下文行數,與 git 預設一致 +# Context lines kept around a change, matching git's default +CONTEXT_LINES = 3 + + +def unified_diff_text( + baseline: str, current: str, file_name: str = "", + context_lines: int = CONTEXT_LINES) -> str: + """ + 比較兩份內容並產生 unified diff + Compare two texts and render a unified diff. + + :param baseline: 已提交的內容 / the committed content + :param current: 編輯中的內容 / the buffer being edited + :param file_name: 顯示在標頭的檔名 / the file name shown in the header + :param context_lines: 保留的上下文行數 / how many context lines to keep + :return: diff 文字;兩者相同時為空字串 / the diff, or an empty string when equal + """ + if baseline == current: + return "" + from_label = f"a/{file_name} ({COMMITTED_LABEL})" if file_name else COMMITTED_LABEL + to_label = f"b/{file_name} ({WORKING_LABEL})" if file_name else WORKING_LABEL + lines = unified_diff( + baseline.splitlines(keepends=True), + current.splitlines(keepends=True), + fromfile=from_label, + tofile=to_label, + n=context_lines, + ) + return "".join(lines) diff --git a/je_editor/utils/format_code/__init__.py b/je_editor/utils/format_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/format_code/yapf_format.py b/je_editor/utils/format_code/yapf_format.py new file mode 100644 index 00000000..d6921d6d --- /dev/null +++ b/je_editor/utils/format_code/yapf_format.py @@ -0,0 +1,46 @@ +""" +用 yapf 格式化 Python 程式碼 +Format Python source with yapf. + +格式化本身是純文字轉換,抽出來之後選單動作與「存檔時自動格式化」可以共用同 +一份邏輯,也能單獨測試。 +Formatting is a plain text-to-text transform. Extracting it lets the menu action +and format-on-save share one implementation, and makes it testable on its own. +""" +from __future__ import annotations + +from yapf.yapflib.errors import YapfError +from yapf.yapflib.yapf_api import FormatCode + +from je_editor.utils.logging.loggin_instance import jeditor_logger + +# 預設的 yapf 風格 / The yapf style used by default +DEFAULT_STYLE = "google" + + +def format_python_source(source: str, style: str = DEFAULT_STYLE) -> str: + """ + 格式化 Python 原始碼 + Reformat Python source. + + 語法錯誤的程式碼無法格式化,這時回傳原內容而不是拋出例外——存檔時自動格式化 + 絕不能因為程式碼還沒寫完就中斷存檔。yapf 對語法錯誤丟的是自己的 ``YapfError`` + 而不是 ``SyntaxError``,兩者都要接。 + Source with a syntax error cannot be formatted; the original is returned + rather than raising, because format-on-save must never block a save just + because the code is still half-written. yapf raises its own ``YapfError`` + for a syntax error rather than ``SyntaxError``, so both are caught. + + :param source: 原始碼 / the source to format + :param style: yapf 風格名稱 / the yapf style to apply + :return: 格式化後的原始碼;無法格式化時為原內容 + the formatted source, or the original when it cannot be formatted + """ + if not source.strip(): + return source + try: + formatted, _changed = FormatCode(unformatted_source=source, style_config=style) + except (YapfError, SyntaxError, ValueError, IndentationError, UnicodeDecodeError) as error: + jeditor_logger.debug(f"yapf_format: source was not formatted: {error!r}") + return source + return formatted if isinstance(formatted, str) else source diff --git a/je_editor/utils/indentation/indent_guides.py b/je_editor/utils/indentation/indent_guides.py new file mode 100644 index 00000000..953ea368 --- /dev/null +++ b/je_editor/utils/indentation/indent_guides.py @@ -0,0 +1,75 @@ +""" +計算縮排參考線與尾端空白的位置 +Work out where indent guides and trailing whitespace sit on a line. + +繪製交給編輯器,這裡只做位置計算,因此可以單獨測試。 +The editor does the drawing; this only computes positions, so it can be tested +on its own. +""" +from __future__ import annotations + +# 一行最多畫幾條參考線,避免極深縮排時畫滿整行 +# How many guides one line may show, so very deep indentation cannot fill it +MAX_GUIDES_PER_LINE = 16 + + +def leading_space_width(text: str, tab_size: int) -> int: + """ + 計算一行前導空白的顯示寬度(tab 換算成空格) + The display width of a line's leading whitespace, counting a tab as *tab_size*. + + :param text: 該行文字 / the line's text + :param tab_size: 一個 tab 相當於幾個空格 / how many spaces a tab stands for + :return: 前導空白的寬度 / the width of the leading whitespace + """ + width = 0 + for character in text: + if character == " ": + width += 1 + elif character == "\t": + # tab 跳到下一個定位點,不是固定加 tab_size + # A tab advances to the next stop rather than adding a fixed amount + width += tab_size - (width % tab_size) + else: + break + return width + + +def guide_columns(text: str, indent_size: int) -> list[int]: + """ + 取得一行要畫縮排參考線的欄位 + The columns where a line should show indent guides. + + 只在該行自己的縮排範圍內畫;空白行沒有縮排資訊,因此不畫,免得畫出誤導的線。 + Guides are drawn only within the line's own indentation. A blank line carries + no indentation information, so it shows none rather than a misleading one. + + :param text: 該行文字 / the line's text + :param indent_size: 一層縮排的寬度 / the width of one indentation level + :return: 要畫線的欄位(0 起算)/ the 0-based columns to draw at + """ + if indent_size <= 0 or not text.strip(): + return [] + width = leading_space_width(text, indent_size) + levels = min(width // indent_size, MAX_GUIDES_PER_LINE) + return [level * indent_size for level in range(1, levels + 1)] + + +def trailing_whitespace_start(text: str) -> int | None: + """ + 取得一行尾端空白的起始位置 + Where a line's trailing whitespace starts. + + 整行都是空白時視為尾端空白;完全空白的行不算(那只是空行)。 + A line of nothing but whitespace counts, while an empty line does not, since + that is simply a blank line. + + :param text: 該行文字 / the line's text + :return: 起始字元索引,沒有尾端空白時為 ``None`` / the index, or ``None`` + """ + if not text: + return None + stripped = text.rstrip() + if len(stripped) == len(text): + return None + return len(stripped) diff --git a/je_editor/utils/lint/__init__.py b/je_editor/utils/lint/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/lint/ruff_diagnostics.py b/je_editor/utils/lint/ruff_diagnostics.py new file mode 100644 index 00000000..56543ac5 --- /dev/null +++ b/je_editor/utils/lint/ruff_diagnostics.py @@ -0,0 +1,226 @@ +""" +解析 ruff 的 JSON 輸出成診斷清單 +Turn ruff's JSON output into a list of diagnostics. + +編輯器用這些診斷畫波浪底線並列在問題面板中。這裡是純邏輯:不執行 ruff、 +不碰 Qt,因此可以用固定的輸出樣本測試。 +The editor underlines these and lists them in the problems panel. Pure logic: +it neither runs ruff nor touches Qt, so it can be tested against fixed samples. + +任何無法辨識的輸出都會被忽略而不是拋出例外——linter 的輸出格式改變不應該 +讓編輯器壞掉。 +Anything unrecognisable is skipped rather than raised: a change in the linter's +output must not break the editor. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass + +# ruff 對語法錯誤不會給規則代碼 / ruff reports no rule code for a syntax error +SYNTAX_ERROR_CODE = "SyntaxError" + +# 嚴重度 / Severity levels +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" +SEVERITY_INFO = "info" + +# ruff 規則代碼開頭對應的嚴重度;其餘視為提示 +# What each ruff rule-code prefix means; anything else counts as a hint +_SEVERITY_BY_PREFIX = { + "E": SEVERITY_ERROR, # pycodestyle errors + "F": SEVERITY_ERROR, # pyflakes + "W": SEVERITY_WARNING, # pycodestyle warnings + "C": SEVERITY_WARNING, # complexity + "B": SEVERITY_WARNING, # bugbear + "S": SEVERITY_WARNING, # security +} + +# LSP 的嚴重度編號 / The numbers LSP uses for severity +_LSP_SEVERITY = {1: SEVERITY_ERROR, 2: SEVERITY_WARNING, 3: SEVERITY_INFO, 4: SEVERITY_INFO} + + +def severity_for_code(code: str) -> str: + """ + 由規則代碼推出嚴重度 + Work out a severity from a rule code. + + :param code: 規則代碼,例如 ``F401`` / the rule code, such as ``F401`` + :return: 嚴重度 / the severity + """ + if not code or code == SYNTAX_ERROR_CODE: + return SEVERITY_ERROR + return _SEVERITY_BY_PREFIX.get(code[0].upper(), SEVERITY_INFO) + + +@dataclass(frozen=True) +class Diagnostic: + """ + 一筆 linter 診斷 + One linter finding. + + :param line: 起始行(1 起算,與 ruff 一致)/ start line, 1-based as ruff reports + :param column: 起始欄(1 起算)/ start column, 1-based + :param end_line: 結束行 / end line + :param end_column: 結束欄 / end column + :param code: 規則代碼,例如 ``F401`` / the rule code, e.g. ``F401`` + :param message: 說明文字 / the human-readable message + :param severity: 嚴重度,未給時由代碼推出 / the severity, derived from the code + when not given + :param file_path: 所屬檔案;只檢查目前緩衝區時為空 + the file it belongs to, empty when only the current buffer was checked + """ + + line: int + column: int + end_line: int + end_column: int + code: str + message: str + severity: str = "" + file_path: str = "" + + @property + def label(self) -> str: + """給面板顯示的一行說明 / A single line for the panel.""" + return f"{self.code} {self.message}" if self.code else self.message + + @property + def level(self) -> str: + """嚴重度;沒有明講時由規則代碼推出 / The severity, derived from the code if unset.""" + return self.severity or severity_for_code(self.code) + + +def _as_position(raw: object, fallback_row: int, fallback_column: int) -> tuple[int, int]: + """讀出 ``{"row": n, "column": n}``,缺漏時退回預設 / Read a position, with fallbacks.""" + if not isinstance(raw, dict): + return fallback_row, fallback_column + row = raw.get("row") + column = raw.get("column") + return ( + row if isinstance(row, int) and row > 0 else fallback_row, + column if isinstance(column, int) and column > 0 else fallback_column, + ) + + +def _as_diagnostic(entry: object) -> Diagnostic | None: + """把一筆 ruff 記錄轉成診斷,無法辨識時回傳 ``None`` / Convert one ruff record.""" + if not isinstance(entry, dict): + return None + message = entry.get("message") + if not isinstance(message, str) or not message: + return None + line, column = _as_position(entry.get("location"), 1, 1) + end_line, end_column = _as_position(entry.get("end_location"), line, column) + code = entry.get("code") + return Diagnostic( + line=line, + column=column, + # 結束位置若在起始之前(輸出有誤)就退回起始位置 + # An end before the start (malformed output) falls back to the start + end_line=max(end_line, line), + end_column=end_column, + code=code if isinstance(code, str) else SYNTAX_ERROR_CODE, + message=message, + file_path=str(entry.get("filename") or ""), + ) + + +def parse_ruff_json(output: str) -> list[Diagnostic]: + """ + 解析 ruff ``--output-format json`` 的輸出 + Parse the output of ruff's ``--output-format json``. + + :param output: ruff 的標準輸出 / ruff's standard output + :return: 診斷清單;輸出為空或無法解析時為空清單 + the diagnostics, or an empty list when the output is empty or unusable + """ + if not output.strip(): + return [] + try: + entries = json.loads(output) + except ValueError: + return [] + if not isinstance(entries, list): + return [] + diagnostics = [_as_diagnostic(entry) for entry in entries] + return [diagnostic for diagnostic in diagnostics if diagnostic is not None] + + +def diagnostic_from_entry(entry: dict) -> Diagnostic | None: + """ + 把其他來源的診斷轉成同一種形式 + Convert a diagnostic from another source into the same shape. + + 語言伺服器回報的診斷與 ruff 的欄位不同,但編輯器只認得一種形式;統一之後 + 底線與問題面板就不必分辨診斷是誰報的。 + A language server reports different fields from ruff, but the editor knows + only one shape. Converting here means the underlines and the problems panel + never have to care which tool produced a finding. + + :param entry: 來源診斷,需含 ``line`` 與 ``message`` / the source diagnostic + :return: 統一形式的診斷,資料不足時為 ``None`` / the diagnostic, or ``None`` + """ + 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") + column = column if isinstance(column, int) and column >= 1 else 1 + end_line = entry.get("end_line") + end_line = end_line if isinstance(end_line, int) and end_line >= line else line + end_column = entry.get("end_column") + end_column = end_column if isinstance(end_column, int) and end_column >= 1 else column + code = entry.get("code") + severity = entry.get("severity") + return Diagnostic( + line=line, column=column, end_line=end_line, end_column=end_column, + code=code if isinstance(code, str) and code else SYNTAX_ERROR_CODE, + message=message, + severity=_LSP_SEVERITY.get(severity, "") if isinstance(severity, int) else "", + file_path=str(entry.get("file_path") or "")) + + +def diagnostics_from_entries(entries: list) -> list[Diagnostic]: + """ + 批次轉換其他來源的診斷 + Convert a batch of diagnostics from another source. + + :param entries: 來源診斷清單 / the source diagnostics + :return: 可用的診斷 / the usable diagnostics + """ + if not isinstance(entries, list): + return [] + converted = [diagnostic_from_entry(entry) for entry in entries] + return [item for item in converted if item is not None] + + +def diagnostics_by_line(diagnostics: list[Diagnostic]) -> dict[int, list[Diagnostic]]: + """ + 依行號分組 + Group diagnostics by their starting line. + + :param diagnostics: 診斷清單 / the diagnostics to group + :return: 行號(1 起算)對應該行的診斷 / 1-based line number -> its diagnostics + """ + grouped: dict[int, list[Diagnostic]] = {} + for diagnostic in diagnostics: + grouped.setdefault(diagnostic.line, []).append(diagnostic) + return grouped + + +def message_for_line(diagnostics: list[Diagnostic], line: int) -> str | None: + """ + 取得某行的診斷說明(多筆以換行分隔) + Return the messages reported on *line*, one per line of text. + + :param diagnostics: 診斷清單 / the diagnostics to search + :param line: 1 起算的行號 / the 1-based line number + :return: 說明文字,該行沒有診斷時為 ``None`` / the text, or ``None`` + """ + on_line = [item.label for item in diagnostics if item.line == line] + return "\n".join(on_line) if on_line else None diff --git a/je_editor/utils/lsp/__init__.py b/je_editor/utils/lsp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/lsp/language_servers.py b/je_editor/utils/lsp/language_servers.py new file mode 100644 index 00000000..584d7855 --- /dev/null +++ b/je_editor/utils/lsp/language_servers.py @@ -0,0 +1,95 @@ +""" +依副檔名決定要用哪一個語言伺服器 +Decide which language server a file should use, based on its suffix. + +Python 仍然走既有的 jedi 補全,因此這裡刻意不列 ``.py``——多接一個伺服器只會 +讓同一份檔案有兩套補全來源。 +Python keeps using the existing jedi completion, so ``.py`` is deliberately not +listed here: adding a server for it would give one file two sources of +completion. +""" +from __future__ import annotations + +import shutil + +# 副檔名對應的語言伺服器指令 / The server command for each file suffix +DEFAULT_SERVERS: dict[str, list[str]] = { + ".ts": ["typescript-language-server", "--stdio"], + ".tsx": ["typescript-language-server", "--stdio"], + ".js": ["typescript-language-server", "--stdio"], + ".jsx": ["typescript-language-server", "--stdio"], + ".json": ["vscode-json-language-server", "--stdio"], + ".rs": ["rust-analyzer"], + ".go": ["gopls"], + ".c": ["clangd"], + ".cpp": ["clangd"], + ".h": ["clangd"], + ".hpp": ["clangd"], + ".lua": ["lua-language-server"], +} + +# 副檔名對應的 LSP language id / The LSP language id for each suffix +LANGUAGE_IDS: dict[str, str] = { + ".ts": "typescript", ".tsx": "typescriptreact", + ".js": "javascript", ".jsx": "javascriptreact", + ".json": "json", ".rs": "rust", ".go": "go", + ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", + ".lua": "lua", +} + + +def merge_servers(stored: object) -> dict[str, list[str]]: + """ + 把使用者設定的伺服器併入預設值 + Merge user-configured servers over the defaults. + + 設定可能被手動編輯,因此型別不符的項目會被略過而不是讓整份設定失效。 + Settings may be hand-edited, so entries with the wrong type are skipped + rather than invalidating the whole mapping. + + :param stored: 從設定讀出的值,任何型別 / the stored value, any type + :return: 副檔名對應指令 / suffix -> server command + """ + servers = {suffix: list(command) for suffix, command in DEFAULT_SERVERS.items()} + if not isinstance(stored, dict): + return servers + for suffix, command in stored.items(): + if not isinstance(suffix, str) or not suffix.startswith("."): + continue + if isinstance(command, list) and command and all( + isinstance(part, str) for part in command): + servers[suffix.lower()] = list(command) + return servers + + +def server_command(suffix: str, servers: dict[str, list[str]] | None = None) -> list[str] | None: + """ + 取得某個副檔名對應的伺服器指令 + The server command for a file suffix. + + 只有指令真的存在於系統上才會回傳,因為沒安裝的伺服器啟動只會失敗。 + A command is only returned when it actually exists on the system, since + starting a server that is not installed would only fail. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :param servers: 伺服器對照表,``None`` 表示用預設 / the mapping, or ``None`` for the default + :return: 指令,沒有對應或沒安裝時為 ``None`` / the command, or ``None`` + """ + table = servers if servers is not None else DEFAULT_SERVERS + command = table.get(suffix.lower()) + if not command: + return None + if shutil.which(command[0]) is None: + return None + return list(command) + + +def language_id(suffix: str) -> str: + """ + 取得副檔名對應的 LSP language id + The LSP language id for a file suffix. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: language id,未知時去掉點當作 id / the id, or the bare suffix when unknown + """ + return LANGUAGE_IDS.get(suffix.lower(), suffix.lower().lstrip(".")) diff --git a/je_editor/utils/lsp/lsp_protocol.py b/je_editor/utils/lsp/lsp_protocol.py new file mode 100644 index 00000000..f6c93103 --- /dev/null +++ b/je_editor/utils/lsp/lsp_protocol.py @@ -0,0 +1,329 @@ +""" +Language Server Protocol 的訊息編解碼 +Encode and decode Language Server Protocol messages. + +LSP 走 JSON-RPC,每則訊息前面有 ``Content-Length`` 標頭;從管線讀到的資料可能 +切在任意位置,因此讀取端要能把不完整的訊息留著等下一批資料。 +LSP speaks JSON-RPC with a ``Content-Length`` header before each message. Data +arrives from a pipe split at arbitrary points, so the reader has to hold an +incomplete message until the rest turns up. + +純邏輯:不啟動任何程序,因此可以直接餵位元組測試。 +Pure logic: it starts no process, so it can be tested by feeding it bytes. +""" +from __future__ import annotations + +import json +from urllib.parse import unquote + +# 標頭與內容之間的分隔 / What separates the header from the body +HEADER_SEPARATOR = b"\r\n\r\n" +# 內容長度標頭 / The content-length header +CONTENT_LENGTH_HEADER = b"Content-Length:" + + +def encode_message(payload: dict) -> bytes: + """ + 把一則訊息編碼成 LSP 的傳輸格式 + Encode one message in the framing LSP uses on the wire. + + :param payload: JSON-RPC 訊息內容 / the JSON-RPC message + :return: 可直接寫入管線的位元組 / bytes ready to write to the pipe + """ + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + return header + body + + +def _content_length(header: bytes) -> int | None: + """讀出標頭中的內容長度 / Read the content length out of a header block.""" + for line in header.split(b"\r\n"): + if line.lower().startswith(CONTENT_LENGTH_HEADER.lower()): + try: + return int(line.split(b":", 1)[1].strip()) + except ValueError: + return None + return None + + +class MessageReader: + """ + 把陸續讀到的位元組組回一則則訊息 + Reassemble whole messages from bytes as they arrive. + """ + + def __init__(self) -> None: + self._buffer = b"" + + def feed(self, data: bytes) -> list[dict]: + """ + 餵入剛讀到的資料,取出其中完整的訊息 + Feed in what was just read and take out every complete message. + + 內容長度不合理或內容不是 JSON 時,該則訊息會被丟掉而不是拋出例外—— + 伺服器輸出異常不該讓編輯器停擺。 + A nonsensical length or a body that is not JSON drops that message rather + than raising: a misbehaving server must not stall the editor. + + :param data: 剛讀到的位元組 / the bytes just read + :return: 這次能組出的完整訊息 / the messages that are now complete + """ + self._buffer += data + messages: list[dict] = [] + while True: + separator = self._buffer.find(HEADER_SEPARATOR) + if separator < 0: + break + header = self._buffer[:separator] + length = _content_length(header) + body_start = separator + len(HEADER_SEPARATOR) + if length is None: + # 標頭讀不出長度:丟掉這段標頭,繼續找下一則 + # No usable length: drop this header and look for the next message + self._buffer = self._buffer[body_start:] + continue + if len(self._buffer) < body_start + length: + break # 內容還沒到齊 / the body has not all arrived yet + body = self._buffer[body_start:body_start + length] + self._buffer = self._buffer[body_start + length:] + try: + messages.append(json.loads(body.decode("utf-8"))) + except (ValueError, UnicodeDecodeError): + continue + return messages + + @property + def pending_bytes(self) -> int: + """還沒組成訊息的位元組數 / How many bytes are still waiting.""" + return len(self._buffer) + + +def request(request_id: int, method: str, params: dict | None = None) -> dict: + """ + 組出一則 JSON-RPC 請求 + Build a JSON-RPC request. + + :param request_id: 請求編號 / the request's id + :param method: 方法名稱 / the method to call + :param params: 參數 / the parameters + :return: 請求訊息 / the request message + """ + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}} + + +def notification(method: str, params: dict | None = None) -> dict: + """ + 組出一則 JSON-RPC 通知(不需要回應) + Build a JSON-RPC notification, which expects no reply. + + :param method: 方法名稱 / the method to call + :param params: 參數 / the parameters + :return: 通知訊息 / the notification message + """ + return {"jsonrpc": "2.0", "method": method, "params": params or {}} + + +def file_uri(path: str) -> str: + """ + 把檔案路徑轉成 LSP 使用的 URI + Turn a file path into the URI form LSP uses. + + :param path: 檔案路徑 / the file path + :return: ``file://`` 開頭的 URI / a ``file://`` URI + """ + normalised = str(path).replace("\\", "/") + if normalised.startswith("/"): + return f"file://{normalised}" + return f"file:///{normalised}" + + +def completion_labels(response: object) -> list[str]: + """ + 從 completion 回應取出候選字 + Take the candidate words out of a completion response. + + 回應可能是清單,也可能是 ``{"items": [...]}``,兩種形式都要接受。 + A response may be a plain list or ``{"items": [...]}``, and both are accepted. + + :param response: 伺服器的回應內容 / the server's result + :return: 候選字,去重且保留順序 / the candidates, unique and in order + """ + items = response.get("items") if isinstance(response, dict) else response + if not isinstance(items, list): + return [] + labels: list[str] = [] + for item in items: + label = item.get("label") if isinstance(item, dict) else item + if isinstance(label, str) and label and label not in labels: + labels.append(label) + return labels + + +def path_from_uri(uri: object) -> str: + """ + 把 LSP 的 ``file://`` URI 轉回檔案路徑 + Turn an LSP ``file://`` URI back into a file path. + + :param uri: 伺服器回報的 URI / the URI the server reported + :return: 檔案路徑,無法辨識時為空字串 / the path, or an empty string + """ + if not isinstance(uri, str) or not uri.startswith("file://"): + return "" + path = unquote(uri[len("file://"):]) + # Windows 的 URI 會多一個開頭斜線:``/D:/x`` 要還原成 ``D:/x`` + # A Windows URI carries a leading slash: ``/D:/x`` becomes ``D:/x`` + if len(path) > 2 and path[0] == "/" and path[2] == ":": + return path[1:] + return path + + +def definition_location(result: object) -> dict | None: + """ + 從 definition 回應取出第一個位置 + Take the first location out of a definition response. + + 回應可能是單一位置、位置清單,或 ``LocationLink`` 清單,三種都要接受。 + A response may be a single location, a list of them, or a list of + ``LocationLink``, and all three are accepted. + + :param result: 伺服器的回應內容 / the server's result + :return: ``{"path", "line", "column"}``,無法辨識時為 ``None`` + the location, or ``None`` when it cannot be read + """ + first = result[0] if isinstance(result, list) and result else result + if not isinstance(first, dict): + return None + uri = first.get("uri") or first.get("targetUri") + span = first.get("range") or first.get("targetSelectionRange") or first.get("targetRange") + path = path_from_uri(uri) + if not path or not isinstance(span, dict): + return None + line, column = _position(span.get("start")) + return {"path": path, "line": line, "column": column} + + +def hover_text(result: object) -> str: + """ + 從 hover 回應取出說明文字 + Take the description out of a hover response. + + ``contents`` 可能是字串、``{"value": ...}``,或兩者混合的清單,全部都要接受。 + ``contents`` may be a string, a ``{"value": ...}`` object, or a list mixing + both, and all of those are accepted. + + :param result: 伺服器的回應內容 / the server's result + :return: 說明文字,沒有內容時為空字串 / the text, or an empty string + """ + if not isinstance(result, dict): + return "" + contents = result.get("contents") + parts = contents if isinstance(contents, list) else [contents] + texts: list[str] = [] + for part in parts: + if isinstance(part, str) and part.strip(): + texts.append(part.strip()) + elif isinstance(part, dict): + value = part.get("value") + if isinstance(value, str) and value.strip(): + texts.append(value.strip()) + return "\n".join(texts) + + +def text_edits(result: object, file_uri_text: str = "") -> list[dict]: + """ + 從 rename 或 formatting 回應取出要套用的編輯 + Take the edits to apply out of a rename or formatting response. + + 格式化回傳的是編輯清單;重新命名回傳的是 ``WorkspaceEdit``,其中的 ``changes`` + 以 URI 分組。只取目前這個檔案的編輯,跨檔案的重新命名不在這裡處理。 + Formatting returns a list of edits, while a rename returns a + ``WorkspaceEdit`` whose ``changes`` are grouped by URI. Only the edits for + this file are taken; a rename spanning several files is not handled here. + + :param result: 伺服器的回應內容 / the server's result + :param file_uri_text: 目前檔案的 URI / this file's URI + :return: 每筆編輯的範圍與新文字 / each edit's range and replacement text + """ + raw = result + if isinstance(result, dict): + changes = result.get("changes") + if isinstance(changes, dict): + raw = changes.get(file_uri_text) or next(iter(changes.values()), []) + else: + raw = result.get("documentChanges") or [] + if raw and isinstance(raw[0], dict): + raw = raw[0].get("edits", []) + if not isinstance(raw, list): + return [] + edits: list[dict] = [] + for item in raw: + if not isinstance(item, dict): + continue + span = item.get("range") + new_text = item.get("newText") + if not isinstance(span, dict) or not isinstance(new_text, str): + continue + start_line, start_column = _position(span.get("start")) + end_line, end_column = _position(span.get("end"), start_line - 1, start_column - 1) + edits.append({ + "start_line": start_line, "start_column": start_column, + "end_line": end_line, "end_column": end_column, + "new_text": new_text, + }) + return edits + + +def diagnostic_entries(params: object) -> list[dict]: + """ + 從 publishDiagnostics 通知取出診斷 + Take the diagnostics out of a ``publishDiagnostics`` notification. + + :param params: 通知的參數 / the notification's parameters + :return: 每筆診斷的位置、代碼與訊息 / each diagnostic's range, code and message + """ + if not isinstance(params, dict): + return [] + raw = params.get("diagnostics") + if not isinstance(raw, list): + return [] + entries: list[dict] = [] + for item in raw: + entry = _diagnostic_entry(item) + if entry is not None: + entries.append(entry) + return entries + + +def _position(raw: object, fallback_line: int = 0, fallback_column: int = 0) -> tuple[int, int]: + """讀出 LSP 位置並轉成 1 起算 / Read an LSP position, converted to 1-based.""" + if not isinstance(raw, dict): + return fallback_line + 1, fallback_column + 1 + line = raw.get("line") + column = raw.get("character") + return ( + (line if isinstance(line, int) and line >= 0 else fallback_line) + 1, + (column if isinstance(column, int) and column >= 0 else fallback_column) + 1, + ) + + +def _diagnostic_entry(item: object) -> dict | None: + """把一筆 LSP 診斷轉成編輯器用的形式 / Convert one LSP diagnostic for the editor.""" + if not isinstance(item, dict): + return None + message = item.get("message") + if not isinstance(message, str) or not message: + return None + span = item.get("range") if isinstance(item.get("range"), dict) else {} + line, column = _position(span.get("start")) + end_line, end_column = _position(span.get("end"), line - 1, column - 1) + code = item.get("code") + return { + # LSP 的行列是 0 起算,編輯器用 1 起算 + # LSP counts lines and columns from zero; the editor counts from one + "line": line, + "column": column, + "end_line": max(end_line, line), + "end_column": end_column, + "code": str(code) if isinstance(code, (str, int)) else "", + "message": message, + } diff --git a/je_editor/utils/macro/__init__.py b/je_editor/utils/macro/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/macro/keystroke_macro.py b/je_editor/utils/macro/keystroke_macro.py new file mode 100644 index 00000000..5626d46f --- /dev/null +++ b/je_editor/utils/macro/keystroke_macro.py @@ -0,0 +1,89 @@ +""" +記錄與重播一段按鍵操作 +Record a run of keystrokes and play it back. + +錄製時把每個按鍵存成 ``(鍵碼, 修飾鍵, 文字)``,重播時再依序送出。純資料結構, +不含 Qt,因此錄製內容可以單獨測試。 +Recording stores each key as ``(key, modifiers, text)`` and playback sends them +again in order. Pure data with no Qt, so what was recorded can be tested on its +own. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# 一段巨集最多記幾個按鍵,避免錄製中忘記停止而無限成長 +# How many keystrokes one macro holds, so a forgotten recording cannot grow forever +MAX_KEYSTROKES = 2000 + + +@dataclass(frozen=True) +class Keystroke: + """ + 一個被記錄下來的按鍵 + One recorded keystroke. + + :param key: Qt 的鍵碼 / Qt's key code + :param modifiers: 修飾鍵的位元值 / the modifier flags + :param text: 該按鍵產生的文字 / the text the key produced + """ + + key: int + modifiers: int + text: str + + +@dataclass +class KeystrokeMacro: + """ + 一段巨集的錄製狀態 + The recording state of one macro. + + :param keystrokes: 已錄下的按鍵 / the keystrokes recorded so far + :param recording: 是否正在錄製 / whether recording is under way + """ + + keystrokes: list[Keystroke] = field(default_factory=list) + recording: bool = False + + def start(self) -> None: + """開始錄製,並丟掉上一段 / Start recording, discarding the previous run.""" + self.keystrokes = [] + self.recording = True + + def stop(self) -> None: + """停止錄製 / Stop recording.""" + self.recording = False + + def toggle(self) -> bool: + """ + 切換錄製狀態 + Start recording, or stop if already going. + + :return: 切換後是否正在錄製 / whether recording is now under way + """ + if self.recording: + self.stop() + else: + self.start() + return self.recording + + def record(self, key: int, modifiers: int, text: str) -> bool: + """ + 記下一個按鍵 + Record one keystroke. + + :param key: Qt 的鍵碼 / Qt's key code + :param modifiers: 修飾鍵的位元值 / the modifier flags + :param text: 該按鍵產生的文字 / the text the key produced + :return: 有記下時為 ``True`` / ``True`` when it was recorded + """ + if not self.recording or len(self.keystrokes) >= MAX_KEYSTROKES: + return False + self.keystrokes.append(Keystroke(key=key, modifiers=modifiers, text=text)) + return True + + @property + def is_empty(self) -> bool: + """是否還沒錄到任何按鍵 / Whether nothing has been recorded yet.""" + return not self.keystrokes diff --git a/je_editor/utils/minimap/__init__.py b/je_editor/utils/minimap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/minimap/minimap_layout.py b/je_editor/utils/minimap/minimap_layout.py new file mode 100644 index 00000000..4231e966 --- /dev/null +++ b/je_editor/utils/minimap/minimap_layout.py @@ -0,0 +1,112 @@ +""" +縮圖(minimap)的座標換算 +Work out the minimap's geometry. + +把「第幾行」與「縮圖上的第幾個像素」互相對應,並在行數多到畫不下時決定取樣間隔。 +Maps a line number to a pixel row in the minimap and back, and decides how to +sample when there are more lines than pixels to draw them on. + +純邏輯,不含 Qt,因此可以單獨測試。 +Pure logic with no Qt, so it can be tested on its own. +""" +from __future__ import annotations + +# 縮圖上每一行佔的像素高度 / How many pixels one line occupies in the minimap +LINE_PIXELS = 2 +# 縮圖的寬度(像素)/ The minimap's width in pixels +MINIMAP_WIDTH = 90 +# 一行畫成長條時,一個字元佔的寬度(像素) +# How wide one character is when a line is drawn as a bar +CHAR_PIXELS = 1 + + +def sample_step(total_lines: int, available_height: int) -> int: + """ + 決定取樣間隔:行數多到畫不下時,每隔幾行畫一條 + How many lines each drawn row stands for when they cannot all be drawn. + + :param total_lines: 文件總行數 / the document's line count + :param available_height: 縮圖可用高度(像素)/ the minimap's height in pixels + :return: 取樣間隔,至少為 1 / the step, never below 1 + """ + if total_lines <= 0 or available_height <= 0: + return 1 + drawable = max(1, available_height // LINE_PIXELS) + if total_lines <= drawable: + return 1 + return -(-total_lines // drawable) # 無條件進位 / round up + + +def row_for_line(line: int, step: int) -> int: + """ + 取得某一行在縮圖上的像素位置 + The pixel row a line is drawn at. + + :param line: 以 0 起算的行號 / the 0-based line number + :param step: 取樣間隔 / the sampling step + :return: 縮圖上的 y 座標 / the y coordinate in the minimap + """ + return (line // max(1, step)) * LINE_PIXELS + + +def line_at_row(row: int, step: int, total_lines: int) -> int: + """ + 取得縮圖上某個像素位置對應的行號 + The line a pixel row in the minimap points at. + + :param row: 縮圖上的 y 座標 / the y coordinate in the minimap + :param step: 取樣間隔 / the sampling step + :param total_lines: 文件總行數 / the document's line count + :return: 以 0 起算的行號,會夾在文件範圍內 / the 0-based line, clamped to the document + """ + if total_lines <= 0: + return 0 + line = (max(0, row) // LINE_PIXELS) * max(1, step) + return min(line, total_lines - 1) + + +def bar_width(text: str, width_limit: int = MINIMAP_WIDTH) -> int: + """ + 取得一行畫成長條時的寬度 + How wide a line's bar should be. + + 以行的長度表示,因此縮圖看起來像程式碼的輪廓;空白行寬度為零。 + The width follows the line's length, so the minimap reads as the shape of the + code; a blank line has no bar at all. + + :param text: 該行文字 / the line's text + :param width_limit: 縮圖寬度上限 / the minimap's width + :return: 長條寬度(像素)/ the bar's width in pixels + """ + stripped = text.rstrip() + if not stripped: + return 0 + return min(len(stripped) * CHAR_PIXELS, width_limit) + + +def bar_offset(text: str, width_limit: int = MINIMAP_WIDTH) -> int: + """ + 取得一行長條的起始位置(依縮排縮進) + Where a line's bar starts, following its indentation. + + :param text: 該行文字 / the line's text + :param width_limit: 縮圖寬度上限 / the minimap's width + :return: 起始 x 座標 / the starting x coordinate + """ + indent = len(text) - len(text.lstrip()) + return min(indent * CHAR_PIXELS, width_limit) + + +def viewport_band(first_visible: int, visible_lines: int, step: int) -> tuple[int, int]: + """ + 取得代表目前可視範圍的方框位置與高度 + The position and height of the band showing what is currently on screen. + + :param first_visible: 畫面最上方的行號 / the top visible line + :param visible_lines: 畫面可容納的行數 / how many lines fit on screen + :param step: 取樣間隔 / the sampling step + :return: ``(起始 y, 高度)``,高度至少 ``LINE_PIXELS`` / ``(top, height)`` + """ + top = row_for_line(max(0, first_visible), step) + height = max(LINE_PIXELS, row_for_line(max(1, visible_lines), step)) + return top, height diff --git a/je_editor/utils/multi_cursor/__init__.py b/je_editor/utils/multi_cursor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/multi_cursor/cursor_positions.py b/je_editor/utils/multi_cursor/cursor_positions.py new file mode 100644 index 00000000..c555a302 --- /dev/null +++ b/je_editor/utils/multi_cursor/cursor_positions.py @@ -0,0 +1,140 @@ +""" +維護多重游標的位置 +Keep track of where the extra carets are. + +在其中一個位置插入或刪除文字之後,排在後面的位置都要跟著位移,否則第二個之後 +的游標就會落在錯的地方。這裡只做位置運算,實際編輯交給編輯器。 +Inserting or deleting at one caret shifts every caret after it; without that, +the second and later carets would land in the wrong place. This module does the +arithmetic only, leaving the editing to the editor. +""" +from __future__ import annotations + + +def add_position(positions: list[int], position: int) -> list[int]: + """ + 加入一個游標位置(已存在則不重複加入) + Add a caret position, ignoring one that is already there. + + :param positions: 現有位置 / the positions so far + :param position: 要加入的位置 / the position to add + :return: 排序後的新清單 / the new sorted list + """ + if position < 0 or position in positions: + return sorted(positions) + return sorted([*positions, position]) + + +def remove_position(positions: list[int], position: int) -> list[int]: + """ + 移除一個游標位置 + Remove a caret position. + + :param positions: 現有位置 / the positions so far + :param position: 要移除的位置 / the position to remove + :return: 排序後的新清單 / the new sorted list + """ + return sorted(value for value in positions if value != position) + + +def toggle_position(positions: list[int], position: int) -> list[int]: + """ + 切換一個游標位置:不在就加入,已在就移除 + Toggle a caret position: add it when absent, remove it when present. + + :param positions: 現有位置 / the positions so far + :param position: 要切換的位置 / the position to toggle + :return: 排序後的新清單 / the new sorted list + """ + if position in positions: + return remove_position(positions, position) + return add_position(positions, position) + + +def shift_after_insert(positions: list[int], at: int, length: int) -> list[int]: + """ + 在 *at* 插入 *length* 個字元之後,調整其他位置 + Adjust the positions after *length* characters are inserted at *at*. + + 插入點之後的位置往後移;插入點本身與之前的位置不變。 + Positions after the insertion move along; the insertion point itself and + anything before it stay put. + + :param positions: 現有位置 / the positions so far + :param at: 插入位置 / where the text went in + :param length: 插入的字元數 / how many characters were inserted + :return: 調整後的位置 / the adjusted positions + """ + return [value + length if value > at else value for value in positions] + + +def shift_after_delete(positions: list[int], at: int, length: int) -> list[int]: + """ + 在 *at* 刪除 *length* 個字元之後,調整其他位置 + Adjust the positions after *length* characters are deleted from *at*. + + 落在被刪除範圍內的位置會被移到刪除點,避免指向已經不存在的位置。 + A position inside the deleted range collapses onto the deletion point, so it + cannot point at text that no longer exists. + + :param positions: 現有位置 / the positions so far + :param at: 刪除起點 / where the deletion started + :param length: 刪除的字元數 / how many characters were deleted + :return: 調整後的位置(去重並排序)/ the adjusted positions, unique and sorted + """ + adjusted: list[int] = [] + for value in positions: + if value <= at: + adjusted.append(value) + elif value >= at + length: + adjusted.append(value - length) + else: + adjusted.append(at) + return sorted(set(adjusted)) + + +def column_span(anchor_line: int, current_line: int) -> range: + """ + 取得欄選取涵蓋的行範圍(可上可下) + The lines a column selection covers, dragged in either direction. + + :param anchor_line: 起點所在行 / the line the drag started on + :param current_line: 目前所在行 / the line the pointer is on now + :return: 涵蓋的行號,含頭含尾 / the lines covered, both ends included + """ + first, last = sorted((max(0, anchor_line), max(0, current_line))) + return range(first, last + 1) + + +def column_caret_columns( + anchor_column: int, current_column: int, line_lengths: list[int]) -> list[int]: + """ + 取得欄選取在每一行的欄位 + The column a rectangular selection lands on for each line it covers. + + 游標放在拖曳到的那一欄;比該欄短的行就停在行尾,因此短行不會產生指向不存在 + 位置的游標。 + The caret goes to the column the pointer reached, and a line shorter than + that stops at its end, so a short line never gets a caret pointing past it. + + :param anchor_column: 起點的欄位 / the column the drag started at + :param current_column: 目前的欄位 / the column the pointer is at now + :param line_lengths: 各涵蓋行的長度 / the length of each covered line + :return: 各行的欄位 / the column for each line + """ + target = max(0, current_column if current_column != anchor_column else anchor_column) + return [min(target, max(0, length)) for length in line_lengths] + + +def clamp_positions(positions: list[int], limit: int) -> list[int]: + """ + 把位置限制在文件範圍內 + Clamp the positions to the document's length. + + :param positions: 現有位置 / the positions so far + :param limit: 文件可用的最大位置 / the highest valid position + :return: 範圍內的位置(去重並排序)/ the in-range positions, unique and sorted + """ + if limit < 0: + return [] + return sorted({min(max(0, value), limit) for value in positions}) diff --git a/je_editor/utils/multi_language/english.py b/je_editor/utils/multi_language/english.py index 88c15b8b..499cd610 100644 --- a/je_editor/utils/multi_language/english.py +++ b/je_editor/utils/multi_language/english.py @@ -85,6 +85,13 @@ "file_menu_open_folder_label": "Open Folder", "file_menu_save_file_label": "Save File", "file_menu_encoding_label": "Encodings", + "file_menu_line_ending_label": "Line Endings", + "file_menu_save_all_label": "Save All", + "recent_locations_title": "Recent Locations", + "recent_locations_prompt": "Jump to:", + "format_on_save_label": "Format on Save", + "style_menu_indent_guides_label": "Show Indent Guides", + "style_menu_trailing_whitespace_label": "Show Trailing Whitespace", "file_menu_font_label": "Font", "file_menu_font_size_label": _FONT_SIZE_LABEL, # Help Menu @@ -377,6 +384,51 @@ "todo_panel_col_message": "Message", "todo_panel_col_file": "File", "todo_panel_col_line": "Line", + "tab_menu_diff_against_head_name": "Diff Against HEAD", + "tab_menu_split_view_label": "Toggle Split View", + "tab_menu_minimap_label": "Toggle Minimap", + "snippet_editor_title": "Snippet Editor", + "snippet_editor_hint": "Tab stops are written $1, ${2:default} and $0.", + "snippet_editor_body_placeholder": "Snippet body...", + "snippet_editor_add": "Add", + "snippet_editor_remove": "Remove", + "snippet_editor_save": "Save", + # Editor context menu + "context_menu_toggle_comment": "Toggle Comment", + "context_menu_toggle_bookmark": "Toggle Bookmark", + "context_menu_go_to_definition": "Go to Definition", + "context_menu_revert_hunk": "Revert This Change", + "context_menu_stage_hunk": "Stage This Change", + "context_menu_hover": "Describe Symbol", + "context_menu_rename_symbol": "Rename Symbol", + "context_menu_rename_prompt": "New name:", + "context_menu_format_document": "Format Document", + # Test panel + "tab_menu_test_panel_tab_name": "Tests", + "test_panel_run": "Run Tests", + "test_panel_run_selected": "Run Selected", + "test_panel_rerun_failures": "Re-run Failures", + "test_panel_filter_placeholder": "Filter tests...", + "test_panel_ready": "Ready", + "test_panel_running": "Running tests...", + "test_panel_no_results": "No tests were reported", + "test_panel_col_outcome": "Outcome", + "test_panel_col_test": "Test", + "test_panel_col_file": "File", + # Problems panel + "tab_menu_problems_panel_tab_name": "Problems", + "problems_panel_refresh": "Recheck", + "problems_panel_ready": "Ready", + "problems_panel_checking": "Checking the project...", + "problems_panel_clean": "No problems found", + "problems_panel_found": "{count} problems", + "problems_panel_col_code": "Rule", + "problems_panel_col_message": "Message", + "problems_panel_col_line": "Line", + "problems_panel_col_file": "File", + "problems_panel_whole_project": "Whole project", + "problems_panel_all_severities": "All severities", + "problems_panel_fix": "Apply Fixes", # Outline panel "tab_menu_outline_panel_tab_name": "Outline", "outline_panel_refresh": "Refresh", diff --git a/je_editor/utils/multi_language/traditional_chinese.py b/je_editor/utils/multi_language/traditional_chinese.py index 6c2697ee..b8211c7f 100644 --- a/je_editor/utils/multi_language/traditional_chinese.py +++ b/je_editor/utils/multi_language/traditional_chinese.py @@ -82,6 +82,13 @@ "file_menu_open_folder_label": "開啟資料夾", "file_menu_save_file_label": "儲存檔案", "file_menu_encoding_label": "編碼", + "file_menu_line_ending_label": "行尾", + "file_menu_save_all_label": "全部儲存", + "recent_locations_title": "最近位置", + "recent_locations_prompt": "跳到:", + "format_on_save_label": "存檔時自動格式化", + "style_menu_indent_guides_label": "顯示縮排參考線", + "style_menu_trailing_whitespace_label": "顯示尾端空白", "file_menu_font_label": "字體", "file_menu_font_size_label": "字體大小", # Help Menu @@ -367,6 +374,51 @@ "todo_panel_col_message": "說明", "todo_panel_col_file": "檔案", "todo_panel_col_line": "行號", + "tab_menu_diff_against_head_name": "與 HEAD 比較差異", + "tab_menu_split_view_label": "切換分割檢視", + "tab_menu_minimap_label": "切換縮圖", + "snippet_editor_title": "片段編輯器", + "snippet_editor_hint": "定位點請用 $1、${2:預設值} 與 $0。", + "snippet_editor_body_placeholder": "片段內容...", + "snippet_editor_add": "新增", + "snippet_editor_remove": "刪除", + "snippet_editor_save": "儲存", + # 編輯器右鍵選單 + "context_menu_toggle_comment": "切換註解", + "context_menu_toggle_bookmark": "切換書籤", + "context_menu_go_to_definition": "跳到定義", + "context_menu_revert_hunk": "還原這段變更", + "context_menu_stage_hunk": "暫存這段變更", + "context_menu_hover": "說明這個符號", + "context_menu_rename_symbol": "重新命名符號", + "context_menu_rename_prompt": "新名稱:", + "context_menu_format_document": "格式化整份文件", + # Test panel + "tab_menu_test_panel_tab_name": "測試", + "test_panel_run": "執行測試", + "test_panel_run_selected": "只跑選取的", + "test_panel_rerun_failures": "重跑失敗的", + "test_panel_filter_placeholder": "篩選測試...", + "test_panel_ready": "就緒", + "test_panel_running": "測試執行中...", + "test_panel_no_results": "沒有回報任何測試", + "test_panel_col_outcome": "結果", + "test_panel_col_test": "測試", + "test_panel_col_file": "檔案", + # Problems panel + "tab_menu_problems_panel_tab_name": "問題", + "problems_panel_refresh": "重新檢查", + "problems_panel_ready": "就緒", + "problems_panel_checking": "檢查專案中……", + "problems_panel_clean": "沒有發現問題", + "problems_panel_found": "{count} 個問題", + "problems_panel_col_code": "規則", + "problems_panel_col_message": "訊息", + "problems_panel_col_line": "行號", + "problems_panel_col_file": "檔案", + "problems_panel_whole_project": "整個專案", + "problems_panel_all_severities": "所有嚴重度", + "problems_panel_fix": "套用修正", # Outline panel "tab_menu_outline_panel_tab_name": "大綱", "outline_panel_refresh": "重新整理", diff --git a/je_editor/utils/selection/surround.py b/je_editor/utils/selection/surround.py new file mode 100644 index 00000000..8b469496 --- /dev/null +++ b/je_editor/utils/selection/surround.py @@ -0,0 +1,45 @@ +""" +用括號或引號包住選取的文字 +Wrap a selection in brackets or quotes. + +純文字轉換,插入與游標處理留給編輯器。 +A plain text transform; inserting it and moving the caret is the editor's job. +""" +from __future__ import annotations + +# 開頭字元對應的結尾字元 / The closing character for each opening one +SURROUND_PAIRS = { + "(": ")", + "[": "]", + "{": "}", + "<": ">", + "'": "'", + '"': '"', + "`": "`", +} + + +def closing_for(opening: str) -> str | None: + """ + 取得對應的結尾字元 + The closing character that matches an opening one. + + :param opening: 開頭字元 / the opening character + :return: 結尾字元,不成對時為 ``None`` / the closing one, or ``None`` + """ + return SURROUND_PAIRS.get(opening) + + +def surround(text: str, opening: str) -> str | None: + """ + 把文字包在成對的字元之間 + Put text between a matching pair of characters. + + :param text: 要包住的文字 / the text to wrap + :param opening: 開頭字元 / the opening character + :return: 包好的文字;字元不成對時為 ``None`` / the wrapped text, or ``None`` + """ + closing = closing_for(opening) + if closing is None: + return None + return f"{opening}{text}{closing}" diff --git a/je_editor/utils/session/editor_state.py b/je_editor/utils/session/editor_state.py new file mode 100644 index 00000000..e677a23b --- /dev/null +++ b/je_editor/utils/session/editor_state.py @@ -0,0 +1,63 @@ +""" +讀取與套用一個編輯分頁的游標、書籤與折疊狀態 +Read and apply one editor tab's caret, bookmarks and folds. + +工作階段還原原本只重新開檔,開起來的檔案永遠停在第一行、書籤與折疊全部消失。 +Session restore used to only reopen files: every one came back at line one with +its bookmarks and folds gone. +""" +from __future__ import annotations + +from je_editor.utils.logging.loggin_instance import jeditor_logger +from je_editor.utils.session.open_files_session import build_file_state + + +def editor_state(widget) -> dict: + """ + 取得一個編輯分頁目前的狀態 + Collect the current state of one editor tab. + + :param widget: 編輯分頁 / the editor tab + :return: 可寫入設定的狀態 / the state, ready to store + """ + code_edit = widget.code_edit + return build_file_state( + code_edit.textCursor().blockNumber(), + list(code_edit.bookmark_manager.bookmarked_lines()), + list(code_edit.folding_manager.folded_header_lines()), + ) + + +def restore_editor_state(widget, state: dict | None) -> bool: + """ + 把狀態套回一個編輯分頁 + Apply a stored state back onto an editor tab. + + 還原是「盡力而為」:檔案在編輯器外被改短時,超出範圍的行號會被跳過,而不是 + 讓整個還原失敗。 + Restoring is best-effort: if the file shrank outside the editor, lines beyond + its end are skipped rather than failing the whole restore. + + :param widget: 編輯分頁,可為 ``None`` / the editor tab, may be ``None`` + :param state: 要套用的狀態,可為 ``None`` / the state to apply, may be ``None`` + :return: 有套用時為 ``True`` / ``True`` when the state was applied + """ + if widget is None or not state: + return False + code_edit = getattr(widget, "code_edit", None) + if code_edit is None: + return False + last_line = code_edit.blockCount() - 1 + try: + for line in state.get("bookmarks", []): + if line <= last_line: + code_edit.bookmark_manager.toggle(line) + for line in state.get("folds", []): + if line <= last_line: + code_edit.folding_manager.toggle_fold(line) + caret = min(state.get("caret_line", 0), last_line) + code_edit.jump_to_line(caret + 1) + except (AttributeError, RuntimeError, ValueError) as error: + jeditor_logger.warning(f"Restoring editor state failed: {error}") + return False + return True diff --git a/je_editor/utils/session/open_files_session.py b/je_editor/utils/session/open_files_session.py index 8d5e4c41..0bdc9834 100644 --- a/je_editor/utils/session/open_files_session.py +++ b/je_editor/utils/session/open_files_session.py @@ -12,9 +12,14 @@ # 設定檔中儲存工作階段的鍵名 / Settings key holding the session SESSION_SETTING_KEY = "open_files" +# 設定檔中儲存各檔案編輯狀態的鍵名 / Settings key holding each file's editor state +SESSION_STATE_KEY = "open_file_states" # 還原檔案數量上限,避免上次留下大量分頁時啟動變慢 # Cap on restored files so a huge previous session cannot slow startup MAX_SESSION_FILES = 20 +# 每個檔案記錄的書籤與折疊行數上限,避免設定檔無限成長 +# Cap on bookmarks and folds stored per file, so the settings cannot grow forever +MAX_STATE_LINES = 200 def collect_open_files(current_files: Iterable[str | None]) -> list[str]: @@ -78,6 +83,83 @@ def restorable_files( return restorable +def _clean_line_list(value: object) -> list[int]: + """ + 整理成排序過、去重、非負的行號清單 + Clean a stored value into sorted, unique, non-negative line numbers. + + :param value: 從設定讀出的值,任何型別 / the stored value, any type + :return: 可用的行號 / the usable line numbers + """ + if not isinstance(value, list): + return [] + lines = { + entry for entry in value + if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 0 + } + return sorted(lines)[:MAX_STATE_LINES] + + +def build_file_state(caret_line: int, bookmarks: object, folds: object) -> dict: + """ + 整理一個檔案要記錄的編輯狀態 + Build the editor state to persist for one file. + + :param caret_line: 游標所在行(0 起算)/ the caret's 0-based line + :param bookmarks: 書籤行號 / the bookmarked line numbers + :param folds: 折疊起始行號 / the folded header line numbers + :return: 可寫入設定的狀態 / the state, ready to store + """ + return { + "caret_line": max(0, int(caret_line)), + "bookmarks": _clean_line_list(bookmarks), + "folds": _clean_line_list(folds), + } + + +def collect_file_states(states: dict) -> dict: + """ + 整理所有檔案的編輯狀態 + Clean every file's editor state before storing it. + + :param states: 路徑對應狀態 / path -> state + :return: 只含有效項目的對照表 / the mapping with unusable entries dropped + """ + collected: dict = {} + for path, state in states.items(): + if not path or not isinstance(state, dict): + continue + collected[str(path)] = build_file_state( + state.get("caret_line", 0), state.get("bookmarks"), state.get("folds")) + return collected + + +def restorable_file_state(stored_states: object, file_path: str) -> dict | None: + """ + 取出某個檔案可還原的編輯狀態 + Return the restorable editor state for one file. + + 設定檔可能是手動編輯或舊版寫的,因此格式不符時視為沒有狀態,而不是讓還原失敗。 + A hand-edited or older settings file may hold anything, so an unusable entry + counts as no state rather than breaking the restore. + + :param stored_states: 從設定讀出的值 / the stored value, any type + :param file_path: 目標檔案路徑 / the file to look up + :return: 狀態,沒有可用狀態時為 ``None`` / the state, or ``None`` + """ + if not isinstance(stored_states, dict): + return None + state = stored_states.get(str(file_path)) + if not isinstance(state, dict): + return None + caret = state.get("caret_line", 0) + return { + "caret_line": caret if isinstance(caret, int) and caret >= 0 else 0, + "bookmarks": _clean_line_list(state.get("bookmarks")), + "folds": _clean_line_list(state.get("folds")), + } + + def _is_readable_file(entry: str) -> bool: """判斷路徑是否為現存的檔案 / Whether the path is an existing file.""" try: diff --git a/je_editor/utils/shortcuts/__init__.py b/je_editor/utils/shortcuts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/shortcuts/shortcut_registry.py b/je_editor/utils/shortcuts/shortcut_registry.py new file mode 100644 index 00000000..8856dec4 --- /dev/null +++ b/je_editor/utils/shortcuts/shortcut_registry.py @@ -0,0 +1,191 @@ +""" +記錄已經被指派出去的快捷鍵,並在重複指派時指出來 +Track which key sequences are already spoken for, and say so when one is reused. + +兩個動作綁到同一組按鍵時,Qt 不會挑一個執行,而是兩個都不執行("Ambiguous +shortcut overload")。也就是說重複指派不會噴例外,只會讓兩個功能一起失效——除非 +有人剛好去按,否則不會被發現。這個模組把「這組按鍵歸誰」變成可以查詢、也可以測試 +的資料。 +When two actions share a key sequence Qt runs neither of them ("Ambiguous +shortcut overload"). A clash therefore raises nothing and simply makes both +features stop working, which nobody notices until they press the keys. This +module turns "who owns this sequence" into something that can be queried and +tested. + +純邏輯,不匯入 Qt,因此可以單獨測試。 +Pure logic with no Qt import, so it can be tested on its own. +""" +from __future__ import annotations + +from typing import Dict, Iterable, List, Optional, Tuple + +# 修飾鍵的各種寫法對應到同一個名字 +# The spellings each modifier is accepted under +_MODIFIER_ALIASES: Dict[str, str] = { + "ctrl": "ctrl", + "control": "ctrl", + "alt": "alt", + "option": "alt", + "shift": "shift", + "meta": "meta", + "cmd": "meta", + "command": "meta", +} + +# 修飾鍵的固定排列順序,讓 "Shift+Ctrl+A" 與 "Ctrl+Shift+A" 得到同一個字串 +# A fixed modifier order, so "Shift+Ctrl+A" and "Ctrl+Shift+A" normalise alike +_MODIFIER_ORDER: Tuple[str, ...] = ("ctrl", "alt", "shift", "meta") + +# 分隔符 / The separator used by Qt key sequences +_SEPARATOR = "+" + + +def normalise_sequence(sequence: str) -> str: + """ + 把按鍵組合轉成可以互相比較的固定寫法 + Reduce a key sequence to a form that compares equal to its variants. + + 大小寫、修飾鍵順序與別名都會被統一,所以 ``"Ctrl+Shift+f"``、``"Shift+Ctrl+F"`` + 與 ``"control+shift+F"`` 都會得到同一個結果。 + Case, modifier order and modifier aliases are all levelled, so ``"Ctrl+Shift+f"``, + ``"Shift+Ctrl+F"`` and ``"control+shift+F"`` all come out the same. + + :param sequence: 按鍵組合,例如 ``"Ctrl+Alt+S"`` / a sequence such as ``"Ctrl+Alt+S"`` + :return: 固定寫法,空字串代表沒有指派 / the canonical form; empty means unassigned + """ + text = str(sequence).strip() + if not text: + return "" + # 「+」自己也可以是那個鍵(Ctrl++),先拆下來再切開其餘部分 + # "+" can be the key itself (Ctrl++), so take it off before splitting + plus_is_the_key = text.endswith(_SEPARATOR) and len(text) > 1 + if plus_is_the_key: + text = text[:-1] + parts = [part.strip().lower() for part in text.split(_SEPARATOR) if part.strip()] + if plus_is_the_key: + parts.append(_SEPARATOR) + modifiers = {_MODIFIER_ALIASES[part] for part in parts if part in _MODIFIER_ALIASES} + keys = [part for part in parts if part not in _MODIFIER_ALIASES] + ordered = [name for name in _MODIFIER_ORDER if name in modifiers] + return _SEPARATOR.join(ordered + keys) + + +class ShortcutRegistry: + """ + 記錄每組按鍵屬於哪個指令 + Remember which command each key sequence belongs to. + """ + + def __init__(self, reserved: Optional[Dict[str, str]] = None) -> None: + """ + :param reserved: 已經被外部(選單、工具列)佔用的按鍵,對應到指令名稱 + / sequences already taken elsewhere (menus, toolbar), mapped to command names + """ + self._owners: Dict[str, str] = {} + self._conflicts: List[Tuple[str, str, str]] = [] + for sequence, command in (reserved or {}).items(): + key = normalise_sequence(sequence) + if key: + self._owners[key] = command + + def owner_of(self, sequence: str) -> Optional[str]: + """ + 查出這組按鍵目前歸誰 + Which command currently owns a sequence. + + :param sequence: 按鍵組合 / the key sequence + :return: 指令名稱,未指派時為 ``None`` / the command name, or ``None`` + """ + return self._owners.get(normalise_sequence(sequence)) + + def register(self, sequence: str, command: str) -> Optional[str]: + """ + 指派一組按鍵給某個指令 + Assign a sequence to a command. + + 已經被別人佔用時不會覆蓋原本的擁有者,而是把衝突記下來並回報,讓呼叫端能夠 + 記錄或讓測試能夠斷言。 + When the sequence is already taken the existing owner is kept rather than + overwritten; the clash is recorded and returned so the caller can log it + and a test can assert on it. + + :param sequence: 按鍵組合 / the key sequence + :param command: 指令名稱 / the command name + :return: 原本的擁有者,沒有衝突時為 ``None`` / the previous owner, or ``None`` + """ + key = normalise_sequence(sequence) + if not key: + return None + existing = self._owners.get(key) + if existing is not None and existing != command: + self._conflicts.append((key, existing, command)) + return existing + self._owners[key] = command + return None + + def register_all(self, bindings: Iterable[Tuple[str, str]]) -> List[Tuple[str, str, str]]: + """ + 一次指派多組按鍵 + Assign several sequences at once. + + :param bindings: ``(按鍵, 指令)`` 的序列 / pairs of ``(sequence, command)`` + :return: 這次產生的衝突 / the clashes this call produced + """ + before = len(self._conflicts) + for sequence, command in bindings: + self.register(sequence, command) + return self._conflicts[before:] + + def conflicts(self) -> List[Tuple[str, str, str]]: + """ + 取得目前累積的所有衝突 + Every clash recorded so far. + + :return: ``(按鍵, 原擁有者, 後來者)`` 的清單 / ``(sequence, owner, newcomer)`` triples + """ + return list(self._conflicts) + + def commands(self) -> Dict[str, str]: + """ + 取得目前的指派表 + The current assignment table. + + :return: 按鍵對應指令 / sequence mapped to command name + """ + return dict(self._owners) + + +# 選單與工具列佔用的按鍵:編輯器的動作不能重複使用這些組合,否則兩邊都不會作用。 +# 這裡列出的是「視窗層級」的指令,編輯器有焦點時它們仍然必須能用。 +# The sequences the menus and toolbar take. Editor actions must not reuse them or +# neither side fires. These are window-level commands that have to keep working +# while the editor has focus. +WINDOW_SHORTCUTS: Dict[str, str] = { + # 工具列 / Toolbar + "F5": "run_program", + "F9": "run_debugger", + "Shift+F5": "stop_program", + "Ctrl+Shift+F": "search_in_files", + "Ctrl+Shift+A": "command_palette", + "Ctrl+P": "quick_open", + "Ctrl+Shift+O": "go_to_symbol", + # 檔案選單 / File menu + "Ctrl+N": "new_file", + "Ctrl+O": "open_file", + "Ctrl+K": "open_folder", + "Ctrl+S": "save_file", + "Ctrl+Shift+S": "save_all", + # 檢查與格式化選單 / Check and format menu + "Ctrl+Shift+Y": "yapf_reformat", + "Ctrl+J": "reformat_json", + "Ctrl+Alt+P": "check_python_format", + # 文字選單 / Text menu + "Alt+W": "word_wrap", + # Python 環境選單 / Python environment menu + "Ctrl+Shift+V": "change_language", + "Ctrl+Shift+U": "pip_upgrade", + "Ctrl+Shift+P": "pip_install", + # 分頁選單 / Tab menu + "Ctrl+Alt+\\": "toggle_split_view", + "Ctrl+Alt+M": "toggle_minimap", +} diff --git a/je_editor/utils/snippets/__init__.py b/je_editor/utils/snippets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/snippets/snippet_expand.py b/je_editor/utils/snippets/snippet_expand.py new file mode 100644 index 00000000..f71e8491 --- /dev/null +++ b/je_editor/utils/snippets/snippet_expand.py @@ -0,0 +1,169 @@ +""" +展開程式碼片段,並算出每個定位點的位置 +Expand a code snippet and work out where each tab stop lands. + +片段用 ``$1``、``${2:預設值}`` 標定位點,``$0`` 是展開後游標最後停留的位置—— +與大多數編輯器的寫法一致,使用者既有的片段可以直接沿用。 +Snippets mark their stops with ``$1``, ``${2:default}`` and ``$0`` for where the +caret ends up, the same notation most editors use, so existing snippets can be +pasted in as they are. + +純邏輯:只做文字與位置計算,插入與游標移動交給編輯器。 +Pure logic: it computes text and offsets only, leaving insertion and caret +movement to the editor. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# 比對 $1 或 ${1:預設值} / Matches $1 or ${1:default} +_STOP_PATTERN = re.compile(r"\$(\d+)|\$\{(\d+):([^}]*)\}") +# ``$0`` 代表最後停留的位置,排序時要放到最後 +# ``$0`` is where the caret finishes, so it sorts after every other stop +_FINAL_STOP = 0 + + +@dataclass(frozen=True) +class SnippetStop: + """ + 展開後的一個定位點 + One tab stop in the expanded text. + + :param position: 相對於片段起點的字元位置 / offset from the snippet's start + :param length: 預設值的長度(沒有預設值時為 0)/ the default value's length + """ + + position: int + length: int + + +def expand_snippet(body: str) -> tuple[str, list[SnippetStop]]: + """ + 展開片段內容,回傳文字與定位點 + Expand a snippet body into text and its tab stops. + + 定位點依編號排序,``$0`` 排在最後;同一個編號出現多次時只取第一次。 + Stops come back in numeric order with ``$0`` last, and a number used more + than once keeps only its first appearance. + + :param body: 片段內容 / the snippet body + :return: ``(展開後的文字, 定位點清單)`` / ``(expanded text, stops)`` + """ + pieces: list[str] = [] + stops: dict[int, SnippetStop] = {} + length = 0 + last_end = 0 + for match in _STOP_PATTERN.finditer(body): + literal = body[last_end:match.start()] + pieces.append(literal) + length += len(literal) + number = int(match.group(1) or match.group(2)) + default = match.group(3) or "" + if number not in stops: + stops[number] = SnippetStop(position=length, length=len(default)) + pieces.append(default) + length += len(default) + last_end = match.end() + pieces.append(body[last_end:]) + text = "".join(pieces) + ordered = sorted(stops.items(), key=lambda item: (item[0] == _FINAL_STOP, item[0])) + return text, [stop for _number, stop in ordered] + + +def default_snippets() -> dict[str, str]: + """ + 內建的 Python 片段 + The Python snippets that ship with the editor. + + :return: 觸發字對應片段內容 / trigger word -> snippet body + """ + return { + "def": "def ${1:name}(${2:args}):\n $0", + "class": "class ${1:Name}:\n def __init__(self${2:, args}):\n $0", + "for": "for ${1:item} in ${2:iterable}:\n $0", + "while": "while ${1:condition}:\n $0", + "if": "if ${1:condition}:\n $0", + "try": "try:\n ${1:pass}\nexcept ${2:Exception} as error:\n $0", + "with": "with ${1:expression} as ${2:name}:\n $0", + "main": 'if __name__ == "__main__":\n $0', + } + + +# 各語言自己的片段;沒有列到的語言只會拿到通用片段 +# Per-language snippets; a language not listed here gets only the shared ones +LANGUAGE_SNIPPETS: dict[str, dict[str, str]] = { + ".ts": { + "fn": "function ${1:name}(${2:args}) {\n $0\n}", + "cls": "class ${1:Name} {\n constructor(${2:args}) {\n $0\n }\n}", + "log": "console.log($0);", + "iff": "if (${1:condition}) {\n $0\n}", + }, + ".js": { + "fn": "function ${1:name}(${2:args}) {\n $0\n}", + "log": "console.log($0);", + "iff": "if (${1:condition}) {\n $0\n}", + }, + ".go": { + "fn": "func ${1:name}(${2:args}) ${3:error} {\n $0\n}", + "iferr": "if err != nil {\n return $0\n}", + "forr": "for ${1:index}, ${2:value} := range ${3:items} {\n $0\n}", + }, + ".rs": { + "fn": "fn ${1:name}(${2:args}) -> ${3:()} {\n $0\n}", + "match": "match ${1:value} {\n ${2:pattern} => $0,\n}", + "test": "#[test]\nfn ${1:name}() {\n $0\n}", + }, +} + + +def language_snippets(suffix: str) -> dict[str, str]: + """ + 取得某個副檔名專屬的片段 + The snippets belonging to one file suffix. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 該語言的片段 / that language's snippets + """ + return dict(LANGUAGE_SNIPPETS.get(suffix.lower(), {})) + + +def merge_snippets(stored: object, suffix: str = "") -> dict[str, str]: + """ + 把使用者定義的片段併入內建片段 + Merge user-defined snippets over the built-in ones. + + 順序由通用到專屬:內建的 Python 片段、該語言的片段,最後才是使用者定義的, + 因此使用者永遠可以蓋掉任何一個。 + The order runs from general to specific — the built-in Python set, then the + language's own, then the user's — so a user definition always wins. + + 使用者檔案可以是 ``{觸發字: 內容}``,也可以用副檔名分組,例如 + ``{".ts": {...}}``;兩種都接受。 + A user file may be ``{trigger: body}`` or grouped by suffix such as + ``{".ts": {...}}``, and both forms are accepted. + + 設定檔可能被手動編輯,因此型別不符的項目會被略過而不是讓載入失敗。 + A hand-edited file may hold anything, so entries with the wrong type are + skipped rather than failing the load. + + :param stored: 讀進來的使用者片段,任何型別 / the loaded user snippets, any type + :param suffix: 目前檔案的副檔名 / the current file's suffix + :return: 可用的片段對照表 / the usable snippets + """ + snippets = default_snippets() + snippets.update(language_snippets(suffix)) + if not isinstance(stored, dict): + return snippets + for key, value in stored.items(): + if not isinstance(key, str) or not key: + continue + if isinstance(value, str): + snippets[key] = value + elif isinstance(value, dict) and suffix and key.lower() == suffix.lower(): + # 這一組是給這個副檔名的 / This group belongs to this suffix + snippets.update({ + trigger: body for trigger, body in value.items() + if isinstance(trigger, str) and trigger and isinstance(body, str) + }) + return snippets diff --git a/je_editor/utils/syntax/__init__.py b/je_editor/utils/syntax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/syntax/language_rules.py b/je_editor/utils/syntax/language_rules.py new file mode 100644 index 00000000..56c33f6d --- /dev/null +++ b/je_editor/utils/syntax/language_rules.py @@ -0,0 +1,136 @@ +""" +各語言的語法高亮規則 +The highlighting rules for each language. + +Python 已經有專屬的高亮器;這裡放的是其他語言的規則,讓打開 TypeScript、Rust、 +Go 這類檔案時不再是一片白。 +Python already has a highlighter of its own; these are the rules for everything +else, so opening a TypeScript, Rust or Go file is no longer a wall of plain text. + +純資料:不含 Qt,因此規則本身可以單獨測試。 +Pure data with no Qt, so the rules themselves can be tested on their own. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# 幾乎每個 C 家族語言都共用的關鍵字 / Keywords shared across the C-family languages +_COMMON = ( + "if", "else", "for", "while", "return", "break", "continue", "switch", + "case", "default", "do", "goto", "struct", "enum", "union", "const", + "static", "void", "int", "char", "long", "short", "float", "double", + "unsigned", "signed", "sizeof", "typedef", +) + + +@dataclass(frozen=True) +class LanguageRules: + """ + 一個語言的高亮規則 + The highlighting rules for one language. + + :param name: 語言名稱 / the language's name + :param keywords: 關鍵字 / its keywords + :param line_comment: 單行註解的開頭 / what starts a line comment + :param block_comment: 區塊註解的起訖,沒有就是 ``None`` + the delimiters of a block comment, or ``None`` when it has none + :param string_delimiters: 字串的引號 / the quote characters for strings + """ + + name: str + keywords: tuple[str, ...] + line_comment: str = "//" + block_comment: tuple[str, str] | None = ("/*", "*/") + string_delimiters: tuple[str, ...] = field(default=('"', "'")) + + +# 副檔名對應的規則 / The rules for each file suffix +LANGUAGE_RULES: dict[str, LanguageRules] = { + ".js": LanguageRules("JavaScript", ( + "function", "var", "let", "const", "class", "extends", "new", "this", + "return", "if", "else", "for", "while", "of", "in", "try", "catch", + "finally", "throw", "typeof", "instanceof", "async", "await", "import", + "export", "from", "default", "null", "undefined", "true", "false", + ), string_delimiters=('"', "'", "`")), + ".ts": LanguageRules("TypeScript", ( + "function", "var", "let", "const", "class", "interface", "type", "enum", + "extends", "implements", "new", "this", "return", "if", "else", "for", + "while", "of", "in", "try", "catch", "finally", "throw", "typeof", + "async", "await", "import", "export", "from", "default", "public", + "private", "protected", "readonly", "as", "null", "undefined", + "true", "false", "string", "number", "boolean", "any", "void", + ), string_delimiters=('"', "'", "`")), + ".rs": LanguageRules("Rust", ( + "fn", "let", "mut", "const", "struct", "enum", "impl", "trait", "pub", + "use", "mod", "match", "if", "else", "loop", "while", "for", "in", + "return", "self", "Self", "where", "async", "await", "move", "ref", + "dyn", "unsafe", "true", "false", "Some", "None", "Ok", "Err", + )), + ".go": LanguageRules("Go", ( + "func", "var", "const", "type", "struct", "interface", "map", "chan", + "package", "import", "return", "if", "else", "for", "range", "switch", + "case", "default", "go", "defer", "select", "nil", "true", "false", + "string", "int", "error", + ), string_delimiters=('"', "`")), + ".c": LanguageRules("C", _COMMON), + ".h": LanguageRules("C header", _COMMON), + ".cpp": LanguageRules("C++", _COMMON + ( + "class", "public", "private", "protected", "virtual", "template", + "namespace", "using", "new", "delete", "this", "nullptr", "auto", + "override", "constexpr", "true", "false", + )), + ".hpp": LanguageRules("C++ header", _COMMON + ( + "class", "public", "private", "protected", "virtual", "template", + "namespace", "using", "nullptr", "auto", "true", "false", + )), + ".java": LanguageRules("Java", ( + "class", "interface", "extends", "implements", "public", "private", + "protected", "static", "final", "void", "new", "this", "super", + "return", "if", "else", "for", "while", "switch", "case", "try", + "catch", "finally", "throw", "throws", "import", "package", "null", + "true", "false", "int", "long", "double", "boolean", "String", + )), + ".sh": LanguageRules("Shell", ( + "if", "then", "else", "elif", "fi", "for", "while", "do", "done", + "case", "esac", "function", "return", "export", "local", "echo", + ), line_comment="#", block_comment=None), + ".yaml": LanguageRules("YAML", ("true", "false", "null"), + line_comment="#", block_comment=None), + ".yml": LanguageRules("YAML", ("true", "false", "null"), + line_comment="#", block_comment=None), + ".toml": LanguageRules("TOML", ("true", "false"), + line_comment="#", block_comment=None), + ".json": LanguageRules("JSON", ("true", "false", "null"), + line_comment="//", block_comment=None, + string_delimiters=('"',)), + ".sql": LanguageRules("SQL", ( + "select", "from", "where", "insert", "into", "values", "update", "set", + "delete", "create", "table", "drop", "alter", "join", "left", "right", + "inner", "outer", "on", "group", "order", "by", "having", "limit", + "and", "or", "not", "null", "as", "distinct", + ), line_comment="--", block_comment=("/*", "*/")), +} + + +def rules_for(suffix: str) -> LanguageRules | None: + """ + 取得副檔名對應的高亮規則 + The highlighting rules for a file suffix. + + Python 不在其中,因為它有專屬的高亮器。 + Python is absent, since it has a highlighter of its own. + + :param suffix: 副檔名(含點)/ the file suffix, dot included + :return: 規則,沒有對應時為 ``None`` / the rules, or ``None`` + """ + return LANGUAGE_RULES.get(suffix.lower()) + + +def supported_suffixes() -> tuple[str, ...]: + """ + 取得所有支援的副檔名 + Every suffix that has rules. + + :return: 副檔名 / the suffixes + """ + return tuple(sorted(LANGUAGE_RULES)) diff --git a/je_editor/utils/test_runner/__init__.py b/je_editor/utils/test_runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/je_editor/utils/test_runner/pytest_output.py b/je_editor/utils/test_runner/pytest_output.py new file mode 100644 index 00000000..b3834ae4 --- /dev/null +++ b/je_editor/utils/test_runner/pytest_output.py @@ -0,0 +1,162 @@ +""" +解析 pytest 的輸出成測試結果 +Turn pytest's output into test results. + +面板用這些結果列出每個測試,並讓失敗的項目可以直接跳到出錯的那一行。 +The panel lists each test from these results and jumps to the failing line. + +純邏輯:不執行 pytest,因此可以用固定的輸出樣本測試。 +Pure logic: it does not run pytest, so it can be tested against fixed output. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# 比對 ``path::test_name PASSED [ 50%]`` 這類結果行 +# Matches a result line such as ``path::test_name PASSED [ 50%]`` +_RESULT_PATTERN = re.compile( + r"^(?P\S+::\S+)\s+(?PPASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)\b") +# 比對 ``--tb=line`` 的失敗位置,例如 ``D:\p\test_x.py:42: AssertionError`` +# Matches a ``--tb=line`` failure location, e.g. ``D:\p\test_x.py:42: AssertionError`` +_FAILURE_PATTERN = re.compile(r"^(?P.+?):(?P\d+): (?P.+)$") +# 比對結尾統計行,例如 ``3 failed, 5 passed in 0.42s`` +# Matches the summary line, e.g. ``3 failed, 5 passed in 0.42s`` +_SUMMARY_PATTERN = re.compile(r"^=+\s*(?P.*?(?:passed|failed|error|no tests ran).*?)\s*=+$") + +# 視為失敗的結果 / Outcomes that count as a failure +FAILING_OUTCOMES = frozenset({"FAILED", "ERROR"}) + + +@dataclass(frozen=True) +class PytestResult: + """ + 一個測試的結果 + One test's result. + + :param node_id: pytest 的節點名稱 / pytest's node id + :param outcome: 結果,例如 ``PASSED`` / the outcome, such as ``PASSED`` + """ + + node_id: str + outcome: str + + @property + def failed(self) -> bool: + """這個結果是否算失敗 / Whether this result counts as a failure.""" + return self.outcome in FAILING_OUTCOMES + + @property + def file_path(self) -> str: + """節點所屬的檔案 / The file the node belongs to.""" + return self.node_id.split("::", 1)[0] + + @property + def name(self) -> str: + """節點的測試名稱 / The test's name within its file.""" + return self.node_id.split("::", 1)[1] if "::" in self.node_id else self.node_id + + +@dataclass(frozen=True) +class FailureLocation: + """ + 一個失敗的位置 + Where a failure happened. + + :param path: 檔案路徑 / the file's path + :param line: 行號(1 起算)/ the 1-based line number + :param message: 錯誤訊息 / the error message + """ + + path: str + line: int + message: str + + +def parse_results(output: str) -> list[PytestResult]: + """ + 解析每個測試的結果 + Parse each test's result. + + :param output: pytest 的輸出(需要 ``-v``)/ pytest's output, as produced with ``-v`` + :return: 測試結果,依出現順序 / the results, in the order they were reported + """ + results: list[PytestResult] = [] + for line in output.splitlines(): + match = _RESULT_PATTERN.match(line.strip()) + if match is not None: + results.append( + PytestResult(node_id=match.group("node"), outcome=match.group("outcome"))) + return results + + +def parse_failures(output: str) -> list[FailureLocation]: + """ + 解析失敗的位置 + Parse the reported failure locations. + + :param output: pytest 的輸出(需要 ``--tb=line``)/ pytest's output, with ``--tb=line`` + :return: 失敗位置,依出現順序且不重複 / the locations, in order, without repeats + """ + failures: list[FailureLocation] = [] + seen: set[tuple[str, int]] = set() + for line in output.splitlines(): + stripped = line.strip() + match = _FAILURE_PATTERN.match(stripped) + if match is None: + continue + try: + line_number = int(match.group("line")) + except ValueError: + continue + path = match.group("path").strip() + # 結果行本身也含有冒號,排除掉才不會被誤認成失敗位置 + # A result line also contains a colon; skipping those avoids reading one + # as a failure location + if "::" in path or not path: + continue + key = (path, line_number) + if key in seen: + continue + seen.add(key) + failures.append( + FailureLocation(path=path, line=line_number, message=match.group("message"))) + return failures + + +def parse_summary(output: str) -> str: + """ + 取得結尾的統計文字 + The summary line pytest finished with. + + :param output: pytest 的輸出 / pytest's output + :return: 統計文字,找不到時為空字串 / the summary, or an empty string + """ + for line in reversed(output.splitlines()): + match = _SUMMARY_PATTERN.match(line.strip()) + if match is not None: + return match.group("summary").strip() + return "" + + +def failure_for_result( + result: PytestResult, failures: list[FailureLocation]) -> FailureLocation | None: + """ + 找出某個失敗測試對應的位置 + Find the failure location belonging to a failed test. + + 以檔案路徑比對,因為 ``--tb=line`` 只報位置不報節點名稱。 + Matching is by file path, since ``--tb=line`` reports a location without the + node it belongs to. + + :param result: 測試結果 / the test result + :param failures: 所有失敗位置 / every reported failure location + :return: 對應的位置,找不到時為 ``None`` / the location, or ``None`` + """ + if not result.failed: + return None + file_name = result.file_path.replace("\\", "/").split("/")[-1] + for failure in failures: + if failure.path.replace("\\", "/").split("/")[-1] == file_name: + return failure + return None diff --git a/test/qt_ui/unit_test/extend_test.py b/test/qt_ui/unit_test/extend_test.py index 2f240a09..0ed28887 100644 --- a/test/qt_ui/unit_test/extend_test.py +++ b/test/qt_ui/unit_test/extend_test.py @@ -15,7 +15,11 @@ def _log(msg): sys.__stderr__.flush() -from PySide6.QtWidgets import QWidget, QGridLayout, QLineEdit, QPushButton, QLabel +# Imported after the platform variable above is set: Qt reads it while loading, +# so importing any Qt module first would lock in the wrong platform plugin. +from PySide6.QtWidgets import ( # noqa: E402 + QWidget, QGridLayout, QLineEdit, QPushButton, QLabel +) class TestUI(QWidget): diff --git a/test/test_blame.py b/test/test_blame.py new file mode 100644 index 00000000..011e40e1 --- /dev/null +++ b/test/test_blame.py @@ -0,0 +1,146 @@ +"""Tests for reading git blame and showing it inline.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.git_client.file_blame import BlameLine, blame_lines + +git = pytest.importorskip("git") + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Blame Tester") + config.set_value("user", "email", "blame@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("first\nsecond\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial commit") + yield repository, tmp_path + repository.close() + + +class TestBlameLines: + def test_every_committed_line_is_annotated(self, repo): + _repository, root = repo + annotations = blame_lines(root / "tracked.py") + assert set(annotations) == {0, 1} + + def test_annotation_names_the_author_and_summary(self, repo): + _repository, root = repo + blame = blame_lines(root / "tracked.py")[0] + assert blame.author == "Blame Tester" + assert blame.summary == "initial commit" + assert len(blame.commit) == 8 + + def test_annotation_line_joins_the_parts(self, repo): + _repository, root = repo + annotation = blame_lines(root / "tracked.py")[0].annotation + assert "Blame Tester" in annotation and "initial commit" in annotation + + def test_untracked_file_has_no_blame(self, repo): + _repository, root = repo + loose = root / "untracked.py" + loose.write_text("x\n", encoding="utf-8") + assert blame_lines(loose) == {} + + def test_file_outside_a_repository_has_no_blame(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert blame_lines(loose) == {} + + def test_long_summary_is_shortened(self, repo): + _repository, root = repo + wordy = root / "wordy.py" + wordy.write_text("x\n", encoding="utf-8") + _repository.index.add(["wordy.py"]) + _repository.index.commit("w" * 200) + assert blame_lines(wordy)[0].summary.endswith("…") + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +SAMPLE_BLAME = { + 0: BlameLine(commit="abc12345", author="Ann", summary="first change"), + 1: BlameLine(commit="def67890", author="Bob", summary="second change"), +} + + +class TestBlameManager: + def test_starts_hidden(self, editor): + assert editor.blame_manager.enabled is False + assert editor.blame_manager.annotation(0) == "" + + def test_annotations_are_hidden_while_disabled(self, editor): + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert editor.blame_manager.annotation(0) == "" + + def test_toggle_without_a_file_stays_off(self, editor): + editor.current_file = None + assert editor.toggle_blame() is False + + def test_annotation_is_shown_once_enabled(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert "Ann" in editor.blame_manager.annotation(0) + assert "second change" in editor.blame_manager.annotation(1) + + def test_line_without_blame_shows_nothing(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + assert editor.blame_manager.annotation(9) == "" + + def test_clear_turns_it_off(self, editor): + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + editor.blame_manager.clear() + assert editor.blame_manager.enabled is False + assert editor.blame_manager.annotation(0) == "" + + def test_toggling_off_drops_the_annotations(self, editor, repo): + _repository, root = repo + editor.current_file = str(root / "tracked.py") + assert editor.toggle_blame() is True + editor.blame_manager.stop() + assert editor.toggle_blame() is False + assert editor.blame_manager.annotation(0) == "" + + def test_painting_with_annotations_does_not_raise(self, editor): + editor.setPlainText("first\nsecond\n") + editor.blame_manager._enabled = True + editor.blame_manager.set_annotations(SAMPLE_BLAME) + editor.show() + editor.viewport().update() + QApplication.processEvents() + editor.hide() + + def test_stop_is_safe_without_a_loader(self, editor): + editor.blame_manager.stop() + editor.blame_manager.stop() diff --git a/test/test_breakpoints.py b/test/test_breakpoints.py new file mode 100644 index 00000000..20fe3049 --- /dev/null +++ b/test/test_breakpoints.py @@ -0,0 +1,184 @@ +"""Tests for breakpoints and the stepping commands sent to pdb.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.debugger.pdb_commands import ( + CONTINUE, + STEP_INTO, + STEP_OUT, + STEP_OVER, + breakpoint_command, + breakpoint_commands, + step_command, +) + + +class TestPdbCommands: + def test_a_breakpoint_names_the_file_and_line(self): + assert breakpoint_command("/project/app.py", 12) == "break /project/app.py:12" + + def test_windows_paths_use_forward_slashes(self): + assert breakpoint_command("D:\\project\\app.py", 3) == "break D:/project/app.py:3" + + def test_line_numbers_below_one_are_clamped(self): + assert breakpoint_command("app.py", 0).endswith(":1") + + def test_several_breakpoints_are_sorted_and_unique(self): + commands = breakpoint_commands("app.py", [7, 3, 7]) + assert commands == ["break app.py:3", "break app.py:7"] + + def test_invalid_lines_are_dropped(self): + assert breakpoint_commands("app.py", [0, -2]) == [] + + @pytest.mark.parametrize("action,expected", [ + ("into", STEP_INTO), ("over", STEP_OVER), + ("out", STEP_OUT), ("continue", CONTINUE), + ]) + def test_stepping_actions(self, action, expected): + assert step_command(action) == expected + + def test_an_unknown_action_has_no_command(self): + assert step_command("sideways") is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + parent = MagicMock() + parent.current_file = None + parent.exec_python_debugger = 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 _place_cursor(editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + +class TestBreakpointManager: + def test_none_at_first(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + assert editor.breakpoint_manager.lines() == [] + + def test_toggling_sets_then_clears(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + assert editor.toggle_breakpoint() is True + assert editor.breakpoint_manager.lines() == [1] + assert editor.toggle_breakpoint() is False + assert editor.breakpoint_manager.lines() == [] + + def test_breakpoints_follow_inserted_lines(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 2) + editor.toggle_breakpoint() + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.insertText("inserted above\n") + # The breakpoint stays on "three", which is now one line further down. + assert editor.breakpoint_manager.lines() == [3] + + def test_pdb_lines_are_one_based(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + assert editor.breakpoint_manager.pdb_lines() == [1] + + def test_clearing_removes_everything(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + assert editor.breakpoint_manager.clear() is True + assert editor.breakpoint_manager.lines() == [] + + def test_a_line_past_the_end_is_refused(self, editor): + editor.setPlainText("only\n") + assert editor.breakpoint_manager.toggle(99) is False + + def test_painting_with_a_breakpoint_does_not_raise(self, editor): + editor.setPlainText("one\ntwo\n") + _place_cursor(editor, 0) + editor.toggle_breakpoint() + editor.show() + QApplication.processEvents() + editor.hide() + + +class TestSendingToTheDebugger: + def test_nothing_is_sent_without_a_debugger(self, editor): + assert editor.send_debugger_command("over") is False + + def test_an_unknown_action_is_refused(self, editor): + assert editor.send_debugger_command("sideways") is False + + def test_a_stepping_command_reaches_stdin(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + assert editor.send_debugger_command("into") is True + stdin.write.assert_called_once_with(b"step\n") + + def test_breakpoints_are_sent_for_the_current_file(self, editor): + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 1) + editor.toggle_breakpoint() + assert editor.send_breakpoints_to_debugger() == 1 + stdin.write.assert_called_once_with(b"break app.py:2\n") + + def test_no_breakpoints_are_sent_without_a_file(self, editor): + editor.current_file = None + assert editor.send_breakpoints_to_debugger() == 0 + + def test_starting_the_debugger_sends_them(self, editor): + # Without this the gutter marks never reach pdb and never stop anything. + from je_editor.pyside_ui.main_ui.menu.run_menu.under_run_menu.build_debug_menu import ( + send_breakpoints + ) + stdin = MagicMock() + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + editor.current_file = "app.py" + editor.setPlainText("one\ntwo\nthree\n") + _place_cursor(editor, 2) + editor.toggle_breakpoint() + widget = MagicMock() + widget.code_edit = editor + assert send_breakpoints(widget) == 1 + stdin.write.assert_called_once_with(b"break app.py:3\n") + + def test_a_tab_without_an_editor_sends_nothing(self, editor): + from je_editor.pyside_ui.main_ui.menu.run_menu.under_run_menu.build_debug_menu import ( + send_breakpoints + ) + widget = MagicMock() + widget.code_edit = None + assert send_breakpoints(widget) == 0 + + def test_a_broken_pipe_is_reported_rather_than_raised(self, editor): + stdin = MagicMock() + stdin.write.side_effect = OSError("broken pipe") + editor.main_window.exec_python_debugger = MagicMock() + editor.main_window.exec_python_debugger.process.stdin = stdin + assert editor.send_debugger_command("continue") is False diff --git a/test/test_context_menu.py b/test/test_context_menu.py new file mode 100644 index 00000000..04133b52 --- /dev/null +++ b/test/test_context_menu.py @@ -0,0 +1,118 @@ +"""Tests for the editor's right-click menu and go-to-definition.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.lsp_protocol import definition_location, path_from_uri + + +class TestDefinitionParsing: + def test_single_location(self): + location = definition_location({ + "uri": "file:///project/app.ts", + "range": {"start": {"line": 9, "character": 4}}, + }) + assert location == {"path": "/project/app.ts", "line": 10, "column": 5} + + def test_list_of_locations_takes_the_first(self): + location = definition_location([ + {"uri": "file:///a.ts", "range": {"start": {"line": 0, "character": 0}}}, + {"uri": "file:///b.ts", "range": {"start": {"line": 5, "character": 0}}}, + ]) + assert location["path"] == "/a.ts" + + def test_location_link_form(self): + location = definition_location([{ + "targetUri": "file:///project/app.rs", + "targetSelectionRange": {"start": {"line": 2, "character": 1}}, + }]) + assert location["path"] == "/project/app.rs" and location["line"] == 3 + + def test_windows_uri_drops_the_leading_slash(self): + assert path_from_uri("file:///D:/project/app.ts") == "D:/project/app.ts" + + def test_percent_encoding_is_decoded(self): + assert path_from_uri("file:///project/my%20app.ts") == "/project/my app.ts" + + def test_unusable_results(self): + assert definition_location(None) is None + assert definition_location([]) is None + assert definition_location({"uri": "file:///a.ts"}) is None + assert definition_location({"range": {"start": {"line": 1}}}) is None + + def test_non_file_uri(self): + assert path_from_uri("http://example.com/a.ts") == "" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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 _labels(menu) -> list[str]: + return [action.text() for action in menu.actions()] + + +class TestContextMenu: + def test_menu_keeps_the_standard_editing_actions(self, editor): + editor.setPlainText("some text") + menu = editor.build_context_menu() + assert any("Paste" in label or "貼上" in label for label in _labels(menu)) + menu.deleteLater() + + def test_menu_adds_the_editor_actions(self, editor): + menu = editor.build_context_menu() + labels = " ".join(_labels(menu)) + assert "Comment" in labels + assert "Bookmark" in labels + assert "Definition" in labels + menu.deleteLater() + + def test_revert_is_disabled_without_a_baseline(self, editor): + menu = editor.build_context_menu() + revert = [a for a in menu.actions() if "Revert" in a.text()][0] + assert revert.isEnabled() is False + menu.deleteLater() + + def test_revert_is_enabled_with_a_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + menu = editor.build_context_menu() + revert = [a for a in menu.actions() if "Revert" in a.text()][0] + assert revert.isEnabled() is True + menu.deleteLater() + + def test_definition_is_disabled_without_a_server(self, editor): + menu = editor.build_context_menu() + definition = [a for a in menu.actions() if "Definition" in a.text()][0] + assert definition.isEnabled() is False + menu.deleteLater() + + def test_toggle_comment_from_the_menu_edits_the_line(self, editor): + editor.setPlainText("value = 1\n") + menu = editor.build_context_menu() + comment = [a for a in menu.actions() if "Comment" in a.text()][0] + comment.trigger() + assert editor.toPlainText().lstrip().startswith("#") + menu.deleteLater() + + def test_go_to_definition_without_a_server_is_refused(self, editor): + assert editor.go_to_definition() is False diff --git a/test/test_diff_marker_manager.py b/test/test_diff_marker_manager.py new file mode 100644 index 00000000..e4d5a526 --- /dev/null +++ b/test/test_diff_marker_manager.py @@ -0,0 +1,173 @@ +"""Tests for the git change-marker manager and its editor wiring.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE +) + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestDiffMarkerManager: + def test_no_baseline_means_no_markers(self, editor): + editor.setPlainText("a\nb\n") + assert editor.diff_marker_manager.statuses() == {} + assert not editor.diff_marker_manager.has_baseline + + def test_baseline_marks_the_changed_line(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + assert editor.diff_marker_manager.status(0) is None + + def test_added_line_after_the_baseline_is_marked(self, editor): + editor.setPlainText("a\nb\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.diff_marker_manager.status(2) == LINE_ADDED + + def test_deletion_marks_the_following_line(self, editor): + editor.setPlainText("a\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.diff_marker_manager.status(1) == LINE_REMOVED_ABOVE + + def test_refresh_tracks_later_edits(self, editor): + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.diff_marker_manager.statuses() == {} + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.refresh() + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + + def test_refresh_reports_whether_anything_changed(self, editor): + editor.setPlainText("a\n") + editor.diff_marker_manager.set_baseline("a\n") + assert editor.diff_marker_manager.refresh() is False + editor.setPlainText("b\n") + assert editor.diff_marker_manager.refresh() is True + assert editor.diff_marker_manager.refresh() is False + + def test_clear_forgets_the_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.clear() + assert not editor.diff_marker_manager.has_baseline + assert editor.diff_marker_manager.statuses() == {} + + def test_setting_a_none_baseline_drops_the_markers(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.set_baseline(None) + assert editor.diff_marker_manager.statuses() == {} + + def test_statuses_returns_a_copy(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.diff_marker_manager.statuses()[1] = "tampered" + assert editor.diff_marker_manager.status(1) == LINE_MODIFIED + + +class TestChangeNavigationWiring: + def _place_cursor(self, editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + def test_next_change_moves_the_caret(self, editor): + editor.setPlainText("a\nB\nc\nD\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + self._place_cursor(editor, 0) + assert editor.next_change() is True + assert editor.textCursor().blockNumber() == 1 + + def test_previous_change_moves_the_caret(self, editor): + editor.setPlainText("a\nB\nc\nD\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + self._place_cursor(editor, 3) + assert editor.previous_change() is True + assert editor.textCursor().blockNumber() == 1 + + def test_navigation_without_changes_does_nothing(self, editor): + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert editor.next_change() is False + assert editor.previous_change() is False + + +class TestGutterWiring: + def test_gutter_is_wider_than_the_bookmark_and_fold_columns(self, editor): + from je_editor.pyside_ui.code.plaintext_code_edit import code_edit_plaintext + minimum = (code_edit_plaintext._BOOKMARK_MARKER_WIDTH + + code_edit_plaintext._FOLD_MARKER_WIDTH + + code_edit_plaintext._DIFF_MARKER_WIDTH) + assert editor.line_number_width() > minimum + + def test_every_status_has_a_colour(self, editor): + from je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext import ( + _DIFF_MARKER_COLOR_KEYS + ) + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for status in (LINE_ADDED, LINE_MODIFIED, LINE_REMOVED_ABOVE): + assert actually_color_dict.get(_DIFF_MARKER_COLOR_KEYS[status]) is not None + + def test_painting_the_gutter_with_markers_does_not_raise(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + editor.show() + editor.line_number.update() + QApplication.processEvents() + editor.hide() + + def test_edits_schedule_a_refresh_only_with_a_baseline(self, editor): + editor.setPlainText("a\n") + assert not editor._diff_timer.isActive() + editor.diff_marker_manager.set_baseline("a\n") + editor.setPlainText("b\n") + assert editor._diff_timer.isActive() + + +class TestBaselineLoading: + def test_no_file_clears_the_baseline(self, editor): + editor.setPlainText("a\nB\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + editor.current_file = None + editor.load_git_baseline() + assert not editor.diff_marker_manager.has_baseline + + def test_loading_a_file_outside_a_repository_leaves_no_baseline(self, editor, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("print('x')\n", encoding="utf-8") + editor.current_file = str(loose) + editor.load_git_baseline() + editor.diff_marker_manager.stop() + assert not editor.diff_marker_manager.has_baseline + + def test_stop_is_safe_without_a_loader(self, editor): + editor.diff_marker_manager.stop() + editor.diff_marker_manager.stop() diff --git a/test/test_editor_conveniences.py b/test/test_editor_conveniences.py new file mode 100644 index 00000000..8b95d741 --- /dev/null +++ b/test/test_editor_conveniences.py @@ -0,0 +1,238 @@ +"""Tests for macros, surrounding a selection, and saving every tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.macro.keystroke_macro import MAX_KEYSTROKES, KeystrokeMacro +from je_editor.utils.selection.surround import closing_for, surround + + +class TestSurroundText: + @pytest.mark.parametrize("opening,expected", [ + ("(", "(text)"), ("[", "[text]"), ("{", "{text}"), + ('"', '"text"'), ("'", "'text'"), ("`", "`text`"), + ]) + def test_pairs(self, opening, expected): + assert surround("text", opening) == expected + + def test_an_unpaired_character_is_refused(self): + assert surround("text", "x") is None + assert closing_for("x") is None + + def test_empty_selection_still_wraps(self): + assert surround("", "(") == "()" + + +class TestKeystrokeMacro: + def test_starts_empty_and_idle(self): + macro = KeystrokeMacro() + assert macro.is_empty and macro.recording is False + + def test_recording_collects_keystrokes(self): + macro = KeystrokeMacro() + macro.start() + macro.record(65, 0, "a") + assert len(macro.keystrokes) == 1 + + def test_nothing_is_recorded_while_idle(self): + macro = KeystrokeMacro() + assert macro.record(65, 0, "a") is False + assert macro.is_empty + + def test_toggling_starts_then_stops(self): + macro = KeystrokeMacro() + assert macro.toggle() is True + assert macro.toggle() is False + + def test_a_new_recording_discards_the_previous(self): + macro = KeystrokeMacro() + macro.start() + macro.record(65, 0, "a") + macro.start() + assert macro.is_empty + + def test_recording_is_capped(self): + macro = KeystrokeMacro() + macro.start() + for _ in range(MAX_KEYSTROKES + 10): + macro.record(65, 0, "a") + assert len(macro.keystrokes) == MAX_KEYSTROKES + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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 _select(editor, start: int, end: int) -> None: + cursor = editor.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + +class TestSurroundInEditor: + def test_typing_a_bracket_over_a_selection_wraps_it(self, editor): + editor.setPlainText("wrap me\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "(") + assert editor.toPlainText() == "(wrap) me\n" + + def test_the_wrapped_text_stays_selected(self, editor): + editor.setPlainText("wrap me\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "[") + assert editor.textCursor().selectedText() == "wrap" + + def test_wrapping_twice_nests(self, editor): + editor.setPlainText("word\n") + _select(editor, 0, 4) + QTest.keyClicks(editor, "(") + QTest.keyClicks(editor, '"') + assert editor.toPlainText() == '("word")\n' + + def test_typing_without_a_selection_inserts_normally(self, editor): + editor.setPlainText("") + QTest.keyClicks(editor, "(") + assert "(" in editor.toPlainText() + + def test_surround_without_a_selection_is_refused(self, editor): + editor.setPlainText("text") + assert editor.surround_selection("(") is False + + +class TestMacroInEditor: + def test_recording_then_playing_repeats_the_typing(self, editor): + editor.setPlainText("") + editor.toggle_macro_recording() + QTest.keyClicks(editor, "abc") + editor.toggle_macro_recording() + editor.play_macro() + assert editor.toPlainText() == "abcabc" + + def test_playing_while_recording_is_refused(self, editor): + editor.toggle_macro_recording() + assert editor.play_macro() is False + editor.toggle_macro_recording() + + def test_playing_an_empty_macro_is_refused(self, editor): + editor.macro.stop() + editor.macro.keystrokes = [] + assert editor.play_macro() is False + + def test_playback_is_one_undo_step(self, editor): + editor.setPlainText("") + editor.toggle_macro_recording() + QTest.keyClicks(editor, "xy") + editor.toggle_macro_recording() + before = editor.toPlainText() + editor.play_macro() + editor.undo() + assert editor.toPlainText() == before + + +class TestRecentLocations: + def test_visited_lines_are_listed_most_recent_first(self, editor): + editor.setPlainText("alpha\nbeta\ngamma\ndelta\n") + for line in (0, 2, 3): + editor.location_history.visit(line) + labels = editor.recent_location_labels() + assert labels[0].startswith("4:") + assert labels[-1].startswith("1:") + + def test_a_label_shows_the_line_text(self, editor): + editor.setPlainText("first line\nsecond line\n") + editor.location_history.visit(1) + assert "second line" in editor.recent_location_labels()[0] + + def test_a_blank_line_still_gets_a_label(self, editor): + editor.setPlainText("\n\n") + editor.location_history.visit(1) + assert editor.recent_location_labels()[0] == "2" + + def test_no_history_means_nothing_to_show(self, editor): + editor.location_history.__init__() + assert editor.recent_location_labels() == [] + assert editor.show_recent_locations() is False + + +class _FakeTabs: + """Stands in for QTabWidget, which cannot hold a mocked widget.""" + + def __init__(self, widgets): + self._widgets = list(widgets) + + def count(self): + return len(self._widgets) + + def widget(self, index): + return self._widgets[index] + + +def _editor_tab(app, path, text="new content\n"): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.code_edit.setPlainText(text) + tab.current_file = str(path) if path else None + tab.file_encoding = "utf-8" + tab.line_ending = "\n" + return tab + + +class TestSaveAll: + def test_every_named_tab_is_written(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + paths = [] + tabs = [] + for name in ("one.py", "two.py"): + path = tmp_path / name + path.write_text("old\n", encoding="utf-8") + paths.append(path) + tabs.append(_editor_tab(app, path)) + window = MagicMock() + window.tab_widget = _FakeTabs(tabs) + assert save_all_tabs(window) == 2 + assert all(path.read_text(encoding="utf-8") == "new content\n" for path in paths) + + def test_a_tab_without_a_file_is_skipped(self, app): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + window = MagicMock() + window.tab_widget = _FakeTabs([_editor_tab(app, None)]) + assert save_all_tabs(window) == 0 + + def test_the_tab_encoding_is_used(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + path = tmp_path / "big5.txt" + tab = _editor_tab(app, path, "中文\n") + tab.file_encoding = "big5" + window = MagicMock() + window.tab_widget = _FakeTabs([tab]) + save_all_tabs(window) + assert path.read_bytes() == "中文\n".encode("big5") + + def test_a_window_without_tabs(self): + from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import save_all_tabs + window = MagicMock() + window.tab_widget = None + assert save_all_tabs(window) == 0 diff --git a/test/test_editor_widget.py b/test/test_editor_widget.py index 7db45e08..31cb51fd 100644 --- a/test/test_editor_widget.py +++ b/test/test_editor_widget.py @@ -38,6 +38,28 @@ def editor_widget(app): mock_main.tab_widget.deleteLater() +class TestClosingTheTab: + """ + Closing the tab has to stop the editor's scheduled work too. A debounce + timer firing afterwards starts a thread nothing is left to wait for, and Qt + aborts the process when a running QThread is destroyed. + """ + + def test_the_editors_lint_timer_is_stopped(self, editor_widget): + editor_widget.code_edit._lint_timer.start() + editor_widget.close() + assert editor_widget.code_edit._lint_timer.isActive() is False + + def test_the_editors_diff_timer_is_stopped(self, editor_widget): + editor_widget.code_edit._diff_timer.start() + editor_widget.close() + assert editor_widget.code_edit._diff_timer.isActive() is False + + def test_no_lint_worker_is_left_running(self, editor_widget): + editor_widget.close() + assert editor_widget.code_edit.lint_manager._worker is None + + class TestEditorWidgetModifiedTracking: def test_initial_not_modified(self, editor_widget): assert editor_widget._is_modified is False diff --git a/test/test_encoding_actions.py b/test/test_encoding_actions.py new file mode 100644 index 00000000..0fb93ab7 --- /dev/null +++ b/test/test_encoding_actions.py @@ -0,0 +1,123 @@ +"""Tests for applying an encoding or line-ending choice to the current tab.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import ( + apply_encoding, + apply_line_ending_choice, + current_editor_tab, +) +from je_editor.utils.encodings.text_codec import LINE_ENDING_CRLF, LINE_ENDING_LF + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +@pytest.fixture() +def editor_tab(app): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.current_file = None + tab.file_encoding = "utf-8" + tab.line_ending = LINE_ENDING_LF + tab.code_save_thread = None + tab._is_modified = False + yield tab + tab.code_edit.deleteLater() + + +def _window(tab): + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + return window + + +class TestCurrentEditorTab: + def test_finds_an_editor_tab(self, editor_tab): + assert current_editor_tab(_window(editor_tab)) is editor_tab + + def test_other_tab_types_are_ignored(self, app): + assert current_editor_tab(_window(object())) is None + + def test_window_without_tabs(self, app): + window = MagicMock() + window.tab_widget = None + assert current_editor_tab(window) is None + + +class TestApplyEncoding: + def test_sets_the_tab_encoding(self, editor_tab): + assert apply_encoding(_window(editor_tab), "big5") is True + assert editor_tab.file_encoding == "big5" + + def test_without_an_editor_tab(self, app): + assert apply_encoding(_window(object()), "big5") is False + + def test_rereads_an_unmodified_file(self, editor_tab, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文\n".encode("big5")) + editor_tab.current_file = str(path) + editor_tab._is_modified = False + apply_encoding(_window(editor_tab), "big5") + assert editor_tab.code_edit.toPlainText() == "中文\n" + assert editor_tab.file_encoding == "big5" + + def test_unsaved_changes_are_never_discarded(self, editor_tab, tmp_path): + path = tmp_path / "sample.txt" + path.write_text("on disk\n", encoding="utf-8") + editor_tab.current_file = str(path) + editor_tab.code_edit.setPlainText("unsaved work") + editor_tab._is_modified = True + apply_encoding(_window(editor_tab), "utf-8") + assert editor_tab.code_edit.toPlainText() == "unsaved work" + assert editor_tab.file_encoding == "utf-8" + + def test_an_encoding_that_cannot_decode_leaves_the_text_alone(self, editor_tab, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文\n".encode("big5")) + editor_tab.current_file = str(path) + editor_tab.code_edit.setPlainText("previous text") + apply_encoding(_window(editor_tab), "ascii") + assert editor_tab.code_edit.toPlainText() == "previous text" + + def test_auto_save_follows_the_encoding(self, editor_tab): + thread = MagicMock() + editor_tab.code_save_thread = thread + apply_encoding(_window(editor_tab), "big5") + assert thread.encoding == "big5" + + +class TestApplyLineEnding: + def test_sets_the_tab_line_ending(self, editor_tab): + assert apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) is True + assert editor_tab.line_ending == LINE_ENDING_CRLF + + def test_without_an_editor_tab(self, app): + assert apply_line_ending_choice(_window(object()), LINE_ENDING_CRLF) is False + + def test_auto_save_follows_the_line_ending(self, editor_tab): + thread = MagicMock() + editor_tab.code_save_thread = thread + apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) + assert thread.line_ending == LINE_ENDING_CRLF + + def test_the_text_itself_is_untouched(self, editor_tab): + editor_tab.code_edit.setPlainText("a\nb\n") + apply_line_ending_choice(_window(editor_tab), LINE_ENDING_CRLF) + assert editor_tab.code_edit.toPlainText() == "a\nb\n" diff --git a/test/test_file_baseline.py b/test/test_file_baseline.py new file mode 100644 index 00000000..bba8e4e4 --- /dev/null +++ b/test/test_file_baseline.py @@ -0,0 +1,82 @@ +"""Tests for reading a file's committed content as the diff baseline.""" +from __future__ import annotations + +import pytest + +from je_editor.git_client.file_baseline import baseline_text, open_repository + +git = pytest.importorskip("git") + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Test") + config.set_value("user", "email", "test@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("first\nsecond\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial") + yield repository, tmp_path + repository.close() + + +class TestOpenRepository: + def test_finds_the_repository_of_a_tracked_file(self, repo): + _repository, root = repo + assert open_repository(root / "tracked.py") is not None + + def test_finds_the_repository_from_a_subdirectory(self, repo): + _repository, root = repo + nested = root / "pkg" + nested.mkdir() + assert open_repository(nested / "module.py") is not None + + def test_path_outside_any_repository(self, tmp_path): + assert open_repository(tmp_path / "loose.py") is None + + +class TestBaselineText: + def test_committed_content_is_returned(self, repo): + _repository, root = repo + assert baseline_text(root / "tracked.py") == "first\nsecond\n" + + def test_working_tree_edits_do_not_change_the_baseline(self, repo): + _repository, root = repo + tracked = root / "tracked.py" + tracked.write_text("first\nCHANGED\n", encoding="utf-8") + assert baseline_text(tracked) == "first\nsecond\n" + + def test_uncommitted_file_has_no_baseline(self, repo): + _repository, root = repo + new_file = root / "untracked.py" + new_file.write_text("brand new\n", encoding="utf-8") + assert baseline_text(new_file) is None + + def test_file_outside_a_repository_has_no_baseline(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert baseline_text(loose) is None + + def test_missing_file_has_no_baseline(self, tmp_path): + assert baseline_text(tmp_path / "gone.py") is None + + def test_line_endings_are_normalised(self, repo): + # A repository may store CRLF (or be committed through a client that + # does); the editor's document always uses \n, so the baseline must too. + _repository, root = repo + crlf = root / "crlf.py" + crlf.write_bytes(b"first\r\nsecond\r\n") + _repository.index.add(["crlf.py"]) + _repository.index.commit("add crlf file") + assert baseline_text(crlf) == "first\nsecond\n" + + def test_binary_content_has_no_baseline(self, repo): + _repository, root = repo + blob = root / "logo.bin" + blob.write_bytes(b"\xff\xfe\x00\x01binary") + _repository.index.add(["logo.bin"]) + _repository.index.commit("add binary") + assert baseline_text(blob) is None diff --git a/test/test_format_on_save.py b/test/test_format_on_save.py new file mode 100644 index 00000000..d01ad359 --- /dev/null +++ b/test/test_format_on_save.py @@ -0,0 +1,103 @@ +"""Tests for yapf formatting and applying it on save.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.main_ui.menu.file_menu.encoding_actions import format_before_save +from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict +from je_editor.utils.format_code.yapf_format import format_python_source + +BADLY_SPACED = "x = 1\ny=2\n" + + +class TestFormatPythonSource: + def test_reformats_spacing(self): + formatted = format_python_source(BADLY_SPACED) + assert "x = 1" in formatted + + def test_already_formatted_source_is_unchanged(self): + source = "x = 1\ny = 2\n" + assert format_python_source(source) == source + + def test_syntax_error_is_returned_untouched(self): + # Half-written code must not break a save. + broken = "def broken(:\n pass\n" + assert format_python_source(broken) == broken + + def test_empty_source(self): + assert format_python_source("") == "" + assert format_python_source(" \n") == " \n" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def widget(app, tmp_path): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from PySide6.QtWidgets import QPlainTextEdit + tab = MagicMock(spec=EditorWidget) + tab.code_edit = QPlainTextEdit() + tab.code_edit.jump_to_line = MagicMock() + tab.current_file = str(tmp_path / "sample.py") + yield tab + tab.code_edit.deleteLater() + + +@pytest.fixture(autouse=True) +def restore_setting(): + original = user_setting_dict.get("format_on_save", False) + yield + user_setting_dict["format_on_save"] = original + + +class TestFormatBeforeSave: + def test_does_nothing_while_the_setting_is_off(self, widget): + user_setting_dict["format_on_save"] = False + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + assert widget.code_edit.toPlainText() == BADLY_SPACED + + def test_formats_when_the_setting_is_on(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is True + assert "x = 1" in widget.code_edit.toPlainText() + + def test_only_python_files_are_formatted(self, widget, tmp_path): + user_setting_dict["format_on_save"] = True + widget.current_file = str(tmp_path / "notes.txt") + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + + def test_a_file_that_was_never_saved_is_skipped(self, widget): + user_setting_dict["format_on_save"] = True + widget.current_file = None + widget.code_edit.setPlainText(BADLY_SPACED) + assert format_before_save(widget) is False + + def test_already_formatted_text_is_left_alone(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText("x = 1\n") + assert format_before_save(widget) is False + + def test_broken_code_still_saves(self, widget): + user_setting_dict["format_on_save"] = True + broken = "def broken(:\n pass\n" + widget.code_edit.setPlainText(broken) + assert format_before_save(widget) is False + assert widget.code_edit.toPlainText() == broken + + def test_the_caret_line_is_restored(self, widget): + user_setting_dict["format_on_save"] = True + widget.code_edit.setPlainText(BADLY_SPACED) + cursor = widget.code_edit.textCursor() + cursor.setPosition(widget.code_edit.document().findBlockByNumber(1).position()) + widget.code_edit.setTextCursor(cursor) + format_before_save(widget) + widget.code_edit.jump_to_line.assert_called_once_with(2) diff --git a/test/test_generic_syntax.py b/test/test_generic_syntax.py new file mode 100644 index 00000000..3a19ccad --- /dev/null +++ b/test/test_generic_syntax.py @@ -0,0 +1,149 @@ +"""Tests for highlighting languages other than Python.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QApplication + +from je_editor.utils.syntax.language_rules import LANGUAGE_RULES, rules_for, supported_suffixes + + +class TestLanguageRules: + @pytest.mark.parametrize("suffix", [".ts", ".js", ".rs", ".go", ".c", ".cpp", ".java"]) + def test_common_languages_have_rules(self, suffix): + assert rules_for(suffix) is not None + + def test_python_is_left_to_its_own_highlighter(self): + assert rules_for(".py") is None + + def test_suffix_matching_ignores_case(self): + assert rules_for(".TS") is rules_for(".ts") + + def test_an_unknown_suffix_has_no_rules(self): + assert rules_for(".unknown") is None + + def test_shell_uses_a_hash_comment_and_no_block(self): + rules = rules_for(".sh") + assert rules.line_comment == "#" + assert rules.block_comment is None + + def test_sql_uses_a_double_dash_comment(self): + assert rules_for(".sql").line_comment == "--" + + def test_template_literals_count_as_strings_in_js(self): + assert "`" in rules_for(".js").string_delimiters + + def test_every_rule_set_names_its_language(self): + assert all(rules.name for rules in LANGUAGE_RULES.values()) + + def test_supported_suffixes_are_sorted(self): + suffixes = supported_suffixes() + assert list(suffixes) == sorted(suffixes) + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _highlighted(suffix: str, text: str): + """Return (highlighter, document) with *text* already highlighted.""" + from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for + document = QTextDocument() + highlighter = highlighter_for(document, suffix) + document.setPlainText(text) + highlighter.rehighlight() + return highlighter, document + + +def _formats_at(document: QTextDocument, line: int) -> list: + block = document.findBlockByNumber(line) + layout = block.layout() + if layout is None: + return [] + return [(fmt.start, fmt.length) for fmt in layout.formats()] + + +class TestGenericHighlighter: + def test_a_keyword_is_coloured(self, app): + highlighter, document = _highlighted(".ts", "const value = 1;") + assert _formats_at(document, 0) != [] + highlighter.setDocument(None) + + def test_an_unknown_suffix_gets_no_highlighter(self, app): + from je_editor.pyside_ui.code.syntax.generic_syntax import highlighter_for + assert highlighter_for(QTextDocument(), ".unknown") is None + + def test_a_line_comment_is_coloured_to_the_end(self, app): + line = "value := 1 // explain" + highlighter, document = _highlighted(".go", line) + assert any(start + length == len(line) for start, length in _formats_at(document, 0)) + highlighter.setDocument(None) + + def test_a_block_comment_spans_lines(self, app): + highlighter, document = _highlighted(".c", "/* first\nstill inside\n*/ after") + assert _formats_at(document, 1) != [] + highlighter.setDocument(None) + + def test_a_language_without_block_comments_is_safe(self, app): + highlighter, document = _highlighted(".sh", "echo hi # comment\n") + assert _formats_at(document, 0) != [] + highlighter.setDocument(None) + + def test_plain_text_gets_no_colouring(self, app): + highlighter, document = _highlighted(".ts", "plainwords here\n") + assert _formats_at(document, 0) == [] + highlighter.setDocument(None) + + def test_every_syntax_colour_is_defined(self, app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for key in ( + "syntax_keyword_color", "syntax_string_color", + "syntax_comment_color", "syntax_number_color", + ): + assert actually_color_dict.get(key) is not None + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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() + + +class TestHighlighterSelection: + def test_a_typescript_file_uses_the_generic_highlighter(self, editor): + from je_editor.pyside_ui.code.syntax.generic_syntax import GenericHighlighter + editor.current_file = "app.ts" + 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 + editor.current_file = "module.py" + editor.reset_highlighter() + assert isinstance(editor.highlighter, PythonHighlighter) + + def test_an_unknown_suffix_falls_back_to_python(self, editor): + from je_editor.pyside_ui.code.syntax.python_syntax import PythonHighlighter + editor.current_file = "notes.unknown" + 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 + editor.current_file = None + editor.reset_highlighter() + assert isinstance(editor.highlighter, PythonHighlighter) diff --git a/test/test_head_diff.py b/test/test_head_diff.py new file mode 100644 index 00000000..f2c2e8cf --- /dev/null +++ b/test/test_head_diff.py @@ -0,0 +1,126 @@ +"""Tests for diffing the open file against its committed version.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.unified import unified_diff_text + + +class TestUnifiedDiffText: + def test_identical_text_produces_no_diff(self): + assert unified_diff_text("a\nb\n", "a\nb\n") == "" + + def test_changed_line_appears_on_both_sides(self): + diff = unified_diff_text("a\nb\n", "a\nB\n") + assert "-b" in diff + assert "+B" in diff + + def test_added_line_is_marked(self): + diff = unified_diff_text("a\n", "a\nb\n") + assert "+b" in diff + + def test_removed_line_is_marked(self): + diff = unified_diff_text("a\nb\n", "a\n") + assert "-b" in diff + + def test_header_names_both_sides(self): + diff = unified_diff_text("a\n", "b\n", "app.py") + assert "a/app.py" in diff and "HEAD" in diff + assert "b/app.py" in diff and "working copy" in diff + + def test_header_without_a_file_name(self): + diff = unified_diff_text("a\n", "b\n") + assert "HEAD" in diff and "working copy" in diff + + def test_context_lines_are_limited(self): + baseline = "".join(f"line{index}\n" for index in range(40)) + current = baseline.replace("line20", "CHANGED") + diff = unified_diff_text(baseline, current, context_lines=1) + assert "line19" in diff + assert "line10" not in diff + + def test_empty_baseline_shows_the_whole_file_as_added(self): + diff = unified_diff_text("", "a\nb\n") + assert "+a" in diff and "+b" in diff + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + self.added = [] + + def currentWidget(self): + return self._widget + + def count(self): + return len(self.added) + + def addTab(self, widget, label): + self.added.append((widget, label)) + + +def _window_with(editor, monkey_editor_widget=True): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + tab = MagicMock(spec=EditorWidget) if monkey_editor_widget else object() + if monkey_editor_widget: + tab.code_edit = editor + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + return window + + +class TestHeadDiffTab: + def test_diff_text_uses_the_editor_baseline(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\nCHANGED\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + diff = head_diff_text(_window_with(editor)) + assert "-b" in diff and "+CHANGED" in diff + + def test_no_baseline_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\n") + assert head_diff_text(_window_with(editor)) == "" + + def test_unchanged_file_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + editor.setPlainText("a\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + assert head_diff_text(_window_with(editor)) == "" + + def test_a_non_editor_tab_means_no_diff(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import head_diff_text + assert head_diff_text(_window_with(editor, monkey_editor_widget=False)) == "" + + def test_opening_the_tab_without_changes_does_nothing(self, editor): + from je_editor.pyside_ui.main_ui.menu.tab_menu.build_tab_git_menu import add_head_diff_tab + editor.setPlainText("a\n") + editor.diff_marker_manager.set_baseline("a\n") + window = _window_with(editor) + assert add_head_diff_tab(window) is False + assert window.tab_widget.added == [] diff --git a/test/test_hunk_staging.py b/test/test_hunk_staging.py new file mode 100644 index 00000000..b0e0cefb --- /dev/null +++ b/test/test_hunk_staging.py @@ -0,0 +1,145 @@ +"""Tests for staging one hunk and comparing staged against working.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.git_client.file_staging import stage_content, staged_text +from je_editor.utils.file_diff.line_status import apply_hunk, hunk_at_line + +git = pytest.importorskip("git") + + +class TestApplyHunk: + def test_only_the_named_hunk_is_applied(self): + baseline = "a\nb\nc\nd\n" + current = "A\nb\nc\nD\n" + first = hunk_at_line(baseline, current, 0) + assert apply_hunk(baseline, current, first) == "A\nb\nc\nd\n" + + def test_the_other_hunk_applies_on_its_own(self): + baseline = "a\nb\nc\nd\n" + current = "A\nb\nc\nD\n" + last = hunk_at_line(baseline, current, 3) + assert apply_hunk(baseline, current, last) == "a\nb\nc\nD\n" + + def test_an_added_line_is_inserted(self): + baseline = "a\nb\n" + current = "a\nnew\nb\n" + hunk = hunk_at_line(baseline, current, 1) + assert apply_hunk(baseline, current, hunk) == "a\nnew\nb\n" + + def test_a_deleted_line_is_removed(self): + baseline = "a\nb\nc\n" + current = "a\nc\n" + hunk = hunk_at_line(baseline, current, 1) + assert apply_hunk(baseline, current, hunk) == "a\nc\n" + + def test_emptying_a_file_yields_empty_content(self): + baseline = "a\n" + hunk = hunk_at_line(baseline, "", 0) + assert hunk is None or apply_hunk(baseline, "", hunk) == "" + + +@pytest.fixture() +def repo(tmp_path): + """A throw-away repository with one committed file.""" + repository = git.Repo.init(tmp_path) + with repository.config_writer() as config: + config.set_value("user", "name", "Stage Tester") + config.set_value("user", "email", "stage@example.com") + tracked = tmp_path / "tracked.py" + tracked.write_text("a\nb\nc\n", encoding="utf-8") + repository.index.add(["tracked.py"]) + repository.index.commit("initial") + repository.close() + yield tmp_path + + +class TestStaging: + def test_staged_text_reads_the_index(self, repo): + assert staged_text(repo / "tracked.py") == "a\nb\nc\n" + + def test_staging_updates_the_index_only(self, repo): + tracked = repo / "tracked.py" + tracked.write_text("a\nWORKING\nc\n", encoding="utf-8") + assert stage_content(tracked, "a\nSTAGED\nc\n") is True + assert staged_text(tracked) == "a\nSTAGED\nc\n" + # The working tree keeps what the user is editing. + assert tracked.read_text(encoding="utf-8") == "a\nWORKING\nc\n" + + def test_staging_one_hunk_leaves_the_other_committed(self, repo): + tracked = repo / "tracked.py" + baseline = "a\nb\nc\n" + current = "A\nb\nC\n" + tracked.write_text(current, encoding="utf-8") + first = hunk_at_line(baseline, current, 0) + stage_content(tracked, apply_hunk(baseline, current, first)) + assert staged_text(tracked) == "A\nb\nc\n" + + def test_a_file_outside_a_repository_cannot_be_staged(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("x\n", encoding="utf-8") + assert stage_content(loose, "y\n") is False + assert staged_text(loose) is None + + def test_an_untracked_file_has_no_staged_text(self, repo): + new_file = repo / "untracked.py" + new_file.write_text("x\n", encoding="utf-8") + assert staged_text(new_file) is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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() + + +class TestEditorStaging: + def test_staging_the_hunk_under_the_caret(self, editor, repo): + tracked = repo / "tracked.py" + editor.current_file = str(tracked) + editor.setPlainText("A\nb\nC\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + cursor = editor.textCursor() + cursor.setPosition(0) + editor.setTextCursor(cursor) + assert editor.stage_change_at_cursor() is True + assert staged_text(tracked) == "A\nb\nc\n" + + def test_staging_without_a_baseline_is_refused(self, editor): + editor.setPlainText("x\n") + assert editor.stage_change_at_cursor() is False + + def test_staging_an_unchanged_line_is_refused(self, editor, repo): + editor.current_file = str(repo / "tracked.py") + editor.setPlainText("a\nb\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor.stage_change_at_cursor() is False + + def test_staged_diff_shows_the_unstaged_edit(self, editor, repo): + tracked = repo / "tracked.py" + editor.current_file = str(tracked) + editor.setPlainText("a\nEDITED\nc\n") + diff = editor.staged_diff_text() + assert "-b" in diff and "+EDITED" in diff + + def test_no_diff_without_a_file(self, editor): + editor.current_file = None + assert editor.staged_diff_text() == "" diff --git a/test/test_indent_guides.py b/test/test_indent_guides.py new file mode 100644 index 00000000..74896041 --- /dev/null +++ b/test/test_indent_guides.py @@ -0,0 +1,135 @@ +"""Tests for indent-guide columns and trailing-whitespace detection.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.indentation.indent_guides import ( + MAX_GUIDES_PER_LINE, + guide_columns, + leading_space_width, + trailing_whitespace_start, +) + + +class TestLeadingSpaceWidth: + def test_spaces(self): + assert leading_space_width(" code", 4) == 4 + + def test_no_indent(self): + assert leading_space_width("code", 4) == 0 + + def test_tab_advances_to_the_next_stop(self): + assert leading_space_width("\tcode", 4) == 4 + + def test_space_then_tab_fills_the_stop(self): + # Two spaces then a tab lands on the next multiple of four, not 2 + 4. + assert leading_space_width(" \tcode", 4) == 4 + + def test_only_leading_whitespace_counts(self): + assert leading_space_width(" a b", 4) == 2 + + +class TestGuideColumns: + def test_one_level(self): + assert guide_columns(" code", 4) == [4] + + def test_two_levels(self): + assert guide_columns(" code", 4) == [4, 8] + + def test_unindented_line_has_no_guides(self): + assert guide_columns("code", 4) == [] + + def test_blank_line_has_no_guides(self): + assert guide_columns("", 4) == [] + assert guide_columns(" ", 4) == [] + + def test_partial_indentation_rounds_down(self): + assert guide_columns(" code", 4) == [4] + + def test_tab_indentation(self): + assert guide_columns("\t\tcode", 4) == [4, 8] + + def test_guides_are_capped(self): + deep = " " * (4 * (MAX_GUIDES_PER_LINE + 10)) + "code" + assert len(guide_columns(deep, 4)) == MAX_GUIDES_PER_LINE + + def test_invalid_indent_size(self): + assert guide_columns(" code", 0) == [] + + +class TestTrailingWhitespace: + def test_trailing_spaces(self): + assert trailing_whitespace_start("code ") == 4 + + def test_trailing_tab(self): + assert trailing_whitespace_start("code\t") == 4 + + def test_clean_line(self): + assert trailing_whitespace_start("code") is None + + def test_empty_line_is_not_reported(self): + assert trailing_whitespace_start("") is None + + def test_whitespace_only_line_is_reported_from_the_start(self): + assert trailing_whitespace_start(" ") == 0 + + def test_inner_spaces_are_not_trailing(self): + assert trailing_whitespace_start("a b") is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestPainting: + def test_painting_indented_text_with_trailing_spaces_does_not_raise(self, editor): + editor.setPlainText("def run():\n x = 1 \n y = 2\n") + editor.show() + QApplication.processEvents() + editor.hide() + + def test_settings_can_turn_the_overlays_off(self, editor): + from je_editor.pyside_ui.main_ui.save_settings.user_setting_file import user_setting_dict + original = ( + user_setting_dict.get("show_indent_guides", True), + user_setting_dict.get("show_trailing_whitespace", True), + ) + user_setting_dict["show_indent_guides"] = False + user_setting_dict["show_trailing_whitespace"] = False + try: + editor.setPlainText(" x = 1 \n") + editor.show() + QApplication.processEvents() + editor.hide() + finally: + user_setting_dict["show_indent_guides"] = original[0] + user_setting_dict["show_trailing_whitespace"] = original[1] + + def test_every_overlay_colour_is_defined(self, app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict + ) + for key in ("indent_guide_color", "trailing_whitespace_color"): + assert actually_color_dict.get(key) is not None diff --git a/test/test_line_status.py b/test/test_line_status.py new file mode 100644 index 00000000..25cc3773 --- /dev/null +++ b/test/test_line_status.py @@ -0,0 +1,108 @@ +"""Tests for the line-by-line diff behind the editor's git change markers.""" +from __future__ import annotations + +from je_editor.utils.file_diff.line_status import ( + LINE_ADDED, + LINE_MODIFIED, + LINE_REMOVED_ABOVE, + MAX_DIFFED_LINES, + changed_line_numbers, + line_statuses, + next_changed_line, + previous_changed_line, +) + + +class TestLineStatuses: + def test_identical_text_has_no_markers(self): + assert line_statuses("a\nb\nc\n", "a\nb\nc\n") == {} + + def test_appended_line_is_added(self): + assert line_statuses("a\nb\n", "a\nb\nc\n") == {2: LINE_ADDED} + + def test_inserted_line_is_added(self): + assert line_statuses("a\nc\n", "a\nb\nc\n") == {1: LINE_ADDED} + + def test_changed_line_is_modified(self): + assert line_statuses("a\nb\nc\n", "a\nB\nc\n") == {1: LINE_MODIFIED} + + def test_several_changed_lines_are_each_modified(self): + statuses = line_statuses("a\nb\nc\n", "a\nB\nC\n") + assert statuses == {1: LINE_MODIFIED, 2: LINE_MODIFIED} + + def test_deleted_line_marks_the_line_below(self): + assert line_statuses("a\nb\nc\n", "a\nc\n") == {1: LINE_REMOVED_ABOVE} + + def test_deletion_at_the_end_marks_the_last_line(self): + assert line_statuses("a\nb\nc\n", "a\nb\n") == {1: LINE_REMOVED_ABOVE} + + def test_new_file_marks_every_line_added(self): + assert line_statuses("", "a\nb\n") == {0: LINE_ADDED, 1: LINE_ADDED} + + def test_emptied_file_has_no_lines_to_mark(self): + assert line_statuses("a\nb\n", "") == {} + + def test_both_empty(self): + assert line_statuses("", "") == {} + + def test_a_real_status_is_not_overwritten_by_a_deletion_marker(self): + # "b" and "c" are gone and "d" is new: the new line keeps its own status. + statuses = line_statuses("a\nb\nc\n", "a\nd\n") + assert statuses[1] in {LINE_MODIFIED, LINE_ADDED} + + def test_no_trailing_newline_is_handled(self): + assert line_statuses("a\nb", "a\nB") == {1: LINE_MODIFIED} + + def test_leading_insertion(self): + assert line_statuses("b\n", "a\nb\n") == {0: LINE_ADDED} + + def test_indentation_only_change_counts_as_modified(self): + assert line_statuses("x = 1\n", " x = 1\n") == {0: LINE_MODIFIED} + + def test_oversized_buffer_is_skipped(self): + big = "\n".join(str(number) for number in range(MAX_DIFFED_LINES + 5)) + assert line_statuses("a\n", big) == {} + + def test_oversized_baseline_is_skipped(self): + big = "\n".join(str(number) for number in range(MAX_DIFFED_LINES + 5)) + assert line_statuses(big, "a\n") == {} + + +class TestChangedLineNumbers: + def test_sorted_ascending(self): + statuses = {5: LINE_ADDED, 1: LINE_MODIFIED, 3: LINE_ADDED} + assert changed_line_numbers(statuses) == [1, 3, 5] + + def test_empty(self): + assert changed_line_numbers({}) == [] + + +class TestChangeNavigation: + def _statuses(self): + return {2: LINE_ADDED, 5: LINE_MODIFIED, 9: LINE_ADDED} + + def test_next_from_before_the_first(self): + assert next_changed_line(self._statuses(), 0) == 2 + + def test_next_skips_the_current_line(self): + assert next_changed_line(self._statuses(), 2) == 5 + + def test_next_wraps_around(self): + assert next_changed_line(self._statuses(), 20) == 2 + + def test_previous_from_after_the_last(self): + assert previous_changed_line(self._statuses(), 20) == 9 + + def test_previous_skips_the_current_line(self): + assert previous_changed_line(self._statuses(), 5) == 2 + + def test_previous_wraps_around(self): + assert previous_changed_line(self._statuses(), 0) == 9 + + def test_no_changes_yields_none(self): + assert next_changed_line({}, 3) is None + assert previous_changed_line({}, 3) is None + + def test_single_change_returns_itself_when_wrapping(self): + assert next_changed_line({4: LINE_ADDED}, 4) == 4 + assert previous_changed_line({4: LINE_ADDED}, 4) == 4 diff --git a/test/test_lint_manager.py b/test/test_lint_manager.py new file mode 100644 index 00000000..fc1df4c1 --- /dev/null +++ b/test/test_lint_manager.py @@ -0,0 +1,225 @@ +"""Tests for the lint manager, the editor's underlines, and the problems panel.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtGui import QTextCharFormat +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lint.ruff_diagnostics import Diagnostic + +SAMPLE = Diagnostic( + line=1, column=1, end_line=1, end_column=7, code="F401", message="unused import") +OTHER = Diagnostic( + line=2, column=1, end_line=2, end_column=4, code="E701", message="multiple statements") + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestClosingStopsScheduledWork: + """ + The debounce timers schedule work that spawns threads. One firing after the + close would start a thread nothing is left to wait for, and Qt aborts the + process outright when a running QThread is destroyed -- far from the cause. + """ + + def test_the_lint_timer_is_stopped(self, editor): + editor._lint_timer.start() + editor.close() + assert editor._lint_timer.isActive() is False + + def test_the_diff_timer_is_stopped(self, editor): + editor._diff_timer.start() + editor.close() + assert editor._diff_timer.isActive() is False + + def test_the_completion_timer_is_stopped(self, editor): + editor._complete_timer.start() + editor.close() + assert editor._complete_timer.isActive() is False + + def test_no_lint_worker_is_left_running(self, editor): + editor.close() + assert editor.lint_manager._worker is None + + +class TestLintManagerState: + def test_starts_empty(self, editor): + assert editor.lint_manager.diagnostics() == [] + + 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_for_line(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE, OTHER]) + assert editor.lint_manager.for_line(1) == [SAMPLE] + assert editor.lint_manager.for_line(3) == [] + + def test_message_for_line(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + assert "unused import" in editor.lint_manager.message_for_line(1) + assert editor.lint_manager.message_for_line(2) is None + + def test_clear_reports_whether_anything_went(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + assert editor.lint_manager.clear() is True + assert editor.lint_manager.clear() is False + + def test_diagnostics_returns_a_copy(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.lint_manager.diagnostics().clear() + assert editor.lint_manager.diagnostics() == [SAMPLE] + + def test_a_non_python_file_is_not_checked(self, editor): + editor.current_file = "notes.txt" + assert editor.request_lint() is False + + def test_no_file_is_not_checked(self, editor): + editor.current_file = None + assert editor.request_lint() is False + + def test_requesting_a_check_clears_stale_diagnostics(self, editor): + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.current_file = "notes.txt" + editor.request_lint() + assert editor.lint_manager.diagnostics() == [] + + +class TestUnderlines: + def _wave_selections(self, editor): + wave = QTextCharFormat.UnderlineStyle.WaveUnderline + return [ + selection for selection in editor.extraSelections() + if selection.format.underlineStyle() == wave + ] + + def test_diagnostics_are_underlined(self, editor): + editor.setPlainText("import os\nx=1\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.refresh_lint_display() + assert len(self._wave_selections(editor)) == 1 + + def test_underline_covers_the_reported_range(self, editor): + # The range is checked on the cursor the editor builds, rather than on + # the one inside an ExtraSelection, whose C++ object dies with the list. + editor.setPlainText("import os\nx=1\n") + cursor = editor._diagnostic_cursor(editor.document(), SAMPLE) + assert cursor.selectedText() == "import" + + 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") + assert editor._diagnostic_cursor(editor.document(), missing) is None + + def test_no_diagnostics_means_no_underline(self, editor): + editor.setPlainText("x = 1\n") + editor.lint_manager.set_diagnostics([]) + editor.refresh_lint_display() + assert self._wave_selections(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")]) + 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")]) + editor.refresh_lint_display() + assert len(self._wave_selections(editor)) == 1 + + def test_current_line_highlight_survives_alongside_underlines(self, editor): + editor.setPlainText("import os\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.highlight_current_line() + assert len(editor.extraSelections()) > len(self._wave_selections(editor)) + + def test_caret_line_message_becomes_the_tooltip(self, editor): + editor.setPlainText("import os\nx = 1\n") + editor.lint_manager.set_diagnostics([SAMPLE]) + editor.refresh_lint_display() + assert "unused import" in editor.toolTip() + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +class TestProblemsPanel: + def test_lists_the_current_editor_diagnostics(self, app, editor): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + tab = MagicMock(spec=EditorWidget) + tab.code_edit = editor + editor.current_file = None + editor.lint_manager.set_diagnostics([SAMPLE, OTHER]) + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + with patch.object(editor, "request_lint", return_value=False): + panel = ProblemsPanelWidget(window) + assert panel.result_tree.topLevelItemCount() == 2 + assert panel.result_tree.topLevelItem(0).text(0) == "F401" + panel.close() + panel.deleteLater() + + def test_no_editor_tab_shows_nothing(self, app): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + window = MagicMock() + window.tab_widget = _FakeTabWidget(None) + panel = ProblemsPanelWidget(window) + assert panel.diagnostics() == [] + assert panel.result_tree.topLevelItemCount() == 0 + panel.close() + panel.deleteLater() + + def test_double_click_jumps_to_the_line(self, app, editor): + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + editor.setPlainText("import os\nx = 1\ny = 2\n") + editor.lint_manager.set_diagnostics([OTHER]) + tab = MagicMock(spec=EditorWidget) + tab.code_edit = editor + window = MagicMock() + window.tab_widget = _FakeTabWidget(tab) + with patch.object(editor, "request_lint", return_value=False): + panel = ProblemsPanelWidget(window) + assert panel.jump_to_diagnostic(OTHER) is True + assert editor.textCursor().blockNumber() == 1 + panel.close() + panel.deleteLater() diff --git a/test/test_lsp.py b/test/test_lsp.py new file mode 100644 index 00000000..06667991 --- /dev/null +++ b/test/test_lsp.py @@ -0,0 +1,275 @@ +"""Tests for the LSP wire protocol, the server registry, and the client.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.language_servers import ( + DEFAULT_SERVERS, + language_id, + merge_servers, + server_command, +) +from je_editor.utils.lsp.lsp_protocol import ( + MessageReader, + completion_labels, + diagnostic_entries, + encode_message, + file_uri, + notification, + request, +) + + +class TestEncoding: + def test_message_starts_with_a_content_length(self): + encoded = encode_message({"jsonrpc": "2.0", "method": "ping"}) + assert encoded.startswith(b"Content-Length: ") + + def test_length_matches_the_body(self): + encoded = encode_message({"method": "ping"}) + header, body = encoded.split(b"\r\n\r\n", 1) + declared = int(header.split(b":")[1].strip()) + assert declared == len(body) + + def test_body_is_the_json_payload(self): + encoded = encode_message({"method": "ping", "id": 3}) + body = encoded.split(b"\r\n\r\n", 1)[1] + assert json.loads(body) == {"method": "ping", "id": 3} + + def test_non_ascii_survives(self): + encoded = encode_message({"text": "中文"}) + body = encoded.split(b"\r\n\r\n", 1)[1] + assert json.loads(body)["text"] == "中文" + + +class TestMessageReader: + def test_one_whole_message(self): + reader = MessageReader() + assert reader.feed(encode_message({"id": 1})) == [{"id": 1}] + + def test_two_messages_in_one_read(self): + reader = MessageReader() + data = encode_message({"id": 1}) + encode_message({"id": 2}) + assert [message["id"] for message in reader.feed(data)] == [1, 2] + + def test_a_message_split_across_reads(self): + reader = MessageReader() + encoded = encode_message({"id": 7}) + assert reader.feed(encoded[:10]) == [] + assert reader.feed(encoded[10:]) == [{"id": 7}] + + def test_a_body_split_from_its_header(self): + reader = MessageReader() + header, body = encode_message({"id": 9}).split(b"\r\n\r\n", 1) + assert reader.feed(header + b"\r\n\r\n") == [] + assert reader.feed(body) == [{"id": 9}] + + def test_pending_bytes_are_reported(self): + reader = MessageReader() + reader.feed(encode_message({"id": 1})[:8]) + assert reader.pending_bytes > 0 + + def test_a_body_that_is_not_json_is_dropped(self): + reader = MessageReader() + broken = b"Content-Length: 5\r\n\r\nnotjs" + assert reader.feed(broken) == [] + + def test_a_header_without_a_length_is_skipped(self): + reader = MessageReader() + data = b"Bad-Header: 1\r\n\r\n" + encode_message({"id": 4}) + assert reader.feed(data) == [{"id": 4}] + + def test_empty_feed(self): + assert MessageReader().feed(b"") == [] + + +class TestMessageBuilders: + def test_request_has_an_id(self): + assert request(5, "textDocument/completion")["id"] == 5 + + def test_notification_has_no_id(self): + assert "id" not in notification("initialized") + + def test_posix_file_uri(self): + assert file_uri("/home/user/app.ts") == "file:///home/user/app.ts" + + def test_windows_file_uri(self): + assert file_uri("D:\\project\\app.ts") == "file:///D:/project/app.ts" + + +class TestResultParsing: + def test_completion_list_form(self): + assert completion_labels([{"label": "alpha"}, {"label": "beta"}]) == ["alpha", "beta"] + + def test_completion_items_form(self): + assert completion_labels({"items": [{"label": "gamma"}]}) == ["gamma"] + + def test_plain_string_items(self): + assert completion_labels(["delta"]) == ["delta"] + + def test_duplicates_are_dropped(self): + assert completion_labels([{"label": "same"}, {"label": "same"}]) == ["same"] + + def test_unusable_result(self): + assert completion_labels(None) == [] + assert completion_labels({"unexpected": 1}) == [] + + def test_diagnostics_are_converted_to_one_based_lines(self): + entries = diagnostic_entries({"diagnostics": [ + {"range": {"start": {"line": 4, "character": 2}}, "message": "boom"}]}) + assert entries[0]["line"] == 5 + assert entries[0]["column"] == 3 + assert entries[0]["message"] == "boom" + + def test_diagnostics_without_a_message_are_skipped(self): + assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] + + def test_unusable_diagnostics_params(self): + assert diagnostic_entries(None) == [] + + +class TestServerRegistry: + def test_python_is_not_served_by_lsp(self): + # Python keeps jedi; two completion sources for one file helps nobody. + assert ".py" not in DEFAULT_SERVERS + + def test_known_suffixes_have_commands(self): + assert DEFAULT_SERVERS[".ts"][0] == "typescript-language-server" + + def test_user_servers_override_the_defaults(self): + merged = merge_servers({".ts": ["my-server", "--stdio"], ".zig": ["zls"]}) + assert merged[".ts"] == ["my-server", "--stdio"] + assert merged[".zig"] == ["zls"] + + def test_hand_edited_entries_are_skipped(self): + merged = merge_servers({"ts": ["no-dot"], ".x": "not-a-list", ".y": []}) + assert "ts" not in merged and ".x" not in merged and ".y" not in merged + + def test_non_dict_falls_back(self): + assert merge_servers("nonsense")[".go"] == ["gopls"] + + def test_an_uninstalled_server_is_not_offered(self): + with patch("je_editor.utils.lsp.language_servers.shutil.which", return_value=None): + assert server_command(".ts") is None + + def test_an_installed_server_is_offered(self): + with patch( + "je_editor.utils.lsp.language_servers.shutil.which", return_value="/usr/bin/x" + ): + assert server_command(".ts")[0] == "typescript-language-server" + + def test_an_unknown_suffix_has_no_server(self): + assert server_command(".unknownsuffix") is None + + def test_language_ids(self): + assert language_id(".ts") == "typescript" + assert language_id(".RS") == "rust" + + def test_unknown_language_id_falls_back_to_the_suffix(self): + assert language_id(".zig") == "zig" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class TestLspClient: + def test_starts_stopped(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + assert client.running is False + client.stop() + + def test_a_missing_server_does_not_start(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + with patch("je_editor.pyside_ui.code.lsp.lsp_client.server_command", return_value=None): + assert client.start_for("app.ts") is False + client.stop() + + def test_sending_without_a_server_is_refused(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + assert client.did_open("text") is False + assert client.request_completion(0, 0) is False + client.stop() + + def test_completion_results_are_emitted(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.completions_ready.connect(received.append) + client._pending_completion_id = 12 + client.handle_message({"id": 12, "result": [{"label": "answer"}]}) + assert received == [["answer"]] + client.stop() + + def test_a_reply_to_another_request_is_ignored(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.completions_ready.connect(received.append) + client._pending_completion_id = 12 + client.handle_message({"id": 99, "result": [{"label": "stale"}]}) + assert received == [] + client.stop() + + def test_diagnostics_are_emitted(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + received = [] + client.diagnostics_ready.connect(received.append) + client.handle_message({ + "method": "textDocument/publishDiagnostics", + "params": {"diagnostics": [ + {"range": {"start": {"line": 0, "character": 0}}, "message": "oops"}]}, + }) + assert received[0][0]["message"] == "oops" + client.stop() + + def test_stopping_twice_is_safe(self, app): + from je_editor.pyside_ui.code.lsp.lsp_client import LspClient + client = LspClient() + client.stop() + client.stop() + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.lsp_client.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestEditorIntegration: + def test_python_files_start_no_server(self, editor): + editor.current_file = "module.py" + assert editor.start_language_server() is False + + def test_a_file_without_a_server_starts_none(self, editor): + editor.current_file = "notes.unknownsuffix" + assert editor.start_language_server() is False + + def test_no_file_starts_none(self, editor): + editor.current_file = None + assert editor.start_language_server() is False + + def test_completion_falls_back_to_jedi_without_a_server(self, editor): + assert editor.request_language_server_completion() is False diff --git a/test/test_lsp_diagnostics.py b/test/test_lsp_diagnostics.py new file mode 100644 index 00000000..2077f077 --- /dev/null +++ b/test/test_lsp_diagnostics.py @@ -0,0 +1,142 @@ +"""Tests for showing language-server diagnostics through the lint display.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lint.ruff_diagnostics import ( + SYNTAX_ERROR_CODE, + diagnostic_from_entry, + diagnostics_from_entries, +) +from je_editor.utils.lsp.lsp_protocol import diagnostic_entries + +SERVER_NOTIFICATION = { + "diagnostics": [ + { + "range": {"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 9}}, + "message": "Cannot find name 'foo'", + "code": 2304, + } + ] +} + + +class TestServerDiagnosticShape: + def test_range_becomes_one_based(self): + entry = diagnostic_entries(SERVER_NOTIFICATION)[0] + assert entry["line"] == 4 and entry["column"] == 5 + assert entry["end_line"] == 4 and entry["end_column"] == 10 + + def test_numeric_code_is_kept_as_text(self): + assert diagnostic_entries(SERVER_NOTIFICATION)[0]["code"] == "2304" + + def test_entry_without_a_range_still_works(self): + entry = diagnostic_entries({"diagnostics": [{"message": "broken"}]})[0] + assert entry["line"] == 1 and entry["column"] == 1 + + def test_entry_without_a_message_is_dropped(self): + assert diagnostic_entries({"diagnostics": [{"range": {}}]}) == [] + + +class TestConversionToTheEditorShape: + def test_server_entry_becomes_a_diagnostic(self): + diagnostic = diagnostic_from_entry(diagnostic_entries(SERVER_NOTIFICATION)[0]) + assert diagnostic.line == 4 + assert diagnostic.code == "2304" + assert "Cannot find name" in diagnostic.message + + def test_batch_conversion(self): + assert len(diagnostics_from_entries(diagnostic_entries(SERVER_NOTIFICATION))) == 1 + + def test_entry_without_a_code_falls_back(self): + diagnostic = diagnostic_from_entry({"line": 2, "message": "no code here"}) + assert diagnostic.code == SYNTAX_ERROR_CODE + + def test_missing_columns_default_to_the_line_start(self): + diagnostic = diagnostic_from_entry({"line": 5, "message": "somewhere"}) + assert diagnostic.column == 1 and diagnostic.end_line == 5 + + def test_unusable_entries_are_dropped(self): + assert diagnostic_from_entry({"message": "no line"}) is None + assert diagnostic_from_entry({"line": 0, "message": "bad line"}) is None + assert diagnostic_from_entry("nonsense") is None + + def test_non_list_batch(self): + assert diagnostics_from_entries("nonsense") == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.lsp_client.stop() + code_editor.close() + code_editor.deleteLater() + + +class TestEditorShowsServerDiagnostics: + def test_diagnostics_reach_the_lint_manager(self, editor): + editor.setPlainText("const value = foo;\n") + assert editor.apply_server_diagnostics(diagnostic_entries({ + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 6}, + "end": {"line": 0, "character": 11}}, + "message": "unused", "code": 6133, + }] + })) is True + assert editor.lint_manager.diagnostics()[0].message == "unused" + + def test_the_same_diagnostics_twice_change_nothing(self, editor): + entries = diagnostic_entries(SERVER_NOTIFICATION) + editor.setPlainText("\n".join("line" for _ in range(10))) + editor.apply_server_diagnostics(entries) + assert editor.apply_server_diagnostics(entries) is False + + def test_underlines_are_drawn_for_server_diagnostics(self, editor): + from PySide6.QtGui import QTextCharFormat + editor.setPlainText("const value = foo;\n") + editor.apply_server_diagnostics(diagnostic_entries({ + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 1}, + "end": {"line": 0, "character": 5}}, + "message": "here", + }] + })) + wave = QTextCharFormat.UnderlineStyle.WaveUnderline + assert any( + selection.format.underlineStyle() == wave + for selection in editor.extraSelections()) + + def test_a_lint_pass_does_not_wipe_server_diagnostics(self, editor): + # The ruff pass skips non-Python files; without a guard it would clear + # whatever the language server had just reported. + editor.current_file = "app.ts" + editor.setPlainText("const value = foo;\n") + editor.apply_server_diagnostics(diagnostic_entries(SERVER_NOTIFICATION)) + with patch.object(type(editor.lsp_client), "running", property(lambda _self: True)): + editor.request_lint() + assert editor.lint_manager.diagnostics() != [] + + def test_without_a_server_a_lint_pass_still_clears(self, editor): + editor.current_file = "notes.txt" + editor.apply_server_diagnostics(diagnostic_entries(SERVER_NOTIFICATION)) + editor.request_lint() + assert editor.lint_manager.diagnostics() == [] diff --git a/test/test_lsp_features.py b/test/test_lsp_features.py new file mode 100644 index 00000000..0f42f6aa --- /dev/null +++ b/test/test_lsp_features.py @@ -0,0 +1,167 @@ +"""Tests for hover, rename and formatting through the language server.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.lsp.lsp_protocol import file_uri, hover_text, text_edits + + +class TestHoverText: + def test_plain_string_contents(self): + assert hover_text({"contents": "a function"}) == "a function" + + def test_marked_string_contents(self): + assert hover_text({"contents": {"value": "def run()"}}) == "def run()" + + def test_a_list_joins_every_part(self): + text = hover_text({"contents": ["first", {"value": "second"}]}) + assert text == "first\nsecond" + + def test_empty_parts_are_dropped(self): + assert hover_text({"contents": [" ", {"value": ""}]}) == "" + + def test_unusable_result(self): + assert hover_text(None) == "" + assert hover_text({"other": 1}) == "" + + +class TestTextEdits: + def test_formatting_returns_a_plain_list(self): + edits = text_edits([{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}}, + "newText": "xyz", + }]) + assert edits == [{ + "start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "xyz"}] + + def test_rename_returns_changes_keyed_by_uri(self): + uri = file_uri("/project/app.ts") + edits = text_edits({"changes": {uri: [{ + "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 9}}, + "newText": "renamed", + }]}}, uri) + assert edits[0]["new_text"] == "renamed" + assert edits[0]["start_line"] == 3 + + def test_rename_falls_back_to_the_only_file_present(self): + edits = text_edits({"changes": {"file:///other.ts": [{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "z", + }]}}, "file:///not-there.ts") + assert len(edits) == 1 + + def test_document_changes_form(self): + edits = text_edits({"documentChanges": [{"edits": [{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 2}}, + "newText": "ab", + }]}]}) + assert edits[0]["new_text"] == "ab" + + def test_entries_without_a_range_are_skipped(self): + assert text_edits([{"newText": "x"}]) == [] + + def test_unusable_result(self): + assert text_edits(None) == [] + assert text_edits({"unexpected": True}) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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() + + +class TestApplyingEdits: + def test_a_single_edit_replaces_its_range(self, editor): + editor.setPlainText("old value\n") + assert editor.apply_server_edits([{ + "start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "new"}]) is True + assert editor.toPlainText() == "new value\n" + + def test_several_edits_do_not_disturb_each_other(self, editor): + editor.setPlainText("aaa bbb ccc\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "111"}, + {"start_line": 1, "start_column": 9, "end_line": 1, "end_column": 12, + "new_text": "333"}, + ]) + assert editor.toPlainText() == "111 bbb 333\n" + + def test_edits_of_different_lengths_still_land_correctly(self, editor): + editor.setPlainText("one two\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "a-much-longer-word"}, + {"start_line": 1, "start_column": 5, "end_line": 1, "end_column": 8, + "new_text": "x"}, + ]) + assert editor.toPlainText() == "a-much-longer-word x\n" + + def test_edits_are_one_undo_step(self, editor): + editor.setPlainText("aaa bbb\n") + editor.apply_server_edits([ + {"start_line": 1, "start_column": 1, "end_line": 1, "end_column": 4, + "new_text": "111"}, + {"start_line": 1, "start_column": 5, "end_line": 1, "end_column": 8, + "new_text": "222"}, + ]) + editor.undo() + assert editor.toPlainText() == "aaa bbb\n" + + def test_an_edit_on_a_missing_line_is_skipped(self, editor): + editor.setPlainText("only one line\n") + editor.apply_server_edits([{ + "start_line": 99, "start_column": 1, "end_line": 99, "end_column": 2, + "new_text": "x"}]) + assert editor.toPlainText() == "only one line\n" + + def test_no_edits(self, editor): + assert editor.apply_server_edits([]) is False + + +class TestEditorRequests: + def test_hover_without_a_server_is_refused(self, editor): + assert editor.request_hover() is False + + def test_formatting_without_a_server_is_refused(self, editor): + assert editor.format_with_language_server() is False + + def test_hover_text_becomes_the_tooltip(self, editor): + editor.show_hover_text("def run() -> None") + assert editor.toolTip() == "def run() -> None" + + def test_rename_without_a_server_falls_back_to_word_replace(self, editor): + editor.setPlainText("value = value + 1\n") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + with patch.object(editor, "rename_word_under_cursor", return_value=True) as fallback: + assert editor.rename_symbol() is True + fallback.assert_called_once() + + def test_definition_in_the_same_file_jumps(self, editor): + editor.current_file = "app.ts" + editor.setPlainText("one\ntwo\nthree\n") + assert editor.go_to_definition_location( + {"path": "app.ts", "line": 3, "column": 1}) is True + assert editor.textCursor().blockNumber() == 2 diff --git a/test/test_minimap.py b/test/test_minimap.py new file mode 100644 index 00000000..1a947720 --- /dev/null +++ b/test/test_minimap.py @@ -0,0 +1,187 @@ +"""Tests for the minimap's geometry and its wiring into the editor.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QTabWidget + +from je_editor.utils.minimap.minimap_layout import ( + LINE_PIXELS, + MINIMAP_WIDTH, + bar_offset, + bar_width, + line_at_row, + row_for_line, + sample_step, + viewport_band, +) + + +class TestSampleStep: + def test_short_file_draws_every_line(self): + assert sample_step(10, 400) == 1 + + def test_long_file_samples(self): + # 1000 lines into 200 pixels: 100 drawable rows, so every 10th line. + assert sample_step(1000, 200) == 10 + + def test_rounding_never_loses_the_tail(self): + step = sample_step(101, 200) + assert step >= 1 + assert row_for_line(100, step) <= 200 + + def test_empty_document(self): + assert sample_step(0, 200) == 1 + + def test_zero_height(self): + assert sample_step(100, 0) == 1 + + +class TestRowMapping: + def test_line_to_row_without_sampling(self): + assert row_for_line(5, 1) == 5 * LINE_PIXELS + + def test_line_to_row_with_sampling(self): + assert row_for_line(20, 10) == 2 * LINE_PIXELS + + def test_row_back_to_line(self): + assert line_at_row(row_for_line(30, 1), 1, 100) == 30 + + def test_row_beyond_the_document_is_clamped(self): + assert line_at_row(10_000, 1, 50) == 49 + + def test_negative_row_is_clamped(self): + assert line_at_row(-20, 1, 50) == 0 + + def test_empty_document(self): + assert line_at_row(40, 1, 0) == 0 + + +class TestBars: + def test_bar_follows_the_line_length(self): + assert bar_width("abcd") == 4 + + def test_blank_line_has_no_bar(self): + assert bar_width("") == 0 + assert bar_width(" ") == 0 + + def test_trailing_space_does_not_lengthen_the_bar(self): + assert bar_width("ab ") == 2 + + def test_bar_is_capped_at_the_minimap_width(self): + assert bar_width("x" * 500) == MINIMAP_WIDTH + + def test_indentation_offsets_the_bar(self): + assert bar_offset(" code") == 4 + + def test_unindented_line_starts_at_the_edge(self): + assert bar_offset("code") == 0 + + +class TestViewportBand: + def test_band_starts_at_the_first_visible_line(self): + top, _height = viewport_band(10, 30, 1) + assert top == row_for_line(10, 1) + + def test_band_covers_the_visible_lines(self): + _top, height = viewport_band(0, 30, 1) + assert height == row_for_line(30, 1) + + def test_band_is_never_invisible(self): + _top, height = viewport_band(0, 0, 50) + assert height >= LINE_PIXELS + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor_widget(app): + main_window = MagicMock() + main_window.working_dir = None + main_window.tab_widget = QTabWidget() + main_window.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(main_window) + widget.code_edit.setPlainText("\n".join(f"line {index}" for index in range(200))) + yield widget + widget.code_edit.lint_manager.stop() + widget.code_edit.diff_marker_manager.stop() + widget.code_edit.blame_manager.stop() + widget.deleteLater() + + +class TestMinimapWiring: + def test_starts_hidden(self, editor_widget): + assert editor_widget.minimap is None + + def test_toggling_on_adds_it(self, editor_widget): + assert editor_widget.toggle_minimap() is True + assert editor_widget.minimap is not None + + def test_toggling_off_removes_it(self, editor_widget): + editor_widget.toggle_minimap() + assert editor_widget.toggle_minimap() is False + assert editor_widget.minimap is None + + def test_painting_does_not_raise(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.show() + QApplication.processEvents() + editor_widget.hide() + + def test_clicking_maps_to_a_line_in_range(self, editor_widget): + editor_widget.toggle_minimap() + minimap = editor_widget.minimap + line = minimap.line_at_position(20) + assert 0 <= line < editor_widget.code_edit.blockCount() + + def test_markers_report_diagnostics(self, editor_widget): + from je_editor.utils.lint.ruff_diagnostics import Diagnostic + editor_widget.toggle_minimap() + editor_widget.code_edit.lint_manager.set_diagnostics([ + Diagnostic(line=4, column=1, end_line=4, end_column=3, code="F401", message="x")]) + assert editor_widget.minimap.marker_lines()["diagnostic"] == [3] + + def test_markers_report_git_changes(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("a\nCHANGED\nc\n") + editor_widget.code_edit.diff_marker_manager.set_baseline("a\nb\nc\n") + assert editor_widget.minimap.marker_lines()["change"] == [1] + + def test_markers_report_occurrences_of_the_word_under_the_caret(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("total = 1\nx = total\ny = total\n") + cursor = editor_widget.code_edit.textCursor() + cursor.setPosition(2) + editor_widget.code_edit.setTextCursor(cursor) + assert editor_widget.minimap.marker_lines()["occurrence"] == [0, 1, 2] + + def test_no_markers_when_there_is_nothing_to_mark(self, editor_widget): + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("alpha\nbeta\n") + markers = editor_widget.minimap.marker_lines() + assert markers["diagnostic"] == [] and markers["change"] == [] + + def test_painting_with_markers_does_not_raise(self, editor_widget): + from je_editor.utils.lint.ruff_diagnostics import Diagnostic + editor_widget.toggle_minimap() + editor_widget.code_edit.setPlainText("a\nCHANGED\nc\n") + editor_widget.code_edit.diff_marker_manager.set_baseline("a\nb\nc\n") + editor_widget.code_edit.lint_manager.set_diagnostics([ + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="E1", message="x")]) + editor_widget.show() + QApplication.processEvents() + editor_widget.hide() + + def test_an_empty_document_is_safe_to_map(self, editor_widget): + editor_widget.code_edit.setPlainText("") + editor_widget.toggle_minimap() + assert editor_widget.minimap.line_at_position(50) == 0 diff --git a/test/test_multi_cursor.py b/test/test_multi_cursor.py new file mode 100644 index 00000000..b12bdd7a --- /dev/null +++ b/test/test_multi_cursor.py @@ -0,0 +1,357 @@ +"""Tests for multiple carets: position bookkeeping and editing at each.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.multi_cursor.cursor_positions import ( + add_position, + clamp_positions, + column_caret_columns, + column_span, + remove_position, + shift_after_delete, + shift_after_insert, + toggle_position, +) + + +class TestPositionBookkeeping: + def test_adding_keeps_them_sorted(self): + assert add_position([5, 1], 3) == [1, 3, 5] + + def test_adding_a_duplicate_changes_nothing(self): + assert add_position([1, 3], 3) == [1, 3] + + def test_negative_positions_are_refused(self): + assert add_position([1], -5) == [1] + + def test_removing(self): + assert remove_position([1, 3, 5], 3) == [1, 5] + + def test_removing_an_absent_position(self): + assert remove_position([1, 5], 3) == [1, 5] + + def test_toggle_adds_then_removes(self): + positions = toggle_position([], 4) + assert positions == [4] + assert toggle_position(positions, 4) == [] + + +class TestShifting: + def test_insert_moves_later_positions(self): + assert shift_after_insert([2, 10], 5, 3) == [2, 13] + + def test_insert_at_a_position_leaves_it_alone(self): + assert shift_after_insert([5], 5, 3) == [5] + + def test_delete_moves_later_positions_back(self): + assert shift_after_delete([2, 10], 5, 3) == [2, 7] + + def test_delete_collapses_positions_inside_the_range(self): + assert shift_after_delete([6], 5, 3) == [5] + + def test_delete_leaves_earlier_positions_alone(self): + assert shift_after_delete([2], 5, 3) == [2] + + def test_clamping_to_the_document(self): + assert clamp_positions([-2, 5, 99], 10) == [0, 5, 10] + + def test_clamping_deduplicates(self): + assert clamp_positions([12, 15], 10) == [10] + + def test_clamping_an_empty_document(self): + assert clamp_positions([3], -1) == [] + + +class TestColumnGeometry: + def test_span_downwards(self): + assert list(column_span(2, 5)) == [2, 3, 4, 5] + + def test_span_upwards_is_the_same_range(self): + assert list(column_span(5, 2)) == [2, 3, 4, 5] + + def test_span_of_one_line(self): + assert list(column_span(3, 3)) == [3] + + def test_columns_follow_the_pointer(self): + assert column_caret_columns(2, 6, [10, 10, 10]) == [6, 6, 6] + + def test_a_short_line_stops_at_its_end(self): + assert column_caret_columns(2, 6, [10, 3, 10]) == [6, 3, 6] + + def test_dragging_straight_down_keeps_the_anchor_column(self): + assert column_caret_columns(4, 4, [10, 10]) == [4, 4] + + def test_no_lines_covered(self): + assert column_caret_columns(0, 3, []) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _select_all(editor) -> None: + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(editor.document().characterCount() - 1, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + +def _type(editor, text: str) -> None: + """Send a keystroke the way Qt does, so it owns the event object. + + Building a QKeyEvent in Python and handing it to the handler leaves Qt + holding an object Python may collect, which crashes the interpreter later + when the event queue is processed. + """ + QTest.keyClicks(editor, text) + + +def _press(editor, key) -> None: + """Send one non-printable key.""" + QTest.keyClick(editor, key) + + +class TestMultiCursorEditing: + def test_no_extra_cursors_at_first(self, editor): + assert editor.multi_cursor_manager.active is False + + def test_cursors_are_added_to_each_selected_line(self, editor): + editor.setPlainText("one\ntwo\nthree\n") + _select_all(editor) + added = editor.add_cursors_to_selected_lines() + assert added >= 1 + assert editor.multi_cursor_manager.active + + def test_typing_reaches_every_line(self, editor): + editor.setPlainText("one\ntwo\nthree") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, ",") + assert editor.toPlainText() == "one,\ntwo,\nthree," + + def test_typing_several_characters(self, editor): + editor.setPlainText("a\nb") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, "x") + _type(editor, "y") + assert editor.toPlainText() == "axy\nbxy" + + def test_backspace_reaches_every_line(self, editor): + editor.setPlainText("one!\ntwo!\nthree!") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _press(editor, Qt.Key.Key_Backspace) + assert editor.toPlainText() == "one\ntwo\nthree" + + def test_multi_cursor_edit_is_one_undo_step(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _type(editor, ";") + editor.undo() + assert editor.toPlainText() == "one\ntwo" + + def test_escape_clears_the_extra_cursors(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _press(editor, Qt.Key.Key_Escape) + assert editor.multi_cursor_manager.active is False + + def test_clearing_leaves_typing_to_the_primary_caret(self, editor): + editor.setPlainText("one\ntwo") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.clear_extra_cursors() + _type(editor, "!") + assert editor.toPlainText().count("!") == 1 + + def test_adding_without_a_selection_does_nothing(self, editor): + editor.setPlainText("one\ntwo") + assert editor.add_cursors_to_selected_lines() == 0 + + def test_toggling_the_same_position_twice_removes_it(self, editor): + editor.setPlainText("hello") + editor.multi_cursor_manager.toggle_at(2) + assert editor.multi_cursor_manager.positions() == [2] + editor.multi_cursor_manager.toggle_at(2) + assert editor.multi_cursor_manager.positions() == [] + + def test_backspace_at_the_document_start_is_refused(self, editor): + editor.setPlainText("abc") + editor.multi_cursor_manager.toggle_at(0) + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + assert editor.multi_cursor_manager.delete_before() is False + assert editor.toPlainText() == "abc" + + def test_delete_reaches_every_line(self, editor): + editor.setPlainText("one!\ntwo!\nthree!") + _select_all(editor) + editor.add_cursors_to_selected_lines() + # Each caret sits at its line end, so Delete takes the newline after it. + editor.multi_cursor_manager.move_all(-1) + cursor = editor.textCursor() + cursor.setPosition(cursor.position() - 1) + editor.setTextCursor(cursor) + _press(editor, Qt.Key.Key_Delete) + assert editor.toPlainText() == "one\ntwo\nthree" + + def test_delete_at_the_document_end_is_refused(self, editor): + editor.setPlainText("abc") + editor.multi_cursor_manager.toggle_at(3) + assert editor.multi_cursor_manager.delete_after() is False + assert editor.toPlainText() == "abc" + + def test_enter_splits_every_line(self, editor): + editor.setPlainText("ab\ncd") + _select_all(editor) + editor.add_cursors_to_selected_lines() + _press(editor, Qt.Key.Key_Return) + assert editor.toPlainText() == "ab\n\ncd\n" + + def test_arrow_keys_move_the_extra_carets(self, editor): + editor.setPlainText("hello world") + editor.multi_cursor_manager.toggle_at(5) + _press(editor, Qt.Key.Key_Right) + assert editor.multi_cursor_manager.positions() == [6] + _press(editor, Qt.Key.Key_Left) + assert editor.multi_cursor_manager.positions() == [5] + + def test_carets_never_move_outside_the_document(self, editor): + editor.setPlainText("ab") + editor.multi_cursor_manager.toggle_at(0) + editor.multi_cursor_manager.move_all(-5) + assert editor.multi_cursor_manager.positions() == [0] + editor.multi_cursor_manager.move_all(99) + assert editor.multi_cursor_manager.positions() == [2] + + def test_a_caret_can_be_added_on_the_line_below(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + assert editor.add_cursor_below() is True + assert editor.multi_cursor_manager.positions() == [8] + + def test_a_caret_can_be_added_on_the_line_above(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + cursor = editor.textCursor() + cursor.setPosition(8) + editor.setTextCursor(cursor) + assert editor.add_cursor_above() is True + assert editor.multi_cursor_manager.positions() == [2] + + def test_no_caret_beyond_the_first_or_last_line(self, editor): + editor.setPlainText("only one line") + assert editor.add_cursor_above() is False + assert editor.add_cursor_below() is False + + def test_a_short_line_clamps_the_column(self, editor): + editor.setPlainText("longer line\nab") + cursor = editor.textCursor() + cursor.setPosition(9) + editor.setTextCursor(cursor) + editor.add_cursor_below() + assert editor.multi_cursor_manager.positions() == [14] + + def test_next_occurrence_adds_a_caret(self, editor): + editor.setPlainText("name = name + name") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + assert editor.add_cursor_at_next_occurrence() is True + assert editor.multi_cursor_manager.positions() == [11] + + def test_next_occurrence_of_a_unique_word_wraps_to_itself(self, editor): + editor.setPlainText("unique word") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + editor.add_cursor_at_next_occurrence() + assert editor.multi_cursor_manager.positions() == [6] + + def test_next_occurrence_without_a_word_does_nothing(self, editor): + editor.setPlainText(" ") + cursor = editor.textCursor() + cursor.setPosition(1) + editor.setTextCursor(cursor) + assert editor.add_cursor_at_next_occurrence() is False + + def test_typing_after_next_occurrence_changes_both(self, editor): + editor.setPlainText("name = name") + cursor = editor.textCursor() + cursor.setPosition(4) + editor.setTextCursor(cursor) + editor.add_cursor_at_next_occurrence() + _type(editor, "s") + assert editor.toPlainText() == "names = names" + + def test_column_selection_puts_a_caret_on_each_line(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + # Drag from column 2 of line 0 to column 2 of line 2. + extra = editor.multi_cursor_manager.select_column(2, 14) + assert extra == 2 + assert editor.textCursor().blockNumber() == 2 + + def test_column_selection_covers_every_line_between(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + editor.multi_cursor_manager.select_column(2, 14) + lines = { + editor.document().findBlock(position).blockNumber() + for position in editor.multi_cursor_manager.positions() + } + assert lines == {0, 1} + + def test_column_selection_clamps_short_lines(self, editor): + editor.setPlainText("a longer line\nab\nanother line") + editor.multi_cursor_manager.select_column(8, 8 + 14 + 8) + for position in editor.multi_cursor_manager.positions(): + block = editor.document().findBlock(position) + assert position - block.position() <= len(block.text()) + + def test_dragging_upwards_works_the_same(self, editor): + editor.setPlainText("alpha\nbeta\ngamma") + assert editor.multi_cursor_manager.select_column(14, 2) == 2 + assert editor.textCursor().blockNumber() == 0 + + def test_typing_after_a_column_selection_edits_each_line(self, editor): + editor.setPlainText("aaa\nbbb\nccc") + editor.multi_cursor_manager.select_column(0, 8) + _type(editor, "-") + assert editor.toPlainText() == "-aaa\n-bbb\n-ccc" + + def test_painting_extra_cursors_does_not_raise(self, editor): + editor.setPlainText("one\ntwo\nthree") + _select_all(editor) + editor.add_cursors_to_selected_lines() + editor.show() + QApplication.processEvents() + editor.hide() diff --git a/test/test_problems_panel.py b/test/test_problems_panel.py new file mode 100644 index 00000000..6ae68ae5 --- /dev/null +++ b/test/test_problems_panel.py @@ -0,0 +1,205 @@ +"""Tests for severity, project-wide checking and applying fixes.""" +from __future__ import annotations + +import json +import threading +from unittest.mock import MagicMock, patch + +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.utils.lint.ruff_diagnostics import ( + SEVERITY_ERROR, + SEVERITY_INFO, + SEVERITY_WARNING, + Diagnostic, + parse_ruff_json, + severity_for_code, +) + + +# Long enough that a slow machine still finishes; a hang fails rather than blocks. +WORKER_TIMEOUT_MS = 10_000 + + +class TestSeverity: + @pytest.mark.parametrize("code,expected", [ + ("F401", SEVERITY_ERROR), + ("E501", SEVERITY_ERROR), + ("W291", SEVERITY_WARNING), + ("B008", SEVERITY_WARNING), + ("S603", SEVERITY_WARNING), + ("D100", SEVERITY_INFO), + ]) + def test_code_prefix_decides(self, code, expected): + assert severity_for_code(code) == expected + + def test_a_syntax_error_is_an_error(self): + assert severity_for_code("") == SEVERITY_ERROR + assert severity_for_code("SyntaxError") == SEVERITY_ERROR + + def test_diagnostic_derives_its_level(self): + diagnostic = Diagnostic( + line=1, column=1, end_line=1, end_column=2, code="W291", message="x") + assert diagnostic.level == SEVERITY_WARNING + + def test_an_explicit_severity_wins(self): + diagnostic = Diagnostic( + line=1, column=1, end_line=1, end_column=2, code="W291", message="x", + severity=SEVERITY_ERROR) + assert diagnostic.level == SEVERITY_ERROR + + def test_ruff_output_carries_the_file(self): + output = json.dumps([{ + "code": "F401", "message": "unused", + "filename": "/project/app.py", + "location": {"row": 1, "column": 1}, + "end_location": {"row": 1, "column": 5}, + }]) + assert parse_ruff_json(output)[0].file_path == "/project/app.py" + + +class TestProjectLint: + def test_finds_problems_across_files(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + (tmp_path / "one.py").write_text("import os\n", encoding="utf-8") + (tmp_path / "two.py").write_text("import sys\n", encoding="utf-8") + diagnostics = lint_project(tmp_path) + assert len(diagnostics) >= 2 + # Each finding says which file it came from, which is what makes a + # project-wide list navigable. + assert all(item.file_path for item in diagnostics) + assert len({item.file_path for item in diagnostics}) == 2 + + def test_a_clean_project_reports_nothing(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + (tmp_path / "clean.py").write_text("print('ok')\n", encoding="utf-8") + assert lint_project(tmp_path) == [] + + def test_missing_ruff_yields_nothing(self, tmp_path): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert lint_project(tmp_path) == [] + + +class TestApplyFixes: + def test_removes_an_unused_import(self, tmp_path): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + target = tmp_path / "fixable.py" + target.write_text("import os\nprint('hello')\n", encoding="utf-8") + assert apply_fixes(target) is True + assert "import os" not in target.read_text(encoding="utf-8") + + def test_missing_ruff_is_reported(self, tmp_path): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert apply_fixes(tmp_path) is False + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class _FakeTabWidget: + def __init__(self, widget=None): + self._widget = widget + + def currentWidget(self): + return self._widget + + +@pytest.fixture() +def panel(app): + from je_editor.pyside_ui.main_ui.problems_panel.problems_panel_widget import ( + ProblemsPanelWidget + ) + window = MagicMock() + window.tab_widget = _FakeTabWidget(None) + widget = ProblemsPanelWidget(window) + yield widget + widget.close() + widget.deleteLater() + + +def _diagnostics() -> list[Diagnostic]: + return [ + Diagnostic(line=1, column=1, end_line=1, end_column=2, code="F401", message="unused"), + Diagnostic(line=2, column=1, end_line=2, end_column=2, code="W291", message="space"), + Diagnostic(line=3, column=1, end_line=3, end_column=2, code="D100", message="docstring"), + ] + + +class TestSeverityFilter: + def test_everything_is_shown_by_default(self, panel): + panel._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)) + 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)) + 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)) + assert panel.result_tree.topLevelItemCount() == 1 + + +class TestProjectScope: + """ + The project check spawns ruff over every file, so it runs on a worker + thread: the panel gets its result through a signal rather than a return + value, and the window stays responsive while it runs. + """ + + def _run_project_check(self, panel, trigger=None): + """Start a project check and wait for it to report back.""" + with patch( + "je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", + return_value=_diagnostics(), + ) as mock_lint: + if trigger is None: + panel.project_check.setChecked(True) + else: + trigger() + worker = panel._project_worker + assert worker is not None, "no background check was started" + worker.wait(WORKER_TIMEOUT_MS) + QApplication.processEvents() + return mock_lint + + def test_project_mode_lints_the_root(self, panel, tmp_path): + assert self._run_project_check(panel).called + + def test_the_result_reaches_the_panel(self, panel): + self._run_project_check(panel) + assert len(panel.diagnostics()) == 3 + + def test_the_check_does_not_run_on_the_ui_thread(self, panel): + seen: list[int] = [] + with patch( + "je_editor.pyside_ui.main_ui.problems_panel.project_lint_worker.lint_project", + side_effect=lambda root: seen.append(threading.get_ident()) or [], + ): + panel.project_check.setChecked(True) + worker = panel._project_worker + assert worker is not None + worker.wait(WORKER_TIMEOUT_MS) + assert seen and seen[0] != threading.get_ident() + + def test_rechecking_replaces_the_previous_run(self, panel): + self._run_project_check(panel) + self._run_project_check(panel, trigger=panel.refresh) + assert len(panel.diagnostics()) == 3 + + def test_fixing_without_a_file_does_nothing(self, panel): + panel.project_check.setChecked(False) + assert panel.apply_available_fixes() is False diff --git a/test/test_pytest_panel.py b/test/test_pytest_panel.py new file mode 100644 index 00000000..7f4f1e57 --- /dev/null +++ b/test/test_pytest_panel.py @@ -0,0 +1,214 @@ +"""Tests for parsing pytest output and showing it in the test panel.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.test_runner.pytest_output import ( + PytestResult, + failure_for_result, + parse_failures, + parse_results, + parse_summary, +) + +SAMPLE_OUTPUT = """ +============================= test session starts ============================= +collected 3 items + +test/test_alpha.py::TestAlpha::test_one PASSED [ 33%] +test/test_alpha.py::TestAlpha::test_two FAILED [ 66%] +test/test_beta.py::test_three SKIPPED [100%] + +=================================== FAILURES ================================== +D:\\project\\test\\test_alpha.py:42: AssertionError: expected 1 got 2 +=========================== short test summary info =========================== +========================= 1 failed, 1 passed, 1 skipped in 0.42s ============== +""" + + +class TestParseResults: + def test_every_reported_test_is_read(self): + assert len(parse_results(SAMPLE_OUTPUT)) == 3 + + def test_outcomes_are_kept(self): + outcomes = [result.outcome for result in parse_results(SAMPLE_OUTPUT)] + assert outcomes == ["PASSED", "FAILED", "SKIPPED"] + + def test_node_is_split_into_file_and_name(self): + result = parse_results(SAMPLE_OUTPUT)[0] + assert result.file_path == "test/test_alpha.py" + assert result.name == "TestAlpha::test_one" + + def test_failed_flag(self): + results = parse_results(SAMPLE_OUTPUT) + assert results[1].failed is True + assert results[0].failed is False + assert results[2].failed is False + + def test_an_error_counts_as_a_failure(self): + assert parse_results("test/x.py::test_a ERROR [100%]")[0].failed is True + + def test_output_without_results(self): + assert parse_results("collected 0 items\n") == [] + + def test_empty_output(self): + assert parse_results("") == [] + + +class TestParseFailures: + def test_failure_location_is_read(self): + failures = parse_failures(SAMPLE_OUTPUT) + assert len(failures) == 1 + assert failures[0].line == 42 + assert failures[0].path.endswith("test_alpha.py") + + def test_message_is_kept(self): + assert "expected 1 got 2" in parse_failures(SAMPLE_OUTPUT)[0].message + + def test_result_lines_are_not_mistaken_for_locations(self): + assert parse_failures("test/test_a.py::test_one PASSED [100%]") == [] + + def test_duplicates_are_dropped(self): + repeated = "x.py:10: boom\nx.py:10: boom\n" + assert len(parse_failures(repeated)) == 1 + + def test_no_failures(self): + assert parse_failures("all good\n") == [] + + +class TestParseSummary: + def test_summary_is_read(self): + assert "1 failed" in parse_summary(SAMPLE_OUTPUT) + + def test_output_without_a_summary(self): + assert parse_summary("nothing here\n") == "" + + +class TestFailureForResult: + def test_matches_by_file(self): + results = parse_results(SAMPLE_OUTPUT) + failure = failure_for_result(results[1], parse_failures(SAMPLE_OUTPUT)) + assert failure is not None and failure.line == 42 + + def test_passing_test_has_no_failure(self): + results = parse_results(SAMPLE_OUTPUT) + assert failure_for_result(results[0], parse_failures(SAMPLE_OUTPUT)) is None + + def test_failure_in_another_file_is_not_matched(self): + result = PytestResult(node_id="test/test_other.py::test_x", outcome="FAILED") + assert failure_for_result(result, parse_failures(SAMPLE_OUTPUT)) is None + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def panel(app, tmp_path): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget + widget = TestPanelWidget(main_window=None, working_dir=str(tmp_path)) + yield widget + widget.close() + widget.deleteLater() + + +class TestTestPanel: + def test_starts_empty(self, panel): + assert panel.results() == [] + assert panel.result_tree.topLevelItemCount() == 0 + + def test_output_fills_the_tree(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.result_tree.topLevelItemCount() == 3 + + def test_failures_are_listed_first(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.result_tree.topLevelItem(0).text(0) == "FAILED" + + def test_summary_reaches_the_status_label(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert "1 failed" in panel.status_label.text() + + def test_output_without_tests_says_so(self, panel): + panel.apply_output("collected 0 items\n") + assert panel.status_label.text() != "" + assert panel.results() == [] + + def test_opening_without_a_window_is_safe(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.open_result(panel.results()[0]) is False + + def test_opening_a_failure_jumps_to_its_line(self, app, tmp_path): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import TestPanelWidget + window = MagicMock() + window.tab_widget = MagicMock() + window.tab_widget.currentWidget.return_value = None + panel = TestPanelWidget(main_window=window, working_dir=str(tmp_path)) + panel.apply_output(SAMPLE_OUTPUT) + failed = [result for result in panel.results() if result.failed][0] + assert panel.open_result(failed) is True + window.go_to_new_tab.assert_called_once() + panel.close() + panel.deleteLater() + + def test_command_is_an_argument_list(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command() + assert command[1:3] == ["-m", "pytest"] + assert "-v" in command and "--tb=line" in command + + def test_command_can_target_specific_tests(self): + from je_editor.pyside_ui.main_ui.test_panel.test_panel_widget import pytest_command + command = pytest_command(["test/test_a.py::test_one"]) + assert command[-1] == "test/test_a.py::test_one" + + +class TestTargetedRuns: + def test_failed_node_ids_are_collected(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert panel.failed_node_ids() == ["test/test_alpha.py::TestAlpha::test_two"] + + def test_rerunning_failures_without_any_does_nothing(self, panel): + panel.apply_output("collected 0 items\n") + assert panel.start_failure_run() is False + + def test_running_the_selection_without_one_does_nothing(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.result_tree.clearSelection() + assert panel.start_selected_run() is False + + def test_selected_node_ids_follow_the_tree(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.result_tree.topLevelItem(0).setSelected(True) + assert panel.selected_node_ids() == ["test/test_alpha.py::TestAlpha::test_two"] + + +class TestFiltering: + def test_an_empty_filter_shows_everything(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + assert len(panel.visible_results()) == 3 + + def test_filtering_narrows_the_list(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("beta") + assert [result.file_path for result in panel.visible_results()] == ["test/test_beta.py"] + assert panel.result_tree.topLevelItemCount() == 1 + + def test_filtering_is_case_insensitive(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("ALPHA") + assert len(panel.visible_results()) == 2 + + def test_a_filter_matching_nothing_empties_the_list(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("nothing matches this") + assert panel.visible_results() == [] + + def test_failures_still_come_first_when_filtered(self, panel): + panel.apply_output(SAMPLE_OUTPUT) + panel.filter_edit.setText("alpha") + assert panel.visible_results()[0].failed is True diff --git a/test/test_revert_hunk.py b/test/test_revert_hunk.py new file mode 100644 index 00000000..141bdf04 --- /dev/null +++ b/test/test_revert_hunk.py @@ -0,0 +1,157 @@ +"""Tests for hunk detection and reverting a change back to its committed form.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.file_diff.line_status import ( + baseline_lines_of, + hunk_at_line, + hunks, +) + + +class TestHunks: + def test_no_change_has_no_hunks(self): + assert hunks("a\nb\n", "a\nb\n") == [] + + def test_a_modified_line(self): + found = hunks("a\nb\nc\n", "a\nB\nc\n") + assert len(found) == 1 + assert (found[0].start, found[0].end) == (1, 2) + assert (found[0].baseline_start, found[0].baseline_end) == (1, 2) + + def test_an_inserted_line_spans_no_baseline(self): + found = hunks("a\nc\n", "a\nb\nc\n") + assert (found[0].start, found[0].end) == (1, 2) + assert found[0].baseline_start == found[0].baseline_end + + def test_a_deleted_line_spans_no_current_lines(self): + found = hunks("a\nb\nc\n", "a\nc\n") + assert found[0].is_pure_deletion + assert (found[0].baseline_start, found[0].baseline_end) == (1, 2) + + def test_several_hunks_are_reported_in_order(self): + found = hunks("a\nb\nc\nd\n", "A\nb\nc\nD\n") + assert [hunk.start for hunk in found] == [0, 3] + + def test_hunk_at_line_finds_the_containing_hunk(self): + hunk = hunk_at_line("a\nb\nc\n", "a\nB\nc\n", 1) + assert hunk is not None and hunk.start == 1 + + def test_hunk_at_an_unchanged_line(self): + assert hunk_at_line("a\nb\nc\n", "a\nB\nc\n", 0) is None + + def test_hunk_at_a_deletion_point(self): + hunk = hunk_at_line("a\nb\nc\n", "a\nc\n", 1) + assert hunk is not None and hunk.is_pure_deletion + + def test_baseline_lines_of_a_hunk(self): + hunk = hunk_at_line("a\nold\nc\n", "a\nnew\nc\n", 1) + assert baseline_lines_of("a\nold\nc\n", hunk) == ["old"] + + def test_baseline_lines_of_an_insertion_is_empty(self): + hunk = hunk_at_line("a\nc\n", "a\nb\nc\n", 1) + assert baseline_lines_of("a\nc\n", hunk) == [] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _place_cursor(editor, line: int) -> None: + block = editor.document().findBlockByNumber(line) + cursor = editor.textCursor() + cursor.setPosition(block.position()) + editor.setTextCursor(cursor) + + +class TestRevertChange: + def test_modified_line_goes_back_to_the_committed_text(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\n" + + def test_added_line_is_removed(self, editor): + editor.setPlainText("a\nextra\nb\n") + editor.diff_marker_manager.set_baseline("a\nb\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\n" + + def test_deleted_line_comes_back(self, editor): + editor.setPlainText("a\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\n" + + def test_only_the_hunk_under_the_caret_is_reverted(self, editor): + editor.setPlainText("A\nb\nC\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 0) + editor.revert_change_at_cursor() + assert editor.toPlainText() == "a\nb\nC\n" + + def test_reverting_an_unchanged_line_does_nothing(self, editor): + editor.setPlainText("a\nB\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 0) + assert editor.revert_change_at_cursor() is False + assert editor.toPlainText() == "a\nB\nc\n" + + def test_reverting_without_a_baseline_does_nothing(self, editor): + editor.setPlainText("a\nb\n") + _place_cursor(editor, 0) + assert editor.revert_change_at_cursor() is False + + def test_revert_is_a_single_undo_step(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + editor.revert_change_at_cursor() + editor.undo() + assert editor.toPlainText() == "a\nCHANGED\nc\n" + + def test_markers_are_updated_after_a_revert(self, editor): + editor.setPlainText("a\nCHANGED\nc\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\n") + _place_cursor(editor, 1) + editor.revert_change_at_cursor() + assert editor.diff_marker_manager.statuses() == {} + + def test_added_line_at_the_end_of_file_is_removed(self, editor): + editor.setPlainText("a\nb\nextra") + editor.diff_marker_manager.set_baseline("a\nb") + _place_cursor(editor, 2) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb" + + def test_reverting_a_multi_line_hunk(self, editor): + editor.setPlainText("a\nX\nY\nd\n") + editor.diff_marker_manager.set_baseline("a\nb\nc\nd\n") + _place_cursor(editor, 1) + assert editor.revert_change_at_cursor() is True + assert editor.toPlainText() == "a\nb\nc\nd\n" diff --git a/test/test_ruff_diagnostics.py b/test/test_ruff_diagnostics.py new file mode 100644 index 00000000..e385c6b3 --- /dev/null +++ b/test/test_ruff_diagnostics.py @@ -0,0 +1,115 @@ +"""Tests for parsing ruff's JSON output into diagnostics.""" +from __future__ import annotations + +import json + +from je_editor.utils.lint.ruff_diagnostics import ( + SYNTAX_ERROR_CODE, + Diagnostic, + diagnostics_by_line, + message_for_line, + parse_ruff_json, +) + + +def _entry(**overrides) -> dict: + entry = { + "code": "F401", + "message": "`os` imported but unused", + "filename": "/project/app.py", + "location": {"row": 1, "column": 8}, + "end_location": {"row": 1, "column": 10}, + "fix": None, + "noqa_row": 1, + "url": "https://docs.astral.sh/ruff/rules/unused-import", + } + entry.update(overrides) + return entry + + +class TestParseRuffJson: + def test_single_finding(self): + found = parse_ruff_json(json.dumps([_entry()])) + assert found == [Diagnostic( + line=1, column=8, end_line=1, end_column=10, + code="F401", message="`os` imported but unused", + file_path="/project/app.py")] + + def test_several_findings_keep_their_order(self): + output = json.dumps([ + _entry(location={"row": 3, "column": 1}), + _entry(location={"row": 1, "column": 1}), + ]) + assert [item.line for item in parse_ruff_json(output)] == [3, 1] + + def test_empty_output(self): + assert parse_ruff_json("") == [] + assert parse_ruff_json(" \n") == [] + + def test_empty_json_array(self): + assert parse_ruff_json("[]") == [] + + def test_invalid_json_is_ignored(self): + assert parse_ruff_json("ruff: command failed") == [] + + def test_json_object_instead_of_array_is_ignored(self): + assert parse_ruff_json('{"error": "boom"}') == [] + + def test_entry_without_a_message_is_skipped(self): + assert parse_ruff_json(json.dumps([_entry(message=None)])) == [] + + def test_entry_that_is_not_an_object_is_skipped(self): + assert parse_ruff_json(json.dumps(["nonsense", _entry()])) == [ + parse_ruff_json(json.dumps([_entry()]))[0]] + + def test_syntax_error_without_a_code(self): + found = parse_ruff_json(json.dumps([_entry(code=None, message="SyntaxError: bad")])) + assert found[0].code == SYNTAX_ERROR_CODE + assert found[0].label == "SyntaxError SyntaxError: bad" + + def test_missing_location_falls_back_to_the_first_line(self): + found = parse_ruff_json(json.dumps([_entry(location=None, end_location=None)])) + assert (found[0].line, found[0].column) == (1, 1) + + def test_missing_end_location_falls_back_to_the_start(self): + found = parse_ruff_json(json.dumps([_entry(end_location=None)])) + assert (found[0].end_line, found[0].end_column) == (1, 8) + + def test_end_before_start_is_clamped(self): + found = parse_ruff_json(json.dumps([ + _entry(location={"row": 5, "column": 2}, end_location={"row": 2, "column": 1})])) + assert found[0].end_line == 5 + + def test_non_integer_position_is_ignored(self): + found = parse_ruff_json(json.dumps([_entry(location={"row": "x", "column": None})])) + assert (found[0].line, found[0].column) == (1, 1) + + def test_label_includes_the_code(self): + assert parse_ruff_json(json.dumps([_entry()]))[0].label.startswith("F401 ") + + +class TestGrouping: + def test_diagnostics_by_line(self): + output = json.dumps([ + _entry(location={"row": 2, "column": 1}), + _entry(location={"row": 2, "column": 5}), + _entry(location={"row": 7, "column": 1}), + ]) + grouped = diagnostics_by_line(parse_ruff_json(output)) + assert sorted(grouped) == [2, 7] + assert len(grouped[2]) == 2 + + def test_message_for_line_joins_every_finding(self): + output = json.dumps([ + _entry(location={"row": 2, "column": 1}, message="first"), + _entry(location={"row": 2, "column": 5}, message="second"), + ]) + message = message_for_line(parse_ruff_json(output), 2) + assert "first" in message and "second" in message + assert message.count("\n") == 1 + + def test_message_for_a_clean_line(self): + assert message_for_line(parse_ruff_json(json.dumps([_entry()])), 99) is None + + def test_message_with_no_diagnostics(self): + assert message_for_line([], 1) is None diff --git a/test/test_ruff_lint_runner.py b/test/test_ruff_lint_runner.py new file mode 100644 index 00000000..760d8692 --- /dev/null +++ b/test/test_ruff_lint_runner.py @@ -0,0 +1,71 @@ +"""Tests for running ruff over a buffer.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from je_editor.code_scan.ruff_lint import ( + find_ruff_executable, + is_lintable, + lint_command, + lint_text, +) + +UNUSED_IMPORT = "import os\n\n\nprint('hello')\n" + + +class TestIsLintable: + @pytest.mark.parametrize("name", ["a.py", "a.pyi", "PACKAGE/MODULE.PY"]) + def test_python_files(self, name): + assert is_lintable(name) + + @pytest.mark.parametrize("name", ["notes.txt", "page.html", "Makefile", "a.pyc"]) + def test_other_files(self, name): + assert not is_lintable(name) + + def test_no_file(self): + assert not is_lintable(None) + + +class TestLintCommand: + def test_passes_the_buffer_through_stdin(self): + command = lint_command("ruff", Path("/project/app.py")) + assert command[-1] == "-" + assert "--stdin-filename" in command + + def test_asks_for_json(self): + command = lint_command("ruff", "app.py") + assert command[command.index("--output-format") + 1] == "json" + + def test_is_an_argument_list_not_a_shell_string(self): + # A shell string would make the file name injectable. + assert all(isinstance(part, str) for part in lint_command("ruff", "a b.py")) + assert lint_command("ruff", "a b.py")[0] == "ruff" + + +class TestLintText: + def test_reports_an_unused_import(self): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + diagnostics = lint_text(UNUSED_IMPORT, "sample.py") + assert any(item.code == "F401" for item in diagnostics) + + def test_clean_code_reports_nothing(self): + if find_ruff_executable() is None: + pytest.skip("ruff is not installed in this environment") + assert lint_text("print('hello')\n", "sample.py") == [] + + def test_missing_ruff_yields_no_diagnostics(self): + with patch("je_editor.code_scan.ruff_lint.find_ruff_executable", return_value=None): + assert lint_text(UNUSED_IMPORT, "sample.py") == [] + + def test_a_failing_ruff_yields_no_diagnostics(self): + # The editor must keep working when the linter cannot run at all. + with patch( + "je_editor.code_scan.ruff_lint.find_ruff_executable", return_value="ruff" + ), patch( + "je_editor.code_scan.ruff_lint.subprocess.run", side_effect=OSError("boom") + ): + assert lint_text(UNUSED_IMPORT, "sample.py") == [] diff --git a/test/test_session_state.py b/test/test_session_state.py new file mode 100644 index 00000000..e07caa06 --- /dev/null +++ b/test/test_session_state.py @@ -0,0 +1,150 @@ +"""Tests for persisting and restoring per-file editor state in the session.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from je_editor.utils.session.editor_state import editor_state, restore_editor_state +from je_editor.utils.session.open_files_session import ( + MAX_STATE_LINES, + build_file_state, + collect_file_states, + restorable_file_state, +) + + +class TestBuildFileState: + def test_keeps_caret_bookmarks_and_folds(self): + state = build_file_state(4, [2, 7], [1]) + assert state == {"caret_line": 4, "bookmarks": [2, 7], "folds": [1]} + + def test_lines_are_sorted_and_deduplicated(self): + assert build_file_state(0, [5, 1, 5], [])["bookmarks"] == [1, 5] + + def test_negative_lines_are_dropped(self): + assert build_file_state(0, [-3, 2], [])["bookmarks"] == [2] + + def test_booleans_are_not_treated_as_lines(self): + assert build_file_state(0, [True, 3], [])["bookmarks"] == [3] + + def test_negative_caret_is_clamped(self): + assert build_file_state(-5, [], [])["caret_line"] == 0 + + def test_non_list_values_become_empty(self): + assert build_file_state(0, "nonsense", None)["bookmarks"] == [] + + def test_line_count_is_capped(self): + state = build_file_state(0, list(range(MAX_STATE_LINES + 50)), []) + assert len(state["bookmarks"]) == MAX_STATE_LINES + + +class TestCollectFileStates: + def test_collects_each_file(self): + collected = collect_file_states({ + "a.py": {"caret_line": 3, "bookmarks": [1], "folds": []}, + "b.py": {"caret_line": 0, "bookmarks": [], "folds": [2]}, + }) + assert collected["a.py"]["caret_line"] == 3 + assert collected["b.py"]["folds"] == [2] + + def test_entries_without_a_path_are_dropped(self): + assert collect_file_states({"": {"caret_line": 1}}) == {} + + def test_entries_that_are_not_dicts_are_dropped(self): + assert collect_file_states({"a.py": "nonsense"}) == {} + + +class TestRestorableFileState: + def test_reads_back_a_stored_state(self): + stored = {"a.py": {"caret_line": 5, "bookmarks": [2], "folds": [1]}} + assert restorable_file_state(stored, "a.py") == { + "caret_line": 5, "bookmarks": [2], "folds": [1]} + + def test_unknown_file(self): + assert restorable_file_state({"a.py": {}}, "b.py") is None + + def test_stored_value_of_the_wrong_type(self): + assert restorable_file_state("nonsense", "a.py") is None + + def test_hand_edited_entry_is_cleaned_rather_than_trusted(self): + stored = {"a.py": {"caret_line": "top", "bookmarks": ["x", 4], "folds": None}} + assert restorable_file_state(stored, "a.py") == { + "caret_line": 0, "bookmarks": [4], "folds": []} + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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) + code_editor.setPlainText("\n".join(f"line {index}" for index in range(20))) + yield code_editor + code_editor.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +@pytest.fixture() +def tab(editor): + widget = MagicMock() + widget.code_edit = editor + return widget + + +class TestEditorStateRoundTrip: + def test_state_records_the_caret_line(self, editor, tab): + editor.jump_to_line(6) + assert editor_state(tab)["caret_line"] == 5 + + def test_state_records_bookmarks(self, editor, tab): + editor.bookmark_manager.toggle(3) + assert 3 in editor_state(tab)["bookmarks"] + + def test_restoring_moves_the_caret(self, editor, tab): + restore_editor_state(tab, {"caret_line": 8, "bookmarks": [], "folds": []}) + assert editor.textCursor().blockNumber() == 8 + + def test_restoring_brings_bookmarks_back(self, editor, tab): + restore_editor_state(tab, {"caret_line": 0, "bookmarks": [2, 5], "folds": []}) + assert set(editor.bookmark_manager.bookmarked_lines()) >= {2, 5} + + def test_round_trip_through_the_session_format(self, editor, tab): + editor.bookmark_manager.toggle(4) + editor.jump_to_line(7) + stored = collect_file_states({"a.py": editor_state(tab)}) + state = restorable_file_state(stored, "a.py") + # A fresh editor state, then restore into it + editor.bookmark_manager.toggle(4) + editor.jump_to_line(1) + restore_editor_state(tab, state) + assert editor.textCursor().blockNumber() == 6 + assert 4 in editor.bookmark_manager.bookmarked_lines() + + def test_lines_past_the_end_are_skipped(self, editor, tab): + assert restore_editor_state( + tab, {"caret_line": 999, "bookmarks": [900], "folds": []}) is True + assert editor.textCursor().blockNumber() <= editor.blockCount() - 1 + + def test_no_state_is_a_no_op(self, tab): + assert restore_editor_state(tab, None) is False + assert restore_editor_state(tab, {}) is False + + def test_no_widget_is_a_no_op(self): + assert restore_editor_state(None, {"caret_line": 1}) is False + + def test_a_widget_without_an_editor_is_a_no_op(self): + assert restore_editor_state(object(), {"caret_line": 1}) is False diff --git a/test/test_shortcut_registry.py b/test/test_shortcut_registry.py new file mode 100644 index 00000000..b2ebdcf1 --- /dev/null +++ b/test/test_shortcut_registry.py @@ -0,0 +1,148 @@ +"""Tests that key sequences are normalised and that nothing claims one twice.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from je_editor.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, ShortcutRegistry, normalise_sequence +) + + +class TestNormalising: + def test_case_is_levelled(self): + assert normalise_sequence("Ctrl+Shift+f") == normalise_sequence("ctrl+shift+F") + + def test_modifier_order_does_not_matter(self): + assert normalise_sequence("Shift+Ctrl+A") == normalise_sequence("Ctrl+Shift+A") + + def test_modifier_aliases_agree(self): + assert normalise_sequence("Control+Alt+S") == normalise_sequence("Ctrl+Option+S") + + def test_a_plain_key_survives(self): + assert normalise_sequence("F7") == "f7" + + def test_plus_can_be_the_key(self): + assert normalise_sequence("Ctrl++") == "ctrl++" + + def test_blank_means_unassigned(self): + assert normalise_sequence(" ") == "" + + def test_surrounding_space_is_ignored(self): + assert normalise_sequence(" Ctrl + G ") == "ctrl+g" + + def test_different_keys_stay_different(self): + assert normalise_sequence("Ctrl+D") != normalise_sequence("Ctrl+Alt+D") + + +class TestRegistry: + def test_an_unused_sequence_is_accepted(self): + registry = ShortcutRegistry() + assert registry.register("Ctrl+Alt+N", "next_occurrence") is None + + def test_the_owner_can_be_looked_up(self): + registry = ShortcutRegistry() + registry.register("Ctrl+Alt+N", "next_occurrence") + assert registry.owner_of("ctrl+alt+n") == "next_occurrence" + + def test_a_second_claim_names_the_first_owner(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + assert registry.register("Ctrl+D", "next_occurrence") == "duplicate_line" + + def test_the_first_owner_keeps_the_sequence(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + assert registry.owner_of("Ctrl+D") == "duplicate_line" + + def test_registering_the_same_command_twice_is_not_a_clash(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + assert registry.register("Ctrl+D", "duplicate_line") is None + + def test_clashes_are_collected(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + assert registry.conflicts() == [("ctrl+d", "duplicate_line", "next_occurrence")] + + def test_reserved_sequences_are_already_taken(self): + registry = ShortcutRegistry({"Ctrl+Shift+P": "pip_install"}) + assert registry.register("Ctrl+Shift+P", "play_macro") == "pip_install" + + def test_register_all_reports_only_its_own_clashes(self): + registry = ShortcutRegistry() + registry.register("Ctrl+D", "duplicate_line") + registry.register("Ctrl+D", "next_occurrence") + clashes = registry.register_all([("Ctrl+G", "go_to_line"), ("Ctrl+G", "other")]) + assert clashes == [("ctrl+g", "go_to_line", "other")] + + def test_a_blank_sequence_is_ignored(self): + registry = ShortcutRegistry() + assert registry.register("", "nothing") is None + assert registry.commands() == {} + + +class TestWindowShortcutTable: + """ + The reserved table has to describe what the menus and toolbar really set. + + The toolbar side of that check lives in ``test_toolbar_actions``, next to the + fixture that builds a real toolbar. + """ + + def test_no_sequence_is_listed_twice(self): + normalised = [normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS] + assert len(normalised) == len(set(normalised)) + + def test_save_all_moved_off_the_editors_sort_shortcut(self): + assert normalise_sequence("Ctrl+Alt+S") not in { + normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + + +@pytest.fixture() +def editor(qapp, qtbot): + """One editor, with its lifetime handled by qtbot rather than by hand.""" + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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) + qtbot.addWidget(code_editor) + return code_editor + + +class TestTheEditorHasNoClashes: + """ + Two actions sharing a sequence make Qt run neither, so a clash silently + disables both features. These walk the real actions rather than a list. + """ + + def test_the_registry_recorded_no_conflict(self, editor): + assert editor.shortcut_registry.conflicts() == [] + + def test_no_two_actions_share_a_sequence(self, editor): + sequences = [ + normalise_sequence(action.shortcut().toString()) + for action in editor.actions() + if action.shortcut().toString() + ] + assert len(sequences) == len(set(sequences)) + + def test_no_action_takes_a_menu_sequence(self, editor): + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + taken = { + normalise_sequence(action.shortcut().toString()) + for action in editor.actions() + if action.shortcut().toString() + } + assert taken & reserved == set() + + def test_duplicate_line_still_owns_control_d(self, editor): + # Ctrl+D is handled in keyPressEvent; no action may shadow it. + assert editor.shortcut_registry.owner_of("Ctrl+D") is None diff --git a/test/test_snippet_editor_dialog.py b/test/test_snippet_editor_dialog.py new file mode 100644 index 00000000..8663d47a --- /dev/null +++ b/test/test_snippet_editor_dialog.py @@ -0,0 +1,84 @@ +"""Tests for the snippet editor dialog.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def dialog(app, tmp_path): + from je_editor.pyside_ui.dialog.snippet_dialog.snippet_editor_dialog import ( + SnippetEditorDialog + ) + path = tmp_path / "snippets.json" + with patch( + "je_editor.pyside_ui.code.snippets.snippet_manager.snippet_file_path", + return_value=path, + ): + window = MagicMock() + window.tab_widget = None + editor = SnippetEditorDialog(window) + editor._test_path = path + yield editor + editor.close() + editor.deleteLater() + + +class TestSnippetEditorDialog: + def test_the_built_in_snippets_are_listed(self, dialog): + triggers = [ + dialog.trigger_list.item(row).text() + for row in range(dialog.trigger_list.count()) + ] + assert "def" in triggers and "for" in triggers + + def test_selecting_a_trigger_shows_its_body(self, dialog): + row = [ + index for index in range(dialog.trigger_list.count()) + if dialog.trigger_list.item(index).text() == "for" + ][0] + dialog.trigger_list.setCurrentRow(row) + assert "in" in dialog.body_edit.toPlainText() + + def test_adding_creates_a_new_trigger(self, dialog): + before = dialog.trigger_list.count() + trigger = dialog.add_snippet() + assert dialog.trigger_list.count() == before + 1 + assert trigger in dialog.snippets() + + def test_adding_twice_does_not_overwrite(self, dialog): + first = dialog.add_snippet() + second = dialog.add_snippet() + assert first != second + + def test_editing_a_body_is_kept(self, dialog): + trigger = dialog.add_snippet() + dialog.body_edit.setPlainText("changed $0") + dialog.add_snippet() # moving away keeps the edited body + assert dialog.snippets()[trigger] == "changed $0" + + def test_removing_takes_it_out_of_the_list(self, dialog): + trigger = dialog.add_snippet() + assert dialog.remove_snippet() is True + assert trigger not in dialog.snippets() + + def test_removing_without_a_selection_is_refused(self, dialog): + dialog.trigger_list.setCurrentRow(-1) + assert dialog.remove_snippet() is False + + def test_saving_writes_the_file(self, dialog): + with patch( + "je_editor.pyside_ui.code.snippets.snippet_manager.snippet_file_path", + return_value=dialog._test_path, + ): + trigger = dialog.add_snippet() + dialog.body_edit.setPlainText("saved body $0") + assert dialog.save() is True + assert trigger in dialog._test_path.read_text(encoding="utf-8") diff --git a/test/test_snippets.py b/test/test_snippets.py new file mode 100644 index 00000000..a641a93d --- /dev/null +++ b/test/test_snippets.py @@ -0,0 +1,219 @@ +"""Tests for snippet expansion and its tab stops.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from je_editor.pyside_ui.code.snippets.snippet_manager import load_snippets +from je_editor.utils.snippets.snippet_expand import ( + default_snippets, + expand_snippet, + merge_snippets, +) + + +class TestExpandSnippet: + def test_plain_text_has_no_stops(self): + text, stops = expand_snippet("print('hello')") + assert text == "print('hello')" + assert stops == [] + + def test_numbered_stop_is_removed_from_the_text(self): + text, stops = expand_snippet("def $1():") + assert text == "def ():" + assert [stop.position for stop in stops] == [4] + + def test_default_value_is_kept_and_selectable(self): + text, stops = expand_snippet("def ${1:name}():") + assert text == "def name():" + assert stops[0].position == 4 + assert stops[0].length == len("name") + + def test_stops_come_back_in_order(self): + _text, stops = expand_snippet("${2:second} ${1:first}") + # $1 is visited before $2 even though it appears later. + assert stops[0].position > stops[1].position + + def test_final_stop_sorts_last(self): + _text, stops = expand_snippet("$0 ${1:first}") + assert stops[-1].position == 0 + + def test_repeated_number_keeps_the_first(self): + text, stops = expand_snippet("$1 and $1") + assert text == " and " + assert len(stops) == 1 + + def test_multiline_body(self): + text, stops = expand_snippet("for ${1:item} in ${2:rows}:\n $0") + assert text == "for item in rows:\n " + assert len(stops) == 3 + + +class TestSnippetSets: + def test_defaults_cover_common_python(self): + assert {"def", "class", "for", "if", "main"} <= set(default_snippets()) + + def test_user_snippets_are_merged_over_the_defaults(self): + merged = merge_snippets({"def": "custom $0", "mine": "body"}) + assert merged["def"] == "custom $0" + assert merged["mine"] == "body" + assert "class" in merged + + def test_a_hand_edited_file_cannot_break_the_set(self): + merged = merge_snippets({"ok": "body", 5: "bad key", "bad value": 7}) + assert merged["ok"] == "body" + assert "class" in merged + + def test_non_dict_falls_back_to_the_defaults(self): + assert merge_snippets("nonsense") == default_snippets() + + def test_loading_a_missing_file_uses_the_defaults(self, tmp_path): + assert load_snippets(tmp_path / "absent.json") == default_snippets() + + def test_loading_invalid_json_uses_the_defaults(self, tmp_path): + broken = tmp_path / "snippets.json" + broken.write_text("{not json", encoding="utf-8") + assert load_snippets(broken) == default_snippets() + + def test_loading_a_user_file(self, tmp_path): + path = tmp_path / "snippets.json" + path.write_text(json.dumps({"log": "print($0)"}), encoding="utf-8") + assert load_snippets(path)["log"] == "print($0)" + + def test_language_snippets_are_added_for_their_suffix(self): + assert "iferr" in merge_snippets(None, ".go") + assert "iferr" not in merge_snippets(None, ".py") + + def test_a_user_group_for_a_suffix_is_applied(self): + merged = merge_snippets({".go": {"mine": "body"}}, ".go") + assert merged["mine"] == "body" + + def test_a_group_for_another_suffix_is_ignored(self): + assert "mine" not in merge_snippets({".ts": {"mine": "body"}}, ".go") + + def test_a_user_definition_beats_the_language_set(self): + merged = merge_snippets({"iferr": "custom"}, ".go") + assert merged["iferr"] == "custom" + + +class TestSaveSnippets: + def test_round_trip_through_the_file(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + path = tmp_path / "snippets.json" + assert save_snippets({"mine": "body $0"}, path) is True + assert load_snippets(path)["mine"] == "body $0" + + def test_saving_creates_the_directory(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + path = tmp_path / "nested" / "snippets.json" + assert save_snippets({"a": "b"}, path) is True + assert path.is_file() + + def test_an_unwritable_path_is_reported(self, tmp_path): + from je_editor.pyside_ui.code.snippets.snippet_manager import save_snippets + # A directory where the file should be cannot be written to. + target = tmp_path / "snippets.json" + target.mkdir() + assert save_snippets({"a": "b"}, target) is False + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def editor(app): + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_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.lint_manager.stop() + code_editor.diff_marker_manager.stop() + code_editor.blame_manager.stop() + code_editor.close() + code_editor.deleteLater() + + +def _press_tab(editor) -> None: + # Sent through QTest so Qt owns the event object; a QKeyEvent built here + # could be collected while Qt still holds it. + QTest.keyClick(editor, Qt.Key.Key_Tab) + + +class TestSnippetExpansionInEditor: + def test_tab_expands_a_trigger_word(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + assert "in" in editor.toPlainText() + assert "for" in editor.toPlainText() + + def test_the_first_default_value_is_selected(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + assert editor.textCursor().selectedText() == "item" + + def test_tab_moves_to_the_next_stop(self, editor): + editor.setPlainText("for") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + _press_tab(editor) + assert editor.textCursor().selectedText() == "iterable" + + def test_an_unknown_word_is_left_alone(self, editor): + editor.setPlainText("notasnippet") + editor.moveCursor(editor.textCursor().MoveOperation.End) + assert editor.snippet_manager.expand_at_cursor() is False + + def test_expansion_is_one_undo_step(self, editor): + editor.setPlainText("if") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + editor.undo() + assert editor.toPlainText() == "if" + + def test_stops_run_out_after_the_last_one(self, editor): + editor.setPlainText("if") + editor.moveCursor(editor.textCursor().MoveOperation.End) + _press_tab(editor) + while editor.snippet_manager.has_pending_stops: + editor.snippet_manager.next_stop() + assert editor.snippet_manager.next_stop() is False + + def test_tab_still_indents_a_selection(self, editor): + editor.setPlainText("alpha\nbeta\n") + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(9, cursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + _press_tab(editor) + assert editor.toPlainText().startswith(" ") + + def test_a_typescript_file_gets_typescript_snippets(self, editor): + editor.current_file = "app.ts" + editor.snippet_manager.reload() + snippets = editor.snippet_manager.snippets() + assert "log" in snippets and "console.log" in snippets["log"] + + def test_a_python_file_keeps_the_python_set(self, editor): + editor.current_file = "module.py" + editor.snippet_manager.reload() + assert "def" in editor.snippet_manager.snippets() + + def test_reloading_picks_up_user_snippets(self, editor, tmp_path): + path = tmp_path / "snippets.json" + path.write_text(json.dumps({"zzz": "expanded $0"}), encoding="utf-8") + editor.snippet_manager.reload(path) + assert "zzz" in editor.snippet_manager.snippets() diff --git a/test/test_split_view.py b/test/test_split_view.py new file mode 100644 index 00000000..c15c414e --- /dev/null +++ b/test/test_split_view.py @@ -0,0 +1,115 @@ +"""Tests for the same-file split view.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit + +from je_editor.pyside_ui.code.split_view.split_editor_view import SplitEditorView + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def source(app): + editor = QPlainTextEdit() + editor.setPlainText("first\nsecond\nthird\n") + yield editor + editor.deleteLater() + + +class TestSplitEditorView: + def test_shows_the_same_content(self, source): + view = SplitEditorView(source) + assert view.toPlainText() == source.toPlainText() + view.close() + view.deleteLater() + + def test_shares_one_document(self, source): + view = SplitEditorView(source) + assert view.document() is source.document() + view.close() + view.deleteLater() + + def test_an_edit_in_the_split_reaches_the_main_editor(self, source): + view = SplitEditorView(source) + view.setPlainText("rewritten\n") + assert source.toPlainText() == "rewritten\n" + view.close() + view.deleteLater() + + def test_an_edit_in_the_main_editor_reaches_the_split(self, source): + view = SplitEditorView(source) + source.setPlainText("from the main editor\n") + assert view.toPlainText() == "from the main editor\n" + view.close() + view.deleteLater() + + def test_carets_are_independent(self, source): + view = SplitEditorView(source) + cursor = view.textCursor() + cursor.setPosition(view.document().findBlockByNumber(2).position()) + view.setTextCursor(cursor) + assert view.textCursor().blockNumber() == 2 + assert source.textCursor().blockNumber() == 0 + view.close() + view.deleteLater() + + def test_closing_releases_the_shared_document(self, source): + view = SplitEditorView(source) + view.close() + # The main editor keeps its document and stays usable. + source.setPlainText("still fine\n") + assert source.toPlainText() == "still fine\n" + view.deleteLater() + + +@pytest.fixture() +def editor_widget(app): + from PySide6.QtWidgets import QTabWidget + main_window = MagicMock() + main_window.working_dir = None + main_window.tab_widget = QTabWidget() + main_window.python_compiler = None + with patch( + "je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext.venv_check" + ) as mock_venv: + mock_venv.return_value = MagicMock(exists=MagicMock(return_value=False)) + from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + widget = EditorWidget(main_window) + yield widget + widget.code_edit.lint_manager.stop() + widget.code_edit.diff_marker_manager.stop() + widget.code_edit.blame_manager.stop() + widget.deleteLater() + + +class TestToggleSplitView: + def test_starts_without_a_split(self, editor_widget): + assert editor_widget.split_view is None + + def test_toggling_on_adds_the_view(self, editor_widget): + assert editor_widget.toggle_split_view() is True + assert editor_widget.split_view is not None + assert editor_widget.split_view.document() is editor_widget.code_edit.document() + + def test_toggling_off_removes_it(self, editor_widget): + editor_widget.toggle_split_view() + assert editor_widget.toggle_split_view() is False + assert editor_widget.split_view is None + + def test_the_main_editor_survives_closing_the_split(self, editor_widget): + editor_widget.code_edit.setPlainText("content\n") + editor_widget.toggle_split_view() + editor_widget.toggle_split_view() + editor_widget.code_edit.setPlainText("still editable\n") + assert editor_widget.code_edit.toPlainText() == "still editable\n" + + def test_split_shows_edits_made_after_opening(self, editor_widget): + editor_widget.toggle_split_view() + editor_widget.code_edit.setPlainText("typed later\n") + assert editor_widget.split_view.toPlainText() == "typed later\n" diff --git a/test/test_text_codec.py b/test/test_text_codec.py new file mode 100644 index 00000000..e2ed584e --- /dev/null +++ b/test/test_text_codec.py @@ -0,0 +1,137 @@ +"""Tests for encoding detection, line-ending handling, and round-tripping files.""" +from __future__ import annotations + +import codecs + +import pytest + +from je_editor.utils.encodings.text_codec import ( + LINE_ENDING_CR, + LINE_ENDING_CRLF, + LINE_ENDING_LF, + apply_line_ending, + decode_bytes, + detect_line_ending, + encoding_from_bom, + line_ending_name, + normalise_line_endings, +) +from je_editor.utils.file.open.open_file import read_file_with_encoding +from je_editor.utils.file.save.save_file import write_file_with_encoding + + +class TestDetectLineEnding: + def test_unix(self): + assert detect_line_ending("a\nb\n") == LINE_ENDING_LF + + def test_windows(self): + assert detect_line_ending("a\r\nb\r\n") == LINE_ENDING_CRLF + + def test_classic_mac(self): + assert detect_line_ending("a\rb\r") == LINE_ENDING_CR + + def test_no_line_break_defaults_to_lf(self): + assert detect_line_ending("single line") == LINE_ENDING_LF + + def test_mixed_endings_pick_the_most_common(self): + assert detect_line_ending("a\r\nb\r\nc\n") == LINE_ENDING_CRLF + + def test_crlf_is_not_counted_as_a_lone_cr(self): + # Two CRLF and one lone LF: CRLF wins, and the CRs are not double-counted. + assert detect_line_ending("a\r\nb\r\nc\nd") == LINE_ENDING_CRLF + + @pytest.mark.parametrize("ending,name", [ + (LINE_ENDING_LF, "LF"), (LINE_ENDING_CRLF, "CRLF"), (LINE_ENDING_CR, "CR")]) + def test_names(self, ending, name): + assert line_ending_name(ending) == name + + def test_unknown_ending_name_falls_back(self): + assert line_ending_name("??") == "LF" + + +class TestApplyLineEnding: + def test_to_crlf(self): + assert apply_line_ending("a\nb\n", LINE_ENDING_CRLF) == "a\r\nb\r\n" + + def test_to_lf(self): + assert apply_line_ending("a\r\nb\r\n", LINE_ENDING_LF) == "a\nb\n" + + def test_to_cr(self): + assert apply_line_ending("a\nb\n", LINE_ENDING_CR) == "a\rb\r" + + def test_mixed_input_becomes_consistent(self): + assert apply_line_ending("a\r\nb\nc\r", LINE_ENDING_LF) == "a\nb\nc\n" + + def test_normalise(self): + assert normalise_line_endings("a\r\nb\rc\n") == "a\nb\nc\n" + + +class TestDecodeBytes: + def test_utf8_without_a_bom(self): + text, encoding = decode_bytes("héllo".encode("utf-8")) + assert text == "héllo" and encoding == "utf-8" + + def test_utf8_bom_is_recognised(self): + text, encoding = decode_bytes(codecs.BOM_UTF8 + "hi".encode("utf-8")) + assert text == "hi" and encoding == "utf-8-sig" + + def test_utf16_bom_is_recognised(self): + text, encoding = decode_bytes("hi".encode("utf-16")) + assert text == "hi" and encoding == "utf-16" + + def test_explicit_encoding_is_honoured(self): + text, encoding = decode_bytes("中文".encode("big5"), "big5") + assert text == "中文" and encoding == "big5" + + def test_undecodable_bytes_fall_back_instead_of_failing(self): + text, encoding = decode_bytes(b"\xff\xfe\x00abc"[1:]) + assert isinstance(text, str) and encoding in {"utf-8", "latin-1"} + + def test_explicit_encoding_that_cannot_decode_raises(self): + with pytest.raises(UnicodeDecodeError): + decode_bytes("中文".encode("big5"), "ascii") + + def test_bom_detection_without_a_bom(self): + assert encoding_from_bom(b"plain") is None + + +class TestFileRoundTrip: + def test_crlf_file_survives_a_save(self, tmp_path): + path = tmp_path / "windows.txt" + path.write_bytes(b"a\r\nb\r\n") + _p, content, encoding, ending = read_file_with_encoding(str(path)) + assert content == "a\nb\n" # normalised for the editor + assert ending == LINE_ENDING_CRLF + write_file_with_encoding(str(path), content, encoding, ending) + assert path.read_bytes() == b"a\r\nb\r\n" + + def test_lf_file_stays_lf(self, tmp_path): + path = tmp_path / "unix.txt" + path.write_bytes(b"a\nb\n") + _p, content, encoding, ending = read_file_with_encoding(str(path)) + write_file_with_encoding(str(path), content, encoding, ending) + assert path.read_bytes() == b"a\nb\n" + + def test_big5_file_round_trips(self, tmp_path): + path = tmp_path / "big5.txt" + path.write_bytes("中文內容\n".encode("big5")) + _p, content, _encoding, ending = read_file_with_encoding(str(path), "big5") + assert content == "中文內容\n" + write_file_with_encoding(str(path), content, "big5", ending) + assert path.read_bytes() == "中文內容\n".encode("big5") + + def test_line_ending_can_be_converted_on_save(self, tmp_path): + path = tmp_path / "convert.txt" + path.write_bytes(b"a\nb\n") + _p, content, encoding, _ending = read_file_with_encoding(str(path)) + write_file_with_encoding(str(path), content, encoding, LINE_ENDING_CRLF) + assert path.read_bytes() == b"a\r\nb\r\n" + + def test_missing_file_returns_none(self, tmp_path): + assert read_file_with_encoding(str(tmp_path / "gone.txt")) is None + + def test_empty_path_returns_none(self): + assert read_file_with_encoding("") is None + + def test_writing_an_empty_path_does_nothing(self, tmp_path): + write_file_with_encoding("", "text") # must not raise diff --git a/test/test_toolbar_actions.py b/test/test_toolbar_actions.py index 58a37841..c7b0b0fa 100644 --- a/test/test_toolbar_actions.py +++ b/test/test_toolbar_actions.py @@ -5,6 +5,9 @@ from PySide6.QtWidgets import QMainWindow from je_editor.pyside_ui.main_ui.toolbar.toolbar_builder import build_toolbar +from je_editor.utils.shortcuts.shortcut_registry import ( + WINDOW_SHORTCUTS, normalise_sequence +) @pytest.fixture() @@ -56,3 +59,12 @@ def test_new_actions_are_on_the_toolbar(self, toolbar_window): toolbar_actions = set(toolbar_window.main_toolbar.actions()) for attribute, _shortcut in self.PICKER_ACTIONS: assert getattr(toolbar_window, attribute) in toolbar_actions + + def test_every_toolbar_shortcut_is_reserved(self, toolbar_window): + # The editor checks its own shortcuts against this table, so a sequence + # the toolbar takes without listing it there could be claimed twice. + reserved = {normalise_sequence(sequence) for sequence in WINDOW_SHORTCUTS} + for action in toolbar_window.main_toolbar.actions(): + sequence = normalise_sequence(action.shortcut().toString()) + if sequence: + assert sequence in reserved, f"{sequence} is set but not reserved"