diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2c885..cee4117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 0.4.2 + +### 🛠️ Fixed + +- Removing a line's indentation in Python so that `return` or `break` ends up outside its function or loop is now a problem: Python code is compiled (still without running anything), not only parsed. +- The check for new problems also runs after deleting (Delete, Backspace, control+Backspace, control+Delete), cutting and pasting, and after Code Compass's own commands such as shift+Tab or moving lines, not only after typing a character. + ## Version 0.4.1 ### 🎉 New diff --git a/addon/doc/en/readme.md b/addon/doc/en/readme.md index 6d98c59..e97a1d3 100644 --- a/addon/doc/en/readme.md +++ b/addon/doc/en/readme.md @@ -77,7 +77,7 @@ When you come back to Notepad and another program (an editor, a code generator, Code Compass finds problems without running anything: syntax errors in Python (checked by NVDA's own Python) and JSON files, and unclosed or extra brackets in every language. After a program run (see below), the error it stopped on is a problem too. -- While you type: about a second after you stop, Code Compass checks again, and a problem your typing just made (an unclosed bracket, a Python syntax error) plays the error sound right away. The settings can also have it read, or turn this off. Plain text files are not checked while typing. +- While you type: about a second after you stop typing, deleting, pasting or using a command such as shift+Tab, Code Compass checks again, and a problem that change just made (an unclosed bracket, a Python syntax error) plays the error sound right away. The settings can also have it read, or turn this off. Plain text files are not checked while typing. - F8 and shift+F8: next and previous problem. The problem is read, then its line. - control+shift+M: a list of every problem; Enter moves there. - control+S saves as usual, then reports problems, or says "Saved, no problems". @@ -177,5 +177,5 @@ NVDA menu, Preferences, Settings, Code Compass: - VS Code only exposes part of a file to screen readers, so levels there can be wrong. VS Code is not enabled by default. - The outline and function moves find declarations with patterns, not a full parser. Unusual code styles may be missed. - Undo steps are kept while NVDA runs: after restarting NVDA, or with the setting turned off, control+Z is Notepad's own single step again. -- Python syntax errors come from Python's own parser, which reports only the first one in a file. +- Python syntax errors come from Python's own compiler (nothing is run), which reports only the first one in a file. A line moved out of a block on purpose is valid Python, so it is no problem; you hear it as a change of the level tone. - Code inside strings is understood for Python f-strings, JavaScript template literals and Swift's \\( ). In other languages' interpolated strings, such as C#'s `$"{name}"` or Kotlin's and Dart's `"${name}"`, the name is text: rename, uses and completion skip it there. diff --git a/addon/doc/id/readme.md b/addon/doc/id/readme.md index 1b0c1b9..0c9e9f7 100644 --- a/addon/doc/id/readme.md +++ b/addon/doc/id/readme.md @@ -77,7 +77,7 @@ Waktu kamu balik ke Notepad dan file yang kebuka udah diubah program lain (edito Code Compass nemuin masalah tanpa ngejalanin kodenya: error sintaks di file Python (dicek pakai Python bawaan NVDA) dan JSON, plus kurung yang belum ketutup atau berlebih di semua bahasa. Sesudah program dijalanin (lihat di bawah), error tempat program berhenti juga ikut jadi masalah. -- Waktu ngetik: sekitar satu detik setelah kamu berhenti, Code Compass ngecek lagi. Masalah yang baru aja muncul gara-gara ketikanmu (kurung belum ketutup, error sintaks Python) langsung dibunyiin pakai suara error. Di pengaturan, masalahnya bisa sekalian dibacain, atau fitur ini dimatiin. File teks biasa nggak dicek waktu ngetik. +- Waktu ngetik: sekitar satu detik setelah kamu berhenti ngetik, ngapus, nempel (paste), atau make perintah kayak shift+Tab, Code Compass ngecek lagi. Masalah yang baru aja muncul gara-gara perubahan itu (kurung belum ketutup, error sintaks Python) langsung dibunyiin pakai suara error. Di pengaturan, masalahnya bisa sekalian dibacain, atau fitur ini dimatiin. File teks biasa nggak dicek waktu ngetik. - F8 dan shift+F8: masalah berikutnya dan sebelumnya. Masalahnya dibacain dulu, baru barisnya. - control+shift+M: daftar semua masalah, Enter buat lompat ke sana. - control+S nyimpen kayak biasa, terus ngelaporin masalah, atau bilang "Tersimpan, tidak ada masalah". @@ -177,5 +177,5 @@ Menu NVDA, Preferences, Settings, Code Compass: - VS Code cuma ngasih sebagian isi file ke screen reader, jadi levelnya bisa salah di sana. VS Code nggak aktif secara default. - Outline dan lompat per fungsi nyari deklarasi pakai pola, bukan parser lengkap. Gaya penulisan kode yang nggak biasa bisa kelewat. - Langkah undo disimpan selama NVDA jalan: habis NVDA di-restart, atau kalau pengaturannya dimatiin, control+Z balik jadi undo bawaan Notepad yang cuma satu langkah. -- Error sintaks Python dicek pakai parser Python sendiri, yang cuma ngelaporin error pertama di satu file. +- Error sintaks Python dicek pakai compiler Python sendiri (kodenya nggak dijalanin), yang cuma ngelaporin error pertama di satu file. Baris yang memang sengaja dikeluarin dari blok itu Python yang sah, jadi nggak dihitung masalah; kamu dengernya sebagai perubahan nada level. - Kode di dalam string dikenali buat f-string Python, template literal JavaScript, dan \\( ) di Swift. Di string interpolasi bahasa lain, misalnya `$"{name}"` di C# atau `"${name}"` di Kotlin dan Dart, namanya dianggap teks: rename, daftar pemakaian, dan autocomplete ngelewatin nama di situ. diff --git a/addon/globalPlugins/codeCompass/__init__.py b/addon/globalPlugins/codeCompass/__init__.py index 98536b6..0d6b18a 100644 --- a/addon/globalPlugins/codeCompass/__init__.py +++ b/addon/globalPlugins/codeCompass/__init__.py @@ -1287,18 +1287,27 @@ def _live_snapshot(found): return sorted(_live_key(p) for p in found if p.kind != "run") -def _schedule_live_check(obj): - """After typing pauses, look for problems again and sound new ones.""" +def _live_baseline(obj): + """Note the problems before a change (typing, a deletion, a paste, a + command), so the check after it knows which ones are new.""" if _conf()["liveCheck"] == "off": - return + return False hwnd = getattr(obj, "windowHandle", None) stored = _liveProblems.get(hwnd) if stored is None or stored[0] != _live_file(obj): - # The problems before this typing started, in this document. try: _liveProblems[hwnd] = (_live_file(obj), _live_snapshot(_problems(obj, _cache.get(obj)))) except Exception: - return + return False + return True + + +def _schedule_live_check(obj): + """After typing or editing pauses, look for problems again and sound new + ones.""" + if not _live_baseline(obj): + return + hwnd = getattr(obj, "windowHandle", None) token = _liveChecks[hwnd] = _liveChecks.get(hwnd, 0) + 1 core.callLater(LIVE_CHECK_DELAY, _live_check, obj, token) @@ -1347,6 +1356,7 @@ def _apply_edit(obj, a, start, end, newText, selStart=None, selEnd=None, timeout step for control+Z. True on success. A command's edit is an undo step of its own; with typing (auto-close, indentation while typing), it joins the typing around it.""" + _live_baseline(obj) if not typing: _undo_record(obj, a.text) try: @@ -1359,6 +1369,7 @@ def _apply_edit(obj, a, start, end, newText, selStart=None, selEnd=None, timeout _forget_selection_change(obj) if not typing: _undo_record(obj, a.text[:start] + newText + a.text[end:]) + _schedule_live_check(obj) return ok @@ -1585,8 +1596,9 @@ def event_gainFocus(self): super(CodeEditor, self).event_gainFocus() hwnd = getattr(self, "windowHandle", None) # Undo starts here, before any typing (and notices a document that - # File > Open or New replaced). + # File > Open or New replaced); so does the list of known problems. _undo_record(self) + _live_baseline(self) # Back in the editor: was the file changed by another program? core.callLater(150, _check_disk, self) before = _goToPending.pop(hwnd, None) @@ -1636,6 +1648,7 @@ def script_ccRedo(self, gesture): ui.message(_("Nothing to redo")) def script_ccBackspace(self, gesture): + _live_baseline(self) if _conf()["autoClose"] and _writable_notepad(self): try: if _delete_auto_pair(self): @@ -1643,6 +1656,31 @@ def script_ccBackspace(self, gesture): except Exception: log.debugWarning("Code Compass: backspace failed", exc_info=True) self.script_caret_backspaceCharacter(gesture) + _schedule_live_check(self) + + # Deleting and pasting change the text without a typed character: check + # for new problems after them too. NVDA's own behaviour is kept. + + def script_ccDelete(self, gesture): + _live_baseline(self) + self.script_caret_deleteCharacter(gesture) + _schedule_live_check(self) + + def script_ccBackspaceWord(self, gesture): + _live_baseline(self) + self.script_caret_backspaceWord(gesture) + _schedule_live_check(self) + + def script_ccDeleteWord(self, gesture): + _live_baseline(self) + self.script_caret_deleteWord(gesture) + _schedule_live_check(self) + + def script_ccPasteOrCut(self, gesture): + _live_baseline(self) + gesture.send() + _schedule_live_check(self) + _schedule_undo_stop(self) def script_ccSave(self, gesture): _before_save(self) @@ -1665,6 +1703,15 @@ def script_ccSaveAs(self, gesture): "kb:alt+end": "ccBlockEnd", "kb:control+s": "ccSave", "kb:backspace": "ccBackspace", + "kb:delete": "ccDelete", + "kb:numpadDelete": "ccDelete", + "kb:control+backspace": "ccBackspaceWord", + "kb:control+delete": "ccDeleteWord", + "kb:control+numpadDelete": "ccDeleteWord", + "kb:control+v": "ccPasteOrCut", + "kb:control+x": "ccPasteOrCut", + "kb:shift+insert": "ccPasteOrCut", + "kb:shift+delete": "ccPasteOrCut", "kb:control+z": "ccUndo", "kb:control+y": "ccRedo", "kb:control+shift+z": "ccRedo", diff --git a/addon/globalPlugins/codeCompass/problems.py b/addon/globalPlugins/codeCompass/problems.py index 4becbbb..1d9f3d1 100644 --- a/addon/globalPlugins/codeCompass/problems.py +++ b/addon/globalPlugins/codeCompass/problems.py @@ -35,15 +35,17 @@ def __repr__(self): def python_problems(text): - """The first syntax error, found by parsing only: nothing is run. - NVDA's own Python parses the code, so very new or very old syntax may be + """The first syntax error, found by compiling only: nothing is run. + Compiling (not just parsing) also finds "return" outside a function and + "break" outside a loop, as after removing a line's indentation. NVDA's + own Python compiles the code, so very new or very old syntax may be judged by NVDA's Python version.""" source = text.replace("\r\n", "\n").replace("\r", "\n") try: with warnings.catch_warnings(): # Invalid escape sequences and similar only warn; keep them quiet. warnings.simplefilter("ignore") - compile(source, "", "exec", ast.PyCF_ONLY_AST, dont_inherit=True) + compile(source, "", "exec", dont_inherit=True) except SyntaxError as e: line = max(0, (e.lineno or 1) - 1) column = max(0, (e.offset or 1) - 1) diff --git a/buildVars.py b/buildVars.py index d967296..6e3777f 100644 --- a/buildVars.py +++ b/buildVars.py @@ -18,7 +18,7 @@ "(automatic indentation, comments, completion, line moves, rename)." ), # version - "addon_version": "0.4.1", + "addon_version": "0.4.2", # Author(s) "addon_author": "Rafli I. ", # URL for the add-on documentation support diff --git a/tests/test_live_edits.py b/tests/test_live_edits.py new file mode 100644 index 0000000..772bf83 --- /dev/null +++ b/tests/test_live_edits.py @@ -0,0 +1,93 @@ +# -*- coding: UTF-8 -*- +# Problems found after any edit, not only typing: deleting indentation, +# Delete and Backspace, pasting, and Code Compass's own commands. + +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "addon", "globalPlugins", "codeCompass")) +import test_sounds # noqa: E402 + +v040 = test_sounds.v040 +codeCompass = v040.codeCompass +MODS = v040.MODS +Gesture = v040.Gesture +played = test_sounds.played + +import problems # noqa: E402 + + +class PythonCompileTests(unittest.TestCase): + def test_dedented_return_and_break(self): + self.assertEqual([p.message for p in problems.python_problems("def f():\n x = 1\nreturn x\n")], ["'return' outside function"]) + self.assertEqual([p.message for p in problems.python_problems("for i in r:\n pass\nbreak\n")], ["'break' outside loop"]) + self.assertEqual(problems.python_problems("def f():\n x = 1\n return x\n"), []) + + +PY = "def f():\r\n x = 1\r\n return x\r\n" + + +class EditTests(test_sounds.SoundTests): + def setUp(self): + super(EditTests, self).setUp() + MODS["api"].getFocusObject.return_value = self.ed + self.use(PY, "main.py - Notepad") + + def checks(self): + return [c[0] for c in MODS["core"].callLater.call_args_list if c[0][1] is codeCompass._live_check] + + def run_check(self): + calls = self.checks() + calls[-1][1](*calls[-1][2:]) + + def test_deleting_indentation_with_delete(self): + # The caret at the start of " return x"; Delete removes 4 spaces. + self.ed.caret = PY.index(" return") + + def delete(gesture): + for _ in range(4): + self.ed.text = self.ed.text[:self.ed.caret] + self.ed.text[self.ed.caret + 1:] + + with mock.patch.object(type(self.ed), "script_caret_deleteCharacter", create=True, side_effect=delete): + self.ed.script_ccDelete(Gesture("kb:delete")) + self.assertTrue(self.checks()) + self.run_check() + self.assertEqual(played(), ["error"]) + + def test_backspace_schedules_a_check(self): + self.ed.caret = PY.index("return") + self.ed.script_ccBackspace(Gesture("kb:backspace")) + self.assertTrue(self.checks()) + + def test_paste_schedules_a_check(self): + gesture = Gesture("kb:control+v") + self.ed.script_ccPasteOrCut(gesture) + gesture.send.assert_called_once() + self.assertTrue(self.checks()) + + def test_commands_schedule_a_check(self): + # shift+tab on the return line: "return x" leaves the function. + self.ed.caret = PY.index(" return") + 6 + self.ed.selection = (self.ed.caret, self.ed.caret) + self.ed.script_ccShiftTab(Gesture("kb:shift+tab")) + self.assertIn("\r\nreturn x", self.ed.text) + self.run_check() + self.assertEqual(played(), ["error"]) + + def test_keys(self): + gestures = codeCompass.CodeEditor._CodeEditor__gestures + self.assertEqual(gestures["kb:delete"], "ccDelete") + self.assertEqual(gestures["kb:control+v"], "ccPasteOrCut") + self.assertEqual(gestures["kb:control+backspace"], "ccBackspaceWord") + + +for _name in list(vars(test_sounds.SoundTests)) + list(vars(v040.V040Tests)): + if _name.startswith("test_") and _name not in vars(EditTests): + setattr(EditTests, _name, None) + + +if __name__ == "__main__": + unittest.main()