Coverage for vibefpmgui/editor_checks.py: 88%
170 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""An editor tab's checks: fortitude, spelling, gremlins, and the compiler's, fortls's and the AI's markers.
3A part of `vibefpmgui.editor.EditorTab`."""
5from __future__ import annotations
7from qtpy.QtCore import QProcess
9from . import gremlins as gremlins_mod
10from . import lint, spelling
11from .background import call_in_background
12from .editor_shared import MARKER_COLORS, PROBLEM_SOURCES
13from .result_tree import Problem
15PARSE_APART = 200_000 # (characters) fortitude's answer, or a text to spell check, bigger: on a pool thread
18class EditorChecksMixin:
19 """The tab's checks and markers (a part of `vibefpmgui.editor.EditorTab`)."""
21 def can_lint(self) -> bool:
22 return self.lint_enabled and lint.can_lint(self.path) and lint.executable() is not None
24 def set_lint_enabled(self, on: bool):
25 if on: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 self.lint(force=True)
27 else:
28 self._lint_timer.stop()
29 self._show_lint([])
31 def lint(self, force: bool = False):
32 """Run fortitude on the current text (in the background), unless it
33 has checked this text of this file already (or is checking it):
34 opening a file asked twice, and recoloring the syntax counts as a
35 change of the text too; ``force``: all the same."""
36 if not self.can_lint():
37 self._show_lint([])
38 return
39 key = (self.path, self.editor.toPlainText())
40 if not force and key == getattr(self, "_lint_key", None):
41 return
42 if self._lint_process is not None: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true
43 self._lint_again = True
44 return
45 self._lint_again = False
46 process = QProcess(self)
47 process.setWorkingDirectory(str(self.path.parent))
48 # (Methods, not lambdas holding the process: see LspClient.start.)
49 process.finished.connect(self._lint_process_finished)
50 process.errorOccurred.connect(self._lint_process_error)
51 self._lint_process = process
52 self._lint_key = key
53 process.start(lint.executable(), lint.command(self.path))
54 # (The text itself, not the document's revision: recoloring the
55 # syntax also counts as a revision.)
56 self._lint_sent_text = self.editor.toPlainText()
57 process.write(self._lint_sent_text.encode("utf-8"))
58 process.closeWriteChannel()
60 def _lint_process_finished(self, *_):
61 if isinstance(self.sender(), QProcess): 61 ↛ exitline 61 didn't return from function '_lint_process_finished' because the condition on line 61 was always true
62 self._lint_finished(self.sender())
64 def _lint_process_error(self, error):
65 if error == QProcess.FailedToStart and isinstance(self.sender(), QProcess):
66 self._lint_finished(self.sender())
68 def _lint_finished(self, process: QProcess):
69 if process is not self._lint_process: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 return
71 self._lint_process = None
72 output = bytes(process.readAllStandardOutput()).decode("utf-8", errors="replace")
73 process.deleteLater()
74 self._lint_answer = serial = getattr(self, "_lint_answer", 0) + 1
75 sent = self._lint_sent_text
76 if len(output) > PARSE_APART: # (a big file's thousands of issues: read on a pool thread)
77 call_in_background(lambda: lint.parse(output),
78 lambda issues: self._lint_parsed(serial, sent, issues), self)
79 else:
80 self._lint_parsed(serial, sent, lint.parse(output))
81 if self._lint_again: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 self._lint_timer.start()
84 def _lint_parsed(self, serial: int, sent: str, issues: list):
85 if serial != self._lint_answer:
86 return # (a newer answer came meanwhile)
87 if self.can_lint():
88 self._show_lint(issues)
89 self._lint_text = sent # the text the issues are about
91 def apply_lint_fix(self, issue):
92 """Apply fortitude's fix for one issue (one Undo step). Only if the
93 text is still the one fortitude checked: otherwise the positions
94 may be off, so check again first."""
95 if not issue.fixable: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 return
97 if getattr(self, "_lint_text", None) != self.editor.toPlainText(): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 self.update_status("the file changed since fortitude checked it — try again in a moment")
99 self._lint_timer.start()
100 return
101 self.apply_text_edits(issue.edits)
102 self.update_status(f"applied fix: {issue.fix or issue.code}")
104 def stop_lint(self):
105 """Before the tab is closed: don't leave fortitude running."""
106 self._lint_timer.stop()
107 if self._lint_process is not None: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 process, self._lint_process = self._lint_process, None
109 process.kill()
110 process.waitForFinished(1000)
112 def _show_lint(self, issues: list[lint.LintIssue]):
113 self._fortitude_issues = list(issues)
114 self._lint_markers = self._markers_for(issues)
115 self._show_issues()
117 def set_gremlins_enabled(self, on: bool):
118 self.check_gremlins()
120 def check_gremlins(self):
121 """Look for gremlins (non-ASCII and invisible characters) again."""
122 found = gremlins_mod.find(self.editor.toPlainText()) if self.gremlins_enabled else []
123 before = self.editor.gremlins
124 self.editor.set_gremlins(found)
125 if self.editor.gremlins != before:
126 self.update_status()
127 self.problems_changed.emit()
129 def set_spelling_enabled(self, on: bool):
130 self.check_spelling() if on else self._show_spelling([])
132 def check_spelling(self):
133 """Spell check the comments and strings (as the highlighter last
134 found them: it lexes again after each edit, then this runs)."""
135 self._spelling_timer.stop()
136 if not (self.spelling_enabled and spelling.available()) or getattr(self, "untitled", False):
137 # (A new file not saved yet is "plain text", all prose: its code isn't, until it has a type.)
138 self._show_spelling([])
139 return
140 hl = self.highlighter
141 if not hl.is_current(): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return # (the highlighter is behind: it calls again when it's caught up)
143 text, prose, names = hl.text, hl.prose, hl.names
144 self._spelling_check = serial = getattr(self, "_spelling_check", 0) + 1
145 if len(text) > PARSE_APART: # (a big file's first check reads every comment: on a pool thread)
146 call_in_background(lambda: spelling.check(text, prose, names),
147 lambda issues: self._spelling_checked(serial, text, issues), self)
148 else:
149 self._spelling_checked(serial, text, spelling.check(text, prose, names))
151 def _spelling_checked(self, serial: int, text: str, issues: list):
152 if serial == self._spelling_check and self.highlighter.text is text: # (not since edited, or asked again)
153 self._show_spelling(issues)
155 def _show_spelling(self, issues: list):
156 if issues == self._spelling_issues:
157 return # (the same words: nothing to redraw, or to tell the Problems tab)
158 self._spelling_issues = issues
159 self._show_issues()
161 def show_lsp_diagnostics(self, issues: list):
162 """fortls's check of this file (it sends one when the file is opened
163 or saved), shown like fortitude's issues (View menu: on or off)."""
164 issues = list(issues) if self.lsp_diagnostics_enabled else []
165 self._lsp_issues = issues
166 self._lsp_markers = self._markers_for(issues)
167 self._show_issues()
169 @staticmethod
170 def _markers_for(issues) -> dict[int, list[tuple[str, str]]]:
171 markers: dict[int, list[tuple[str, str]]] = {}
172 for issue in issues:
173 markers.setdefault(issue.line, []).append((issue.severity, issue.describe()))
174 return markers
176 def _show_issues(self):
177 self.editor.set_lint(self._fortitude_issues + self._lsp_issues + self._spelling_issues)
178 self._apply_markers()
180 def show_diagnostics(self, diagnostics):
181 """Mark compiler messages (objects with .line, .severity, .message)."""
182 markers: dict[int, list[tuple[str, str]]] = {}
183 for d in diagnostics:
184 markers.setdefault(d.line, []).append((d.severity, d.message))
185 self._compiler_markers = markers
186 self._apply_markers()
188 def show_review(self, findings):
189 """Mark AI review findings (objects with .line, .category, .comment)."""
190 markers: dict[int, list[tuple[str, str]]] = {}
191 for f in findings:
192 markers.setdefault(f.line, []).append(("review", f"AI ({f.category}): {f.comment}"))
193 self._review_markers = markers
194 self._apply_markers()
196 def clear_review(self):
197 self._review_markers = {}
198 self._apply_markers()
200 def _apply_markers(self):
201 merged: dict[int, list[tuple[str, str]]] = {}
202 for source in (self._compiler_markers, self._review_markers, self._lint_markers, self._lsp_markers):
203 for line, notes in source.items():
204 merged.setdefault(line, []).extend(notes)
205 self.editor.set_markers(merged)
206 self.update_status()
207 self.problems_changed.emit()
209 def compiler_diagnostics(self) -> list[tuple[int, str, str]]:
210 """The last build's messages about this file: (line, severity, message), by line."""
211 return [(line, severity, message) for line, notes in sorted(self._compiler_markers.items())
212 for severity, message in notes]
214 def problems(self) -> list[Problem]:
215 """Everything flagged in the file, in line order: the last build's
216 compiler messages, the AI review's findings, and the underlined
217 issues (fortitude, fortls, spelling)."""
218 found = []
219 for markers, source in ((self._compiler_markers, "build"), (self._review_markers, "review")):
220 for line, notes in markers.items():
221 for severity, message in notes:
222 found.append(Problem(line, 0, line, 0, severity, severity, message,
223 MARKER_COLORS.get(severity, "#868e96"), source))
224 for line_gremlins in self.editor.gremlins.values():
225 for g in line_gremlins:
226 found.append(Problem(g.line, g.column, g.line, g.column + 1, "gremlin", g.code, g.message,
227 gremlins_mod.COLOR, "gremlins"))
228 for issue in self.editor.lint_issues:
229 found.append(Problem(issue.line, max(0, issue.column - 1), issue.end_line, max(0, issue.end_column - 1),
230 issue.severity, issue.code, issue.message, MARKER_COLORS[issue.severity],
231 PROBLEM_SOURCES.get(issue.severity, "fortitude"), issue))
232 found.sort(key=lambda p: (p.line, p.column))
233 return found
235 def _explain_line(self, line: int):
236 """Explain Error with AI on a line: its most serious compiler, fortitude
237 or fortls problem."""
238 rank = {"fatal error": 0, "error": 1, "lint error": 2, "fortls error": 3, "warning": 4}
239 found = [p for p in self.problems() if p.line == line and p.source in ("build", "fortitude", "fortls")]
240 if found: 240 ↛ exitline 240 didn't return from function '_explain_line' because the condition on line 240 was always true
241 self.explain_requested.emit(min(found, key=lambda p: rank.get(p.severity, 9)))