Coverage for vibefpmgui/lsp.py: 90%
559 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"""A small client for the Fortran language server, fortls.
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.
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"""
18from __future__ import annotations
20import html
21import json
22import re
23import shutil
24from collections.abc import Callable
25from pathlib import Path
26from typing import NamedTuple
28from qtpy.QtCore import QObject, QProcess, QUrl, Signal
29from qtpy.QtGui import QPalette, QTextDocument
31from . import manifest as m
32from .project_tree import fpm_folders
33from .wire import FrameReader, frame
35FORTRAN_EXTS = {".f", ".for", ".ftn", ".f77", ".f90", ".f95", ".f03", ".f08", ".f18", ".f23", ".fpp"}
36CONFIG_FILES = (".fortlsrc", ".fortls.json", ".fortls")
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)
48def executable() -> str | None:
49 return shutil.which("fortls")
52def is_fortran(path: Path) -> bool:
53 return path.suffix.lower() in FORTRAN_EXTS
56def to_uri(path: Path) -> str:
57 return QUrl.fromLocalFile(str(path)).toString()
60def from_uri(uri: str) -> Path:
61 return Path(QUrl(uri).toLocalFile()).resolve()
64def has_config(project_dir: Path) -> bool:
65 return any((project_dir / name).is_file() for name in CONFIG_FILES)
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]
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
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
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
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"
166 @property
167 def fixable(self) -> bool:
168 return False
170 @property
171 def severity(self) -> str:
172 return "fortls error" if self.level == 1 else "fortls"
174 def describe(self) -> str:
175 return f"fortls: {self.message}"
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
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)?")
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 "")
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()
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
226 from .highlighter import FortranSourceLexer
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>")
239def _current_style() -> str:
240 from qtpy.QtWidgets import QApplication
242 from . import highlighter as hl
243 return hl.style_name(QApplication.palette())
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()))
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>")
270 return re.sub(r"<h(\d)[^>]*>(.*?)</h\1>", heading, result, flags=re.DOTALL)
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
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))
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*\()")]
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))
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
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"]
348class LspClient(QObject):
349 """One fortls process for the open project."""
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
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
367 # ---------------------------------------------------------- lifecycle
368 def is_running(self) -> bool:
369 return self.process is not None
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
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()
400 def _reset(self):
401 self._reader.clear()
402 self._callbacks.clear()
403 self._queue.clear()
404 self._versions.clear()
405 self._ready = False
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())
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())
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()})")
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)
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
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))
445 def _post(self, message: dict):
446 if self._ready:
447 self._send(message)
448 else:
449 self._queue.append(message)
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
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})
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)
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
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)
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}]})
514 def save_document(self, path: Path):
515 self.notify("textDocument/didSave", {"textDocument": {"uri": to_uri(path)}})
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)
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}})
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}}
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)
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)
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)
546 def document_symbols(self, path: Path, callback: Callable) -> bool:
547 return self.request("textDocument/documentSymbol",
548 {"textDocument": {"uri": to_uri(path)}}, callback)
550 def workspace_symbols(self, query: str, callback: Callable) -> bool:
551 return self.request("workspace/symbol", {"query": query}, callback)
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)
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)
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)
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)
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 = []
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
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)
596 visit(symbols, 0, {})
597 rows.sort(key=lambda r: (r[3], r[0]))
598 return rows
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"}
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)
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("\\}", "}")
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
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
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))
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
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
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
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]))
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
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)
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]))
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
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