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

1"""An editor tab's checks: fortitude, spelling, gremlins, and the compiler's, fortls's and the AI's markers. 

2 

3A part of `vibefpmgui.editor.EditorTab`.""" 

4 

5from __future__ import annotations 

6 

7from qtpy.QtCore import QProcess 

8 

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 

14 

15PARSE_APART = 200_000 # (characters) fortitude's answer, or a text to spell check, bigger: on a pool thread 

16 

17 

18class EditorChecksMixin: 

19 """The tab's checks and markers (a part of `vibefpmgui.editor.EditorTab`).""" 

20 

21 def can_lint(self) -> bool: 

22 return self.lint_enabled and lint.can_lint(self.path) and lint.executable() is not None 

23 

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([]) 

30 

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() 

59 

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()) 

63 

64 def _lint_process_error(self, error): 

65 if error == QProcess.FailedToStart and isinstance(self.sender(), QProcess): 

66 self._lint_finished(self.sender()) 

67 

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() 

83 

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 

90 

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}") 

103 

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) 

111 

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() 

116 

117 def set_gremlins_enabled(self, on: bool): 

118 self.check_gremlins() 

119 

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() 

128 

129 def set_spelling_enabled(self, on: bool): 

130 self.check_spelling() if on else self._show_spelling([]) 

131 

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)) 

150 

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) 

154 

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() 

160 

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() 

168 

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 

175 

176 def _show_issues(self): 

177 self.editor.set_lint(self._fortitude_issues + self._lsp_issues + self._spelling_issues) 

178 self._apply_markers() 

179 

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() 

187 

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() 

195 

196 def clear_review(self): 

197 self._review_markers = {} 

198 self._apply_markers() 

199 

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() 

208 

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] 

213 

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 

234 

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)))