Coverage for vibefpmgui/lsp.py: 90%

559 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""A small client for the Fortran language server, fortls. 

2 

3`fortls <https://fortls.fortran-lang.org>`_ indexes the project's Fortran 

4sources and answers questions about them: what a name is (hover), where it 

5is defined, the outline of a file and the symbols in the whole project. It 

6runs as a background process speaking the Language Server Protocol (JSON 

7messages over stdin/stdout); `LspClient` handles that with a QProcess, 

8so the editor never waits for it. 

9 

10Open editor tabs send their text to the server (``didOpen``/``didChange``), 

11so unsaved edits are seen. Unless the project has its own fortls settings 

12(``.fortlsrc``, ``.fortls.json`` or ``.fortls``), fortls is told to index 

13just the fpm folders (``src``, ``app``, ``test``, ``example``, ``include`` and 

14any ``source-dir``) of the project and each downloaded dependency, rather 

15than every folder under the project (a Python environment, say). 

16""" 

17 

18from __future__ import annotations 

19 

20import html 

21import json 

22import re 

23import shutil 

24from collections.abc import Callable 

25from pathlib import Path 

26from typing import NamedTuple 

27 

28from qtpy.QtCore import QObject, QProcess, QUrl, Signal 

29from qtpy.QtGui import QPalette, QTextDocument 

30 

31from . import manifest as m 

32from .project_tree import fpm_folders 

33from .wire import FrameReader, frame 

34 

35FORTRAN_EXTS = {".f", ".for", ".ftn", ".f77", ".f90", ".f95", ".f03", ".f08", ".f18", ".f23", ".fpp"} 

36CONFIG_FILES = (".fortlsrc", ".fortls.json", ".fortls") 

37 

38# LSP SymbolKind numbers -> words (the ones fortls uses for Fortran). 

39SYMBOL_KINDS = {2: "module", 5: "type", 6: "subroutine", 8: "component", 9: "constructor", 

40 10: "enum", 11: "interface", 12: "function", 13: "variable", 14: "constant", 

41 22: "enumerator", 23: "type", 26: "type parameter"} 

42OUTLINE_KINDS = {2, 5, 6, 9, 10, 11, 12, 23} # what the outline lists (no variables) 

43# fortls's outline calls subroutines "functions" and programs "modules"; the 

44# keyword on the symbol's first line says which it is. 

45KEYWORD_RE = re.compile(r"^(?:[^!'\"]*?\s)?(program|submodule|module|subroutine|function)\b", re.IGNORECASE) 

46 

47 

48def executable() -> str | None: 

49 return shutil.which("fortls") 

50 

51 

52def is_fortran(path: Path) -> bool: 

53 return path.suffix.lower() in FORTRAN_EXTS 

54 

55 

56def to_uri(path: Path) -> str: 

57 return QUrl.fromLocalFile(str(path)).toString() 

58 

59 

60def from_uri(uri: str) -> Path: 

61 return Path(QUrl(uri).toLocalFile()).resolve() 

62 

63 

64def has_config(project_dir: Path) -> bool: 

65 return any((project_dir / name).is_file() for name in CONFIG_FILES) 

66 

67 

68def source_dirs(graph: m.DependencyGraph | None, project_dir: Path) -> list[str]: 

69 """``folder/**`` globs for the fpm folders of the project and of every 

70 dependency on disk (fortls doesn't look into subfolders otherwise).""" 

71 packages = [project_dir] 

72 if graph is not None: 72 ↛ 76line 72 didn't jump to line 76 because the condition on line 72 was always true

73 packages += [p.directory for p in graph.packages.values() 

74 if p.name != graph.root and p.kind not in (m.META, m.MISSING) 

75 and p.directory and p.directory.is_dir()] 

76 folders: set[str] = set() 

77 for directory in packages: 

78 manifest = directory / "fpm.toml" 

79 found = fpm_folders(manifest) if manifest.is_file() else {} 

80 folders.update(found or {str(directory.resolve()): ""}) 

81 # A folder inside another listed one is already covered by its "/**". 

82 kept = [f for f in sorted(folders) 

83 if not any(other != f and Path(other) in Path(f).parents for other in folders)] 

84 return [f"{folder}/**" for folder in kept] 

85 

86 

87def _packages(graph: m.DependencyGraph | None, project_dir: Path) -> list[tuple[Path, str]]: 

88 """(folder, version) of the project and of every dependency on disk.""" 

89 out = [(project_dir, "")] 

90 if graph is not None: 90 ↛ 96line 90 didn't jump to line 96 because the condition on line 90 was always true

91 root = graph.packages.get(graph.root) 

92 out[0] = (project_dir, (root.version if root else "") or "") 

93 out += [(p.directory, p.version or "") for p in graph.packages.values() 

94 if p.name != graph.root and p.kind not in (m.META, m.MISSING) 

95 and p.directory and p.directory.is_dir()] 

96 return out 

97 

98 

99def preprocessor_args(graph: m.DependencyGraph | None, project_dir: Path) -> list[str]: 

100 """fortls options for the C preprocessor settings in the fpm.toml files 

101 (``[preprocess.cpp]``: macros, suffixes) and the packages' include 

102 folders, so #ifdef branches are read as fpm compiles them. [] when no 

103 package has preprocessor settings (fortls's own defaults then).""" 

104 macros: dict[str, str] = {} 

105 suffixes: list[str] = [] 

106 includes: list[str] = [] 

107 found = False 

108 for directory, version in reversed(_packages(graph, project_dir)): # (the project's own win) 

109 manifest = directory / "fpm.toml" 

110 try: 

111 data = m.load_toml(manifest) if manifest.is_file() else {} 

112 except Exception: # noqa: BLE001 - an unreadable manifest has no settings 

113 data = {} 

114 cpp = (data.get("preprocess") or {}).get("cpp") if isinstance(data.get("preprocess"), dict) else None 

115 if not isinstance(cpp, dict): 

116 continue 

117 found = True 

118 for macro in cpp.get("macros") or []: 

119 name, _, value = str(macro).partition("=") 

120 if name.strip(): 120 ↛ 118line 120 didn't jump to line 118 because the condition on line 120 was always true

121 macros[name.strip()] = value.strip().replace("{version}", version) 

122 for suffix in cpp.get("suffixes") or []: 

123 suffix = "." + str(suffix).lstrip(".") 

124 if suffix not in suffixes: 124 ↛ 122line 124 didn't jump to line 122 because the condition on line 124 was always true

125 suffixes.append(suffix) 

126 includes += [f for f, role in fpm_folders(manifest).items() if role == "include" and f not in includes] 

127 if not found: 

128 return [] 

129 args = [] 

130 if macros: 130 ↛ 132line 130 didn't jump to line 132 because the condition on line 130 was always true

131 args += ["--pp_defs", json.dumps(macros)] 

132 if suffixes: 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was always true

133 args += ["--pp_suffixes", *suffixes] 

134 if includes: 134 ↛ 136line 134 didn't jump to line 136 because the condition on line 134 was always true

135 args += ["--include_dirs", *includes] 

136 return args 

137 

138 

139def server_args(graph: m.DependencyGraph | None, project_dir: Path) -> list[str]: 

140 args = ["--disable_autoupdate", # it would otherwise ask PyPI for updates 

141 "--hover_signature", 

142 "--use_signature_help", # complete names only; arguments are hinted on "(" 

143 "--lowercase_intrinsics"] # complete "size", not "SIZE" 

144 if not has_config(project_dir): # (a .fortlsrc says it all) 

145 args += ["--source_dirs", *source_dirs(graph, project_dir)] 

146 args += preprocessor_args(graph, project_dir) 

147 return args 

148 

149 

150# --------------------------------------------------------- diagnostics 

151class LspIssue(NamedTuple): 

152 """A problem fortls found in a file (it checks a file when it's opened 

153 or saved: the saved text), shown like fortitude's issues.""" 

154 line: int # 1-based 

155 column: int # 1-based 

156 end_line: int 

157 end_column: int 

158 message: str 

159 level: int = 2 # LSP: 1 error, 2 warning, 3 information, 4 hint 

160 code: str = "fortls" 

161 fix: str = "" 

162 fix_safe: bool = False 

163 edits: tuple = () 

164 source: str = "fortls" 

165 

166 @property 

167 def fixable(self) -> bool: 

168 return False 

169 

170 @property 

171 def severity(self) -> str: 

172 return "fortls error" if self.level == 1 else "fortls" 

173 

174 def describe(self) -> str: 

175 return f"fortls: {self.message}" 

176 

177 

178def diagnostic_issues(diagnostics) -> list[LspIssue]: 

179 """publishDiagnostics' list -> issues (anything malformed is skipped).""" 

180 out = [] 

181 for d in diagnostics or []: 

182 try: 

183 r = d["range"] 

184 out.append(LspIssue(r["start"]["line"] + 1, r["start"]["character"] + 1, 

185 r["end"]["line"] + 1, r["end"]["character"] + 1, 

186 " ".join(str(d.get("message", "")).split()), int(d.get("severity") or 2))) 

187 except (KeyError, TypeError, ValueError): 

188 continue 

189 return out 

190 

191 

192# fortls's hover text for an intrinsic is a manual page in markdown that 

193# starts with a rule and the name as a heading ("-----\n## sum\n\n### **Name**"); 

194# for the project's own names it starts with a ```fortran block. 

195INTRINSIC_RE = re.compile(r"^\s*-{3,}[ \t]*\n\s*##[ \t]+(\w+)[ \t]*\n(?:\s*###[ \t]*\**Name\**[ \t]*\n)?") 

196 

197 

198def hover_text(contents) -> str: 

199 """The markdown text of a hover answer (MarkupContent, string or list).""" 

200 if isinstance(contents, dict): 

201 return contents.get("value", "") 

202 if isinstance(contents, list): 

203 return "\n\n".join(c.get("value", "") if isinstance(c, dict) else str(c) for c in contents) 

204 return str(contents or "") 

205 

206 

207def _accent(dark: bool) -> str: 

208 """The highlight color, made lighter on a dark background so it reads.""" 

209 from qtpy.QtWidgets import QApplication 

210 color = QApplication.palette().color(QPalette.Active, QPalette.Highlight) 

211 if dark: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 color = color.lighter(170) 

213 elif color.lightness() > 150: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 color = color.darker(140) 

215 return color.name() 

216 

217 

218def code_html(code: str, language: str = "fortran", style: str | None = None) -> str: 

219 """A block of code, syntax-highlighted with the editor's Pygments style, 

220 on a panel in that style's background color.""" 

221 from pygments import highlight 

222 from pygments.formatters import HtmlFormatter 

223 from pygments.lexers import TextLexer 

224 from pygments.styles import get_style_by_name 

225 

226 from .highlighter import FortranSourceLexer 

227 

228 style = style or _current_style() 

229 lexer = FortranSourceLexer() if language.lower().startswith("fortran") or not language else TextLexer() 

230 spans = highlight(code.rstrip("\n"), lexer, HtmlFormatter(nowrap=True, noclasses=True, style=style)) 

231 # Synopses aren't always valid Fortran ("[,dim] | [mask]"): no error boxes. 

232 spans = re.sub(r"(background-color|border):\s*[^;\"]+;?\s*", "", spans) 

233 background = get_style_by_name(style).background_color 

234 return (f"<table width='100%' cellspacing='0' cellpadding='6' style='margin:3px 0; " 

235 f"background-color:{background}'><tr><td><pre style='margin:0'>{spans.rstrip()}</pre>" 

236 "</td></tr></table>") 

237 

238 

239def _current_style() -> str: 

240 from qtpy.QtWidgets import QApplication 

241 

242 from . import highlighter as hl 

243 return hl.style_name(QApplication.palette()) 

244 

245 

246def markdown_html(text: str, style: str | None = None) -> str: 

247 """Markdown as HTML for a rich-text label (rendered by Qt): code blocks 

248 syntax-highlighted, headings as small colored labels.""" 

249 out = [] 

250 for i, part in enumerate(re.split(r"```([\w+-]*)[ \t]*\n(.*?)```", text, flags=re.DOTALL)): 

251 kind = i % 3 

252 if kind == 1: 

253 language = part 

254 elif kind == 2: 

255 out.append(code_html(part, language, style)) 

256 elif part.strip(): 

257 doc = QTextDocument() 

258 doc.setMarkdown(part, QTextDocument.MarkdownDialectGitHub) 

259 body = re.search(r"<body[^>]*>(.*)</body>", doc.toHtml(), re.DOTALL) 

260 out.append(body.group(1).strip() if body else "") 

261 result = "".join(out) 

262 from . import highlighter as hl 

263 accent = _accent(hl.style_is_dark(style or _current_style())) 

264 

265 def heading(match): 

266 title = html.unescape(re.sub(r"<[^>]+>", "", match.group(2))).strip() 

267 return (f"<p style='margin:9px 0 2px 0'><span style='color:{accent}; font-weight:600; " 

268 f"font-size:small'>{html.escape(title.upper())}</span></p>") 

269 

270 return re.sub(r"<h(\d)[^>]*>(.*?)</h\1>", heading, result, flags=re.DOTALL) 

271 

272 

273class HoverInfo(NamedTuple): 

274 kind: str # "intrinsic", "function", "subroutine", "variable", "parameter", … or "" 

275 category: str # an intrinsic's group, e.g. "ARRAY · REDUCTION" 

276 summary: str # an intrinsic's one-line description 

277 body_html: str # the rest, rendered 

278 

279 

280def _clean(text: str) -> str: 

281 # "-----" between the declaration and a doc comment: just a gap (the code 

282 # panel already sets them apart; markdown would make the line above a heading). 

283 text = re.sub(r"^[ \t]*-{3,}[ \t]*$", "\n", text, flags=re.MULTILINE) 

284 # Qt shows _word_ underlined (like a link); the manual pages mean italics. 

285 parts = re.split(r"(```.*?```|`[^`]*`)", text, flags=re.DOTALL) 

286 return "".join(part if i % 2 else re.sub(r"(?<![\w\\*])_([^_\n]+?)_(?!\w)", r"*\1*", part) 

287 for i, part in enumerate(parts)) 

288 

289 

290DECL_KINDS = [("subroutine", r"\bSUBROUTINE\b"), ("function", r"\bFUNCTION\b"), 

291 ("program", r"^\s*PROGRAM\b"), ("submodule", r"^\s*SUBMODULE\b"), 

292 ("module", r"^\s*MODULE\b(?!\s+PROCEDURE)"), ("interface", r"\bINTERFACE\b"), 

293 ("type", r"^\s*TYPE\b(?!\s*\()")] 

294 

295 

296def hover_info(contents, style: str | None = None) -> HoverInfo | None: 

297 """fortls's hover answer, taken apart for the hover card (None if empty).""" 

298 text = hover_text(contents) 

299 if not text.strip(): 

300 return None 

301 match = INTRINSIC_RE.match(text) 

302 if match: 

303 rest = text[match.end():] 

304 category = summary = "" 

305 line = re.match(r"\s*\*\*\w+\*\*\s*-\s*(?:\\?\[([^\]\\]*)\\?\])?\s*(.*)\n", rest) 

306 if line: 306 ↛ 310line 306 didn't jump to line 310 because the condition on line 306 was always true

307 category = " · ".join(p.strip() for p in (line.group(1) or "").split(":") if p.strip()) 

308 summary = line.group(2).strip() 

309 rest = rest[line.end():] 

310 return HoverInfo("intrinsic", category, summary, markdown_html(_clean(rest), style)) 

311 kind = "" 

312 code = re.match(r"\s*```[\w+-]*[ \t]*\n(.*?)```", text, re.DOTALL) 

313 if code: 313 ↛ 321line 313 didn't jump to line 321 because the condition on line 313 was always true

314 first = code.group(1).strip().splitlines()[0] if code.group(1).strip() else "" 

315 if "::" in first and not re.match(r"^\s*TYPE\b\s*(,|::)", first, re.IGNORECASE): 

316 kind = "parameter" if re.search(r"\bPARAMETER\b", first, re.IGNORECASE) else "variable" 

317 else: 

318 kind = next((k for k, pattern in DECL_KINDS if re.search(pattern, first, re.IGNORECASE)), "") 

319 if not kind and re.match(r"^\s*TYPE\b", first, re.IGNORECASE): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 kind = "type" 

321 return HoverInfo(kind, "", "", markdown_html(_clean(text), style)) 

322 

323 

324def hover_html(contents) -> str: 

325 """fortls's hover text (markdown) as HTML, e.g. for an argument's 

326 documentation in the argument hints; "" if there is nothing.""" 

327 info = hover_info(contents) 

328 if info is None: 

329 return "" 

330 lead = f"<p style='margin:0 0 4px 0'>{html.escape(info.summary)}</p>" if info.summary else "" 

331 return lead + info.body_html 

332 

333 

334def first_location(result) -> tuple[Path, int, int] | None: 

335 """(file, 1-based line, 0-based column) from a definition answer 

336 (a Location, a list of them, or LocationLinks).""" 

337 if isinstance(result, list): 

338 result = result[0] if result else None 

339 if not isinstance(result, dict): 

340 return None 

341 uri = result.get("uri") or result.get("targetUri") 

342 rng = result.get("targetSelectionRange") or result.get("range") or result.get("targetRange") 

343 if not uri or not rng: 

344 return None 

345 return from_uri(uri), rng["start"]["line"] + 1, rng["start"]["character"] 

346 

347 

348class LspClient(QObject): 

349 """One fortls process for the open project.""" 

350 

351 stopped = Signal(str) # the server exited on its own (with why) 

352 message = Signal(str) # something the server wants the user to know 

353 diagnostics = Signal(object, object) # file (Path), [LspIssue]: fortls's check of a file 

354 

355 def __init__(self, parent=None): 

356 super().__init__(parent) 

357 self.process: QProcess | None = None 

358 self.root: Path | None = None 

359 self.args: list[str] = [] 

360 self._reader = FrameReader() 

361 self._next_id = 0 

362 self._callbacks: dict[int, Callable] = {} 

363 self._ready = False 

364 self._queue: list[dict] = [] # sent once initialize has answered 

365 self._versions: dict[str, int] = {} # open document uri -> version 

366 

367 # ---------------------------------------------------------- lifecycle 

368 def is_running(self) -> bool: 

369 return self.process is not None 

370 

371 def start(self, root: Path, args: list[str]) -> bool: 

372 self.stop() 

373 program = executable() 

374 if program is None: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 return False 

376 self.root, self.args = root, list(args) 

377 process = QProcess(self) 

378 process.setWorkingDirectory(str(root)) 

379 process.setStandardErrorFile(QProcess.nullDevice()) 

380 process.readyReadStandardOutput.connect(self._read) 

381 # (Methods, not lambdas holding the process: when Qt deletes the 

382 # process, PySide drops those lambdas, and with them possibly the last 

383 # reference to the process's Python wrapper, mid-delete: a crash.) 

384 process.finished.connect(self._process_finished) 

385 process.errorOccurred.connect(self._process_error) 

386 self.process = process 

387 process.start(program, self.args) 

388 self._send({"jsonrpc": "2.0", "id": self._new_id(self._initialized), "method": "initialize", 

389 "params": {"processId": None, "rootUri": to_uri(root), "capabilities": {}}}) 

390 return True 

391 

392 def stop(self): 

393 process, self.process = self.process, None 

394 self._reset() 

395 if process is not None: 

396 process.kill() 

397 process.waitForFinished(1000) 

398 process.deleteLater() 

399 

400 def _reset(self): 

401 self._reader.clear() 

402 self._callbacks.clear() 

403 self._queue.clear() 

404 self._versions.clear() 

405 self._ready = False 

406 

407 def _process_finished(self, *_): 

408 if isinstance(self.sender(), QProcess): 408 ↛ exitline 408 didn't return from function '_process_finished' because the condition on line 408 was always true

409 self._finished(self.sender()) 

410 

411 def _process_error(self, error): 

412 if error == QProcess.FailedToStart and isinstance(self.sender(), QProcess): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 self._finished(self.sender()) 

414 

415 def _finished(self, process: QProcess): 

416 if process is not self.process: 

417 return # one we stopped 

418 self.process = None 

419 self._reset() 

420 try: 

421 process.deleteLater() 

422 except RuntimeError: 

423 return # the app is shutting down (Qt has already deleted it) 

424 self.stopped.emit("fortls could not be started" if process.error() == QProcess.FailedToStart 

425 else f"fortls stopped (exit code {process.exitCode()})") 

426 

427 def _initialized(self, _result): 

428 self._ready = True 

429 self._send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) 

430 queue, self._queue = self._queue, [] 

431 for message in queue: 

432 self._send(message) 

433 

434 # ---------------------------------------------------------- messages 

435 def _new_id(self, callback: Callable) -> int: 

436 self._next_id += 1 

437 self._callbacks[self._next_id] = callback 

438 return self._next_id 

439 

440 def _send(self, message: dict): 

441 if self.process is None: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true

442 return 

443 self.process.write(frame(message)) 

444 

445 def _post(self, message: dict): 

446 if self._ready: 

447 self._send(message) 

448 else: 

449 self._queue.append(message) 

450 

451 def request(self, method: str, params: dict, callback: Callable) -> bool: 

452 """Ask the server something; ``callback(result)`` gets the answer 

453 (None on an error). False if no server is running.""" 

454 if self.process is None: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 return False 

456 self._post({"jsonrpc": "2.0", "id": self._new_id(callback), "method": method, "params": params}) 

457 return True 

458 

459 def notify(self, method: str, params: dict): 

460 if self.process is not None: 

461 self._post({"jsonrpc": "2.0", "method": method, "params": params}) 

462 

463 def _read(self): 

464 if self.process is None: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 return 

466 for message in self._reader.feed(bytes(self.process.readAllStandardOutput())): 

467 self._dispatch(message) 

468 

469 def _dispatch(self, message: dict): 

470 if "method" in message: 

471 if "id" in message: # a request from the server: we don't support any 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true

472 self._send({"jsonrpc": "2.0", "id": message["id"], "result": None}) 

473 elif message["method"] == "window/showMessage": # e.g. why a rename failed 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true

474 text = (message.get("params") or {}).get("message") 

475 if text: 

476 self.message.emit(str(text)) 

477 elif message["method"] == "textDocument/publishDiagnostics": 477 ↛ 481line 477 didn't jump to line 481 because the condition on line 477 was always true

478 params = message.get("params") or {} 

479 if params.get("uri"): 479 ↛ 481line 479 didn't jump to line 481 because the condition on line 479 was always true

480 self.diagnostics.emit(from_uri(params["uri"]), diagnostic_issues(params.get("diagnostics"))) 

481 return # other notifications (logs) aren't used 

482 callback = self._callbacks.pop(message.get("id"), None) 

483 if callback is not None: 483 ↛ exitline 483 didn't return from function '_dispatch' because the condition on line 483 was always true

484 try: 

485 callback(message.get("result") if "error" not in message else None) 

486 except RuntimeError: 

487 pass # the editor that asked has been closed 

488 

489 # ---------------------------------------------------------- documents 

490 def open_document(self, path: Path, text: str): 

491 uri = to_uri(path) 

492 if self.process is None: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 return 

494 if uri in self._versions: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true

495 self.change_document(path, text) 

496 return 

497 self._versions[uri] = 1 

498 self.notify("textDocument/didOpen", {"textDocument": { 

499 "uri": uri, "languageId": "fortran", "version": 1, "text": text}}) 

500 # fortls reads the file from disk on didOpen; send the editor's text 

501 # too, in case it has unsaved changes. 

502 self.change_document(path, text) 

503 

504 def change_document(self, path: Path, text: str): 

505 uri = to_uri(path) 

506 if uri not in self._versions: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 self.open_document(path, text) 

508 return 

509 self._versions[uri] += 1 

510 self.notify("textDocument/didChange", { 

511 "textDocument": {"uri": uri, "version": self._versions[uri]}, 

512 "contentChanges": [{"text": text}]}) 

513 

514 def save_document(self, path: Path): 

515 self.notify("textDocument/didSave", {"textDocument": {"uri": to_uri(path)}}) 

516 

517 def file_changed_on_disk(self, path: Path): 

518 """A file that isn't open was rewritten (e.g. by Rename): fortls 

519 re-reads a file from disk when told it was saved.""" 

520 if to_uri(path) not in self._versions: 520 ↛ exitline 520 didn't return from function 'file_changed_on_disk' because the condition on line 520 was always true

521 self.save_document(path) 

522 

523 def close_document(self, path: Path): 

524 uri = to_uri(path) 

525 if self._versions.pop(uri, None) is not None: 525 ↛ exitline 525 didn't return from function 'close_document' because the condition on line 525 was always true

526 self.notify("textDocument/didClose", {"textDocument": {"uri": uri}}) 

527 

528 # ---------------------------------------------------------- questions 

529 @staticmethod 

530 def _at(path: Path, line: int, column: int) -> dict: 

531 """Position parameters; ``line`` is 1-based, ``column`` 0-based.""" 

532 return {"textDocument": {"uri": to_uri(path)}, 

533 "position": {"line": line - 1, "character": column}} 

534 

535 def hover(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

536 return self.request("textDocument/hover", self._at(path, line, column), callback) 

537 

538 def definition(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

539 return self.request("textDocument/definition", self._at(path, line, column), callback) 

540 

541 def implementation(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

542 """For a type-bound procedure, the procedure it binds; for a generic 

543 interface, its first module procedure.""" 

544 return self.request("textDocument/implementation", self._at(path, line, column), callback) 

545 

546 def document_symbols(self, path: Path, callback: Callable) -> bool: 

547 return self.request("textDocument/documentSymbol", 

548 {"textDocument": {"uri": to_uri(path)}}, callback) 

549 

550 def workspace_symbols(self, query: str, callback: Callable) -> bool: 

551 return self.request("workspace/symbol", {"query": query}, callback) 

552 

553 def completion(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

554 return self.request("textDocument/completion", self._at(path, line, column), callback) 

555 

556 def signature_help(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

557 return self.request("textDocument/signatureHelp", self._at(path, line, column), callback) 

558 

559 def references(self, path: Path, line: int, column: int, callback: Callable) -> bool: 

560 return self.request("textDocument/references", 

561 {**self._at(path, line, column), "context": {"includeDeclaration": True}}, 

562 callback) 

563 

564 def rename(self, path: Path, line: int, column: int, new_name: str, callback: Callable) -> bool: 

565 return self.request("textDocument/rename", 

566 {**self._at(path, line, column), "newName": new_name}, callback) 

567 

568 

569def outline(symbols, line_text: Callable[[int], str] | None = None) -> list[tuple[int, str, str, int, int]]: 

570 """documentSymbol answer -> [(depth, kind word, name, first line, last 

571 line)] for modules, types and procedures, in file order (lines 1-based). 

572 ``line_text(line)`` (the file's text) refines the kind words.""" 

573 rows = [] 

574 

575 def kind_word(s, first): 

576 word = SYMBOL_KINDS.get(s["kind"], "") 

577 if line_text is not None and s["kind"] in (2, 6, 12): 

578 match = KEYWORD_RE.match(line_text(first).strip()) 

579 if match: 

580 word = match.group(1).lower() 

581 return word 

582 

583 def visit(items, depth, container_depth): 

584 for s in items or []: 

585 if not isinstance(s, dict) or s.get("kind") not in OUTLINE_KINDS: 

586 continue 

587 rng = s.get("range") or s.get("location", {}).get("range") 

588 if not rng: 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true

589 continue 

590 level = depth if "children" in s else container_depth.get(s.get("containerName"), -1) + 1 

591 rows.append((level, kind_word(s, rng["start"]["line"] + 1), s.get("name", ""), 

592 rng["start"]["line"] + 1, rng["end"]["line"] + 1)) 

593 container_depth[s.get("name")] = level 

594 visit(s.get("children"), depth + 1, container_depth) 

595 

596 visit(symbols, 0, {}) 

597 rows.sort(key=lambda r: (r[3], r[0])) 

598 return rows 

599 

600 

601# ------------------------------------------------------------- completion 

602# LSP CompletionItemKind numbers -> words. 

603COMPLETION_KINDS = {1: "text", 2: "method", 3: "function", 4: "constructor", 5: "component", 

604 6: "variable", 7: "type", 8: "interface", 9: "module", 10: "property", 

605 13: "enum", 14: "keyword", 15: "snippet", 20: "enumerator", 21: "constant", 

606 22: "type", 25: "type parameter"} 

607 

608 

609class Completion(NamedTuple): 

610 label: str 

611 insert: str # the text to insert (snippet placeholders removed) 

612 kind: str 

613 detail: str 

614 doc: str # markdown (fortls: the declaration and doc comment) 

615 

616 

617def snippet_text(text: str) -> str: 

618 """A snippet ("foo(${1:x}, $2)$0") as plain text ("foo(x, )").""" 

619 text = re.sub(r"\$\{\d+:([^}]*)\}", r"\1", text) 

620 text = re.sub(r"\$\{\d+\}|\$\d+", "", text) 

621 return text.replace("\\$", "$").replace("\\}", "}") 

622 

623 

624def completions(result) -> list[Completion]: 

625 items = result.get("items", []) if isinstance(result, dict) else result or [] 

626 out = [] 

627 for item in items: 

628 if not isinstance(item, dict) or not item.get("label"): 

629 continue 

630 insert = item.get("insertText") or item.get("label") 

631 edit = item.get("textEdit") 

632 if isinstance(edit, dict) and edit.get("newText"): 

633 insert = edit["newText"] 

634 if item.get("insertTextFormat") == 2: 

635 insert = snippet_text(insert) 

636 doc = item.get("documentation") or "" 

637 if isinstance(doc, dict): 

638 doc = doc.get("value", "") 

639 out.append(Completion(str(item["label"]), insert, COMPLETION_KINDS.get(item.get("kind"), ""), 

640 str(item.get("detail") or ""), str(doc))) 

641 return out 

642 

643 

644def code_spans(line: str) -> list[tuple[int, int]]: 

645 """The parts of a line that are code: not in a string or a "!" comment.""" 

646 spans, start, quote = [], 0, None 

647 for i, ch in enumerate(line): 

648 if quote: 

649 if ch == quote: 

650 quote, start = None, i + 1 

651 elif ch in "'\"": 

652 spans.append((start, i)) 

653 quote = ch 

654 elif ch == "!": 

655 spans.append((start, i)) 

656 return spans 

657 if quote is None: 

658 spans.append((start, len(line))) 

659 return spans 

660 

661 

662def in_comment_or_string(line_text: str, column: int) -> bool: 

663 """Is ``column`` of a line inside a "!" comment or a quoted string?""" 

664 return not any(start <= column <= end for start, end in code_spans(line_text)) 

665 

666# --------------------------------------------------------- signature help 

667def signature_html(result, active_hint: int | None = None) -> str: 

668 """signatureHelp answer -> tooltip HTML: the call with the current 

669 argument in bold, then that argument's declaration and doc comment.""" 

670 if not isinstance(result, dict) or not result.get("signatures"): 

671 return "" 

672 signatures = result["signatures"] 

673 sig = signatures[min(int(result.get("activeSignature") or 0), len(signatures) - 1)] 

674 params = sig.get("parameters") or [] 

675 active = sig.get("activeParameter", result.get("activeParameter")) 

676 if active is None: 

677 active = active_hint 

678 label = str(sig.get("label", "")) 

679 shown = html.escape(label) 

680 doc = "" 

681 if active is not None and 0 <= int(active) < len(params): 681 ↛ 697line 681 didn't jump to line 697 because the condition on line 681 was always true

682 param = params[int(active)] 

683 name = param.get("label") 

684 if isinstance(name, list) and len(name) == 2: # [start, end] offsets 

685 start, end = name 

686 shown = (html.escape(label[:start]) + "<b><u>" + html.escape(label[start:end]) + "</u></b>" 

687 + html.escape(label[end:])) 

688 elif isinstance(name, str): 688 ↛ 696line 688 didn't jump to line 696 because the condition on line 688 was always true

689 # The argument's name inside the parentheses (not the routine's name). 

690 open_paren = label.find("(") 

691 match = re.search(rf"\b{re.escape(name)}\b", label[open_paren + 1:]) if open_paren >= 0 else None 

692 if match: 692 ↛ 696line 692 didn't jump to line 696 because the condition on line 692 was always true

693 start, end = open_paren + 1 + match.start(), open_paren + 1 + match.end() 

694 shown = (html.escape(label[:start]) + "<b><u>" + html.escape(label[start:end]) + "</u></b>" 

695 + html.escape(label[end:])) 

696 doc = hover_html(param.get("documentation") or "") 

697 return f"<pre style='margin:0'>{shown}</pre>" + doc 

698 

699 

700def argument_index(text_before: str) -> int | None: 

701 """Which argument (0-based) the cursor is in, from the code before it on 

702 the line: the commas at the level of the innermost open "(" (None if the 

703 cursor isn't inside parentheses, or the innermost is a "[").""" 

704 open_brackets: list[list] = [] # [bracket, commas] of each one still open 

705 for start, end in code_spans(text_before): 

706 for ch in text_before[start:end]: 

707 if ch in "([": 

708 open_brackets.append([ch, 0]) 

709 elif ch in ")]": 

710 if open_brackets: 710 ↛ 706line 710 didn't jump to line 706 because the condition on line 710 was always true

711 open_brackets.pop() 

712 elif ch == "," and open_brackets: 

713 open_brackets[-1][1] += 1 

714 if not open_brackets: 

715 return None 

716 bracket, commas = open_brackets[-1] 

717 return commas if bracket == "(" else None 

718 

719# ------------------------------------------------------- references / rename 

720class TextEdit(NamedTuple): 

721 line: int # 0-based, as in LSP 

722 column: int 

723 end_line: int 

724 end_column: int 

725 new_text: str 

726 

727 

728def locations(result) -> list[tuple[Path, int, int, int]]: 

729 """references answer -> [(file, 1-based line, start column, end column)].""" 

730 out = [] 

731 for loc in result or []: 

732 try: 

733 rng = loc["range"] 

734 out.append((from_uri(loc["uri"]), rng["start"]["line"] + 1, rng["start"]["character"], 

735 rng["end"]["character"] if rng["end"]["line"] == rng["start"]["line"] 

736 else rng["start"]["character"])) 

737 except (KeyError, TypeError): 

738 continue 

739 return sorted(set(out), key=lambda r: (str(r[0]), r[1], r[2])) 

740 

741 

742def workspace_edits(result) -> dict[Path, list[TextEdit]]: 

743 """rename answer (a WorkspaceEdit) -> {file: [edits]}.""" 

744 if not isinstance(result, dict): 

745 return {} 

746 pairs = list((result.get("changes") or {}).items()) 

747 for change in result.get("documentChanges") or []: 

748 if isinstance(change, dict) and "textDocument" in change: 748 ↛ 747line 748 didn't jump to line 747 because the condition on line 748 was always true

749 pairs.append((change["textDocument"]["uri"], change.get("edits", []))) 

750 out: dict[Path, list[TextEdit]] = {} 

751 for uri, edits in pairs: 

752 for e in edits or []: 

753 try: 

754 r = e["range"] 

755 out.setdefault(from_uri(uri), []).append(TextEdit( 

756 r["start"]["line"], r["start"]["character"], r["end"]["line"], r["end"]["character"], 

757 e["newText"])) 

758 except (KeyError, TypeError): 

759 continue 

760 return out 

761 

762 

763def apply_edits(text: str, edits: list[TextEdit]) -> str: 

764 """Apply LSP text edits to a text ("\\n" line ends), last first so the 

765 earlier positions stay valid.""" 

766 lines = text.split("\n") 

767 starts = [0] 

768 for line in lines: 

769 starts.append(starts[-1] + len(line) + 1) 

770 

771 def offset(line: int, column: int) -> int: 

772 line = min(max(line, 0), len(lines) - 1) 

773 return starts[line] + min(column, len(lines[line])) 

774 

775 for e in sorted(edits, key=lambda e: (e.line, e.column), reverse=True): 

776 text = text[:offset(e.line, e.column)] + e.new_text + text[offset(e.end_line, e.end_column):] 

777 return text 

778 

779 

780def add_result_variable_edits(edits: dict[Path, list[TextEdit]], old: str, new: str, 

781 file_text: Callable[[Path], str]) -> dict[Path, list[TextEdit]]: 

782 """fortls's rename misses a function's result variable: in a function 

783 without a ``result(...)`` clause, ``old = ...`` inside the body also 

784 names the function and must be renamed too. Adds those edits.""" 

785 header = re.compile(rf"\bfunction\s+{re.escape(old)}\s*\(", re.IGNORECASE) 

786 word = re.compile(rf"\b{re.escape(old)}\b", re.IGNORECASE) 

787 end = re.compile(r"^\s*(end\s*(function\b|$)|contains\b)", re.IGNORECASE) 

788 out = {} 

789 for path, file_edits in edits.items(): 

790 try: 

791 lines = file_text(path).split("\n") 

792 except OSError: 

793 out[path] = file_edits 

794 continue 

795 have = {(e.line, e.column) for e in file_edits} 

796 added = list(file_edits) 

797 for i, line in enumerate(lines): 

798 code = "".join(line[a:b] for a, b in code_spans(line)) 

799 if not header.search(code): 

800 continue 

801 # The whole statement (it may continue over "&" lines). 

802 statement, j = code, i 

803 while statement.rstrip().endswith("&") and j + 1 < len(lines): 803 ↛ 804line 803 didn't jump to line 804 because the condition on line 803 was never true

804 j += 1 

805 statement += "".join(lines[j][a:b] for a, b in code_spans(lines[j])) 

806 if re.search(r"\bresult\s*\(", statement, re.IGNORECASE): 806 ↛ 807line 806 didn't jump to line 807 because the condition on line 806 was never true

807 continue # the result has its own name 

808 for k in range(j + 1, len(lines)): 808 ↛ 797line 808 didn't jump to line 797 because the loop on line 808 didn't complete

809 if end.match(lines[k]): 

810 break 

811 for a, b in code_spans(lines[k]): 

812 for match in word.finditer(lines[k], a, b): 

813 if (k, match.start()) not in have: 813 ↛ 812line 813 didn't jump to line 812 because the condition on line 813 was always true

814 have.add((k, match.start())) 

815 added.append(TextEdit(k, match.start(), k, match.end(), new)) 

816 out[path] = added 

817 return out