Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions addon/doc/en/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions addon/doc/id/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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.
59 changes: 53 additions & 6 deletions addon/globalPlugins/codeCompass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1636,13 +1648,39 @@ 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):
return
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)
Expand All @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions addon/globalPlugins/codeCompass/problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<code>", "exec", ast.PyCF_ONLY_AST, dont_inherit=True)
compile(source, "<code>", "exec", dont_inherit=True)
except SyntaxError as e:
line = max(0, (e.lineno or 1) - 1)
column = max(0, (e.offset or 1) - 1)
Expand Down
2 changes: 1 addition & 1 deletion buildVars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. <rafli08523717409@gmail.com>",
# URL for the add-on documentation support
Expand Down
93 changes: 93 additions & 0 deletions tests/test_live_edits.py
Original file line number Diff line number Diff line change
@@ -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()
Loading